mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
committed by
GitHub
parent
86b13dd4d0
commit
ddead24678
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user