From 1cdface33c88fd7f4bf31e629ee33a574a918526 Mon Sep 17 00:00:00 2001 From: saopig1 Date: Mon, 30 Mar 2026 03:16:37 +0800 Subject: [PATCH] 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) --- src/audio/player.ts | 52 ++++++++++++++++++++++---------- src/bot/instance.ts | 4 +++ src/web/api/player.ts | 16 ++++++++++ web/src/components/Player.vue | 22 +++++++++----- web/src/stores/player.ts | 57 +++++++++++++++++++---------------- 5 files changed, 101 insertions(+), 50 deletions(-) diff --git a/src/audio/player.ts b/src/audio/player.ts index a830772..48e7b42 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -22,6 +22,8 @@ export class AudioPlayer extends EventEmitter { private logger: Logger; private frameLoopRunning = false; private nextFrameTime = 0; + private currentUrl = ""; + private seekOffset = 0; // seconds offset from seek constructor(logger: Logger) { super(); @@ -29,27 +31,32 @@ export class AudioPlayer extends EventEmitter { this.logger = logger; } - play(url: string): void { + play(url: string, seekSeconds = 0): void { 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( - "ffmpeg", - [ - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_delay_max", "5", - "-i", url, - "-f", "s16le", - "-ar", "48000", - "-ac", "2", - "-acodec", "pcm_s16le", - "-", - ], - { stdio: ["ignore", "pipe", "pipe"] } + const args = [ + "-reconnect", "1", + "-reconnect_streamed", "1", + "-reconnect_delay_max", "5", + ]; + if (seekSeconds > 0) { + args.push("-ss", String(seekSeconds)); + } + args.push( + "-i", url, + "-f", "s16le", + "-ar", "48000", + "-ac", "2", + "-acodec", "pcm_s16le", + "-", ); + this.ffmpeg = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] }); + this.ffmpeg.stderr!.on("data", () => { // 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 { this.frameLoopRunning = false; if (this.ffmpeg) { @@ -167,6 +185,8 @@ export class AudioPlayer extends EventEmitter { } this.pcmBuffer = Buffer.alloc(0); this.state = "idle"; + this.currentUrl = ""; + this.seekOffset = 0; } setVolume(vol: number): void { diff --git a/src/bot/instance.ts b/src/bot/instance.ts index ca5d760..95f69bd 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -37,6 +37,8 @@ export interface BotStatus { queueSize: number; volume: number; playMode: PlayMode; + seekOffset: number; // seconds offset from last seek/play start + playStartTime: number; // server timestamp when playback started } export class BotInstance extends EventEmitter { @@ -505,6 +507,8 @@ export class BotInstance extends EventEmitter { queueSize: this.queue.size(), volume: this.player.getVolume(), playMode: this.queue.getMode(), + seekOffset: this.player.getSeekOffset(), + playStartTime: Date.now(), }; } diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 388d100..2c6731c 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -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) => { const bot = (req as any).bot; res.json({ queue: bot.getQueue(), status: bot.getStatus() }); diff --git a/web/src/components/Player.vue b/web/src/components/Player.vue index 7b19ee8..ee2dc3d 100644 --- a/web/src/components/Player.vue +++ b/web/src/components/Player.vue @@ -74,6 +74,7 @@