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/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("SEARCH", id="view-title")
62
+ yield Static("TRENDING", id="view-title")
50
63
 
51
64
  with Vertical(id="search-container"):
52
65
  yield Input(
53
- placeholder="Search processes...",
66
+ placeholder="Search YouTube music...",
54
67
  id="search-input",
55
68
  )
56
69
 
57
70
  yield Static(
58
- " # Process PID Duration Status",
71
+ " # Title Channel Duration Views",
59
72
  id="list-header",
60
73
  )
61
74
 
62
- yield ListView(id="results-list")
63
- 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")
64
86
 
65
87
  def on_mount(self) -> None:
66
- """Focus the search input on mount."""
67
- 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()
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
- status = f"Found {len(self.results)} | Enter/Space=Play A=Queue R=Repeat{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}"
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 " [REPEAT ONE]"
219
+ return " [R1]"
147
220
  elif mode == "all":
148
- return " [REPEAT ALL]"
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
- self._update_status(f"Repeat: {mode_names[mode]} | Found {count}{repeat_status}")
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
 
@@ -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
@@ -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
- 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]■[/]"
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).update(display_title)
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).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")
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).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
+
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
- 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)
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 = "[R1]"
132
+ indicator = "[bold purple]⟳1[/]"
104
133
  elif new_mode == "all":
105
- indicator = "[RA]"
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")