feat(zen-box): 新增云端配置同步功能,完善应用构建与配置

本次提交实现了观空禅音应用的核心云端同步功能,包括:
1.  添加用户登录验证接口与本地存储管理
2.  新增云端配置存储与拉取接口,支持跨设备同步歌单、播放进度等设置
3.  新增登录页面与设置页账号管理模块
4.  更新依赖包版本与安卓、iOS打包配置
5.  优化缓存下载逻辑,修复OSS链接编码问题
6.  新增离线模式支持,自动过滤无缓存歌单
7.  新增歌曲排序偏好本地与云端同步功能
This commit is contained in:
yangxiangyuan
2026-07-11 10:57:02 +08:00
parent 33625e3438
commit 6a043687f5
18 changed files with 880 additions and 66 deletions
+196 -13
View File
@@ -1,15 +1,12 @@
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 { Track, CacheInfo, SortMode } from '../types';
import * as Cache from '../utils/cache';
const SORT_PREF_KEY = '@zen_box_sort_pref';
type SortMode = 'original' | 'asc' | 'desc';
import * as API from '../api';
interface PlaylistScreenProps {
tracks: Track[];
@@ -17,10 +14,15 @@ interface PlaylistScreenProps {
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 };
}
@@ -142,10 +144,15 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
downloadingTrackSrcs,
currentIndex,
isPlaying,
playlistFiles,
currentPlaylistFile,
offlineAvailablePlaylists,
isOffline,
onPlaybackOrderChange,
onSelectTrack,
onDownloadTrack,
onDownloadAll,
onPlaylistChange,
isDownloading = false,
downloadProgress,
}) => {
@@ -157,10 +164,8 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
useEffect(() => {
const loadSortPref = async () => {
try {
const saved = await AsyncStorage.getItem(SORT_PREF_KEY);
if (saved && ['original', 'asc', 'desc'].includes(saved)) {
setSortMode(saved as SortMode);
}
const saved = await API.getSortPreference();
setSortMode(saved);
} catch (error) {
console.error('加载排序偏好失败:', error);
}
@@ -171,7 +176,7 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
// 保存排序偏好
const saveSortPref = async (newMode: SortMode) => {
try {
await AsyncStorage.setItem(SORT_PREF_KEY, newMode);
await API.saveSortPreference(newMode);
} catch (error) {
console.error('保存排序偏好失败:', error);
}
@@ -241,17 +246,112 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
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>
<Text style={styles.headerSubtitle}>
{cachedCount}/{tracks.length} 首已缓存
</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}>
@@ -343,6 +443,10 @@ const styles = StyleSheet.create({
headerLeft: {
flex: 1,
},
headerSubtitleRow: {
flexDirection: 'row',
alignItems: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
@@ -353,6 +457,85 @@ const styles = StyleSheet.create({
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,