mytunes-pro 2.0.5__py3-none-any.whl → 2.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mytunes/app.py CHANGED
@@ -44,7 +44,7 @@ MPV_SOCKET = "/tmp/mpv_socket"
44
44
  LOG_FILE = "/tmp/mytunes_mpv.log"
45
45
  PID_FILE = "/tmp/mytunes_mpv.pid"
46
46
  APP_NAME = "MyTunes Pro"
47
- APP_VERSION = "2.0.5"
47
+ APP_VERSION = "2.0.6"
48
48
 
49
49
  # === [Strings & Localization] ===
50
50
  STRINGS = {
@@ -64,7 +64,8 @@ STRINGS = {
64
64
  "fav_added": "★ 즐겨찾기에 추가됨",
65
65
  "fav_removed": "☆ 즐겨찾기 해제됨",
66
66
  "header_r1": "[S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [A/5]즐겨찾기추가 [Q/6]뒤로",
67
- "header_r2": "[F7]유튜브 [SPC]Play/Stop [+/-]볼륨 [<>]빨리감기 [D/Del]삭제",
67
+ "header_r1": "[S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [A/5]즐겨찾기추가 [Q/6]뒤로",
68
+ "header_r2": "[F7]유튜브 [E]이퀄라이저 [SPC]Play/Stop [+/-]볼륨 [D/Del]삭제",
68
69
  "help_guide": "[j/k]이동 [En]선택 [h/q]뒤로 [S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [F7]유튜브",
69
70
  "menu_main": "☰ 메인 메뉴",
70
71
  "menu_search_results": "⌕ 미디어 콘텐츠 검색",
@@ -95,7 +96,8 @@ STRINGS = {
95
96
  "fav_added": "★ Added to Favorites",
96
97
  "fav_removed": "☆ Removed from Favorites",
97
98
  "header_r1": "[S/1]Srch [F/2]Favs [R/3]Hist [M/4]Main [A/5]AddFav [Q/6]Back",
98
- "header_r2": "[F7]YT [SPC]Play/Stop [+/-]Vol [<>]Seek [D/Del]Del",
99
+ "header_r1": "[S/1]Srch [F/2]Favs [R/3]Hist [M/4]Main [A/5]AddFav [Q/6]Back",
100
+ "header_r2": "[F7]YT [E]EQ [SPC]Play/Stop [+/-]Vol [<>]Seek [D/Del]Del",
99
101
  "help_guide": "[j/k]Move [En]Select [h/q]Back [S/1]Srch [F/2]Fav [R/3]Hist [M/4]Main [F7]YT",
100
102
  "menu_main": "☰ Main Menu",
101
103
  "menu_search_results": "⌕ Search Media Content",
@@ -110,7 +112,28 @@ STRINGS = {
110
112
  "ime_warning": "Switch to English for shortcuts.",
111
113
  "invalid_key": "Invalid key: '{}'"
112
114
  }
115
+
116
+ }
117
+
118
+ # === [Audio Presets] ===
119
+ # === [Audio Presets] ===
120
+ # Standard "Most Used" Presets (Amplified/Professional tuned)
121
+ # Professional EQ Presets (10-Band: 32Hz, 64Hz, 125Hz, 250Hz, 500Hz, 1kHz, 2kHz, 4kHz, 8kHz, 16kHz)
122
+ # Based on: Harman Target Curve, Dolby Atmos Guidelines, AES Standards
123
+ # Reference: Harman International (AKG, JBL, Harman Kardon), Sennheiser HD800S, Dolby Labs
124
+ EQUALIZER_PRESETS = {
125
+ "Flat": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], # Reference: No coloration
126
+ "Pop": [1, 2, 4, 3, 1, 2, 3, 4, 3, 1], # Harman-inspired: Vocal clarity, balanced warmth
127
+ "Rock": [4, 5, 3, 1, -1, 1, 2, 4, 5, 4], # V-Curve: Powerful bass, crisp highs, recessed mids
128
+ "Jazz": [3, 4, 3, 2, 1, 0, 1, 2, 3, 4], # Warm: Natural bass, smooth highs, open soundstage
129
+ "Classical": [2, 3, 2, 1, 0, 0, 1, 2, 3, 4], # Neutral: Flat mids, airy highs (Dolby-inspired)
130
+ "Full Bass": [6, 7, 6, 4, 2, 0, 0, 0, 0, 0], # Sub-bass focus: Clean low-end (AKG K371 style)
131
+ "Dance": [5, 6, 4, 2, 0, 1, 2, 4, 4, 3], # Club: Punchy kick, clear hi-hats
132
+ "Club": [4, 5, 6, 4, 2, 0, 1, 3, 3, 2], # EDM: Deep sub, mid-bass punch (Harman bass shelf)
133
+ "Live": [2, 3, 2, 1, 2, 3, 2, 3, 4, 3], # Concert: Natural reverb, presence boost
134
+ "Soft": [2, 3, 2, 1, 0, 0, -1, 0, 1, 2] # Relaxed: Gentle bass, rolled-off harshness
113
135
  }
136
+ EQUALIZER_KEYS = ["Auto"] + list(EQUALIZER_PRESETS.keys())
114
137
 
115
138
  class DataManager:
116
139
  def __init__(self):
@@ -288,7 +311,7 @@ class Player:
288
311
  subprocess.run(["pkill", "-f", "mpv --video=no"], stderr=subprocess.DEVNULL)
289
312
  except: pass
290
313
 
291
- def play(self, url, start_pos=0):
314
+ def play(self, url, start_pos=0, initial_eq_preset="Flat"):
292
315
  # 1. Try to reuse existing instance via IPC (Graceful)
293
316
  if os.path.exists(MPV_SOCKET):
294
317
  try:
@@ -297,6 +320,11 @@ class Player:
297
320
  if resp and not resp.get("error"):
298
321
  if start_pos > 0:
299
322
  self.send_cmd(["seek", str(start_pos), "absolute"])
323
+
324
+ # Apply EQ immediately for reused instance
325
+ if initial_eq_preset and initial_eq_preset != "Flat":
326
+ self.set_equalizer(initial_eq_preset)
327
+
300
328
  self.loading = True
301
329
  self.loading_ts = time.time()
302
330
  return # Success! No need to restart
@@ -318,10 +346,16 @@ class Player:
318
346
  "mpv", "--video=no", "--vo=null", "--force-window=no",
319
347
  "--audio-display=no", "--no-config",
320
348
  f"--input-ipc-server={MPV_SOCKET}",
321
- "--idle=yes",
322
- url
349
+ "--idle=yes"
323
350
  ]
324
351
 
352
+ # Inject Initial EQ (0ms Latency)
353
+ eq_af = self._get_eq_af_string(initial_eq_preset)
354
+ if eq_af:
355
+ cmd.append(f"--af={eq_af}")
356
+
357
+ cmd.append(url)
358
+
325
359
  # B. macOS Specific UI Optimizations
326
360
  if sys.platform == "darwin":
327
361
  # 'accessory' hides Dock but allows system resources
@@ -420,6 +454,25 @@ class Player:
420
454
  """Seek relative to current position."""
421
455
  self.send_cmd(["seek", seconds, "relative"])
422
456
 
