mytunes-pro 2.0.0__tar.gz → 2.0.2__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.0
3
+ Version: 2.0.2
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.0
22
+ ## 🚀 Professional TUI Music Player v2.0.2
23
23
 
24
24
  MyTunes Pro는 **Context7**의 심층 리서치를 기반으로 제작된 **Premium CLI Music Player**입니다.
25
25
  Python `curses` 라이브러리를 사용하여 터미널 환경에서도 **GUI급의 유려한 UX**를 제공하며,
@@ -184,19 +184,21 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
184
184
  | **`F7`** | **유튜브 열기** | 현재 곡을 브라우저에서 보기 |
185
185
  | **`F8`** | **라이브 (Live)** | **실시간 음악 대시보드 열기** (전용 팝업창) |
186
186
  | **`F9`** | **공유 (Share)** | **현재 곡을 라이브 스테이션에 즉시 공유** |
187
- | **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `H`와 동일) |
187
+ | **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `h`와 동일) |
188
+ | **`L`** | **앞으로** | 이전 화면에서 앞화면으로 다시 이동 (`Right Arrow`) |
188
189
  | **`ESC`** | **배경재생** | **음악 끄지 않고 나가기** (백그라운드 재생) |
189
190
 
190
191
  ### 🧭 기본 탐색
191
192
  | 키 | 동작 |
192
193
  | :--- | :--- |
193
194
  | `↑` / `↓` / `k` / `j` | 리스트 위/아래 이동 (Vim 키 지원) |
194
- | `Enter` / `l` | **선택 / 재생** (한글 `ㅣ`도 지원) |
195
+ | `Enter` / `l` | **선택 / 재생** |
195
196
  | `Space` | 재생 / 일시정지 (Play/Pause) |
196
197
  | `-` / `+` | **볼륨 조절** (- / +) |
197
198
  | `,` / `.` | 10초 뒤로 / 앞으로 감기 |
198
199
  | `<` / `>` | **30초** 뒤로 / 앞으로 감기 (Shift) |
199
200
  | `Backspace` / `h` / `q` | 뒤로 가기 / 검색어 지우기 |
201
+ | `L` | **앞으로 가기** |
200
202
  | `/` | **검색** (Vim Style) |
201
203
 
202
204
  ---
@@ -210,7 +212,7 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
210
212
 
211
213
  # 🎵 MyTunes Pro (English)
212
214
 
213
- **Modern CLI YouTube Music Player (v1.9.9)**
215
+ **Modern CLI YouTube Music Player (v2.0.2)**
214
216
  A lightweight, keyboard-centric terminal player for streaming YouTube music.
215
217
 
216
218
  ---
@@ -291,13 +293,27 @@ sudo apt install mpv python3 python3-pip pipx python3-venv -y
291
293
  | **`5`** | **Add/Del** | Toggle Favorite (Same as `A`) |
292
294
  | **`+`** | **Vol Up** | Volume +5% (Same as `=`) |
293
295
  | **`-`** | **Vol Down** | Volume -5% (Same as `_`) |
294
- | **`6`** | **Back** | Go back (Same as `Q`, `H`) |
296
+ | **`6`** | **Back** | Go back (Same as `Q`, `h`) |
297
+ | **`L`** | **Forward** | Go forward (`Right Arrow`) |
295
298
  | **`ESC`** | **Bg Play** | **Exit app but keep music playing** |
296
299
 
297
300
  ---
298
301
 
299
302
  ## 🔄 Changelog
300
303
 
304
+ ### v2.0.2 (Stability & Browser Optimization)
305
+
306
+ - **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.
307
+ - **App Mode Restore**: Fixed and improved Chrome/Brave App Mode (Popup) for the Live Station on macOS.
308
+ - **Improved Remote Detection**: Refined SSH/WSL detection to ensure local browser features are correctly enabled where possible.
309
+
310
+ ### v2.0.1 (Keymap Refinement & Version Sync)
311
+
312
+ - **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
313
+ - **Keybinding Optimization**: Updated History mapping to `R` / `3` and refined Back/Forward logic.
314
+ - **IME Stability**: Removed unstable Korean character mappings (`ㄴ`, `ㄹ`, `ㄱ`, 등) to prevent ghost key issues in the TUI.
315
+ - **Global Synchronization**: Synchronized version v2.0.1 across CLI, TUI, and Web interfaces.
316
+
301
317
  ### v1.9.9 (Domain Migration & Realtime Sync)
302
318
 
303
319
  - **Domain Migration**: Updated all branding and internal links to support `mytunes-pro.com`.
@@ -1,6 +1,6 @@
1
1
  # 🎵 MyTunes Pro (Korean)
2
2
 
3
- ## 🚀 Professional TUI Music Player v2.0.0
3
+ ## 🚀 Professional TUI Music Player v2.0.2
4
4
 
5
5
  MyTunes Pro는 **Context7**의 심층 리서치를 기반으로 제작된 **Premium CLI Music Player**입니다.
6
6
  Python `curses` 라이브러리를 사용하여 터미널 환경에서도 **GUI급의 유려한 UX**를 제공하며,
@@ -165,19 +165,21 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
165
165
  | **`F7`** | **유튜브 열기** | 현재 곡을 브라우저에서 보기 |
166
166
  | **`F8`** | **라이브 (Live)** | **실시간 음악 대시보드 열기** (전용 팝업창) |
167
167
  | **`F9`** | **공유 (Share)** | **현재 곡을 라이브 스테이션에 즉시 공유** |
168
- | **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `H`와 동일) |
168
+ | **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `h`와 동일) |
169
+ | **`L`** | **앞으로** | 이전 화면에서 앞화면으로 다시 이동 (`Right Arrow`) |
169
170
  | **`ESC`** | **배경재생** | **음악 끄지 않고 나가기** (백그라운드 재생) |
170
171
 
171
172
  ### 🧭 기본 탐색
172
173
  | 키 | 동작 |
