wrkmon 1.0.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 +4 -0
- wrkmon/__main__.py +6 -0
- wrkmon/app.py +568 -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 +150 -0
- wrkmon/ui/widgets/__init__.py +7 -0
- wrkmon/ui/widgets/header.py +59 -0
- wrkmon/ui/widgets/player_bar.py +115 -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.0.dist-info/METADATA +193 -0
- wrkmon-1.0.0.dist-info/RECORD +41 -0
- wrkmon-1.0.0.dist-info/WHEEL +5 -0
- wrkmon-1.0.0.dist-info/entry_points.txt +2 -0
- wrkmon-1.0.0.dist-info/licenses/LICENSE.txt +21 -0
- wrkmon-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Playlists view container for wrkmon."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Vertical
|
|
5
|
+
from textual.widgets import Static, ListView, ListItem, Label, Input
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual import on
|
|
8
|
+
|
|
9
|
+
from wrkmon.core.youtube import SearchResult
|
|
10
|
+
from wrkmon.data.models import Playlist, Track
|
|
11
|
+
from wrkmon.ui.messages import TrackSelected, TrackQueued, StatusMessage
|
|
12
|
+
from wrkmon.utils.stealth import get_stealth
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlaylistItem(ListItem):
|
|
16
|
+
"""List item for a playlist."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, playlist: Playlist, index: int, **kwargs) -> None:
|
|
19
|
+
super().__init__(**kwargs)
|
|
20
|
+
self.playlist = playlist
|
|
21
|
+
self.index = index
|
|
22
|
+
|
|
23
|
+
def compose(self) -> ComposeResult:
|
|
24
|
+
count = self.playlist.track_count
|
|
25
|
+
duration = self.playlist.total_duration_str
|
|
26
|
+
text = f"{self.index:>2} {self.playlist.name:<42} {count:>4} tracks {duration:>8}"
|
|
27
|
+
yield Label(text)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TrackItem(ListItem):
|
|
31
|
+
"""List item for a track in a playlist."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, track: Track, index: int, **kwargs) -> None:
|
|
34
|
+
super().__init__(**kwargs)
|
|
35
|
+
self.track = track
|
|
36
|
+
self.index = index
|
|
37
|
+
self._stealth = get_stealth()
|
|
38
|
+
|
|
39
|
+
def compose(self) -> ComposeResult:
|
|
40
|
+
process_name = self._stealth.get_fake_process_name(self.track.title)[:40]
|
|
41
|
+
duration = self.track.duration_str
|
|
42
|
+
text = f" {self.index:>2} {process_name:<40} {duration:>8}"
|
|
43
|
+
yield Label(text)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PlaylistsView(Vertical):
|
|
47
|
+
"""Playlists view - manage playlists and their tracks."""
|
|
48
|
+
|
|
49
|
+
BINDINGS = [
|
|
50
|
+
Binding("a", "queue_selected", "Add to Queue", show=True),
|
|
51
|
+
Binding("n", "new_playlist", "New Playlist", show=True),
|
|
52
|
+
Binding("d", "delete_item", "Delete", show=True),
|
|
53
|
+
Binding("p", "play_all", "Play All", show=True),
|
|
54
|
+
Binding("escape", "go_back", "Back", show=True),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
def __init__(self, **kwargs) -> None:
|
|
58
|
+
super().__init__(**kwargs)
|
|
59
|
+
self.playlists: list[Playlist] = []
|
|
60
|
+
self.current_playlist: Playlist | None = None
|
|
61
|
+
self.viewing_tracks = False
|
|
62
|
+
|
|
63
|
+
def compose(self) -> ComposeResult:
|
|
64
|
+
yield Static("PLAYLISTS", id="view-title")
|
|
65
|
+
|
|
66
|
+
yield Static(
|
|
67
|
+
" # Name Tracks Duration",
|
|
68
|
+
id="list-header",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
yield ListView(id="playlist-list")
|
|
72
|
+
|
|
73
|
+
yield Input(
|
|
74
|
+
placeholder="Enter playlist name...",
|
|
75
|
+
id="new-playlist-input",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
yield Static("Loading playlists...", id="status-bar")
|
|
79
|
+
|
|
80
|
+
def on_mount(self) -> None:
|
|
81
|
+
"""Load playlists on mount."""
|
|
82
|
+
self.query_one("#new-playlist-input", Input).display = False
|
|
83
|
+
self.load_playlists()
|
|
84
|
+
|
|
85
|
+
def load_playlists(self) -> None:
|
|
86
|
+
"""Load all playlists."""
|
|
87
|
+
try:
|
|
88
|
+
db = self.app.database
|
|
89
|
+
self.playlists = db.get_all_playlists()
|
|
90
|
+
self._display_playlists()
|
|
91
|
+
except Exception as e:
|
|
92
|
+
self._update_status(f"Failed to load: {e}")
|
|
93
|
+
|
|
94
|
+
def _display_playlists(self) -> None:
|
|
95
|
+
"""Display playlist list."""
|
|
96
|
+
self.viewing_tracks = False
|
|
97
|
+
self.current_playlist = None
|
|
98
|
+
|
|
99
|
+
self.query_one("#view-title", Static).update("PLAYLISTS")
|
|
100
|
+
self.query_one("#list-header", Static).update(
|
|
101
|
+
" # Name Tracks Duration"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
list_view = self.query_one("#playlist-list", ListView)
|
|
105
|
+
list_view.clear()
|
|
106
|
+
|
|
107
|
+
if not self.playlists:
|
|
108
|
+
self._update_status("No playlists. Press 'n' to create one.")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
for i, playlist in enumerate(self.playlists, 1):
|
|
112
|
+
list_view.append(PlaylistItem(playlist, i))
|
|
113
|
+
|
|
114
|
+
self._update_status(f"{len(self.playlists)} playlists")
|
|
115
|
+
|
|
116
|
+
def _display_tracks(self, playlist: Playlist) -> None:
|
|
117
|
+
"""Display tracks in a playlist."""
|
|
118
|
+
self.viewing_tracks = True
|
|
119
|
+
self.current_playlist = playlist
|
|
120
|
+
|
|
121
|
+
# Load full playlist with tracks
|
|
122
|
+
try:
|
|
123
|
+
full_playlist = self.app.database.get_playlist(playlist.id)
|
|
124
|
+
if full_playlist:
|
|
125
|
+
self.current_playlist = full_playlist
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
self.query_one("#view-title", Static).update(f"PLAYLIST: {playlist.name}")
|
|
130
|
+
self.query_one("#list-header", Static).update(
|
|
131
|
+
" # Process Duration"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
list_view = self.query_one("#playlist-list", ListView)
|
|
135
|
+
list_view.clear()
|
|
136
|
+
|
|
137
|
+
if not self.current_playlist.tracks:
|
|
138
|
+
self._update_status("Playlist empty")
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
for i, track in enumerate(self.current_playlist.tracks, 1):
|
|
142
|
+
list_view.append(TrackItem(track, i))
|
|
143
|
+
|
|
144
|
+
self._update_status(
|
|
145
|
+
f"{len(self.current_playlist.tracks)} tracks - "
|
|
146
|
+
f"{self.current_playlist.total_duration_str}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _update_status(self, message: str) -> None:
|
|
150
|
+
"""Update status bar."""
|
|
151
|
+
try:
|
|
152
|
+
self.query_one("#status-bar", Static).update(message)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
def _track_to_result(self, track: Track) -> SearchResult:
|
|
157
|
+
"""Convert a Track to SearchResult."""
|
|
158
|
+
return SearchResult(
|
|
159
|
+
video_id=track.video_id,
|
|
160
|
+
title=track.title,
|
|
161
|
+
channel=track.channel,
|
|
162
|
+
duration=track.duration,
|
|
163
|
+
view_count=0,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def action_go_back(self) -> None:
|
|
167
|
+
"""Go back to playlist list."""
|
|
168
|
+
if self.viewing_tracks:
|
|
169
|
+
self._display_playlists()
|
|
170
|
+
|
|
171
|
+
@on(ListView.Selected, "#playlist-list")
|
|
172
|
+
def handle_item_selected(self, event: ListView.Selected) -> None:
|
|
173
|
+
"""Handle Enter key - open playlist or play track."""
|
|
174
|
+
item = event.item
|
|
175
|
+
|
|
176
|
+
if isinstance(item, PlaylistItem):
|
|
177
|
+
self._display_tracks(item.playlist)
|
|
178
|
+
elif isinstance(item, TrackItem):
|
|
179
|
+
result = self._track_to_result(item.track)
|
|
180
|
+
self.post_message(TrackSelected(result))
|
|
181
|
+
self._update_status(f"Playing: {item.track.title[:40]}...")
|
|
182
|
+
|
|
183
|
+
def action_new_playlist(self) -> None:
|
|
184
|
+
"""Create a new playlist."""
|
|
185
|
+
if self.viewing_tracks:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
input_widget = self.query_one("#new-playlist-input", Input)
|
|
189
|
+
input_widget.display = True
|
|
190
|
+
input_widget.focus()
|
|
191
|
+
|
|
192
|
+
@on(Input.Submitted, "#new-playlist-input")
|
|
193
|
+
def handle_new_playlist(self, event: Input.Submitted) -> None:
|
|
194
|
+
"""Handle new playlist name submission."""
|
|
195
|
+
name = event.value.strip()
|
|
196
|
+
input_widget = self.query_one("#new-playlist-input", Input)
|
|
197
|
+
input_widget.display = False
|
|
198
|
+
input_widget.value = ""
|
|
199
|
+
|
|
200
|
+
if not name:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
db = self.app.database
|
|
205
|
+
playlist = db.create_playlist(name)
|
|
206
|
+
self.playlists.append(playlist)
|
|
207
|
+
self._display_playlists()
|
|
208
|
+
self.post_message(StatusMessage(f"Created: {name}", "success"))
|
|
209
|
+
except Exception as e:
|
|
210
|
+
self.post_message(StatusMessage(f"Error: {e}", "error"))
|
|
211
|
+
|
|
212
|
+
def action_delete_item(self) -> None:
|
|
213
|
+
"""Delete selected item."""
|
|
214
|
+
list_view = self.query_one("#playlist-list", ListView)
|
|
215
|
+
if list_view.highlighted_child is None:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
item = list_view.highlighted_child
|
|
219
|
+
db = self.app.database
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
if isinstance(item, PlaylistItem):
|
|
223
|
+
if db.delete_playlist(item.playlist.id):
|
|
224
|
+
self.load_playlists()
|
|
225
|
+
self.post_message(StatusMessage(f"Deleted: {item.playlist.name}", "info"))
|
|
226
|
+
elif isinstance(item, TrackItem) and self.current_playlist:
|
|
227
|
+
if db.remove_track_from_playlist(self.current_playlist.id, item.track.id):
|
|
228
|
+
self._display_tracks(self.current_playlist)
|
|
229
|
+
self.post_message(StatusMessage("Track removed", "info"))
|
|
230
|
+
except Exception as e:
|
|
231
|
+
self.post_message(StatusMessage(f"Error: {e}", "error"))
|
|
232
|
+
|
|
233
|
+
async def action_play_all(self) -> None:
|
|
234
|
+
"""Play all tracks in current playlist."""
|
|
235
|
+
if not self.current_playlist or not self.current_playlist.tracks:
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
# Add all to queue
|
|
239
|
+
for track in self.current_playlist.tracks:
|
|
240
|
+
result = self._track_to_result(track)
|
|
241
|
+
self.app.add_to_queue(result)
|
|
242
|
+
|
|
243
|
+
# Play first
|
|
244
|
+
first = self.current_playlist.tracks[0]
|
|
245
|
+
result = self._track_to_result(first)
|
|
246
|
+
self.post_message(TrackSelected(result))
|
|
247
|
+
self._update_status("Playing playlist")
|
|
248
|
+
|
|
249
|
+
def action_queue_selected(self) -> None:
|
|
250
|
+
"""Add selected track to queue."""
|
|
251
|
+
list_view = self.query_one("#playlist-list", ListView)
|
|
252
|
+
if list_view.highlighted_child is None:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
item = list_view.highlighted_child
|
|
256
|
+
if isinstance(item, TrackItem):
|
|
257
|
+
result = self._track_to_result(item.track)
|
|
258
|
+
self.post_message(TrackQueued(result))
|
|
259
|
+
self._update_status(f"Queued: {item.track.title[:40]}...")
|
wrkmon/ui/views/queue.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Queue view container for wrkmon."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Vertical, Horizontal
|
|
5
|
+
from textual.widgets import Static, ListView, ProgressBar
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.reactive import reactive
|
|
8
|
+
|
|
9
|
+
from wrkmon.ui.widgets.result_item import QueueItem
|
|
10
|
+
from wrkmon.ui.messages import StatusMessage
|
|
11
|
+
from wrkmon.utils.stealth import get_stealth
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QueueView(Vertical):
|
|
15
|
+
"""Queue/Player view - shows current playback and queue."""
|
|
16
|
+
|
|
17
|
+
BINDINGS = [
|
|
18
|
+
Binding("space", "toggle_pause", "Play/Pause", show=True),
|
|
19
|
+
Binding("n", "next_track", "Next", show=True),
|
|
20
|
+
Binding("p", "prev_track", "Previous", show=True),
|
|
21
|
+
Binding("s", "toggle_shuffle", "Shuffle", show=True),
|
|
22
|
+
Binding("r", "toggle_repeat", "Repeat", show=True),
|
|
23
|
+
Binding("c", "clear_queue", "Clear Queue", show=True),
|
|
24
|
+
Binding("delete", "remove_selected", "Remove", show=False),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Reactive state
|
|
28
|
+
shuffle_enabled = reactive(False)
|
|
29
|
+
repeat_mode = reactive("none")
|
|
30
|
+
|
|
31
|
+
def __init__(self, **kwargs) -> None:
|
|
32
|
+
super().__init__(**kwargs)
|
|
33
|
+
self._stealth = get_stealth()
|
|
34
|
+
|
|
35
|
+
def compose(self) -> ComposeResult:
|
|
36
|
+
yield Static("QUEUE", id="view-title")
|
|
37
|
+
|
|
38
|
+
# Now playing section
|
|
39
|
+
with Vertical(id="now-playing-section"):
|
|
40
|
+
yield Static("NOW RUNNING", id="section-header")
|
|
41
|
+
yield Static("No process running", id="current-track")
|
|
42
|
+
|
|
43
|
+
with Horizontal(id="playback-progress"):
|
|
44
|
+
yield Static("--:--", id="pos-time")
|
|
45
|
+
yield ProgressBar(total=100, show_percentage=False, id="track-progress")
|
|
46
|
+
yield Static("--:--", id="dur-time")
|
|
47
|
+
|
|
48
|
+
# Mode indicators
|
|
49
|
+
with Horizontal(id="mode-indicators"):
|
|
50
|
+
yield Static("", id="shuffle-indicator")
|
|
51
|
+
yield Static("", id="repeat-indicator")
|
|
52
|
+
|
|
53
|
+
# Queue list
|
|
54
|
+
yield Static(
|
|
55
|
+
" # Process Duration Status",
|
|
56
|
+
id="list-header",
|
|
57
|
+
)
|
|
58
|
+
yield ListView(id="queue-list")
|
|
59
|
+
yield Static("Queue empty", id="status-bar")
|
|
60
|
+
|
|
61
|
+
def on_mount(self) -> None:
|
|
62
|
+
"""Initialize the view."""
|
|
63
|
+
self.refresh_queue()
|
|
64
|
+
|
|
65
|
+
def refresh_queue(self) -> None:
|
|
66
|
+
"""Refresh the queue display."""
|
|
67
|
+
try:
|
|
68
|
+
queue = self.app.queue
|
|
69
|
+
list_view = self.query_one("#queue-list", ListView)
|
|
70
|
+
list_view.clear()
|
|
71
|
+
|
|
72
|
+
items = queue.to_list()
|
|
73
|
+
if not items:
|
|
74
|
+
self._update_status("Queue empty")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
for i, item in enumerate(items):
|
|
78
|
+
is_current = i == queue.current_index
|
|
79
|
+
list_view.append(
|
|
80
|
+
QueueItem(
|
|
81
|
+
title=item.title,
|
|
82
|
+
duration=item.duration,
|
|
83
|
+
index=i + 1,
|
|
84
|
+
is_current=is_current,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self._update_status(f"{len(items)} in queue")
|
|
89
|
+
self._update_mode_indicators()
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
def update_now_playing(self, title: str, position: float, duration: float) -> None:
|
|
94
|
+
"""Update the now playing display."""
|
|
95
|
+
try:
|
|
96
|
+
process_name = self._stealth.get_fake_process_name(title)
|
|
97
|
+
self.query_one("#current-track", Static).update(process_name)
|
|
98
|
+
self.query_one("#pos-time", Static).update(self._stealth.format_duration(position))
|
|
99
|
+
self.query_one("#dur-time", Static).update(self._stealth.format_duration(duration))
|
|
100
|
+
|
|
101
|
+
if duration > 0:
|
|
102
|
+
progress = (position / duration) * 100
|
|
103
|
+
self.query_one("#track-progress", ProgressBar).update(progress=progress)
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
def _update_status(self, message: str) -> None:
|
|
108
|
+
"""Update status bar."""
|
|
109
|
+
try:
|
|
110
|
+
self.query_one("#status-bar", Static).update(message)
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
def _update_mode_indicators(self) -> None:
|
|
115
|
+
"""Update shuffle/repeat indicators."""
|
|
116
|
+
try:
|
|
117
|
+
queue = self.app.queue
|
|
118
|
+
|
|
119
|
+
shuffle_text = "[SHUFFLE]" if queue.shuffle_mode else ""
|
|
120
|
+
self.query_one("#shuffle-indicator", Static).update(shuffle_text)
|
|
121
|
+
|
|
122
|
+
repeat_text = ""
|
|
123
|
+
if queue.repeat_mode == "one":
|
|
124
|
+
repeat_text = "[REPEAT ONE]"
|
|
125
|
+
elif queue.repeat_mode == "all":
|
|
126
|
+
repeat_text = "[REPEAT ALL]"
|
|
127
|
+
self.query_one("#repeat-indicator", Static).update(repeat_text)
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
async def action_toggle_pause(self) -> None:
|
|
132
|
+
"""Toggle playback."""
|
|
133
|
+
try:
|
|
134
|
+
await self.app.toggle_pause()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
async def action_next_track(self) -> None:
|
|
139
|
+
"""Play next track."""
|
|
140
|
+
try:
|
|
141
|
+
await self.app.play_next()
|
|
142
|
+
self.refresh_queue()
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
async def action_prev_track(self) -> None:
|
|
147
|
+
"""Play previous track."""
|
|
148
|
+
try:
|
|
149
|
+
await self.app.play_previous()
|
|
150
|
+
self.refresh_queue()
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def action_toggle_shuffle(self) -> None:
|
|
155
|
+
"""Toggle shuffle mode."""
|
|
156
|
+
try:
|
|
157
|
+
is_shuffle = self.app.queue.toggle_shuffle()
|
|
158
|
+
self._update_mode_indicators()
|
|
159
|
+
status = "Shuffle ON" if is_shuffle else "Shuffle OFF"
|
|
160
|
+
self.post_message(StatusMessage(status, "info"))
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
def action_toggle_repeat(self) -> None:
|
|
165
|
+
"""Cycle repeat mode."""
|
|
166
|
+
try:
|
|
167
|
+
mode = self.app.queue.cycle_repeat()
|
|
168
|
+
self._update_mode_indicators()
|
|
169
|
+
mode_names = {"none": "OFF", "one": "ONE", "all": "ALL"}
|
|
170
|
+
self.post_message(StatusMessage(f"Repeat: {mode_names[mode]}", "info"))
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
def action_clear_queue(self) -> None:
|
|
175
|
+
"""Clear the queue."""
|
|
176
|
+
try:
|
|
177
|
+
self.app.queue.clear()
|
|
178
|
+
self.refresh_queue()
|
|
179
|
+
self.post_message(StatusMessage("Queue cleared", "info"))
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
def action_remove_selected(self) -> None:
|
|
184
|
+
"""Remove selected item from queue."""
|
|
185
|
+
try:
|
|
186
|
+
list_view = self.query_one("#queue-list", ListView)
|
|
187
|
+
if list_view.index is not None:
|
|
188
|
+
self.app.queue.remove(list_view.index)
|
|
189
|
+
self.refresh_queue()
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Search view container for wrkmon."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Vertical
|
|
6
|
+
from textual.widgets import Static, Input, ListView
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.events import Key
|
|
9
|
+
from textual import on
|
|
10
|
+
|
|
11
|
+
from wrkmon.core.youtube import SearchResult
|
|
12
|
+
from wrkmon.ui.messages import TrackSelected, TrackQueued, StatusMessage
|
|
13
|
+
from wrkmon.ui.widgets.result_item import ResultItem
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("wrkmon.search")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SearchView(Vertical):
|
|
19
|
+
"""Main search view - search YouTube and display results."""
|
|
20
|
+
|
|
21
|
+
BINDINGS = [
|
|
22
|
+
Binding("a", "queue_selected", "Add to Queue", show=True),
|
|
23
|
+
Binding("escape", "clear_search", "Clear", show=True),
|
|
24
|
+
Binding("/", "focus_search", "Search", show=True),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def __init__(self, **kwargs) -> None:
|
|
28
|
+
super().__init__(**kwargs)
|
|
29
|
+
self.results: list[SearchResult] = []
|
|
30
|
+
self._is_searching = False
|
|
31
|
+
|
|
32
|
+
def compose(self) -> ComposeResult:
|
|
33
|
+
yield Static("SEARCH", id="view-title")
|
|
34
|
+
|
|
35
|
+
with Vertical(id="search-container"):
|
|
36
|
+
yield Input(
|
|
37
|
+
placeholder="Search processes...",
|
|
38
|
+
id="search-input",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
yield Static(
|
|
42
|
+
" # Process PID Duration Status",
|
|
43
|
+
id="list-header",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
yield ListView(id="results-list")
|
|
47
|
+
yield Static("Type to search", id="status-bar")
|
|
48
|
+
|
|
49
|
+
def on_mount(self) -> None:
|
|
50
|
+
"""Focus the search input on mount."""
|
|
51
|
+
self.query_one("#search-input", Input).focus()
|
|
52
|
+
|
|
53
|
+
@on(Input.Submitted, "#search-input")
|
|
54
|
+
async def handle_search(self, event: Input.Submitted) -> None:
|
|
55
|
+
"""Execute search when Enter is pressed."""
|
|
56
|
+
query = event.value.strip()
|
|
57
|
+
if not query or self._is_searching:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
self._is_searching = True
|
|
61
|
+
self._update_status("Searching...")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
# Access the app's YouTube client
|
|
65
|
+
youtube = self.app.youtube
|
|
66
|
+
self.results = await youtube.search(query, max_results=15)
|
|
67
|
+
self._display_results()
|
|
68
|
+
except Exception as e:
|
|
69
|
+
self._update_status(f"Search failed: {e}")
|
|
70
|
+
self.post_message(StatusMessage(f"Search error: {e}", "error"))
|
|
71
|
+
finally:
|
|
72
|
+
self._is_searching = False
|
|
73
|
+
|
|
74
|
+
def _display_results(self) -> None:
|
|
75
|
+
"""Display search results in the list."""
|
|
76
|
+
list_view = self.query_one("#results-list", ListView)
|
|
77
|
+
list_view.clear()
|
|
78
|
+
|
|
79
|
+
if not self.results:
|
|
80
|
+
self._update_status("No results found")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
for i, result in enumerate(self.results, 1):
|
|
84
|
+
list_view.append(ResultItem(result, i))
|
|
85
|
+
|
|
86
|
+
self._update_status(f"Found {len(self.results)} | Enter=Play A=Queue /=Search ↑↓=Nav")
|
|
87
|
+
list_view.focus()
|
|
88
|
+
|
|
89
|
+
def _update_status(self, message: str) -> None:
|
|
90
|
+
"""Update the status bar."""
|
|
91
|
+
try:
|
|
92
|
+
self.query_one("#status-bar", Static).update(message)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
def _get_selected(self) -> SearchResult | None:
|
|
97
|
+
"""Get the currently selected result."""
|
|
98
|
+
list_view = self.query_one("#results-list", ListView)
|
|
99
|
+
if list_view.highlighted_child is None:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
item = list_view.highlighted_child
|
|
103
|
+
if isinstance(item, ResultItem):
|
|
104
|
+
return item.result
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def action_clear_search(self) -> None:
|
|
108
|
+
"""Clear the search input."""
|
|
109
|
+
search_input = self.query_one("#search-input", Input)
|
|
110
|
+
if search_input.value:
|
|
111
|
+
search_input.value = ""
|
|
112
|
+
search_input.focus()
|
|
113
|
+
|
|
114
|
+
@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):
|
|
118
|
+
result = event.item.result
|
|
119
|
+
self.post_message(TrackSelected(result))
|
|
120
|
+
self._update_status(f"Playing: {result.title[:40]}...")
|
|
121
|
+
|
|
122
|
+
def action_queue_selected(self) -> None:
|
|
123
|
+
"""Add selected track to queue."""
|
|
124
|
+
logger.info("=== 'a' PRESSED: action_queue_selected ===")
|
|
125
|
+
result = self._get_selected()
|
|
126
|
+
logger.info(f" Selected result: {result}")
|
|
127
|
+
if result:
|
|
128
|
+
logger.info(f" Posting TrackQueued for: {result.title}")
|
|
129
|
+
self.post_message(TrackQueued(result))
|
|
130
|
+
self._update_status(f"Queued: {result.title[:40]}...")
|
|
131
|
+
else:
|
|
132
|
+
logger.warning(" No result selected!")
|
|
133
|
+
|
|
134
|
+
def focus_input(self) -> None:
|
|
135
|
+
"""Focus the search input (called from parent)."""
|
|
136
|
+
self.query_one("#search-input", Input).focus()
|
|
137
|
+
|
|
138
|
+
def action_focus_search(self) -> None:
|
|
139
|
+
"""Focus the search input (/ key)."""
|
|
140
|
+
self.focus_input()
|
|
141
|
+
|
|
142
|
+
def on_key(self, event: Key) -> None:
|
|
143
|
+
"""Handle key events - up at top of list goes to search."""
|
|
144
|
+
if event.key == "up":
|
|
145
|
+
list_view = self.query_one("#results-list", ListView)
|
|
146
|
+
# If list is focused and at top (index 0), go to search input
|
|
147
|
+
if list_view.has_focus and list_view.index == 0:
|
|
148
|
+
self.focus_input()
|
|
149
|
+
event.prevent_default()
|
|
150
|
+
event.stop()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Header bar widget for wrkmon."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Horizontal
|
|
5
|
+
from textual.reactive import reactive
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
|
|
8
|
+
from wrkmon.utils.stealth import get_stealth
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HeaderBar(Static):
|
|
12
|
+
"""Application header with title and fake system stats."""
|
|
13
|
+
|
|
14
|
+
cpu = reactive(23)
|
|
15
|
+
mem = reactive(45)
|
|
16
|
+
|
|
17
|
+
def __init__(self, **kwargs) -> None:
|
|
18
|
+
super().__init__(**kwargs)
|
|
19
|
+
self._stealth = get_stealth()
|
|
20
|
+
|
|
21
|
+
def compose(self) -> ComposeResult:
|
|
22
|
+
with Horizontal(id="header-inner"):
|
|
23
|
+
yield Static("WRKMON", id="app-title")
|
|
24
|
+
yield Static("", id="current-view")
|
|
25
|
+
yield Static(self._format_stats(), id="sys-stats")
|
|
26
|
+
|
|
27
|
+
def _format_stats(self) -> str:
|
|
28
|
+
return f"CPU:{self.cpu:>3}% MEM:{self.mem:>3}%"
|
|
29
|
+
|
|
30
|
+
def on_mount(self) -> None:
|
|
31
|
+
"""Start periodic stats updates."""
|
|
32
|
+
self.set_interval(3.0, self._update_stats)
|
|
33
|
+
|
|
34
|
+
def _update_stats(self) -> None:
|
|
35
|
+
"""Update fake system stats."""
|
|
36
|
+
self.cpu = self._stealth.get_fake_cpu()
|
|
37
|
+
self.mem = self._stealth.get_fake_memory()
|
|
38
|
+
|
|
39
|
+
def watch_cpu(self) -> None:
|
|
40
|
+
"""React to CPU changes."""
|
|
41
|
+
self._refresh_stats()
|
|
42
|
+
|
|
43
|
+
def watch_mem(self) -> None:
|
|
44
|
+
"""React to memory changes."""
|
|
45
|
+
self._refresh_stats()
|
|
46
|
+
|
|
47
|
+
def _refresh_stats(self) -> None:
|
|
48
|
+
"""Update the stats display."""
|
|
49
|
+
try:
|
|
50
|
+
self.query_one("#sys-stats", Static).update(self._format_stats())
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def set_view_name(self, name: str) -> None:
|
|
55
|
+
"""Update the current view indicator."""
|
|
56
|
+
try:
|
|
57
|
+
self.query_one("#current-view", Static).update(f"/{name.upper()}")
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|