feat(keybindings): in-app modifier chords; fix seek shortcut units

Persist chords as ctrl/alt/shift/super plus key code; legacy single-key
bindings still match without modifiers. Settings capture uses buildInAppBinding;
App uses matchInAppBinding and skips chords also registered as global shortcuts.
Share MODIFIER_KEY_CODES and formatBinding with global shortcut formatting.

Fix seek-forward/backward hotkeys: seek() expects 0-1 progress, not seconds.
This commit is contained in:
Maxim Isaev
2026-04-13 01:01:50 +03:00
parent 805b6bf163
commit 6439200e95
4 changed files with 84 additions and 33 deletions
+3 -14
View File
@@ -1,18 +1,13 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { formatKeyCode } from './keybindingsStore';
import { MODIFIER_KEY_CODES, formatBinding } from './keybindingsStore';
export type GlobalAction = 'play-pause' | 'next' | 'prev' | 'volume-up' | 'volume-down';
const MODIFIER_CODES = [
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight',
'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'OSLeft', 'OSRight',
];
/** Build a Tauri-compatible shortcut string from a KeyboardEvent, or null if invalid. */
export function buildGlobalShortcut(e: KeyboardEvent): string | null {
if (MODIFIER_CODES.includes(e.code)) return null;
if ((MODIFIER_KEY_CODES as readonly string[]).includes(e.code)) return null;
// Require at least Ctrl, Alt, or Meta — Shift alone is too invasive
if (!e.ctrlKey && !e.altKey && !e.metaKey) return null;
@@ -27,13 +22,7 @@ export function buildGlobalShortcut(e: KeyboardEvent): string | null {
/** Human-readable label for a stored shortcut string, e.g. "ctrl+alt+ArrowRight" → "Ctrl+Alt+→". */
export function formatGlobalShortcut(shortcut: string): string {
return shortcut.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('+');
return formatBinding(shortcut);
}
// Module-level guard — prevents double-registration from React StrictMode's
+55 -4
View File
@@ -14,7 +14,13 @@ export type KeyAction =
| 'fullscreen-player'
| 'native-fullscreen';
// key = action, value = e.code string (e.g. 'Space', 'KeyN', 'F11') or null for unbound
/** 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 = {
@@ -33,16 +39,49 @@ export const DEFAULT_BINDINGS: Bindings = {
interface KeybindingsState {
bindings: Bindings;
setBinding: (action: KeyAction, code: string | null) => void;
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, code) =>
set(s => ({ bindings: { ...s.bindings, [action]: code } })),
setBinding: (action, binding) =>
set(s => ({ bindings: { ...s.bindings, [action]: binding } })),
resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }),
}),
{ name: 'psysonic_keybindings' }
@@ -80,3 +119,15 @@ export function formatKeyCode(code: string): string {
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('+');
}