diff --git a/src/lib/components/ExpandedPlayer.svelte b/src/lib/components/ExpandedPlayer.svelte index 0a5874446..f622273eb 100644 --- a/src/lib/components/ExpandedPlayer.svelte +++ b/src/lib/components/ExpandedPlayer.svelte @@ -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(() => { diff --git a/src/lib/components/FocusMode.svelte b/src/lib/components/FocusMode.svelte index 39180fa3a..2fba1ee3e 100644 --- a/src/lib/components/FocusMode.svelte +++ b/src/lib/components/FocusMode.svelte @@ -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 | 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() { diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index ed6ac3716..a55e3ffd4 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -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" + } } } diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index 0a0064dd2..1f6f8bbdd 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -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" + } } } diff --git a/src/lib/stores/keybindingsStore.ts b/src/lib/stores/keybindingsStore.ts new file mode 100644 index 000000000..309d027c5 --- /dev/null +++ b/src/lib/stores/keybindingsStore.ts @@ -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 = 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; +} + +function loadUserBindings(): Record { + 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): 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 { + if (typeof bindings !== 'object' || bindings === null) { + return {}; + } + + const validActionIds = new Set(ACTIONS.map((a) => a.id)); + const validated: Record = {}; + + 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>(loadUserBindings()); + +/** Callbacks registered by components */ +const actionCallbacks = new Map 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(); + 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 { + const grouped = new Map(); + + 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; +} diff --git a/src/lib/utils/keyboard.ts b/src/lib/utils/keyboard.ts new file mode 100644 index 000000000..c016b2572 --- /dev/null +++ b/src/lib/utils/keyboard.ts @@ -0,0 +1,109 @@ +/** + * Keyboard utilities for keybindings system + */ + +/** Display mappings for special keys */ +const KEY_DISPLAY_MAP: Record = { + 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) + ); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index aca621b03..c58c87cb3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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();