173
174
  | :--- | :--- |
174
175
  | `↑` / `↓` / `k` / `j` | 리스트 위/아래 이동 (Vim 키 지원) |
175
- | `Enter` / `l` | **선택 / 재생** (한글 `ㅣ`도 지원) |
176
+ | `Enter` / `l` | **선택 / 재생** |
176
177
  | `Space` | 재생 / 일시정지 (Play/Pause) |
177
178
  | `-` / `+` | **볼륨 조절** (- / +) |
178
179
  | `,` / `.` | 10초 뒤로 / 앞으로 감기 |
179
180
  | `<` / `>` | **30초** 뒤로 / 앞으로 감기 (Shift) |
180
181
  | `Backspace` / `h` / `q` | 뒤로 가기 / 검색어 지우기 |
182
+ | `L` | **앞으로 가기** |
181
183
  | `/` | **검색** (Vim Style) |
182
184
 
183
185
  ---
@@ -191,7 +193,7 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
191
193
 
192
194
  # 🎵 MyTunes Pro (English)
193
195
 
194
- **Modern CLI YouTube Music Player (v1.9.9)**
196
+ **Modern CLI YouTube Music Player (v2.0.2)**
195
197
  A lightweight, keyboard-centric terminal player for streaming YouTube music.
196
198
 
197
199
  ---
@@ -272,13 +274,27 @@ sudo apt install mpv python3 python3-pip pipx python3-venv -y
272
274
  | **`5`** | **Add/Del** | Toggle Favorite (Same as `A`) |
273
275
  | **`+`** | **Vol Up** | Volume +5% (Same as `=`) |
274
276
  | **`-`** | **Vol Down** | Volume -5% (Same as `_`) |
275
- | **`6`** | **Back** | Go back (Same as `Q`, `H`) |
277
+ | **`6`** | **Back** | Go back (Same as `Q`, `h`) |
278
+ | **`L`** | **Forward** | Go forward (`Right Arrow`) |
276
279
  | **`ESC`** | **Bg Play** | **Exit app but keep music playing** |
277
280
 
278
281
  ---
279
282
 
280
283
  ## 🔄 Changelog
281
284
 
285
+ ### v2.0.2 (Stability & Browser Optimization)
286
+
287
+ - **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.
288
+ - **App Mode Restore**: Fixed and improved Chrome/Brave App Mode (Popup) for the Live Station on macOS.
289
+ - **Improved Remote Detection**: Refined SSH/WSL detection to ensure local browser features are correctly enabled where possible.
290
+
291
+ ### v2.0.1 (Keymap Refinement & Version Sync)
292
+
293
+ - **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
294
+ - **Keybinding Optimization**: Updated History mapping to `R` / `3` and refined Back/Forward logic.
295
+ - **IME Stability**: Removed unstable Korean character mappings (`ㄴ`, `ㄹ`, `ㄱ`, 등) to prevent ghost key issues in the TUI.
296
+ - **Global Synchronization**: Synchronized version v2.0.1 across CLI, TUI, and Web interfaces.
297
+
282
298
  ### v1.9.9 (Domain Migration & Realtime Sync)
283
299
 
284
300
  - **Domain Migration**: Updated all branding and internal links to support `mytunes-pro.com`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mytunes-pro"
7
- version = "2.0.0"
7
+ version = "2.0.2"
8
8
  authors = [
9
9
  { name = "loxo", email = "loxo5432@gmail.com" },
10
10
  ]
@@ -18,12 +18,8 @@ import locale
18
18
  import signal
19
19
  import warnings
20
20
  import webbrowser
21
- # Suppress urllib3 warning about LibreSSL compatibility
22
- warnings.filterwarnings("ignore", message=".*urllib3 v2 only supports OpenSSL 1.1.1+.*")
23
- import webbrowser
24
21
  import tempfile
25
22
  import shutil
26
-
27
23
  import requests
28
24
 
29
25
 
@@ -36,7 +32,7 @@ MPV_SOCKET = "/tmp/mpv_socket"
36
32
  LOG_FILE = "/tmp/mytunes_mpv.log"
37
33
  PID_FILE = "/tmp/mytunes_mpv.pid"
38
34
  APP_NAME = "MyTunes Pro"
39
- APP_VERSION = "2.0.0"
35
+ APP_VERSION = "2.0.2"
40
36
 
41
37
  # === [Strings & Localization] ===
