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
+101 -50

No files matched your search

+36 -16
View File
@@ -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 {
+4
View File
@@ -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(),
};
}
+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) => {
const bot = (req as any).bot;
res.json({ queue: bot.getQueue(), status: bot.getStatus() });