refactor(player): E.11 — extract two playback-coordination helpers (#574)

Two small file-private helpers move into dedicated modules under
src/store/:

- `waveformRefreshGen.ts` — the per-track generation counter that
  guards against applying a stale waveform read after the cache was
  invalidated. Exposes `bumpWaveformRefreshGen` (existing) +
  `getWaveformRefreshGen` (new accessor that replaces the two direct
  reads at `refreshWaveformForTrack`).
- `hotCacheTouch.ts` — `touchHotCacheOnPlayback` with its empty-id
  guards, called from every `audio_play` entry point.

Both file-private; no caller-side changes outside playerStore's own
imports. 8 focused tests pin the generation-increment + isolation
contract and the touch helper's empty-id guards.

playerStore 3302 → 3291 LOC.
This commit is contained in:
Frank Stellmacher
2026-05-12 14:14:50 +02:00
committed by GitHub
parent 86b13dd4d0
commit ddead24678
5 changed files with 129 additions and 18 deletions
+32
View File
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { touchPlayedMock } = vi.hoisted(() => ({
touchPlayedMock: vi.fn(),
}));
vi.mock('./hotCacheStore', () => ({
useHotCacheStore: { getState: () => ({ touchPlayed: touchPlayedMock }) },
}));
import { touchHotCacheOnPlayback } from './hotCacheTouch';
beforeEach(() => {
touchPlayedMock.mockClear();
});
describe('touchHotCacheOnPlayback', () => {
it('forwards a populated id pair to the hot-cache store', () => {
touchHotCacheOnPlayback('t1', 'srv');
expect(touchPlayedMock).toHaveBeenCalledWith('t1', 'srv');
});
it('skips when the trackId is empty', () => {
touchHotCacheOnPlayback('', 'srv');
expect(touchPlayedMock).not.toHaveBeenCalled();
});
it('skips when the serverId is empty', () => {
touchHotCacheOnPlayback('t1', '');
expect(touchPlayedMock).not.toHaveBeenCalled();
});
});
+14
View File
@@ -0,0 +1,14 @@
import { useHotCacheStore } from './hotCacheStore';
/**
* Mark a track as recently played for the hot-cache LRU. Called from every
* `audio_play` entry point — cold start, gapless switch, queue rewrite,
* radio next — so the hot cache promotes frequently-played tracks even when
* playback bounced through different code paths. The empty-id guards keep
* dev-time crashes (e.g. unauthenticated state, server still resolving)
* from surfacing as cache writes against a meaningless key.
*/
export function touchHotCacheOnPlayback(trackId: string, serverId: string): void {
if (!trackId || !serverId) return;
useHotCacheStore.getState().touchPlayed(trackId, serverId);
}
+7 -18
View File
@@ -69,6 +69,11 @@ import {
invokeAudioSetNormalizationDeduped,
invokeAudioUpdateReplayGainDeduped,
} from './normalizationIpcDedupe';
import {
bumpWaveformRefreshGen,
getWaveformRefreshGen,
} from './waveformRefreshGen';
import { touchHotCacheOnPlayback } from './hotCacheTouch';
// Re-export the playback-progress public surface so existing call sites
// (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView,
@@ -630,22 +635,6 @@ radioAudio.addEventListener('suspend', () => {
// Used to suppress ghost-commands from stale IPC arriving after the switch.
let lastGaplessSwitchTime = 0;
function touchHotCacheOnPlayback(trackId: string, serverId: string) {
if (!trackId || !serverId) return;
useHotCacheStore.getState().touchPlayed(trackId, serverId);
}
/** Last-write-wins generation per track: avoids applying a stale empty waveform read when
* `analysis:waveform-updated` bumps gen after SQLite commit while an older `analysis_get_waveform_for_track`
* is still in flight. Gen is bumped only on explicit invalidation (waveform-updated, analysis storage),
* not on every `refreshWaveformForTrack` call — otherwise bursts (Lucky Mix, queue) cancel each other. */
const waveformRefreshGenByTrackId: Record<string, number> = {};
function bumpWaveformRefreshGen(trackId: string) {
if (!trackId) return;
waveformRefreshGenByTrackId[trackId] = (waveformRefreshGenByTrackId[trackId] ?? 0) + 1;
}
/** Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode pair. The
* analysis:waveform-updated listener fires refreshWaveform + refreshLoudness in
* parallel for every full-track analysis completion; without coalescing, gapless
@@ -701,10 +690,10 @@ async function reseedLoudnessForTrackId(trackId: string) {
async function refreshWaveformForTrack(trackId: string) {
if (!trackId) return;
const gen = waveformRefreshGenByTrackId[trackId] ?? 0;
const gen = getWaveformRefreshGen(trackId);
try {
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
if ((waveformRefreshGenByTrackId[trackId] ?? 0) !== gen) return;
if (getWaveformRefreshGen(trackId) !== gen) return;
// Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour).
if (usePlayerStore.getState().currentTrack?.id !== trackId) return;
const bins = row ? coerceWaveformBins(row.bins) : null;
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
_resetWaveformRefreshGenForTest,
bumpWaveformRefreshGen,
getWaveformRefreshGen,
} from './waveformRefreshGen';
afterEach(() => {
_resetWaveformRefreshGenForTest();
});
describe('waveformRefreshGen', () => {
it('returns 0 for an unknown track', () => {
expect(getWaveformRefreshGen('missing')).toBe(0);
});
it('increments the per-track generation on each bump', () => {
bumpWaveformRefreshGen('t1');
expect(getWaveformRefreshGen('t1')).toBe(1);
bumpWaveformRefreshGen('t1');
expect(getWaveformRefreshGen('t1')).toBe(2);
});
it('keeps tracks independent', () => {
bumpWaveformRefreshGen('a');
bumpWaveformRefreshGen('a');
bumpWaveformRefreshGen('b');
expect(getWaveformRefreshGen('a')).toBe(2);
expect(getWaveformRefreshGen('b')).toBe(1);
});
it('is a no-op for an empty trackId', () => {
bumpWaveformRefreshGen('');
expect(getWaveformRefreshGen('')).toBe(0);
});
it('captures the stale-result guard pattern: a snapshot is invalidated by a later bump', () => {
bumpWaveformRefreshGen('t1');
const snapshot = getWaveformRefreshGen('t1');
expect(snapshot).toBe(1);
bumpWaveformRefreshGen('t1');
expect(getWaveformRefreshGen('t1')).not.toBe(snapshot);
});
});
+32
View File
@@ -0,0 +1,32 @@
/**
* Last-write-wins generation counter per track. Avoids applying a stale
* empty waveform read when `analysis:waveform-updated` bumps the gen after
* SQLite commit while an older `analysis_get_waveform_for_track` is still
* in flight. Gen is bumped only on explicit invalidation (waveform-updated,
* analysis storage), not on every `refreshWaveformForTrack` call —
* otherwise bursts (Lucky Mix, queue) cancel each other.
*
* Typical usage:
*
* const gen = getWaveformRefreshGen(trackId);
* const row = await invoke('analysis_get_waveform_for_track', { trackId });
* if (getWaveformRefreshGen(trackId) !== gen) return; // stale result
*/
const waveformRefreshGenByTrackId: Record<string, number> = {};
export function bumpWaveformRefreshGen(trackId: string): void {
if (!trackId) return;
waveformRefreshGenByTrackId[trackId] = (waveformRefreshGenByTrackId[trackId] ?? 0) + 1;
}
export function getWaveformRefreshGen(trackId: string): number {
return waveformRefreshGenByTrackId[trackId] ?? 0;
}
/** Test-only: wipe the per-track generations so each spec starts fresh. */
export function _resetWaveformRefreshGenForTest(): void {
for (const k of Object.keys(waveformRefreshGenByTrackId)) {
delete waveformRefreshGenByTrackId[k];
}
}