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

481 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, ActivityIndicator, ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Path } from 'react-native-svg';
import { theme } from '../theme';
import * as Cache from '../utils/cache';
const TrashIcon = () => (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke={theme.colors.error} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</Svg>
);
const InfoIcon = () => (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke={theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<Path d="M12 12h.01M12 16h.01" />
</Svg>
);
const ChevronRight = () => (
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke={theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M9 18l6-6-6-6" />
</Svg>
);
interface SettingItemProps {
title: string;
subtitle?: string;
rightElement?: React.ReactNode;
onPress?: () => void;
showChevron?: boolean;
isDanger?: boolean;
}
const SettingItem: React.FC<SettingItemProps> = ({
title,
subtitle,
rightElement,
onPress,
showChevron = false,
isDanger = false,
}) => (
<TouchableOpacity
style={styles.settingItem}
onPress={onPress}
disabled={!onPress}
>
<View style={styles.settingItemLeft}>
<Text style={[styles.settingTitle, isDanger && styles.settingTitleDanger]}>
{title}
</Text>
{subtitle && (
<Text style={styles.settingSubtitle}>{subtitle}</Text>
)}
</View>
{rightElement}
{showChevron && <ChevronRight />}
</TouchableOpacity>
);
interface SettingsScreenProps {
cacheOnly: boolean;
resumePlaybackEnabled: boolean;
playlistFiles: string[];
currentPlaylistFile: string;
cachedTrackCount: number;
trackCount: number;
loggedInUser: string | null;
onCacheOnlyChange: (value: boolean) => Promise<void>;
onResumePlaybackChange: (value: boolean) => Promise<void>;
onCacheChanged: () => Promise<void> | void;
onPlaylistChange: (fileName: string) => Promise<void>;
onLoginPress: () => void;
onLogout: () => void;
}
export const SettingsScreen: React.FC<SettingsScreenProps> = ({
cacheOnly,
resumePlaybackEnabled,
playlistFiles,
currentPlaylistFile,
cachedTrackCount,
trackCount,
loggedInUser,
onCacheOnlyChange,
onResumePlaybackChange,
onCacheChanged,
onPlaylistChange,
onLoginPress,
onLogout,
}) => {
const [cacheSize, setCacheSize] = useState<string>('0 B');
const [isLoading, setIsLoading] = useState(false);
const [playlistExpanded, setPlaylistExpanded] = useState(false);
const [isSwitchingPlaylist, setIsSwitchingPlaylist] = useState(false);
const loadCacheSize = async () => {
try {
const size = await Cache.getCacheSize();
setCacheSize(Cache.formatSize(size));
} catch (error) {
console.error('Failed to get cache size:', error);
}
};
const toggleCacheOnly = async (value: boolean) => {
try {
await onCacheOnlyChange(value);
} catch (error) {
console.error('Failed to save setting:', error);
Alert.alert('错误', '保存设置失败');
}
};
const toggleResumePlayback = async (value: boolean) => {
try {
await onResumePlaybackChange(value);
} catch (error) {
console.error('Failed to save resume playback setting:', error);
Alert.alert('错误', '保存设置失败');
}
};
useEffect(() => {
loadCacheSize();
}, []);
const handlePlaylistPress = async (fileName: string) => {
if (fileName === currentPlaylistFile || isSwitchingPlaylist) {
setPlaylistExpanded(false);
return;
}
setIsSwitchingPlaylist(true);
try {
await onPlaylistChange(fileName);
setPlaylistExpanded(false);
} catch (error) {
console.error('切换歌单失败:', error);
} finally {
setIsSwitchingPlaylist(false);
}
};
const handleClearCache = () => {
Alert.alert(
'清除缓存',
'确定要清除所有缓存的音频文件吗?这不会删除歌曲列表。',
[
{ text: '取消', style: 'cancel' },
{
text: '清除',
style: 'destructive',
onPress: async () => {
setIsLoading(true);
try {
await Cache.clearAllCache();
await onCacheChanged();
await loadCacheSize();
Alert.alert('完成', '缓存已清除');
} catch (error) {
Alert.alert('错误', '清除缓存失败');
} finally {
setIsLoading(false);
}
},
},
]
);
};
const handleLogout = () => {
Alert.alert(
'退出登录',
'确定要退出登录吗?退出后云端配置将不再同步。',
[
{ text: '取消', style: 'cancel' },
{
text: '退出',
style: 'destructive',
onPress: () => {
onLogout();
Alert.alert('已退出', '已退出登录');
},
},
]
);
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Text style={styles.headerTitle}>设置</Text>
</View>
<ScrollView style={styles.scrollContent} contentContainerStyle={styles.scrollContentInner}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>播放设置</Text>
<SettingItem
title="当前歌单"
subtitle={currentPlaylistFile}
onPress={() => setPlaylistExpanded((prev) => !prev)}
rightElement={
isSwitchingPlaylist
? <ActivityIndicator size="small" color={theme.colors.primary} />
: <Text style={styles.settingMeta}>{cachedTrackCount}/{trackCount} 已缓存</Text>
}
showChevron={!isSwitchingPlaylist}
/>
{playlistExpanded && (
<View style={styles.playlistOptions}>
{playlistFiles.map((fileName) => {
const isActive = fileName === currentPlaylistFile;
return (
<TouchableOpacity
key={fileName}
style={[styles.playlistOption, isActive && styles.playlistOptionActive]}
onPress={() => { void handlePlaylistPress(fileName); }}
disabled={isSwitchingPlaylist}
>
<Text
style={[styles.playlistOptionText, isActive && styles.playlistOptionTextActive]}
numberOfLines={2}
>
{fileName}
</Text>
{isActive && <Text style={styles.playlistOptionBadge}>当前</Text>}
</TouchableOpacity>
);
})}
<Text style={styles.playlistHint}>
自动读取所有以 playlist.json 结尾的歌单,并记住你上次选择。
</Text>
</View>
)}
<View style={styles.settingItem}>
<View style={styles.settingItemLeft}>
<Text style={styles.settingTitle}>记住上次位置继续听</Text>
<Text style={styles.settingSubtitle}>默认开启,下次打开时从上次暂停的位置继续播放</Text>
</View>
<Switch
value={resumePlaybackEnabled}
onValueChange={toggleResumePlayback}
trackColor={{ false: theme.colors.surface2, true: theme.colors.primarySoft }}
thumbColor={resumePlaybackEnabled ? theme.colors.primary : theme.colors.textSecondary}
/>
</View>
<View style={styles.settingItem}>
<View style={styles.settingItemLeft}>
<Text style={styles.settingTitle}>只用缓存听歌曲</Text>
<Text style={styles.settingSubtitle}>节约流量,只播放已缓存的歌曲</Text>
</View>
<Switch
value={cacheOnly}
onValueChange={toggleCacheOnly}
trackColor={{ false: theme.colors.surface2, true: theme.colors.primarySoft }}
thumbColor={cacheOnly ? theme.colors.primary : theme.colors.textSecondary}
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>缓存管理</Text>
<SettingItem
title="已缓存大小"
subtitle={cacheSize}
rightElement={<InfoIcon />}
/>
<SettingItem
title="清除所有缓存"
onPress={handleClearCache}
isDanger={true}
rightElement={<TrashIcon />}
/>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>账号</Text>
{loggedInUser ? (
<>
<SettingItem
title="已登录"
subtitle={`当前账号:${loggedInUser}`}
rightElement={
<TouchableOpacity onPress={handleLogout} style={styles.logoutBtn}>
<Text style={styles.logoutText}>退出</Text>
</TouchableOpacity>
}
/>
<Text style={styles.syncHint}>
歌单选择、播放进度等配置已同步到云端,跨设备可用。
</Text>
</>
) : (
<SettingItem
title="登录以同步配置"
subtitle="跨设备同步歌单选择、播放进度等"
onPress={onLoginPress}
showChevron={true}
/>
)}
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>关于</Text>
<View style={styles.aboutCard}>
<Text style={styles.aboutTitle}>观空禅音</Text>
<Text style={styles.aboutEnglish}>ZenVoid</Text>
<Text style={styles.aboutBody}>
这不是一款为了喧闹和效率而设计的播放器。它更像一处留白,让人愿意慢下来,听见一点安静。
</Text>
<Text style={styles.aboutBody}>
“ZenVoid”和“观空”都不是标准答案,只是我们尽力靠近这种感受的一种表达。希望它陪你在日常里,留一点空,听一点静。
</Text>
</View>
<SettingItem
title="版本"
subtitle="1.0.0 (2)"
/>
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
scrollContent: {
flex: 1,
},
scrollContentInner: {
paddingBottom: theme.spacing.xl,
},
header: {
paddingHorizontal: theme.spacing.md,
paddingVertical: theme.spacing.sm,
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: theme.colors.text,
},
section: {
marginTop: theme.spacing.md,
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
color: theme.colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
paddingHorizontal: theme.spacing.md,
marginBottom: theme.spacing.xs,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: theme.spacing.md,
paddingHorizontal: theme.spacing.md,
backgroundColor: theme.colors.surface,
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.colors.border,
},
settingItemLeft: {
flex: 1,
},
settingTitle: {
fontSize: 16,
color: theme.colors.text,
},
settingTitleDanger: {
color: theme.colors.error,
},
settingSubtitle: {
fontSize: 14,
color: theme.colors.textSecondary,
marginTop: 2,
},
settingMeta: {
fontSize: 12,
color: theme.colors.primary,
marginRight: theme.spacing.xs,
},
playlistOptions: {
marginTop: 1,
paddingHorizontal: theme.spacing.md,
paddingTop: theme.spacing.sm,
paddingBottom: theme.spacing.md,
backgroundColor: theme.colors.surface,
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.colors.border,
gap: theme.spacing.xs,
},
playlistOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: theme.spacing.sm,
paddingHorizontal: theme.spacing.sm,
paddingVertical: theme.spacing.sm,
borderRadius: theme.borderRadius.md,
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',
},
playlistOptionBadge: {
fontSize: 12,
color: theme.colors.primary,
},
playlistHint: {
marginTop: theme.spacing.xs,
fontSize: 12,
lineHeight: 18,
color: theme.colors.textSecondary,
},
aboutCard: {
marginHorizontal: theme.spacing.md,
marginBottom: theme.spacing.xs,
paddingHorizontal: theme.spacing.md,
paddingVertical: theme.spacing.md,
backgroundColor: theme.colors.surface,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
},
aboutTitle: {
fontSize: 18,
fontWeight: '600',
color: theme.colors.text,
},
aboutEnglish: {
marginTop: 4,
fontSize: 13,
letterSpacing: 0.8,
color: theme.colors.primary,
},
aboutBody: {
marginTop: theme.spacing.sm,
fontSize: 14,
lineHeight: 22,
color: theme.colors.textSecondary,
},
logoutBtn: {
paddingHorizontal: theme.spacing.sm,
paddingVertical: theme.spacing.xs,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.error,
},
logoutText: {
fontSize: 13,
color: theme.colors.error,
},
syncHint: {
fontSize: 12,
lineHeight: 18,
color: theme.colors.textSecondary,
paddingHorizontal: theme.spacing.md,
paddingVertical: theme.spacing.xs,
},
});