mytunes-pro 2.0.1__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.1
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.1
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.1)**
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,19 @@ 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
+
311
+ ### v2.0.2 (Stability & Browser Optimization)
312
+
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.
314
+ - **App Mode Restore**: Fixed and improved Chrome/Brave App Mode (Popup) for the Live Station on macOS.
315
+ - **Improved Remote Detection**: Refined SSH/WSL detection to ensure local browser features are correctly enabled where possible.
316
+
304
317
  ### v2.0.1 (Keymap Refinement & Version Sync)
305
318
 
306
319
  - **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
@@ -1,6 +1,6 @@
1
1
  # 🎵 MyTunes Pro (Korean)
2
2
 
3
- ## 🚀 Professional TUI Music Player v2.0.1
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.1)**
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,19 @@ 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
+
292
+ ### v2.0.2 (Stability & Browser Optimization)
293
+
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.
295
+ - **App Mode Restore**: Fixed and improved Chrome/Brave App Mode (Popup) for the Live Station on macOS.
296
+ - **Improved Remote Detection**: Refined SSH/WSL detection to ensure local browser features are correctly enabled where possible.
297
+
285
298
  ### v2.0.1 (Keymap Refinement & Version Sync)
286
299
 
287
300
  - **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mytunes-pro"
7
- version = "2.0.1"
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.1"
35
+ APP_VERSION = "2.0.3"
36
36
 
37
37
  # === [Strings & Localization] ===