42
38
  STRINGS = {
@@ -56,7 +52,7 @@ STRINGS = {
56
52
  "fav_added": "★ 즐겨찾기에 추가됨",
57
53
  "fav_removed": "☆ 즐겨찾기 해제됨",
58
54
  "header_r1": "[S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [A/5]즐겨찾기추가 [Q/6]뒤로",
59
- "header_r2": "[F7]유튜브 [F8]라이브 [F9]라이브공유 [SPC]Play/Stop [+/-]볼륨 [<>]빨리감기",
55
+ "header_r2": "[F7]유튜브 [F8]라이브 [F9]라이브공유 [SPC]Play/Stop [+/-]볼륨 [<>]빨리감기 [D/Del]삭제",
60
56
  "help_guide": "[j/k]이동 [En]선택 [h/q]뒤로 [S/1]검색 [F/2]즐겨찾기 [R/3]기록 [M/4]메인 [F7]유튜브 [F8]라이브 [F9]라이브공유",
61
57
  "menu_main": "☰ 메인 메뉴",
62
58
  "menu_search_results": "⌕ YouTube 음악 검색",
@@ -85,7 +81,7 @@ STRINGS = {
85
81
  "fav_added": "★ Added to Favorites",
86
82
  "fav_removed": "☆ Removed from Favorites",
87
83
  "header_r1": "[S/1]Srch [F/2]Favs [R/3]Hist [M/4]Main [A/5]AddFav [Q/6]Back",
88
- "header_r2": "[F7]YT [F8]Live [F9]LiveShare [SPC]Play/Stop [+/-]Vol [<>]Seek",
84
+ "header_r2": "[F7]YT [F8]Live [F9]LiveShare [SPC]Play/Stop [+/-]Vol [<>]Seek [D/Del]Del",
89
85
  "help_guide": "[j/k]Move [En]Select [h/q]Back [S/1]Srch [F/2]Fav [R/3]Hist [M/4]Main [F7]YT [F8]Live [F9]Share",
90
86
  "menu_main": "☰ Main Menu",
91
87
  "menu_search_results": "⌕ Search YouTube Music",
@@ -100,11 +96,11 @@ STRINGS = {
100
96
  }
101
97
  }
102
98
 
103
- # === [Data Management] ===
104
99
  class DataManager:
105
100
  def __init__(self):
106
101
  self.data = self.load_data()
107
102
  self.favorites_set = {f['url'] for f in self.data.get('favorites', []) if 'url' in f}
103
+ self.lock = threading.Lock()
108
104
 
109
105
  # Auto-fetch country if missing
110
106
  if 'country' not in self.data:
@@ -124,8 +120,11 @@ class DataManager:
124
120
  return {"history": [], "favorites": [], "language": "ko", "resume": {}, "search_results_history": []}
125
121
 
126
122
  def save_data(self):
127
- with open(DATA_FILE, "w", encoding="utf-8") as f:
128
- json.dump(self.data, f, indent=2, ensure_ascii=False)
123
+ with self.lock:
124
+ try:
125
+ with open(DATA_FILE, "w", encoding="utf-8") as f:
126
+ json.dump(self.data, f, indent=2, ensure_ascii=False)
127
+ except Exception: pass
129
128
 
130
129
  def get_progress(self, url):
131
130
  return self.data.get("resume", {}).get(url, 0)
@@ -230,16 +229,35 @@ class DataManager:
230
229
  self.data['search_results_history'] = unique_history[:200]
231
230
  self.save_data()
232
231
 
232
+ def remove_favorite_by_index(self, index):
233
+ if 0 <= index < len(self.data['favorites']):
234
+ item = self.data['favorites'].pop(index)
235
+ if item.get('url') in self.favorites_set:
236
+ self.favorites_set.remove(item['url'])
237
+ self.save_data()
238
+ return True
239
+ return False
240
+
241
+ def remove_history_by_index(self, index):
242
+ if 0 <= index < len(self.data['history']):
243
+ self.data['history'].pop(index)
244
+ self.save_data()
245
+ return True
246
+ return False
247
+
248
+ def remove_search_history_by_index(self, index):
249
+ if 0 <= index < len(self.data['search_results_history']):
250
+ self.data['search_results_history'].pop(index)
251
+ self.save_data()
252
+ return True
253
+ return False
254
+
255
+
233
256
  # === [Player Logic with Advanced IPC] ===
234
257
  class Player:
235
258
  def __init__(self):
236
259
  self.current_proc = None
237
260
  self.loading = False
238
-
239
- self.current_proc = None
240
- self.loading = False
241
-
242
-
243
261
  self.loading_ts = 0
244
262
 
245
263
  # Cleanup pre-existing instance if any
@@ -352,7 +370,7 @@ class Player:
352
370
  """Send raw command list to MPV via JSON IPC."""
353
371
  try:
354
372
  client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
355
- client.settimeout(0.1) # Fast timeout
373
+ client.settimeout(0.5) # Fast timeout (Optimization for Sleep/Wake resilience)
356
374
  client.connect(MPV_SOCKET)
357
375
  cmd_str = json.dumps({"command": command}) + "\n"
358
376
  client.send(cmd_str.encode('utf-8'))
@@ -410,10 +428,11 @@ class MyTunesApp:
410
428
 
411
429
  # Search State
412
430
  self.current_search_query = None
413
- self.search_page = 1
414
- self.is_loading_more = False
431
+ # self.search_page = 1 # Deprecated: Pagination Removed v2.0.2
432
+ # self.is_loading_more = False # Deprecated
415
433
 
416
434
  # Playback State
435
+
417
436
  self.playback_time = 0
418
437
  self.playback_duration = 0
419
438
  self.is_paused = False
@@ -436,6 +455,8 @@ class MyTunesApp:
436
455
  curses.curs_set(0)
437
456
  self.stdscr.nodelay(True)
438
457
  self.stdscr.timeout(200) # Update loop every 200ms
458
+ self.last_input_time = time.time() # For Idle Detection
459
+
439
460
 
440
461
  # Register Signal for Terminal Disconnect (Window Close)
441
462
  try:
@@ -523,6 +544,15 @@ class MyTunesApp:
523
544
  elif self.playback_time > 10:
524
545
  self.dm.set_progress(self.current_track['url'], self.playback_time)
525
546
 
547
+
548
+
549
+ # Safety: If loading takes too long (> 8s), force reset to allow error handling/skip
550
+ # Consolidated redundancy checks into a single clean block
551
+ now = time.time()
552
+ if self.player.loading and (now - self.player.loading_ts > 8):
553
+ self.player.loading = False
554
+ self.status_msg = "⚠️ Load timed out. Skipping..."
555
+
526
556
  # 2. Frequent: Pause state (Every 2 loops ~400ms)
527
557
  if self.loop_count % 2 == 0:
528
558
  p = self.player.get_property("pause")
@@ -542,10 +572,6 @@ class MyTunesApp:
542
572
  is_idle = self.player.get_property("idle-active")
543
573
  if is_idle and self.player.loading:
544
574
  self.player.loading = False
545
-
546
- # Timeout fallback for loading state (remains every loop logic)
547
- if self.player.loading and (time.time() - getattr(self.player, 'loading_ts', 0) > 8):
548
- self.player.loading = False
549
575
 
550
576
  # Periodic Save (Throttle 10s)
551
577
  if time.time() - getattr(self, 'last_save_time', 0) > 10:
@@ -570,6 +596,10 @@ class MyTunesApp:
570
596
 
571
597
  if key == -1: return
572
598
 
599
+ # Reset Idle Timer
600
+ self.last_input_time = time.time()
601
+
602
+
573
603
  # Handle formatting: invalid key might be int -1
574
604
 
575
605
  # Resize Info
@@ -580,10 +610,37 @@ class MyTunesApp:
580
610
 
581
611
  # GLOBAL ESC: Background Play (Exit but keep music)
582
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
583
614
  if key == 27 or key == '\x1b':
584
- self.stop_on_exit = False
585
- self.running = False
586
- return
615
+ # Peek for next key with very short timeout
616
+ self.stdscr.timeout(50)
617
+ 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)
587
644
 
588
645
  # Handle Mouse Click
589
646
  if key == curses.KEY_MOUSE:
@@ -603,12 +660,10 @@ class MyTunesApp:
603
660
  rel_x = mx - branding_x
604
661
  if rel_x < 15:
605
662
  url = "https://mytunes-pro.com"
606
- self.status_msg = f"🌐 Opening {url}..."
607
- threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
663
+ self.open_browser(url)
608
664
  elif rel_x > 15:
609
665
  url = "https://postgresql.co.kr"
610
- self.status_msg = f"🌐 Opening {url}..."
611
- threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
666
+ self.open_browser(url)
612
667
  except:
613
668
  pass
614
669
  return
@@ -619,9 +674,10 @@ class MyTunesApp:
619
674
  current_list = self.get_current_list()
620
675
 
621
676
  # Navigation logic
622
- # Back: Q, Left Arrow, Backspace, Korean 'ㅂ' (q), h, 6
677
+ # Back: Q, Left Arrow, Backspace, h, 6
678
+ # Fix: Removed Korean mappings ('ㅂ', 'ㅗ') to prevent IME ghost keys per user request
623
679
  if key == curses.KEY_LEFT or key == curses.KEY_BACKSPACE or key == 127 or \
624
- k_char in ['q', 'ㅂ', '6', 'h', 'ㅗ']:
680
+ k_char in ['q', '6', 'h']:
625
681
  if len(self.view_stack) > 1:
626
682
  # Pop current view and push to forward stack
627
683
  current_view = self.view_stack.pop()
@@ -634,7 +690,8 @@ class MyTunesApp:
634
690
 
635
691
  # Forward: L, Right Arrow (Browser Style)
636
692
  # Re-visit the view we just popped from
637
- if k_char in ['l', 'L', 'ㅣ'] or key == curses.KEY_RIGHT:
693
+ # Fix: Removed Korean mappings ('ㅣ') to prevent IME ghost keys
694
+ if k_char in ['l', 'L'] or key == curses.KEY_RIGHT:
638
695
  if self.forward_stack:
639
696
  next_view = self.forward_stack.pop()
640
697
  self.view_stack.append(next_view)
@@ -642,7 +699,10 @@ class MyTunesApp:
642
699
  self.status_msg = ""
643
700
  return
644
701
 
645
- if key == curses.KEY_UP or k_char in ['k', 'ㅏ']:
702
+ return
703
+
704
+ # Fix: Removed Korean mappings ('ㅏ', 'ㅓ') for stability
705
+ if key == curses.KEY_UP or k_char in ['k']:
646
706
  if self.selection_idx > 0:
647
707
  self.selection_idx -= 1
648
708
  if self.selection_idx < self.scroll_offset: self.scroll_offset = self.selection_idx
@@ -653,7 +713,7 @@ class MyTunesApp:
653
713
  # Maintain scroll consistency (h - 10 matches draw() layout)
654
714
  list_area_height = h - 10
655
715
  self.scroll_offset = max(0, self.selection_idx - list_area_height + 1)
656
- elif key == curses.KEY_DOWN or k_char in ['j', 'ㅓ']:
716
+ elif key == curses.KEY_DOWN or k_char in ['j']:
657
717
  if self.selection_idx < len(current_list) - 1:
658
718
  self.selection_idx += 1
659
719
  h, _ = self.stdscr.getmaxyx()
@@ -665,26 +725,29 @@ class MyTunesApp:
665
725
  self.selection_idx = 0
666
726
  self.scroll_offset = 0
667
727
 
668
- # Enter / Select: Enter Only (L moved to Forward)
669
- elif key == '\n' or key == 10 or key == 13:
670
- self.activate_selection(current_list)
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)
671
734
 
