mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Merge pull request #109 from ZHANGTIANYAO1/fix/player-elapsed-reactivity
fix(web): 播放器时间每帧更新、歌词同步 (closes #107)
This commit is contained in:
5 files changed
+107
-11
No files matched your search
+3
-1
@@ -118,8 +118,10 @@ let mobileRaf: number | null = null;
|
||||
|
||||
function updateMobileProgress() {
|
||||
const duration = currentSong.value?.duration ?? 0;
|
||||
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
|
||||
// leave the mobile bar frozen between server pushes (#107).
|
||||
mobileProgressPct.value = duration > 0
|
||||
? Math.min((playerStore.elapsed / duration) * 100, 100)
|
||||
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
|
||||
: 0;
|
||||
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
||||
}
|
||||
|
||||
@@ -135,8 +135,9 @@ function formatTime(seconds: number): string {
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
// Use store.elapsed which interpolates from server ground truth
|
||||
currentElapsed.value = store.elapsed;
|
||||
// liveElapsed() (an action, not the cached `elapsed` getter) re-interpolates
|
||||
// from the server anchor on every frame so the clock ticks each second (#107).
|
||||
currentElapsed.value = store.liveElapsed();
|
||||
|
||||
const duration = currentSong.value?.duration ?? 0;
|
||||
progressPercent.value = duration > 0
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ export interface FavoritePlaylist {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface TimingState {
|
||||
export interface TimingState {
|
||||
serverElapsed: number;
|
||||
serverSyncTime: number;
|
||||
wasPlaying: boolean;
|
||||
@@ -59,6 +59,33 @@ function defaultTiming(): TimingState {
|
||||
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate the live elapsed seconds from the last server anchor.
|
||||
*
|
||||
* This is a PURE function (its only time source is `Date.now()`), deliberately
|
||||
* kept OUT of the Pinia getter so it can be called fresh every animation frame.
|
||||
* The `elapsed` getter is a Vue `computed` and caches its result until a
|
||||
* REACTIVE dependency changes — but `Date.now()` is not reactive, so a getter
|
||||
* only re-runs on a WebSocket push / server poll (every few seconds). Reading
|
||||
* the getter from a requestAnimationFrame loop therefore returns a frozen value
|
||||
* and the clock appears to jump ~3s at a time (issue #107). Per-frame consumers
|
||||
* must call this helper (via the `liveElapsed` action) instead.
|
||||
*/
|
||||
export function interpolateElapsed(
|
||||
timing: TimingState,
|
||||
isPaused: boolean,
|
||||
maxDuration: number,
|
||||
): number {
|
||||
// No live anchor yet, or paused: report the frozen server position.
|
||||
if (!timing.wasPlaying || timing.serverSyncTime === 0 || isPaused) {
|
||||
return Math.min(timing.serverElapsed, maxDuration);
|
||||
}
|
||||
return Math.min(
|
||||
timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000,
|
||||
maxDuration,
|
||||
);
|
||||
}
|
||||
|
||||
export const usePlayerStore = defineStore('player', {
|
||||
state: () => ({
|
||||
bots: [] as BotStatus[],
|
||||
@@ -111,15 +138,19 @@ export const usePlayerStore = defineStore('player', {
|
||||
if (!botId) return [];
|
||||
return this.queues[botId] ?? [];
|
||||
},
|
||||
/** Interpolated elapsed for the active bot */
|
||||
/**
|
||||
* Interpolated elapsed for the active bot. NOTE: as a Pinia getter this is
|
||||
* a Vue `computed` and is CACHED — it only re-runs when a reactive
|
||||
* dependency changes, so it does NOT tick every second on its own. Use it
|
||||
* for one-off reactive reads; per-frame consumers (progress bar, lyrics)
|
||||
* must call the `liveElapsed` action so the clock advances smoothly (#107).
|
||||
*/
|
||||
elapsed(): number {
|
||||
const botId = this.activeBotId ?? this.bots[0]?.id;
|
||||
if (!botId || !this.activeBot?.currentSong) return 0;
|
||||
const timing = this.timings[botId] ?? defaultTiming();
|
||||
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
||||
if (!timing.wasPlaying || timing.serverSyncTime === 0) return Math.min(timing.serverElapsed, maxDuration);
|
||||
if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration);
|
||||
return Math.min(timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000, maxDuration);
|
||||
return interpolateElapsed(timing, this.isPaused, maxDuration);
|
||||
},
|
||||
/** Sources that are currently logged in. Order: netease before qq. */
|
||||
availableSources(): Source[] {
|
||||
@@ -131,6 +162,21 @@ export const usePlayerStore = defineStore('player', {
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Live elapsed seconds for the active bot, recomputed on every call. Unlike
|
||||
* the `elapsed` getter (a cached computed), this is an action, so it is NOT
|
||||
* memoised — call it from requestAnimationFrame / interval loops so the
|
||||
* progress bar and lyrics advance every frame instead of jumping on each
|
||||
* server push (#107).
|
||||
*/
|
||||
liveElapsed(): number {
|
||||
const botId = this.activeBotId ?? this.bots[0]?.id;
|
||||
if (!botId || !this.activeBot?.currentSong) return 0;
|
||||
const timing = this.timings[botId] ?? defaultTiming();
|
||||
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
||||
return interpolateElapsed(timing, this.isPaused, maxDuration);
|
||||
},
|
||||
|
||||
_getTiming(botId: string): TimingState {
|
||||
if (!this.timings[botId]) {
|
||||
this.timings[botId] = defaultTiming();
|
||||
@@ -403,9 +449,10 @@ export const usePlayerStore = defineStore('player', {
|
||||
|
||||
async pause() {
|
||||
if (!this.activeBotId) return;
|
||||
// Freeze elapsed at current interpolated value
|
||||
// Freeze elapsed at the current LIVE interpolated value. Using the cached
|
||||
// `elapsed` getter here could snapshot a value up to a few seconds stale.
|
||||
this._setTiming(this.activeBotId, {
|
||||
serverElapsed: this.elapsed,
|
||||
serverElapsed: this.liveElapsed(),
|
||||
wasPlaying: false,
|
||||
});
|
||||
await axios.post(`/api/player/${this.activeBotId}/pause`);
|
||||
|
||||
@@ -136,7 +136,9 @@ function scrollToActiveLine(idx: number) {
|
||||
|
||||
function syncLyrics() {
|
||||
if (!store.isPlaying || lines.value.length === 0) return;
|
||||
const elapsed = store.elapsed;
|
||||
// liveElapsed() (action) is recomputed now; the cached `elapsed` getter only
|
||||
// refreshed on server pushes, leaving highlights ~half a line behind (#107).
|
||||
const elapsed = store.liveElapsed();
|
||||
const idx = findActiveLine(elapsed);
|
||||
// Only update when the active line actually changes
|
||||
if (idx !== activeLine.value && idx >= 0) {
|
||||
|
||||
Reference in new issue
Block a user