import { create } from 'zustand'; import { persist } from 'zustand/middleware'; export type KeyAction = | 'play-pause' | 'next' | 'prev' | 'volume-up' | 'volume-down' | 'seek-forward' | 'seek-backward' | 'toggle-queue' | 'open-folder-browser' | 'fullscreen-player' | 'native-fullscreen' | 'open-mini-player'; /** Physical keys only — ignore for binding capture */ export const MODIFIER_KEY_CODES = [ 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'OSLeft', 'OSRight', ] as const; // key = action, value = plain e.code ("Space", "KeyN") or chord "ctrl+shift+KeyN", null = unbound export type Bindings = Record; export const DEFAULT_BINDINGS: Bindings = { 'play-pause': 'Space', 'next': null, 'prev': null, 'volume-up': null, 'volume-down': null, 'seek-forward': null, 'seek-backward': null, 'toggle-queue': null, 'open-folder-browser': null, 'fullscreen-player': null, 'native-fullscreen': 'F11', 'open-mini-player': null, }; interface KeybindingsState { bindings: Bindings; setBinding: (action: KeyAction, binding: string | null) => void; resetToDefaults: () => void; } /** Build persisted binding from a keydown: single key or modifier+key chord. */ export function buildInAppBinding(e: KeyboardEvent): string | null { if ((MODIFIER_KEY_CODES as readonly string[]).includes(e.code)) return null; if (!e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) return e.code; const mods: string[] = []; if (e.ctrlKey) mods.push('ctrl'); if (e.altKey) mods.push('alt'); if (e.shiftKey) mods.push('shift'); if (e.metaKey) mods.push('super'); return [...mods, e.code].join('+'); } /** True if the event matches a stored binding (legacy plain codes = no modifiers). */ export function matchInAppBinding(e: KeyboardEvent, binding: string | null): boolean { if (!binding) return false; if (!binding.includes('+')) { return ( e.code === binding && !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey ); } const parts = binding.split('+'); const code = parts[parts.length - 1]; if (e.code !== code) return false; const mods = new Set(parts.slice(0, -1)); return ( e.ctrlKey === mods.has('ctrl') && e.altKey === mods.has('alt') && e.shiftKey === mods.has('shift') && e.metaKey === mods.has('super') ); } export const useKeybindingsStore = create()( persist( (set) => ({ bindings: { ...DEFAULT_BINDINGS }, setBinding: (action, binding) => set(s => ({ bindings: { ...s.bindings, [action]: binding } })), resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }), }), { name: 'psysonic_keybindings' } ) ); /** Format an e.code value into a human-readable label. */ export function formatKeyCode(code: string): string { if (code === 'Space') return 'Space'; if (code === 'ArrowUp') return '↑'; if (code === 'ArrowDown') return '↓'; if (code === 'ArrowLeft') return '←'; if (code === 'ArrowRight') return '→'; if (code === 'Escape') return 'Esc'; if (code === 'Enter') return 'Enter'; if (code === 'Backspace') return '⌫'; if (code === 'Tab') return 'Tab'; if (code === 'Delete') return 'Del'; if (code === 'Home') return 'Home'; if (code === 'End') return 'End'; if (code === 'PageUp') return 'PgUp'; if (code === 'PageDown') return 'PgDn'; if (/^F\d+$/.test(code)) return code; if (code.startsWith('Key')) return code.slice(3).toUpperCase(); if (code.startsWith('Digit')) return code.slice(5); if (code === 'Minus') return '-'; if (code === 'Equal') return '='; if (code === 'BracketLeft') return '['; if (code === 'BracketRight') return ']'; if (code === 'Semicolon') return ';'; if (code === 'Quote') return "'"; if (code === 'Backslash') return '\\'; if (code === 'Comma') return ','; if (code === 'Period') return '.'; if (code === 'Slash') return '/'; return code; } /** Label for settings UI: plain key or chord (same string shape as global shortcuts). */ export function formatBinding(binding: string): string { if (!binding.includes('+')) return formatKeyCode(binding); return binding.split('+').map(part => { if (part === 'ctrl') return 'Ctrl'; if (part === 'alt') return 'Alt'; if (part === 'shift') return 'Shift'; if (part === 'super' || part === 'meta') return 'Super'; return formatKeyCode(part); }).join('+'); }