From 390d3fa782ba84a606257e7e19ca934b1a547553 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 16:28:52 +0800 Subject: [PATCH] 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) --- src/audio/queue.test.ts | 103 ++++++++++++++++++++++++++++++++++++++++ src/audio/queue.ts | 36 +++++++++++++- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/audio/queue.test.ts b/src/audio/queue.test.ts index dc58ce5..bb51cd1 100644 --- a/src/audio/queue.test.ts +++ b/src/audio/queue.test.ts @@ -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 + }); + }); }); diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 31b993a..6e1d0fc 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -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); }