feat: add playback keyboard shortcuts (#474)

- Space: play/pause
- Left/Right arrows: seek ยฑ10 seconds
- J/L: previous/next track
- M: mute/unmute

All shortcuts respect input focus and are disabled when search modal is open.

Also fixes AGENTS.md to reflect that STATUS.md is now tracked.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub d67ffa76 bc0689bd

Changed files
+128 -6
docs
frontend
src
+2 -2
AGENTS.md
··· 7 - check the justfiles. there's a root one, one for the backend, one for the frontend, and one for the transcoder etc 8 9 ## ๐Ÿšจ Critical Rules & Workflows 10 - * **Read `STATUS.md` First:** Always check for active tasks and known issues. This file is NEVER tracked in git. 11 * **Workflow:** 12 * Use **GitHub Issues** (not Linear). 13 * **PRs:** Always create for review; never push to main directly. ··· 52 โ”‚ โ””โ”€โ”€ src/lib/ # Components & State (.svelte.ts) 53 โ”œโ”€โ”€ scripts/ # Admin scripts (uv run scripts/...) 54 โ”œโ”€โ”€ docs/ # Architecture & Guides 55 - โ””โ”€โ”€ STATUS.md # Living status document (Untracked) 56 ``` 57 58 this file ("AGENTS.md") is symlinked to `CLAUDE.md` and `GEMINI.md` for maximal compatibility.
··· 7 - check the justfiles. there's a root one, one for the backend, one for the frontend, and one for the transcoder etc 8 9 ## ๐Ÿšจ Critical Rules & Workflows 10 + * **Read `STATUS.md` First:** Always check for active tasks and known issues. 11 * **Workflow:** 12 * Use **GitHub Issues** (not Linear). 13 * **PRs:** Always create for review; never push to main directly. ··· 52 โ”‚ โ””โ”€โ”€ src/lib/ # Components & State (.svelte.ts) 53 โ”œโ”€โ”€ scripts/ # Admin scripts (uv run scripts/...) 54 โ”œโ”€โ”€ docs/ # Architecture & Guides 55 + โ””โ”€โ”€ STATUS.md # Living status document 56 ``` 57 58 this file ("AGENTS.md") is symlinked to `CLAUDE.md` and `GEMINI.md` for maximal compatibility.
+33 -3
docs/frontend/keyboard-shortcuts.md
··· 49 - `aria-label="toggle queue (Q)"` for screen readers 50 - tooltip shows keyboard hint 51 52 ## adding new shortcuts 53 54 when adding keyboard shortcuts: ··· 95 ## future candidates 96 97 potential shortcuts to consider: 98 - - **space** - play/pause (when not focused on button) 99 - - **arrow keys** - skip forward/back (context-aware) 100 - - **shift + arrow** - navigate queue 101 - **/** - focus search (alternative to Cmd/Ctrl+K) 102 - **T** - cycle theme (dark/light/system) 103 104 ## design principles 105
··· 49 - `aria-label="toggle queue (Q)"` for screen readers 50 - tooltip shows keyboard hint 51 52 + --- 53 + 54 + ## playback shortcuts 55 + 56 + all playback shortcuts require a track to be loaded and are disabled when the search modal is open. 57 + 58 + ### Space - play/pause 59 + 60 + toggles playback of the current track. 61 + 62 + ### Left Arrow - seek backward 63 + 64 + seeks backward 10 seconds in the current track. 65 + 66 + ### Right Arrow - seek forward 67 + 68 + seeks forward 10 seconds in the current track. 69 + 70 + ### J - previous track 71 + 72 + goes to the previous track in the queue. if more than 3 seconds into the current track, restarts it instead. 73 + 74 + ### L - next track 75 + 76 + advances to the next track in the queue (if available). 77 + 78 + ### M - mute/unmute 79 + 80 + toggles mute. restores previous volume level when unmuting. 81 + 82 + --- 83 + 84 ## adding new shortcuts 85 86 when adding keyboard shortcuts: ··· 127 ## future candidates 128 129 potential shortcuts to consider: 130 - **/** - focus search (alternative to Cmd/Ctrl+K) 131 - **T** - cycle theme (dark/light/system) 132 + - **S** - shuffle queue 133 134 ## design principles 135
+93 -1
frontend/src/routes/+layout.svelte
··· 15 import { auth } from '$lib/auth.svelte'; 16 import { preferences } from '$lib/preferences.svelte'; 17 import { player } from '$lib/player.svelte'; 18 import { search } from '$lib/search.svelte'; 19 import { browser } from '$app/environment'; 20 import type { LayoutData } from './$types'; ··· 80 document.documentElement.style.setProperty('--queue-width', queueWidth); 81 }); 82 83 function handleKeyboardShortcuts(event: KeyboardEvent) { 84 // Cmd/Ctrl+K: toggle search 85 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { ··· 103 return; 104 } 105 106 // toggle queue on 'q' key 107 - if (event.key.toLowerCase() === 'q') { 108 event.preventDefault(); 109 toggleQueue(); 110 } 111 } 112
··· 15 import { auth } from '$lib/auth.svelte'; 16 import { preferences } from '$lib/preferences.svelte'; 17 import { player } from '$lib/player.svelte'; 18 + import { queue } from '$lib/queue.svelte'; 19 import { search } from '$lib/search.svelte'; 20 import { browser } from '$app/environment'; 21 import type { LayoutData } from './$types'; ··· 81 document.documentElement.style.setProperty('--queue-width', queueWidth); 82 }); 83 84 + const SEEK_AMOUNT = 10; // seconds 85 + let previousVolume = 0.7; // for mute toggle 86 + 87 function handleKeyboardShortcuts(event: KeyboardEvent) { 88 // Cmd/Ctrl+K: toggle search 89 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { ··· 107 return; 108 } 109 110 + // ignore playback shortcuts when search modal is open 111 + if (search.isOpen) { 112 + return; 113 + } 114 + 115 + const key = event.key.toLowerCase(); 116 + 117 // toggle queue on 'q' key 118 + if (key === 'q') { 119 event.preventDefault(); 120 toggleQueue(); 121 + return; 122 + } 123 + 124 + // playback shortcuts - only when a track is loaded 125 + if (!player.currentTrack) { 126 + return; 127 + } 128 + 129 + switch (event.key) { 130 + case ' ': // space - play/pause 131 + event.preventDefault(); 132 + player.togglePlayPause(); 133 + break; 134 + 135 + case 'ArrowLeft': // seek backward 136 + event.preventDefault(); 137 + seekBy(-SEEK_AMOUNT); 138 + break; 139 + 140 + case 'ArrowRight': // seek forward 141 + event.preventDefault(); 142 + seekBy(SEEK_AMOUNT); 143 + break; 144 + 145 + case 'j': // previous track (youtube-style) 146 + case 'J': 147 + event.preventDefault(); 148 + handlePreviousTrack(); 149 + break; 150 + 151 + case 'l': // next track (youtube-style) 152 + case 'L': 153 + event.preventDefault(); 154 + if (queue.hasNext) { 155 + queue.next(); 156 + } 157 + break; 158 + 159 + case 'm': // mute/unmute 160 + case 'M': 161 + event.preventDefault(); 162 + toggleMute(); 163 + break; 164 + } 165 + } 166 + 167 + function seekBy(seconds: number) { 168 + if (!player.audioElement || !player.duration) return; 169 + 170 + const newTime = Math.max(0, Math.min(player.duration, player.currentTime + seconds)); 171 + player.currentTime = newTime; 172 + player.audioElement.currentTime = newTime; 173 + } 174 + 175 + function handlePreviousTrack() { 176 + const RESTART_THRESHOLD = 3; // restart if more than 3 seconds in 177 + 178 + if (player.currentTime > RESTART_THRESHOLD) { 179 + // restart current track 180 + player.currentTime = 0; 181 + if (player.audioElement) { 182 + player.audioElement.currentTime = 0; 183 + } 184 + } else if (queue.hasPrevious) { 185 + // go to previous track 186 + queue.previous(); 187 + } else { 188 + // restart from beginning 189 + player.currentTime = 0; 190 + if (player.audioElement) { 191 + player.audioElement.currentTime = 0; 192 + } 193 + } 194 + } 195 + 196 + function toggleMute() { 197 + if (player.volume > 0) { 198 + previousVolume = player.volume; 199 + player.volume = 0; 200 + } else { 201 + player.volume = previousVolume || 0.7; 202 } 203 } 204