wrkmon 1.0.1__py3-none-any.whl → 1.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.
- wrkmon/__init__.py +1 -1
- wrkmon/app.py +297 -3
- wrkmon/cli.py +100 -0
- wrkmon/core/media_keys.py +377 -0
- wrkmon/core/queue.py +55 -0
- wrkmon/core/youtube.py +102 -0
- wrkmon/data/database.py +120 -0
- wrkmon/data/migrations.py +26 -0
- wrkmon/ui/screens/help.py +80 -0
- wrkmon/ui/theme.py +332 -75
- wrkmon/ui/views/search.py +168 -12
- wrkmon/ui/widgets/header.py +31 -1
- wrkmon/ui/widgets/player_bar.py +40 -7
- wrkmon/ui/widgets/thumbnail.py +230 -0
- wrkmon/utils/ascii_art.py +408 -0
- wrkmon/utils/config.py +85 -4
- wrkmon/utils/updater.py +311 -0
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/METADATA +170 -166
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/RECORD +23 -18
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/entry_points.txt +0 -0
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/top_level.txt +0 -0
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/WHEEL +0 -0
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/licenses/LICENSE +0 -0
wrkmon/ui/views/search.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Search view container for wrkmon."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
4
5
|
from textual.app import ComposeResult
|
|
5
|
-
from textual.containers import Vertical
|
|
6
|
+
from textual.containers import Vertical, Horizontal
|
|
6
7
|
from textual.widgets import Static, Input, ListView, ListItem, Label
|
|
7
8
|
from textual.binding import Binding
|
|
8
9
|
from textual.events import Key
|
|
@@ -11,6 +12,8 @@ from textual import on
|
|
|
11
12
|
from wrkmon.core.youtube import SearchResult
|
|
12
13
|
from wrkmon.ui.messages import TrackSelected, TrackQueued, StatusMessage
|
|
13
14
|
from wrkmon.ui.widgets.result_item import ResultItem
|
|
15
|
+
from wrkmon.ui.widgets.thumbnail import ThumbnailPreview
|
|
16
|
+
from wrkmon.utils.config import get_config
|
|
14
17
|
|
|
15
18
|
logger = logging.getLogger("wrkmon.search")
|
|
16
19
|
|
|
@@ -35,6 +38,8 @@ class SearchView(Vertical):
|
|
|
35
38
|
Binding("/", "focus_search", "Search", show=True),
|
|
36
39
|
Binding("space", "play_selected", "Play", show=False, priority=True),
|
|
37
40
|
Binding("r", "toggle_repeat", "Repeat", show=True),
|
|
41
|
+
Binding("t", "toggle_thumbnail", "Thumb", show=True),
|
|
42
|
+
Binding("y", "cycle_thumbnail_style", "Style", show=True),
|
|
38
43
|
]
|
|
39
44
|
|
|
40
45
|
def __init__(self, **kwargs) -> None:
|
|
@@ -44,27 +49,87 @@ class SearchView(Vertical):
|
|
|
44
49
|
self._current_query = ""
|
|
45
50
|
self._load_more_offset = 0
|
|
46
51
|
self._batch_size = 15
|
|
52
|
+
self._thumbnail_task: asyncio.Task | None = None
|
|
53
|
+
self._is_trending = False # True when showing trending, False after search
|
|
54
|
+
|
|
55
|
+
# Load settings from config
|
|
56
|
+
self._config = get_config()
|
|
57
|
+
self._show_thumbnail = self._config.show_thumbnails
|
|
58
|
+
self._thumbnail_style = self._config.thumbnail_style
|
|
59
|
+
self._thumbnail_width = self._config.thumbnail_width
|
|
47
60
|
|
|
48
61
|
def compose(self) -> ComposeResult:
|
|
49
|
-
yield Static("
|
|
62
|
+
yield Static("TRENDING", id="view-title")
|
|
50
63
|
|
|
51
64
|
with Vertical(id="search-container"):
|
|
52
65
|
yield Input(
|
|
53
|
-
placeholder="Search
|
|
66
|
+
placeholder="Search YouTube music...",
|
|
54
67
|
id="search-input",
|
|
55
68
|
)
|
|
56
69
|
|
|
57
70
|
yield Static(
|
|
58
|
-
" #
|
|
71
|
+
" # Title Channel Duration Views",
|
|
59
72
|
id="list-header",
|
|
60
73
|
)
|
|
61
74
|
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
with Horizontal(id="search-content"):
|
|
76
|
+
yield ListView(id="results-list")
|
|
77
|
+
with Vertical(id="thumbnail-panel"):
|
|
78
|
+
yield Static("PREVIEW", id="thumb-title")
|
|
79
|
+
yield ThumbnailPreview(
|
|
80
|
+
width=self._thumbnail_width,
|
|
81
|
+
style=self._thumbnail_style,
|
|
82
|
+
id="thumb-preview"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
yield Static("Loading trending music...", id="status-bar")
|
|
64
86
|
|
|
65
87
|
def on_mount(self) -> None:
|
|
66
|
-
"""
|
|
67
|
-
|
|
88
|
+
"""Load trending videos and setup initial state on mount."""
|
|
89
|
+
# Apply initial thumbnail visibility
|
|
90
|
+
if not self._show_thumbnail:
|
|
91
|
+
try:
|
|
92
|
+
self.query_one("#thumbnail-panel", Vertical).add_class("hidden")
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
# Load trending videos if enabled
|
|
97
|
+
if self._config.show_trending_on_start:
|
|
98
|
+
asyncio.create_task(self._load_trending())
|
|
99
|
+
else:
|
|
100
|
+
self.query_one("#search-input", Input).focus()
|
|
101
|
+
self._update_status("Type to search YouTube music")
|
|
102
|
+
|
|
103
|
+
async def _load_trending(self) -> None:
|
|
104
|
+
"""Load trending/popular music videos on startup."""
|
|
105
|
+
self._is_searching = True
|
|
106
|
+
self._is_trending = True
|
|
107
|
+
self._update_status("Loading trending music...")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
# Update view title to show trending
|
|
111
|
+
self.query_one("#view-title", Static).update("TRENDING")
|
|
112
|
+
|
|
113
|
+
# Access the app's YouTube client
|
|
114
|
+
youtube = self.app.youtube
|
|
115
|
+
self.results = await youtube.get_trending_music(max_results=15)
|
|
116
|
+
|
|
117
|
+
if self.results:
|
|
118
|
+
self._display_results(show_load_more=False)
|
|
119
|
+
self._update_status(f"Trending: {len(self.results)} popular tracks | Search to find more")
|
|
120
|
+
else:
|
|
121
|
+
self._update_status("Could not load trending | Type to search")
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.debug(f"Failed to load trending: {e}")
|
|
125
|
+
self._update_status("Type to search YouTube music")
|
|
126
|
+
finally:
|
|
127
|
+
self._is_searching = False
|
|
128
|
+
# Focus the list if we have results, otherwise search input
|
|
129
|
+
if self.results:
|
|
130
|
+
self.query_one("#results-list", ListView).focus()
|
|
131
|
+
else:
|
|
132
|
+
self.query_one("#search-input", Input).focus()
|
|
68
133
|
|
|
69
134
|
@on(Input.Submitted, "#search-input")
|
|
70
135
|
async def handle_search(self, event: Input.Submitted) -> None:
|
|
@@ -74,10 +139,17 @@ class SearchView(Vertical):
|
|
|
74
139
|
return
|
|
75
140
|
|
|
76
141
|
self._is_searching = True
|
|
142
|
+
self._is_trending = False # No longer showing trending
|
|
77
143
|
self._update_status("Searching...")
|
|
78
144
|
self._current_query = query
|
|
79
145
|
self._load_more_offset = 0
|
|
80
146
|
|
|
147
|
+
# Update view title to SEARCH
|
|
148
|
+
try:
|
|
149
|
+
self.query_one("#view-title", Static).update("SEARCH")
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
81
153
|
try:
|
|
82
154
|
# Access the app's YouTube client
|
|
83
155
|
youtube = self.app.youtube
|
|
@@ -134,7 +206,8 @@ class SearchView(Vertical):
|
|
|
134
206
|
|
|
135
207
|
# Build status with repeat indicator
|
|
136
208
|
repeat_status = self._get_repeat_status()
|
|
137
|
-
|
|
209
|
+
thumb_status = " [THUMB ON]" if self._show_thumbnail else ""
|
|
210
|
+
status = f"Found {len(self.results)} | Enter=Play A=Queue R=Repeat T=Thumb{repeat_status}{thumb_status}"
|
|
138
211
|
self._update_status(status)
|
|
139
212
|
list_view.focus()
|
|
140
213
|
|
|
@@ -143,9 +216,9 @@ class SearchView(Vertical):
|
|
|
143
216
|
try:
|
|
144
217
|
mode = self.app.queue.repeat_mode
|
|
145
218
|
if mode == "one":
|
|
146
|
-
return " [
|
|
219
|
+
return " [R1]"
|
|
147
220
|
elif mode == "all":
|
|
148
|
-
return " [
|
|
221
|
+
return " [RA]"
|
|
149
222
|
except Exception:
|
|
150
223
|
pass
|
|
151
224
|
return ""
|
|
@@ -168,11 +241,93 @@ class SearchView(Vertical):
|
|
|
168
241
|
return item.result
|
|
169
242
|
return None
|
|
170
243
|
|
|
244
|
+
def _update_thumbnail_preview(self) -> None:
|
|
245
|
+
"""Update thumbnail preview for currently highlighted item."""
|
|
246
|
+
if not self._show_thumbnail:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
result = self._get_selected()
|
|
250
|
+
if result:
|
|
251
|
+
try:
|
|
252
|
+
preview = self.query_one("#thumb-preview", ThumbnailPreview)
|
|
253
|
+
title = self.query_one("#thumb-title", Static)
|
|
254
|
+
title.update(result.title[:35] + "..." if len(result.title) > 35 else result.title)
|
|
255
|
+
preview.set_video(result.video_id)
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
else:
|
|
259
|
+
self._clear_thumbnail()
|
|
260
|
+
|
|
261
|
+
def _clear_thumbnail(self) -> None:
|
|
262
|
+
"""Clear the thumbnail preview."""
|
|
263
|
+
try:
|
|
264
|
+
preview = self.query_one("#thumb-preview", ThumbnailPreview)
|
|
265
|
+
title = self.query_one("#thumb-title", Static)
|
|
266
|
+
title.update("PREVIEW")
|
|
267
|
+
preview.clear()
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
@on(ListView.Highlighted, "#results-list")
|
|
272
|
+
def handle_highlight_changed(self, event: ListView.Highlighted) -> None:
|
|
273
|
+
"""Update thumbnail when highlighted item changes."""
|
|
274
|
+
self._update_thumbnail_preview()
|
|
275
|
+
|
|
276
|
+
def action_toggle_thumbnail(self) -> None:
|
|
277
|
+
"""Toggle thumbnail preview visibility."""
|
|
278
|
+
self._show_thumbnail = not self._show_thumbnail
|
|
279
|
+
# Save to config
|
|
280
|
+
self._config.show_thumbnails = self._show_thumbnail
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
panel = self.query_one("#thumbnail-panel", Vertical)
|
|
284
|
+
if self._show_thumbnail:
|
|
285
|
+
panel.remove_class("hidden")
|
|
286
|
+
self._update_thumbnail_preview()
|
|
287
|
+
else:
|
|
288
|
+
panel.add_class("hidden")
|
|
289
|
+
|
|
290
|
+
# Update status
|
|
291
|
+
repeat_status = self._get_repeat_status()
|
|
292
|
+
thumb_status = " [THUMB ON]" if self._show_thumbnail else ""
|
|
293
|
+
count = len(self.results) if self.results else 0
|
|
294
|
+
self._update_status(f"Found {count} | T=Thumb{repeat_status}{thumb_status}")
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
def action_cycle_thumbnail_style(self) -> None:
|
|
299
|
+
"""Cycle through thumbnail rendering styles (Y key)."""
|
|
300
|
+
styles = ["colored_blocks", "colored_simple", "braille", "blocks"]
|
|
301
|
+
style_names = {
|
|
302
|
+
"colored_blocks": "Color Blocks",
|
|
303
|
+
"colored_simple": "Color Simple",
|
|
304
|
+
"braille": "Braille",
|
|
305
|
+
"blocks": "Grayscale",
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
current_idx = styles.index(self._thumbnail_style) if self._thumbnail_style in styles else 0
|
|
310
|
+
next_idx = (current_idx + 1) % len(styles)
|
|
311
|
+
self._thumbnail_style = styles[next_idx]
|
|
312
|
+
|
|
313
|
+
# Save to config
|
|
314
|
+
self._config.thumbnail_style = self._thumbnail_style
|
|
315
|
+
|
|
316
|
+
# Update the preview widget
|
|
317
|
+
preview = self.query_one("#thumb-preview", ThumbnailPreview)
|
|
318
|
+
preview.set_style(self._thumbnail_style)
|
|
319
|
+
|
|
320
|
+
# Update status to show new style
|
|
321
|
+
self._update_status(f"Thumbnail style: {style_names[self._thumbnail_style]}")
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
|
|
171
325
|
def action_clear_search(self) -> None:
|
|
172
326
|
"""Clear the search input."""
|
|
173
327
|
search_input = self.query_one("#search-input", Input)
|
|
174
328
|
if search_input.value:
|
|
175
329
|
search_input.value = ""
|
|
330
|
+
self._clear_thumbnail()
|
|
176
331
|
search_input.focus()
|
|
177
332
|
|
|
178
333
|
@on(ListView.Selected, "#results-list")
|
|
@@ -204,7 +359,8 @@ class SearchView(Vertical):
|
|
|
204
359
|
mode_names = {"none": "OFF", "one": "ONE", "all": "ALL"}
|
|
205
360
|
repeat_status = self._get_repeat_status()
|
|
206
361
|
count = len(self.results) if self.results else 0
|
|
207
|
-
|
|
362
|
+
thumb_status = " [THUMB ON]" if self._show_thumbnail else ""
|
|
363
|
+
self._update_status(f"Repeat: {mode_names[mode]} | Found {count}{repeat_status}{thumb_status}")
|
|
208
364
|
except Exception:
|
|
209
365
|
pass
|
|
210
366
|
|
wrkmon/ui/widgets/header.py
CHANGED
|
@@ -5,6 +5,7 @@ from textual.containers import Horizontal
|
|
|
5
5
|
from textual.reactive import reactive
|
|
6
6
|
from textual.widgets import Static
|
|
7
7
|
|
|
8
|
+
from wrkmon import __version__
|
|
8
9
|
from wrkmon.utils.stealth import get_stealth
|
|
9
10
|
|
|
10
11
|
|
|
@@ -13,6 +14,8 @@ class HeaderBar(Static):
|
|
|
13
14
|
|
|
14
15
|
cpu = reactive(23)
|
|
15
16
|
mem = reactive(45)
|
|
17
|
+
update_available = reactive(False)
|
|
18
|
+
latest_version = reactive("")
|
|
16
19
|
|
|
17
20
|
def __init__(self, **kwargs) -> None:
|
|
18
21
|
super().__init__(**kwargs)
|
|
@@ -20,7 +23,8 @@ class HeaderBar(Static):
|
|
|
20
23
|
|
|
21
24
|
def compose(self) -> ComposeResult:
|
|
22
25
|
with Horizontal(id="header-inner"):
|
|
23
|
-
yield Static("WRKMON", id="app-title")
|
|
26
|
+
yield Static(f"WRKMON v{__version__}", id="app-title")
|
|
27
|
+
yield Static("", id="update-indicator")
|
|
24
28
|
yield Static("", id="current-view")
|
|
25
29
|
yield Static(self._format_stats(), id="sys-stats")
|
|
26
30
|
|
|
@@ -44,6 +48,14 @@ class HeaderBar(Static):
|
|
|
44
48
|
"""React to memory changes."""
|
|
45
49
|
self._refresh_stats()
|
|
46
50
|
|
|
51
|
+
def watch_update_available(self) -> None:
|
|
52
|
+
"""React to update availability changes."""
|
|
53
|
+
self._refresh_update_indicator()
|
|
54
|
+
|
|
55
|
+
def watch_latest_version(self) -> None:
|
|
56
|
+
"""React to latest version changes."""
|
|
57
|
+
self._refresh_update_indicator()
|
|
58
|
+
|
|
47
59
|
def _refresh_stats(self) -> None:
|
|
48
60
|
"""Update the stats display."""
|
|
49
61
|
try:
|
|
@@ -51,9 +63,27 @@ class HeaderBar(Static):
|
|
|
51
63
|
except Exception:
|
|
52
64
|
pass
|
|
53
65
|
|
|
66
|
+
def _refresh_update_indicator(self) -> None:
|
|
67
|
+
"""Update the update indicator."""
|
|
68
|
+
try:
|
|
69
|
+
indicator = self.query_one("#update-indicator", Static)
|
|
70
|
+
if self.update_available and self.latest_version:
|
|
71
|
+
indicator.update(f"[UPDATE: v{self.latest_version}]")
|
|
72
|
+
indicator.add_class("update-available")
|
|
73
|
+
else:
|
|
74
|
+
indicator.update("")
|
|
75
|
+
indicator.remove_class("update-available")
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
54
79
|
def set_view_name(self, name: str) -> None:
|
|
55
80
|
"""Update the current view indicator."""
|
|
56
81
|
try:
|
|
57
82
|
self.query_one("#current-view", Static).update(f"/{name.upper()}")
|
|
58
83
|
except Exception:
|
|
59
84
|
pass
|
|
85
|
+
|
|
86
|
+
def set_update_info(self, available: bool, version: str = "") -> None:
|
|
87
|
+
"""Set update availability information."""
|
|
88
|
+
self.update_available = available
|
|
89
|
+
self.latest_version = version
|
wrkmon/ui/widgets/player_bar.py
CHANGED
|
@@ -19,6 +19,7 @@ class PlayerBar(Static):
|
|
|
19
19
|
volume = reactive(80)
|
|
20
20
|
status_text = reactive("") # For showing errors/buffering
|
|
21
21
|
repeat_mode = reactive("none") # none, one, all
|
|
22
|
+
is_muted = reactive(False)
|
|
22
23
|
|
|
23
24
|
def __init__(self, **kwargs) -> None:
|
|
24
25
|
super().__init__(**kwargs)
|
|
@@ -46,7 +47,15 @@ class PlayerBar(Static):
|
|
|
46
47
|
yield Static(f"{self.volume}%", id="vol-value")
|
|
47
48
|
|
|
48
49
|
def _get_status_icon(self) -> str:
|
|
49
|
-
|
|
50
|
+
"""Get status icon with color markup."""
|
|
51
|
+
if self.is_playing:
|
|
52
|
+
return "[bold green]▶[/]"
|
|
53
|
+
else:
|
|
54
|
+
return "[bold orange1]⏸[/]"
|
|
55
|
+
|
|
56
|
+
def _get_status_text(self) -> str:
|
|
57
|
+
"""Get status text for stopped state."""
|
|
58
|
+
return "[dim]■[/]"
|
|
50
59
|
|
|
51
60
|
def _format_time(self, seconds: float) -> str:
|
|
52
61
|
return self._stealth.format_duration(seconds)
|
|
@@ -59,21 +68,32 @@ class PlayerBar(Static):
|
|
|
59
68
|
"""Update title display."""
|
|
60
69
|
try:
|
|
61
70
|
display_title = self._format_title(new_title) if new_title else "No process running"
|
|
62
|
-
self.query_one("#track-title", Static)
|
|
71
|
+
title_widget = self.query_one("#track-title", Static)
|
|
72
|
+
title_widget.update(display_title)
|
|
63
73
|
except Exception:
|
|
64
74
|
pass
|
|
65
75
|
|
|
66
76
|
def watch_is_playing(self) -> None:
|
|
67
77
|
"""Update play/pause icon."""
|
|
68
78
|
try:
|
|
69
|
-
self.query_one("#play-status", Static)
|
|
79
|
+
status_widget = self.query_one("#play-status", Static)
|
|
80
|
+
status_widget.update(self._get_status_icon())
|
|
81
|
+
|
|
82
|
+
# Update CSS classes for styling
|
|
83
|
+
if self.is_playing:
|
|
84
|
+
status_widget.remove_class("paused")
|
|
85
|
+
status_widget.remove_class("stopped")
|
|
86
|
+
else:
|
|
87
|
+
status_widget.add_class("paused")
|
|
70
88
|
except Exception:
|
|
71
89
|
pass
|
|
72
90
|
|
|
73
91
|
def watch_position(self, new_pos: float) -> None:
|
|
74
92
|
"""Update progress bar and time."""
|
|
75
93
|
try:
|
|
76
|
-
self.query_one("#time-current", Static)
|
|
94
|
+
time_widget = self.query_one("#time-current", Static)
|
|
95
|
+
time_widget.update(self._format_time(new_pos))
|
|
96
|
+
|
|
77
97
|
if self.duration > 0:
|
|
78
98
|
progress = (new_pos / self.duration) * 100
|
|
79
99
|
self.query_one("#progress", ProgressBar).update(progress=progress)
|
|
@@ -91,7 +111,16 @@ class PlayerBar(Static):
|
|
|
91
111
|
"""Update volume display."""
|
|
92
112
|
try:
|
|
93
113
|
self.query_one("#volume", ProgressBar).update(progress=new_vol)
|
|
94
|
-
|
|
114
|
+
vol_text = f"{new_vol}%" if not self.is_muted else "[red]MUTE[/]"
|
|
115
|
+
self.query_one("#vol-value", Static).update(vol_text)
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
def watch_is_muted(self, is_muted: bool) -> None:
|
|
120
|
+
"""Update mute indicator."""
|
|
121
|
+
try:
|
|
122
|
+
vol_text = f"{self.volume}%" if not is_muted else "[red]MUTE[/]"
|
|
123
|
+
self.query_one("#vol-value", Static).update(vol_text)
|
|
95
124
|
except Exception:
|
|
96
125
|
pass
|
|
97
126
|
|
|
@@ -100,9 +129,9 @@ class PlayerBar(Static):
|
|
|
100
129
|
try:
|
|
101
130
|
indicator = ""
|
|
102
131
|
if new_mode == "one":
|
|
103
|
-
indicator = "[
|
|
132
|
+
indicator = "[bold purple]⟳1[/]"
|
|
104
133
|
elif new_mode == "all":
|
|
105
|
-
indicator = "[
|
|
134
|
+
indicator = "[bold purple]⟳∞[/]"
|
|
106
135
|
self.query_one("#repeat-indicator", Static).update(indicator)
|
|
107
136
|
except Exception:
|
|
108
137
|
pass
|
|
@@ -127,3 +156,7 @@ class PlayerBar(Static):
|
|
|
127
156
|
def set_volume(self, volume: int) -> None:
|
|
128
157
|
"""Update volume display."""
|
|
129
158
|
self.volume = max(0, min(100, volume))
|
|
159
|
+
|
|
160
|
+
def set_muted(self, muted: bool) -> None:
|
|
161
|
+
"""Update mute state."""
|
|
162
|
+
self.is_muted = muted
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""ASCII thumbnail widget for wrkmon - with color support."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Container
|
|
6
|
+
from textual.reactive import reactive
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from wrkmon.utils.ascii_art import get_or_fetch_ascii, get_cached_ascii, clear_cache
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ThumbnailPreview(Static):
|
|
13
|
+
"""Widget to display colored ASCII art thumbnail preview."""
|
|
14
|
+
|
|
15
|
+
DEFAULT_CSS = """
|
|
16
|
+
ThumbnailPreview {
|
|
17
|
+
width: 100%;
|
|
18
|
+
height: auto;
|
|
19
|
+
min-height: 8;
|
|
20
|
+
max-height: 18;
|
|
21
|
+
background: #0d1117;
|
|
22
|
+
padding: 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ThumbnailPreview.loading {
|
|
26
|
+
color: #6e7681;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ThumbnailPreview.hidden {
|
|
30
|
+
display: none;
|
|
31
|
+
}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
video_id = reactive("")
|
|
35
|
+
is_loading = reactive(False)
|
|
36
|
+
style_mode = reactive("colored_blocks") # colored_blocks, colored_simple, blocks, braille
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
video_id: str = "",
|
|
41
|
+
width: int = 45,
|
|
42
|
+
style: str = "colored_blocks",
|
|
43
|
+
**kwargs
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(**kwargs)
|
|
46
|
+
self._ascii_width = width
|
|
47
|
+
self._current_task: asyncio.Task | None = None
|
|
48
|
+
self.style_mode = style
|
|
49
|
+
if video_id:
|
|
50
|
+
self.video_id = video_id
|
|
51
|
+
|
|
52
|
+
def compose(self) -> ComposeResult:
|
|
53
|
+
yield Static("", id="ascii-content", markup=True)
|
|
54
|
+
|
|
55
|
+
def on_mount(self) -> None:
|
|
56
|
+
"""Load thumbnail on mount if video_id is set."""
|
|
57
|
+
if self.video_id:
|
|
58
|
+
self._load_thumbnail()
|
|
59
|
+
|
|
60
|
+
def watch_video_id(self, video_id: str) -> None:
|
|
61
|
+
"""React to video_id changes."""
|
|
62
|
+
if video_id:
|
|
63
|
+
self._load_thumbnail()
|
|
64
|
+
else:
|
|
65
|
+
self._clear_thumbnail()
|
|
66
|
+
|
|
67
|
+
def _load_thumbnail(self) -> None:
|
|
68
|
+
"""Load ASCII thumbnail for current video."""
|
|
69
|
+
if not self.video_id:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
# Check cache first
|
|
73
|
+
cache_key = f"{self.video_id}_{self.style_mode}_{self._ascii_width}"
|
|
74
|
+
from wrkmon.utils.ascii_art import _thumbnail_cache
|
|
75
|
+
cached = _thumbnail_cache.get(cache_key)
|
|
76
|
+
if cached:
|
|
77
|
+
self._display_ascii(cached)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Load async
|
|
81
|
+
self.is_loading = True
|
|
82
|
+
self.add_class("loading")
|
|
83
|
+
self._update_content("[dim]Loading...[/]")
|
|
84
|
+
|
|
85
|
+
# Cancel any existing task
|
|
86
|
+
if self._current_task and not self._current_task.done():
|
|
87
|
+
self._current_task.cancel()
|
|
88
|
+
|
|
89
|
+
# Start new load task
|
|
90
|
+
self._current_task = asyncio.create_task(self._fetch_and_display())
|
|
91
|
+
|
|
92
|
+
async def _fetch_and_display(self) -> None:
|
|
93
|
+
"""Fetch thumbnail and display it."""
|
|
94
|
+
try:
|
|
95
|
+
ascii_art = await get_or_fetch_ascii(
|
|
96
|
+
self.video_id,
|
|
97
|
+
width=self._ascii_width,
|
|
98
|
+
style=self.style_mode,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if ascii_art:
|
|
102
|
+
self._display_ascii(ascii_art)
|
|
103
|
+
else:
|
|
104
|
+
self._update_content("[dim]No thumbnail[/]")
|
|
105
|
+
|
|
106
|
+
except asyncio.CancelledError:
|
|
107
|
+
pass
|
|
108
|
+
except Exception as e:
|
|
109
|
+
self._update_content(f"[red]Error: {e}[/]")
|
|
110
|
+
finally:
|
|
111
|
+
self.is_loading = False
|
|
112
|
+
self.remove_class("loading")
|
|
113
|
+
|
|
114
|
+
def _display_ascii(self, ascii_art: str) -> None:
|
|
115
|
+
"""Display ASCII art in the widget."""
|
|
116
|
+
self._update_content(ascii_art)
|
|
117
|
+
|
|
118
|
+
def _update_content(self, content: str) -> None:
|
|
119
|
+
"""Update the content display."""
|
|
120
|
+
try:
|
|
121
|
+
widget = self.query_one("#ascii-content", Static)
|
|
122
|
+
widget.update(content)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def _clear_thumbnail(self) -> None:
|
|
127
|
+
"""Clear the thumbnail display."""
|
|
128
|
+
self._update_content("")
|
|
129
|
+
|
|
130
|
+
def set_video(self, video_id: str) -> None:
|
|
131
|
+
"""Set video ID and load thumbnail."""
|
|
132
|
+
self.video_id = video_id
|
|
133
|
+
|
|
134
|
+
def set_style(self, style: str) -> None:
|
|
135
|
+
"""Change the rendering style and reload."""
|
|
136
|
+
if style != self.style_mode:
|
|
137
|
+
self.style_mode = style
|
|
138
|
+
if self.video_id:
|
|
139
|
+
self._load_thumbnail()
|
|
140
|
+
|
|
141
|
+
def clear(self) -> None:
|
|
142
|
+
"""Clear the thumbnail."""
|
|
143
|
+
self.video_id = ""
|
|
144
|
+
|
|
145
|
+
def show(self) -> None:
|
|
146
|
+
"""Show the widget."""
|
|
147
|
+
self.remove_class("hidden")
|
|
148
|
+
|
|
149
|
+
def hide(self) -> None:
|
|
150
|
+
"""Hide the widget."""
|
|
151
|
+
self.add_class("hidden")
|
|
152
|
+
|
|
153
|
+
def cycle_style(self) -> str:
|
|
154
|
+
"""Cycle through rendering styles."""
|
|
155
|
+
styles = ["colored_blocks", "colored_simple", "braille", "blocks"]
|
|
156
|
+
current_idx = styles.index(self.style_mode) if self.style_mode in styles else 0
|
|
157
|
+
next_idx = (current_idx + 1) % len(styles)
|
|
158
|
+
self.set_style(styles[next_idx])
|
|
159
|
+
return styles[next_idx]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ThumbnailPanel(Container):
|
|
163
|
+
"""Panel containing thumbnail preview with title."""
|
|
164
|
+
|
|
165
|
+
DEFAULT_CSS = """
|
|
166
|
+
ThumbnailPanel {
|
|
167
|
+
width: 100%;
|
|
168
|
+
height: auto;
|
|
169
|
+
background: #161b22;
|
|
170
|
+
border: solid #30363d;
|
|
171
|
+
padding: 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
ThumbnailPanel > #panel-title {
|
|
175
|
+
height: 1;
|
|
176
|
+
color: #58a6ff;
|
|
177
|
+
text-style: bold;
|
|
178
|
+
margin-bottom: 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
ThumbnailPanel.hidden {
|
|
182
|
+
display: none;
|
|
183
|
+
}
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(self, title: str = "Preview", width: int = 45, **kwargs) -> None:
|
|
187
|
+
super().__init__(**kwargs)
|
|
188
|
+
self._title = title
|
|
189
|
+
self._width = width
|
|
190
|
+
|
|
191
|
+
def compose(self) -> ComposeResult:
|
|
192
|
+
yield Static(self._title, id="panel-title", markup=True)
|
|
193
|
+
yield ThumbnailPreview(width=self._width, id="thumbnail-preview")
|
|
194
|
+
|
|
195
|
+
def set_video(self, video_id: str, title: str = "") -> None:
|
|
196
|
+
"""Set video to preview."""
|
|
197
|
+
if title:
|
|
198
|
+
try:
|
|
199
|
+
display_title = title[:40] + "..." if len(title) > 40 else title
|
|
200
|
+
self.query_one("#panel-title", Static).update(f"[bold]{display_title}[/]")
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
self.query_one("#thumbnail-preview", ThumbnailPreview).set_video(video_id)
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
def clear(self) -> None:
|
|
210
|
+
"""Clear the preview."""
|
|
211
|
+
try:
|
|
212
|
+
self.query_one("#thumbnail-preview", ThumbnailPreview).clear()
|
|
213
|
+
self.query_one("#panel-title", Static).update("Preview")
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
def cycle_style(self) -> str:
|
|
218
|
+
"""Cycle the thumbnail style."""
|
|
219
|
+
try:
|
|
220
|
+
return self.query_one("#thumbnail-preview", ThumbnailPreview).cycle_style()
|
|
221
|
+
except Exception:
|
|
222
|
+
return "colored_blocks"
|
|
223
|
+
|
|
224
|
+
def show(self) -> None:
|
|
225
|
+
"""Show the panel."""
|
|
226
|
+
self.remove_class("hidden")
|
|
227
|
+
|
|
228
|
+
def hide(self) -> None:
|
|
229
|
+
"""Hide the panel."""
|
|
230
|
+
self.add_class("hidden")
|