38
38
  STRINGS = {
@@ -585,336 +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.status_msg = f"🌐 Opening {url}..."
664
- threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
665
- elif rel_x > 15:
666
- url = "https://postgresql.co.kr"
667
- self.status_msg = f"🌐 Opening {url}..."
668
- threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
669
- except:
670
- pass
671
- 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
672
660
 
673
- # Helper to normalize input for checking
674
- k_char = str(key).lower() if isinstance(key, str) else ""
675
-
676
661
  current_list = self.get_current_list()
677
662
 
678
- # Navigation logic
679
- # Back: Q, Left Arrow, Backspace, h, 6
680
- # Fix: Removed Korean mappings ('ㅂ', 'ㅗ') to prevent IME ghost keys per user request
681
- if key == curses.KEY_LEFT or key == curses.KEY_BACKSPACE or key == 127 or \
682
- k_char in ['q', '6', 'h']:
663
+ # 1. Functional Commands (Require Logic)
664
+ if cmd == "NAV_BACK":
683
665
  if len(self.view_stack) > 1:
684
- # Pop current view and push to forward stack
685
- current_view = self.view_stack.pop()
686
- self.forward_stack.append(current_view)
687
-
688
- self.selection_idx = 0; self.scroll_offset = 0
689
- self.status_msg = ""
690
- # Else: Do nothing (Prevent Quit on Q)
691
- return
692
-
693
- # Forward: L, Right Arrow (Browser Style)
694
- # Re-visit the view we just popped from
695
- # Fix: Removed Korean mappings ('ㅣ') to prevent IME ghost keys
696
- 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":
697
670
  if self.forward_stack:
698
- next_view = self.forward_stack.pop()
699
- self.view_stack.append(next_view)
700
- self.selection_idx = 0; self.scroll_offset = 0
701
- self.status_msg = ""
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
- return
705
-
706
- # Fix: Removed Korean mappings ('ㅏ', 'ㅓ') for stability
707
- if key == curses.KEY_UP or k_char in ['k']:
674
+ elif cmd == "MOVE_UP":
708
675
  if self.selection_idx > 0:
709
676
  self.selection_idx -= 1
710
677
  if self.selection_idx < self.scroll_offset: self.scroll_offset = self.selection_idx
711
678
  elif current_list:
712
- # v1.8.5 - Wrapping: Top to Bottom
713
679
  self.selection_idx = len(current_list) - 1
714
680
  h, _ = self.stdscr.getmaxyx()
715
- # Maintain scroll consistency (h - 10 matches draw() layout)
716
- list_area_height = h - 10
717
- self.scroll_offset = max(0, self.selection_idx - list_area_height + 1)
718
- 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":
719
684
  if self.selection_idx < len(current_list) - 1:
720
685
  self.selection_idx += 1
721
686
  h, _ = self.stdscr.getmaxyx()
722
- list_area_height = h - 10
723
- if self.selection_idx >= self.scroll_offset + list_area_height:
724
- 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
725
689
  elif current_list:
726
- # v1.8.5 - Wrapping: Bottom to Top
727
- self.selection_idx = 0
728
- self.scroll_offset = 0
729
-
730
- # Enter / Select Logic
731
- elif key in ['\n', '\r', 10, 13, curses.KEY_ENTER]:
732
- # v2.0.3 Stability: Debounce Enter to prevent double-firing
733
- if time.time() - getattr(self, 'last_enter_time', 0) > 0.3:
734
- self.last_enter_time = time.time()
735
- self.activate_selection(current_list)
736
-
737
- # Shortcuts: Number keys & English letters (Strict Mode)
738
- # Search: S, 1, /
739
- elif k_char in ['s', 'S', '1', '/'] and (not isinstance(key, str) or key.isprintable()):
740
- self.forward_stack = [] # Clear forward history on new navigation
741
- self.prompt_search()
742
-
743
- # Favorites: F, 2
744
- 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":
745
701
  if self.view_stack[-1] != "favorites":
746
- self.forward_stack = []
747
- self.view_stack.append("favorites")
748
- self.selection_idx = 0
702
+ self.forward_stack = []; self.view_stack.append("favorites"); self.selection_idx = 0
749
703
  self.status_msg = self.t("favorites_info", DATA_FILE)
750
-
751
- # History: R, 3 (Changed from H to avoid Back conflict)
752
- elif k_char in ['r', 'R', '3']:
704
+
705
+ elif cmd == "HISTORY":
753
706
  if self.view_stack[-1] != "history":
754
- self.forward_stack = []
755
- self.cached_history = list(self.dm.data['history']) # Snapshot
756
- self.view_stack.append("history")
757
- 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
758
709
  self.status_msg = self.t("hist_info")
759
-
760
- # Main Menu: M, 4
761
- elif k_char in ['m', 'M', '4']:
762
- self.forward_stack = [] # Clear forward history
763
- self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
764
-
765
- # Play/Pause: Space
766
- elif k_char == ' ':
767
- self.player.toggle_pause()
768
-
769
- # Volume: 9/0 or [/] or -/+
770
- elif k_char in ['-','_']:
771
- self.player.change_volume(-5)
772
- self.status_msg = "Volume -5"
773
- elif k_char in ['+','=']:
774
- self.player.change_volume(5)
775
- self.status_msg = "Volume +5"
776
-
777
- # Seek: ,/. (10s), </> (30s)
778
- elif k_char == ',':
779
- self.player.seek(-10)
780
- elif k_char == '.':
781
- self.player.seek(10)
782
- elif k_char == '<':
783
- self.player.seek(-30)
784
- self.status_msg = "Rewind 30s"
785
- elif k_char == '>':
786
- self.player.seek(30)
787
- self.status_msg = "Forward 30s"
788
-
789
- elif key == 27:
790
- self.stop_on_exit = False
791
- self.running = False
792
-
793
- # Share Track (F9): Real-time Publish
794
- elif key == curses.KEY_F9:
795
- if current_list and 0 <= self.selection_idx < len(current_list):
796
- target_item = current_list[self.selection_idx]
797
- url = target_item.get('url')
798
- title = target_item.get('title', 'Unknown Title')
799
-
800
- if url:
801
- # If it's US, try to re-fetch country info one more time (maybe misdetected)
802
- if self.dm.get_country() == 'US':
803
- threading.Thread(target=self.dm.fetch_country, daemon=True).start()
804
-
805
- # Dedup Check: Using a time-based cooldown (e.g. 5 seconds) for same URL
806
- last_sent_time = self.sent_history.get(url, 0)
807
- if time.time() - last_sent_time < 5:
808
- self.status_msg = "⚠️ Already Shared Recently!"
809
- else:
810
- try:
811
- # Send to Serverless Proxy (Secure)
812
- payload = {
813
- "title": title,
814
- "url": url,
815
- "duration": target_item.get('duration', '--:--'),
816
- "country": self.dm.get_country(),
817
- "timestamp": time.time()
818
- }
819
-
820
- # v1.9.9 Security Update: Use centralized API with Auth Header
821
- # v2.0.0 Threading for Smoothness
822
- def send_share_async(payload, headers, url_to_share, title_to_share):
823
- try:
824
- resp = requests.post(
825
- self.share_api_url,
826
- json=payload,
827
- headers=headers,
828
- timeout=3
829
- )
830
- if resp.status_code == 200:
831
- self.sent_history[url_to_share] = time.time()
832
- safe_t = self.truncate(title_to_share, 50)
833
- self.status_msg = f"🚀 Shared: {safe_t}..."
834
- else:
835
- self.status_msg = f"❌ Share Error: {resp.status_code}"
836
- except:
837
- self.status_msg = "❌ Network Error (API)"
838
-
839
- headers = {
840
- "Content-Type": "application/json",
841
- "x-mytunes-secret": "mytunes-v1-secret-8822"
842
- }
843
- threading.Thread(target=send_share_async, args=(payload, headers, url, title), daemon=True).start()
844
-
845
- except Exception as e:
846
- self.status_msg = f"❌ Share Failed: {str(e)}"
847
710
 
848
-
849
- # Add to Favorites: A, 5
850
- 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":
851
723
  if current_list and 0 <= self.selection_idx < len(current_list):
852
- target_item = current_list[self.selection_idx]
853
- # Ensure it's a valid track item (has url)
854
- if "url" in target_item:
855
- 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)
856
727
  self.status_msg = self.t("fav_added") if is_added else self.t("fav_removed")
