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() {
|
function updateMobileProgress() {
|
||||||
const duration = currentSong.value?.duration ?? 0;
|
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
|
mobileProgressPct.value = duration > 0
|
||||||
? Math.min((playerStore.elapsed / duration) * 100, 100)
|
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
|
||||||
: 0;
|
: 0;
|
||||||
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,8 +135,9 @@ function formatTime(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateProgress() {
|
function updateProgress() {
|
||||||
// Use store.elapsed which interpolates from server ground truth
|
// liveElapsed() (an action, not the cached `elapsed` getter) re-interpolates
|
||||||
currentElapsed.value = store.elapsed;
|
// from the server anchor on every frame so the clock ticks each second (#107).
|
||||||
|
currentElapsed.value = store.liveElapsed();
|
||||||
|
|
||||||
const duration = currentSong.value?.duration ?? 0;
|
const duration = currentSong.value?.duration ?? 0;
|
||||||
progressPercent.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;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimingState {
|
export interface TimingState {
|
||||||
serverElapsed: number;
|
serverElapsed: number;
|
||||||
serverSyncTime: number;
|
serverSyncTime: number;
|
||||||
wasPlaying: boolean;
|
wasPlaying: boolean;
|
||||||
@@ -59,6 +59,33 @@ function defaultTiming(): TimingState {
|
|||||||
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false };
|
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', {
|
export const usePlayerStore = defineStore('player', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
bots: [] as BotStatus[],
|
bots: [] as BotStatus[],
|
||||||
@@ -111,15 +138,19 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
if (!botId) return [];
|
if (!botId) return [];
|
||||||
return this.queues[botId] ?? [];
|
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 {
|
elapsed(): number {
|
||||||
const botId = this.activeBotId ?? this.bots[0]?.id;
|
const botId = this.activeBotId ?? this.bots[0]?.id;
|
||||||
if (!botId || !this.activeBot?.currentSong) return 0;
|
if (!botId || !this.activeBot?.currentSong) return 0;
|
||||||
const timing = this.timings[botId] ?? defaultTiming();
|
const timing = this.timings[botId] ?? defaultTiming();
|
||||||
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
||||||
if (!timing.wasPlaying || timing.serverSyncTime === 0) return Math.min(timing.serverElapsed, maxDuration);
|
return interpolateElapsed(timing, this.isPaused, maxDuration);
|
||||||
if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration);
|
|
||||||
return Math.min(timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000, maxDuration);
|
|
||||||
},
|
},
|
||||||
/** Sources that are currently logged in. Order: netease before qq. */
|
/** Sources that are currently logged in. Order: netease before qq. */
|
||||||
availableSources(): Source[] {
|
availableSources(): Source[] {
|
||||||
@@ -131,6 +162,21 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
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 {
|
_getTiming(botId: string): TimingState {
|
||||||
if (!this.timings[botId]) {
|
if (!this.timings[botId]) {
|
||||||
this.timings[botId] = defaultTiming();
|
this.timings[botId] = defaultTiming();
|
||||||
@@ -403,9 +449,10 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
|
|
||||||
async pause() {
|
async pause() {
|
||||||
if (!this.activeBotId) return;
|
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, {
|
this._setTiming(this.activeBotId, {
|
||||||
serverElapsed: this.elapsed,
|
serverElapsed: this.liveElapsed(),
|
||||||
wasPlaying: false,
|
wasPlaying: false,
|
||||||
});
|
});
|
||||||
await axios.post(`/api/player/${this.activeBotId}/pause`);
|
await axios.post(`/api/player/${this.activeBotId}/pause`);
|
||||||
|
|||||||
@@ -136,7 +136,9 @@ function scrollToActiveLine(idx: number) {
|
|||||||
|
|
||||||
function syncLyrics() {
|
function syncLyrics() {
|
||||||
if (!store.isPlaying || lines.value.length === 0) return;
|
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);
|
const idx = findActiveLine(elapsed);
|
||||||
// Only update when the active line actually changes
|
// Only update when the active line actually changes
|
||||||
if (idx !== activeLine.value && idx >= 0) {
|
if (idx !== activeLine.value && idx >= 0) {
|
||||||
|
|||||||
Reference in new issue
Block a user