feat(queue): history-aware prev that walks real play history

In random modes, prev was just doing currentIndex-1 in the array, which
has no relationship to what the user actually played before. Add a
50-entry back-stack that's pushed by next/playAt, popped by prev, reset
on play/clear/setMode, and shifted by remove. Sequential and Loop modes
keep their old fallback for the case where history is empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.7 committed 2026-05-06 16:28:52 +08:00
1 parent c4b7cfaa2f
commit 390d3fa782
2 files changed
+137 -2

No files matched your search

+103
View File
@@ -261,4 +261,107 @@ describe("PlayQueue", () => {
queue.playAt(2);
expect(queue.current()?.id).toBe("3");
});
describe("history-aware prev", () => {
it("walks back through played indices in random mode", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.add(makeSong("d"));
queue.add(makeSong("e"));
// Force a deterministic random sequence: a → c → e
queue.playAt(0);
queue.playAt(2);
queue.playAt(4);
expect(queue.current()?.id).toBe("e");
// prev pops back through history: e → c → a
expect(queue.prev()?.id).toBe("c");
expect(queue.prev()?.id).toBe("a");
});
it("returns null when history is empty in random mode", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.playAt(0);
// No further moves → history is empty (only 'a' is current, never pushed)
expect(queue.prev()).toBeNull();
});
it("preserves sequential prev when history is empty", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.play();
queue.next(); // currentIndex = 1
// Sequential next() pushed 0 to history → prev pops back to 0
expect(queue.prev()?.id).toBe("a");
});
it("clears history on play()", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.playAt(0);
queue.playAt(1);
queue.play(); // resets to index 0 and clears history
expect(queue.prev()).toBeNull();
});
it("clears history on clear()", () => {
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.play();
queue.next();
queue.clear();
queue.add(makeSong("c"));
queue.play();
// History was wiped — no prev path available beyond index 0
expect(queue.prev()).toBeNull();
});
it("clears history on setMode()", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.play();
queue.next();
// Mode change resets context
queue.setMode(PlayMode.Random);
expect(queue.prev()).toBeNull();
});
it("drops history entries pointing at a removed song", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.playAt(0);
queue.playAt(1); // history: [0]
queue.playAt(2); // history: [0, 1]
// Remove song at index 1 → history entry 1 dropped
queue.remove(1);
// queue is now [a, c], history should be [0]
// current was at 2 → after remove shifts to 1 → song "c"
expect(queue.current()?.id).toBe("c");
expect(queue.prev()?.id).toBe("a");
});
it("does not push to history on prev itself", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.playAt(0);
queue.playAt(1);
queue.playAt(2); // history: [0, 1]
queue.prev(); // pops 1, history: [0]
queue.prev(); // pops 0, history: []
expect(queue.prev()).toBeNull(); // no fallback target in random mode
});
});
});
+34 -2
View File
@@ -58,6 +58,12 @@ export class PlayQueue {
}
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;
}
@@ -65,11 +71,13 @@ export class PlayQueue {
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];
@@ -77,7 +85,7 @@ export class PlayQueue {
playAt(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null;
this.playedIndices.clear();
this.pushHistory(this.currentIndex);
this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index];
@@ -90,10 +98,12 @@ export class PlayQueue {
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];
}
@@ -105,12 +115,14 @@ export class PlayQueue {
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];
}
@@ -118,6 +130,7 @@ export class PlayQueue {
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.pushHistory(this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
}
@@ -126,9 +139,27 @@ export class PlayQueue {
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) {
// In Sequential mode, don't wrap around
if (this.mode === PlayMode.Sequential) return null;
this.currentIndex = this.songs.length - 1;
} else {
@@ -163,6 +194,7 @@ export class PlayQueue {
setMode(mode: PlayMode): void {
this.mode = mode;
this.playedIndices.clear();
this.history = [];
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}