feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
+503
View File
@@ -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',
},
});