From 4362233d7c8088f57f41af2400aa806f4adde711 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 11:17:59 -0400 Subject: [PATCH] feat(web): global keyboard shortcuts (Space/J/L/M/Arrows//) Universal music-player conventions, attached at window level from +layout.svelte: Space, K play / pause ArrowLeft previous track ArrowRight next track J seek -10s L seek +10s ArrowUp volume +5% ArrowDown volume -5% M mute / unmute (restores prior volume) / focus the global search input Skipped when focus is in an input / textarea / select / contenteditable so typing in the search box and rename fields behaves normally. Modifier-only chords (Ctrl/Cmd/Alt + key) intentionally pass through to the browser. New store helper `toggleMute()` captures the last non-zero volume so M can toggle back without permanently losing the user's level. shortcuts.svelte.ts is the new module; +layout.svelte wires it alongside useMediaSession / useEventsDispatcher. Scribe 529, local task #60. --- web/src/lib/player/shortcuts.svelte.test.ts | 123 +++++++++++++++++++ web/src/lib/player/shortcuts.svelte.ts | 124 ++++++++++++++++++++ web/src/lib/player/store.svelte.ts | 15 +++ web/src/routes/+layout.svelte | 2 + 4 files changed, 264 insertions(+) create mode 100644 web/src/lib/player/shortcuts.svelte.test.ts create mode 100644 web/src/lib/player/shortcuts.svelte.ts diff --git a/web/src/lib/player/shortcuts.svelte.test.ts b/web/src/lib/player/shortcuts.svelte.test.ts new file mode 100644 index 00000000..434bc81b --- /dev/null +++ b/web/src/lib/player/shortcuts.svelte.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { flushSync } from 'svelte'; +import type { TrackRef } from '$lib/api/types'; +import { makeTrack } from '$test-utils/fixtures/track'; + +function track(id: string): TrackRef { + return makeTrack({ + id, + title: `T ${id}`, + duration_sec: 200, + stream_url: `/api/tracks/${id}/stream` + }); +} + +function press(key: string, target?: EventTarget): void { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + (target ?? window).dispatchEvent(event); +} + +describe('useGlobalShortcuts', () => { + let cleanup: () => void; + + beforeEach(async () => { + const store = await import('./store.svelte'); + store.playQueue([track('1'), track('2'), track('3')]); + store.reportDuration(200); + flushSync(); + const { useGlobalShortcuts } = await import('./shortcuts.svelte'); + cleanup = $effect.root(() => useGlobalShortcuts()); + }); + + afterEach(() => { + cleanup?.(); + }); + + test('Space toggles play/pause', async () => { + const store = await import('./store.svelte'); + expect(store.player.state).toBe('paused'); + press(' '); + flushSync(); + expect(store.player.state).toBe('playing'); + press(' '); + flushSync(); + expect(store.player.state).toBe('paused'); + }); + + test('K is an alias for Space (YouTube convention)', async () => { + const store = await import('./store.svelte'); + expect(store.player.state).toBe('paused'); + press('k'); + flushSync(); + expect(store.player.state).toBe('playing'); + }); + + test('ArrowRight advances to the next track', async () => { + const store = await import('./store.svelte'); + expect(store.player.index).toBe(0); + press('ArrowRight'); + flushSync(); + expect(store.player.index).toBe(1); + }); + + test('ArrowLeft goes to the previous track', async () => { + const store = await import('./store.svelte'); + store.skipNext(); + flushSync(); + expect(store.player.index).toBe(1); + press('ArrowLeft'); + flushSync(); + expect(store.player.index).toBe(0); + }); + + test('M toggles mute, restoring the previous volume', async () => { + const store = await import('./store.svelte'); + store.setVolume(0.42); + flushSync(); + expect(store.player.volume).toBe(0.42); + press('m'); + flushSync(); + expect(store.player.volume).toBe(0); + press('m'); + flushSync(); + expect(store.player.volume).toBeCloseTo(0.42, 5); + }); + + test('ArrowUp / ArrowDown adjust volume by 5%', async () => { + const store = await import('./store.svelte'); + store.setVolume(0.5); + flushSync(); + press('ArrowUp'); + flushSync(); + expect(store.player.volume).toBeCloseTo(0.55, 5); + press('ArrowDown'); + flushSync(); + expect(store.player.volume).toBeCloseTo(0.5, 5); + }); + + test('shortcuts are ignored when focus is in an input', async () => { + const store = await import('./store.svelte'); + expect(store.player.state).toBe('paused'); + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + press(' ', input); + flushSync(); + // No state change — Space should pass through to the input. + expect(store.player.state).toBe('paused'); + input.remove(); + }); + + test('modifier keys disable the shortcut (browser hotkey passthrough)', async () => { + const store = await import('./store.svelte'); + const event = new KeyboardEvent('keydown', { + key: ' ', + ctrlKey: true, + bubbles: true, + cancelable: true + }); + window.dispatchEvent(event); + flushSync(); + expect(store.player.state).toBe('paused'); + }); +}); diff --git a/web/src/lib/player/shortcuts.svelte.ts b/web/src/lib/player/shortcuts.svelte.ts new file mode 100644 index 00000000..be4b1c12 --- /dev/null +++ b/web/src/lib/player/shortcuts.svelte.ts @@ -0,0 +1,124 @@ +import { + player, + togglePlay, + skipNext, + skipPrev, + seekTo, + setVolume, + toggleMute +} from './store.svelte'; + +// Universal music-player keyboard conventions, attached at window +// level from +layout.svelte. Skipped when focus is in a text input +// (or contenteditable surface) so typing in the search box, playlist +// rename field, etc. behaves normally. +// +// Convention map: +// Space play / pause +// K play / pause (YouTube convention; same as Space) +// ArrowLeft previous track +// ArrowRight next track +// J seek -10s +// L seek +10s +// ArrowUp volume +5% +// ArrowDown volume -5% +// M mute / unmute +// / focus the global search input +// +// Escape on the queue drawer is already handled in +layout.svelte; we +// intentionally don't claim Escape here so menus, modals, and the +// queue close as they do today. + +const SEEK_STEP_SEC = 10; +const VOLUME_STEP = 0.05; + +function isTypingTarget(el: EventTarget | null): boolean { + if (!(el instanceof HTMLElement)) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + if (el.isContentEditable) return true; + return false; +} + +function adjustVolume(delta: number): void { + setVolume(Math.max(0, Math.min(1, player.volume + delta))); +} + +function adjustPosition(delta: number): void { + const target = Math.max(0, Math.min(player.duration, player.position + delta)); + seekTo(target); +} + +function focusSearch(): void { + // Top-bar SearchInput renders . + // querySelector is the simplest reach into a component whose internal + // ref isn't exported. + const el = document.querySelector('input[type="search"]'); + el?.focus(); + el?.select(); +} + +export function useGlobalShortcuts(): void { + if (typeof window === 'undefined') return; + + const handler = (e: KeyboardEvent) => { + // Don't claim shortcuts when modifier-only (would conflict with + // browser hotkeys) or when the user is typing. + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (isTypingTarget(e.target)) return; + + switch (e.key) { + case ' ': + case 'k': + case 'K': + e.preventDefault(); + togglePlay(); + break; + case 'ArrowLeft': + e.preventDefault(); + skipPrev(); + break; + case 'ArrowRight': + e.preventDefault(); + skipNext(); + break; + case 'j': + case 'J': + e.preventDefault(); + adjustPosition(-SEEK_STEP_SEC); + break; + case 'l': + case 'L': + e.preventDefault(); + adjustPosition(SEEK_STEP_SEC); + break; + case 'ArrowUp': + e.preventDefault(); + adjustVolume(VOLUME_STEP); + break; + case 'ArrowDown': + e.preventDefault(); + adjustVolume(-VOLUME_STEP); + break; + case 'm': + case 'M': + e.preventDefault(); + toggleMute(); + break; + case '/': + // The "/" key has historically been the focus-search hotkey + // (Gmail, GitHub, Vim). preventDefault stops it from typing + // into whichever element ends up focused after. + e.preventDefault(); + focusSearch(); + break; + default: + return; + } + }; + + $effect(() => { + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }); +} diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts index 156d2b49..2a9978d2 100644 --- a/web/src/lib/player/store.svelte.ts +++ b/web/src/lib/player/store.svelte.ts @@ -166,6 +166,21 @@ export function setVolume(v: number): void { storage.write(VOLUME_KEY, String(clamped)); } +// Last non-zero volume captured so the M shortcut can toggle to silent +// then restore. Set lazily on the first toggle and on any setVolume +// that's non-zero. Default 0.5 keeps unmute usable even if the user +// somehow lands at volume=0 with no prior non-zero value. +let _volumeBeforeMute = 0.5; + +export function toggleMute(): void { + if (_volume > 0) { + _volumeBeforeMute = _volume; + setVolume(0); + } else { + setVolume(_volumeBeforeMute > 0 ? _volumeBeforeMute : 0.5); + } +} + export function reportTimeUpdate(sec: number): void { _position = sec; } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 66e54a0e..63c94fcb 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -20,6 +20,7 @@ } from '$lib/player/store.svelte'; import { useMediaSession } from '$lib/player/mediaSession.svelte'; import { useEventsDispatcher } from '$lib/player/events.svelte'; + import { useGlobalShortcuts } from '$lib/player/shortcuts.svelte'; import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte'; import { audioLoader } from '$lib/player/audioLoader'; import { attachStallRetry } from '$lib/player/stallRetry'; @@ -113,6 +114,7 @@ useMediaSession(); useEventsDispatcher(); + useGlobalShortcuts(); applyMetaThemeColor();