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.
@@ -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]}...")