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:
1 parent
cec4c32bcc
commit
fa6538df2c
7 files changed
+651
-51
No files matched your search
@@ -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(() => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user