mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
7a7a9f5e6b
111 of 122 top-level src/utils/ files move into 16 topic folders (audio, cache, cover, share, server, playback, playlist, deviceSync, waveform, mix, format, export, changelog, ui, perf, componentHelpers). True singletons with no cluster stay at the utils/ root. Pure file-move: a path-aware codemod rewrote 539 relative-import specifiers across 275 files; no logic touched. The hot-path coverage gate list (.github/frontend-hot-path-files.txt) is updated to the new paths for the 11 gated utils files — a mechanical consequence of the move, not a CI change. tsc is green.
379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
import { star, unstar } from '../api/subsonicStarRating';
|
|
import { getSong } from '../api/subsonicLibrary';
|
|
import { songToTrack } from '../utils/playback/songToTrack';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
|
import i18n from '../i18n';
|
|
import { usePlayerStore } from '../store/playerStore';
|
|
import { usePreviewStore } from '../store/previewStore';
|
|
import { useLyricsStore } from '../store/lyricsStore';
|
|
import { showToast } from '../utils/ui/toast';
|
|
import type { ActionContext, ShortcutSlot, ShortcutActionMeta } from './shortcutTypes';
|
|
|
|
let cliPremuteVolume: number | null = null;
|
|
|
|
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];
|