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.
- {mytunes_pro-2.0.2/src/mytunes_pro.egg-info → mytunes_pro-2.0.3}/PKG-INFO +10 -3
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3}/README.md +9 -2
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3}/pyproject.toml +1 -1
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3}/src/mytunes/app.py +191 -288
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3/src/mytunes_pro.egg-info}/PKG-INFO +10 -3
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3}/LICENSE +0 -0
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3}/setup.cfg +0 -0
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3}/src/mytunes/__init__.py +0 -0
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3}/src/mytunes_pro.egg-info/SOURCES.txt +0 -0
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3}/src/mytunes_pro.egg-info/dependency_links.txt +0 -0
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3}/src/mytunes_pro.egg-info/entry_points.txt +0 -0
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3}/src/mytunes_pro.egg-info/requires.txt +0 -0
- {mytunes_pro-2.0.2 → mytunes_pro-2.0.3}/src/mytunes_pro.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mytunes-pro
|
|
3
|
-
Version: 2.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
594
|
-
except:
|
|
595
|
-
return
|
|
596
|
-
|
|
597
|
-
if key == -1: return
|
|
592
|
+
except curses.error: return None
|
|
593
|
+
except: return None
|
|
598
594
|
|
|
599
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
616
|
-
self.stdscr.timeout(50)
|
|
606
|
+
self.stdscr.timeout(50) # Tiny peek timeout
|
|
617
607
|
try:
|
|
618
|
-
|
|
619
|
-
if
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
#
|
|
677
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
697
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
721
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
848
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
857
|
-
|
|
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
|
-
|
|
860
|
-
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
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
742
|
+
elif cmd == "OPEN_PARTNER":
|
|
743
|
+
self.open_browser("https://postgresql.co.kr")
|
|
875
744
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
951
|
-
|
|
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
|
|
846
|
+
res = False
|
|
958
847
|
break
|
|
959
848
|
|
|
960
|
-
|
|
849
|
+
# Enter / Space -> Resume
|
|
850
|
+
if k in [10, 13, curses.KEY_ENTER, '\n', '\r', ' ']:
|
|
961
851
|
res = True
|
|
962
852
|
break
|
|
963
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|