mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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:
1 parent
1cdface33c
commit
0439bd047f
6 files changed
+131
-109
No files matched your search
+24
-30
@@ -17,13 +17,14 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
private ffmpeg: ChildProcess | null = null;
|
private ffmpeg: ChildProcess | null = null;
|
||||||
private encoder: Encoder;
|
private encoder: Encoder;
|
||||||
private state: PlayerState = "idle";
|
private state: PlayerState = "idle";
|
||||||
private volume = 75; // 0-100
|
private volume = 75;
|
||||||
private pcmBuffer: Buffer = Buffer.alloc(0);
|
private pcmBuffer: Buffer = Buffer.alloc(0);
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private frameLoopRunning = false;
|
private frameLoopRunning = false;
|
||||||
private nextFrameTime = 0;
|
private nextFrameTime = 0;
|
||||||
private currentUrl = "";
|
private currentUrl = "";
|
||||||
private seekOffset = 0; // seconds offset from seek
|
private seekOffset = 0;
|
||||||
|
private framesPlayed = 0; // ground truth: number of 20ms frames sent
|
||||||
|
|
||||||
constructor(logger: Logger) {
|
constructor(logger: Logger) {
|
||||||
super();
|
super();
|
||||||
@@ -35,6 +36,7 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.stop();
|
this.stop();
|
||||||
this.currentUrl = url;
|
this.currentUrl = url;
|
||||||
this.seekOffset = seekSeconds;
|
this.seekOffset = seekSeconds;
|
||||||
|
this.framesPlayed = 0;
|
||||||
|
|
||||||
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
|
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
|
||||||
|
|
||||||
@@ -57,19 +59,14 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
|
|
||||||
this.ffmpeg = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
this.ffmpeg = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
|
||||||
this.ffmpeg.stderr!.on("data", () => {
|
this.ffmpeg.stderr!.on("data", () => {});
|
||||||
// Suppress FFmpeg stderr output
|
|
||||||
});
|
|
||||||
|
|
||||||
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
||||||
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ffmpeg.on("close", (code) => {
|
this.ffmpeg.on("close", () => {
|
||||||
this.logger.debug({ code }, "FFmpeg process closed");
|
// Let frame loop drain remaining buffer
|
||||||
if (this.state === "playing" || this.state === "paused") {
|
|
||||||
// Let the frame loop drain remaining frames, then it will emit trackEnd
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ffmpeg.on("error", (err) => {
|
this.ffmpeg.on("error", (err) => {
|
||||||
@@ -81,10 +78,6 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.startFrameLoop();
|
this.startFrameLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* High-precision frame sending loop using drift-correcting setTimeout.
|
|
||||||
* Much more accurate than setInterval which drifts ~4-16ms on Windows.
|
|
||||||
*/
|
|
||||||
private startFrameLoop(): void {
|
private startFrameLoop(): void {
|
||||||
if (this.frameLoopRunning) return;
|
if (this.frameLoopRunning) return;
|
||||||
this.frameLoopRunning = true;
|
this.frameLoopRunning = true;
|
||||||
@@ -105,11 +98,9 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
if (this.state === "playing") {
|
if (this.state === "playing") {
|
||||||
this.sendNextFrame();
|
this.sendNextFrame();
|
||||||
} else if (this.state === "paused") {
|
} else if (this.state === "paused") {
|
||||||
// Keep the loop alive but adjust timing to avoid drift accumulation
|
|
||||||
this.nextFrameTime = performance.now();
|
this.nextFrameTime = performance.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should stop (FFmpeg done + buffer empty)
|
|
||||||
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||||
this.frameLoopRunning = false;
|
this.frameLoopRunning = false;
|
||||||
if (this.state !== "idle") {
|
if (this.state !== "idle") {
|
||||||
@@ -129,10 +120,10 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
|
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
|
||||||
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
|
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
|
||||||
|
|
||||||
// Apply volume by scaling PCM samples directly
|
|
||||||
const adjusted = this.applyVolume(pcmFrame);
|
const adjusted = this.applyVolume(pcmFrame);
|
||||||
const opusFrame = this.encoder.encode(adjusted);
|
const opusFrame = this.encoder.encode(adjusted);
|
||||||
this.emit("frame", opusFrame);
|
this.emit("frame", opusFrame);
|
||||||
|
this.framesPlayed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyVolume(pcm: Buffer): Buffer {
|
private applyVolume(pcm: Buffer): Buffer {
|
||||||
@@ -142,7 +133,6 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
for (let i = 0; i < pcm.length; i += 2) {
|
for (let i = 0; i < pcm.length; i += 2) {
|
||||||
let sample = pcm.readInt16LE(i);
|
let sample = pcm.readInt16LE(i);
|
||||||
sample = Math.round(sample * factor);
|
sample = Math.round(sample * factor);
|
||||||
// Clamp to 16-bit range
|
|
||||||
if (sample > 32767) sample = 32767;
|
if (sample > 32767) sample = 32767;
|
||||||
else if (sample < -32768) sample = -32768;
|
else if (sample < -32768) sample = -32768;
|
||||||
out.writeInt16LE(sample, i);
|
out.writeInt16LE(sample, i);
|
||||||
@@ -150,6 +140,21 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Actual elapsed time in seconds (ground truth from frame count) */
|
||||||
|
getElapsed(): number {
|
||||||
|
return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(seconds: number): void {
|
||||||
|
if (!this.currentUrl) return;
|
||||||
|
this.logger.info({ seek: seconds }, "Seeking");
|
||||||
|
this.play(this.currentUrl, seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSeekOffset(): number {
|
||||||
|
return this.seekOffset;
|
||||||
|
}
|
||||||
|
|
||||||
pause(): void {
|
pause(): void {
|
||||||
if (this.state === "playing") {
|
if (this.state === "playing") {
|
||||||
this.state = "paused";
|
this.state = "paused";
|
||||||
@@ -160,23 +165,11 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
resume(): void {
|
resume(): void {
|
||||||
if (this.state === "paused") {
|
if (this.state === "paused") {
|
||||||
this.state = "playing";
|
this.state = "playing";
|
||||||
// Reset timing to avoid burst of frames after unpause
|
|
||||||
this.nextFrameTime = performance.now();
|
this.nextFrameTime = performance.now();
|
||||||
this.logger.debug("Playback resumed");
|
this.logger.debug("Playback resumed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seek to a position in seconds. Restarts FFmpeg with -ss offset. */
|
|
||||||
seek(seconds: number): void {
|
|
||||||
if (!this.currentUrl) return;
|
|
||||||
this.logger.info({ seek: seconds }, "Seeking");
|
|
||||||
this.play(this.currentUrl, seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSeekOffset(): number {
|
|
||||||
return this.seekOffset;
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.frameLoopRunning = false;
|
this.frameLoopRunning = false;
|
||||||
if (this.ffmpeg) {
|
if (this.ffmpeg) {
|
||||||
@@ -187,6 +180,7 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.state = "idle";
|
this.state = "idle";
|
||||||
this.currentUrl = "";
|
this.currentUrl = "";
|
||||||
this.seekOffset = 0;
|
this.seekOffset = 0;
|
||||||
|
this.framesPlayed = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume(vol: number): void {
|
setVolume(vol: number): void {
|
||||||
|
|||||||
+2
-4
@@ -37,8 +37,7 @@ export interface BotStatus {
|
|||||||
queueSize: number;
|
queueSize: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
playMode: PlayMode;
|
playMode: PlayMode;
|
||||||
seekOffset: number; // seconds offset from last seek/play start
|
elapsed: number; // ground truth elapsed seconds from frame count
|
||||||
playStartTime: number; // server timestamp when playback started
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BotInstance extends EventEmitter {
|
export class BotInstance extends EventEmitter {
|
||||||
@@ -507,8 +506,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
queueSize: this.queue.size(),
|
queueSize: this.queue.size(),
|
||||||
volume: this.player.getVolume(),
|
volume: this.player.getVolume(),
|
||||||
playMode: this.queue.getMode(),
|
playMode: this.queue.getMode(),
|
||||||
seekOffset: this.player.getSeekOffset(),
|
elapsed: this.player.getElapsed(),
|
||||||
playStartTime: Date.now(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -104,6 +104,12 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get current elapsed time (ground truth from server)
|
||||||
|
router.get("/:botId/elapsed", (req, res) => {
|
||||||
|
const bot = (req as any).bot;
|
||||||
|
res.json({ elapsed: bot.getPlayer().getElapsed() });
|
||||||
|
});
|
||||||
|
|
||||||
// Seek to position
|
// Seek to position
|
||||||
router.post("/:botId/seek", async (req, res) => {
|
router.post("/:botId/seek", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -153,6 +159,7 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Play a playlist by ID — stores metadata only, resolves URL for first song
|
// Play a playlist by ID — stores metadata only, resolves URL for first song
|
||||||
|
// Respects current play mode (random = pick random first song)
|
||||||
router.post("/:botId/play-playlist", async (req, res) => {
|
router.post("/:botId/play-playlist", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
@@ -163,21 +170,31 @@ export function createPlayerRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop current playback
|
||||||
|
bot.getPlayer().stop();
|
||||||
|
|
||||||
const songs = await provider.getPlaylistSongs(playlistId);
|
const songs = await provider.getPlaylistSongs(playlistId);
|
||||||
if (songs.length === 0) {
|
if (songs.length === 0) {
|
||||||
res.json({ message: "Playlist is empty" });
|
res.json({ message: "Playlist is empty" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all songs as metadata only (no URL fetching!)
|
|
||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
queue.clear();
|
queue.clear();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
queue.add({ ...song, platform: provider.platform });
|
queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only resolve URL for the first song
|
// Use queue.play() for sequential, or pick random index for random modes
|
||||||
const first = queue.play();
|
const mode = queue.getMode();
|
||||||
|
let first;
|
||||||
|
if (mode === "random" || mode === "rloop") {
|
||||||
|
const idx = Math.floor(Math.random() * queue.size());
|
||||||
|
first = queue.playAt(idx);
|
||||||
|
} else {
|
||||||
|
first = queue.play();
|
||||||
|
}
|
||||||
|
|
||||||
if (first) {
|
if (first) {
|
||||||
await bot.resolveAndPlay(first);
|
await bot.resolveAndPlay(first);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -9,7 +9,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue';
|
import { computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { usePlayerStore } from './stores/player.js';
|
import { usePlayerStore } from './stores/player.js';
|
||||||
import { useWebSocket } from './composables/useWebSocket.js';
|
import { useWebSocket } from './composables/useWebSocket.js';
|
||||||
import Navbar from './components/Navbar.vue';
|
import Navbar from './components/Navbar.vue';
|
||||||
@@ -19,10 +19,18 @@ const playerStore = usePlayerStore();
|
|||||||
const theme = computed(() => playerStore.theme);
|
const theme = computed(() => playerStore.theme);
|
||||||
const { connect } = useWebSocket();
|
const { connect } = useWebSocket();
|
||||||
|
|
||||||
|
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
playerStore.loadTheme();
|
playerStore.loadTheme();
|
||||||
connect();
|
connect();
|
||||||
playerStore.fetchBots();
|
playerStore.fetchBots();
|
||||||
|
// Periodically sync elapsed time from server (ground truth)
|
||||||
|
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (syncTimer) clearInterval(syncTimer);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import axios from 'axios';
|
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
import CoverArt from './CoverArt.vue';
|
import CoverArt from './CoverArt.vue';
|
||||||
import Queue from './Queue.vue';
|
import Queue from './Queue.vue';
|
||||||
@@ -102,13 +101,8 @@ function formatTime(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateProgress() {
|
function updateProgress() {
|
||||||
const bot = store.activeBot;
|
// Use store.elapsed which interpolates from server ground truth
|
||||||
const seekOff = store.seekOffset;
|
currentElapsed.value = store.elapsed;
|
||||||
if (bot?.playing && !bot.paused && store.playStartedAt > 0) {
|
|
||||||
currentElapsed.value = seekOff + (Date.now() - store.playStartedAt) / 1000;
|
|
||||||
} else if (bot?.paused) {
|
|
||||||
currentElapsed.value = store.pausedElapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = currentSong.value?.duration ?? 0;
|
const duration = currentSong.value?.duration ?? 0;
|
||||||
progressPercent.value = duration > 0
|
progressPercent.value = duration > 0
|
||||||
@@ -120,20 +114,12 @@ function updateProgress() {
|
|||||||
|
|
||||||
async function onProgressClick(e: MouseEvent) {
|
async function onProgressClick(e: MouseEvent) {
|
||||||
const bar = progressBarRef.value;
|
const bar = progressBarRef.value;
|
||||||
if (!bar || !store.activeBotId) return;
|
if (!bar) return;
|
||||||
const rect = bar.getBoundingClientRect();
|
const rect = bar.getBoundingClientRect();
|
||||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
const duration = currentSong.value?.duration ?? 0;
|
const duration = currentSong.value?.duration ?? 0;
|
||||||
const seekTime = ratio * duration;
|
const seekTime = ratio * duration;
|
||||||
|
await store.seek(seekTime);
|
||||||
try {
|
|
||||||
await axios.post(`/api/player/${store.activeBotId}/seek`, { position: seekTime });
|
|
||||||
store.seekOffset = seekTime;
|
|
||||||
store.playStartedAt = Date.now();
|
|
||||||
store.pausedElapsed = 0;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onProgressHover(e: MouseEvent) {
|
function onProgressHover(e: MouseEvent) {
|
||||||
|
|||||||
+72
-53
@@ -21,8 +21,7 @@ export interface BotStatus {
|
|||||||
queueSize: number;
|
queueSize: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
playMode: string;
|
playMode: string;
|
||||||
seekOffset?: number;
|
elapsed?: number;
|
||||||
playStartTime?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaylistItem {
|
export interface PlaylistItem {
|
||||||
@@ -33,7 +32,7 @@ export interface PlaylistItem {
|
|||||||
platform: string;
|
platform: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOME_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
const HOME_CACHE_TTL = 5 * 60 * 1000;
|
||||||
|
|
||||||
export const usePlayerStore = defineStore('player', {
|
export const usePlayerStore = defineStore('player', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -41,10 +40,13 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
activeBotId: null as string | null,
|
activeBotId: null as string | null,
|
||||||
queue: [] as Song[],
|
queue: [] as Song[],
|
||||||
theme: 'dark' as 'dark' | 'light',
|
theme: 'dark' as 'dark' | 'light',
|
||||||
playStartedAt: 0, // client timestamp when playback started
|
|
||||||
seekOffset: 0, // server seek offset in seconds
|
// Server-synced elapsed time (ground truth)
|
||||||
pausedElapsed: 0, // elapsed seconds when paused
|
serverElapsed: 0, // last known server elapsed (seconds)
|
||||||
// Home page cached data
|
serverSyncTime: 0, // Date.now() when we got serverElapsed
|
||||||
|
wasPlaying: false, // was playing at last sync
|
||||||
|
|
||||||
|
// Home page cache
|
||||||
recommendPlaylists: [] as PlaylistItem[],
|
recommendPlaylists: [] as PlaylistItem[],
|
||||||
dailySongs: [] as Song[],
|
dailySongs: [] as Song[],
|
||||||
userPlaylists: [] as PlaylistItem[],
|
userPlaylists: [] as PlaylistItem[],
|
||||||
@@ -64,11 +66,12 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
isPaused(): boolean {
|
isPaused(): boolean {
|
||||||
return this.activeBot?.paused ?? false;
|
return this.activeBot?.paused ?? false;
|
||||||
},
|
},
|
||||||
|
/** Interpolated elapsed: serverElapsed + time since last sync (if playing) */
|
||||||
elapsed(): number {
|
elapsed(): number {
|
||||||
if (!this.activeBot?.currentSong) return 0;
|
if (!this.activeBot?.currentSong) return 0;
|
||||||
if (this.isPaused) return this.pausedElapsed;
|
if (!this.wasPlaying || this.serverSyncTime === 0) return this.serverElapsed;
|
||||||
if (!this.isPlaying || this.playStartedAt === 0) return 0;
|
if (this.isPaused) return this.serverElapsed;
|
||||||
return this.seekOffset + (Date.now() - this.playStartedAt) / 1000;
|
return this.serverElapsed + (Date.now() - this.serverSyncTime) / 1000;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -80,9 +83,6 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
updateBotStatus(botId: string, status: BotStatus) {
|
updateBotStatus(botId: string, status: BotStatus) {
|
||||||
const prev = this.bots.find((b) => b.id === botId);
|
const prev = this.bots.find((b) => b.id === botId);
|
||||||
const prevSongId = prev?.currentSong?.id;
|
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);
|
const index = this.bots.findIndex((b) => b.id === botId);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@@ -93,28 +93,18 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
|
|
||||||
if (botId !== (this.activeBotId ?? this.bots[0]?.id)) return;
|
if (botId !== (this.activeBotId ?? this.bots[0]?.id)) return;
|
||||||
|
|
||||||
// Use server-provided seekOffset
|
// Sync elapsed from server status
|
||||||
const serverSeek = status.seekOffset ?? 0;
|
if (status.elapsed !== undefined) {
|
||||||
|
this.serverElapsed = status.elapsed;
|
||||||
// Song changed or seek offset changed — reset timer
|
this.serverSyncTime = Date.now();
|
||||||
if ((newSongId && newSongId !== prevSongId) || serverSeek !== this.seekOffset) {
|
this.wasPlaying = status.playing && !status.paused;
|
||||||
this.seekOffset = serverSeek;
|
|
||||||
this.playStartedAt = Date.now();
|
|
||||||
this.pausedElapsed = 0;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resumed
|
// Song changed — reset
|
||||||
if (status.playing && !status.paused && prevPaused) {
|
if (status.currentSong?.id !== prevSongId) {
|
||||||
this.playStartedAt = Date.now() - this.pausedElapsed * 1000;
|
this.serverElapsed = status.elapsed ?? 0;
|
||||||
return;
|
this.serverSyncTime = Date.now();
|
||||||
}
|
this.wasPlaying = status.playing && !status.paused;
|
||||||
|
|
||||||
// Paused
|
|
||||||
if (status.paused && prevPlaying && !prevPaused) {
|
|
||||||
this.pausedElapsed = this.playStartedAt > 0
|
|
||||||
? this.seekOffset + (Date.now() - this.playStartedAt) / 1000
|
|
||||||
: this.seekOffset;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -142,10 +132,25 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
if (!this.activeBotId && this.bots.length > 0) {
|
if (!this.activeBotId && this.bots.length > 0) {
|
||||||
this.activeBotId = this.bots[0].id;
|
this.activeBotId = this.bots[0].id;
|
||||||
}
|
}
|
||||||
// If a song is playing, set the start time
|
// Sync elapsed from first status
|
||||||
const bot = this.activeBot;
|
const bot = this.activeBot;
|
||||||
if (bot?.playing && bot.currentSong && this.playStartedAt === 0) {
|
if (bot?.elapsed !== undefined) {
|
||||||
this.playStartedAt = Date.now();
|
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) {
|
_syncAfterAction() {
|
||||||
this.seekOffset = seekSec;
|
this.serverSyncTime = Date.now();
|
||||||
this.playStartedAt = Date.now();
|
this.wasPlaying = true;
|
||||||
this.pausedElapsed = 0;
|
// Sync from server after a short delay for accuracy
|
||||||
|
setTimeout(() => this.syncElapsed(), 500);
|
||||||
},
|
},
|
||||||
|
|
||||||
async play(query: string, platform = 'netease') {
|
async play(query: string, platform = 'netease') {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/play`, { query, platform });
|
await axios.post(`/api/player/${this.activeBotId}/play`, { query, platform });
|
||||||
this._resetTiming();
|
this.serverElapsed = 0;
|
||||||
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
async playById(songId: string, platform = 'netease') {
|
async playById(songId: string, platform = 'netease') {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/play-by-id`, { songId, platform });
|
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') {
|
async addToQueue(query: string, platform = 'netease') {
|
||||||
@@ -190,41 +198,53 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
async playPlaylist(playlistId: string, platform = 'netease') {
|
async playPlaylist(playlistId: string, platform = 'netease') {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
||||||
this._resetTiming();
|
this.serverElapsed = 0;
|
||||||
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
async pause() {
|
async pause() {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
// Save current elapsed including seekOffset
|
// Freeze elapsed at current interpolated value
|
||||||
this.pausedElapsed = this.elapsed;
|
this.serverElapsed = this.elapsed;
|
||||||
|
this.wasPlaying = false;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/pause`);
|
await axios.post(`/api/player/${this.activeBotId}/pause`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resume() {
|
async resume() {
|
||||||
if (!this.activeBotId) return;
|
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`);
|
await axios.post(`/api/player/${this.activeBotId}/resume`);
|
||||||
|
this.serverSyncTime = Date.now();
|
||||||
|
this.wasPlaying = true;
|
||||||
|
setTimeout(() => this.syncElapsed(), 300);
|
||||||
},
|
},
|
||||||
|
|
||||||
async next() {
|
async next() {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/next`);
|
await axios.post(`/api/player/${this.activeBotId}/next`);
|
||||||
this._resetTiming();
|
this.serverElapsed = 0;
|
||||||
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
async prev() {
|
async prev() {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/prev`);
|
await axios.post(`/api/player/${this.activeBotId}/prev`);
|
||||||
this._resetTiming();
|
this.serverElapsed = 0;
|
||||||
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/stop`);
|
await axios.post(`/api/player/${this.activeBotId}/stop`);
|
||||||
this.seekOffset = 0;
|
this.serverElapsed = 0;
|
||||||
this.playStartedAt = 0;
|
this.serverSyncTime = 0;
|
||||||
this.pausedElapsed = 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) {
|
async setVolume(volume: number) {
|
||||||
@@ -238,7 +258,6 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchHomeData() {
|
async fetchHomeData() {
|
||||||
// Skip if data was fetched within the last 5 minutes
|
|
||||||
if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) {
|
if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user