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