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
+150
View File
@@ -0,0 +1,150 @@
/**
* `reseedLoudnessForTrackId` orchestrates a full analysis re-run for one
* track: invalidate waveform gen, clear loudness + backfill state, wipe
* server rows, kick a forced seed. The orchestration is what's worth
* pinning — each individual helper is already tested in its own module.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const authState = {
normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness',
loudnessTargetLufs: -14,
};
const playerSnapshot: {
currentTrack: { id: string } | null;
updateReplayGainForCurrentTrack: ReturnType<typeof vi.fn>;
} = {
currentTrack: null,
updateReplayGainForCurrentTrack: vi.fn(),
};
return {
authState,
playerSnapshot,
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => undefined),
buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`),
bumpWaveformRefreshGenMock: vi.fn(),
clearLoudnessCacheMock: vi.fn(),
resetBackfillStateMock: vi.fn(),
playerSetStateMock: vi.fn(),
};
});
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('../api/subsonic', () => ({ buildStreamUrl: hoisted.buildStreamUrlMock }));
vi.mock('./authStore', () => ({ useAuthStore: { getState: () => hoisted.authState } }));
vi.mock('./playerStore', () => ({
usePlayerStore: {
getState: () => hoisted.playerSnapshot,
setState: hoisted.playerSetStateMock,
},
}));
vi.mock('./waveformRefreshGen', () => ({
bumpWaveformRefreshGen: hoisted.bumpWaveformRefreshGenMock,
}));
vi.mock('./loudnessGainCache', () => ({
clearLoudnessCacheStateForTrackId: hoisted.clearLoudnessCacheMock,
}));
vi.mock('./loudnessBackfillState', () => ({
resetLoudnessBackfillStateForTrackId: hoisted.resetBackfillStateMock,
}));
import { reseedLoudnessForTrackId } from './loudnessReseed';
beforeEach(() => {
hoisted.authState.normalizationEngine = 'loudness';
hoisted.authState.loudnessTargetLufs = -14;
hoisted.playerSnapshot.currentTrack = null;
hoisted.invokeMock.mockReset();
hoisted.invokeMock.mockResolvedValue(undefined);
hoisted.buildStreamUrlMock.mockClear();
hoisted.bumpWaveformRefreshGenMock.mockClear();
hoisted.clearLoudnessCacheMock.mockClear();
hoisted.resetBackfillStateMock.mockClear();
hoisted.playerSetStateMock.mockClear();
hoisted.playerSnapshot.updateReplayGainForCurrentTrack = vi.fn();
});
describe('reseedLoudnessForTrackId', () => {
it('is a no-op for empty trackId', async () => {
await reseedLoudnessForTrackId('');
expect(hoisted.invokeMock).not.toHaveBeenCalled();
expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled();
});
it("is a no-op when normalization engine isn't loudness", async () => {
hoisted.authState.normalizationEngine = 'off';
await reseedLoudnessForTrackId('t1');
expect(hoisted.invokeMock).not.toHaveBeenCalled();
expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled();
});
it('runs the full reseed pipeline in order', async () => {
await reseedLoudnessForTrackId('t1');
expect(hoisted.bumpWaveformRefreshGenMock).toHaveBeenCalledWith('t1');
expect(hoisted.clearLoudnessCacheMock).toHaveBeenCalledWith('t1');
expect(hoisted.resetBackfillStateMock).toHaveBeenCalledWith('t1');
const invokeCalls = hoisted.invokeMock.mock.calls.map(c => c[0]);
expect(invokeCalls).toEqual([
'analysis_delete_waveform_for_track',
'analysis_delete_loudness_for_track',
'analysis_enqueue_seed_from_url',
]);
expect(hoisted.invokeMock.mock.calls[2][1]).toEqual({
trackId: 't1',
url: 'https://mock/stream/t1',
force: true,
});
});
it('blanks the seekbar only when the reseed target is the current track', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
await reseedLoudnessForTrackId('t1');
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
expect(setStateCalls).toContainEqual({ waveformBins: null });
});
it('does NOT blank the seekbar when reseeding a different track', async () => {
hoisted.playerSnapshot.currentTrack = { id: 'other' };
await reseedLoudnessForTrackId('t1');
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
expect(setStateCalls).not.toContainEqual({ waveformBins: null });
});
it('resets live normalization-state to placeholder values', async () => {
hoisted.authState.loudnessTargetLufs = -10;
await reseedLoudnessForTrackId('t1');
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
expect(setStateCalls).toContainEqual({
normalizationNowDb: null,
normalizationTargetLufs: -10,
normalizationEngineLive: 'loudness',
});
});
it('continues past errors in delete-waveform', async () => {
hoisted.invokeMock
.mockRejectedValueOnce(new Error('waveform delete failed'))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
await reseedLoudnessForTrackId('t1');
expect(hoisted.invokeMock).toHaveBeenCalledTimes(3);
});
it('continues past errors in delete-loudness', async () => {
hoisted.invokeMock
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('loudness delete failed'))
.mockResolvedValueOnce(undefined);
await reseedLoudnessForTrackId('t1');
expect(hoisted.invokeMock).toHaveBeenCalledTimes(3);
});
it('swallows the final enqueue-seed error', async () => {
hoisted.invokeMock
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('enqueue failed'));
await expect(reseedLoudnessForTrackId('t1')).resolves.toBeUndefined();
});
});
+66
View File
@@ -0,0 +1,66 @@
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl } from '../api/subsonic';
import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore';
import { bumpWaveformRefreshGen } from './waveformRefreshGen';
import { clearLoudnessCacheStateForTrackId } from './loudnessGainCache';
import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState';
/**
* Tear down every cached piece of analysis for a track and re-enqueue a
* forced reseed. Used by the Settings „Re-analyse this track" action.
*
* Sequence:
* 1. Skip if loudness engine isn't active (re-analysis only makes sense
* when normalization actually consumes the result).
* 2. Invalidate the waveform refresh generation so any in-flight read
* for this id is discarded, and blank the seekbar bins immediately if
* the track is current.
* 3. Wipe both loudness-cache maps (both forms of the id) + the backfill
* retry state.
* 4. Reset the live normalization-state to placeholder values so the UI
* doesn't show stale dB until the new analysis lands.
* 5. Delete the on-disk waveform + loudness rows via Rust.
* 6. Re-issue `updateReplayGainForCurrentTrack` so the engine drops
* whatever gain it was holding for this track.
* 7. Enqueue a forced seed via `analysis_enqueue_seed_from_url`.
*
* Best-effort throughout — Rust errors are logged but never thrown.
*/
export async function reseedLoudnessForTrackId(trackId: string): Promise<void> {
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);
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);
}
}
+14 -64
View File
@@ -105,6 +105,15 @@ import {
setGaplessPreloadingId, setGaplessPreloadingId,
} from './gaplessPreloadState'; } from './gaplessPreloadState';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache'; 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 // Re-export so TauriEventBridge + persistence test keep their existing
// `from './playerStore'` imports. // `from './playerStore'` imports.
@@ -476,11 +485,6 @@ function queueUndoRestoreAudioEngine(opts: {
// Debounce timer for seek slider drags. // Debounce timer for seek slider drags.
let seekDebounce: ReturnType<typeof setTimeout> | null = null; 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. // Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null; let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
let seekFallbackRetryStartedAt = 0; let seekFallbackRetryStartedAt = 0;
@@ -499,16 +503,6 @@ const STORE_PROGRESS_COMMIT_MIN_DELTA_SEC = 5.0;
let lastStoreProgressCommitAt = 0; let lastStoreProgressCommitAt = 0;
function setSeekTarget(seconds: number) {
seekTarget = seconds;
seekTargetSetAt = Date.now();
}
function clearSeekTarget() {
seekTarget = null;
seekTargetSetAt = 0;
}
function clearSeekFallbackRetry() { function clearSeekFallbackRetry() {
if (seekFallbackRetryTimer) { if (seekFallbackRetryTimer) {
clearTimeout(seekFallbackRetryTimer); clearTimeout(seekFallbackRetryTimer);
@@ -562,9 +556,6 @@ function scheduleSeekFallbackRetry(trackId: string, seconds: number) {
}, SEEK_FALLBACK_RETRY_INTERVAL_MS); }, 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 ──────────────────────────────────────────────────────── // ── HTML5 Radio Player ────────────────────────────────────────────────────────
// Internet radio streams are played via a native <audio> element instead of // Internet radio streams are played via a native <audio> element instead of
// the Rust/Symphonia engine. This gives us browser-native reconnect logic, // 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. */ * preload + current-track completion can stack two SQLite reads + two state writes. */
const loudnessRefreshInflight = new Map<string, Promise<void>>(); 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) { async function refreshWaveformForTrack(trackId: string) {
if (!trackId) return; if (!trackId) return;
const gen = getWaveformRefreshGen(trackId); 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 // After the debounce fires, Rust may still emit 12 ticks with the old
// position before the seek takes effect. Block until current_time is // position before the seek takes effect. Block until current_time is
// within 2 s of the requested target, then clear the guard. // within 2 s of the requested target, then clear the guard.
if (seekTarget !== null) { const activeSeekTarget = getSeekTarget();
if (Math.abs(current_time - seekTarget) > 2.0) { 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 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(); clearSeekTarget();
} else { } else {
clearSeekTarget(); clearSeekTarget();
@@ -2515,9 +2467,7 @@ export const usePlayerStore = create<PlayerState>()(
}, },
togglePlay: () => { togglePlay: () => {
if (togglePlayLock) return; if (!tryAcquireTogglePlayLock()) return;
togglePlayLock = true;
setTimeout(() => { togglePlayLock = false; }, 300);
const { isPlaying } = get(); const { isPlaying } = get();
isPlaying ? get().pause() : get().resume(); isPlaying ? get().pause() : get().resume();
}, },
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SEEK_TARGET_GUARD_TIMEOUT_MS,
_resetSeekTargetStateForTest,
clearSeekTarget,
getSeekTarget,
getSeekTargetSetAt,
setSeekTarget,
} from './seekTargetState';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
});
afterEach(() => {
_resetSeekTargetStateForTest();
vi.useRealTimers();
});
describe('SEEK_TARGET_GUARD_TIMEOUT_MS', () => {
it('is the value the progress handler expects', () => {
expect(SEEK_TARGET_GUARD_TIMEOUT_MS).toBe(5000);
});
});
describe('seekTargetState', () => {
it('returns null + 0 before any seek', () => {
expect(getSeekTarget()).toBeNull();
expect(getSeekTargetSetAt()).toBe(0);
});
it('stores target + timestamp on set', () => {
setSeekTarget(42);
expect(getSeekTarget()).toBe(42);
expect(getSeekTargetSetAt()).toBe(Date.now());
});
it('updates timestamp when called again', () => {
setSeekTarget(10);
const first = getSeekTargetSetAt();
vi.advanceTimersByTime(1000);
setSeekTarget(20);
expect(getSeekTarget()).toBe(20);
expect(getSeekTargetSetAt()).toBeGreaterThan(first);
});
it('clearSeekTarget resets both fields', () => {
setSeekTarget(42);
clearSeekTarget();
expect(getSeekTarget()).toBeNull();
expect(getSeekTargetSetAt()).toBe(0);
});
it('clearSeekTarget is a no-op when nothing is set', () => {
expect(() => clearSeekTarget()).not.toThrow();
expect(getSeekTarget()).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Tracks the target time of the most recent user seek. The progress
* handler reads this to suppress stale Rust progress ticks until the
* engine has actually caught up to the new position — without the guard,
* the slider snaps back briefly because Rust keeps emitting the old
* position until the seek finishes propagating.
*
* Lifetime: set when a seek IPC is issued, cleared once progress reaches
* within ~2 s of the target or when the guard timeout (5 s) elapses.
*/
export const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000;
let seekTarget: number | null = null;
let seekTargetSetAt = 0;
export function setSeekTarget(seconds: number): void {
seekTarget = seconds;
seekTargetSetAt = Date.now();
}
export function clearSeekTarget(): void {
seekTarget = null;
seekTargetSetAt = 0;
}
export function getSeekTarget(): number | null {
return seekTarget;
}
export function getSeekTargetSetAt(): number {
return seekTargetSetAt;
}
/** Test-only: reset both mutables so each spec starts fresh. */
export function _resetSeekTargetStateForTest(): void {
seekTarget = null;
seekTargetSetAt = 0;
}
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
_resetTogglePlayLockForTest,
tryAcquireTogglePlayLock,
} from './togglePlayLock';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
_resetTogglePlayLockForTest();
vi.useRealTimers();
});
describe('tryAcquireTogglePlayLock', () => {
it('returns true on first acquire', () => {
expect(tryAcquireTogglePlayLock()).toBe(true);
});
it('returns false when already held', () => {
tryAcquireTogglePlayLock();
expect(tryAcquireTogglePlayLock()).toBe(false);
});
it('auto-releases after the default 300 ms window', () => {
tryAcquireTogglePlayLock();
vi.advanceTimersByTime(299);
expect(tryAcquireTogglePlayLock()).toBe(false);
vi.advanceTimersByTime(1);
expect(tryAcquireTogglePlayLock()).toBe(true);
});
it('honours a custom lock duration', () => {
tryAcquireTogglePlayLock(1000);
vi.advanceTimersByTime(500);
expect(tryAcquireTogglePlayLock()).toBe(false);
vi.advanceTimersByTime(500);
expect(tryAcquireTogglePlayLock()).toBe(true);
});
it('_resetTogglePlayLockForTest force-releases', () => {
tryAcquireTogglePlayLock();
_resetTogglePlayLockForTest();
expect(tryAcquireTogglePlayLock()).toBe(true);
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Short cooldown guard for the play/pause toggle so a rapid double-click
* doesn't send two state transitions to the Rust backend before the first
* one has finished. Held for 300 ms by default — long enough to absorb a
* double-click + the engine round-trip, short enough that intentional
* fast toggles still feel responsive.
*
* Usage:
*
* if (!tryAcquireTogglePlayLock()) return;
* // ... perform toggle ...
*
* The lock auto-releases on a timer; no manual release call needed.
*/
let togglePlayLock = false;
const DEFAULT_LOCK_MS = 300;
export function tryAcquireTogglePlayLock(durationMs: number = DEFAULT_LOCK_MS): boolean {
if (togglePlayLock) return false;
togglePlayLock = true;
setTimeout(() => { togglePlayLock = false; }, durationMs);
return true;
}
/** Test-only: force-release the lock so each spec starts fresh. */
export function _resetTogglePlayLockForTest(): void {
togglePlayLock = false;
}