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,142 @@
|
|
|
1
|
+
"""History screen for wrkmon."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.screen import Screen
|
|
5
|
+
from textual.containers import Vertical
|
|
6
|
+
from textual.widgets import Static, ListView, ListItem, Label
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
|
|
9
|
+
from wrkmon.data.models import HistoryEntry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HistoryListItem(ListItem):
|
|
13
|
+
"""A history list item."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, entry: HistoryEntry, index: int, **kwargs):
|
|
16
|
+
super().__init__(**kwargs)
|
|
17
|
+
self.entry = entry
|
|
18
|
+
self.index = index
|
|
19
|
+
|
|
20
|
+
def compose(self) -> ComposeResult:
|
|
21
|
+
from wrkmon.utils.stealth import get_stealth
|
|
22
|
+
stealth = get_stealth()
|
|
23
|
+
|
|
24
|
+
track = self.entry.track
|
|
25
|
+
process_name = stealth.get_fake_process_name(track.title)[:35]
|
|
26
|
+
duration = track.duration_str
|
|
27
|
+
plays = self.entry.play_count
|
|
28
|
+
played_at = self.entry.played_at.strftime("%Y-%m-%d %H:%M")
|
|
29
|
+
|
|
30
|
+
text = f"{self.index:2} {process_name:<37} {duration:>8} {plays:>3}x {played_at}"
|
|
31
|
+
yield Label(text)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HistoryScreen(Screen):
|
|
35
|
+
"""Play history screen."""
|
|
36
|
+
|
|
37
|
+
BINDINGS = [
|
|
38
|
+
Binding("escape", "go_back", "Back", show=False),
|
|
39
|
+
Binding("enter", "play_selected", "Play", show=False),
|
|
40
|
+
Binding("q", "add_to_queue", "Add to Queue", show=False),
|
|
41
|
+
Binding("c", "clear_history", "Clear History", show=False),
|
|
42
|
+
Binding("r", "refresh", "Refresh", show=False),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
def __init__(self, **kwargs):
|
|
46
|
+
super().__init__(**kwargs)
|
|
47
|
+
self.entries: list[HistoryEntry] = []
|
|
48
|
+
|
|
49
|
+
def compose(self) -> ComposeResult:
|
|
50
|
+
with Vertical(id="history-screen"):
|
|
51
|
+
yield Static("PROCESS HISTORY", id="history-header")
|
|
52
|
+
yield Static(
|
|
53
|
+
" # Process Name Duration Runs Last Run",
|
|
54
|
+
id="history-list-header",
|
|
55
|
+
)
|
|
56
|
+
yield ListView(id="history-list")
|
|
57
|
+
yield Static("", id="history-status")
|
|
58
|
+
|
|
59
|
+
def on_mount(self) -> None:
|
|
60
|
+
"""Called when screen is mounted."""
|
|
61
|
+
self.load_history()
|
|
62
|
+
|
|
63
|
+
def load_history(self) -> None:
|
|
64
|
+
"""Load play history."""
|
|
65
|
+
db = self.app.database
|
|
66
|
+
self.entries = db.get_history(limit=100)
|
|
67
|
+
self.display_history()
|
|
68
|
+
|
|
69
|
+
def display_history(self) -> None:
|
|
70
|
+
"""Display history list."""
|
|
71
|
+
list_view = self.query_one("#history-list", ListView)
|
|
72
|
+
list_view.clear()
|
|
73
|
+
|
|
74
|
+
if not self.entries:
|
|
75
|
+
self.update_status("No history yet")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
for i, entry in enumerate(self.entries, 1):
|
|
79
|
+
list_view.append(HistoryListItem(entry, i))
|
|
80
|
+
|
|
81
|
+
total_plays = sum(e.play_count for e in self.entries)
|
|
82
|
+
self.update_status(f"{len(self.entries)} tracks, {total_plays} total plays")
|
|
83
|
+
|
|
84
|
+
def update_status(self, message: str) -> None:
|
|
85
|
+
"""Update status message."""
|
|
86
|
+
self.query_one("#history-status", Static).update(message)
|
|
87
|
+
|
|
88
|
+
def action_go_back(self) -> None:
|
|
89
|
+
"""Go back."""
|
|
90
|
+
self.app.pop_screen()
|
|
91
|
+
|
|
92
|
+
async def action_play_selected(self) -> None:
|
|
93
|
+
"""Play the selected track."""
|
|
94
|
+
list_view = self.query_one("#history-list", ListView)
|
|
95
|
+
if list_view.highlighted_child is None:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
item = list_view.highlighted_child
|
|
99
|
+
if isinstance(item, HistoryListItem):
|
|
100
|
+
track = item.entry.track
|
|
101
|
+
from wrkmon.core.youtube import SearchResult
|
|
102
|
+
result = SearchResult(
|
|
103
|
+
video_id=track.video_id,
|
|
104
|
+
title=track.title,
|
|
105
|
+
channel=track.channel,
|
|
106
|
+
duration=track.duration,
|
|
107
|
+
view_count=0,
|
|
108
|
+
)
|
|
109
|
+
await self.app.play_track(result)
|
|
110
|
+
|
|
111
|
+
def action_add_to_queue(self) -> None:
|
|
112
|
+
"""Add selected to queue."""
|
|
113
|
+
list_view = self.query_one("#history-list", ListView)
|
|
114
|
+
if list_view.highlighted_child is None:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
item = list_view.highlighted_child
|
|
118
|
+
if isinstance(item, HistoryListItem):
|
|
119
|
+
track = item.entry.track
|
|
120
|
+
from wrkmon.core.youtube import SearchResult
|
|
121
|
+
result = SearchResult(
|
|
122
|
+
video_id=track.video_id,
|
|
123
|
+
title=track.title,
|
|
124
|
+
channel=track.channel,
|
|
125
|
+
duration=track.duration,
|
|
126
|
+
view_count=0,
|
|
127
|
+
)
|
|
128
|
+
self.app.add_to_queue(result)
|
|
129
|
+
self.update_status(f"Added to queue: {track.title[:30]}...")
|
|
130
|
+
|
|
131
|
+
def action_clear_history(self) -> None:
|
|
132
|
+
"""Clear all history."""
|
|
133
|
+
db = self.app.database
|
|
134
|
+
count = db.clear_history()
|
|
135
|
+
self.entries = []
|
|
136
|
+
self.display_history()
|
|
137
|
+
self.update_status(f"Cleared {count} history entries")
|
|
138
|
+
|
|
139
|
+
def action_refresh(self) -> None:
|
|
140
|
+
"""Refresh history."""
|
|
141
|
+
self.load_history()
|
|
142
|
+
self.update_status("History refreshed")
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Player screen for wrkmon."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.screen import Screen
|
|
5
|
+
from textual.containers import Vertical, Horizontal
|
|
6
|
+
from textual.widgets import Static, ProgressBar, ListView, ListItem, Label
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.reactive import reactive
|
|
9
|
+
|
|
10
|
+
from wrkmon.core.queue import QueueItem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QueueListItem(ListItem):
|
|
14
|
+
"""A queue list item."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, item: QueueItem, index: int, is_current: bool = False, **kwargs):
|
|
17
|
+
super().__init__(**kwargs)
|
|
18
|
+
self.queue_item = item
|
|
19
|
+
self.index = index
|
|
20
|
+
self.is_current = is_current
|
|
21
|
+
|
|
22
|
+
def compose(self) -> ComposeResult:
|
|
23
|
+
from wrkmon.utils.stealth import get_stealth
|
|
24
|
+
stealth = get_stealth()
|
|
25
|
+
|
|
26
|
+
process_name = stealth.get_fake_process_name(self.queue_item.title)[:40]
|
|
27
|
+
duration = stealth.format_duration(self.queue_item.duration)
|
|
28
|
+
status = "RUNNING" if self.is_current else "QUEUED"
|
|
29
|
+
|
|
30
|
+
prefix = ">" if self.is_current else " "
|
|
31
|
+
text = f"{prefix} {self.index:2} {process_name:<42} {duration:>8} {status}"
|
|
32
|
+
yield Label(text)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PlayerScreen(Screen):
|
|
36
|
+
"""Player/Now Playing screen showing current track and queue."""
|
|
37
|
+
|
|
38
|
+
BINDINGS = [
|
|
39
|
+
Binding("escape", "go_back", "Back", show=False),
|
|
40
|
+
Binding("space", "toggle_pause", "Play/Pause", show=False),
|
|
41
|
+
Binding("+", "volume_up", "Volume Up", show=False),
|
|
42
|
+
Binding("-", "volume_down", "Volume Down", show=False),
|
|
43
|
+
Binding("n", "next_track", "Next", show=False),
|
|
44
|
+
Binding("p", "prev_track", "Previous", show=False),
|
|
45
|
+
Binding("s", "toggle_shuffle", "Shuffle", show=False),
|
|
46
|
+
Binding("r", "toggle_repeat", "Repeat", show=False),
|
|
47
|
+
Binding("c", "clear_queue", "Clear Queue", show=False),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
title = reactive("No process running")
|
|
51
|
+
status = reactive("STOPPED")
|
|
52
|
+
position = reactive(0.0)
|
|
53
|
+
duration = reactive(0.0)
|
|
54
|
+
volume = reactive(80)
|
|
55
|
+
|
|
56
|
+
def __init__(self, **kwargs):
|
|
57
|
+
super().__init__(**kwargs)
|
|
58
|
+
|
|
59
|
+
def compose(self) -> ComposeResult:
|
|
60
|
+
from wrkmon.utils.stealth import get_stealth
|
|
61
|
+
self._stealth = get_stealth()
|
|
62
|
+
|
|
63
|
+
with Vertical(id="player-screen"):
|
|
64
|
+
# Now playing section
|
|
65
|
+
yield Static("NOW RUNNING", id="now-playing-header")
|
|
66
|
+
yield Static("", id="now-playing-title")
|
|
67
|
+
|
|
68
|
+
# Progress
|
|
69
|
+
with Horizontal(id="progress-section"):
|
|
70
|
+
yield ProgressBar(total=100, show_percentage=False, id="progress-bar")
|
|
71
|
+
yield Static("--:-- / --:--", id="progress-time")
|
|
72
|
+
|
|
73
|
+
# Controls status
|
|
74
|
+
with Horizontal(id="controls-section"):
|
|
75
|
+
yield Static("VOL:", id="vol-label")
|
|
76
|
+
yield ProgressBar(total=100, show_percentage=False, id="volume-bar")
|
|
77
|
+
yield Static("80%", id="volume-value")
|
|
78
|
+
yield Static("", id="shuffle-status")
|
|
79
|
+
yield Static("", id="repeat-status")
|
|
80
|
+
|
|
81
|
+
# Queue section
|
|
82
|
+
yield Static("PROCESS QUEUE", id="queue-header")
|
|
83
|
+
yield Static(
|
|
84
|
+
" # Process Name Duration Status",
|
|
85
|
+
id="queue-list-header",
|
|
86
|
+
)
|
|
87
|
+
yield ListView(id="queue-list")
|
|
88
|
+
|
|
89
|
+
# Status
|
|
90
|
+
yield Static("", id="player-status")
|
|
91
|
+
|
|
92
|
+
def on_mount(self) -> None:
|
|
93
|
+
"""Called when screen is mounted."""
|
|
94
|
+
self.update_display()
|
|
95
|
+
|
|
96
|
+
def update_display(self) -> None:
|
|
97
|
+
"""Update the entire display."""
|
|
98
|
+
self.update_now_playing()
|
|
99
|
+
self.update_queue()
|
|
100
|
+
self.update_controls()
|
|
101
|
+
|
|
102
|
+
def update_now_playing(self) -> None:
|
|
103
|
+
"""Update now playing display."""
|
|
104
|
+
player = self.app.player
|
|
105
|
+
queue = self.app.queue
|
|
106
|
+
|
|
107
|
+
current = queue.current
|
|
108
|
+
if current:
|
|
109
|
+
process_name = self._stealth.get_fake_process_name(current.title)
|
|
110
|
+
title_widget = self.query_one("#now-playing-title", Static)
|
|
111
|
+
title_widget.update(f" {process_name}")
|
|
112
|
+
|
|
113
|
+
# Update progress
|
|
114
|
+
pos = player.current_position
|
|
115
|
+
dur = player.duration or current.duration
|
|
116
|
+
self.update_progress(pos, dur)
|
|
117
|
+
else:
|
|
118
|
+
self.query_one("#now-playing-title", Static).update(" No process running")
|
|
119
|
+
|
|
120
|
+
def update_progress(self, position: float, duration: float) -> None:
|
|
121
|
+
"""Update progress bar and time."""
|
|
122
|
+
self.position = position
|
|
123
|
+
self.duration = duration
|
|
124
|
+
|
|
125
|
+
progress_bar = self.query_one("#progress-bar", ProgressBar)
|
|
126
|
+
if duration > 0:
|
|
127
|
+
progress_bar.update(progress=(position / duration) * 100)
|
|
128
|
+
else:
|
|
129
|
+
progress_bar.update(progress=0)
|
|
130
|
+
|
|
131
|
+
pos_str = self._stealth.format_duration(position)
|
|
132
|
+
dur_str = self._stealth.format_duration(duration)
|
|
133
|
+
self.query_one("#progress-time", Static).update(f"{pos_str} / {dur_str}")
|
|
134
|
+
|
|
135
|
+
def update_volume(self, volume: int) -> None:
|
|
136
|
+
"""Update volume display."""
|
|
137
|
+
self.volume = volume
|
|
138
|
+
self.query_one("#volume-bar", ProgressBar).update(progress=volume)
|
|
139
|
+
self.query_one("#volume-value", Static).update(f"{volume}%")
|
|
140
|
+
|
|
141
|
+
def update_controls(self) -> None:
|
|
142
|
+
"""Update control status indicators."""
|
|
143
|
+
queue = self.app.queue
|
|
144
|
+
|
|
145
|
+
shuffle_text = "[SHUF]" if queue.shuffle_mode else ""
|
|
146
|
+
self.query_one("#shuffle-status", Static).update(shuffle_text)
|
|
147
|
+
|
|
148
|
+
repeat_text = ""
|
|
149
|
+
if queue.repeat_mode == "one":
|
|
150
|
+
repeat_text = "[REP1]"
|
|
151
|
+
elif queue.repeat_mode == "all":
|
|
152
|
+
repeat_text = "[REPA]"
|
|
153
|
+
self.query_one("#repeat-status", Static).update(repeat_text)
|
|
154
|
+
|
|
155
|
+
def update_queue(self) -> None:
|
|
156
|
+
"""Update queue list."""
|
|
157
|
+
queue = self.app.queue
|
|
158
|
+
list_view = self.query_one("#queue-list", ListView)
|
|
159
|
+
list_view.clear()
|
|
160
|
+
|
|
161
|
+
items = queue.to_list()
|
|
162
|
+
for i, item in enumerate(items):
|
|
163
|
+
is_current = i == queue.current_index
|
|
164
|
+
list_view.append(QueueListItem(item, i + 1, is_current))
|
|
165
|
+
|
|
166
|
+
if not items:
|
|
167
|
+
self.query_one("#player-status", Static).update("Queue empty")
|
|
168
|
+
|
|
169
|
+
def update_status(self, message: str) -> None:
|
|
170
|
+
"""Update status message."""
|
|
171
|
+
self.query_one("#player-status", Static).update(message)
|
|
172
|
+
|
|
173
|
+
def action_go_back(self) -> None:
|
|
174
|
+
"""Go back to search screen."""
|
|
175
|
+
self.app.pop_screen()
|
|
176
|
+
|
|
177
|
+
async def action_toggle_pause(self) -> None:
|
|
178
|
+
"""Toggle play/pause."""
|
|
179
|
+
await self.app.toggle_pause()
|
|
180
|
+
status = "RUNNING" if self.app.player.is_playing else "SUSPENDED"
|
|
181
|
+
self.update_status(f"Status: {status}")
|
|
182
|
+
|
|
183
|
+
async def action_volume_up(self) -> None:
|
|
184
|
+
"""Increase volume."""
|
|
185
|
+
new_vol = min(100, self.volume + 5)
|
|
186
|
+
await self.app.set_volume(new_vol)
|
|
187
|
+
self.update_volume(new_vol)
|
|
188
|
+
|
|
189
|
+
async def action_volume_down(self) -> None:
|
|
190
|
+
"""Decrease volume."""
|
|
191
|
+
new_vol = max(0, self.volume - 5)
|
|
192
|
+
await self.app.set_volume(new_vol)
|
|
193
|
+
self.update_volume(new_vol)
|
|
194
|
+
|
|
195
|
+
async def action_next_track(self) -> None:
|
|
196
|
+
"""Play next track."""
|
|
197
|
+
await self.app.play_next()
|
|
198
|
+
self.update_display()
|
|
199
|
+
|
|
200
|
+
async def action_prev_track(self) -> None:
|
|
201
|
+
"""Play previous track."""
|
|
202
|
+
await self.app.play_previous()
|
|
203
|
+
self.update_display()
|
|
204
|
+
|
|
205
|
+
def action_toggle_shuffle(self) -> None:
|
|
206
|
+
"""Toggle shuffle mode."""
|
|
207
|
+
is_shuffle = self.app.queue.toggle_shuffle()
|
|
208
|
+
self.update_controls()
|
|
209
|
+
self.update_status(f"Shuffle: {'ON' if is_shuffle else 'OFF'}")
|
|
210
|
+
|
|
211
|
+
def action_toggle_repeat(self) -> None:
|
|
212
|
+
"""Toggle repeat mode."""
|
|
213
|
+
mode = self.app.queue.cycle_repeat()
|
|
214
|
+
self.update_controls()
|
|
215
|
+
mode_names = {"none": "OFF", "one": "ONE", "all": "ALL"}
|
|
216
|
+
self.update_status(f"Repeat: {mode_names[mode]}")
|
|
217
|
+
|
|
218
|
+
def action_clear_queue(self) -> None:
|
|
219
|
+
"""Clear the queue."""
|
|
220
|
+
self.app.queue.clear()
|
|
221
|
+
self.update_queue()
|
|
222
|
+
self.update_status("Queue cleared")
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Playlist screen for wrkmon."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.screen import Screen
|
|
5
|
+
from textual.containers import Vertical
|
|
6
|
+
from textual.widgets import Static, ListView, ListItem, Label, Input
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual import on
|
|
9
|
+
|
|
10
|
+
from wrkmon.data.models import Playlist, Track
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PlaylistListItem(ListItem):
|
|
14
|
+
"""A playlist list item."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, playlist: Playlist, index: int, **kwargs):
|
|
17
|
+
super().__init__(**kwargs)
|
|
18
|
+
self.playlist = playlist
|
|
19
|
+
self.index = index
|
|
20
|
+
|
|
21
|
+
def compose(self) -> ComposeResult:
|
|
22
|
+
track_count = self.playlist.track_count
|
|
23
|
+
duration = self.playlist.total_duration_str
|
|
24
|
+
text = f"{self.index:2} {self.playlist.name:<40} {track_count:>4} tracks {duration:>10}"
|
|
25
|
+
yield Label(text)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TrackListItem(ListItem):
|
|
29
|
+
"""A track list item within a playlist."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, track: Track, index: int, **kwargs):
|
|
32
|
+
super().__init__(**kwargs)
|
|
33
|
+
self.track = track
|
|
34
|
+
self.index = index
|
|
35
|
+
|
|
36
|
+
def compose(self) -> ComposeResult:
|
|
37
|
+
from wrkmon.utils.stealth import get_stealth
|
|
38
|
+
stealth = get_stealth()
|
|
39
|
+
|
|
40
|
+
process_name = 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 PlaylistScreen(Screen):
|
|
47
|
+
"""Playlist management screen."""
|
|
48
|
+
|
|
49
|
+
BINDINGS = [
|
|
50
|
+
Binding("escape", "go_back", "Back", show=False),
|
|
51
|
+
Binding("enter", "select_item", "Select", show=False),
|
|
52
|
+
Binding("n", "new_playlist", "New Playlist", show=False),
|
|
53
|
+
Binding("d", "delete_item", "Delete", show=False),
|
|
54
|
+
Binding("p", "play_all", "Play All", show=False),
|
|
55
|
+
Binding("a", "add_to_queue", "Add to Queue", show=False),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def __init__(self, **kwargs):
|
|
59
|
+
super().__init__(**kwargs)
|
|
60
|
+
self.playlists: list[Playlist] = []
|
|
61
|
+
self.current_playlist: Playlist | None = None
|
|
62
|
+
self.viewing_tracks = False
|
|
63
|
+
|
|
64
|
+
def compose(self) -> ComposeResult:
|
|
65
|
+
with Vertical(id="playlist-screen"):
|
|
66
|
+
yield Static("PLAYLISTS", id="playlist-header")
|
|
67
|
+
yield Static(
|
|
68
|
+
" # Name Tracks Duration",
|
|
69
|
+
id="playlist-list-header",
|
|
70
|
+
)
|
|
71
|
+
yield ListView(id="playlist-list")
|
|
72
|
+
yield Input(
|
|
73
|
+
placeholder="Enter playlist name...",
|
|
74
|
+
id="playlist-name-input",
|
|
75
|
+
)
|
|
76
|
+
yield Static("", id="playlist-status")
|
|
77
|
+
|
|
78
|
+
def on_mount(self) -> None:
|
|
79
|
+
"""Called when screen is mounted."""
|
|
80
|
+
self.query_one("#playlist-name-input", Input).display = False
|
|
81
|
+
self.load_playlists()
|
|
82
|
+
|
|
83
|
+
def load_playlists(self) -> None:
|
|
84
|
+
"""Load all playlists."""
|
|
85
|
+
db = self.app.database
|
|
86
|
+
self.playlists = db.get_all_playlists()
|
|
87
|
+
self.display_playlists()
|
|
88
|
+
|
|
89
|
+
def display_playlists(self) -> None:
|
|
90
|
+
"""Display playlist list."""
|
|
91
|
+
self.viewing_tracks = False
|
|
92
|
+
self.current_playlist = None
|
|
93
|
+
|
|
94
|
+
header = self.query_one("#playlist-header", Static)
|
|
95
|
+
header.update("PLAYLISTS")
|
|
96
|
+
|
|
97
|
+
list_header = self.query_one("#playlist-list-header", Static)
|
|
98
|
+
list_header.update(
|
|
99
|
+
" # Name Tracks Duration"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
list_view = self.query_one("#playlist-list", ListView)
|
|
103
|
+
list_view.clear()
|
|
104
|
+
|
|
105
|
+
if not self.playlists:
|
|
106
|
+
self.update_status("No playlists. Press 'n' to create one.")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
for i, playlist in enumerate(self.playlists, 1):
|
|
110
|
+
list_view.append(PlaylistListItem(playlist, i))
|
|
111
|
+
|
|
112
|
+
self.update_status(f"{len(self.playlists)} playlists")
|
|
113
|
+
|
|
114
|
+
def display_tracks(self, playlist: Playlist) -> None:
|
|
115
|
+
"""Display tracks in a playlist."""
|
|
116
|
+
self.viewing_tracks = True
|
|
117
|
+
self.current_playlist = playlist
|
|
118
|
+
|
|
119
|
+
# Load full playlist with tracks
|
|
120
|
+
full_playlist = self.app.database.get_playlist(playlist.id)
|
|
121
|
+
if full_playlist:
|
|
122
|
+
self.current_playlist = full_playlist
|
|
123
|
+
|
|
124
|
+
header = self.query_one("#playlist-header", Static)
|
|
125
|
+
header.update(f"PLAYLIST: {playlist.name}")
|
|
126
|
+
|
|
127
|
+
list_header = self.query_one("#playlist-list-header", Static)
|
|
128
|
+
list_header.update(
|
|
129
|
+
" # Process Name Duration"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
list_view = self.query_one("#playlist-list", ListView)
|
|
133
|
+
list_view.clear()
|
|
134
|
+
|
|
135
|
+
if not self.current_playlist.tracks:
|
|
136
|
+
self.update_status("Playlist empty")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
for i, track in enumerate(self.current_playlist.tracks, 1):
|
|
140
|
+
list_view.append(TrackListItem(track, i))
|
|
141
|
+
|
|
142
|
+
self.update_status(
|
|
143
|
+
f"{len(self.current_playlist.tracks)} tracks - "
|
|
144
|
+
f"{self.current_playlist.total_duration_str}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def update_status(self, message: str) -> None:
|
|
148
|
+
"""Update status message."""
|
|
149
|
+
self.query_one("#playlist-status", Static).update(message)
|
|
150
|
+
|
|
151
|
+
def action_go_back(self) -> None:
|
|
152
|
+
"""Go back."""
|
|
153
|
+
if self.viewing_tracks:
|
|
154
|
+
self.display_playlists()
|
|
155
|
+
else:
|
|
156
|
+
self.app.pop_screen()
|
|
157
|
+
|
|
158
|
+
def action_select_item(self) -> None:
|
|
159
|
+
"""Select the current item."""
|
|
160
|
+
list_view = self.query_one("#playlist-list", ListView)
|
|
161
|
+
if list_view.highlighted_child is None:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
item = list_view.highlighted_child
|
|
165
|
+
|
|
166
|
+
if isinstance(item, PlaylistListItem):
|
|
167
|
+
self.display_tracks(item.playlist)
|
|
168
|
+
elif isinstance(item, TrackListItem):
|
|
169
|
+
# Play the track
|
|
170
|
+
from wrkmon.core.youtube import SearchResult
|
|
171
|
+
result = SearchResult(
|
|
172
|
+
video_id=item.track.video_id,
|
|
173
|
+
title=item.track.title,
|
|
174
|
+
channel=item.track.channel,
|
|
175
|
+
duration=item.track.duration,
|
|
176
|
+
view_count=0,
|
|
177
|
+
)
|
|
178
|
+
self.app.call_later(self.app.play_track, result)
|
|
179
|
+
|
|
180
|
+
def action_new_playlist(self) -> None:
|
|
181
|
+
"""Create a new playlist."""
|
|
182
|
+
if self.viewing_tracks:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
name_input = self.query_one("#playlist-name-input", Input)
|
|
186
|
+
name_input.display = True
|
|
187
|
+
name_input.focus()
|
|
188
|
+
|
|
189
|
+
@on(Input.Submitted, "#playlist-name-input")
|
|
190
|
+
def handle_playlist_name(self, event: Input.Submitted) -> None:
|
|
191
|
+
"""Handle playlist name submission."""
|
|
192
|
+
name = event.value.strip()
|
|
193
|
+
name_input = self.query_one("#playlist-name-input", Input)
|
|
194
|
+
name_input.display = False
|
|
195
|
+
name_input.value = ""
|
|
196
|
+
|
|
197
|
+
if not name:
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
# Create playlist
|
|
201
|
+
db = self.app.database
|
|
202
|
+
try:
|
|
203
|
+
playlist = db.create_playlist(name)
|
|
204
|
+
self.playlists.append(playlist)
|
|
205
|
+
self.display_playlists()
|
|
206
|
+
self.update_status(f"Created playlist: {name}")
|
|
207
|
+
except Exception as e:
|
|
208
|
+
self.update_status(f"Error: {e}")
|
|
209
|
+
|
|
210
|
+
def action_delete_item(self) -> None:
|
|
211
|
+
"""Delete the selected item."""
|
|
212
|
+
list_view = self.query_one("#playlist-list", ListView)
|
|
213
|
+
if list_view.highlighted_child is None:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
item = list_view.highlighted_child
|
|
217
|
+
db = self.app.database
|
|
218
|
+
|
|
219
|
+
if isinstance(item, PlaylistListItem):
|
|
220
|
+
# Delete playlist
|
|
221
|
+
if db.delete_playlist(item.playlist.id):
|
|
222
|
+
self.load_playlists()
|
|
223
|
+
self.update_status(f"Deleted playlist: {item.playlist.name}")
|
|
224
|
+
elif isinstance(item, TrackListItem) and self.current_playlist:
|
|
225
|
+
# Remove track from playlist
|
|
226
|
+
if db.remove_track_from_playlist(self.current_playlist.id, item.track.id):
|
|
227
|
+
self.display_tracks(self.current_playlist)
|
|
228
|
+
self.update_status(f"Removed track")
|
|
229
|
+
|
|
230
|
+
async def action_play_all(self) -> None:
|
|
231
|
+
"""Play all tracks in current playlist."""
|
|
232
|
+
if not self.current_playlist or not self.current_playlist.tracks:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
# Add all to queue and play
|
|
236
|
+
for track in self.current_playlist.tracks:
|
|
237
|
+
from wrkmon.core.youtube import SearchResult
|
|
238
|
+
result = SearchResult(
|
|
239
|
+
video_id=track.video_id,
|
|
240
|
+
title=track.title,
|
|
241
|
+
channel=track.channel,
|
|
242
|
+
duration=track.duration,
|
|
243
|
+
view_count=0,
|
|
244
|
+
)
|
|
245
|
+
self.app.add_to_queue(result)
|
|
246
|
+
|
|
247
|
+
# Play first
|
|
248
|
+
first = self.current_playlist.tracks[0]
|
|
249
|
+
from wrkmon.core.youtube import SearchResult
|
|
250
|
+
result = SearchResult(
|
|
251
|
+
video_id=first.video_id,
|
|
252
|
+
title=first.title,
|
|
253
|
+
channel=first.channel,
|
|
254
|
+
duration=first.duration,
|
|
255
|
+
view_count=0,
|
|
256
|
+
)
|
|
257
|
+
await self.app.play_track(result)
|
|
258
|
+
self.update_status("Playing playlist")
|
|
259
|
+
|
|
260
|
+
def action_add_to_queue(self) -> None:
|
|
261
|
+
"""Add selected track to queue."""
|
|
262
|
+
list_view = self.query_one("#playlist-list", ListView)
|
|
263
|
+
if list_view.highlighted_child is None:
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
item = list_view.highlighted_child
|
|
267
|
+
|
|
268
|
+
if isinstance(item, TrackListItem):
|
|
269
|
+
from wrkmon.core.youtube import SearchResult
|
|
270
|
+
result = SearchResult(
|
|
271
|
+
video_id=item.track.video_id,
|
|
272
|
+
title=item.track.title,
|
|
273
|
+
channel=item.track.channel,
|
|
274
|
+
duration=item.track.duration,
|
|
275
|
+
view_count=0,
|
|
276
|
+
)
|
|
277
|
+
self.app.add_to_queue(result)
|
|
278
|
+
self.update_status(f"Added to queue: {item.track.title[:30]}...")
|