Files
teamspeak-music-bot/src/audio/queue.ts
T
saopig1andClaude Opus 4.7 11c6a3f51b feat(queue): addNext inserts a song to play right after current
Splices into currentIndex+1 and shifts both playedIndices and the
history back-stack to keep references valid. Falls through to plain
push when nothing is playing so the idle-bot "add → start playing"
flow is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:34:32 +08:00

244 lines
6.8 KiB
TypeScript

export enum PlayMode {
Sequential = "seq",
Loop = "loop",
Random = "random",
RandomLoop = "rloop",
}
export interface QueuedSong {
id: string;
name: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube";
url?: string; // resolved lazily at play time
coverUrl: string;
duration: number; // seconds
}
export class PlayQueue {
private songs: QueuedSong[] = [];
private currentIndex = -1;
private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
private history: number[] = [];
private static readonly HISTORY_LIMIT = 50;
private pushHistory(idx: number): void {
if (idx < 0 || idx >= this.songs.length) return;
this.history.push(idx);
if (this.history.length > PlayQueue.HISTORY_LIMIT) {
this.history.shift();
}
}
add(song: QueuedSong): void {
this.songs.push(song);
}
addMany(songs: QueuedSong[]): void {
this.songs.push(...songs);
}
/**
* Insert a song to play immediately after the current one. Falls
* through to plain push when nothing is playing yet (currentIndex < 0
* or queue empty), so the existing "add → idle bot starts playing"
* flow continues to work.
*
* Shifts playedIndices and history entries > currentIndex by +1 so
* their references stay valid after the splice.
*/
addNext(song: QueuedSong): void {
if (this.currentIndex < 0 || this.songs.length === 0) {
this.songs.push(song);
return;
}
const insertAt = this.currentIndex + 1;
this.songs.splice(insertAt, 0, song);
const shifted = new Set<number>();
for (const i of this.playedIndices) {
shifted.add(i > this.currentIndex ? i + 1 : i);
}
this.playedIndices = shifted;
this.history = this.history.map((i) =>
i > this.currentIndex ? i + 1 : i,
);
}
remove(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null;
const [removed] = this.songs.splice(index, 1);
if (index < this.currentIndex) {
this.currentIndex--;
} else if (index === this.currentIndex) {
this.currentIndex--;
}
// Rebuild playedIndices to account for shifted indices
const newPlayed = new Set<number>();
for (const idx of this.playedIndices) {
if (idx === index) continue;
newPlayed.add(idx > index ? idx - 1 : idx);
}
this.playedIndices = newPlayed;
// Same shift logic for history — drop entries pointing at the
// removed song; shift entries > index down by 1.
this.history = this.history
.filter((idx) => idx !== index)
.map((idx) => (idx > index ? idx - 1 : idx));
return removed;
}
clear(): void {
this.songs = [];
this.currentIndex = -1;
this.playedIndices.clear();
this.history = [];
}
play(): QueuedSong | null {
if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.history = [];
this.currentIndex = 0;
this.playedIndices.add(0);
return this.songs[0];
}
playAt(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null;
this.pushHistory(this.currentIndex);
// Reset the Random-mode "unplayed" pool — explicit picks restart
// shuffle from this point. History tracking is independent and
// unaffected by this clear.
this.playedIndices.clear();
this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index];
}
next(): QueuedSong | null {
if (this.songs.length === 0) return null;
switch (this.mode) {
case PlayMode.Sequential: {
const nextIndex = this.currentIndex + 1;
if (nextIndex >= this.songs.length) return null;
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex;
return this.songs[nextIndex];
}
case PlayMode.Loop: {
this.pushHistory(this.currentIndex);
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
return this.songs[this.currentIndex];
}
case PlayMode.Random: {
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
case PlayMode.RandomLoop: {
if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
return this.songs[0];
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.pushHistory(this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
}
}
}
prev(): QueuedSong | null {
if (this.songs.length === 0) return null;
// Preferred: pop from the back-stack so prev means "the song I
// actually played before this one," not "the previous array slot."
while (this.history.length > 0) {
const idx = this.history.pop()!;
if (idx >= 0 && idx < this.songs.length) {
this.currentIndex = idx;
this.playedIndices.add(idx);
return this.songs[idx];
}
// Stale entry (song removed) — keep popping.
}
// Fallback: no history to walk back through. In Sequential we
// can still meaningfully step the index backward; in random
// modes there's nothing useful to return.
if (this.mode === PlayMode.Random || this.mode === PlayMode.RandomLoop) {
return null;
}
const prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
if (this.mode === PlayMode.Sequential) return null;
this.currentIndex = this.songs.length - 1;
} else {
this.currentIndex = prevIndex;
}
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex];
}
current(): QueuedSong | null {
if (this.currentIndex < 0 || this.currentIndex >= this.songs.length)
return null;
return this.songs[this.currentIndex];
}
list(): QueuedSong[] {
return [...this.songs];
}
size(): number {
return this.songs.length;
}
isEmpty(): boolean {
return this.songs.length === 0;
}
getMode(): PlayMode {
return this.mode;
}
setMode(mode: PlayMode): void {
this.mode = mode;
this.playedIndices.clear();
this.history = [];
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
}
getCurrentIndex(): number {
return this.currentIndex;
}
/** Number of songs not yet played in Random mode. */
unplayedCount(): number {
return this.songs.length - this.playedIndices.size;
}
}