feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, ActivityIndicator } 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;
|
||||
onCacheOnlyChange: (value: boolean) => Promise<void>;
|
||||
onResumePlaybackChange: (value: boolean) => Promise<void>;
|
||||
onCacheChanged: () => Promise<void> | void;
|
||||
onPlaylistChange: (fileName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
||||
cacheOnly,
|
||||
resumePlaybackEnabled,
|
||||
playlistFiles,
|
||||
currentPlaylistFile,
|
||||
cachedTrackCount,
|
||||
trackCount,
|
||||
onCacheOnlyChange,
|
||||
onResumePlaybackChange,
|
||||
onCacheChanged,
|
||||
onPlaylistChange,
|
||||
}) => {
|
||||
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);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>设置</Text>
|
||||
</View>
|
||||
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user