mytunes-pro 2.0.2__tar.gz → 2.0.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mytunes-pro
3
- Version: 2.0.2
3
+ Version: 2.0.3
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
@@ -19,7 +19,7 @@ Dynamic: license-file
19
19
 
20
20
  # 🎵 MyTunes Pro (Korean)
21
21
 
22
- ## 🚀 Professional TUI Music Player v2.0.2
22
+ ## 🚀 Professional TUI Music Player v2.0.3
23
23
 
24
24
  MyTunes Pro는 **Context7**의 심층 리서치를 기반으로 제작된 **Premium CLI Music Player**입니다.
25
25
  Python `curses` 라이브러리를 사용하여 터미널 환경에서도 **GUI급의 유려한 UX**를 제공하며,
@@ -212,7 +212,7 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
212
212
 
213
213
  # 🎵 MyTunes Pro (English)
214
214
 
215
- **Modern CLI YouTube Music Player (v2.0.2)**
215
+ **Modern CLI YouTube Music Player (v2.0.3)**
216
216
  A lightweight, keyboard-centric terminal player for streaming YouTube music.
217
217
 
218
218
  ---
@@ -301,6 +301,13 @@ sudo apt install mpv python3 python3-pip pipx python3-venv -y
301
301
 
302
302
  ## 🔄 Changelog
303
303
 
304
+ ### v2.0.3 (Input Handling & Unicode Stability)
305
+
306
+ - **Input Logic**: Replaced legacy `getch()` with `get_wch()` in all UI dialogs (`ask_resume`, `show_copy_dialog`) for robust wide-character and Unicode support.
307
+ - **Architecture**: Refactored the input handling system into a modular, command-based architecture (v2.0.3).
308
+ - **Decoupling**: Separated input collection (`get_next_event`), event normalization, and command execution.
309
+ - **Improved ESC Handling**: Enhanced detection of ESC and multi-byte sequences (including Option+Backspace) for smoother navigation.
310
+
304
311
  ### v2.0.2 (Stability & Browser Optimization)
305
312
 
306
313
  - **Browser Launch**: Switched to fully decoupled `subprocess.Popen` logic for browser opening. This eliminates occasional TUI freezes when launching YouTube (F7) or Live Station (F8) by bypassing `webbrowser` library limitations.
@@ -1,6 +1,6 @@
1
1
  # 🎵 MyTunes Pro (Korean)
2
2
 
3
- ## 🚀 Professional TUI Music Player v2.0.2
3
+ ## 🚀 Professional TUI Music Player v2.0.3
4
4
 
5
5
  MyTunes Pro는 **Context7**의 심층 리서치를 기반으로 제작된 **Premium CLI Music Player**입니다.
6
6
  Python `curses` 라이브러리를 사용하여 터미널 환경에서도 **GUI급의 유려한 UX**를 제공하며,
@@ -193,7 +193,7 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
193
193
 
194
194
  # 🎵 MyTunes Pro (English)
195
195
 
196
- **Modern CLI YouTube Music Player (v2.0.2)**
196
+ **Modern CLI YouTube Music Player (v2.0.3)**
197
197
  A lightweight, keyboard-centric terminal player for streaming YouTube music.
198
198
 
199
199
  ---
@@ -282,6 +282,13 @@ sudo apt install mpv python3 python3-pip pipx python3-venv -y
282
282
 
283
283
  ## 🔄 Changelog
284
284
 
285
+ ### v2.0.3 (Input Handling & Unicode Stability)
286
+
287
+ - **Input Logic**: Replaced legacy `getch()` with `get_wch()` in all UI dialogs (`ask_resume`, `show_copy_dialog`) for robust wide-character and Unicode support.
288
+ - **Architecture**: Refactored the input handling system into a modular, command-based architecture (v2.0.3).
289
+ - **Decoupling**: Separated input collection (`get_next_event`), event normalization, and command execution.
290
+ - **Improved ESC Handling**: Enhanced detection of ESC and multi-byte sequences (including Option+Backspace) for smoother navigation.
291
+
285
292
  ### v2.0.2 (Stability & Browser Optimization)
286
293
 