457
+ def _get_eq_af_string(self, preset_name):
458
+ """Generate lavfi string for EQ preset."""
459
+ gains = EQUALIZER_PRESETS.get(preset_name, EQUALIZER_PRESETS["Flat"])
460
+ if preset_name == "Flat": return ""
461
+
462
+ freqs = [31.25, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
463
+ filters = []
464
+ for f, g in zip(freqs, gains):
465
+ if g != 0:
466
+ filters.append(f"equalizer=f={f}:width_type=o:width=1:g={g}")
467
+
468
+ if not filters: return ""
469
+ return "lavfi=[" + ",".join(filters) + "]"
470
+
471
+ def set_equalizer(self, preset_name):
472
+ """Apply 10-band equalizer preset using lavfi."""
473
+ af_str = self._get_eq_af_string(preset_name)
474
+ self.set_property("af", af_str)
475
+
423
476
  # === [TUI Application] ===
424
477
  class MyTunesApp:
425
478
  def __init__(self, stdscr):
@@ -436,12 +489,19 @@ class MyTunesApp:
436
489
  self.scroll_offset = 0
437
490
  self.current_track = None
438
491
  self.cached_history = [] # Snapshot for stable history view
439
- self.status_msg = ""
492
+ self.status_msg = "" # Deprecated, keeping for safety until full refactor
493
+ self.feedback_msg = ""
494
+ self.feedback_expiry = 0
495
+ self.view_msg = ""
440
496
 
441
497
  # Queue System
442
498
  self.queue = []
443
499
  self.queue_idx = -1
444
500
 
501
+ # Audio EQ
502
+ self.current_eq_index = 0 # Default to 0 -> Flat
503
+ self.eq_overlay_time = 0 # For showing EQ change status temporarily
504
+
445
505
  # Search State
446
506
  self.current_search_query = None
447
507
  # self.search_page = 1 # Deprecated: Pagination Removed v2.0.2
@@ -454,6 +514,7 @@ class MyTunesApp:
454
514
  self.is_paused = False
455
515
  self.last_save_time = time.time()
456
516
  self.status_set_time = 0
517
+ self.auto_preset_name = "Pop" # Default Auto detected genre
457
518
 
458
519
  # Throttling Counters
459
520
  self.loop_count = 0
@@ -486,6 +547,17 @@ class MyTunesApp:
486
547
  self.sent_history = {}
487
548
 
488
549
 
550
+ def show_feedback(self, msg, duration=2.5):
551
+ """Show transient feedback (keys/errors) that overrides view status temporarily."""
552
+ self.feedback_msg = msg
553
+ self.feedback_expiry = time.time() + duration
554
+ self.draw() # Force immediate update
555
+
556
+ def set_view_status(self, msg):
557
+ """Set persistent status specific to the current view (e.g., Favorites path)."""
558
+ self.view_msg = msg
559
+ # We don't force draw here usually, the loop handles it, or caller does.
560
+
489
561
  def handle_disconnect(self, signum, frame):
490
562
  """Auto-background if terminal disconnects."""
491
563
  self.stop_on_exit = False
@@ -564,7 +636,7 @@ class MyTunesApp:
564
636
  now = time.time()
565
637
  if self.player.loading and (now - self.player.loading_ts > 8):
566
638
  self.player.loading = False
567
- self.status_msg = "⚠️ Load timed out. Skipping..."
639
+ self.show_feedback("⚠️ Load timed out. Skipping...")
568
640
 
569
641
  # 2. Frequent: Pause state (Every 2 loops ~400ms)
570
642
  if self.loop_count % 2 == 0:
@@ -660,7 +732,8 @@ class MyTunesApp:
660
732
  "<": "SEEK_BACK_30", ">": "SEEK_FWD_30",
661
733
  "a": "TOGGLE_FAV", "5": "TOGGLE_FAV",
662
734
  str(curses.KEY_F7): "OPEN_BROWSER",
663
- str(curses.KEY_DC): "DELETE", "d": "DELETE"
735
+ str(curses.KEY_DC): "DELETE", "d": "DELETE",
736
+ "e": "CYCLE_EQ", "E": "CYCLE_EQ"
664
737
  }
665
738
  cmd = mapping.get(k_char)
666
739
  if cmd: return cmd
@@ -682,12 +755,12 @@ class MyTunesApp:
682
755
  if cmd == "NAV_BACK":
683
756
  if len(self.view_stack) > 1:
684
757
  self.forward_stack.append(self.view_stack.pop())
685
- self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
758
+ self.selection_idx = 0; self.scroll_offset = 0; self.set_view_status("")
686
759
 
687
760
  elif cmd == "NAV_FORWARD":
688
761
  if self.forward_stack:
689
762
  self.view_stack.append(self.forward_stack.pop())
690
- self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
763
+ self.selection_idx = 0; self.scroll_offset = 0; self.set_view_status("")
691
764
 
692
765
  elif cmd == "MOVE_UP":
693
766
  if self.selection_idx > 0:
@@ -718,31 +791,31 @@ class MyTunesApp:
718
791
  elif cmd == "FAVORITES":
719
792
  if self.view_stack[-1] != "favorites":
720
793
  self.forward_stack = []; self.view_stack.append("favorites"); self.selection_idx = 0
721
- self.status_msg = self.t("favorites_info", DATA_FILE)
794
+ self.set_view_status(self.t("favorites_info", DATA_FILE))
722
795
 
723
796
  elif cmd == "HISTORY":
724
797
  if self.view_stack[-1] != "history":
725
798
  self.forward_stack = []; self.cached_history = list(self.dm.data['history'])
726
799
  self.view_stack.append("history"); self.selection_idx = 0
727
- self.status_msg = self.t("hist_info")
800
+ self.set_view_status(self.t("hist_info"))
728
801
 
729
802
  elif cmd == "MAIN_MENU":
730
- self.forward_stack = []; self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
803
+ self.forward_stack = []; self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.set_view_status("")
731
804
 
732
805
  elif cmd == "TOGGLE_PAUSE": self.player.toggle_pause()
733
- elif cmd == "VOL_DOWN": self.player.change_volume(-5); self.status_msg = "Volume -5"
734
- elif cmd == "VOL_UP": self.player.change_volume(5); self.status_msg = "Volume +5"
806
+ elif cmd == "VOL_DOWN": self.player.change_volume(-5); self.show_feedback("Volume -5")
807
+ elif cmd == "VOL_UP": self.player.change_volume(5); self.show_feedback("Volume +5")
735
808
  elif cmd == "SEEK_BACK_10": self.player.seek(-10)
736
809
  elif cmd == "SEEK_FWD_10": self.player.seek(10)
737
- elif cmd == "SEEK_BACK_30": self.player.seek(-30); self.status_msg = "Rewind 30s"
738
- elif cmd == "SEEK_FWD_30": self.player.seek(30); self.status_msg = "Forward 30s"
810
+ elif cmd == "SEEK_BACK_30": self.player.seek(-30); self.show_feedback("Rewind 30s")
811
+ elif cmd == "SEEK_FWD_30": self.player.seek(30); self.show_feedback("Forward 30s")
739
812
 
740
813
  elif cmd == "TOGGLE_FAV":
741
814
  if current_list and 0 <= self.selection_idx < len(current_list):
742
815
  target = current_list[self.selection_idx]
743
816
  if "url" in target:
744
817
  is_added = self.dm.toggle_favorite(target)
745
- self.status_msg = self.t("fav_added") if is_added else self.t("fav_removed")
818
+ self.show_feedback(self.t("fav_added") if is_added else self.t("fav_removed"))
746
819
 
747
820
  elif cmd == "DELETE":
748
821
  self.handle_deletion(current_list)
@@ -768,16 +841,19 @@ class MyTunesApp:
768
841
  elif cmd == "EXIT_BKG":
769
842
  self.stop_on_exit = False; self.running = False
770
843
 
844
+ elif cmd == "CYCLE_EQ":
845
+ self.cycle_equalizer()
846
+
771
847
  elif isinstance(cmd, tuple) and cmd[0] == "UNKNOWN":
772
848
  key = cmd[1]
773
849
  if isinstance(key, str) and ord(key[0]) > 127:
774
- self.status_msg = self.t("ime_warning")
850
+ self.show_feedback(self.t("ime_warning"))
775
851
  self.status_set_time = time.time()
776
- self.draw() # Internal redraw for instant feedback
852
+ # self.draw() # Handled by show_feedback
777
853
  elif isinstance(key, str) and key.isprintable():
778
- self.status_msg = self.t("invalid_key", key)
854
+ self.show_feedback(self.t("invalid_key", key))
779
855
  self.status_set_time = time.time()
780
- self.draw() # Internal redraw for instant feedback
856
+ # self.draw() # Handled by show_feedback
781
857
 
782
858
  def handle_deletion(self, current_list):
783
859
  """Sub-logic for DELETE command to keep dispatcher clean."""
@@ -787,23 +863,277 @@ class MyTunesApp:
787
863
  success = False
788
864
  if view == "favorites":
