wrkmon 1.0.0__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 +323 -5
- 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 +283 -19
- wrkmon/ui/widgets/header.py +31 -1
- wrkmon/ui/widgets/player_bar.py +52 -5
- 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.0.dist-info → wrkmon-1.2.0.dist-info}/METADATA +170 -193
- {wrkmon-1.0.0.dist-info → wrkmon-1.2.0.dist-info}/RECORD +23 -18
- {wrkmon-1.0.0.dist-info → wrkmon-1.2.0.dist-info}/WHEEL +1 -1
- {wrkmon-1.0.0.dist-info → wrkmon-1.2.0.dist-info}/entry_points.txt +0 -0
- {wrkmon-1.0.0.dist-info → wrkmon-1.2.0.dist-info}/top_level.txt +0 -0
- /wrkmon-1.0.0.dist-info/licenses/LICENSE.txt → /wrkmon-1.2.0.dist-info/licenses/LICENSE +0 -0
wrkmon/ui/views/search.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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.widgets import Static, Input, ListView
|
|
6
|
+
from textual.containers import Vertical, Horizontal
|
|
7
|
+
from textual.widgets import Static, Input, ListView, ListItem, Label
|
|
7
8
|
from textual.binding import Binding
|
|
8
9
|
from textual.events import Key
|
|
9
10
|
from textual import on
|
|
@@ -11,10 +12,23 @@ 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
|
|
|
17
20
|
|
|
21
|
+
class LoadMoreItem(ListItem):
|
|
22
|
+
"""Special list item for loading more results."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.is_load_more = True
|
|
27
|
+
|
|
28
|
+
def compose(self):
|
|
29
|
+
yield Label(" >>> Load More Results <<<", classes="load-more")
|
|
30
|
+
|
|
31
|
+
|
|
18
32
|
class SearchView(Vertical):
|
|
19
33
|
"""Main search view - search YouTube and display results."""
|
|
20
34
|
|
|
@@ -22,33 +36,100 @@ class SearchView(Vertical):
|
|
|
22
36
|
Binding("a", "queue_selected", "Add to Queue", show=True),
|
|
23
37
|
Binding("escape", "clear_search", "Clear", show=True),
|
|
24
38
|
Binding("/", "focus_search", "Search", show=True),
|
|
39
|
+
Binding("space", "play_selected", "Play", show=False, priority=True),
|
|
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),
|
|
25
43
|
]
|
|
26
44
|
|
|
27
45
|
def __init__(self, **kwargs) -> None:
|
|
28
46
|
super().__init__(**kwargs)
|
|
29
47
|
self.results: list[SearchResult] = []
|
|
30
48
|
self._is_searching = False
|
|
49
|
+
self._current_query = ""
|
|
50
|
+
self._load_more_offset = 0
|
|
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
|
|
31
60
|
|
|
32
61
|
def compose(self) -> ComposeResult:
|
|
33
|
-
yield Static("
|
|
62
|
+
yield Static("TRENDING", id="view-title")
|
|
34
63
|
|
|
35
64
|
with Vertical(id="search-container"):
|
|
36
65
|
yield Input(
|
|
37
|
-
placeholder="Search
|
|
66
|
+
placeholder="Search YouTube music...",
|
|
38
67
|
id="search-input",
|
|
39
68
|
)
|
|
40
69
|
|
|
41
70
|
yield Static(
|
|
42
|
-
" #
|
|
71
|
+
" # Title Channel Duration Views",
|
|
43
72
|
id="list-header",
|
|
44
73
|
)
|
|
45
74
|
|
|
46
|
-
|
|
47
|
-
|
|
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")
|
|
48
86
|
|
|
49
87
|
def on_mount(self) -> None:
|
|
50
|
-
"""
|
|
51
|
-
|
|
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()
|
|
52
133
|
|
|
53
134
|
@on(Input.Submitted, "#search-input")
|
|
54
135
|
async def handle_search(self, event: Input.Submitted) -> None:
|
|
@@ -58,20 +139,56 @@ class SearchView(Vertical):
|
|
|
58
139
|
return
|
|
59
140
|
|
|
60
141
|
self._is_searching = True
|
|
142
|
+
self._is_trending = False # No longer showing trending
|
|
61
143
|
self._update_status("Searching...")
|
|
144
|
+
self._current_query = query
|
|
145
|
+
self._load_more_offset = 0
|
|
146
|
+
|
|
147
|
+
# Update view title to SEARCH
|
|
148
|
+
try:
|
|
149
|
+
self.query_one("#view-title", Static).update("SEARCH")
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
62
152
|
|
|
63
153
|
try:
|
|
64
154
|
# Access the app's YouTube client
|
|
65
155
|
youtube = self.app.youtube
|
|
66
|
-
self.results = await youtube.search(query, max_results=
|
|
67
|
-
self._display_results()
|
|
156
|
+
self.results = await youtube.search(query, max_results=self._batch_size)
|
|
157
|
+
self._display_results(show_load_more=True)
|
|
68
158
|
except Exception as e:
|
|
69
159
|
self._update_status(f"Search failed: {e}")
|
|
70
160
|
self.post_message(StatusMessage(f"Search error: {e}", "error"))
|
|
71
161
|
finally:
|
|
72
162
|
self._is_searching = False
|
|
73
163
|
|
|
74
|
-
def
|
|
164
|
+
async def _load_more_results(self) -> None:
|
|
165
|
+
"""Load more search results."""
|
|
166
|
+
if not self._current_query or self._is_searching:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
self._is_searching = True
|
|
170
|
+
self._update_status("Loading more...")
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
youtube = self.app.youtube
|
|
174
|
+
self._load_more_offset += self._batch_size
|
|
175
|
+
# Search with offset by fetching more and skipping existing
|
|
176
|
+
new_results = await youtube.search(
|
|
177
|
+
self._current_query,
|
|
178
|
+
max_results=self._batch_size + self._load_more_offset
|
|
179
|
+
)
|
|
180
|
+
# Get only the new results we don't have yet
|
|
181
|
+
if len(new_results) > len(self.results):
|
|
182
|
+
self.results = new_results
|
|
183
|
+
self._display_results(show_load_more=True)
|
|
184
|
+
else:
|
|
185
|
+
self._update_status(f"No more results | Found {len(self.results)} total")
|
|
186
|
+
except Exception as e:
|
|
187
|
+
self._update_status(f"Load more failed: {e}")
|
|
188
|
+
finally:
|
|
189
|
+
self._is_searching = False
|
|
190
|
+
|
|
191
|
+
def _display_results(self, show_load_more: bool = False) -> None:
|
|
75
192
|
"""Display search results in the list."""
|
|
76
193
|
list_view = self.query_one("#results-list", ListView)
|
|
77
194
|
list_view.clear()
|
|
@@ -83,9 +200,29 @@ class SearchView(Vertical):
|
|
|
83
200
|
for i, result in enumerate(self.results, 1):
|
|
84
201
|
list_view.append(ResultItem(result, i))
|
|
85
202
|
|
|
86
|
-
|
|
203
|
+
# Add "Load More" option at the end
|
|
204
|
+
if show_load_more:
|
|
205
|
+
list_view.append(LoadMoreItem())
|
|
206
|
+
|
|
207
|
+
# Build status with repeat indicator
|
|
208
|
+
repeat_status = self._get_repeat_status()
|
|
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}"
|
|
211
|
+
self._update_status(status)
|
|
87
212
|
list_view.focus()
|
|
88
213
|
|
|
214
|
+
def _get_repeat_status(self) -> str:
|
|
215
|
+
"""Get repeat mode status string."""
|
|
216
|
+
try:
|
|
217
|
+
mode = self.app.queue.repeat_mode
|
|
218
|
+
if mode == "one":
|
|
219
|
+
return " [R1]"
|
|
220
|
+
elif mode == "all":
|
|
221
|
+
return " [RA]"
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
return ""
|
|
225
|
+
|
|
89
226
|
def _update_status(self, message: str) -> None:
|
|
90
227
|
"""Update the status bar."""
|
|
91
228
|
try:
|
|
@@ -104,20 +241,128 @@ class SearchView(Vertical):
|
|
|
104
241
|
return item.result
|
|
105
242
|
return None
|
|
106
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
|
+
|
|
107
325
|
def action_clear_search(self) -> None:
|
|
108
326
|
"""Clear the search input."""
|
|
109
327
|
search_input = self.query_one("#search-input", Input)
|
|
110
328
|
if search_input.value:
|
|
111
329
|
search_input.value = ""
|
|
330
|
+
self._clear_thumbnail()
|
|
112
331
|
search_input.focus()
|
|
113
332
|
|
|
114
333
|
@on(ListView.Selected, "#results-list")
|
|
115
|
-
def handle_result_selected(self, event: ListView.Selected) -> None:
|
|
116
|
-
"""Handle Enter key on a result - play it."""
|
|
117
|
-
if isinstance(event.item,
|
|
334
|
+
async def handle_result_selected(self, event: ListView.Selected) -> None:
|
|
335
|
+
"""Handle Enter key on a result - play it or load more."""
|
|
336
|
+
if isinstance(event.item, LoadMoreItem):
|
|
337
|
+
await self._load_more_results()
|
|
338
|
+
elif isinstance(event.item, ResultItem):
|
|
118
339
|
result = event.item.result
|
|
119
340
|
self.post_message(TrackSelected(result))
|
|
120
|
-
self.
|
|
341
|
+
repeat_status = self._get_repeat_status()
|
|
342
|
+
self._update_status(f"Playing: {result.title[:40]}...{repeat_status}")
|
|
343
|
+
|
|
344
|
+
def action_play_selected(self) -> None:
|
|
345
|
+
"""Play the selected track (Space key)."""
|
|
346
|
+
list_view = self.query_one("#results-list", ListView)
|
|
347
|
+
if not list_view.has_focus:
|
|
348
|
+
return
|
|
349
|
+
result = self._get_selected()
|
|
350
|
+
if result:
|
|
351
|
+
self.post_message(TrackSelected(result))
|
|
352
|
+
repeat_status = self._get_repeat_status()
|
|
353
|
+
self._update_status(f"Playing: {result.title[:40]}...{repeat_status}")
|
|
354
|
+
|
|
355
|
+
def action_toggle_repeat(self) -> None:
|
|
356
|
+
"""Cycle repeat mode (R key)."""
|
|
357
|
+
try:
|
|
358
|
+
mode = self.app.queue.cycle_repeat()
|
|
359
|
+
mode_names = {"none": "OFF", "one": "ONE", "all": "ALL"}
|
|
360
|
+
repeat_status = self._get_repeat_status()
|
|
361
|
+
count = len(self.results) if self.results else 0
|
|
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}")
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
121
366
|
|
|
122
367
|
def action_queue_selected(self) -> None:
|
|
123
368
|
"""Add selected track to queue."""
|
|
@@ -127,7 +372,8 @@ class SearchView(Vertical):
|
|
|
127
372
|
if result:
|
|
128
373
|
logger.info(f" Posting TrackQueued for: {result.title}")
|
|
129
374
|
self.post_message(TrackQueued(result))
|
|
130
|
-
self.
|
|
375
|
+
repeat_status = self._get_repeat_status()
|
|
376
|
+
self._update_status(f"Queued: {result.title[:40]}...{repeat_status}")
|
|
131
377
|
else:
|
|
132
378
|
logger.warning(" No result selected!")
|
|
133
379
|
|
|
@@ -140,7 +386,7 @@ class SearchView(Vertical):
|
|
|
140
386
|
self.focus_input()
|
|
141
387
|
|
|
142
388
|
def on_key(self, event: Key) -> None:
|
|
143
|
-
"""Handle key events - up at top of list goes to search."""
|
|
389
|
+
"""Handle key events - up at top of list goes to search, down from search goes to list."""
|
|
144
390
|
if event.key == "up":
|
|
145
391
|
list_view = self.query_one("#results-list", ListView)
|
|
146
392
|
# If list is focused and at top (index 0), go to search input
|
|
@@ -148,3 +394,21 @@ class SearchView(Vertical):
|
|
|
148
394
|
self.focus_input()
|
|
149
395
|
event.prevent_default()
|
|
150
396
|
event.stop()
|
|
397
|
+
elif event.key == "down":
|
|
398
|
+
search_input = self.query_one("#search-input", Input)
|
|
399
|
+
# If search input is focused, go to list
|
|
400
|
+
if search_input.has_focus:
|
|
401
|
+
list_view = self.query_one("#results-list", ListView)
|
|
402
|
+
if len(list_view.children) > 0:
|
|
403
|
+
list_view.focus()
|
|
404
|
+
list_view.index = 0
|
|
405
|
+
event.prevent_default()
|
|
406
|
+
event.stop()
|
|
407
|
+
|
|
408
|
+
def focus_list(self) -> None:
|
|
409
|
+
"""Focus the results list (called from parent)."""
|
|
410
|
+
list_view = self.query_one("#results-list", ListView)
|
|
411
|
+
if len(list_view.children) > 0:
|
|
412
|
+
list_view.focus()
|
|
413
|
+
else:
|
|
414
|
+
self.query_one("#search-input", Input).focus()
|
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
|
@@ -18,6 +18,8 @@ class PlayerBar(Static):
|
|
|
18
18
|
duration = reactive(0.0)
|
|
19
19
|
volume = reactive(80)
|
|
20
20
|
status_text = reactive("") # For showing errors/buffering
|
|
21
|
+
repeat_mode = reactive("none") # none, one, all
|
|
22
|
+
is_muted = reactive(False)
|
|
21
23
|
|
|
22
24
|
def __init__(self, **kwargs) -> None:
|
|
23
25
|
super().__init__(**kwargs)
|
|
@@ -30,6 +32,7 @@ class PlayerBar(Static):
|
|
|
30
32
|
yield Static("NOW", id="now-label", classes="label")
|
|
31
33
|
yield Static(self._get_status_icon(), id="play-status")
|
|
32
34
|
yield Static(self.title, id="track-title")
|
|
35
|
+
yield Static("", id="repeat-indicator")
|
|
33
36
|
|
|
34
37
|
# Progress row
|
|
35
38
|
with Horizontal(id="progress-row"):
|
|
@@ -44,7 +47,15 @@ class PlayerBar(Static):
|
|
|
44
47
|
yield Static(f"{self.volume}%", id="vol-value")
|
|
45
48
|
|
|
46
49
|
def _get_status_icon(self) -> str:
|
|
47
|
-
|
|
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]■[/]"
|
|
48
59
|
|
|
49
60
|
def _format_time(self, seconds: float) -> str:
|
|
50
61
|
return self._stealth.format_duration(seconds)
|
|
@@ -57,21 +68,32 @@ class PlayerBar(Static):
|
|
|
57
68
|
"""Update title display."""
|
|
58
69
|
try:
|
|
59
70
|
display_title = self._format_title(new_title) if new_title else "No process running"
|
|
60
|
-
self.query_one("#track-title", Static)
|
|
71
|
+
title_widget = self.query_one("#track-title", Static)
|
|
72
|
+
title_widget.update(display_title)
|
|
61
73
|
except Exception:
|
|
62
74
|
pass
|
|
63
75
|
|
|
64
76
|
def watch_is_playing(self) -> None:
|
|
65
77
|
"""Update play/pause icon."""
|
|
66
78
|
try:
|
|
67
|
-
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")
|
|
68
88
|
except Exception:
|
|
69
89
|
pass
|
|
70
90
|
|
|
71
91
|
def watch_position(self, new_pos: float) -> None:
|
|
72
92
|
"""Update progress bar and time."""
|
|
73
93
|
try:
|
|
74
|
-
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
|
+
|
|
75
97
|
if self.duration > 0:
|
|
76
98
|
progress = (new_pos / self.duration) * 100
|
|
77
99
|
self.query_one("#progress", ProgressBar).update(progress=progress)
|
|
@@ -89,7 +111,28 @@ class PlayerBar(Static):
|
|
|
89
111
|
"""Update volume display."""
|
|
90
112
|
try:
|
|
91
113
|
self.query_one("#volume", ProgressBar).update(progress=new_vol)
|
|
92
|
-
|
|
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)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
def watch_repeat_mode(self, new_mode: str) -> None:
|
|
128
|
+
"""Update repeat indicator."""
|
|
129
|
+
try:
|
|
130
|
+
indicator = ""
|
|
131
|
+
if new_mode == "one":
|
|
132
|
+
indicator = "[bold purple]⟳1[/]"
|
|
133
|
+
elif new_mode == "all":
|
|
134
|
+
indicator = "[bold purple]⟳∞[/]"
|
|
135
|
+
self.query_one("#repeat-indicator", Static).update(indicator)
|
|
93
136
|
except Exception:
|
|
94
137
|
pass
|
|
95
138
|
|
|
@@ -113,3 +156,7 @@ class PlayerBar(Static):
|
|
|
113
156
|
def set_volume(self, volume: int) -> None:
|
|
114
157
|
"""Update volume display."""
|
|
115
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
|