import { defineStore } from 'pinia'; import axios from 'axios'; import { resolveScopedBot } from './scope.js'; import { useSession } from '../composables/useSession.js'; export interface Song { id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local'; } export type Source = 'netease' | 'qq'; export interface BotStatus { id: string; name: string; connected: boolean; playing: boolean; paused: boolean; currentSong: Song | null; queueSize: number; volume: number; playMode: string; elapsed?: number; } export interface PlaylistItem { id: string; name: string; coverUrl: string; songCount: number; platform: string; } export interface FavoritePlaylist { id: number; userId: string; platform: string; playlistId: string; name: string; coverUrl: string; songCount: number; createdAt: string; } interface TimingState { serverElapsed: number; serverSyncTime: number; wasPlaying: boolean; } const HOME_CACHE_TTL = 5 * 60 * 1000; function defaultTiming(): TimingState { return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false }; } export const usePlayerStore = defineStore('player', { state: () => ({ bots: [] as BotStatus[], activeBotId: null as string | null, /** When set, the UI is locked to a single bot (dedicated link, from ?bot). * Source of truth is the URL — never persisted to localStorage. */ scopedBotId: null as string | null, /** Per-bot queues keyed by botId */ queues: {} as Record, /** Per-bot timing state keyed by botId */ timings: {} as Record, theme: 'dark' as 'dark' | 'light', // 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, // Favorited playlists (fetched from server, isolated per WebUI user) favoritedPlaylists: [] as FavoritePlaylist[], // 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: { activeBot(): BotStatus | null { return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null; }, /** True when the UI is locked to a single bot via a dedicated link. */ isScoped(): boolean { return this.scopedBotId !== null; }, currentSong(): Song | null { return this.activeBot?.currentSong ?? null; }, isPlaying(): boolean { return this.activeBot?.playing ?? false; }, isPaused(): boolean { return this.activeBot?.paused ?? false; }, /** Queue for the currently active bot */ queue(): Song[] { const botId = this.activeBotId ?? this.bots[0]?.id; if (!botId) return []; return this.queues[botId] ?? []; }, /** Interpolated elapsed for the active bot */ elapsed(): number { const botId = this.activeBotId ?? this.bots[0]?.id; if (!botId || !this.activeBot?.currentSong) return 0; const timing = this.timings[botId] ?? defaultTiming(); const maxDuration = this.activeBot.currentSong.duration || Infinity; if (!timing.wasPlaying || timing.serverSyncTime === 0) return Math.min(timing.serverElapsed, maxDuration); if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration); return Math.min(timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000, maxDuration); }, /** Sources that are currently logged in. Order: netease before qq. */ availableSources(): Source[] { const s: Source[] = []; if (this.authStatus.netease) s.push('netease'); if (this.authStatus.qq) s.push('qq'); return s; }, }, actions: { _getTiming(botId: string): TimingState { if (!this.timings[botId]) { this.timings[botId] = defaultTiming(); } return this.timings[botId]; }, _setTiming(botId: string, partial: Partial) { const current = this._getTiming(botId); this.timings[botId] = { ...current, ...partial }; }, getQueueForBot(botId: string): Song[] { return this.queues[botId] ?? []; }, setActiveBotId(id: string) { // While scoped to a dedicated link, switching bots is blocked. if (this.scopedBotId !== null && id !== this.scopedBotId) return; this.activeBotId = id; // Fetch queue for newly active bot if we don't have it yet if (!this.queues[id]) { this.fetchQueue(); } }, /** Lock the UI to a single bot (dedicated link). Sets scope first so the * setActiveBotId guard does not block the switch to the scoped bot. */ setScope(id: string) { this.scopedBotId = id; this.activeBotId = id; // Lazily fetch this bot's queue, mirroring setActiveBotId. if (!this.queues[id]) { this.fetchQueue(); } }, clearScope() { this.scopedBotId = null; }, /** Reconcile the scope with the desired id from the URL (?bot). A stale or * forbidden id resolves to null and clears the scope rather than locking. */ applyScopeFromQuery(requestedId: string | null) { const r = resolveScopedBot(requestedId, this.bots.map((b) => b.id)); if (r) { this.setScope(r); } else if (requestedId) { this.clearScope(); } }, updateBotStatus(botId: string, status: BotStatus) { const prev = this.bots.find((b) => b.id === botId); const prevSongId = prev?.currentSong?.id; const index = this.bots.findIndex((b) => b.id === botId); if (index >= 0) { this.bots[index] = status; } else { this.bots.push(status); } // Sync elapsed from server status — always per-bot if (status.elapsed !== undefined) { this._setTiming(botId, { serverElapsed: status.elapsed, serverSyncTime: Date.now(), wasPlaying: status.playing && !status.paused, }); } // Song changed — reset timing for this bot if (status.currentSong?.id !== prevSongId) { this._setTiming(botId, { serverElapsed: status.elapsed ?? 0, serverSyncTime: Date.now(), wasPlaying: status.playing && !status.paused, }); } }, removeBotStatus(botId: string) { this.bots = this.bots.filter((b) => b.id !== botId); delete this.queues[botId]; delete this.timings[botId]; // If the bot we were locked to is gone, drop the scope so the UI does not // stay 'locked' onto a phantom (activeBot would silently fall back to bots[0]). if (this.scopedBotId === botId) { this.clearScope(); } }, setQueue(botId: string, queue: Song[]) { this.queues[botId] = queue; }, toggleTheme() { this.theme = this.theme === 'dark' ? 'light' : 'dark'; localStorage.setItem('theme', this.theme); }, loadTheme() { const saved = localStorage.getItem('theme') as 'dark' | 'light' | null; if (saved) this.theme = saved; }, async startBotInstance(id: string) { await axios.post(`/api/bot/${id}/start`); }, async stopBotInstance(id: string) { await axios.post(`/api/bot/${id}/stop`); }, async fetchBots() { const res = await axios.get('/api/bot'); this.bots = res.data.bots; if (!this.activeBotId && this.bots.length > 0) { this.activeBotId = this.bots[0].id; } // Sync elapsed from each bot's status for (const bot of this.bots) { if (bot.elapsed !== undefined) { this._setTiming(bot.id, { serverElapsed: bot.elapsed, serverSyncTime: Date.now(), wasPlaying: bot.playing && !bot.paused, }); } } }, /** Poll server for real elapsed time for active bot */ async syncElapsed() { if (!this.activeBotId || !this.isPlaying) return; try { const res = await axios.get(`/api/player/${this.activeBotId}/elapsed`); this._setTiming(this.activeBotId, { serverElapsed: res.data.elapsed, serverSyncTime: Date.now(), wasPlaying: true, }); } catch { // ignore } }, async fetchQueue() { if (!this.activeBotId) return; try { const res = await axios.get(`/api/player/${this.activeBotId}/queue`); this.queues[this.activeBotId] = res.data.queue ?? []; } catch { // ignore } }, async fetchQueueForBot(botId: string) { try { const res = await axios.get(`/api/player/${botId}/queue`); this.queues[botId] = res.data.queue ?? []; } catch { // ignore } }, _syncAfterAction() { if (!this.activeBotId) return; this._setTiming(this.activeBotId, { serverSyncTime: Date.now(), wasPlaying: true, }); // Sync from server after a short delay for accuracy setTimeout(() => this.syncElapsed(), 500); }, async playAtIndex(index: number) { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/play-at`, { index }); this._setTiming(this.activeBotId, { serverElapsed: 0 }); this._syncAfterAction(); }, async play(query: string, platform = 'netease') { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/play`, { query, platform }); this._setTiming(this.activeBotId, { serverElapsed: 0 }); this._syncAfterAction(); }, async playById(songId: string, platform = 'netease') { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/play-by-id`, { songId, platform }); this._setTiming(this.activeBotId, { serverElapsed: 0 }); this._syncAfterAction(); }, notify(message: string, type: 'error' | 'info' = 'info') { this.notification = { id: Date.now(), message, type }; }, async playSong(song: Song) { if (!this.activeBotId) return; // Guests use the non-destructive "play now" (insert-next + skip) so they // can't wipe everyone else's queue; members/admins keep the normal behavior. const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song'; const res = await axios.post(`/api/player/${this.activeBotId}/${endpoint}`, { song }); if (res.data?.ok === false && res.data?.message) { this.notify(res.data.message, 'error'); } this._setTiming(this.activeBotId, { serverElapsed: 0 }); 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 }); }, async addToQueueById(songId: string, platform = 'netease') { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform }); }, async addSong(song: Song) { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/add-song`, { song }); }, async playPlaylist(playlistId: string, platform = 'netease') { if (!this.activeBotId) return; try { 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(); } catch (e: any) { // A 403 here means a guest lacks the "play entire collection" permission // (issue #103) — surface it instead of failing silently. this.notify(e?.response?.status === 403 ? '没有权限播放整个歌单' : '播放歌单失败', 'error'); } }, async playAlbum(albumId: string, platform = 'netease') { if (!this.activeBotId) return; try { const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform }); if (res.data?.message) { this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); } this._setTiming(this.activeBotId, { serverElapsed: 0 }); this._syncAfterAction(); } catch (e: any) { this.notify(e?.response?.status === 403 ? '没有权限播放整个专辑' : '播放专辑失败', 'error'); } }, async pause() { if (!this.activeBotId) return; // Freeze elapsed at current interpolated value this._setTiming(this.activeBotId, { serverElapsed: this.elapsed, wasPlaying: false, }); await axios.post(`/api/player/${this.activeBotId}/pause`); }, async resume() { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/resume`); this._setTiming(this.activeBotId, { serverSyncTime: Date.now(), wasPlaying: true, }); setTimeout(() => this.syncElapsed(), 300); }, async next() { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/next`); this._setTiming(this.activeBotId, { serverElapsed: 0 }); this._syncAfterAction(); }, async prev() { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/prev`); this._setTiming(this.activeBotId, { serverElapsed: 0 }); this._syncAfterAction(); }, async stop() { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/stop`); this._setTiming(this.activeBotId, { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false, }); }, async seek(position: number) { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/seek`, { position }); this._setTiming(this.activeBotId, { serverElapsed: position }); this._syncAfterAction(); }, async setVolume(volume: number) { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/volume`, { volume }); const bot = this.bots.find((b) => b.id === this.activeBotId); if (bot) bot.volume = volume; }, async setMode(mode: string) { if (!this.activeBotId) return; await axios.post(`/api/player/${this.activeBotId}/mode`, { mode }); const bot = this.bots.find((b) => b.id === this.activeBotId); if (bot) bot.playMode = mode; }, async startFm(platform: Source = 'netease') { if (!this.activeBotId) return; const res = await axios.post(`/api/player/${this.activeBotId}/fm`, { platform }); if (res.data?.message) { this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); } this._setTiming(this.activeBotId, { serverElapsed: 0 }); this._syncAfterAction(); this.fetchQueue(); }, async fetchFavorites() { try { const res = await axios.get('/api/favorites'); this.favoritedPlaylists = res.data.favorites ?? []; } catch { // not critical } }, async addFavorite(playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }) { try { await axios.post('/api/favorites', playlist); await this.fetchFavorites(); this.notify('已收藏', 'info'); } catch (err: any) { // 409 = already favorited (e.g. stale heart); just resync so the UI converges. if (err?.response?.status === 409) { await this.fetchFavorites(); return; } this.notify('收藏失败', 'error'); } }, async removeFavorite(id: number) { try { await axios.delete(`/api/favorites/${id}`); await this.fetchFavorites(); this.notify('已取消收藏', 'info'); } catch (err: any) { // 404 = already gone; resync. Otherwise report failure. if (err?.response?.status === 404) { await this.fetchFavorites(); return; } this.notify('取消收藏失败', 'error'); } }, isFavorited(playlistId: string, platform: string): boolean { return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform); }, async fetchHomeData() { // 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' } }), ]); 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; // Favorites are user-local and cheap; always refresh them, even on a // home-data cache hit, so hearts stay correct across tabs/sessions. this.fetchFavorites(); // 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. 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; this.recommendPlaylists.netease = neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : []; this.dailySongs.netease = neDaily.status === 'fulfilled' ? (neDaily.value.data.songs ?? []) : []; this.userPlaylists.netease = neUserPL.status === 'fulfilled' ? (neUserPL.value.data.playlists ?? []) : []; this.recommendPlaylists.qq = qqRecPL.status === 'fulfilled' ? (qqRecPL.value.data.playlists ?? []) : []; this.dailySongs.qq = qqDaily.status === 'fulfilled' ? (qqDaily.value.data.songs ?? []) : []; this.userPlaylists.qq = qqUserPL.status === 'fulfilled' ? (qqUserPL.value.data.playlists ?? []) : []; // bilibili popular: keep previous value on failure (it's an anonymous endpoint // unrelated to user auth state, and stale popular results are harmless) if (bili.status === 'fulfilled') { this.bilibiliPopular = bili.value.data.songs ?? []; } // Only mark as fetched if at least the auth-status calls succeeded — // a fully failed fetch (network blip / server down) should NOT be // cached for 5 minutes, otherwise the user has to hard-reload to // recover when connectivity returns. const authOk = neAuthRes.status === 'fulfilled' || qqAuthRes.status === 'fulfilled'; if (authOk) { this.lastFetchTime = Date.now(); } }, }, });