feat(keybindings): implement centralized keybindings system

Phase 1: Core infrastructure
- Add keyboard.ts with event-to-shortcut conversion, display formatting
- Add keybindingsStore.ts with action registry, conflict detection, persistence

Phase 2: Migration
- Migrate +page.svelte shortcuts to use keybinding manager
- Register global actions: playback, navigation, UI controls
- Integrate FocusMode with contextual seek actions
- Simplify ExpandedPlayer to use keybinding handler
- Add i18n translations for keybinding labels (en/es)
This commit is contained in:
vicrodh committed 2026-02-02 23:02:33 -06:00
1 parent cec4c32bcc
commit fa6538df2c
7 files changed
+651 -51

No files matched your search

+3 -6
View File
@@ -5,6 +5,7 @@
import StackIcon from './StackIcon.svelte';
import LyricsLines from './lyrics/LyricsLines.svelte';
import { startActiveLineUpdates } from '$lib/stores/lyricsStore';
import { handleKeydown as keybindingHandler } from '$lib/stores/keybindingsStore';
interface HardwareAudioStatus {
hardware_sample_rate: number | null;
@@ -202,12 +203,8 @@
function handleKeydown(e: KeyboardEvent) {
if (!isOpen) return;
if (e.key === 'Escape') {
onClose();
} else if (e.key === ' ') {
e.preventDefault();
onTogglePlay();
}
// Delegate to keybinding manager (global shortcuts handle Escape and Space)
keybindingHandler(e);
}
$effect(() => {
+24 -6
View File
@@ -4,6 +4,11 @@
import LyricsLines from './lyrics/LyricsLines.svelte';
import { startActiveLineUpdates, stopActiveLineUpdates } from '$lib/stores/lyricsStore';
import { t } from '$lib/i18n';
import {
registerAction,
unregisterAction,
handleKeydown as keybindingHandler
} from '$lib/stores/keybindingsStore';
interface LyricsLine {
text: string;
@@ -68,6 +73,23 @@
};
});
// Register focus mode seek actions when open
$effect(() => {
if (isOpen) {
registerAction('focus.seekForward', () => onSeek(Math.min(duration, currentTime + 5)));
registerAction('focus.seekBack', () => onSeek(Math.max(0, currentTime - 5)));
registerAction('focus.seekForwardLong', () => onSeek(Math.min(duration, currentTime + 10)));
registerAction('focus.seekBackLong', () => onSeek(Math.max(0, currentTime - 10)));
return () => {
unregisterAction('focus.seekForward');
unregisterAction('focus.seekBack');
unregisterAction('focus.seekForwardLong');
unregisterAction('focus.seekBackLong');
};
}
});
let showControls = $state(true);
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
let progressRef: HTMLDivElement | null = $state(null);
@@ -135,12 +157,8 @@
function handleKeyPress(e: KeyboardEvent) {
if (!isOpen) return;
if (e.key === 'Escape') {
onClose();
} else if (e.key === ' ') {
e.preventDefault();
onTogglePlay();
}
// Delegate to keybinding manager with focus mode context
keybindingHandler(e, { focusMode: true });
}
function handleMouseMove() {
+39
View File
@@ -970,5 +970,44 @@
"collapse": "Collapse",
"expand": "Expand",
"clearFilter": "Clear filter"
},
"keybindings": {
"title": "Keyboard Shortcuts",
"categories": {
"playback": "Playback",
"navigation": "Navigation",
"ui": "Interface",
"focus": "Focus Mode"
},
"actions": {
"playbackToggle": "Play / Pause",
"playbackNext": "Next Track",
"playbackPrev": "Previous Track",
"navBack": "Go Back",
"navForward": "Go Forward",
"uiFocusMode": "Focus Mode",
"uiQueue": "Queue",
"uiEscape": "Close / Dismiss",
"uiShowShortcuts": "Show Shortcuts",
"focusSeekForward": "Seek Forward (5s)",
"focusSeekBack": "Seek Back (5s)",
"focusSeekForwardLong": "Seek Forward (10s)",
"focusSeekBackLong": "Seek Back (10s)"
},
"descriptions": {
"playbackToggle": "Toggle playback",
"playbackNext": "Skip to next track",
"playbackPrev": "Go to previous track",
"navBack": "Navigate to previous page",
"navForward": "Navigate to next page",
"uiFocusMode": "Toggle focus mode",
"uiQueue": "Toggle queue panel",
"uiEscape": "Close active panel or modal",
"uiShowShortcuts": "Show keyboard shortcuts modal",
"focusSeekForward": "Seek forward 5 seconds",
"focusSeekBack": "Seek back 5 seconds",
"focusSeekForwardLong": "Seek forward 10 seconds",
"focusSeekBackLong": "Seek back 10 seconds"
}
}
}
+39
View File
@@ -970,5 +970,44 @@
"collapse": "Contraer",
"expand": "Expandir",
"clearFilter": "Limpiar filtro"
},
"keybindings": {
"title": "Atajos de Teclado",
"categories": {
"playback": "Reproducción",
"navigation": "Navegación",
"ui": "Interfaz",
"focus": "Modo Focus"
},
"actions": {
"playbackToggle": "Reproducir / Pausar",
"playbackNext": "Siguiente Pista",
"playbackPrev": "Pista Anterior",
"navBack": "Ir Atrás",
"navForward": "Ir Adelante",
"uiFocusMode": "Modo Focus",
"uiQueue": "Cola",
"uiEscape": "Cerrar / Descartar",
"uiShowShortcuts": "Mostrar Atajos",
"focusSeekForward": "Avanzar (5s)",
"focusSeekBack": "Retroceder (5s)",
"focusSeekForwardLong": "Avanzar (10s)",
"focusSeekBackLong": "Retroceder (10s)"
},
"descriptions": {
"playbackToggle": "Alternar reproducción",
"playbackNext": "Saltar a la siguiente pista",
"playbackPrev": "Ir a la pista anterior",
"navBack": "Navegar a la página anterior",
"navForward": "Navegar a la siguiente página",
"uiFocusMode": "Alternar modo focus",
"uiQueue": "Alternar panel de cola",
"uiEscape": "Cerrar panel activo o modal",
"uiShowShortcuts": "Mostrar modal de atajos de teclado",
"focusSeekForward": "Avanzar 5 segundos",
"focusSeekBack": "Retroceder 5 segundos",
"focusSeekForwardLong": "Avanzar 10 segundos",
"focusSeekBackLong": "Retroceder 10 segundos"
}
}
}
+416
View File
@@ -0,0 +1,416 @@
/**
* Keybindings Store
*
* Centralized keybindings system with:
* - Default shortcuts for all actions
* - User customization support
* - Conflict detection
* - Persistence to localStorage
*/
import {
eventToShortcut,
formatShortcutDisplay,
isInputTarget,
} from '$lib/utils/keyboard';
// Re-export utilities for convenience
export { eventToShortcut, formatShortcutDisplay, isInputTarget };
// ============================================================================
// Types
// ============================================================================
export type KeybindingCategory = 'playback' | 'navigation' | 'ui' | 'focus';
export interface KeybindingAction {
/** Unique identifier, e.g., 'playback.toggle' */
id: string;
/** Label for UI display */
label: string;
/** Category for grouping in UI */
category: KeybindingCategory;
/** Default shortcut */
defaultShortcut: string;
/** Optional description for tooltips */
description?: string;
/** If true, only works in certain contexts (e.g., focus mode) */
contextual?: boolean;
}
export interface CategoryInfo {
id: KeybindingCategory;
label: string;
description: string;
}
// ============================================================================
// Constants
// ============================================================================
export const CATEGORIES: CategoryInfo[] = [
{ id: 'playback', label: 'Playback', description: 'Playback controls' },
{ id: 'navigation', label: 'Navigation', description: 'App navigation' },
{ id: 'ui', label: 'Interface', description: 'UI elements' },
{ id: 'focus', label: 'Focus Mode', description: 'Focus mode controls' },
];
export const ACTIONS: KeybindingAction[] = [
// Playback
{
id: 'playback.toggle',
label: 'Play / Pause',
category: 'playback',
defaultShortcut: 'Space',
description: 'Toggle playback',
},
{
id: 'playback.next',
label: 'Next Track',
category: 'playback',
defaultShortcut: 'Ctrl+ArrowRight',
description: 'Skip to next track',
},
{
id: 'playback.prev',
label: 'Previous Track',
category: 'playback',
defaultShortcut: 'Ctrl+ArrowLeft',
description: 'Go to previous track',
},
// Navigation
{
id: 'nav.back',
label: 'Go Back',
category: 'navigation',
defaultShortcut: 'Alt+ArrowLeft',
description: 'Navigate to previous page',
},
{
id: 'nav.forward',
label: 'Go Forward',
category: 'navigation',
defaultShortcut: 'Alt+ArrowRight',
description: 'Navigate to next page',
},
// UI
{
id: 'ui.focusMode',
label: 'Focus Mode',
category: 'ui',
defaultShortcut: 'f',
description: 'Toggle focus mode',
},
{
id: 'ui.queue',
label: 'Queue',
category: 'ui',
defaultShortcut: 'q',
description: 'Toggle queue panel',
},
{
id: 'ui.escape',
label: 'Close / Dismiss',
category: 'ui',
defaultShortcut: 'Escape',
description: 'Close active panel or modal',
},
{
id: 'ui.showShortcuts',
label: 'Show Shortcuts',
category: 'ui',
defaultShortcut: '?',
description: 'Show keyboard shortcuts modal',
},
// Focus Mode specific
{
id: 'focus.seekForward',
label: 'Seek Forward (5s)',
category: 'focus',
defaultShortcut: 'ArrowRight',
description: 'Seek forward 5 seconds',
contextual: true,
},
{
id: 'focus.seekBack',
label: 'Seek Back (5s)',
category: 'focus',
defaultShortcut: 'ArrowLeft',
description: 'Seek back 5 seconds',
contextual: true,
},
{
id: 'focus.seekForwardLong',
label: 'Seek Forward (10s)',
category: 'focus',
defaultShortcut: 'Shift+ArrowRight',
description: 'Seek forward 10 seconds',
contextual: true,
},
{
id: 'focus.seekBackLong',
label: 'Seek Back (10s)',
category: 'focus',
defaultShortcut: 'Shift+ArrowLeft',
description: 'Seek back 10 seconds',
contextual: true,
},
];
/** Map of default bindings for quick lookup */
export const DEFAULT_BINDINGS: Record<string, string> = Object.fromEntries(
ACTIONS.map((a) => [a.id, a.defaultShortcut])
);
// ============================================================================
// Storage
// ============================================================================
const STORAGE_KEY = 'qbz_keybindings';
const STORAGE_VERSION = 1;
interface StoredKeybindings {
version: number;
bindings: Record<string, string>;
}
function loadUserBindings(): Record<string, string> {
if (typeof window === 'undefined') return {};
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return {};
const parsed = JSON.parse(stored);
// Handle legacy format (no version)
if (!parsed.version) {
return validateBindings(parsed);
}
// Current format
if (parsed.version === STORAGE_VERSION) {
return validateBindings(parsed.bindings);
}
// Future version - try to load anyway
return validateBindings(parsed.bindings || {});
} catch (error) {
console.error('[Keybindings] Failed to load:', error);
return {};
}
}
function saveUserBindings(bindings: Record<string, string>): void {
if (typeof window === 'undefined') return;
const data: StoredKeybindings = {
version: STORAGE_VERSION,
bindings,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (error) {
console.error('[Keybindings] Failed to save:', error);
}
}
function validateBindings(bindings: unknown): Record<string, string> {
if (typeof bindings !== 'object' || bindings === null) {
return {};
}
const validActionIds = new Set(ACTIONS.map((a) => a.id));
const validated: Record<string, string> = {};
for (const [key, value] of Object.entries(bindings)) {
if (validActionIds.has(key) && typeof value === 'string') {
validated[key] = value;
}
}
return validated;
}
// ============================================================================
// State
// ============================================================================
/** User overrides (persisted) */
let userBindings = $state<Record<string, string>>(loadUserBindings());
/** Callbacks registered by components */
const actionCallbacks = new Map<string, () => void>();
/** Active bindings (defaults + overrides) */
export const activeBindings = $derived.by(() => {
return { ...DEFAULT_BINDINGS, ...userBindings };
});
/** Reverse map: shortcut -> actionId (for quick lookup) */
export const shortcutToAction = $derived.by(() => {
const map = new Map<string, string>();
for (const [actionId, shortcut] of Object.entries(activeBindings)) {
map.set(shortcut, actionId);
}
return map;
});
// ============================================================================
// Manager Functions
// ============================================================================
/**
* Registers a callback for an action
*/
export function registerAction(actionId: string, callback: () => void): void {
actionCallbacks.set(actionId, callback);
}
/**
* Unregisters a callback
*/
export function unregisterAction(actionId: string): void {
actionCallbacks.delete(actionId);
}
/**
* Unregisters all callbacks
*/
export function unregisterAll(): void {
actionCallbacks.clear();
}
/**
* Gets the current shortcut for an action
*/
export function getBinding(actionId: string): string {
return activeBindings[actionId] || '';
}
/**
* Sets a new shortcut for an action
* @returns true if set, false if there's a conflict
*/
export function setBinding(actionId: string, shortcut: string): boolean {
// Check for conflict
const existingAction = shortcutToAction.get(shortcut);
if (existingAction && existingAction !== actionId) {
return false; // Conflict
}
userBindings = { ...userBindings, [actionId]: shortcut };
saveUserBindings(userBindings);
return true;
}
/**
* Resets an action to its default shortcut
*/
export function resetBinding(actionId: string): void {
const { [actionId]: _, ...rest } = userBindings;
userBindings = rest;
// If no overrides remain, clear localStorage
if (Object.keys(userBindings).length === 0) {
localStorage.removeItem(STORAGE_KEY);
} else {
saveUserBindings(userBindings);
}
}
/**
* Resets all bindings to defaults
*/
export function resetAllBindings(): void {
userBindings = {};
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
}
/**
* Checks if a shortcut has a conflict
* @param shortcut The shortcut to check
* @param excludeActionId Action to exclude from check (for editing)
*/
export function hasConflict(
shortcut: string,
excludeActionId?: string
): boolean {
const existingAction = shortcutToAction.get(shortcut);
return existingAction !== undefined && existingAction !== excludeActionId;
}
/**
* Gets the action that conflicts with a shortcut
*/
export function getConflictingAction(
shortcut: string,
excludeActionId?: string
): KeybindingAction | null {
const existingActionId = shortcutToAction.get(shortcut);
if (!existingActionId || existingActionId === excludeActionId) {
return null;
}
return ACTIONS.find((a) => a.id === existingActionId) || null;
}
/**
* Global keydown handler
* @param event The keyboard event
* @param context Current context (for contextual shortcuts)
*/
export function handleKeydown(
event: KeyboardEvent,
context: { focusMode?: boolean } = {}
): boolean {
// Ignore if we're in an input
if (isInputTarget(event)) {
return false;
}
const shortcut = eventToShortcut(event);
if (!shortcut) return false;
const actionId = shortcutToAction.get(shortcut);
if (!actionId) return false;
// Check if contextual and if context applies
const action = ACTIONS.find((a) => a.id === actionId);
if (action?.contextual) {
if (action.category === 'focus' && !context.focusMode) {
return false;
}
}
// Execute callback
const callback = actionCallbacks.get(actionId);
if (callback) {
event.preventDefault();
callback();
return true;
}
return false;
}
/**
* Gets all actions grouped by category
*/
export function getActionsByCategory(): Map<KeybindingCategory, KeybindingAction[]> {
const grouped = new Map<KeybindingCategory, KeybindingAction[]>();
for (const category of CATEGORIES) {
const actions = ACTIONS.filter((a) => a.category === category.id);
if (actions.length > 0) {
grouped.set(category.id, actions);
}
}
return grouped;
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Keyboard utilities for keybindings system
*/
/** Display mappings for special keys */
const KEY_DISPLAY_MAP: Record<string, string> = {
ArrowLeft: '←',
ArrowRight: '→',
ArrowUp: '↑',
ArrowDown: '↓',
Space: 'Space',
Escape: 'Esc',
Enter: '↵',
Backspace: '⌫',
Delete: 'Del',
Tab: 'Tab',
};
/**
* Converts a KeyboardEvent to a normalized shortcut string
* @example eventToShortcut({ key: 'ArrowRight', ctrlKey: true }) // 'Ctrl+ArrowRight'
*/
export function eventToShortcut(event: KeyboardEvent): string {
const parts: string[] = [];
if (event.ctrlKey || event.metaKey) parts.push('Ctrl');
if (event.altKey) parts.push('Alt');
if (event.shiftKey) parts.push('Shift');
// Normalize key
let key = event.key;
// Ignore if it's only a modifier key
if (['Control', 'Alt', 'Shift', 'Meta'].includes(key)) {
return '';
}
// Normalize space
if (key === ' ') key = 'Space';
parts.push(key);
return parts.join('+');
}
/**
* Parses a shortcut string into its components
* @example parseShortcut('Ctrl+Shift+F') // { ctrl: true, alt: false, shift: true, key: 'F' }
*/
export function parseShortcut(shortcut: string): {
ctrl: boolean;
alt: boolean;
shift: boolean;
key: string;
} {
const parts = shortcut.split('+');
const key = parts.pop() || '';
return {
ctrl: parts.includes('Ctrl'),
alt: parts.includes('Alt'),
shift: parts.includes('Shift'),
key,
};
}
/**
* Formats a shortcut for display in UI (with platform-specific symbols)
* @example formatShortcutDisplay('Ctrl+ArrowRight') // '⌘ →' on macOS, 'Ctrl + →' on others
*/
export function formatShortcutDisplay(shortcut: string): string {
const isMac =
typeof navigator !== 'undefined' &&
navigator.platform.toLowerCase().includes('mac');
const { ctrl, alt, shift, key } = parseShortcut(shortcut);
const parts: string[] = [];
if (ctrl) parts.push(isMac ? '⌘' : 'Ctrl');
if (alt) parts.push(isMac ? '⌥' : 'Alt');
if (shift) parts.push(isMac ? '⇧' : 'Shift');
const displayKey = KEY_DISPLAY_MAP[key] || key.toUpperCase();
parts.push(displayKey);
return parts.join(isMac ? ' ' : ' + ');
}
/**
* Checks if an event matches a shortcut string
*/
export function eventMatchesShortcut(
event: KeyboardEvent,
shortcut: string
): boolean {
const eventShortcut = eventToShortcut(event);
return eventShortcut === shortcut;
}
/**
* Checks if the event target is an input element (to ignore shortcuts)
*/
export function isInputTarget(event: KeyboardEvent): boolean {
const target = event.target;
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
);
}
+21 -39
View File
@@ -82,6 +82,13 @@
shouldShowTitleBar
} from '$lib/stores/titleBarStore';
// Keybindings system
import {
registerAction,
unregisterAll,
handleKeydown as keybindingHandler
} from '$lib/stores/keybindingsStore';
// Auth state management
import {
subscribe as subscribeAuth,
@@ -2337,48 +2344,12 @@
}
}
// Keyboard Shortcuts
// Keyboard Shortcuts - delegated to keybindings system
function handleKeydown(e: KeyboardEvent) {
if (!isLoggedIn) return;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
switch (e.key) {
case ' ':
e.preventDefault();
togglePlay();
break;
case 'ArrowLeft':
if (e.altKey) {
e.preventDefault();
navGoBack();
} else if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSkipBack();
}
break;
case 'ArrowRight':
if (e.altKey) {
e.preventDefault();
navGoForward();
} else if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSkipForward();
}
break;
case 'f':
if (!e.ctrlKey && !e.metaKey) {
toggleFocusMode();
}
break;
case 'q':
toggleQueue();
break;
case 'Escape':
handleUIEscape();
break;
}
// Delegate to keybinding manager (handles input target filtering internally)
keybindingHandler(e);
}
// Playback state polling - managed by playerStore
@@ -2453,6 +2424,16 @@
// Keyboard navigation
document.addEventListener('keydown', handleKeydown);
// Register keybinding actions
registerAction('playback.toggle', togglePlay);
registerAction('playback.next', handleSkipForward);
registerAction('playback.prev', handleSkipBack);
registerAction('nav.back', navGoBack);
registerAction('nav.forward', navGoForward);
registerAction('ui.focusMode', toggleFocusMode);
registerAction('ui.queue', toggleQueue);
registerAction('ui.escape', handleUIEscape);
// Session save on window close/hide
const handleBeforeUnload = () => {
saveSessionBeforeClose();
@@ -2773,6 +2754,7 @@
saveSessionBeforeClose();
cleanupBootstrap();
document.removeEventListener('keydown', handleKeydown);
unregisterAll(); // Cleanup keybinding actions
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
stopOfflineCacheEventListeners();