Files
teamspeak-music-bot/docs/superpowers/plans/2026-05-06-prev-history-and-play-next.md
T
saopig1andClaude Opus 4.7 94c60ad465 docs: implementation plan for history-aware prev + play next
Eight tasks: queue history field/helper, history-aware prev with
TDD tests, addNext with TDD tests, REST + command surface, store
action, SongCard third button, view wiring, final verify + smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:10:57 +08:00

35 KiB

History-aware prev + Play Next Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make prev walk back through real play history (not array index) in random modes, and add a "Play Next" insert path with both UI button and !playnext chat command.

Architecture: Add a 50-entry back-stack history to PlayQueue. Mutators push the previous currentIndex before changing it; prev pops and falls back to the existing index-walk when the stack is empty. New addNext(song) splices into currentIndex+1 and shifts playedIndices and history to keep references valid. Backend exposes POST /play-next-song and !playnext command. Frontend adds a third action button on SongCard and a playNextSong(song) store action that surfaces backend {ok, message} via the existing Toast.

Tech Stack: TypeScript, Vue 3 (Composition API), Pinia, Vitest, Express.

Spec: docs/superpowers/specs/2026-05-06-prev-history-and-play-next-design.md


File Structure

Modified (TDD-backed):

  • src/audio/queue.ts — history field, pushHistory, addNext, rewritten prev, mutator updates (next, playAt, play, clear, setMode, remove)
  • src/audio/queue.test.ts — new test cases for history + addNext

Modified (smoke-tested):

  • src/bot/instance.ts — cmdPlayNext method, register playnext / pn in AUDIO_COMMANDS and the command switch, help text update
  • src/web/api/player.ts — POST /:botId/play-next-song route
  • web/src/stores/player.ts — playNextSong(song) action
  • web/src/components/SongCard.vue — third action button + emit
  • web/src/views/Home.vue — wire @playNext (2 SongCard usages)
  • web/src/views/Library.vue — wire @playNext (1 SongCard usage)
  • web/src/views/Search.vue — wire @playNext
  • web/src/views/History.vue — wire @playNext
  • web/src/views/Playlist.vue — wire @playNext

Untouched:

  • web/src/components/Queue.vue — songs already in queue; play-next there has confusing semantics, deliberately skipped per spec.

Task 1: Queue history field + pushHistory helper (no behavior change yet)

Files:

  • Modify: src/audio/queue.ts

This is a no-op refactor to set up the history infrastructure. prev still uses the old code path until Task 2.

  • Step 1.1: Add the field and helper

In src/audio/queue.ts, find the private playedIndices = new Set<number>(); line in the PlayQueue class (around line 23) and add immediately after:

  private history: number[] = [];
  private static readonly HISTORY_LIMIT = 50;

  private pushHistory(idx: number): void {
    if (idx < 0 || idx >= this.songs.length) return;
    this.history.push(idx);
    if (this.history.length > PlayQueue.HISTORY_LIMIT) {
      this.history.shift();
    }
  }
  • Step 1.2: Verify tests still pass

Run: npm test -- src/audio/queue.test.ts Expected: all existing PlayQueue tests pass (no behavior change).

  • Step 1.3: Commit
git add src/audio/queue.ts
git commit -m "refactor(queue): add history field and pushHistory helper

Inert in this commit — no callers yet. Sets up the back-stack used
by the upcoming history-aware prev rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 2: History-aware prev (TDD)

Files:

  • Modify: src/audio/queue.ts

  • Modify: src/audio/queue.test.ts

  • Step 2.1: Add failing tests

Append to src/audio/queue.test.ts (within the existing describe("PlayQueue", ...) block, before the closing });):

  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
    });
  });
  • Step 2.2: Run tests, verify the new ones fail

Run: npm test -- src/audio/queue.test.ts Expected: 8 new tests fail, existing tests pass.

  • Step 2.3: Wire pushHistory into mutators

In src/audio/queue.ts:

Find the play() method:

  play(): QueuedSong | null {
    if (this.songs.length === 0) return null;
    this.playedIndices.clear();
    this.currentIndex = 0;
    this.playedIndices.add(0);
    return this.songs[0];
  }

Replace with:

  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];
  }

Find playAt(index):

  playAt(index: number): QueuedSong | null {
    if (index < 0 || index >= this.songs.length) return null;
    this.playedIndices.clear();
    this.currentIndex = index;
    this.playedIndices.add(index);
    return this.songs[index];
  }