789
865
  success = self.dm.remove_favorite_by_index(self.selection_idx)
790
- if success: self.status_msg = "🗑️ Deleted from Favorites"
866
+ if success: self.show_feedback("🗑️ Deleted from Favorites")
791
867
  elif view == "history":
792
868
  success = self.dm.remove_history_by_index(self.selection_idx)
793
- if success: self.cached_history = list(self.dm.data['history']); self.status_msg = "🗑️ Deleted from History"
869
+ if success: self.cached_history = list(self.dm.data['history']); self.show_feedback("🗑️ Deleted from History")
794
870
  elif view == "search":
795
871
  if self.current_search_query is None:
796
872
  success = self.dm.remove_search_history_by_index(self.selection_idx)
797
- if success: self.search_results = self.dm.get_search_history(); self.status_msg = "🗑️ Deleted from Search History"
873
+ if success: self.search_results = self.dm.get_search_history(); self.show_feedback("🗑️ Deleted from Search History")
798
874
  else:
799
- try: self.search_results.pop(self.selection_idx); success = True; self.status_msg = "Removed from list"
875
+ try: self.search_results.pop(self.selection_idx); success = True; self.show_feedback("Removed from list")
800
876
  except: pass
801
- if success:
802
- self.selection_idx = max(0, min(self.selection_idx, len(self.get_current_list()) - 1))
803
877
 
878
+ def cycle_equalizer(self):
879
+ self.current_eq_index = (self.current_eq_index + 1) % len(EQUALIZER_KEYS)
880
+ new_preset = EQUALIZER_KEYS[self.current_eq_index]
881
+
882
+ if new_preset == "Auto":
883
+ # Immediate trigger for current track
884
+ auto_preset = self.detect_auto_eq(self.current_track) if self.current_track else "Pop"
885
+ self.player.set_equalizer(auto_preset)
886
+ self.show_feedback(f"🎚 Auto: {auto_preset}") # Explicit feedback on change
887
+ else:
888
+ self.player.set_equalizer(new_preset)
889
+ self.show_feedback(f"🎚 EQ: {new_preset}") # Explicit feedback on change
890
+
891
+ self.status_set_time = time.time()
892
+
893
+ def detect_auto_eq(self, item):
894
+ """
895
+ Smart genre detection using weighted scoring.
896
+ Analyzes Title and Author for keywords.
897
+ """
898
+ # Prepare texts
899
+ title_text = ""
900
+ extra_text = ""
901
+
902
+ if isinstance(item, str):
903
+ title_text = item.lower()
904
+ else:
905
+ title_text = item.get('title', '').lower()
906
+ # extra_text removed to strictly follow title-based detection
907
+
908
+ # DEBUG: Log detection attempt
909
+ # (Debug code removed)
910
+
911
+ scores = {k: 0 for k in EQUALIZER_PRESETS.keys() if k != "Flat"}
912
+
913
+ # Multilingual Genre Keywords (En, Ko, Ja, Zh, Ru, Es, Fr, Vi)
914
+ # Priorities specific genre words over artist names for accuracy.
915
+ rules = [
916
+ ("Rock", [
917
+ "rock", "metal", "grunge", "punk", "band", "guitar solo", "drum",
918
+ "락", "록", "메탈", "밴드", "기타", # Ko
919
+ "ロック", "メタル", "パンク", "バンド", # Ja
920
+ "摇滚", "金属乐", "乐队", # Zh
921
+ "рок", "metal", "панк", "группа", # Ru
922
+ "roca", "metal", "punk", "banda", # Es
923
+ "rocher", "métal", "groupe", # Fr
924
+ "nhạc rock", "ban nhạc", # Vi
925
+ "roque", "metal", "banda", "guitarra", # Pt
926
+ "rock", "metall", "band", "gitarre", # De
927
+ "rock", "band", # Hi
928
+ "ร็อค", "วง", "กีตาร์", # Th
929
+ "queen", "ac/dc", "nirvana", "linkin park", "oasis", "coldplay" # Iconic Fallbacks
930
+ ]),
931
+ ("Jazz", [
932
+ "jazz", "blues", "piano", "saxophone", "trumpet", "cafe", "coffee", "lounge", "smooth", "relaxing", "dinner", "wine", "bar", "mood",
933
+ "재즈", "블루스", "피아노", "카페", "커피", "라운지", "무드", # Ko
934
+ "ジャズ", "ブルース", "ピアノ", "カフェ", "ラウンジ", # Ja
935
+ "爵士", "蓝调", "钢琴", "咖啡", # Zh
936
+ "джаз", "блюз", "пианино", "кафе", "лаунж", # Ru
937
+ "jazz", "blues", "piano", "café", "salón", # Es
938
+ "jazz", "blues", "piano", "café", "salon", # Fr
939
+ "nhạc jazz", "nhạc blues", "dương cầm", "cà phê", # Vi
940
+ "jazz", "blues", "piano", "bossa nova", "samba", "café", # Pt
941
+ "jazz", "blues", "klavier", "kaffee", # De
942
+ "jazz", "piano", # Hi
943
+ "แจ๊ส", "เปียโน", "กาแฟ", # Th
944
+ "norah jones", "chet baker", "bill evans"
945
+ ]),
946
+ ("Classical", [
947
+ "classical", "classic", "orchestra", "symphony", "concerto", "sonata", "violin", "cello", "opera", "choir", "philharmonic",
948
+ "클래식", "오케스트라", "교향곡", "협주곡", "소나타", "바이올린", "첼로", "오페라", "합창", # Ko
949
+ "クラシック", "オーケストラ", "交響曲", "協奏曲", "ソナタ", "バイオリン", "チェロ", # Ja
950
+ "古典", "交响乐", "协奏曲", "奏鸣曲", "小提琴", "大提琴", # Zh
951
+ "классика", "оркестр", "симфония", "концерт", "соната", "скрипка", "виолончель", # Ru
952
+ "clásica", "orquesta", "sinfonía", "concierto", # Es
953
+ "classique", "orchestre", "symphonie", "concerto", # Fr
954
+ "cổ điển", "dàn nhạc", "giao hưởng", # Vi
955
+ "clássica", "orquestra", "sinfonia", "piano", # Pt
956
+ "klassik", "orchester", "sinfonie", "klavier", # De
957
+ "classical", "orchestra", # Hi
958
+ "คลาสสิก", "ออเคสตรา", "เปียโน", # Th
959
+ "mozart", "bach", "beethoven", "chopin", "disney", "ghibli"
960
+ ]),
961
+ ("Club", [
962
+ "edm", "club", "dance floor", "remix", "mix", "dj", "techno", "house", "trance", "dubstep", "bass boost", "electronic",
963
+ "클럽", "리믹스", "믹스", "디제이", "테크노", "하우스", "일렉", "이디엠", # Ko
964
+ "クラブ", "リミックス", "テクノ", "ハウス", "エレクトロニック", # Ja
965
+ "俱乐部", "混音", "电音", "电子", # Zh
966
+ "клуб", "ремикс", "диджей", "техно", "хаус", "электроника", # Ru
967
+ "club", "remix", "mezcla", "electrónica", # Es
968
+ "club", "remix", "mélange", "électronique", # Fr
969
+ "câu lạc bộ", "phối lại", "điện tử", "nhạc sàn", # Vi
970
+ "clube", "remix", "eletrônica", "balada", # Pt
971
+ "club", "remix", "elektronisch", "techno", "nacht", # De
972
+ "club", "remix", "dj", # Hi
973
+ "คลับ", "รีมิกซ์", "ดีเจ", "แดนซ์" # Th
974
+ ]),
975
+ ("Dance", [
976
+ "dance", "disco", "party", "choreography", "upbeat", "idol", "kpop", "k-pop", "j-pop", "pop dance", "tango", "salsa", "swing",
977
+ "댄스", "디스코", "파티", "안무", "아이돌", "케이팝", "신나는", "탱고", "살사", # Ko
978
+ "ダンス", "ディスコ", "パーティー", "アイドル", "タンゴ", "サルサ", # Ja
979
+ "舞曲", "迪斯科", "派对", "偶像", "探戈", "莎莎", # Zh
980
+ "танец", "диско", "вечеринка", "айдол", "танго", # Ru
981
+ "baile", "disco", "fiesta", "íbodo", "tango", "salsa", # Es
982
+ "danse", "discothèque", "fête", "tango", "salsa", # Fr
983
+ "nhảy", "khiêu vũ", "tiệc", "thần tượng", "tango", # Vi
984
+ "dança", "festa", "funk", "ídolo", # Pt
985
+ "tanz", "party", "schlager", # De
986
+ "dance", "party", "bollywood", "nach", # Hi
987
+ "เต้น", "ปาร์ตี้", "ไอดอล" # Th
988
+ ]),
989
+ ("Full Bass", [
990
+ "hip hop", "hiphop", "rap", "r&b", "soul", "trap", "beat", "bass", "boom bap", "drill", "grime",
991
+ "힙합", "랩", "알앤비", "소울", "트랩", "비트", "베이스", "쇼미더머니", # Ko
992
+ "ヒップホップ", "ラップ", "ソウル", "トラップ", "ベース", # Ja
993
+ "嘻哈", "说唱", "饶舌", "灵魂乐", "贝斯", # Zh
994
+ "хип-хоп", "рэп", "соул", "трэп", "бас", # Ru
995
+ "hip hop", "rap", "alma", "bajo", # Es
996
+ "hip hop", "rap", "âme", "basse", # Fr
997
+ "hip hop", "rap", "tâm hồn", # Vi
998
+ "hip hop", "rap", "alma", "batida", # Pt
999
+ "hip hop", "rap", "seele", # De
1000
+ "hip hop", "rap", # Hi
1001
+ "ฮิปฮอป", "แร็ป" # Th
1002
+ ]),
1003
+ ("Live", [
1004
+ "live", "concert", "performance", "stage", "tour", "fancam", "busking", "unplugged", "session",
1005
+ "라이브", "콘서트", "공연", "무대", "투어", "직캠", "버스킹", "실황", # Ko
1006
+ "ライブ", "コンサート", "パフォーマンス", "ステージ", "ツアー", # Ja
1007
+ "现场", "演唱会", "表演", "舞台", "巡演", # Zh
1008
+ "жить", "концерт", "выступление", "сцена", "тур", # Ru
1009
+ "vivo", "concierto", "rendimiento", "escenario", # Es
1010
+ "vivre", "concert", "performance", "scène", # Fr
1011
+ "trực tiếp", "buổi hòa nhạc", "biểu diễn", "sân khấu", # Vi
1012
+ "ao vivo", "concerto", "palco", # Pt
1013
+ "live", "konzert", "bühne", "auftritt", # De
1014
+ "live", "concert", # Hi
1015
+ "สด", "คอนเสิร์ต", "การแสดง" # Th
1016
+ ]),
1017
+ ("Soft", [
1018
+ "soft", "ballad", "acoustic", "lofi", "lo-fi", "chill", "relax", "sleep", "healing", "study", "reading", "winter", "rain", "snow", "night", "dawn", "morning", "piano", "guitar", "folk", "indie",
1019
+ "소프트", "발라드", "어쿠스틱", "로파이", "칠", "휴식", "자장가", "수면", "힐링", "공부", "독서", "겨울", "비", "눈", "밤", "새벽", "아침", "인디", "포크", "잔잔한", "감성", # Ko
1020
+ "ソフト", "バラード", "アコースティック", "ローファイ", "リラックス", "睡眠", "癒し", "勉強", "冬", "雨", "雪", "夜", "夜明け", # Ja
1021
+ "柔和", "民谣", "原声", "低保真", "放松", "睡眠", "治愈", "学习", "冬", "雨", "雪", "夜", # Zh
1022
+ "мягкий", "баллада", "акустика", "лоу-фай", "расслабляться", "спать", "исцеление", "зима", "дождь", "снег", "ночь", # Ru
1023
+ "suave", "balada", "acústico", "relajarse", "curación", "invierno", "lluvia", "nieve", "noche", # Es
1024
+ "doux", "ballade", "acoustique", "se détendre", "guérison", "hiver", "pluie", "neige", "nuit", # Fr
1025
+ "nhẹ nhàng", "bản ballad", "âm thanh", "thư giãn", "chữa lành", "mùa đông", "mưa", "tuyết", "đêm", # Vi
1026
+ "suave", "balada", "acústico", "relaxar", "sono", # Pt
1027
+ "weich", "ballade", "akustisch", "entspannung", "schlaf", "ruhig", # De
1028
+ "soft", "relax", "sukoon", # Hi
1029
+ "เบาๆ", "บัลลาด", "อะคูสติก", "ผ่อนคลาย", "นอนหลับ" # Th
1030
+ ]),
1031
+ ("Pop", [
1032
+ "pop", "hits", "best", "top", "chart", "trending", "billboard", "imayo", "kayo", "ost", "soundtrack", "city pop",
1033
+ "팝", "가요", "인기", "히트", "차트", "트렌드", "노래모음", "오에스티", "사운드트랙", "아이돌", "시티팝", "트로트", # Ko
1034
+ "ポップ", "ヒット", "ベスト", "チャート", "トレンド", "サウンドトラック", "シティポップ", "アニメ", # Ja
1035
+ "流行", "热门", "最佳", "榜单", "趋势", "原森", # Zh
1036
+ "поп", "хиты", "лучший", "диаграмма", "тенденция", "саундтрек", # Ru
1037
+ "pop", "éxitos", "mejor", "gráfico", "tendencia", "banda sonora", # Es
1038
+ "pop", "coups", "mieux", "graphique", "tendance", "bande sonore", # Fr
1039
+ "nhạc pop", "lượt truy cập", "tốt nhất", "biểu đồ", "xu hướng", "nhạc phim", # Vi
1040
+ "musica", "pop", "sucesso", "mais tocadas", # Pt
1041
+ "pop", "musik", "hits", "besten", "chart", # De
1042
+ "gana", "geet", "top", "best", # Hi
1043
+ "เพลง", "ป๊อป", "ฮิต", "ยอดนิยม" # Th
1044
+ ])
1045
+ ]
804
1046
 
