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();