refactor(player): E.39 — extract resume action (#603)

Move the ~165-LOC `resume` action body into `runResume(set, get)`
in `src/store/resumeAction.ts`, following the helper-with-thin-wrapper
pattern from E.37 + E.38.

Covers all three resume branches:
- Orbit guest catch-up to host's live position (same-track → seek;
  different-track → playTrack + deferred seek).
- Radio resume via HTML5 audio.
- Regular track resume: warm engine (audio_resume) or cold start
  (hot-cache promote → getSong refetch → audio_play → seek to
  persisted currentTime, with currentTrack fallback on fetch fail).

Pure code-move. playerStore.ts: 868 → 702 LOC (−166).
This commit is contained in:
Frank Stellmacher
2026-05-12 21:46:20 +02:00
committed by GitHub
parent 0aadae061f
commit 7e902b918c
2 changed files with 224 additions and 169 deletions
+3 -169
View File
@@ -10,7 +10,6 @@ import { useOfflineStore } from './offlineStore';
import { useHotCacheStore } from './hotCacheStore';
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
import { useOrbitStore } from './orbitStore';
import { estimateLivePosition } from '../api/orbit';
import { resolveReplayGainDb } from '../utils/resolveReplayGainDb';
import { shuffleArray } from '../utils/shuffleArray';
import { songToTrack } from '../utils/songToTrack';
@@ -58,10 +57,7 @@ import {
} from './seekTargetState';
import { refreshWaveformForTrack } from './waveformRefresh';
import { refreshLoudnessForTrack } from './loudnessRefresh';
import {
resumeRadio,
stopRadio,
} from './radioPlayer';
import { stopRadio } from './radioPlayer';
import {
clearSeekFallbackRetry,
getSeekFallbackVisualTarget,
@@ -136,6 +132,7 @@ import type { PlayerState, Track } from './playerStoreTypes';
export type { PlayerState, Track };
import { createLastfmActions } from './lastfmActions';
import { createMiscActions } from './miscActions';
import { runResume } from './resumeAction';
import { runSeek } from './seekAction';
import { runUpdateReplayGainForCurrentTrack } from './updateReplayGainAction';
import { createQueueMutationActions } from './queueMutationActions';
@@ -480,170 +477,7 @@ export const usePlayerStore = create<PlayerState>()(
},
// ── resume ───────────────────────────────────────────────────────────────
resume: () => {
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
// Orbit guest: resume means "catch up to the host's live stream".
// The user hit pause at some earlier point; resuming shouldn't drop
// them back at the stale local position while the host is already
// two songs ahead. Covers PlayerBar, media keys, MPRIS — everything
// that funnels through resume().
const orbit = useOrbitStore.getState();
const hostState = orbit.state;
if (orbit.role === 'guest' && hostState?.isPlaying && hostState.currentTrack) {
const trackId = hostState.currentTrack.trackId;
const targetMs = estimateLivePosition(hostState, Date.now());
const targetSec = Math.max(0, targetMs / 1000);
const localTrackId = get().currentTrack?.id;
void (async () => {
try {
const song = await getSong(trackId);
if (!song) return;
const track = songToTrack(song);
const fraction = Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration)));
if (localTrackId === trackId) {
// Same track: seek + un-pause via the Rust engine directly.
// Bypasses this resume() branch re-entry via the early return below.
get().seek(fraction);
if (getIsAudioPaused()) {
invoke('audio_resume').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: true });
} else {
set({ isPlaying: true });
}
} else {
// Host has a different track — load it (`_orbitConfirmed=true`
// skips the bulk gate; single-track play isn't a bulk replace
// anyway). Seek after a short defer once the engine loads.
get().playTrack(track, [track], false, true);
window.setTimeout(() => {
if (get().currentTrack?.id === trackId) get().seek(fraction);
}, 400);
}
} catch { /* silent */ }
})();
return;
}
if (get().currentRadio) {
resumeRadio().catch(console.error);
set({ isPlaying: true });
return;
}
const { currentTrack, queue, queueIndex, currentTime } = get();
if (!currentTrack) return;
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
if (getIsAudioPaused()) {
// Rust engine has audio loaded but paused — just resume it.
invoke('audio_resume').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: true });
touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? '');
} else {
// Engine has no loaded paused stream (app relaunch, or track ended and user
// hits play — `isAudioPaused` is false after `audio:ended`). Flush any
// `stream_completed_cache` from the prior play to hot disk before resolving URL.
const gen = bumpPlayGeneration();
const vol = get().volume;
set({ isPlaying: true });
void (async () => {
const authHot = useAuthStore.getState();
const resumePromoteSid = authHot.activeServerId;
if (authHot.hotCacheEnabled && resumePromoteSid) {
await promoteCompletedStreamToHotCache(
currentTrack,
resumePromoteSid,
authHot.hotCacheDownloadDir || null,
);
}
if (getPlayGeneration() !== gen) return;
// Fetch fresh track data from server to get replay gain metadata
getSong(currentTrack.id).then(freshSong => {
if (getPlayGeneration() !== gen) return;
const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
// Update store with fresh track data if available
if (freshSong) set({ currentTrack: trackToPlay });
const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb(
trackToPlay, coldPrev, coldNext,
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
recordEnginePlayUrl(trackToPlay.id, coldUrl);
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id),
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes,
analysisTrackId: trackToPlay.id,
streamFormatSuffix: trackToPlay.suffix ?? null,
}).then(() => {
if (getPlayGeneration() === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
}
}).catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queue, trackToPlay, currentTime);
}).catch(() => {
if (getPlayGeneration() !== gen) return;
// Fallback to currentTrack if fetch fails
const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb(
currentTrack, coldPrev, coldNext,
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
recordEnginePlayUrl(currentTrack.id, coldUrl);
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id),
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes,
analysisTrackId: currentTrack.id,
streamFormatSuffix: currentTrack.suffix ?? null,
}).catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queue, currentTrack, currentTime);
});
})();
}
},
resume: () => runResume(set, get),
// ── next / previous ──────────────────────────────────────────────────────
next: (manual = true) => {