Files
Psychotoxical-psysonic/src/store/playerStore.ts
T
Frank Stellmacher e92535a5f5 refactor(player): E.41 — extract playTrack action (#605)
Move the ~280-LOC `playTrack` action body into `runPlayTrack(set, get,
track, queue, manual, _orbitConfirmed, targetQueueIndex)` in
`src/store/playTrackAction.ts`, following the helper-with-thin-wrapper
pattern from E.37–E.40.

Covers all three guard layers (Orbit bulk-gate, Orbit-host
single-track protection, ghost-command 500 ms guard), the
same-track-hot-promote branch, and the inner `runPlayTrackBody`
closure that resolves the URL, updates store + normalization
optimistically, invokes the Rust engine, and on success seeks to
the visual target if there was a pending one.

With playTrack extracted, every action body has been moved out of
the playerStore create() body. playerStore.ts: 515 → 180 LOC (−335),
down from Phase E's starting 3732 LOC (~95% smaller).
2026-05-12 22:21:44 +02:00

181 lines
7.3 KiB
TypeScript

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { resolveReplayGainDb } from '../utils/resolveReplayGainDb';
import { shuffleArray } from '../utils/shuffleArray';
import { songToTrack } from '../utils/songToTrack';
import {
emitPlaybackProgress,
getPlaybackProgressSnapshot,
subscribePlaybackProgress,
type PlaybackProgressSnapshot,
} from './playbackProgress';
import { flushPlayQueuePosition } from './queueSync';
import { initAudioListeners } from './initAudioListeners';
import { installQueueUndoHotkey } from './queueUndoHotkey';
import { readInitialQueueVisibility } from './queueVisibilityStorage';
// Re-export so MainApp + the 3 playerStore characterization tests keep
// their existing `from './playerStore'` imports.
export { initAudioListeners };
// Re-export so bootstrap.ts + bootstrap.test keep their existing
// `from './playerStore'` imports.
export { installQueueUndoHotkey };
// Re-export so TauriEventBridge + persistence test keep their existing
// `from './playerStore'` imports.
export { flushPlayQueuePosition };
// Re-export the playback-progress public surface so existing call sites
// (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView,
// TauriEventBridge, plus the progress characterization test) keep their
// `from './playerStore'` imports working.
export {
getPlaybackProgressSnapshot,
subscribePlaybackProgress,
type PlaybackProgressSnapshot,
};
import {
_resetQueueUndoStacksForTest,
consumePendingQueueListScrollTop,
pushQueueUndoFromGetter,
registerQueueListScrollTopReader,
} from './queueUndo';
// Re-export for backward compatibility with the ~30 call sites that still
// import these helpers from playerStore. Phase E (store splits) will migrate
// the imports to '../utils/*' directly and drop these re-exports.
export { resolveReplayGainDb, shuffleArray, songToTrack };
// Re-export the queue-undo public API so existing callers (QueuePanel,
// test/helpers/storeReset) keep their `from './playerStore'` imports.
export {
_resetQueueUndoStacksForTest,
consumePendingQueueListScrollTop,
registerQueueListScrollTopReader,
};
import type { PlayerState, Track } from './playerStoreTypes';
export type { PlayerState, Track };
import { createLastfmActions } from './lastfmActions';
import { createMiscActions } from './miscActions';
import { runNext } from './nextAction';
import { runPlayTrack } from './playTrackAction';
import { runResume } from './resumeAction';
import { runSeek } from './seekAction';
import { runUpdateReplayGainForCurrentTrack } from './updateReplayGainAction';
import { createQueueMutationActions } from './queueMutationActions';
import { createScheduleActions } from './scheduleActions';
import { createTransportLightActions } from './transportLightActions';
import { createUiStateActions } from './uiStateActions';
import { createUndoRedoActions } from './undoRedoActions';
// ─── Module-level playback primitives ─────────────────────────────────────────
// ─── Store ────────────────────────────────────────────────────────────────────
export const usePlayerStore = create<PlayerState>()(
persist(
(set, get) => {
return {
currentTrack: null,
waveformBins: null,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
normalizationDbgSource: null,
normalizationDbgTrackId: null,
normalizationDbgCacheGainDb: null,
normalizationDbgCacheTargetLufs: null,
normalizationDbgCacheUpdatedAt: null,
normalizationDbgLastEventAt: null,
currentRadio: null,
currentPlaybackSource: null,
enginePreloadedTrackId: null,
queue: [],
queueIndex: 0,
isPlaying: false,
progress: 0,
buffered: 0,
currentTime: 0,
volume: 0.8,
scrobbled: false,
lastfmLoved: false,
lastfmLovedCache: {},
starredOverrides: {},
userRatingOverrides: {},
isQueueVisible: readInitialQueueVisibility(),
isFullscreenOpen: false,
scheduledPauseAtMs: null,
scheduledPauseStartMs: null,
scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
repeatMode: 'off',
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
songInfoModal: { isOpen: false, songId: null },
...createUiStateActions(set),
...createLastfmActions(set, get),
...createQueueMutationActions(set, get),
...createTransportLightActions(set, get),
...createUndoRedoActions(set, get),
...createMiscActions(set, get),
...createScheduleActions(set, get),
// ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue, manual = true, _orbitConfirmed = false, targetQueueIndex) =>
runPlayTrack(set, get, track, queue, manual, _orbitConfirmed, targetQueueIndex),
// ── resume ───────────────────────────────────────────────────────────────
resume: () => runResume(set, get),
// ── next ────────────────────────────────────────────────────────────────
next: (manual = true) => runNext(set, get, manual),
// ── seek ─────────────────────────────────────────────────────────────────
seek: (progress) => runSeek(set, get, progress),
updateReplayGainForCurrentTrack: () => runUpdateReplayGainForCurrentTrack(set, get),
};
},
{
name: 'psysonic-player',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
volume: state.volume,
repeatMode: state.repeatMode,
currentTrack: state.currentTrack,
queue: state.queue,
queueIndex: state.queueIndex,
isQueueVisible: state.isQueueVisible,
// currentTime is intentionally NOT persisted here.
// handleAudioProgress fires every 100ms and each setState with a
// persisted field triggers a full JSON serialisation of the queue to
// localStorage. After ~10 minutes of Artist Radio the queue grows to
// 50+ tracks; 6 000+ synchronous SQLite writes cause WKWebView's
// storage process to crash on macOS → black screen + audio stop.
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
lastfmLovedCache: state.lastfmLovedCache,
}),
}
)
);
usePlayerStore.subscribe((state, prev) => {
if (
state.currentTime === prev.currentTime &&
state.progress === prev.progress &&
state.buffered === prev.buffered
) return;
emitPlaybackProgress({
currentTime: state.currentTime,
progress: state.progress,
buffered: state.buffered,
});
});