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