672
- # Shortcuts with Korean support AND Number keys (for instant reaction)
673
- # Search: S, ㄴ, 1, /
674
- elif k_char in ['s', 'S', 'ㄴ', '1', '/']:
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()):
675
738
  self.forward_stack = [] # Clear forward history on new navigation
676
739
  self.prompt_search()
677
740
 
678
- # Favorites: F, ㄹ, 2
679
- elif k_char in ['f', 'F', 'ㄹ', '2']:
741
+ # Favorites: F, 2
742
+ elif k_char in ['f', 'F', '2']:
680
743
  if self.view_stack[-1] != "favorites":
681
744
  self.forward_stack = []
682
745
  self.view_stack.append("favorites")
683
746
  self.selection_idx = 0
684
747
  self.status_msg = self.t("favorites_info", DATA_FILE)
685
748
 
686
- # History: R, ㄱ, 3 (Changed from H to avoid Back conflict)
687
- elif k_char in ['r', 'R', 'ㄱ', '3']:
749
+ # History: R, 3 (Changed from H to avoid Back conflict)
750
+ elif k_char in ['r', 'R', '3']:
688
751
  if self.view_stack[-1] != "history":
689
752
  self.forward_stack = []
690
753
  self.cached_history = list(self.dm.data['history']) # Snapshot
@@ -692,8 +755,8 @@ class MyTunesApp:
692
755
  self.selection_idx = 0
693
756
  self.status_msg = self.t("hist_info")
