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/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()