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/tui.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""A full-screen ttyplayer: search bar, tabs with a results table, a now-playing panel, help, command palette."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import functools
|
|
5
|
+
import io
|
|
6
|
+
import threading
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual import on, work
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.command import DiscoveryHit, Hit, Provider
|
|
13
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
14
|
+
from textual.screen import ModalScreen
|
|
15
|
+
from textual.widgets import (
|
|
16
|
+
DataTable,
|
|
17
|
+
Footer,
|
|
18
|
+
Header,
|
|
19
|
+
Input,
|
|
20
|
+
LoadingIndicator,
|
|
21
|
+
ProgressBar,
|
|
22
|
+
Static,
|
|
23
|
+
TabbedContent,
|
|
24
|
+
TabPane,
|
|
25
|
+
)
|
|
26
|
+
from textual.worker import get_current_worker
|
|
27
|
+
|
|
28
|
+
from ttyplayer import control, favorites, history, player, youtube
|
|
29
|
+
from ttyplayer.utils import APP_NAME, format_time, unseen
|
|
30
|
+
|
|
31
|
+
SEARCH_LIMIT = 10
|
|
32
|
+
HISTORY_LIMIT = 50
|
|
33
|
+
FAVORITE_MARK = " ♥"
|
|
34
|
+
IDLE_TEXT = "Nothing playing — press / to search"
|
|
35
|
+
LONG_SEEK_SECONDS = 30
|
|
36
|
+
|
|
37
|
+
# The command palette's entries and the help modal's Commands section: (name, app action, help).
|
|
38
|
+
COMMANDS = [
|
|
39
|
+
("Search…", "focus_search", "Focus the search box"),
|
|
40
|
+
("Next theme", "next_theme", "Switch to the next color theme"),
|
|
41
|
+
("Help", "help", "Every key and command"),
|
|
42
|
+
("Quit", "quit", "Quit and stop mpv"),
|
|
43
|
+
("Pause / resume", "toggle_pause", "Pause or resume playback"),
|
|
44
|
+
("Next", "next", "Play the next track in the queue"),
|
|
45
|
+
("Previous", "prev", "Play the previous track in the queue"),
|
|
46
|
+
("Mute", "toggle_mute", "Mute or unmute"),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve(text, limit=SEARCH_LIMIT):
|
|
51
|
+
"""A link's videos, or the first `limit` search results for words."""
|
|
52
|
+
if youtube.is_url(text):
|
|
53
|
+
return youtube.fetch(text)
|
|
54
|
+
return youtube.search(text, limit)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def number_cell(number, playing):
|
|
58
|
+
return f"{'▸' if playing else ' '}{number}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def video_cells(video, number, playing=False, favorite=False):
|
|
62
|
+
"""One table row in column order: #, Title (♥ when a favorite), Uploader, Length."""
|
|
63
|
+
title = video.title + FAVORITE_MARK if favorite else video.title
|
|
64
|
+
return number_cell(number, playing), Text(title), Text(video.uploader), format_time(video.duration)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def tab_label(title, count):
|
|
68
|
+
return f"{title} ({count})" if count else title
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def queue_text(status):
|
|
72
|
+
if status["total"] <= 1:
|
|
73
|
+
return ""
|
|
74
|
+
up_next = status["up_next"]
|
|
75
|
+
return f"Up next: {up_next}" if up_next else "End of queue"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def timing_text(status):
|
|
79
|
+
started_in = status.get("started_in")
|
|
80
|
+
if player.timing() and started_in is not None:
|
|
81
|
+
return f"started in {started_in:.1f}s"
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class NowPlaying(Vertical):
|
|
86
|
+
"""Three lines built from MpvClient.status(); show() is the only writer."""
|
|
87
|
+
|
|
88
|
+
BORDER_TITLE = "Now playing"
|
|
89
|
+
|
|
90
|
+
def compose(self) -> ComposeResult:
|
|
91
|
+
yield Static(IDLE_TEXT, id="np-idle", markup=False)
|
|
92
|
+
with Horizontal(classes="np-line"):
|
|
93
|
+
yield Static(id="np-state")
|
|
94
|
+
yield Static(id="np-title", markup=False)
|
|
95
|
+
yield Static(id="np-uploader", markup=False)
|
|
96
|
+
yield Static(id="np-position")
|
|
97
|
+
with Horizontal(classes="np-line"):
|
|
98
|
+
yield ProgressBar(id="np-progress", show_percentage=False, show_eta=False)
|
|
99
|
+
yield Static(id="np-time")
|
|
100
|
+
with Horizontal(classes="np-line"):
|
|
101
|
+
yield Static(id="np-volume")
|
|
102
|
+
yield Static(id="np-next", markup=False)
|
|
103
|
+
yield Static(id="np-timing")
|
|
104
|
+
|
|
105
|
+
def on_mount(self):
|
|
106
|
+
self.show(None)
|
|
107
|
+
|
|
108
|
+
def show(self, status):
|
|
109
|
+
idle = status is None or status["idle"]
|
|
110
|
+
self.set_class(idle, "-idle")
|
|
111
|
+
if idle:
|
|
112
|
+
return
|
|
113
|
+
self.query_one("#np-state", Static).update("⏸" if status["paused"] else "▶")
|
|
114
|
+
self.query_one("#np-title", Static).update(status["title"])
|
|
115
|
+
self.query_one("#np-uploader", Static).update(f" · {status['uploader']}" if status.get("uploader") else "")
|
|
116
|
+
position = f"[{status['index']}/{status['total']}]" if status["total"] > 1 else ""
|
|
117
|
+
self.query_one("#np-position", Static).update(position)
|
|
118
|
+
self.query_one(ProgressBar).update(total=status["duration"], progress=status["position"] or 0)
|
|
119
|
+
self.query_one("#np-time", Static).update(
|
|
120
|
+
f"{format_time(status['position'])} / {format_time(status['duration'])}"
|
|
121
|
+
)
|
|
122
|
+
self.query_one("#np-volume", Static).update(player.volume_meter(status["volume"], status["muted"]))
|
|
123
|
+
self.query_one("#np-next", Static).update(queue_text(status))
|
|
124
|
+
self.query_one("#np-timing", Static).update(timing_text(status))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SearchBox(Input):
|
|
128
|
+
BINDINGS = [Binding("escape", "app.focus_results", "Back to the table", show=False)]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class VideoTable(DataTable):
|
|
132
|
+
"""A table of videos in the design's columns, with the playback keys."""
|
|
133
|
+
|
|
134
|
+
BINDINGS = [
|
|
135
|
+
Binding("space", "app.toggle_pause", "Pause"),
|
|
136
|
+
Binding("n", "app.next", "Next"),
|
|
137
|
+
Binding("p", "app.prev", "Prev"),
|
|
138
|
+
Binding("comma", f"app.seek({-player.SEEK_SECONDS})", f"Seek −{player.SEEK_SECONDS}s", show=False),
|
|
139
|
+
Binding("full_stop", f"app.seek({player.SEEK_SECONDS})", f"Seek +{player.SEEK_SECONDS}s", show=False),
|
|
140
|
+
Binding("less_than_sign", f"app.seek({-LONG_SEEK_SECONDS})", f"Seek −{LONG_SEEK_SECONDS}s", show=False),
|
|
141
|
+
Binding("greater_than_sign", f"app.seek({LONG_SEEK_SECONDS})", f"Seek +{LONG_SEEK_SECONDS}s", show=False),
|
|
142
|
+
Binding("minus", f"app.change_volume({-player.VOLUME_STEP})", f"Volume −{player.VOLUME_STEP}", show=False),
|
|
143
|
+
Binding("plus", f"app.change_volume({player.VOLUME_STEP})", f"Volume +{player.VOLUME_STEP}", show=False),
|
|
144
|
+
Binding("M", "app.toggle_mute", "Mute", show=False),
|
|
145
|
+
Binding("f", "app.favorite", "Fav"),
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
def __init__(self, **kwargs):
|
|
149
|
+
super().__init__(zebra_stripes=True, cursor_type="row", **kwargs)
|
|
150
|
+
|
|
151
|
+
def on_mount(self):
|
|
152
|
+
self.add_column("#", key="number")
|
|
153
|
+
self.add_columns("Title", "Uploader", "Length")
|
|
154
|
+
|
|
155
|
+
def fill(self, rows):
|
|
156
|
+
"""Replace every row with rows (cells in column order), the cursor kept in place."""
|
|
157
|
+
row = self.cursor_row
|
|
158
|
+
self.clear()
|
|
159
|
+
for cells in rows:
|
|
160
|
+
self.add_row(*cells)
|
|
161
|
+
self.move_cursor(row=min(row, self.row_count - 1))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class PickTable(VideoTable):
|
|
165
|
+
"""Videos to pick from (Search, History, Favorites): videos holds them in row order."""
|
|
166
|
+
|
|
167
|
+
BINDINGS = [
|
|
168
|
+
Binding("enter", "select_cursor", "Play from here", show=False),
|
|
169
|
+
Binding("a", "app.append", "Add"),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
def __init__(self, **kwargs):
|
|
173
|
+
super().__init__(**kwargs)
|
|
174
|
+
self.videos = []
|
|
175
|
+
|
|
176
|
+
def show(self, videos, playing, favorite_ids):
|
|
177
|
+
self.videos = list(videos)
|
|
178
|
+
rows = [
|
|
179
|
+
video_cells(video, number, video == playing, video.id in favorite_ids)
|
|
180
|
+
for number, video in enumerate(self.videos, start=1)
|
|
181
|
+
]
|
|
182
|
+
self.fill(rows)
|
|
183
|
+
|
|
184
|
+
def picked(self):
|
|
185
|
+
return self.videos[self.cursor_row] if self.videos else None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ResultsTable(PickTable):
|
|
189
|
+
BINDINGS = [Binding("m", "app.more", "More")]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class HistoryTable(PickTable):
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class FavoritesTable(PickTable):
|
|
197
|
+
BINDINGS = [Binding("d", "app.unfavorite", "Unfav")]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class QueueTable(VideoTable):
|
|
201
|
+
BINDINGS = [
|
|
202
|
+
Binding("enter", "app.jump", "Jump here", show=False),
|
|
203
|
+
Binding("d", "app.remove", "Remove"),
|
|
204
|
+
Binding("K,shift+up", "app.move_in_queue(-1)", "Move up"),
|
|
205
|
+
Binding("J,shift+down", "app.move_in_queue(1)", "Move down"),
|
|
206
|
+
Binding("c", "app.clear_queue", "Clear"),
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class TtyplayerCommands(Provider):
|
|
211
|
+
"""The command palette's ttyplayer entries, from COMMANDS; each runs the action its key runs."""
|
|
212
|
+
|
|
213
|
+
def run(self, action):
|
|
214
|
+
return functools.partial(self.app.run_action, action)
|
|
215
|
+
|
|
216
|
+
async def discover(self):
|
|
217
|
+
for name, action, help in COMMANDS:
|
|
218
|
+
yield DiscoveryHit(name, self.run(action), help=help)
|
|
219
|
+
|
|
220
|
+
async def search(self, query):
|
|
221
|
+
matcher = self.matcher(query)
|
|
222
|
+
for name, action, help in COMMANDS:
|
|
223
|
+
score = matcher.match(name)
|
|
224
|
+
if score > 0:
|
|
225
|
+
yield Hit(score, matcher.highlight(name), self.run(action), help=help)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class HelpScreen(ModalScreen):
|
|
229
|
+
"""Every key and command, generated from the BINDINGS lists and COMMANDS so it can never drift from them."""
|
|
230
|
+
|
|
231
|
+
BINDINGS = [
|
|
232
|
+
Binding("escape", "dismiss", "Close", show=False),
|
|
233
|
+
Binding("question_mark", "dismiss", "Close", show=False),
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
def __init__(self, sections):
|
|
237
|
+
super().__init__()
|
|
238
|
+
self.sections = sections
|
|
239
|
+
|
|
240
|
+
def compose(self) -> ComposeResult:
|
|
241
|
+
with VerticalScroll(id="help") as help:
|
|
242
|
+
help.border_title = "Keys"
|
|
243
|
+
for heading, bindings in self.sections:
|
|
244
|
+
yield Static(heading, classes="help-heading")
|
|
245
|
+
for binding in bindings:
|
|
246
|
+
yield Static(f"{self.app.get_key_display(binding):>8} {binding.description}", markup=False)
|
|
247
|
+
yield Static("Commands (Ctrl-P)", classes="help-heading")
|
|
248
|
+
for name, _, help in COMMANDS:
|
|
249
|
+
yield Static(f"{name} {help}", markup=False)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class TtyplayerApp(App):
|
|
253
|
+
TITLE = APP_NAME
|
|
254
|
+
CSS_PATH = "tui.tcss"
|
|
255
|
+
# Textual's own (themes, keys, quit, …) plus ttyplayer's.
|
|
256
|
+
COMMANDS = App.COMMANDS | {TtyplayerCommands}
|
|
257
|
+
BINDINGS = [
|
|
258
|
+
Binding("slash", "focus_search", "Search"),
|
|
259
|
+
Binding("question_mark", "help", "Help"),
|
|
260
|
+
Binding("1", "show_tab('search')", "Search tab", show=False),
|
|
261
|
+
Binding("2", "show_tab('queue')", "Queue tab", show=False),
|
|
262
|
+
Binding("3", "show_tab('history')", "History tab", show=False),
|
|
263
|
+
Binding("4", "show_tab('favorites')", "Favorites tab", show=False),
|
|
264
|
+
Binding("t", "next_theme", "Next theme", show=False),
|
|
265
|
+
# Not a priority binding: the search box must be able to take a typed q.
|
|
266
|
+
Binding("q", "quit", "Quit"),
|
|
267
|
+
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
def __init__(self, client_factory=player.MpvClient, resolve=resolve, video=False):
|
|
271
|
+
super().__init__()
|
|
272
|
+
self.client_factory = client_factory
|
|
273
|
+
self.resolve = resolve
|
|
274
|
+
self.video = video
|
|
275
|
+
self.searched = None # the text behind the Search tab's rows, for m
|
|
276
|
+
self.favorite_ids = set()
|
|
277
|
+
self.history_stale = False
|
|
278
|
+
self.queue_shown = ([], None)
|
|
279
|
+
self.client = None
|
|
280
|
+
self.remote = None
|
|
281
|
+
self.closing = False
|
|
282
|
+
|
|
283
|
+
def compose(self) -> ComposeResult:
|
|
284
|
+
yield Header(show_clock=True)
|
|
285
|
+
with Horizontal(id="search-row"):
|
|
286
|
+
yield SearchBox(placeholder="Search YouTube or paste a link…")
|
|
287
|
+
yield LoadingIndicator()
|
|
288
|
+
with TabbedContent():
|
|
289
|
+
with TabPane("Search", id="search"):
|
|
290
|
+
yield ResultsTable()
|
|
291
|
+
with TabPane("Queue", id="queue"):
|
|
292
|
+
yield QueueTable()
|
|
293
|
+
with TabPane("History", id="history"):
|
|
294
|
+
yield HistoryTable()
|
|
295
|
+
with TabPane("Favorites", id="favorites"):
|
|
296
|
+
yield FavoritesTable()
|
|
297
|
+
yield NowPlaying()
|
|
298
|
+
yield Footer()
|
|
299
|
+
|
|
300
|
+
def on_mount(self):
|
|
301
|
+
self.app_thread = threading.get_ident()
|
|
302
|
+
self.set_searching(False)
|
|
303
|
+
self.show_favorites() # first: the other tables read favorite_ids for their ♥
|
|
304
|
+
self.show_history()
|
|
305
|
+
self.query_one(SearchBox).focus()
|
|
306
|
+
|
|
307
|
+
def on_unmount(self):
|
|
308
|
+
self.shut_down()
|
|
309
|
+
|
|
310
|
+
def toast(self, message, severity):
|
|
311
|
+
# Messages carry titles and yt-dlp errors, whose [brackets] are not markup.
|
|
312
|
+
self.notify(message, severity=severity, markup=False)
|
|
313
|
+
|
|
314
|
+
# --- search ---------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def set_searching(self, searching):
|
|
317
|
+
self.query_one(LoadingIndicator).display = searching
|
|
318
|
+
|
|
319
|
+
def on_input_submitted(self, event: Input.Submitted):
|
|
320
|
+
text = event.value.strip()
|
|
321
|
+
if text:
|
|
322
|
+
self.set_searching(True)
|
|
323
|
+
self.lookup(text)
|
|
324
|
+
|
|
325
|
+
def action_more(self):
|
|
326
|
+
"""The next batch of the last search, after the rows already shown."""
|
|
327
|
+
if self.searched is None:
|
|
328
|
+
return
|
|
329
|
+
self.set_searching(True)
|
|
330
|
+
self.lookup(self.searched, self.query_one(ResultsTable).videos)
|
|
331
|
+
|
|
332
|
+
@work(thread=True, exclusive=True)
|
|
333
|
+
def lookup(self, text, shown=None):
|
|
334
|
+
"""Search for text; with shown, for the results after those (as the CLI's m does)."""
|
|
335
|
+
videos, error = None, None
|
|
336
|
+
try:
|
|
337
|
+
if shown is None:
|
|
338
|
+
videos = self.resolve(text)
|
|
339
|
+
else:
|
|
340
|
+
videos = unseen(self.resolve(text, len(shown) + SEARCH_LIMIT), shown)
|
|
341
|
+
except youtube.YouTubeError as caught:
|
|
342
|
+
error = caught
|
|
343
|
+
if not get_current_worker().is_cancelled: # a newer search replaced this one
|
|
344
|
+
self.call_from_thread(self.search_done, text, videos, error, shown)
|
|
345
|
+
|
|
346
|
+
def search_done(self, text, videos, error, shown):
|
|
347
|
+
self.set_searching(False)
|
|
348
|
+
if error is not None:
|
|
349
|
+
self.toast(f"YouTube lookup failed: {error}", severity="error")
|
|
350
|
+
elif not videos:
|
|
351
|
+
self.toast("No more results" if shown else "No videos found", severity="warning")
|
|
352
|
+
elif shown:
|
|
353
|
+
self.query_one(ResultsTable).show(shown + videos, self.playing_video(), self.favorite_ids)
|
|
354
|
+
else:
|
|
355
|
+
self.searched = text
|
|
356
|
+
self.show_results(videos)
|
|
357
|
+
|
|
358
|
+
def show_results(self, videos):
|
|
359
|
+
table = self.query_one(ResultsTable)
|
|
360
|
+
table.show(videos, self.playing_video(), self.favorite_ids)
|
|
361
|
+
table.move_cursor(row=0)
|
|
362
|
+
table.focus()
|
|
363
|
+
|
|
364
|
+
# --- playing --------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
@on(DataTable.RowSelected, "PickTable")
|
|
367
|
+
def play_from_row(self, event: DataTable.RowSelected):
|
|
368
|
+
client = self.ensure_client()
|
|
369
|
+
if client is None:
|
|
370
|
+
return
|
|
371
|
+
client.queue.clear()
|
|
372
|
+
client.index = 0
|
|
373
|
+
for video in event.data_table.videos[event.cursor_row :]:
|
|
374
|
+
client.add(video)
|
|
375
|
+
client.play_current()
|
|
376
|
+
|
|
377
|
+
def picked(self):
|
|
378
|
+
"""The video under the cursor of the focused Search/History/Favorites table, or None."""
|
|
379
|
+
table = self.focused
|
|
380
|
+
return table.picked() if isinstance(table, PickTable) else None
|
|
381
|
+
|
|
382
|
+
def action_append(self):
|
|
383
|
+
video = self.picked()
|
|
384
|
+
if video is None:
|
|
385
|
+
return
|
|
386
|
+
client = self.ensure_client()
|
|
387
|
+
if client is None:
|
|
388
|
+
return
|
|
389
|
+
was_empty = not client.queue
|
|
390
|
+
client.add(video)
|
|
391
|
+
if was_empty:
|
|
392
|
+
client.play_current()
|
|
393
|
+
else:
|
|
394
|
+
client.notify() # the Queue tab and the panel's [i/n] follow
|
|
395
|
+
|
|
396
|
+
def ensure_client(self):
|
|
397
|
+
"""The player, created (with remote control) on first use; None and a toast if mpv fails."""
|
|
398
|
+
if self.client is not None:
|
|
399
|
+
return self.client
|
|
400
|
+
try:
|
|
401
|
+
self.client = self.client_factory(self.video, on_play=self.on_play, on_state=self.on_player_state)
|
|
402
|
+
except FileNotFoundError:
|
|
403
|
+
self.toast("mpv is not installed. Install it with: brew install mpv", severity="error")
|
|
404
|
+
return None
|
|
405
|
+
except RuntimeError as error:
|
|
406
|
+
self.toast(str(error), severity="error")
|
|
407
|
+
return None
|
|
408
|
+
self.start_remote()
|
|
409
|
+
return self.client
|
|
410
|
+
|
|
411
|
+
def start_remote(self):
|
|
412
|
+
# Textual owns the terminal, so serve()'s stderr warning becomes a toast.
|
|
413
|
+
warning = io.StringIO()
|
|
414
|
+
with contextlib.redirect_stderr(warning):
|
|
415
|
+
self.remote = control.serve(self.client.handle_control)
|
|
416
|
+
if self.remote is None:
|
|
417
|
+
self.toast(warning.getvalue().strip(), severity="warning")
|
|
418
|
+
|
|
419
|
+
def on_player_state(self, status):
|
|
420
|
+
"""on_state for the player: runs on its listener thread, or on ours via play_current()."""
|
|
421
|
+
if self.closing:
|
|
422
|
+
return
|
|
423
|
+
if threading.get_ident() == self.app_thread:
|
|
424
|
+
self.show_status(status)
|
|
425
|
+
return
|
|
426
|
+
try:
|
|
427
|
+
self.call_from_thread(self.show_status, status)
|
|
428
|
+
except RuntimeError:
|
|
429
|
+
pass # the app stopped while this status was on its way
|
|
430
|
+
|
|
431
|
+
def on_play(self, video):
|
|
432
|
+
"""on_play for the player: the CLI's history.record; the History tab follows on the next on_state.
|
|
433
|
+
|
|
434
|
+
It runs under the player's queue_lock, so it must not wait for the app thread.
|
|
435
|
+
"""
|
|
436
|
+
history.record(video)
|
|
437
|
+
self.history_stale = True
|
|
438
|
+
|
|
439
|
+
def show_status(self, status):
|
|
440
|
+
if self.history_stale:
|
|
441
|
+
self.history_stale = False
|
|
442
|
+
self.show_history()
|
|
443
|
+
self.query_one(NowPlaying).show(status)
|
|
444
|
+
self.mark_playing()
|
|
445
|
+
self.show_queue()
|
|
446
|
+
|
|
447
|
+
def playing_video(self):
|
|
448
|
+
client = self.client
|
|
449
|
+
if client is None or client.idle or not 0 <= client.index < len(client.queue):
|
|
450
|
+
return None
|
|
451
|
+
return client.queue[client.index]
|
|
452
|
+
|
|
453
|
+
def mark_playing(self):
|
|
454
|
+
"""▸ in the # cell of the Search/History/Favorites row being played, a blank everywhere else."""
|
|
455
|
+
playing = self.playing_video()
|
|
456
|
+
for table in self.query(PickTable):
|
|
457
|
+
for number, (row_key, video) in enumerate(zip(table.rows, table.videos), start=1):
|
|
458
|
+
table.update_cell(row_key, "number", number_cell(number, video == playing))
|
|
459
|
+
|
|
460
|
+
# --- history and favorites ------------------------------------------
|
|
461
|
+
|
|
462
|
+
@on(TabbedContent.TabActivated)
|
|
463
|
+
def refresh_library_tab(self, event: TabbedContent.TabActivated):
|
|
464
|
+
if event.pane.id == "history":
|
|
465
|
+
self.show_history()
|
|
466
|
+
elif event.pane.id == "favorites":
|
|
467
|
+
self.show_favorites()
|
|
468
|
+
|
|
469
|
+
def show_library(self, table_type, pane, title, videos):
|
|
470
|
+
self.query_one(table_type).show(videos, self.playing_video(), self.favorite_ids)
|
|
471
|
+
self.query_one(TabbedContent).get_tab(pane).label = tab_label(title, len(videos))
|
|
472
|
+
|
|
473
|
+
def show_history(self):
|
|
474
|
+
self.show_library(HistoryTable, "history", "History", history.load(limit=HISTORY_LIMIT))
|
|
475
|
+
|
|
476
|
+
def show_favorites(self):
|
|
477
|
+
self.favorite_ids = favorites.ids()
|
|
478
|
+
self.show_library(FavoritesTable, "favorites", "Favorites", favorites.load())
|
|
479
|
+
|
|
480
|
+
def action_favorite(self):
|
|
481
|
+
"""Toggle the row under the cursor in favorites, or the track playing when there is none."""
|
|
482
|
+
video = self.picked() or self.playing_video()
|
|
483
|
+
if video is None:
|
|
484
|
+
return
|
|
485
|
+
if video.id in self.favorite_ids:
|
|
486
|
+
self.unfavorite(video)
|
|
487
|
+
else:
|
|
488
|
+
favorites.add(video)
|
|
489
|
+
self.toast(f"Favorited: {video.title}", severity="information")
|
|
490
|
+
self.favorites_changed()
|
|
491
|
+
|
|
492
|
+
def action_unfavorite(self):
|
|
493
|
+
video = self.picked()
|
|
494
|
+
if video is not None:
|
|
495
|
+
self.unfavorite(video)
|
|
496
|
+
|
|
497
|
+
def unfavorite(self, video):
|
|
498
|
+
favorites.remove_id(video.id)
|
|
499
|
+
self.toast(f"Removed from favorites: {video.title}", severity="information")
|
|
500
|
+
self.favorites_changed()
|
|
501
|
+
|
|
502
|
+
def favorites_changed(self):
|
|
503
|
+
"""The Favorites tab and every table's ♥ follow the file."""
|
|
504
|
+
self.show_favorites()
|
|
505
|
+
playing = self.playing_video()
|
|
506
|
+
for table in (self.query_one(ResultsTable), self.query_one(HistoryTable)):
|
|
507
|
+
table.show(table.videos, playing, self.favorite_ids)
|
|
508
|
+
self.queue_shown = None # the Queue tab redraws its titles too
|
|
509
|
+
self.show_queue()
|
|
510
|
+
|
|
511
|
+
# --- queue --------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
def show_queue(self):
|
|
514
|
+
"""Rebuild the Queue tab from client.queue / client.index (no ▸ when idle), the cursor kept in place.
|
|
515
|
+
|
|
516
|
+
Skipped when neither changed: on_state also fires for every time-pos tick.
|
|
517
|
+
"""
|
|
518
|
+
videos, current = [], None
|
|
519
|
+
if self.client is not None:
|
|
520
|
+
with self.client.queue_lock:
|
|
521
|
+
videos = list(self.client.queue)
|
|
522
|
+
current = None if self.client.idle else self.client.index
|
|
523
|
+
if (videos, current) == self.queue_shown:
|
|
524
|
+
return
|
|
525
|
+
self.queue_shown = (videos, current)
|
|
526
|
+
self.query_one(QueueTable).fill(
|
|
527
|
+
video_cells(video, index + 1, index == current, video.id in self.favorite_ids)
|
|
528
|
+
for index, video in enumerate(videos)
|
|
529
|
+
)
|
|
530
|
+
self.query_one(TabbedContent).get_tab("queue").label = tab_label("Queue", len(videos))
|
|
531
|
+
|
|
532
|
+
def action_jump(self):
|
|
533
|
+
if self.client:
|
|
534
|
+
self.client.jump(self.query_one(QueueTable).cursor_row)
|
|
535
|
+
|
|
536
|
+
def action_remove(self):
|
|
537
|
+
if self.client:
|
|
538
|
+
self.client.remove(self.query_one(QueueTable).cursor_row)
|
|
539
|
+
|
|
540
|
+
def action_move_in_queue(self, step):
|
|
541
|
+
table = self.query_one(QueueTable)
|
|
542
|
+
row = table.cursor_row
|
|
543
|
+
if self.client and self.client.move(row, row + step):
|
|
544
|
+
table.move_cursor(row=row + step)
|
|
545
|
+
|
|
546
|
+
def action_clear_queue(self):
|
|
547
|
+
if self.client and self.client.clear_others():
|
|
548
|
+
self.toast("Queue cleared", severity="information")
|
|
549
|
+
|
|
550
|
+
# --- keys -----------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
def action_toggle_pause(self):
|
|
553
|
+
if self.client:
|
|
554
|
+
self.client.toggle_pause()
|
|
555
|
+
|
|
556
|
+
def action_next(self):
|
|
557
|
+
if self.client:
|
|
558
|
+
self.client.next()
|
|
559
|
+
|
|
560
|
+
def action_prev(self):
|
|
561
|
+
if self.client:
|
|
562
|
+
self.client.prev()
|
|
563
|
+
|
|
564
|
+
def action_seek(self, seconds):
|
|
565
|
+
if self.client:
|
|
566
|
+
self.client.seek(seconds)
|
|
567
|
+
|
|
568
|
+
def action_change_volume(self, step):
|
|
569
|
+
if self.client:
|
|
570
|
+
self.client.change_volume(step)
|
|
571
|
+
|
|
572
|
+
def action_toggle_mute(self):
|
|
573
|
+
if self.client:
|
|
574
|
+
self.client.toggle_mute()
|
|
575
|
+
|
|
576
|
+
def action_next_theme(self):
|
|
577
|
+
themes = sorted(self.available_themes)
|
|
578
|
+
self.theme = themes[(themes.index(self.theme) + 1) % len(themes)]
|
|
579
|
+
self.toast(f"Theme: {self.theme}", severity="information")
|
|
580
|
+
|
|
581
|
+
def action_focus_search(self):
|
|
582
|
+
self.query_one(SearchBox).focus()
|
|
583
|
+
|
|
584
|
+
def action_focus_results(self):
|
|
585
|
+
pane = self.query_one(TabbedContent).active_pane
|
|
586
|
+
if pane is not None:
|
|
587
|
+
pane.query_one(DataTable).focus()
|
|
588
|
+
|
|
589
|
+
def action_show_tab(self, tab):
|
|
590
|
+
self.query_one(TabbedContent).active = tab
|
|
591
|
+
self.action_focus_results()
|
|
592
|
+
|
|
593
|
+
def action_help(self):
|
|
594
|
+
self.push_screen(
|
|
595
|
+
HelpScreen(
|
|
596
|
+
[
|
|
597
|
+
("Anywhere", self.BINDINGS),
|
|
598
|
+
("Search box", SearchBox.BINDINGS),
|
|
599
|
+
("Tables", VideoTable.BINDINGS),
|
|
600
|
+
("Search, History, Favorites tables", PickTable.BINDINGS),
|
|
601
|
+
("Search table", ResultsTable.BINDINGS),
|
|
602
|
+
("Queue table", QueueTable.BINDINGS),
|
|
603
|
+
("Favorites table", FavoritesTable.BINDINGS),
|
|
604
|
+
]
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
# --- quitting -------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
def shut_down(self):
|
|
611
|
+
"""Stop remote control, then mpv. Runs once, however the app exits."""
|
|
612
|
+
if self.closing:
|
|
613
|
+
return
|
|
614
|
+
self.closing = True
|
|
615
|
+
if self.remote:
|
|
616
|
+
self.remote.stop()
|
|
617
|
+
if self.client:
|
|
618
|
+
self.client.quit()
|