feat: implement seeking (FFmpeg -ss restart), fix lyrics sync with seekOffset

- AudioPlayer.seek() restarts FFmpeg with -ss parameter for actual seeking
- New POST /api/player/:botId/seek endpoint
- BotStatus includes seekOffset and playStartTime from server
- Frontend elapsed = seekOffset + (now - playStartedAt)
- Progress bar click now seeks to clicked position
- All timing actions use _resetTiming() helper with seekOffset
- Lyrics sync uses store.elapsed which includes seekOffset

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:16:37 +08:00
1 parent 080c2836c9
commit 1cdface33c
5 files changed
+92 -41

No files matched your search

+27 -7
View File
@@ -22,6 +22,8 @@ export class AudioPlayer extends EventEmitter {
private logger: Logger; private logger: Logger;
private frameLoopRunning = false; private frameLoopRunning = false;
private nextFrameTime = 0; private nextFrameTime = 0;
private currentUrl = "";
private seekOffset = 0; // seconds offset from seek
constructor(logger: Logger) { constructor(logger: Logger) {
super(); super();
@@ -29,27 +31,32 @@ export class AudioPlayer extends EventEmitter {
this.logger = logger; this.logger = logger;
} }
play(url: string): void { play(url: string, seekSeconds = 0): void {
this.stop(); this.stop();
this.currentUrl = url;
this.seekOffset = seekSeconds;
this.logger.info({ url: url.slice(0, 80) }, "Starting playback"); this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
this.ffmpeg = spawn( const args = [
"ffmpeg",
[
"-reconnect", "1", "-reconnect", "1",
"-reconnect_streamed", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "5", "-reconnect_delay_max", "5",
];
if (seekSeconds > 0) {
args.push("-ss", String(seekSeconds));
}
args.push(
"-i", url, "-i", url,
"-f", "s16le", "-f", "s16le",
"-ar", "48000", "-ar", "48000",
"-ac", "2", "-ac", "2",
"-acodec", "pcm_s16le", "-acodec", "pcm_s16le",
"-", "-",
],
{ 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 // Suppress FFmpeg stderr output
}); });
@@ -159,6 +166,17 @@ export class AudioPlayer extends EventEmitter {
} }
} }
/** 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) {
@@ -167,6 +185,8 @@ export class AudioPlayer extends EventEmitter {
} }
this.pcmBuffer = Buffer.alloc(0); this.pcmBuffer = Buffer.alloc(0);
this.state = "idle"; this.state = "idle";
this.currentUrl = "";
this.seekOffset = 0;
} }
setVolume(vol: number): void { setVolume(vol: number): void {
+4
View File
@@ -37,6 +37,8 @@ export interface BotStatus {
queueSize: number; queueSize: number;
volume: number; volume: number;
playMode: PlayMode; playMode: PlayMode;
seekOffset: number; // seconds offset from last seek/play start
playStartTime: number; // server timestamp when playback started
} }
export class BotInstance extends EventEmitter { export class BotInstance extends EventEmitter {
@@ -505,6 +507,8 @@ 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(),
playStartTime: Date.now(),
}; };
} }
+16
View File
@@ -104,6 +104,22 @@ export function createPlayerRouter(
} }
}); });
// Seek to position
router.post("/:botId/seek", async (req, res) => {
try {
const bot = (req as any).bot;
const { position } = req.body; // seconds
if (typeof position !== "number" || position < 0) {
res.status(400).json({ error: "position (seconds) is required" });
return;
}
bot.getPlayer().seek(position);
res.json({ message: `Seeked to ${Math.floor(position)}s`, seekOffset: position });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/:botId/queue", (req, res) => { router.get("/:botId/queue", (req, res) => {
const bot = (req as any).bot; const bot = (req as any).bot;
res.json({ queue: bot.getQueue(), status: bot.getStatus() }); res.json({ queue: bot.getQueue(), status: bot.getStatus() });
+14 -8
View File
@@ -74,6 +74,7 @@
<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';
@@ -101,10 +102,10 @@ function formatTime(seconds: number): string {
} }
function updateProgress() { function updateProgress() {
// Directly compute elapsed from raw values (bypass Vue reactivity)
const bot = store.activeBot; const bot = store.activeBot;
const seekOff = store.seekOffset;
if (bot?.playing && !bot.paused && store.playStartedAt > 0) { if (bot?.playing && !bot.paused && store.playStartedAt > 0) {
currentElapsed.value = (Date.now() - store.playStartedAt) / 1000; currentElapsed.value = seekOff + (Date.now() - store.playStartedAt) / 1000;
} else if (bot?.paused) { } else if (bot?.paused) {
currentElapsed.value = store.pausedElapsed; currentElapsed.value = store.pausedElapsed;
} }
@@ -117,17 +118,22 @@ function updateProgress() {
rafId = requestAnimationFrame(updateProgress); rafId = requestAnimationFrame(updateProgress);
} }
function onProgressClick(e: MouseEvent) { async function onProgressClick(e: MouseEvent) {
const bar = progressBarRef.value; const bar = progressBarRef.value;
if (!bar) return; if (!bar || !store.activeBotId) 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;
// Can't seek server-side FFmpeg stream — show tooltip with target time
progressTooltipTime.value = `跳转不可用 (${formatTime(seekTime)})`; try {
progressTooltipVisible.value = true; await axios.post(`/api/player/${store.activeBotId}/seek`, { position: seekTime });
setTimeout(() => { progressTooltipVisible.value = false; }, 1500); store.seekOffset = seekTime;
store.playStartedAt = Date.now();
store.pausedElapsed = 0;
} catch {
// ignore
}
} }
function onProgressHover(e: MouseEvent) { function onProgressHover(e: MouseEvent) {
+31 -26
View File
@@ -21,6 +21,8 @@ export interface BotStatus {
queueSize: number; queueSize: number;
volume: number; volume: number;
playMode: string; playMode: string;
seekOffset?: number;
playStartTime?: number;
} }
export interface PlaylistItem { export interface PlaylistItem {
@@ -39,8 +41,9 @@ 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, // timestamp when current song started playStartedAt: 0, // client timestamp when playback started
pausedElapsed: 0, // elapsed time when paused seekOffset: 0, // server seek offset in seconds
pausedElapsed: 0, // elapsed seconds when paused
// Home page cached data // Home page cached data
recommendPlaylists: [] as PlaylistItem[], recommendPlaylists: [] as PlaylistItem[],
dailySongs: [] as Song[], dailySongs: [] as Song[],
@@ -61,12 +64,11 @@ export const usePlayerStore = defineStore('player', {
isPaused(): boolean { isPaused(): boolean {
return this.activeBot?.paused ?? false; return this.activeBot?.paused ?? false;
}, },
/** Elapsed playback time in seconds */
elapsed(): number { elapsed(): number {
if (!this.activeBot?.currentSong) return 0; if (!this.activeBot?.currentSong) return 0;
if (this.isPaused) return this.pausedElapsed; if (this.isPaused) return this.pausedElapsed;
if (!this.isPlaying || this.playStartedAt === 0) return 0; if (!this.isPlaying || this.playStartedAt === 0) return 0;
return (Date.now() - this.playStartedAt) / 1000; return this.seekOffset + (Date.now() - this.playStartedAt) / 1000;
}, },
}, },
@@ -76,14 +78,12 @@ export const usePlayerStore = defineStore('player', {
}, },
updateBotStatus(botId: string, status: BotStatus) { updateBotStatus(botId: string, status: BotStatus) {
// Capture previous state BEFORE updating
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 prevPaused = prev?.paused ?? false;
const prevPlaying = prev?.playing ?? false; const prevPlaying = prev?.playing ?? false;
const newSongId = status.currentSong?.id; const newSongId = status.currentSong?.id;
// Update the bot status
const index = this.bots.findIndex((b) => b.id === botId); const index = this.bots.findIndex((b) => b.id === botId);
if (index >= 0) { if (index >= 0) {
this.bots[index] = status; this.bots[index] = status;
@@ -91,17 +91,20 @@ export const usePlayerStore = defineStore('player', {
this.bots.push(status); this.bots.push(status);
} }
// Only adjust timing for the active bot
if (botId !== (this.activeBotId ?? this.bots[0]?.id)) return; if (botId !== (this.activeBotId ?? this.bots[0]?.id)) return;
// Song changed — reset timer // Use server-provided seekOffset
if (newSongId && newSongId !== prevSongId) { 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.playStartedAt = Date.now();
this.pausedElapsed = 0; this.pausedElapsed = 0;
return; return;
} }
// Resumed from pause // Resumed
if (status.playing && !status.paused && prevPaused) { if (status.playing && !status.paused && prevPaused) {
this.playStartedAt = Date.now() - this.pausedElapsed * 1000; this.playStartedAt = Date.now() - this.pausedElapsed * 1000;
return; return;
@@ -110,8 +113,8 @@ export const usePlayerStore = defineStore('player', {
// Paused // Paused
if (status.paused && prevPlaying && !prevPaused) { if (status.paused && prevPlaying && !prevPaused) {
this.pausedElapsed = this.playStartedAt > 0 this.pausedElapsed = this.playStartedAt > 0
? (Date.now() - this.playStartedAt) / 1000 ? this.seekOffset + (Date.now() - this.playStartedAt) / 1000
: 0; : this.seekOffset;
} }
}, },
@@ -156,18 +159,22 @@ export const usePlayerStore = defineStore('player', {
} }
}, },
_resetTiming(seekSec = 0) {
this.seekOffset = seekSec;
this.playStartedAt = Date.now();
this.pausedElapsed = 0;
},
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.playStartedAt = Date.now(); this._resetTiming();
this.pausedElapsed = 0;
}, },
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.playStartedAt = Date.now(); this._resetTiming();
this.pausedElapsed = 0;
}, },
async addToQueue(query: string, platform = 'netease') { async addToQueue(query: string, platform = 'netease') {
@@ -183,41 +190,39 @@ 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.playStartedAt = Date.now(); this._resetTiming();
this.pausedElapsed = 0;
}, },
async pause() { async pause() {
if (!this.activeBotId) return; if (!this.activeBotId) return;
this.pausedElapsed = this.playStartedAt > 0 // Save current elapsed including seekOffset
? (Date.now() - this.playStartedAt) / 1000 this.pausedElapsed = this.elapsed;
: 0;
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;
this.playStartedAt = Date.now() - this.pausedElapsed * 1000; // 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`);
}, },
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.playStartedAt = Date.now(); this._resetTiming();
this.pausedElapsed = 0;
}, },
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.playStartedAt = Date.now(); this._resetTiming();
this.pausedElapsed = 0;
}, },
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.playStartedAt = 0; this.playStartedAt = 0;
this.pausedElapsed = 0; this.pausedElapsed = 0;
}, },