857
728
 
858
- # Open in Browser (YouTube): F7
859
- elif key == curses.KEY_F7:
729
+ elif cmd == "DELETE":
730
+ self.handle_deletion(current_list)
731
+
732
+ elif cmd == "OPEN_BROWSER":
860
733
  if current_list and 0 <= self.selection_idx < len(current_list):
861
- target_item = current_list[self.selection_idx]
862
- url = target_item.get('url')
863
- if url:
864
- if self.is_remote():
865
- self.show_copy_dialog("YouTube", url)
866
- else:
867
- # v1.8.4 - Use standard webbrowser library for maximum stability on F7
868
- self.status_msg = "🌐 Opening YouTube in Browser..."
869
- threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
870
-
871
- elif key == curses.KEY_F8:
872
- homepage_url = "https://mytunes-pro.com"
873
- if self.is_remote():
874
- self.show_copy_dialog("MyTunes Home", homepage_url)
875
- return
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))
876
736
 
877
- self.status_msg = "🌐 Opening MyTunes Home..."
878
- threading.Thread(target=webbrowser.open, args=(homepage_url,), daemon=True).start()
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)
879
741
 
880
- # Delete Item: DEL, d
881
- elif key == curses.KEY_DC or k_char in ['d']:
882
- if current_list and 0 <= self.selection_idx < len(current_list):
883
- view = self.view_stack[-1]
884
- success = False
885
-
886
- if view == "favorites":
887
- success = self.dm.remove_favorite_by_index(self.selection_idx)
888
- if success: self.status_msg = "🗑️ Deleted from Favorites"
889
-
890
- elif view == "history":
891
- success = self.dm.remove_history_by_index(self.selection_idx)
892
- if success:
893
- self.cached_history = list(self.dm.data['history']) # Refresh view
894
- self.status_msg = "🗑️ Deleted from History"
895
-
896
- elif view == "search":
897
- # If current_search_query is None, we are viewing Search History
898
- if self.current_search_query is None:
899
- success = self.dm.remove_search_history_by_index(self.selection_idx)
900
- if success:
901
- self.search_results = self.dm.get_search_history() # Refresh
902
- self.status_msg = "🗑️ Deleted from Search History"
903
- else:
904
- # Ephemeral removal from result list
905
- try:
906
- self.search_results.pop(self.selection_idx)
907
- self.status_msg = "Start new search"
908
- success = True
909
- except: pass
910
-
911
- if success:
912
- # Adjust selection index if out of bounds
913
- # If list became empty, idx will be 0 but len is 0.
914
- # We just need to ensure we don't crash next draw.
915
- # The draw logic (get_current_list) handles empty lists safely.
916
- if self.selection_idx >= len(self.get_current_list()):
917
- self.selection_idx = max(0, len(self.get_current_list()) - 1)
742
+ elif cmd == "OPEN_PARTNER":
743
+ self.open_browser("https://postgresql.co.kr")
744
+
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()
918
801
 
919
802
 
920
803
 
@@ -951,20 +834,26 @@ class MyTunesApp:
951
834
  curses.flushinp()
952
835
 
953
836
  while True:
