tidal-cli 1.0.0__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.
- ticli/__init__.py +3 -0
- ticli/cli.py +19 -0
- ticli/player.py +1452 -0
- ticli/tests/__init__.py +0 -0
- ticli/tests/test_full_e2e.py +70 -0
- ticli/utils/__init__.py +1 -0
- ticli/utils/credential_store.py +94 -0
- tidal_cli-1.0.0.dist-info/METADATA +171 -0
- tidal_cli-1.0.0.dist-info/RECORD +12 -0
- tidal_cli-1.0.0.dist-info/WHEEL +5 -0
- tidal_cli-1.0.0.dist-info/entry_points.txt +2 -0
- tidal_cli-1.0.0.dist-info/top_level.txt +1 -0
ticli/player.py
ADDED
|
@@ -0,0 +1,1452 @@
|
|
|
1
|
+
"""Ticli - Terminal music player for TIDAL.
|
|
2
|
+
|
|
3
|
+
Uses tidalapi for TIDAL API access and ffplay/mpv for audio playback.
|
|
4
|
+
OAuth login via browser, session persisted to disk.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import threading
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import tidalapi
|
|
23
|
+
except ImportError:
|
|
24
|
+
print("This feature requires 'tidalapi'. Install it with: pip install tidalapi")
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
from rich.live import Live
|
|
30
|
+
from rich.panel import Panel
|
|
31
|
+
from rich.text import Text
|
|
32
|
+
except ImportError:
|
|
33
|
+
print("This feature requires 'rich'. Install it with: pip install rich")
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_time(seconds):
|
|
38
|
+
if seconds is None or seconds != seconds:
|
|
39
|
+
return "--:--"
|
|
40
|
+
seconds = int(seconds)
|
|
41
|
+
if seconds < 0:
|
|
42
|
+
return "0:00"
|
|
43
|
+
m, s = divmod(seconds, 60)
|
|
44
|
+
return f"{m}:{s:02d}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Key constants
|
|
48
|
+
KEY_UP = "\x1b[A"
|
|
49
|
+
KEY_DOWN = "\x1b[B"
|
|
50
|
+
KEY_RIGHT = "\x1b[C"
|
|
51
|
+
KEY_LEFT = "\x1b[D"
|
|
52
|
+
KEY_ESC = "\x1b"
|
|
53
|
+
KEY_ENTER = "\r"
|
|
54
|
+
KEY_ENTER2 = "\n"
|
|
55
|
+
KEY_BACKSPACE = "\x7f"
|
|
56
|
+
KEY_BACKSPACE2 = "\x08"
|
|
57
|
+
|
|
58
|
+
from ticli.utils.credential_store import save_tokens, load_tokens
|
|
59
|
+
|
|
60
|
+
PAGE_SIZE = 15
|
|
61
|
+
|
|
62
|
+
STATE_DIR = Path.home() / ".config" / "ticli"
|
|
63
|
+
STATE_FILE = STATE_DIR / "player_state.json"
|
|
64
|
+
|
|
65
|
+
AUDIO_PLAYERS = ["mpv", "ffplay"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _find_audio_player():
|
|
69
|
+
"""Find an available audio player binary."""
|
|
70
|
+
for player in AUDIO_PLAYERS:
|
|
71
|
+
for path_dir in os.environ.get("PATH", "").split(os.pathsep):
|
|
72
|
+
full = os.path.join(path_dir, player)
|
|
73
|
+
if os.path.isfile(full) and os.access(full, os.X_OK):
|
|
74
|
+
return player
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AudioPlayer:
|
|
79
|
+
"""Manages audio playback via external player (mpv or ffplay).
|
|
80
|
+
|
|
81
|
+
Supports pause/resume:
|
|
82
|
+
- mpv: uses IPC socket to send pause property commands
|
|
83
|
+
- ffplay: kills process on pause, restarts from cached local file on resume
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, player_cmd: str):
|
|
87
|
+
self.player_cmd = player_cmd
|
|
88
|
+
self._process: Optional[subprocess.Popen] = None
|
|
89
|
+
self._lock = threading.Lock()
|
|
90
|
+
self._paused = False
|
|
91
|
+
self._ipc_path: Optional[str] = None
|
|
92
|
+
# For ffplay pause/resume: track position and local cache
|
|
93
|
+
self._current_url: Optional[str] = None
|
|
94
|
+
self._cache_file: Optional[str] = None
|
|
95
|
+
self._cache_process: Optional[subprocess.Popen] = None
|
|
96
|
+
self._play_start: Optional[float] = None
|
|
97
|
+
self._seek_offset: float = 0
|
|
98
|
+
|
|
99
|
+
def play_url(self, url: str, seek: float = 0):
|
|
100
|
+
"""Play an audio URL, stopping any current playback."""
|
|
101
|
+
self.stop()
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._paused = False
|
|
104
|
+
self._current_url = url
|
|
105
|
+
self._seek_offset = seek
|
|
106
|
+
self._play_start = time.time()
|
|
107
|
+
if self.player_cmd == "mpv":
|
|
108
|
+
self._ipc_path = f"/tmp/ticli-mpv-{os.getpid()}.sock"
|
|
109
|
+
try:
|
|
110
|
+
os.unlink(self._ipc_path)
|
|
111
|
+
except OSError:
|
|
112
|
+
pass
|
|
113
|
+
cmd = [
|
|
114
|
+
"mpv", "--no-video", "--really-quiet",
|
|
115
|
+
f"--input-ipc-server={self._ipc_path}",
|
|
116
|
+
url,
|
|
117
|
+
]
|
|
118
|
+
if seek > 0:
|
|
119
|
+
cmd.insert(-1, f"--start={seek}")
|
|
120
|
+
else: # ffplay
|
|
121
|
+
self._ipc_path = None
|
|
122
|
+
# Download to temp file in background for instant resume
|
|
123
|
+
self._cache_file = f"/tmp/ticli-cache-{os.getpid()}.flac"
|
|
124
|
+
self._cache_process = subprocess.Popen(
|
|
125
|
+
["ffmpeg", "-y", "-loglevel", "quiet", "-i", url,
|
|
126
|
+
"-c", "copy", self._cache_file],
|
|
127
|
+
stdout=subprocess.DEVNULL,
|
|
128
|
+
stderr=subprocess.DEVNULL,
|
|
129
|
+
)
|
|
130
|
+
# Play directly from URL for first play (cache may not be ready yet)
|
|
131
|
+
source = url if seek == 0 else self._cache_file
|
|
132
|
+
cmd = ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet"]
|
|
133
|
+
if seek > 0:
|
|
134
|
+
cmd += ["-ss", str(seek)]
|
|
135
|
+
cmd.append(source)
|
|
136
|
+
self._process = subprocess.Popen(
|
|
137
|
+
cmd,
|
|
138
|
+
stdout=subprocess.DEVNULL,
|
|
139
|
+
stderr=subprocess.DEVNULL,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _play_from_cache(self, seek: float):
|
|
143
|
+
"""Resume ffplay from local cached file at given position."""
|
|
144
|
+
cmd = ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet",
|
|
145
|
+
"-ss", str(seek), self._cache_file]
|
|
146
|
+
self._process = subprocess.Popen(
|
|
147
|
+
cmd,
|
|
148
|
+
stdout=subprocess.DEVNULL,
|
|
149
|
+
stderr=subprocess.DEVNULL,
|
|
150
|
+
)
|
|
151
|
+
self._play_start = time.time()
|
|
152
|
+
self._paused = False
|
|
153
|
+
|
|
154
|
+
def pause(self):
|
|
155
|
+
"""Pause playback."""
|
|
156
|
+
with self._lock:
|
|
157
|
+
if not self._process or self._process.poll() is not None or self._paused:
|
|
158
|
+
return
|
|
159
|
+
if self.player_cmd == "mpv" and self._ipc_path:
|
|
160
|
+
self._mpv_command({"command": ["set_property", "pause", True]})
|
|
161
|
+
self._paused = True
|
|
162
|
+
else:
|
|
163
|
+
# ffplay: record position, kill process (instant silence)
|
|
164
|
+
elapsed = time.time() - self._play_start if self._play_start else 0
|
|
165
|
+
self._seek_offset += elapsed
|
|
166
|
+
self._play_start = None
|
|
167
|
+
self._process.terminate()
|
|
168
|
+
try:
|
|
169
|
+
self._process.wait(timeout=2)
|
|
170
|
+
except subprocess.TimeoutExpired:
|
|
171
|
+
self._process.kill()
|
|
172
|
+
self._process = None
|
|
173
|
+
self._paused = True
|
|
174
|
+
|
|
175
|
+
def resume(self):
|
|
176
|
+
"""Resume paused playback."""
|
|
177
|
+
with self._lock:
|
|
178
|
+
if not self._paused:
|
|
179
|
+
return
|
|
180
|
+
if self.player_cmd == "mpv" and self._ipc_path:
|
|
181
|
+
self._mpv_command({"command": ["set_property", "pause", False]})
|
|
182
|
+
self._paused = False
|
|
183
|
+
else:
|
|
184
|
+
# ffplay: restart from cached local file (instant, no network)
|
|
185
|
+
if self._cache_file and os.path.exists(self._cache_file):
|
|
186
|
+
self._play_from_cache(self._seek_offset)
|
|
187
|
+
elif self._current_url:
|
|
188
|
+
# Cache not ready — fall back to URL
|
|
189
|
+
self._paused = False
|
|
190
|
+
url = self._current_url
|
|
191
|
+
seek = self._seek_offset
|
|
192
|
+
self._lock.release()
|
|
193
|
+
try:
|
|
194
|
+
self.play_url(url, seek=seek)
|
|
195
|
+
finally:
|
|
196
|
+
self._lock.acquire()
|
|
197
|
+
|
|
198
|
+
def _mpv_command(self, cmd: dict):
|
|
199
|
+
"""Send a JSON IPC command to mpv via Unix socket."""
|
|
200
|
+
try:
|
|
201
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
202
|
+
sock.settimeout(0.5)
|
|
203
|
+
sock.connect(self._ipc_path)
|
|
204
|
+
sock.sendall((json.dumps(cmd) + "\n").encode())
|
|
205
|
+
sock.close()
|
|
206
|
+
except (OSError, ConnectionRefusedError):
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
def stop(self):
|
|
210
|
+
"""Stop current playback."""
|
|
211
|
+
with self._lock:
|
|
212
|
+
if self._process and self._process.poll() is None:
|
|
213
|
+
self._process.terminate()
|
|
214
|
+
try:
|
|
215
|
+
self._process.wait(timeout=2)
|
|
216
|
+
except subprocess.TimeoutExpired:
|
|
217
|
+
self._process.kill()
|
|
218
|
+
self._process = None
|
|
219
|
+
# Stop cache download
|
|
220
|
+
if self._cache_process and self._cache_process.poll() is None:
|
|
221
|
+
self._cache_process.terminate()
|
|
222
|
+
self._cache_process = None
|
|
223
|
+
# Clean up cache file
|
|
224
|
+
if self._cache_file:
|
|
225
|
+
try:
|
|
226
|
+
os.unlink(self._cache_file)
|
|
227
|
+
except OSError:
|
|
228
|
+
pass
|
|
229
|
+
self._cache_file = None
|
|
230
|
+
self._paused = False
|
|
231
|
+
self._play_start = None
|
|
232
|
+
self._seek_offset = 0
|
|
233
|
+
# Clean up mpv socket
|
|
234
|
+
if self._ipc_path:
|
|
235
|
+
try:
|
|
236
|
+
os.unlink(self._ipc_path)
|
|
237
|
+
except OSError:
|
|
238
|
+
pass
|
|
239
|
+
self._ipc_path = None
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def is_playing(self) -> bool:
|
|
243
|
+
with self._lock:
|
|
244
|
+
if self._paused:
|
|
245
|
+
return True # Paused but track is active
|
|
246
|
+
return self._process is not None and self._process.poll() is None
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def is_paused(self) -> bool:
|
|
250
|
+
with self._lock:
|
|
251
|
+
return self._paused
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class HeadlessTidalPlayer:
|
|
255
|
+
"""Headless TIDAL player - no desktop app required."""
|
|
256
|
+
|
|
257
|
+
MODE_PLAYER = "player"
|
|
258
|
+
MODE_SEARCH = "search"
|
|
259
|
+
MODE_BROWSE = "browse"
|
|
260
|
+
MODE_QUEUE = "queue"
|
|
261
|
+
MODE_PLAYLISTS = "playlists"
|
|
262
|
+
|
|
263
|
+
def __init__(self, quality: str = "HIGH"):
|
|
264
|
+
self.console = Console()
|
|
265
|
+
self.session = tidalapi.Session()
|
|
266
|
+
self.audio = None # set after finding player
|
|
267
|
+
self.running = True
|
|
268
|
+
self._mode = self.MODE_PLAYER
|
|
269
|
+
# Playback state
|
|
270
|
+
self._current_track: Optional[tidalapi.Track] = None
|
|
271
|
+
self._queue: list = []
|
|
272
|
+
self._queue_index: int = -1
|
|
273
|
+
self._playing = False
|
|
274
|
+
self._play_start_time: Optional[float] = None
|
|
275
|
+
self._play_offset: float = 0
|
|
276
|
+
self._liked_ids: set = set()
|
|
277
|
+
# Search state
|
|
278
|
+
self._search_query = ""
|
|
279
|
+
self._search_results = []
|
|
280
|
+
self._search_cursor = 0
|
|
281
|
+
self._search_loading = False
|
|
282
|
+
self._search_message = ""
|
|
283
|
+
self._search_history: list = [] # recent searches, newest first
|
|
284
|
+
# Browse state
|
|
285
|
+
self._browse_title = ""
|
|
286
|
+
self._browse_tracks = []
|
|
287
|
+
self._browse_cursor = 0
|
|
288
|
+
self._browse_loading = False
|
|
289
|
+
self._browse_message = ""
|
|
290
|
+
# Queue view state
|
|
291
|
+
self._queue_cursor = 0
|
|
292
|
+
# Playlists state
|
|
293
|
+
self._playlists: list = []
|
|
294
|
+
self._playlists_cursor = 0
|
|
295
|
+
self._playlists_loading = False
|
|
296
|
+
self._playlists_message = ""
|
|
297
|
+
# Quit confirmation
|
|
298
|
+
self._quit_pending = False
|
|
299
|
+
# Logout confirmation
|
|
300
|
+
self._logout_pending = False
|
|
301
|
+
# Mini player mode
|
|
302
|
+
self._mini_player = False
|
|
303
|
+
# Show more controls
|
|
304
|
+
self._show_more = False
|
|
305
|
+
# Space-held guard: prevents toggle-looping from key repeat
|
|
306
|
+
self._space_held = False
|
|
307
|
+
# User display name (set after login)
|
|
308
|
+
self._user_display_name = ""
|
|
309
|
+
# Navigation
|
|
310
|
+
self._nav_history = []
|
|
311
|
+
# Quality
|
|
312
|
+
quality_map = {
|
|
313
|
+
"LOW": tidalapi.Quality.low_320k,
|
|
314
|
+
"HIGH": tidalapi.Quality.high_lossless,
|
|
315
|
+
"LOSSLESS": tidalapi.Quality.high_lossless,
|
|
316
|
+
"HIRES": tidalapi.Quality.hi_res_lossless,
|
|
317
|
+
}
|
|
318
|
+
self.session.audio_quality = quality_map.get(quality.upper(), tidalapi.Quality.high_lossless)
|
|
319
|
+
|
|
320
|
+
def _get_user_display_name(self) -> str:
|
|
321
|
+
"""Get a display name for the logged-in user."""
|
|
322
|
+
u = self.session.user
|
|
323
|
+
if not u:
|
|
324
|
+
return "Unknown"
|
|
325
|
+
# LoggedInUser has username; FetchedUser has first_name/last_name
|
|
326
|
+
first = getattr(u, "first_name", None)
|
|
327
|
+
last = getattr(u, "last_name", None)
|
|
328
|
+
if first and last:
|
|
329
|
+
return f"{first} {last}"
|
|
330
|
+
if first:
|
|
331
|
+
return first
|
|
332
|
+
username = getattr(u, "username", None)
|
|
333
|
+
if username:
|
|
334
|
+
return username
|
|
335
|
+
email = getattr(u, "email", None)
|
|
336
|
+
if email:
|
|
337
|
+
return email
|
|
338
|
+
return f"User {u.id}"
|
|
339
|
+
|
|
340
|
+
def _login(self) -> bool:
|
|
341
|
+
"""Login to TIDAL via OAuth device flow."""
|
|
342
|
+
# Try loading existing session from secure storage
|
|
343
|
+
data = load_tokens()
|
|
344
|
+
if data:
|
|
345
|
+
try:
|
|
346
|
+
self.session.load_oauth_session(
|
|
347
|
+
data["token_type"],
|
|
348
|
+
data["access_token"],
|
|
349
|
+
data.get("refresh_token"),
|
|
350
|
+
data.get("expiry_time"),
|
|
351
|
+
)
|
|
352
|
+
if self.session.check_login():
|
|
353
|
+
self._user_display_name = self._get_user_display_name()
|
|
354
|
+
return True
|
|
355
|
+
except Exception as e:
|
|
356
|
+
logger.debug("Failed to load saved session: %s", e)
|
|
357
|
+
|
|
358
|
+
# Fresh login
|
|
359
|
+
self.console.print("[cyan]Starting TIDAL login...[/cyan]")
|
|
360
|
+
login, future = self.session.login_oauth()
|
|
361
|
+
self.console.print(f"\n[bold yellow]Open this URL to login:[/bold yellow]")
|
|
362
|
+
self.console.print(f"[bold white]https://{login.verification_uri_complete}[/bold white]\n")
|
|
363
|
+
self.console.print(f"[dim]Or go to [bold]{login.verification_uri}[/bold] and enter code: [bold]{login.user_code}[/bold][/dim]\n")
|
|
364
|
+
self.console.print("[dim]Waiting for authorization...[/dim]")
|
|
365
|
+
|
|
366
|
+
future.result()
|
|
367
|
+
|
|
368
|
+
if self.session.check_login():
|
|
369
|
+
# Save session to secure storage (keychain or chmod-600 file)
|
|
370
|
+
try:
|
|
371
|
+
data = {
|
|
372
|
+
"token_type": self.session.token_type,
|
|
373
|
+
"access_token": self.session.access_token,
|
|
374
|
+
"refresh_token": self.session.refresh_token,
|
|
375
|
+
"expiry_time": self.session.expiry_time.isoformat() if self.session.expiry_time else None,
|
|
376
|
+
}
|
|
377
|
+
save_tokens(data)
|
|
378
|
+
except Exception as e:
|
|
379
|
+
logger.warning("Failed to save session: %s", e)
|
|
380
|
+
self._user_display_name = self._get_user_display_name()
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
self.console.print("[red]Login failed.[/red]")
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
def _logout(self):
|
|
387
|
+
"""Log out and clear saved tokens."""
|
|
388
|
+
from ticli.utils.credential_store import delete_tokens
|
|
389
|
+
delete_tokens()
|
|
390
|
+
self.audio.stop()
|
|
391
|
+
self._playing = False
|
|
392
|
+
self._current_track = None
|
|
393
|
+
self._queue = []
|
|
394
|
+
self._queue_index = -1
|
|
395
|
+
self.running = False
|
|
396
|
+
self.console.print("[yellow]Logged out. Tokens cleared.[/yellow]")
|
|
397
|
+
|
|
398
|
+
def _load_favorites(self):
|
|
399
|
+
"""Load liked track IDs in background."""
|
|
400
|
+
def _run():
|
|
401
|
+
try:
|
|
402
|
+
favs = self.session.user.favorites.tracks(limit=999)
|
|
403
|
+
self._liked_ids = {t.id for t in favs}
|
|
404
|
+
except Exception:
|
|
405
|
+
pass
|
|
406
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
407
|
+
|
|
408
|
+
def _save_state(self):
|
|
409
|
+
"""Save queue and playback state to disk for next session."""
|
|
410
|
+
try:
|
|
411
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
412
|
+
state = {
|
|
413
|
+
"track_ids": [t.id for t in self._queue],
|
|
414
|
+
"queue_index": self._queue_index,
|
|
415
|
+
"position": self._get_position(),
|
|
416
|
+
"search_history": self._search_history[:20],
|
|
417
|
+
}
|
|
418
|
+
STATE_FILE.write_text(json.dumps(state))
|
|
419
|
+
os.chmod(STATE_FILE, 0o600)
|
|
420
|
+
except Exception as e:
|
|
421
|
+
logger.debug("Failed to save player state: %s", e)
|
|
422
|
+
|
|
423
|
+
def _restore_state(self):
|
|
424
|
+
"""Restore queue and search history from previous session."""
|
|
425
|
+
if not STATE_FILE.exists():
|
|
426
|
+
return
|
|
427
|
+
try:
|
|
428
|
+
data = json.loads(STATE_FILE.read_text())
|
|
429
|
+
except (json.JSONDecodeError, OSError):
|
|
430
|
+
return
|
|
431
|
+
self._search_history = data.get("search_history", [])[:20]
|
|
432
|
+
track_ids = data.get("track_ids", [])
|
|
433
|
+
queue_index = data.get("queue_index", 0)
|
|
434
|
+
if not track_ids:
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
def _run():
|
|
438
|
+
try:
|
|
439
|
+
tracks = []
|
|
440
|
+
for tid in track_ids:
|
|
441
|
+
try:
|
|
442
|
+
t = self.session.track(tid)
|
|
443
|
+
if t:
|
|
444
|
+
tracks.append(t)
|
|
445
|
+
except Exception:
|
|
446
|
+
pass
|
|
447
|
+
if tracks:
|
|
448
|
+
self._queue = tracks
|
|
449
|
+
idx = min(queue_index, len(tracks) - 1)
|
|
450
|
+
self._queue_index = idx
|
|
451
|
+
self._current_track = tracks[idx]
|
|
452
|
+
except Exception as e:
|
|
453
|
+
logger.debug("Failed to restore player state: %s", e)
|
|
454
|
+
|
|
455
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
456
|
+
|
|
457
|
+
def _play_track(self, track: tidalapi.Track):
|
|
458
|
+
"""Play a track via the audio player."""
|
|
459
|
+
try:
|
|
460
|
+
url = track.get_url()
|
|
461
|
+
self.audio.play_url(url)
|
|
462
|
+
self._current_track = track
|
|
463
|
+
self._playing = True
|
|
464
|
+
self._play_start_time = time.time()
|
|
465
|
+
self._play_offset = 0
|
|
466
|
+
except Exception:
|
|
467
|
+
self._playing = False
|
|
468
|
+
|
|
469
|
+
def _play_queue_index(self, index: int):
|
|
470
|
+
"""Play track at queue index."""
|
|
471
|
+
if 0 <= index < len(self._queue):
|
|
472
|
+
self._queue_index = index
|
|
473
|
+
self._play_track(self._queue[index])
|
|
474
|
+
|
|
475
|
+
def _next_track(self):
|
|
476
|
+
"""Skip to next track in queue."""
|
|
477
|
+
if self._queue and self._queue_index < len(self._queue) - 1:
|
|
478
|
+
self._play_queue_index(self._queue_index + 1)
|
|
479
|
+
|
|
480
|
+
def _prev_track(self):
|
|
481
|
+
"""Go to previous track in queue."""
|
|
482
|
+
if self._queue and self._queue_index > 0:
|
|
483
|
+
self._play_queue_index(self._queue_index - 1)
|
|
484
|
+
|
|
485
|
+
def _toggle_play(self):
|
|
486
|
+
"""Toggle play/pause — pauses in place, resumes from same position."""
|
|
487
|
+
if self._playing:
|
|
488
|
+
self.audio.pause()
|
|
489
|
+
self._playing = False
|
|
490
|
+
if self._play_start_time:
|
|
491
|
+
self._play_offset += time.time() - self._play_start_time
|
|
492
|
+
self._play_start_time = None
|
|
493
|
+
else:
|
|
494
|
+
if self._current_track and self.audio and self.audio.is_paused:
|
|
495
|
+
# Resume from paused position
|
|
496
|
+
self.audio.resume()
|
|
497
|
+
self._playing = True
|
|
498
|
+
self._play_start_time = time.time()
|
|
499
|
+
elif self._current_track:
|
|
500
|
+
# No paused process — start fresh
|
|
501
|
+
self._play_track(self._current_track)
|
|
502
|
+
|
|
503
|
+
def _toggle_like(self):
|
|
504
|
+
"""Toggle like on current track."""
|
|
505
|
+
if not self._current_track:
|
|
506
|
+
return
|
|
507
|
+
tid = self._current_track.id
|
|
508
|
+
def _run():
|
|
509
|
+
try:
|
|
510
|
+
if tid in self._liked_ids:
|
|
511
|
+
self.session.user.favorites.remove_track(tid)
|
|
512
|
+
self._liked_ids.discard(tid)
|
|
513
|
+
else:
|
|
514
|
+
self.session.user.favorites.add_track(tid)
|
|
515
|
+
self._liked_ids.add(tid)
|
|
516
|
+
except Exception:
|
|
517
|
+
pass
|
|
518
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
519
|
+
|
|
520
|
+
def _start_track_radio(self):
|
|
521
|
+
"""Start radio based on current track."""
|
|
522
|
+
if not self._current_track:
|
|
523
|
+
return
|
|
524
|
+
track_id = self._current_track.id
|
|
525
|
+
def _run():
|
|
526
|
+
try:
|
|
527
|
+
radio_tracks = self._current_track.get_track_radio(limit=25)
|
|
528
|
+
if radio_tracks:
|
|
529
|
+
self._queue = radio_tracks
|
|
530
|
+
self._queue_index = 0
|
|
531
|
+
self._play_track(self._queue[0])
|
|
532
|
+
except Exception:
|
|
533
|
+
pass
|
|
534
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
535
|
+
|
|
536
|
+
def _monitor_playback(self):
|
|
537
|
+
"""Background thread to auto-advance when track ends."""
|
|
538
|
+
while self.running:
|
|
539
|
+
if self._playing and self.audio and not self.audio.is_paused and not self.audio.is_playing:
|
|
540
|
+
# Track ended (not paused), play next
|
|
541
|
+
if self._queue and self._queue_index < len(self._queue) - 1:
|
|
542
|
+
self._play_queue_index(self._queue_index + 1)
|
|
543
|
+
else:
|
|
544
|
+
self._playing = False
|
|
545
|
+
self._play_start_time = None
|
|
546
|
+
time.sleep(0.5)
|
|
547
|
+
|
|
548
|
+
# ── Display builders ──
|
|
549
|
+
|
|
550
|
+
def _get_position(self) -> float:
|
|
551
|
+
if self._play_start_time and self._playing:
|
|
552
|
+
return self._play_offset + (time.time() - self._play_start_time)
|
|
553
|
+
return self._play_offset
|
|
554
|
+
|
|
555
|
+
def _build_player_display(self) -> Text:
|
|
556
|
+
s = self._current_track
|
|
557
|
+
title = s.name if s else "No track"
|
|
558
|
+
artist = ", ".join(a.name for a in s.artists) if s and s.artists else ""
|
|
559
|
+
album = s.album.name if s and s.album else ""
|
|
560
|
+
duration = s.duration if s else 0
|
|
561
|
+
position = self._get_position() if s else 0
|
|
562
|
+
liked = (s.id in self._liked_ids) if s else None
|
|
563
|
+
|
|
564
|
+
state_icon = "\u25b6" if self._playing else "\u23f8"
|
|
565
|
+
|
|
566
|
+
# Mini player: single compact line
|
|
567
|
+
if self._mini_player:
|
|
568
|
+
content = Text()
|
|
569
|
+
content.append(f" {state_icon} ", style="bold cyan")
|
|
570
|
+
if liked is True:
|
|
571
|
+
content.append("\u2665 ", style="bold red")
|
|
572
|
+
content.append(title, style="bold white")
|
|
573
|
+
if artist:
|
|
574
|
+
content.append(f" \u2022 {artist}", style="dim white")
|
|
575
|
+
pos_str = format_time(position)
|
|
576
|
+
dur_str = format_time(duration) if duration > 0 else "--:--"
|
|
577
|
+
content.append(f" {pos_str}/{dur_str}", style="cyan")
|
|
578
|
+
if self._queue:
|
|
579
|
+
content.append(f" [{self._queue_index + 1}/{len(self._queue)}]", style="dim")
|
|
580
|
+
return content
|
|
581
|
+
|
|
582
|
+
# Full player display
|
|
583
|
+
track_line = Text()
|
|
584
|
+
track_line.append(f" {state_icon} ", style="bold cyan")
|
|
585
|
+
if liked is True:
|
|
586
|
+
track_line.append("\u2665 ", style="bold red")
|
|
587
|
+
elif liked is False:
|
|
588
|
+
track_line.append("\u2661 ", style="dim")
|
|
589
|
+
track_line.append(title, style="bold white")
|
|
590
|
+
if artist:
|
|
591
|
+
track_line.append(f" {artist}", style="dim white")
|
|
592
|
+
|
|
593
|
+
album_line = Text()
|
|
594
|
+
if album:
|
|
595
|
+
album_line.append(f" {album}", style="dim")
|
|
596
|
+
|
|
597
|
+
progress_pct = (position / duration * 100) if duration > 0 else 0
|
|
598
|
+
pos_str = format_time(position)
|
|
599
|
+
dur_str = format_time(duration) if duration > 0 else "--:--"
|
|
600
|
+
|
|
601
|
+
bar_width = 50
|
|
602
|
+
filled = int(bar_width * min(progress_pct, 100) / 100)
|
|
603
|
+
if duration > 0:
|
|
604
|
+
bar = "\u2501" * filled + "\u2578" + "\u2500" * max(0, bar_width - filled - 1)
|
|
605
|
+
else:
|
|
606
|
+
bar = "\u2500" * bar_width
|
|
607
|
+
progress_line = Text()
|
|
608
|
+
progress_line.append(f" {pos_str} ", style="cyan")
|
|
609
|
+
progress_line.append(bar, style="bold cyan" if self._playing else "dim")
|
|
610
|
+
progress_line.append(f" {dur_str}", style="cyan")
|
|
611
|
+
|
|
612
|
+
# Queue info
|
|
613
|
+
status_line = Text()
|
|
614
|
+
if self._queue:
|
|
615
|
+
status_line.append(f" Queue: {self._queue_index + 1}/{len(self._queue)}", style="dim")
|
|
616
|
+
quality_label = {
|
|
617
|
+
tidalapi.Quality.low_320k: "HIGH 320k",
|
|
618
|
+
tidalapi.Quality.high_lossless: "LOSSLESS",
|
|
619
|
+
tidalapi.Quality.hi_res_lossless: "HI-RES",
|
|
620
|
+
}.get(self.session.audio_quality, "")
|
|
621
|
+
if quality_label:
|
|
622
|
+
status_line.append(f" {quality_label}", style="dim cyan")
|
|
623
|
+
|
|
624
|
+
# Next track preview (only in player mode)
|
|
625
|
+
up_next = Text()
|
|
626
|
+
if self._mode == self.MODE_PLAYER and self._queue and self._queue_index < len(self._queue) - 1:
|
|
627
|
+
t = self._queue[self._queue_index + 1]
|
|
628
|
+
t_name = t.name if hasattr(t, "name") else "?"
|
|
629
|
+
t_artist = t.artists[0].name if hasattr(t, "artists") and t.artists else ""
|
|
630
|
+
up_next.append("\n Next: ", style="dim")
|
|
631
|
+
up_next.append(t_name, style="dim white")
|
|
632
|
+
if t_artist:
|
|
633
|
+
up_next.append(f" \u2022 {t_artist}", style="dim")
|
|
634
|
+
|
|
635
|
+
content = Text()
|
|
636
|
+
content.append_text(track_line)
|
|
637
|
+
content.append("\n")
|
|
638
|
+
content.append_text(album_line)
|
|
639
|
+
content.append("\n")
|
|
640
|
+
content.append_text(progress_line)
|
|
641
|
+
content.append("\n")
|
|
642
|
+
content.append_text(status_line)
|
|
643
|
+
content.append_text(up_next)
|
|
644
|
+
return content
|
|
645
|
+
|
|
646
|
+
def _build_search_display(self) -> Text:
|
|
647
|
+
content = Text()
|
|
648
|
+
content.append(" Search: ", style="bold yellow")
|
|
649
|
+
content.append(self._search_query, style="white")
|
|
650
|
+
content.append("\u2588", style="bold white")
|
|
651
|
+
|
|
652
|
+
if self._search_loading:
|
|
653
|
+
content.append("\n\n Searching...", style="dim yellow")
|
|
654
|
+
elif self._search_message:
|
|
655
|
+
content.append(f"\n\n {self._search_message}", style="dim green")
|
|
656
|
+
elif self._search_results:
|
|
657
|
+
total = len(self._search_results)
|
|
658
|
+
page_start = (self._search_cursor // PAGE_SIZE) * PAGE_SIZE
|
|
659
|
+
page_end = min(page_start + PAGE_SIZE, total)
|
|
660
|
+
content.append("\n", style="")
|
|
661
|
+
for i in range(page_start, page_end):
|
|
662
|
+
item = self._search_results[i]
|
|
663
|
+
content.append("\n")
|
|
664
|
+
if i == self._search_cursor:
|
|
665
|
+
content.append(" \u25b8 ", style="bold cyan")
|
|
666
|
+
else:
|
|
667
|
+
content.append(" ", style="")
|
|
668
|
+
type_styles = {"track": "bold green", "album": "bold magenta", "artist": "bold yellow"}
|
|
669
|
+
badge = item["type"].upper()
|
|
670
|
+
content.append(f"[{badge}]", style=type_styles.get(item["type"], "dim"))
|
|
671
|
+
content.append(f" {item['name']}", style="bold white" if i == self._search_cursor else "white")
|
|
672
|
+
if item.get("artist"):
|
|
673
|
+
content.append(f" {item['artist']}", style="dim")
|
|
674
|
+
if total > PAGE_SIZE:
|
|
675
|
+
page_num = (self._search_cursor // PAGE_SIZE) + 1
|
|
676
|
+
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE
|
|
677
|
+
content.append(f"\n\n Page {page_num}/{total_pages}", style="dim")
|
|
678
|
+
content.append(f" ({total} results)", style="dim")
|
|
679
|
+
elif self._search_query:
|
|
680
|
+
content.append("\n\n Press Enter to search", style="dim")
|
|
681
|
+
|
|
682
|
+
return content
|
|
683
|
+
|
|
684
|
+
def _build_browse_display(self) -> Text:
|
|
685
|
+
content = Text()
|
|
686
|
+
content.append(f" {self._browse_title}", style="bold magenta")
|
|
687
|
+
|
|
688
|
+
if self._browse_loading:
|
|
689
|
+
content.append("\n\n Loading...", style="dim yellow")
|
|
690
|
+
elif self._browse_message:
|
|
691
|
+
content.append(f"\n\n {self._browse_message}", style="dim green")
|
|
692
|
+
elif self._browse_tracks:
|
|
693
|
+
total = len(self._browse_tracks)
|
|
694
|
+
# browse_cursor -1 = "Play All" row, 0..N-1 = tracks
|
|
695
|
+
page_start = max(0, ((self._browse_cursor - 1) // PAGE_SIZE) * PAGE_SIZE) if self._browse_cursor > 0 else 0
|
|
696
|
+
page_end = min(page_start + PAGE_SIZE, total)
|
|
697
|
+
content.append(f" ({total} tracks)", style="dim")
|
|
698
|
+
content.append("\n", style="")
|
|
699
|
+
|
|
700
|
+
# "Play All" row (always visible when on first page)
|
|
701
|
+
if self._browse_cursor <= 0 or page_start == 0:
|
|
702
|
+
content.append("\n")
|
|
703
|
+
if self._browse_cursor == -1:
|
|
704
|
+
content.append(" \u25b8 ", style="bold cyan")
|
|
705
|
+
content.append("\u25b6 Play All", style="bold cyan")
|
|
706
|
+
else:
|
|
707
|
+
content.append(" ", style="")
|
|
708
|
+
content.append("\u25b6 Play All", style="dim green")
|
|
709
|
+
|
|
710
|
+
for i in range(page_start, page_end):
|
|
711
|
+
track = self._browse_tracks[i]
|
|
712
|
+
content.append("\n")
|
|
713
|
+
if i == self._browse_cursor:
|
|
714
|
+
content.append(" \u25b8 ", style="bold cyan")
|
|
715
|
+
else:
|
|
716
|
+
content.append(" ", style="")
|
|
717
|
+
content.append(f"{i+1:>2}. ", style="dim")
|
|
718
|
+
content.append(track.name, style="bold white" if i == self._browse_cursor else "white")
|
|
719
|
+
if track.artists:
|
|
720
|
+
content.append(f" {track.artists[0].name}", style="dim")
|
|
721
|
+
content.append(f" {format_time(track.duration)}", style="dim cyan")
|
|
722
|
+
if total > PAGE_SIZE:
|
|
723
|
+
page_num = (max(0, self._browse_cursor - 1) // PAGE_SIZE) + 1 if self._browse_cursor > 0 else 1
|
|
724
|
+
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE
|
|
725
|
+
content.append(f"\n\n Page {page_num}/{total_pages}", style="dim")
|
|
726
|
+
|
|
727
|
+
return content
|
|
728
|
+
|
|
729
|
+
def _build_queue_display(self) -> Text:
|
|
730
|
+
content = Text()
|
|
731
|
+
content.append(" Queue", style="bold yellow")
|
|
732
|
+
if not self._queue:
|
|
733
|
+
content.append("\n\n Queue is empty", style="dim")
|
|
734
|
+
else:
|
|
735
|
+
total = len(self._queue)
|
|
736
|
+
page_start = (self._queue_cursor // PAGE_SIZE) * PAGE_SIZE
|
|
737
|
+
page_end = min(page_start + PAGE_SIZE, total)
|
|
738
|
+
content.append(f" ({total} tracks)", style="dim")
|
|
739
|
+
content.append("\n", style="")
|
|
740
|
+
for i in range(page_start, page_end):
|
|
741
|
+
track = self._queue[i]
|
|
742
|
+
content.append("\n")
|
|
743
|
+
is_current = (i == self._queue_index)
|
|
744
|
+
is_cursor = (i == self._queue_cursor)
|
|
745
|
+
if is_cursor:
|
|
746
|
+
content.append(" \u25b8 ", style="bold cyan")
|
|
747
|
+
elif is_current:
|
|
748
|
+
content.append(" \u266b ", style="bold cyan")
|
|
749
|
+
else:
|
|
750
|
+
content.append(" ", style="")
|
|
751
|
+
t_name = track.name if hasattr(track, "name") else "?"
|
|
752
|
+
t_artist = track.artists[0].name if hasattr(track, "artists") and track.artists else ""
|
|
753
|
+
t_dur = format_time(track.duration) if hasattr(track, "duration") else ""
|
|
754
|
+
name_style = "bold cyan" if is_current else ("bold white" if is_cursor else "white")
|
|
755
|
+
content.append(f"{i + 1:>2}. ", style="dim")
|
|
756
|
+
content.append(t_name, style=name_style)
|
|
757
|
+
if is_current:
|
|
758
|
+
content.append(" \u25b6" if self._playing else " \u23f8", style="bold cyan")
|
|
759
|
+
if t_artist:
|
|
760
|
+
content.append(f" {t_artist}", style="dim")
|
|
761
|
+
if t_dur:
|
|
762
|
+
content.append(f" {t_dur}", style="dim cyan")
|
|
763
|
+
if total > PAGE_SIZE:
|
|
764
|
+
page_num = (self._queue_cursor // PAGE_SIZE) + 1
|
|
765
|
+
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE
|
|
766
|
+
content.append(f"\n\n Page {page_num}/{total_pages}", style="dim")
|
|
767
|
+
return content
|
|
768
|
+
|
|
769
|
+
def _build_playlists_display(self) -> Text:
|
|
770
|
+
content = Text()
|
|
771
|
+
content.append(" Your Playlists", style="bold magenta")
|
|
772
|
+
|
|
773
|
+
if self._playlists_loading:
|
|
774
|
+
content.append("\n\n Loading playlists...", style="dim yellow")
|
|
775
|
+
elif self._playlists_message:
|
|
776
|
+
content.append(f"\n\n {self._playlists_message}", style="dim green")
|
|
777
|
+
elif self._playlists:
|
|
778
|
+
total = len(self._playlists)
|
|
779
|
+
page_start = (self._playlists_cursor // PAGE_SIZE) * PAGE_SIZE
|
|
780
|
+
page_end = min(page_start + PAGE_SIZE, total)
|
|
781
|
+
content.append(f" ({total})", style="dim")
|
|
782
|
+
content.append("\n", style="")
|
|
783
|
+
for i in range(page_start, page_end):
|
|
784
|
+
pl = self._playlists[i]
|
|
785
|
+
content.append("\n")
|
|
786
|
+
if i == self._playlists_cursor:
|
|
787
|
+
content.append(" \u25b8 ", style="bold cyan")
|
|
788
|
+
else:
|
|
789
|
+
content.append(" ", style="")
|
|
790
|
+
pl_name = pl.name if hasattr(pl, "name") else "?"
|
|
791
|
+
num_tracks = pl.num_tracks if hasattr(pl, "num_tracks") else ""
|
|
792
|
+
creator = ""
|
|
793
|
+
if hasattr(pl, "creator") and pl.creator:
|
|
794
|
+
creator = pl.creator.name if hasattr(pl.creator, "name") else ""
|
|
795
|
+
content.append(pl_name, style="bold white" if i == self._playlists_cursor else "white")
|
|
796
|
+
if num_tracks:
|
|
797
|
+
content.append(f" {num_tracks} tracks", style="dim cyan")
|
|
798
|
+
if creator:
|
|
799
|
+
content.append(f" by {creator}", style="dim")
|
|
800
|
+
if total > PAGE_SIZE:
|
|
801
|
+
page_num = (self._playlists_cursor // PAGE_SIZE) + 1
|
|
802
|
+
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE
|
|
803
|
+
content.append(f"\n\n Page {page_num}/{total_pages}", style="dim")
|
|
804
|
+
else:
|
|
805
|
+
content.append("\n\n No playlists found", style="dim")
|
|
806
|
+
|
|
807
|
+
return content
|
|
808
|
+
|
|
809
|
+
def _build_quit_confirm(self) -> Text:
|
|
810
|
+
content = Text()
|
|
811
|
+
content.append("\n Quit player? ", style="bold yellow")
|
|
812
|
+
content.append("Press ", style="dim")
|
|
813
|
+
content.append("Esc", style="bold")
|
|
814
|
+
content.append(" again to confirm, any other key to cancel", style="dim")
|
|
815
|
+
return content
|
|
816
|
+
|
|
817
|
+
def _build_logout_confirm(self) -> Text:
|
|
818
|
+
content = Text()
|
|
819
|
+
content.append("\n Log out and clear saved tokens? ", style="bold yellow")
|
|
820
|
+
content.append("Press ", style="dim")
|
|
821
|
+
content.append("y", style="bold")
|
|
822
|
+
content.append(" to confirm, any other key to cancel", style="dim")
|
|
823
|
+
return content
|
|
824
|
+
|
|
825
|
+
def _build_display(self) -> Panel:
|
|
826
|
+
player = self._build_player_display()
|
|
827
|
+
|
|
828
|
+
controls = Text()
|
|
829
|
+
if self._mode == self.MODE_PLAYER:
|
|
830
|
+
controls.append(" [space]", style="bold")
|
|
831
|
+
controls.append(" play/pause ", style="dim")
|
|
832
|
+
controls.append("[\u2190/\u2192]", style="bold")
|
|
833
|
+
controls.append(" prev/next ", style="dim")
|
|
834
|
+
controls.append("[s]", style="bold")
|
|
835
|
+
controls.append(" search ", style="dim")
|
|
836
|
+
controls.append("[t]", style="bold")
|
|
837
|
+
controls.append(" tiny ", style="dim")
|
|
838
|
+
controls.append("[m]", style="bold")
|
|
839
|
+
controls.append(" more", style="dim")
|
|
840
|
+
if self._show_more:
|
|
841
|
+
controls.append("\n [l]", style="bold")
|
|
842
|
+
controls.append(" like ", style="dim")
|
|
843
|
+
controls.append("[r]", style="bold")
|
|
844
|
+
controls.append(" radio ", style="dim")
|
|
845
|
+
controls.append("[q]", style="bold")
|
|
846
|
+
controls.append(" queue ", style="dim")
|
|
847
|
+
controls.append("[p]", style="bold")
|
|
848
|
+
controls.append(" playlists ", style="dim")
|
|
849
|
+
controls.append("[o]", style="bold")
|
|
850
|
+
controls.append(" logout ", style="dim")
|
|
851
|
+
controls.append("[Esc]", style="bold")
|
|
852
|
+
controls.append(" quit", style="dim")
|
|
853
|
+
if self._user_display_name:
|
|
854
|
+
controls.append(f"\n Logged in as ", style="dim")
|
|
855
|
+
controls.append(self._user_display_name, style="bold")
|
|
856
|
+
elif self._mode == self.MODE_SEARCH:
|
|
857
|
+
controls.append(" [Enter/\u2192]", style="bold")
|
|
858
|
+
controls.append(" search/open ", style="dim")
|
|
859
|
+
controls.append("[\u2191/\u2193]", style="bold")
|
|
860
|
+
controls.append(" navigate ", style="dim")
|
|
861
|
+
if self._search_results:
|
|
862
|
+
controls.append("[Space]", style="bold")
|
|
863
|
+
controls.append(" pause/play ", style="dim")
|
|
864
|
+
controls.append("[\u2190/Esc]", style="bold")
|
|
865
|
+
controls.append(" back ", style="dim")
|
|
866
|
+
controls.append("[Bksp]", style="bold")
|
|
867
|
+
controls.append(" delete", style="dim")
|
|
868
|
+
elif self._mode == self.MODE_BROWSE:
|
|
869
|
+
controls.append(" [Enter/\u2192]", style="bold")
|
|
870
|
+
controls.append(" play track ", style="dim")
|
|
871
|
+
controls.append("[\u2191/\u2193]", style="bold")
|
|
872
|
+
controls.append(" navigate ", style="dim")
|
|
873
|
+
controls.append("[Space]", style="bold")
|
|
874
|
+
controls.append(" pause/play ", style="dim")
|
|
875
|
+
controls.append("[a]", style="bold")
|
|
876
|
+
controls.append(" play all ", style="dim")
|
|
877
|
+
controls.append("[\u2190/Esc]", style="bold")
|
|
878
|
+
controls.append(" back", style="dim")
|
|
879
|
+
elif self._mode == self.MODE_QUEUE:
|
|
880
|
+
controls.append(" [Enter]", style="bold")
|
|
881
|
+
controls.append(" play ", style="dim")
|
|
882
|
+
controls.append("[\u2191/\u2193]", style="bold")
|
|
883
|
+
controls.append(" navigate ", style="dim")
|
|
884
|
+
controls.append("[Space]", style="bold")
|
|
885
|
+
controls.append(" pause/play ", style="dim")
|
|
886
|
+
controls.append("[x]", style="bold")
|
|
887
|
+
controls.append(" remove ", style="dim")
|
|
888
|
+
controls.append("[\u2190/Esc]", style="bold")
|
|
889
|
+
controls.append(" back", style="dim")
|
|
890
|
+
elif self._mode == self.MODE_PLAYLISTS:
|
|
891
|
+
controls.append(" [Enter/\u2192]", style="bold")
|
|
892
|
+
controls.append(" open ", style="dim")
|
|
893
|
+
controls.append("[\u2191/\u2193]", style="bold")
|
|
894
|
+
controls.append(" navigate ", style="dim")
|
|
895
|
+
controls.append("[Space]", style="bold")
|
|
896
|
+
controls.append(" pause/play ", style="dim")
|
|
897
|
+
controls.append("[\u2190/Esc]", style="bold")
|
|
898
|
+
controls.append(" back", style="dim")
|
|
899
|
+
|
|
900
|
+
content = Text()
|
|
901
|
+
content.append_text(player)
|
|
902
|
+
|
|
903
|
+
if self._mini_player:
|
|
904
|
+
# Tiny mode: just the player line, no controls
|
|
905
|
+
if self._quit_pending:
|
|
906
|
+
content.append_text(self._build_quit_confirm())
|
|
907
|
+
return Panel(
|
|
908
|
+
content,
|
|
909
|
+
title="[bold cyan]Ticli[/bold cyan]",
|
|
910
|
+
border_style="cyan",
|
|
911
|
+
padding=(0, 1),
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
if self._mode != self.MODE_PLAYER:
|
|
915
|
+
content.append("\n\n")
|
|
916
|
+
content.append(" " + "\u2500" * 56, style="dim")
|
|
917
|
+
content.append("\n\n")
|
|
918
|
+
if self._mode == self.MODE_SEARCH:
|
|
919
|
+
content.append_text(self._build_search_display())
|
|
920
|
+
elif self._mode == self.MODE_BROWSE:
|
|
921
|
+
content.append_text(self._build_browse_display())
|
|
922
|
+
elif self._mode == self.MODE_QUEUE:
|
|
923
|
+
content.append_text(self._build_queue_display())
|
|
924
|
+
elif self._mode == self.MODE_PLAYLISTS:
|
|
925
|
+
content.append_text(self._build_playlists_display())
|
|
926
|
+
|
|
927
|
+
if self._quit_pending:
|
|
928
|
+
content.append_text(self._build_quit_confirm())
|
|
929
|
+
elif self._logout_pending:
|
|
930
|
+
content.append_text(self._build_logout_confirm())
|
|
931
|
+
|
|
932
|
+
content.append("\n\n")
|
|
933
|
+
content.append_text(controls)
|
|
934
|
+
|
|
935
|
+
return Panel(
|
|
936
|
+
content,
|
|
937
|
+
title="[bold cyan]Ticli[/bold cyan]",
|
|
938
|
+
border_style="cyan",
|
|
939
|
+
padding=(1, 2),
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
# ── Actions ──
|
|
943
|
+
|
|
944
|
+
def _push_nav(self):
|
|
945
|
+
if self._mode == self.MODE_SEARCH:
|
|
946
|
+
self._nav_history.append({
|
|
947
|
+
"mode": self.MODE_SEARCH,
|
|
948
|
+
"query": self._search_query,
|
|
949
|
+
"results": list(self._search_results),
|
|
950
|
+
"cursor": self._search_cursor,
|
|
951
|
+
})
|
|
952
|
+
elif self._mode == self.MODE_BROWSE:
|
|
953
|
+
self._nav_history.append({
|
|
954
|
+
"mode": self.MODE_BROWSE,
|
|
955
|
+
"title": self._browse_title,
|
|
956
|
+
"tracks": list(self._browse_tracks),
|
|
957
|
+
"cursor": self._browse_cursor,
|
|
958
|
+
})
|
|
959
|
+
elif self._mode == self.MODE_QUEUE:
|
|
960
|
+
self._nav_history.append({
|
|
961
|
+
"mode": self.MODE_QUEUE,
|
|
962
|
+
"cursor": self._queue_cursor,
|
|
963
|
+
})
|
|
964
|
+
elif self._mode == self.MODE_PLAYLISTS:
|
|
965
|
+
self._nav_history.append({
|
|
966
|
+
"mode": self.MODE_PLAYLISTS,
|
|
967
|
+
"cursor": self._playlists_cursor,
|
|
968
|
+
})
|
|
969
|
+
else:
|
|
970
|
+
self._nav_history.append({"mode": self.MODE_PLAYER})
|
|
971
|
+
|
|
972
|
+
def _go_back(self):
|
|
973
|
+
if not self._nav_history:
|
|
974
|
+
self._mode = self.MODE_PLAYER
|
|
975
|
+
return
|
|
976
|
+
state = self._nav_history.pop()
|
|
977
|
+
mode = state["mode"]
|
|
978
|
+
if mode == self.MODE_SEARCH:
|
|
979
|
+
self._mode = self.MODE_SEARCH
|
|
980
|
+
self._search_query = state.get("query", "")
|
|
981
|
+
self._search_results = state.get("results", [])
|
|
982
|
+
self._search_cursor = state.get("cursor", 0)
|
|
983
|
+
self._search_loading = False
|
|
984
|
+
self._search_message = ""
|
|
985
|
+
elif mode == self.MODE_BROWSE:
|
|
986
|
+
self._mode = self.MODE_BROWSE
|
|
987
|
+
self._browse_title = state.get("title", "")
|
|
988
|
+
self._browse_tracks = state.get("tracks", [])
|
|
989
|
+
self._browse_cursor = state.get("cursor", 0)
|
|
990
|
+
self._browse_loading = False
|
|
991
|
+
self._browse_message = ""
|
|
992
|
+
elif mode == self.MODE_QUEUE:
|
|
993
|
+
self._mode = self.MODE_QUEUE
|
|
994
|
+
self._queue_cursor = state.get("cursor", 0)
|
|
995
|
+
elif mode == self.MODE_PLAYLISTS:
|
|
996
|
+
self._mode = self.MODE_PLAYLISTS
|
|
997
|
+
self._playlists_cursor = state.get("cursor", 0)
|
|
998
|
+
else:
|
|
999
|
+
self._mode = self.MODE_PLAYER
|
|
1000
|
+
|
|
1001
|
+
def _add_to_history(self, query: str):
|
|
1002
|
+
"""Add a search query to history (deduped, newest first)."""
|
|
1003
|
+
q = query.strip()
|
|
1004
|
+
if not q:
|
|
1005
|
+
return
|
|
1006
|
+
# Remove if already present, then prepend
|
|
1007
|
+
self._search_history = [h for h in self._search_history if h.lower() != q.lower()]
|
|
1008
|
+
self._search_history.insert(0, q)
|
|
1009
|
+
self._search_history = self._search_history[:20]
|
|
1010
|
+
|
|
1011
|
+
def _do_search(self):
|
|
1012
|
+
query = self._search_query.strip()
|
|
1013
|
+
if not query:
|
|
1014
|
+
return
|
|
1015
|
+
self._add_to_history(query)
|
|
1016
|
+
self._search_loading = True
|
|
1017
|
+
self._search_results = []
|
|
1018
|
+
self._search_cursor = 0
|
|
1019
|
+
self._search_message = ""
|
|
1020
|
+
|
|
1021
|
+
def _run():
|
|
1022
|
+
try:
|
|
1023
|
+
results = self.session.search(query, models=[tidalapi.Track, tidalapi.Album, tidalapi.Artist], limit=8)
|
|
1024
|
+
items = []
|
|
1025
|
+
for track in (results.get("tracks") or [])[:5]:
|
|
1026
|
+
artist = track.artists[0].name if track.artists else ""
|
|
1027
|
+
items.append({"type": "track", "name": track.name, "artist": artist, "obj": track})
|
|
1028
|
+
for album in (results.get("albums") or [])[:3]:
|
|
1029
|
+
artist = album.artist.name if album.artist else ""
|
|
1030
|
+
items.append({"type": "album", "name": album.name, "artist": artist, "obj": album})
|
|
1031
|
+
for artist in (results.get("artists") or [])[:2]:
|
|
1032
|
+
items.append({"type": "artist", "name": artist.name, "artist": "", "obj": artist})
|
|
1033
|
+
self._search_results = items
|
|
1034
|
+
if not items:
|
|
1035
|
+
self._search_message = "No results found"
|
|
1036
|
+
except Exception as e:
|
|
1037
|
+
self._search_message = f"Search failed: {e}"
|
|
1038
|
+
finally:
|
|
1039
|
+
self._search_loading = False
|
|
1040
|
+
|
|
1041
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
1042
|
+
|
|
1043
|
+
def _select_search_result(self):
|
|
1044
|
+
if not self._search_results:
|
|
1045
|
+
return
|
|
1046
|
+
item = self._search_results[self._search_cursor]
|
|
1047
|
+
obj = item["obj"]
|
|
1048
|
+
|
|
1049
|
+
if item["type"] == "track":
|
|
1050
|
+
self._queue = [obj]
|
|
1051
|
+
self._queue_index = 0
|
|
1052
|
+
self._play_track(obj)
|
|
1053
|
+
self._mode = self.MODE_PLAYER
|
|
1054
|
+
self._nav_history.clear()
|
|
1055
|
+
elif item["type"] == "album":
|
|
1056
|
+
self._open_album(obj)
|
|
1057
|
+
elif item["type"] == "artist":
|
|
1058
|
+
self._open_artist(obj)
|
|
1059
|
+
|
|
1060
|
+
def _open_album(self, album):
|
|
1061
|
+
self._push_nav()
|
|
1062
|
+
self._mode = self.MODE_BROWSE
|
|
1063
|
+
self._browse_title = album.name
|
|
1064
|
+
self._browse_tracks = []
|
|
1065
|
+
self._browse_cursor = -1
|
|
1066
|
+
self._browse_loading = True
|
|
1067
|
+
self._browse_message = ""
|
|
1068
|
+
|
|
1069
|
+
def _run():
|
|
1070
|
+
try:
|
|
1071
|
+
tracks = album.tracks()
|
|
1072
|
+
self._browse_tracks = list(tracks)
|
|
1073
|
+
if not self._browse_tracks:
|
|
1074
|
+
self._browse_message = "No tracks found"
|
|
1075
|
+
except Exception:
|
|
1076
|
+
self._browse_message = "Failed to load album"
|
|
1077
|
+
finally:
|
|
1078
|
+
self._browse_loading = False
|
|
1079
|
+
|
|
1080
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
1081
|
+
|
|
1082
|
+
def _open_artist(self, artist):
|
|
1083
|
+
self._push_nav()
|
|
1084
|
+
self._mode = self.MODE_BROWSE
|
|
1085
|
+
self._browse_title = f"{artist.name} - Top Tracks"
|
|
1086
|
+
self._browse_tracks = []
|
|
1087
|
+
self._browse_cursor = -1
|
|
1088
|
+
self._browse_loading = True
|
|
1089
|
+
self._browse_message = ""
|
|
1090
|
+
|
|
1091
|
+
def _run():
|
|
1092
|
+
try:
|
|
1093
|
+
tracks = artist.get_top_tracks(limit=20)
|
|
1094
|
+
self._browse_tracks = list(tracks)
|
|
1095
|
+
if not self._browse_tracks:
|
|
1096
|
+
self._browse_message = "No tracks found"
|
|
1097
|
+
except Exception:
|
|
1098
|
+
self._browse_message = "Failed to load artist"
|
|
1099
|
+
finally:
|
|
1100
|
+
self._browse_loading = False
|
|
1101
|
+
|
|
1102
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
1103
|
+
|
|
1104
|
+
def _play_browse_track(self):
|
|
1105
|
+
if not self._browse_tracks:
|
|
1106
|
+
return
|
|
1107
|
+
track = self._browse_tracks[self._browse_cursor]
|
|
1108
|
+
self._queue = list(self._browse_tracks)
|
|
1109
|
+
self._queue_index = self._browse_cursor
|
|
1110
|
+
self._play_track(track)
|
|
1111
|
+
|
|
1112
|
+
def _play_all_browse(self):
|
|
1113
|
+
if not self._browse_tracks:
|
|
1114
|
+
return
|
|
1115
|
+
self._queue = list(self._browse_tracks)
|
|
1116
|
+
self._play_queue_index(0)
|
|
1117
|
+
|
|
1118
|
+
def _load_playlists(self):
|
|
1119
|
+
"""Load user playlists in background."""
|
|
1120
|
+
self._playlists_loading = True
|
|
1121
|
+
self._playlists = []
|
|
1122
|
+
self._playlists_cursor = 0
|
|
1123
|
+
self._playlists_message = ""
|
|
1124
|
+
|
|
1125
|
+
def _run():
|
|
1126
|
+
try:
|
|
1127
|
+
playlists = self.session.user.playlists()
|
|
1128
|
+
self._playlists = list(playlists) if playlists else []
|
|
1129
|
+
if not self._playlists:
|
|
1130
|
+
self._playlists_message = "No playlists found"
|
|
1131
|
+
except Exception:
|
|
1132
|
+
self._playlists_message = "Failed to load playlists"
|
|
1133
|
+
finally:
|
|
1134
|
+
self._playlists_loading = False
|
|
1135
|
+
|
|
1136
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
1137
|
+
|
|
1138
|
+
def _open_playlist(self, playlist):
|
|
1139
|
+
"""Open a playlist and show its tracks in browse mode."""
|
|
1140
|
+
self._push_nav()
|
|
1141
|
+
self._mode = self.MODE_BROWSE
|
|
1142
|
+
self._browse_title = playlist.name if hasattr(playlist, "name") else "Playlist"
|
|
1143
|
+
self._browse_tracks = []
|
|
1144
|
+
self._browse_cursor = -1
|
|
1145
|
+
self._browse_loading = True
|
|
1146
|
+
self._browse_message = ""
|
|
1147
|
+
|
|
1148
|
+
def _run():
|
|
1149
|
+
try:
|
|
1150
|
+
tracks = playlist.tracks()
|
|
1151
|
+
self._browse_tracks = list(tracks) if tracks else []
|
|
1152
|
+
if not self._browse_tracks:
|
|
1153
|
+
self._browse_message = "Playlist is empty"
|
|
1154
|
+
except Exception:
|
|
1155
|
+
self._browse_message = "Failed to load playlist"
|
|
1156
|
+
finally:
|
|
1157
|
+
self._browse_loading = False
|
|
1158
|
+
|
|
1159
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
1160
|
+
|
|
1161
|
+
def _remove_from_queue(self):
|
|
1162
|
+
"""Remove the selected track from the queue."""
|
|
1163
|
+
if not self._queue or self._queue_cursor >= len(self._queue):
|
|
1164
|
+
return
|
|
1165
|
+
removing_current = (self._queue_cursor == self._queue_index)
|
|
1166
|
+
removing_before_current = (self._queue_cursor < self._queue_index)
|
|
1167
|
+
self._queue.pop(self._queue_cursor)
|
|
1168
|
+
if removing_before_current:
|
|
1169
|
+
self._queue_index -= 1
|
|
1170
|
+
elif removing_current:
|
|
1171
|
+
# If we removed the playing track, play the next one or stop
|
|
1172
|
+
if self._queue and self._queue_index < len(self._queue):
|
|
1173
|
+
self._play_track(self._queue[self._queue_index])
|
|
1174
|
+
elif self._queue and self._queue_index > 0:
|
|
1175
|
+
self._queue_index = len(self._queue) - 1
|
|
1176
|
+
self._play_track(self._queue[self._queue_index])
|
|
1177
|
+
else:
|
|
1178
|
+
self._playing = False
|
|
1179
|
+
self._current_track = None
|
|
1180
|
+
if self.audio:
|
|
1181
|
+
self.audio.stop()
|
|
1182
|
+
# Adjust cursor
|
|
1183
|
+
if self._queue_cursor >= len(self._queue) and self._queue:
|
|
1184
|
+
self._queue_cursor = len(self._queue) - 1
|
|
1185
|
+
|
|
1186
|
+
# ── Key handlers ──
|
|
1187
|
+
|
|
1188
|
+
def _handle_key(self, key: str):
|
|
1189
|
+
# Handle quit confirmation first
|
|
1190
|
+
if self._quit_pending:
|
|
1191
|
+
if key == KEY_ESC:
|
|
1192
|
+
self.running = False
|
|
1193
|
+
else:
|
|
1194
|
+
self._quit_pending = False
|
|
1195
|
+
return
|
|
1196
|
+
|
|
1197
|
+
# Handle logout confirmation
|
|
1198
|
+
if self._logout_pending:
|
|
1199
|
+
if key == "y" or key == "Y":
|
|
1200
|
+
self._logout()
|
|
1201
|
+
self._logout_pending = False
|
|
1202
|
+
return
|
|
1203
|
+
|
|
1204
|
+
if self._mode == self.MODE_SEARCH:
|
|
1205
|
+
self._handle_search_key(key)
|
|
1206
|
+
elif self._mode == self.MODE_BROWSE:
|
|
1207
|
+
self._handle_browse_key(key)
|
|
1208
|
+
elif self._mode == self.MODE_QUEUE:
|
|
1209
|
+
self._handle_queue_key(key)
|
|
1210
|
+
elif self._mode == self.MODE_PLAYLISTS:
|
|
1211
|
+
self._handle_playlists_key(key)
|
|
1212
|
+
else:
|
|
1213
|
+
self._handle_player_key(key)
|
|
1214
|
+
|
|
1215
|
+
def _handle_player_key(self, key: str):
|
|
1216
|
+
if key in (" ", "k"):
|
|
1217
|
+
if self._space_held:
|
|
1218
|
+
return # Key is still held down — ignore repeats
|
|
1219
|
+
self._toggle_play()
|
|
1220
|
+
self._space_held = True
|
|
1221
|
+
elif key == "n" or key == KEY_RIGHT:
|
|
1222
|
+
self._next_track()
|
|
1223
|
+
elif key == KEY_LEFT:
|
|
1224
|
+
self._prev_track()
|
|
1225
|
+
elif key == "s":
|
|
1226
|
+
self._mini_player = False
|
|
1227
|
+
self._mode = self.MODE_SEARCH
|
|
1228
|
+
self._search_query = ""
|
|
1229
|
+
self._search_results = []
|
|
1230
|
+
self._search_cursor = 0
|
|
1231
|
+
self._search_message = ""
|
|
1232
|
+
self._nav_history.clear()
|
|
1233
|
+
elif key == "t":
|
|
1234
|
+
self._mini_player = not self._mini_player
|
|
1235
|
+
elif key == "m":
|
|
1236
|
+
self._show_more = not self._show_more
|
|
1237
|
+
# Commands below are in the "more" menu but still work even when hidden
|
|
1238
|
+
elif key == "l":
|
|
1239
|
+
self._toggle_like()
|
|
1240
|
+
elif key == "r":
|
|
1241
|
+
self._start_track_radio()
|
|
1242
|
+
elif key == "q":
|
|
1243
|
+
self._mini_player = False
|
|
1244
|
+
self._mode = self.MODE_QUEUE
|
|
1245
|
+
self._queue_cursor = self._queue_index if self._queue else 0
|
|
1246
|
+
self._nav_history.clear()
|
|
1247
|
+
elif key == "p":
|
|
1248
|
+
self._mini_player = False
|
|
1249
|
+
self._mode = self.MODE_PLAYLISTS
|
|
1250
|
+
self._nav_history.clear()
|
|
1251
|
+
if not self._playlists and not self._playlists_loading:
|
|
1252
|
+
self._load_playlists()
|
|
1253
|
+
elif key == "o":
|
|
1254
|
+
self._logout_pending = True
|
|
1255
|
+
elif key == KEY_ESC:
|
|
1256
|
+
self._quit_pending = True
|
|
1257
|
+
|
|
1258
|
+
def _handle_search_key(self, key: str):
|
|
1259
|
+
if key == KEY_ESC or key == KEY_LEFT:
|
|
1260
|
+
self._go_back()
|
|
1261
|
+
return
|
|
1262
|
+
if key == " " and self._search_results:
|
|
1263
|
+
# Space toggles play/pause when browsing search results
|
|
1264
|
+
if self._space_held:
|
|
1265
|
+
return
|
|
1266
|
+
self._toggle_play()
|
|
1267
|
+
self._space_held = True
|
|
1268
|
+
return
|
|
1269
|
+
if key == KEY_UP:
|
|
1270
|
+
if self._search_results:
|
|
1271
|
+
self._search_cursor = max(0, self._search_cursor - 1)
|
|
1272
|
+
elif key == KEY_DOWN:
|
|
1273
|
+
if self._search_results:
|
|
1274
|
+
self._search_cursor = min(len(self._search_results) - 1, self._search_cursor + 1)
|
|
1275
|
+
elif key in (KEY_ENTER, KEY_ENTER2, KEY_RIGHT):
|
|
1276
|
+
if self._search_results:
|
|
1277
|
+
self._select_search_result()
|
|
1278
|
+
elif key != KEY_RIGHT:
|
|
1279
|
+
self._do_search()
|
|
1280
|
+
elif key in (KEY_BACKSPACE, KEY_BACKSPACE2):
|
|
1281
|
+
self._search_query = self._search_query[:-1]
|
|
1282
|
+
self._search_results = []
|
|
1283
|
+
self._search_cursor = 0
|
|
1284
|
+
self._search_message = ""
|
|
1285
|
+
elif len(key) == 1 and key.isprintable():
|
|
1286
|
+
self._search_query += key
|
|
1287
|
+
self._search_results = []
|
|
1288
|
+
self._search_cursor = 0
|
|
1289
|
+
self._search_message = ""
|
|
1290
|
+
|
|
1291
|
+
def _handle_browse_key(self, key: str):
|
|
1292
|
+
if key == KEY_ESC or key == KEY_LEFT:
|
|
1293
|
+
self._go_back()
|
|
1294
|
+
return
|
|
1295
|
+
if key == " ":
|
|
1296
|
+
if self._space_held:
|
|
1297
|
+
return
|
|
1298
|
+
self._toggle_play()
|
|
1299
|
+
self._space_held = True
|
|
1300
|
+
return
|
|
1301
|
+
if key == KEY_UP:
|
|
1302
|
+
if self._browse_tracks:
|
|
1303
|
+
self._browse_cursor = max(-1, self._browse_cursor - 1)
|
|
1304
|
+
elif key == KEY_DOWN:
|
|
1305
|
+
if self._browse_tracks:
|
|
1306
|
+
self._browse_cursor = min(len(self._browse_tracks) - 1, self._browse_cursor + 1)
|
|
1307
|
+
elif key in (KEY_ENTER, KEY_ENTER2, KEY_RIGHT):
|
|
1308
|
+
if self._browse_cursor == -1:
|
|
1309
|
+
self._play_all_browse()
|
|
1310
|
+
else:
|
|
1311
|
+
self._play_browse_track()
|
|
1312
|
+
elif key == "a":
|
|
1313
|
+
self._play_all_browse()
|
|
1314
|
+
|
|
1315
|
+
def _handle_queue_key(self, key: str):
|
|
1316
|
+
if key == KEY_ESC or key == KEY_LEFT:
|
|
1317
|
+
self._mode = self.MODE_PLAYER
|
|
1318
|
+
return
|
|
1319
|
+
if key == " ":
|
|
1320
|
+
if self._space_held:
|
|
1321
|
+
return
|
|
1322
|
+
self._toggle_play()
|
|
1323
|
+
self._space_held = True
|
|
1324
|
+
return
|
|
1325
|
+
if key == KEY_UP:
|
|
1326
|
+
if self._queue:
|
|
1327
|
+
self._queue_cursor = max(0, self._queue_cursor - 1)
|
|
1328
|
+
elif key == KEY_DOWN:
|
|
1329
|
+
if self._queue:
|
|
1330
|
+
self._queue_cursor = min(len(self._queue) - 1, self._queue_cursor + 1)
|
|
1331
|
+
elif key in (KEY_ENTER, KEY_ENTER2):
|
|
1332
|
+
if self._queue:
|
|
1333
|
+
self._play_queue_index(self._queue_cursor)
|
|
1334
|
+
elif key == "x":
|
|
1335
|
+
self._remove_from_queue()
|
|
1336
|
+
|
|
1337
|
+
def _handle_playlists_key(self, key: str):
|
|
1338
|
+
if key == KEY_ESC or key == KEY_LEFT:
|
|
1339
|
+
self._mode = self.MODE_PLAYER
|
|
1340
|
+
return
|
|
1341
|
+
if key == " ":
|
|
1342
|
+
if self._space_held:
|
|
1343
|
+
return
|
|
1344
|
+
self._toggle_play()
|
|
1345
|
+
self._space_held = True
|
|
1346
|
+
return
|
|
1347
|
+
if key == KEY_UP:
|
|
1348
|
+
if self._playlists:
|
|
1349
|
+
self._playlists_cursor = max(0, self._playlists_cursor - 1)
|
|
1350
|
+
elif key == KEY_DOWN:
|
|
1351
|
+
if self._playlists:
|
|
1352
|
+
self._playlists_cursor = min(len(self._playlists) - 1, self._playlists_cursor + 1)
|
|
1353
|
+
elif key in (KEY_ENTER, KEY_ENTER2, KEY_RIGHT):
|
|
1354
|
+
if self._playlists:
|
|
1355
|
+
self._open_playlist(self._playlists[self._playlists_cursor])
|
|
1356
|
+
|
|
1357
|
+
# ── Main loop ──
|
|
1358
|
+
|
|
1359
|
+
def _drain_stdin(self, select_mod=None):
|
|
1360
|
+
"""Discard all pending stdin input to prevent buffered key repeats."""
|
|
1361
|
+
import select as _sel
|
|
1362
|
+
# Wait briefly for in-flight key-repeat bytes, then drain everything
|
|
1363
|
+
time.sleep(0.05)
|
|
1364
|
+
while _sel.select([sys.stdin], [], [], 0)[0]:
|
|
1365
|
+
os.read(sys.stdin.fileno(), 4096)
|
|
1366
|
+
|
|
1367
|
+
def _read_key(self, select_mod):
|
|
1368
|
+
if not select_mod.select([sys.stdin], [], [], 0.25)[0]:
|
|
1369
|
+
return None
|
|
1370
|
+
ch = os.read(sys.stdin.fileno(), 1)
|
|
1371
|
+
if not ch:
|
|
1372
|
+
return None
|
|
1373
|
+
# If escape byte, try to read the rest of the sequence (arrow keys etc.)
|
|
1374
|
+
if ch == b"\x1b":
|
|
1375
|
+
if select_mod.select([sys.stdin], [], [], 0.05)[0]:
|
|
1376
|
+
ch += os.read(sys.stdin.fileno(), 7)
|
|
1377
|
+
return ch.decode("utf-8", errors="ignore")
|
|
1378
|
+
|
|
1379
|
+
def run(self):
|
|
1380
|
+
"""Start the headless player."""
|
|
1381
|
+
# Find audio player
|
|
1382
|
+
player_cmd = _find_audio_player()
|
|
1383
|
+
if not player_cmd:
|
|
1384
|
+
self.console.print("[red]No audio player found. Install mpv or ffplay.[/red]")
|
|
1385
|
+
return
|
|
1386
|
+
self.audio = AudioPlayer(player_cmd)
|
|
1387
|
+
|
|
1388
|
+
# Login
|
|
1389
|
+
if not self._login():
|
|
1390
|
+
return
|
|
1391
|
+
|
|
1392
|
+
# Load favorites in background
|
|
1393
|
+
self._load_favorites()
|
|
1394
|
+
|
|
1395
|
+
# Restore previous session state (queue, current track)
|
|
1396
|
+
self._restore_state()
|
|
1397
|
+
|
|
1398
|
+
# Start playback monitor
|
|
1399
|
+
monitor = threading.Thread(target=self._monitor_playback, daemon=True)
|
|
1400
|
+
monitor.start()
|
|
1401
|
+
|
|
1402
|
+
import tty
|
|
1403
|
+
import termios
|
|
1404
|
+
import select
|
|
1405
|
+
|
|
1406
|
+
if not sys.stdin.isatty():
|
|
1407
|
+
self.console.print("[red]Player requires an interactive terminal.[/red]")
|
|
1408
|
+
return
|
|
1409
|
+
|
|
1410
|
+
old_settings = termios.tcgetattr(sys.stdin)
|
|
1411
|
+
try:
|
|
1412
|
+
tty.setcbreak(sys.stdin.fileno())
|
|
1413
|
+
self.console.clear()
|
|
1414
|
+
|
|
1415
|
+
with Live(
|
|
1416
|
+
self._build_display(),
|
|
1417
|
+
console=self.console,
|
|
1418
|
+
refresh_per_second=4,
|
|
1419
|
+
screen=False,
|
|
1420
|
+
) as live:
|
|
1421
|
+
while self.running:
|
|
1422
|
+
live.update(self._build_display())
|
|
1423
|
+
key = self._read_key(select)
|
|
1424
|
+
if key is not None:
|
|
1425
|
+
self._handle_key(key)
|
|
1426
|
+
elif self._space_held:
|
|
1427
|
+
# No key this cycle — user released space
|
|
1428
|
+
self._space_held = False
|
|
1429
|
+
finally:
|
|
1430
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
|
1431
|
+
# Save state before cleanup
|
|
1432
|
+
self._save_state()
|
|
1433
|
+
if self.audio:
|
|
1434
|
+
self.audio.stop()
|
|
1435
|
+
|
|
1436
|
+
self.console.print("[dim]Player closed.[/dim]")
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def main():
|
|
1440
|
+
import click
|
|
1441
|
+
|
|
1442
|
+
@click.command()
|
|
1443
|
+
@click.option("--quality", default="HIGH", type=click.Choice(["LOW", "HIGH", "LOSSLESS", "HIRES"], case_sensitive=False), help="Audio quality")
|
|
1444
|
+
def headless(quality):
|
|
1445
|
+
"""Launch Ticli terminal player."""
|
|
1446
|
+
HeadlessTidalPlayer(quality=quality).run()
|
|
1447
|
+
|
|
1448
|
+
headless()
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
if __name__ == "__main__":
|
|
1452
|
+
main()
|