mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
+14
-7
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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('+');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user