diff --git a/src/config/shortcutActionRegistry.ts b/src/config/shortcutActionRegistry.ts new file mode 100644 index 00000000..fea36548 --- /dev/null +++ b/src/config/shortcutActionRegistry.ts @@ -0,0 +1,378 @@ +import { star, unstar } from '../api/subsonicStarRating'; +import { getSong } from '../api/subsonicLibrary'; +import { songToTrack } from '../utils/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/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; + +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]; diff --git a/src/config/shortcutActions.ts b/src/config/shortcutActions.ts index 1e297b31..c6b65127 100644 --- a/src/config/shortcutActions.ts +++ b/src/config/shortcutActions.ts @@ -1,523 +1,37 @@ -import { setRating, star, unstar } from '../api/subsonicStarRating'; -import { getSong } from '../api/subsonicLibrary'; -import { songToTrack } from '../utils/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/toast'; -import { playByOpaqueId } from '../utils/playByOpaqueId'; +// Barrel for the shortcut-action subsystem. Split into: +// shortcutTypes.ts — shared types +// shortcutActionRegistry.ts — SHORTCUT_ACTION_REGISTRY + action id types +// shortcutDispatch.ts — runtime + CLI dispatch +// shortcutBindings.ts — derived in-app / global binding tables +// Existing call sites import from this module unchanged. -export type TranslateLike = (key: string, options?: any) => string; +export type { + TranslateLike, + ShortcutSlot, + ActionContext, + CliContext, + ShortcutActionMeta, +} from './shortcutTypes'; -type ShortcutSlot = { defaultBinding: string | null; hidden?: boolean }; +export { + SHORTCUT_ACTION_REGISTRY, + type ShortcutAction, + type KeyAction, + type GlobalAction, +} from './shortcutActionRegistry'; -type ActionContext = { - navigate: (to: string, options?: any) => void; - previewPolicy: 'stop' | 'ignore'; -}; +export { + isShortcutAction, + isGlobalShortcutActionId, + canRunShortcutActionInMiniWindow, + executeRuntimeAction, + executeCliPlayerCommand, + type RuntimeAction, +} from './shortcutDispatch'; -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; - -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 { - 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; - -export const DEFAULT_GLOBAL_SHORTCUTS: Partial> = {}; -for (const action of GLOBAL_SHORTCUT_ACTIONS) { - if (action.defaultBinding !== null) { - DEFAULT_GLOBAL_SHORTCUTS[action.id] = action.defaultBinding; - } -} +export { + IN_APP_SHORTCUT_ACTIONS, + GLOBAL_SHORTCUT_ACTIONS, + DEFAULT_IN_APP_BINDINGS, + DEFAULT_GLOBAL_SHORTCUTS, +} from './shortcutBindings'; diff --git a/src/config/shortcutBindings.ts b/src/config/shortcutBindings.ts new file mode 100644 index 00000000..c2da8494 --- /dev/null +++ b/src/config/shortcutBindings.ts @@ -0,0 +1,40 @@ +import type { ShortcutSlot } from './shortcutTypes'; +import { + SHORTCUT_ACTION_REGISTRY, + type ShortcutAction, + type KeyAction, + type GlobalAction, +} from './shortcutActionRegistry'; + +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; + +export const DEFAULT_GLOBAL_SHORTCUTS: Partial> = {}; +for (const action of GLOBAL_SHORTCUT_ACTIONS) { + if (action.defaultBinding !== null) { + DEFAULT_GLOBAL_SHORTCUTS[action.id] = action.defaultBinding; + } +} diff --git a/src/config/shortcutDispatch.ts b/src/config/shortcutDispatch.ts new file mode 100644 index 00000000..953a7f8d --- /dev/null +++ b/src/config/shortcutDispatch.ts @@ -0,0 +1,100 @@ +import { setRating } from '../api/subsonicStarRating'; +import i18n from '../i18n'; +import { usePlayerStore } from '../store/playerStore'; +import { showToast } from '../utils/toast'; +import { playByOpaqueId } from '../utils/playByOpaqueId'; +import type { ActionContext, CliContext } from './shortcutTypes'; +import { + SHORTCUT_ACTION_REGISTRY, + type ShortcutAction, + type GlobalAction, +} from './shortcutActionRegistry'; + +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 { + 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 +} diff --git a/src/config/shortcutTypes.ts b/src/config/shortcutTypes.ts new file mode 100644 index 00000000..da6ebbfd --- /dev/null +++ b/src/config/shortcutTypes.ts @@ -0,0 +1,22 @@ +export type TranslateLike = (key: string, options?: any) => string; + +export type ShortcutSlot = { defaultBinding: string | null; hidden?: boolean }; + +export type ActionContext = { + navigate: (to: string, options?: any) => void; + previewPolicy: 'stop' | 'ignore'; +}; + +export type CliContext = { + navigate: (to: string, options?: any) => void; + payload: any; +}; + +export type ShortcutActionMeta = { + getLabel: (t: TranslateLike) => string; + inApp?: ShortcutSlot; + global?: ShortcutSlot; + runInMiniWindow: boolean; + run: (ctx: ActionContext) => void; + cli?: { verb: string; description: string; command?: string }; +};