1047
+ # Scoring
1048
+ for genre, keywords in rules:
1049
+ if genre not in scores: continue
1050
+ for k in keywords:
1051
+ # Title Match Only
1052
+ if k in title_text:
1053
+ score = 3
1054
+ # Boost specific keywords even more
1055
+ scores[genre] += score
1056
+
1057
+ # Find winner
1058
+ best_genre = max(scores, key=scores.get)
1059
+
1060
+ if scores[best_genre] > 0:
1061
+ self.auto_preset_name = best_genre
1062
+ return best_genre
1063
+
1064
+ # Default Fallback
1065
+ self.auto_preset_name = "Pop"
1066
+ return "Pop"
805
1067
 
1068
+ def play_music(self, item, interactive=True, preserve_queue=False):
1069
+ if not item.get("url"): return # Guard against dummy items
1070
+
1071
+ self.current_track = item
1072
+ self.dm.add_history(item)
1073
+
1074
+ # Apply Auto EQ if enabled
1075
+ target_eq_preset = "Flat"
1076
+ current_eq_mode = EQUALIZER_KEYS[self.current_eq_index]
1077
+
1078
+ # Always run detection for debug logging and state freshness
1079
+ self.detect_auto_eq(item) # Updates self.auto_preset_name
1080
+
1081
+ if current_eq_mode == "Auto":
1082
+ target_eq_preset = self.auto_preset_name
1083
+ else:
1084
+ target_eq_preset = current_eq_mode
806
1085
 
