refactor(player): E.25 — extract audio-orchestration cluster (#589)

Two thematic cuts in one PR:

  - `src/store/queueUndoAudioRestore.ts` — `queueUndoRestoreAudioEngine`
    (~70 LOC). Reload the Rust audio engine to match a queue-undo
    snapshot: audio_play with snapshot track params, optional
    audio_seek to the snapshot position, audio_pause if the snapshot
    captured a paused state. Generation-guard bails on concurrent
    playTrack. Drives recordEnginePlayUrl, setDeferHotCachePrefetch,
    and touchHotCacheOnPlayback.
  - `src/store/loudnessPrefetch.ts` — `prefetchLoudnessForEnqueuedTracks`.
    Warms the loudness cache for the current track + next-N window
    after a bulk enqueue, no-op when normalization isn't loudness.

Both file-private; no caller-side changes outside playerStore's own
imports. Removes the unused `collectLoudnessBackfillWindowTrackIds`
import that was only feeding the moved prefetch helper.

11 tests across the two modules pin the orchestration: audio_play
payload + seek-when-near-zero + wantPlaying=false → audio_pause +
generation-mismatch bail + .finally clears the hot-cache gate even
on errors; engine guard + window forwarding + sync-flag for prefetch.

playerStore 2865 → 2779 LOC.
This commit is contained in:
Frank Stellmacher
2026-05-12 18:08:09 +02:00
committed by GitHub
parent 14bdcde33f
commit a5aadeea67
5 changed files with 382 additions and 88 deletions
+2 -88
View File
@@ -70,7 +70,6 @@ import { bumpWaveformRefreshGen } from './waveformRefreshGen';
import { touchHotCacheOnPlayback } from './hotCacheTouch';
import { applySkipStarOnManualNext } from './skipStarRating';
import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState';
import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow';
import {
flushPlayQueuePosition,
flushQueueSyncToServer,
@@ -156,6 +155,8 @@ import {
isInfiniteQueueFetching,
setInfiniteQueueFetching,
} from './infiniteQueueState';
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
// Re-export so TauriEventBridge + persistence test keep their existing
// `from './playerStore'` imports.
@@ -406,96 +407,9 @@ type NormalizationStatePayload = {
// ─── Module-level playback primitives ─────────────────────────────────────────
/** Reload Rust audio to match a queue-undo snapshot (Zustand alone does not move the engine). */
function queueUndoRestoreAudioEngine(opts: {
generation: number;
track: Track;
queue: Track[];
queueIndex: number;
atSeconds: number;
wantPlaying: boolean;
}): void {
const { generation, track, queue, queueIndex, atSeconds, wantPlaying } = opts;
const authState = useAuthStore.getState();
const vol = usePlayerStore.getState().volume;
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
const replayGainDb = resolveReplayGainDb(
track, coldPrev, coldNext,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
recordEnginePlayUrl(track.id, url);
usePlayerStore.setState({
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, authState.activeServerId ?? '', url),
});
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
setDeferHotCachePrefetch(true);
invoke('audio_play', {
url,
volume: vol,
durationHint: track.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
manual: false,
hiResEnabled: authState.enableHiRes,
analysisTrackId: track.id,
streamFormatSuffix: track.suffix ?? null,
})
.then(() => {
if (getPlayGeneration() !== generation) return;
if (keepPreloadHint) {
usePlayerStore.setState({ enginePreloadedTrackId: null });
}
const dur = track.duration && track.duration > 0 ? track.duration : null;
const seekTo = Math.max(0, atSeconds);
const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05);
const afterSeek = () => {
if (getPlayGeneration() !== generation) return;
if (!wantPlaying) {
invoke('audio_pause').catch(console.error);
setIsAudioPaused(true);
usePlayerStore.setState({ isPlaying: false });
} else {
setIsAudioPaused(false);
}
};
if (canSeek) {
void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek);
} else {
afterSeek();
}
})
.catch((err: unknown) => {
if (getPlayGeneration() !== generation) return;
console.error('[psysonic] queue-undo audio_play failed:', err);
usePlayerStore.setState({ isPlaying: false });
})
.finally(() => {
setDeferHotCachePrefetch(false);
});
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
}
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
function prefetchLoudnessForEnqueuedTracks(
mergedQueue: Track[],
queueIndex: number,
) {
if (useAuthStore.getState().normalizationEngine !== 'loudness') return;
const currentTrack = usePlayerStore.getState().currentTrack;
const ids = collectLoudnessBackfillWindowTrackIds(mergedQueue, queueIndex, currentTrack);
for (const id of ids) {
void refreshLoudnessForTrack(id, { syncPlayingEngine: false });
}
}
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
function handleAudioPlaying(_duration: number) {