Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

487 lines
18 KiB
TypeScript

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: <K extends keyof SpotifySettings>(category: K, patch: Partial<SpotifySettings[K]>) => 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<SpotifyState, SpotifyActions>(
'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<SpotifyState> = {
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());
});