954
- k = win.getch()
955
- if k == -1: continue
837
+ try:
838
+ k = win.get_wch()
839
+ except curses.error:
840
+ continue
956
841
 
957
842
  # ESC -> Background Play (Exit app)
958
- if k == 27:
843
+ if k == 27 or k == '\x1b':
959
844
  self.stop_on_exit = False
960
845
  self.running = False
961
- res = False # Or irrelevant since we quit
846
+ res = False
962
847
  break
963
848
 
964
- if k in [10, 13, curses.KEY_ENTER, ord(' ')]:
849
+ # Enter / Space -> Resume
850
+ if k in [10, 13, curses.KEY_ENTER, '\n', '\r', ' ']:
965
851
  res = True
966
852
  break
967
- 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']:
968
857
  res = False
969
858
  break
970
859
 
@@ -977,7 +866,68 @@ class MyTunesApp:
977
866
  return res
978
867
 
979
868
  def is_remote(self):
980
- return 'SSH_CONNECTION' in os.environ or 'SSH_CLIENT' in os.environ
869
+ """Check if running in a remote SSH session (excluding local WSL)."""
870
+ if 'WSL_DISTRO_NAME' in os.environ or 'WSL_INTEROP' in os.environ:
871
+ return False
872
+ return 'SSH_CONNECTION' in os.environ or 'SSH_CLIENT' in os.environ or 'SSH_TTY' in os.environ
873
+
874
+ def open_browser(self, url, app_mode=False):
875
+ """Open browser using detached subprocess to prevent TUI freezing."""
876
+ self.status_msg = f"🌐 Opening Link: {url[:30]}..."
877
+
878
+ def run_open():
879
+ try:
880
+ # Prepare DEVNULL for fire-and-forget
881
+ devnull = os.open(os.devnull, os.O_RDWR)
882
+ popen_kwargs = {
883
+ 'stdin': devnull,
884
+ 'stdout': devnull,
885
+ 'stderr': devnull,
886
+ 'close_fds': True
887
+ }
888
+
889
+ # Use start_new_session for process group detachment (if possible)
890
+ if hasattr(os, 'setsid') or sys.platform != 'win32':
891
+ popen_kwargs['start_new_session'] = True
892
+
893
+ if sys.platform == 'darwin':
894
+ if app_mode:
895
+ # Attempt "App Mode" for Chrome/Brave on macOS
896
+ launched = False
897
+ browsers = [
898
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
899
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
900
+ ]
901
+ for b_path in browsers:
902
+ if os.path.exists(b_path):
903
+ try:
904
+ subprocess.Popen([b_path, f"--app={url}", "--window-size=600,800"], **popen_kwargs)
905
+ launched = True
906
+ break
907
+ except: pass
908
+ if not launched:
909
+ subprocess.Popen(['open', url], **popen_kwargs)
910
+ else:
911
+ subprocess.Popen(['open', url], **popen_kwargs)
912
+ elif self.is_wsl():
913
+ # For WSL, we usually use cmd.exe /c start
914
+ subprocess.Popen(['cmd.exe', '/c', 'start', url], **popen_kwargs)
915
+ else:
916
+ # Linux or others
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 = ""
924
+ except Exception as e:
925
+ # Log error silently to TUI status
926
+ self.status_msg = f"❌ Browser Error: {str(e)[:20]}"
927
+
928
+ # Still execute Popen in a thread to be extra safe,
929
+ # but Popen itself is now detached and redirected.
930
+ threading.Thread(target=run_open, daemon=True).start()
981
931
 
982
932
  def is_wsl(self):
983
933
  try:
@@ -1024,8 +974,12 @@ class MyTunesApp:
1024
974
 
1025
975
  # Wait for key
1026
976
  while True:
1027
- k = win.getch()
1028
- 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', ' ']:
1029
983
  break
1030
984
  except: pass
1031
985
  finally:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mytunes-pro
3
- Version: 2.0.1
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.1
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.1)**
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,19 @@ 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
+
311
+ ### v2.0.2 (Stability & Browser Optimization)
312
+
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.
314
+ - **App Mode Restore**: Fixed and improved Chrome/Brave App Mode (Popup) for the Live Station on macOS.
315
+ - **Improved Remote Detection**: Refined SSH/WSL detection to ensure local browser features are correctly enabled where possible.
316
+
304
317
  ### v2.0.1 (Keymap Refinement & Version Sync)
305
318
 
306
319
  - **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
File without changes
File without changes