fix: Random mode now stops after all songs played instead of looping forever

In Random (shuffle) mode, the queue's next() method would return the same
song indefinitely when only one song was in the playlist, and never terminate
even with multiple songs. This happened because played songs were not tracked.

Added a playedIndices Set to track which songs have already been played in
Random mode. Once all songs have been played once, next() returns null to
stop playback — matching the expected behavior where Random plays each song
once in random order, while RandomLoop is the mode for infinite shuffling.

https://claude.ai/code/session_01W3ZncxL5VfdZeB4qqYWDmY
This commit is contained in:
Claude committed 2026-04-12 14:53:15 +00:00
1 parent 78380f27d4
commit 4eac2e4dde
2 files changed
+49 -12

No files matched your search

+26
View File
@@ -150,6 +150,32 @@ describe("PlayQueue", () => {
expect(next).not.toBeNull(); expect(next).not.toBeNull();
}); });
it("random mode with single song returns null on next", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("1"));
queue.play();
expect(queue.next()).toBeNull();
});
it("random mode plays each song exactly once then stops", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
const played = new Set<string>();
played.add(queue.current()!.id);
for (let i = 0; i < 3; i++) {
const song = queue.next();
if (!song) break;
played.add(song.id);
}
// All 3 songs should have been played
expect(played).toEqual(new Set(["A", "B", "C"]));
// next() after all played should return null
expect(queue.next()).toBeNull();
});
it("random-loop mode never returns null", () => { it("random-loop mode never returns null", () => {
queue.setMode(PlayMode.RandomLoop); queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("1")); queue.add(makeSong("1"));
+23 -12
View File
@@ -20,6 +20,7 @@ export class PlayQueue {
private songs: QueuedSong[] = []; private songs: QueuedSong[] = [];
private currentIndex = -1; private currentIndex = -1;
private mode: PlayMode = PlayMode.Sequential; private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
add(song: QueuedSong): void { add(song: QueuedSong): void {
this.songs.push(song); this.songs.push(song);
@@ -36,32 +37,36 @@ export class PlayQueue {
if (index < this.currentIndex) { if (index < this.currentIndex) {
this.currentIndex--; this.currentIndex--;
} else if (index === this.currentIndex) { } else if (index === this.currentIndex) {
// Move the pointer back by one so next() in sequential mode advances
// to the song that shifted into the removed slot. Without this, the
// shifted song is silently skipped because current() incorrectly
// returns it (even though the player is still on the removed song)
// and next() then increments past it. currentIndex may become -1,
// which is fine — it represents "no current song" and next() will
// pick index 0.
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;
return removed; return removed;
} }
clear(): void { clear(): void {
this.songs = []; this.songs = [];
this.currentIndex = -1; this.currentIndex = -1;
this.playedIndices.clear();
} }
play(): QueuedSong | null { play(): QueuedSong | null {
if (this.songs.length === 0) return null; if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.currentIndex = 0; this.currentIndex = 0;
return this.songs[0]; return this.songs[0];
} }
playAt(index: number): QueuedSong | null { playAt(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null; if (index < 0 || index >= this.songs.length) return null;
this.playedIndices.clear();
this.currentIndex = index; this.currentIndex = index;
return this.songs[index]; return this.songs[index];
} }
@@ -81,11 +86,16 @@ export class PlayQueue {
return this.songs[this.currentIndex]; return this.songs[this.currentIndex];
} }
case PlayMode.Random: { case PlayMode.Random: {
if (this.songs.length === 1) return this.songs[0]; if (this.currentIndex >= 0) {
let nextIndex: number; this.playedIndices.add(this.currentIndex);
do { }
nextIndex = Math.floor(Math.random() * this.songs.length); const unplayed: number[] = [];
} while (nextIndex === this.currentIndex && this.songs.length > 1); 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.currentIndex = nextIndex; this.currentIndex = nextIndex;
return this.songs[nextIndex]; return this.songs[nextIndex];
} }
@@ -141,6 +151,7 @@ export class PlayQueue {
setMode(mode: PlayMode): void { setMode(mode: PlayMode): void {
this.mode = mode; this.mode = mode;
this.playedIndices.clear();
} }
getCurrentIndex(): number { getCurrentIndex(): number {