1086
+ self.status_set_time = time.time()
1087
+
1088
+ # Queue Management
1089
+ if not preserve_queue:
1090
+ # New Queue Context from current view
1091
+ current_list = self.get_current_list()
1092
+ # Copy list to queue (Filter only playable items)
1093
+ self.queue = [i for i in current_list if i.get("url")]
1094
+ # Find index in queue
1095
+ try:
1096
+ # Find by URL
1097
+ self.queue_idx = next(i for i, x in enumerate(self.queue) if x['url'] == item['url'])
1098
+ except StopIteration:
1099
+ self.queue_idx = -1
1100
+ self.queue = [] # Should not happen if item came from list
1101
+
1102
+ start_pos = 0
1103
+ if 'url' in item:
1104
+ saved = self.dm.get_progress(item['url'])
1105
+ if saved > 10:
1106
+ # Autoskip resume prompt in Autoplay (interactive=False)
1107
+ if interactive:
1108
+ if self.ask_resume(saved, item.get('title', 'Unknown')): start_pos = saved
1109
+ else:
1110
+ start_pos = 0
1111
+
1112
+ self.player.play(item['url'], start_pos)
1113
+
1114
+ # Re-apply EQ logic (double check: mpv restart wipes af property?)
1115
+ # Yes, play() might restart mpv if socket fails.
1116
+ # But set_equalizer uses set_property which works via IPC *after* launch.
1117
+ # However, play() sends "loadfile" if existing, or restarts.
1118
+ # If restarted, we must re-apply EQ.
1119
+ # Ideally Player.play should handle restoring EQ state or we rely on MyTunesApp to do it.
1120
+ # But for now, let's just re-apply it here after a tiny delay or ensure Player persists it?
1121
+ # Actually Player doesn't persist state. MyTunesApp drives it.
1122
+ # So calling set_equalizer *after* play is correct.
1123
+
1124
+ # Player.play() handles loading the file.
1125
+ # Since we set "af" via IPC AFTER play() starts in some logic,
1126
+ # but here we set it before/during. set_property works anytime.
1127
+
1128
+ # Reset state for new track
1129
+ self.playback_time = start_pos
1130
+ self.playback_duration = 0
1131
+ self.is_paused = False
1132
+ self.stdscr.nodelay(False) # Blocking input for dialog
1133
+ h, w = self.stdscr.getmaxyx()
1134
+ box_h, box_w = 8, 60
1135
+ box_y, box_x = (h - box_h) // 2, (w - box_w) // 2
1136
+
807
1137
  def ask_resume(self, saved_time, track_title):
808
1138
  self.stdscr.nodelay(False) # Blocking input for dialog
809
1139
  h, w = self.stdscr.getmaxyx()
@@ -876,7 +1206,7 @@ class MyTunesApp:
876
1206
 
877
1207
  def open_browser(self, url, app_mode=False):
878
1208
  """Open browser using detached subprocess to prevent TUI freezing."""
879
- self.status_msg = f"🌐 Opening Link: {url[:30]}..."
1209
+ self.show_feedback(f"🌐 Opening Link: {url[:30]}...")
880
1210
 
881
1211
  def run_open():
882
1212
  try:
@@ -920,13 +1250,13 @@ class MyTunesApp:
920
1250
  subprocess.Popen(['xdg-open', url], **popen_kwargs)
921
1251
 
922
1252
  # Feedback logic: Success message then auto-clear
923
- self.status_msg = "✅ Browser Launched! (Check Browser)"
1253
+ self.show_feedback("✅ Browser Launched! (Check Browser)")
924
1254
  time.sleep(2.5)
925
- if "Launched!" in self.status_msg:
926
- self.status_msg = ""
1255
+ # if "Launched!" in self.status_msg: # Logic changed, feedback auto-clears
1256
+ # self.status_msg = ""
927
1257
  except Exception as e:
928
1258
  # Log error silently to TUI status
929
- self.status_msg = f"❌ Browser Error: {str(e)[:20]}"
1259
+ self.show_feedback(f"❌ Browser Error: {str(e)[:20]}")
930
1260
 
931
1261
  # Still execute Popen in a thread to be extra safe,
932
1262
  # but Popen itself is now detached and redirected.
@@ -1011,47 +1341,13 @@ class MyTunesApp:
1011
1341
  self.lang = "en" if self.lang == "ko" else "ko"
1012
1342
  self.dm.data["language"] = self.lang
1013
1343
  self.dm.save_data()
1014
- self.status_msg = "" # Clear stale messages on language switch
1344
+ self.show_feedback("Language Switched / 언어 변경됨") # Clear stale messages on language switch
1015
1345
  elif item["id"] == "quit": self.running = False
1016
1346
  else:
1017
1347
  self.play_music(item, interactive=True)
1018
1348
 
1019
1349
 
1020
- def play_music(self, item, interactive=True, preserve_queue=False):
1021
- if not item.get("url"): return # Guard against dummy items
1022
-
1023
- self.current_track = item
1024
- self.dm.add_history(item)
1025
-
1026
- # Queue Management
1027
- if not preserve_queue:
1028
- # New Queue Context from current view
1029
- current_list = self.get_current_list()
1030
- # Copy list to queue (Filter only playable items)
1031
- self.queue = [i for i in current_list if i.get("url")]
1032
- # Find index in queue
1033
- try:
1034
- # Find by URL
1035
- self.queue_idx = next(i for i, x in enumerate(self.queue) if x['url'] == item['url'])
1036
- except StopIteration:
1037
- self.queue_idx = -1
1038
- self.queue = [] # Should not happen if item came from list
1039
-
1040
- start_pos = 0
1041
- if 'url' in item:
1042
- saved = self.dm.get_progress(item['url'])
1043
- if saved > 10:
1044
- # Autoskip resume prompt in Autoplay (interactive=False)
1045
- if interactive:
1046
- if self.ask_resume(saved, item.get('title', 'Unknown')): start_pos = saved
1047
- else:
1048
- start_pos = 0
1049
-
1050
- self.player.play(item['url'], start_pos)
1051
- # Reset state for new track
1052
- self.playback_time = start_pos
1053
- self.playback_duration = 0
1054
- self.is_paused = False
1350
+
1055
1351
 
1056
1352
  def input_dialog(self, title, prompt):
1057
1353
  """Show a centered input dialog with robust byte-level handling (Fixes Double Enter)."""
@@ -1157,7 +1453,7 @@ class MyTunesApp:
1157
1453
  self.scroll_offset = 0
1158
1454
  if self.view_stack[-1] != "search":
1159
1455
  self.view_stack.append("search")
1160
- self.status_msg = "" # Clear "List is empty" etc.
1456
+ self.set_view_status("") # Clear "List is empty" etc.
1161
1457
  self.draw()
1162
1458
 
1163
1459
  query = self.input_dialog(self.t("search_label"), self.t("search_prompt"))
@@ -1169,7 +1465,7 @@ class MyTunesApp:
1169
1465
  # I should check if it's possible to distinguish.
1170
1466
 
1171
1467
  if query:
1172
- self.status_msg = self.t("searching")
1468
+ self.show_feedback(self.t("searching"))
1173
1469
  self.draw()
1174
1470
  # v2.0.0 Refactor: Threaded Search
1175
1471
  threading.Thread(target=self.perform_search, args=(query,), daemon=True).start()
@@ -1188,7 +1484,7 @@ class MyTunesApp:
1188
1484
  # self.player.loading = True
1189
1485
 
1190
1486
  self.current_search_query = query
1191
- self.status_msg = self.t("searching")
1487
+ self.set_view_status(self.t("searching")) # Persist while threading? Or feedback? Use View Status for async wait
1192
1488
 
1193
1489
  # Resolve yt-dlp path
1194
1490
  yt_dlp_cmd = "yt-dlp"
@@ -1224,7 +1520,15 @@ class MyTunesApp:
1224
1520
  # Dedup Check
1225
1521
  if url not in seen_urls:
1226
1522
  seen_urls.add(url)
1227
- new.append({"title": d.get("title", "Unknown"), "url": url, "duration": dur_str})
1523
+ # Extract Channel/Author
1524
+ channel = d.get("uploader") or d.get("channel") or d.get("uploader_id") or "Unknown"
1525
+ new.append({
1526
+ "title": d.get("title", "Unknown"),
1527
+ "url": url,
1528
+ "duration": dur_str,
1529
+ "author": channel,
1530
+ "channel": channel
1531
+ })
1228
1532
  except: pass
1229
1533
 
1230
1534
  # Enforce hard limit
@@ -1239,11 +1543,11 @@ class MyTunesApp:
1239
1543
  # SAVE to History
