feat(web): global keyboard shortcuts (Space/J/L/M/Arrows//)
test-web / test (push) Failing after 32s

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.
This commit is contained in:
2026-06-01 11:17:59 -04:00
parent 6e6fbc7856
commit 4362233d7c
4 changed files with 264 additions and 0 deletions
+123
View File
@@ -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');
});
});