feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"devices": []
}
+3
View File
@@ -0,0 +1,3 @@
{
"urlRandomness": "oVzuXCs"
}
+805
View File
@@ -0,0 +1,805 @@
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 } 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';
// 安全地编码 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 }) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M8 5v14l11-7z" />
</Svg>
);
const ListIconNav = ({ color, size }: { color: string; size: number }) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round">
<Path d="M8 6h13M8 12h13M8 18h13" />
<Path d="M3 6h.01M3 12h.01M3 18h.01" />
</Svg>
);
const SettingsIconNav = ({ color, size }: { color: string; size: number }) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M12 15a3 3 0 1 0 0-6a3 3 0 0 0 0 6z" />
<Path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83a2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2a2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0a2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2a2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83a2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51v-.09a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0a2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2a2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</Svg>
);
const StartupLoadingScreen = () => (
<ImageBackground
source={require('./assets/splash.png')}
resizeMode="cover"
style={loadingStyles.background}
>
<View style={loadingStyles.overlay}>
<View style={loadingStyles.badgeWrap}>
<Image source={require('./assets/icon.png')} style={loadingStyles.badgeImage} />
</View>
<Text style={loadingStyles.title}>观空禅音</Text>
<Text style={loadingStyles.subtitle}>正在加载歌单与缓存...</Text>
<ActivityIndicator size="small" color={theme.colors.primary} style={loadingStyles.spinner} />
</View>
</ImageBackground>
);
type SortMode = 'original' | 'asc' | 'desc';
const TabNavigator = () => {
const [tracks, setTracks] = useState<Track[]>([]);
const [cacheInfo, setCacheInfo] = useState<CacheInfo>({});
const [currentIndex, setCurrentIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isShuffle, setIsShuffle] = useState(false);
const [repeatMode, setRepeatMode] = useState<RepeatMode>('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<string[]>([]);
const [cacheOnly, setCacheOnly] = useState(true);
const [playlistFiles, setPlaylistFiles] = useState<string[]>(['playlist.json']);
const [currentPlaylistFile, setCurrentPlaylistFile] = useState('playlist.json');
const [resumePlaybackEnabled, setResumePlaybackEnabled] = useState(true);
const navigation = useNavigation();
const soundRef = useRef<Audio.Sound | null>(null);
const shuffleOrderRef = useRef<number[]>([]);
const playbackOrderRef = useRef<number[]>([]);
const tracksRef = useRef<Track[]>([]);
const currentIndexRef = useRef(0);
const isPlayingRef = useRef(false);
const isShuffleRef = useRef(false);
const repeatModeRef = useRef<RepeatMode>('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 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 cacheOnlySetting = await Cache.getCacheOnlySetting();
setCacheOnlyState(cacheOnlySetting);
const resumePlaybackSetting = await API.getResumePlaybackEnabled();
setResumePlaybackEnabledState(resumePlaybackSetting);
const files = await API.getPlaylistFiles();
setPlaylistFiles(files);
const savedPlaylist = await API.getSavedSelectedPlaylist();
const initialPlaylist = files.includes(savedPlaylist) ? savedPlaylist : 'playlist.json';
await loadPlaylistBundle(initialPlaylist, { showSnapshotAlert: true });
} 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);
} catch (error) {
setCacheOnlyState(!value);
throw error;
}
};
const handlePlaylistChange = async (playlistFile: string) => {
try {
await loadPlaylistBundle(playlistFile, { showSnapshotAlert: true });
} catch (error) {
console.error('切换歌单失败:', error);
Alert.alert('错误', '切换歌单失败');
throw error;
}
};
const handleResumePlaybackChange = async (value: boolean) => {
setResumePlaybackEnabledState(value);
try {
await API.saveResumePlaybackEnabled(value);
} 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');
};
if (isLoading) {
return <StartupLoadingScreen />;
}
return (
<Tab.Navigator
screenOptions={{
tabBarStyle: {
backgroundColor: theme.colors.surface,
borderTopColor: theme.colors.border,
},
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: theme.colors.textSecondary,
headerShown: false,
}}
>
<Tab.Screen
name="Player"
children={() => (
<PlayerScreen
currentTrack={tracks[currentIndex]}
trackCount={tracks.length}
isPlaying={isPlaying}
isShuffle={isShuffle}
repeatMode={repeatMode}
position={position}
duration={duration}
onPlayPause={togglePlayPause}
onNext={nextTrack}
onPrev={prevTrack}
onShuffle={toggleShuffle}
onRepeat={toggleRepeat}
onSeek={seek}
onOpenPlaylist={openPlaylist}
/>
)}
options={{
tabBarLabel: '播放',
tabBarIcon: ({ color, size }) => <PlayIconNav color={color} size={size} />,
}}
/>
<Tab.Screen
name="Playlist"
children={() => (
<PlaylistScreen
tracks={tracks}
cacheInfo={cacheInfo}
downloadingTrackSrcs={downloadingTrackSrcs}
currentIndex={currentIndex}
isPlaying={isPlaying}
onPlaybackOrderChange={handlePlaybackOrderChange}
onSelectTrack={(index) => loadTrack(index, true)}
onDownloadTrack={handleDownloadTrack}
onDownloadAll={downloadAllTracks}
isDownloading={isDownloading}
downloadProgress={downloadProgress}
/>
)}
options={{
tabBarLabel: '列表',
tabBarIcon: ({ color, size }) => <ListIconNav color={color} size={size} />,
}}
/>
<Tab.Screen
name="Settings"
children={() => (
<SettingsScreen
cacheOnly={cacheOnly}
resumePlaybackEnabled={resumePlaybackEnabled}
playlistFiles={playlistFiles}
currentPlaylistFile={currentPlaylistFile}
onCacheOnlyChange={handleCacheOnlyChange}
onResumePlaybackChange={handleResumePlaybackChange}
onCacheChanged={refreshCacheInfo}
onPlaylistChange={handlePlaylistChange}
cachedTrackCount={tracks.filter((track) => !!cacheInfo[track.src]).length}
trackCount={tracks.length}
/>
)}
options={{
tabBarLabel: '设置',
tabBarIcon: ({ color, size }) => <SettingsIconNav color={color} size={size} />,
}}
/>
</Tab.Navigator>
);
};
export default function App() {
return (
<>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<NavigationContainer>
<TabNavigator />
</NavigationContainer>
</>
);
}
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,
},
});
+205
View File
@@ -0,0 +1,205 @@
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Track } from './types';
const API_BASE_URL = 'http://47.101.56.101:8976';
const PLAYLIST_LIST_PATH = '/api/zen_box/playlists';
const DEFAULT_PLAYLIST_FILE = 'playlist.json';
const PLAYLIST_SNAPSHOT_KEY_PREFIX = '@zen_box_playlist_snapshot:';
const SELECTED_PLAYLIST_KEY = '@zen_box_selected_playlist';
const RESUME_ENABLED_KEY = '@zen_box_resume_enabled';
const PLAYBACK_POSITION_KEY_PREFIX = '@zen_box_playback_position:';
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
export const formatError = (error: any): string => {
if (error.response) {
return `服务器响应 ${error.response.status}: ${error.response.statusText}`;
} else if (error.request) {
return `服务器无响应: 无法连接到 ${API_BASE_URL}`;
} else if (error.message) {
return `请求错误: ${error.message}`;
} else {
return `未知错误`;
}
};
const normalizePlaylistFile = (fileName?: string): string => {
return String(fileName || DEFAULT_PLAYLIST_FILE).trim() || DEFAULT_PLAYLIST_FILE;
};
const buildPlaylistPath = (fileName?: string): string => {
return `/tools/zen_box/${encodeURIComponent(normalizePlaylistFile(fileName))}`;
};
const getPlaylistSnapshotKey = (fileName?: string): string => {
return `${PLAYLIST_SNAPSHOT_KEY_PREFIX}${normalizePlaylistFile(fileName)}`;
};
const getPlaybackPositionKey = (playlistFile?: string, trackSrc?: string): string => {
return `${PLAYBACK_POSITION_KEY_PREFIX}${normalizePlaylistFile(playlistFile)}::${String(trackSrc || '').trim()}`;
};
export const getPlaylistFiles = async (): Promise<string[]> => {
try {
const response = await api.get<{ ok?: boolean; playlists?: string[] }>(PLAYLIST_LIST_PATH);
const raw = Array.isArray(response.data?.playlists) ? response.data.playlists : [];
const files = raw.map(item => String(item || '')).filter(Boolean);
if (!files.includes(DEFAULT_PLAYLIST_FILE)) {
files.unshift(DEFAULT_PLAYLIST_FILE);
}
return files;
} catch (error) {
console.error('读取歌单列表失败:', error);
return [DEFAULT_PLAYLIST_FILE];
}
};
export const getSavedSelectedPlaylist = async (): Promise<string> => {
try {
const value = await AsyncStorage.getItem(SELECTED_PLAYLIST_KEY);
return normalizePlaylistFile(value || DEFAULT_PLAYLIST_FILE);
} catch (error) {
console.error('读取当前歌单失败:', error);
return DEFAULT_PLAYLIST_FILE;
}
};
export const saveSelectedPlaylist = async (fileName: string): Promise<void> => {
try {
await AsyncStorage.setItem(SELECTED_PLAYLIST_KEY, normalizePlaylistFile(fileName));
} catch (error) {
console.error('保存当前歌单失败:', error);
}
};
export const getResumePlaybackEnabled = async (): Promise<boolean> => {
try {
const value = await AsyncStorage.getItem(RESUME_ENABLED_KEY);
if (value === null) {
return true;
}
return value === '1';
} catch (error) {
console.error('读取断点续播设置失败:', error);
return true;
}
};
export const saveResumePlaybackEnabled = async (enabled: boolean): Promise<void> => {
try {
await AsyncStorage.setItem(RESUME_ENABLED_KEY, enabled ? '1' : '0');
} catch (error) {
console.error('保存断点续播设置失败:', error);
}
};
export const getSavedPlaybackPosition = async (playlistFile?: string, trackSrc?: string): Promise<number> => {
try {
const key = getPlaybackPositionKey(playlistFile, trackSrc);
const value = await AsyncStorage.getItem(key);
const seconds = Number(value || 0);
return Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
} catch (error) {
console.error('读取播放进度失败:', error);
return 0;
}
};
export const savePlaybackPosition = async (
playlistFile: string,
trackSrc: string,
positionSeconds: number
): Promise<void> => {
try {
const key = getPlaybackPositionKey(playlistFile, trackSrc);
const safeSeconds = Math.max(0, Math.floor(positionSeconds || 0));
await AsyncStorage.setItem(key, String(safeSeconds));
} catch (error) {
console.error('保存播放进度失败:', error);
}
};
export const clearPlaybackPosition = async (playlistFile: string, trackSrc: string): Promise<void> => {
try {
const key = getPlaybackPositionKey(playlistFile, trackSrc);
await AsyncStorage.removeItem(key);
} catch (error) {
console.error('清除播放进度失败:', error);
}
};
export const getPlaylist = async (fileName?: string): Promise<Track[]> => {
try {
const response = await api.get<Track[]>(buildPlaylistPath(fileName));
return response.data;
} catch (error) {
throw new Error(formatError(error));
}
};
const isValidPlaylist = (data: unknown): data is Track[] => {
return Array.isArray(data) && data.every((item) => {
if (!item || typeof item !== 'object') {
return false;
}
const track = item as Track;
return typeof track.title === 'string'
&& typeof track.artist === 'string'
&& typeof track.src === 'string';
});
};
export const savePlaylistSnapshot = async (tracks: Track[], fileName?: string): Promise<void> => {
try {
await AsyncStorage.setItem(getPlaylistSnapshotKey(fileName), JSON.stringify(tracks));
} catch (error) {
console.error('保存歌单快照失败:', error);
}
};
export const getPlaylistSnapshot = async (fileName?: string): Promise<Track[]> => {
try {
const raw = await AsyncStorage.getItem(getPlaylistSnapshotKey(fileName));
if (!raw) {
return [];
}
const parsed: unknown = JSON.parse(raw);
return isValidPlaylist(parsed) ? parsed : [];
} catch (error) {
console.error('读取歌单快照失败:', error);
return [];
}
};
export const getPlaylistWithFallback = async (fileName?: string): Promise<{
tracks: Track[];
source: 'remote' | 'snapshot';
fallbackReason?: string;
playlistFile: string;
}> => {
const normalizedFile = normalizePlaylistFile(fileName);
try {
const tracks = await getPlaylist(normalizedFile);
await savePlaylistSnapshot(tracks, normalizedFile);
return { tracks, source: 'remote', playlistFile: normalizedFile };
} catch (error) {
const fallbackTracks = await getPlaylistSnapshot(normalizedFile);
if (fallbackTracks.length > 0) {
return {
tracks: fallbackTracks,
source: 'snapshot',
fallbackReason: error instanceof Error ? error.message : '远程歌单加载失败',
playlistFile: normalizedFile,
};
}
throw error;
}
};
+44
View File
@@ -0,0 +1,44 @@
{
"expo": {
"name": "观空禅音",
"slug": "zen-box",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#060612"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"infoPlist": {
"UIBackgroundModes": [
"audio"
]
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#060612"
}
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-av",
"expo-splash-screen"
],
"extra": {
"eas": {
"projectId": "545555e0-1bf6-4f12-989a-8c772f3caffe"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+6
View File
@@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
+143
View File
@@ -0,0 +1,143 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
Easing,
LayoutChangeEvent,
StyleProp,
StyleSheet,
Text,
TextProps,
TextStyle,
View,
ViewStyle,
} from 'react-native';
interface MarqueeTextProps extends Omit<TextProps, 'style'> {
text: string;
enabled?: boolean;
style?: StyleProp<TextStyle>;
containerStyle?: StyleProp<ViewStyle>;
gap?: number;
speed?: number;
initialDelayMs?: number;
}
export const MarqueeText: React.FC<MarqueeTextProps> = ({
text,
enabled = false,
style,
containerStyle,
gap = 40,
speed = 28,
initialDelayMs = 1200,
...textProps
}) => {
const translateX = useRef(new Animated.Value(0)).current;
const animationRef = useRef<Animated.CompositeAnimation | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [textWidth, setTextWidth] = useState(0);
const normalizedText = useMemo(() => String(text || ''), [text]);
const shouldAnimate = enabled && containerWidth > 0 && textWidth > containerWidth + 4;
const distance = Math.max(0, textWidth + gap);
const durationMs = Math.max(6000, Math.round((distance / Math.max(speed, 1)) * 1000));
useEffect(() => {
animationRef.current?.stop();
translateX.stopAnimation();
translateX.setValue(0);
if (!shouldAnimate || !distance) {
return () => {
animationRef.current?.stop();
};
}
const sequence = Animated.loop(
Animated.sequence([
Animated.delay(initialDelayMs),
Animated.timing(translateX, {
toValue: -distance,
duration: durationMs,
easing: Easing.linear,
useNativeDriver: true,
}),
Animated.delay(700),
Animated.timing(translateX, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
])
);
animationRef.current = sequence;
sequence.start();
return () => {
sequence.stop();
animationRef.current = null;
translateX.stopAnimation();
translateX.setValue(0);
};
}, [distance, durationMs, initialDelayMs, shouldAnimate, translateX]);
const handleContainerLayout = (event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width || 0);
if (nextWidth !== containerWidth) {
setContainerWidth(nextWidth);
}
};
const handleTextLayout = (event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width || 0);
if (nextWidth !== textWidth) {
setTextWidth(nextWidth);
}
};
return (
<View style={[styles.container, containerStyle]} onLayout={handleContainerLayout}>
<Text
{...textProps}
style={[style, styles.measureText]}
onLayout={handleTextLayout}
numberOfLines={1}
>
{normalizedText}
</Text>
{shouldAnimate ? (
<Animated.View style={[styles.track, { transform: [{ translateX }] }]}>
<Text {...textProps} style={style} numberOfLines={1}>
{normalizedText}
</Text>
<View style={{ width: gap }} />
<Text {...textProps} style={style} numberOfLines={1}>
{normalizedText}
</Text>
</Animated.View>
) : (
<Text {...textProps} style={style} numberOfLines={1} ellipsizeMode="tail">
{normalizedText}
</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
width: '100%',
overflow: 'hidden',
},
track: {
flexDirection: 'row',
alignItems: 'center',
},
measureText: {
position: 'absolute',
opacity: 0,
left: 0,
top: -9999,
},
});
@@ -0,0 +1,282 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, PanResponder } from 'react-native';
import Svg, { Circle, Path, Polyline, Text as SvgText } from 'react-native-svg';
import { theme } from '../theme';
import { RepeatMode } from '../types';
interface PlayerControlsProps {
isPlaying: boolean;
isShuffle: boolean;
repeatMode: RepeatMode;
position: number;
duration: number;
onPlayPause: () => void;
onNext: () => void;
onPrev: () => void;
onShuffle: () => void;
onRepeat: () => void;
onSeek: (position: number) => void;
}
const formatTime = (seconds: number): string => {
if (isNaN(seconds) || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const PlayIcon = ({ size = 24 }: { size?: number }) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.background}>
<Path d="M8 5v14l11-7z" />
</Svg>
);
const PauseIcon = ({ size = 24 }: { size?: number }) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.background}>
<Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</Svg>
);
const NextIcon = ({ size = 22 }: { size?: number }) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.textSecondary}>
<Path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</Svg>
);
const PrevIcon = ({ size = 22 }: { size?: number }) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.textSecondary}>
<Path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
</Svg>
);
const ShuffleIcon = ({ active = false }: { active?: boolean }) => (
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke={active ? theme.colors.primary : theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Polyline points="16 1 21 3 21 8" />
<Path d="M4 4 C8 4 10 8 12 12 C14 16 16 20 20 20" />
<Polyline points="16 23 21 21 21 18" />
<Path d="M4 20 C8 20 10 16 12 12 C14 8 16 4 20 4" />
</Svg>
);
const RepeatIcon = ({ mode }: { mode: RepeatMode }) => {
const active = mode !== 'none';
const color = active ? theme.colors.primary : theme.colors.textSecondary;
const badgeFill = mode === 'none' ? 'transparent' : theme.colors.primary;
const badgeStroke = mode === 'none' ? 'transparent' : theme.colors.background;
return (
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Polyline points="17 1 21 5 17 9" />
<Path d="M3 11V9a4 4 0 0 1 4-4h14" />
<Polyline points="7 23 3 19 7 15" />
<Path d="M21 13v2a4 4 0 0 1-4 4H3" />
{mode !== 'none' ? <Circle cx="18" cy="18" r="4.4" fill={badgeFill} stroke={badgeStroke} strokeWidth={1.2} /> : null}
{mode === 'one' ? (
<SvgText
x="18"
y="19.4"
textAnchor="middle"
fontSize="6.5"
fontWeight="700"
fill={theme.colors.background}
stroke="none"
>
1
</SvgText>
) : null}
{mode === 'all' ? (
<>
<Circle cx="16.4" cy="18" r="0.9" fill={theme.colors.background} stroke="none" />
<Circle cx="19.6" cy="18" r="0.9" fill={theme.colors.background} stroke="none" />
</>
) : null}
</Svg>
);
};
export const PlayerControls: React.FC<PlayerControlsProps> = ({
isPlaying,
isShuffle,
repeatMode,
position,
duration,
onPlayPause,
onNext,
onPrev,
onShuffle,
onRepeat,
onSeek,
}) => {
const progress = duration > 0 ? position / duration : 0;
const progressBarRef = React.useRef<View>(null);
const [isDragging, setIsDragging] = React.useState(false);
const [dragProgress, setDragProgress] = React.useState(progress);
const handleSeekTo = (newProgress: number) => {
if (duration > 0) {
onSeek(newProgress * duration);
}
};
const panResponder = React.useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderGrant: () => {
setIsDragging(true);
},
onPanResponderMove: (evt) => {
// 测量进度条宽度
progressBarRef.current?.measure((x, y, width) => {
if (width > 0) {
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
setDragProgress(newProgress);
}
});
},
onPanResponderRelease: (evt) => {
setIsDragging(false);
progressBarRef.current?.measure((x, y, width) => {
if (width > 0) {
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
handleSeekTo(newProgress);
}
});
},
onPanResponderTerminate: (evt) => {
setIsDragging(false);
progressBarRef.current?.measure((x, y, width) => {
if (width > 0) {
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
handleSeekTo(newProgress);
}
});
},
}),
[duration]
);
const currentProgress = isDragging ? dragProgress : progress;
const thumbPosition = currentProgress * 100;
return (
<View style={styles.container}>
<View style={styles.glassBar}>
{/* Progress bar */}
<View style={styles.progressWrap}>
<View style={styles.timeRow}>
<Text style={styles.timeText}>{formatTime(position)}</Text>
<Text style={styles.timeText}>{formatTime(duration)}</Text>
</View>
<View
ref={progressBarRef}
style={styles.progressTrack}
{...panResponder.panHandlers}
>
<View style={[styles.progressFill, { width: `${currentProgress * 100}%` }]} />
<View style={[styles.progressThumb, { left: `${thumbPosition}%`, transform: [{ translateX: -7 }] }]} />
</View>
</View>
{/* Main controls */}
<View style={styles.controls}>
<TouchableOpacity style={styles.ctrlBtn} onPress={onShuffle}>
<ShuffleIcon active={isShuffle} />
</TouchableOpacity>
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onPrev()}>
<PrevIcon />
</TouchableOpacity>
<TouchableOpacity style={styles.playBtn} onPress={() => onPlayPause()}>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</TouchableOpacity>
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onNext()}>
<NextIcon />
</TouchableOpacity>
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onRepeat()}>
<RepeatIcon mode={repeatMode} />
</TouchableOpacity>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
paddingHorizontal: theme.spacing.md,
paddingBottom: theme.spacing.md,
},
glassBar: {
backgroundColor: 'rgba(10,10,30,0.65)',
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: theme.colors.border,
padding: theme.spacing.md,
},
progressWrap: {
marginBottom: theme.spacing.md,
},
progressTrack: {
height: 4,
backgroundColor: 'rgba(255,255,255,0.10)',
borderRadius: 2,
position: 'relative',
},
progressFill: {
height: '100%',
backgroundColor: theme.colors.primary,
borderRadius: 2,
},
progressThumb: {
position: 'absolute',
top: -5,
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: theme.colors.primary,
shadowColor: theme.colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 4,
elevation: 5,
},
timeRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: theme.spacing.xs,
},
timeText: {
fontSize: 11,
color: theme.colors.textSecondary,
fontVariant: ['tabular-nums'],
},
controls: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing.lg,
},
ctrlBtn: {
padding: theme.spacing.sm,
},
playBtn: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: theme.colors.primary,
justifyContent: 'center',
alignItems: 'center',
},
});
+87
View File
@@ -0,0 +1,87 @@
import React, { useMemo } from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import { theme } from '../theme';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
interface Star {
x: number;
y: number;
size: number;
isGold: boolean;
}
const generateStars = (count: number, size: number, isGold: boolean = false): Star[] => {
const stars: Star[] = [];
for (let i = 0; i < count; i++) {
stars.push({
x: Math.random() * SCREEN_WIDTH,
y: Math.random() * SCREEN_HEIGHT,
size,
isGold,
});
}
return stars;
};
const StarComponent: React.FC<Star> = ({ x, y, size, isGold }) => {
return (
<View
style={[
styles.star,
{
left: x,
top: y,
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: isGold ? theme.colors.primary : theme.colors.text,
opacity: 0.6 + Math.random() * 0.4,
},
]}
/>
);
};
export const Starfield: React.FC = () => {
const smallStars = useMemo(() => generateStars(30, 1), []);
const mediumStars = useMemo(() => generateStars(15, 1.5), []);
const largeStars = useMemo(() => generateStars(8, 2, true), []);
return (
<View style={styles.container}>
{/* Radial gradient background simulation */}
<View style={styles.radialGlow} />
{/* Stars */}
{smallStars.map((star, i) => (
<StarComponent key={`small-${i}`} {...star} />
))}
{mediumStars.map((star, i) => (
<StarComponent key={`medium-${i}`} {...star} />
))}
{largeStars.map((star, i) => (
<StarComponent key={`large-${i}`} {...star} />
))}
</View>
);
};
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: theme.colors.background,
},
radialGlow: {
position: 'absolute',
top: SCREEN_HEIGHT * 0.2,
left: SCREEN_WIDTH / 2 - 150,
width: 300,
height: 300,
borderRadius: 150,
backgroundColor: theme.colors.primarySoft,
},
star: {
position: 'absolute',
},
});
+543
View File
@@ -0,0 +1,543 @@
import React from 'react';
import { Animated, Easing, View, StyleSheet } from 'react-native';
import Svg, { Path, Ellipse, Circle } from 'react-native-svg';
import { theme } from '../theme';
interface ZenCenterProps {
isPlaying: boolean;
}
const ZenFigure = () => (
<View style={styles.zenFigure}>
<Svg viewBox="0 0 200 210" style={styles.zenSvg}>
{/* Ground shadow */}
<Ellipse cx="100" cy="178" rx="44" ry="5.5" fill="rgba(232,184,109,0.08)" />
{/* Lower body - Full lotus base */}
<Ellipse cx="100" cy="158" rx="46" ry="14" fill="#c8973e" opacity={0.7} />
{/* Left knee */}
<Path
d="M 54 150 C 44 152, 36 158, 36 165 C 36 171, 42 175, 52 174 C 60 173, 68 167, 72 160 C 74 155, 70 149, 64 148 Z"
fill="#d4a045"
opacity={0.78}
/>
{/* Right knee */}
<Path
d="M 146 150 C 156 152, 164 158, 164 165 C 164 171, 158 175, 148 174 C 140 173, 132 167, 128 160 C 126 155, 130 149, 136 148 Z"
fill="#d4a045"
opacity={0.78}
/>
{/* Left foot */}
<Path
d="M 124 148 C 132 144, 142 145, 146 150 C 148 153, 145 157, 138 157 C 131 157, 124 153, 122 150 Z"
fill="#c8973e"
opacity={0.72}
/>
{/* Right foot */}
<Path
d="M 76 148 C 68 144, 58 145, 54 150 C 52 153, 55 157, 62 157 C 69 157, 76 153, 78 150 Z"
fill="#c8973e"
opacity={0.72}
/>
{/* Torso */}
<Path
d="M 72 152 C 70 140, 69 126, 70 114 C 71 102, 75 92, 82 85 C 88 79, 94 77, 100 76 C 106 77, 112 79, 118 85 C 125 92, 129 102, 130 114 C 131 126, 130 140, 128 152 C 120 157, 110 160, 100 160 C 90 160, 80 157, 72 152 Z"
fill="#e0a84e"
opacity={0.88}
/>
{/* Chest line */}
<Path
d="M 100 88 C 99 108, 99 130, 100 152"
stroke="#c8882a"
strokeWidth={0.8}
opacity={0.18}
strokeLinecap="round"
/>
{/* Left upper arm */}
<Path
d="M 70 114 C 64 116, 58 122, 56 130 C 54 136, 56 142, 62 146 C 68 148, 75 146, 78 140 C 74 136, 70 128, 70 118 Z"
fill="#d4a045"
opacity={0.82}
/>
{/* Right upper arm */}
<Path
d="M 130 114 C 136 116, 142 122, 144 130 C 146 136, 144 142, 138 146 C 132 148, 125 146, 122 140 C 126 136, 130 128, 130 118 Z"
fill="#d4a045"
opacity={0.82}
/>
{/* Left hand */}
<Path
d="M 84 154 C 80 152, 76 152, 75 155 C 74 158, 76 162, 82 163 C 88 164, 96 162, 98 158 C 98 155, 92 154, 84 154 Z"
fill="#d4a045"
opacity={0.8}
/>
{/* Right hand */}
<Path
d="M 102 152 C 98 150, 94 150, 92 153 C 90 156, 91 160, 96 162 C 102 163, 110 161, 114 157 C 116 154, 112 151, 106 151 Z"
fill="#c8973e"
opacity={0.78}
/>
{/* Thumb tips touching */}
<Ellipse cx="99" cy="153" rx="4" ry="2.5" fill="#e8b86d" opacity={0.55} />
{/* Neck */}
<Path
d="M 93 78 C 93 74, 96 70, 100 69 C 104 70, 107 74, 107 78 L 107 86 L 93 86 Z"
fill="#e0a84e"
opacity={0.88}
/>
{/* Head */}
<Path
d="M 82 54 C 82 36, 90 26, 100 25 C 110 26, 118 36, 118 54 C 118 66, 112 74, 106 77 L 100 79 L 94 77 C 88 74, 82 66, 82 54 Z"
fill="#e0a84e"
opacity={0.88}
/>
{/* Hair */}
<Path
d="M 82 50 C 82 36, 90 26, 100 25 C 110 26, 118 36, 118 50 C 112 43, 106 39, 100 38 C 94 39, 88 43, 82 50 Z"
fill="#b8812a"
opacity={0.8}
/>
{/* Face features */}
{/* Left brow */}
<Path
d="M 89 53 Q 93 51, 97 53"
stroke="#8a6020"
strokeWidth={0.8}
strokeLinecap="round"
opacity={0.35}
/>
{/* Right brow */}
<Path
d="M 103 53 Q 107 51, 111 53"
stroke="#8a6020"
strokeWidth={0.8}
strokeLinecap="round"
opacity={0.35}
/>
{/* Left eye closed */}
<Path
d="M 89 57 Q 93 61, 97 57"
stroke="#7a5010"
strokeWidth={1.4}
strokeLinecap="round"
opacity={0.65}
/>
{/* Right eye closed */}
<Path
d="M 103 57 Q 107 61, 111 57"
stroke="#7a5010"
strokeWidth={1.4}
strokeLinecap="round"
opacity={0.65}
/>
{/* Nose */}
<Path
d="M 100 62 C 99 65, 97 67, 97 68"
stroke="#8a6020"
strokeWidth={1}
strokeLinecap="round"
opacity={0.25}
/>
{/* Mouth - serene half smile */}
<Path
d="M 95 72 Q 100 75, 105 72"
stroke="#8a6020"
strokeWidth={1.2}
strokeLinecap="round"
opacity={0.3}
/>
{/* Crown chakra */}
<Circle cx="100" cy="24" r="3.5" fill="rgba(232,184,109,0.2)" />
<Circle cx="100" cy="24" r="1.8" fill="#e8b86d" opacity={0.75} />
<Circle cx="100" cy="24" r="0.8" fill="#fff" opacity={0.6} />
{/* Subtle inner light */}
<Circle cx="100" cy="110" r="12" fill="rgba(232,184,109,0.04)" />
</Svg>
</View>
);
export const ZenCenter: React.FC<ZenCenterProps> = ({ isPlaying }) => {
const orbitAnimPrimary = React.useRef(new Animated.Value(0)).current;
const orbitAnimSecondary = React.useRef(new Animated.Value(0)).current;
const starBreathPrimary = React.useRef(new Animated.Value(0)).current;
const starBreathSecondary = React.useRef(new Animated.Value(0)).current;
const solarHaloAnim = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
orbitAnimPrimary.setValue(0);
orbitAnimSecondary.setValue(0);
starBreathPrimary.setValue(0);
starBreathSecondary.setValue(0);
solarHaloAnim.setValue(0);
const primaryLoop = Animated.loop(
Animated.timing(orbitAnimPrimary, {
toValue: 1,
duration: 18000,
easing: Easing.linear,
useNativeDriver: true,
})
);
const secondaryLoop = Animated.loop(
Animated.timing(orbitAnimSecondary, {
toValue: 1,
duration: 26000,
easing: Easing.linear,
useNativeDriver: true,
})
);
const primaryBreathLoop = Animated.loop(
Animated.sequence([
Animated.timing(starBreathPrimary, {
toValue: 1,
duration: 4200,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(starBreathPrimary, {
toValue: 0,
duration: 4200,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
])
);
const secondaryBreathLoop = Animated.loop(
Animated.sequence([
Animated.timing(starBreathSecondary, {
toValue: 1,
duration: 6100,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(starBreathSecondary, {
toValue: 0,
duration: 6100,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
])
);
const solarHaloLoop = Animated.loop(
Animated.timing(solarHaloAnim, {
toValue: 1,
duration: 210000,
easing: Easing.linear,
useNativeDriver: true,
})
);
primaryLoop.start();
secondaryLoop.start();
primaryBreathLoop.start();
secondaryBreathLoop.start();
solarHaloLoop.start();
return () => {
primaryLoop.stop();
secondaryLoop.stop();
primaryBreathLoop.stop();
secondaryBreathLoop.stop();
solarHaloLoop.stop();
};
}, [orbitAnimPrimary, orbitAnimSecondary, solarHaloAnim, starBreathPrimary, starBreathSecondary]);
const primaryRotate = orbitAnimPrimary.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const secondaryRotate = orbitAnimSecondary.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '-360deg'],
});
const primaryStarOpacity = starBreathPrimary.interpolate({
inputRange: [0, 1],
outputRange: [0.45, 0.9],
});
const primaryStarScale = starBreathPrimary.interpolate({
inputRange: [0, 1],
outputRange: [0.92, 1.14],
});
const secondaryStarOpacity = starBreathSecondary.interpolate({
inputRange: [0, 1],
outputRange: [0.16, 0.55],
});
const secondaryStarScale = starBreathSecondary.interpolate({
inputRange: [0, 1],
outputRange: [0.9, 1.2],
});
const haloRotate = solarHaloAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const haloOpacity = solarHaloAnim.interpolate({
inputRange: [0, 0.12, 0.18, 0.26, 0.62, 0.72, 0.8, 1],
outputRange: [0.02, 0.03, 0.12, 0.04, 0.03, 0.11, 0.04, 0.02],
});
const haloScale = solarHaloAnim.interpolate({
inputRange: [0, 0.18, 0.62, 0.72, 1],
outputRange: [0.88, 1.06, 0.9, 1.08, 0.88],
});
return (
<View style={styles.container}>
<View style={styles.orbitWrap}>
<Animated.View
pointerEvents="none"
style={[
styles.solarHaloTrack,
{
opacity: haloOpacity,
transform: [{ rotate: haloRotate }],
},
]}
>
<Animated.View
style={[
styles.solarHalo,
{
transform: [{ scale: haloScale }],
},
]}
>
<View style={styles.solarCore} />
</Animated.View>
</Animated.View>
<Animated.View
pointerEvents="none"
style={[styles.orbitLayerPrimary, { transform: [{ rotate: primaryRotate }] }]}
>
<View style={styles.orbitRing} />
<Animated.View
style={[
styles.orbitDot1,
{
opacity: primaryStarOpacity,
transform: [{ scale: primaryStarScale }],
},
]}
/>
<Animated.View
style={[
styles.orbitDot1Trail,
{
opacity: secondaryStarOpacity,
transform: [{ scale: secondaryStarScale }],
},
]}
/>
</Animated.View>
<Animated.View
pointerEvents="none"
style={[styles.orbitLayerSecondary, { transform: [{ rotate: secondaryRotate }] }]}
>
<View style={styles.orbitRing2} />
<Animated.View
style={[
styles.orbitDot2,
{
opacity: secondaryStarOpacity,
transform: [{ scale: secondaryStarScale }],
},
]}
/>
<Animated.View
style={[
styles.orbitDot3,
{
opacity: primaryStarOpacity,
transform: [{ scale: primaryStarScale }],
},
]}
/>
</Animated.View>
{/* Zen glow */}
<View style={[styles.zenGlow, { opacity: isPlaying ? 1 : 0.55 }]} />
{/* Zen waves */}
<View style={styles.zenWaves}>
<View style={styles.zenWave} />
</View>
{/* Zen figure */}
<ZenFigure />
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
orbitWrap: {
width: 220,
height: 220,
justifyContent: 'center',
alignItems: 'center',
},
orbitLayerPrimary: {
position: 'absolute',
width: 220,
height: 220,
justifyContent: 'center',
alignItems: 'center',
},
orbitLayerSecondary: {
position: 'absolute',
width: 256,
height: 256,
justifyContent: 'center',
alignItems: 'center',
},
solarHaloTrack: {
position: 'absolute',
width: 320,
height: 320,
justifyContent: 'center',
alignItems: 'center',
},
solarHalo: {
position: 'absolute',
top: -18,
right: 24,
width: 92,
height: 92,
borderRadius: 46,
backgroundColor: 'rgba(232,184,109,0.14)',
shadowColor: theme.colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.18,
shadowRadius: 24,
elevation: 8,
justifyContent: 'center',
alignItems: 'center',
},
solarCore: {
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: 'rgba(255,244,214,0.28)',
},
orbitRing: {
position: 'absolute',
width: 220,
height: 220,
borderRadius: 110,
borderWidth: 1,
borderColor: 'rgba(232,184,109,0.12)',
},
orbitRing2: {
position: 'absolute',
width: 256,
height: 256,
borderRadius: 128,
borderWidth: 1,
borderColor: 'rgba(232,184,109,0.06)',
},
orbitDot1: {
position: 'absolute',
top: -3,
left: 107,
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.primary,
opacity: 0.55,
},
orbitDot1Trail: {
position: 'absolute',
top: 26,
right: 30,
width: 3,
height: 3,
borderRadius: 1.5,
backgroundColor: theme.colors.primary,
opacity: 0.22,
},
orbitDot2: {
position: 'absolute',
bottom: -2,
left: 127,
width: 4,
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.primary,
opacity: 0.3,
},
orbitDot3: {
position: 'absolute',
top: 36,
left: 18,
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: theme.colors.primary,
opacity: 0.18,
},
zenGlow: {
position: 'absolute',
width: 160,
height: 160,
borderRadius: 80,
backgroundColor: theme.colors.primarySoft,
opacity: 0,
},
zenWaves: {
position: 'absolute',
width: 220,
height: 220,
justifyContent: 'center',
alignItems: 'center',
},
zenWave: {
position: 'absolute',
width: 160,
height: 160,
borderRadius: 80,
borderWidth: 1,
borderColor: 'rgba(232,184,109,0.08)',
},
zenFigure: {
width: 140,
height: 140,
},
zenSvg: {
width: '100%',
height: '100%',
},
});
+7225
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "app-zen-box",
"version": "1.0.0",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^6.5.0",
"@react-navigation/native": "^6.1.0",
"axios": "^1.6.0",
"expo": "~54.0.0",
"expo-av": "~16.0.8",
"expo-file-system": "~17.0.1",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.2.0",
"react-native-worklets": "^0.10.0",
"react-native-worklets-core": "^1.6.3"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.1.10",
"@types/react-native": "~0.73.0",
"babel-preset-expo": "~54.0.10",
"typescript": "~5.9.2"
},
"private": true
}
+168
View File
@@ -0,0 +1,168 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Path } from 'react-native-svg';
import { Starfield } from '../components/Starfield';
import { ZenCenter } from '../components/ZenCenter';
import { MarqueeText } from '../components/MarqueeText';
import { PlayerControls } from '../components/PlayerControls';
import { theme } from '../theme';
import { Track, RepeatMode } from '../types';
interface PlayerScreenProps {
currentTrack: Track | null;
trackCount: number;
isPlaying: boolean;
isShuffle: boolean;
repeatMode: RepeatMode;
position: number;
duration: number;
onPlayPause: () => void;
onNext: () => void;
onPrev: () => void;
onShuffle: () => void;
onRepeat: () => void;
onSeek: (position: number) => void;
onOpenPlaylist: () => void;
}
const ListIcon = () => (
<Svg width={24} height={24} viewBox="0 0 24 24" fill="none" stroke={theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round">
<Path d="M8 6h13M8 12h13M8 18h13" />
<Path d="M3 6h.01M3 12h.01M3 18h.01" />
</Svg>
);
export const PlayerScreen: React.FC<PlayerScreenProps> = ({
currentTrack,
trackCount,
isPlaying,
isShuffle,
repeatMode,
position,
duration,
onPlayPause,
onNext,
onPrev,
onShuffle,
onRepeat,
onSeek,
onOpenPlaylist,
}) => {
return (
<View style={styles.container}>
{/* Starfield background */}
<Starfield />
<SafeAreaView style={styles.safeContainer} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>观空禅音</Text>
<Text style={styles.headerCount}>{trackCount} 首</Text>
</View>
{/* Zen center */}
<View style={styles.zenCenterContainer}>
<ZenCenter isPlaying={isPlaying} />
</View>
{/* Track info */}
<View style={styles.trackInfo}>
<MarqueeText
text={currentTrack?.title || '未选择歌曲'}
enabled={!!currentTrack?.title}
style={styles.trackTitle}
containerStyle={styles.trackTitleWrap}
/>
<Text style={styles.trackArtist} numberOfLines={1}>
{currentTrack?.artist || '—'}
</Text>
</View>
{/* Spacer */}
<View style={styles.spacer} />
{/* Player controls */}
<PlayerControls
isPlaying={isPlaying}
isShuffle={isShuffle}
repeatMode={repeatMode}
position={position}
duration={duration}
onPlayPause={onPlayPause}
onNext={onNext}
onPrev={onPrev}
onShuffle={onShuffle}
onRepeat={onRepeat}
onSeek={onSeek}
/>
{/* Playlist button */}
<View style={styles.playlistButtonContainer}>
<TouchableOpacity style={styles.playlistButton} onPress={onOpenPlaylist}>
<ListIcon />
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
safeContainer: {
flex: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: theme.spacing.md,
paddingVertical: theme.spacing.sm,
},
headerTitle: {
fontSize: 20,
fontWeight: '600',
letterSpacing: 0.06,
color: theme.colors.text,
},
headerCount: {
fontSize: 13,
color: theme.colors.textSecondary,
},
zenCenterContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
trackInfo: {
alignItems: 'center',
paddingHorizontal: theme.spacing.md,
},
trackTitleWrap: {
width: '100%',
},
trackTitle: {
fontSize: 17,
fontWeight: '600',
color: theme.colors.text,
marginBottom: 3,
},
trackArtist: {
fontSize: 13,
color: theme.colors.textSecondary,
},
spacer: {
height: theme.spacing.md,
},
playlistButtonContainer: {
alignItems: 'center',
paddingBottom: theme.spacing.sm,
},
playlistButton: {
padding: theme.spacing.sm,
},
});
+503
View File
@@ -0,0 +1,503 @@
import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, ActivityIndicator, Animated } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Svg, { Path, Circle } from 'react-native-svg';
import { MarqueeText } from '../components/MarqueeText';
import { theme } from '../theme';
import { Track, CacheInfo } from '../types';
import * as Cache from '../utils/cache';
const SORT_PREF_KEY = '@zen_box_sort_pref';
type SortMode = 'original' | 'asc' | 'desc';
interface PlaylistScreenProps {
tracks: Track[];
cacheInfo: CacheInfo;
downloadingTrackSrcs: string[];
currentIndex: number;
isPlaying: boolean;
onPlaybackOrderChange: (order: number[]) => void;
onSelectTrack: (index: number) => void;
onDownloadTrack: (track: Track) => Promise<void>;
onDownloadAll?: () => void;
isDownloading?: boolean;
downloadProgress?: { completed: number; total: number; title: string };
}
const PlayingIndicator = () => {
const anim1 = useRef(new Animated.Value(0.3)).current;
const anim2 = useRef(new Animated.Value(0.7)).current;
const anim3 = useRef(new Animated.Value(0.5)).current;
useEffect(() => {
const animate = (value: Animated.Value, delay: number) => {
Animated.loop(
Animated.sequence([
Animated.timing(value, {
toValue: 1,
duration: 400,
delay: delay,
useNativeDriver: true,
}),
Animated.timing(value, {
toValue: 0.3,
duration: 400,
useNativeDriver: true,
}),
])
).start();
};
animate(anim1, 0);
animate(anim2, 200);
animate(anim3, 400);
}, [anim1, anim2, anim3]);
return (
<View style={styles.playingIndicator}>
<Animated.View style={[styles.playingDot, { transform: [{ scale: anim1 }] }]} />
<Animated.View style={[styles.playingDot, { transform: [{ scale: anim2 }] }]} />
<Animated.View style={[styles.playingDot, { transform: [{ scale: anim3 }] }]} />
</View>
);
};
const DownloadIcon = () => (
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke={theme.colors.primary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<Path d="M7 10l5 5 5-5" />
<Path d="M12 15V3" />
</Svg>
);
const CheckIcon = () => (
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke={theme.colors.primary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M20 6L9 17l-5-5" />
</Svg>
);
const ArrowUpIcon = () => (
<Svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke={theme.colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M12 19V5M5 12l7-7 7 7" />
</Svg>
);
const ArrowDownIcon = () => (
<Svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke={theme.colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M12 5v14M5 12l7 7 7-7" />
</Svg>
);
const TrackItem: React.FC<{
track: Track;
index: number;
isCurrent: boolean;
isPlaying: boolean;
isCached: boolean;
isDownloading: boolean;
onPress: () => void;
onDownload?: () => void;
}> = ({ track, index, isCurrent, isPlaying, isCached, isDownloading, onPress, onDownload }) => (
<TouchableOpacity style={styles.trackItem} onPress={onPress}>
<View style={styles.trackNumber}>
{isCurrent && isPlaying ? (
<PlayingIndicator />
) : (
<Text style={[styles.trackNumberText, isCurrent && styles.trackNumberActive]}>
{index + 1}
</Text>
)}
</View>
<View style={styles.trackInfo}>
<MarqueeText
text={track.title}
enabled={isCurrent}
style={[styles.trackTitle, isCurrent && styles.trackTitleActive]}
containerStyle={styles.trackTitleWrap}
/>
<Text style={styles.trackArtist} numberOfLines={1}>
{track.artist}
</Text>
</View>
<TouchableOpacity
style={[styles.downloadBtn, isDownloading && styles.downloadBtnDisabled]}
onPress={onDownload}
disabled={isCached || isDownloading}
>
{isCached ? (
<CheckIcon />
) : isDownloading ? (
<ActivityIndicator size="small" color={theme.colors.primary} />
) : (
<DownloadIcon />
)}
</TouchableOpacity>
</TouchableOpacity>
);
export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
tracks,
cacheInfo,
downloadingTrackSrcs,
currentIndex,
isPlaying,
onPlaybackOrderChange,
onSelectTrack,
onDownloadTrack,
onDownloadAll,
isDownloading = false,
downloadProgress,
}) => {
const [sortMode, setSortMode] = useState<SortMode>('original');
const [sortedTracks, setSortedTracks] = useState<Track[]>(tracks);
const [originalTracks, setOriginalTracks] = useState<Track[]>(tracks);
// 加载排序偏好
useEffect(() => {
const loadSortPref = async () => {
try {
const saved = await AsyncStorage.getItem(SORT_PREF_KEY);
if (saved && ['original', 'asc', 'desc'].includes(saved)) {
setSortMode(saved as SortMode);
}
} catch (error) {
console.error('加载排序偏好失败:', error);
}
};
loadSortPref();
}, []);
// 保存排序偏好
const saveSortPref = async (newMode: SortMode) => {
try {
await AsyncStorage.setItem(SORT_PREF_KEY, newMode);
} catch (error) {
console.error('保存排序偏好失败:', error);
}
};
// 排序函数
useEffect(() => {
let result = [...tracks];
setOriginalTracks(tracks);
if (sortMode === 'asc') {
result.sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
} else if (sortMode === 'desc') {
result.sort((a, b) => b.title.localeCompare(a.title, 'zh-CN'));
}
setSortedTracks(result);
}, [tracks, sortMode]);
useEffect(() => {
const order = sortedTracks
.map((track) => originalTracks.findIndex((item) => item.src === track.src))
.filter((index) => index >= 0);
if (order.length === originalTracks.length) {
onPlaybackOrderChange(order);
}
}, [sortedTracks, originalTracks, onPlaybackOrderChange]);
const handleDownloadTrack = async (track: Track) => {
try {
await onDownloadTrack(track);
} catch (error) {
Alert.alert('下载失败', '无法下载此歌曲');
}
};
const handleDownloadAll = async () => {
if (!onDownloadAll) {
try {
await Cache.downloadAllTracks(tracks, (current, total, title) => {
console.log(`Downloading ${current + 1}/${total}: ${title}`);
});
Alert.alert('完成', '所有歌曲已缓存');
} catch (error) {
Alert.alert('下载失败', '部分歌曲下载失败');
}
} else {
onDownloadAll();
}
};
const setSort = (newMode: SortMode) => {
setSortMode(newMode);
saveSortPref(newMode);
};
const cachedCount = tracks.filter((track) => !!cacheInfo[track.src]).length;
const progressPercent =
isDownloading && downloadProgress && downloadProgress.total > 0
? Math.min(100, Math.round((downloadProgress.completed / downloadProgress.total) * 100))
: null;
// 获取原始索引用于播放
const getOriginalIndex = (sortedIndex: number) => {
const track = sortedTracks[sortedIndex];
return originalTracks.findIndex(t => t.src === track.src);
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<View style={styles.headerLeft}>
<Text style={styles.headerTitle}>播放列表</Text>
<Text style={styles.headerSubtitle}>
{cachedCount}/{tracks.length} 首已缓存
</Text>
</View>
</View>
{isDownloading && downloadProgress && downloadProgress.total > 0 && (
<View style={styles.progressSection}>
<View style={styles.progressTextRow}>
<Text style={styles.progressText} numberOfLines={1}>
{downloadProgress.title || '正在下载...'}
</Text>
<Text style={styles.progressPercent}>{progressPercent}%</Text>
</View>
<View style={styles.progressBar}>
{progressPercent !== null && (
<View
style={[styles.progressFill, { width: `${progressPercent}%` }]}
/>
)}
</View>
<Text style={styles.progressCount}>
{downloadProgress.completed}/{downloadProgress.total}
</Text>
</View>
)}
<FlatList
data={sortedTracks}
keyExtractor={(item) => item.src}
contentContainerStyle={styles.listContent}
renderItem={({ item, index }) => (
<TrackItem
track={item}
index={index}
isCurrent={getOriginalIndex(index) === currentIndex}
isPlaying={isPlaying}
isCached={!!cacheInfo[item.src]}
isDownloading={downloadingTrackSrcs.includes(item.src)}
onPress={() => onSelectTrack(getOriginalIndex(index))}
onDownload={() => handleDownloadTrack(item)}
/>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
{/* 底部操作栏 */}
<View style={styles.bottomActions}>
<TouchableOpacity
style={[styles.actionBtn, sortMode === 'asc' && styles.actionBtnActive]}
onPress={() => setSort('asc')}
>
<Text style={[styles.actionBtnText, sortMode === 'asc' && styles.actionBtnTextActive]}>
📋 名称正序
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, sortMode === 'desc' && styles.actionBtnActive]}
onPress={() => setSort('desc')}
>
<Text style={[styles.actionBtnText, sortMode === 'desc' && styles.actionBtnTextActive]}>
📋 名称倒序
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.downloadAllBtn]}
onPress={handleDownloadAll}
disabled={isDownloading}
>
{isDownloading ? (
<ActivityIndicator size="small" color={theme.colors.primary} />
) : (
<Text style={styles.downloadAllText}>⬇️ 一键下载所有</Text>
)}
</TouchableOpacity>
</View>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
paddingHorizontal: theme.spacing.md,
paddingVertical: theme.spacing.sm,
},
headerLeft: {
flex: 1,
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: theme.colors.text,
marginBottom: 3,
},
headerSubtitle: {
fontSize: 13,
color: theme.colors.textSecondary,
},
progressSection: {
paddingHorizontal: theme.spacing.md,
paddingBottom: theme.spacing.sm,
},
progressTextRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
},
progressText: {
flex: 1,
fontSize: 14,
color: theme.colors.text,
},
progressPercent: {
fontSize: 14,
fontWeight: '600',
color: theme.colors.primary,
marginLeft: theme.spacing.sm,
},
progressBar: {
height: 4,
backgroundColor: theme.colors.surface2,
borderRadius: 2,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: theme.colors.primary,
},
progressCount: {
fontSize: 12,
color: theme.colors.textSecondary,
marginTop: 4,
textAlign: 'right',
},
listContent: {
paddingHorizontal: theme.spacing.md,
paddingBottom: theme.spacing.xl,
},
trackItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: theme.spacing.sm,
},
trackNumber: {
width: 24,
marginRight: theme.spacing.md,
alignItems: 'center',
},
trackNumberText: {
fontSize: 13,
color: theme.colors.textSecondary,
fontVariant: ['tabular-nums'],
},
trackNumberActive: {
color: theme.colors.primary,
},
trackInfo: {
flex: 1,
marginRight: theme.spacing.sm,
minWidth: 0,
},
trackTitleWrap: {
width: '100%',
},
trackTitle: {
fontSize: 15,
fontWeight: '500',
color: theme.colors.text,
marginBottom: 3,
},
trackTitleActive: {
color: theme.colors.primary,
},
trackArtist: {
fontSize: 12,
color: theme.colors.textSecondary,
},
downloadBtn: {
padding: theme.spacing.xs,
width: 28,
alignItems: 'center',
justifyContent: 'center',
},
downloadBtnDisabled: {
opacity: 0.7,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
marginLeft: 40,
},
playingIndicator: {
flexDirection: 'row',
alignItems: 'center',
height: 12,
gap: 3,
},
playingDot: {
width: 4,
height: 4,
backgroundColor: theme.colors.primary,
borderRadius: 2,
},
bottomActions: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: theme.spacing.md,
paddingVertical: theme.spacing.md,
backgroundColor: theme.colors.surface,
borderTopWidth: 1,
borderTopColor: theme.colors.border,
gap: theme.spacing.sm,
},
actionBtn: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: theme.spacing.sm,
paddingVertical: theme.spacing.sm,
borderRadius: 8,
backgroundColor: theme.colors.surface2,
},
actionBtnActive: {
backgroundColor: theme.colors.primary + '20',
borderWidth: 1,
borderColor: theme.colors.primary,
},
actionBtnText: {
fontSize: 13,
color: theme.colors.textSecondary,
},
actionBtnTextActive: {
color: theme.colors.primary,
fontWeight: '600',
},
downloadAllBtn: {
backgroundColor: theme.colors.primary + '20',
},
downloadAllText: {
fontSize: 13,
color: theme.colors.primary,
fontWeight: '600',
},
});
+402
View File
@@ -0,0 +1,402 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Path } from 'react-native-svg';
import { theme } from '../theme';
import * as Cache from '../utils/cache';
const TrashIcon = () => (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke={theme.colors.error} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</Svg>
);
const InfoIcon = () => (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke={theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<Path d="M12 12h.01M12 16h.01" />
</Svg>
);
const ChevronRight = () => (
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke={theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M9 18l6-6-6-6" />
</Svg>
);
interface SettingItemProps {
title: string;
subtitle?: string;
rightElement?: React.ReactNode;
onPress?: () => void;
showChevron?: boolean;
isDanger?: boolean;
}
const SettingItem: React.FC<SettingItemProps> = ({
title,
subtitle,
rightElement,
onPress,
showChevron = false,
isDanger = false,
}) => (
<TouchableOpacity
style={styles.settingItem}
onPress={onPress}
disabled={!onPress}
>
<View style={styles.settingItemLeft}>
<Text style={[styles.settingTitle, isDanger && styles.settingTitleDanger]}>
{title}
</Text>
{subtitle && (
<Text style={styles.settingSubtitle}>{subtitle}</Text>
)}
</View>
{rightElement}
{showChevron && <ChevronRight />}
</TouchableOpacity>
);
interface SettingsScreenProps {
cacheOnly: boolean;
resumePlaybackEnabled: boolean;
playlistFiles: string[];
currentPlaylistFile: string;
cachedTrackCount: number;
trackCount: number;
onCacheOnlyChange: (value: boolean) => Promise<void>;
onResumePlaybackChange: (value: boolean) => Promise<void>;
onCacheChanged: () => Promise<void> | void;
onPlaylistChange: (fileName: string) => Promise<void>;
}
export const SettingsScreen: React.FC<SettingsScreenProps> = ({
cacheOnly,
resumePlaybackEnabled,
playlistFiles,
currentPlaylistFile,
cachedTrackCount,
trackCount,
onCacheOnlyChange,
onResumePlaybackChange,
onCacheChanged,
onPlaylistChange,
}) => {
const [cacheSize, setCacheSize] = useState<string>('0 B');
const [isLoading, setIsLoading] = useState(false);
const [playlistExpanded, setPlaylistExpanded] = useState(false);
const [isSwitchingPlaylist, setIsSwitchingPlaylist] = useState(false);
const loadCacheSize = async () => {
try {
const size = await Cache.getCacheSize();
setCacheSize(Cache.formatSize(size));
} catch (error) {
console.error('Failed to get cache size:', error);
}
};
const toggleCacheOnly = async (value: boolean) => {
try {
await onCacheOnlyChange(value);
} catch (error) {
console.error('Failed to save setting:', error);
Alert.alert('错误', '保存设置失败');
}
};
const toggleResumePlayback = async (value: boolean) => {
try {
await onResumePlaybackChange(value);
} catch (error) {
console.error('Failed to save resume playback setting:', error);
Alert.alert('错误', '保存设置失败');
}
};
useEffect(() => {
loadCacheSize();
}, []);
const handlePlaylistPress = async (fileName: string) => {
if (fileName === currentPlaylistFile || isSwitchingPlaylist) {
setPlaylistExpanded(false);
return;
}
setIsSwitchingPlaylist(true);
try {
await onPlaylistChange(fileName);
setPlaylistExpanded(false);
} catch (error) {
console.error('切换歌单失败:', error);
} finally {
setIsSwitchingPlaylist(false);
}
};
const handleClearCache = () => {
Alert.alert(
'清除缓存',
'确定要清除所有缓存的音频文件吗?这不会删除歌曲列表。',
[
{ text: '取消', style: 'cancel' },
{
text: '清除',
style: 'destructive',
onPress: async () => {
setIsLoading(true);
try {
await Cache.clearAllCache();
await onCacheChanged();
await loadCacheSize();
Alert.alert('完成', '缓存已清除');
} catch (error) {
Alert.alert('错误', '清除缓存失败');
} finally {
setIsLoading(false);
}
},
},
]
);
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Text style={styles.headerTitle}>设置</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>播放设置</Text>
<SettingItem
title="当前歌单"
subtitle={currentPlaylistFile}
onPress={() => setPlaylistExpanded((prev) => !prev)}
rightElement={
isSwitchingPlaylist
? <ActivityIndicator size="small" color={theme.colors.primary} />
: <Text style={styles.settingMeta}>{cachedTrackCount}/{trackCount} 已缓存</Text>
}
showChevron={!isSwitchingPlaylist}
/>
{playlistExpanded && (
<View style={styles.playlistOptions}>
{playlistFiles.map((fileName) => {
const isActive = fileName === currentPlaylistFile;
return (
<TouchableOpacity
key={fileName}
style={[styles.playlistOption, isActive && styles.playlistOptionActive]}
onPress={() => { void handlePlaylistPress(fileName); }}
disabled={isSwitchingPlaylist}
>
<Text
style={[styles.playlistOptionText, isActive && styles.playlistOptionTextActive]}
numberOfLines={2}
>
{fileName}
</Text>
{isActive && <Text style={styles.playlistOptionBadge}>当前</Text>}
</TouchableOpacity>
);
})}
<Text style={styles.playlistHint}>
自动读取所有以 playlist.json 结尾的歌单,并记住你上次选择。
</Text>
</View>
)}
<View style={styles.settingItem}>
<View style={styles.settingItemLeft}>
<Text style={styles.settingTitle}>记住上次位置继续听</Text>
<Text style={styles.settingSubtitle}>默认开启,下次打开时从上次暂停的位置继续播放</Text>
</View>
<Switch
value={resumePlaybackEnabled}
onValueChange={toggleResumePlayback}
trackColor={{ false: theme.colors.surface2, true: theme.colors.primarySoft }}
thumbColor={resumePlaybackEnabled ? theme.colors.primary : theme.colors.textSecondary}
/>
</View>
<View style={styles.settingItem}>
<View style={styles.settingItemLeft}>
<Text style={styles.settingTitle}>只用缓存听歌曲</Text>
<Text style={styles.settingSubtitle}>节约流量,只播放已缓存的歌曲</Text>
</View>
<Switch
value={cacheOnly}
onValueChange={toggleCacheOnly}
trackColor={{ false: theme.colors.surface2, true: theme.colors.primarySoft }}
thumbColor={cacheOnly ? theme.colors.primary : theme.colors.textSecondary}
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>缓存管理</Text>
<SettingItem
title="已缓存大小"
subtitle={cacheSize}
rightElement={<InfoIcon />}
/>
<SettingItem
title="清除所有缓存"
onPress={handleClearCache}
isDanger={true}
rightElement={<TrashIcon />}
/>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>关于</Text>
<View style={styles.aboutCard}>
<Text style={styles.aboutTitle}>观空禅音</Text>
<Text style={styles.aboutEnglish}>ZenVoid</Text>
<Text style={styles.aboutBody}>
这不是一款为了喧闹和效率而设计的播放器。它更像一处留白,让人愿意慢下来,听见一点安静。
</Text>
<Text style={styles.aboutBody}>
“ZenVoid”和“观空”都不是标准答案,只是我们尽力靠近这种感受的一种表达。希望它陪你在日常里,留一点空,听一点静。
</Text>
</View>
<SettingItem
title="版本"
subtitle="1.0.0"
/>
</View>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
header: {
paddingHorizontal: theme.spacing.md,
paddingVertical: theme.spacing.sm,
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: theme.colors.text,
},
section: {
marginTop: theme.spacing.md,
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
color: theme.colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
paddingHorizontal: theme.spacing.md,
marginBottom: theme.spacing.xs,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: theme.spacing.md,
paddingHorizontal: theme.spacing.md,
backgroundColor: theme.colors.surface,
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.colors.border,
},
settingItemLeft: {
flex: 1,
},
settingTitle: {
fontSize: 16,
color: theme.colors.text,
},
settingTitleDanger: {
color: theme.colors.error,
},
settingSubtitle: {
fontSize: 14,
color: theme.colors.textSecondary,
marginTop: 2,
},
settingMeta: {
fontSize: 12,
color: theme.colors.primary,
marginRight: theme.spacing.xs,
},
playlistOptions: {
marginTop: 1,
paddingHorizontal: theme.spacing.md,
paddingTop: theme.spacing.sm,
paddingBottom: theme.spacing.md,
backgroundColor: theme.colors.surface,
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.colors.border,
gap: theme.spacing.xs,
},
playlistOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: theme.spacing.sm,
paddingHorizontal: theme.spacing.sm,
paddingVertical: theme.spacing.sm,
borderRadius: theme.borderRadius.md,
backgroundColor: 'rgba(255,255,255,0.03)',
},
playlistOptionActive: {
borderWidth: 1,
borderColor: theme.colors.primary,
backgroundColor: theme.colors.primarySoft,
},
playlistOptionText: {
flex: 1,
fontSize: 14,
color: theme.colors.text,
},
playlistOptionTextActive: {
color: theme.colors.primary,
fontWeight: '600',
},
playlistOptionBadge: {
fontSize: 12,
color: theme.colors.primary,
},
playlistHint: {
marginTop: theme.spacing.xs,
fontSize: 12,
lineHeight: 18,
color: theme.colors.textSecondary,
},
aboutCard: {
marginHorizontal: theme.spacing.md,
marginBottom: theme.spacing.xs,
paddingHorizontal: theme.spacing.md,
paddingVertical: theme.spacing.md,
backgroundColor: theme.colors.surface,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
},
aboutTitle: {
fontSize: 18,
fontWeight: '600',
color: theme.colors.text,
},
aboutEnglish: {
marginTop: 4,
fontSize: 13,
letterSpacing: 0.8,
color: theme.colors.primary,
},
aboutBody: {
marginTop: theme.spacing.sm,
fontSize: 14,
lineHeight: 22,
color: theme.colors.textSecondary,
},
});
+46
View File
@@ -0,0 +1,46 @@
import { TextStyle, ViewStyle } from 'react-native';
interface Typography {
[key: string]: TextStyle;
}
export const theme = {
colors: {
background: '#060612',
backgroundMid: '#0a0a1e',
surface: 'rgba(255,255,255,0.06)',
surface2: 'rgba(255,255,255,0.10)',
border: 'rgba(255,255,255,0.08)',
primary: '#e8b86d',
primaryDim: '#a07840',
primaryGlow: 'rgba(232,184,109,0.15)',
primarySoft: 'rgba(232,184,109,0.08)',
text: '#e8e6f0',
textSecondary: '#7a7890',
success: '#4ade80',
warning: '#fbbf24',
error: '#ef4444',
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
},
borderRadius: {
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 24,
},
typography: {
h1: { fontSize: 28, fontWeight: 'bold' } as TextStyle,
h2: { fontSize: 24, fontWeight: 'bold' } as TextStyle,
h3: { fontSize: 20, fontWeight: '600' } as TextStyle,
body: { fontSize: 16, fontWeight: 'normal' } as TextStyle,
bodySmall: { fontSize: 14, fontWeight: 'normal' } as TextStyle,
caption: { fontSize: 12, fontWeight: 'normal' } as TextStyle,
},
};
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx"
]
}
+27
View File
@@ -0,0 +1,27 @@
export interface Track {
title: string;
artist: string;
src: string;
cover?: string;
}
export type RepeatMode = 'none' | 'one' | 'all';
export interface PlayerState {
playlist: Track[];
currentIndex: number;
isPlaying: boolean;
isShuffle: boolean;
repeatMode: RepeatMode;
position: number;
duration: number;
isLoading: boolean;
}
export interface CacheInfo {
[src: string]: {
path: string;
size: number;
downloadedAt: number;
};
}
+276
View File
@@ -0,0 +1,276 @@
import * as FileSystem from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { CacheInfo, Track } from '../types';
const CACHE_DIR = `${FileSystem.documentDirectory}audio_cache/`;
const CACHE_INFO_KEY = '@zen_box_cache_info';
const CACHE_ONLY_KEY = '@zen_box_cache_only';
let cacheInfo: CacheInfo = {};
export const initCache = async (): Promise<void> => {
try {
// 确保缓存目录存在
const dirInfo = await FileSystem.getInfoAsync(CACHE_DIR);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
}
// 加载缓存信息
const cachedInfo = await AsyncStorage.getItem(CACHE_INFO_KEY);
if (cachedInfo) {
cacheInfo = JSON.parse(cachedInfo);
}
} catch (error) {
console.error('初始化缓存失败:', error);
}
};
export const getCacheInfo = (): CacheInfo => {
return { ...cacheInfo };
};
export interface DownloadAllResult {
successCount: number;
skippedCount: number;
failedTracks: string[];
total: number;
}
const generateCacheKey = (url: string): string => {
// 简单的 URL 哈希
let hash = 0;
for (let i = 0; i < url.length; i++) {
const char = url.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36) + '.mp3';
};
export const isCached = (url: string): boolean => {
return !!cacheInfo[url];
};
export const getCachedPath = (url: string): string | null => {
if (cacheInfo[url]) {
return cacheInfo[url].path;
}
return null;
};
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// 安全地编码 URL 中的路径部分,保留协议、域名等
const encodeUrlPath = (url: string): string => {
try {
const parsedUrl = new URL(url);
// 只编码路径部分,不要编码整个 URL
const pathParts = parsedUrl.pathname.split('/');
const encodedPathParts = pathParts.map((part, index) => {
// 不要编码空字符串(开头的斜杠)
if (part === '') return part;
// 使用 encodeURIComponent 编码每个路径段
return encodeURIComponent(decodeURIComponent(part));
});
parsedUrl.pathname = encodedPathParts.join('/');
const finalUrl = parsedUrl.toString();
console.log(`[URL] 原始: ${url}`);
console.log(`[URL] 编码后: ${finalUrl}`);
return finalUrl;
} catch (error) {
console.error('[URL] 编码失败:', error);
// 如果解析失败,尝试简单编码
try {
const encoded = encodeURI(url);
console.log(`[URL] 简单编码: ${encoded}`);
return encoded;
} catch {
return url;
}
}
};
export const downloadAndCache = async (
url: string,
onProgress?: (progress: number) => void
): Promise<string> => {
const maxRetries = 3;
let retryCount = 0;
// 使用原始 URL 作为缓存键,但下载时使用编码后的 URL
const cacheKey = generateCacheKey(url);
const cachePath = `${CACHE_DIR}${cacheKey}`;
// 对 URL 进行编码,特别处理中文路径
const encodedUrl = encodeUrlPath(url);
while (retryCount < maxRetries) {
try {
console.log(`[下载] 开始下载: ${encodedUrl}`);
// 添加更多下载选项
const downloadResumable = FileSystem.createDownloadResumable(
encodedUrl,
cachePath,
{
// 设置一些常用的请求头,兼容 OSS
headers: {
'User-Agent': 'ZenBox/1.0',
'Accept': 'audio/mpeg,audio/*;q=0.9,*/*;q=0.8',
},
},
(downloadProgress) => {
if (onProgress && downloadProgress.totalBytesWritten > 0 && downloadProgress.totalBytesExpectedToWrite > 0) {
const progress = downloadProgress.totalBytesWritten / downloadProgress.totalBytesExpectedToWrite;
onProgress(progress);
}
}
);
const downloadResult = await downloadResumable.downloadAsync();
if (downloadResult && downloadResult.status === 200) {
console.log(`[下载] 成功: ${encodedUrl}`);
const fileInfo = await FileSystem.getInfoAsync(cachePath);
const size = fileInfo.exists ? (fileInfo as any).size || 0 : 0;
cacheInfo[url] = {
path: cachePath,
size,
downloadedAt: Date.now(),
};
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
return cachePath;
}
console.error(`[下载] 失败,状态码: ${downloadResult?.status}`);
throw new Error(`下载失败,状态码: ${downloadResult?.status}`);
} catch (error) {
retryCount++;
console.error(`[下载] 尝试 ${retryCount}/${maxRetries} 失败:`, error);
if (retryCount >= maxRetries) {
console.error(`[下载] 达到最大重试次数,放弃: ${encodedUrl}`);
throw error;
}
// 指数退避重试
const backoffTime = Math.min(1000 * Math.pow(2, retryCount - 1), 5000);
console.log(`[下载] ${backoffTime}ms 后重试...`);
await sleep(backoffTime);
}
}
throw new Error('下载失败,已达到最大重试次数');
};
export const deleteCache = async (url: string): Promise<void> => {
try {
if (cacheInfo[url]) {
const path = cacheInfo[url].path;
await FileSystem.deleteAsync(path, { idempotent: true });
delete cacheInfo[url];
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
}
} catch (error) {
console.error('删除缓存失败:', error);
}
};
export const clearAllCache = async (): Promise<void> => {
try {
// 删除所有缓存文件
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
// 清空缓存信息
cacheInfo = {};
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
} catch (error) {
console.error('清除缓存失败:', error);
}
};
export const getCacheSize = async (): Promise<number> => {
try {
let totalSize = 0;
for (const url in cacheInfo) {
totalSize += cacheInfo[url].size;
}
return totalSize;
} catch (error) {
console.error('获取缓存大小失败:', error);
return 0;
}
};
export const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
export const downloadAllTracks = async (
tracks: Track[],
onProgress?: (completed: number, total: number, trackTitle: string) => void
): Promise<DownloadAllResult> => {
let successCount = 0;
let skippedCount = 0;
const failedTracks: string[] = [];
let completedCount = 0;
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (onProgress) {
onProgress(completedCount, tracks.length, track.title);
}
if (!isCached(track.src)) {
try {
await downloadAndCache(track.src);
successCount += 1;
} catch (error) {
console.error(`Failed to download ${track.title}:`, error);
failedTracks.push(track.title);
}
} else {
skippedCount += 1;
}
completedCount += 1;
}
if (onProgress) {
onProgress(tracks.length, tracks.length, '完成');
}
return {
successCount,
skippedCount,
failedTracks,
total: tracks.length,
};
};
export const getCacheOnlySetting = async (): Promise<boolean> => {
try {
const value = await AsyncStorage.getItem(CACHE_ONLY_KEY);
// 默认返回 true
return value !== 'false';
} catch (error) {
console.error('Failed to get cache only setting:', error);
return true;
}
};
export const setCacheOnlySetting = async (value: boolean): Promise<void> => {
try {
await AsyncStorage.setItem(CACHE_ONLY_KEY, value.toString());
} catch (error) {
console.error('Failed to save cache only setting:', error);
throw error;
}
};