wrkmon 1.0.1__py3-none-any.whl → 1.2.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 CHANGED
@@ -1,4 +1,4 @@
1
1
  """wrkmon - Work Monitor: A developer productivity tool."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.2.0"
4
4
  __app_name__ = "wrkmon"
wrkmon/app.py CHANGED
@@ -1,8 +1,10 @@
1
1
  """Main TUI application for wrkmon - properly structured with Textual best practices."""
2
2
 
3
+ import asyncio
3
4
  import logging
4
5
  import sys
5
6
  from pathlib import Path
7
+ from typing import Optional
6
8
 
7
9
  # Setup logging to file
8
10
  log_path = Path.home() / ".wrkmon_debug.log"
@@ -41,6 +43,14 @@ from wrkmon.ui.messages import (
41
43
  StatusMessage,
42
44
  PlaybackStateChanged,
43
45
  )
46
+ from wrkmon.ui.screens.help import HelpScreen
47
+ from wrkmon.utils.updater import (
48
+ check_for_updates_async,
49
+ check_dependencies,
50
+ get_js_runtime,
51
+ install_deno_async,
52
+ )
53
+ from wrkmon.core.media_keys import get_media_keys_handler, MediaKeysHandler
44
54
 
45
55
 
46
56
  class WrkmonApp(App):
@@ -56,12 +66,12 @@ class WrkmonApp(App):
56
66
  Binding("f3", "switch_view('history')", "History", show=True, priority=True),
57
67
  Binding("f4", "switch_view('playlists')", "Lists", show=True, priority=True),
58
68
  # Playback controls (global)
59
- Binding("f5", "toggle_pause", "Play/Pause", show=True, priority=True),
69
+ Binding("f5", "toggle_pause", "▶/⏸", show=True, priority=True),
60
70
  Binding("f6", "volume_down", "Vol-", show=True, priority=True),
61
71
  Binding("f7", "volume_up", "Vol+", show=True, priority=True),
62
72
  Binding("f8", "next_track", "Next", show=True, priority=True),
63
73
  Binding("f9", "stop", "Stop", show=True, priority=True),
64
- Binding("f10", "queue_current", "Queue", show=True, priority=True),
74
+ Binding("f10", "queue_current", "+Queue", show=True, priority=True),
65
75
  # Additional controls (when not in input)
66
76
  Binding("space", "toggle_pause", "Play/Pause", show=False),
67
77
  Binding("+", "volume_up", "Vol+", show=False),
@@ -70,6 +80,14 @@ class WrkmonApp(App):
70
80
  Binding("n", "next_track", "Next", show=False),
71
81
  Binding("p", "prev_track", "Prev", show=False),
72
82
  Binding("s", "stop", "Stop", show=False),
83
+ Binding("m", "toggle_mute", "Mute", show=False),
84
+ # Vim-style navigation
85
+ Binding("j", "cursor_down", "Down", show=False),
86
+ Binding("k", "cursor_up", "Up", show=False),
87
+ Binding("g", "cursor_top", "Top", show=False),
88
+ Binding("G", "cursor_bottom", "Bottom", show=False, key_display="shift+g"),
89
+ # Help
90
+ Binding("?", "show_help", "Help", show=True, priority=True),
73
91
  # App controls
74
92
  Binding("escape", "focus_search", "Back", show=False, priority=True),
75
93
  Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
@@ -95,6 +113,13 @@ class WrkmonApp(App):
95
113
  self._current_track: SearchResult | None = None
96
114
  self._current_view = "search"
97
115
 
116
+ # Restore playback settings from config
117
+ self.queue.repeat_mode = self._config.repeat_mode
118
+ self.queue.shuffle_mode = self._config.shuffle
119
+
120
+ # Media keys handler (for Fn+media buttons)
121
+ self._media_keys: Optional[MediaKeysHandler] = None
122
+
98
123
  def compose(self) -> ComposeResult:
99
124
  """Compose the application layout."""
100
125
  # Header (docked top)
@@ -119,6 +144,12 @@ class WrkmonApp(App):
119
144
  # Set terminal title
120
145
  self._stealth.set_terminal_title("wrkmon")
121
146
 
147
+ # Check for updates in background
148
+ self._check_for_updates_task = asyncio.create_task(self._check_for_updates())
149
+
150
+ # Check dependencies
151
+ await self._check_dependencies()
152
+
122
153
  # Check if mpv is available
123
154
  from wrkmon.utils.mpv_installer import is_mpv_installed, ensure_mpv_installed
124
155
 
@@ -161,6 +192,134 @@ class WrkmonApp(App):
161
192
  # Update header view indicator
162
193
  self._get_header().set_view_name("search")
163
194
 
195
+ # Start media keys handler (for Fn+Play/Pause, Next, Previous)
196
+ await self._start_media_keys()
197
+
198
+ # Load saved queue
199
+ self._load_saved_queue()
200
+
201
+ def _load_saved_queue(self) -> None:
202
+ """Load the saved queue from database."""
203
+ try:
204
+ items, current_index, shuffle_mode, repeat_mode = self.database.load_queue()
205
+ if items:
206
+ self.queue.load_from_dicts(items, current_index)
207
+ self.queue.shuffle_mode = shuffle_mode
208
+ self.queue.repeat_mode = repeat_mode
209
+ logger.info(f"Loaded {len(items)} items from saved queue, index={current_index}")
210
+
211
+ # Show notification about restored queue
212
+ if len(items) > 0:
213
+ current = self.queue.current
214
+ if current:
215
+ pos_str = f" @ {current.playback_position // 60}:{current.playback_position % 60:02d}" if current.playback_position > 0 else ""
216
+ self.notify(
217
+ f"Queue restored: {len(items)} tracks\n"
218
+ f"Current: {current.title[:30]}...{pos_str}",
219
+ timeout=4
220
+ )
221
+ except Exception as e:
222
+ logger.debug(f"Failed to load saved queue: {e}")
223
+
224
+ def _save_queue(self) -> None:
225
+ """Save the current queue to database."""
226
+ try:
227
+ items = self.queue.to_dict_list()
228
+ self.database.save_queue(
229
+ items=items,
230
+ current_index=self.queue.current_index,
231
+ shuffle_mode=self.queue.shuffle_mode,
232
+ repeat_mode=self.queue.repeat_mode,
233
+ )
234
+ logger.info(f"Saved {len(items)} items to queue")
235
+ except Exception as e:
236
+ logger.debug(f"Failed to save queue: {e}")
237
+
238
+ async def _check_for_updates(self) -> None:
239
+ """Check for updates in background."""
240
+ try:
241
+ update_info = await check_for_updates_async()
242
+ if update_info and update_info.is_update_available:
243
+ self._get_header().set_update_info(
244
+ available=True,
245
+ version=update_info.latest_version
246
+ )
247
+ self.notify(
248
+ f"New version {update_info.latest_version} available!\n"
249
+ f"Run: {update_info.update_command}",
250
+ title="Update Available",
251
+ timeout=8
252
+ )
253
+ logger.info(f"Update available: {update_info.latest_version}")
254
+ except Exception as e:
255
+ logger.debug(f"Update check failed: {e}")
256
+
257
+ async def _check_dependencies(self) -> None:
258
+ """Check optional dependencies and notify user."""
259
+ try:
260
+ js_runtime = get_js_runtime()
261
+ if not js_runtime:
262
+ # No JavaScript runtime - suggest installing deno
263
+ self.notify(
264
+ "Install deno for better YouTube compatibility:\n"
265
+ "curl -fsSL https://deno.land/install.sh | sh",
266
+ title="Tip: Install deno",
267
+ timeout=6
268
+ )
269
+ logger.info("No JavaScript runtime found, suggesting deno installation")
270
+ except Exception as e:
271
+ logger.debug(f"Dependency check failed: {e}")
272
+
273
+ async def _start_media_keys(self) -> None:
274
+ """Start media keys handler for Fn+media buttons."""
275
+ try:
276
+ self._media_keys = get_media_keys_handler(self._handle_media_key)
277
+ if self._media_keys and self._media_keys.is_available:
278
+ started = await self._media_keys.start()
279
+ if started:
280
+ logger.info(f"Media keys enabled via {self._media_keys.backend_name}")
281
+ self.notify(
282
+ f"Media keys active ({self._media_keys.backend_name})",
283
+ timeout=3
284
+ )
285
+ else:
286
+ logger.info("Media keys handler failed to start")
287
+ else:
288
+ logger.info("Media keys not available on this platform")
289
+ except Exception as e:
290
+ logger.debug(f"Failed to start media keys: {e}")
291
+
292
+ async def _handle_media_key(self, command: str, *args) -> None:
293
+ """Handle media key commands from OS."""
294
+ logger.info(f"Media key: {command} {args}")
295
+ try:
296
+ if command == "play_pause":
297
+ await self.action_toggle_pause()
298
+ elif command == "play":
299
+ if not self.player.is_playing:
300
+ await self.action_toggle_pause()
301
+ elif command == "pause":
302
+ if self.player.is_playing:
303
+ await self.toggle_pause()
304
+ elif command == "stop":
305
+ await self.action_stop()
306
+ elif command == "next":
307
+ await self.action_next_track()
308
+ elif command == "previous":
309
+ await self.action_prev_track()
310
+ elif command == "volume_up":
311
+ await self.action_volume_up()
312
+ elif command == "volume_down":
313
+ await self.action_volume_down()
314
+ elif command == "mute":
315
+ await self.action_toggle_mute()
316
+ elif command == "set_volume" and args:
317
+ await self.set_volume(args[0])
318
+ elif command == "quit":
319
+ await self.action_quit()
320
+ except Exception as e:
321
+ logger.error(f"Error handling media key {command}: {e}")
322
+
164
323
  # ----------------------------------------
165
324
  # Component getters
166
325
  # ----------------------------------------
@@ -258,8 +417,28 @@ class WrkmonApp(App):
258
417
 
259
418
  if success:
260
419
  logger.info(" SUCCESS - audio should be playing!")
420
+
421
+ # Check if we should resume from a saved position
422
+ saved_position = self.queue.get_playback_position(result.video_id)
423
+ if saved_position > 5: # Only resume if > 5 seconds in
424
+ logger.info(f" Resuming from saved position: {saved_position}s")
425
+ player_bar.update_playback(title=f"Resuming: {result.title[:30]}...")
426
+ await asyncio.sleep(0.5) # Give mpv time to load
427
+ await self.player.seek(saved_position, relative=False) # Absolute seek
428
+
261
429
  player_bar.update_playback(title=result.title, is_playing=True)
262
430
 
431
+ # Update media keys metadata (for MPRIS/system media controls)
432
+ if self._media_keys:
433
+ self._media_keys.update_track(
434
+ title=result.title,
435
+ artist=result.channel,
436
+ duration=result.duration,
437
+ art_url=result.thumbnail_url or "",
438
+ track_id=result.video_id,
439
+ )
440
+ self._media_keys.update_playback(is_playing=True)
441
+
263
442
  # Add to history
264
443
  track = self.database.get_or_create_track(
265
444
  video_id=result.video_id,
@@ -354,6 +533,18 @@ class WrkmonApp(App):
354
533
  is_playing=is_playing,
355
534
  )
356
535
 
536
+ # Save playback position every 10 seconds (to reduce DB writes)
537
+ if is_playing and int(pos) % 10 == 0 and int(pos) > 0:
538
+ self.queue.update_playback_position(self._current_track.video_id, int(pos))
539
+
540
+ # Update media keys state (for MPRIS seekbar, etc.)
541
+ if self._media_keys:
542
+ self._media_keys.update_playback(
543
+ is_playing=is_playing,
544
+ position=pos,
545
+ volume=self._volume,
546
+ )
547
+
357
548
  # Update queue view if visible
358
549
  if self._current_view == "queue":
359
550
  queue_view = self.query_one("#queue", QueueView)
@@ -525,6 +716,89 @@ class WrkmonApp(App):
525
716
  except Exception:
526
717
  pass
527
718
 
719
+ def action_show_help(self) -> None:
720
+ """Show the help screen."""
721
+ self.push_screen(HelpScreen())
722
+
723
+ async def action_toggle_mute(self) -> None:
724
+ """Toggle mute."""
725
+ if not hasattr(self, '_muted'):
726
+ self._muted = False
727
+ self._pre_mute_volume = self._volume
728
+
729
+ if self._muted:
730
+ # Unmute - restore previous volume
731
+ await self.set_volume(self._pre_mute_volume)
732
+ self._muted = False
733
+ self.notify("Unmuted", timeout=1)
734
+ else:
735
+ # Mute - save current volume and set to 0
736
+ self._pre_mute_volume = self._volume
737
+ await self.set_volume(0)
738
+ self._muted = True
739
+ self.notify("Muted", timeout=1)
740
+
741
+ def action_cursor_down(self) -> None:
742
+ """Move cursor down in current list (vim j key)."""
743
+ self._navigate_list(1)
744
+
745
+ def action_cursor_up(self) -> None:
746
+ """Move cursor up in current list (vim k key)."""
747
+ self._navigate_list(-1)
748
+
749
+ def action_cursor_top(self) -> None:
750
+ """Jump to top of current list (vim g key)."""
751
+ self._navigate_list_to(0)
752
+
753
+ def action_cursor_bottom(self) -> None:
754
+ """Jump to bottom of current list (vim G key)."""
755
+ self._navigate_list_to(-1)
756
+
757
+ def _navigate_list(self, delta: int) -> None:
758
+ """Navigate in the current view's list."""
759
+ try:
760
+ if self._current_view == "search":
761
+ list_view = self.query_one("#results-list")
762
+ elif self._current_view == "queue":
763
+ list_view = self.query_one("#queue-list")
764
+ elif self._current_view == "history":
765
+ list_view = self.query_one("#history-list")
766
+ elif self._current_view == "playlists":
767
+ list_view = self.query_one("#playlist-list")
768
+ else:
769
+ return
770
+
771
+ if list_view and hasattr(list_view, 'index'):
772
+ new_index = max(0, list_view.index + delta)
773
+ if hasattr(list_view, 'children'):
774
+ new_index = min(new_index, len(list_view.children) - 1)
775
+ list_view.index = new_index
776
+ except Exception:
777
+ pass
778
+
779
+ def _navigate_list_to(self, index: int) -> None:
780
+ """Navigate to specific index in current list."""
781
+ try:
782
+ if self._current_view == "search":
783
+ list_view = self.query_one("#results-list")
784
+ elif self._current_view == "queue":
785
+ list_view = self.query_one("#queue-list")
786
+ elif self._current_view == "history":
787
+ list_view = self.query_one("#history-list")
788
+ elif self._current_view == "playlists":
789
+ list_view = self.query_one("#playlist-list")
790
+ else:
791
+ return
792
+
793
+ if list_view and hasattr(list_view, 'index'):
794
+ if index == -1 and hasattr(list_view, 'children'):
795
+ # Go to last item
796
+ list_view.index = max(0, len(list_view.children) - 1)
797
+ else:
798
+ list_view.index = index
799
+ except Exception:
800
+ pass
801
+
528
802
  async def action_quit(self) -> None:
