diff --git a/docs/superpowers/plans/2026-05-06-music-source-tabs.md b/docs/superpowers/plans/2026-05-06-music-source-tabs.md new file mode 100644 index 0000000..491c3e2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-music-source-tabs.md @@ -0,0 +1,911 @@ +# Music Source Tabs 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:** Add NetEase / QQ source-switcher tabs to Home (推荐歌单 / 每日推荐 / 我的歌单) and Library (我的歌单), with per-section persistence and graceful degradation when only one source is logged in. + +**Architecture:** A single shared `` Vue component handles the tab UI and self-hides when fewer than 2 sources are available. The Pinia store splits the affected fields into `{ netease, qq }` objects, fetches from both platforms in `fetchHomeData()` based on `authStatus`, and consumers select with a reactive `activeSource` ref persisted to localStorage. + +**Tech Stack:** Vue 3 (Composition API + ` + + +``` + +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 `