diff --git a/frontend/index.ts b/frontend/index.ts index 49aafe5..174861b 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -13,7 +13,6 @@ import '@components/genres-view/genres-view.ts'; import '@components/genre-details/genre-details.ts'; import '@components/search-bar/search-bar.ts'; import '@components/track-details/track-details.ts'; -import type { SearchBar } from '@components/search-bar/search-bar.ts'; import '@awesome.me/webawesome/dist/styles/themes/default.css'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js'; @@ -24,6 +23,9 @@ import * as Queue from '@go/queue/Queue'; // Importing the theme store triggers initialization: it fetches the saved // theme from the backend and applies CSS custom properties to :root. import '@store/theme-store'; +// Importing the keyboard shortcut service triggers initialization: +// registers the document keydown listener for global shortcuts. +import './src/services/keyboard-shortcut-service'; import { hasTrackPayload, getDragPayload, @@ -150,23 +152,6 @@ if (queueButton && queuePanel) { ); } -// --------------------------------------------------------------- -// Ctrl+F to focus the search bar -// --------------------------------------------------------------- - -document.addEventListener('keydown', (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'f') { - const bar = document.querySelector( - 'search-bar', - ) as SearchBar | null; - - if (bar && !bar.hasAttribute('hidden')) { - e.preventDefault(); - bar.focusInput(); - } - } -}); - // --------------------------------------------------------------- // Request current state from the backend // --------------------------------------------------------------- diff --git a/frontend/src/services/keyboard-shortcut-service.ts b/frontend/src/services/keyboard-shortcut-service.ts new file mode 100644 index 0000000..85d0103 --- /dev/null +++ b/frontend/src/services/keyboard-shortcut-service.ts @@ -0,0 +1,355 @@ +/** + * Keyboard Shortcut Service + * + * Singleton that listens for keydown events on document and dispatches + * shortcut actions based on the current scope. Handles: + * + * - Key string normalization (modifiers in canonical order) + * - Shadow DOM active element resolution + * - Text input suppression (only Escape passes through) + * - Scope resolution: text-input > panel-specific > global + * - Action dispatch to player/queue/nav stores + */ +import { shortcutsStore } from '@store/shortcuts-store'; +import { playerStore } from '@store/player-store'; +import { queueStore } from '@store/queue-store'; +import * as Player from '@go/player/Player'; +import type { SearchBar } from '@components/search-bar/search-bar'; + +// =================================================================== +// KEY STRING UTILITIES +// =================================================================== + +/** Map of KeyboardEvent.key values to canonical key names. */ +const KEY_ALIASES: Record = { + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + ' ': 'Space', +}; + +/** Keys that are modifier-only presses and should be ignored. */ +const MODIFIER_KEYS = new Set([ + 'Control', + 'Alt', + 'Shift', + 'Meta', +]); + +/** + * Build a canonical key string from a KeyboardEvent. + * + * Format: `[Ctrl+][Alt+][Shift+]Key` + * Examples: "Ctrl+F", "Space", "Shift+Delete", "N" + * + * Exported for reuse by the shortcut-capture widget (Plan 04). + */ +export function buildKeyString(e: KeyboardEvent): string { + // Skip bare modifier presses. + if (MODIFIER_KEYS.has(e.key)) return ''; + + const parts: string[] = []; + + // Modifiers in fixed order. Treat Meta (Cmd on Mac) as Ctrl. + if (e.ctrlKey || e.metaKey) parts.push('Ctrl'); + if (e.altKey) parts.push('Alt'); + if (e.shiftKey) parts.push('Shift'); + + // Normalize the key name. + let key = KEY_ALIASES[e.key] ?? e.key; + + // Single printable characters → uppercase. + if (key.length === 1) { + key = key.toUpperCase(); + } + + parts.push(key); + + return parts.join('+'); +} + +// =================================================================== +// SHADOW DOM HELPERS +// =================================================================== + +/** + * Walk the shadow DOM active element chain to find the deepest + * focused element. Necessary because `document.activeElement` + * stops at the shadow host boundary. + */ +function getDeepActiveElement(): Element | null { + let el = document.activeElement; + + while (el?.shadowRoot?.activeElement) { + el = el.shadowRoot.activeElement; + } + + return el; +} + +/** Text input types that should suppress shortcuts. */ +const TEXT_INPUT_TYPES = new Set([ + 'text', + 'search', + 'url', + 'email', + 'password', + 'number', + 'tel', +]); + +/** + * Check whether the deepest active element is a text input. + */ +function isTextInputFocused(el: Element | null): boolean { + if (!el) return false; + + const tag = el.tagName.toUpperCase(); + + if (tag === 'TEXTAREA') return true; + + if (tag === 'INPUT') { + const inputType = ( + el as HTMLInputElement + ).type.toLowerCase(); + + return TEXT_INPUT_TYPES.has(inputType) || inputType === ''; + } + + if ((el as HTMLElement).isContentEditable) return true; + + return false; +} + +// =================================================================== +// SCOPE RESOLUTION +// =================================================================== + +type ShortcutScope = + | 'text-input' + | `panel:${string}` + | 'global'; + +/** + * Resolve the current shortcut scope based on the focused element. + * + * Priority: text-input > panel-specific > global + */ +function resolveScope(deepEl: Element | null): ShortcutScope { + if (isTextInputFocused(deepEl)) return 'text-input'; + + // Walk up from the deep active element looking for a + // `data-shortcut-scope` attribute on any ancestor. + let walker: Element | null = deepEl; + + while (walker) { + const scope = walker.getAttribute?.('data-shortcut-scope'); + + if (scope) return `panel:${scope}` as ShortcutScope; + + // Cross shadow boundaries: if we're at a shadow root host, + // continue walking from the host element. + const parent = + walker.parentElement ?? + (walker.getRootNode() as ShadowRoot).host; + + if (parent === walker) break; + + walker = parent ?? null; + } + + return 'global'; +} + +// =================================================================== +// VOLUME / SEEK STEP SIZES +// =================================================================== + +const VOLUME_STEP = 5; +const SEEK_STEP = 5; // seconds + +// =================================================================== +// ACTION DISPATCH +// =================================================================== + +/** + * Dispatch the action associated with an action ID. + * + * Each case maps an action string to the appropriate store method + * or Wails binding call. + */ +async function dispatch(action: string): Promise { + switch (action) { + // Player controls + case 'player.playPause': + if (playerStore.getState().isPlaying) { + playerStore.pause(); + } else { + queueStore.play(); + } + + break; + + case 'player.next': + queueStore.next(); + break; + + case 'player.previous': + queueStore.previous(); + break; + + case 'player.volumeUp': + await Player.ChangeVolume(VOLUME_STEP); + break; + + case 'player.volumeDown': + await Player.ChangeVolume(-VOLUME_STEP); + break; + + case 'player.seekForward': { + const pos = await Player.CurrentPositionSeconds(); + const len = await Player.TrackLengthInSeconds(); + const target = Math.min(pos + SEEK_STEP, len); + + await Player.Seek(target); + break; + } + + case 'player.seekBack': { + const pos = await Player.CurrentPositionSeconds(); + const target = Math.max(pos - SEEK_STEP, 0); + + await Player.Seek(target); + break; + } + + case 'player.shuffle': + queueStore.toggleShuffle(); + break; + + case 'player.repeat': + queueStore.cycleRepeat(); + break; + + case 'player.mute': + await Player.MuteToggle(); + break; + + // Navigation + case 'nav.search': + case 'nav.searchAlt': { + const bar = document.querySelector( + 'search-bar', + ) as SearchBar | null; + + if (bar && !bar.hasAttribute('hidden')) { + bar.focusInput(); + } + + break; + } + + case 'nav.queue': { + const queuePanel = document.getElementById( + 'queue-panel', + ) as HTMLElement | null; + + if (queuePanel) { + const isOpen = queuePanel.hasAttribute('open'); + + if (isOpen) { + queuePanel.removeAttribute('open'); + } else { + queuePanel.setAttribute('open', ''); + } + } + + break; + } + + // App actions + case 'app.selectAll': + document.execCommand('selectAll'); + break; + + // Panel-specific: track list + case 'tracklist.play': + document.dispatchEvent( + new CustomEvent('shortcut:tracklist-play'), + ); + break; + + case 'tracklist.delete': + document.dispatchEvent( + new CustomEvent('shortcut:tracklist-delete'), + ); + break; + + default: + // Unknown action — silently ignore. + break; + } +} + +// =================================================================== +// SERVICE +// =================================================================== + +/** + * KeyboardShortcutService is a singleton that intercepts keydown + * events on the document and dispatches matched shortcut actions. + */ +class KeyboardShortcutService { + constructor() { + document.addEventListener('keydown', this.handleKeydown); + } + + private handleKeydown = (e: KeyboardEvent): void => { + const deepEl = getDeepActiveElement(); + const scope = resolveScope(deepEl); + + // In text inputs, only allow Escape (to blur the input). + if (scope === 'text-input') { + if (e.key === 'Escape' && deepEl) { + (deepEl as HTMLElement).blur(); + e.preventDefault(); + } + + // Suppress all other shortcuts in text inputs. + return; + } + + // Build the canonical key string. + const keyStr = buildKeyString(e); + + if (!keyStr) return; + + // Look up the action: panel-specific first, then global. + const action = shortcutsStore.getActionForKey( + keyStr, + scope, + ); + + if (!action) return; + + // Found a match — prevent default and dispatch. + e.preventDefault(); + void dispatch(action); + }; + + /** Remove the event listener (for cleanup if ever needed). */ + destroy(): void { + document.removeEventListener( + 'keydown', + this.handleKeydown, + ); + } +} + +// Singleton instance — instantiation registers the keydown listener. +export const keyboardShortcutService = + new KeyboardShortcutService(); + +// Re-export for the shortcut capture widget. +export type { KeyboardShortcutService }; diff --git a/frontend/src/store/controllers/shortcuts-controller.ts b/frontend/src/store/controllers/shortcuts-controller.ts new file mode 100644 index 0000000..f425550 --- /dev/null +++ b/frontend/src/store/controllers/shortcuts-controller.ts @@ -0,0 +1,60 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type { ShortcutsState } from '../shortcuts-store'; +import { shortcutsStore } from '../shortcuts-store'; + +/** + * ShortcutsController connects a Lit component to the ShortcutsStore. + * + * Use this controller in components that need to read or change + * shortcut bindings (e.g. the shortcut settings UI in config-page). + */ +export class ShortcutsController implements ReactiveController { + private host: ReactiveControllerHost; + private unsubscribe?: () => void; + + constructor(host: ReactiveControllerHost) { + this.host = host; + host.addController(this); + } + + // =================================================================== + // LIFECYCLE HOOKS + // =================================================================== + + hostConnected(): void { + this.unsubscribe = shortcutsStore.subscribe(() => { + this.host.requestUpdate(); + }); + } + + hostDisconnected(): void { + this.unsubscribe?.(); + } + + // =================================================================== + // STATE ACCESSORS + // =================================================================== + + get state(): Readonly { + return shortcutsStore.getState(); + } + + get bindings(): Map { + return shortcutsStore.getBindings(); + } + + // =================================================================== + // ACTIONS + // =================================================================== + + async updateBinding( + action: string, + key: string, + ): Promise { + await shortcutsStore.updateBinding(action, key); + } + + async resetAll(): Promise { + await shortcutsStore.resetAll(); + } +} diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index 92deb27..2f8fc2a 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -9,3 +9,6 @@ export type { ThemeState, BackgroundShade } from './theme-store'; export { ThemeController } from './controllers/theme-controller'; export { searchStore } from './search-store'; export { SearchController } from './controllers/search-controller'; +export { shortcutsStore } from './shortcuts-store'; +export type { ShortcutsState } from './shortcuts-store'; +export { ShortcutsController } from './controllers/shortcuts-controller'; diff --git a/frontend/src/store/shortcuts-store.ts b/frontend/src/store/shortcuts-store.ts new file mode 100644 index 0000000..c032e32 --- /dev/null +++ b/frontend/src/store/shortcuts-store.ts @@ -0,0 +1,189 @@ +import { EventsOn } from '@runtime/runtime'; +import { + GetShortcuts, + SetShortcut, + SetShortcuts, + ResetShortcuts, +} from '@go/config/Config'; +import { Events } from '../events'; + +export interface ShortcutsState { + bindings: Map; // action → key combo + loaded: boolean; +} + +type Subscriber = () => void; + +/** Panel scope prefixes for scope-aware lookups. */ +const PANEL_PREFIXES = ['tracklist.'] as const; + +/** + * ShortcutsStore holds the current keyboard shortcut bindings + * and syncs them with the Go backend via Wails bindings. + */ +class ShortcutsStore { + private state: ShortcutsState = { + bindings: new Map(), + loaded: false, + }; + + private subscribers = new Set(); + private notifyQueued = false; + + constructor() { + this.initializeEventListeners(); + this.loadFromBackend(); + } + + // =================================================================== + // WAILS EVENT BRIDGE + // =================================================================== + + private initializeEventListeners(): void { + EventsOn( + Events.ShortcutsConfigChanged, + (data: Record) => { + this.state = { + bindings: new Map(Object.entries(data)), + loaded: true, + }; + this.notify(); + }, + ); + } + + private async loadFromBackend(): Promise { + try { + const raw = await GetShortcuts(); + this.state = { + bindings: new Map(Object.entries(raw)), + loaded: true, + }; + this.notify(); + } catch { + // Use empty bindings on failure; they'll be populated + // when the backend pushes a ShortcutsConfigChanged event. + this.state.loaded = true; + this.notify(); + } + } + + // =================================================================== + // STATE ACCESS + // =================================================================== + + getState(): Readonly { + return this.state; + } + + /** Returns the full action→key bindings map. */ + getBindings(): Map { + return this.state.bindings; + } + + /** Look up the key combo for a given action. */ + getKeyForAction(action: string): string | undefined { + return this.state.bindings.get(action); + } + + /** + * Reverse lookup: find the action bound to a given key combo. + * If a panel scope is provided, panel-specific bindings are checked + * first, then global bindings. + */ + getActionForKey( + key: string, + scope?: string, + ): string | undefined { + // If we have a panel scope, check panel-specific bindings first. + if (scope && scope.startsWith('panel:')) { + const panelPrefix = scope.replace('panel:', '') + '.'; + + for (const [action, boundKey] of this.state.bindings) { + if ( + action.startsWith(panelPrefix) && + boundKey === key + ) { + return action; + } + } + } + + // Fall back to global (non-panel) bindings. + for (const [action, boundKey] of this.state.bindings) { + if (boundKey !== key) continue; + + const isPanel = PANEL_PREFIXES.some((p) => + action.startsWith(p), + ); + + if (!isPanel) return action; + } + + return undefined; + } + + /** + * Check for binding conflicts. Returns the conflicting binding + * or null if no conflict. + */ + findConflict( + key: string, + _scope: string, + excludeAction: string, + ): { action: string; key: string } | null { + for (const [action, boundKey] of this.state.bindings) { + if (action === excludeAction) continue; + if (boundKey === key) return { action, key: boundKey }; + } + + return null; + } + + // =================================================================== + // ACTIONS + // =================================================================== + + /** Update a single shortcut binding. */ + async updateBinding( + action: string, + key: string, + ): Promise { + await SetShortcut(action, key); + } + + /** Replace all bindings at once. */ + async setAll( + bindings: Record, + ): Promise { + await SetShortcuts(bindings); + } + + /** Reset all shortcuts to defaults. */ + async resetAll(): Promise { + await ResetShortcuts(); + } + + // =================================================================== + // SUBSCRIPTION SYSTEM + // =================================================================== + + subscribe(callback: Subscriber): () => void { + this.subscribers.add(callback); + + return () => this.subscribers.delete(callback); + } + + private notify(): void { + if (this.notifyQueued) return; + + this.notifyQueued = true; + queueMicrotask(() => { + this.notifyQueued = false; + this.subscribers.forEach((cb) => cb()); + }); + } +} + +// Singleton instance. +export const shortcutsStore = new ShortcutsStore();