mytunes-pro 2.0.1__py3-none-any.whl → 2.0.3__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 +248 -294
- {mytunes_pro-2.0.1.dist-info → mytunes_pro-2.0.3.dist-info}/METADATA +16 -3
- mytunes_pro-2.0.3.dist-info/RECORD +8 -0
- mytunes_pro-2.0.1.dist-info/RECORD +0 -8
- {mytunes_pro-2.0.1.dist-info → mytunes_pro-2.0.3.dist-info}/WHEEL +0 -0
- {mytunes_pro-2.0.1.dist-info → mytunes_pro-2.0.3.dist-info}/entry_points.txt +0 -0
- {mytunes_pro-2.0.1.dist-info → mytunes_pro-2.0.3.dist-info}/licenses/LICENSE +0 -0
- {mytunes_pro-2.0.1.dist-info → mytunes_pro-2.0.3.dist-info}/top_level.txt +0 -0
mytunes/app.py
CHANGED
|
@@ -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,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
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
#
|
|
679
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
699
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
723
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
850
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
859
|
-
|
|
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
|
-
|
|
862
|
-
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
|
-
|
|
878
|
-
|
|
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
|
-
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
955
|
-
|
|
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
|
|
846
|
+
res = False
|
|
962
847
|
break
|
|
963
848
|
|
|
964
|
-
|
|
849
|
+
# Enter / Space -> Resume
|
|
850
|
+
if k in [10, 13, curses.KEY_ENTER, '\n', '\r', ' ']:
|
|
965
851
|
res = True
|
|
966
852
|
break
|
|
967
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
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.
|
|
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,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`).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
mytunes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
mytunes/app.py,sha256=LQY9p6w25cIz1WBJ4LIWmX4BsvfHotZ_FVZffqjGy7A,59351
|
|
3
|
+
mytunes_pro-2.0.3.dist-info/licenses/LICENSE,sha256=lOrP0EIjxcgJia__W3f3PVDZkRd2oRzFkyH2g3LRRCg,1063
|
|
4
|
+
mytunes_pro-2.0.3.dist-info/METADATA,sha256=xQW9fpXYf9jho1a3RBirqRHy6xYoeiUCWeHtzXeoL-g,23396
|
|
5
|
+
mytunes_pro-2.0.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
mytunes_pro-2.0.3.dist-info/entry_points.txt,sha256=6-MsC13nIgzLvrREaGotc32FgxHx_Iuu1z2qCzJs1_4,65
|
|
7
|
+
mytunes_pro-2.0.3.dist-info/top_level.txt,sha256=KWzdFyNNG_sO7GT83-sN5fYArP4_DL5I8HYIwgazXyY,8
|
|
8
|
+
mytunes_pro-2.0.3.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
mytunes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
mytunes/app.py,sha256=i6G_1_8guGJqc_EvzlVzMpYfPSSZL1jB7_Nb30qX1T4,61619
|
|
3
|
-
mytunes_pro-2.0.1.dist-info/licenses/LICENSE,sha256=lOrP0EIjxcgJia__W3f3PVDZkRd2oRzFkyH2g3LRRCg,1063
|
|
4
|
-
mytunes_pro-2.0.1.dist-info/METADATA,sha256=fqpGMPvQnZxzvgaViPMVrvNPEy29yHV0mH3zT59hyKY,22317
|
|
5
|
-
mytunes_pro-2.0.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
-
mytunes_pro-2.0.1.dist-info/entry_points.txt,sha256=6-MsC13nIgzLvrREaGotc32FgxHx_Iuu1z2qCzJs1_4,65
|
|
7
|
-
mytunes_pro-2.0.1.dist-info/top_level.txt,sha256=KWzdFyNNG_sO7GT83-sN5fYArP4_DL5I8HYIwgazXyY,8
|
|
8
|
-
mytunes_pro-2.0.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|