mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
cb1a110afb
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
store/<x> -> features/playback/store/<x>
store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
utils/playback/<x> -> features/playback/utils/playback/<x>
utils/audio/<x> -> features/playback/utils/audio/<x>
184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).
KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).
Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).
tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
import { queueSongStar } from '@/features/playback/store/pendingStarSync';
|
|
import { getSong } from '@/lib/api/subsonicLibrary';
|
|
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
|
import i18n from '@/lib/i18n';
|
|
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
|
import { usePreviewStore } from '@/features/playback/store/previewStore';
|
|
import { useLyricsStore } from '../store/lyricsStore';
|
|
import { showToast } from '../utils/ui/toast';
|
|
import type { ActionContext, ShortcutSlot, ShortcutActionMeta } from '@/config/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;
|
|
}
|
|
queueSongStar(track.id, true, track.serverId);
|
|
},
|
|
},
|
|
'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;
|
|
}
|
|
queueSongStar(track.id, true, track.serverId);
|
|
},
|
|
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;
|
|
}
|
|
queueSongStar(track.id, false, track.serverId);
|
|
},
|
|
cli: { verb: 'unstar', description: 'unstar' },
|
|
},
|
|
'reload': {
|
|
getLabel: t => t('settings.shortcutPlayPause'),
|
|
runInMiniWindow: false,
|
|
run: () => {
|
|
const store = usePlayerStore.getState();
|
|
const { currentTrack, 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;
|
|
// No-arg queue: keep the canonical refs, re-bind the current track.
|
|
playTrack(t, undefined, true);
|
|
} catch {
|
|
playTrack(currentTrack, undefined, 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];
|