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/__init__.py +2 -0
- ttyplayer/cli.py +343 -0
- ttyplayer/control.py +244 -0
- ttyplayer/favorites.py +90 -0
- ttyplayer/history.py +64 -0
- ttyplayer/models.py +13 -0
- ttyplayer/player.py +652 -0
- ttyplayer/tui.py +618 -0
- ttyplayer/tui.tcss +125 -0
- ttyplayer/utils.py +80 -0
- ttyplayer/youtube.py +57 -0
- ttyplayer-0.3.0.dist-info/METADATA +222 -0
- ttyplayer-0.3.0.dist-info/RECORD +16 -0
- ttyplayer-0.3.0.dist-info/WHEEL +4 -0
- ttyplayer-0.3.0.dist-info/entry_points.txt +3 -0
- ttyplayer-0.3.0.dist-info/licenses/LICENSE +21 -0
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)
|