wrkmon 1.0.1__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 +4 -0
- wrkmon/__main__.py +6 -0
- wrkmon/app.py +592 -0
- wrkmon/cli.py +289 -0
- wrkmon/core/__init__.py +8 -0
- wrkmon/core/cache.py +208 -0
- wrkmon/core/player.py +301 -0
- wrkmon/core/queue.py +264 -0
- wrkmon/core/youtube.py +178 -0
- wrkmon/data/__init__.py +6 -0
- wrkmon/data/database.py +426 -0
- wrkmon/data/migrations.py +134 -0
- wrkmon/data/models.py +144 -0
- wrkmon/ui/__init__.py +5 -0
- wrkmon/ui/components.py +211 -0
- wrkmon/ui/messages.py +89 -0
- wrkmon/ui/screens/__init__.py +8 -0
- wrkmon/ui/screens/history.py +142 -0
- wrkmon/ui/screens/player.py +222 -0
- wrkmon/ui/screens/playlist.py +278 -0
- wrkmon/ui/screens/search.py +165 -0
- wrkmon/ui/theme.py +326 -0
- wrkmon/ui/views/__init__.py +8 -0
- wrkmon/ui/views/history.py +138 -0
- wrkmon/ui/views/playlists.py +259 -0
- wrkmon/ui/views/queue.py +191 -0
- wrkmon/ui/views/search.py +258 -0
- wrkmon/ui/widgets/__init__.py +7 -0
- wrkmon/ui/widgets/header.py +59 -0
- wrkmon/ui/widgets/player_bar.py +129 -0
- wrkmon/ui/widgets/result_item.py +98 -0
- wrkmon/utils/__init__.py +6 -0
- wrkmon/utils/config.py +172 -0
- wrkmon/utils/mpv_installer.py +190 -0
- wrkmon/utils/stealth.py +124 -0
- wrkmon-1.0.1.dist-info/METADATA +166 -0
- wrkmon-1.0.1.dist-info/RECORD +41 -0
- wrkmon-1.0.1.dist-info/WHEEL +5 -0
- wrkmon-1.0.1.dist-info/entry_points.txt +2 -0
- wrkmon-1.0.1.dist-info/licenses/LICENSE +21 -0
- wrkmon-1.0.1.dist-info/top_level.txt +1 -0
wrkmon/__init__.py
ADDED
wrkmon/__main__.py
ADDED
wrkmon/app.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""Main TUI application for wrkmon - properly structured with Textual best practices."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
# Setup logging to file
|
|
8
|
+
log_path = Path.home() / ".wrkmon_debug.log"
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
filename=str(log_path),
|
|
11
|
+
level=logging.DEBUG,
|
|
12
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
13
|
+
datefmt="%H:%M:%S",
|
|
14
|
+
)
|
|
15
|
+
logger = logging.getLogger("wrkmon.app")
|
|
16
|
+
logger.info(f"=== WRKMON STARTED === Log file: {log_path}")
|
|
17
|
+
|
|
18
|
+
from textual.app import App, ComposeResult
|
|
19
|
+
from textual.binding import Binding
|
|
20
|
+
from textual.containers import Container
|
|
21
|
+
from textual.widgets import Footer, ContentSwitcher
|
|
22
|
+
|
|
23
|
+
from wrkmon.core.youtube import YouTubeClient, SearchResult
|
|
24
|
+
from wrkmon.core.player import AudioPlayer
|
|
25
|
+
from wrkmon.core.queue import PlayQueue
|
|
26
|
+
from wrkmon.core.cache import Cache
|
|
27
|
+
from wrkmon.data.database import Database
|
|
28
|
+
from wrkmon.utils.config import get_config
|
|
29
|
+
from wrkmon.utils.stealth import get_stealth
|
|
30
|
+
|
|
31
|
+
from wrkmon.ui.theme import APP_CSS
|
|
32
|
+
from wrkmon.ui.widgets.header import HeaderBar
|
|
33
|
+
from wrkmon.ui.widgets.player_bar import PlayerBar
|
|
34
|
+
from wrkmon.ui.views.search import SearchView
|
|
35
|
+
from wrkmon.ui.views.queue import QueueView
|
|
36
|
+
from wrkmon.ui.views.history import HistoryView
|
|
37
|
+
from wrkmon.ui.views.playlists import PlaylistsView
|
|
38
|
+
from wrkmon.ui.messages import (
|
|
39
|
+
TrackSelected,
|
|
40
|
+
TrackQueued,
|
|
41
|
+
StatusMessage,
|
|
42
|
+
PlaybackStateChanged,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WrkmonApp(App):
|
|
47
|
+
"""Main wrkmon TUI application with proper Textual architecture."""
|
|
48
|
+
|
|
49
|
+
CSS = APP_CSS
|
|
50
|
+
TITLE = "wrkmon"
|
|
51
|
+
|
|
52
|
+
BINDINGS = [
|
|
53
|
+
# Global navigation (priority so they work even when input focused)
|
|
54
|
+
Binding("f1", "switch_view('search')", "Search", show=True, priority=True),
|
|
55
|
+
Binding("f2", "switch_view('queue')", "Queue", show=True, priority=True),
|
|
56
|
+
Binding("f3", "switch_view('history')", "History", show=True, priority=True),
|
|
57
|
+
Binding("f4", "switch_view('playlists')", "Lists", show=True, priority=True),
|
|
58
|
+
# Playback controls (global)
|
|
59
|
+
Binding("f5", "toggle_pause", "Play/Pause", show=True, priority=True),
|
|
60
|
+
Binding("f6", "volume_down", "Vol-", show=True, priority=True),
|
|
61
|
+
Binding("f7", "volume_up", "Vol+", show=True, priority=True),
|
|
62
|
+
Binding("f8", "next_track", "Next", show=True, priority=True),
|
|
63
|
+
Binding("f9", "stop", "Stop", show=True, priority=True),
|
|
64
|
+
Binding("f10", "queue_current", "Queue", show=True, priority=True),
|
|
65
|
+
# Additional controls (when not in input)
|
|
66
|
+
Binding("space", "toggle_pause", "Play/Pause", show=False),
|
|
67
|
+
Binding("+", "volume_up", "Vol+", show=False),
|
|
68
|
+
Binding("=", "volume_up", "Vol+", show=False),
|
|
69
|
+
Binding("-", "volume_down", "Vol-", show=False),
|
|
70
|
+
Binding("n", "next_track", "Next", show=False),
|
|
71
|
+
Binding("p", "prev_track", "Prev", show=False),
|
|
72
|
+
Binding("s", "stop", "Stop", show=False),
|
|
73
|
+
# App controls
|
|
74
|
+
Binding("escape", "focus_search", "Back", show=False, priority=True),
|
|
75
|
+
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
|
|
76
|
+
Binding("ctrl+q", "quit", "Quit", show=False, priority=True),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
def __init__(self, **kwargs) -> None:
|
|
80
|
+
super().__init__(**kwargs)
|
|
81
|
+
|
|
82
|
+
# Load config
|
|
83
|
+
self._config = get_config()
|
|
84
|
+
self._stealth = get_stealth()
|
|
85
|
+
|
|
86
|
+
# Core services
|
|
87
|
+
self.youtube = YouTubeClient()
|
|
88
|
+
self.player = AudioPlayer()
|
|
89
|
+
self.queue = PlayQueue()
|
|
90
|
+
self.cache = Cache()
|
|
91
|
+
self.database = Database()
|
|
92
|
+
|
|
93
|
+
# State
|
|
94
|
+
self._volume = self._config.volume
|
|
95
|
+
self._current_track: SearchResult | None = None
|
|
96
|
+
self._current_view = "search"
|
|
97
|
+
|
|
98
|
+
def compose(self) -> ComposeResult:
|
|
99
|
+
"""Compose the application layout."""
|
|
100
|
+
# Header (docked top)
|
|
101
|
+
yield HeaderBar()
|
|
102
|
+
|
|
103
|
+
# Main content area with view switcher
|
|
104
|
+
with Container(id="content-area"):
|
|
105
|
+
with ContentSwitcher(initial="search"):
|
|
106
|
+
yield SearchView(id="search")
|
|
107
|
+
yield QueueView(id="queue")
|
|
108
|
+
yield HistoryView(id="history")
|
|
109
|
+
yield PlaylistsView(id="playlists")
|
|
110
|
+
|
|
111
|
+
# Player bar (docked bottom)
|
|
112
|
+
yield PlayerBar()
|
|
113
|
+
|
|
114
|
+
# Footer with key hints
|
|
115
|
+
yield Footer()
|
|
116
|
+
|
|
117
|
+
async def on_mount(self) -> None:
|
|
118
|
+
"""Initialize app on mount."""
|
|
119
|
+
# Set terminal title
|
|
120
|
+
self._stealth.set_terminal_title("wrkmon")
|
|
121
|
+
|
|
122
|
+
# Check if mpv is available
|
|
123
|
+
from wrkmon.utils.mpv_installer import is_mpv_installed, ensure_mpv_installed
|
|
124
|
+
|
|
125
|
+
if not is_mpv_installed():
|
|
126
|
+
success, msg = ensure_mpv_installed()
|
|
127
|
+
if not success:
|
|
128
|
+
# Show error in player bar
|
|
129
|
+
player_bar = self._get_player_bar()
|
|
130
|
+
player_bar.update_playback(
|
|
131
|
+
title="mpv not found! Run: winget install mpv",
|
|
132
|
+
is_playing=False
|
|
133
|
+
)
|
|
134
|
+
self.notify(
|
|
135
|
+
"mpv is required for audio playback.\n"
|
|
136
|
+
"Install with: winget install mpv",
|
|
137
|
+
title="mpv Not Found",
|
|
138
|
+
severity="error",
|
|
139
|
+
timeout=10
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
# Try to start player
|
|
143
|
+
await self.player.start()
|
|
144
|
+
else:
|
|
145
|
+
# Start the audio player
|
|
146
|
+
started = await self.player.start()
|
|
147
|
+
if not started:
|
|
148
|
+
self._get_player_bar().update_playback(
|
|
149
|
+
title="Failed to start mpv",
|
|
150
|
+
is_playing=False
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Set initial volume
|
|
154
|
+
if self.player.is_connected:
|
|
155
|
+
await self.player.set_volume(self._volume)
|
|
156
|
+
self._get_player_bar().set_volume(self._volume)
|
|
157
|
+
|
|
158
|
+
# Start periodic updates
|
|
159
|
+
self.set_interval(1.0, self._update_playback_display)
|
|
160
|
+
|
|
161
|
+
# Update header view indicator
|
|
162
|
+
self._get_header().set_view_name("search")
|
|
163
|
+
|
|
164
|
+
# ----------------------------------------
|
|
165
|
+
# Component getters
|
|
166
|
+
# ----------------------------------------
|
|
167
|
+
def _get_header(self) -> HeaderBar:
|
|
168
|
+
"""Get the header bar widget."""
|
|
169
|
+
return self.query_one(HeaderBar)
|
|
170
|
+
|
|
171
|
+
def _get_player_bar(self) -> PlayerBar:
|
|
172
|
+
"""Get the player bar widget."""
|
|
173
|
+
return self.query_one(PlayerBar)
|
|
174
|
+
|
|
175
|
+
def _get_content_switcher(self) -> ContentSwitcher:
|
|
176
|
+
"""Get the content switcher."""
|
|
177
|
+
return self.query_one(ContentSwitcher)
|
|
178
|
+
|
|
179
|
+
# ----------------------------------------
|
|
180
|
+
# Message handlers
|
|
181
|
+
# ----------------------------------------
|
|
182
|
+
async def on_track_selected(self, message: TrackSelected) -> None:
|
|
183
|
+
"""Handle track selection for playback."""
|
|
184
|
+
await self.play_track(message.result)
|
|
185
|
+
|
|
186
|
+
def on_track_queued(self, message: TrackQueued) -> None:
|
|
187
|
+
"""Handle adding track to queue."""
|
|
188
|
+
logger.info(f"=== TrackQueued received: {message.result.title} ===")
|
|
189
|
+
pos = self.add_to_queue(message.result)
|
|
190
|
+
logger.info(f" Added at position: {pos}")
|
|
191
|
+
logger.info(f" Queue length now: {self.queue.length}")
|
|
192
|
+
logger.info(f" Queue current_index: {self.queue.current_index}")
|
|
193
|
+
|
|
194
|
+
def on_status_message(self, message: StatusMessage) -> None:
|
|
195
|
+
"""Handle status messages (could show in a notification area)."""
|
|
196
|
+
# For now, just log or ignore
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
# ----------------------------------------
|
|
200
|
+
# Playback methods
|
|
201
|
+
# ----------------------------------------
|
|
202
|
+
async def play_track(self, result: SearchResult) -> bool:
|
|
203
|
+
"""Play a track from search result."""
|
|
204
|
+
logger.info(f"=== play_track called: {result.title} ===")
|
|
205
|
+
logger.info(f" video_id: {result.video_id}")
|
|
206
|
+
|
|
207
|
+
self._current_track = result
|
|
208
|
+
player_bar = self._get_player_bar()
|
|
209
|
+
player_bar.update_playback(title=f"Loading: {result.title[:30]}...", is_playing=False)
|
|
210
|
+
|
|
211
|
+
# Check cache first
|
|
212
|
+
cached = self.cache.get(result.video_id)
|
|
213
|
+
if cached:
|
|
214
|
+
audio_url = cached.audio_url
|
|
215
|
+
logger.info(f" Cache HIT, audio_url: {audio_url[:80]}...")
|
|
216
|
+
else:
|
|
217
|
+
logger.info(" Cache MISS, fetching stream URL...")
|
|
218
|
+
# Get stream URL
|
|
219
|
+
player_bar.update_playback(title=f"Fetching: {result.title[:30]}...")
|
|
220
|
+
stream_info = await self.youtube.get_stream_url(result.video_id)
|
|
221
|
+
if not stream_info:
|
|
222
|
+
logger.error(" FAILED to get stream URL!")
|
|
223
|
+
player_bar.update_playback(title="ERROR: Failed to get stream URL", is_playing=False)
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
audio_url = stream_info.audio_url
|
|
227
|
+
logger.info(f" Got audio_url: {audio_url[:80]}...")
|
|
228
|
+
|
|
229
|
+
# Cache it
|
|
230
|
+
self.cache.set(
|
|
231
|
+
video_id=result.video_id,
|
|
232
|
+
title=result.title,
|
|
233
|
+
channel=result.channel,
|
|
234
|
+
duration=result.duration,
|
|
235
|
+
audio_url=audio_url,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Check if player is connected
|
|
239
|
+
logger.info(f" player.is_connected: {self.player.is_connected}")
|
|
240
|
+
if not self.player.is_connected:
|
|
241
|
+
logger.info(" Starting player...")
|
|
242
|
+
player_bar.update_playback(title="Starting player...")
|
|
243
|
+
started = await self.player.start()
|
|
244
|
+
logger.info(f" player.start() returned: {started}")
|
|
245
|
+
if not started:
|
|
246
|
+
logger.error(" FAILED to start player!")
|
|
247
|
+
player_bar.update_playback(
|
|
248
|
+
title="ERROR: mpv not found! Install mpv first.",
|
|
249
|
+
is_playing=False
|
|
250
|
+
)
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
# Play
|
|
254
|
+
logger.info(" Calling player.play()...")
|
|
255
|
+
player_bar.update_playback(title=f"Buffering: {result.title[:30]}...")
|
|
256
|
+
success = await self.player.play(audio_url)
|
|
257
|
+
logger.info(f" player.play() returned: {success}")
|
|
258
|
+
|
|
259
|
+
if success:
|
|
260
|
+
logger.info(" SUCCESS - audio should be playing!")
|
|
261
|
+
player_bar.update_playback(title=result.title, is_playing=True)
|
|
262
|
+
|
|
263
|
+
# Add to history
|
|
264
|
+
track = self.database.get_or_create_track(
|
|
265
|
+
video_id=result.video_id,
|
|
266
|
+
title=result.title,
|
|
267
|
+
channel=result.channel,
|
|
268
|
+
duration=result.duration,
|
|
269
|
+
)
|
|
270
|
+
self.database.add_to_history(track)
|
|
271
|
+
|
|
272
|
+
# Add to queue if empty
|
|
273
|
+
if self.queue.is_empty:
|
|
274
|
+
self.add_to_queue(result)
|
|
275
|
+
self.queue.jump_to(0)
|
|
276
|
+
else:
|
|
277
|
+
logger.error(" FAILED - player.play() returned False!")
|
|
278
|
+
player_bar.update_playback(
|
|
279
|
+
title="ERROR: Playback failed - check mpv installation",
|
|
280
|
+
is_playing=False
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return success
|
|
284
|
+
|
|
285
|
+
def add_to_queue(self, result: SearchResult) -> int:
|
|
286
|
+
"""Add a track to the queue."""
|
|
287
|
+
return self.queue.add_search_result(result)
|
|
288
|
+
|
|
289
|
+
async def toggle_pause(self) -> None:
|
|
290
|
+
"""Toggle play/pause."""
|
|
291
|
+
await self.player.toggle_pause()
|
|
292
|
+
is_playing = self.player.is_playing
|
|
293
|
+
self._get_player_bar().is_playing = is_playing
|
|
294
|
+
|
|
295
|
+
async def set_volume(self, volume: int) -> None:
|
|
296
|
+
"""Set volume level."""
|
|
297
|
+
self._volume = max(0, min(100, volume))
|
|
298
|
+
await self.player.set_volume(self._volume)
|
|
299
|
+
self._get_player_bar().set_volume(self._volume)
|
|
300
|
+
|
|
301
|
+
async def play_next(self) -> None:
|
|
302
|
+
"""Play next track in queue."""
|
|
303
|
+
next_item = self.queue.next()
|
|
304
|
+
if next_item:
|
|
305
|
+
result = SearchResult(
|
|
306
|
+
video_id=next_item.video_id,
|
|
307
|
+
title=next_item.title,
|
|
308
|
+
channel=next_item.channel,
|
|
309
|
+
duration=next_item.duration,
|
|
310
|
+
view_count=0,
|
|
311
|
+
)
|
|
312
|
+
await self.play_track(result)
|
|
313
|
+
|
|
314
|
+
async def play_previous(self) -> None:
|
|
315
|
+
"""Play previous track in queue."""
|
|
316
|
+
prev_item = self.queue.previous()
|
|
317
|
+
if prev_item:
|
|
318
|
+
result = SearchResult(
|
|
319
|
+
video_id=prev_item.video_id,
|
|
320
|
+
title=prev_item.title,
|
|
321
|
+
channel=prev_item.channel,
|
|
322
|
+
duration=prev_item.duration,
|
|
323
|
+
view_count=0,
|
|
324
|
+
)
|
|
325
|
+
await self.play_track(result)
|
|
326
|
+
|
|
327
|
+
# ----------------------------------------
|
|
328
|
+
# Periodic updates
|
|
329
|
+
# ----------------------------------------
|
|
330
|
+
async def _update_playback_display(self) -> None:
|
|
331
|
+
"""Update the player bar with current playback position."""
|
|
332
|
+
player_bar = self._get_player_bar()
|
|
333
|
+
|
|
334
|
+
# Always sync repeat mode to player bar
|
|
335
|
+
try:
|
|
336
|
+
player_bar.repeat_mode = self.queue.repeat_mode
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
if not self._current_track:
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
# Get current position and duration via IPC
|
|
345
|
+
pos = await self.player.get_position()
|
|
346
|
+
dur = await self.player.get_duration()
|
|
347
|
+
if dur == 0:
|
|
348
|
+
dur = self._current_track.duration
|
|
349
|
+
is_playing = self.player.is_playing
|
|
350
|
+
|
|
351
|
+
player_bar.update_playback(
|
|
352
|
+
position=pos,
|
|
353
|
+
duration=dur,
|
|
354
|
+
is_playing=is_playing,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Update queue view if visible
|
|
358
|
+
if self._current_view == "queue":
|
|
359
|
+
queue_view = self.query_one("#queue", QueueView)
|
|
360
|
+
queue_view.update_now_playing(
|
|
361
|
+
self._current_track.title, pos, dur
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Check if track ended
|
|
365
|
+
if dur > 0 and pos >= dur - 1:
|
|
366
|
+
await self._on_track_end()
|
|
367
|
+
|
|
368
|
+
except Exception:
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
async def _on_track_end(self) -> None:
|
|
372
|
+
"""Handle track end - play next."""
|
|
373
|
+
next_item = self.queue.next()
|
|
374
|
+
if next_item:
|
|
375
|
+
result = SearchResult(
|
|
376
|
+
video_id=next_item.video_id,
|
|
377
|
+
title=next_item.title,
|
|
378
|
+
channel=next_item.channel,
|
|
379
|
+
duration=next_item.duration,
|
|
380
|
+
view_count=0,
|
|
381
|
+
)
|
|
382
|
+
await self.play_track(result)
|
|
383
|
+
|
|
384
|
+
# ----------------------------------------
|
|
385
|
+
# Actions
|
|
386
|
+
# ----------------------------------------
|
|
387
|
+
def action_switch_view(self, view_name: str) -> None:
|
|
388
|
+
"""Switch to a different view."""
|
|
389
|
+
switcher = self._get_content_switcher()
|
|
390
|
+
switcher.current = view_name
|
|
391
|
+
self._current_view = view_name
|
|
392
|
+
self._get_header().set_view_name(view_name)
|
|
393
|
+
|
|
394
|
+
# Refresh queue view when switching to it
|
|
395
|
+
if view_name == "queue":
|
|
396
|
+
try:
|
|
397
|
+
self.query_one("#queue", QueueView).refresh_queue()
|
|
398
|
+
except Exception:
|
|
399
|
+
pass
|
|
400
|
+
# Auto-focus list when switching to search (if has results)
|
|
401
|
+
elif view_name == "search":
|
|
402
|
+
try:
|
|
403
|
+
self.query_one("#search", SearchView).focus_list()
|
|
404
|
+
except Exception:
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
async def action_toggle_pause(self) -> None:
|
|
408
|
+
"""Smart play/pause - starts playback if nothing playing."""
|
|
409
|
+
logger.info("=== F5 PRESSED: action_toggle_pause ===")
|
|
410
|
+
logger.info(f" player.is_connected: {self.player.is_connected}")
|
|
411
|
+
logger.info(f" _current_track: {self._current_track}")
|
|
412
|
+
logger.info(f" queue.is_empty: {self.queue.is_empty}")
|
|
413
|
+
logger.info(f" queue.length: {self.queue.length}")
|
|
414
|
+
logger.info(f" queue.current: {self.queue.current}")
|
|
415
|
+
|
|
416
|
+
# If player is actively playing, just toggle pause
|
|
417
|
+
if self.player.is_connected and self._current_track:
|
|
418
|
+
logger.info(" -> Toggling pause (already playing)")
|
|
419
|
+
await self.toggle_pause()
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
# Nothing playing - if in search view with selected item, play it
|
|
423
|
+
if self._current_view == "search":
|
|
424
|
+
try:
|
|
425
|
+
search_view = self.query_one("#search", SearchView)
|
|
426
|
+
result = search_view._get_selected()
|
|
427
|
+
if result:
|
|
428
|
+
logger.info(f" -> Playing selected search result: {result.title}")
|
|
429
|
+
await self.play_track(result)
|
|
430
|
+
return
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
|
|
434
|
+
# Nothing playing - try to play from queue
|
|
435
|
+
current = self.queue.current
|
|
436
|
+
if current:
|
|
437
|
+
logger.info(f" -> Playing current queue item: {current.title}")
|
|
438
|
+
# Play the current queue item
|
|
439
|
+
result = SearchResult(
|
|
440
|
+
video_id=current.video_id,
|
|
441
|
+
title=current.title,
|
|
442
|
+
channel=current.channel,
|
|
443
|
+
duration=current.duration,
|
|
444
|
+
view_count=0,
|
|
445
|
+
)
|
|
446
|
+
await self.play_track(result)
|
|
447
|
+
elif not self.queue.is_empty:
|
|
448
|
+
logger.info(" -> Queue has items, jumping to first")
|
|
449
|
+
# Queue has items but no current - start from first
|
|
450
|
+
self.queue.jump_to(0)
|
|
451
|
+
first = self.queue.current
|
|
452
|
+
if first:
|
|
453
|
+
logger.info(f" -> Playing first item: {first.title}")
|
|
454
|
+
result = SearchResult(
|
|
455
|
+
video_id=first.video_id,
|
|
456
|
+
title=first.title,
|
|
457
|
+
channel=first.channel,
|
|
458
|
+
duration=first.duration,
|
|
459
|
+
view_count=0,
|
|
460
|
+
)
|
|
461
|
+
await self.play_track(result)
|
|
462
|
+
else:
|
|
463
|
+
logger.warning(" -> Queue is EMPTY, cannot play")
|
|
464
|
+
# Queue is empty - notify user
|
|
465
|
+
self._get_player_bar().update_playback(
|
|
466
|
+
title="Queue empty - search and add tracks first",
|
|
467
|
+
is_playing=False
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
async def action_volume_up(self) -> None:
|
|
471
|
+
"""Increase volume."""
|
|
472
|
+
await self.set_volume(self._volume + 5)
|
|
473
|
+
|
|
474
|
+
async def action_volume_down(self) -> None:
|
|
475
|
+
"""Decrease volume."""
|
|
476
|
+
await self.set_volume(self._volume - 5)
|
|
477
|
+
|
|
478
|
+
async def action_next_track(self) -> None:
|
|
479
|
+
"""Play next track."""
|
|
480
|
+
await self.play_next()
|
|
481
|
+
|
|
482
|
+
async def action_prev_track(self) -> None:
|
|
483
|
+
"""Play previous track."""
|
|
484
|
+
await self.play_previous()
|
|
485
|
+
|
|
486
|
+
async def action_stop(self) -> None:
|
|
487
|
+
"""Stop playback completely."""
|
|
488
|
+
logger.info("=== F9 PRESSED: action_stop ===")
|
|
489
|
+
await self.player.stop()
|
|
490
|
+
self._current_track = None
|
|
491
|
+
self._get_player_bar().update_playback(
|
|
492
|
+
title="Stopped",
|
|
493
|
+
is_playing=False,
|
|
494
|
+
position=0,
|
|
495
|
+
duration=0,
|
|
496
|
+
)
|
|
497
|
+
logger.info(" Playback stopped")
|
|
498
|
+
|
|
499
|
+
def action_queue_current(self) -> None:
|
|
500
|
+
"""Queue the currently highlighted search result (F10)."""
|
|
501
|
+
logger.info("=== F10 PRESSED: action_queue_current ===")
|
|
502
|
+
if self._current_view != "search":
|
|
503
|
+
logger.info(" Not in search view, ignoring")
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
search_view = self.query_one("#search", SearchView)
|
|
508
|
+
result = search_view._get_selected()
|
|
509
|
+
if result:
|
|
510
|
+
logger.info(f" Queueing: {result.title}")
|
|
511
|
+
pos = self.add_to_queue(result)
|
|
512
|
+
self.notify(f"Queued: {result.title[:30]}...", timeout=2)
|
|
513
|
+
logger.info(f" Added at position: {pos}, queue length: {self.queue.length}")
|
|
514
|
+
else:
|
|
515
|
+
logger.warning(" No item selected")
|
|
516
|
+
self.notify("Select a track first", severity="warning", timeout=2)
|
|
517
|
+
except Exception as e:
|
|
518
|
+
logger.exception(f" Error: {e}")
|
|
519
|
+
|
|
520
|
+
def action_focus_search(self) -> None:
|
|
521
|
+
"""Switch to search view and focus input."""
|
|
522
|
+
self.action_switch_view("search")
|
|
523
|
+
try:
|
|
524
|
+
self.query_one("#search", SearchView).focus_input()
|
|
525
|
+
except Exception:
|
|
526
|
+
pass
|
|
527
|
+
|
|
528
|
+
async def action_quit(self) -> None:
|
|
529
|
+
"""Quit the application cleanly."""
|
|
530
|
+
await self._cleanup()
|
|
531
|
+
self.exit()
|
|
532
|
+
|
|
533
|
+
async def _cleanup(self) -> None:
|
|
534
|
+
"""Clean up resources."""
|
|
535
|
+
logger.info("=== Cleaning up ===")
|
|
536
|
+
# Save config
|
|
537
|
+
self._config.volume = self._volume
|
|
538
|
+
self._config.save()
|
|
539
|
+
|
|
540
|
+
# Shutdown player - MUST stop mpv
|
|
541
|
+
logger.info(" Stopping player...")
|
|
542
|
+
await self.player.shutdown()
|
|
543
|
+
|
|
544
|
+
# Close database
|
|
545
|
+
self.database.close()
|
|
546
|
+
|
|
547
|
+
# Restore terminal
|
|
548
|
+
self._stealth.restore_terminal_title()
|
|
549
|
+
logger.info(" Cleanup done")
|
|
550
|
+
|
|
551
|
+
async def on_unmount(self) -> None:
|
|
552
|
+
"""Called when app is unmounting - ensure cleanup."""
|
|
553
|
+
await self._cleanup()
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def run_app() -> None:
|
|
557
|
+
"""Run the wrkmon application."""
|
|
558
|
+
import atexit
|
|
559
|
+
import signal
|
|
560
|
+
|
|
561
|
+
app = WrkmonApp()
|
|
562
|
+
|
|
563
|
+
def cleanup_on_exit():
|
|
564
|
+
"""Ensure mpv is killed on exit."""
|
|
565
|
+
if app.player._process:
|
|
566
|
+
try:
|
|
567
|
+
app.player._process.terminate()
|
|
568
|
+
app.player._process.wait(timeout=1)
|
|
569
|
+
except Exception:
|
|
570
|
+
try:
|
|
571
|
+
app.player._process.kill()
|
|
572
|
+
except Exception:
|
|
573
|
+
pass
|
|
574
|
+
|
|
575
|
+
atexit.register(cleanup_on_exit)
|
|
576
|
+
|
|
577
|
+
# Handle Ctrl+C gracefully
|
|
578
|
+
def handle_sigint(signum, frame):
|
|
579
|
+
cleanup_on_exit()
|
|
580
|
+
raise SystemExit(0)
|
|
581
|
+
|
|
582
|
+
if sys.platform != "win32":
|
|
583
|
+
signal.signal(signal.SIGINT, handle_sigint)
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
app.run()
|
|
587
|
+
finally:
|
|
588
|
+
cleanup_on_exit()
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
if __name__ == "__main__":
|
|
592
|
+
run_app()
|