Two queue features: - prev walks back through actual play history (50-entry stack), so random modes navigate predictably instead of falling back to the meaningless currentIndex-1 array walk. - addNext inserts at currentIndex+1, exposed via new /play-next-song endpoint, !playnext command, and a third "下一首播放" button on SongCard. Insert path keeps playedIndices and history index refs valid by shifting entries > currentIndex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.6 KiB
History-aware prev + "Play Next" Insert
Date: 2026-05-06 Status: Spec — pending implementation
Problem
Two queue/playback gaps surfaced in real use:
-
In
PlayMode.RandomandPlayMode.RandomLoop,!prevdoes not play the actually-previously-played song. It just walkscurrentIndex - 1in the underlying array — but in random modescurrentIndexjumps non-sequentially, so the "previous" array slot has no relationship to play history. -
!add/ web "添加到队列" appends to the queue tail. There is no way to say "play this song right after the current one." Users want a "下一首 播放" affordance comparable to Spotify "Add to Queue (next up)" or Apple Music "Play Next".
Goals
prevwalks back through the actual play history regardless of mode.- A new "Play Next" path inserts a song at
currentIndex + 1, available via web UI button and TS3 chat command. - Both features are usable on desktop and mobile web.
Out of Scope
- Forward/redo through prev'd songs (user would need to push next manually, which picks a fresh random in random modes — acceptable simplification).
- Reordering songs already in the queue ("move to next" inside Queue.vue).
- Persisting play history across bot restarts (in-memory only).
Non-functional Constraints
- History capped at 50 entries to bound memory.
addNextmust keepplayedIndicesandhistoryindex references valid after insertion (shift all indices > current by +1).- New Toast UX from the previous round still applies (failures surface).
- TypeScript and existing test suite must not regress.
Architecture
A. History-aware prev
src/audio/queue.ts — PlayQueue gains a back-stack:
private history: number[] = [];
private static readonly HISTORY_LIMIT = 50;
private pushHistory(idx: number): void {
if (idx < 0) return;
this.history.push(idx);
if (this.history.length > PlayQueue.HISTORY_LIMIT) {
this.history.shift();
}
}
Mutators call pushHistory(this.currentIndex) before changing currentIndex:
| Method | History action |
|---|---|
play() |
this.history = [] (fresh playback) |
playAt(idx) |
pushHistory(currentIndex), then set currentIndex = idx |
next() |
pushHistory(currentIndex), then advance per mode |
prev() |
Pop from history → currentIndex = popped. If empty, fall back to existing currentIndex - 1 (which keeps Sequential's wrap behavior; Random returns null). prev itself does NOT push to history. |
clear() |
this.history = [] |
setMode(m) |
this.history = [] (mode change resets context) |
remove(idx) |
Drop matching entries from history; shift any entry > idx by -1. Same logic as the existing playedIndices rebuild. |
prev() rewrite:
prev(): QueuedSong | null {
if (this.songs.length === 0) return null;
// History-driven path (preferred when we have one)
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];
}
// popped index is stale (song removed) — keep popping
}
// Fallback: old index-based prev
const prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
if (this.mode === PlayMode.Sequential) return null;
this.currentIndex = this.songs.length - 1;
} else {
this.currentIndex = prevIndex;
}
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex];
}
B. Play Next (insert after current)
PlayQueue.addNext(song):
addNext(song: QueuedSong): void {
if (this.currentIndex < 0 || this.songs.length === 0) {
this.songs.push(song);
return;
}
const insertAt = this.currentIndex + 1;
this.songs.splice(insertAt, 0, song);
// Shift any tracked index > currentIndex by +1
const shifted = new Set<number>();
for (const i of this.playedIndices) {
shifted.add(i > this.currentIndex ? i + 1 : i);
}
this.playedIndices = shifted;
this.history = this.history.map((i) => (i > this.currentIndex ? i + 1 : i));
}
Backend endpoint — src/web/api/player.ts:
POST /api/player/:botId/play-next-song
body: { song: Song }
Behavior:
- If queue is empty or
currentIndex < 0:queue.addNext(song)(which falls through to plain push), thenqueue.play(), thenresolveAndPlay. Same semantics as a successful/play-song— message: "正在播放:…" - Otherwise:
queue.addNext(song), no resolveAndPlay. Message: "已加入下一首:…" - Returns
{ ok: boolean, message: string }matching the convention established in the previous round.
Bot command — src/bot/instance.ts:
Register !playnext <query> (alias !pn):
- Mirror of
cmdPlay's search step - On match:
queue.addNext(song). If no current playback, fall through toresolveAndPlay. - Reply with
已加入下一首:<name>or正在播放:<name>accordingly
Frontend store action — web/src/stores/player.ts:
async playNextSong(song: Song) {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
}
Frontend SongCard — web/src/components/SongCard.vue:
Add a third action button between the existing "play" and "add to queue":
<button class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
<Icon icon="mdi:playlist-play" />
</button>
Add playNext: [] to defineEmits.
Caller updates — Home.vue, Library.vue, Search.vue, History.vue,
Playlist.vue: each <SongCard> usage adds
@playNext="store.playNextSong(song)".
Queue.vue is intentionally not updated — clicking "play next" on a song already in the queue would create a confusing duplicate.
Edge Cases
| Case | Behavior |
|---|---|
prev with empty history in Sequential mode |
Walks currentIndex - 1; returns null at index 0 (existing) |
prev with empty history in Random/RandomLoop |
Returns null (no past to recover) |
Repeated prev past start of history |
Pops what's there, then falls back to index walk; eventually null |
addNext while currentIndex == -1 (nothing played yet) |
Falls through to push; queue.play() will pick it as first |
addNext while playing and queue size = 1 |
Inserts at index 1; current index unchanged; next() will advance to it |
remove removes a song whose index is in history |
Entry dropped; shifted accordingly |
| Mode switched mid-playback | History cleared (intentional — mode change is a context boundary) |
addNext then prev |
Inserted song was never played → not in history; prev pops the previously-played song, NOT the just-inserted one |
Files Touched
src/audio/queue.ts— history field,pushHistory,addNext, rewrittenprev, mutator updatessrc/audio/queue.test.ts(or add if missing) — unit tests for history behavior + addNext shift logicsrc/bot/instance.ts— register!playnext/!pncommand handlersrc/web/api/player.ts— new/play-next-songrouteweb/src/stores/player.ts—playNextSongactionweb/src/components/SongCard.vue— third action button + emitweb/src/views/Home.vue— wire@playNextweb/src/views/Library.vue— wire@playNextweb/src/views/Search.vue— wire@playNextweb/src/views/History.vue— wire@playNextweb/src/views/Playlist.vue— wire@playNext
Test Plan
Unit (vitest, queue.test.ts):
- prev with empty history in Sequential: walks back, null at index 0
- prev with empty history in Random: returns null
- next → next → next → prev pops correctly; prev again pops earlier
- prev after
clear()returns null (history reset) - prev after
setMode()returns null (history reset) remove(idx)drops from history and shifts entries > idxaddNextwhile empty: appendsaddNextwhile playing index 2 in a 5-song queue: ends up at index 3, currentIndex still 2, queue size 6addNextthennext(): plays the inserted songaddNextshifts existing playedIndices and history correctly
Integration (manual smoke):
- Random mode: play 4 songs, hit
prev3 times → walks back through history - Click "下一首播放" on a search result → next song after current is the chosen one
!playnext 七里香→ bot replies "已加入下一首:..."; current keeps playing; next song is 七里香!playnextwhile idle → starts playing immediately
Regression:
- Existing 161 source-tree tests still pass.
- TypeScript
tsc --noEmitandnpm run build:webclean.