mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
committed by
GitHub
parent
6438fff019
commit
8dfe05fe7d
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user