fix: server-side elapsed tracking via frame count, periodic sync, playlist respects play mode

Progress/lyrics sync:
- AudioPlayer tracks framesPlayed (ground truth: frames * 20ms + seekOffset)
- BotStatus.elapsed is the real elapsed from server
- Frontend polls GET /elapsed every 3s for ground truth
- Client interpolates between syncs (serverElapsed + timeSinceSync)
- Pause freezes at interpolated value, resume resyncs from server
- No more client-side timestamp drift

Playlist play all:
- Stops current playback before loading
- Respects play mode: random/rloop picks random first song, seq plays first

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.6 committed 2026-03-30 03:27:16 +08:00
1 parent 1cdface33c
commit 0439bd047f
6 files changed
+131 -109

No files matched your search

+9 -1
View File
@@ -9,7 +9,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { computed, onMounted, onUnmounted } from 'vue';
import { usePlayerStore } from './stores/player.js';
import { useWebSocket } from './composables/useWebSocket.js';
import Navbar from './components/Navbar.vue';
@@ -19,10 +19,18 @@ const playerStore = usePlayerStore();
const theme = computed(() => playerStore.theme);
const { connect } = useWebSocket();
let syncTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
playerStore.loadTheme();
connect();
playerStore.fetchBots();
// Periodically sync elapsed time from server (ground truth)
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
});
onUnmounted(() => {
if (syncTimer) clearInterval(syncTimer);
});
</script>
+4 -18
View File
@@ -74,7 +74,6 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
import CoverArt from './CoverArt.vue';
import Queue from './Queue.vue';
@@ -102,13 +101,8 @@ function formatTime(seconds: number): string {
}
function updateProgress() {
const bot = store.activeBot;
const seekOff = store.seekOffset;
if (bot?.playing && !bot.paused && store.playStartedAt > 0) {
currentElapsed.value = seekOff + (Date.now() - store.playStartedAt) / 1000;
} else if (bot?.paused) {
currentElapsed.value = store.pausedElapsed;
}
// Use store.elapsed which interpolates from server ground truth
currentElapsed.value = store.elapsed;
const duration = currentSong.value?.duration ?? 0;
progressPercent.value = duration > 0
@@ -120,20 +114,12 @@ function updateProgress() {
async function onProgressClick(e: MouseEvent) {
const bar = progressBarRef.value;
if (!bar || !store.activeBotId) return;
if (!bar) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const duration = currentSong.value?.duration ?? 0;
const seekTime = ratio * duration;
try {
await axios.post(`/api/player/${store.activeBotId}/seek`, { position: seekTime });
store.seekOffset = seekTime;
store.playStartedAt = Date.now();
store.pausedElapsed = 0;
} catch {
// ignore
}
await store.seek(seekTime);
}
function onProgressHover(e: MouseEvent) {
+72 -53
View File
@@ -21,8 +21,7 @@ export interface BotStatus {
queueSize: number;
volume: number;
playMode: string;
seekOffset?: number;
playStartTime?: number;
elapsed?: number;
}
export interface PlaylistItem {
@@ -33,7 +32,7 @@ export interface PlaylistItem {
platform: string;
}
const HOME_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const HOME_CACHE_TTL = 5 * 60 * 1000;
export const usePlayerStore = defineStore('player', {
state: () => ({
@@ -41,10 +40,13 @@ export const usePlayerStore = defineStore('player', {
activeBotId: null as string | null,
queue: [] as Song[],
theme: 'dark' as 'dark' | 'light',
playStartedAt: 0, // client timestamp when playback started
seekOffset: 0, // server seek offset in seconds
pausedElapsed: 0, // elapsed seconds when paused
// Home page cached data
// Server-synced elapsed time (ground truth)
serverElapsed: 0, // last known server elapsed (seconds)
serverSyncTime: 0, // Date.now() when we got serverElapsed
wasPlaying: false, // was playing at last sync
// Home page cache
recommendPlaylists: [] as PlaylistItem[],
dailySongs: [] as Song[],
userPlaylists: [] as PlaylistItem[],
@@ -64,11 +66,12 @@ export const usePlayerStore = defineStore('player', {
isPaused(): boolean {
return this.activeBot?.paused ?? false;
},
/** Interpolated elapsed: serverElapsed + time since last sync (if playing) */
elapsed(): number {
if (!this.activeBot?.currentSong) return 0;
if (this.isPaused) return this.pausedElapsed;
if (!this.isPlaying || this.playStartedAt === 0) return 0;
return this.seekOffset + (Date.now() - this.playStartedAt) / 1000;
if (!this.wasPlaying || this.serverSyncTime === 0) return this.serverElapsed;
if (this.isPaused) return this.serverElapsed;
return this.serverElapsed + (Date.now() - this.serverSyncTime) / 1000;
},
},
@@ -80,9 +83,6 @@ export const usePlayerStore = defineStore('player', {
updateBotStatus(botId: string, status: BotStatus) {
const prev = this.bots.find((b) => b.id === botId);
const prevSongId = prev?.currentSong?.id;
const prevPaused = prev?.paused ?? false;
const prevPlaying = prev?.playing ?? false;
const newSongId = status.currentSong?.id;
const index = this.bots.findIndex((b) => b.id === botId);
if (index >= 0) {
@@ -93,28 +93,18 @@ export const usePlayerStore = defineStore('player', {
if (botId !== (this.activeBotId ?? this.bots[0]?.id)) return;
// Use server-provided seekOffset
const serverSeek = status.seekOffset ?? 0;
// Song changed or seek offset changed — reset timer
if ((newSongId && newSongId !== prevSongId) || serverSeek !== this.seekOffset) {
this.seekOffset = serverSeek;
this.playStartedAt = Date.now();
this.pausedElapsed = 0;
return;
// Sync elapsed from server status
if (status.elapsed !== undefined) {
this.serverElapsed = status.elapsed;
this.serverSyncTime = Date.now();
this.wasPlaying = status.playing && !status.paused;
}
// Resumed
if (status.playing && !status.paused && prevPaused) {
this.playStartedAt = Date.now() - this.pausedElapsed * 1000;
return;
}
// Paused
if (status.paused && prevPlaying && !prevPaused) {
this.pausedElapsed = this.playStartedAt > 0
? this.seekOffset + (Date.now() - this.playStartedAt) / 1000
: this.seekOffset;
// Song changed — reset
if (status.currentSong?.id !== prevSongId) {
this.serverElapsed = status.elapsed ?? 0;
this.serverSyncTime = Date.now();
this.wasPlaying = status.playing && !status.paused;
}
},
@@ -142,10 +132,25 @@ export const usePlayerStore = defineStore('player', {
if (!this.activeBotId && this.bots.length > 0) {
this.activeBotId = this.bots[0].id;
}
// If a song is playing, set the start time
// Sync elapsed from first status
const bot = this.activeBot;
if (bot?.playing && bot.currentSong && this.playStartedAt === 0) {
this.playStartedAt = Date.now();
if (bot?.elapsed !== undefined) {
this.serverElapsed = bot.elapsed;
this.serverSyncTime = Date.now();
this.wasPlaying = bot.playing && !bot.paused;
}
},
/** Poll server for real elapsed time */
async syncElapsed() {
if (!this.activeBotId || !this.isPlaying) return;
try {
const res = await axios.get(`/api/player/${this.activeBotId}/elapsed`);
this.serverElapsed = res.data.elapsed;
this.serverSyncTime = Date.now();
this.wasPlaying = true;
} catch {
// ignore
}
},
@@ -159,22 +164,25 @@ export const usePlayerStore = defineStore('player', {
}
},
_resetTiming(seekSec = 0) {
this.seekOffset = seekSec;
this.playStartedAt = Date.now();
this.pausedElapsed = 0;
_syncAfterAction() {
this.serverSyncTime = Date.now();
this.wasPlaying = true;
// Sync from server after a short delay for accuracy
setTimeout(() => this.syncElapsed(), 500);
},
async play(query: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play`, { query, platform });
this._resetTiming();
this.serverElapsed = 0;
this._syncAfterAction();
},
async playById(songId: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play-by-id`, { songId, platform });
this._resetTiming();
this.serverElapsed = 0;
this._syncAfterAction();
},
async addToQueue(query: string, platform = 'netease') {
@@ -190,41 +198,53 @@ export const usePlayerStore = defineStore('player', {
async playPlaylist(playlistId: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
this._resetTiming();
this.serverElapsed = 0;
this._syncAfterAction();
},
async pause() {
if (!this.activeBotId) return;
// Save current elapsed including seekOffset
this.pausedElapsed = this.elapsed;
// Freeze elapsed at current interpolated value
this.serverElapsed = this.elapsed;
this.wasPlaying = false;
await axios.post(`/api/player/${this.activeBotId}/pause`);
},
async resume() {
if (!this.activeBotId) return;
// Restore: playStartedAt = now - (pausedElapsed - seekOffset)
this.playStartedAt = Date.now() - (this.pausedElapsed - this.seekOffset) * 1000;
await axios.post(`/api/player/${this.activeBotId}/resume`);
this.serverSyncTime = Date.now();
this.wasPlaying = true;
setTimeout(() => this.syncElapsed(), 300);
},
async next() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/next`);
this._resetTiming();
this.serverElapsed = 0;
this._syncAfterAction();
},
async prev() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/prev`);
this._resetTiming();
this.serverElapsed = 0;
this._syncAfterAction();
},
async stop() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/stop`);
this.seekOffset = 0;
this.playStartedAt = 0;
this.pausedElapsed = 0;
this.serverElapsed = 0;
this.serverSyncTime = 0;
this.wasPlaying = false;
},
async seek(position: number) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/seek`, { position });
this.serverElapsed = position;
this._syncAfterAction();
},
async setVolume(volume: number) {
@@ -238,7 +258,6 @@ export const usePlayerStore = defineStore('player', {
},
async fetchHomeData() {
// Skip if data was fetched within the last 5 minutes
if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) {
return;
}