Replace with:

  playAt(index: number): QueuedSong | null {
    if (index < 0 || index >= this.songs.length) return null;
    this.pushHistory(this.currentIndex);
    this.currentIndex = index;
    this.playedIndices.add(index);
    return this.songs[index];
  }

Note: removed playedIndices.clear(). Random mode in next() reads playedIndices to pick unplayed songs; clearing on every playAt would defeat that. The test it("walks back through played indices in random mode") exercises this — three playAts in a row should each push to history.

Find next():

  next(): QueuedSong | null {
    if (this.songs.length === 0) return null;

    switch (this.mode) {
      case PlayMode.Sequential: {
        const nextIndex = this.currentIndex + 1;
        if (nextIndex >= this.songs.length) return null;
        this.currentIndex = nextIndex;
        return this.songs[nextIndex];
      }
      case PlayMode.Loop: {
        this.currentIndex = (this.currentIndex + 1) % this.songs.length;
        return this.songs[this.currentIndex];
      }
      case PlayMode.Random: {
        const unplayed: number[] = [];
        for (let i = 0; i < this.songs.length; i++) {
          if (!this.playedIndices.has(i)) unplayed.push(i);
        }
        if (unplayed.length === 0) return null;
        const nextIndex =
          unplayed[Math.floor(Math.random() * unplayed.length)];
        this.currentIndex = nextIndex;
        this.playedIndices.add(nextIndex);
        return this.songs[nextIndex];
      }
      case PlayMode.RandomLoop: {
        if (this.songs.length === 1) {
          this.currentIndex = 0;
          return this.songs[0];
        }
        let idx: number;
        do {
          idx = Math.floor(Math.random() * this.songs.length);
        } while (idx === this.currentIndex);
        this.currentIndex = idx;
        return this.songs[idx];
      }
    }
  }

Replace with:

  next(): QueuedSong | null {
    if (this.songs.length === 0) return null;

    switch (this.mode) {
      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];
      }
      case PlayMode.Random: {
        const unplayed: number[] = [];
        for (let i = 0; i < this.songs.length; i++) {
          if (!this.playedIndices.has(i)) unplayed.push(i);
        }
        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];
        }
        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];
      }
    }
  }

Find clear():

  clear(): void {
    this.songs = [];
    this.currentIndex = -1;
    this.playedIndices.clear();
  }

Replace with:

  clear(): void {
    this.songs = [];
    this.currentIndex = -1;
    this.playedIndices.clear();
    this.history = [];
  }

Find setMode(mode):

  setMode(mode: PlayMode): void {
    this.mode = mode;
    this.playedIndices.clear();
    if (this.currentIndex >= 0) {
      this.playedIndices.add(this.currentIndex);
    }
  }

Replace with:

  setMode(mode: PlayMode): void {
    this.mode = mode;
    this.playedIndices.clear();
    this.history = [];
    if (this.currentIndex >= 0) {
      this.playedIndices.add(this.currentIndex);
    }
  }

Find remove(index):

  remove(index: number): QueuedSong | null {
    if (index < 0 || index >= this.songs.length) return null;
    const [removed] = this.songs.splice(index, 1);

    if (index < this.currentIndex) {
      this.currentIndex--;
    } else if (index === 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;
  }

Replace with:

  remove(index: number): QueuedSong | null {
    if (index < 0 || index >= this.songs.length) return null;
    const [removed] = this.songs.splice(index, 1);

    if (index < this.currentIndex) {
      this.currentIndex--;
    } else if (index === 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;

    // 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;
  }
  • Step 2.4: Rewrite prev()

Find prev():

  prev(): QueuedSong | null {
    if (this.songs.length === 0) 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 {
      this.currentIndex = prevIndex;
    }
    this.playedIndices.add(this.currentIndex);
    return this.songs[this.currentIndex];
  }

Replace with:

  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) {
      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];
  }
  • Step 2.5: Run all queue tests

Run: npm test -- src/audio/queue.test.ts Expected: all tests pass (existing + 8 new).

  • Step 2.6: Run full test suite to confirm no regression

Run: npm test Expected: source-tree tests show same baseline as before plus the 8 new tests in src/audio/queue.test.ts. The 2 known pre-existing failures in dist/ and .claude/worktrees/ should still be exactly 2.

  • Step 2.7: Commit
git add src/audio/queue.ts src/audio/queue.test.ts
git commit -m "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>"