529
803
  """Quit the application cleanly."""
530
804
  await self._cleanup()
@@ -533,9 +807,29 @@ class WrkmonApp(App):
533
807
  async def _cleanup(self) -> None:
534
808
  """Clean up resources."""
535
809
  logger.info("=== Cleaning up ===")
536
- # Save config
810
+
811
+ # Save current playback position before cleanup
812
+ if self._current_track:
813
+ try:
814
+ pos = await self.player.get_position()
815
+ self.queue.update_playback_position(self._current_track.video_id, int(pos))
816
+ logger.info(f" Saved position {int(pos)}s for {self._current_track.title[:30]}")
817
+ except Exception:
818
+ pass
819
+
820
+ # Save queue to database
821
+ self._save_queue()
822
+
823
+ # Save all settings to config
537
824
  self._config.volume = self._volume
825
+ self._config.repeat_mode = self.queue.repeat_mode
826
+ self._config.shuffle = self.queue.shuffle_mode
538
827
  self._config.save()
828
+ logger.info(f" Saved settings: vol={self._volume}, repeat={self.queue.repeat_mode}, shuffle={self.queue.shuffle_mode}")
829
+
830
+ # Stop media keys handler
831
+ if self._media_keys:
832
+ await self._media_keys.stop()
539
833
 
