Merge pull request #27 from ZHANGTIANYAO1/claude/fix-shuffle-loop-bug-FrIbI

Fix random mode to track played songs and prevent duplicates
This commit is contained in:
TIANYAO ZHANG authored and GitHub committed 2026-04-12 23:09:33 +08:00
commit 05edca4a9a
2 files changed
+122 -12

No files matched your search

+95
View File
@@ -150,6 +150,101 @@ 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 mode: removing currently-playing song does not skip others", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.add(makeSong("D"));
queue.play(); // plays A (index 0)
const second = queue.next()!; // plays some song
// Remove the currently-playing song
const curIdx = queue.getCurrentIndex();
queue.remove(curIdx);
// Remaining songs (excluding A and the removed song) should all be reachable
const played = new Set<string>();
played.add("A"); // already played via play()
played.add(second.id); // played and then removed
let song = queue.next();
while (song) {
played.add(song.id);
song = queue.next();
}
// All 4 original songs should have been played or accounted for
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
});
it("random mode: prev does not cause duplicate plays", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play(); // plays A
queue.next(); // plays B or C
queue.prev(); // go back — this song is now marked as played
// Exhaust remaining songs
const ids: string[] = [];
let song = queue.next();
while (song) {
ids.push(song.id);
song = queue.next();
}
// No song ID should appear more than once across the entire session
expect(new Set(ids).size).toBe(ids.length);
});
it("random mode: adding song mid-playback includes the new song", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A
queue.next(); // plays B
// Add a new song while all existing songs have been played
queue.add(makeSong("C"));
const song = queue.next();
expect(song).not.toBeNull();
expect(song!.id).toBe("C");
// After C, should stop
expect(queue.next()).toBeNull();
});
it("random mode: setMode preserves current song as played", () => {
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A in sequential mode
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
// next() should only return B, never A again
const song = queue.next();
expect(song?.id).toBe("B");
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"));
+27 -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,33 +37,39 @@ 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;
this.playedIndices.add(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;
this.playedIndices.add(index);
return this.songs[index]; return this.songs[index];
} }
@@ -81,12 +88,15 @@ 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]; const unplayed: number[] = [];
let nextIndex: number; for (let i = 0; i < this.songs.length; i++) {
do { if (!this.playedIndices.has(i)) unplayed.push(i);
nextIndex = Math.floor(Math.random() * this.songs.length); }
} while (nextIndex === this.currentIndex && this.songs.length > 1); if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.currentIndex = nextIndex; this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex]; return this.songs[nextIndex];
} }
case PlayMode.RandomLoop: { case PlayMode.RandomLoop: {
@@ -114,6 +124,7 @@ export class PlayQueue {
} else { } else {
this.currentIndex = prevIndex; this.currentIndex = prevIndex;
} }
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex]; return this.songs[this.currentIndex];
} }
@@ -141,6 +152,10 @@ export class PlayQueue {
setMode(mode: PlayMode): void { setMode(mode: PlayMode): void {
this.mode = mode; this.mode = mode;
this.playedIndices.clear();
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
} }
getCurrentIndex(): number { getCurrentIndex(): number {