694
757
 
695
- # Main Menu: M, ㅡ, 4
696
- elif k_char in ['m', 'M', 'ㅡ', '4']:
758
+ # Main Menu: M, 4
759
+ elif k_char in ['m', 'M', '4']:
697
760
  self.forward_stack = [] # Clear forward history
698
761
  self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
699
762
 
@@ -753,32 +816,36 @@ class MyTunesApp:
753
816
  }
754
817
 
755
818
  # v1.9.9 Security Update: Use centralized API with Auth Header
756
- try:
757
- headers = {
758
- "Content-Type": "application/json",
759
- "x-mytunes-secret": "mytunes-v1-secret-8822"
760
- }
761
- resp = requests.post(
762
- self.share_api_url,
763
- json=payload,
764
- headers=headers,
765
- timeout=3
766
- )
767
- if resp.status_code == 200:
768
- self.sent_history[url] = time.time()
769
- safe_title = self.truncate(title, 50)
770
- self.status_msg = f"🚀 Shared: {safe_title}..."
771
- else:
772
- self.status_msg = f"❌ Share Error: {resp.status_code}"
773
- except:
774
- self.status_msg = "❌ Network Error (API)"
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()
775
842
 
776
843
  except Exception as e:
777
844
  self.status_msg = f"❌ Share Failed: {str(e)}"
778
845
 
779
846
 
780
- # Add to Favorites: A, ㅁ, 5
781
- elif k_char in ['a', 'A', 'ㅁ', '5']:
847
+ # Add to Favorites: A, 5
848
+ elif k_char in ['a', 'A', '5']:
782
849
  if current_list and 0 <= self.selection_idx < len(current_list):
783
850
  target_item = current_list[self.selection_idx]
784
851
  # Ensure it's a valid track item (has url)
@@ -795,88 +862,57 @@ class MyTunesApp:
795
862
  if self.is_remote():
796
863
  self.show_copy_dialog("YouTube", url)
797
864
  else:
798
- # v1.8.4 - Use standard webbrowser library for maximum stability on F7
799
- self.status_msg = "🌐 Opening YouTube in Browser..."
800
- threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
865
+ self.open_browser(url)
801
866
 
802
- # Open Live Station (F8): App Mode with Optimized Flags (v1.8.6)
867
+ # Open Live Station: F8
803
868
  elif key == curses.KEY_F8:
804
- live_url = "https://mytunes-pro.com/live/"
869
+ homepage_url = "https://mytunes-pro.com"
805
870
  if self.is_remote():
806
- self.show_copy_dialog("Live Station", live_url)
871
+ self.show_copy_dialog("MyTunes Home", homepage_url)
807
872
  return
808
873
 
809
- # v1.9.4 - Ultimate WSL Fix: Use Standard Webbrowser Module
810
- # Subprocess/cmd.exe based launching in WSL is unstable.
811
- # We switch to the standard `webbrowser` module which handles system default browser reliably.
812
- # This sacrifices window sizing but guarantees the URL opens.
813
- if self.is_wsl():
814
- threading.Thread(target=webbrowser.open, args=(live_url,), daemon=True).start()
815
- return
874
+ self.open_browser(homepage_url, app_mode=True)
816
875
 
