mytunes-pro 2.0.0__tar.gz → 2.0.1__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.0/src/mytunes_pro.egg-info → mytunes_pro-2.0.1}/PKG-INFO +16 -6
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1}/README.md +15 -5
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1}/pyproject.toml +1 -1
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1}/src/mytunes/app.py +228 -216
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1/src/mytunes_pro.egg-info}/PKG-INFO +16 -6
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1}/LICENSE +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1}/setup.cfg +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1}/src/mytunes/__init__.py +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1}/src/mytunes_pro.egg-info/SOURCES.txt +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1}/src/mytunes_pro.egg-info/dependency_links.txt +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1}/src/mytunes_pro.egg-info/entry_points.txt +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1}/src/mytunes_pro.egg-info/requires.txt +0 -0
- {mytunes_pro-2.0.0 → mytunes_pro-2.0.1}/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.1
|
|
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.1
|
|
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`, `
|
|
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 (
|
|
215
|
+
**Modern CLI YouTube Music Player (v2.0.1)**
|
|
214
216
|
A lightweight, keyboard-centric terminal player for streaming YouTube music.
|
|
215
217
|
|
|
216
218
|
---
|
|
@@ -291,13 +293,21 @@ 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`, `
|
|
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.1 (Keymap Refinement & Version Sync)
|
|
305
|
+
|
|
306
|
+
- **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
|
|
307
|
+
- **Keybinding Optimization**: Updated History mapping to `R` / `3` and refined Back/Forward logic.
|
|
308
|
+
- **IME Stability**: Removed unstable Korean character mappings (`ㄴ`, `ㄹ`, `ㄱ`, 등) to prevent ghost key issues in the TUI.
|
|
309
|
+
- **Global Synchronization**: Synchronized version v2.0.1 across CLI, TUI, and Web interfaces.
|
|
310
|
+
|
|
301
311
|
### v1.9.9 (Domain Migration & Realtime Sync)
|
|
302
312
|
|
|
303
313
|
- **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.
|
|
3
|
+
## 🚀 Professional TUI Music Player v2.0.1
|
|
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`, `
|
|
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 (
|
|
196
|
+
**Modern CLI YouTube Music Player (v2.0.1)**
|
|
195
197
|
A lightweight, keyboard-centric terminal player for streaming YouTube music.
|
|
196
198
|
|
|
197
199
|
---
|
|
@@ -272,13 +274,21 @@ 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`, `
|
|
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.1 (Keymap Refinement & Version Sync)
|
|
286
|
+
|
|
287
|
+
- **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
|
|
288
|
+
- **Keybinding Optimization**: Updated History mapping to `R` / `3` and refined Back/Forward logic.
|
|
289
|
+
- **IME Stability**: Removed unstable Korean character mappings (`ㄴ`, `ㄹ`, `ㄱ`, 등) to prevent ghost key issues in the TUI.
|
|
290
|
+
- **Global Synchronization**: Synchronized version v2.0.1 across CLI, TUI, and Web interfaces.
|
|
291
|
+
|
|
282
292
|
### v1.9.9 (Domain Migration & Realtime Sync)
|
|
283
293
|
|
|
284
294
|
- **Domain Migration**: Updated all branding and internal links to support `mytunes-pro.com`.
|
|
@@ -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.
|
|
35
|
+
APP_VERSION = "2.0.1"
|
|
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
|
|
128
|
-
|
|
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.
|
|
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
|
-
|
|
585
|
-
self.
|
|
586
|
-
|
|
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:
|
|
@@ -619,9 +676,10 @@ class MyTunesApp:
|
|
|
619
676
|
current_list = self.get_current_list()
|
|
620
677
|
|
|
621
678
|
# Navigation logic
|
|
622
|
-
# Back: Q, Left Arrow, Backspace,
|
|
679
|
+
# Back: Q, Left Arrow, Backspace, h, 6
|
|
680
|
+
# Fix: Removed Korean mappings ('ㅂ', 'ㅗ') to prevent IME ghost keys per user request
|
|
623
681
|
if key == curses.KEY_LEFT or key == curses.KEY_BACKSPACE or key == 127 or \
|
|
624
|
-
k_char in ['q', '
|
|
682
|
+
k_char in ['q', '6', 'h']:
|
|
625
683
|
if len(self.view_stack) > 1:
|
|
626
684
|
# Pop current view and push to forward stack
|
|
627
685
|
current_view = self.view_stack.pop()
|
|
@@ -634,7 +692,8 @@ class MyTunesApp:
|
|
|
634
692
|
|
|
635
693
|
# Forward: L, Right Arrow (Browser Style)
|
|
636
694
|
# Re-visit the view we just popped from
|
|
637
|
-
|
|
695
|
+
# Fix: Removed Korean mappings ('ㅣ') to prevent IME ghost keys
|
|
696
|
+
if k_char in ['l', 'L'] or key == curses.KEY_RIGHT:
|
|
638
697
|
if self.forward_stack:
|
|
639
698
|
next_view = self.forward_stack.pop()
|
|
640
699
|
self.view_stack.append(next_view)
|
|
@@ -642,7 +701,10 @@ class MyTunesApp:
|
|
|
642
701
|
self.status_msg = ""
|
|
643
702
|
return
|
|
644
703
|
|
|
645
|
-
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
# Fix: Removed Korean mappings ('ㅏ', 'ㅓ') for stability
|
|
707
|
+
if key == curses.KEY_UP or k_char in ['k']:
|
|
646
708
|
if self.selection_idx > 0:
|
|
647
709
|
self.selection_idx -= 1
|
|
648
710
|
if self.selection_idx < self.scroll_offset: self.scroll_offset = self.selection_idx
|
|
@@ -653,7 +715,7 @@ class MyTunesApp:
|
|
|
653
715
|
# Maintain scroll consistency (h - 10 matches draw() layout)
|
|
654
716
|
list_area_height = h - 10
|
|
655
717
|
self.scroll_offset = max(0, self.selection_idx - list_area_height + 1)
|
|
656
|
-
elif key == curses.KEY_DOWN or k_char in ['j'
|
|
718
|
+
elif key == curses.KEY_DOWN or k_char in ['j']:
|
|
657
719
|
if self.selection_idx < len(current_list) - 1:
|
|
658
720
|
self.selection_idx += 1
|
|
659
721
|
h, _ = self.stdscr.getmaxyx()
|
|
@@ -665,26 +727,29 @@ class MyTunesApp:
|
|
|
665
727
|
self.selection_idx = 0
|
|
666
728
|
self.scroll_offset = 0
|
|
667
729
|
|
|
668
|
-
# Enter / Select
|
|
669
|
-
elif key
|
|
670
|
-
|
|
730
|
+
# Enter / Select Logic
|
|
731
|
+
elif key in ['\n', '\r', 10, 13, curses.KEY_ENTER]:
|
|
732
|
+
# v2.0.3 Stability: Debounce Enter to prevent double-firing
|
|
733
|
+
if time.time() - getattr(self, 'last_enter_time', 0) > 0.3:
|
|
734
|
+
self.last_enter_time = time.time()
|
|
735
|
+
self.activate_selection(current_list)
|
|
671
736
|
|
|
672
|
-
# Shortcuts
|
|
673
|
-
# Search: S,
|
|
674
|
-
elif k_char in ['s', 'S', '
|
|
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()):
|
|
675
740
|
self.forward_stack = [] # Clear forward history on new navigation
|
|
676
741
|
self.prompt_search()
|
|
677
742
|
|
|
678
|
-
# Favorites: F,
|
|
679
|
-
elif k_char in ['f', 'F', '
|
|
743
|
+
# Favorites: F, 2
|
|
744
|
+
elif k_char in ['f', 'F', '2']:
|
|
680
745
|
if self.view_stack[-1] != "favorites":
|
|
681
746
|
self.forward_stack = []
|
|
682
747
|
self.view_stack.append("favorites")
|
|
683
748
|
self.selection_idx = 0
|
|
684
749
|
self.status_msg = self.t("favorites_info", DATA_FILE)
|
|
685
750
|
|
|
686
|
-
# History: R,
|
|
687
|
-
elif k_char in ['r', 'R', '
|
|
751
|
+
# History: R, 3 (Changed from H to avoid Back conflict)
|
|
752
|
+
elif k_char in ['r', 'R', '3']:
|
|
688
753
|
if self.view_stack[-1] != "history":
|
|
689
754
|
self.forward_stack = []
|
|
690
755
|
self.cached_history = list(self.dm.data['history']) # Snapshot
|
|
@@ -692,8 +757,8 @@ class MyTunesApp:
|
|
|
692
757
|
self.selection_idx = 0
|
|
693
758
|
self.status_msg = self.t("hist_info")
|
|
694
759
|
|
|
695
|
-
# Main Menu: M,
|
|
696
|
-
elif k_char in ['m', 'M', '
|
|
760
|
+
# Main Menu: M, 4
|
|
761
|
+
elif k_char in ['m', 'M', '4']:
|
|
697
762
|
self.forward_stack = [] # Clear forward history
|
|
698
763
|
self.view_stack = ["main"]; self.selection_idx = 0; self.scroll_offset = 0; self.status_msg = ""
|
|
699
764
|
|
|
@@ -753,32 +818,36 @@ class MyTunesApp:
|
|
|
753
818
|
}
|
|
754
819
|
|
|
755
820
|
# v1.9.9 Security Update: Use centralized API with Auth Header
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
self.status_msg =
|
|
773
|
-
|
|
774
|
-
|
|
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()
|
|
775
844
|
|
|
776
845
|
except Exception as e:
|
|
777
846
|
self.status_msg = f"❌ Share Failed: {str(e)}"
|
|
778
847
|
|
|
779
848
|
|
|
780
|
-
# Add to Favorites: A,
|
|
781
|
-
elif k_char in ['a', 'A', '
|
|
849
|
+
# Add to Favorites: A, 5
|
|
850
|
+
elif k_char in ['a', 'A', '5']:
|
|
782
851
|
if current_list and 0 <= self.selection_idx < len(current_list):
|
|
783
852
|
target_item = current_list[self.selection_idx]
|
|
784
853
|
# Ensure it's a valid track item (has url)
|
|
@@ -799,84 +868,55 @@ class MyTunesApp:
|
|
|
799
868
|
self.status_msg = "🌐 Opening YouTube in Browser..."
|
|
800
869
|
threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
|
|
801
870
|
|
|
802
|
-
# Open Live Station (F8): App Mode with Optimized Flags (v1.8.6)
|
|
803
871
|
elif key == curses.KEY_F8:
|
|
804
|
-
|
|
872
|
+
homepage_url = "https://mytunes-pro.com"
|
|
805
873
|
if self.is_remote():
|
|
806
|
-
self.show_copy_dialog("
|
|
874
|
+
self.show_copy_dialog("MyTunes Home", homepage_url)
|
|
807
875
|
return
|
|
808
876
|
|
|
809
|
-
|
|
810
|
-
|
|
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
|
|
877
|
+
self.status_msg = "🌐 Opening MyTunes Home..."
|
|
878
|
+
threading.Thread(target=webbrowser.open, args=(homepage_url,), daemon=True).start()
|
|
816
879
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
880
|
+
# Delete Item: DEL, d
|
|
881
|
+
elif key == curses.KEY_DC or k_char in ['d']:
|
|
882
|
+
if current_list and 0 <= self.selection_idx < len(current_list):
|
|
883
|
+
view = self.view_stack[-1]
|
|
884
|
+
success = False
|
|
885
|
+
|
|
886
|
+
if view == "favorites":
|
|
887
|
+
success = self.dm.remove_favorite_by_index(self.selection_idx)
|
|
888
|
+
if success: self.status_msg = "🗑️ Deleted from Favorites"
|
|
889
|
+
|
|
890
|
+
elif view == "history":
|
|
891
|
+
success = self.dm.remove_history_by_index(self.selection_idx)
|
|
892
|
+
if success:
|
|
893
|
+
self.cached_history = list(self.dm.data['history']) # Refresh view
|
|
894
|
+
self.status_msg = "🗑️ Deleted from History"
|
|
895
|
+
|
|
896
|
+
elif view == "search":
|
|
897
|
+
# If current_search_query is None, we are viewing Search History
|
|
898
|
+
if self.current_search_query is None:
|
|
899
|
+
success = self.dm.remove_search_history_by_index(self.selection_idx)
|
|
900
|
+
if success:
|
|
901
|
+
self.search_results = self.dm.get_search_history() # Refresh
|
|
902
|
+
self.status_msg = "🗑️ Deleted from Search History"
|
|
903
|
+
else:
|
|
904
|
+
# Ephemeral removal from result list
|
|
905
|
+
try:
|
|
906
|
+
self.search_results.pop(self.selection_idx)
|
|
907
|
+
self.status_msg = "Start new search"
|
|
908
|
+
success = True
|
|
909
|
+
except: pass
|
|
910
|
+
|
|
911
|
+
if success:
|
|
912
|
+
# Adjust selection index if out of bounds
|
|
913
|
+
# If list became empty, idx will be 0 but len is 0.
|
|
914
|
+
# We just need to ensure we don't crash next draw.
|
|
915
|
+
# The draw logic (get_current_list) handles empty lists safely.
|
|
916
|
+
if self.selection_idx >= len(self.get_current_list()):
|
|
917
|
+
self.selection_idx = max(0, len(self.get_current_list()) - 1)
|
|
865
918
|
|
|
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
919
|
|
|
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
920
|
|
|
881
921
|
def ask_resume(self, saved_time, track_title):
|
|
882
922
|
self.stdscr.nodelay(False) # Blocking input for dialog
|
|
@@ -1017,13 +1057,9 @@ class MyTunesApp:
|
|
|
1017
1057
|
self.status_msg = "" # Clear stale messages on language switch
|
|
1018
1058
|
elif item["id"] == "quit": self.running = False
|
|
1019
1059
|
else:
|
|
1020
|
-
# Check for Load More Button
|
|
1021
|
-
if item.get("id") == "load_more_btn":
|
|
1022
|
-
self.load_more_results()
|
|
1023
|
-
return
|
|
1024
|
-
|
|
1025
1060
|
self.play_music(item, interactive=True)
|
|
1026
1061
|
|
|
1062
|
+
|
|
1027
1063
|
def play_music(self, item, interactive=True, preserve_queue=False):
|
|
1028
1064
|
if not item.get("url"): return # Guard against dummy items
|
|
1029
1065
|
|
|
@@ -1178,7 +1214,8 @@ class MyTunesApp:
|
|
|
1178
1214
|
if query:
|
|
1179
1215
|
self.status_msg = self.t("searching")
|
|
1180
1216
|
self.draw()
|
|
1181
|
-
|
|
1217
|
+
# v2.0.0 Refactor: Threaded Search
|
|
1218
|
+
threading.Thread(target=self.perform_search, args=(query,), daemon=True).start()
|
|
1182
1219
|
else:
|
|
1183
1220
|
# Revert if no query and we were just previewing history
|
|
1184
1221
|
# But requirement 2: "If Enter with no query, preserve previous search results"
|
|
@@ -1187,50 +1224,38 @@ class MyTunesApp:
|
|
|
1187
1224
|
# If the user wants to CANCEL and go back to Main, they might need ESC.
|
|
1188
1225
|
pass
|
|
1189
1226
|
|
|
1190
|
-
def perform_search(self, query
|
|
1227
|
+
def perform_search(self, query):
|
|
1191
1228
|
try:
|
|
1192
|
-
|
|
1193
|
-
if
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
# Resolve yt-dlp path: checks dirname of current python (venv/bin) first
|
|
1229
|
+
# v2.0.4 Fix: Don't set player.loading=True for Search.
|
|
1230
|
+
# It triggers playback timeout (skipping) logic if search is slow.
|
|
1231
|
+
# self.player.loading = True
|
|
1232
|
+
|
|
1233
|
+
self.current_search_query = query
|
|
1234
|
+
self.status_msg = self.t("searching")
|
|
1235
|
+
|
|
1236
|
+
# Resolve yt-dlp path
|
|
1202
1237
|
yt_dlp_cmd = "yt-dlp"
|
|
1203
1238
|
venv_bin = os.path.dirname(sys.executable)
|
|
1204
1239
|
venv_yt_dlp = os.path.join(venv_bin, "yt-dlp")
|
|
1205
1240
|
if os.path.exists(venv_yt_dlp) and os.access(venv_yt_dlp, os.X_OK):
|
|
1206
1241
|
yt_dlp_cmd = venv_yt_dlp
|
|
1207
1242
|
|
|
1208
|
-
#
|
|
1209
|
-
limit =
|
|
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
|
-
|
|
1243
|
+
# v2.0.2 Optimization: 25 Items (Better space usage per user request)
|
|
1244
|
+
limit = 25
|
|
1220
1245
|
search_query = f"{query} music"
|
|
1221
1246
|
cmd = [
|
|
1222
1247
|
yt_dlp_cmd,
|
|
1223
|
-
f"ytsearch{
|
|
1224
|
-
"--dump-json", "--flat-playlist", "--no-playlist", "--skip-download"
|
|
1225
|
-
"--playlist-start", str(start_index)
|
|
1248
|
+
f"ytsearch{limit}:{search_query}",
|
|
1249
|
+
"--dump-json", "--flat-playlist", "--no-playlist", "--skip-download"
|
|
1226
1250
|
]
|
|
1227
1251
|
|
|
1228
1252
|
try:
|
|
1229
1253
|
result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode('utf-8')
|
|
1230
1254
|
except subprocess.CalledProcessError:
|
|
1231
|
-
result = ""
|
|
1255
|
+
result = ""
|
|
1232
1256
|
|
|
1233
1257
|
new = []
|
|
1258
|
+
seen_urls = set()
|
|
1234
1259
|
for line in result.strip().split("\n"):
|
|
1235
1260
|
if line:
|
|
1236
1261
|
try:
|
|
@@ -1239,57 +1264,35 @@ class MyTunesApp:
|
|
|
1239
1264
|
if not url or "http" not in url: url = f"https://www.youtube.com/watch?v={d.get('id')}"
|
|
1240
1265
|
dur = d.get("duration", 0)
|
|
1241
1266
|
dur_str = f"{int(dur)//60}:{int(dur)%60:02d}" if dur else ""
|
|
1242
|
-
|
|
1267
|
+
# Dedup Check
|
|
1268
|
+
if url not in seen_urls:
|
|
1269
|
+
seen_urls.add(url)
|
|
1270
|
+
new.append({"title": d.get("title", "Unknown"), "url": url, "duration": dur_str})
|
|
1243
1271
|
except: pass
|
|
1244
1272
|
|
|
1273
|
+
# Enforce hard limit
|
|
1274
|
+
new = new[:limit]
|
|
1275
|
+
|
|
1245
1276
|
if new:
|
|
1246
|
-
|
|
1247
|
-
if self.
|
|
1248
|
-
self.
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
#
|
|
1252
|
-
|
|
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)
|
|
1277
|
+
self.search_results = new
|
|
1278
|
+
if self.view_stack[-1] != "search":
|
|
1279
|
+
self.view_stack.append("search")
|
|
1280
|
+
self.selection_idx = 0; self.scroll_offset = 0
|
|
1281
|
+
|
|
1282
|
+
# SAVE to History
|
|
1283
|
+
self.dm.add_search_results(new)
|
|
1276
1284
|
|
|
1277
|
-
self.
|
|
1278
|
-
self.status_msg = f"Search Done. ({len(self.search_results)-1})" # -1 for button
|
|
1285
|
+
self.status_msg = f"Search Done. ({len(new)} results)"
|
|
1279
1286
|
else:
|
|
1280
|
-
|
|
1281
|
-
|
|
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()
|
|
1287
|
+
self.status_msg = self.t("no_results")
|
|
1288
|
+
|
|
1286
1289
|
except Exception as e: self.status_msg = f"Error: {e}"
|
|
1287
1290
|
finally:
|
|
1288
|
-
self.
|
|
1291
|
+
self.player.loading = False
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
|
|
1289
1295
|
|
|
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
1296
|
|
|
1294
1297
|
def draw(self):
|
|
1295
1298
|
self.stdscr.erase()
|
|
@@ -1445,18 +1448,15 @@ class MyTunesApp:
|
|
|
1445
1448
|
|
|
1446
1449
|
def check_autoplay(self):
|
|
1447
1450
|
# Auto-play next track from Global Queue
|
|
1451
|
+
# Guard: Don't autoplay if we are currently loading a track
|
|
1452
|
+
if self.player.loading: return
|
|
1453
|
+
|
|
1448
1454
|
try:
|
|
1449
1455
|
is_idle = self.player.get_property("idle-active")
|
|
1450
1456
|
if is_idle and self.current_track and self.queue:
|
|
1451
1457
|
if self.queue_idx + 1 < len(self.queue):
|
|
1452
1458
|
self.queue_idx += 1
|
|
1453
1459
|
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
1460
|
try: self.play_music(next_item, interactive=False, preserve_queue=True)
|
|
1461
1461
|
except: pass
|
|
1462
1462
|
else:
|
|
@@ -1471,6 +1471,14 @@ class MyTunesApp:
|
|
|
1471
1471
|
self.check_autoplay()
|
|
1472
1472
|
self.draw()
|
|
1473
1473
|
self.handle_input()
|
|
1474
|
+
|
|
1475
|
+
# Idle / Sleep Check
|
|
1476
|
+
# If no input for 60s and Paused, slow down loop
|
|
1477
|
+
if time.time() - getattr(self, 'last_input_time', 0) > 60 and self.is_paused:
|
|
1478
|
+
self.stdscr.timeout(1000)
|
|
1479
|
+
else:
|
|
1480
|
+
self.stdscr.timeout(200)
|
|
1481
|
+
|
|
1474
1482
|
except Exception as e:
|
|
1475
1483
|
# v1.8.4 - Global resilience: Catch and log loop errors instead of crashing
|
|
1476
1484
|
try:
|
|
@@ -1480,6 +1488,10 @@ class MyTunesApp:
|
|
|
1480
1488
|
# Small sleep to prevent infinite tight loop on persistent error
|
|
1481
1489
|
time.sleep(0.1)
|
|
1482
1490
|
|
|
1491
|
+
# Cleanup Mouse (Prevent terminal artifacts)
|
|
1492
|
+
try: curses.mousemask(0)
|
|
1493
|
+
except: pass
|
|
1494
|
+
|
|
1483
1495
|
if self.stop_on_exit:
|
|
1484
1496
|
self.player.stop()
|
|
1485
1497
|
self.player.cleanup_orphaned_mpv()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mytunes-pro
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.1
|
|
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.1
|
|
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`, `
|
|
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 (
|
|
215
|
+
**Modern CLI YouTube Music Player (v2.0.1)**
|
|
214
216
|
A lightweight, keyboard-centric terminal player for streaming YouTube music.
|
|
215
217
|
|
|
216
218
|
---
|
|
@@ -291,13 +293,21 @@ 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`, `
|
|
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.1 (Keymap Refinement & Version Sync)
|
|
305
|
+
|
|
306
|
+
- **Navigation**: Added browser-style Forward navigation (`L` / `Right Arrow`).
|
|
307
|
+
- **Keybinding Optimization**: Updated History mapping to `R` / `3` and refined Back/Forward logic.
|
|
308
|
+
- **IME Stability**: Removed unstable Korean character mappings (`ㄴ`, `ㄹ`, `ㄱ`, 등) to prevent ghost key issues in the TUI.
|
|
309
|
+
- **Global Synchronization**: Synchronized version v2.0.1 across CLI, TUI, and Web interfaces.
|
|
310
|
+
|
|
301
311
|
### v1.9.9 (Domain Migration & Realtime Sync)
|
|
302
312
|
|
|
303
313
|
- **Domain Migration**: Updated all branding and internal links to support `mytunes-pro.com`.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|