1240
1544
  self.dm.add_search_results(new)
1241
1545
 
1242
- self.status_msg = f"Search Done. ({len(new)} results)"
1546
+ self.set_view_status(f"Search Done. ({len(new)} results)")
1243
1547
  else:
1244
- self.status_msg = self.t("no_results")
1548
+ self.set_view_status(self.t("no_results"))
1245
1549
 
1246
- except Exception as e: self.status_msg = f"Error: {e}"
1550
+ except Exception as e: self.show_feedback(f"Error: {e}")
1247
1551
  finally:
1248
1552
  self.player.loading = False
1249
1553
 
@@ -1293,11 +1597,47 @@ class MyTunesApp:
1293
1597
  bar_str = f"[{'='*fill_w}{'-'*(bar_w-fill_w)}] {time_str}"
1294
1598
  self.stdscr.addstr(h - 4, 2, bar_str, curses.color_pair(3))
1295
1599
 
1296
- # Footer Line 2: Song Title
1600
+ # Footer Line 2: Song Title + EQ Status (Right Aligned)
1297
1601
  if self.current_track:
1298
1602
  status_icon = "❚❚" if self.is_paused else "▶"
1299
- song_title = self.truncate(self.current_track['title'], w - 10)
1300
- self.stdscr.addstr(h - 3, 2, f"{status_icon} {song_title}", curses.color_pair(2))
1603
+
1604
+ # Prepare EQ Info for Title Line
1605
+ current_eq = EQUALIZER_KEYS[self.current_eq_index]
1606
+
1607
+ # Show effective EQ with Mode Indicator
1608
+ # Auto Mode: "🎚 Auto: Jazz"
1609
+ # Manual Mode: "🎚 EQ: Pop"
1610
+ if current_eq == "Auto":
1611
+ eq_display = f"🎚 Auto: {self.auto_preset_name}"
1612
+ else:
1613
+ eq_display = f"🎚 EQ: {current_eq}"
1614
+
1615
+ eq_info = f" [{eq_display}]" # Right side content
1616
+
1617
+ # Calculate space for Title
1618
+ # Total Width - margins(2) - Icon(2) - Space(1) - EQ Info - Branding(Maybe separate line, but here we just need fit)
1619
+ # Actually Title line is separate from Branding line.
1620
+ # w - 2 (left) - 2 (icon) - len(eq_info)
1621
+
1622
+ avail_w = w - 6 - self.get_display_width(eq_info)
1623
+
1624
+ song_title = self.current_track['title']
1625
+ # Append Channel check? May make title too long, prioritize Title + EQ
1626
+ ch_name = self.current_track.get('channel') or self.current_track.get('author')
1627
+ if ch_name:
1628
+ song_title += f" [{ch_name}]" # Append content first
1629
+
1630
+ trunc_title = self.truncate(song_title, avail_w)
1631
+
1632
+ # Draw Title
1633
+ self.stdscr.addstr(h - 3, 2, f"{status_icon} {trunc_title}", curses.color_pair(2))
1634
+
1635
+ # Draw EQ Info (Right Aligned on same line)
1636
+ eq_x = 2 + self.get_display_width(f"{status_icon} {trunc_title}")
1637
+ # Or strictly right aligned?
1638
+ eq_x_strict = w - 2 - self.get_display_width(eq_info)
1639
+ self.stdscr.addstr(h - 3, eq_x_strict, eq_info, curses.color_pair(3) | curses.A_BOLD)
1640
+
1301
1641
  else:
1302
1642
  self.stdscr.addstr(h - 3, 2, self.t("stopped"), curses.color_pair(1))
1303
1643
 
@@ -1308,22 +1648,31 @@ class MyTunesApp:
1308
1648
  # Draw Branding always - Bright/Bold White
1309
1649
  self.stdscr.addstr(h - 2, branding_x, branding, curses.color_pair(1) | curses.A_BOLD)
1310
1650
 
1311
- # Draw Status Msg
1651
+ # Draw Status Msg (Priority Logic)
1652
+ displayed_msg = ""
1653
+ attr = curses.A_NORMAL
1654
+
1655
+ # 1. Loading
1312
1656
  if self.player.loading:
1313
- self.stdscr.addstr(h - 2, 2, f"⏳ Loading...", curses.color_pair(6) | curses.A_BLINK)
1314
- elif self.status_msg:
1315
- # Auto-clear transient warnings after 5 seconds
1316
- is_transient = "Invalid key" in self.status_msg or "잘못된 키" in self.status_msg or "영문" in self.status_msg or "English" in self.status_msg
1317
- if time.time() - self.status_set_time > 5 and is_transient:
1318
- self.status_msg = ""
1657
+ displayed_msg = "⏳ Loading..."
1658
+ attr = curses.color_pair(6) | curses.A_BLINK
1659
+
1660
+ # 2. Transient Feedback (Volume, Keypress, Errors)
1661
+ elif time.time() < self.feedback_expiry and self.feedback_msg:
1662
+ displayed_msg = f"📢 {self.feedback_msg}"
1663
+ attr = curses.color_pair(3) | curses.A_BOLD
1319
1664
 
1320
- if self.status_msg:
1321
- avail_w = branding_x - 4
1322
- if avail_w > 5:
1323
- msg = self.truncate(self.status_msg, avail_w)
1324
- # Use Bold Yellow (Pair 3) for premium static warning
1325
- attr = curses.color_pair(3) | curses.A_BOLD
1326
- self.stdscr.addstr(h - 2, 2, f"📢 {msg}", attr)
1665
+ # 3. Persistent View Context (Favorites path, History count, etc.)
1666
+ elif self.view_msg:
1667
+ displayed_msg = f"ℹ️ {self.view_msg}"
1668
+ attr = curses.color_pair(1)
1669
+
1670
+ # 4. Fallback/Idle (Empty)
1671
+
1672
+ if displayed_msg:
1673
+ avail_w = branding_x - 4
1674
+ if avail_w > 5:
1675
+ self.stdscr.addstr(h - 2, 2, self.truncate(displayed_msg, avail_w), attr)
1327
1676
 
1328
1677
  # List Area (Remaining Middle)
1329
1678
  list_top = 4
@@ -1363,6 +1712,25 @@ class MyTunesApp:
1363
1712
 
1364
1713
  title_txt = self.truncate(item.get('title',''), avail_w)
1365
1714
 
