From 1552fa1a394ea6830d116e58ec2a04084a68d36e Mon Sep 17 00:00:00 2001 From: saopig1 Date: Tue, 21 Apr 2026 01:12:58 +0800 Subject: [PATCH 01/28] feat(web): mobile-responsive redesign with Library view and design tokens Add mobile layout (mini player, bottom tab bar, responsive navbar), new Library view, redesigned bot dropdown with action buttons, and centralize colors/typography/shadows into CSS variables in variables.scss. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.local.json | 5 +- web/src/App.vue | 177 ++++++++++++++++++- web/src/components/Navbar.vue | 302 +++++++++++++++++++++----------- web/src/components/Player.vue | 12 +- web/src/components/Queue.vue | 2 +- web/src/components/SongCard.vue | 24 +-- web/src/router/index.ts | 5 + web/src/styles/variables.scss | 67 ++++++- web/src/views/Home.vue | 13 +- web/src/views/Library.vue | 198 +++++++++++++++++++++ web/src/views/Settings.vue | 18 +- 11 files changed, 684 insertions(+), 139 deletions(-) create mode 100644 web/src/views/Library.vue diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7e003df..28d65be 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,10 @@ "mcp__Claude_Preview__*", "mcp__Claude_in_Chrome__*", "mcp__scheduled-tasks__*", - "Bash(npx vitest:*)" + "Bash(npx vitest:*)", + "Bash(cp \"C:\\\\Users\\\\saopig1\\\\.claude\\\\projects\\\\C--Users-saopig1-Music-teamspeak-music-bot\\\\b5a64d6f-051e-4b87-966c-ece97d2b879b\\\\tool-results\\\\webfetch-1776569008470-exv7qe.bin\" /tmp/design.gz)", + "Bash(gunzip -f /tmp/design.gz)", + "Read(//tmp/**)" ], "deny": [ "Bash(git push * main)", diff --git a/web/src/App.vue b/web/src/App.vue index 16f8d86..a76eefa 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -5,32 +5,87 @@ + + +
+
+
+
+ +
+
{{ currentSong.name }}
+
{{ currentSong.artist }}
+
+ + +
+ + +
@@ -47,5 +102,123 @@ onUnmounted(() => { @media (max-width: 1336px) { padding: 80px 5vw 80px; } + + @media (max-width: 768px) { + padding: 72px 16px 200px; + } +} + +// Mobile mini player +.m-player { + position: fixed; + left: 8px; + right: 8px; + bottom: 68px; + height: 58px; + padding: 8px 10px; + display: flex; + align-items: center; + gap: 10px; + background: var(--bg-secondary); + border-radius: var(--radius-md); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35); + z-index: 95; + cursor: pointer; + + @media (min-width: 769px) { + display: none; + } +} + +.m-player-progress { + position: absolute; + top: 0; + left: 10px; + right: 10px; + height: 2px; +} + +.m-player-progress-fill { + height: 2px; + background: var(--color-primary); + border-radius: 1px; +} + +.m-player-info { + flex: 1; + min-width: 0; +} + +.m-player-name { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.m-player-artist { + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.m-player-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + opacity: 0.85; + flex-shrink: 0; +} + +// Mobile bottom tab bar +.m-tabbar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: 60px; + display: flex; + align-items: center; + justify-content: space-around; + padding-bottom: env(safe-area-inset-bottom, 0); + background: var(--bg-navbar); + backdrop-filter: saturate(180%) blur(20px); + -webkit-backdrop-filter: saturate(180%) blur(20px); + border-top: 1px solid var(--border-color); + z-index: 100; + + @media (min-width: 769px) { + display: none; + } +} + +.m-tab { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 14px; + color: var(--text-tertiary); + text-decoration: none; + font-family: inherit; + + &.active { + color: var(--color-primary); + } + + .tab-icon { + font-size: 22px; + } + + .tab-label { + font-size: 10px; + font-weight: 500; + } } diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index c095d5d..1776d59 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -5,6 +5,7 @@ @@ -19,38 +20,68 @@
+
机器人
- - - +
+
+ + + + + + +
-
-
点击切换 · 🔗 复制专属链接
@@ -227,6 +258,11 @@ onUnmounted(() => { @media (max-width: 1336px) { padding: 0 5vw; } + + @media (max-width: 768px) { + padding: 0 16px; + height: 52px; + } } .logo { @@ -234,11 +270,20 @@ onUnmounted(() => { font-weight: 700; color: var(--color-primary); margin-right: 40px; + + @media (max-width: 768px) { + font-size: 17px; + margin-right: 0; + } } .nav-links { display: flex; gap: 24px; + + @media (max-width: 768px) { + display: none; + } } .nav-link { @@ -266,7 +311,7 @@ onUnmounted(() => { opacity: 0.6; &.online { - background: rgba(51, 94, 234, 0.15); + background: var(--color-primary-15); color: var(--color-primary); opacity: 1; } @@ -295,12 +340,25 @@ onUnmounted(() => { background: var(--bg-card); border-color: var(--color-primary); } + + @media (max-width: 768px) { + padding: 6px 10px; + font-size: 12px; + font-weight: 600; + min-height: 32px; + gap: 6px; + border-radius: var(--radius-full); + } } .bot-state-mini { font-size: 14px; - &.playing { color: #22c55e; } - &.paused { color: #eab308; } + &.playing { color: var(--color-online); } + &.paused { color: var(--color-paused); } + + @media (max-width: 768px) { + display: none; + } } .bot-chevron { @@ -311,6 +369,10 @@ onUnmounted(() => { &.rotated { transform: rotate(180deg); } + + @media (max-width: 768px) { + display: none; + } } .bot-dot { @@ -321,7 +383,12 @@ onUnmounted(() => { flex-shrink: 0; &.online { - background: #22c55e; + background: var(--color-online); + } + + @media (max-width: 768px) { + width: 8px; + height: 8px; } } @@ -330,96 +397,83 @@ onUnmounted(() => { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + + @media (max-width: 768px) { + max-width: 80px; + } } .bot-dropdown { position: absolute; top: calc(100% + 6px); right: 0; - min-width: 200px; + min-width: 320px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-md); - padding: 4px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + padding: 6px; + box-shadow: var(--shadow-dropdown); z-index: 200; + + @media (max-width: 768px) { + position: fixed; + top: 52px; + left: 8px; + right: 8px; + min-width: auto; + } } -.bot-dropdown-item { +.bot-dropdown-header { + font-size: 11px; + font-weight: 600; + color: var(--text-tertiary); + padding: 6px 10px 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.bot-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + border-radius: var(--radius-sm); + margin-bottom: 4px; + border: 1px solid transparent; + transition: background var(--transition-fast); + + &.active { + background: var(--color-primary-12); + border-color: rgba(99, 102, 241, 0.25); + } +} + +.bot-card-head { display: flex; align-items: center; gap: 8px; - flex: 1; - min-width: 0; - padding: 8px 12px; - border-radius: var(--radius-sm); - font-size: 13px; cursor: pointer; - transition: background var(--transition-fast); - - &:hover { - background: var(--hover-bg); - } - - &.active { - background: rgba(51, 94, 234, 0.12); - color: var(--color-primary); - } } -.bot-dropdown-row { - display: flex; - align-items: center; - gap: 2px; -} - -.bot-dropdown-name { +.bot-card-name { flex: 1; + font-size: 13px; + font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.bot-link-btn { +.bot-current-badge { + font-size: 10px; + font-weight: 700; + color: var(--color-primary); + padding: 2px 6px; + border-radius: 4px; + background: var(--color-primary-15); flex-shrink: 0; - padding: 6px 8px; - border-radius: var(--radius-sm); - font-size: 15px; - opacity: 0.4; - transition: opacity var(--transition-fast), background var(--transition-fast); - cursor: pointer; - - &:hover { - opacity: 1; - background: var(--hover-bg); - } -} - -.bot-power-btn { - flex-shrink: 0; - padding: 6px 8px; - border-radius: var(--radius-sm); - font-size: 16px; - opacity: 0.5; - color: var(--text-tertiary); - transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast); - cursor: pointer; - - &:hover:not(:disabled) { - opacity: 1; - background: var(--hover-bg); - } - - &:disabled { - opacity: 0.25; - cursor: wait; - } - - &.online { - color: #22c55e; - opacity: 0.9; - } } .bot-playing-badge, @@ -427,25 +481,25 @@ onUnmounted(() => { .bot-idle-badge, .bot-offline-badge { font-size: 11px; - padding: 1px 6px; + padding: 2px 6px; border-radius: 4px; font-weight: 500; flex-shrink: 0; } .bot-playing-badge { - background: rgba(34, 197, 94, 0.15); - color: #22c55e; + background: var(--color-online-15); + color: var(--color-online); } .bot-paused-badge { - background: rgba(234, 179, 8, 0.15); - color: #eab308; + background: var(--color-paused-15); + color: var(--color-paused); } .bot-idle-badge { - background: rgba(51, 94, 234, 0.12); - color: var(--color-primary); + background: var(--hover-bg); + color: var(--text-secondary); } .bot-offline-badge { @@ -453,17 +507,47 @@ onUnmounted(() => { color: var(--text-tertiary); } -.bot-dropdown-divider { - height: 1px; - background: var(--border-color); - margin: 4px 0; +.bot-card-controls { + display: flex; + gap: 6px; } -.bot-dropdown-hint { - padding: 4px 12px 6px; +.bot-ctrl-btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 6px 8px; + border-radius: var(--radius-sm); + background: var(--hover-bg); + border: 1px solid var(--border-color); + color: var(--text-primary); font-size: 11px; - color: var(--text-tertiary); - text-align: center; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: all var(--transition-fast); + + &:hover:not(:disabled) { + background: var(--bg-card); + border-color: var(--color-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &.primary { + background: var(--color-primary); + color: #fff; + border-color: var(--color-primary); + } + + &.danger { + color: #ef4444; + } } .settings-btn { @@ -471,12 +555,16 @@ onUnmounted(() => { opacity: 0.6; transition: opacity var(--transition-fast); &:hover { opacity: 1; } + + @media (max-width: 768px) { + display: none; + } } .link-dialog-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.55); + background: var(--bg-modal-scrim); display: flex; align-items: center; justify-content: center; @@ -490,7 +578,7 @@ onUnmounted(() => { padding: 20px; min-width: 360px; max-width: 90vw; - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); + box-shadow: var(--shadow-modal); } .link-dialog-title { diff --git a/web/src/components/Player.vue b/web/src/components/Player.vue index e77b56c..7f4b836 100644 --- a/web/src/components/Player.vue +++ b/web/src/components/Player.vue @@ -206,6 +206,10 @@ function cycleMode() { left: 0; right: 0; z-index: 100; + + @media (max-width: 768px) { + display: none; + } } .player-bar { @@ -326,12 +330,12 @@ function cycleMode() { .bot-badge { display: inline-block; - font-size: 10px; - font-weight: 600; + font-size: var(--fs-micro); + font-weight: var(--fw-semi); padding: 0 5px; - background: rgba(51, 94, 234, 0.15); + background: var(--color-primary-15); color: var(--color-primary); - border-radius: 3px; + border-radius: var(--radius-xs); line-height: 16px; white-space: nowrap; flex-shrink: 0; diff --git a/web/src/components/Queue.vue b/web/src/components/Queue.vue index 83356b1..5a654c9 100644 --- a/web/src/components/Queue.vue +++ b/web/src/components/Queue.vue @@ -146,7 +146,7 @@ async function removeSong(index: number) { } &.active { - background: rgba(51, 94, 234, 0.1); + background: var(--color-primary-10); } } diff --git a/web/src/components/SongCard.vue b/web/src/components/SongCard.vue index 35e48fc..9c8d21d 100644 --- a/web/src/components/SongCard.vue +++ b/web/src/components/SongCard.vue @@ -63,7 +63,7 @@ function formatDuration(seconds: number): string { } &.active { - background: rgba(51, 94, 234, 0.1); + background: var(--color-primary-10); } } @@ -96,31 +96,31 @@ function formatDuration(seconds: number): string { .platform-badge { flex-shrink: 0; - font-size: 10px; - font-weight: 600; + font-size: var(--fs-micro); + font-weight: var(--fw-semi); padding: 1px 5px; - border-radius: 3px; + border-radius: var(--radius-xs); line-height: 1.4; } .badge-netease { - background: rgba(232, 17, 35, 0.15); - color: #e81123; + background: var(--brand-netease-15); + color: var(--brand-netease); } .badge-qq { - background: rgba(18, 183, 106, 0.15); - color: #12b76a; + background: var(--brand-qq-15); + color: var(--brand-qq); } .badge-bilibili { - background: rgba(0, 161, 214, 0.15); - color: #00a1d6; + background: var(--brand-bilibili-15); + color: var(--brand-bilibili); } .badge-youtube { - background: rgba(255, 0, 0, 0.12); - color: #ff0000; + background: var(--brand-youtube-12); + color: var(--brand-youtube); } .song-artist { diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 356d57f..cc060f5 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -13,6 +13,11 @@ const router = createRouter({ name: 'search', component: () => import('../views/Search.vue'), }, + { + path: '/library', + name: 'library', + component: () => import('../views/Library.vue'), + }, { path: '/playlist/:id', name: 'playlist', diff --git a/web/src/styles/variables.scss b/web/src/styles/variables.scss index c407688..e65ab4c 100644 --- a/web/src/styles/variables.scss +++ b/web/src/styles/variables.scss @@ -1,33 +1,95 @@ -// YesPlayMusic-inspired design tokens +// TSMusicBot Design System — canonical tokens :root { - // Fonts + // Typography --font-primary: 'Barlow', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, 'Cascadia Code', monospace; + + --fs-hero: 22px; + --fs-h1: 20px; + --fs-h2: 17px; + --fs-h3: 16px; + --fs-body: 14px; + --fs-sm: 13px; + --fs-xs: 12px; + --fs-2xs: 11px; + --fs-micro: 10px; + + --fw-regular: 400; + --fw-medium: 500; + --fw-semi: 600; + --fw-bold: 700; + --fw-black: 800; + + --lh-tight: 1.2; + --lh-normal: 1.4; + --lh-body: 1.5; // Spacing + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 36px; + --space-10: 80px; + + // Layout --navbar-height: 56px; --player-height: 56px; + --page-gutter: 10vw; // Radius + --radius-xs: 3px; --radius-sm: 6px; --radius-md: 10px; --radius-lg: 14px; --radius-xl: 20px; + --radius-full: 9999px; // Transitions --transition-fast: 0.2s ease; --transition-normal: 0.3s ease; + --ease-cinematic: cubic-bezier(0.25, 0.1, 0.25, 1); + + // Shadows + --shadow-dropdown: 0 8px 24px rgba(0, 0, 0, 0.2); + --shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.35); // Accent --color-primary: #335eea; --color-primary-bg: #eaeffd; + --color-primary-15: rgba(51, 94, 234, 0.15); + --color-primary-10: rgba(51, 94, 234, 0.10); + --color-primary-12: rgba(51, 94, 234, 0.12); + --gradient-fm: linear-gradient(135deg, #335eea, #6366f1); + + // Semantic (theme-invariant) + --color-online: #22c55e; + --color-online-15: rgba(34, 197, 94, 0.15); + --color-paused: #eab308; + --color-paused-15: rgba(234, 179, 8, 0.15); + + // Platform brand + --brand-netease: #e81123; + --brand-netease-15: rgba(232, 17, 35, 0.15); + --brand-qq: #12b76a; + --brand-qq-15: rgba(18, 183, 106, 0.15); + --brand-bilibili: #00a1d6; + --brand-bilibili-15: rgba(0, 161, 214, 0.15); + --brand-youtube: #ff0000; + --brand-youtube-12: rgba(255, 0, 0, 0.12); } +// Dark theme (default) +:root, [data-theme='dark'] { --bg-primary: #222222; --bg-secondary: #323232; --bg-card: rgba(255, 255, 255, 0.04); --bg-navbar: rgba(34, 34, 34, 0.86); + --bg-modal-scrim: rgba(0, 0, 0, 0.55); --text-primary: #ffffff; --text-secondary: rgba(255, 255, 255, 0.58); --text-tertiary: rgba(255, 255, 255, 0.38); @@ -40,6 +102,7 @@ --bg-secondary: #f5f5f7; --bg-card: rgba(0, 0, 0, 0.02); --bg-navbar: rgba(255, 255, 255, 0.86); + --bg-modal-scrim: rgba(0, 0, 0, 0.35); --text-primary: #000000; --text-secondary: rgba(0, 0, 0, 0.58); --text-tertiary: rgba(0, 0, 0, 0.38); diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue index 1f8aaf8..1564c1f 100644 --- a/web/src/views/Home.vue +++ b/web/src/views/Home.vue @@ -179,6 +179,10 @@ onMounted(() => { .section { margin-bottom: 36px; + + @media (max-width: 768px) { + margin-bottom: 28px; + } } .section-title { @@ -188,6 +192,11 @@ onMounted(() => { display: flex; align-items: center; gap: 8px; + + @media (max-width: 768px) { + font-size: 18px; + margin-bottom: 12px; + } } .section-count { @@ -223,7 +232,7 @@ onMounted(() => { justify-content: center; width: 24px; height: 24px; - background: #00a1d6; + background: var(--brand-bilibili); color: white; border-radius: 4px; font-size: 14px; @@ -317,6 +326,7 @@ onMounted(() => { @media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); } @media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); } + @media (max-width: 768px) { grid-template-columns: repeat(3, 1fr); gap: 14px; } } .daily-card { @@ -348,6 +358,7 @@ onMounted(() => { @media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); } @media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); } + @media (max-width: 768px) { grid-template-columns: repeat(3, 1fr); gap: 14px; } } .playlist-card { diff --git a/web/src/views/Library.vue b/web/src/views/Library.vue new file mode 100644 index 0000000..f90a655 --- /dev/null +++ b/web/src/views/Library.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index ca90e21..cacfed3 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -820,16 +820,16 @@ onUnmounted(() => { background: var(--border-color); color: var(--text-tertiary); &.online { - background: rgba(51, 94, 234, 0.15); + background: var(--color-primary-15); color: var(--color-primary); } &.playing { - background: rgba(76, 175, 80, 0.15); - color: #4caf50; + background: var(--color-online-15); + color: var(--color-online); } &.paused { - background: rgba(255, 152, 0, 0.15); - color: #ff9800; + background: var(--color-paused-15); + color: var(--color-paused); } } @@ -853,7 +853,7 @@ onUnmounted(() => { color: var(--color-primary); &.bilibili-icon { - color: #00a1d6; + color: var(--brand-bilibili); } } @@ -865,7 +865,7 @@ onUnmounted(() => { .account-status { font-size: 12px; color: var(--text-tertiary); - &.logged { color: #4caf50; } + &.logged { color: var(--color-online); } } .login-methods { @@ -888,7 +888,7 @@ onUnmounted(() => { &:hover { border-color: var(--color-primary); color: var(--color-primary); } &.active { - background: rgba(51, 94, 234, 0.1); + background: var(--color-primary-10); border-color: var(--color-primary); color: var(--color-primary); } @@ -1012,7 +1012,7 @@ onUnmounted(() => { &:hover { border-color: var(--border-color); } &.active { border-color: var(--color-primary); - background: rgba(51, 94, 234, 0.1); + background: var(--color-primary-10); } } From 652424b74c78f333811d1fac9529be78194b4fde Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 12:47:19 +0800 Subject: [PATCH 02/28] fix: post-merge type and test fixes Library.vue: use Song type from store so SongCard's strict platform union accepts the data (was platform: string, broke after merge tightened SongCard prop type). database.test.ts: switch toEqual -> toMatchObject so getBotInstances() returning extra profile_* schema columns no longer fails the assertion. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/data/database.test.ts | 2 +- web/src/views/Library.vue | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 38b140d..f9c4108 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -68,7 +68,7 @@ describe("database", () => { botDb.saveBotInstance(instance); const instances = botDb.getBotInstances(); expect(instances).toHaveLength(1); - expect(instances[0]).toEqual(instance); + expect(instances[0]).toMatchObject(instance); expect(instances[0].autoStart).toBe(true); // Test upsert diff --git a/web/src/views/Library.vue b/web/src/views/Library.vue index f90a655..1a96103 100644 --- a/web/src/views/Library.vue +++ b/web/src/views/Library.vue @@ -67,16 +67,14 @@ import { ref, onMounted } from 'vue'; import { Icon } from '@iconify/vue'; import axios from 'axios'; -import { usePlayerStore } from '../stores/player.js'; +import { usePlayerStore, type Song } from '../stores/player.js'; import CoverArt from '../components/CoverArt.vue'; import SongCard from '../components/SongCard.vue'; const store = usePlayerStore(); -type SongItem = { id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }; - -const history = ref([]); -const liked = ref([]); +const history = ref([]); +const liked = ref([]); const historyLoading = ref(true); onMounted(async () => { From e5ac3ad89695e51d4aa1be4514b074b38562f21c Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 13:04:45 +0800 Subject: [PATCH 03/28] docs: spec for multi-source tabs on Home and Library Design for adding NetEase / QQ source switcher tabs to recommend playlists, daily songs, and user playlists on Home, plus user playlists on Library. Tabs auto-hide when only one source is available; selection persists per-section in localStorage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-06-music-source-tabs-design.md | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-06-music-source-tabs-design.md diff --git a/docs/superpowers/specs/2026-05-06-music-source-tabs-design.md b/docs/superpowers/specs/2026-05-06-music-source-tabs-design.md new file mode 100644 index 0000000..3ac5b05 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-music-source-tabs-design.md @@ -0,0 +1,189 @@ +# Multi-Source Tabs for Recommend / User Playlists / Daily Songs + +**Date:** 2026-05-06 +**Status:** Spec — pending implementation + +## Problem + +Home 和 Library 页面的"推荐歌单 / 每日推荐 / 我的歌单"这三类内容当前硬编码只走网易云。当用户同时登录了网易云和 QQ 音乐时,无法在 Web UI 上看到 QQ 侧的对应内容、也无法切换查看。 + +## Goal + +在以下 4 个 section 上提供"网易云 / QQ"来源切换 tab,桌面端和移动端均可用: + +- `Home.vue` — 推荐歌单 +- `Home.vue` — 每日推荐 +- `Home.vue` — 我的歌单 +- `Library.vue` — 我的歌单 + +切换为纯前端动作(数据已预先 fetch),无加载闪烁。各 section 的选择独立持久化。 + +## Out of Scope + +- 私人 FM(QQ 无对应概念) +- B 站热门(独立第三来源,不属于网易/QQ 切换语义) +- 最近播放(bot 维度的播放历史,与音乐源无关) +- Library 现有的"我的收藏"段落 —— 当前调用的 `/api/music/user/liked` 端点不存在,是死代码,本次顺手移除 +- 登录状态实时同步(用户在 Settings 登录后需手动刷新 Home/Library 才能看到 QQ tab) +- Tab 排序、隐藏、拖动等高级配置 + +## Non-functional Constraints + +- 桌面端(>768px)和移动端(≤768px)布局均可用,tab 与 section title 同行排布;空间不足时允许 flex-wrap +- Tab 触控区域有效高度 ≥36px +- 现有 5 分钟 home data cache 行为保留 +- 不引入新的后端端点(后端已通过 `?platform=` 参数支持多源) + +## Architecture + +### 数据层(`web/src/stores/player.ts`) + +字段从单平台改为按 platform 切分: + +```ts +// 前 +recommendPlaylists: PlaylistItem[] +userPlaylists: PlaylistItem[] +dailySongs: Song[] + +// 后 +recommendPlaylists: { netease: PlaylistItem[]; qq: PlaylistItem[] } +userPlaylists: { netease: PlaylistItem[]; qq: PlaylistItem[] } +dailySongs: { netease: Song[]; qq: Song[] } + +// 新增 +authStatus: { netease: boolean; qq: boolean } +``` + +`fetchHomeData()` 改写: + +1. 并发调用 `/api/auth/status?platform=netease` 与 `?platform=qq`,写入 `authStatus` +2. 网易云的三类数据照常 fetch(推荐歌单匿名可访问;每日推荐和我的歌单需登录,未登录时 API 自然返回空或失败,`Promise.allSettled` 已隔离) +3. QQ 的三类数据**仅在 QQ 登录时** fetch,未登录则为空数组 +4. B 站热门保持原样 +5. 5 分钟缓存 TTL 不变 + +### UI 组件 + +新增 `web/src/components/SourceTabs.vue`: + +```vue + +``` + +Props: +- `sources: ('netease' | 'qq')[]` — 由父组件根据 auth 状态过滤后传入 +- `modelValue: 'netease' | 'qq'` — v-model 绑定 + +行为: +- `sources.length < 2` 时组件**自身不渲染**(返回空),父组件无需 v-if 包装 +- 文字标签:`{ netease: '网易云', qq: 'QQ' }` +- 视觉:水平排列,激活态用主色(`var(--color-primary)`)下划线 + 加粗,未激活态使用次要文字色 +- 紧贴 section-title 右侧,使用 `display: inline-flex`,移动端 padding/font-size 缩小 + +### 各 section 接入模板 + +```vue +
+

+ 推荐歌单 + +

+
+ + +
{{ pl.name }}
+
+
+
+``` + +每个 section 在 ` + + +``` + +Why these choices: +- `v-if="sources.length >= 2"` — auto-hide when only one source available; parent doesn't need wrapper logic +- Min-height 28px desktop / 36px mobile — comfortable touch on phones +- `--color-primary-12` (12% primary tint) — matches existing active-state pattern in the codebase +- No `--brand-netease/qq` in active state — keeps tab visually consistent regardless of which platform; brand colors are reserved for SongCard platform badges where they identify content origin + +- [ ] **Step 1.2: Verify it imports cleanly via type check** + +Run from project root: + +``` +npx tsc --noEmit +``` + +Expected: exit code 0, no output. + +Then verify the web project also type-checks: + +``` +cd web && npx vue-tsc --noEmit && cd .. +``` + +Expected: exit code 0, no output. + +- [ ] **Step 1.3: Commit** + +``` +git add web/src/components/SourceTabs.vue +git commit -m "feat(web): add SourceTabs component for platform switcher + +Presentational component for switching between netease and qq music +sources. Auto-hides when fewer than 2 sources are passed in. Mobile +breakpoint enlarges touch target to 36px. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: Refactor the store (state + fetchHomeData) + +**Files:** +- Modify: `web/src/stores/player.ts` + +This task changes types, which will break Home.vue and Library.vue at compile time. **Do not run tsc/build between Task 2 and Task 4** — they are migrated in a single coherent commit. After Task 4, type-check confirms the whole change. + +- [ ] **Step 2.1: Add the `Source` type alias and update state shape** + +In `web/src/stores/player.ts`, locate the `state: () => ({ ... })` block (around line 47-63). + +**Find:** + +```ts + // Home page cache + recommendPlaylists: [] as PlaylistItem[], + dailySongs: [] as Song[], + userPlaylists: [] as PlaylistItem[], + bilibiliPopular: [] as Song[], + lastFetchTime: 0, +``` + +**Replace with:** + +```ts + // Home page cache, split by source + recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] }, + dailySongs: { netease: [] as Song[], qq: [] as Song[] }, + userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] }, + bilibiliPopular: [] as Song[], + authStatus: { netease: false, qq: false }, + lastFetchTime: 0, +``` + +Also add this exported type at the top of the file, right after the existing `Song` interface (around line 12): + +```ts +export type Source = 'netease' | 'qq'; +``` + +- [ ] **Step 2.2: Rewrite `fetchHomeData()`** + +In the same file, find the `fetchHomeData` action (around line 352-378). + +**Replace the entire action body with:** + +```ts + async fetchHomeData() { + if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) { + return; + } + + // 1. Fetch auth status for both platforms first. + const [neAuthRes, qqAuthRes] = await Promise.allSettled([ + axios.get('/api/auth/status', { params: { platform: 'netease' } }), + axios.get('/api/auth/status', { params: { platform: 'qq' } }), + ]); + this.authStatus.netease = + neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn; + this.authStatus.qq = + qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn; + + // 2. NetEase data: recommend playlists work anonymously; daily/user + // playlists need login but Promise.allSettled isolates failures. + const neteasePromises = [ + axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }), + axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }), + axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }), + ]; + + // 3. QQ data: only fetch when QQ is logged in. When not logged in, + // resolve to empty payloads so the same indexed handling works. + const emptyPlaylists = { data: { playlists: [] } }; + const emptySongs = { data: { songs: [] } }; + const qqPromises = this.authStatus.qq + ? [ + axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }), + axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }), + axios.get('/api/music/user/playlists', { params: { platform: 'qq' } }), + ] + : [ + Promise.resolve(emptyPlaylists), + Promise.resolve(emptySongs), + Promise.resolve(emptyPlaylists), + ]; + + const biliPromise = axios.get('/api/music/bilibili/popular?limit=12'); + + const results = await Promise.allSettled([ + ...neteasePromises, + ...qqPromises, + biliPromise, + ]); + + const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results; + + if (neRecPL.status === 'fulfilled') { + this.recommendPlaylists.netease = neRecPL.value.data.playlists ?? []; + } + if (neDaily.status === 'fulfilled') { + this.dailySongs.netease = neDaily.value.data.songs ?? []; + } + if (neUserPL.status === 'fulfilled') { + this.userPlaylists.netease = neUserPL.value.data.playlists ?? []; + } + if (qqRecPL.status === 'fulfilled') { + this.recommendPlaylists.qq = qqRecPL.value.data.playlists ?? []; + } + if (qqDaily.status === 'fulfilled') { + this.dailySongs.qq = qqDaily.value.data.songs ?? []; + } + if (qqUserPL.status === 'fulfilled') { + this.userPlaylists.qq = qqUserPL.value.data.playlists ?? []; + } + if (bili.status === 'fulfilled') { + this.bilibiliPopular = bili.value.data.songs ?? []; + } + + this.lastFetchTime = Date.now(); + }, +``` + +**Do NOT type-check yet** — Home/Library still reference the old shape. They'll be migrated in Tasks 3 and 4. + +--- + +## Task 3: Migrate Home.vue to multi-source tabs + +**Files:** +- Modify: `web/src/views/Home.vue` + +- [ ] **Step 3.1: Add a localStorage helper module** + +Create `web/src/stores/sourceTabs.ts`: + +```ts +import type { Source } from './player.js'; + +const STORAGE_KEY = 'source-tabs'; + +export type TabKey = + | 'home.recommend' + | 'home.daily' + | 'home.user' + | 'library.user'; + +function readAll(): Partial> { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source { + const all = readAll(); + const v = all[key]; + return v === 'netease' || v === 'qq' ? v : fallback; +} + +export function saveTabSource(key: TabKey, value: Source): void { + try { + const all = readAll(); + all[key] = value; + localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); + } catch { + // localStorage may be unavailable (private browsing); silently no-op + } +} +``` + +This is a separate file rather than inline so Library can reuse it without duplication. + +- [ ] **Step 3.2: Update Home.vue template** + +Replace the three `
` blocks (推荐歌单 / 每日推荐 / 我的歌单) and the ` +``` + +**Replace with:** + +```ts + +``` + +Note: The 我的歌单 template uses `userSource` (not `userSourceSafe`) on the `` v-model so the user's click maps directly to the persisted ref. The grid below the tabs uses `currentUserPlaylists` which derives from `userSourceSafe`, so even if `userSource` points at an unavailable platform momentarily, the grid still renders something sensible. Same pattern for 推荐歌单 / 每日推荐. + +--- + +## Task 4: Migrate Library.vue and remove dead code + +**Files:** +- Modify: `web/src/views/Library.vue` + +- [ ] **Step 4.1: Replace the template** + +**Find** the ` diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index f8ce798..ba2a310 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -782,6 +782,13 @@ async function loadProfileConfig(botId: string) { profileLoadError[botId] = null; try { const res = await axios.get(`/api/player/${botId}/profile`); + // Defensive: a 200 response with non-object body (empty / proxy + // injection / etc.) would otherwise leave the row stuck on + // "加载中..." because profileConfigs[botId] would be falsy. + if (!res.data || typeof res.data !== 'object' || typeof res.data.avatarEnabled !== 'boolean') { + profileLoadError[botId] = '响应格式异常'; + return; + } profileConfigs[botId] = res.data; } catch (err: any) { profileLoadError[botId] = err?.response?.status === 404 From 59fb3ee3bd11d39e6b82256ea8822215fbbe20b8 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 16:00:48 +0800 Subject: [PATCH 16/28] fix: address all known follow-up issues except NetEase batch precheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface failures + tighten edges: 1. Toast for /play-song & /play-playlist failures. Backend now returns {ok, message} (localized in Chinese to match the rest of the UI). Store stashes a notification on ok=false; new Toast.vue mounted in App.vue shows it for 3-5s then fades. Clicking the X dismisses immediately. Sits above the player on desktop and above the mobile tabbar on phones. 2. QQ collected playlists pagination. fcg_get_profile_order_asset.fcg returns max 30 per call; we now loop using has_more / short-page detection up to a 300-playlist hard cap. Single-call users (typical) exit the loop on the first iteration so no extra requests. 3. getPlayableSongIds chunking. 100 mids per request keeps URL well under 8KB; chunk-level errors are isolated so a transient blip on one chunk doesn't poison the whole batch. Returns null only when every chunk failed (caller falls back to sequential retry). 4. SourceTabs single-source mode now renders a small subdued "网易云" or "QQ" label instead of vanishing entirely, so the user always knows which platform's data they're looking at. 5. Hide the "我的歌单 N" count badge when N=0 — Home and Library no longer show "我的歌单 0" with an empty grid. 6. Auth state change invalidates fetchHomeData cache. Previously, a user who logged out as account A and into account B within 5 minutes would see A's playlists. Now we always re-check auth at the top of fetchHomeData and bypass the TTL cache when authStatus has changed. Out of scope: - NetEase analogous batch precheck (per request). - 60s TS3 UDP idle disconnect — that's the bundled @honeybbq/teamspeak- client UDP layer kicking when no server packet arrives in 60s. It's baked in (constant `v=6e4`) and not exposed as an option, and root cause is server-side or network-layer behavior we can't reach from here. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/music/qq.ts | 110 ++++++++++++++++++------------ src/web/api/player.ts | 14 ++-- web/src/App.vue | 2 + web/src/components/SourceTabs.vue | 38 ++++++++--- web/src/components/Toast.vue | 106 ++++++++++++++++++++++++++++ web/src/stores/player.ts | 47 ++++++++++--- web/src/views/Home.vue | 2 +- web/src/views/Library.vue | 2 +- 8 files changed, 245 insertions(+), 76 deletions(-) create mode 100644 web/src/components/Toast.vue diff --git a/src/music/qq.ts b/src/music/qq.ts index 47a3b95..9c0360d 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -122,38 +122,41 @@ export class QQMusicProvider implements MusicProvider { * current user — a sequential retry loop wastes time guessing. * * The wrapper's /getMusicPlay accepts a comma-separated songmid list - * and resolves all of them in a single upstream call (~2-3s for 100+ - * songs), so this is much cheaper than per-song probing. + * and resolves all of them in a single upstream call. We chunk to keep + * the URL well under typical 8KB query-string limits and to keep per- + * request latency bounded (~2-3s per 100 mids). * * Returns: * - non-null Set: authoritative result. Empty Set means all songs are * unplayable; non-empty means filter to those mids. - * - null: the batch endpoint failed (timeout/exception). Caller - * should fall back to sequential retry rather than treating as - * "all unplayable", since we don't actually know. - * - * TODO: songIds with 1000+ entries may exceed URL length; chunk if - * we ever support that scale. + * - null: every chunk failed. Caller should fall back to sequential + * retry rather than treating as "all unplayable". */ async getPlayableSongIds(songIds: string[]): Promise | null> { if (songIds.length === 0) return new Set(); - try { - const res = await this.api.get("/getMusicPlay", { - params: { songmid: songIds.join(","), quality: this.quality, ...this.cookieParams }, - }); - const playUrlMap: Record | undefined = - res.data?.data?.playUrl; - // Distinguish "endpoint returned no playUrl object at all" (treat - // as failure → null) from "returned an empty/all-unplayable map". - if (!playUrlMap) return null; - const playable = new Set(); - for (const [mid, info] of Object.entries(playUrlMap)) { - if (info?.url) playable.add(mid); + + const CHUNK = 100; // ~14 chars/mid * 100 + commas ≈ 1.5KB + const playable = new Set(); + let allChunksFailed = true; + for (let i = 0; i < songIds.length; i += CHUNK) { + const slice = songIds.slice(i, i + CHUNK); + try { + const res = await this.api.get("/getMusicPlay", { + params: { songmid: slice.join(","), quality: this.quality, ...this.cookieParams }, + }); + const playUrlMap: Record | undefined = + res.data?.data?.playUrl; + if (!playUrlMap) continue; // chunk-level failure, try next + allChunksFailed = false; + for (const [mid, info] of Object.entries(playUrlMap)) { + if (info?.url) playable.add(mid); + } + } catch { + // chunk-level failure — keep going so a transient error on one + // chunk doesn't poison the whole batch. } - return playable; - } catch { - return null; } + return allChunksFailed ? null : playable; } async getSongDetail(songId: string): Promise { @@ -433,30 +436,47 @@ export class QQMusicProvider implements MusicProvider { const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie); if (!pSkeyMatch) return []; const gtk = computeGtk(pSkeyMatch[1]); + + const PAGE_SIZE = 30; + const MAX_PAGES = 10; // 300-playlist hard cap; should cover any sane user + const all: Playlist[] = []; try { - const res = await qqFavApi.get("/fav/fcgi-bin/fcg_get_profile_order_asset.fcg", { - params: { - ct: 20, - cid: 205360956, - userid: uin, - reqtype: 3, - sin: 0, - ein: 29, - g_tk: gtk, - format: "json", - }, - headers: { Cookie: this.cookie }, - }); - if (res.data?.code !== 0) return []; - return (res.data?.data?.cdlist ?? []).map((p: any) => ({ - id: String(p.dissid ?? ""), - name: p.dissname ?? "", - coverUrl: p.logo ?? "", - songCount: p.songnum ?? 0, - platform: "qq" as const, - })); + for (let page = 0; page < MAX_PAGES; page++) { + const sin = page * PAGE_SIZE; + const ein = sin + PAGE_SIZE - 1; + const res = await qqFavApi.get("/fav/fcgi-bin/fcg_get_profile_order_asset.fcg", { + params: { + ct: 20, + cid: 205360956, + userid: uin, + reqtype: 3, + sin, + ein, + g_tk: gtk, + format: "json", + }, + headers: { Cookie: this.cookie }, + }); + if (res.data?.code !== 0) break; + const list: any[] = res.data?.data?.cdlist ?? []; + for (const p of list) { + all.push({ + id: String(p.dissid ?? ""), + name: p.dissname ?? "", + coverUrl: p.logo ?? "", + songCount: p.songnum ?? 0, + platform: "qq", + }); + } + // Stop when upstream signals no more pages, or when this page is + // short (also indicates end). has_more is the canonical signal. + const hasMore = res.data?.data?.has_more === 1 || res.data?.data?.has_more === true; + if (!hasMore || list.length < PAGE_SIZE) break; + } } catch { - return []; + // Return whatever we got so far on partial failure rather than dropping + // earlier pages. } + return all; } } diff --git a/src/web/api/player.ts b/src/web/api/player.ts index dfaf30e..cb0e0ba 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -268,7 +268,7 @@ export function createPlayerRouter( // the sequential retry path, which still has a chance. } if (queueable.length === 0) { - res.json({ message: `Loaded ${totalCount} songs but none were playable (likely copyright/region restrictions).` }); + res.json({ ok: false, message: `歌单 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` }); return; } @@ -300,12 +300,12 @@ export function createPlayerRouter( const playing = queue.current(); const loadedMsg = queueable.length < totalCount - ? `Loaded ${queueable.length} of ${totalCount} songs (rest are copyright/region restricted)` - : `Loaded ${queueable.length} songs`; + ? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)` + : `已加载 ${queueable.length} 首`; if (started && playing) { - res.json({ message: `${loadedMsg}. Now playing: ${playing.name}` }); + res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` }); } else { - res.json({ message: `${loadedMsg}, but couldn't start playback.` }); + res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` }); } } catch (err) { logger.error({ err }, "Play playlist failed"); @@ -330,11 +330,11 @@ export function createPlayerRouter( bot.getPlayer().resetFailures(); const ok = await bot.resolveAndPlay(queue.current()!); if (!ok) { - res.json({ message: `Cannot play: ${song.name || song.id}` }); + res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` }); return; } - res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }); + res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }); } catch (err) { res.status(500).json({ error: (err as Error).message }); } diff --git a/web/src/App.vue b/web/src/App.vue index a76eefa..5bbed9b 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -5,6 +5,7 @@ +
@@ -55,6 +56,7 @@ import { useWebSocket } from './composables/useWebSocket.js'; import Navbar from './components/Navbar.vue'; import Player from './components/Player.vue'; import CoverArt from './components/CoverArt.vue'; +import Toast from './components/Toast.vue'; const playerStore = usePlayerStore(); const theme = computed(() => playerStore.theme); diff --git a/web/src/components/SourceTabs.vue b/web/src/components/SourceTabs.vue index 365cf9b..b065199 100644 --- a/web/src/components/SourceTabs.vue +++ b/web/src/components/SourceTabs.vue @@ -1,15 +1,20 @@ @@ -63,6 +68,17 @@ defineEmits<{ } } +// Single-source mode: not interactive, but tells the user which platform +// they're looking at instead of leaving them guessing. +.source-tab-label { + padding: 4px 10px; + font-size: var(--fs-xs); + font-weight: var(--fw-medium); + color: var(--text-tertiary); + background: var(--hover-bg); + border-radius: var(--radius-sm); +} + @media (max-width: 768px) { .source-tabs { margin-left: 8px; diff --git a/web/src/components/Toast.vue b/web/src/components/Toast.vue new file mode 100644 index 0000000..e695c98 --- /dev/null +++ b/web/src/components/Toast.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index bf24a07..9a14f11 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -63,6 +63,10 @@ export const usePlayerStore = defineStore('player', { bilibiliPopular: [] as Song[], authStatus: { netease: false, qq: false }, lastFetchTime: 0, + + // Transient notification for surfacing failures (e.g., "song not playable") + // to a global Toast. Bumped `id` triggers re-render of the same message. + notification: null as { id: number; message: string; type: 'error' | 'info' } | null, }), getters: { @@ -269,9 +273,16 @@ export const usePlayerStore = defineStore('player', { this._syncAfterAction(); }, + notify(message: string, type: 'error' | 'info' = 'info') { + this.notification = { id: Date.now(), message, type }; + }, + async playSong(song: Song) { if (!this.activeBotId) return; - await axios.post(`/api/player/${this.activeBotId}/play-song`, { song }); + const res = await axios.post(`/api/player/${this.activeBotId}/play-song`, { song }); + if (res.data?.ok === false && res.data?.message) { + this.notify(res.data.message, 'error'); + } this._setTiming(this.activeBotId, { serverElapsed: 0 }); this._syncAfterAction(); }, @@ -293,7 +304,10 @@ export const usePlayerStore = defineStore('player', { async playPlaylist(playlistId: string, platform = 'netease') { if (!this.activeBotId) return; - await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform }); + const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform }); + if (res.data?.message) { + this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); + } this._setTiming(this.activeBotId, { serverElapsed: 0 }); this._syncAfterAction(); }, @@ -360,19 +374,30 @@ export const usePlayerStore = defineStore('player', { }, async fetchHomeData() { - if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) { - return; - } - - // 1. Fetch auth status for both platforms first. + // Always check auth status first — if it changed since the cached + // fetch (e.g., user logged in/out as a different account), the + // cached playlists belong to a different user and we MUST refetch. const [neAuthRes, qqAuthRes] = await Promise.allSettled([ axios.get('/api/auth/status', { params: { platform: 'netease' } }), axios.get('/api/auth/status', { params: { platform: 'qq' } }), ]); - this.authStatus.netease = - neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn; - this.authStatus.qq = - qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn; + const newAuth = { + netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn, + qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn, + }; + const authChanged = + newAuth.netease !== this.authStatus.netease || newAuth.qq !== this.authStatus.qq; + this.authStatus.netease = newAuth.netease; + this.authStatus.qq = newAuth.qq; + + // Cache hit only if auth is unchanged AND within TTL. + if ( + !authChanged && + this.lastFetchTime > 0 && + Date.now() - this.lastFetchTime < HOME_CACHE_TTL + ) { + return; + } // 2. NetEase data: recommend playlists work anonymously; daily/user // playlists need login but Promise.allSettled isolates failures. diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue index 675b0d3..c7d64ed 100644 --- a/web/src/views/Home.vue +++ b/web/src/views/Home.vue @@ -76,7 +76,7 @@

我的歌单 - {{ currentUserPlaylists.length }} + {{ currentUserPlaylists.length }}

diff --git a/web/src/views/Library.vue b/web/src/views/Library.vue index e23d2e7..0f0bd6f 100644 --- a/web/src/views/Library.vue +++ b/web/src/views/Library.vue @@ -6,7 +6,7 @@

我的歌单 - {{ currentUserPlaylists.length }} + {{ currentUserPlaylists.length }}

From 58bdfb94a9f1e4c9bf2f3653d71c4b38b10961c6 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 16:06:56 +0800 Subject: [PATCH 17/28] docs: spec for history-aware prev + play-next insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...05-06-prev-history-and-play-next-design.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-06-prev-history-and-play-next-design.md diff --git a/docs/superpowers/specs/2026-05-06-prev-history-and-play-next-design.md b/docs/superpowers/specs/2026-05-06-prev-history-and-play-next-design.md new file mode 100644 index 0000000..00217c1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-prev-history-and-play-next-design.md @@ -0,0 +1,226 @@ +# History-aware `prev` + "Play Next" Insert + +**Date:** 2026-05-06 +**Status:** Spec — pending implementation + +## Problem + +Two queue/playback gaps surfaced in real use: + +1. In `PlayMode.Random` and `PlayMode.RandomLoop`, `!prev` does not play the + actually-previously-played song. It just walks `currentIndex - 1` in the + underlying array — but in random modes `currentIndex` jumps non-sequentially, + so the "previous" array slot has no relationship to play history. + +2. `!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 + +- `prev` walks 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. +- `addNext` must keep `playedIndices` and `history` index 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: + +```ts +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:** + +```ts +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)`:** + +```ts +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(); + 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), then `queue.play()`, then `resolveAndPlay`. 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 ` (alias `!pn`): +- Mirror of `cmdPlay`'s search step +- On match: `queue.addNext(song)`. If no current playback, fall through to + `resolveAndPlay`. +- Reply with `已加入下一首:` or `正在播放:` accordingly + +**Frontend store action** — `web/src/stores/player.ts`: + +```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": + +```vue + +``` + +Add `playNext: []` to `defineEmits`. + +**Caller updates** — `Home.vue`, `Library.vue`, `Search.vue`, `History.vue`, +`Playlist.vue`: each `` 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`, rewritten `prev`, mutator updates +- `src/audio/queue.test.ts` (or add if missing) — unit tests for history behavior + addNext shift logic +- `src/bot/instance.ts` — register `!playnext` / `!pn` command handler +- `src/web/api/player.ts` — new `/play-next-song` route +- `web/src/stores/player.ts` — `playNextSong` action +- `web/src/components/SongCard.vue` — third action button + emit +- `web/src/views/Home.vue` — wire `@playNext` +- `web/src/views/Library.vue` — wire `@playNext` +- `web/src/views/Search.vue` — wire `@playNext` +- `web/src/views/History.vue` — wire `@playNext` +- `web/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 > idx +- `addNext` while empty: appends +- `addNext` while playing index 2 in a 5-song queue: ends up at index 3, currentIndex still 2, queue size 6 +- `addNext` then `next()`: plays the inserted song +- `addNext` shifts existing playedIndices and history correctly + +**Integration (manual smoke):** +- Random mode: play 4 songs, hit `prev` 3 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 七里香 +- `!playnext` while idle → starts playing immediately + +**Regression:** +- Existing 161 source-tree tests still pass. +- TypeScript `tsc --noEmit` and `npm run build:web` clean. From 94c60ad465d0da9759d2f669fc3f4fbbe24be9b9 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 16:10:57 +0800 Subject: [PATCH 18/28] 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) --- .../2026-05-06-prev-history-and-play-next.md | 1143 +++++++++++++++++ 1 file changed, 1143 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-06-prev-history-and-play-next.md diff --git a/docs/superpowers/plans/2026-05-06-prev-history-and-play-next.md b/docs/superpowers/plans/2026-05-06-prev-history-and-play-next.md new file mode 100644 index 0000000..0b840a4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-prev-history-and-play-next.md @@ -0,0 +1,1143 @@ +# 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();` line in the `PlayQueue` class (around line 23) and add immediately after: + +```ts + 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) " +``` + +--- + +## 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 `});`): + +```ts + 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: +```ts + 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:** +```ts + 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)`: +```ts + 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:** +```ts + 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 `playAt`s in a row should each push to history. + +**Find** `next()`: +```ts + 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:** +```ts + 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()`: +```ts + clear(): void { + this.songs = []; + this.currentIndex = -1; + this.playedIndices.clear(); + } +``` +**Replace with:** +```ts + clear(): void { + this.songs = []; + this.currentIndex = -1; + this.playedIndices.clear(); + this.history = []; + } +``` + +**Find** `setMode(mode)`: +```ts + setMode(mode: PlayMode): void { + this.mode = mode; + this.playedIndices.clear(); + if (this.currentIndex >= 0) { + this.playedIndices.add(this.currentIndex); + } + } +``` +**Replace with:** +```ts + setMode(mode: PlayMode): void { + this.mode = mode; + this.playedIndices.clear(); + this.history = []; + if (this.currentIndex >= 0) { + this.playedIndices.add(this.currentIndex); + } + } +``` + +**Find** `remove(index)`: +```ts + 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(); + for (const idx of this.playedIndices) { + if (idx === index) continue; + newPlayed.add(idx > index ? idx - 1 : idx); + } + this.playedIndices = newPlayed; + + return removed; + } +``` +**Replace with:** +```ts + 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(); + 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()`: +```ts + 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:** +```ts + 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) " +``` + +--- + +## 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", ...)`): + +```ts + 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): + +```ts + addMany(songs: QueuedSong[]): void { + this.songs.push(...songs); + } +``` + +**Add a new method immediately after** `addMany`: + +```ts + /** + * 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(); + 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) " +``` + +--- + +## 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): + +```ts + // 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`: + +```ts + private async cmdPlayNext(cmd: ParsedCommand): Promise { + if (!cmd.args) return "Usage: !playnext "; + 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): + +```ts + const AUDIO_COMMANDS = new Set([ + "play", + "add", + "next", + "skip", + "prev", + "playlist", + "album", + "fm", + "artist", + ]); +``` + +**Replace with:** + +```ts + 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:** + +```ts + 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:** + +```ts + `${p}add — Add to queue`, +``` + +**Replace with:** + +```ts + `${p}add — Add to queue`, + `${p}playnext — 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//play-next-song" \ + -H "Content-Type: application/json" \ + -d '{"song":{"id":"002CGrO91icqlg","name":"测试","artist":"测试","album":"","duration":200,"coverUrl":"","platform":"qq"}}' | head -c 200 +``` + +Replace `` 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) " +``` + +--- + +## 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`: + +```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'); + } + // 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) " +``` + +--- + +## 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: + +```vue +
+ + +
+``` + +**Replace with:** + +```vue +
+ + + +
+``` + +In the `defineEmits` block, find: + +```ts +defineEmits<{ + play: []; + add: []; +}>(); +``` + +**Replace with:** + +```ts +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) " +``` + +--- + +## 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 `` 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: + +```vue + +``` + +**Replace with:** + +```vue + +``` + +- [ ] **Step 7.3: Search.vue** + +In `web/src/views/Search.vue`, find the `` 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: + +```vue + +``` + +(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 `` usage and add `@playNext="store.playNextSong(song)"`. Result should look like: + +```vue + +``` + +- [ ] **Step 7.5: Playlist.vue** + +In `web/src/views/Playlist.vue`, find: + +```vue + +``` + +**Replace with:** + +```vue + +``` + +- [ ] **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) " +``` + +--- + +## 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 From c4b7cfaa2f011c7da41bce63c88c7a3df4ff9a99 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 16:24:52 +0800 Subject: [PATCH 19/28] refactor(queue): add history field and pushHistory helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/audio/queue.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/audio/queue.ts b/src/audio/queue.ts index f63980c..31b993a 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -21,6 +21,16 @@ export class PlayQueue { private currentIndex = -1; private mode: PlayMode = PlayMode.Sequential; private playedIndices = new Set(); + 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(); + } + } add(song: QueuedSong): void { this.songs.push(song); 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 20/28] 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); } From 30fd8a19b8957d321d00211b5f5c88d7eb7850dc Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 16:32:23 +0800 Subject: [PATCH 21/28] fix(queue): restore playedIndices clear in playAt; test HISTORY_LIMIT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review of 390d3fa flagged that dropping playedIndices.clear() from playAt is an unnecessary behavior change. The two operations (push history, reset random pool) are not in tension — restoring the clear preserves the original 'explicit pick restarts shuffle' semantic that Random mode users rely on, while still tracking the prev-history stack. Also add a regression test that the 50-entry HISTORY_LIMIT cap works. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/audio/queue.test.ts | 22 ++++++++++++++++++++++ src/audio/queue.ts | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/src/audio/queue.test.ts b/src/audio/queue.test.ts index bb51cd1..2416527 100644 --- a/src/audio/queue.test.ts +++ b/src/audio/queue.test.ts @@ -363,5 +363,27 @@ describe("PlayQueue", () => { queue.prev(); // pops 0, history: [] expect(queue.prev()).toBeNull(); // no fallback target in random mode }); + + it("caps history at HISTORY_LIMIT (50) entries, dropping oldest", () => { + queue.setMode(PlayMode.Random); + // Build a queue large enough to overflow HISTORY_LIMIT + for (let i = 0; i < 60; i++) queue.add(makeSong(`s${i}`)); + // Walk through 60 explicit picks → 59 pushes to history + // (playAt pushes the previous currentIndex; first call has -1 + // which pushHistory rejects). After 60 playAts, history holds + // the last 50 of those 59 entries. + for (let i = 0; i < 60; i++) queue.playAt(i); + + // Walk back through history. The first prev returns whatever the + // 50th-most-recent push was (= index 9, since pushes 0..58 happened + // and the oldest 9 fell off). We can verify by counting prevs that + // succeed before history exhausts and prev returns null in random. + let count = 0; + while (queue.prev() !== null) { + count++; + if (count > 100) break; // safety + } + expect(count).toBe(50); + }); }); }); diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 6e1d0fc..578dee1 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -86,6 +86,10 @@ export class PlayQueue { playAt(index: number): QueuedSong | null { if (index < 0 || index >= this.songs.length) return null; this.pushHistory(this.currentIndex); + // Reset the Random-mode "unplayed" pool — explicit picks restart + // shuffle from this point. History tracking is independent and + // unaffected by this clear. + this.playedIndices.clear(); this.currentIndex = index; this.playedIndices.add(index); return this.songs[index]; From 11c6a3f51b491150f8bb3a9ee67f2662f0e41aac Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 16:34:32 +0800 Subject: [PATCH 22/28] feat(queue): addNext inserts a song to play right after current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/audio/queue.test.ts | 74 +++++++++++++++++++++++++++++++++++++++++ src/audio/queue.ts | 28 ++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/audio/queue.test.ts b/src/audio/queue.test.ts index 2416527..d454ae9 100644 --- a/src/audio/queue.test.ts +++ b/src/audio/queue.test.ts @@ -386,4 +386,78 @@ describe("PlayQueue", () => { expect(count).toBe(50); }); }); + + 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"); + }); + }); }); diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 578dee1..8a57e13 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -40,6 +40,34 @@ export class PlayQueue { this.songs.push(...songs); } + /** + * 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(); + 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, + ); + } + remove(index: number): QueuedSong | null { if (index < 0 || index >= this.songs.length) return null; const [removed] = this.songs.splice(index, 1); From 3e795d9a83bcb48c67a88a298541b269fe449354 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 16:37:52 +0800 Subject: [PATCH 23/28] 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) --- src/bot/instance.ts | 31 +++++++++++++++++++++++++++++++ src/web/api/player.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 0174207..febd20c 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -254,6 +254,8 @@ export class BotInstance extends EventEmitter { const AUDIO_COMMANDS = new Set([ "play", "add", + "playnext", + "pn", "next", "skip", "prev", @@ -270,6 +272,9 @@ export class BotInstance extends EventEmitter { return this.cmdPlay(cmd); case "add": return this.cmdAdd(cmd); + case "playnext": + case "pn": + return this.cmdPlayNext(cmd); case "pause": return this.cmdPause(); case "resume": @@ -428,6 +433,31 @@ export class BotInstance extends EventEmitter { return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`; } + private async cmdPlayNext(cmd: ParsedCommand): Promise { + if (!cmd.args) return "Usage: !playnext "; + 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}`; + } + private cmdPause(): string { this.player.pause(); this.emit("stateChange"); @@ -715,6 +745,7 @@ export class BotInstance extends EventEmitter { `${p}play -b — Search from BiliBili`, `${p}play -y — Search from YouTube (yt-dlp)`, `${p}add — Add to queue`, + `${p}playnext — Insert as next song (alias: ${p}pn)`, `${p}pause/resume — Pause/resume`, `${p}next/prev — Next/previous`, `${p}stop — Stop and clear queue`, diff --git a/src/web/api/player.ts b/src/web/api/player.ts index cb0e0ba..ae418c6 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -340,6 +340,40 @@ export function createPlayerRouter( } }); + // 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 }); + } + }); + router.post("/:botId/add-song", async (req, res) => { try { const bot = (req as any).bot; From 44b0b5c31c8b52eed138a17ae1a28d543a30f154 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 16:40:12 +0800 Subject: [PATCH 24/28] 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) --- web/src/stores/player.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 9a14f11..4c1c970 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -287,6 +287,16 @@ export const usePlayerStore = defineStore('player', { this._syncAfterAction(); }, + 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(); + }, + async addToQueue(query: string, platform = 'netease') { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform }); From ad4fb5d3c73ca5b6469df8c6ba929215150c0950 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 16:41:47 +0800 Subject: [PATCH 25/28] feat(web): third 'play next' action on SongCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/src/components/SongCard.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/src/components/SongCard.vue b/web/src/components/SongCard.vue index 61ef7b4..8dd2b1a 100644 --- a/web/src/components/SongCard.vue +++ b/web/src/components/SongCard.vue @@ -18,6 +18,9 @@ + @@ -38,6 +41,7 @@ defineProps<{ defineEmits<{ play: []; + playNext: []; add: []; }>(); From caeef65cdbf5a6a065732cfa83a19e28bf87e7f9 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 16:43:10 +0800 Subject: [PATCH 26/28] 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) --- web/src/views/History.vue | 1 + web/src/views/Library.vue | 1 + web/src/views/Playlist.vue | 1 + web/src/views/Search.vue | 1 + 4 files changed, 4 insertions(+) diff --git a/web/src/views/History.vue b/web/src/views/History.vue index 6c76035..a52c0d2 100644 --- a/web/src/views/History.vue +++ b/web/src/views/History.vue @@ -20,6 +20,7 @@ :index="i + 1" :active="store.currentSong?.id === song.id" @play="store.playSong(song)" + @playNext="store.playNextSong(song)" @add="store.addSong(song)" />
diff --git a/web/src/views/Library.vue b/web/src/views/Library.vue index 0f0bd6f..03a8d78 100644 --- a/web/src/views/Library.vue +++ b/web/src/views/Library.vue @@ -36,6 +36,7 @@ :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)" />
diff --git a/web/src/views/Playlist.vue b/web/src/views/Playlist.vue index 01ba118..d5c9f8d 100644 --- a/web/src/views/Playlist.vue +++ b/web/src/views/Playlist.vue @@ -32,6 +32,7 @@ :index="i + 1" :active="store.currentSong?.id === song.id" @play="store.playSong(song)" + @playNext="store.playNextSong(song)" @add="store.addSong(song)" />
diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue index f7a6016..0536e36 100644 --- a/web/src/views/Search.vue +++ b/web/src/views/Search.vue @@ -28,6 +28,7 @@ :index="i + 1" :active="store.currentSong?.id === song.id" @play="store.playSong(song)" + @playNext="store.playNextSong(song)" @add="store.addSong(song)" /> From fab8c194e38ad23b74d8186fc274f702653e985d Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 17:00:05 +0800 Subject: [PATCH 27/28] fix(player): play-next must use insertedAt, not size-1, when idle Final review of caeef65 caught a stale-currentIndex bug in /play-next-song and cmdPlayNext: when the player is idle but queue.currentIndex >= 0 (natural end-of-track, or playNext gave up after retries without queue.clear), addNext splices mid-queue and playAt(size-1) jumps PAST the inserted song to whatever was last. Capture the insertion slot before calling addNext and promote that exact index instead. Add a regression test covering the [a,b,c,d] queue with currentIndex=1 case -- splice at 2 yields x at index 2, but size-1 would point at d (index 4). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/audio/queue.test.ts | 24 ++++++++++++++++++++++++ src/bot/instance.ts | 12 ++++++++++-- src/web/api/player.ts | 12 +++++++++--- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/audio/queue.test.ts b/src/audio/queue.test.ts index d454ae9..e0dd233 100644 --- a/src/audio/queue.test.ts +++ b/src/audio/queue.test.ts @@ -459,5 +459,29 @@ describe("PlayQueue", () => { // prev again → pop 0 → song at index 0 = a expect(queue.prev()?.id).toBe("a"); }); + + it("idle player + stale currentIndex: insertion target is currentIndex+1, not size-1", () => { + // Reproduces the scenario where the player has gone idle but the + // queue still has a non-negative currentIndex (e.g., after natural + // track end without queue.clear()). + 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) + // Simulate idle-with-stale-currentIndex: the player has gone idle + // but queue still points at b. + // Caller pre-captures insertedAt: + const insertedAt = queue.getCurrentIndex() + 1; // = 2 + queue.addNext(makeSong("x")); + // queue is now [a, b, x, c, d] + // size-1 would be 4 (d) — WRONG. + // insertedAt is 2 (x) — RIGHT. + expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "x", "c", "d"]); + expect(queue.size() - 1).toBe(4); // proves size-1 strategy would pick d + const promoted = queue.playAt(insertedAt); + expect(promoted?.id).toBe("x"); + }); }); }); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index febd20c..728ded7 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -442,11 +442,19 @@ export class BotInstance extends EventEmitter { const song = result.songs[0]; const wasIdle = this.player.getState() === "idle"; + // Capture the slot addNext WILL insert at, before mutating the queue. + // addNext pushes when currentIndex<0 (slot = size); otherwise splices + // at currentIndex+1. Using size-1 after addNext was wrong when the + // queue had stale currentIndex>=0 while the player was idle (e.g., + // after natural track end without queue.clear()). + const insertedAt = + this.queue.getCurrentIndex() < 0 + ? this.queue.size() + : this.queue.getCurrentIndex() + 1; 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.queue.playAt(insertedAt); this.player.resetFailures(); const ok = await this.resolveAndPlay(this.queue.current()!); this.emit("stateChange"); diff --git a/src/web/api/player.ts b/src/web/api/player.ts index ae418c6..a9af600 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -352,12 +352,18 @@ export function createPlayerRouter( } const queue = bot.getQueueManager(); const wasIdle = bot.getPlayer().getState() === "idle"; + // Capture the slot addNext WILL insert at, before mutating the queue. + // addNext pushes when currentIndex<0 (slot = size); otherwise splices + // at currentIndex+1. Using size-1 after addNext was wrong when the + // queue had stale currentIndex>=0 while the player was idle (e.g., + // after natural track end without queue.clear()). + const insertedAt = + queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1; 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); + // Promote the just-added song to current and start it. + queue.playAt(insertedAt); bot.getPlayer().resetFailures(); const ok = await bot.resolveAndPlay(queue.current()!); if (!ok) { From 73f3f7749ac7fecaf529dc476f29544024f4106e Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 6 May 2026 17:06:18 +0800 Subject: [PATCH 28/28] fix: cmdPrev retry-skip + show SongCard actions on touch devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corner-case fixes for the prev-history + play-next feature: 1. cmdPrev only tried queue.prev() once. instance.playNext's auto- advance retry-skip pushes failed songs into the same history stack, so a single prev frequently lands on an unplayable song — returning "Cannot play previous song" while leaving queue.currentIndex stuck mid-failure (causing next() to skip past the actually-playing song). Retry up to 4 times so prev finds a playable history entry, matching the retry budget already used by playNext for auto-advance. 2. SongCard.song-actions has opacity:0 by default and is revealed via parent :hover. Touch devices have no hover, so all three action buttons (Play / Play Next / Add) were invisible to phone/tablet users. Add @media (pointer: coarse) → opacity:1 to always show on touch. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bot/instance.ts | 14 +++++++++----- web/src/components/SongCard.vue | 9 +++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 728ded7..3f6bf50 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -498,13 +498,17 @@ export class BotInstance extends EventEmitter { } private async cmdPrev(): Promise { - const prev = this.queue.prev(); - if (prev) { + // Retry-skip up to 4 attempts: history can include failed songs + // that playNext's auto-advance retry-skipped past, so a single + // prev would otherwise land on an unplayable song and leave the + // queue's currentIndex stuck mid-failure. + for (let i = 0; i < 4; i++) { + const prev = this.queue.prev(); + if (!prev) return "No previous song"; const ok = await this.resolveAndPlay(prev); - if (!ok) return "Cannot play previous song"; - return `Now playing: ${prev.name} - ${prev.artist}`; + if (ok) return `Now playing: ${prev.name} - ${prev.artist}`; } - return "No previous song"; + return "Cannot play any previous songs (all failed to resolve)"; } private cmdVol(cmd: ParsedCommand): string { diff --git a/web/src/components/SongCard.vue b/web/src/components/SongCard.vue index 8dd2b1a..c2cded1 100644 --- a/web/src/components/SongCard.vue +++ b/web/src/components/SongCard.vue @@ -156,6 +156,15 @@ function formatDuration(seconds: number): string { transition: opacity var(--transition-fast); } +// Touch devices have no :hover, so the parent-hover-reveals-actions +// pattern leaves all action buttons invisible. Always show on coarse- +// pointer (touch) inputs — this is also where bigger tap targets matter. +@media (pointer: coarse) { + .song-actions { + opacity: 1; + } +} + .action-btn { font-size: 18px; padding: 4px;