817
- # Native (Mac/Windows/Linux) Logic Continues Below...
818
- temp_user_data = os.path.join(tempfile.gettempdir(), f"mytunes_v190_{int(time.time() / 10)}")
819
-
820
- # Optimized Flag Set (Context7 Research)
821
- flags = [
822
- f"--app={live_url}",
823
- "--window-size=600,900",
824
- "--window-position=100,100",
825
- f"--user-data-dir={temp_user_data}",
826
- "--no-first-run",
827
- "--no-default-browser-check",
828
- "--disable-default-apps",
829
- "--disable-infobars",
830
- "--disable-translate",
831
- "--disable-features=Translation",
832
- "--disable-save-password-bubble",
833
- "--autoplay-policy=no-user-gesture-required",
834
- "--new-window",
835
- "--disable-extensions"
836
- ]
837
-
838
- launched = False
839
- # v1.8.4 - Subprocess Isolation (start_new_session) to prevent crashes on WSL/Linux
840
- # 1. macOS
841
- if sys.platform == 'darwin':
842
- browsers = ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"]
843
- for b_path in browsers:
844
- if os.path.exists(b_path):
845
- try:
846
- # Use -na to open a fresh instance
847
- subprocess.Popen(["open", "-na", b_path, "--args"] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
848
- launched = True; break
849
- except: pass
850
-
851
- # 2. Windows Native
852
- elif sys.platform == 'win32':
853
- win_paths = [
854
- os.path.join(os.environ.get('PROGRAMFILES', ''), 'Google\\Chrome\\Application\\chrome.exe'),
855
- os.path.join(os.environ.get('PROGRAMFILES(X86)', ''), 'Google\\Chrome\\Application\\chrome.exe'),
856
- os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Google\\Chrome\\Application\\chrome.exe'),
857
- ]
858
- for p in win_paths:
859
- if p and os.path.exists(p):
860
- try:
861
- subprocess.Popen([p] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
862
- launched = True; break
863
- except: pass
864
-
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)
865
914
 
866
- # 4. Native Linux
867
- else:
868
- for b in ['google-chrome', 'google-chrome-stable', 'brave-browser', 'chromium-browser', 'chromium']:
869
- p = shutil.which(b)
870
- if p:
871
- try:
872
- subprocess.Popen([p] + flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); launched = True; break
873
- except: pass
874
915
 
875
- if launched:
876
- self.status_msg = "📡 Opening Live Popup (712x800)..."
877
- else:
878
- webbrowser.open(live_url)
879
- self.status_msg = "📡 Opening Live Station (Browser)..."
880
916
 
881
917
  def ask_resume(self, saved_time, track_title):
882
918
  self.stdscr.nodelay(False) # Blocking input for dialog
@@ -937,7 +973,62 @@ class MyTunesApp:
937
973
  return res
938
974
 
939
975
  def is_remote(self):
940
- return 'SSH_CONNECTION' in os.environ or 'SSH_CLIENT' in os.environ
976
+ """Check if running in a remote SSH session (excluding local WSL)."""
977
+ if 'WSL_DISTRO_NAME' in os.environ or 'WSL_INTEROP' in os.environ:
978
+ return False
979
+ return 'SSH_CONNECTION' in os.environ or 'SSH_CLIENT' in os.environ or 'SSH_TTY' in os.environ
980
+
981
+ def open_browser(self, url, app_mode=False):
982
+ """Open browser using detached subprocess to prevent TUI freezing."""
983
+ self.status_msg = f"🌐 Opening Link: {url[:30]}..."
984
+
985
+ def run_open():
986
+ try:
987
+ # Prepare DEVNULL for fire-and-forget
988
+ devnull = os.open(os.devnull, os.O_RDWR)
989
+ popen_kwargs = {
990
+ 'stdin': devnull,
991
+ 'stdout': devnull,
992
+ 'stderr': devnull,
993
+ 'close_fds': True
994
+ }
995
+
996
+ # Use start_new_session for process group detachment (if possible)
997
+ if hasattr(os, 'setsid') or sys.platform != 'win32':
998
+ popen_kwargs['start_new_session'] = True
999
+
1000
+ if sys.platform == 'darwin':
1001
+ if app_mode:
1002
+ # Attempt "App Mode" for Chrome/Brave on macOS
1003
+ launched = False
1004
+ browsers = [
1005
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1006
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
1007
+ ]
1008
+ for b_path in browsers:
1009
+ if os.path.exists(b_path):
1010
+ try:
1011
+ subprocess.Popen([b_path, f"--app={url}", "--window-size=600,800"], **popen_kwargs)
1012
+ launched = True
1013
+ break
1014
+ except: pass
1015
+ if not launched:
1016
+ subprocess.Popen(['open', url], **popen_kwargs)
1017
+ else:
1018
+ subprocess.Popen(['open', url], **popen_kwargs)
1019
+ elif self.is_wsl():
1020
+ # For WSL, we usually use cmd.exe /c start
1021
+ subprocess.Popen(['cmd.exe', '/c', 'start', url], **popen_kwargs)
1022
+ else:
1023
+ # Linux or others
1024
+ subprocess.Popen(['xdg-open', url], **popen_kwargs)
1025
+ except Exception as e:
1026
+ # Log error silently to TUI status
1027
+ self.status_msg = f"❌ Browser Error: {str(e)[:20]}"
1028
+
1029
+ # Still execute Popen in a thread to be extra safe,
1030
+ # but Popen itself is now detached and redirected.
1031
+ threading.Thread(target=run_open, daemon=True).start()
941
1032
 
942
1033
  def is_wsl(self):
943
1034
  try:
@@ -1017,13 +1108,9 @@ class MyTunesApp:
1017
1108
  self.status_msg = "" # Clear stale messages on language switch
1018
1109
  elif item["id"] == "quit": self.running = False
1019
1110
  else:
1020
- # Check for Load More Button
1021
- if item.get("id") == "load_more_btn":
1022
- self.load_more_results()
1023
- return
1024
-
1025
1111
  self.play_music(item, interactive=True)
1026
1112
 
1113
+
1027
1114
  def play_music(self, item, interactive=True, preserve_queue=False):
1028
1115
  if not item.get("url"): return # Guard against dummy items
1029
1116
 
@@ -1178,7 +1265,8 @@ class MyTunesApp:
1178
1265
  if query:
1179
1266
  self.status_msg = self.t("searching")
1180
1267
  self.draw()
1181
- self.perform_search(query)
1268
+ # v2.0.0 Refactor: Threaded Search
1269
+ threading.Thread(target=self.perform_search, args=(query,), daemon=True).start()
1182
1270
  else:
1183
1271
  # Revert if no query and we were just previewing history
1184
1272
  # But requirement 2: "If Enter with no query, preserve previous search results"
@@ -1187,50 +1275,38 @@ class MyTunesApp:
1187
1275
  # If the user wants to CANCEL and go back to Main, they might need ESC.
1188
1276
  pass
1189
1277
 
1190
- def perform_search(self, query, page=1):
1278
+ def perform_search(self, query):
1191
1279
  try:
1192
- self.is_loading_more = True
1193
- if page == 1:
1194
- self.current_search_query = query
1195
- self.search_page = 1
1196
- self.status_msg = self.t("searching")
1197
- else:
1198
- self.status_msg = "Loading next 50..."
1199
- self.draw() # Force redraw to show status
1200
-
1201
- # Resolve yt-dlp path: checks dirname of current python (venv/bin) first
1280
+ # v2.0.4 Fix: Don't set player.loading=True for Search.
1281
+ # It triggers playback timeout (skipping) logic if search is slow.
1282
+ # self.player.loading = True
1283
+
1284
+ self.current_search_query = query
1285
+ self.status_msg = self.t("searching")
1286
+
1287
+ # Resolve yt-dlp path
1202
1288
  yt_dlp_cmd = "yt-dlp"
1203
1289
  venv_bin = os.path.dirname(sys.executable)
1204
1290
  venv_yt_dlp = os.path.join(venv_bin, "yt-dlp")
1205
1291
  if os.path.exists(venv_yt_dlp) and os.access(venv_yt_dlp, os.X_OK):
1206
1292
  yt_dlp_cmd = venv_yt_dlp
1207
1293
 
1208
- # Optimize search for music/audio
1209
- limit = 50
1210
- # yt-dlp logic: ytsearchN asks for N results total.
1211
- # to get page 2 (51-100), we ask for 100, checking playlist-items indices?
1212
- # actually ytsearchN with --playlist-start START works.
1213
- # We ask for (page * limit) because 'ytsearch' usually returns 'up to N'.
1214
- # If we just ask for 50 with start 51, it might fail depending on yt-dlp version.
1215
- # Safest is: ytsearch(page*limit) with --playlist-start ((page-1)*limit + 1)
1216
-
1217
- total_fetch = page * limit
1218
- start_index = (page - 1) * limit + 1
1219
-
1294
+ # v2.0.2 Optimization: 25 Items (Better space usage per user request)
1295
+ limit = 25
1220
1296
  search_query = f"{query} music"
1221
1297
  cmd = [
1222
1298
  yt_dlp_cmd,
1223
- f"ytsearch{total_fetch}:{search_query}",
1224
- "--dump-json", "--flat-playlist", "--no-playlist", "--skip-download",
1225
- "--playlist-start", str(start_index)
1299
+ f"ytsearch{limit}:{search_query}",
1300
+ "--dump-json", "--flat-playlist", "--no-playlist", "--skip-download"
1226
1301
  ]
1227
1302
 
1228
1303
  try:
1229
1304
  result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode('utf-8')
1230
1305
  except subprocess.CalledProcessError:
1231
- result = "" # Handle error or empty
1306
+ result = ""
1232
1307
 
1233
1308
  new = []
1309
+ seen_urls = set()
1234
1310
  for line in result.strip().split("\n"):
1235
1311
  if line:
1236
1312
  try:
@@ -1239,57 +1315,35 @@ class MyTunesApp:
1239
1315
  if not url or "http" not in url: url = f"https://www.youtube.com/watch?v={d.get('id')}"
1240
1316
  dur = d.get("duration", 0)
1241
1317
  dur_str = f"{int(dur)//60}:{int(dur)%60:02d}" if dur else ""
1242
- new.append({"title": d.get("title", "Unknown"), "url": url, "duration": dur_str})
1318
+ # Dedup Check
1319
+ if url not in seen_urls:
1320
+ seen_urls.add(url)
1321
+ new.append({"title": d.get("title", "Unknown"), "url": url, "duration": dur_str})
1243
1322
  except: pass
1244
1323
 
1324
+ # Enforce hard limit
1325
+ new = new[:limit]
1326
+
1245
1327
  if new:
1246
- # Remove previous 'Load More' button if exists
1247
- if self.search_results and self.search_results[-1].get("id") == "load_more_btn":
1248
- self.search_results.pop()
1249
-
1250
- # Append Load More Button if we got full batch (likely more exists)
1251
- # Or just always add it if we got results.
1252
- # Adding it at the end of new list
1253
- load_more_item = {
1254
- "title": "[ Next 50 Results... ]" if self.lang == 'en' else "[ 다음 50개 더 보기... ]",
1255
- "id": "load_more_btn",
1256
- "url": "", # Dummy
1257
- "duration": ""
1258
- }
1259
- new.append(load_more_item)
1260
-
1261
- if page == 1:
1262
- self.search_results = new
1263
- if self.view_stack[-1] != "search":
1264
- self.view_stack.append("search")
1265
- self.selection_idx = 0; self.scroll_offset = 0
1266
-
1267
- # SAVE to History (Exclude load_more_btn)
1268
- items_to_save = [x for x in new if x.get('id') != 'load_more_btn']
1269
- self.dm.add_search_results(items_to_save)
1270
-
1271
- else:
1272
- self.search_results.extend(new)
1273
- # Also save subsequent pages to history
1274
- items_to_save = [x for x in new if x.get('id') != 'load_more_btn']
1275
- self.dm.add_search_results(items_to_save)
1328
+ self.search_results = new
1329
+ if self.view_stack[-1] != "search":
1330
+ self.view_stack.append("search")
1331
+ self.selection_idx = 0; self.scroll_offset = 0
1276
1332
 
1277
- self.search_page = page
1278
- self.status_msg = f"Search Done. ({len(self.search_results)-1})" # -1 for button
1333
+ # SAVE to History
1334
+ self.dm.add_search_results(new)
1335
+
1336
+ self.status_msg = f"Search Done. ({len(new)} results)"
1279
1337
  else:
1280
- if page == 1: self.status_msg = self.t("no_results")
1281
- else:
1282
- self.status_msg = "No more results."
1283
- # Remove button if no more
1284
- if self.search_results and self.search_results[-1].get("id") == "load_more_btn":
1285
- self.search_results.pop()
1338
+ self.status_msg = self.t("no_results")
1339
+
1286
1340
  except Exception as e: self.status_msg = f"Error: {e}"
1287
1341
  finally:
1288
- self.is_loading_more = False
1342
+ self.player.loading = False
1343
+
1344
+
1345
+
1289
1346
 
1290
- def load_more_results(self):
1291
- if self.current_search_query and not self.is_loading_more:
1292
- self.perform_search(self.current_search_query, self.search_page + 1)
1293
1347
 
1294
1348
  def draw(self):
1295
1349
  self.stdscr.erase()
@@ -1445,18 +1499,15 @@ class MyTunesApp:
1445
1499
 
1446
1500
  def check_autoplay(self):
1447
1501
  # Auto-play next track from Global Queue
1502
+ # Guard: Don't autoplay if we are currently loading a track
1503
+ if self.player.loading: return
1504
+
1448
1505
  try:
1449
1506
  is_idle = self.player.get_property("idle-active")
1450
1507
  if is_idle and self.current_track and self.queue:
1451
1508
  if self.queue_idx + 1 < len(self.queue):
1452
1509
  self.queue_idx += 1
1453
1510
  next_item = self.queue[self.queue_idx]
1454
-
1455
- if next_item.get('id') == 'load_more_btn':
1456
- # TODO: Auto-trigger load more? For now just stop.
1457
- self.current_track = None
1458
- return
1459
-
1460
1511
  try: self.play_music(next_item, interactive=False, preserve_queue=True)
1461
1512
  except: pass
1462
1513
  else:
@@ -1471,6 +1522,14 @@ class MyTunesApp:
1471
1522
  self.check_autoplay()
1472
1523
  self.draw()
1473
1524
  self.handle_input()
1525
+
1526
+ # Idle / Sleep Check
1527
+ # If no input for 60s and Paused, slow down loop
1528
+ if time.time() - getattr(self, 'last_input_time', 0) > 60 and self.is_paused:
1529
+ self.stdscr.timeout(1000)
1530
+ else:
1531
+ self.stdscr.timeout(200)
1532
+
1474
1533
  except Exception as e:
1475
1534
  # v1.8.4 - Global resilience: Catch and log loop errors instead of crashing
1476
1535
  try:
@@ -1480,6 +1539,10 @@ class MyTunesApp:
1480
1539
  # Small sleep to prevent infinite tight loop on persistent error
1481
1540
  time.sleep(0.1)
1482
1541
 
1542
+ # Cleanup Mouse (Prevent terminal artifacts)
1543
+ try: curses.mousemask(0)
1544
+ except: pass
1545
+
1483
1546
  if self.stop_on_exit:
1484
1547
  self.player.stop()
1485
1548
  self.player.cleanup_orphaned_mpv()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mytunes-pro
3
- Version: 2.0.0
3
+ Version: 2.0.2
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.0
22
+ ## 🚀 Professional TUI Music Player v2.0.2
23
23
 
24
24
  MyTunes Pro는 **Context7**의 심층 리서치를 기반으로 제작된 **Premium CLI Music Player**입니다.
25
25
  Python `curses` 라이브러리를 사용하여 터미널 환경에서도 **GUI급의 유려한 UX**를 제공하며,
@@ -184,19 +184,21 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
184
184
  | **`F7`** | **유튜브 열기** | 현재 곡을 브라우저에서 보기 |
185
185
  | **`F8`** | **라이브 (Live)** | **실시간 음악 대시보드 열기** (전용 팝업창) |
186
186
  | **`F9`** | **공유 (Share)** | **현재 곡을 라이브 스테이션에 즉시 공유** |
187
- | **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `H`와 동일) |
187
+ | **`6`** | **뒤로가기** | 이전 화면으로 이동 (단축키 `Q`, `h`와 동일) |
188
+ | **`L`** | **앞으로** | 이전 화면에서 앞화면으로 다시 이동 (`Right Arrow`) |
188
189
  | **`ESC`** | **배경재생** | **음악 끄지 않고 나가기** (백그라운드 재생) |
