Files
Toolbox/apps/app_zen_box/screens/PlaylistScreen.tsx
T

504 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
},
});