feat(web): global keyboard shortcuts (Space/J/L/M/Arrows//)
test-web / test (push) Failing after 32s
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user