1715
+ # Channel Name Logic (Smart Display)
1716
+ channel_txt = ""
1717
+ box_w = 0
1718
+ if w > 80: # Only show if screen is wide enough
1719
+ raw_ch = item.get('channel') or item.get('author') or ""
1720
+ if raw_ch:
1721
+ # Max channel width: 20 or 25% of width
1722
+ max_ch_w = min(20, w // 4)
1723
+ if len(raw_ch) > max_ch_w:
1724
+ raw_ch = raw_ch[:max_ch_w-1] + "…"
1725
+ channel_txt = f"[{raw_ch}]"
1726
+ box_w = len(channel_txt) + 1 # +1 padding
1727
+
1728
+ # Refine Title Width based on Channel presence
1729
+ real_avail_w = avail_w - box_w
1730
+ if real_avail_w < 5: real_avail_w = 5
1731
+
1732
+ title_txt = self.truncate(item.get('title',''), real_avail_w)
1733
+
1366
1734
  try:
1367
1735
  curr_x = 2
1368
1736
  # Base Style
@@ -1374,17 +1742,15 @@ class MyTunesApp:
1374
1742
  base_style = curses.A_NORMAL
1375
1743
 
1376
1744
  # 1. Prefix
1377
- # If selected, base_style is Blue/White. If playing(unselected), Green.
1378
1745
  self.stdscr.addstr(y_pos, curr_x, prefix, base_style)
1379
1746
  curr_x += len(prefix)
1380
1747
 
1381
- # 2. Play Icon (Green if not selected)
1382
- # base_style already covers Green if playing and not selected.
1748
+ # 2. Play Icon
1383
1749
  if chk_icon:
1384
1750
  self.stdscr.addstr(y_pos, curr_x, chk_icon, base_style)
1385
1751
  curr_x += len(chk_icon)
1386
1752
 
1387
- # 3. Fav Icon (Yellow if not selected)
1753
+ # 3. Fav Icon
1388
1754
  f_style = base_style
1389
1755
  if fav_icon and not is_sel: f_style = curses.color_pair(3) | curses.A_BOLD
1390
1756
  if fav_icon:
@@ -1395,15 +1761,15 @@ class MyTunesApp:
1395
1761
  self.stdscr.addstr(y_pos, curr_x, title_txt, base_style)
1396
1762
  curr_x += self.get_display_width(title_txt)
1397
1763
 
1398
- # 5. Fill Padding
1399
- remain = w - 2 - curr_x - len(dur_txt)
1400
- if remain > 0:
1401
- self.stdscr.addstr(y_pos, curr_x, " "*remain, base_style)
1402
- curr_x += remain
1403
-
1404
- # 6. Duration
1764
+ # 5. Channel Info (Right aligned relative to title space or fixed?)
1765
+ if channel_txt:
1766
+ self.stdscr.addstr(y_pos, curr_x + 1, channel_txt, base_style | curses.A_DIM)
1767
+ # No update to curr_x needed for duration anchor, but good for safety
1768
+
1769
+ # 6. Duration (Right Anchored)
1405
1770
  if dur_txt:
1406
- self.stdscr.addstr(y_pos, curr_x, dur_txt, base_style)
1771
+ dur_x = w - 2 - len(dur_txt)
1772
+ self.stdscr.addstr(y_pos, dur_x, dur_txt, base_style)
1407
1773
 
1408
1774
  except: pass
1409
1775
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mytunes-pro
3
- Version: 2.0.5
3
+ Version: 2.0.6
4
4
  Summary: A lightweight, keyboard-centric terminal player for streaming YouTube music.
5
5
  Author-email: loxo <loxo5432@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/postgresql-co-kr/mytunes
@@ -23,9 +23,9 @@ Dynamic: license-file
23
23
  ## 🚀 Terminal-based Media Workflow Experiment v2.0.5
24
24
 
25
25
  > [!IMPORTANT]
26
- > **Legal Disclaimer:** This project is a personal, non-commercial research experiment for developer education.
27
- > It does not host, provide, or distribute any media content.
28
- > All media sources are independently accessed and configured by the user.
26
+ > **Legal Disclaimer:** This project is a personal, non-commercial research experiment for developer education.
27
+ > It does not host, provide, or distribute any media content.
28
+ > All media sources are independently accessed and configured by the user.
29
29
  > Users are solely responsible for ensuring that their usage complies with the terms of service of any third-party platforms accessed via this tool.
30
30
 
31
31
  MyTunes Pro is a developer-focused **CLI Media Tool** for experimenting with terminal-based media workflows.
@@ -39,6 +39,8 @@ leveraging the `mpv` engine for local media processing and playback.
39
39
  ![Python](https://img.shields.io/badge/Python-3.9%2B-blue.svg)
40
40
  ![License](https://img.shields.io/badge/License-MIT-green.svg)
41
41
 
42
+ ---
43
+
42
44
  ## 📸 Screenshots
43
45
  | | |
44
46
  | :---: | :---: |
@@ -68,6 +70,179 @@ leveraging the `mpv` engine for local media processing and playback.
68
70
 
69
71
  ---
70
72
 
73
+ ## 🚀 Quick Install
74
+
75
+ We strongly recommend using **`pipx`** on modern macOS/Linux systems (PEP 668).
76
+
77
+ ### 1. Recommended Method (pipx)
78
+ Automatically creates an isolated environment and registers the command.
79
+
80
+ ```bash
81
+ # Ensure ensuredpath is run to register the command after pipx install!
82
+ pipx install mytunes-pro
83
+ pipx ensurepath
84
+ source ~/.zshrc # or source ~/.bashrc (apply immediately to current terminal)
85
+ ```
86
+
87
+ ### 2. Standard pip Method
88
+ If you encounter the `externally-managed-environment` error, add the following flag:
89
+
90
+ ```bash
91
+ pip install mytunes-pro --break-system-packages
92
+ ```
93
+
94
+ After installation, type **`mp`** anywhere in the terminal to run!
95
+
96
+ ### 🔄 Update
97
+ If already installed, simply use the command below to update to the latest features:
98
+
99
+ ```bash
100
+ pipx upgrade mytunes-pro
101
+ ```
102
+
103
+ ---
104
+
105
+ ## 🛠 Prerequisites
106
+
107
+ Please install the necessary tools for your operating system before running.
108
+
109
+ ### macOS (Using Homebrew)
110
+ ```bash
111
+ brew install mpv python3 pipx
112
+ ```
113
+
114
+ ### Linux (Ubuntu/Debian)
115
+ ```bash
116
+ sudo apt update
117
+ sudo apt install mpv python3 python3-pip pipx python3-venv -y
118
+ ```
119
+
120
+ ### Windows (WSL Guide)
121
+
122
+ Guide for Windows users where Korean search might not work or installation is difficult.
123
+
124
+ > **❓ What is WSL?**
125
+ > It allows you to run Linux environments directly on Windows. MyTunes works perfectly in this environment.
126
+
127
+ 1. **Install WSL**:
128
+ - Right-click `Start` -> Run `Terminal (Admin)`.
129
+ - Enter the command below and **Reboot**:
130
+ ```powershell
131
+ wsl --install -d Debian
132
+ ```
133
+
134
+ 2. **Install Essentials**:
135
+ ```bash
136
+ sudo apt update && sudo apt install mpv python3-pip pipx -y
137
+ ```
138
+
139
+ 3. **Install MyTunes**:
140
+ ```bash
141
+ pipx install mytunes-pro
142
+ pipx ensurepath
143
+ source ~/.bashrc # Apply settings immediately
144
+ ```
145
+
146
+ ---
147
+
148
+ ## 🧑‍💻 Manual Installation (For Developers)
149
+
150
+ Follow these steps to modify source code or use the development version.
151
+
152
+ 1. **Clone Repository**:
153
+ ```bash
154
+ git clone https://github.com/postgresql-co-kr/mytunes.git
155
+ cd mytunes
156
+ ```
157
+
158
+ 2. **Setup Virtual Environment**:
159
+ ```bash
160
+ python3 -m venv venv
161
+ source venv/bin/activate # macOS/Linux
162
+ pip install -r requirements.txt
163
+ ```
164
+
165
+ 3. **Run**:
166
+ ```bash
167
+ python3 mytune.py
168
+ ```
169
+
170
+ ---
171
+
172
+ ## ⌨️ Controls
173
+
174
+ **MyTunes Pro** is controlled entirely by keyboard.
175
+ We recommend using **Number Keys** for lag-free operation even in multi-language input modes.
176
+
177
+ ### ⚡️ Instant Hotkeys (Number Keys)
178
+ Executes immediately without worrying about input language status.
179
+
180
+ | Key | Function | Description |
181
+ | :--- | :--- | :--- |
182
+ | **`1`** | **Search** | Open music search (Same as `S`) |
183
+ | **`2`** | **Favorites** | View favorites list (Same as `F`) |
184
+ | **`3`** | **History** | View recently played 100 tracks (Same as `R`) |
185
+ | **`4`** | **Main** | Return to main screen (Same as `M`) |
186
+ | **`5`** | **Add/Del** | Toggle favorite for selected track (Same as `A`) |
187
+ | **`+`** | **Vol UP** | Volume +5% (Same as `=`) |
188
+ | **`-`** | **Vol DOWN** | Volume -5% (Same as `_`) |
189
+ | **`F7`** | **Open YouTube** | View current track in browser |
190
+ | **`6`** | **Back** | Go to previous screen (Same as `Q`, `h`) |
191
+ | **`L`** | **Forward** | Go forward to previous screen (`Right Arrow`) |
192
+ | **`ESC`** | **Background** | **Exit without stopping music** (Background Play) |
193
+
194
+ ### 🧭 Basic Navigation
195
+ | Key | Action |
196
+ | :--- | :--- |
197
+ | `↑` / `↓` / `k` / `j` | Move selection Up/Down (Vim keys supported) |
198
+ | `Enter` / `l` | **Select / Play** |
199
+ | `Space` | Play / Pause |
200
+ | `-` / `+` | **Volume Control** |
201
+ | `,` / `.` | Rewind / Forward 10s |
202
+ | `<` / `>` | Rewind / Forward 30s (Shift) |
203
+ | `Backspace` / `h` / `q` | Go Back / Clear Search |
204
+ | `L` | **Go Forward** |
205
+ | `/` | **Search** (Vim Style) |
206
+
207
+ ---
208
+
209
+ ## 📂 Data Storage
210
+ - Favorites and playback history are permanently saved in `~/.pymusic_data.json` in your home directory.
211
+ - Data is preserved even after restarting the program.
212
+
213
+ ---
214
+ ---
215
+
216
+ # 🎵 MyTunes Pro (Experimental Media Tool - KR)
217
+
218
+ ## 🚀 터미널 기반 미디어 워크플로우 실험 v2.0.5
219
+
220
+ > [!IMPORTANT]
221
+ > **법적 면책 고지:** 본 프로젝트는 개발자 교육 및 연구를 목적으로 하는 개인적, 비상업적 실험입니다.
222
+ > 본 소프트웨어는 어떠한 미디어 콘텐츠도 직접 호스팅하거나 배포하지 않습니다.
223
+ > 모든 미디어 소스는 사용자의 로컬 환경에서 직접 구성되고 접근되며, 사용자는 외부 플랫폼의 이용 약관을 준수할 책임이 있습니다.
224
+
225
+ MyTunes Pro는 개발자를 위해 설계된 **CLI 미디어 실험 도구**입니다.
226
+ Python `curses` 라이브러리를 통해 터미널 환경에서 미디어 URL을 로드하고 관리하며,
227
+ 사용자가 설치한 `mpv` 등의 외부 도구와 연동하여 미디어 워크플로우를 테스트할 수 있습니다.
228
+
229
+ ## ✨ 주요 특징
230
+
231
+ - **미디어 핸들링**: 외부 추출 도구를 사용한 미디어 URL 로드 및 처리 지원.
232
+ - **TUI 워크플로우**: `curses` 라이브러리 기반의 효율적인 터미널 인터페이스.
233
+ - **작업 유지**: 순차적 미디어 로딩 및 마지막 작업 상태 복원 기능.
234
+ - **환경 연동**: 사용자에 의해 구성된 외부 미디어 도구와의 연동 지원. (본 소프트웨어는 외부 도구를 포함하여 배포하지 않습니다.)
235
+
236
+ ---
237
+
238
+ ## 📸 스크린샷 (Screenshots)
239
+ | | |
240
+ | :---: | :---: |
241
+ | ![Main](https://raw.githubusercontent.com/postgresql-co-kr/mytunes/master/screenshots/screenshot_1.webp) | ![Search](https://raw.githubusercontent.com/postgresql-co-kr/mytunes/master/screenshots/screenshot_2.webp) |
242
+ | ![Play](https://raw.githubusercontent.com/postgresql-co-kr/mytunes/master/screenshots/screenshot_3.webp) | ![List](https://raw.githubusercontent.com/postgresql-co-kr/mytunes/master/screenshots/screenshot_4.webp) |
243
+
244
+ ---
245
+
71
246
  ## 🚀 빠른 설치 (Quick Install)
72
247
 
73
248
  최신 macOS/Linux 시스템(PEP 668)에서는 **`pipx`** 사용을 강력히 권장합니다.
@@ -129,12 +304,12 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
129
304
  wsl --install -d Debian
130
305
  ```
131
306
 
132
- 3. **필수 도구 설치**:
307
+ 2. **필수 도구 설치**:
133
308
  ```bash
134
309
  sudo apt update && sudo apt install mpv python3-pip pipx -y
135
310
  ```
136
311
 
137
- 4. **MyTunes 설치**:
312
+ 3. **MyTunes 설치**:
138
313
  ```bash
139
314
  pipx install mytunes-pro
140
315
  pipx ensurepath
@@ -211,30 +386,6 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
211
386
  ---
212
387
  ---
213
388
 
214
- ---
215
-
216
- # 🎵 MyTunes Pro (Experimental Media Tool - KR)
217
-
218
- ## 🚀 터미널 기반 미디어 워크플로우 실험 v2.0.5
219
-
220
- > [!IMPORTANT]
221
- > **법적 면책 고지:** 본 프로젝트는 개발자 교육 및 연구를 목적으로 하는 개인적, 비상업적 실험입니다.
222
- > 본 소프트웨어는 어떠한 미디어 콘텐츠도 직접 호스팅하거나 배포하지 않습니다.
223
- > 모든 미디어 소스는 사용자의 로컬 환경에서 직접 구성되고 접근되며, 사용자는 외부 플랫폼의 이용 약관을 준수할 책임이 있습니다.
224
-
225
- MyTunes Pro는 개발자를 위해 설계된 **CLI 미디어 실험 도구**입니다.
226
- Python `curses` 라이브러리를 통해 터미널 환경에서 미디어 URL을 로드하고 관리하며,
227
- 사용자가 설치한 `mpv` 등의 외부 도구와 연동하여 미디어 워크플로우를 테스트할 수 있습니다.
228
-
229
- ## ✨ 주요 특징
230
-
231
- - **미디어 핸들링**: 외부 추출 도구를 사용한 미디어 URL 로드 및 처리 지원.
232
- - **TUI 워크플로우**: `curses` 라이브러리 기반의 효율적인 터미널 인터페이스.
233
- - **작업 유지**: 순차적 미디어 로딩 및 마지막 작업 상태 복원 기능.
234
- - **환경 연동**: 사용자에 의해 구성된 외부 미디어 도구와의 연동 지원. (본 소프트웨어는 외부 도구를 포함하여 배포하지 않습니다.)
235
-
236
- ---
237
-
238
389
  ## 🔄 Changelog
239
390
 
240
391
  ### v2.0.5 (2026-02-01)
@@ -0,0 +1,8 @@
1
+ mytunes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mytunes/app.py,sha256=uZUIV2dCPFZh0ONTazup5wwP6dqEOthtgOITA99iFrk,80327
3
+ mytunes_pro-2.0.6.dist-info/licenses/LICENSE,sha256=lOrP0EIjxcgJia__W3f3PVDZkRd2oRzFkyH2g3LRRCg,1063
4
+ mytunes_pro-2.0.6.dist-info/METADATA,sha256=r_WP429frS7K5SRuLiWpYAq70DHchDu_v_YzSYPPnjs,27583
5
+ mytunes_pro-2.0.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
+ mytunes_pro-2.0.6.dist-info/entry_points.txt,sha256=6-MsC13nIgzLvrREaGotc32FgxHx_Iuu1z2qCzJs1_4,65
7
+ mytunes_pro-2.0.6.dist-info/top_level.txt,sha256=KWzdFyNNG_sO7GT83-sN5fYArP4_DL5I8HYIwgazXyY,8
8
+ mytunes_pro-2.0.6.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- mytunes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- mytunes/app.py,sha256=-vJSyC0gF0rj0Q3LkJWwlDH0c75OHZRxQ3l4Nxj19Ps,59412
3
- mytunes_pro-2.0.5.dist-info/licenses/LICENSE,sha256=lOrP0EIjxcgJia__W3f3PVDZkRd2oRzFkyH2g3LRRCg,1063
4
- mytunes_pro-2.0.5.dist-info/METADATA,sha256=GelVLtHE3uFp4s76DylYNpNFF4lgz8hY4hokILwIE6g,23172
5
- mytunes_pro-2.0.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
- mytunes_pro-2.0.5.dist-info/entry_points.txt,sha256=6-MsC13nIgzLvrREaGotc32FgxHx_Iuu1z2qCzJs1_4,65
7
- mytunes_pro-2.0.5.dist-info/top_level.txt,sha256=KWzdFyNNG_sO7GT83-sN5fYArP4_DL5I8HYIwgazXyY,8
8
- mytunes_pro-2.0.5.dist-info/RECORD,,