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; 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 ( ); }; const DownloadIcon = () => ( ); const CheckIcon = () => ( ); const ArrowUpIcon = () => ( ); const ArrowDownIcon = () => ( ); 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 }) => ( {isCurrent && isPlaying ? ( ) : ( {index + 1} )} {track.artist} {isCached ? ( ) : isDownloading ? ( ) : ( )} ); export const PlaylistScreen: React.FC = ({ tracks, cacheInfo, downloadingTrackSrcs, currentIndex, isPlaying, onPlaybackOrderChange, onSelectTrack, onDownloadTrack, onDownloadAll, isDownloading = false, downloadProgress, }) => { const [sortMode, setSortMode] = useState('original'); const [sortedTracks, setSortedTracks] = useState(tracks); const [originalTracks, setOriginalTracks] = useState(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 ( 播放列表 {cachedCount}/{tracks.length} 首已缓存 {isDownloading && downloadProgress && downloadProgress.total > 0 && ( {downloadProgress.title || '正在下载...'} {progressPercent}% {progressPercent !== null && ( )} {downloadProgress.completed}/{downloadProgress.total} )} item.src} contentContainerStyle={styles.listContent} renderItem={({ item, index }) => ( onSelectTrack(getOriginalIndex(index))} onDownload={() => handleDownloadTrack(item)} /> )} ItemSeparatorComponent={() => } /> {/* 底部操作栏 */} setSort('asc')} > 📋 名称正序 setSort('desc')} > 📋 名称倒序 {isDownloading ? ( ) : ( ⬇️ 一键下载所有 )} ); }; 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', }, });