refactor(player): E.30 — extract applyQueueHistorySnapshot (#594)

The ~150-LOC `applyQueueHistorySnapshot` helper that lived inside the
playerStore `create((set, get) => { … })` closure moves out into
`src/store/applyQueueHistorySnapshot.ts`. The function now takes the
zustand `set` / `get` references as explicit parameters; the two
caller sites (`undoLastQueueEdit`, `redoLastQueueEdit`) pass them
through unchanged.

Side-cleanup: four imports that only fed the moved helper drop from
playerStore (getPlaybackSourceKind, queueUndoRestoreAudioEngine,
setPendingQueueListScrollTop, shallowCloneQueueTracks).

playerStore 1706 → 1550 LOC (−156).

The undo/redo flow keeps behaving 1:1 — the function is exported and
deterministic; the closure capture only existed for the set/get pair
which is trivial to pass through.
This commit is contained in:
Frank Stellmacher
2026-05-12 20:07:44 +02:00
committed by GitHub
parent 6438fff019
commit 8dfe05fe7d
2 changed files with 209 additions and 160 deletions
+205
View File
@@ -0,0 +1,205 @@
import { invoke } from '@tauri-apps/api/core';
import { reportNowPlaying } from '../api/subsonic';
import { getPlaybackSourceKind } from '../utils/resolvePlaybackUrl';
import { useAuthStore } from './authStore';
import {
bumpPlayGeneration,
getPlayGeneration,
setIsAudioPaused,
} from './engineState';
import { clearPreloadingIds } from './gaplessPreloadState';
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import type { PlayerState } from './playerStoreTypes';
import { sameQueueTrackId, shallowCloneQueueTracks } from '../utils/queueIdentity';
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
import {
setPendingQueueListScrollTop,
type QueueUndoSnapshot,
} from './queueUndo';
import { refreshLoudnessForTrack } from './loudnessRefresh';
import { refreshWaveformForTrack } from './waveformRefresh';
import { stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
import { syncQueueToServer } from './queueSync';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
type GetState = () => PlayerState;
/**
* Apply a queue-undo snapshot to the player store and resync the Rust
* audio engine where needed. Used by both `undoLastQueueEdit` and
* `redoLastQueueEdit` actions inside the store.
*
* Behaviour matrix:
* - **Snapshot has no current track but playback is live** — keep the
* playing track and prepend it to the restored queue (or rebind by
* id when it's already there).
* - **Snapshot's current track matches the live one** — keep playback
* going, restore queue + position only.
* - **Snapshot's current track differs** — issue a full audio_play via
* `queueUndoRestoreAudioEngine` to put the engine on the snapshot
* track at the captured position.
*
* Returns false only when nothing changed (caller shows no toast).
*/
export function applyQueueHistorySnapshot(
snap: QueueUndoSnapshot,
prior: PlayerState,
set: SetState,
get: GetState,
): boolean {
if (prior.currentRadio) {
stopRadio();
}
let nextQueue = shallowCloneQueueTracks(snap.queue);
let nextIndex = snap.queueIndex;
let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null;
if (snap.currentTrack == null && prior.currentTrack) {
const playing = prior.currentTrack;
const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id));
if (pos === -1) {
nextQueue = [{ ...playing }, ...nextQueue];
nextIndex = 0;
nextTrack = { ...playing };
} else {
nextTrack = { ...playing };
nextIndex = pos;
}
}
nextIndex = Math.max(0, Math.min(nextIndex, Math.max(0, nextQueue.length - 1)));
const keepPlaybackFromPrior =
prior.currentTrack != null
&& nextTrack != null
&& sameQueueTrackId(prior.currentTrack.id, nextTrack.id)
&& nextQueue.some(t => sameQueueTrackId(t.id, prior.currentTrack!.id))
&& (
(snap.currentTrack != null && sameQueueTrackId(prior.currentTrack.id, snap.currentTrack.id))
|| snap.currentTrack == null
);
if (keepPlaybackFromPrior) {
const playingKeep = prior.currentTrack;
if (playingKeep) {
const idxPrior = nextQueue.findIndex(t => sameQueueTrackId(t.id, playingKeep.id));
if (idxPrior >= 0) {
nextIndex = idxPrior;
nextTrack = { ...playingKeep };
}
}
}
let tRestoreRaw = typeof snap.currentTime === 'number' && Number.isFinite(snap.currentTime)
? snap.currentTime
: 0;
let playingRestore = snap.isPlaying !== false;
if (keepPlaybackFromPrior && prior.currentTrack) {
tRestoreRaw = prior.currentTime;
playingRestore = prior.isPlaying;
}
const durForProgress = nextTrack?.duration && nextTrack.duration > 0 ? nextTrack.duration : null;
let pRestore = typeof snap.progress === 'number' && Number.isFinite(snap.progress)
? snap.progress
: (durForProgress != null && durForProgress > 0
? Math.max(0, Math.min(1, tRestoreRaw / durForProgress))
: 0);
if (keepPlaybackFromPrior) {
pRestore = prior.progress;
}
const tRestore = durForProgress != null
? Math.max(0, Math.min(tRestoreRaw, durForProgress))
: Math.max(0, tRestoreRaw);
const keepWaveform =
prior.currentTrack?.id != null &&
nextTrack?.id != null &&
sameQueueTrackId(prior.currentTrack.id, nextTrack.id);
const norm =
nextTrack != null
? deriveNormalizationSnapshot(nextTrack, nextQueue, nextIndex)
: ({
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
} as Pick<
PlayerState,
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
>);
const authSnap = useAuthStore.getState();
const playbackSourceUndo = nextTrack
? getPlaybackSourceKind(nextTrack.id, authSnap.activeServerId ?? '', null)
: null;
const playbackSourceFinal = keepPlaybackFromPrior && prior.currentPlaybackSource != null
? prior.currentPlaybackSource
: playbackSourceUndo;
clearAllPlaybackScheduleTimers();
set({
scheduledPauseAtMs: null,
scheduledPauseStartMs: null,
scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
});
clearPreloadingIds();
let gen = getPlayGeneration();
const resyncEngine = Boolean(nextTrack) && !keepPlaybackFromPrior;
if (resyncEngine || !nextTrack) {
gen = bumpPlayGeneration();
if (resyncEngine) {
setIsAudioPaused(false);
}
}
set({
queue: nextQueue,
queueIndex: nextIndex,
currentTrack: nextTrack,
currentRadio: null,
currentTime: tRestore,
progress: pRestore,
isPlaying: playingRestore,
waveformBins: keepWaveform ? prior.waveformBins : null,
enginePreloadedTrackId: keepPlaybackFromPrior ? prior.enginePreloadedTrackId : null,
currentPlaybackSource: playbackSourceFinal,
...norm,
});
if (!nextTrack) {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
syncQueueToServer(nextQueue, null, 0);
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
}
return true;
}
void refreshWaveformForTrack(nextTrack.id);
void refreshLoudnessForTrack(nextTrack.id);
get().updateReplayGainForCurrentTrack();
if (!keepPlaybackFromPrior) {
const { nowPlayingEnabled: npUndo } = useAuthStore.getState();
if (npUndo) reportNowPlaying(nextTrack.id);
queueUndoRestoreAudioEngine({
generation: gen,
track: nextTrack,
queue: nextQueue,
queueIndex: nextIndex,
atSeconds: tRestore,
wantPlaying: playingRestore,
});
}
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
}
syncQueueToServer(nextQueue, nextTrack, tRestore);
return true;
}
+4 -160
View File
@@ -4,7 +4,7 @@ import { invoke } from '@tauri-apps/api/core';
import { showToast } from '../utils/toast';
import i18n from '../i18n';
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, getSong, getSimilarSongs2, getTopSongs, setRating } from '../api/subsonic';
import { resolvePlaybackUrl, getPlaybackSourceKind } from '../utils/resolvePlaybackUrl';
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
import { lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore';
@@ -22,7 +22,6 @@ import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandida
import {
queuesStructuralEqual,
sameQueueTrackId,
shallowCloneQueueTracks,
} from '../utils/queueIdentity';
import { waveformBlobLenOk } from '../utils/waveformParse';
import { isRecoverableSeekError } from '../utils/seekErrors';
@@ -117,7 +116,6 @@ import {
isInfiniteQueueFetching,
setInfiniteQueueFetching,
} from './infiniteQueueState';
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
import { initAudioListeners } from './initAudioListeners';
import { installQueueUndoHotkey } from './queueUndoHotkey';
@@ -157,7 +155,6 @@ import {
pushQueueUndoSnapshot,
queueUndoSnapshotFromState,
registerQueueListScrollTopReader,
setPendingQueueListScrollTop,
type QueueUndoSnapshot,
} from './queueUndo';
@@ -176,6 +173,7 @@ export {
import type { PlayerState, Track } from './playerStoreTypes';
export type { PlayerState, Track };
import { applyQueueHistorySnapshot } from './applyQueueHistorySnapshot';
// ─── Module-level playback primitives ─────────────────────────────────────────
@@ -186,160 +184,6 @@ export type { PlayerState, Track };
export const usePlayerStore = create<PlayerState>()(
persist(
(set, get) => {
function applyQueueHistorySnapshot(snap: QueueUndoSnapshot, prior: PlayerState): boolean {
if (prior.currentRadio) {
stopRadio();
}
let nextQueue = shallowCloneQueueTracks(snap.queue);
let nextIndex = snap.queueIndex;
let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null;
if (snap.currentTrack == null && prior.currentTrack) {
const playing = prior.currentTrack;
const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id));
if (pos === -1) {
nextQueue = [{ ...playing }, ...nextQueue];
nextIndex = 0;
nextTrack = { ...playing };
} else {
nextTrack = { ...playing };
nextIndex = pos;
}
}
nextIndex = Math.max(0, Math.min(nextIndex, Math.max(0, nextQueue.length - 1)));
const keepPlaybackFromPrior =
prior.currentTrack != null
&& nextTrack != null
&& sameQueueTrackId(prior.currentTrack.id, nextTrack.id)
&& nextQueue.some(t => sameQueueTrackId(t.id, prior.currentTrack!.id))
&& (
(snap.currentTrack != null && sameQueueTrackId(prior.currentTrack.id, snap.currentTrack.id))
|| snap.currentTrack == null
);
if (keepPlaybackFromPrior) {
const playingKeep = prior.currentTrack;
if (playingKeep) {
const idxPrior = nextQueue.findIndex(t => sameQueueTrackId(t.id, playingKeep.id));
if (idxPrior >= 0) {
nextIndex = idxPrior;
nextTrack = { ...playingKeep };
}
}
}
let tRestoreRaw = typeof snap.currentTime === 'number' && Number.isFinite(snap.currentTime)
? snap.currentTime
: 0;
let playingRestore = snap.isPlaying !== false;
if (keepPlaybackFromPrior && prior.currentTrack) {
tRestoreRaw = prior.currentTime;
playingRestore = prior.isPlaying;
}
const durForProgress = nextTrack?.duration && nextTrack.duration > 0 ? nextTrack.duration : null;
let pRestore = typeof snap.progress === 'number' && Number.isFinite(snap.progress)
? snap.progress
: (durForProgress != null && durForProgress > 0
? Math.max(0, Math.min(1, tRestoreRaw / durForProgress))
: 0);
if (keepPlaybackFromPrior) {
pRestore = prior.progress;
}
const tRestore = durForProgress != null
? Math.max(0, Math.min(tRestoreRaw, durForProgress))
: Math.max(0, tRestoreRaw);
const keepWaveform =
prior.currentTrack?.id != null &&
nextTrack?.id != null &&
sameQueueTrackId(prior.currentTrack.id, nextTrack.id);
const norm =
nextTrack != null
? deriveNormalizationSnapshot(nextTrack, nextQueue, nextIndex)
: ({
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
} as Pick<
PlayerState,
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
>);
const authSnap = useAuthStore.getState();
const playbackSourceUndo = nextTrack
? getPlaybackSourceKind(nextTrack.id, authSnap.activeServerId ?? '', null)
: null;
const playbackSourceFinal = keepPlaybackFromPrior && prior.currentPlaybackSource != null
? prior.currentPlaybackSource
: playbackSourceUndo;
clearAllPlaybackScheduleTimers();
set({
scheduledPauseAtMs: null,
scheduledPauseStartMs: null,
scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
});
clearPreloadingIds();
let gen = getPlayGeneration();
const resyncEngine = Boolean(nextTrack) && !keepPlaybackFromPrior;
if (resyncEngine || !nextTrack) {
gen = bumpPlayGeneration();
if (resyncEngine) {
setIsAudioPaused(false);
}
}
set({
queue: nextQueue,
queueIndex: nextIndex,
currentTrack: nextTrack,
currentRadio: null,
currentTime: tRestore,
progress: pRestore,
isPlaying: playingRestore,
waveformBins: keepWaveform ? prior.waveformBins : null,
enginePreloadedTrackId: keepPlaybackFromPrior ? prior.enginePreloadedTrackId : null,
currentPlaybackSource: playbackSourceFinal,
...norm,
});
if (!nextTrack) {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
syncQueueToServer(nextQueue, null, 0);
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
}
return true;
}
void refreshWaveformForTrack(nextTrack.id);
void refreshLoudnessForTrack(nextTrack.id);
get().updateReplayGainForCurrentTrack();
if (!keepPlaybackFromPrior) {
const { nowPlayingEnabled: npUndo } = useAuthStore.getState();
if (npUndo) reportNowPlaying(nextTrack.id);
queueUndoRestoreAudioEngine({
generation: gen,
track: nextTrack,
queue: nextQueue,
queueIndex: nextIndex,
atSeconds: tRestore,
wantPlaying: playingRestore,
});
}
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
}
syncQueueToServer(nextQueue, nextTrack, tRestore);
return true;
}
return {
currentTrack: null,
@@ -1555,7 +1399,7 @@ export const usePlayerStore = create<PlayerState>()(
const snap = popQueueUndoSnapshot();
if (!snap) return false;
pushQueueRedoSnapshot(queueUndoSnapshotFromState(prior));
return applyQueueHistorySnapshot(snap, prior);
return applyQueueHistorySnapshot(snap, prior, set, get);
},
redoLastQueueEdit: () => {
@@ -1563,7 +1407,7 @@ export const usePlayerStore = create<PlayerState>()(
const snap = popQueueRedoSnapshot();
if (!snap) return false;
pushQueueUndoSnapshot(queueUndoSnapshotFromState(prior));
return applyQueueHistorySnapshot(snap, prior);
return applyQueueHistorySnapshot(snap, prior, set, get);
},
removeTrack: (index) => {