feat(shortcuts): action registry + dynamic CLI help + new input targets (#435)

* feat(shortcuts): unify action-driven shortcut and CLI routing

Centralize shortcut action metadata in one TypeScript registry and route keyboard, global shortcut, mini-window, and CLI inputs through shared runtime handlers.
Keep CLI as an abstract transport layer by emitting player-command payloads without depending on shortcut definitions.

* feat(shortcuts): generate CLI action help from shortcut registry

Move no-arg player commands and their descriptions into the central action registry so CLI parsing and --player help are derived dynamically from one source of truth. Also route runtime action execution through the registry and remove duplicated shortcut runtime handling.

* feat(shortcuts): add new input actions and hidden F1 help binding

Add the requested input actions (search, advanced search, sidebar, mute, equalizer, repeat, now playing, lyrics, favorite current track) to the central shortcut action registry and wire runtime handlers for sidebar/equalizer toggles. Keep Help bound to F1 by default while hiding it from Settings input lists, and backfill persisted keybindings with new defaults so F1 works for existing users.

Requested by @zunoz (Discord community).
This commit is contained in:
cucadmuh
2026-05-03 00:37:43 +03:00
committed by GitHub
parent 402e288a24
commit 1e05180418
11 changed files with 820 additions and 335 deletions
+51 -185
View File
@@ -81,17 +81,12 @@ import { useAuthStore } from './store/authStore';
import {
getMusicFolders,
getSimilarSongs,
getSong,
probeEntityRatingSupport,
search as subsonicSearch,
setRating,
star,
unstar,
} from './api/subsonic';
import { useOfflineStore } from './store/offlineStore';
import { initHotCachePrefetch } from './hotCachePrefetch';
import i18n from './i18n';
import { playByOpaqueId } from './utils/playByOpaqueId';
import { switchActiveServer } from './utils/switchActiveServer';
import {
usePlayerStore,
@@ -104,15 +99,15 @@ import { useThemeStore } from './store/themeStore';
import { useThemeScheduler } from './hooks/useThemeScheduler';
import { useFontStore } from './store/fontStore';
import { useEqStore } from './store/eqStore';
import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './store/keybindingsStore';
import { useKeybindingsStore, buildInAppBinding } from './store/keybindingsStore';
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
import { useZipDownloadStore } from './store/zipDownloadStore';
import { usePreviewStore } from './store/previewStore';
import { DEFAULT_IN_APP_BINDINGS, canRunShortcutActionInMiniWindow, executeCliPlayerCommand, executeRuntimeAction, isGlobalShortcutActionId, isShortcutAction } from './config/shortcutActions';
import { matchInAppShortcutAction } from './shortcuts/runtime';
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
import PasteClipboardHandler from './components/PasteClipboardHandler';
/** Volume before last `psysonic --player mute` (CLI only; in-memory). */
let cliPremuteVolume: number | null = null;
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed';
function readInitialSidebarCollapsed(): boolean {
@@ -384,6 +379,12 @@ function AppShell() {
setIsSidebarCollapsed(collapsed);
}, []);
useEffect(() => {
const onToggleSidebar = () => setSidebarCollapsed(!isSidebarCollapsed);
window.addEventListener('psy:toggle-sidebar', onToggleSidebar);
return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar);
}, [isSidebarCollapsed, setSidebarCollapsed]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (isDraggingQueue) {
const newWidth = Math.max(310, Math.min(window.innerWidth - e.clientX, 500));
@@ -758,9 +759,6 @@ function AppShell() {
// Media key + tray event handler
function TauriEventBridge() {
const navigate = useNavigate();
const togglePlay = usePlayerStore(s => s.togglePlay);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
// ZIP download progress events from Rust
useEffect(() => {
@@ -983,95 +981,8 @@ function TauriEventBridge() {
}).catch(() => {});
}
}).then(u => unsubs.push(u));
listen<string>('cli:play-id', async e => {
const id = typeof e.payload === 'string' ? e.payload.trim() : '';
if (!id) return;
try {
await playByOpaqueId(id);
} catch (err) {
console.error('CLI play failed', err);
const notFound = err instanceof Error && err.message === 'play_by_id_not_found';
showToast(
i18n.t('contextMenu.cliPlayIdNotFound', {
defaultValue: notFound
? 'No song, album, or artist matches this id.'
: 'Could not start playback.',
}),
5000,
'error',
);
}
}).then(u => unsubs.push(u));
listen('cli:shuffle-queue', () => {
usePlayerStore.getState().shuffleQueue();
}).then(u => unsubs.push(u));
listen<string>('cli:set-repeat', e => {
const m = typeof e.payload === 'string' ? e.payload : '';
const mode = m === 'all' ? 'all' : m === 'one' ? 'one' : 'off';
usePlayerStore.setState({ repeatMode: mode });
}).then(u => unsubs.push(u));
listen('cli:mute', () => {
const { volume, setVolume } = usePlayerStore.getState();
if (volume > 0) cliPremuteVolume = volume;
setVolume(0);
}).then(u => unsubs.push(u));
listen('cli:unmute', () => {
const restore = cliPremuteVolume ?? 0.8;
cliPremuteVolume = null;
usePlayerStore.getState().setVolume(restore);
}).then(u => unsubs.push(u));
listen<boolean>('cli:star-current', async e => {
const want = e.payload === true;
const track = usePlayerStore.getState().currentTrack;
if (!track) {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
try {
if (want) {
await star(track.id, 'song');
usePlayerStore.getState().setStarredOverride(track.id, true);
} else {
await unstar(track.id, 'song');
usePlayerStore.getState().setStarredOverride(track.id, false);
}
} catch (err) {
console.error('CLI star failed', err);
showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error');
}
}).then(u => unsubs.push(u));
listen<number>('cli:set-rating-current', async e => {
const stars = e.payload;
if (typeof stars !== 'number' || Number.isNaN(stars) || stars < 0 || stars > 5) return;
const track = usePlayerStore.getState().currentTrack;
if (!track) {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
try {
await setRating(track.id, stars);
usePlayerStore.getState().setUserRatingOverride(track.id, stars);
} catch (err) {
console.error('CLI set rating failed', err);
}
}).then(u => unsubs.push(u));
listen('cli:reload-player', async () => {
const store = usePlayerStore.getState();
const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store;
stop();
resetAudioPause();
await invoke('audio_stop').catch(() => {});
if (currentTrack) {
try {
const fresh = await getSong(currentTrack.id);
const t = fresh ? songToTrack(fresh) : currentTrack;
playTrack(t, queue, true);
} catch {
playTrack(currentTrack, queue, true);
}
} else {
await initializeFromServerQueue();
}
listen<any>('cli:player-command', async e => {
await executeCliPlayerCommand({ payload: e.payload ?? {}, navigate });
}).then(u => unsubs.push(u));
return () => {
unsubs.forEach(u => u());
@@ -1100,62 +1011,11 @@ function TauriEventBridge() {
}
const { bindings } = useKeybindingsStore.getState();
const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState();
const action = (Object.entries(bindings) as [string, string | null][])
.find(([, b]) => matchInAppBinding(e, b))?.[0];
const action = matchInAppShortcutAction(e, { ...DEFAULT_IN_APP_BINDINGS, ...bindings });
if (!action) return;
e.preventDefault();
// While a track preview is running, Spacebar pauses the preview rather
// than the main player (which is already paused under it). Skip / prev
// also cancel the preview so the main player resumes cleanly.
const previewing = usePreviewStore.getState().previewingId !== null;
switch (action) {
case 'play-pause':
if (previewing) usePreviewStore.getState().stopPreview();
else togglePlay();
break;
case 'next':
if (previewing) usePreviewStore.getState().stopPreview();
next();
break;
case 'prev':
if (previewing) usePreviewStore.getState().stopPreview();
previous();
break;
case 'volume-up': setVolume(Math.min(1, usePlayerStore.getState().volume + 0.05)); break;
case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break;
case 'seek-forward': {
const s = usePlayerStore.getState();
const dur = s.currentTrack?.duration ?? 0;
if (!dur) break;
seek(Math.min(1, (s.currentTime + 10) / dur));
break;
}
case 'seek-backward': {
const s = usePlayerStore.getState();
const dur = s.currentTrack?.duration ?? 0;
if (!dur) break;
seek(Math.max(0, (s.currentTime - 10) / dur));
break;
}
case 'toggle-queue': toggleQueue(); break;
case 'open-folder-browser':
navigate('/folders', { state: { folderBrowserRevealTs: Date.now() } });
break;
case 'fullscreen-player': toggleFullscreen(); break;
case 'native-fullscreen': {
const win = getCurrentWindow();
win.isFullscreen().then(fs => win.setFullscreen(!fs));
break;
}
case 'open-mini-player':
invoke('open_mini_player').catch(() => {});
break;
}
executeRuntimeAction(action, { navigate, previewPolicy: 'stop' });
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
@@ -1166,38 +1026,20 @@ function TauriEventBridge() {
const unlisten: Array<() => void> = [];
const setup = async () => {
// Hardware mediakeys are silently dropped while a preview is playing —
// matches the cucadmuh-flow expectation that headphone buttons don't
// accidentally interrupt or switch the previewed track.
const ifNoPreview = (fn: () => void) => () => {
if (usePreviewStore.getState().previewingId === null) fn();
};
const handlers: Array<[string, () => void]> = [
['media:play-pause', ifNoPreview(() => togglePlay())],
['media:play', ifNoPreview(() => { const s = usePlayerStore.getState(); if (!s.isPlaying) s.resume(); })],
['media:pause', ifNoPreview(() => { const s = usePlayerStore.getState(); if (s.isPlaying) s.pause(); })],
['media:next', ifNoPreview(() => next())],
['media:prev', ifNoPreview(() => previous())],
// Tray clicks are user-driven UI, so they fall through to the keyboard
// semantics: cancel the preview, then act.
['tray:play-pause', () => {
if (usePreviewStore.getState().previewingId !== null) {
usePreviewStore.getState().stopPreview();
} else {
togglePlay();
}
}],
['tray:next', () => {
if (usePreviewStore.getState().previewingId !== null) usePreviewStore.getState().stopPreview();
next();
}],
['tray:previous', () => {
if (usePreviewStore.getState().previewingId !== null) usePreviewStore.getState().stopPreview();
previous();
}],
['media:stop', ifNoPreview(() => usePlayerStore.getState().stop())],
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
// Hardware media controls should not interrupt active preview playback.
['media:play-pause', () => executeRuntimeAction('play-pause', { navigate, previewPolicy: 'ignore' })],
['media:play', () => executeRuntimeAction('play', { navigate, previewPolicy: 'ignore' })],
['media:pause', () => executeRuntimeAction('pause', { navigate, previewPolicy: 'ignore' })],
['media:next', () => executeRuntimeAction('next', { navigate, previewPolicy: 'ignore' })],
['media:prev', () => executeRuntimeAction('prev', { navigate, previewPolicy: 'ignore' })],
['media:stop', () => executeRuntimeAction('stop', { navigate, previewPolicy: 'ignore' })],
['media:volume-up', () => executeRuntimeAction('volume-up', { navigate, previewPolicy: 'ignore' })],
['media:volume-down', () => executeRuntimeAction('volume-down', { navigate, previewPolicy: 'ignore' })],
// Tray clicks are explicit UI intent: stop preview first, then act.
['tray:play-pause', () => executeRuntimeAction('play-pause', { navigate, previewPolicy: 'stop' })],
['tray:next', () => executeRuntimeAction('next', { navigate, previewPolicy: 'stop' })],
['tray:previous', () => executeRuntimeAction('prev', { navigate, previewPolicy: 'stop' })],
];
for (const [event, handler] of handlers) {
const u = await listen(event, handler);
@@ -1205,6 +1047,30 @@ function TauriEventBridge() {
unlisten.push(u);
}
{
const u = await listen<string>('shortcut:global-action', e => {
const action = e.payload;
if (!isGlobalShortcutActionId(action)) return;
executeRuntimeAction(action, { navigate, previewPolicy: 'ignore' });
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<{ action: string; source?: string }>('shortcut:run-action', e => {
const action = e.payload?.action;
const source = e.payload?.source;
if (!action || !isShortcutAction(action)) return;
if (source === 'mini-window' && !canRunShortcutActionInMiniWindow(action)) return;
const previewPolicy = source === 'cli' ? 'ignore' : 'stop';
executeRuntimeAction(action, { navigate, previewPolicy });
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
// Seek events carry a numeric payload (seconds) — seek() expects 0-1 progress
{
const u = await listen<number>('media:seek-relative', e => {
@@ -1281,7 +1147,7 @@ function TauriEventBridge() {
setup();
return () => { cancelled = true; unlisten.forEach(u => u()); };
}, [togglePlay, next, previous]);
}, [navigate]);
// `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically).
useEffect(() => {
+16 -4
View File
@@ -229,7 +229,10 @@ export default function MiniPlayer() {
const openMiniBinding = useKeybindingsStore.getState().bindings['open-mini-player'];
if (matchInAppBinding(e, openMiniBinding)) {
e.preventDefault();
invoke('open_mini_player').catch(() => {});
emit('shortcut:run-action', {
action: 'open-mini-player',
source: 'mini-window',
}).catch(() => {});
return;
}
@@ -245,11 +248,20 @@ export default function MiniPlayer() {
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
emit('mini:control', 'toggle').catch(() => {});
emit('shortcut:run-action', {
action: 'play-pause',
source: 'mini-window',
}).catch(() => {});
} else if (e.key === 'ArrowRight') {
emit('mini:control', 'next').catch(() => {});
emit('shortcut:run-action', {
action: 'next',
source: 'mini-window',
}).catch(() => {});
} else if (e.key === 'ArrowLeft') {
emit('mini:control', 'prev').catch(() => {});
emit('shortcut:run-action', {
action: 'prev',
source: 'mini-window',
}).catch(() => {});
}
};
window.addEventListener('keydown', onKey);
+6
View File
@@ -203,6 +203,12 @@ export default function PlayerBar() {
}
}, []);
useEffect(() => {
const onToggleEqualizer = () => setEqOpen(v => !v);
window.addEventListener('psy:toggle-equalizer', onToggleEqualizer);
return () => window.removeEventListener('psy:toggle-equalizer', onToggleEqualizer);
}, []);
useEffect(() => {
if (!utilityMenuOpen) return;
const MENU_WIDTH = 238;
+521
View File
@@ -0,0 +1,521 @@
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import i18n from '../i18n';
import { getSong, setRating, star, unstar } from '../api/subsonic';
import { songToTrack, usePlayerStore } from '../store/playerStore';
import { usePreviewStore } from '../store/previewStore';
import { useLyricsStore } from '../store/lyricsStore';
import { showToast } from '../utils/toast';
import { playByOpaqueId } from '../utils/playByOpaqueId';
export type TranslateLike = (key: string, options?: any) => string;
type ShortcutSlot = { defaultBinding: string | null; hidden?: boolean };
type ActionContext = {
navigate: (to: string, options?: any) => void;
previewPolicy: 'stop' | 'ignore';
};
type CliContext = {
navigate: (to: string, options?: any) => void;
payload: any;
};
let cliPremuteVolume: number | null = null;
type ShortcutActionMeta = {
getLabel: (t: TranslateLike) => string;
inApp?: ShortcutSlot;
global?: ShortcutSlot;
runInMiniWindow: boolean;
run: (ctx: ActionContext) => void;
cli?: { verb: string; description: string; command?: string };
};
const withPreviewPolicy = (
action: 'play' | 'pause' | 'stop' | 'play-pause' | 'next' | 'prev',
options: ActionContext,
fn: () => void
) => {
const previewing = usePreviewStore.getState().previewingId !== null;
if (previewing && options.previewPolicy === 'ignore') return;
if (previewing && options.previewPolicy === 'stop') {
usePreviewStore.getState().stopPreview();
}
fn();
};
function focusLiveSearchInput(): boolean {
const input = document.getElementById('live-search-input') as HTMLInputElement | null;
if (!input) return false;
input.focus();
input.select();
return true;
}
export const SHORTCUT_ACTION_REGISTRY = {
'play': {
getLabel: t => t('settings.shortcutPlayPause'),
runInMiniWindow: false,
run: ({ previewPolicy }) => withPreviewPolicy('play', { navigate: () => {}, previewPolicy }, () => {
const state = usePlayerStore.getState();
if (!state.isPlaying) state.resume();
}),
cli: { verb: 'play', description: 'play' },
},
'pause': {
getLabel: t => t('settings.shortcutPlayPause'),
runInMiniWindow: false,
run: ({ previewPolicy }) => withPreviewPolicy('pause', { navigate: () => {}, previewPolicy }, () => {
const state = usePlayerStore.getState();
if (state.isPlaying) state.pause();
}),
cli: { verb: 'pause', description: 'pause' },
},
'stop': {
getLabel: t => t('settings.shortcutPlayPause'),
runInMiniWindow: false,
run: ({ previewPolicy }) => withPreviewPolicy('stop', { navigate: () => {}, previewPolicy }, () => {
usePlayerStore.getState().stop();
}),
cli: { verb: 'stop', description: 'stop' },
},
'play-pause': {
getLabel: t => t('settings.shortcutPlayPause'),
inApp: { defaultBinding: 'Space' },
global: { defaultBinding: null },
runInMiniWindow: true,
run: ({ previewPolicy }) => withPreviewPolicy('play-pause', { navigate: () => {}, previewPolicy }, () => {
usePlayerStore.getState().togglePlay();
}),
},
next: {
getLabel: t => t('settings.shortcutNext'),
inApp: { defaultBinding: null },
global: { defaultBinding: null },
runInMiniWindow: true,
run: ({ previewPolicy }) => withPreviewPolicy('next', { navigate: () => {}, previewPolicy }, () => {
usePlayerStore.getState().next();
}),
cli: { verb: 'next', description: 'next track' },
},
prev: {
getLabel: t => t('settings.shortcutPrev'),
inApp: { defaultBinding: null },
global: { defaultBinding: null },
runInMiniWindow: true,
run: ({ previewPolicy }) => withPreviewPolicy('prev', { navigate: () => {}, previewPolicy }, () => {
usePlayerStore.getState().previous();
}),
cli: { verb: 'prev', description: 'previous track' },
},
'volume-up': {
getLabel: t => t('settings.shortcutVolumeUp'),
inApp: { defaultBinding: null },
global: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
const state = usePlayerStore.getState();
state.setVolume(Math.min(1, state.volume + 0.05));
},
},
'volume-down': {
getLabel: t => t('settings.shortcutVolumeDown'),
inApp: { defaultBinding: null },
global: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
const state = usePlayerStore.getState();
state.setVolume(Math.max(0, state.volume - 0.05));
},
},
'seek-forward': {
getLabel: t => t('settings.shortcutSeekForward'),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
const state = usePlayerStore.getState();
const duration = state.currentTrack?.duration ?? 0;
if (!duration) return;
state.seek(Math.min(1, (state.currentTime + 10) / duration));
},
},
'seek-backward': {
getLabel: t => t('settings.shortcutSeekBackward'),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
const state = usePlayerStore.getState();
const duration = state.currentTrack?.duration ?? 0;
if (!duration) return;
state.seek(Math.max(0, (state.currentTime - 10) / duration));
},
},
'toggle-queue': {
getLabel: t => t('settings.shortcutToggleQueue'),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
usePlayerStore.getState().toggleQueue();
},
},
'open-folder-browser': {
getLabel: t => t('settings.shortcutOpenFolderBrowser', { folderBrowser: t('sidebar.folderBrowser') }),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: ({ navigate }) => {
navigate('/folders', { state: { folderBrowserRevealTs: Date.now() } });
},
},
'fullscreen-player': {
getLabel: t => t('settings.shortcutFullscreenPlayer'),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
usePlayerStore.getState().toggleFullscreen();
},
},
'native-fullscreen': {
getLabel: t => t('settings.shortcutNativeFullscreen'),
inApp: { defaultBinding: 'F11' },
runInMiniWindow: false,
run: () => {
const win = getCurrentWindow();
win.isFullscreen().then(fs => win.setFullscreen(!fs));
},
},
'open-mini-player': {
getLabel: t => t('settings.shortcutOpenMiniPlayer'),
inApp: { defaultBinding: null },
runInMiniWindow: true,
run: () => {
invoke('open_mini_player').catch(() => {});
},
},
'start-search': {
getLabel: t => t('settings.shortcutStartSearch', { defaultValue: 'Start a search' }),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: ({ navigate }) => {
if (focusLiveSearchInput()) return;
navigate('/');
requestAnimationFrame(() => {
window.setTimeout(() => { focusLiveSearchInput(); }, 80);
});
},
},
'start-advanced-search': {
getLabel: t => t('settings.shortcutStartAdvancedSearch', { defaultValue: 'Start an advanced search' }),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: ({ navigate }) => {
navigate('/search/advanced');
},
},
'toggle-sidebar': {
getLabel: t => t('settings.shortcutToggleSidebar', { defaultValue: 'Toggle sidebar' }),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
window.dispatchEvent(new Event('psy:toggle-sidebar'));
},
},
'mute-sound': {
getLabel: t => t('settings.shortcutMuteSound', { defaultValue: 'Mute sound' }),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
const state = usePlayerStore.getState();
if (state.volume <= 0) {
const restore = cliPremuteVolume ?? 0.8;
cliPremuteVolume = null;
state.setVolume(restore);
return;
}
cliPremuteVolume = state.volume;
state.setVolume(0);
},
},
'toggle-equalizer': {
getLabel: t => t('settings.shortcutToggleEqualizer', { defaultValue: 'Open / Toggle Equalizer' }),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
window.dispatchEvent(new Event('psy:toggle-equalizer'));
},
},
'toggle-repeat': {
getLabel: t => t('settings.shortcutToggleRepeat', { defaultValue: 'Toggle repeat' }),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
usePlayerStore.getState().toggleRepeat();
},
},
'open-now-playing': {
getLabel: t => t('settings.shortcutOpenNowPlaying', { defaultValue: 'Open "Now Playing"' }),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: ({ navigate }) => {
navigate('/now-playing');
},
},
'show-lyrics': {
getLabel: t => t('settings.shortcutShowLyrics', { defaultValue: 'Show lyrics' }),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
const player = usePlayerStore.getState();
player.setQueueVisible(true);
useLyricsStore.getState().showLyrics();
},
},
'favorite-current-track': {
getLabel: t => t('settings.shortcutFavoriteCurrentTrack', { defaultValue: 'Add current track to favorites' }),
inApp: { defaultBinding: null },
runInMiniWindow: false,
run: () => {
const track = usePlayerStore.getState().currentTrack;
if (!track) {
showToast(i18n.t('contextMenu.cliMixNeedsTrack', { defaultValue: 'Load a track first.' }), 5000, 'error');
return;
}
star(track.id, 'song')
.then(() => usePlayerStore.getState().setStarredOverride(track.id, true))
.catch(err => {
console.error('Favorite current track failed', err);
showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Could not add the track to favorites.' }), 5000, 'error');
});
},
},
'open-help': {
getLabel: t => t('settings.shortcutOpenHelp', { defaultValue: 'Help' }),
inApp: { defaultBinding: 'F1', hidden: true },
runInMiniWindow: false,
run: ({ navigate }) => {
navigate('/help');
},
},
'shuffle': {
getLabel: t => t('settings.shortcutNext'),
runInMiniWindow: false,
run: () => {
usePlayerStore.getState().shuffleQueue();
},
cli: { verb: 'shuffle', description: 'shuffle' },
},
'mute': {
getLabel: t => t('settings.shortcutVolumeDown'),
runInMiniWindow: false,
run: () => {
const state = usePlayerStore.getState();
if (state.volume > 0) cliPremuteVolume = state.volume;
state.setVolume(0);
},
cli: { verb: 'mute', description: 'mute' },
},
'unmute': {
getLabel: t => t('settings.shortcutVolumeUp'),
runInMiniWindow: false,
run: () => {
const restore = cliPremuteVolume ?? 0.8;
cliPremuteVolume = null;
usePlayerStore.getState().setVolume(restore);
},
cli: { verb: 'unmute', description: 'unmute' },
},
'star': {
getLabel: t => t('settings.shortcutPlayPause'),
runInMiniWindow: false,
run: () => {
const track = usePlayerStore.getState().currentTrack;
if (!track) {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
star(track.id, 'song')
.then(() => usePlayerStore.getState().setStarredOverride(track.id, true))
.catch(err => {
console.error('CLI star failed', err);
showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error');
});
},
cli: { verb: 'star', description: 'star' },
},
'unstar': {
getLabel: t => t('settings.shortcutPlayPause'),
runInMiniWindow: false,
run: () => {
const track = usePlayerStore.getState().currentTrack;
if (!track) {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
unstar(track.id, 'song')
.then(() => usePlayerStore.getState().setStarredOverride(track.id, false))
.catch(err => {
console.error('CLI star failed', err);
showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error');
});
},
cli: { verb: 'unstar', description: 'unstar' },
},
'reload': {
getLabel: t => t('settings.shortcutPlayPause'),
runInMiniWindow: false,
run: () => {
const store = usePlayerStore.getState();
const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store;
stop();
resetAudioPause();
invoke('audio_stop')
.catch(() => {})
.then(async () => {
if (currentTrack) {
try {
const fresh = await getSong(currentTrack.id);
const t = fresh ? songToTrack(fresh) : currentTrack;
playTrack(t, queue, true);
} catch {
playTrack(currentTrack, queue, true);
}
} else {
await initializeFromServerQueue();
}
});
},
cli: { verb: 'reload', description: 'reload' },
},
} as const satisfies Record<string, ShortcutActionMeta>;
export type ShortcutAction = keyof typeof SHORTCUT_ACTION_REGISTRY;
export type KeyAction = {
[Action in ShortcutAction]: (typeof SHORTCUT_ACTION_REGISTRY)[Action] extends { inApp: ShortcutSlot } ? Action : never
}[ShortcutAction];
export type GlobalAction = {
[Action in ShortcutAction]: (typeof SHORTCUT_ACTION_REGISTRY)[Action] extends { global: ShortcutSlot } ? Action : never
}[ShortcutAction];
export function isShortcutAction(action: string): action is ShortcutAction {
return action in SHORTCUT_ACTION_REGISTRY;
}
export function isGlobalShortcutActionId(action: string): action is GlobalAction {
return isShortcutAction(action) && 'global' in SHORTCUT_ACTION_REGISTRY[action];
}
export function canRunShortcutActionInMiniWindow(action: ShortcutAction): boolean {
return SHORTCUT_ACTION_REGISTRY[action].runInMiniWindow;
}
export type RuntimeAction = ShortcutAction;
export function executeRuntimeAction(action: RuntimeAction, ctx: ActionContext): void {
SHORTCUT_ACTION_REGISTRY[action].run(ctx);
}
const CLI_NO_ARG_ACTIONS = Object.entries(SHORTCUT_ACTION_REGISTRY)
.flatMap(([id, def]) => {
if (!('cli' in def)) return [];
const cli = def.cli as { command?: string };
return [{ command: cli.command ?? id, action: id as ShortcutAction }];
});
export function executeCliPlayerCommand(ctx: CliContext): void | Promise<void> {
const command = typeof ctx.payload?.command === 'string' ? ctx.payload.command : '';
if (!command) return;
const mapped = CLI_NO_ARG_ACTIONS.find(it => it.command === command);
if (mapped) {
executeRuntimeAction(mapped.action, { navigate: ctx.navigate, previewPolicy: 'ignore' });
return;
}
if (command === 'play-id') {
const id = typeof ctx.payload.id === 'string' ? ctx.payload.id.trim() : '';
if (!id) return;
return playByOpaqueId(id).catch(err => {
console.error('CLI play failed', err);
const notFound = err instanceof Error && err.message === 'play_by_id_not_found';
showToast(
i18n.t('contextMenu.cliPlayIdNotFound', {
defaultValue: notFound
? 'No song, album, or artist matches this id.'
: 'Could not start playback.',
}),
5000,
'error',
);
});
}
if (command === 'seek-relative') {
const delta = Number(ctx.payload.deltaSecs);
if (!Number.isFinite(delta)) return;
const state = usePlayerStore.getState();
const duration = state.currentTrack?.duration;
if (!duration) return;
state.seek(Math.max(0, state.currentTime + delta) / duration);
return;
}
if (command === 'set-volume') {
const p = Number(ctx.payload.percent);
if (!Number.isFinite(p)) return;
usePlayerStore.getState().setVolume(Math.min(1, Math.max(0, p / 100)));
return;
}
if (command === 'set-repeat') {
const modeRaw = typeof ctx.payload.mode === 'string' ? ctx.payload.mode : '';
const mode = modeRaw === 'all' ? 'all' : modeRaw === 'one' ? 'one' : 'off';
usePlayerStore.setState({ repeatMode: mode });
return;
}
if (command === 'set-rating-current') {
const stars = Number(ctx.payload.stars);
if (!Number.isFinite(stars) || stars < 0 || stars > 5) return;
const track = usePlayerStore.getState().currentTrack;
if (!track) {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
return setRating(track.id, stars)
.then(() => {
usePlayerStore.getState().setUserRatingOverride(track.id, stars);
})
.catch(err => console.error('CLI set rating failed', err));
}
// no-op for unknown command
}
const ALL_IN_APP_SHORTCUT_ACTIONS = (Object.keys(SHORTCUT_ACTION_REGISTRY) as ShortcutAction[])
.filter((action): action is KeyAction => 'inApp' in SHORTCUT_ACTION_REGISTRY[action])
.map(action => {
const inApp = SHORTCUT_ACTION_REGISTRY[action].inApp as ShortcutSlot;
return {
id: action,
getLabel: SHORTCUT_ACTION_REGISTRY[action].getLabel,
defaultBinding: inApp.defaultBinding,
hidden: inApp.hidden === true,
};
});
export const IN_APP_SHORTCUT_ACTIONS = ALL_IN_APP_SHORTCUT_ACTIONS.filter(action => !action.hidden);
export const GLOBAL_SHORTCUT_ACTIONS = (Object.keys(SHORTCUT_ACTION_REGISTRY) as ShortcutAction[])
.filter((action): action is GlobalAction => 'global' in SHORTCUT_ACTION_REGISTRY[action])
.map(action => ({
id: action,
getLabel: SHORTCUT_ACTION_REGISTRY[action].getLabel,
defaultBinding: SHORTCUT_ACTION_REGISTRY[action].global.defaultBinding,
}));
export const DEFAULT_IN_APP_BINDINGS = Object.fromEntries(
ALL_IN_APP_SHORTCUT_ACTIONS.map(action => [action.id, action.defaultBinding])
) as Record<KeyAction, string | null>;
export const DEFAULT_GLOBAL_SHORTCUTS: Partial<Record<GlobalAction, string>> = {};
for (const action of GLOBAL_SHORTCUT_ACTIONS) {
if (action.defaultBinding !== null) {
DEFAULT_GLOBAL_SHORTCUTS[action.id] = action.defaultBinding;
}
}
+10
View File
@@ -844,6 +844,16 @@ export const enTranslation = {
shortcutFullscreenPlayer: 'Fullscreen player',
shortcutNativeFullscreen: 'Native fullscreen',
shortcutOpenMiniPlayer: 'Open mini player',
shortcutStartSearch: 'Start a search',
shortcutStartAdvancedSearch: 'Start an advanced search',
shortcutToggleSidebar: 'Toggle Sidebar',
shortcutMuteSound: 'Mute sound',
shortcutToggleEqualizer: 'Open / Toggle Equalizer',
shortcutToggleRepeat: 'Toggle Repeat',
shortcutOpenNowPlaying: 'Open "Now Playing"',
shortcutShowLyrics: 'Show Lyrics',
shortcutFavoriteCurrentTrack: 'Add current track to favorites',
shortcutOpenHelp: 'Help',
playbackTitle: 'Playback',
replayGain: 'Replay Gain',
replayGainDesc: 'Normalize track volume using ReplayGain metadata',
+5 -21
View File
@@ -45,6 +45,7 @@ import { useThemeStore } from '../store/themeStore';
import { useFontStore, FontId } from '../store/fontStore';
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
import { IN_APP_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_ACTIONS } from '../config/shortcutActions';
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
import {
effectiveLoudnessPreAnalysisAttenuationDb,
@@ -3898,20 +3899,8 @@ export default function Settings() {
>
<div className="settings-card">
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{([
['play-pause', t('settings.shortcutPlayPause')],
['next', t('settings.shortcutNext')],
['prev', t('settings.shortcutPrev')],
['volume-up', t('settings.shortcutVolumeUp')],
['volume-down', t('settings.shortcutVolumeDown')],
['seek-forward', t('settings.shortcutSeekForward')],
['seek-backward', t('settings.shortcutSeekBackward')],
['toggle-queue', t('settings.shortcutToggleQueue')],
['open-folder-browser', t('settings.shortcutOpenFolderBrowser', { folderBrowser: t('sidebar.folderBrowser') })],
['fullscreen-player', t('settings.shortcutFullscreenPlayer')],
['native-fullscreen', t('settings.shortcutNativeFullscreen')],
['open-mini-player', t('settings.shortcutOpenMiniPlayer')],
] as [KeyAction, string][]).map(([action, label]) => {
{IN_APP_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
const label = getLabel(t);
const bound = kb.bindings[action];
const isListening = listeningFor === action;
return (
@@ -3994,13 +3983,8 @@ export default function Settings() {
>
<div className="settings-card">
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{([
['play-pause', t('settings.shortcutPlayPause')],
['next', t('settings.shortcutNext')],
['prev', t('settings.shortcutPrev')],
['volume-up', t('settings.shortcutVolumeUp')],
['volume-down', t('settings.shortcutVolumeDown')],
] as [GlobalAction, string][]).map(([action, label]) => {
{GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
const label = getLabel(t);
const bound = gs.shortcuts[action] ?? null;
const isListening = listeningForGlobal === action;
return (
+10
View File
@@ -0,0 +1,10 @@
import { matchInAppBinding, type Bindings } from '../store/keybindingsStore';
import type { KeyAction } from '../config/shortcutActions';
export function matchInAppShortcutAction(
event: KeyboardEvent,
bindings: Bindings
): KeyAction | null {
return (Object.entries(bindings) as [KeyAction, string | null][])
.find(([, binding]) => matchInAppBinding(event, binding))?.[0] ?? null;
}
+6 -4
View File
@@ -2,8 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { MODIFIER_KEY_CODES, formatBinding } from './keybindingsStore';
export type GlobalAction = 'play-pause' | 'next' | 'prev' | 'volume-up' | 'volume-down';
import { DEFAULT_GLOBAL_SHORTCUTS, isGlobalShortcutActionId, type GlobalAction } from '../config/shortcutActions';
/** Build a Tauri-compatible shortcut string from a KeyboardEvent, or null if invalid. */
export function buildGlobalShortcut(e: KeyboardEvent): string | null {
@@ -39,7 +38,7 @@ interface GlobalShortcutsState {
export const useGlobalShortcutsStore = create<GlobalShortcutsState>()(
persist(
(set, get) => ({
shortcuts: {},
shortcuts: { ...DEFAULT_GLOBAL_SHORTCUTS },
setShortcut: async (action, shortcut) => {
const prev = get().shortcuts[action];
@@ -67,6 +66,7 @@ export const useGlobalShortcutsStore = create<GlobalShortcutsState>()(
_registerAllCalled = true;
const { shortcuts } = get();
for (const [action, shortcut] of Object.entries(shortcuts)) {
if (!isGlobalShortcutActionId(action)) continue;
if (shortcut) {
try {
await invoke('register_global_shortcut', { shortcut, action });
@@ -84,9 +84,11 @@ export const useGlobalShortcutsStore = create<GlobalShortcutsState>()(
try { await invoke('unregister_global_shortcut', { shortcut }); } catch {}
}
}
set({ shortcuts: {} });
set({ shortcuts: { ...DEFAULT_GLOBAL_SHORTCUTS } });
},
}),
{ name: 'psysonic_global_shortcuts' }
)
);
export type { GlobalAction } from '../config/shortcutActions';
+21 -31
View File
@@ -1,19 +1,6 @@
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';
import { DEFAULT_IN_APP_BINDINGS, type KeyAction } from '../config/shortcutActions';
/** Physical keys only — ignore for binding capture */
export const MODIFIER_KEY_CODES = [
@@ -24,20 +11,17 @@ export const MODIFIER_KEY_CODES = [
// 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,
};
export const DEFAULT_BINDINGS: Bindings = { ...DEFAULT_IN_APP_BINDINGS };
export type { KeyAction } from '../config/shortcutActions';
function normalizeBindings(
bindings: Partial<Record<KeyAction, string | null>> | undefined
): Bindings {
return {
...DEFAULT_BINDINGS,
...(bindings ?? {}),
} as Bindings;
}
interface KeybindingsState {
bindings: Bindings;
@@ -81,12 +65,18 @@ export function matchInAppBinding(e: KeyboardEvent, binding: string | null): boo
export const useKeybindingsStore = create<KeybindingsState>()(
persist(
(set) => ({
bindings: { ...DEFAULT_BINDINGS },
bindings: normalizeBindings(undefined),
setBinding: (action, binding) =>
set(s => ({ bindings: { ...s.bindings, [action]: binding } })),
resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }),
resetToDefaults: () => set({ bindings: normalizeBindings(undefined) }),
}),
{ name: 'psysonic_keybindings' }
{
name: 'psysonic_keybindings',
onRehydrateStorage: () => state => {
if (!state) return;
state.bindings = normalizeBindings(state.bindings);
},
}
)
);