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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <input type="search" aria-label="Search">.
|
||||||
|
// querySelector is the simplest reach into a component whose internal
|
||||||
|
// ref isn't exported.
|
||||||
|
const el = document.querySelector<HTMLInputElement>('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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -166,6 +166,21 @@ export function setVolume(v: number): void {
|
|||||||
storage.write(VOLUME_KEY, String(clamped));
|
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 {
|
export function reportTimeUpdate(sec: number): void {
|
||||||
_position = sec;
|
_position = sec;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
} from '$lib/player/store.svelte';
|
} from '$lib/player/store.svelte';
|
||||||
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
||||||
import { useEventsDispatcher } from '$lib/player/events.svelte';
|
import { useEventsDispatcher } from '$lib/player/events.svelte';
|
||||||
|
import { useGlobalShortcuts } from '$lib/player/shortcuts.svelte';
|
||||||
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
|
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
|
||||||
import { audioLoader } from '$lib/player/audioLoader';
|
import { audioLoader } from '$lib/player/audioLoader';
|
||||||
import { attachStallRetry } from '$lib/player/stallRetry';
|
import { attachStallRetry } from '$lib/player/stallRetry';
|
||||||
@@ -113,6 +114,7 @@
|
|||||||
|
|
||||||
useMediaSession();
|
useMediaSession();
|
||||||
useEventsDispatcher();
|
useEventsDispatcher();
|
||||||
|
useGlobalShortcuts();
|
||||||
applyMetaThemeColor();
|
applyMetaThemeColor();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user