import BroadcastBus, { ACTION_BOOT_COMPLETED } from '../../os/BroadcastBus'; import { createAppStoreWithActions, memoSelector } from '../../os/createAppStore'; import MediaSessionService, { type ActiveMediaSession } from '../../os/MediaSessionService'; import { SPOTIFY_CONFIG } from './data'; import type { SpotifyTrack, SpotifyPlaylist, SpotifyArtist, PlaySource, PlayHistoryEntry, SearchHistoryEntry } from './types'; import * as TimeService from '../../os/TimeService'; let localSeq = 0; function nextId(prefix: string): string { localSeq += 1; return `${prefix}_${TimeService.now()}_${localSeq}`; } // ── Types ────────────────────────────────────────────────────────── export interface SpotifySettings { dataSaver: { saverMode: number; downloadCellular: boolean; downloadAudioOnly: boolean; streamAudioOnly: boolean; }; mediaQuality: { wifiQuality: string; cellQuality: string; downloadQuality: string; }; playback: { gapless: boolean; automix: boolean; crossfade: number; autoplay: boolean; monoAudio: boolean; broadcast: boolean; sleepTimer: number; sleepFade: boolean; lyricsEnabled: boolean; }; privacy: { shareActivity: boolean; privateSession: boolean; recentArtists: boolean; publicPlaylists: boolean; profilePlaylists: boolean; }; contentDisplay: { reduceMotion: boolean; canvas: boolean; explicit: boolean; unavailable: boolean; createBtn: boolean; }; appsDevices: { voiceAssistant: boolean; connectControl: boolean; localVisibility: boolean; localFiles: boolean; }; } export interface SpotifyState { currentUser: any; accounts: any[]; currentTrack: SpotifyTrack | null; isPlaying: boolean; shuffle: boolean; repeat: 'off' | 'context' | 'track'; queue: SpotifyTrack[]; recentPlays: SpotifyTrack[]; likedSongs: SpotifyTrack[]; followedArtists: string[]; customPlaylists: (SpotifyPlaylist | SpotifyArtist)[]; playHistory: PlayHistoryEntry[]; settings: SpotifySettings; pendingPlaylistTrack: SpotifyTrack | null; searchHistory: SearchHistoryEntry[]; _temp: { queueToast: SpotifyTrack | null; likedToast: SpotifyTrack | null; }; } export interface SpotifyActions { playTrack: (track: SpotifyTrack, source?: PlaySource) => void; setQueueWithTracks: (tracks: SpotifyTrack[], startIndex?: number, source?: PlaySource) => void; togglePlay: () => void; toggleShuffle: () => void; toggleRepeat: () => void; toggleLike: (track: SpotifyTrack) => void; toggleFollowArtist: (artistName: string) => void; addToQueue: (track: SpotifyTrack) => void; createPlaylist: (name: string, initialTrack?: SpotifyTrack | null) => SpotifyPlaylist; skipToNext: () => void; skipToPrevious: () => void; updateTrackCover: (trackId: string, newCover: string) => void; addAccount: (name: string) => void; switchAccount: (userId: string) => void; addTrackToPlaylist: (name: string, track: SpotifyTrack) => void; removeTrackFromPlaylist: (name: string, trackId: string) => void; updateSettings: (category: K, patch: Partial) => void; setSearchResults: (query: string, tracks: SpotifyTrack[]) => void; showQueueToast: (track: SpotifyTrack) => void; clearQueueToast: () => void; clearLikedToast: () => void; } // ── Initial State ────────────────────────────────────────────────── const configUser = SPOTIFY_CONFIG.user as { id: string; name: string; initial: string; color: string }; const initialState: SpotifyState = { currentUser: configUser, accounts: [configUser], currentTrack: SPOTIFY_CONFIG.recentPlays.length > 0 ? SPOTIFY_CONFIG.recentPlays[0] : SPOTIFY_CONFIG.recommendedTracks.length > 0 ? SPOTIFY_CONFIG.recommendedTracks[0] : null, isPlaying: false, shuffle: false, repeat: 'off', queue: SPOTIFY_CONFIG.recommendedTracks, recentPlays: SPOTIFY_CONFIG.recentPlays as SpotifyTrack[], likedSongs: (SPOTIFY_CONFIG.likedSongs ?? []) as SpotifyTrack[], followedArtists: (() => { const fromConfig = (SPOTIFY_CONFIG.followedArtists ?? []) as string[]; if (fromConfig.length > 0) return fromConfig; // Fallback: init from libraryArtists names const lib = (SPOTIFY_CONFIG as any).libraryArtists ?? []; return lib.map((a: any) => a.name).filter(Boolean); })(), customPlaylists: [], playHistory: [], settings: { ...SPOTIFY_CONFIG.settings }, pendingPlaylistTrack: null, searchHistory: [], _temp: { queueToast: null, likedToast: null, }, }; // ── Store ────────────────────────────────────────────────────────── export const useSpotifyStore = createAppStoreWithActions( 'spotify', initialState, (set, get) => ({ playTrack: (track, source) => { const s = get(); const inQueue = s.queue.find(t => t.id === track.id); const newQueue = inQueue ? s.queue : [track, ...s.queue]; const newRecent = [track, ...s.recentPlays.filter(t => t.id !== track.id)].slice(0, 20); const entry: PlayHistoryEntry = { sourceType: source?.type ?? 'standalone', sourceId: source?.id ?? 'standalone', sourceTitle: source?.title ?? '', sourceCover: source?.cover, track, timestamp: TimeService.now(), }; const newHistory = [entry, ...s.playHistory].slice(0, 100); set({ currentTrack: track, isPlaying: true, queue: newQueue, recentPlays: newRecent, playHistory: newHistory, }); }, setQueueWithTracks: (tracks, startIndex = 0, source) => { const s = get(); const safeIndex = Math.max(0, Math.min(startIndex, tracks.length - 1)); const playedTrack = tracks[safeIndex] ?? null; const historyEntries: PlayHistoryEntry[] = playedTrack && source ? [{ sourceType: source.type, sourceId: source.id, sourceTitle: source.title, sourceCover: source.cover, track: playedTrack, timestamp: TimeService.now(), }] : []; const newRecent = playedTrack ? [playedTrack, ...s.recentPlays.filter(t => t.id !== playedTrack.id)].slice(0, 20) : s.recentPlays; set({ queue: tracks.slice(), currentTrack: playedTrack, isPlaying: tracks.length > 0, recentPlays: newRecent, playHistory: [...historyEntries, ...s.playHistory].slice(0, 100), }); }, togglePlay: () => { set({ isPlaying: !get().isPlaying }); }, toggleShuffle: () => { const nextShuffle = !get().shuffle; // 互斥:开启随机播放则关闭循环模式 set({ shuffle: nextShuffle, repeat: nextShuffle ? 'off' : get().repeat }); }, toggleRepeat: () => { const s = get(); const next = s.repeat === 'off' ? 'context' : s.repeat === 'context' ? 'track' : 'off'; set({ repeat: next, shuffle: next !== 'off' ? false : s.shuffle }); }, toggleLike: (track) => { const s = get(); const titleNorm = track.title.trim().toLowerCase(); const artistNorm = track.artist.trim().toLowerCase(); const exists = s.likedSongs.some(t => t.id === track.id || (t.title.trim().toLowerCase() === titleNorm && t.artist.trim().toLowerCase() === artistNorm) ); const patch: Partial = { likedSongs: exists ? s.likedSongs.filter(t => t.id !== track.id && !(t.title.trim().toLowerCase() === titleNorm && t.artist.trim().toLowerCase() === artistNorm) ) : [track, ...s.likedSongs], }; if (!exists) set({ _temp: { ...get()._temp, likedToast: track } }); set(patch); }, toggleFollowArtist: (artistName) => { const s = get(); const artistNorm = artistName.trim().toLowerCase(); const exists = s.followedArtists.some(name => name.trim().toLowerCase() === artistNorm); set({ followedArtists: exists ? s.followedArtists.filter(name => name.trim().toLowerCase() !== artistNorm) : [artistName, ...s.followedArtists], }); }, addToQueue: (track) => { const s = get(); if (s.queue.some(t => t.id === track.id)) return; set({ queue: [...s.queue, track] }); }, showQueueToast: (track) => { set({ _temp: { ...get()._temp, queueToast: track } }); }, clearQueueToast: () => { set({ _temp: { ...get()._temp, queueToast: null } }); }, clearLikedToast: () => { set({ _temp: { ...get()._temp, likedToast: null } }); }, skipToNext: () => { const s = get(); if (!s.currentTrack || s.queue.length === 0) return; const idx = s.queue.findIndex(t => t.id === s.currentTrack?.id); const nextIdx = (idx + 1) % s.queue.length; set({ currentTrack: s.queue[nextIdx], isPlaying: true }); }, skipToPrevious: () => { const s = get(); if (!s.currentTrack || s.queue.length === 0) return; const idx = s.queue.findIndex(t => t.id === s.currentTrack?.id); const prevIdx = (idx - 1 + s.queue.length) % s.queue.length; set({ currentTrack: s.queue[prevIdx], isPlaying: true }); }, createPlaylist: (name, initialTrack = null) => { const s = get(); const trackIds = initialTrack ? [initialTrack.id] : []; const storedTracks = initialTrack ? [initialTrack] : []; const newPlaylist = { id: nextId('cp'), title: name, subtitle: `歌单 • ${trackIds.length} 首歌曲|en:Playlist • ${trackIds.length} songs`, cover: initialTrack?.cover || '', type: 'playlist' as const, trackIds, storedTracks, } as SpotifyPlaylist & { trackIds: string[]; storedTracks: SpotifyTrack[] }; set({ customPlaylists: [newPlaylist, ...(s.customPlaylists || [])] }); return newPlaylist; }, addTrackToPlaylist: (name, track) => { const s = get(); const playlists = [...(s.customPlaylists || [])]; let pl = playlists.find(p => (p as any).title === name); if (!pl) { pl = { id: nextId('cp'), title: name, subtitle: '歌单 • 0 首歌曲|en:Playlist • 0 songs', cover: '', type: 'playlist' as const, } as any; playlists.unshift(pl!); } const trackIds: string[] = Array.isArray((pl as any).trackIds) ? [...(pl as any).trackIds] : []; const storedTracks: SpotifyTrack[] = Array.isArray((pl as any).storedTracks) ? [...(pl as any).storedTracks] : []; const tNorm = track.title.trim().toLowerCase(); const aNorm = track.artist.trim().toLowerCase(); const dupById = trackIds.includes(track.id); const dupByName = storedTracks.some(t => t.title.trim().toLowerCase() === tNorm && t.artist.trim().toLowerCase() === aNorm); if (!dupById && !dupByName) { trackIds.push(track.id); storedTracks.push(track); } (pl as any).trackIds = trackIds; (pl as any).storedTracks = storedTracks; if (!((pl as any).cover) && track.cover) { (pl as any).cover = track.cover; } const count = trackIds.length; (pl as any).subtitle = `歌单 • ${count} 首歌曲|en:Playlist • ${count} songs`; set({ customPlaylists: playlists }); }, removeTrackFromPlaylist: (name, trackId) => { const s = get(); const playlists = [...(s.customPlaylists || [])]; const pl = playlists.find(p => (p as any).title === name); if (!pl) return; const trackIds: string[] = Array.isArray((pl as any).trackIds) ? [...(pl as any).trackIds] : []; const storedTracks: SpotifyTrack[] = Array.isArray((pl as any).storedTracks) ? [...(pl as any).storedTracks] : []; const nextIds = trackIds.filter(id => id !== trackId); const nextStored = storedTracks.filter(t => t.id !== trackId); (pl as any).trackIds = nextIds; (pl as any).storedTracks = nextStored; const count = nextIds.length; (pl as any).subtitle = `歌单 • ${count} 首歌曲|en:Playlist • ${count} songs`; // Update cover: use first remaining track's cover, or clear (pl as any).cover = nextStored.length > 0 ? (nextStored[0].cover || '') : ''; set({ customPlaylists: playlists }); }, updateTrackCover: (trackId, newCover) => { const s = get(); set({ currentTrack: s.currentTrack?.id === trackId ? { ...s.currentTrack, cover: newCover } : s.currentTrack, }); }, // ── Settings ────────────────────────────────────────────── updateSettings: (category, patch) => { set(state => ({ settings: { ...state.settings, [category]: { ...(state.settings[category] as any), ...patch }, }, })); }, // ── Accounts ────────────────────────────────────────────── addAccount: (name) => { const s = get(); const colors = ['bg-pink-500', 'bg-purple-500', 'bg-blue-500', 'bg-green-500', 'bg-red-500']; const newColor = colors[s.accounts.length % colors.length]; const newUser = { id: nextId('u'), name, initial: name.charAt(0).toUpperCase(), color: newColor, }; set({ accounts: [...s.accounts, newUser], currentUser: newUser }); }, switchAccount: (userId) => { const s = get(); const user = s.accounts.find(u => u.id === userId); if (!user) return; set({ currentUser: user }); }, setSearchResults: (query, tracks) => { const s = get(); // 追加新搜索记录(同 query 则覆盖最后一条) const history = [...s.searchHistory]; if (history.length > 0 && history[history.length - 1].query === query) { history[history.length - 1] = { query, tracks }; } else { history.push({ query, tracks }); } set({ searchHistory: history }); }, }), ); // ── Memoized selectors (for derived arrays/objects) ─────────────── export const selectLikedSongs = memoSelector( (s: SpotifyState & SpotifyActions) => s.likedSongs, (likedSongs) => likedSongs, ); export const selectLikedSongIds = memoSelector( (s: SpotifyState & SpotifyActions) => s.likedSongs, (likedSongs) => { const ids = new Set(likedSongs.map(t => t.id)); const keys = new Set(likedSongs.map(t => `${t.title.trim().toLowerCase()}||${t.artist.trim().toLowerCase()}`)); return { has(trackId: string, track?: { title: string; artist: string }) { if (ids.has(trackId)) return true; if (track) return keys.has(`${track.title.trim().toLowerCase()}||${track.artist.trim().toLowerCase()}`); return false; }, }; }, ); export const selectCustomPlaylists = memoSelector( (s: SpotifyState & SpotifyActions) => s.customPlaylists, (customPlaylists) => customPlaylists, ); export const selectQueue = memoSelector( (s: SpotifyState & SpotifyActions) => s.queue, (queue) => queue, ); export const selectRecentPlays = memoSelector( (s: SpotifyState & SpotifyActions) => s.recentPlays, (recentPlays) => recentPlays, ); // ── MediaSession publisher ───────────────────────────────────────── // Real Android: Spotify keeps an active MediaSession that lockscreen / widget // / Bluetooth controls listen to via MediaSessionManager. mobile-gym mirrors // this via os/MediaSessionService — subscribers consume "now playing" without // importing Spotify code. Push on every change so the music widget stays // in sync. function parseTrackDurationMs(value: unknown): number { if (typeof value !== 'string') return 0; const parts = value.split(':').map((part) => parseInt(part, 10)); if (parts.some((part) => !Number.isFinite(part))) return 0; if (parts.length === 2) return (parts[0] * 60 + parts[1]) * 1000; if (parts.length === 3) return (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000; return 0; } function publishMediaSession(state: SpotifyState & SpotifyActions): void { const track = state.currentTrack; if (!track) { MediaSessionService.clearActiveSession(); return; } const session: ActiveMediaSession = { title: track.title ?? '', artist: track.artist ?? '', durationMs: Math.max(1, parseTrackDurationMs(track.duration)), positionMs: 0, isPlaying: Boolean(state.isPlaying), packageName: 'com.spotify.music', activityClass: 'com.spotify.music.MainActivity', }; MediaSessionService.setActiveSession(session); } // Publish the initial session on module load (covers cold-start with a // non-null currentTrack from SPOTIFY_CONFIG.recentPlays). publishMediaSession(useSpotifyStore.getState()); useSpotifyStore.subscribe((state, prev) => { if (state.currentTrack === prev.currentTrack && state.isPlaying === prev.isPlaying) { return; } publishMediaSession(state); }); // __SIM__.resetState() (no page reload) wipes the volatile MediaSession store // after app stores re-init, so the music widget would read nothing until the // next play-state change. OSContext re-emits BOOT_COMPLETED at the end of the // reset; re-publish the (restored) now-playing state in response. BroadcastBus.registerReceiver(ACTION_BOOT_COMPLETED, () => { publishMediaSession(useSpotifyStore.getState()); });