287
294
  - **Browser Launch**: Switched to fully decoupled `subprocess.Popen` logic for browser opening. This eliminates occasional TUI freezes when launching YouTube (F7) or Live Station (F8) by bypassing `webbrowser` library limitations.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mytunes-pro"
7
- version = "2.0.2"
7
+ version = "2.0.3"
8
8
  authors = [
9
9
  { name = "loxo", email = "loxo5432@gmail.com" },
10
10
  ]
@@ -32,7 +32,7 @@ MPV_SOCKET = "/tmp/mpv_socket"
32
32
  LOG_FILE = "/tmp/mytunes_mpv.log"
33
33
  PID_FILE = "/tmp/mytunes_mpv.pid"
34
34
  APP_NAME = "MyTunes Pro"
35
- APP_VERSION = "2.0.2"
35
+ APP_VERSION = "2.0.3"
36
36
 
37
37
  # === [Strings & Localization] ===
38
38
  STRINGS = {
@@ -585,332 +585,219 @@ class MyTunesApp:
585
585
  m, s = divmod(int(seconds), 60)
586
586
  return f"{m:02d}:{s:02d}"
587
587
 
588
- def handle_input(self):
588
+ def get_next_event(self):
589
+ """Unified input collection and normalization (Unicode, special keys, macros)."""
589
590
  try:
590
- # Use get_wch for Unicode support (captures Korean shortcuts)
591
591
  key = self.stdscr.get_wch()
592
- except curses.error:
593
- return
594
- except:
595
- return
596
-
597
- if key == -1: return
592
+ except curses.error: return None
593
+ except: return None
598
594
 
599
- # Reset Idle Timer
595
+ if key == -1: return None
600
596
  self.last_input_time = time.time()
601
597
 
602
-
603
- # Handle formatting: invalid key might be int -1
604
-
605
- # Resize Info
598
+ # 1. Resize handling
606
599
  if key == curses.KEY_RESIZE:
607
600
  self.stdscr.clear()
608
601
  self.stdscr.refresh()
609
- return
602
+ return "RESIZE"
610
603
 
611
- # GLOBAL ESC: Background Play (Exit but keep music)
612
- # get_wch returns int 27 or str '\x1b' depending on system/lib
613
- # v2.0.1 MAC FIX: Check for Option+Backspace (ESC then 127) for Deletion
604
+ # 2. ESC / Combined sequences (Option+Backspace macro)
614
605
  if key == 27 or key == '\x1b':
615
- # Peek for next key with very short timeout
616
- self.stdscr.timeout(50)
606
+ self.stdscr.timeout(50) # Tiny peek timeout
617
607
  try:
618
- next_key = self.stdscr.getch()
619
- if next_key == 127: # Backspace
620
- # This is Option+Backspace -> Treat as DELETE
621
- key = curses.KEY_DC # Transform to Delete Key
622
- else:
623
- # If valid key but not 127, put it back or handle?
624
- # For simplicity, if it's not the sequence we want, we treat ESC as ESC
625
- # and if we consumed a key, well, generic ESC logic applies.
626
- # Ideally ungetch if possible, but for now fallback to ESC behavior.
627
- # But if we consumed a legitimate key user typed fast, that's bad.
628
- # However, 50ms is very fast.
629
- if next_key != -1:
630
- curses.ungetch(next_key)
631
-
632
- # Proceed with standard ESC behavior
633
- self.stop_on_exit = False
634
- self.running = False
635
- return
636
- except:
637
- # Timeout / Error -> Just ESC
638
- self.stop_on_exit = False
639
- self.running = False
640
- return
641
- finally:
642
- # Restore timeout
643
- self.stdscr.timeout(1000 if (time.time() - getattr(self, 'last_input_time', 0) > 60 and self.is_paused) else 200)
608
+ nk = self.stdscr.getch()
609
+ if nk == 127: return "DELETE" # Option+Backspace as DELETE
610
+ if nk != -1: curses.ungetch(nk)
611
+ except: pass
612
+ finally: self.stdscr.timeout(200) # Reset to standard
613
+ return "EXIT_BKG" # Standard ESC
644
614
 
645
- # Handle Mouse Click
615
+ # 3. Mouse Click
646
616
  if key == curses.KEY_MOUSE:
647
617
  try:
648
618
  _, mx, my, _, bstate = curses.getmouse()
649
- if bstate & curses.BUTTON1_CLICKED or bstate & curses.BUTTON1_RELEASED:
619
+ if bstate & (curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED):
650
620
  h, w = self.stdscr.getmaxyx()
651
621
  branding = "mytunes-pro.com/postgresql.co.kr"
652
- branding_len = len(branding)
653
- branding_x = w - 2 - branding_len
654
-
622
+ branding_x = w - 2 - len(branding)
655
623
  if my == h - 2 and branding_x <= mx < w - 2:
656
- # Check which part was clicked
657
- # mytunes-pro.com (first 15 chars)
658
- # / (1 char)
659
- # postgresql.co.kr (last 16 chars)
660
624
  rel_x = mx - branding_x
661
- if rel_x < 15:
662
- url = "https://mytunes-pro.com"
663
- self.open_browser(url)
664
- elif rel_x > 15:
665
- url = "https://postgresql.co.kr"
666
- self.open_browser(url)
667
- except:
668
- pass
669
- return
625
+ if rel_x < 15: return "OPEN_HOME"
626
+ if rel_x > 15: return "OPEN_PARTNER"
627
+ except: pass
628
+ return "MOUSE_CLICK"
629
+
630
+ # 4. Standard Keys Mapping
631
+ k_char = str(key).lower() if isinstance(key, str) else str(key)
632
+ mapping = {
633
+ str(curses.KEY_LEFT): "NAV_BACK", str(curses.KEY_BACKSPACE): "NAV_BACK", "127": "NAV_BACK",
634
+ "q": "NAV_BACK", "6": "NAV_BACK", "h": "NAV_BACK",
635
+ str(curses.KEY_RIGHT): "NAV_FORWARD", "l": "NAV_FORWARD",
636
+ str(curses.KEY_UP): "MOVE_UP", "k": "MOVE_UP",
637
+ str(curses.KEY_DOWN): "MOVE_DOWN", "j": "MOVE_DOWN",
638
+ "\n": "ACTIVATE", "\r": "ACTIVATE", "10": "ACTIVATE", "13": "ACTIVATE", str(curses.KEY_ENTER): "ACTIVATE",
639
+ "s": "SEARCH", "1": "SEARCH", "/": "SEARCH",
640
+ "f": "FAVORITES", "2": "FAVORITES",
641
+ "r": "HISTORY", "3": "HISTORY",
642
+ "m": "MAIN_MENU", "4": "MAIN_MENU",
643
+ " ": "TOGGLE_PAUSE",
644
+ "-": "VOL_DOWN", "_": "VOL_DOWN",
645
+ "+": "VOL_UP", "=": "VOL_UP",
646
+ ",": "SEEK_BACK_10", ".": "SEEK_FWD_10",
647
+ "<": "SEEK_BACK_30", ">": "SEEK_FWD_30",
648
+ "a": "TOGGLE_FAV", "5": "TOGGLE_FAV",
649
+ str(curses.KEY_F7): "OPEN_BROWSER",
650
+ str(curses.KEY_F8): "OPEN_HOME_APP",
651
+ str(curses.KEY_F9): "SHARE",
652
+ str(curses.KEY_DC): "DELETE", "d": "DELETE"
653
+ }
654
+ return mapping.get(k_char)
655
+
656
+ def handle_input(self):
657
+ """Clean dispatcher: Get normalized command and execute it."""
658
+ cmd = self.get_next_event()
659
+ if not cmd: return
670
660
 
671
- # Helper to normalize input for checking
672
- k_char = str(key).lower() if isinstance(key, str) else ""
673
-
674
661
  current_list = self.get_current_list()
675
662
 
676
- # Navigation logic
677
- # Back: Q, Left Arrow, Backspace, h, 6
678
- # Fix: Removed Korean mappings ('ㅂ', 'ㅗ') to prevent IME ghost keys per user request
679
- if key == curses.KEY_LEFT or key == curses.KEY_BACKSPACE or key == 127 or \
680
- k_char in ['q', '6', 'h']:
663
+ # 1. Functional Commands (Require Logic)
664
+ if cmd == "NAV_BACK":
681
665
  if len(self.view_stack) > 1:
682
- # Pop current view and push to forward stack
683
- current_view = self.view_stack.pop()
684
- self.forward_stack.append(current_view)
685
-
686
- self.selection_idx = 0; self.scroll_offset = 0
687
- self.status_msg = ""
688
- # Else: Do nothing (Prevent Quit on Q)
689
- return
690
-
691
- # Forward: L, Right Arrow (Browser Style)
692
- # Re-visit the view we just popped from
693
- # Fix: Removed Korean mappings ('ㅣ') to prevent IME ghost keys
694
- if k_char in ['l', 'L'] or key == curses.KEY_RIGHT:
666
+ self.forward_stack.append(self.view_stack.pop())
667
+ self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
668
+
669
+ elif cmd == "NAV_FORWARD":
695
670
  if self.forward_stack:
696
- next_view = self.forward_stack.pop()
697
- self.view_stack.append(next_view)
698
- self.selection_idx = 0; self.scroll_offset = 0
699
- self.status_msg = ""
700
- return
701
-
702
- return
671
+ self.view_stack.append(self.forward_stack.pop())
672
+ self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
703
673
 
704
- # Fix: Removed Korean mappings ('ㅏ', 'ㅓ') for stability
705
- if key == curses.KEY_UP or k_char in ['k']:
674
+ elif cmd == "MOVE_UP":
706
675
  if self.selection_idx > 0:
707
676
  self.selection_idx -= 1
708
677
  if self.selection_idx < self.scroll_offset: self.scroll_offset = self.selection_idx
709
678
  elif current_list:
710
- # v1.8.5 - Wrapping: Top to Bottom
711
679
  self.selection_idx = len(current_list) - 1
712
680
  h, _ = self.stdscr.getmaxyx()
713
- # Maintain scroll consistency (h - 10 matches draw() layout)
714
- list_area_height = h - 10
715
- self.scroll_offset = max(0, self.selection_idx - list_area_height + 1)
716
- elif key == curses.KEY_DOWN or k_char in ['j']:
681
+ self.scroll_offset = max(0, self.selection_idx - (h - 11))
682
+
683
+ elif cmd == "MOVE_DOWN":
717
684
  if self.selection_idx < len(current_list) - 1:
718
685
  self.selection_idx += 1
719
686
  h, _ = self.stdscr.getmaxyx()
720
- list_area_height = h - 10
721
- if self.selection_idx >= self.scroll_offset + list_area_height:
722
- self.scroll_offset = self.selection_idx - list_area_height + 1
687
+ if self.selection_idx >= self.scroll_offset + (h - 10):
688
+ self.scroll_offset = self.selection_idx - (h - 10) + 1
723
689
  elif current_list:
724
- # v1.8.5 - Wrapping: Bottom to Top
725
- self.selection_idx = 0
726
- self.scroll_offset = 0
727
-
728
- # Enter / Select Logic
729
- elif key in ['\n', '\r', 10, 13, curses.KEY_ENTER]:
730
- # v2.0.3 Stability: Debounce Enter to prevent double-firing
731
- if time.time() - getattr(self, 'last_enter_time', 0) > 0.3:
732
- self.last_enter_time = time.time()
733
- self.activate_selection(current_list)
734
-
735
- # Shortcuts: Number keys & English letters (Strict Mode)
736
- # Search: S, 1, /
737
- elif k_char in ['s', 'S', '1', '/'] and (not isinstance(key, str) or key.isprintable()):
738
- self.forward_stack = [] # Clear forward history on new navigation
739
- self.prompt_search()
740
-
741
- # Favorites: F, 2
742
- elif k_char in ['f', 'F', '2']:
690
+ self.selection_idx = 0; self.scroll_offset = 0
691
+
692
+ elif cmd == "ACTIVATE":
693
+ if time.time() - getattr(self, 'last_enter_time', 0) > 0.3:
694
+ self.last_enter_time = time.time()
695
+ self.activate_selection(current_list)
696
+
697
+ elif cmd == "SEARCH":
698
+ self.forward_stack = []; self.prompt_search()
699
+
700
+ elif cmd == "FAVORITES":
743
701
  if self.view_stack[-1] != "favorites":
744
- self.forward_stack = []
745
- self.view_stack.append("favorites")
746
- self.selection_idx = 0
702
+ self.forward_stack = []; self.view_stack.append("favorites"); self.selection_idx = 0
747
703
  self.status_msg = self.t("favorites_info", DATA_FILE)
748
-
749
- # History: R, 3 (Changed from H to avoid Back conflict)
750
- elif k_char in ['r', 'R', '3']:
704
+
705
+ elif cmd == "HISTORY":
751
706
  if self.view_stack[-1] != "history":
752
- self.forward_stack = []
753
- self.cached_history = list(self.dm.data['history']) # Snapshot
754
- self.view_stack.append("history")
755
- self.selection_idx = 0
707
+ self.forward_stack = []; self.cached_history = list(self.dm.data['history'])
708
+ self.view_stack.append("history"); self.selection_idx = 0
756
709
  self.status_msg = self.t("hist_info")
757
-
758
- # Main Menu: M, 4
759
- elif k_char in ['m', 'M', '4']:
760
- self.forward_stack = [] # Clear forward history
761
- self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
762
-
763
- # Play/Pause: Space
764
- elif k_char == ' ':
765
- self.player.toggle_pause()
766
-
767
- # Volume: 9/0 or [/] or -/+
768
- elif k_char in ['-','_']:
769
- self.player.change_volume(-5)
770
- self.status_msg = "Volume -5"
771
- elif k_char in ['+','=']:
772
- self.player.change_volume(5)
773
- self.status_msg = "Volume +5"
774
-
775
- # Seek: ,/. (10s), </> (30s)
776
- elif k_char == ',':
777
- self.player.seek(-10)
778
- elif k_char == '.':
779
- self.player.seek(10)
780
- elif k_char == '<':
781
- self.player.seek(-30)
782
- self.status_msg = "Rewind 30s"
783
- elif k_char == '>':
784
- self.player.seek(30)
785
- self.status_msg = "Forward 30s"
786
-
787
- elif key == 27:
788
- self.stop_on_exit = False
789
- self.running = False
790
-
791
- # Share Track (F9): Real-time Publish
792
- elif key == curses.KEY_F9:
793
- if current_list and 0 <= self.selection_idx < len(current_list):
794
- target_item = current_list[self.selection_idx]
795
- url = target_item.get('url')
796
- title = target_item.get('title', 'Unknown Title')
797
-
798
- if url:
799
- # If it's US, try to re-fetch country info one more time (maybe misdetected)
800
- if self.dm.get_country() == 'US':
801
- threading.Thread(target=self.dm.fetch_country, daemon=True).start()
802
-
803
- # Dedup Check: Using a time-based cooldown (e.g. 5 seconds) for same URL
804
- last_sent_time = self.sent_history.get(url, 0)
805
- if time.time() - last_sent_time < 5:
806
- self.status_msg = "⚠️ Already Shared Recently!"
807
- else:
808
- try:
809
- # Send to Serverless Proxy (Secure)
810
- payload = {
811
- "title": title,
812
- "url": url,
813
- "duration": target_item.get('duration', '--:--'),
814
- "country": self.dm.get_country(),
815
- "timestamp": time.time()
816
- }
817
-
818
- # v1.9.9 Security Update: Use centralized API with Auth Header
819
- # v2.0.0 Threading for Smoothness
820
- def send_share_async(payload, headers, url_to_share, title_to_share):
821
- try:
822
- resp = requests.post(
823
- self.share_api_url,
824
- json=payload,
825
- headers=headers,
826
- timeout=3
827
- )
828
- if resp.status_code == 200:
829
- self.sent_history[url_to_share] = time.time()
830
- safe_t = self.truncate(title_to_share, 50)
831
- self.status_msg = f"🚀 Shared: {safe_t}..."
832
- else:
833
- self.status_msg = f"❌ Share Error: {resp.status_code}"
834
- except:
835
- self.status_msg = "❌ Network Error (API)"
836
-
837
- headers = {
838
- "Content-Type": "application/json",
839
- "x-mytunes-secret": "mytunes-v1-secret-8822"
840
- }
841
- threading.Thread(target=send_share_async, args=(payload, headers, url, title), daemon=True).start()
842
-
843
- except Exception as e:
844
- self.status_msg = f"❌ Share Failed: {str(e)}"
845
710
 
846
-
847
- # Add to Favorites: A, 5
848
- elif k_char in ['a', 'A', '5']:
711
+ elif cmd == "MAIN_MENU":
712
+ self.forward_stack = []; self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
713
+
714
+ elif cmd == "TOGGLE_PAUSE": self.player.toggle_pause()
715
+ elif cmd == "VOL_DOWN": self.player.change_volume(-5); self.status_msg = "Volume -5"
716
+ elif cmd == "VOL_UP": self.player.change_volume(5); self.status_msg = "Volume +5"
717
+ elif cmd == "SEEK_BACK_10": self.player.seek(-10)
718
+ elif cmd == "SEEK_FWD_10": self.player.seek(10)
719
+ elif cmd == "SEEK_BACK_30": self.player.seek(-30); self.status_msg = "Rewind 30s"
720
+ elif cmd == "SEEK_FWD_30": self.player.seek(30); self.status_msg = "Forward 30s"
721
+
722
+ elif cmd == "TOGGLE_FAV":
849
723
  if current_list and 0 <= self.selection_idx < len(current_list):
850
- target_item = current_list[self.selection_idx]
851
- # Ensure it's a valid track item (has url)
852
- if "url" in target_item:
853
- is_added = self.dm.toggle_favorite(target_item)
724
+ target = current_list[self.selection_idx]
725
+ if "url" in target:
726
+ is_added = self.dm.toggle_favorite(target)
854
727
  self.status_msg = self.t("fav_added") if is_added else self.t("fav_removed")
855
728
 
856
- # Open in Browser (YouTube): F7
857
- elif key == curses.KEY_F7:
729
+ elif cmd == "DELETE":
730
+ self.handle_deletion(current_list)
731
+
732
+ elif cmd == "OPEN_BROWSER":
858
733
  if current_list and 0 <= self.selection_idx < len(current_list):
859
- target_item = current_list[self.selection_idx]
860
- url = target_item.get('url')
861
- if url:
862
- if self.is_remote():
863
- self.show_copy_dialog("YouTube", url)
864
- else:
865
- self.open_browser(url)
734
+ url = current_list[self.selection_idx].get('url')
735
+ if url: (self.show_copy_dialog("YouTube", url) if self.is_remote() else self.open_browser(url))
866
736
 
867
- # Open Live Station: F8
868
- elif key == curses.KEY_F8:
869
- homepage_url = "https://mytunes-pro.com"
870
- if self.is_remote():
871
- self.show_copy_dialog("MyTunes Home", homepage_url)
872
- return
737
+ elif cmd in ["OPEN_HOME_APP", "OPEN_HOME"]:
738
+ url = "https://mytunes-pro.com"
739
+ if self.is_remote(): self.show_copy_dialog("MyTunes Home", url)
740
+ else: self.open_browser(url, app_mode=False)
873
741
 
874
- self.open_browser(homepage_url, app_mode=True)
742
+ elif cmd == "OPEN_PARTNER":
743
+ self.open_browser("https://postgresql.co.kr")
875
744
 
876
- # Delete Item: DEL, d
877
- elif key == curses.KEY_DC or k_char in ['d']:
878
- if current_list and 0 <= self.selection_idx < len(current_list):
879
- view = self.view_stack[-1]
880
- success = False
881
-
882
- if view == "favorites":
883
- success = self.dm.remove_favorite_by_index(self.selection_idx)
884
- if success: self.status_msg = "🗑️ Deleted from Favorites"
885
-
886
- elif view == "history":
887
- success = self.dm.remove_history_by_index(self.selection_idx)
888
- if success:
889
- self.cached_history = list(self.dm.data['history']) # Refresh view
890
- self.status_msg = "🗑️ Deleted from History"
891
-
892
- elif view == "search":
893
- # If current_search_query is None, we are viewing Search History
894
- if self.current_search_query is None:
895
- success = self.dm.remove_search_history_by_index(self.selection_idx)
896
- if success:
897
- self.search_results = self.dm.get_search_history() # Refresh
898
- self.status_msg = "🗑️ Deleted from Search History"
899
- else:
900
- # Ephemeral removal from result list
901
- try:
902
- self.search_results.pop(self.selection_idx)
903
- self.status_msg = "Start new search"
904
- success = True
905
- except: pass
906
-
907
- if success:
908
- # Adjust selection index if out of bounds
909
- # If list became empty, idx will be 0 but len is 0.
910
- # We just need to ensure we don't crash next draw.
911
- # The draw logic (get_current_list) handles empty lists safely.
912
- if self.selection_idx >= len(self.get_current_list()):
913
- self.selection_idx = max(0, len(self.get_current_list()) - 1)
745
+ elif cmd == "SHARE":
746
+ self.handle_share(current_list)
747
+
748
+ elif cmd == "RESIZE":
749
+ self.stdscr.clear()
750
+ self.stdscr.refresh()
751
+
752
+ elif cmd == "EXIT_BKG":
753
+ self.stop_on_exit = False; self.running = False
754
+
755
+ def handle_deletion(self, current_list):
756
+ """Sub-logic for DELETE command to keep dispatcher clean."""
757
+ if not current_list or not (0 <= self.selection_idx < len(current_list)): return
758
+
759
+ view = self.view_stack[-1]
760
+ success = False
761
+ if view == "favorites":
762
+ success = self.dm.remove_favorite_by_index(self.selection_idx)
763
+ if success: self.status_msg = "🗑️ Deleted from Favorites"
764
+ elif view == "history":
765
+ success = self.dm.remove_history_by_index(self.selection_idx)
766
+ if success: self.cached_history = list(self.dm.data['history']); self.status_msg = "🗑️ Deleted from History"
767
+ elif view == "search":
768
+ if self.current_search_query is None:
769
+ success = self.dm.remove_search_history_by_index(self.selection_idx)
770
+ if success: self.search_results = self.dm.get_search_history(); self.status_msg = "🗑️ Deleted from Search History"
771
+ else:
772
+ try: self.search_results.pop(self.selection_idx); success = True; self.status_msg = "Removed from list"
773
+ except: pass
774
+ if success:
775
+ self.selection_idx = max(0, min(self.selection_idx, len(self.get_current_list()) - 1))
776
+
777
+ def handle_share(self, current_list):
778
+ """Sub-logic for SHARE command."""
779
+ if not current_list or not (0 <= self.selection_idx < len(current_list)): return
780
+ target_item = current_list[self.selection_idx]
781
+ url = target_item.get('url')
782
+ title = target_item.get('title', 'Unknown Title')
783
+
784
+ if not url: return
785
+ if time.time() - self.sent_history.get(url, 0) < 5:
786
+ self.status_msg = "⚠️ Already Shared Recently!"; return
787
+
788
+ def send_share_async(payload, headers, url_to_share, title_to_share):
789
+ try:
790
+ resp = requests.post(self.share_api_url, json=payload, headers=headers, timeout=3)
791
+ if resp.status_code == 200:
792
+ self.sent_history[url_to_share] = time.time()
793
+ self.status_msg = f"🚀 Shared: {self.truncate(title_to_share, 40)}..."
794
+ else: self.status_msg = f"❌ Share Error: {resp.status_code}"
795
+ except: self.status_msg = "❌ Network Error (API)"
796
+
797
+ payload = {"title": title, "url": url, "duration": target_item.get('duration', '--:--'),
798
+ "country": self.dm.get_country(), "timestamp": time.time()}
799
+ headers = {"Content-Type": "application/json", "x-mytunes-secret": "mytunes-v1-secret-8822"}
800
+ threading.Thread(target=send_share_async, args=(payload, headers, url, title), daemon=True).start()
914
801
 
915
802
 
916
803
 
@@ -947,20 +834,26 @@ class MyTunesApp:
947
834
  curses.flushinp()
948
835
 
949
836
  while True:
950
- k = win.getch()
951
- if k == -1: continue
837
+ try:
838
+ k = win.get_wch()
839
+ except curses.error:
840
+ continue
952
841
 
953
842
  # ESC -> Background Play (Exit app)
954
- if k == 27:
843
+ if k == 27 or k == '\x1b':
955
844
  self.stop_on_exit = False
956
845
  self.running = False
957
- res = False # Or irrelevant since we quit
846
+ res = False
958
847
  break
959
848
 
960
- if k in [10, 13, curses.KEY_ENTER, ord(' ')]:
849
+ # Enter / Space -> Resume
850
+ if k in [10, 13, curses.KEY_ENTER, '\n', '\r', ' ']:
961
851
  res = True
962
852
  break
963
- if k in [ord('0'), ord('r'), ord('R')]:
853
+
854
+ # 0 / R -> Restart
855
+ k_char = str(k).lower() if isinstance(k, str) else ""
856
+ if k_char in ['0', 'r']:
964
857
  res = False
965
858
  break
966
859
 
@@ -1022,6 +915,12 @@ class MyTunesApp:
1022
915
  else:
1023
916
  # Linux or others
1024
917
  subprocess.Popen(['xdg-open', url], **popen_kwargs)
918
+
919
+ # Feedback logic: Success message then auto-clear
920
+ self.status_msg = "✅ Browser Launched! (Check Browser)"
921
+ time.sleep(2.5)
922
+ if "Launched!" in self.status_msg:
923
+ self.status_msg = ""
1025
924
  except Exception as e:
1026
925
  # Log error silently to TUI status
1027
926
  self.status_msg = f"❌ Browser Error: {str(e)[:20]}"
@@ -1075,8 +974,12 @@ class MyTunesApp:
1075
974
 
1076
975
  # Wait for key
1077
976
  while True:
1078
- k = win.getch()
1079
- if k in [10, 13, curses.KEY_ENTER, 27, ord(' ')]:
977
+ try:
978
+ k = win.get_wch()
979
+ except curses.error:
980
+ continue
981
+
982
+ if k in [10, 13, curses.KEY_ENTER, 27, '\n', '\r', '\x1b', ' ']:
1080
983
  break
1081
984
  except: pass
1082
985
  finally:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mytunes-pro
3
- Version: 2.0.2
3
+ Version: 2.0.3
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
@@ -19,7 +19,7 @@ Dynamic: license-file
19
19
 
20
20
  # 🎵 MyTunes Pro (Korean)
21
21
 
22
- ## 🚀 Professional TUI Music Player v2.0.2
22
+ ## 🚀 Professional TUI Music Player v2.0.3
23
23
 
24
24
  MyTunes Pro는 **Context7**의 심층 리서치를 기반으로 제작된 **Premium CLI Music Player**입니다.
25
25
  Python `curses` 라이브러리를 사용하여 터미널 환경에서도 **GUI급의 유려한 UX**를 제공하며,
@@ -212,7 +212,7 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
212
212
 
213
213
  # 🎵 MyTunes Pro (English)
214
214
 
215
- **Modern CLI YouTube Music Player (v2.0.2)**
215
+ **Modern CLI YouTube Music Player (v2.0.3)**
216
216
  A lightweight, keyboard-centric terminal player for streaming YouTube music.
217
217
 
218
218
  ---
@@ -301,6 +301,13 @@ sudo apt install mpv python3 python3-pip pipx python3-venv -y
301
301
 
302
302
  ## 🔄 Changelog
303
303
 
304
+ ### v2.0.3 (Input Handling & Unicode Stability)
305
+
306
+ - **Input Logic**: Replaced legacy `getch()` with `get_wch()` in all UI dialogs (`ask_resume`, `show_copy_dialog`) for robust wide-character and Unicode support.
307
+ - **Architecture**: Refactored the input handling system into a modular, command-based architecture (v2.0.3).
308
+ - **Decoupling**: Separated input collection (`get_next_event`), event normalization, and command execution.
309
+ - **Improved ESC Handling**: Enhanced detection of ESC and multi-byte sequences (including Option+Backspace) for smoother navigation.
310
+
304
311
  ### v2.0.2 (Stability & Browser Optimization)
305
312
 
306
313
  - **Browser Launch**: Switched to fully decoupled `subprocess.Popen` logic for browser opening. This eliminates occasional TUI freezes when launching YouTube (F7) or Live Station (F8) by bypassing `webbrowser` library limitations.
File without changes
File without changes