Task 3: Queue addNext (TDD)

Files:

  • Modify: src/audio/queue.ts

  • Modify: src/audio/queue.test.ts

  • Step 3.1: Add failing tests

Append to src/audio/queue.test.ts (still inside the outer describe("PlayQueue", ...)):

  describe("addNext", () => {
    it("appends when queue is empty (no current)", () => {
      queue.addNext(makeSong("a"));
      expect(queue.size()).toBe(1);
      expect(queue.list()[0].id).toBe("a");
    });

    it("appends when nothing is currently playing (currentIndex < 0)", () => {
      queue.add(makeSong("a"));
      queue.add(makeSong("b"));
      // No play() yet → currentIndex still -1
      queue.addNext(makeSong("c"));
      expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c"]);
    });

    it("inserts at currentIndex+1 mid-queue", () => {
      queue.add(makeSong("a"));
      queue.add(makeSong("b"));
      queue.add(makeSong("c"));
      queue.add(makeSong("d"));
      queue.play();      // current = 0 (a)
      queue.next();      // current = 1 (b)
      queue.addNext(makeSong("x"));
      expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "x", "c", "d"]);
      expect(queue.current()?.id).toBe("b"); // current unchanged
    });

    it("makes the inserted song play next when next() is called", () => {
      queue.setMode(PlayMode.Sequential);
      queue.add(makeSong("a"));
      queue.add(makeSong("b"));
      queue.play();      // current = 0 (a)
      queue.addNext(makeSong("x"));
      expect(queue.next()?.id).toBe("x");
    });

    it("shifts playedIndices entries > currentIndex by +1", () => {
      queue.setMode(PlayMode.Random);
      queue.add(makeSong("a"));
      queue.add(makeSong("b"));
      queue.add(makeSong("c"));
      queue.add(makeSong("d"));
      queue.playAt(2); // current = 2 (c), played = {2}
      queue.playAt(3); // current = 3 (d), played = {2, 3}
      queue.playAt(2); // current = 2 (c), played = {2, 3}
      // Now insert after c — d's index 3 should become 4
      queue.addNext(makeSong("x"));
      expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c", "x", "d"]);
      // After addNext: currentIndex still 2; played should be {2, 4}
      // (the previously-played 'd' is now at index 4)
      // Verify by removing 'x' (index 3) — d should remain played at index 3
      queue.remove(3);
      expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c", "d"]);
    });

    it("shifts history entries > currentIndex by +1", () => {
      queue.setMode(PlayMode.Random);
      queue.add(makeSong("a"));
      queue.add(makeSong("b"));
      queue.add(makeSong("c"));
      queue.add(makeSong("d"));
      queue.playAt(0); // current = 0
      queue.playAt(3); // current = 3 (d), history = [0]
      queue.playAt(1); // current = 1 (b), history = [0, 3]
      queue.addNext(makeSong("x"));
      // Insert at index 2 → entries > 1 shift +1 → history becomes [0, 4]
      // queue: [a, b, x, c, d]; d is now at index 4
      // prev → pop 4 → song at index 4 = d
      expect(queue.prev()?.id).toBe("d");
      // prev again → pop 0 → song at index 0 = a
      expect(queue.prev()?.id).toBe("a");
    });
  });
  • Step 3.2: Run tests, verify they fail

Run: npm test -- src/audio/queue.test.ts Expected: 6 new addNext tests fail with "addNext is not a function" (or similar).

  • Step 3.3: Implement addNext

In src/audio/queue.ts, find addMany (around line 29-31):

  addMany(songs: QueuedSong[]): void {
    this.songs.push(...songs);
  }

Add a new method immediately after addMany:

  /**
   * Insert a song to play immediately after the current one. Falls
   * through to plain push when nothing is playing yet (currentIndex < 0
   * or queue empty), so the existing "add → idle bot starts playing"
   * flow continues to work.
   *
   * Shifts playedIndices and history entries > currentIndex by +1 so
   * their references stay valid after the splice.
   */
  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);

    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,
    );
  }
  • Step 3.4: Run queue tests

Run: npm test -- src/audio/queue.test.ts Expected: all PlayQueue tests pass (existing + 8 from Task 2 + 6 new).

  • Step 3.5: Commit
git add src/audio/queue.ts src/audio/queue.test.ts
git commit -m "feat(queue): addNext inserts a song to play right after current

