Merge pull request #78 from ZHANGTIANYAO1/feat/shuffle-bag-random-modes

feat(queue): 随机循环改为洗牌袋,每首歌播完一轮再重复 (优化随机循环逻辑)
This commit is contained in:
TIANYAO ZHANG authored and GitHub committed 2026-05-29 22:16:35 +08:00
commit a861b41809
2 files changed
+106 -17

No files matched your search

+80
View File
@@ -484,4 +484,84 @@ describe("PlayQueue", () => {
expect(promoted?.id).toBe("x"); expect(promoted?.id).toBe("x");
}); });
}); });
// Issue #70: 随机循环 (rloop) used true random-with-replacement, so some
// songs repeated often while others were starved. It should behave like a
// shuffle bag (NetEase/QQ style): play every song once per cycle in random
// order, then reshuffle and continue, avoiding an immediate cross-cycle repeat.
describe("random-loop shuffle bag (issue #70)", () => {
it("plays every song exactly once per cycle before repeating", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 12;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
const cycle1 = [queue.current()!.id];
for (let i = 0; i < N - 1; i++) cycle1.push(queue.next()!.id);
const cycle2: string[] = [];
for (let i = 0; i < N; i++) cycle2.push(queue.next()!.id);
// Each cycle is a full permutation of all N songs — zero repeats within
// a cycle, and both cycles cover the same complete set.
expect(new Set(cycle1).size).toBe(N);
expect(new Set(cycle2).size).toBe(N);
expect(new Set(cycle1)).toEqual(new Set(cycle2));
});
it("distributes plays evenly across songs over many cycles (no starvation)", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 6;
const CYCLES = 20;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
const counts = new Map<string, number>();
counts.set(queue.current()!.id, 1);
for (let i = 0; i < CYCLES * N - 1; i++) {
const id = queue.next()!.id;
counts.set(id, (counts.get(id) ?? 0) + 1);
}
// Shuffle bag => each song plays exactly CYCLES times. True random
// would skew heavily.
for (let i = 0; i < N; i++) {
expect(counts.get(`s${i}`)).toBe(CYCLES);
}
});
it("does not replay the same song across a cycle boundary", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 5;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
// Walk to the last song of cycle 1, then cross into cycle 2.
for (let i = 0; i < N - 1; i++) queue.next();
const lastOfCycle1 = queue.current()!.id;
const firstOfCycle2 = queue.next()!.id;
expect(firstOfCycle2).not.toBe(lastOfCycle1);
});
it("includes a song added mid-cycle within the current cycle", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // A
queue.next(); // B — both originals now played this cycle
queue.add(makeSong("C")); // added mid-cycle, still unplayed
// C is the only unplayed song, so it must come next (not a reshuffle).
expect(queue.next()?.id).toBe("C");
});
it("keeps looping forever with multiple songs (never returns null)", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
for (let i = 0; i < 30; i++) {
expect(queue.next()).not.toBeNull();
}
});
});
}); });
+26 -17
View File
@@ -155,33 +155,42 @@ export class PlayQueue {
return this.songs[target]; return this.songs[target];
} }
} }
// 前进栈为空,走纯随机逻辑
if (this.mode === PlayMode.Random) { // Shuffle bag: pick uniformly from the songs not yet played this
// cycle, so every song plays once before any repeats (NetEase/QQ
// style). Songs added mid-cycle aren't in playedIndices, so they're
// naturally eligible within the current cycle.
const unplayed: number[] = []; const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) { for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i); if (!this.playedIndices.has(i)) unplayed.push(i);
} }
if (unplayed.length === 0) return null;
if (unplayed.length === 0) {
// Cycle complete.
if (this.mode === PlayMode.Random) return null; // 随机:播完即停
// 随机循环:reshuffle and keep going forever.
if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
this.playedIndices = new Set([0]);
return this.songs[0];
}
// Start a fresh cycle: every song is eligible again, but exclude
// the song that just played from THIS pick only, so it doesn't
// repeat back-to-back across the boundary. It stays eligible for
// the rest of the new cycle, so every song still plays exactly once.
this.playedIndices = new Set();
for (let i = 0; i < this.songs.length; i++) {
if (i !== this.currentIndex) unplayed.push(i);
}
}
const nextIndex = const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)]; unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex); this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex; this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex); this.playedIndices.add(nextIndex);
return this.songs[nextIndex]; return this.songs[nextIndex];
} else {
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];
}
} }
} }
} }