plex-tui 0.2.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.
plextui/app.py ADDED
@@ -0,0 +1,2248 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from dataclasses import dataclass, replace
6
+ from typing import Any
7
+
8
+ from rich.console import Group
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+ from textual import work
12
+ from textual.app import App, ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Horizontal, Vertical, VerticalScroll
15
+ from textual.message import Message
16
+ from textual.reactive import reactive
17
+ from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, Static
18
+
19
+ from .artwork import artwork_is_cached, fetch_artwork, render_artwork, render_protocol_artwork
20
+ from .auth import LoginSession, ServerChoice, save_server_choice
21
+ from .config import (
22
+ DEFAULT_AUTO_LOAD_THRESHOLD,
23
+ DEFAULT_PAGE_SIZE,
24
+ MAX_AUTO_LOAD_THRESHOLD,
25
+ MAX_PAGE_SIZE,
26
+ MIN_AUTO_LOAD_THRESHOLD,
27
+ MIN_PAGE_SIZE,
28
+ AppConfig,
29
+ cache_path,
30
+ config_path,
31
+ debug_log_path,
32
+ load_config,
33
+ save_config,
34
+ valid_mpv_window_size,
35
+ write_debug_log,
36
+ )
37
+ from .models import LibraryItem, MediaItem
38
+ from .player import (
39
+ PlayerError,
40
+ PlayerHandle,
41
+ StreamChoice,
42
+ audio_choices,
43
+ play_with_mpv,
44
+ preferred_audio_choice,
45
+ preferred_subtitle_choice,
46
+ same_stream,
47
+ stop_mpv,
48
+ stream_language_key,
49
+ stream_language_label,
50
+ subtitle_choices,
51
+ )
52
+ from .plex_service import PlexService, media_details, row_progress_marker
53
+ GRID_CARD_GAP = 2
54
+ GRID_DENSITY_SPECS = {
55
+ "compact": {"width": 19, "content_width": 16, "art_width": 14, "art_height": 7, "height": 10, "max_columns": 6},
56
+ "comfortable": {"width": 23, "content_width": 20, "art_width": 18, "art_height": 9, "height": 12, "max_columns": 5},
57
+ "large": {"width": 29, "content_width": 26, "art_width": 24, "art_height": 12, "height": 15, "max_columns": 4},
58
+ }
59
+
60
+
61
+ @dataclass
62
+ class BrowseState:
63
+ title: str
64
+ items: list[MediaItem]
65
+ selected_library: LibraryItem | None = None
66
+ search: bool = False
67
+ search_query: str = ""
68
+ global_search: bool = False
69
+ next_start: int = 0
70
+ total: int | None = None
71
+
72
+ @property
73
+ def has_more(self) -> bool:
74
+ if self.total is None or self.next_start >= self.total:
75
+ return False
76
+ if self.search:
77
+ return bool(self.search_query and self.selected_library is not None and not self.global_search)
78
+ return self.selected_library is not None
79
+
80
+
81
+ class LibraryRow(ListItem):
82
+ def __init__(self, library: LibraryItem) -> None:
83
+ super().__init__(Label(library.title))
84
+ self.library = library
85
+
86
+
87
+ class MediaRow(ListItem):
88
+ def __init__(self, media: MediaItem) -> None:
89
+ marker = ">" if not media.playable else " "
90
+ subtitle = f" [{media.kind}] {media.subtitle}".rstrip()
91
+ progress = row_progress_marker(media.raw)
92
+ progress_text = f" {progress}" if progress else ""
93
+ self.label_text = f"{marker} {media.title}{subtitle}{progress_text}"
94
+ super().__init__(Label(self.label_text))
95
+ self.media = media
96
+
97
+
98
+ class MediaGrid(Static):
99
+ can_focus = True
100
+
101
+ class Highlighted(Message):
102
+ def __init__(self, media: MediaItem) -> None:
103
+ self.media = media
104
+ super().__init__()
105
+
106
+ class Selected(Message):
107
+ def __init__(self, media: MediaItem) -> None:
108
+ self.media = media
109
+ super().__init__()
110
+
111
+ def __init__(self) -> None:
112
+ super().__init__("", id="media-grid")
113
+ self.items: list[MediaItem] = []
114
+ self.selected_index = 0
115
+ self.columns = 1
116
+ self.rows = 1
117
+ self.config: AppConfig | None = None
118
+ self.artwork: dict[str, object] = {}
119
+
120
+ @property
121
+ def selected_media(self) -> MediaItem | None:
122
+ if not self.items:
123
+ return None
124
+ return self.items[min(self.selected_index, len(self.items) - 1)]
125
+
126
+ def set_items(
127
+ self,
128
+ items: list[MediaItem],
129
+ selected_index: int,
130
+ config: AppConfig,
131
+ columns: int,
132
+ rows: int = 1,
133
+ ) -> None:
134
+ self.items = items
135
+ self.selected_index = min(max(0, selected_index), max(0, len(items) - 1))
136
+ self.columns = max(1, columns)
137
+ self.rows = max(1, rows)
138
+ self.config = config
139
+ self.artwork = {key: value for key, value in self.artwork.items() if key in {item.key for item in items}}
140
+ self.refresh_grid()
141
+ self.scroll_selected_visible()
142
+ selected = self.selected_media
143
+ if selected is not None:
144
+ self.post_message(self.Highlighted(selected))
145
+
146
+ def set_selected_key(self, selected_key: str) -> None:
147
+ for index, item in enumerate(self.items):
148
+ if item.key == selected_key:
149
+ self.set_selected_index(index)
150
+ return
151
+
152
+ def set_selected_index(self, selected_index: int) -> None:
153
+ if not self.items:
154
+ return
155
+ next_index = min(max(0, selected_index), len(self.items) - 1)
156
+ if next_index == self.selected_index:
157
+ return
158
+ self.selected_index = next_index
159
+ self.refresh_grid()
160
+ self.scroll_selected_visible()
161
+ selected = self.selected_media
162
+ if selected is not None:
163
+ self.post_message(self.Highlighted(selected))
164
+
165
+ def move_selection(self, offset: int) -> None:
166
+ self.set_selected_index(self.selected_index + offset)
167
+
168
+ def set_artwork(self, media_key: str, artwork: object) -> None:
169
+ self.artwork[media_key] = artwork
170
+ self.refresh_grid()
171
+
172
+ @property
173
+ def page_size(self) -> int:
174
+ return max(1, self.columns * self.rows)
175
+
176
+ @property
177
+ def page_start(self) -> int:
178
+ return (self.selected_index // self.page_size) * self.page_size
179
+
180
+ def visible_page_items(self, rows: int | None = None, page_offset: int = 0) -> list[MediaItem]:
181
+ page_size = max(1, self.columns * (rows or self.rows))
182
+ start = ((self.selected_index // page_size) + page_offset) * page_size
183
+ return self.items[start:start + page_size]
184
+
185
+ def refresh_grid(self) -> None:
186
+ if self.config is None or not self.items:
187
+ self.update("")
188
+ return
189
+ selected = self.selected_media
190
+ selected_key = selected.key if selected is not None else self.items[0].key
191
+ started = time.perf_counter()
192
+ self.update(render_media_grid(self.visible_page_items(), selected_key, self.config, self.columns, self.artwork))
193
+ write_performance_log("grid_render", started, f"items={len(self.visible_page_items())} columns={self.columns}")
194
+
195
+ def scroll_selected_visible(self) -> None:
196
+ if not self.is_mounted:
197
+ return
198
+ row = (self.selected_index - self.page_start) // max(1, self.columns)
199
+ if self.parent is not None:
200
+ self.parent.scroll_to(y=max(0, row * grid_card_height(self.config)), animate=False)
201
+
202
+ def on_key(self, event) -> None:
203
+ if not self.items:
204
+ return
205
+ if event.key == "left":
206
+ self.move_selection(-1)
207
+ event.stop()
208
+ elif event.key == "right":
209
+ self.move_selection(1)
210
+ event.stop()
211
+ elif event.key == "up":
212
+ self.move_selection(-self.columns)
213
+ event.stop()
214
+ elif event.key == "down":
215
+ self.move_selection(self.columns)
216
+ event.stop()
217
+ elif event.key in {"pageup", "page_up"}:
218
+ self.move_selection(-self.page_size)
219
+ event.stop()
220
+ elif event.key in {"pagedown", "page_down"}:
221
+ self.move_selection(self.page_size)
222
+ event.stop()
223
+ elif event.key == "enter":
224
+ selected = self.selected_media
225
+ if selected is not None:
226
+ self.post_message(self.Selected(selected))
227
+ event.stop()
228
+
229
+
230
+ class LoadMoreRow(ListItem):
231
+ def __init__(self, loaded: int, total: int | None) -> None:
232
+ total_text = str(total) if total is not None else "?"
233
+ super().__init__(Label(f" Load more... ({loaded} of {total_text})"))
234
+
235
+
236
+ class ServerRow(ListItem):
237
+ def __init__(self, choice: ServerChoice) -> None:
238
+ super().__init__(Label(f"{choice.name} {choice.uri}"))
239
+ self.choice = choice
240
+
241
+
242
+ class StreamRow(ListItem):
243
+ def __init__(self, choice: StreamChoice, stream_type: str, current: bool = False) -> None:
244
+ marker = "* " if current else " "
245
+ suffix = " (current)" if current else ""
246
+ super().__init__(Label(f"{marker}{choice.label}{suffix}"))
247
+ self.choice = choice
248
+ self.stream_type = stream_type
249
+
250
+
251
+ class SettingsActionRow(ListItem):
252
+ def __init__(self, label: str, action: str) -> None:
253
+ self.label_text = label
254
+ super().__init__(Label(label))
255
+ self.action = action
256
+
257
+
258
+ class SettingsHeaderRow(ListItem):
259
+ def __init__(self, label: str) -> None:
260
+ self.label_text = f"[ {label} ]"
261
+ super().__init__(Label(self.label_text))
262
+
263
+
264
+ class SettingsValueRow(ListItem):
265
+ def __init__(self, label: str) -> None:
266
+ self.label_text = label
267
+ super().__init__(Label(label))
268
+
269
+
270
+ class StatusChanged(Message):
271
+ def __init__(self, text: str) -> None:
272
+ self.text = text
273
+ super().__init__()
274
+
275
+
276
+ class PlexTuiApp(App[None]):
277
+ CSS = """
278
+ Screen {
279
+ layout: vertical;
280
+ }
281
+
282
+ #body {
283
+ height: 1fr;
284
+ }
285
+
286
+ #sidebar {
287
+ width: 30;
288
+ border: solid $accent;
289
+ }
290
+
291
+ #main {
292
+ width: 1fr;
293
+ border: solid $primary;
294
+ }
295
+
296
+ #details {
297
+ width: 42;
298
+ border: solid $secondary;
299
+ }
300
+
301
+ #search {
302
+ margin: 0 1;
303
+ }
304
+
305
+ #status {
306
+ height: 1;
307
+ padding: 0 1;
308
+ background: $surface;
309
+ }
310
+
311
+ .pane-title {
312
+ text-style: bold;
313
+ padding: 0 1;
314
+ }
315
+
316
+ #detail-content {
317
+ padding: 0 1;
318
+ width: 1fr;
319
+ height: auto;
320
+ }
321
+
322
+ #detail-scroll {
323
+ height: 1fr;
324
+ }
325
+
326
+ .active-row {
327
+ background: $accent;
328
+ color: $text;
329
+ }
330
+
331
+ #media-grid-scroll {
332
+ height: 1fr;
333
+ overflow-y: auto;
334
+ }
335
+
336
+ #media-grid {
337
+ padding: 0 1;
338
+ }
339
+ """
340
+
341
+ BINDINGS = [
342
+ Binding("q", "quit", "Quit"),
343
+ Binding("r", "reload", "Reload"),
344
+ Binding("/", "focus_search", "Search"),
345
+ Binding("g", "focus_global_search", "Global"),
346
+ Binding("tab", "focus_next", "Next"),
347
+ Binding("shift+tab", "focus_previous", "Prev"),
348
+ Binding("question_mark", "show_help", "Help"),
349
+ Binding("l", "focus_libraries", "Focus libraries"),
350
+ Binding("m", "focus_media", "Focus media list"),
351
+ Binding("v", "toggle_media_view", "View"),
352
+ Binding("left", "grid_left", "Left"),
353
+ Binding("right", "grid_right", "Right"),
354
+ Binding("comma", "show_settings", "Settings"),
355
+ Binding("escape", "back_or_clear", "Back"),
356
+ Binding("p", "play_selected", "Play"),
357
+ Binding("a", "audio_picker", "Audio"),
358
+ Binding("s", "subtitle_picker", "Subtitles"),
359
+ Binding("A", "clear_audio_preference", "Clear audio"),
360
+ Binding("S", "cycle_subtitle_mode", "Sub mode"),
361
+ Binding("x", "stop_playback", "Stop"),
362
+ ]
363
+
364
+ service: reactive[PlexService | None] = reactive(None)
365
+ selected_library: reactive[LibraryItem | None] = reactive(None)
366
+ browsing_stack: list[BrowseState]
367
+ config: AppConfig
368
+ login_session: LoginSession | None
369
+ pending_account_token: str
370
+ search_global: bool
371
+ input_mode: str
372
+ pending_confirmation_action: str
373
+ help_visible: bool
374
+ settings_visible: bool
375
+ picker_visible: bool
376
+ selected_subtitle: StreamChoice | None
377
+ selected_audio: StreamChoice | None
378
+ picker_media_key: str | None
379
+ loading_more: bool
380
+ suppress_auto_load: bool
381
+ player: PlayerHandle | None
382
+ prefetched_grid_pages: set[tuple[str, ...]]
383
+ applying_config_theme: bool
384
+ detail_refresh_token: int
385
+ grid_prefetch_token: int
386
+
387
+ def compose(self) -> ComposeResult:
388
+ yield Header()
389
+ with Horizontal(id="body"):
390
+ with Vertical(id="sidebar"):
391
+ yield Static("Libraries", classes="pane-title")
392
+ yield ListView(id="libraries")
393
+ with Vertical(id="main"):
394
+ yield Static("Media", id="media-title", classes="pane-title")
395
+ yield Input(placeholder="Search current library", id="search")
396
+ yield ListView(id="media")
397
+ with VerticalScroll(id="media-grid-scroll"):
398
+ yield MediaGrid()
399
+ with Vertical(id="details"):
400
+ yield Static("Details", classes="pane-title")
401
+ with VerticalScroll(id="detail-scroll"):
402
+ yield Static("Select an item", id="detail-content")
403
+ yield Static("", id="status")
404
+ yield Footer()
405
+
406
+ def on_mount(self) -> None:
407
+ self.browsing_stack = []
408
+ self.login_session = None
409
+ self.pending_account_token = ""
410
+ self.search_global = False
411
+ self.input_mode = ""
412
+ self.pending_confirmation_action = ""
413
+ self.help_visible = False
414
+ self.settings_visible = False
415
+ self.picker_visible = False
416
+ self.selected_subtitle = None
417
+ self.selected_audio = None
418
+ self.picker_media_key = None
419
+ self.loading_more = False
420
+ self.suppress_auto_load = False
421
+ self.player = None
422
+ self.prefetched_grid_pages = set()
423
+ self.applying_config_theme = False
424
+ self.detail_refresh_token = 0
425
+ self.grid_prefetch_token = 0
426
+ try:
427
+ self.config = load_config()
428
+ self.apply_config_theme()
429
+ except Exception:
430
+ pass
431
+ self.query_one("#search", Input).display = False
432
+ self.query_one("#media-grid-scroll", VerticalScroll).display = False
433
+ self.set_interval(1.0, self.check_player_status)
434
+ self.load_server()
435
+
436
+ def apply_config_theme(self) -> None:
437
+ if self.config.theme not in self.available_themes:
438
+ write_debug_log(f"invalid theme {self.config.theme!r}; using current theme")
439
+ return
440
+ if self.theme == self.config.theme:
441
+ return
442
+ self.applying_config_theme = True
443
+ try:
444
+ self.theme = self.config.theme
445
+ finally:
446
+ self.applying_config_theme = False
447
+
448
+ def _watch_theme(self, theme_name: str) -> None:
449
+ super()._watch_theme(theme_name)
450
+ if getattr(self, "applying_config_theme", False) or not hasattr(self, "config"):
451
+ return
452
+ if getattr(self.config, "theme", "") == theme_name:
453
+ return
454
+ self.config = replace(self.config, theme=theme_name)
455
+ try:
456
+ save_config(self.config)
457
+ except OSError as exc:
458
+ self.set_status(f"Error: failed to save theme: {exc}")
459
+
460
+ @work(thread=True)
461
+ def load_server(self) -> None:
462
+ self.post_message(StatusChanged("Connecting to Plex..."))
463
+ try:
464
+ self.config = load_config()
465
+ if not self.config.base_url or not self.config.token:
466
+ self.call_from_thread(self.begin_login)
467
+ return
468
+ service = PlexService(self.config)
469
+ libraries = service.libraries()
470
+ except Exception as exc:
471
+ self.call_from_thread(self.show_error, str(exc))
472
+ return
473
+
474
+ def update() -> None:
475
+ self.service = service
476
+ self.apply_config_theme()
477
+ self.title = f"plex-tui - {service.friendly_name}"
478
+ self.set_status(f"Connected to {service.friendly_name}")
479
+ self.populate_libraries(libraries)
480
+ if libraries:
481
+ self.open_library(libraries[0])
482
+
483
+ self.call_from_thread(update)
484
+
485
+ @work(thread=True)
486
+ def begin_login(self) -> None:
487
+ self.config = load_config()
488
+ self.post_message(StatusChanged("Starting Plex login..."))
489
+ try:
490
+ session = LoginSession(self.config)
491
+ self.login_session = session
492
+ url = session.start()
493
+ except Exception as exc:
494
+ self.call_from_thread(self.show_error, str(exc))
495
+ return
496
+
497
+ def show_url() -> None:
498
+ self.query_one("#media-title", Static).update("Plex Login")
499
+ view = self.show_media_list()
500
+ view.clear()
501
+ view.append(ListItem(Label("A Plex login page was opened in your browser.")))
502
+ view.append(ListItem(Label("If it did not open, use this URL:")))
503
+ view.append(ListItem(Label(url)))
504
+ self.show_detail_text("Complete login in your browser.")
505
+ self.set_status("Waiting for Plex login...")
506
+
507
+ self.call_from_thread(show_url)
508
+
509
+ try:
510
+ account_token, choices = session.wait()
511
+ except Exception as exc:
512
+ self.call_from_thread(self.show_error, str(exc))
513
+ return
514
+
515
+ def show_choices() -> None:
516
+ self.pending_account_token = account_token
517
+ if len(choices) == 1:
518
+ self.choose_server(choices[0])
519
+ return
520
+ self.query_one("#media-title", Static).update("Select Server")
521
+ view = self.show_media_list()
522
+ view.clear()
523
+ for choice in choices:
524
+ view.append(ServerRow(choice))
525
+ view.focus()
526
+ self.show_detail_text("Choose the connection you want this app to use.")
527
+ self.set_status("Select a Plex server connection and press Enter")
528
+
529
+ self.call_from_thread(show_choices)
530
+
531
+ def populate_libraries(self, libraries: list[LibraryItem]) -> None:
532
+ self.replace_list_rows_async(
533
+ "#libraries",
534
+ [LibraryRow(library) for library in libraries],
535
+ 0 if libraries else None,
536
+ "library-list",
537
+ )
538
+
539
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
540
+ row = event.item
541
+ if isinstance(row, LibraryRow):
542
+ self.open_library(row.library)
543
+ elif isinstance(row, MediaRow):
544
+ self.open_media(row.media)
545
+ elif isinstance(row, LoadMoreRow):
546
+ self.load_more_media()
547
+ elif isinstance(row, ServerRow):
548
+ self.choose_server(row.choice)
549
+ elif isinstance(row, StreamRow):
550
+ self.choose_stream(row.choice, row.stream_type)
551
+ elif isinstance(row, SettingsActionRow):
552
+ self.run_settings_action(row.action)
553
+
554
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
555
+ if event.list_view.id not in {"libraries", "media"}:
556
+ return
557
+ row = event.item
558
+ if row is not None and row not in list(event.list_view.children):
559
+ return
560
+ if row is not None and event.list_view.highlighted_child is not row:
561
+ return
562
+ if isinstance(row, LibraryRow):
563
+ mark_active_row(event.list_view, row)
564
+ self.set_status(context_hint(row))
565
+ elif isinstance(row, MediaRow):
566
+ mark_active_row(event.list_view, row)
567
+ self.show_media_details(row.media)
568
+ self.set_status(context_hint(row))
569
+ self.maybe_auto_load_more(row.media)
570
+ elif isinstance(row, LoadMoreRow):
571
+ mark_active_row(event.list_view, row)
572
+ self.show_detail_text("Load the next page of items.")
573
+ self.set_status(context_hint(row))
574
+ elif isinstance(row, ServerRow):
575
+ mark_active_row(event.list_view, row)
576
+ self.show_detail_text(f"{row.choice.name}\n\n{row.choice.uri}\n\nSource: {row.choice.source}")
577
+ self.set_status(context_hint(row))
578
+ elif isinstance(row, StreamRow) or isinstance(row, (SettingsActionRow, SettingsHeaderRow, SettingsValueRow)):
579
+ mark_active_row(event.list_view, row)
580
+ self.set_status(context_hint(row))
581
+ elif row is None and not list(event.list_view.children):
582
+ self.show_detail_text("Select an item")
583
+
584
+ def on_media_grid_highlighted(self, event: MediaGrid.Highlighted) -> None:
585
+ self.show_media_details(event.media)
586
+ if isinstance(event.control, MediaGrid):
587
+ self.schedule_grid_prefetch(event.control)
588
+ self.set_status(grid_status(event.control, self.browsing_stack[-1] if self.browsing_stack else None))
589
+ self.maybe_auto_load_more(event.media)
590
+
591
+ def on_media_grid_selected(self, event: MediaGrid.Selected) -> None:
592
+ self.open_media(event.media)
593
+
594
+ def choose_server(self, choice: ServerChoice) -> None:
595
+ try:
596
+ self.config = save_server_choice(self.config, self.pending_account_token, choice)
597
+ except Exception as exc:
598
+ self.show_error(f"failed to save config: {exc}")
599
+ return
600
+ self.set_status(f"Saved server {choice.name}. Connecting...")
601
+ self.load_server()
602
+
603
+ @work(thread=True)
604
+ def open_library(self, library: LibraryItem) -> None:
605
+ if self.service is None:
606
+ return
607
+ self.post_message(StatusChanged(f"Loading {library.title}..."))
608
+ started = time.perf_counter()
609
+ try:
610
+ page = self.service.library_page(library, 0, self.config.page_size)
611
+ except Exception as exc:
612
+ self.call_from_thread(self.show_error, str(exc))
613
+ return
614
+ write_performance_log(
615
+ "library_page",
616
+ started,
617
+ f"title={library.title!r} start=0 size={self.config.page_size} items={len(page.items)} total={page.total}",
618
+ )
619
+
620
+ def update() -> None:
621
+ self.selected_library = library
622
+ state = BrowseState(
623
+ library.title,
624
+ page.items,
625
+ library,
626
+ next_start=page.next_start,
627
+ total=page.total,
628
+ )
629
+ self.browsing_stack = [state]
630
+ self.show_browse_state(state)
631
+ self.focus_media_browser()
632
+ self.set_status(render_loaded_status(library.title, len(page.items), page.total, page.has_more))
633
+
634
+ self.call_from_thread(update)
635
+
636
+ def maybe_auto_load_more(self, media: MediaItem) -> None:
637
+ if self.suppress_auto_load:
638
+ self.suppress_auto_load = False
639
+ return
640
+ if self.settings_visible or self.picker_visible or self.loading_more or not self.browsing_stack:
641
+ return
642
+ state = self.browsing_stack[-1]
643
+ if should_auto_load_more(state, media.key, self.config.auto_load_threshold):
644
+ self.load_more_media(selected_key=media.key)
645
+
646
+ def selected_media(self) -> MediaItem | None:
647
+ if self.media_grid_visible():
648
+ return self.query_one("#media-grid", MediaGrid).selected_media
649
+ return selected_media_from_row(self.query_one("#media", ListView).highlighted_child)
650
+
651
+ def focus_media_browser(self) -> None:
652
+ if self.media_grid_visible():
653
+ self.query_one("#media-grid", MediaGrid).focus()
654
+ return
655
+ self.query_one("#media", ListView).focus()
656
+
657
+ def media_grid_visible(self) -> bool:
658
+ return bool(self.query_one("#media-grid-scroll", VerticalScroll).display)
659
+
660
+ def show_media_list(self) -> ListView:
661
+ media = self.query_one("#media", ListView)
662
+ grid_scroll = self.query_one("#media-grid-scroll", VerticalScroll)
663
+ media.display = True
664
+ grid_scroll.display = False
665
+ return media
666
+
667
+ def show_media_grid(self) -> MediaGrid:
668
+ media = self.query_one("#media", ListView)
669
+ grid_scroll = self.query_one("#media-grid-scroll", VerticalScroll)
670
+ grid = self.query_one("#media-grid", MediaGrid)
671
+ media.display = False
672
+ grid_scroll.display = True
673
+ return grid
674
+
675
+ @work(thread=True, exclusive=True)
676
+ def load_more_media(self, selected_key: str | None = None) -> None:
677
+ if self.service is None or not self.browsing_stack:
678
+ return
679
+ state = self.browsing_stack[-1]
680
+ if self.loading_more:
681
+ return
682
+ if not state.has_more:
683
+ self.call_from_thread(self.set_status, "No more items to load")
684
+ return
685
+ self.loading_more = True
686
+ self.post_message(StatusChanged(f"Loading more {state.title}..."))
687
+ started = time.perf_counter()
688
+ try:
689
+ if state.search:
690
+ page = self.service.search_page(state.search_query, state.selected_library, state.next_start, self.config.page_size)
691
+ else:
692
+ page = self.service.library_page(state.selected_library, state.next_start, self.config.page_size)
693
+ except Exception as exc:
694
+ self.loading_more = False
695
+ self.call_from_thread(self.show_error, str(exc))
696
+ return
697
+ write_performance_log(
698
+ "load_more_page",
699
+ started,
700
+ f"title={state.title!r} start={state.next_start} size={self.config.page_size} items={len(page.items)} total={page.total}",
701
+ )
702
+
703
+ def update() -> None:
704
+ first_new_key = page.items[0].key if page.items else None
705
+ state.items.extend(page.items)
706
+ state.next_start = page.next_start
707
+ state.total = page.total
708
+ self.loading_more = False
709
+ self.suppress_auto_load = True
710
+ self.show_browse_state(state, selected_key=selected_key or first_new_key)
711
+ self.focus_media_browser()
712
+ self.set_status(render_loaded_status(state.title, len(state.items), state.total, state.has_more))
713
+
714
+ self.call_from_thread(update)
715
+
716
+ @work(thread=True)
717
+ def open_media(self, media: MediaItem) -> None:
718
+ if self.service is None:
719
+ return
720
+ if media.playable:
721
+ self.call_from_thread(self.set_status, f"Selected {media.title}. Press p to play.")
722
+ return
723
+ self.post_message(StatusChanged(f"Opening {media.title}..."))
724
+ try:
725
+ children = self.service.children(media)
726
+ except Exception as exc:
727
+ self.call_from_thread(self.show_error, str(exc))
728
+ return
729
+
730
+ def update() -> None:
731
+ if not children:
732
+ self.set_status(f"No child items for {media.title}")
733
+ return
734
+ state = BrowseState(media.title, children, self.selected_library)
735
+ self.browsing_stack.append(state)
736
+ self.show_browse_state(state)
737
+ self.focus_media_browser()
738
+ self.set_status(f"{media.title}: {len(children)} items")
739
+
740
+ self.call_from_thread(update)
741
+
742
+ def show_media(self, title: str, items: list[MediaItem], selected_key: str | None = None) -> None:
743
+ self.query_one("#media-title", Static).update(title)
744
+ state = BrowseState(title, items)
745
+ self.show_browse_state(state, selected_key=selected_key)
746
+
747
+ def show_browse_state(self, state: BrowseState, selected_key: str | None = None) -> None:
748
+ self.query_one("#media-title", Static).update(state.title)
749
+ if state.items:
750
+ started = time.perf_counter()
751
+ selected_index = selected_media_index(state.items, selected_key)
752
+ if self.config.media_view == "grid":
753
+ grid = self.show_media_grid()
754
+ columns, rows = self.media_grid_geometry()
755
+ grid.set_items(state.items, selected_index, self.config, columns, rows)
756
+ self.schedule_grid_prefetch(grid)
757
+ else:
758
+ self.show_media_list()
759
+ rows, selected_row_index = media_rows(state.items, self.config, selected_index)
760
+ if state.has_more:
761
+ rows.append(LoadMoreRow(len(state.items), state.total))
762
+ self.replace_media_rows(rows, selected_row_index)
763
+ self.show_media_details(state.items[selected_index])
764
+ write_performance_log(
765
+ "browse_render",
766
+ started,
767
+ f"title={state.title!r} view={self.config.media_view} items={len(state.items)} selected={selected_index}",
768
+ )
769
+ else:
770
+ self.show_media_list()
771
+ self.replace_media_rows([])
772
+ self.show_detail_text("No items")
773
+
774
+ def replace_media_rows(self, rows: list[ListItem], selected_index: int | None = None) -> None:
775
+ self.replace_list_rows_async("#media", rows, selected_index, "media-list")
776
+
777
+ def replace_list_rows_async(
778
+ self,
779
+ selector: str,
780
+ rows: list[ListItem],
781
+ selected_index: int | None,
782
+ group: str,
783
+ ) -> None:
784
+ self.run_worker(
785
+ self.replace_list_rows(selector, rows, selected_index),
786
+ group=group,
787
+ exclusive=True,
788
+ )
789
+
790
+ async def replace_list_rows(self, selector: str, rows: list[ListItem], selected_index: int | None = None) -> None:
791
+ view = self.query_one(selector, ListView)
792
+ await view.clear()
793
+ if rows:
794
+ await view.extend(rows)
795
+ if selected_index is not None and rows:
796
+ set_list_index(view, selected_index)
797
+
798
+ def show_media_details(self, item: MediaItem) -> None:
799
+ self.detail_refresh_token += 1
800
+ token = self.detail_refresh_token
801
+ details = media_details(item)
802
+ self.show_detail_text(render_detail_content(details, self.config, raw=item.raw))
803
+ delay = 0.35 if self.media_grid_visible() else 0.0
804
+ self.refresh_media_details(item, token, delay)
805
+
806
+ @work(thread=True, exclusive=True)
807
+ def refresh_media_details(self, item: MediaItem, token: int, delay: float = 0.0) -> None:
808
+ started = time.perf_counter()
809
+ if delay:
810
+ time.sleep(delay)
811
+ if token != self.detail_refresh_token:
812
+ write_performance_log("detail_refresh_skipped", started, f"title={item.title!r} reason=stale")
813
+ return
814
+ if not hasattr(item.raw, "reload"):
815
+ return
816
+ try:
817
+ full_item = MediaItem(
818
+ title=item.title,
819
+ subtitle=item.subtitle,
820
+ kind=item.kind,
821
+ key=item.key,
822
+ playable=item.playable,
823
+ raw=item.raw.reload(),
824
+ artwork_path=item.artwork_path,
825
+ )
826
+ except Exception:
827
+ return
828
+ write_performance_log("detail_reload", started, f"title={item.title!r}")
829
+
830
+ details = media_details(full_item)
831
+
832
+ def update_text() -> None:
833
+ if token != self.detail_refresh_token:
834
+ return
835
+ selected = self.selected_media()
836
+ if selected is not None and selected.key == item.key:
837
+ self.show_detail_text(render_detail_content(details, self.config, raw=full_item.raw))
838
+
839
+ self.call_from_thread(update_text)
840
+
841
+ artwork = None
842
+ card_artwork = None
843
+ if artwork_enabled(self.config) and details.artwork_path:
844
+ artwork_started = time.perf_counter()
845
+ try:
846
+ data = fetch_artwork(full_item.raw, details.artwork_path, self.config)
847
+ if detail_artwork_enabled(self.config):
848
+ width, height = self.detail_artwork_size()
849
+ artwork = (
850
+ render_protocol_artwork(data, self.config.artwork_renderer, width=width, max_height=height)
851
+ or render_artwork(data, width=width, max_height=height)
852
+ )
853
+ card_artwork = render_card_artwork(data, self.config)
854
+ except Exception:
855
+ artwork = None
856
+ write_performance_log("detail_artwork", artwork_started, f"title={item.title!r} path={details.artwork_path!r}")
857
+ if not artwork and not card_artwork:
858
+ return
859
+
860
+ def update_artwork() -> None:
861
+ if token != self.detail_refresh_token:
862
+ return
863
+ selected = self.selected_media()
864
+ if selected is not None and selected.key == item.key:
865
+ if artwork is not None:
866
+ self.show_detail_text(render_detail_content(details, self.config, artwork, raw=full_item.raw))
867
+ grid = self.query_one("#media-grid", MediaGrid)
868
+ if self.media_grid_visible() and card_artwork is not None:
869
+ grid.set_artwork(item.key, card_artwork)
870
+
871
+ self.call_from_thread(update_artwork)
872
+
873
+ def show_detail_text(self, content: Any) -> None:
874
+ self.query_one("#detail-content", Static).update(content)
875
+ self.query_one("#detail-scroll", VerticalScroll).scroll_home(animate=False)
876
+
877
+ def detail_artwork_size(self) -> tuple[int, int]:
878
+ pane_width = self.query_one("#details").size.width
879
+ width = min(36, max(14, pane_width - 6))
880
+ return width, 22
881
+
882
+ def media_grid_geometry(self) -> tuple[int, int]:
883
+ media_size = self.query_one("#main").size
884
+ spec = grid_density_spec(self.config)
885
+ columns = max(1, min(int(spec["max_columns"]), max(1, media_size.width - 4) // grid_card_render_width(self.config)))
886
+ rows = max(1, min(4, max(1, media_size.height - 2) // grid_card_height(self.config)))
887
+ return columns, rows
888
+
889
+ def action_focus_search(self) -> None:
890
+ self.search_global = False
891
+ self.input_mode = "search"
892
+ search = self.query_one("#search", Input)
893
+ search.placeholder = "Search current library"
894
+ search.value = ""
895
+ search.display = True
896
+ search.focus()
897
+
898
+ def action_focus_global_search(self) -> None:
899
+ self.search_global = True
900
+ self.input_mode = "search"
901
+ search = self.query_one("#search", Input)
902
+ search.placeholder = "Search all libraries"
903
+ search.value = ""
904
+ search.display = True
905
+ search.focus()
906
+
907
+ def action_focus_libraries(self) -> None:
908
+ self.query_one("#libraries", ListView).focus()
909
+ self.set_status("Focus moved to libraries")
910
+
911
+ def action_focus_media(self) -> None:
912
+ self.focus_media_browser()
913
+ self.set_status("Focus moved to media list")
914
+
915
+ def action_toggle_media_view(self) -> None:
916
+ next_view = next_media_view(self.config.media_view)
917
+ if not self.update_preferences(media_view=next_view):
918
+ return
919
+ if self.settings_visible:
920
+ self.action_show_settings()
921
+ self.set_status(f"Media view: {media_view_value(self.config)}")
922
+ return
923
+ if self.browsing_stack:
924
+ selected = self.selected_media()
925
+ selected_key = selected.key if selected is not None else None
926
+ self.show_browse_state(self.browsing_stack[-1], selected_key=selected_key)
927
+ self.set_status(f"Media view: {media_view_value(self.config)}")
928
+
929
+ def action_grid_left(self) -> None:
930
+ self.move_grid_selection(-1)
931
+
932
+ def action_grid_right(self) -> None:
933
+ self.move_grid_selection(1)
934
+
935
+ def move_grid_selection(self, direction: int) -> None:
936
+ grid = self.query_one("#media-grid", MediaGrid)
937
+ if self.media_grid_visible():
938
+ grid.move_selection(direction)
939
+
940
+ def schedule_grid_prefetch(self, grid: MediaGrid) -> None:
941
+ if not artwork_enabled(self.config):
942
+ return
943
+ self.grid_prefetch_token += 1
944
+ token = self.grid_prefetch_token
945
+ self.prefetch_grid_items(grid.visible_page_items(), token, "current")
946
+ next_items = grid.visible_page_items(page_offset=1)
947
+ if next_items:
948
+ self.prefetch_grid_items(next_items, token, "next", delay=0.5)
949
+
950
+ @work(thread=True)
951
+ def prefetch_grid_items(
952
+ self,
953
+ items: list[MediaItem],
954
+ token: int,
955
+ page_label: str,
956
+ delay: float = 0.0,
957
+ ) -> None:
958
+ if not artwork_enabled(self.config):
959
+ return
960
+ started = time.perf_counter()
961
+ if delay:
962
+ time.sleep(delay)
963
+ if token != self.grid_prefetch_token:
964
+ write_performance_log("grid_prefetch_skipped", started, f"page={page_label} reason=stale")
965
+ return
966
+ page_key = tuple(item.key for item in items)
967
+ if page_key in self.prefetched_grid_pages:
968
+ write_performance_log("grid_prefetch_skipped", started, f"page={page_label} reason=cached items={len(items)}")
969
+ return
970
+ self.prefetched_grid_pages.add(page_key)
971
+
972
+ rendered: dict[str, object] = {}
973
+ for item in items:
974
+ if not item.artwork_path:
975
+ continue
976
+ try:
977
+ data = fetch_artwork(item.raw, item.artwork_path, self.config)
978
+ rendered[item.key] = render_card_artwork(data, self.config)
979
+ except Exception:
980
+ continue
981
+
982
+ write_performance_log(
983
+ "grid_prefetch",
984
+ started,
985
+ f"page={page_label} items={len(items)} rendered={len(rendered)}",
986
+ )
987
+ if not rendered:
988
+ return
989
+
990
+ def update() -> None:
991
+ grid = self.query_one("#media-grid", MediaGrid)
992
+ if not grid.is_mounted:
993
+ return
994
+ grid.artwork.update(rendered)
995
+ grid.refresh_grid()
996
+
997
+ self.call_from_thread(update)
998
+
999
+ def action_show_settings(self, selected_action: str | None = None) -> None:
1000
+ self.help_visible = False
1001
+ self.picker_visible = False
1002
+ self.settings_visible = True
1003
+ self.query_one("#media-title", Static).update("Settings")
1004
+ view = self.show_media_list()
1005
+ view.clear()
1006
+ selected_index = 0
1007
+ rows = settings_rows(self.config)
1008
+ for index, row in enumerate(rows):
1009
+ if selected_action and isinstance(row, SettingsActionRow) and row.action == selected_action:
1010
+ selected_index = index
1011
+ break
1012
+ for row in rows:
1013
+ view.append(row)
1014
+ self.show_detail_text(render_settings(self.config))
1015
+ view.focus()
1016
+ set_list_index(view, selected_index)
1017
+ view.call_after_refresh(set_list_index, view, selected_index)
1018
+ self.set_status("Settings")
1019
+
1020
+ def refresh_settings_after_change(self, action: str, label: str, value: str) -> None:
1021
+ self.action_show_settings(selected_action=action)
1022
+ self.show_detail_text(f"Changed\n\n{label}: {value}\n\nSettings saved.")
1023
+ self.set_status(f"{label}: {value}")
1024
+
1025
+ def action_show_help(self) -> None:
1026
+ self.help_visible = True
1027
+ self.settings_visible = False
1028
+ self.picker_visible = False
1029
+ self.query_one("#media-title", Static).update("Help")
1030
+ rows = [ListItem(Label(line)) for line in render_help().splitlines()]
1031
+ self.show_media_list()
1032
+ self.replace_media_rows(rows, 0 if rows else None)
1033
+ self.show_detail_text("Keyboard reference. Press escape to return.")
1034
+ self.query_one("#media", ListView).focus()
1035
+ self.set_status("Help")
1036
+
1037
+ def run_settings_action(self, action: str) -> None:
1038
+ if confirmation_required(action) and self.pending_confirmation_action != action:
1039
+ self.pending_confirmation_action = action
1040
+ self.show_detail_text(f"Confirm Action\n\n{settings_action_label(action)}\n\nPress Enter on the same row again to confirm.")
1041
+ self.set_status(f"Press Enter again to confirm: {settings_action_label(action)}")
1042
+ return
1043
+ if action != self.pending_confirmation_action:
1044
+ self.pending_confirmation_action = ""
1045
+ if action == "reload":
1046
+ self.settings_visible = False
1047
+ self.load_server()
1048
+ return
1049
+ if action == "relogin":
1050
+ self.settings_visible = False
1051
+ self.selected_audio = None
1052
+ self.selected_subtitle = None
1053
+ self.begin_login()
1054
+ return
1055
+ if action == "clear_tracks":
1056
+ self.pending_confirmation_action = ""
1057
+ self.selected_audio = None
1058
+ self.selected_subtitle = None
1059
+ if not self.update_preferences(
1060
+ preferred_audio_language="",
1061
+ preferred_subtitle_language="",
1062
+ subtitle_mode="auto",
1063
+ ):
1064
+ return
1065
+ self.refresh_settings_after_change(action, "Audio/subtitle preferences", "Plex/default")
1066
+ return
1067
+ if action == "clear_audio":
1068
+ self.pending_confirmation_action = ""
1069
+ if self.update_preferences(preferred_audio_language=""):
1070
+ self.refresh_settings_after_change(action, "Audio preference", "Plex/default")
1071
+ return
1072
+ if action == "subtitle_auto":
1073
+ if self.update_preferences(preferred_subtitle_language="", subtitle_mode="auto"):
1074
+ self.refresh_settings_after_change(action, "Subtitle preference", "Auto")
1075
+ return
1076
+ if action == "subtitle_none":
1077
+ if self.update_preferences(preferred_subtitle_language="", subtitle_mode="none"):
1078
+ self.refresh_settings_after_change(action, "Subtitle preference", "None")
1079
+ return
1080
+ if action == "clear_subtitle":
1081
+ self.pending_confirmation_action = ""
1082
+ if self.update_preferences(preferred_subtitle_language="", subtitle_mode="auto"):
1083
+ self.refresh_settings_after_change(action, "Subtitle preference", "Auto")
1084
+ return
1085
+ if action == "toggle_artwork":
1086
+ next_mode = "off" if self.config.artwork_mode == "on" else "on"
1087
+ if self.update_preferences(artwork_mode=next_mode):
1088
+ self.refresh_settings_after_change(action, "Artwork", artwork_mode_value(self.config))
1089
+ return
1090
+ if action == "cycle_detail_artwork":
1091
+ next_mode = next_detail_artwork_mode(self.config.detail_artwork_mode)
1092
+ if self.update_preferences(detail_artwork_mode=next_mode):
1093
+ self.refresh_settings_after_change(action, "Details artwork", detail_artwork_mode_value(self.config))
1094
+ return
1095
+ if action == "toggle_media_view":
1096
+ self.action_toggle_media_view()
1097
+ return
1098
+ if action == "cycle_grid_density":
1099
+ next_density = next_grid_density(self.config.grid_density)
1100
+ if self.update_preferences(grid_density=next_density):
1101
+ self.refresh_settings_after_change(action, "Grid density", grid_density_value(self.config))
1102
+ return
1103
+ if action == "cycle_mpv_window_size":
1104
+ self.action_cycle_mpv_window_size()
1105
+ return
1106
+ if action == "set_mpv_window_size":
1107
+ self.prompt_mpv_window_size()
1108
+ return
1109
+ if action == "reset_mpv_window_size":
1110
+ if self.update_preferences(mpv_window_size=""):
1111
+ self.refresh_settings_after_change(action, "mpv window size", "Default")
1112
+ return
1113
+ if action == "increase_page_size":
1114
+ if self.update_numeric_preference("page_size", 10, MIN_PAGE_SIZE, MAX_PAGE_SIZE):
1115
+ self.set_status(f"Page size: {self.config.page_size}")
1116
+ return
1117
+ if action == "decrease_page_size":
1118
+ if self.update_numeric_preference("page_size", -10, MIN_PAGE_SIZE, MAX_PAGE_SIZE):
1119
+ self.set_status(f"Page size: {self.config.page_size}")
1120
+ return
1121
+ if action == "reset_page_size":
1122
+ if self.update_preferences(page_size=DEFAULT_PAGE_SIZE):
1123
+ self.refresh_settings_after_change(action, "Page size", str(self.config.page_size))
1124
+ return
1125
+ if action == "set_page_size":
1126
+ self.prompt_numeric_setting(
1127
+ "page_size",
1128
+ "Page size",
1129
+ self.config.page_size,
1130
+ MIN_PAGE_SIZE,
1131
+ MAX_PAGE_SIZE,
1132
+ DEFAULT_PAGE_SIZE,
1133
+ )
1134
+ return
1135
+ if action == "increase_auto_load_threshold":
1136
+ if self.update_numeric_preference("auto_load_threshold", 5, MIN_AUTO_LOAD_THRESHOLD, MAX_AUTO_LOAD_THRESHOLD):
1137
+ self.set_status(f"Auto-load threshold: {self.config.auto_load_threshold}")
1138
+ return
1139
+ if action == "decrease_auto_load_threshold":
1140
+ if self.update_numeric_preference("auto_load_threshold", -5, MIN_AUTO_LOAD_THRESHOLD, MAX_AUTO_LOAD_THRESHOLD):
1141
+ self.set_status(f"Auto-load threshold: {self.config.auto_load_threshold}")
1142
+ return
1143
+ if action == "reset_auto_load_threshold":
1144
+ if self.update_preferences(auto_load_threshold=DEFAULT_AUTO_LOAD_THRESHOLD):
1145
+ self.refresh_settings_after_change(action, "Auto-load threshold", str(self.config.auto_load_threshold))
1146
+ return
1147
+ if action == "set_auto_load_threshold":
1148
+ self.prompt_numeric_setting(
1149
+ "auto_load_threshold",
1150
+ "Auto-load threshold",
1151
+ self.config.auto_load_threshold,
1152
+ MIN_AUTO_LOAD_THRESHOLD,
1153
+ MAX_AUTO_LOAD_THRESHOLD,
1154
+ DEFAULT_AUTO_LOAD_THRESHOLD,
1155
+ )
1156
+ return
1157
+ if action == "show_debug_log":
1158
+ path = debug_log_path()
1159
+ self.show_detail_text(f"Debug log\n\n{path}\n\nSet PLEX_TUI_PERF_LOG=1 before launch to include browsing performance timings.")
1160
+ self.set_status(f"Debug log: {path}")
1161
+ return
1162
+ if action == "artwork_renderer_block":
1163
+ if self.update_preferences(artwork_renderer="block"):
1164
+ self.refresh_settings_after_change(action, "Artwork renderer", "Block")
1165
+ return
1166
+ if action == "artwork_renderer_auto":
1167
+ if self.update_preferences(artwork_renderer="auto"):
1168
+ self.refresh_settings_after_change(action, "Artwork renderer", "Auto")
1169
+ return
1170
+ if action == "artwork_renderer_kitty":
1171
+ if self.update_preferences(artwork_renderer="kitty"):
1172
+ self.refresh_settings_after_change(action, "Artwork renderer", "Kitty")
1173
+ return
1174
+ self.set_status(f"Unknown settings action: {action}")
1175
+
1176
+ def action_subtitle_picker(self) -> None:
1177
+ media = self.selected_media()
1178
+ if media is None or not media.playable:
1179
+ self.set_status("Select playable media before choosing subtitles")
1180
+ return
1181
+ self.open_stream_picker(media, "subtitle")
1182
+
1183
+ def action_audio_picker(self) -> None:
1184
+ media = self.selected_media()
1185
+ if media is None or not media.playable:
1186
+ self.set_status("Select playable media before choosing audio")
1187
+ return
1188
+ self.open_stream_picker(media, "audio")
1189
+
1190
+ def action_clear_audio_preference(self) -> None:
1191
+ if self.update_preferences(preferred_audio_language=""):
1192
+ self.set_status("Cleared audio preference")
1193
+
1194
+ def action_cycle_subtitle_mode(self) -> None:
1195
+ if self.config.subtitle_mode == "auto":
1196
+ changes = {"preferred_subtitle_language": "", "subtitle_mode": "none"}
1197
+ else:
1198
+ changes = {"preferred_subtitle_language": "", "subtitle_mode": "auto"}
1199
+ if self.update_preferences(**changes):
1200
+ self.set_status(f"Subtitle preference: {subtitle_preference_value(self.config)}")
1201
+
1202
+ def action_cycle_mpv_window_size(self) -> None:
1203
+ next_size = next_mpv_window_size(self.config.mpv_window_size)
1204
+ if self.update_preferences(mpv_window_size=next_size):
1205
+ if self.settings_visible:
1206
+ self.action_show_settings()
1207
+ self.set_status(f"mpv window size: {mpv_window_size_value(self.config)}")
1208
+
1209
+ @work(thread=True)
1210
+ def open_stream_picker(self, media: MediaItem, stream_type: str) -> None:
1211
+ self.post_message(StatusChanged(f"Loading {stream_type} tracks..."))
1212
+ try:
1213
+ choices = subtitle_choices(media.raw) if stream_type == "subtitle" else audio_choices(media.raw)
1214
+ except Exception as exc:
1215
+ self.call_from_thread(self.show_error, str(exc))
1216
+ return
1217
+
1218
+ def update() -> None:
1219
+ current_choice = self.current_stream_choice(media.raw, choices, stream_type)
1220
+ current_index = selected_stream_index(choices, current_choice)
1221
+ self.picker_visible = True
1222
+ self.settings_visible = False
1223
+ self.picker_media_key = media.key
1224
+ picker_title = "Subtitle Tracks" if stream_type == "subtitle" else "Audio Tracks"
1225
+ self.query_one("#media-title", Static).update(f"{picker_title}: {media.title}")
1226
+ rows = [
1227
+ StreamRow(choice, stream_type, stream_choice_matches(choice, current_choice))
1228
+ for choice in choices
1229
+ ]
1230
+ self.show_media_list()
1231
+ self.replace_media_rows(rows, current_index if choices else None)
1232
+ view = self.query_one("#media", ListView)
1233
+ view.focus()
1234
+ self.show_detail_text(render_picker_details(stream_type, current_choice, self.config))
1235
+ self.set_status(f"Choose {stream_type} track")
1236
+
1237
+ self.call_from_thread(update)
1238
+
1239
+ def choose_stream(self, choice: StreamChoice, stream_type: str) -> None:
1240
+ try:
1241
+ self.save_stream_preference(choice, stream_type)
1242
+ except OSError as exc:
1243
+ self.show_error(f"failed to save preference: {exc}")
1244
+ return
1245
+ if stream_type == "subtitle":
1246
+ self.selected_subtitle = None
1247
+ self.set_status(f"Subtitle preference: {choice.label}")
1248
+ elif stream_type == "audio":
1249
+ self.selected_audio = None
1250
+ self.set_status(f"Audio preference: {choice.label}")
1251
+ self.picker_visible = False
1252
+ if self.browsing_stack:
1253
+ state = self.browsing_stack[-1]
1254
+ self.show_browse_state(state, selected_key=self.picker_media_key)
1255
+ self.picker_media_key = None
1256
+ self.focus_media_browser()
1257
+
1258
+ def save_stream_preference(self, choice: StreamChoice, stream_type: str) -> None:
1259
+ if stream_type == "audio":
1260
+ self.config = replace(self.config, preferred_audio_language=stream_preference_key(choice))
1261
+ elif choice.stream_id is None:
1262
+ self.config = replace(self.config, preferred_subtitle_language="", subtitle_mode="auto")
1263
+ elif choice.stream_id == 0:
1264
+ self.config = replace(self.config, preferred_subtitle_language="", subtitle_mode="none")
1265
+ else:
1266
+ self.config = replace(
1267
+ self.config,
1268
+ preferred_subtitle_language=stream_preference_key(choice),
1269
+ subtitle_mode="preferred",
1270
+ )
1271
+ save_config(self.config)
1272
+
1273
+ def update_preferences(self, **changes: object) -> bool:
1274
+ self.config = replace(self.config, **changes)
1275
+ try:
1276
+ save_config(self.config)
1277
+ except OSError as exc:
1278
+ self.show_error(f"failed to save preference: {exc}")
1279
+ return False
1280
+ return True
1281
+
1282
+ def update_numeric_preference(self, name: str, step: int, minimum: int, maximum: int) -> bool:
1283
+ current = int(getattr(self.config, name))
1284
+ value = min(maximum, max(minimum, current + step))
1285
+ if value == current:
1286
+ self.action_show_settings()
1287
+ return True
1288
+ if not self.update_preferences(**{name: value}):
1289
+ return False
1290
+ label = numeric_setting_label(name)
1291
+ self.refresh_settings_after_change(numeric_step_action(name, step), label, str(value))
1292
+ return True
1293
+
1294
+ def prompt_mpv_window_size(self) -> None:
1295
+ self.input_mode = "mpv_window_size"
1296
+ search = self.query_one("#search", Input)
1297
+ search.placeholder = 'mpv window size: 1280x720, 80%, 80%x80%, or empty for default'
1298
+ search.value = self.config.mpv_window_size
1299
+ search.display = True
1300
+ search.focus()
1301
+ self.show_detail_text("Enter an mpv --autofit value. Examples: 1280x720, 80%, 80%x80%. Submit empty to reset to Default.")
1302
+ self.set_status("Enter custom mpv window size")
1303
+
1304
+ def save_mpv_window_size_input(self, value: str) -> None:
1305
+ size = value.strip()
1306
+ if size and not valid_mpv_window_size(size):
1307
+ self.prompt_mpv_window_size()
1308
+ self.set_status("Invalid mpv window size. Use 1280x720, 80%, or 80%x80%.")
1309
+ return
1310
+ if not self.update_preferences(mpv_window_size=size):
1311
+ return
1312
+ self.input_mode = ""
1313
+ self.refresh_settings_after_change("set_mpv_window_size", "mpv window size", mpv_window_size_value(self.config))
1314
+
1315
+ def prompt_numeric_setting(
1316
+ self,
1317
+ name: str,
1318
+ label: str,
1319
+ current: int,
1320
+ minimum: int,
1321
+ maximum: int,
1322
+ default: int,
1323
+ ) -> None:
1324
+ self.input_mode = name
1325
+ search = self.query_one("#search", Input)
1326
+ search.placeholder = f"{label}: {minimum}-{maximum}, or empty for default {default}"
1327
+ search.value = str(current)
1328
+ search.display = True
1329
+ search.focus()
1330
+ self.show_detail_text(f"Enter {label.lower()} as a whole number from {minimum} to {maximum}. Submit empty to reset to {default}.")
1331
+ self.set_status(f"Enter custom {label.lower()}")
1332
+
1333
+ def save_numeric_setting_input(
1334
+ self,
1335
+ name: str,
1336
+ label: str,
1337
+ value: str,
1338
+ minimum: int,
1339
+ maximum: int,
1340
+ default: int,
1341
+ ) -> None:
1342
+ text = value.strip()
1343
+ if not text:
1344
+ parsed = default
1345
+ else:
1346
+ try:
1347
+ parsed = int(text)
1348
+ except ValueError:
1349
+ self.prompt_numeric_setting(name, label, int(getattr(self.config, name)), minimum, maximum, default)
1350
+ self.set_status(f"Invalid {label.lower()}. Use a whole number from {minimum} to {maximum}.")
1351
+ return
1352
+ if parsed < minimum or parsed > maximum:
1353
+ self.prompt_numeric_setting(name, label, int(getattr(self.config, name)), minimum, maximum, default)
1354
+ self.set_status(f"Invalid {label.lower()}. Use a whole number from {minimum} to {maximum}.")
1355
+ return
1356
+ if not self.update_preferences(**{name: parsed}):
1357
+ return
1358
+ self.input_mode = ""
1359
+ self.refresh_settings_after_change(f"set_{name}", label, str(parsed))
1360
+
1361
+ def current_stream_choice(
1362
+ self,
1363
+ item: object,
1364
+ choices: list[StreamChoice],
1365
+ stream_type: str,
1366
+ ) -> StreamChoice | None:
1367
+ if stream_type == "subtitle":
1368
+ choice = preferred_subtitle_choice(
1369
+ item,
1370
+ self.config.preferred_subtitle_language,
1371
+ self.config.subtitle_mode,
1372
+ )
1373
+ if choice is not None:
1374
+ return choice
1375
+ return choices[0] if choices else None
1376
+ choice = preferred_audio_choice(item, self.config.preferred_audio_language)
1377
+ if choice is not None:
1378
+ return choice
1379
+ for candidate in choices:
1380
+ if getattr(candidate.stream, "selected", False):
1381
+ return candidate
1382
+ return choices[0] if choices else None
1383
+
1384
+ def on_input_submitted(self, event: Input.Submitted) -> None:
1385
+ if event.input.id == "search":
1386
+ query = event.value.strip()
1387
+ event.input.display = False
1388
+ event.input.value = ""
1389
+ if self.input_mode == "mpv_window_size":
1390
+ self.save_mpv_window_size_input(query)
1391
+ return
1392
+ if self.input_mode == "page_size":
1393
+ self.save_numeric_setting_input("page_size", "Page size", query, MIN_PAGE_SIZE, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE)
1394
+ return
1395
+ if self.input_mode == "auto_load_threshold":
1396
+ self.save_numeric_setting_input(
1397
+ "auto_load_threshold",
1398
+ "Auto-load threshold",
1399
+ query,
1400
+ MIN_AUTO_LOAD_THRESHOLD,
1401
+ MAX_AUTO_LOAD_THRESHOLD,
1402
+ DEFAULT_AUTO_LOAD_THRESHOLD,
1403
+ )
1404
+ return
1405
+ self.input_mode = ""
1406
+ self.run_search(query, self.search_global)
1407
+
1408
+ @work(thread=True)
1409
+ def run_search(self, query: str, global_search: bool = False) -> None:
1410
+ if self.service is None or not query:
1411
+ return
1412
+ scope = "all libraries" if global_search else "current library"
1413
+ self.post_message(StatusChanged(f"Searching {scope} for {query}..."))
1414
+ started = time.perf_counter()
1415
+ try:
1416
+ library = None if global_search else self.selected_library
1417
+ page = self.service.search_page(query, library, 0, self.config.page_size)
1418
+ except Exception as exc:
1419
+ self.call_from_thread(self.show_error, str(exc))
1420
+ return
1421
+ write_performance_log(
1422
+ "search_page",
1423
+ started,
1424
+ f"query={query!r} global={global_search} size={self.config.page_size} items={len(page.items)} total={page.total}",
1425
+ )
1426
+
1427
+ def update() -> None:
1428
+ title = f"Global search: {query}" if global_search else f"Search: {query}"
1429
+ if self.browsing_stack and self.browsing_stack[-1].search:
1430
+ self.browsing_stack.pop()
1431
+ state = BrowseState(
1432
+ title,
1433
+ page.items,
1434
+ None if global_search else self.selected_library,
1435
+ search=True,
1436
+ search_query=query,
1437
+ global_search=global_search,
1438
+ next_start=page.next_start,
1439
+ total=page.total,
1440
+ )
1441
+ self.browsing_stack.append(state)
1442
+ self.show_browse_state(state)
1443
+ self.focus_media_browser()
1444
+ self.set_status(render_loaded_status(title, len(page.items), page.total, page.has_more))
1445
+
1446
+ self.call_from_thread(update)
1447
+
1448
+ def action_back_or_clear(self) -> None:
1449
+ search = self.query_one("#search", Input)
1450
+ if search.display:
1451
+ search.value = ""
1452
+ search.display = False
1453
+ input_mode = self.input_mode
1454
+ self.input_mode = ""
1455
+ if input_mode in {"mpv_window_size", "page_size", "auto_load_threshold"}:
1456
+ self.action_show_settings()
1457
+ return
1458
+ if self.browsing_stack:
1459
+ state = self.browsing_stack[-1]
1460
+ self.show_browse_state(state)
1461
+ self.focus_media_browser()
1462
+ return
1463
+
1464
+ if self.help_visible or self.settings_visible or self.picker_visible:
1465
+ self.help_visible = False
1466
+ self.settings_visible = False
1467
+ self.picker_visible = False
1468
+ selected_key = self.picker_media_key
1469
+ self.picker_media_key = None
1470
+ if self.browsing_stack:
1471
+ state = self.browsing_stack[-1]
1472
+ self.show_browse_state(state, selected_key=selected_key)
1473
+ self.focus_media_browser()
1474
+ return
1475
+
1476
+ if len(self.browsing_stack) > 1:
1477
+ self.browsing_stack.pop()
1478
+ state = self.browsing_stack[-1]
1479
+ self.show_browse_state(state)
1480
+ self.focus_media_browser()
1481
+ self.set_status(state.title)
1482
+
1483
+ def action_play_selected(self) -> None:
1484
+ media = self.selected_media()
1485
+ if media is None:
1486
+ self.set_status("No media selected")
1487
+ return
1488
+ if not media.playable:
1489
+ self.set_status("Selected item is not directly playable")
1490
+ return
1491
+ subtitle_choice = preferred_subtitle_choice(
1492
+ media.raw,
1493
+ self.config.preferred_subtitle_language,
1494
+ self.config.subtitle_mode,
1495
+ )
1496
+ audio_choice = preferred_audio_choice(media.raw, self.config.preferred_audio_language)
1497
+ try:
1498
+ stop_mpv(self.player)
1499
+ self.player = play_with_mpv(
1500
+ media.raw,
1501
+ subtitle_choice=subtitle_choice,
1502
+ audio_choice=audio_choice,
1503
+ window_size=self.config.mpv_window_size,
1504
+ )
1505
+ except PlayerError as exc:
1506
+ self.show_error(str(exc))
1507
+ return
1508
+ self.detail_refresh_token += 1
1509
+ self.show_detail_text(
1510
+ render_playback_details(media.title, self.player, self.config, audio_choice, subtitle_choice)
1511
+ )
1512
+ self.set_status(
1513
+ render_playback_status(media.title, self.player, self.config, audio_choice, subtitle_choice)
1514
+ )
1515
+
1516
+ def check_player_status(self) -> None:
1517
+ if self.player is None:
1518
+ return
1519
+ status = playback_exit_status(self.player, debug_log_path())
1520
+ if status is None:
1521
+ return
1522
+ selected = self.selected_media()
1523
+ self.player = None
1524
+ if selected is not None:
1525
+ self.show_media_details(selected)
1526
+ self.set_status(status)
1527
+
1528
+ def action_stop_playback(self) -> None:
1529
+ if self.player is None:
1530
+ self.set_status("Nothing is playing")
1531
+ self.player = None
1532
+ return
1533
+ if not self.player.active:
1534
+ self.set_status(playback_exit_status(self.player, debug_log_path()) or "Nothing is playing")
1535
+ self.player = None
1536
+ return
1537
+ title = self.player.title
1538
+ stop_mpv(self.player)
1539
+ self.player = None
1540
+ self.set_status(f"Stopped {title}")
1541
+
1542
+ def action_reload(self) -> None:
1543
+ self.load_server()
1544
+
1545
+ def on_unmount(self) -> None:
1546
+ if self.login_session is not None:
1547
+ self.login_session.stop()
1548
+
1549
+ def on_status_changed(self, event: StatusChanged) -> None:
1550
+ self.set_status(event.text)
1551
+
1552
+ def set_status(self, text: str) -> None:
1553
+ self.query_one("#status", Static).update(text)
1554
+
1555
+ def show_error(self, text: str) -> None:
1556
+ config_hint = f"Config: {config_path()}"
1557
+ self.set_status(f"Error: {text}")
1558
+ self.query_one("#media-title", Static).update("Error")
1559
+ view = self.show_media_list()
1560
+ view.clear()
1561
+ view.append(ListItem(Label(f"{text}\n{config_hint}")))
1562
+ self.show_detail_text(config_hint)
1563
+
1564
+
1565
+ def format_offset(milliseconds: int) -> str:
1566
+ seconds = max(0, milliseconds // 1000)
1567
+ hours, remainder = divmod(seconds, 3600)
1568
+ minutes, secs = divmod(remainder, 60)
1569
+ if hours:
1570
+ return f"{hours}:{minutes:02d}:{secs:02d}"
1571
+ return f"{minutes}:{secs:02d}"
1572
+
1573
+
1574
+ def render_details(details: object, config: AppConfig | None = None, raw: object | None = None) -> str:
1575
+ lines = [getattr(details, "title"), ""]
1576
+
1577
+ lines.append("Metadata")
1578
+ for label, value in getattr(details, "metadata"):
1579
+ lines.append(f"{label}: {value}")
1580
+ lines.append(f"Playable: {'yes' if getattr(details, 'playable') else 'no'}")
1581
+ lines.append(f"Artwork: {artwork_status(details, config)}")
1582
+
1583
+ if config is not None:
1584
+ lines.extend([
1585
+ "",
1586
+ "Preferences",
1587
+ f"Audio preference: {preference_value(config.preferred_audio_language)}",
1588
+ f"Subtitle mode: {subtitle_mode_value(config)}",
1589
+ f"Subtitle language: {subtitle_language_value(config)}",
1590
+ ])
1591
+ if raw is not None:
1592
+ effective = effective_stream_preference_rows(raw, config)
1593
+ if effective:
1594
+ lines.extend(["", "Effective Playback"])
1595
+ lines.extend(f"{label}: {value}" for label, value in effective)
1596
+
1597
+ lines.extend(["", "Audio"])
1598
+ audio = getattr(details, "audio", [])
1599
+ if audio:
1600
+ lines.extend(f"- {track}" for track in audio)
1601
+ else:
1602
+ lines.append("None reported")
1603
+
1604
+ lines.extend(["", "Subtitles"])
1605
+ subtitles = getattr(details, "subtitles")
1606
+ if subtitles:
1607
+ lines.extend(f"- {subtitle}" for subtitle in subtitles)
1608
+ else:
1609
+ lines.append("None reported")
1610
+
1611
+ summary = getattr(details, "summary")
1612
+ if summary:
1613
+ lines.extend(["", "Summary", summary])
1614
+
1615
+ return "\n".join(lines)
1616
+
1617
+
1618
+ def render_detail_content(
1619
+ details: object,
1620
+ config: AppConfig | None = None,
1621
+ artwork: object | None = None,
1622
+ raw: object | None = None,
1623
+ ) -> object:
1624
+ text = render_details(details, config, raw)
1625
+ if artwork is None:
1626
+ return text
1627
+ return Group(artwork, Text(""), text)
1628
+
1629
+
1630
+ def media_rows(
1631
+ items: list[MediaItem],
1632
+ config: AppConfig,
1633
+ selected_index: int,
1634
+ ) -> tuple[list[ListItem], int]:
1635
+ return [media_row(item, config) for item in items], selected_index
1636
+
1637
+
1638
+ def media_row(item: MediaItem, config: AppConfig) -> MediaRow:
1639
+ return MediaRow(item)
1640
+
1641
+
1642
+ def render_media_grid(
1643
+ items: list[MediaItem],
1644
+ selected_key: str,
1645
+ config: AppConfig,
1646
+ columns: int,
1647
+ artwork_overrides: dict[str, object] | None = None,
1648
+ ) -> object:
1649
+ rows = []
1650
+ for start in range(0, len(items), columns):
1651
+ chunk = items[start:start + columns]
1652
+ cards = [
1653
+ render_media_grid_card(item, item.key == selected_key, config, artwork_overrides)
1654
+ for item in chunk
1655
+ ]
1656
+ row = Table.grid(padding=(0, 1))
1657
+ for _ in cards:
1658
+ row.add_column(width=grid_card_width(config), no_wrap=True)
1659
+ row.add_row(*cards)
1660
+ rows.append(row)
1661
+ return Group(*rows)
1662
+
1663
+
1664
+ def render_media_grid_card(
1665
+ media: MediaItem,
1666
+ selected: bool,
1667
+ config: AppConfig,
1668
+ artwork_overrides: dict[str, object] | None = None,
1669
+ ) -> object:
1670
+ marker = "▸ " if selected else " "
1671
+ title_style = "bold #e5a00d" if selected else "bold"
1672
+ content_width = grid_card_content_width(config)
1673
+ title = truncate_text(media.title, content_width)
1674
+ subtitle = truncate_text(" ".join(bit for bit in (media.kind, media.subtitle) if bit), content_width)
1675
+ artwork = artwork_overrides.get(media.key) if artwork_overrides is not None else None
1676
+ if artwork is None:
1677
+ artwork = cached_card_artwork(media, config)
1678
+ if artwork is None:
1679
+ status = "poster" if media.artwork_path else "no poster"
1680
+ artwork = Text(f"[{status}]", style="dim")
1681
+ footer = "selected" if selected else ""
1682
+ return Group(
1683
+ artwork,
1684
+ Text(f"{marker}{title}", style=title_style),
1685
+ Text(f" {subtitle}", style="dim"),
1686
+ Text(f" {footer}", style="#e5a00d" if selected else "dim"),
1687
+ )
1688
+
1689
+
1690
+ def cached_card_artwork(media: MediaItem, config: AppConfig) -> object | None:
1691
+ if not artwork_enabled(config) or not media.artwork_path or not artwork_is_cached(media.artwork_path, config):
1692
+ return None
1693
+ try:
1694
+ return render_card_artwork(fetch_artwork(media.raw, media.artwork_path, config), config)
1695
+ except Exception:
1696
+ return None
1697
+
1698
+
1699
+ def render_card_artwork(data: bytes, config: AppConfig) -> object:
1700
+ spec = grid_density_spec(config)
1701
+ return render_artwork(data, width=int(spec["art_width"]), max_height=int(spec["art_height"]))
1702
+
1703
+
1704
+ def grid_density_spec(config: AppConfig | None) -> dict[str, int]:
1705
+ density = getattr(config, "grid_density", "comfortable")
1706
+ return GRID_DENSITY_SPECS.get(density, GRID_DENSITY_SPECS["comfortable"])
1707
+
1708
+
1709
+ def grid_card_width(config: AppConfig | None) -> int:
1710
+ return int(grid_density_spec(config)["width"])
1711
+
1712
+
1713
+ def grid_card_content_width(config: AppConfig | None) -> int:
1714
+ return int(grid_density_spec(config)["content_width"])
1715
+
1716
+
1717
+ def grid_card_render_width(config: AppConfig | None) -> int:
1718
+ return grid_card_width(config) + GRID_CARD_GAP
1719
+
1720
+
1721
+ def grid_card_height(config: AppConfig | None) -> int:
1722
+ return int(grid_density_spec(config)["height"])
1723
+
1724
+
1725
+ def truncate_text(value: str, width: int) -> str:
1726
+ if len(value) <= width:
1727
+ return value
1728
+ return value[:max(0, width - 3)] + "..."
1729
+
1730
+
1731
+ def selected_media_from_row(row: object) -> MediaItem | None:
1732
+ if isinstance(row, MediaRow):
1733
+ return row.media
1734
+ return None
1735
+
1736
+
1737
+ def artwork_enabled(config: AppConfig | None) -> bool:
1738
+ return config is None or config.artwork_mode == "on"
1739
+
1740
+
1741
+ def detail_artwork_enabled(config: AppConfig) -> bool:
1742
+ if not artwork_enabled(config):
1743
+ return False
1744
+ if config.detail_artwork_mode == "off":
1745
+ return False
1746
+ if config.detail_artwork_mode == "list_only" and config.media_view == "grid":
1747
+ return False
1748
+ return True
1749
+
1750
+
1751
+ def artwork_status(details: object, config: AppConfig | None) -> str:
1752
+ if not artwork_enabled(config):
1753
+ return "disabled"
1754
+ path = getattr(details, "artwork_path", "")
1755
+ if not path:
1756
+ return "missing"
1757
+ if config is not None and artwork_is_cached(path, config):
1758
+ return "cached"
1759
+ return "available"
1760
+
1761
+
1762
+ def settings_rows(config: AppConfig) -> list[ListItem]:
1763
+ return [
1764
+ SettingsHeaderRow("Account"),
1765
+ SettingsValueRow(f"Server: {config.base_url or 'not set'}"),
1766
+ SettingsValueRow(f"Server Token: {'saved' if config.token else 'not set'}"),
1767
+ SettingsValueRow(f"Account Token: {'saved' if config.account_token else 'not set'}"),
1768
+ SettingsActionRow("Reconnect / reload libraries", "reload"),
1769
+ SettingsActionRow("Relogin with Plex", "relogin"),
1770
+ SettingsHeaderRow("Streams"),
1771
+ SettingsValueRow(f"Audio Preference: {preference_value(config.preferred_audio_language)}"),
1772
+ SettingsValueRow(f"Subtitle Mode: {subtitle_mode_value(config)}"),
1773
+ SettingsValueRow(f"Subtitle Language: {subtitle_language_value(config)}"),
1774
+ SettingsActionRow("Clear audio preference", "clear_audio"),
1775
+ SettingsActionRow("Set subtitles to Auto", "subtitle_auto"),
1776
+ SettingsActionRow("Set subtitles to None", "subtitle_none"),
1777
+ SettingsActionRow("Clear subtitle preference", "clear_subtitle"),
1778
+ SettingsActionRow("Clear audio/subtitle preferences", "clear_tracks"),
1779
+ SettingsHeaderRow("Playback"),
1780
+ SettingsActionRow(f"mpv Window Size: {mpv_window_size_value(config)} [cycle]", "cycle_mpv_window_size"),
1781
+ SettingsActionRow("mpv Window Size: set custom value...", "set_mpv_window_size"),
1782
+ SettingsActionRow("mpv Window Size: reset to Default", "reset_mpv_window_size"),
1783
+ SettingsHeaderRow("Artwork"),
1784
+ SettingsActionRow(f"Artwork: {artwork_mode_value(config)} [toggle]", "toggle_artwork"),
1785
+ SettingsActionRow(f"Details Artwork: {detail_artwork_mode_value(config)} [cycle]", "cycle_detail_artwork"),
1786
+ SettingsActionRow("Artwork Renderer: block", "artwork_renderer_block"),
1787
+ SettingsActionRow("Artwork Renderer: auto", "artwork_renderer_auto"),
1788
+ SettingsActionRow("Artwork Renderer: Kitty", "artwork_renderer_kitty"),
1789
+ SettingsHeaderRow("Browsing"),
1790
+ SettingsActionRow(f"Media View: {media_view_value(config)} [toggle]", "toggle_media_view"),
1791
+ SettingsActionRow(f"Grid Density: {grid_density_value(config)} [cycle]", "cycle_grid_density"),
1792
+ SettingsActionRow(f"Page Size: {config.page_size} [-10]", "decrease_page_size"),
1793
+ SettingsActionRow(f"Page Size: {config.page_size} [+10]", "increase_page_size"),
1794
+ SettingsActionRow("Page Size: set custom value...", "set_page_size"),
1795
+ SettingsActionRow(f"Page Size: reset to {DEFAULT_PAGE_SIZE}", "reset_page_size"),
1796
+ SettingsActionRow(f"Auto-load Threshold: {config.auto_load_threshold} [-5]", "decrease_auto_load_threshold"),
1797
+ SettingsActionRow(f"Auto-load Threshold: {config.auto_load_threshold} [+5]", "increase_auto_load_threshold"),
1798
+ SettingsActionRow("Auto-load Threshold: set custom value...", "set_auto_load_threshold"),
1799
+ SettingsActionRow(f"Auto-load Threshold: reset to {DEFAULT_AUTO_LOAD_THRESHOLD}", "reset_auto_load_threshold"),
1800
+ SettingsHeaderRow("Diagnostics"),
1801
+ SettingsValueRow(f"Config Path: {config_path()}"),
1802
+ SettingsValueRow(f"Cache Path: {cache_path()}"),
1803
+ SettingsValueRow(f"Debug Log: {debug_log_path()}"),
1804
+ SettingsValueRow(f"Client ID: {config.client_identifier or 'not set'}"),
1805
+ SettingsValueRow(f"Theme: {config.theme}"),
1806
+ SettingsActionRow("Show debug log path", "show_debug_log"),
1807
+ ]
1808
+
1809
+
1810
+ def render_settings(config: AppConfig) -> str:
1811
+ lines = [
1812
+ "Settings",
1813
+ "",
1814
+ "Account",
1815
+ f"Server: {config.base_url or 'not set'}",
1816
+ f"Server Token: {'saved' if config.token else 'not set'}",
1817
+ f"Account Token: {'saved' if config.account_token else 'not set'}",
1818
+ "Reconnect / reload libraries",
1819
+ "Relogin with Plex",
1820
+ "",
1821
+ "Streams",
1822
+ f"Audio Preference: {preference_value(config.preferred_audio_language)}",
1823
+ f"Subtitle Mode: {subtitle_mode_value(config)}",
1824
+ f"Subtitle Language: {subtitle_language_value(config)}",
1825
+ "Clear audio preference",
1826
+ "Set subtitles to Auto",
1827
+ "Set subtitles to None",
1828
+ "Clear subtitle preference",
1829
+ "Clear audio/subtitle preferences",
1830
+ "",
1831
+ "Playback",
1832
+ f"mpv Window Size: {mpv_window_size_value(config)}",
1833
+ "Set custom mpv window size with values like 1280x720, 80%, or 80%x80%.",
1834
+ "",
1835
+ "Artwork",
1836
+ f"Artwork: {artwork_mode_value(config)}",
1837
+ f"Details Artwork: {detail_artwork_mode_value(config)}",
1838
+ f"Artwork Renderer: {artwork_renderer_value(config)}",
1839
+ "",
1840
+ "Browsing",
1841
+ f"Media View: {media_view_value(config)}",
1842
+ f"Grid Density: {grid_density_value(config)}",
1843
+ f"Page Size: {config.page_size}",
1844
+ f"Auto-load Threshold: {config.auto_load_threshold}",
1845
+ "Set custom browsing values with whole numbers inside the allowed range.",
1846
+ "",
1847
+ "Diagnostics",
1848
+ f"Config Path: {config_path()}",
1849
+ f"Cache Path: {cache_path()}",
1850
+ f"Debug Log: {debug_log_path()}",
1851
+ f"Client ID: {config.client_identifier or 'not set'}",
1852
+ f"Theme: {config.theme}",
1853
+ ]
1854
+ return "\n".join(lines)
1855
+
1856
+
1857
+ def render_help() -> str:
1858
+ return "\n".join([
1859
+ "Navigation",
1860
+ "enter: open selected row",
1861
+ "escape: go back / close current view",
1862
+ "tab / shift+tab: move focus",
1863
+ "l: focus libraries",
1864
+ "m: focus media list",
1865
+ "v: toggle list/grid view",
1866
+ "left/right: move across grid cards",
1867
+ "pageup/pagedown: move one grid page",
1868
+ "",
1869
+ "Search",
1870
+ "/: search current library",
1871
+ "g: search all libraries",
1872
+ "",
1873
+ "Playback",
1874
+ "p: play selected media with mpv",
1875
+ "x: stop launched mpv",
1876
+ "",
1877
+ "Streams",
1878
+ "a: choose and save audio preference",
1879
+ "s: choose and save subtitle preference",
1880
+ "A: clear audio preference",
1881
+ "S: cycle subtitle mode",
1882
+ "",
1883
+ "Settings",
1884
+ ",: show settings",
1885
+ "r: reconnect / reload libraries",
1886
+ "PLEX_TUI_PERF_LOG=1: write browsing timings to the debug log",
1887
+ "?: show help",
1888
+ "q: quit",
1889
+ "",
1890
+ "Paths",
1891
+ f"Config: {config_path()}",
1892
+ f"Debug log: {debug_log_path()}",
1893
+ ])
1894
+
1895
+
1896
+ def context_hint(row: object) -> str:
1897
+ if isinstance(row, LibraryRow):
1898
+ return "Enter opens library"
1899
+ if isinstance(row, LoadMoreRow):
1900
+ return "Enter loads next page"
1901
+ if isinstance(row, MediaRow):
1902
+ if row.media.playable:
1903
+ return "Enter selects item / p plays / a audio / s subtitles"
1904
+ return "Enter opens item"
1905
+ if isinstance(row, MediaGrid):
1906
+ media = row.selected_media
1907
+ if media is not None and media.playable:
1908
+ return "Arrows/page select card / p plays / a audio / s subtitles"
1909
+ return "Arrows/page select card / Enter opens item"
1910
+ if isinstance(row, ServerRow):
1911
+ return "Enter selects server"
1912
+ if isinstance(row, StreamRow):
1913
+ return "Enter saves preference"
1914
+ if isinstance(row, SettingsActionRow):
1915
+ return "Enter runs action"
1916
+ if isinstance(row, SettingsHeaderRow):
1917
+ return "Settings section"
1918
+ if isinstance(row, SettingsValueRow):
1919
+ return "Current setting value"
1920
+ return "Enter selects row"
1921
+
1922
+
1923
+ def confirmation_required(action: str) -> bool:
1924
+ return action in {"clear_tracks", "clear_audio", "clear_subtitle"}
1925
+
1926
+
1927
+ def settings_action_label(action: str) -> str:
1928
+ labels = {
1929
+ "clear_tracks": "Clear audio/subtitle preferences",
1930
+ "clear_audio": "Clear audio preference",
1931
+ "clear_subtitle": "Clear subtitle preference",
1932
+ }
1933
+ return labels.get(action, action)
1934
+
1935
+
1936
+ def numeric_setting_label(name: str) -> str:
1937
+ if name == "auto_load_threshold":
1938
+ return "Auto-load threshold"
1939
+ if name == "page_size":
1940
+ return "Page size"
1941
+ return name
1942
+
1943
+
1944
+ def numeric_step_action(name: str, step: int) -> str:
1945
+ prefix = "increase" if step > 0 else "decrease"
1946
+ return f"{prefix}_{name}"
1947
+
1948
+
1949
+ def render_loaded_status(title: str, loaded: int, total: int | None, has_more: bool) -> str:
1950
+ if total is None:
1951
+ return f"{title}: {loaded} items"
1952
+ if has_more:
1953
+ return f"{title}: {loaded} of {total} items loaded"
1954
+ return f"{title}: {loaded} items"
1955
+
1956
+
1957
+ def grid_status(grid: MediaGrid, state: BrowseState | None) -> str:
1958
+ total_loaded = len(grid.items)
1959
+ total_available = state.total if state is not None else None
1960
+ page_count = max(1, (total_loaded + grid.page_size - 1) // grid.page_size)
1961
+ current_page = min(page_count, (grid.selected_index // grid.page_size) + 1)
1962
+ selected = min(grid.selected_index + 1, total_loaded)
1963
+ total_text = f"{total_loaded} loaded" if total_available is None else f"{total_loaded} of {total_available} loaded"
1964
+ return f"{context_hint(grid)} / item {selected} / page {current_page} of {page_count} / {total_text}"
1965
+
1966
+
1967
+ def should_auto_load_more(state: BrowseState, selected_key: str, threshold: int) -> bool:
1968
+ if not state.has_more:
1969
+ return False
1970
+ if threshold <= 0:
1971
+ return False
1972
+ start_index = max(0, len(state.items) - threshold)
1973
+ for index, item in enumerate(state.items):
1974
+ if item.key == selected_key:
1975
+ return index >= start_index
1976
+ return False
1977
+
1978
+
1979
+ def render_picker_details(stream_type: str, current_choice: StreamChoice | None, config: AppConfig) -> str:
1980
+ lines = [
1981
+ "Current Selection",
1982
+ current_choice.label if current_choice is not None else "None available",
1983
+ "",
1984
+ "Saved Preference",
1985
+ ]
1986
+ if stream_type == "audio":
1987
+ lines.append(f"Audio: {preference_value(config.preferred_audio_language)}")
1988
+ else:
1989
+ lines.append(f"Subtitles: {subtitle_preference_value(config)}")
1990
+ lines.extend([
1991
+ "",
1992
+ "Enter saves the highlighted track as the global preference for future playback.",
1993
+ "Escape returns without changing the saved preference.",
1994
+ ])
1995
+ return "\n".join(lines)
1996
+
1997
+
1998
+ def preference_value(value: str) -> str:
1999
+ return value or "Plex/default"
2000
+
2001
+
2002
+ def subtitle_preference_value(config: AppConfig) -> str:
2003
+ if config.subtitle_mode == "none":
2004
+ return "None"
2005
+ if config.subtitle_mode == "preferred":
2006
+ return preference_value(config.preferred_subtitle_language)
2007
+ return "Plex/default"
2008
+
2009
+
2010
+ def subtitle_mode_value(config: AppConfig) -> str:
2011
+ if config.subtitle_mode == "none":
2012
+ return "None"
2013
+ if config.subtitle_mode == "preferred":
2014
+ return "Preferred"
2015
+ return "Auto"
2016
+
2017
+
2018
+ def subtitle_language_value(config: AppConfig) -> str:
2019
+ if config.subtitle_mode != "preferred":
2020
+ return "Plex/default"
2021
+ return preference_value(config.preferred_subtitle_language)
2022
+
2023
+
2024
+ def artwork_mode_value(config: AppConfig) -> str:
2025
+ return "On" if config.artwork_mode == "on" else "Off"
2026
+
2027
+
2028
+ def artwork_renderer_value(config: AppConfig) -> str:
2029
+ if config.artwork_renderer == "kitty":
2030
+ return "Kitty"
2031
+ if config.artwork_renderer == "auto":
2032
+ return "Auto"
2033
+ return "Block"
2034
+
2035
+
2036
+ def detail_artwork_mode_value(config: AppConfig) -> str:
2037
+ if config.detail_artwork_mode == "on":
2038
+ return "On"
2039
+ if config.detail_artwork_mode == "off":
2040
+ return "Off"
2041
+ return "List only"
2042
+
2043
+
2044
+ def next_detail_artwork_mode(value: str) -> str:
2045
+ if value == "list_only":
2046
+ return "on"
2047
+ if value == "on":
2048
+ return "off"
2049
+ return "list_only"
2050
+
2051
+
2052
+ def media_view_value(config: AppConfig) -> str:
2053
+ if config.media_view == "grid":
2054
+ return "Grid"
2055
+ return "List"
2056
+
2057
+
2058
+ def grid_density_value(config: AppConfig) -> str:
2059
+ if config.grid_density == "compact":
2060
+ return "Compact"
2061
+ if config.grid_density == "large":
2062
+ return "Large"
2063
+ return "Comfortable"
2064
+
2065
+
2066
+ def next_media_view(media_view: str) -> str:
2067
+ return "grid" if media_view == "list" else "list"
2068
+
2069
+
2070
+ def next_grid_density(value: str) -> str:
2071
+ values = ["comfortable", "large", "compact"]
2072
+ try:
2073
+ index = values.index(value)
2074
+ except ValueError:
2075
+ return "comfortable"
2076
+ return values[(index + 1) % len(values)]
2077
+
2078
+
2079
+ def mpv_window_size_value(config: AppConfig) -> str:
2080
+ return config.mpv_window_size or "Default"
2081
+
2082
+
2083
+ def next_mpv_window_size(value: str) -> str:
2084
+ sizes = ["", "1280x720", "1600x900", "1920x1080", "80%"]
2085
+ try:
2086
+ index = sizes.index(value)
2087
+ except ValueError:
2088
+ return ""
2089
+ return sizes[(index + 1) % len(sizes)]
2090
+
2091
+
2092
+ def render_playback_preferences(
2093
+ config: AppConfig,
2094
+ audio_choice: StreamChoice | None,
2095
+ subtitle_choice: StreamChoice | None,
2096
+ ) -> str:
2097
+ return "; ".join([
2098
+ render_audio_playback_preference(config, audio_choice),
2099
+ render_subtitle_playback_preference(config, subtitle_choice),
2100
+ ])
2101
+
2102
+
2103
+ def render_playback_status(
2104
+ title: str,
2105
+ player: PlayerHandle,
2106
+ config: AppConfig,
2107
+ audio_choice: StreamChoice | None,
2108
+ subtitle_choice: StreamChoice | None,
2109
+ ) -> str:
2110
+ details = [f"Playing {title}"]
2111
+ if player.start_offset_ms:
2112
+ details.append(f"resume {format_offset(player.start_offset_ms)}")
2113
+ details.append(player.stream_mode)
2114
+ if player.subtitle_count:
2115
+ details.append(f"{player.subtitle_count} subtitles")
2116
+ details.append(render_playback_preferences(config, audio_choice, subtitle_choice))
2117
+ return " / ".join(details)
2118
+
2119
+
2120
+ def render_playback_details(
2121
+ title: str,
2122
+ player: PlayerHandle,
2123
+ config: AppConfig,
2124
+ audio_choice: StreamChoice | None,
2125
+ subtitle_choice: StreamChoice | None,
2126
+ ) -> str:
2127
+ lines = [
2128
+ title,
2129
+ "",
2130
+ "Playback",
2131
+ "Status: playing",
2132
+ f"Stream mode: {player.stream_mode}",
2133
+ f"Resume: {format_offset(player.start_offset_ms) if player.start_offset_ms else 'start'}",
2134
+ f"Subtitles available: {player.subtitle_count}",
2135
+ f"mpv window size: {mpv_window_size_value(config)}",
2136
+ "",
2137
+ "Selected Streams",
2138
+ f"Audio: {render_audio_playback_preference(config, audio_choice).removeprefix('audio ')}",
2139
+ f"Subtitles: {render_subtitle_playback_preference(config, subtitle_choice).removeprefix('subtitles ')}",
2140
+ "",
2141
+ "Diagnostics",
2142
+ f"Debug log: {debug_log_path()}",
2143
+ ]
2144
+ return "\n".join(lines)
2145
+
2146
+
2147
+ def effective_stream_preference_rows(raw: object, config: AppConfig) -> list[tuple[str, str]]:
2148
+ audio_choice = preferred_audio_choice(raw, config.preferred_audio_language)
2149
+ subtitle_choice = preferred_subtitle_choice(
2150
+ raw,
2151
+ config.preferred_subtitle_language,
2152
+ config.subtitle_mode,
2153
+ )
2154
+ return [
2155
+ ("Audio", render_audio_playback_preference(config, audio_choice).removeprefix("audio ")),
2156
+ ("Subtitles", render_subtitle_playback_preference(config, subtitle_choice).removeprefix("subtitles ")),
2157
+ ]
2158
+
2159
+
2160
+ def playback_exit_status(player: PlayerHandle, debug_path: object | None = None) -> str | None:
2161
+ returncode = player.process.poll()
2162
+ if returncode is None:
2163
+ return None
2164
+ if returncode == 0:
2165
+ return f"Playback ended: {player.title}"
2166
+ if returncode < 0:
2167
+ return append_debug_log_hint(f"Playback terminated by signal {-returncode}: {player.title}", debug_path)
2168
+ return append_debug_log_hint(f"Playback exited with code {returncode}: {player.title}", debug_path)
2169
+
2170
+
2171
+ def append_debug_log_hint(message: str, debug_path: object | None) -> str:
2172
+ if debug_path is None:
2173
+ return message
2174
+ return f"{message}. Debug log: {debug_path}"
2175
+
2176
+
2177
+ def render_audio_playback_preference(config: AppConfig, audio_choice: StreamChoice | None) -> str:
2178
+ preferred = config.preferred_audio_language
2179
+ if not preferred:
2180
+ return "audio Plex/default"
2181
+ if audio_choice is None:
2182
+ return f"audio {preferred} not found, Plex/default"
2183
+ return f"audio {audio_choice.label}"
2184
+
2185
+
2186
+ def render_subtitle_playback_preference(config: AppConfig, subtitle_choice: StreamChoice | None) -> str:
2187
+ if config.subtitle_mode == "none":
2188
+ return "subtitles none"
2189
+ if config.subtitle_mode != "preferred" or not config.preferred_subtitle_language:
2190
+ return "subtitles Plex/default"
2191
+ if subtitle_choice is None:
2192
+ return f"subtitles {config.preferred_subtitle_language} not found, Plex/default"
2193
+ return f"subtitles {subtitle_choice.label}"
2194
+
2195
+
2196
+ def stream_preference_key(choice: StreamChoice) -> str:
2197
+ if choice.stream is None:
2198
+ return ""
2199
+ return stream_language_key(choice.stream) or stream_language_label(choice.stream).lower()
2200
+
2201
+
2202
+ def selected_stream_index(choices: list[StreamChoice], selected_choice: StreamChoice | None) -> int:
2203
+ for index, choice in enumerate(choices):
2204
+ if stream_choice_matches(choice, selected_choice):
2205
+ return index
2206
+ return 0
2207
+
2208
+
2209
+ def set_list_index(view: ListView, index: int) -> None:
2210
+ view.index = None
2211
+ view.index = index
2212
+ children = list(view.children)
2213
+ if 0 <= index < len(children) and isinstance(children[index], ListItem):
2214
+ mark_active_row(view, children[index])
2215
+ view.call_after_refresh(mark_active_row, view, children[index])
2216
+
2217
+
2218
+ def mark_active_row(view: ListView, active_row: ListItem) -> None:
2219
+ for child in view.children:
2220
+ if isinstance(child, ListItem):
2221
+ child.set_class(child is active_row, "active-row")
2222
+
2223
+
2224
+ def stream_choice_matches(choice: StreamChoice, selected_choice: StreamChoice | None) -> bool:
2225
+ if selected_choice is None:
2226
+ return False
2227
+ if choice.stream_id != selected_choice.stream_id:
2228
+ return False
2229
+ if choice.stream is None or selected_choice.stream is None:
2230
+ return choice.stream is selected_choice.stream
2231
+ return same_stream(choice.stream, selected_choice.stream)
2232
+
2233
+
2234
+ def selected_media_index(items: list[MediaItem], selected_key: str | None) -> int:
2235
+ if selected_key is None:
2236
+ return 0
2237
+ for index, item in enumerate(items):
2238
+ if item.key == selected_key:
2239
+ return index
2240
+ return 0
2241
+
2242
+
2243
+ def write_performance_log(event: str, started: float, detail: str = "") -> None:
2244
+ if os.environ.get("PLEX_TUI_PERF_LOG") != "1":
2245
+ return
2246
+ elapsed_ms = (time.perf_counter() - started) * 1000
2247
+ suffix = f" {detail}" if detail else ""
2248
+ write_debug_log(f"perf {event} {elapsed_ms:.1f}ms{suffix}")