feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user