Files
yangxiangyuan 6a043687f5 feat(zen-box): 新增云端配置同步功能,完善应用构建与配置
本次提交实现了观空禅音应用的核心云端同步功能,包括:
1.  添加用户登录验证接口与本地存储管理
2.  新增云端配置存储与拉取接口,支持跨设备同步歌单、播放进度等设置
3.  新增登录页面与设置页账号管理模块
4.  更新依赖包版本与安卓、iOS打包配置
5.  优化缓存下载逻辑,修复OSS链接编码问题
6.  新增离线模式支持,自动过滤无缓存歌单
7.  新增歌曲排序偏好本地与云端同步功能
2026-07-11 10:57:02 +08:00

687 lines
20 KiB
TypeScript
Raw Permalink 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.
This file contains Unicode characters that might be confused with other characters. 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 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<void>;
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 (
<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,
playlistFiles,
currentPlaylistFile,
offlineAvailablePlaylists,
isOffline,
onPlaybackOrderChange,
onSelectTrack,
onDownloadTrack,
onDownloadAll,
onPlaylistChange,
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 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 (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<View style={styles.headerLeft}>
<Text style={styles.headerTitle}>播放列表</Text>
<View style={styles.headerSubtitleRow}>
<Text style={styles.headerSubtitle}>
{cachedCount}/{tracks.length} 首已缓存
</Text>
{isOffline && (
<Text style={styles.offlineBadge}> 离线模式</Text>
)}
</View>
</View>
</View>
{/* 歌单切换区域 */}
{displayPlaylistFiles.length > 1 && (
<View style={styles.playlistSwitchSection}>
<TouchableOpacity
style={styles.playlistSwitchBtn}
onPress={() => setPlaylistExpanded(prev => !prev)}
disabled={isSwitching}
>
<Text style={styles.playlistSwitchText}>
当前歌单:{currentPlaylistFile}
</Text>
{isSwitching ? (
<ActivityIndicator size="small" color={theme.colors.primary} />
) : (
<Text style={styles.playlistSwitchArrow}>{playlistExpanded ? '▲' : '▼'}</Text>
)}
</TouchableOpacity>
{playlistExpanded && (
<View style={styles.playlistOptions}>
{displayPlaylistFiles.map((fileName) => {
const isActive = fileName === currentPlaylistFile;
const count = playlistCacheCounts[fileName];
return (
<TouchableOpacity
key={fileName}
style={[styles.playlistOption, isActive && styles.playlistOptionActive]}
onPress={() => { void handlePlaylistPress(fileName); }}
disabled={isSwitching}
>
<Text
style={[styles.playlistOptionText, isActive && styles.playlistOptionTextActive]}
numberOfLines={2}
>
{fileName}
</Text>
{count && (
<Text style={[styles.playlistCacheBadge, isActive && styles.playlistCacheBadgeActive]}>
{count.cached}/{count.total}
</Text>
)}
{isActive && <Text style={styles.playlistOptionBadgeText}>当前</Text>}
</TouchableOpacity>
);
})}
{isOffline && (
<Text style={styles.playlistHint}>
离线模式下,仅显示有缓存歌曲的歌单。
</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,
},
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',
},
});