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/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("SEARCH", id="view-title")
62
+ yield Static("TRENDING", id="view-title")
34
63
 
35
64
  with Vertical(id="search-container"):
36
65
  yield Input(
37
- placeholder="Search processes...",
66
+ placeholder="Search YouTube music...",
38
67
  id="search-input",
39
68
  )
40
69
 
41
70
  yield Static(
42
- " # Process PID Duration Status",
71
+ " # Title Channel Duration Views",
43
72
  id="list-header",
44
73
  )
45
74
 
46
- yield ListView(id="results-list")
47
- yield Static("Type to search", id="status-bar")
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
- """Focus the search input on mount."""
51
- self.query_one("#search-input", Input).focus()
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=15)
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 _display_results(self) -> None:
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
- self._update_status(f"Found {len(self.results)} | Enter=Play A=Queue /=Search ↑↓=Nav")
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, ResultItem):
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._update_status(f"Playing: {result.title[:40]}...")
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._update_status(f"Queued: {result.title[:40]}...")
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()
@@ -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
@@ -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
- return "" if self.is_playing else ""
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).update(display_title)
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).update(self._get_status_icon())
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).update(self._format_time(new_pos))
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
- self.query_one("#vol-value", Static).update(f"{new_vol}%")
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