189
190
 
190
191
  ### 🧭 기본 탐색
191
192
  | 키 | 동작 |
192
193
  | :--- | :--- |
193
194
  | `↑` / `↓` / `k` / `j` | 리스트 위/아래 이동 (Vim 키 지원) |
194
- | `Enter` / `l` | **선택 / 재생** (한글 `ㅣ`도 지원) |
195
+ | `Enter` / `l` | **선택 / 재생** |
195
196
  | `Space` | 재생 / 일시정지 (Play/Pause) |
196
197
  | `-` / `+` | **볼륨 조절** (- / +) |
197
198
  | `,` / `.` | 10초 뒤로 / 앞으로 감기 |
198
199
  | `<` / `>` | **30초** 뒤로 / 앞으로 감기 (Shift) |
199
200
  | `Backspace` / `h` / `q` | 뒤로 가기 / 검색어 지우기 |
201
+ | `L` | **앞으로 가기** |
200
202
  | `/` | **검색** (Vim Style) |
201
203
 
202
204
  ---
@@ -210,7 +212,7 @@ Windows 환경에서 한글 검색이 안 되거나 설치가 어려운 분들
210
212
 
211
213
  # 🎵 MyTunes Pro (English)
212
214
 
213
- **Modern CLI YouTube Music Player (v1.9.9)**
215
+ **Modern CLI YouTube Music Player (v2.0.2)**
214
216
  A lightweight, keyboard-centric terminal player for streaming YouTube music.
