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 StackIcon from './StackIcon.svelte';
|
||||||
import LyricsLines from './lyrics/LyricsLines.svelte';
|
import LyricsLines from './lyrics/LyricsLines.svelte';
|
||||||
import { startActiveLineUpdates } from '$lib/stores/lyricsStore';
|
import { startActiveLineUpdates } from '$lib/stores/lyricsStore';
|
||||||
|
import { handleKeydown as keybindingHandler } from '$lib/stores/keybindingsStore';
|
||||||
|
|
||||||
interface HardwareAudioStatus {
|
interface HardwareAudioStatus {
|
||||||
hardware_sample_rate: number | null;
|
hardware_sample_rate: number | null;
|
||||||
@@ -202,12 +203,8 @@
|
|||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
if (e.key === 'Escape') {
|
// Delegate to keybinding manager (global shortcuts handle Escape and Space)
|
||||||
onClose();
|
keybindingHandler(e);
|
||||||
} else if (e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onTogglePlay();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
import LyricsLines from './lyrics/LyricsLines.svelte';
|
import LyricsLines from './lyrics/LyricsLines.svelte';
|
||||||
import { startActiveLineUpdates, stopActiveLineUpdates } from '$lib/stores/lyricsStore';
|
import { startActiveLineUpdates, stopActiveLineUpdates } from '$lib/stores/lyricsStore';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import {
|
||||||
|
registerAction,
|
||||||
|
unregisterAction,
|
||||||
|
handleKeydown as keybindingHandler
|
||||||
|
} from '$lib/stores/keybindingsStore';
|
||||||
|
|
||||||
interface LyricsLine {
|
interface LyricsLine {
|
||||||
text: string;
|
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 showControls = $state(true);
|
||||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let progressRef: HTMLDivElement | null = $state(null);
|
let progressRef: HTMLDivElement | null = $state(null);
|
||||||
@@ -135,12 +157,8 @@
|
|||||||
|
|
||||||
function handleKeyPress(e: KeyboardEvent) {
|
function handleKeyPress(e: KeyboardEvent) {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
if (e.key === 'Escape') {
|
// Delegate to keybinding manager with focus mode context
|
||||||
onClose();
|
keybindingHandler(e, { focusMode: true });
|
||||||
} else if (e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onTogglePlay();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseMove() {
|
function handleMouseMove() {
|
||||||
|
|||||||
@@ -970,5 +970,44 @@
|
|||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"clearFilter": "Clear filter"
|
"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",
|
"collapse": "Contraer",
|
||||||
"expand": "Expandir",
|
"expand": "Expandir",
|
||||||
"clearFilter": "Limpiar filtro"
|
"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
|
shouldShowTitleBar
|
||||||
} from '$lib/stores/titleBarStore';
|
} from '$lib/stores/titleBarStore';
|
||||||
|
|
||||||
|
// Keybindings system
|
||||||
|
import {
|
||||||
|
registerAction,
|
||||||
|
unregisterAll,
|
||||||
|
handleKeydown as keybindingHandler
|
||||||
|
} from '$lib/stores/keybindingsStore';
|
||||||
|
|
||||||
// Auth state management
|
// Auth state management
|
||||||
import {
|
import {
|
||||||
subscribe as subscribeAuth,
|
subscribe as subscribeAuth,
|
||||||
@@ -2337,48 +2344,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard Shortcuts
|
// Keyboard Shortcuts - delegated to keybindings system
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (!isLoggedIn) return;
|
if (!isLoggedIn) return;
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (e.key) {
|
// Delegate to keybinding manager (handles input target filtering internally)
|
||||||
case ' ':
|
keybindingHandler(e);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playback state polling - managed by playerStore
|
// Playback state polling - managed by playerStore
|
||||||
@@ -2453,6 +2424,16 @@
|
|||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
document.addEventListener('keydown', handleKeydown);
|
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
|
// Session save on window close/hide
|
||||||
const handleBeforeUnload = () => {
|
const handleBeforeUnload = () => {
|
||||||
saveSessionBeforeClose();
|
saveSessionBeforeClose();
|
||||||
@@ -2773,6 +2754,7 @@
|
|||||||
saveSessionBeforeClose();
|
saveSessionBeforeClose();
|
||||||
cleanupBootstrap();
|
cleanupBootstrap();
|
||||||
document.removeEventListener('keydown', handleKeydown);
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
|
unregisterAll(); // Cleanup keybinding actions
|
||||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
stopOfflineCacheEventListeners();
|
stopOfflineCacheEventListeners();
|
||||||
|
|||||||
Reference in new issue
Block a user