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.
- plex_tui-0.2.0.dist-info/METADATA +226 -0
- plex_tui-0.2.0.dist-info/RECORD +15 -0
- plex_tui-0.2.0.dist-info/WHEEL +4 -0
- plex_tui-0.2.0.dist-info/entry_points.txt +2 -0
- plex_tui-0.2.0.dist-info/licenses/LICENSE +21 -0
- plextui/__init__.py +3 -0
- plextui/__main__.py +40 -0
- plextui/app.py +2248 -0
- plextui/artwork.py +158 -0
- plextui/auth.py +93 -0
- plextui/config.py +200 -0
- plextui/models.py +36 -0
- plextui/player.py +519 -0
- plextui/plex_service.py +344 -0
- plextui/smoke.py +18 -0
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}")
|