215
217
 
216
218
  ---
@@ -291,13 +293,27 @@ sudo apt install mpv python3 python3-pip pipx python3-venv -y
291
293
  | **`5`** | **Add/Del** | Toggle Favorite (Same as `A`) |
292
294
  | **`+`** | **Vol Up** | Volume +5% (Same as `=`) |
293
295
  | **`-`** | **Vol Down** | Volume -5% (Same as `_`) |
294
- | **`6`** | **Back** | Go back (Same as `Q`, `H`) |
296
+ | **`6`** | **Back** | Go back (Same as `Q`, `h`) |
297
+ | **`L`** | **Forward** | Go forward (`Right Arrow`) |
295
298
  | **`ESC`** | **Bg Play** | **Exit app but keep music playing** |
296
299
 
297
300
  ---
298
301
 
299
302
  ## 🔄 Changelog
300
303
 
304
+ ### v2.0.2 (Stability & Browser Optimization)
305
+
306
+ - **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.
307
+ - **App Mode Restore**: Fixed and improved Chrome/Brave App Mode (Popup) for the Live Station on macOS.
308
+ - **Improved Remote Detection**: Refined SSH/WSL detection to ensure local browser features are correctly enabled where possible.
309
+
310
+ ### v2.0.1 (Keymap Refinement & Version Sync)
311
+
312
+ - **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
313
+ - **Keybinding Optimization**: Updated History mapping to `R` / `3` and refined Back/Forward logic.
314
+ - **IME Stability**: Removed unstable Korean character mappings (`ㄴ`, `ㄹ`, `ㄱ`, 등) to prevent ghost key issues in the TUI.
315
+ - **Global Synchronization**: Synchronized version v2.0.1 across CLI, TUI, and Web interfaces.
316
+
301
317
  ### v1.9.9 (Domain Migration & Realtime Sync)
302
318
 
303
319
  - **Domain Migration**: Updated all branding and internal links to support `mytunes-pro.com`.
File without changes
File without changes