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 Svg, { Path, Circle } from 'react-native-svg'; import { MarqueeText } from '../components/MarqueeText'; import { theme } from '../theme'; import { Track, CacheInfo, SortMode } from '../types'; import * as Cache from '../utils/cache'; import * as API from '../api'; interface PlaylistScreenProps { tracks: Track[]; cacheInfo: CacheInfo; downloadingTrackSrcs: string[]; currentIndex: number; isPlaying: boolean; playlistFiles: string[]; currentPlaylistFile: string; offlineAvailablePlaylists?: string[]; isOffline?: boolean; onPlaybackOrderChange: (order: number[]) => void; onSelectTrack: (index: number) => void; onDownloadTrack: (track: Track) => Promise; onDownloadAll?: () => void; onPlaylistChange?: (fileName: string) => 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, playlistFiles, currentPlaylistFile, offlineAvailablePlaylists, isOffline, onPlaybackOrderChange, onSelectTrack, onDownloadTrack, onDownloadAll, onPlaylistChange, 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 API.getSortPreference(); setSortMode(saved); } catch (error) { console.error('加载排序偏好失败:', error); } }; loadSortPref(); }, []); // 保存排序偏好 const saveSortPref = async (newMode: SortMode) => { try { await API.saveSortPreference(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); }; // 离线时使用过滤后的歌单列表 const displayPlaylistFiles = isOffline ? (offlineAvailablePlaylists || []) : playlistFiles; // 计算每个歌单的缓存数 const [playlistCacheCounts, setPlaylistCacheCounts] = useState<{ [file: string]: { cached: number; total: number } }>({}); useEffect(() => { const counts: { [file: string]: { cached: number; total: number } } = {}; for (const file of displayPlaylistFiles) { const snapshotTracks = file === currentPlaylistFile ? tracks : []; const total = snapshotTracks.length; const cached = snapshotTracks.filter(t => !!cacheInfo[t.src]).length; counts[file] = { cached, total }; } setPlaylistCacheCounts(counts); }, [displayPlaylistFiles, currentPlaylistFile, tracks, cacheInfo]); const [playlistExpanded, setPlaylistExpanded] = useState(false); const [isSwitching, setIsSwitching] = useState(false); const handlePlaylistPress = async (fileName: string) => { if (fileName === currentPlaylistFile || isSwitching || !onPlaylistChange) { setPlaylistExpanded(false); return; } setIsSwitching(true); try { await onPlaylistChange(fileName); setPlaylistExpanded(false); } catch (error) { console.error('切换歌单失败:', error); } finally { setIsSwitching(false); } }; return ( 播放列表 {cachedCount}/{tracks.length} 首已缓存 {isOffline && ( 离线模式 )} {/* 歌单切换区域 */} {displayPlaylistFiles.length > 1 && ( setPlaylistExpanded(prev => !prev)} disabled={isSwitching} > 当前歌单:{currentPlaylistFile} {isSwitching ? ( ) : ( {playlistExpanded ? '▲' : '▼'} )} {playlistExpanded && ( {displayPlaylistFiles.map((fileName) => { const isActive = fileName === currentPlaylistFile; const count = playlistCacheCounts[fileName]; return ( { void handlePlaylistPress(fileName); }} disabled={isSwitching} > {fileName} {count && ( {count.cached}/{count.total} )} {isActive && 当前} ); })} {isOffline && ( 离线模式下,仅显示有缓存歌曲的歌单。 )} )} )} {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, }, headerSubtitleRow: { flexDirection: 'row', alignItems: 'center', }, headerTitle: { fontSize: 18, fontWeight: '600', color: theme.colors.text, marginBottom: 3, }, headerSubtitle: { fontSize: 13, color: theme.colors.textSecondary, }, offlineBadge: { fontSize: 12, color: theme.colors.primary, fontWeight: '600', marginLeft: theme.spacing.sm, }, playlistSwitchSection: { paddingHorizontal: theme.spacing.md, paddingBottom: theme.spacing.sm, }, playlistSwitchBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: theme.spacing.sm, paddingHorizontal: theme.spacing.md, backgroundColor: theme.colors.surface, borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, }, playlistSwitchText: { fontSize: 14, color: theme.colors.text, }, playlistSwitchArrow: { fontSize: 12, color: theme.colors.textSecondary, }, playlistOptions: { marginTop: theme.spacing.xs, paddingVertical: theme.spacing.xs, paddingHorizontal: theme.spacing.sm, backgroundColor: theme.colors.surface, borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, gap: theme.spacing.xs, }, playlistOption: { flexDirection: 'row', alignItems: 'center', gap: theme.spacing.sm, paddingHorizontal: theme.spacing.sm, paddingVertical: theme.spacing.sm, borderRadius: theme.borderRadius.sm, 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', }, playlistCacheBadge: { fontSize: 12, color: theme.colors.textSecondary, }, playlistCacheBadgeActive: { color: theme.colors.primary, }, playlistOptionBadgeText: { fontSize: 12, color: theme.colors.primary, }, playlistHint: { marginTop: theme.spacing.xs, fontSize: 12, lineHeight: 18, 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', }, });