fix(web): tick player time every frame and keep lyrics in sync (#107)

`store.elapsed` is a Pinia getter (a cached Vue computed) that interpolates
with `Date.now()`. Because `Date.now()` is not a reactive dependency, the
computed only re-ran on WebSocket pushes / the 3s server poll, so the bottom
progress bar jumped ~3s at a time and lyric highlighting lagged ~half a line —
even though the consumers read it from a 60fps requestAnimationFrame loop.

Add a pure `interpolateElapsed()` helper and a non-cached `liveElapsed()` store
action. The per-frame consumers now call `liveElapsed()` so the value advances
every frame instead of returning a frozen cache:
- web/src/components/Player.vue  (desktop progress bar, rAF)
- web/src/App.vue                (mobile progress bar, rAF)
- web/src/views/Lyrics.vue       (lyric highlight, 500ms interval)

pause() now freezes at the live value rather than a possibly-stale cached one.
The `elapsed` getter is refactored onto the same helper (behaviour unchanged).

Adds web/src/stores/elapsed.test.ts covering the time-advancing interpolation,
paused freeze, no-anchor, and duration-clamp cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-06-30 16:17:53 +08:00
1 parent ea6820204d
commit 45f5d236c1
5 files changed
+107 -11

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { interpolateElapsed, type TimingState } from "./player.js";
afterEach(() => {
vi.restoreAllMocks();
});
function timing(partial: Partial<TimingState>): TimingState {
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false, ...partial };
}
describe("interpolateElapsed", () => {
it("returns serverElapsed before playback has a sync anchor", () => {
expect(interpolateElapsed(timing({ serverElapsed: 12, wasPlaying: false }), false, Infinity)).toBe(12);
// wasPlaying but no sync time yet
expect(interpolateElapsed(timing({ serverElapsed: 5, wasPlaying: true, serverSyncTime: 0 }), false, Infinity)).toBe(5);
});
it("advances with wall-clock time while playing (regression: must not be frozen)", () => {
const spy = vi.spyOn(Date, "now");
const t = timing({ serverElapsed: 30, serverSyncTime: 10_000, wasPlaying: true });
spy.mockReturnValue(10_000);
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(30, 5);
spy.mockReturnValue(11_000); // +1s
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(31, 5);
spy.mockReturnValue(13_500); // +3.5s — distinct from the 1s reading
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(33.5, 5);
});
it("freezes at serverElapsed while paused", () => {
vi.spyOn(Date, "now").mockReturnValue(99_000);
const t = timing({ serverElapsed: 42, serverSyncTime: 10_000, wasPlaying: true });
expect(interpolateElapsed(t, true, Infinity)).toBe(42);
});
it("clamps to maxDuration", () => {
vi.spyOn(Date, "now").mockReturnValue(1_000_000);
const t = timing({ serverElapsed: 100, serverSyncTime: 1_000, wasPlaying: true });
expect(interpolateElapsed(t, false, 180)).toBe(180);
});
});