540
834
  # Shutdown player - MUST stop mpv
541
835
  logger.info(" Stopping player...")
wrkmon/cli.py CHANGED
@@ -285,5 +285,105 @@ def config() -> None:
285
285
  console.print(f"Cache TTL: {cfg.url_ttl_hours} hours")
286
286
 
287
287
 
288
+ @app.command()
289
+ def update(
290
+ check_only: bool = typer.Option(
291
+ False, "--check", "-c", help="Only check for updates, don't install"
292
+ ),
293
+ ) -> None:
294
+ """Check for and install updates."""
295
+ from wrkmon.utils.updater import check_for_updates, perform_update
296
+
297
+ console.print("[dim]Checking for updates...[/dim]")
298
+
299
+ update_info = check_for_updates()
300
+
301
+ if update_info is None:
302
+ console.print("[yellow]Could not check for updates. Please try again later.[/yellow]")
303
+ return
304
+
305
+ console.print(f"Current version: [cyan]{update_info.current_version}[/cyan]")
306
+ console.print(f"Latest version: [cyan]{update_info.latest_version}[/cyan]")
307
+
308
+ if not update_info.is_update_available:
309
+ console.print("\n[green]You are running the latest version![/green]")
310
+ return
311
+
312
+ console.print(f"\n[yellow]Update available![/yellow]")
313
+
314
+ if check_only:
315
+ console.print(f"\nTo update, run: [bold]{update_info.update_command}[/bold]")
316
+ return
317
+
318
+ # Prompt for update
319
+ if typer.confirm("Do you want to update now?"):
320
+ console.print("[dim]Updating...[/dim]")
321
+ success, message = perform_update()
322
+ if success:
323
+ console.print(f"[green]{message}[/green]")
324
+ else:
325
+ console.print(f"[red]{message}[/red]")
326
+ else:
327
+ console.print(f"\nTo update later, run: [bold]{update_info.update_command}[/bold]")
328
+
329
+
330
+ @app.command()
331
+ def deps() -> None:
332
+ """Check and manage dependencies."""
333
+ from wrkmon.utils.updater import check_dependencies, get_deno_install_command
334
+ from wrkmon.utils.mpv_installer import is_mpv_installed, get_mpv_path
335
+
336
+ console.print("[bold]Dependency Status[/bold]\n")
337
+
338
+ deps_status = check_dependencies()
339
+
340
+ table = Table()
341
+ table.add_column("Dependency", style="bold")
342
+ table.add_column("Status")
343
+ table.add_column("Required")
344
+ table.add_column("Description", style="dim")
345
+
346
+ for name, info in deps_status.items():
347
+ if name == "js_runtime":
348
+ continue # Skip aggregate
349
+
350
+ status = "[green]Installed[/green]" if info["installed"] else "[red]Missing[/red]"
351
+ required = "[yellow]Yes[/yellow]" if info["required"] else "No"
352
+ table.add_row(name, status, required, info["description"])
353
+
354
+ console.print(table)
355
+
356
+ # Show recommendations
357
+ if not deps_status["mpv"]["installed"]:
358
+ console.print("\n[red]mpv is required for audio playback![/red]")
359
+ console.print("Install with:")
360
+ console.print(" [bold]Linux:[/bold] sudo apt install mpv")
361
+ console.print(" [bold]macOS:[/bold] brew install mpv")
362
+ console.print(" [bold]Windows:[/bold] winget install mpv")
363
+
364
+ if not deps_status["js_runtime"]["installed"]:
365
+ console.print("\n[yellow]No JavaScript runtime found.[/yellow]")
366
+ console.print("For better YouTube compatibility, install deno:")
367
+ console.print(f" [bold]{get_deno_install_command()}[/bold]")
368
+
369
+
370
+ @app.command()
371
+ def install_deno() -> None:
372
+ """Install deno for better YouTube compatibility."""
373
+ from wrkmon.utils.updater import install_deno as do_install_deno, is_deno_installed
374
+
375
+ if is_deno_installed():
376
+ console.print("[green]deno is already installed![/green]")
377
+ return
378
+
379
+ console.print("[dim]Attempting to install deno...[/dim]")
380
+ success, message = do_install_deno()
381
+
382
+ if success:
383
+ console.print(f"[green]{message}[/green]")
384
+ else:
385
+ console.print(f"[yellow]{message}[/yellow]")
386
+
387
+
288
388
  if __name__ == "__main__":
289
389
  app()