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
|
@@ -0,0 +1,258 @@
|
|
|
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, ListItem, Label
|
|
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 LoadMoreItem(ListItem):
|
|
19
|
+
"""Special list item for loading more results."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.is_load_more = True
|
|
24
|
+
|
|
25
|
+
def compose(self):
|
|
26
|
+
yield Label(" >>> Load More Results <<<", classes="load-more")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SearchView(Vertical):
|
|
30
|
+
"""Main search view - search YouTube and display results."""
|
|
31
|
+
|
|
32
|
+
BINDINGS = [
|
|
33
|
+
Binding("a", "queue_selected", "Add to Queue", show=True),
|
|
34
|
+
Binding("escape", "clear_search", "Clear", show=True),
|
|
35
|
+
Binding("/", "focus_search", "Search", show=True),
|
|
36
|
+
Binding("space", "play_selected", "Play", show=False, priority=True),
|
|
37
|
+
Binding("r", "toggle_repeat", "Repeat", show=True),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
def __init__(self, **kwargs) -> None:
|
|
41
|
+
super().__init__(**kwargs)
|
|
42
|
+
self.results: list[SearchResult] = []
|
|
43
|
+
self._is_searching = False
|
|
44
|
+
self._current_query = ""
|
|
45
|
+
self._load_more_offset = 0
|
|
46
|
+
self._batch_size = 15
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
yield Static("SEARCH", id="view-title")
|
|
50
|
+
|
|
51
|
+
with Vertical(id="search-container"):
|
|
52
|
+
yield Input(
|
|
53
|
+
placeholder="Search processes...",
|
|
54
|
+
id="search-input",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
yield Static(
|
|
58
|
+
" # Process PID Duration Status",
|
|
59
|
+
id="list-header",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
yield ListView(id="results-list")
|
|
63
|
+
yield Static("Type to search", id="status-bar")
|
|
64
|
+
|
|
65
|
+
def on_mount(self) -> None:
|
|
66
|
+
"""Focus the search input on mount."""
|
|
67
|
+
self.query_one("#search-input", Input).focus()
|
|
68
|
+
|
|
69
|
+
@on(Input.Submitted, "#search-input")
|
|
70
|
+
async def handle_search(self, event: Input.Submitted) -> None:
|
|
71
|
+
"""Execute search when Enter is pressed."""
|
|
72
|
+
query = event.value.strip()
|
|
73
|
+
if not query or self._is_searching:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
self._is_searching = True
|
|
77
|
+
self._update_status("Searching...")
|
|
78
|
+
self._current_query = query
|
|
79
|
+
self._load_more_offset = 0
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
# Access the app's YouTube client
|
|
83
|
+
youtube = self.app.youtube
|
|
84
|
+
self.results = await youtube.search(query, max_results=self._batch_size)
|
|
85
|
+
self._display_results(show_load_more=True)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
self._update_status(f"Search failed: {e}")
|
|
88
|
+
self.post_message(StatusMessage(f"Search error: {e}", "error"))
|
|
89
|
+
finally:
|
|
90
|
+
self._is_searching = False
|
|
91
|
+
|
|
92
|
+
async def _load_more_results(self) -> None:
|
|
93
|
+
"""Load more search results."""
|
|
94
|
+
if not self._current_query or self._is_searching:
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
self._is_searching = True
|
|
98
|
+
self._update_status("Loading more...")
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
youtube = self.app.youtube
|
|
102
|
+
self._load_more_offset += self._batch_size
|
|
103
|
+
# Search with offset by fetching more and skipping existing
|
|
104
|
+
new_results = await youtube.search(
|
|
105
|
+
self._current_query,
|
|
106
|
+
max_results=self._batch_size + self._load_more_offset
|
|
107
|
+
)
|
|
108
|
+
# Get only the new results we don't have yet
|
|
109
|
+
if len(new_results) > len(self.results):
|
|
110
|
+
self.results = new_results
|
|
111
|
+
self._display_results(show_load_more=True)
|
|
112
|
+
else:
|
|
113
|
+
self._update_status(f"No more results | Found {len(self.results)} total")
|
|
114
|
+
except Exception as e:
|
|
115
|
+
self._update_status(f"Load more failed: {e}")
|
|
116
|
+
finally:
|
|
117
|
+
self._is_searching = False
|
|
118
|
+
|
|
119
|
+
def _display_results(self, show_load_more: bool = False) -> None:
|
|
120
|
+
"""Display search results in the list."""
|
|
121
|
+
list_view = self.query_one("#results-list", ListView)
|
|
122
|
+
list_view.clear()
|
|
123
|
+
|
|
124
|
+
if not self.results:
|
|
125
|
+
self._update_status("No results found")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
for i, result in enumerate(self.results, 1):
|
|
129
|
+
list_view.append(ResultItem(result, i))
|
|
130
|
+
|
|
131
|
+
# Add "Load More" option at the end
|
|
132
|
+
if show_load_more:
|
|
133
|
+
list_view.append(LoadMoreItem())
|
|
134
|
+
|
|
135
|
+
# Build status with repeat indicator
|
|
136
|
+
repeat_status = self._get_repeat_status()
|
|
137
|
+
status = f"Found {len(self.results)} | Enter/Space=Play A=Queue R=Repeat{repeat_status}"
|
|
138
|
+
self._update_status(status)
|
|
139
|
+
list_view.focus()
|
|
140
|
+
|
|
141
|
+
def _get_repeat_status(self) -> str:
|
|
142
|
+
"""Get repeat mode status string."""
|
|
143
|
+
try:
|
|
144
|
+
mode = self.app.queue.repeat_mode
|
|
145
|
+
if mode == "one":
|
|
146
|
+
return " [REPEAT ONE]"
|
|
147
|
+
elif mode == "all":
|
|
148
|
+
return " [REPEAT ALL]"
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
return ""
|
|
152
|
+
|
|
153
|
+
def _update_status(self, message: str) -> None:
|
|
154
|
+
"""Update the status bar."""
|
|
155
|
+
try:
|
|
156
|
+
self.query_one("#status-bar", Static).update(message)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
def _get_selected(self) -> SearchResult | None:
|
|
161
|
+
"""Get the currently selected result."""
|
|
162
|
+
list_view = self.query_one("#results-list", ListView)
|
|
163
|
+
if list_view.highlighted_child is None:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
item = list_view.highlighted_child
|
|
167
|
+
if isinstance(item, ResultItem):
|
|
168
|
+
return item.result
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def action_clear_search(self) -> None:
|
|
172
|
+
"""Clear the search input."""
|
|
173
|
+
search_input = self.query_one("#search-input", Input)
|
|
174
|
+
if search_input.value:
|
|
175
|
+
search_input.value = ""
|
|
176
|
+
search_input.focus()
|
|
177
|
+
|
|
178
|
+
@on(ListView.Selected, "#results-list")
|
|
179
|
+
async def handle_result_selected(self, event: ListView.Selected) -> None:
|
|
180
|
+
"""Handle Enter key on a result - play it or load more."""
|
|
181
|
+
if isinstance(event.item, LoadMoreItem):
|
|
182
|
+
await self._load_more_results()
|
|
183
|
+
elif isinstance(event.item, ResultItem):
|
|
184
|
+
result = event.item.result
|
|
185
|
+
self.post_message(TrackSelected(result))
|
|
186
|
+
repeat_status = self._get_repeat_status()
|
|
187
|
+
self._update_status(f"Playing: {result.title[:40]}...{repeat_status}")
|
|
188
|
+
|
|
189
|
+
def action_play_selected(self) -> None:
|
|
190
|
+
"""Play the selected track (Space key)."""
|
|
191
|
+
list_view = self.query_one("#results-list", ListView)
|
|
192
|
+
if not list_view.has_focus:
|
|
193
|
+
return
|
|
194
|
+
result = self._get_selected()
|
|
195
|
+
if result:
|
|
196
|
+
self.post_message(TrackSelected(result))
|
|
197
|
+
repeat_status = self._get_repeat_status()
|
|
198
|
+
self._update_status(f"Playing: {result.title[:40]}...{repeat_status}")
|
|
199
|
+
|
|
200
|
+
def action_toggle_repeat(self) -> None:
|
|
201
|
+
"""Cycle repeat mode (R key)."""
|
|
202
|
+
try:
|
|
203
|
+
mode = self.app.queue.cycle_repeat()
|
|
204
|
+
mode_names = {"none": "OFF", "one": "ONE", "all": "ALL"}
|
|
205
|
+
repeat_status = self._get_repeat_status()
|
|
206
|
+
count = len(self.results) if self.results else 0
|
|
207
|
+
self._update_status(f"Repeat: {mode_names[mode]} | Found {count}{repeat_status}")
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
def action_queue_selected(self) -> None:
|
|
212
|
+
"""Add selected track to queue."""
|
|
213
|
+
logger.info("=== 'a' PRESSED: action_queue_selected ===")
|
|
214
|
+
result = self._get_selected()
|
|
215
|
+
logger.info(f" Selected result: {result}")
|
|
216
|
+
if result:
|
|
217
|
+
logger.info(f" Posting TrackQueued for: {result.title}")
|
|
218
|
+
self.post_message(TrackQueued(result))
|
|
219
|
+
repeat_status = self._get_repeat_status()
|
|
220
|
+
self._update_status(f"Queued: {result.title[:40]}...{repeat_status}")
|
|
221
|
+
else:
|
|
222
|
+
logger.warning(" No result selected!")
|
|
223
|
+
|
|
224
|
+
def focus_input(self) -> None:
|
|
225
|
+
"""Focus the search input (called from parent)."""
|
|
226
|
+
self.query_one("#search-input", Input).focus()
|
|
227
|
+
|
|
228
|
+
def action_focus_search(self) -> None:
|
|
229
|
+
"""Focus the search input (/ key)."""
|
|
230
|
+
self.focus_input()
|
|
231
|
+
|
|
232
|
+
def on_key(self, event: Key) -> None:
|
|
233
|
+
"""Handle key events - up at top of list goes to search, down from search goes to list."""
|
|
234
|
+
if event.key == "up":
|
|
235
|
+
list_view = self.query_one("#results-list", ListView)
|
|
236
|
+
# If list is focused and at top (index 0), go to search input
|
|
237
|
+
if list_view.has_focus and list_view.index == 0:
|
|
238
|
+
self.focus_input()
|
|
239
|
+
event.prevent_default()
|
|
240
|
+
event.stop()
|
|
241
|
+
elif event.key == "down":
|
|
242
|
+
search_input = self.query_one("#search-input", Input)
|
|
243
|
+
# If search input is focused, go to list
|
|
244
|
+
if search_input.has_focus:
|
|
245
|
+
list_view = self.query_one("#results-list", ListView)
|
|
246
|
+
if len(list_view.children) > 0:
|
|
247
|
+
list_view.focus()
|
|
248
|
+
list_view.index = 0
|
|
249
|
+
event.prevent_default()
|
|
250
|
+
event.stop()
|
|
251
|
+
|
|
252
|
+
def focus_list(self) -> None:
|
|
253
|
+
"""Focus the results list (called from parent)."""
|
|
254
|
+
list_view = self.query_one("#results-list", ListView)
|
|
255
|
+
if len(list_view.children) > 0:
|
|
256
|
+
list_view.focus()
|
|
257
|
+
else:
|
|
258
|
+
self.query_one("#search-input", Input).focus()
|
|
@@ -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
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Player bar widget - persistent playback controls at the bottom."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Horizontal, Vertical
|
|
5
|
+
from textual.reactive import reactive
|
|
6
|
+
from textual.widgets import Static, ProgressBar
|
|
7
|
+
|
|
8
|
+
from wrkmon.utils.stealth import get_stealth
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PlayerBar(Static):
|
|
12
|
+
"""Persistent player bar showing current track and playback controls."""
|
|
13
|
+
|
|
14
|
+
# Reactive state
|
|
15
|
+
title = reactive("No process running")
|
|
16
|
+
is_playing = reactive(False)
|
|
17
|
+
position = reactive(0.0)
|
|
18
|
+
duration = reactive(0.0)
|
|
19
|
+
volume = reactive(80)
|
|
20
|
+
status_text = reactive("") # For showing errors/buffering
|
|
21
|
+
repeat_mode = reactive("none") # none, one, all
|
|
22
|
+
|
|
23
|
+
def __init__(self, **kwargs) -> None:
|
|
24
|
+
super().__init__(**kwargs)
|
|
25
|
+
self._stealth = get_stealth()
|
|
26
|
+
|
|
27
|
+
def compose(self) -> ComposeResult:
|
|
28
|
+
with Vertical(id="player-bar-inner"):
|
|
29
|
+
# Now playing row
|
|
30
|
+
with Horizontal(id="now-playing-row"):
|
|
31
|
+
yield Static("NOW", id="now-label", classes="label")
|
|
32
|
+
yield Static(self._get_status_icon(), id="play-status")
|
|
33
|
+
yield Static(self.title, id="track-title")
|
|
34
|
+
yield Static("", id="repeat-indicator")
|
|
35
|
+
|
|
36
|
+
# Progress row
|
|
37
|
+
with Horizontal(id="progress-row"):
|
|
38
|
+
yield Static(self._format_time(0), id="time-current")
|
|
39
|
+
yield ProgressBar(total=100, show_percentage=False, id="progress")
|
|
40
|
+
yield Static(self._format_time(0), id="time-total")
|
|
41
|
+
|
|
42
|
+
# Volume row
|
|
43
|
+
with Horizontal(id="volume-row"):
|
|
44
|
+
yield Static("VOL", id="vol-label", classes="label")
|
|
45
|
+
yield ProgressBar(total=100, show_percentage=False, id="volume")
|
|
46
|
+
yield Static(f"{self.volume}%", id="vol-value")
|
|
47
|
+
|
|
48
|
+
def _get_status_icon(self) -> str:
|
|
49
|
+
return "▶" if self.is_playing else "■"
|
|
50
|
+
|
|
51
|
+
def _format_time(self, seconds: float) -> str:
|
|
52
|
+
return self._stealth.format_duration(seconds)
|
|
53
|
+
|
|
54
|
+
def _format_title(self, title: str) -> str:
|
|
55
|
+
return self._stealth.get_fake_process_name(title)
|
|
56
|
+
|
|
57
|
+
# Watchers for reactive properties
|
|
58
|
+
def watch_title(self, new_title: str) -> None:
|
|
59
|
+
"""Update title display."""
|
|
60
|
+
try:
|
|
61
|
+
display_title = self._format_title(new_title) if new_title else "No process running"
|
|
62
|
+
self.query_one("#track-title", Static).update(display_title)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
def watch_is_playing(self) -> None:
|
|
67
|
+
"""Update play/pause icon."""
|
|
68
|
+
try:
|
|
69
|
+
self.query_one("#play-status", Static).update(self._get_status_icon())
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def watch_position(self, new_pos: float) -> None:
|
|
74
|
+
"""Update progress bar and time."""
|
|
75
|
+
try:
|
|
76
|
+
self.query_one("#time-current", Static).update(self._format_time(new_pos))
|
|
77
|
+
if self.duration > 0:
|
|
78
|
+
progress = (new_pos / self.duration) * 100
|
|
79
|
+
self.query_one("#progress", ProgressBar).update(progress=progress)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def watch_duration(self, new_dur: float) -> None:
|
|
84
|
+
"""Update total duration display."""
|
|
85
|
+
try:
|
|
86
|
+
self.query_one("#time-total", Static).update(self._format_time(new_dur))
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
def watch_volume(self, new_vol: int) -> None:
|
|
91
|
+
"""Update volume display."""
|
|
92
|
+
try:
|
|
93
|
+
self.query_one("#volume", ProgressBar).update(progress=new_vol)
|
|
94
|
+
self.query_one("#vol-value", Static).update(f"{new_vol}%")
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def watch_repeat_mode(self, new_mode: str) -> None:
|
|
99
|
+
"""Update repeat indicator."""
|
|
100
|
+
try:
|
|
101
|
+
indicator = ""
|
|
102
|
+
if new_mode == "one":
|
|
103
|
+
indicator = "[R1]"
|
|
104
|
+
elif new_mode == "all":
|
|
105
|
+
indicator = "[RA]"
|
|
106
|
+
self.query_one("#repeat-indicator", Static).update(indicator)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def update_playback(
|
|
111
|
+
self,
|
|
112
|
+
title: str | None = None,
|
|
113
|
+
is_playing: bool | None = None,
|
|
114
|
+
position: float | None = None,
|
|
115
|
+
duration: float | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Batch update playback state."""
|
|
118
|
+
if title is not None:
|
|
119
|
+
self.title = title
|
|
120
|
+
if is_playing is not None:
|
|
121
|
+
self.is_playing = is_playing
|
|
122
|
+
if position is not None:
|
|
123
|
+
self.position = position
|
|
124
|
+
if duration is not None:
|
|
125
|
+
self.duration = duration
|
|
126
|
+
|
|
127
|
+
def set_volume(self, volume: int) -> None:
|
|
128
|
+
"""Update volume display."""
|
|
129
|
+
self.volume = max(0, min(100, volume))
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Result item widget for displaying search results, queue items, etc."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.widgets import ListItem, Label
|
|
5
|
+
|
|
6
|
+
from wrkmon.core.youtube import SearchResult
|
|
7
|
+
from wrkmon.utils.stealth import get_stealth
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ResultItem(ListItem):
|
|
11
|
+
"""A list item representing a search result or track."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
result: SearchResult,
|
|
16
|
+
index: int,
|
|
17
|
+
status: str = "READY",
|
|
18
|
+
show_duration: bool = True,
|
|
19
|
+
**kwargs,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(**kwargs)
|
|
22
|
+
self.result = result
|
|
23
|
+
self.index = index
|
|
24
|
+
self.status = status
|
|
25
|
+
self.show_duration = show_duration
|
|
26
|
+
self._stealth = get_stealth()
|
|
27
|
+
|
|
28
|
+
def compose(self) -> ComposeResult:
|
|
29
|
+
process_name = self._stealth.get_fake_process_name(self.result.title)
|
|
30
|
+
pid = self._stealth.get_fake_pid()
|
|
31
|
+
|
|
32
|
+
if self.show_duration:
|
|
33
|
+
duration = self._stealth.format_duration(self.result.duration)
|
|
34
|
+
text = f"{self.index:>2} {process_name:<38} {pid:>5} {duration:>7} {self.status}"
|
|
35
|
+
else:
|
|
36
|
+
text = f"{self.index:>2} {process_name:<42} {pid:>5} {self.status}"
|
|
37
|
+
|
|
38
|
+
yield Label(text, classes="result-text")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class QueueItem(ListItem):
|
|
42
|
+
"""A list item representing a queue entry."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
title: str,
|
|
47
|
+
duration: int,
|
|
48
|
+
index: int,
|
|
49
|
+
is_current: bool = False,
|
|
50
|
+
**kwargs,
|
|
51
|
+
) -> None:
|
|
52
|
+
super().__init__(**kwargs)
|
|
53
|
+
self.title = title
|
|
54
|
+
self.duration = duration
|
|
55
|
+
self.index = index
|
|
56
|
+
self.is_current = is_current
|
|
57
|
+
self._stealth = get_stealth()
|
|
58
|
+
|
|
59
|
+
def compose(self) -> ComposeResult:
|
|
60
|
+
process_name = self._stealth.get_fake_process_name(self.title)
|
|
61
|
+
duration = self._stealth.format_duration(self.duration)
|
|
62
|
+
status = "RUNNING" if self.is_current else "QUEUED"
|
|
63
|
+
marker = "▶" if self.is_current else " "
|
|
64
|
+
|
|
65
|
+
text = f"{marker} {self.index:>2} {process_name:<40} {duration:>7} {status}"
|
|
66
|
+
yield Label(text, classes="queue-text")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class HistoryItem(ListItem):
|
|
70
|
+
"""A list item representing a history entry."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
title: str,
|
|
75
|
+
duration: int,
|
|
76
|
+
play_count: int,
|
|
77
|
+
last_played: str,
|
|
78
|
+
index: int,
|
|
79
|
+
video_id: str,
|
|
80
|
+
channel: str,
|
|
81
|
+
**kwargs,
|
|
82
|
+
) -> None:
|
|
83
|
+
super().__init__(**kwargs)
|
|
84
|
+
self.title = title
|
|
85
|
+
self.duration = duration
|
|
86
|
+
self.play_count = play_count
|
|
87
|
+
self.last_played = last_played
|
|
88
|
+
self.index = index
|
|
89
|
+
self.video_id = video_id
|
|
90
|
+
self.channel = channel
|
|
91
|
+
self._stealth = get_stealth()
|
|
92
|
+
|
|
93
|
+
def compose(self) -> ComposeResult:
|
|
94
|
+
process_name = self._stealth.get_fake_process_name(self.title)[:32]
|
|
95
|
+
duration = self._stealth.format_duration(self.duration)
|
|
96
|
+
|
|
97
|
+
text = f"{self.index:>2} {process_name:<34} {duration:>7} {self.play_count:>3}x {self.last_played}"
|
|
98
|
+
yield Label(text, classes="history-text")
|