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

feat(keybindings): in-app modifier chords; fix seek shortcut units
This commit is contained in:
Psychotoxical
2026-04-13 17:35:45 +02:00
committed by GitHub
4 changed files with 84 additions and 33 deletions
+14 -7
View File
@@ -66,7 +66,7 @@ import { useThemeStore } from './store/themeStore';
import { useThemeScheduler } from './hooks/useThemeScheduler'; import { useThemeScheduler } from './hooks/useThemeScheduler';
import { useFontStore } from './store/fontStore'; import { useFontStore } from './store/fontStore';
import { useEqStore } from './store/eqStore'; import { useEqStore } from './store/eqStore';
import { useKeybindingsStore } from './store/keybindingsStore'; import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './store/keybindingsStore';
import { useGlobalShortcutsStore } from './store/globalShortcutsStore'; import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
import { useZipDownloadStore } from './store/zipDownloadStore'; import { useZipDownloadStore } from './store/zipDownloadStore';
import ZipDownloadOverlay from './components/ZipDownloadOverlay'; import ZipDownloadOverlay from './components/ZipDownloadOverlay';
@@ -457,15 +457,18 @@ function TauriEventBridge() {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName; const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
// Global shortcuts use modifier combos — skip in-app bindings for those
// (X11 GrabModeAsync delivers the key to both the grabber and the focused WebView) const chord = buildInAppBinding(e);
if (e.ctrlKey || e.altKey || e.metaKey) return; if (chord) {
const registered = Object.values(useGlobalShortcutsStore.getState().shortcuts);
if (registered.includes(chord)) return;
}
const { bindings } = useKeybindingsStore.getState(); const { bindings } = useKeybindingsStore.getState();
const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState(); const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState();
const action = (Object.entries(bindings) as [string, string | null][]) const action = (Object.entries(bindings) as [string, string | null][])
.find(([, code]) => code === e.code)?.[0]; .find(([, b]) => matchInAppBinding(e, b))?.[0];
if (!action) return; if (!action) return;
e.preventDefault(); e.preventDefault();
@@ -478,12 +481,16 @@ function TauriEventBridge() {
case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break; case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break;
case 'seek-forward': { case 'seek-forward': {
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
seek(Math.min(s.currentTrack?.duration ?? 0, s.currentTime + 10)); const dur = s.currentTrack?.duration ?? 0;
if (!dur) break;
seek(Math.min(1, (s.currentTime + 10) / dur));
break; break;
} }
case 'seek-backward': { case 'seek-backward': {
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
seek(Math.max(0, s.currentTime - 10)); const dur = s.currentTrack?.duration ?? 0;
if (!dur) break;
seek(Math.max(0, (s.currentTime - 10) / dur));
break; break;
} }
case 'toggle-queue': toggleQueue(); break; case 'toggle-queue': toggleQueue(); break;
+12 -8
View File
@@ -25,7 +25,7 @@ import { SeekbarPreview } from '../components/WaveformSeek';
import { IS_LINUX } from '../utils/platform'; import { IS_LINUX } from '../utils/platform';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
import { useFontStore, FontId } from '../store/fontStore'; import { useFontStore, FontId } from '../store/fontStore';
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore'; import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore'; import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore'; import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
import { useHomeStore, HomeSectionId } from '../store/homeStore'; import { useHomeStore, HomeSectionId } from '../store/homeStore';
@@ -1577,13 +1577,17 @@ export default function Settings() {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (e.code !== 'Escape') { if (e.code === 'Escape') {
// unbind any existing action with this key first setListeningFor(null);
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][]) window.removeEventListener('keydown', handler, true);
.find(([, c]) => c === e.code)?.[0]; return;
if (existing && existing !== action) kb.setBinding(existing, null);
kb.setBinding(action, e.code);
} }
const chord = buildInAppBinding(e);
if (!chord) return;
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
.find(([, c]) => c === chord)?.[0];
if (existing && existing !== action) kb.setBinding(existing, null);
kb.setBinding(action, chord);
setListeningFor(null); setListeningFor(null);
window.removeEventListener('keydown', handler, true); window.removeEventListener('keydown', handler, true);
}; };
@@ -1599,7 +1603,7 @@ export default function Settings() {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
{isListening ? t('settings.shortcutListening') : bound ? formatKeyCode(bound) : t('settings.shortcutUnbound')} {isListening ? t('settings.shortcutListening') : bound ? formatBinding(bound) : t('settings.shortcutUnbound')}
</button> </button>
{bound && !isListening && ( {bound && !isListening && (
<button <button
+3 -14
View File
@@ -1,18 +1,13 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core'; 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'; 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. */ /** Build a Tauri-compatible shortcut string from a KeyboardEvent, or null if invalid. */
export function buildGlobalShortcut(e: KeyboardEvent): string | null { 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 // Require at least Ctrl, Alt, or Meta — Shift alone is too invasive
if (!e.ctrlKey && !e.altKey && !e.metaKey) return null; 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+→". */ /** Human-readable label for a stored shortcut string, e.g. "ctrl+alt+ArrowRight" → "Ctrl+Alt+→". */
export function formatGlobalShortcut(shortcut: string): string { export function formatGlobalShortcut(shortcut: string): string {
return shortcut.split('+').map(part => { return formatBinding(shortcut);
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('+');
} }
// Module-level guard — prevents double-registration from React StrictMode's // Module-level guard — prevents double-registration from React StrictMode's
+55 -4
View File
@@ -14,7 +14,13 @@ export type KeyAction =
| 'fullscreen-player' | 'fullscreen-player'
| 'native-fullscreen'; | '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 type Bindings = Record<KeyAction, string | null>;
export const DEFAULT_BINDINGS: Bindings = { export const DEFAULT_BINDINGS: Bindings = {
@@ -33,16 +39,49 @@ export const DEFAULT_BINDINGS: Bindings = {
interface KeybindingsState { interface KeybindingsState {
bindings: Bindings; bindings: Bindings;
setBinding: (action: KeyAction, code: string | null) => void; setBinding: (action: KeyAction, binding: string | null) => void;
resetToDefaults: () => 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>()( export const useKeybindingsStore = create<KeybindingsState>()(
persist( persist(
(set) => ({ (set) => ({
bindings: { ...DEFAULT_BINDINGS }, bindings: { ...DEFAULT_BINDINGS },
setBinding: (action, code) => setBinding: (action, binding) =>
set(s => ({ bindings: { ...s.bindings, [action]: code } })), set(s => ({ bindings: { ...s.bindings, [action]: binding } })),
resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }), resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }),
}), }),
{ name: 'psysonic_keybindings' } { name: 'psysonic_keybindings' }
@@ -80,3 +119,15 @@ export function formatKeyCode(code: string): string {
if (code === 'Slash') return '/'; if (code === 'Slash') return '/';
return code; 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('+');
}