import React, { useState, useEffect, useRef } from 'react'; import { StatusBar, Alert, ActivityIndicator, Image, ImageBackground, StyleSheet, Text, View } from 'react-native'; import { NavigationContainer, useNavigation } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { Audio, AVPlaybackStatus } from 'expo-av'; import * as SplashScreen from 'expo-splash-screen'; import Svg, { Path } from 'react-native-svg'; import { theme } from './theme'; import { Track, RepeatMode, CacheInfo, UserConfig, SortMode } from './types'; import * as API from './api'; import * as Cache from './utils/cache'; import { PlayerScreen } from './screens/PlayerScreen'; import { PlaylistScreen } from './screens/PlaylistScreen'; import { SettingsScreen } from './screens/SettingsScreen'; import { LoginScreen } from './screens/LoginScreen'; // 安全地编码 URL 中的路径部分 const encodeUrlPath = (url: string): string => { try { const parsedUrl = new URL(url); const pathParts = parsedUrl.pathname.split('/'); const encodedPathParts = pathParts.map((part, index) => { if (part === '') return part; return encodeURIComponent(decodeURIComponent(part)); }); parsedUrl.pathname = encodedPathParts.join('/'); return parsedUrl.toString(); } catch (error) { console.error('[URL] 编码失败:', error); try { return encodeURI(url); } catch { return url; } } }; const Tab = createBottomTabNavigator(); void SplashScreen.preventAutoHideAsync().catch(() => {}); SplashScreen.setOptions({ duration: 500, fade: true, }); const PlayIconNav = ({ color, size }: { color: string; size: number }) => ( ); const ListIconNav = ({ color, size }: { color: string; size: number }) => ( ); const SettingsIconNav = ({ color, size }: { color: string; size: number }) => ( ); const StartupLoadingScreen = () => ( 观空禅音 正在加载歌单与缓存... ); const TabNavigator = () => { const [tracks, setTracks] = useState([]); const [cacheInfo, setCacheInfo] = useState({}); const [currentIndex, setCurrentIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [isShuffle, setIsShuffle] = useState(false); const [repeatMode, setRepeatMode] = useState('none'); const [position, setPosition] = useState(0); const [duration, setDuration] = useState(0); const [isLoading, setIsLoading] = useState(true); const [isDownloading, setIsDownloading] = useState(false); const [downloadProgress, setDownloadProgress] = useState({ completed: 0, total: 0, title: '' }); const [downloadingTrackSrcs, setDownloadingTrackSrcs] = useState([]); const [cacheOnly, setCacheOnly] = useState(true); const [playlistFiles, setPlaylistFiles] = useState(['playlist.json']); const [currentPlaylistFile, setCurrentPlaylistFile] = useState('playlist.json'); const [resumePlaybackEnabled, setResumePlaybackEnabled] = useState(true); const [sortMode, setSortMode] = useState('original'); const [showLoginScreen, setShowLoginScreen] = useState(false); const [loggedInUser, setLoggedInUser] = useState(null); const [isOffline, setIsOffline] = useState(false); const [offlineAvailablePlaylists, setOfflineAvailablePlaylists] = useState([]); const navigation = useNavigation(); const soundRef = useRef(null); const shuffleOrderRef = useRef([]); const playbackOrderRef = useRef([]); const tracksRef = useRef([]); const currentIndexRef = useRef(0); const isPlayingRef = useRef(false); const isShuffleRef = useRef(false); const repeatModeRef = useRef('none'); const cacheOnlyRef = useRef(true); const positionRef = useRef(0); const durationRef = useRef(0); const currentPlaylistFileRef = useRef('playlist.json'); const resumePlaybackEnabledRef = useRef(true); const lastSavedPositionRef = useRef(0); const setTracksState = (nextTracks: Track[]) => { tracksRef.current = nextTracks; playbackOrderRef.current = nextTracks.map((_, index) => index); setTracks(nextTracks); }; const setCurrentIndexState = (index: number) => { currentIndexRef.current = index; setCurrentIndex(index); }; const setIsPlayingState = (value: boolean) => { isPlayingRef.current = value; setIsPlaying(value); }; const setIsShuffleState = (value: boolean) => { isShuffleRef.current = value; setIsShuffle(value); }; const setRepeatModeState = (value: RepeatMode) => { repeatModeRef.current = value; setRepeatMode(value); }; const setCacheOnlyState = (value: boolean) => { cacheOnlyRef.current = value; setCacheOnly(value); }; const setCurrentPlaylistFileState = (value: string) => { currentPlaylistFileRef.current = value; setCurrentPlaylistFile(value); }; const setResumePlaybackEnabledState = (value: boolean) => { resumePlaybackEnabledRef.current = value; setResumePlaybackEnabled(value); }; const setPositionState = (value: number) => { positionRef.current = value; setPosition(value); }; const setDurationState = (value: number) => { durationRef.current = value; setDuration(value); }; const refreshCacheInfo = () => { setCacheInfo(Cache.getCacheInfo()); }; const applyCloudConfig = async (config: UserConfig) => { try { if (config.selected_playlist) { await API.saveSelectedPlaylist(config.selected_playlist); setCurrentPlaylistFileState(config.selected_playlist); } if (config.resume_enabled !== undefined) { await API.saveResumePlaybackEnabled(config.resume_enabled); setResumePlaybackEnabledState(config.resume_enabled); } if (config.cache_only !== undefined) { await Cache.setCacheOnlySetting(config.cache_only); setCacheOnlyState(config.cache_only); } if (config.sort_mode) { await API.saveSortPreference(config.sort_mode); setSortMode(config.sort_mode); } // 恢复播放进度 if (config.playback_positions) { for (const [key, seconds] of Object.entries(config.playback_positions)) { const [playlistFile, trackSrc] = key.split('::'); if (playlistFile && trackSrc && seconds > 0) { await API.savePlaybackPosition(playlistFile, trackSrc, seconds); } } } } catch (error) { console.error('应用云端配置失败:', error); } }; const buildUserConfig = (): UserConfig => { return { selected_playlist: currentPlaylistFileRef.current, resume_enabled: resumePlaybackEnabledRef.current, cache_only: cacheOnlyRef.current, sort_mode: sortMode, playback_positions: {}, }; }; const pushConfigIfNeeded = async () => { try { const config = buildUserConfig(); await API.pushCloudConfig(config); } catch (error) { console.error('推送云端配置失败:', error); } }; const persistPlaybackPosition = async ( trackIndex = currentIndexRef.current, positionSeconds = positionRef.current, durationSeconds = durationRef.current, options?: { force?: boolean } ) => { if (!resumePlaybackEnabledRef.current) return; const track = tracksRef.current[trackIndex]; if (!track || !track.src) return; const safePosition = Math.max(0, Math.floor(positionSeconds || 0)); const safeDuration = Math.max(0, Math.floor(durationSeconds || 0)); const isNearEnd = safeDuration > 0 && safeDuration - safePosition <= 30; if (safePosition < 5 || isNearEnd) { await API.clearPlaybackPosition(currentPlaylistFileRef.current, track.src); lastSavedPositionRef.current = 0; return; } if (!options?.force && Math.abs(safePosition - lastSavedPositionRef.current) < 15) { return; } await API.savePlaybackPosition(currentPlaylistFileRef.current, track.src, safePosition); lastSavedPositionRef.current = safePosition; }; const applyPlaylistState = (nextTracks: Track[], playlistFile: string) => { const previousTrackSrc = tracksRef.current[currentIndexRef.current]?.src || ''; const wasPlaying = isPlayingRef.current; const matchedIndex = previousTrackSrc ? nextTracks.findIndex((track) => track.src === previousTrackSrc) : -1; const nextIndex = matchedIndex >= 0 ? matchedIndex : 0; setTracksState(nextTracks); buildShuffleOrder(nextTracks.length); if (!nextTracks.length) { setCurrentPlaylistFileState(playlistFile); if (soundRef.current) { void soundRef.current.unloadAsync(); soundRef.current = null; } setCurrentIndexState(0); setPositionState(0); setIsPlayingState(false); setDurationState(0); return; } if (soundRef.current) { void loadTrack(nextIndex, wasPlaying && matchedIndex >= 0, playlistFile); } else { setCurrentPlaylistFileState(playlistFile); setCurrentIndexState(nextIndex); setPositionState(0); setDurationState(0); setIsPlayingState(false); } }; const loadPlaylistBundle = async ( playlistFile: string, options?: { showSnapshotAlert?: boolean } ) => { const playlistResult = await API.getPlaylistWithFallback(playlistFile); applyPlaylistState(playlistResult.tracks, playlistResult.playlistFile); await API.saveSelectedPlaylist(playlistResult.playlistFile); refreshCacheInfo(); if (options?.showSnapshotAlert && playlistResult.source === 'snapshot') { Alert.alert( '离线歌单', `远程歌单加载失败,已自动切换到本地歌单快照继续使用。\n\n原因:${playlistResult.fallbackReason || '网络不可用'}` ); } return playlistResult; }; const getPlaybackOrder = () => { const currentTracks = tracksRef.current; const fallbackOrder = currentTracks.map((_, index) => index); const currentOrder = playbackOrderRef.current; if (currentOrder.length !== currentTracks.length) { return fallbackOrder; } const uniqueIndexes = new Set(currentOrder); if (uniqueIndexes.size !== currentTracks.length) { return fallbackOrder; } return currentOrder; }; const handlePlaybackOrderChange = (order: number[]) => { playbackOrderRef.current = order; }; // Initialize useEffect(() => { const init = async () => { try { await Cache.initCache(); refreshCacheInfo(); await Audio.setAudioModeAsync({ playsInSilentModeIOS: true, staysActiveInBackground: true, }); // 检查登录态 const user = await API.getLoggedInUser(); setLoggedInUser(user); // 检查网络状态 const connected = await API.isNetworkConnected(); setIsOffline(!connected); // 如果已登录且有网络,拉取云端配置 if (user && connected) { const cloudConfig = await API.pullCloudConfig(); if (cloudConfig) { await applyCloudConfig(cloudConfig); } } const cacheOnlySetting = await Cache.getCacheOnlySetting(); setCacheOnlyState(cacheOnlySetting); const resumePlaybackSetting = await API.getResumePlaybackEnabled(); setResumePlaybackEnabledState(resumePlaybackSetting); const savedSort = await API.getSortPreference(); setSortMode(savedSort); let files = await API.getPlaylistFiles(); setPlaylistFiles(files); // 离线模式:反推可用歌单 if (!connected) { const available = await API.getOfflineAvailablePlaylists((src) => Cache.isCached(src)); setOfflineAvailablePlaylists(available); files = available; } const savedPlaylist = await API.getSavedSelectedPlaylist(); const initialPlaylist = files.includes(savedPlaylist) ? savedPlaylist : 'playlist.json'; await loadPlaylistBundle(initialPlaylist, { showSnapshotAlert: !connected }); } catch (error) { console.error('Init failed:', error); Alert.alert('错误', '无法加载播放列表'); } finally { setIsLoading(false); } }; init(); return () => { void persistPlaybackPosition(currentIndexRef.current, positionRef.current, durationRef.current, { force: true }); if (soundRef.current) { soundRef.current.unloadAsync(); } }; }, []); useEffect(() => { if (!isLoading) { void SplashScreen.hideAsync().catch(() => {}); } }, [isLoading]); // Build shuffle order const buildShuffleOrder = (length: number) => { const order = [...Array(length).keys()]; for (let i = order.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; } shuffleOrderRef.current = order; }; // Load track const loadTrack = async (index: number, autoPlay = false, playlistFileOverride?: string) => { try { const track = tracksRef.current[index]; if (!track) return; const previousIndex = currentIndexRef.current; const targetPlaylistFile = String(playlistFileOverride || currentPlaylistFileRef.current || 'playlist.json'); if (soundRef.current && tracksRef.current[previousIndex]) { await persistPlaybackPosition(previousIndex, positionRef.current, durationRef.current, { force: true }); } if (currentPlaylistFileRef.current !== targetPlaylistFile) { setCurrentPlaylistFileState(targetPlaylistFile); } let sourceUri: string; const cachedPath = Cache.getCachedPath(track.src); if (cacheOnlyRef.current && !cachedPath) { // 只用缓存模式,但歌曲未缓存 Alert.alert( '无法播放', `歌曲"${track.title}"未缓存,且当前设置为只用缓存听歌曲。\n\n请先在播放列表页面下载此歌曲。`, [{ text: '好的', style: 'default' }] ); return; } if (cachedPath) { sourceUri = cachedPath; } else { // 对原始 URL 进行编码 sourceUri = encodeUrlPath(track.src); } const savedPositionSeconds = resumePlaybackEnabledRef.current ? await API.getSavedPlaybackPosition(targetPlaylistFile, track.src) : 0; const initialPositionMillis = savedPositionSeconds > 5 ? savedPositionSeconds * 1000 : 0; const activeTrack = track; const { sound, status } = await Audio.Sound.createAsync( { uri: sourceUri }, { shouldPlay: autoPlay, isLooping: repeatModeRef.current === 'one', progressUpdateIntervalMillis: 250, ...(initialPositionMillis > 0 ? { positionMillis: initialPositionMillis } : {}), } ); sound.setOnPlaybackStatusUpdate((playbackStatus: AVPlaybackStatus) => { if (soundRef.current !== sound) return; if (!playbackStatus.isLoaded) { if (playbackStatus.error) { console.error('Playback status error:', playbackStatus.error); } setIsPlayingState(false); return; } setPositionState(playbackStatus.positionMillis / 1000); setDurationState(playbackStatus.durationMillis ? playbackStatus.durationMillis / 1000 : 0); setIsPlayingState(playbackStatus.isPlaying); if (playbackStatus.isPlaying) { void persistPlaybackPosition( index, playbackStatus.positionMillis / 1000, playbackStatus.durationMillis ? playbackStatus.durationMillis / 1000 : 0 ); } if (playbackStatus.didJustFinish) { void API.clearPlaybackPosition(targetPlaylistFile, activeTrack.src); lastSavedPositionRef.current = 0; void handleTrackEnd(); } }); const previousSound = soundRef.current; soundRef.current = sound; setCurrentIndexState(index); if (status.isLoaded) { setPositionState(status.positionMillis / 1000); setDurationState(status.durationMillis ? status.durationMillis / 1000 : 0); setIsPlayingState(autoPlay || status.isPlaying); lastSavedPositionRef.current = status.positionMillis > 0 ? Math.floor(status.positionMillis / 1000) : 0; } else { setPositionState(0); setDurationState(0); setIsPlayingState(false); lastSavedPositionRef.current = 0; } if (previousSound) { previousSound.setOnPlaybackStatusUpdate(null); await previousSound.unloadAsync(); } if (autoPlay && status.isLoaded && !status.isPlaying) { await sound.playAsync(); setIsPlayingState(true); } } catch (error) { console.error('Failed to load track:', error); Alert.alert('错误', '无法加载歌曲'); } }; // Handle track end const handleTrackEnd = async () => { if (repeatModeRef.current === 'one') { if (soundRef.current) { await soundRef.current.replayAsync(); setIsPlayingState(true); } return; } await nextTrack(true); }; // Play/Pause const togglePlayPause = async () => { if (!soundRef.current) { await loadTrack(currentIndexRef.current, true); return; } if (isPlayingRef.current) { setIsPlayingState(false); await soundRef.current.pauseAsync(); await persistPlaybackPosition(currentIndexRef.current, positionRef.current, durationRef.current, { force: true }); } else { setIsPlayingState(true); await soundRef.current.playAsync(); } }; // Find next cached track const findNextCachedTrack = (startIndex: number): number | null => { const currentTracks = tracksRef.current; if (!cacheOnlyRef.current) return startIndex; for (let i = 0; i < currentTracks.length; i++) { const idx = (startIndex + i) % currentTracks.length; if (Cache.isCached(currentTracks[idx].src)) { return idx; } } return null; }; const findPrevCachedTrack = (startIndex: number): number | null => { const currentTracks = tracksRef.current; if (!cacheOnlyRef.current) return startIndex; for (let i = 0; i < currentTracks.length; i++) { const idx = (startIndex - i + currentTracks.length) % currentTracks.length; if (Cache.isCached(currentTracks[idx].src)) { return idx; } } return null; }; // Next track const nextTrack = async (forceAutoPlay?: boolean) => { const currentTracks = tracksRef.current; if (!currentTracks.length) return; const playbackOrder = getPlaybackOrder(); let nextIndex; if (isShuffleRef.current) { const currentPos = shuffleOrderRef.current.indexOf(currentIndexRef.current); nextIndex = shuffleOrderRef.current[(currentPos + 1) % shuffleOrderRef.current.length]; } else { const currentPos = playbackOrder.indexOf(currentIndexRef.current); const safePos = currentPos >= 0 ? currentPos : 0; nextIndex = playbackOrder[(safePos + 1) % playbackOrder.length]; } if ( !isShuffleRef.current && repeatModeRef.current === 'none' && playbackOrder.indexOf(currentIndexRef.current) === playbackOrder.length - 1 ) { // End of playlist, stop await soundRef.current?.pauseAsync(); await soundRef.current?.setPositionAsync(0); setPositionState(0); setIsPlayingState(false); return; } // 如果是缓存模式,找下一个已缓存的歌曲 const finalIndex = findNextCachedTrack(nextIndex); if (finalIndex === null) { Alert.alert('提示', '没有已缓存的歌曲可以播放,请先下载一些歌曲'); return; } await loadTrack(finalIndex, forceAutoPlay ?? isPlayingRef.current); }; // Previous track const prevTrack = async () => { const currentTracks = tracksRef.current; if (!currentTracks.length) return; const playbackOrder = getPlaybackOrder(); if (positionRef.current > 3) { await soundRef.current?.setPositionAsync(0); setPositionState(0); return; } let prevIndex; if (isShuffleRef.current) { const currentPos = shuffleOrderRef.current.indexOf(currentIndexRef.current); prevIndex = shuffleOrderRef.current[(currentPos - 1 + shuffleOrderRef.current.length) % shuffleOrderRef.current.length]; } else { const currentPos = playbackOrder.indexOf(currentIndexRef.current); const safePos = currentPos >= 0 ? currentPos : 0; prevIndex = playbackOrder[(safePos - 1 + playbackOrder.length) % playbackOrder.length]; } const finalIndex = findPrevCachedTrack(prevIndex); if (finalIndex === null) { Alert.alert('提示', '没有已缓存的歌曲可以播放,请先下载一些歌曲'); return; } await loadTrack(finalIndex, isPlayingRef.current); }; // Toggle shuffle const toggleShuffle = () => { const nextShuffle = !isShuffleRef.current; setIsShuffleState(nextShuffle); if (nextShuffle) { buildShuffleOrder(tracksRef.current.length); } }; // Toggle repeat const toggleRepeat = () => { const modes: RepeatMode[] = ['none', 'one', 'all']; const currentPos = modes.indexOf(repeatModeRef.current); const nextMode = modes[(currentPos + 1) % modes.length]; setRepeatModeState(nextMode); if (soundRef.current) { void soundRef.current.setIsLoopingAsync(nextMode === 'one'); } }; // Seek const seek = async (newPosition: number) => { if (soundRef.current) { await soundRef.current.setPositionAsync(newPosition * 1000); setPositionState(newPosition); await persistPlaybackPosition(currentIndexRef.current, newPosition, durationRef.current, { force: true }); } }; const handleCacheOnlyChange = async (value: boolean) => { setCacheOnlyState(value); try { await Cache.setCacheOnlySetting(value); await pushConfigIfNeeded(); } catch (error) { setCacheOnlyState(!value); throw error; } }; const handlePlaylistChange = async (playlistFile: string) => { try { await loadPlaylistBundle(playlistFile, { showSnapshotAlert: true }); await pushConfigIfNeeded(); } catch (error) { console.error('切换歌单失败:', error); Alert.alert('错误', '切换歌单失败'); throw error; } }; const handleResumePlaybackChange = async (value: boolean) => { setResumePlaybackEnabledState(value); try { await API.saveResumePlaybackEnabled(value); await pushConfigIfNeeded(); } catch (error) { setResumePlaybackEnabledState(!value); throw error; } }; const handleDownloadTrack = async (track: Track) => { if (downloadingTrackSrcs.includes(track.src)) { return; } setDownloadingTrackSrcs((prev) => [...prev, track.src]); try { await Cache.downloadAndCache(track.src); refreshCacheInfo(); } finally { setDownloadingTrackSrcs((prev) => prev.filter((src) => src !== track.src)); } }; // Download all tracks const downloadAllTracks = async () => { setIsDownloading(true); setDownloadProgress({ completed: 0, total: tracks.length, title: '准备下载...' }); try { const result = await Cache.downloadAllTracks(tracks, (completed, total, title) => { setDownloadProgress({ completed, total, title }); console.log(`Downloading ${completed}/${total}: ${title}`); }); refreshCacheInfo(); if (result.failedTracks.length > 0) { Alert.alert( '部分下载失败', `成功下载 ${result.successCount} 首,跳过已缓存 ${result.skippedCount} 首,失败 ${result.failedTracks.length} 首。\n\n失败歌曲:${result.failedTracks.join('、')}` ); } else { Alert.alert( '完成', `下载完成。新缓存 ${result.successCount} 首,已缓存跳过 ${result.skippedCount} 首。` ); } } catch (error) { console.error('批量下载失败:', error); Alert.alert('错误', '批量下载时发生异常,请稍后重试'); } finally { setIsDownloading(false); setDownloadProgress({ completed: 0, total: 0, title: '' }); } }; const openPlaylist = () => { // Use navigation to switch to Playlist tab // @ts-ignore navigation.navigate('Playlist'); }; const handleLoginSuccess = async () => { setShowLoginScreen(false); const user = await API.getLoggedInUser(); setLoggedInUser(user); // 登录成功后拉取云端配置 const connected = await API.isNetworkConnected(); if (connected && user) { const cloudConfig = await API.pullCloudConfig(); if (cloudConfig) { await applyCloudConfig(cloudConfig); } } }; const handleLogout = async () => { await API.logout(); setLoggedInUser(null); }; if (showLoginScreen) { return ( setShowLoginScreen(false)} /> ); } if (isLoading) { return ; } return ( ( )} options={{ tabBarLabel: '播放', tabBarIcon: ({ color, size }) => , }} /> ( loadTrack(index, true)} onDownloadTrack={handleDownloadTrack} onDownloadAll={downloadAllTracks} onPlaylistChange={handlePlaylistChange} isDownloading={isDownloading} downloadProgress={downloadProgress} /> )} options={{ tabBarLabel: '列表', tabBarIcon: ({ color, size }) => , }} /> ( !!cacheInfo[track.src]).length} trackCount={tracks.length} loggedInUser={loggedInUser} onLoginPress={() => setShowLoginScreen(true)} onLogout={handleLogout} /> )} options={{ tabBarLabel: '设置', tabBarIcon: ({ color, size }) => , }} /> ); }; export default function App() { return ( <> ); } const loadingStyles = StyleSheet.create({ background: { flex: 1, backgroundColor: theme.colors.background, }, overlay: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: theme.spacing.xl, backgroundColor: 'rgba(6,6,18,0.18)', }, badgeWrap: { width: 84, height: 84, borderRadius: 24, alignItems: 'center', justifyContent: 'center', marginBottom: theme.spacing.md, backgroundColor: 'rgba(6,6,18,0.38)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.18)', overflow: 'hidden', }, badgeImage: { width: '100%', height: '100%', }, title: { fontSize: 24, fontWeight: '700', color: '#f3f1eb', marginBottom: theme.spacing.xs, }, subtitle: { fontSize: 14, color: 'rgba(243,241,235,0.86)', }, spinner: { marginTop: theme.spacing.lg, }, });