mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
60da17f7cc
Adds an 'open-mini-player' entry to the in-app keybindings (Settings → Shortcuts), default unbound. Pressing the chord from the main window opens the mini and minimises main; pressing it from the mini hides the mini and restores main — both paths invoke open_mini_player which already handles the toggle. The mini window has its own keydown listener (same pattern as Space / arrows for play/skip) that reads the binding from useKeybindingsStore. Binding changes in main propagate to an open mini through the existing storage-event sync (now also covers psysonic_keybindings) so the user doesn't need to restart the mini after rebinding. Localized in all 8 supported locales. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
4.5 KiB
TypeScript
136 lines
4.5 KiB
TypeScript
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<KeyAction, string | null>;
|
|
|
|
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<KeybindingsState>()(
|
|
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('+');
|
|
}
|