From 5b89051817dd3a7fc6a11ff770d5420e9f25cca1 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 11:58:17 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.4=20=E2=80=94=20extract?= =?UTF-8?q?=20playback-progress=20pub/sub=20into=20module=20(#567)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The high-frequency PlaybackProgressSnapshot channel — type, mutable snapshot, listener Set, emit/get/subscribe — moves into a dedicated src/store/playbackProgress.ts. playerStore re-exports the public 3 (getPlaybackProgressSnapshot, subscribePlaybackProgress, the type) so the 5+ external callers (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, TauriEventBridge) keep their existing imports. Direct unit tests pin the delta short-circuit (`currentTime <0.005`, `progress/buffered <0.0002`) that keeps idle CPU bounded. The end-to-end Tauri-event drive path remains covered by the existing playerStore.progress characterization test (unchanged). playerStore 3516 → 3490 LOC. --- src/store/playbackProgress.test.ts | 88 ++++++++++++++++++++++++++++++ src/store/playbackProgress.ts | 58 ++++++++++++++++++++ src/store/playerStore.ts | 58 ++++++-------------- 3 files changed, 162 insertions(+), 42 deletions(-) create mode 100644 src/store/playbackProgress.test.ts create mode 100644 src/store/playbackProgress.ts diff --git a/src/store/playbackProgress.test.ts b/src/store/playbackProgress.test.ts new file mode 100644 index 00000000..38c73081 --- /dev/null +++ b/src/store/playbackProgress.test.ts @@ -0,0 +1,88 @@ +/** + * Direct unit coverage for the playback-progress pub/sub — focused on the + * delta-short-circuit behaviour that keeps idle CPU bounded. The end-to-end + * "Tauri event → emit → subscriber" path is covered by + * `playerStore.progress.test.ts`. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + _resetPlaybackProgressForTest, + emitPlaybackProgress, + getPlaybackProgressSnapshot, + subscribePlaybackProgress, +} from './playbackProgress'; + +afterEach(() => { + _resetPlaybackProgressForTest(); +}); + +describe('getPlaybackProgressSnapshot', () => { + it('starts at zero', () => { + const snap = getPlaybackProgressSnapshot(); + expect(snap).toEqual({ currentTime: 0, progress: 0, buffered: 0 }); + }); + + it('returns the latest snapshot after an emit', () => { + emitPlaybackProgress({ currentTime: 42, progress: 0.5, buffered: 0.7 }); + expect(getPlaybackProgressSnapshot()).toEqual({ currentTime: 42, progress: 0.5, buffered: 0.7 }); + }); +}); + +describe('subscribePlaybackProgress', () => { + it('fires the listener with (next, prev) on a meaningful change', () => { + const cb = vi.fn(); + subscribePlaybackProgress(cb); + emitPlaybackProgress({ currentTime: 1, progress: 0.1, buffered: 0.2 }); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0]).toEqual({ currentTime: 1, progress: 0.1, buffered: 0.2 }); + expect(cb.mock.calls[0][1]).toEqual({ currentTime: 0, progress: 0, buffered: 0 }); + }); + + it('returns an unsubscribe that detaches the listener', () => { + const cb = vi.fn(); + const unsub = subscribePlaybackProgress(cb); + unsub(); + emitPlaybackProgress({ currentTime: 1, progress: 0.1, buffered: 0.2 }); + expect(cb).not.toHaveBeenCalled(); + }); + + it('fans out to multiple listeners', () => { + const a = vi.fn(); + const b = vi.fn(); + subscribePlaybackProgress(a); + subscribePlaybackProgress(b); + emitPlaybackProgress({ currentTime: 1, progress: 0.1, buffered: 0.2 }); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + }); +}); + +describe('emitPlaybackProgress delta short-circuit', () => { + it('skips emits whose deltas are below thresholds (currentTime <0.005, progress/buffered <0.0002)', () => { + const cb = vi.fn(); + subscribePlaybackProgress(cb); + emitPlaybackProgress({ currentTime: 1.0, progress: 0.5, buffered: 0.6 }); + expect(cb).toHaveBeenCalledTimes(1); + // All deltas below the cut-off — must be suppressed. + emitPlaybackProgress({ currentTime: 1.001, progress: 0.50005, buffered: 0.60005 }); + expect(cb).toHaveBeenCalledTimes(1); + // currentTime delta crosses the 0.005 threshold — fires. + emitPlaybackProgress({ currentTime: 1.01, progress: 0.50005, buffered: 0.60005 }); + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('fires when only progress crosses its threshold', () => { + const cb = vi.fn(); + subscribePlaybackProgress(cb); + emitPlaybackProgress({ currentTime: 1.0, progress: 0.5, buffered: 0.6 }); + cb.mockClear(); + emitPlaybackProgress({ currentTime: 1.0, progress: 0.5005, buffered: 0.6 }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('does not advance the snapshot when an emit is suppressed', () => { + emitPlaybackProgress({ currentTime: 1.0, progress: 0.5, buffered: 0.6 }); + emitPlaybackProgress({ currentTime: 1.001, progress: 0.5, buffered: 0.6 }); + expect(getPlaybackProgressSnapshot().currentTime).toBe(1.0); + }); +}); diff --git a/src/store/playbackProgress.ts b/src/store/playbackProgress.ts new file mode 100644 index 00000000..6d0f6e1c --- /dev/null +++ b/src/store/playbackProgress.ts @@ -0,0 +1,58 @@ +/** + * High-frequency playback-progress channel. Decoupled from the main Zustand + * store so subscribers (waveform, time labels, lyrics scroller, mini player + * mirror) can re-render on every audio tick without invalidating selectors + * that watch unrelated player state. + * + * `emitPlaybackProgress` short-circuits when the next snapshot is within a + * sub-perceptible delta of the previous one — keeps idle CPU bounded when + * the player is paused or the engine reports identical frames in a row. + */ +export type PlaybackProgressSnapshot = { + currentTime: number; + progress: number; + buffered: number; +}; + +let playbackProgressSnapshot: PlaybackProgressSnapshot = { + currentTime: 0, + progress: 0, + buffered: 0, +}; + +const playbackProgressListeners = new Set<( + next: PlaybackProgressSnapshot, + prev: PlaybackProgressSnapshot, +) => void>(); + +export function emitPlaybackProgress(next: PlaybackProgressSnapshot): void { + const prev = playbackProgressSnapshot; + if ( + Math.abs(prev.currentTime - next.currentTime) < 0.005 && + Math.abs(prev.progress - next.progress) < 0.0002 && + Math.abs(prev.buffered - next.buffered) < 0.0002 + ) { + return; + } + playbackProgressSnapshot = next; + playbackProgressListeners.forEach(cb => cb(next, prev)); +} + +export function getPlaybackProgressSnapshot(): PlaybackProgressSnapshot { + return playbackProgressSnapshot; +} + +export function subscribePlaybackProgress( + cb: (next: PlaybackProgressSnapshot, prev: PlaybackProgressSnapshot) => void, +): () => void { + playbackProgressListeners.add(cb); + return () => { + playbackProgressListeners.delete(cb); + }; +} + +/** Test-only: reset module state between specs so suites stay isolated. */ +export function _resetPlaybackProgressForTest(): void { + playbackProgressSnapshot = { currentTime: 0, progress: 0, buffered: 0 }; + playbackProgressListeners.clear(); +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 1bab27ec..0d00a8b9 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -33,6 +33,22 @@ import { import { coerceWaveformBins, waveformBlobLenOk } from '../utils/waveformParse'; import { normalizationAlmostEqual } from '../utils/normalizationCompare'; import { isRecoverableSeekError } from '../utils/seekErrors'; +import { + emitPlaybackProgress, + getPlaybackProgressSnapshot, + subscribePlaybackProgress, + type PlaybackProgressSnapshot, +} from './playbackProgress'; + +// Re-export the playback-progress public surface so existing call sites +// (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, +// TauriEventBridge, plus the progress characterization test) keep their +// `from './playerStore'` imports working. +export { + getPlaybackProgressSnapshot, + subscribePlaybackProgress, + type PlaybackProgressSnapshot, +}; import { getWindowKind } from '../app/windowKind'; import { _resetQueueUndoStacksForTest, @@ -475,48 +491,6 @@ const STORE_PROGRESS_COMMIT_MIN_MS = 20_000; const STORE_PROGRESS_COMMIT_MIN_DELTA_SEC = 5.0; let lastStoreProgressCommitAt = 0; -export type PlaybackProgressSnapshot = { - currentTime: number; - progress: number; - buffered: number; -}; - -let playbackProgressSnapshot: PlaybackProgressSnapshot = { - currentTime: 0, - progress: 0, - buffered: 0, -}; -const playbackProgressListeners = new Set<( - next: PlaybackProgressSnapshot, - prev: PlaybackProgressSnapshot -) => void>(); - -function emitPlaybackProgress(next: PlaybackProgressSnapshot): void { - const prev = playbackProgressSnapshot; - if ( - Math.abs(prev.currentTime - next.currentTime) < 0.005 && - Math.abs(prev.progress - next.progress) < 0.0002 && - Math.abs(prev.buffered - next.buffered) < 0.0002 - ) { - return; - } - playbackProgressSnapshot = next; - playbackProgressListeners.forEach(cb => cb(next, prev)); -} - -export function getPlaybackProgressSnapshot(): PlaybackProgressSnapshot { - return playbackProgressSnapshot; -} - -export function subscribePlaybackProgress( - cb: (next: PlaybackProgressSnapshot, prev: PlaybackProgressSnapshot) => void, -): () => void { - playbackProgressListeners.add(cb); - return () => { - playbackProgressListeners.delete(cb); - }; -} - /** Deferred pause / resume — cleared on stop, new track, manual pause/resume. */ let scheduledPauseTimer: number | null = null;