refactor(player): E.18 — extract three transport-coordination modules (#581)

Cluster of three small thematically-related cuts in one PR (per the new
'cluster, don't single-shot' convention):

  - `src/store/seekTargetState.ts` — the seek-target guard (`seekTarget`,
    `seekTargetSetAt`, `SEEK_TARGET_GUARD_TIMEOUT_MS`, set/clear/get
    accessors) that suppresses stale Rust progress ticks until the
    engine catches up to the requested position
  - `src/store/togglePlayLock.ts` — the 300 ms double-click cooldown
    behind a `tryAcquireTogglePlayLock()` helper that auto-releases on
    a timer (collapses the three-line inline pattern in `togglePlay`)
  - `src/store/loudnessReseed.ts` — the full `reseedLoudnessForTrackId`
    pipeline (gen-bump → cache + backfill wipe → state reset → server
    row delete → forced seed enqueue), pulled out of playerStore as a
    single async helper

All three were file-private; no caller-side changes outside
playerStore's own imports. The progress handler's seek-guard branch is
now ~3 lines shorter and reads through accessors. `togglePlay` collapses
to one guard check.

24 tests across the three modules pin the API contracts.

playerStore 3189 → 3139 LOC.
This commit is contained in:
Frank Stellmacher
2026-05-12 15:23:40 +02:00
committed by GitHub
parent 9029ab8ec5
commit 4c64844349
7 changed files with 404 additions and 64 deletions
+14 -64
View File
@@ -105,6 +105,15 @@ import {
setGaplessPreloadingId,
} from './gaplessPreloadState';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import {
SEEK_TARGET_GUARD_TIMEOUT_MS,
clearSeekTarget,
getSeekTarget,
getSeekTargetSetAt,
setSeekTarget,
} from './seekTargetState';
import { tryAcquireTogglePlayLock } from './togglePlayLock';
import { reseedLoudnessForTrackId } from './loudnessReseed';
// Re-export so TauriEventBridge + persistence test keep their existing
// `from './playerStore'` imports.
@@ -476,11 +485,6 @@ function queueUndoRestoreAudioEngine(opts: {
// Debounce timer for seek slider drags.
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// Target time of the last seek — blocks stale Rust progress ticks until the
// engine has actually caught up to the new position.
let seekTarget: number | null = null;
let seekTargetSetAt = 0;
const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000;
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
let seekFallbackRetryStartedAt = 0;
@@ -499,16 +503,6 @@ const STORE_PROGRESS_COMMIT_MIN_DELTA_SEC = 5.0;
let lastStoreProgressCommitAt = 0;
function setSeekTarget(seconds: number) {
seekTarget = seconds;
seekTargetSetAt = Date.now();
}
function clearSeekTarget() {
seekTarget = null;
seekTargetSetAt = 0;
}
function clearSeekFallbackRetry() {
if (seekFallbackRetryTimer) {
clearTimeout(seekFallbackRetryTimer);
@@ -562,9 +556,6 @@ function scheduleSeekFallbackRetry(trackId: string, seconds: number) {
}, SEEK_FALLBACK_RETRY_INTERVAL_MS);
}
// Guard against rapid double-click play/pause sending two state transitions
// to the Rust backend before it has finished the previous one.
let togglePlayLock = false;
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
// Internet radio streams are played via a native <audio> element instead of
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
@@ -640,46 +631,6 @@ radioAudio.addEventListener('suspend', () => {
* preload + current-track completion can stack two SQLite reads + two state writes. */
const loudnessRefreshInflight = new Map<string, Promise<void>>();
async function reseedLoudnessForTrackId(trackId: string) {
if (!trackId) return;
const auth = useAuthStore.getState();
if (auth.normalizationEngine !== 'loudness') return;
bumpWaveformRefreshGen(trackId);
if (usePlayerStore.getState().currentTrack?.id === trackId) {
usePlayerStore.setState({ waveformBins: null });
}
clearLoudnessCacheStateForTrackId(trackId);
resetLoudnessBackfillStateForTrackId(trackId);
if (auth.normalizationEngine === 'loudness') {
usePlayerStore.setState({
normalizationNowDb: null,
normalizationTargetLufs: auth.loudnessTargetLufs,
normalizationEngineLive: 'loudness',
});
}
try {
await invoke('analysis_delete_waveform_for_track', { trackId });
} catch (e) {
console.error('[psysonic] analysis_delete_waveform_for_track failed:', e);
}
try {
await invoke('analysis_delete_loudness_for_track', { trackId });
} catch (e) {
console.error('[psysonic] analysis_delete_loudness_for_track failed:', e);
}
usePlayerStore.getState().updateReplayGainForCurrentTrack();
const url = buildStreamUrl(trackId);
try {
await invoke('analysis_enqueue_seed_from_url', {
trackId,
url,
force: true,
});
} catch (e) {
console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e);
}
}
async function refreshWaveformForTrack(trackId: string) {
if (!trackId) return;
const gen = getWaveformRefreshGen(trackId);
@@ -828,10 +779,11 @@ function handleAudioProgress(current_time: number, duration: number) {
// After the debounce fires, Rust may still emit 12 ticks with the old
// position before the seek takes effect. Block until current_time is
// within 2 s of the requested target, then clear the guard.
if (seekTarget !== null) {
if (Math.abs(current_time - seekTarget) > 2.0) {
const activeSeekTarget = getSeekTarget();
if (activeSeekTarget !== null) {
if (Math.abs(current_time - activeSeekTarget) > 2.0) {
// If a seek command hangs while streaming is stalled, do not freeze UI.
if (Date.now() - seekTargetSetAt <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
if (Date.now() - getSeekTargetSetAt() <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
clearSeekTarget();
} else {
clearSeekTarget();
@@ -2515,9 +2467,7 @@ export const usePlayerStore = create<PlayerState>()(
},
togglePlay: () => {
if (togglePlayLock) return;
togglePlayLock = true;
setTimeout(() => { togglePlayLock = false; }, 300);
if (!tryAcquireTogglePlayLock()) return;
const { isPlaying } = get();
isPlaying ? get().pause() : get().resume();
},