ttyplayer 0.3.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.
ttyplayer/player.py ADDED
@@ -0,0 +1,652 @@
1
+ import _thread
2
+ import json
3
+ import os
4
+ import queue
5
+ import secrets
6
+ import shutil
7
+ import signal
8
+ import socket
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import threading
13
+ import time
14
+
15
+ from ttyplayer import control
16
+ from ttyplayer.models import Video
17
+ from ttyplayer.utils import APP_NAME, WINDOWS, format_time
18
+
19
+ if WINDOWS:
20
+ import msvcrt
21
+ else:
22
+ import termios
23
+ import tty
24
+
25
+ SEEK_SECONDS = 5
26
+ VOLUME_STEP = 5
27
+ VOLUME_POLL_SECONDS = 2
28
+ VOLUME_CELLS = 10
29
+ VOLUME_SOURCES = {"system-volume": "system", "ao-volume": "device", "volume": "player"} # in order of preference
30
+ SYSTEM_VOLUME_TIMEOUT = 1 # seconds osascript may take
31
+ CONNECT_ATTEMPTS = 30 # x 0.1s = 3s for mpv to create its socket
32
+ KEY_POLL_SECONDS = 0.1 # how often the Windows key reader looks for a key, or a remote stop
33
+
34
+ # What each key does, by the key's name: the character typed, or the arrow's direction.
35
+ KEYS = {
36
+ " ": ("toggle_pause",),
37
+ ",": ("seek", -SEEK_SECONDS),
38
+ ".": ("seek", SEEK_SECONDS),
39
+ "left": ("seek", -SEEK_SECONDS),
40
+ "right": ("seek", SEEK_SECONDS),
41
+ "up": ("change_volume", VOLUME_STEP),
42
+ "down": ("change_volume", -VOLUME_STEP),
43
+ "n": ("next",),
44
+ "p": ("prev",),
45
+ }
46
+ TERMINAL_ARROWS = {"[D": "left", "[C": "right", "[A": "up", "[B": "down"} # after ESC
47
+ CONSOLE_ARROWS = {"K": "left", "M": "right", "H": "up", "P": "down"} # after \xe0 or \x00
48
+
49
+
50
+ def build_argv(video, socket_path):
51
+ argv = ["mpv", "--idle", "--no-terminal", f"--input-ipc-server={socket_path}"]
52
+ if not video:
53
+ # Audio only: also tell yt-dlp not to pick (and buffer) a video stream,
54
+ # which shortens the wait before sound starts.
55
+ argv += ["--no-video", "--ytdl-format=bestaudio/best"]
56
+ return argv
57
+
58
+
59
+ def timing():
60
+ """Whether TTYPLAYER_TIMING=1 asks for start-up times on screen."""
61
+ return os.environ.get("TTYPLAYER_TIMING") == "1"
62
+
63
+
64
+ def volume_meter(volume, muted=False):
65
+ """🔊 (🔇 when muted) and ten cells filled to the volume, then the number; -- while mpv has not said."""
66
+ icon = "🔇" if muted else "🔊"
67
+ if volume is None:
68
+ return f"{icon} {'â–¯' * VOLUME_CELLS} --"
69
+ filled = max(0, min(VOLUME_CELLS, round(volume / 100 * VOLUME_CELLS)))
70
+ return f"{icon} {'â–®' * filled}{'â–¯' * (VOLUME_CELLS - filled)} {round(volume)}%"
71
+
72
+
73
+ def macos():
74
+ return sys.platform == "darwin"
75
+
76
+
77
+ def osascript(script):
78
+ """Run one AppleScript; its output, or None if osascript is missing, fails or times out."""
79
+ try:
80
+ result = subprocess.run(
81
+ ["osascript", "-e", script], capture_output=True, text=True, timeout=SYSTEM_VOLUME_TIMEOUT
82
+ )
83
+ except (OSError, subprocess.TimeoutExpired):
84
+ return None
85
+ return result.stdout if result.returncode == 0 else None
86
+
87
+
88
+ def read_system_volume():
89
+ """The macOS output volume, 0-100; None when it can't be read."""
90
+ output = osascript("output volume of (get volume settings)")
91
+ try:
92
+ return int(output)
93
+ except (TypeError, ValueError):
94
+ return None # no output, or "missing value" when the output device has no volume
95
+
96
+
97
+ def write_system_volume(volume):
98
+ """Set the macOS output volume to volume (an int, 0-100); whether osascript did."""
99
+ return osascript(f"set volume output volume {int(volume)}") is not None
100
+
101
+
102
+ def status_line(status):
103
+ """The status line's first part, from a status() dict: times, state, volume, title, [i/n]."""
104
+ position = format_time(status["position"])
105
+ duration = format_time(status["duration"])
106
+ state = "Paused" if status["paused"] else "Playing"
107
+ volume = volume_meter(status["volume"], status["muted"])
108
+ # The meter goes before the title, so a narrow terminal cuts the title, not the meter.
109
+ line = f"{position} / {duration} {state} {volume} {status['title']}"
110
+ if status["total"] > 1:
111
+ line += f" [{status['index']}/{status['total']}]"
112
+ if timing() and status.get("started_in") is not None:
113
+ line += f" started in {status['started_in']:.1f}s"
114
+ return line
115
+
116
+
117
+ def interrupt_main():
118
+ """Ctrl-C the main thread, even while it blocks in sys.stdin.read(1).
119
+
120
+ Windows has no pthread_kill; there the KeyboardInterrupt lands on the key
121
+ reader's next poll tick.
122
+ """
123
+ if WINDOWS:
124
+ _thread.interrupt_main()
125
+ else:
126
+ signal.pthread_kill(threading.main_thread().ident, signal.SIGINT)
127
+
128
+
129
+ def ipc_path():
130
+ """(private dir to remove on exit, path for --input-ipc-server): a named pipe on Windows."""
131
+ if WINDOWS:
132
+ return None, rf"\\.\pipe\{APP_NAME}-{os.getpid()}-{secrets.token_hex(4)}"
133
+ directory = tempfile.mkdtemp(prefix=f"{APP_NAME}-")
134
+ return directory, os.path.join(directory, "mpv.sock")
135
+
136
+
137
+ class SocketTransport:
138
+ """mpv's IPC over its Unix socket."""
139
+
140
+ def __init__(self, path):
141
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
142
+ try:
143
+ self.sock.connect(path)
144
+ except OSError:
145
+ self.sock.close()
146
+ raise
147
+ self.reader = self.sock.makefile("rb")
148
+
149
+ def write(self, data):
150
+ self.sock.sendall(data)
151
+
152
+ def readline(self):
153
+ return self.reader.readline()
154
+
155
+ def close(self):
156
+ self.reader.close()
157
+ self.sock.close()
158
+
159
+
160
+ class PipeTransport:
161
+ """mpv's IPC over its Windows named pipe, opened as a file."""
162
+
163
+ def __init__(self, path):
164
+ self.pipe = open(path, "r+b", buffering=0)
165
+
166
+ def write(self, data):
167
+ while data:
168
+ data = data[self.pipe.write(data) :] # an unbuffered write may take only part
169
+
170
+ def readline(self):
171
+ return self.pipe.readline()
172
+
173
+ def close(self):
174
+ self.pipe.close()
175
+
176
+
177
+ def connect(path):
178
+ """The transport to mpv at path, retried while mpv creates it; None if it never appears."""
179
+ transport = PipeTransport if WINDOWS else SocketTransport
180
+ for _ in range(CONNECT_ATTEMPTS):
181
+ try:
182
+ return transport(path)
183
+ except (FileNotFoundError, ConnectionRefusedError):
184
+ time.sleep(0.1)
185
+ return None
186
+
187
+
188
+ def terminal_keys():
189
+ """Key names from a POSIX terminal in cbreak mode; restores the terminal when closed."""
190
+ fd = sys.stdin.fileno()
191
+ old = termios.tcgetattr(fd)
192
+ try:
193
+ tty.setcbreak(fd)
194
+ while True:
195
+ key = sys.stdin.read(1)
196
+ if key == "\x1b": # arrow keys arrive as ESC [ letter
197
+ key = TERMINAL_ARROWS.get(sys.stdin.read(2))
198
+ yield key
199
+ finally:
200
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
201
+
202
+
203
+ def console_keys():
204
+ """Key names from the Windows console, polled so a remote stop needs no key press."""
205
+ while True:
206
+ if not msvcrt.kbhit():
207
+ time.sleep(KEY_POLL_SECONDS)
208
+ continue
209
+ key = msvcrt.getwch()
210
+ if key in ("\xe0", "\x00"): # arrows and other special keys arrive as a prefix, then a letter
211
+ key = CONSOLE_ARROWS.get(msvcrt.getwch())
212
+ elif key == "\x03":
213
+ raise KeyboardInterrupt
214
+ yield key
215
+
216
+
217
+ class MpvClient:
218
+ """Runs mpv in the background and drives it over its JSON IPC socket.
219
+
220
+ One request per line is sent with send(); a listener thread reads replies and
221
+ events, keeps the latest observed properties in self.state, and notifies on
222
+ every change. The main thread owns the keyboard in run().
223
+ """
224
+
225
+ on_state = None
226
+ loaded_at = None # monotonic time of the last loadfile, until playback restarts
227
+ started_in = None
228
+ idle = True # nothing loaded: before the first track, after the queue ran out
229
+ request_id = 0 # of the last command sent
230
+ poller = None
231
+ volume_writer = None # macOS only
232
+
233
+ def __init__(self, video=False, on_play=None, on_state=None):
234
+ # on_play(video) is called whenever a queued video starts; the CLI uses
235
+ # it to keep history, the player itself knows nothing about files.
236
+ self.on_play = on_play
237
+ # on_state(status) replaces the printed status line, for a UI that owns
238
+ # the terminal itself.
239
+ self.on_state = on_state
240
+ # A private socket (or pipe) per client, so two ttyplayers never share one mpv.
241
+ self.socket_dir, self.socket_path = ipc_path()
242
+ try:
243
+ self.process = subprocess.Popen(build_argv(video, self.socket_path))
244
+ except FileNotFoundError:
245
+ self._remove_socket_dir()
246
+ raise
247
+ self.ipc = connect(self.socket_path)
248
+ if self.ipc is None:
249
+ self.process.kill()
250
+ self._remove_socket_dir()
251
+ raise RuntimeError("mpv did not start")
252
+ # Keys, listener, control socket and TUI all move through the queue.
253
+ self.queue_lock = threading.RLock()
254
+ self.send_lock = threading.Lock()
255
+ self.reply_lock = threading.Lock()
256
+ self.replies = {} # request_id -> callback(data), until the reply arrives
257
+ self.state = {}
258
+ self.queue = []
259
+ self.index = 0
260
+
261
+ self.listener = threading.Thread(target=self.listen, daemon=True)
262
+ self.listener.start()
263
+ for number, name in enumerate(["time-pos", "duration", "pause", "media-title", "volume", "mute", "ao-volume"], start=1):
264
+ self.send(["observe_property", number, name])
265
+ self.stop_polling = threading.Event()
266
+ self.poller = threading.Thread(target=self.poll_volume, daemon=True)
267
+ self.poller.start()
268
+ if macos():
269
+ self.start_volume_writer()
270
+
271
+ # --- wire protocol -------------------------------------------------
272
+
273
+ def send(self, command, on_reply=None):
274
+ """Send one command; on_reply(data), if given, gets its reply's data, or None on an error."""
275
+ with self.send_lock:
276
+ self.request_id += 1
277
+ if on_reply:
278
+ # Registered before sending, so the reply can't arrive first.
279
+ with self.reply_lock:
280
+ self.replies[self.request_id] = on_reply
281
+ line = json.dumps({"command": command, "request_id": self.request_id}) + "\n"
282
+ self.ipc.write(line.encode())
283
+
284
+ def get_property(self, name, callback):
285
+ self.send(["get_property", name], callback)
286
+
287
+ def read(self):
288
+ text = self.ipc.readline()
289
+ if not text:
290
+ return None # mpv closed the connection
291
+ return json.loads(text)
292
+
293
+ def listen(self):
294
+ while True:
295
+ message = self.read()
296
+ if message is None:
297
+ break
298
+ self.handle_message(message)
299
+
300
+ def handle_message(self, message):
301
+ if "request_id" in message:
302
+ with self.reply_lock:
303
+ callback = self.replies.pop(message["request_id"], None)
304
+ if callback: # replies nobody asked for (observe_property, loadfile, ...) are dropped
305
+ callback(message.get("data") if message.get("error") == "success" else None)
306
+ elif message.get("event") == "property-change":
307
+ if message["name"] == "ao-volume" and "data" not in message:
308
+ return # coreaudio's first event has no data; the poll reads the real value
309
+ self.state[message["name"]] = message.get("data")
310
+ self.notify()
311
+ elif message.get("event") == "playback-restart":
312
+ if self._mark_started():
313
+ self.notify()
314
+ elif message.get("event") == "end-file" and message.get("reason") == "eof":
315
+ # At the end of the queue this still notifies, to tell a UI it went idle.
316
+ self._edit(self._next_or_idle)
317
+
318
+ def _mark_started(self):
319
+ """Store started_in on the first restart after a loadfile; later ones are seeks.
320
+
321
+ Under queue_lock, so a track change can't land between reading the clock and
322
+ reading the stamp it is measured from.
323
+ """
324
+ with self.queue_lock:
325
+ if self.loaded_at is None:
326
+ return False
327
+ self.started_in = round(time.monotonic() - self.loaded_at, 1)
328
+ self.loaded_at = None
329
+ return True
330
+
331
+ def poll_volume(self):
332
+ """Refresh the volumes every VOLUME_POLL_SECONDS while a track is loaded, until quit().
333
+
334
+ mpv sends no property-change for ao-volume when the OS changes it (the app's
335
+ stream volume on PulseAudio/PipeWire), so it is read. On macOS mpv can't see the
336
+ system volume at all, so osascript reads it here, never at once with write_volumes().
337
+ """
338
+ while not self.stop_polling.wait(VOLUME_POLL_SECONDS):
339
+ if not self.idle:
340
+ if macos():
341
+ self._system_volume(lambda: self._commit_volume("system-volume", read_system_volume()))
342
+ self.get_property("ao-volume", self._ao_volume_read)
343
+
344
+ def start_volume_writer(self):
345
+ """Start the thread that sets the system volume, so a key never waits on osascript."""
346
+ self.system_volume_lock = threading.Lock() # one osascript at a time, see _system_volume()
347
+ self.volume_steps = queue.Queue() # steps for write_volumes(), in the order they were pressed
348
+ self.stop_writing = threading.Event()
349
+ self.volume_writer = threading.Thread(target=self.write_volumes, daemon=True)
350
+ self.volume_writer.start()
351
+
352
+ def stop_volume_writer(self):
353
+ """Stop write_volumes(), dropping the steps not yet written."""
354
+ self.stop_writing.set()
355
+ self.volume_steps.put(None) # wakes it if it waits for a step
356
+ self.volume_writer.join(timeout=1)
357
+
358
+ def write_volumes(self):
359
+ """Apply each queued step to the system volume, one osascript at a time, until stop_volume_writer()."""
360
+ while True:
361
+ step = self.volume_steps.get()
362
+ try:
363
+ if self.stop_writing.is_set():
364
+ return
365
+ self._system_volume(lambda: self._write_system_volume_step(step))
366
+ finally:
367
+ self.volume_steps.task_done()
368
+
369
+ def _write_system_volume_step(self, step):
370
+ """Write the system volume moved by step; whether the stored value changed."""
371
+ current = self.state.get("system-volume")
372
+ if current is None: # a failed read since the key: the step is dropped
373
+ return False
374
+ volume = max(0, min(100, current + step))
375
+ return write_system_volume(volume) and self._commit_volume("system-volume", volume)
376
+
377
+ def _system_volume(self, change):
378
+ """Run change() under system_volume_lock; notify() after releasing it if it changed something.
379
+
380
+ The poll's read and the writer's write each hold it from osascript to the
381
+ stored value, so a read begun before a write can't store its older volume after it.
382
+ """
383
+ with self.system_volume_lock:
384
+ changed = change()
385
+ if changed:
386
+ self.notify()
387
+
388
+ def _ao_volume_read(self, value):
389
+ if self._commit_volume("ao-volume", value):
390
+ self.notify()
391
+
392
+ def _commit_volume(self, name, value):
393
+ """Store a volume, None when there is none; whether it changed."""
394
+ if value == self.state.get(name):
395
+ return False
396
+ self.state[name] = value
397
+ return True
398
+
399
+ # --- lifecycle ------------------------------------------------------
400
+
401
+ def quit(self):
402
+ if self.poller:
403
+ self.stop_polling.set() # before the socket closes under it
404
+ self.poller.join(timeout=1)
405
+ if self.volume_writer:
406
+ self.stop_volume_writer()
407
+ self.send(["quit"])
408
+ try:
409
+ self.process.wait(timeout=2)
410
+ except subprocess.TimeoutExpired:
411
+ self.process.kill()
412
+ self.ipc.close()
413
+ self._remove_socket_dir()
414
+
415
+ def _remove_socket_dir(self):
416
+ if self.socket_dir:
417
+ shutil.rmtree(self.socket_dir, ignore_errors=True)
418
+
419
+ def run(self):
420
+ """Own the keyboard until q, Ctrl-C or `ttyplayer stop`. Always restores the terminal.
421
+
422
+ Meanwhile the control socket takes commands from other terminals.
423
+ """
424
+ keys = console_keys() if WINDOWS else terminal_keys()
425
+ remote = control.serve(self.handle_control)
426
+ try:
427
+ for key in keys:
428
+ if key == "q":
429
+ self.quit()
430
+ break
431
+ self.press(key)
432
+ except KeyboardInterrupt:
433
+ self.quit()
434
+ finally:
435
+ if remote:
436
+ remote.stop()
437
+ keys.close() # restores the terminal
438
+ print() # leave the status line behind instead of overwriting it
439
+
440
+ def press(self, key):
441
+ """Do what KEYS says the key named key does; other keys do nothing."""
442
+ if key in KEYS:
443
+ method, *args = KEYS[key]
444
+ getattr(self, method)(*args)
445
+
446
+ def toggle_pause(self):
447
+ self.send(["cycle", "pause"])
448
+
449
+ def seek(self, seconds):
450
+ self.send(["seek", seconds])
451
+
452
+ def change_volume(self, step):
453
+ """Change the volume the meter shows: the system's on macOS, the device's when mpv exposes it, else mpv's own."""
454
+ backend = self.volume_backend()
455
+ if backend == "system-volume":
456
+ self.volume_steps.put(step) # write_volumes() runs osascript off the caller's (maybe the TUI's) thread
457
+ else:
458
+ self.send(["add", backend or "volume", step])
459
+
460
+ def toggle_mute(self):
461
+ self.send(["cycle", "mute"])
462
+
463
+ # --- remote control ---------------------------------------------------
464
+
465
+ def handle_control(self, name):
466
+ """Answer a command from the control socket with the action its key performs."""
467
+ if name == "pause":
468
+ self.toggle_pause()
469
+ elif name == "next":
470
+ self.next()
471
+ elif name == "prev":
472
+ self.prev()
473
+ elif name == "mute":
474
+ self.toggle_mute()
475
+ elif name == "stop":
476
+ interrupt_main() # run()'s Ctrl-C path quits and restores the terminal
477
+ elif name == "status":
478
+ return control.ok(**self.status())
479
+ else:
480
+ return control.failure(f"unknown command {name}")
481
+ return control.ok()
482
+
483
+ # --- queue ----------------------------------------------------------
484
+
485
+ def add(self, video: Video):
486
+ self.queue.append(video)
487
+
488
+ def load(self, url):
489
+ self.send(["loadfile", url])
490
+
491
+ def play_current(self):
492
+ self._move(0)
493
+
494
+ def next(self):
495
+ return self._move(1)
496
+
497
+ def prev(self):
498
+ return self._move(-1)
499
+
500
+ def jump(self, index):
501
+ """Play queue[index]; False, doing nothing, out of range."""
502
+ return self._edit(self._play_index, index)
503
+
504
+ def remove(self, index):
505
+ """Drop queue[index]; removing the current track plays the next one, else the previous."""
506
+ return self._edit(self._remove, index)
507
+
508
+ def move(self, source, target):
509
+ """Move queue[source] to target; the current track stays the current track."""
510
+ return self._edit(self._reorder, source, target)
511
+
512
+ def clear_others(self):
513
+ """Keep only the current track, or empty the queue when idle."""
514
+ return self._edit(self._clear_others)
515
+
516
+ def _move(self, step):
517
+ """Step the queue and load that video; False, doing nothing, past either end."""
518
+ return self._edit(lambda: self._play_index(self.index + step))
519
+
520
+ def _edit(self, change, *args):
521
+ """Run change(*args) under queue_lock; notify() after releasing it if it changed something.
522
+
523
+ notify() runs after queue_lock is released: a UI's on_state may wait for its
524
+ own thread, which may itself be waiting here to move the queue.
525
+ """
526
+ with self.queue_lock:
527
+ changed = change(*args)
528
+ if changed:
529
+ self.notify()
530
+ return changed
531
+
532
+ # The changes below run under queue_lock, through _edit().
533
+
534
+ def _play_index(self, index):
535
+ if not 0 <= index < len(self.queue):
536
+ return False
537
+ self.index = index
538
+ video = self.queue[index]
539
+ self.idle = False
540
+ self.started_in = None
541
+ self.loaded_at = time.monotonic()
542
+ self.load(video.url)
543
+ if self.on_play:
544
+ self.on_play(video)
545
+ return True
546
+
547
+ def _next_or_idle(self):
548
+ if not self._play_index(self.index + 1):
549
+ self.idle = True
550
+ return True
551
+
552
+ def _remove(self, index):
553
+ if not 0 <= index < len(self.queue):
554
+ return False
555
+ del self.queue[index]
556
+ if index < self.index:
557
+ self.index -= 1
558
+ elif index == self.index:
559
+ if not (self._play_index(index) or self._play_index(index - 1)):
560
+ self.index = 0
561
+ self.idle = True
562
+ self.started_in = None
563
+ self.loaded_at = None
564
+ self.send(["stop"])
565
+ return True
566
+
567
+ def _reorder(self, source, target):
568
+ size = len(self.queue)
569
+ if source == target or not (0 <= source < size and 0 <= target < size):
570
+ return False
571
+ current = self.index
572
+ self.queue.insert(target, self.queue.pop(source))
573
+ if source == current:
574
+ self.index = target
575
+ elif source < current <= target:
576
+ self.index -= 1
577
+ elif target <= current < source:
578
+ self.index += 1
579
+ return True
580
+
581
+ def _clear_others(self):
582
+ kept = [] if self.idle else self.queue[self.index : self.index + 1]
583
+ if len(kept) == len(self.queue):
584
+ return False
585
+ self.queue[:] = kept
586
+ self.index = 0
587
+ return True
588
+
589
+ def current_title(self):
590
+ # The queue knows the title before mpv does, so no file-name flicker on load.
591
+ if self.queue:
592
+ return self.queue[self.index].title
593
+ if self.idle:
594
+ return None # mpv's media-title outlives the track it named
595
+ return self.state.get("media-title")
596
+
597
+ def current_uploader(self):
598
+ if self.queue:
599
+ return self.queue[self.index].uploader
600
+ return None
601
+
602
+ def up_next(self):
603
+ if self.index + 1 < len(self.queue):
604
+ return self.queue[self.index + 1].title
605
+ return None
606
+
607
+ # --- display --------------------------------------------------------
608
+
609
+ def volume_backend(self):
610
+ """The first VOLUME_SOURCES key with a number in state (system-volume only on macOS); None if none."""
611
+ for name in VOLUME_SOURCES:
612
+ if name == "system-volume" and not macos():
613
+ continue
614
+ if isinstance(self.state.get(name), (int, float)):
615
+ return name
616
+ return None
617
+
618
+ def status(self):
619
+ volume_property = self.volume_backend()
620
+ return {
621
+ "title": self.current_title(),
622
+ "uploader": self.current_uploader(),
623
+ "position": self.state.get("time-pos"),
624
+ "duration": self.state.get("duration"),
625
+ "paused": bool(self.state.get("pause")),
626
+ "index": self.index + 1,
627
+ "total": len(self.queue),
628
+ "volume": self.state.get(volume_property),
629
+ "volume_source": VOLUME_SOURCES.get(volume_property),
630
+ "muted": bool(self.state.get("mute")),
631
+ "up_next": self.up_next(),
632
+ "started_in": self.started_in,
633
+ "idle": self.idle,
634
+ }
635
+
636
+ def notify(self):
637
+ """Hand the new status to on_state if a UI set one, else redraw the status line."""
638
+ if self.on_state:
639
+ self.on_state(self.status())
640
+ else:
641
+ self.render()
642
+
643
+ def render(self):
644
+ status = self.status()
645
+ line = status_line(status)
646
+ if status["total"] > 1:
647
+ up_next = status["up_next"]
648
+ line += f" Up next: {up_next}" if up_next else " End of queue"
649
+
650
+ width = shutil.get_terminal_size(fallback=(80, 23)).columns
651
+ line = line[: max(width - 1, 1)]
652
+ print(f"\r{line}\x1b[K", end="", flush=True)