504 lines
14 KiB
TypeScript
504 lines
14 KiB
TypeScript
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',
|
||
},
|
||
});
|