Splices into currentIndex+1 and shifts both playedIndices and the
history back-stack to keep references valid. Falls through to plain
push when nothing is playing so the idle-bot \"add → start playing\"
flow is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 4: Backend /play-next-song endpoint + !playnext command

Files:

  • Modify: src/web/api/player.ts

  • Modify: src/bot/instance.ts

  • Step 4.1: Add the REST endpoint

In src/web/api/player.ts, find the existing /add-song route (around line 343). Add a new route immediately before it (so it sits next to other "play" endpoints):

  // Insert a single song to play right after the current one.
  // If nothing is playing, behaves like /play-song (start immediately).
  router.post("/:botId/play-next-song", async (req, res) => {
    try {
      const bot = (req as any).bot;
      const { song } = req.body;
      if (!song || !song.id || !song.platform) {
        res.status(400).json({ error: "song object with id and platform is required" });
        return;
      }
      const queue = bot.getQueueManager();
      const wasIdle = bot.getPlayer().getState() === "idle";
      queue.addNext(song);

      if (wasIdle) {
        // No current playback — promote the just-added song to current
        // and start it. addNext fell through to push, so it's the last item.
        queue.playAt(queue.size() - 1);
        bot.getPlayer().resetFailures();
        const ok = await bot.resolveAndPlay(queue.current()!);
        if (!ok) {
          res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
          return;
        }
        res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
        return;
      }

      res.json({ ok: true, message: `已加入下一首:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
    } catch (err) {
      res.status(500).json({ error: (err as Error).message });
    }
  });
  • Step 4.2: Add the cmdPlayNext method

In src/bot/instance.ts, find cmdAdd (around line 405-429). Add a new method immediately after cmdAdd:

  private async cmdPlayNext(cmd: ParsedCommand): Promise<string> {
    if (!cmd.args) return "Usage: !playnext <song name>";
    const provider = this.getProvider(cmd.flags);
    const result = await provider.search(cmd.args, 1);
    if (result.songs.length === 0)
      return `No results found for: ${cmd.args}`;

    const song = result.songs[0];
    const wasIdle = this.player.getState() === "idle";
    this.queue.addNext({ ...song, platform: provider.platform });

    if (wasIdle) {
      // Nothing playing — addNext fell through to push, promote and start.
      this.queue.playAt(this.queue.size() - 1);
      this.player.resetFailures();
      const ok = await this.resolveAndPlay(this.queue.current()!);
      this.emit("stateChange");
      if (!ok) return `Cannot play: ${song.name}`;
      return `Now playing: ${song.name} - ${song.artist}`;
    }

    this.emit("stateChange");
    return `Up next: ${song.name} - ${song.artist}`;
  }
  • Step 4.3: Register the command

In src/bot/instance.ts, find the AUDIO_COMMANDS set (around line 254-264):

    const AUDIO_COMMANDS = new Set([
      "play",
      "add",
      "next",
      "skip",
      "prev",
      "playlist",
      "album",
      "fm",
      "artist",
    ]);

Replace with:

    const AUDIO_COMMANDS = new Set([
      "play",
      "add",
      "playnext",
      "pn",
      "next",
      "skip",
      "prev",
      "playlist",
      "album",
      "fm",
      "artist",
    ]);

Find the command switch (around line 268). After the case "add": return this.cmdAdd(cmd); line, add:

      case "playnext":
      case "pn":
        return this.cmdPlayNext(cmd);
  • Step 4.4: Update help text

In src/bot/instance.ts, find cmdHelp (around line 709). Find the line:

      `${p}add <song>   — Add to queue`,

Replace with:

      `${p}add <song>   — Add to queue`,
      `${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
  • Step 4.5: Type-check

Run: npx tsc --noEmit Expected: exit 0, no output.

  • Step 4.6: Smoke test

Make sure dev server is running (npm run dev if not). Then verify:

curl -s -X POST "http://localhost:3000/api/player/<botId>/play-next-song" \
  -H "Content-Type: application/json" \
  -d '{"song":{"id":"002CGrO91icqlg","name":"测试","artist":"测试","album":"","duration":200,"coverUrl":"","platform":"qq"}}' | head -c 200

Replace <botId> with one from curl http://localhost:3000/api/bot. Expected response shape: {"ok":true,"message":"已加入下一首:…"} (when bot is playing) or {"ok":true,"message":"正在播放:…"} (when idle).

  • Step 4.7: Commit
git add src/web/api/player.ts src/bot/instance.ts
git commit -m "feat: !playnext command and /play-next-song endpoint

Mirror of !play / /play-song but uses queue.addNext to splice in at
currentIndex+1 instead of clearing the queue. Idle bot still starts
the song immediately. Adds 'playnext' / 'pn' to AUDIO_COMMANDS, the
command switch, and the help text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 5: Frontend playNextSong store action

Files:

  • Modify: web/src/stores/player.ts

  • Step 5.1: Add the action

In web/src/stores/player.ts, find the playSong(song: Song) action (search for async playSong). Add a new action immediately after playSong:

    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');
      }
      // Refresh queue so the inserted item shows up in the side panel
      this.fetchQueue();
    },
  • Step 5.2: Type-check the web project

Run: cd web && npx vue-tsc --noEmit && cd .. Expected: exit 0, no output.

  • Step 5.3: Commit
git add web/src/stores/player.ts
git commit -m "feat(web): playNextSong store action

Posts to /play-next-song, surfaces failures via the existing Toast,
and refreshes the queue panel so the inserted song appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 6: SongCard third action button

Files:

  • Modify: web/src/components/SongCard.vue

  • Step 6.1: Add the button and emit

Read web/src/components/SongCard.vue. Find the action buttons block:

    <div class="song-actions">
      <button class="action-btn" @click.stop="$emit('play')" title="播放">
        <Icon icon="mdi:play" />
      </button>
      <button class="action-btn" @click.stop="$emit('add')" title="添加到队列">
        <Icon icon="mdi:playlist-plus" />
      </button>
    </div>

Replace with:

    <div class="song-actions">
      <button class="action-btn" @click.stop="$emit('play')" title="播放">
        <Icon icon="mdi:play" />
      </button>
      <button class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
        <Icon icon="mdi:playlist-play" />
      </button>
      <button class="action-btn" @click.stop="$emit('add')" title="添加到队列">
        <Icon icon="mdi:playlist-plus" />
      </button>
    </div>

In the defineEmits block, find:

defineEmits<{
  play: [];
  add: [];
}>();

Replace with:

defineEmits<{
  play: [];
  playNext: [];
  add: [];
}>();
  • Step 6.2: Verify the web build

Run: npm run build:web Expected: build succeeds with ✓ built in N.NNs. The vue-tsc --noEmit step is part of build:web so any type mismatch on existing SongCard usages would surface here.

  • Step 6.3: Commit
git add web/src/components/SongCard.vue
git commit -m "feat(web): third 'play next' action on SongCard

Button sits between Play and Add to queue. Icon is mdi:playlist-play,
title '下一首播放'. Emits 'playNext' for callers to wire up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 7: Wire @playNext on all SongCard call sites

Files:

  • Modify: web/src/views/Home.vue

  • Modify: web/src/views/Library.vue

  • Modify: web/src/views/Search.vue

  • Modify: web/src/views/History.vue

  • Modify: web/src/views/Playlist.vue

  • Step 7.1: Home.vue

In web/src/views/Home.vue, there are no <SongCard> usages — the Home page renders songs through ad-hoc daily-card divs. Skip this file; the SongCard third button only matters where SongCard is actually rendered.

  • Step 7.2: Library.vue

In web/src/views/Library.vue, find:

        <SongCard
          v-for="(song, i) in history.slice(0, 10)"
          :key="`hist-${song.id}-${i}`"
          :song="song"
          :index="i + 1"
          :active="store.currentSong?.id === song.id"
          @play="store.play(song.name, song.platform)"
          @add="store.addToQueue(song.name, song.platform)"
        />

Replace with:

        <SongCard
          v-for="(song, i) in history.slice(0, 10)"
          :key="`hist-${song.id}-${i}`"
          :song="song"
          :index="i + 1"
          :active="store.currentSong?.id === song.id"
          @play="store.play(song.name, song.platform)"
          @playNext="store.playNextSong(song)"
          @add="store.addToQueue(song.name, song.platform)"
        />
  • Step 7.3: Search.vue

In web/src/views/Search.vue, find the <SongCard ...> usage and add @playNext="store.playNextSong(song)" on a new line right before @play="...". The exact existing handler may be @play="store.playSong(song)" — keep it; just add the @playNext line above.

The full block should end up like:

        <SongCard
          v-for="(song, i) in results.songs"
          :key="`search-${song.id}-${i}`"
          :song="song"
          :index="i + 1"
          :active="store.currentSong?.id === song.id"
          @playNext="store.playNextSong(song)"
          @play="store.playSong(song)"
          @add="store.addSong(song)"
        />

(If the existing @play= or @add= handlers differ, leave them as-is — only add the new @playNext line.)

  • Step 7.4: History.vue

Same pattern — in web/src/views/History.vue, locate the <SongCard> usage and add @playNext="store.playNextSong(song)". Result should look like:

        <SongCard
          v-for="(song, i) in records"
          :key="`hist-${song.id}-${i}`"
          :song="song"
          :index="i + 1"
          :active="store.currentSong?.id === song.id"
          @playNext="store.playNextSong(song)"
          @play="store.playSong(song)"
          @add="store.addSong(song)"
        />
  • Step 7.5: Playlist.vue

In web/src/views/Playlist.vue, find:

        <SongCard
          v-for="(song, i) in songs"
          :key="song.id"
          :song="song"
          :index="i + 1"
          :active="store.currentSong?.id === song.id"
          @play="store.playSong(song)"
          @add="store.addSong(song)"
        />

Replace with:

        <SongCard
          v-for="(song, i) in songs"
          :key="song.id"
          :song="song"
          :index="i + 1"
          :active="store.currentSong?.id === song.id"
          @play="store.playSong(song)"
          @playNext="store.playNextSong(song)"
          @add="store.addSong(song)"
        />
  • Step 7.6: Verify the build

Run: npm run build:web Expected: clean build.

  • Step 7.7: Commit
git add web/src/views/Library.vue web/src/views/Search.vue web/src/views/History.vue web/src/views/Playlist.vue
git commit -m "feat(web): wire @playNext on all SongCard call sites

Library / Search / History / Playlist now route the third action to
store.playNextSong. Home is unchanged (it doesn't use SongCard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 8: Verify the whole thing

  • Step 8.1: Type check + build
npx tsc --noEmit
npm run build:web

Expected: both clean.

  • Step 8.2: Run the test suite
npm test

Expected: source-tree tests show prior count + 14 new in queue.test.ts (8 from Task 2 + 6 from Task 3) all passing. The 2 pre-existing failures in dist/ and .claude/worktrees/ should still be exactly 2.

  • Step 8.3: Manual smoke (web)

The dev server should pick up the rebuilt bundle from web/dist/ automatically (the backend serves static files). Hard reload the browser (Ctrl+Shift+R) and exercise:

  1. Find a SongCard somewhere (e.g., Search results) — confirm three action buttons (play / playlist-play / playlist-plus).
  2. Click the middle button on a song. Toast should show "已加入下一首:…". Open the queue panel — the song is now at position currentIndex+1.
  3. Click next (or wait for current to end). The inserted song plays.
  • Step 8.4: Manual smoke (TS3 chat)

In the bot's TS channel:

  • !playnext 七里香 → bot replies "Up next: 七里香 - 周杰伦" (or "Now playing:" if idle).

  • !pn 同桌的你 → same as above (alias).

  • Step 8.5: Manual smoke (history-aware prev)

Set Random mode in the web UI (Player → mode dropdown → 随机). Play a playlist with 4+ songs. Let next run twice (or click skip twice). Click prev — should land on the song you played 2 ago, not a freshly random one. Click prev again — earlier song. Click next — picks a new random song.

  • Step 8.6: Done
git log --oneline -10

Expected: 7 new commits from Tasks 1-7 on top of 58bdfb9 (the spec commit). All tasks checkboxes ticked.


Self-Review Checklist (already applied)

  • All 8 unit tests for history + 6 for addNext are written with concrete code, not "test similar behaviors".
  • Method signatures match across tasks: pushHistory (Task 1) used in next/playAt (Task 2.3); addNext (Task 3) called by REST + command (Task 4); playNextSong (Task 5) called by views (Task 7).
  • No "TODO" / "fill in details". Code blocks present in every editing step.
  • Spec coverage:
    • History stack ✅ Tasks 1-2
    • addNext ✅ Task 3
    • REST endpoint ✅ Task 4.1
    • !playnext + !pn alias ✅ Task 4.2-4.4
    • Toast on failure ✅ Task 5.1 (uses existing notify primitive)
    • SongCard 3rd button ✅ Task 6
    • All 5 caller view updates ✅ Task 7 (Home is intentionally skipped per spec/codebase)
    • Tests ✅ Tasks 2.1, 3.1
    • Manual smoke plan ✅ Task 8