mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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:
1 parent
080c2836c9
commit
1cdface33c
5 files changed
+101
-50
No files matched your search
+36
-16
@@ -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_streamed", "1",
|
||||||
"-reconnect", "1",
|
"-reconnect_delay_max", "5",
|
||||||
"-reconnect_streamed", "1",
|
];
|
||||||
"-reconnect_delay_max", "5",
|
if (seekSeconds > 0) {
|
||||||
"-i", url,
|
args.push("-ss", String(seekSeconds));
|
||||||
"-f", "s16le",
|
}
|
||||||
"-ar", "48000",
|
args.push(
|
||||||
"-ac", "2",
|
"-i", url,
|
||||||
"-acodec", "pcm_s16le",
|
"-f", "s16le",
|
||||||
"-",
|
"-ar", "48000",
|
||||||
],
|
"-ac", "2",
|
||||||
{ stdio: ["ignore", "pipe", "pipe"] }
|
"-acodec", "pcm_s16le",
|
||||||
|
"-",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user