feat: 暗黑模式 + 节流模式

- settingsStore: 移除 coverQuality,新增 darkMode / trafficSaving 布尔字段(AsyncStorage 持久化)
- utils/theme.ts: 新建 useTheme() hook,返回浅色/深色颜色表
- settings.tsx: 新增外观(浅色/深色)和流量(标准/节流)选项,移除封面图清晰度
- BigVideoCard: 节流模式跳过预加载、禁止自动播放;封面低画质;支持暗黑配色
- useVideoDetail: 节流模式下默认 qn=16(360p)
- VideoCard / LiveCard: 移除 coverQuality 依赖,改用 trafficSaving;支持暗黑配色
- index.tsx: 导航栏/搜索栏/列表背景跟随主题
- search.tsx: 页面背景/输入框/文字跟随主题
- downloads.tsx: 顶栏/背景/文字跟随主题
- _layout.tsx: StatusBar 随暗黑模式切换 light/dark
This commit is contained in:
Developer
2026-03-24 21:04:10 +08:00
parent aeaaa0af01
commit f2fc4693a6
12 changed files with 164 additions and 79 deletions

1
.gitignore vendored
View File

@@ -49,4 +49,5 @@ docs/
feature.md feature.md
livePlan.md livePlan.md
Promotion.md Promotion.md
wordsFilter.md
.env.local .env.local

View File

@@ -6,6 +6,7 @@ import { useEffect } from 'react';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useDownloadStore } from '../store/downloadStore'; import { useDownloadStore } from '../store/downloadStore';
import { useSettingsStore } from '../store/settingsStore'; import { useSettingsStore } from '../store/settingsStore';
import { useTheme } from '../utils/theme';
import { MiniPlayer } from '../components/MiniPlayer'; import { MiniPlayer } from '../components/MiniPlayer';
import * as Sentry from '@sentry/react-native'; import * as Sentry from '@sentry/react-native';
import { ErrorBoundary } from '@sentry/react-native'; import { ErrorBoundary } from '@sentry/react-native';
@@ -21,6 +22,7 @@ function RootLayout() {
const restore = useAuthStore(s => s.restore); const restore = useAuthStore(s => s.restore);
const loadDownloads = useDownloadStore(s => s.loadFromStorage); const loadDownloads = useDownloadStore(s => s.loadFromStorage);
const restoreSettings = useSettingsStore(s => s.restore); const restoreSettings = useSettingsStore(s => s.restore);
const darkMode = useSettingsStore(s => s.darkMode);
useEffect(() => { useEffect(() => {
restore(); restore();
@@ -31,7 +33,7 @@ function RootLayout() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<StatusBar style="dark" /> <StatusBar style={darkMode ? 'light' : 'dark'} />
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<ErrorBoundary fallback={<Text style={{ padding: 32, textAlign: 'center' }}> App</Text>}> <ErrorBoundary fallback={<Text style={{ padding: 32, textAlign: 'center' }}> App</Text>}>
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>

View File

@@ -27,9 +27,11 @@ function formatFileSize(bytes?: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
} }
import { proxyImageUrl } from '../utils/imageUrl'; import { proxyImageUrl } from '../utils/imageUrl';
import { useTheme } from '../utils/theme';
export default function DownloadsScreen() { export default function DownloadsScreen() {
const router = useRouter(); const router = useRouter();
const theme = useTheme();
const { tasks, loadFromStorage, removeTask } = useDownloadStore(); const { tasks, loadFromStorage, removeTask } = useDownloadStore();
const [playingUri, setPlayingUri] = useState<string | null>(null); const [playingUri, setPlayingUri] = useState<string | null>(null);
const [playingTitle, setPlayingTitle] = useState(''); const [playingTitle, setPlayingTitle] = useState('');
@@ -61,12 +63,12 @@ export default function DownloadsScreen() {
if (done.length > 0) sections.push({ title: '已下载', data: done }); if (done.length > 0) sections.push({ title: '已下载', data: done });
return ( return (
<SafeAreaView style={styles.safe}> <SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]}>
<View style={styles.topBar}> <View style={[styles.topBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}> <TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color="#212121" /> <Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.topTitle}></Text> <Text style={[styles.topTitle, { color: theme.text }]}></Text>
<View style={{ width: 32 }} /> <View style={{ width: 32 }} />
</View> </View>

View File

@@ -40,6 +40,7 @@ import {
} from "../utils/videoRows"; } from "../utils/videoRows";
import { BigVideoCard } from "../components/BigVideoCard"; import { BigVideoCard } from "../components/BigVideoCard";
import { FollowedLiveStrip } from "../components/FollowedLiveStrip"; import { FollowedLiveStrip } from "../components/FollowedLiveStrip";
import { useTheme } from "../utils/theme";
import type { LiveRoom } from "../services/types"; import type { LiveRoom } from "../services/types";
const HEADER_H = 44; const HEADER_H = 44;
@@ -83,6 +84,7 @@ export default function HomeScreen() {
const [activeTab, setActiveTab] = useState<TabKey>("hot"); const [activeTab, setActiveTab] = useState<TabKey>("hot");
const [liveAreaId, setLiveAreaId] = useState(0); const [liveAreaId, setLiveAreaId] = useState(0);
const theme = useTheme();
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null); const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]); const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
const pagerRef = useRef<PagerView>(null); const pagerRef = useRef<PagerView>(null);
@@ -286,7 +288,7 @@ export default function HomeScreen() {
activeTab === "hot" ? headerOpacity : liveHeaderOpacity; activeTab === "hot" ? headerOpacity : liveHeaderOpacity;
return ( return (
<SafeAreaView style={styles.safe} edges={["left", "right"]}> <SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={["left", "right"]}>
{/* 滑动切换容器 */} {/* 滑动切换容器 */}
<PagerView <PagerView
ref={pagerRef} ref={pagerRef}
@@ -410,6 +412,7 @@ export default function HomeScreen() {
styles.navBar, styles.navBar,
{ {
paddingTop: insets.top, paddingTop: insets.top,
backgroundColor: theme.card,
transform: [{ translateY: currentHeaderTranslate }], transform: [{ translateY: currentHeaderTranslate }],
}, },
]} ]}
@@ -419,6 +422,7 @@ export default function HomeScreen() {
styles.header, styles.header,
{ {
opacity: currentHeaderOpacity, opacity: currentHeaderOpacity,
borderBottomColor: theme.border,
}, },
]} ]}
> >
@@ -439,19 +443,19 @@ export default function HomeScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<TouchableOpacity <TouchableOpacity
style={styles.searchBar} style={[styles.searchBar, { backgroundColor: theme.inputBg }]}
onPress={() => router.push("/search" as any)} onPress={() => router.push("/search" as any)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Ionicons name="search" size={14} color="#999" /> <Ionicons name="search" size={14} color={theme.textSub} />
<Text style={styles.searchPlaceholder}>UP主...</Text> <Text style={[styles.searchPlaceholder, { color: theme.textSub }]}>UP主...</Text>
</TouchableOpacity> </TouchableOpacity>
<DownloadProgressBtn <DownloadProgressBtn
onPress={() => router.push("/downloads" as any)} onPress={() => router.push("/downloads" as any)}
/> />
</Animated.View> </Animated.View>
<View style={styles.tabRow}> <View style={[styles.tabRow, { backgroundColor: theme.card }]}>
{TABS.map((tab) => ( {TABS.map((tab) => (
<TouchableOpacity <TouchableOpacity
key={tab.key} key={tab.key}
@@ -462,6 +466,7 @@ export default function HomeScreen() {
<Text <Text
style={[ style={[
styles.tabText, styles.tabText,
{ color: theme.textSub },
activeTab === tab.key && styles.tabTextActive, activeTab === tab.key && styles.tabTextActive,
]} ]}
> >

View File

@@ -13,11 +13,13 @@ import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { VideoCard } from '../components/VideoCard'; import { VideoCard } from '../components/VideoCard';
import { useSearch } from '../hooks/useSearch'; import { useSearch } from '../hooks/useSearch';
import { useTheme } from '../utils/theme';
import type { VideoItem } from '../services/types'; import type { VideoItem } from '../services/types';
export default function SearchScreen() { export default function SearchScreen() {
const router = useRouter(); const router = useRouter();
const { keyword, setKeyword, results, loading, hasMore, search, loadMore } = useSearch(); const { keyword, setKeyword, results, loading, hasMore, search, loadMore } = useSearch();
const theme = useTheme();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const handleSearch = useCallback(() => { const handleSearch = useCallback(() => {
@@ -64,7 +66,7 @@ export default function SearchScreen() {
return ( return (
<View style={styles.emptyBox}> <View style={styles.emptyBox}>
<Ionicons name="search-outline" size={48} color="#ddd" /> <Ionicons name="search-outline" size={48} color="#ddd" />
<Text style={styles.emptyText}> <Text style={[styles.emptyText, { color: theme.textSub }]}>
{results.length === 0 && keyword.trim() {results.length === 0 && keyword.trim()
? '没有找到相关视频' ? '没有找到相关视频'
: '输入关键词搜索'} : '输入关键词搜索'}
@@ -74,16 +76,16 @@ export default function SearchScreen() {
}; };
return ( return (
<SafeAreaView style={styles.safe} edges={['top', 'left', 'right']}> <SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['top', 'left', 'right']}>
{/* Search header */} {/* Search header */}
<View style={styles.header}> <View style={[styles.header, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}> <TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color="#212121" /> <Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.inputWrap}> <View style={[styles.inputWrap, { backgroundColor: theme.inputBg }]}>
<TextInput <TextInput
ref={inputRef} ref={inputRef}
style={styles.input} style={[styles.input, { color: theme.text }]}
placeholder="搜索视频、UP主..." placeholder="搜索视频、UP主..."
placeholderTextColor="#999" placeholderTextColor="#999"
value={keyword} value={keyword}

View File

@@ -5,12 +5,14 @@ import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore'; import { useSettingsStore } from '../store/settingsStore';
import { useTheme } from '../utils/theme';
import { useCheckUpdate } from '../hooks/useCheckUpdate'; import { useCheckUpdate } from '../hooks/useCheckUpdate';
export default function SettingsScreen() { export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { isLoggedIn, logout } = useAuthStore(); const { isLoggedIn, logout } = useAuthStore();
const { coverQuality, setCoverQuality } = useSettingsStore(); const { darkMode, setDarkMode, trafficSaving, setTrafficSaving } = useSettingsStore();
const theme = useTheme();
const { currentVersion, isChecking, downloadProgress, checkUpdate } = useCheckUpdate(); const { currentVersion, isChecking, downloadProgress, checkUpdate } = useCheckUpdate();
const handleLogout = async () => { const handleLogout = async () => {
@@ -19,25 +21,25 @@ export default function SettingsScreen() {
}; };
return ( return (
<SafeAreaView style={styles.safe}> <SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]}>
<View style={styles.topBar}> <View style={[styles.topBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}> <TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color="#212121" /> <Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.topTitle}></Text> <Text style={[styles.topTitle, { color: theme.text }]}></Text>
<View style={styles.spacer} /> <View style={styles.spacer} />
</View> </View>
<View style={styles.section}> <View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={styles.sectionLabel}></Text> <Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<View style={styles.versionRow}> <View style={styles.versionRow}>
<Text style={styles.versionLabel}></Text> <Text style={[styles.versionLabel, { color: theme.text }]}></Text>
<Text style={styles.versionValue}>v{currentVersion}</Text> <Text style={[styles.versionValue, { color: theme.textSub }]}>v{currentVersion}</Text>
</View> </View>
</View> </View>
<View style={styles.section}> <View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={styles.sectionLabel}></Text> <Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<TouchableOpacity <TouchableOpacity
style={styles.updateBtn} style={styles.updateBtn}
onPress={checkUpdate} onPress={checkUpdate}
@@ -57,30 +59,51 @@ export default function SettingsScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.section}> <View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={styles.sectionLabel}></Text> <Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<View style={styles.optionRow}> <View style={styles.optionRow}>
<TouchableOpacity <TouchableOpacity
style={[styles.option, coverQuality === 'hd' && styles.optionActive]} style={[styles.option, !darkMode && styles.optionActive]}
onPress={() => setCoverQuality('hd')} onPress={() => setDarkMode(false)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.optionText, coverQuality === 'hd' && styles.optionTextActive]}> <Text style={[styles.optionText, !darkMode && styles.optionTextActive]}></Text>
</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.option, coverQuality === 'normal' && styles.optionActive]} style={[styles.option, darkMode && styles.optionActive]}
onPress={() => setCoverQuality('normal')} onPress={() => setDarkMode(true)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.optionText, coverQuality === 'normal' && styles.optionTextActive]}> <Text style={[styles.optionText, darkMode && styles.optionTextActive]}></Text>
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<View style={styles.optionRow}>
<TouchableOpacity
style={[styles.option, !trafficSaving && styles.optionActive]}
onPress={() => setTrafficSaving(false)}
activeOpacity={0.7}
>
<Text style={[styles.optionText, !trafficSaving && styles.optionTextActive]}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.option, trafficSaving && styles.optionActive]}
onPress={() => setTrafficSaving(true)}
activeOpacity={0.7}
>
<Text style={[styles.optionText, trafficSaving && styles.optionTextActive]}></Text>
</TouchableOpacity>
</View>
{trafficSaving && (
<Text style={[styles.hint, { color: theme.textSub }]}>
· · 360p
</Text>
)}
</View>
{isLoggedIn && ( {isLoggedIn && (
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout} activeOpacity={0.8}> <TouchableOpacity style={styles.logoutBtn} onPress={handleLogout} activeOpacity={0.8}>
<Text style={styles.logoutText}>退</Text> <Text style={styles.logoutText}>退</Text>
@@ -91,15 +114,13 @@ export default function SettingsScreen() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#f4f4f4' }, safe: { flex: 1 },
topBar: { topBar: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 8, paddingVertical: 8,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
}, },
backBtn: { padding: 4, width: 32 }, backBtn: { padding: 4, width: 32 },
spacer: { width: 32 }, spacer: { width: 32 },
@@ -107,20 +128,16 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
color: '#212121',
textAlign: 'center', textAlign: 'center',
}, },
section: { section: {
backgroundColor: '#fff',
marginTop: 16, marginTop: 16,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 14, paddingVertical: 14,
borderTopWidth: StyleSheet.hairlineWidth, borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#eee',
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
}, },
sectionLabel: { fontSize: 13, color: '#999', marginBottom: 10 }, sectionLabel: { fontSize: 13, marginBottom: 10 },
optionRow: { flexDirection: 'row', gap: 10 }, optionRow: { flexDirection: 'row', gap: 10 },
option: { option: {
paddingHorizontal: 20, paddingHorizontal: 20,
@@ -128,18 +145,19 @@ const styles = StyleSheet.create({
borderRadius: 20, borderRadius: 20,
borderWidth: 1, borderWidth: 1,
borderColor: '#e0e0e0', borderColor: '#e0e0e0',
backgroundColor: '#fff', backgroundColor: 'transparent',
}, },
optionActive: { borderColor: '#00AEEC', backgroundColor: '#e8f7fd' }, optionActive: { borderColor: '#00AEEC', backgroundColor: '#e8f7fd' },
optionText: { fontSize: 14, color: '#666' }, optionText: { fontSize: 14, color: '#666' },
optionTextActive: { color: '#00AEEC', fontWeight: '600' }, optionTextActive: { color: '#00AEEC', fontWeight: '600' },
hint: { fontSize: 12, marginTop: 8 },
versionRow: { versionRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
}, },
versionLabel: { fontSize: 14, color: '#212121' }, versionLabel: { fontSize: 14 },
versionValue: { fontSize: 14, color: '#999' }, versionValue: { fontSize: 14 },
updateBtn: { updateBtn: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -150,7 +168,6 @@ const styles = StyleSheet.create({
margin: 24, margin: 24,
paddingVertical: 12, paddingVertical: 12,
borderRadius: 8, borderRadius: 8,
backgroundColor: '#fff',
borderWidth: 1, borderWidth: 1,
borderColor: '#ff4757', borderColor: '#ff4757',
alignItems: 'center', alignItems: 'center',

View File

@@ -22,6 +22,7 @@ import { buildDashMpdUri } from "../utils/dash";
import { getPlayUrl, getVideoDetail } from "../services/bilibili"; import { getPlayUrl, getVideoDetail } from "../services/bilibili";
import { coverImageUrl } from "../utils/imageUrl"; import { coverImageUrl } from "../utils/imageUrl";
import { useSettingsStore } from "../store/settingsStore"; import { useSettingsStore } from "../store/settingsStore";
import { useTheme } from "../utils/theme";
import { formatCount, formatDuration } from "../utils/format"; import { formatCount, formatDuration } from "../utils/format";
import type { VideoItem } from "../services/types"; import type { VideoItem } from "../services/types";
@@ -55,7 +56,8 @@ export const BigVideoCard = React.memo(function BigVideoCard({
onPress, onPress,
}: Props) { }: Props) {
const { width: SCREEN_W } = useWindowDimensions(); const { width: SCREEN_W } = useWindowDimensions();
const coverQuality = useSettingsStore(s => s.coverQuality); const trafficSaving = useSettingsStore(s => s.trafficSaving);
const theme = useTheme();
const THUMB_H = SCREEN_W * 0.5625; const THUMB_H = SCREEN_W * 0.5625;
const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H }; const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H };
@@ -90,7 +92,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
// Preload: fetch play URL on mount (before card is visible) // Preload: fetch play URL on mount (before card is visible)
useEffect(() => { useEffect(() => {
if (videoUrl) return; if (videoUrl || trafficSaving) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
@@ -125,8 +127,8 @@ export const BigVideoCard = React.memo(function BigVideoCard({
// Pause/resume based on visibility and scroll state // Pause/resume based on visibility and scroll state
useEffect(() => { useEffect(() => {
if (!videoUrl) return; if (!videoUrl) return;
if (!isVisible) { if (!isVisible || trafficSaving) {
// Off-screen: pause, mute, show thumbnail // Off-screen or traffic saving: pause, mute, show thumbnail
setPaused(true); setPaused(true);
setMuted(true); setMuted(true);
Animated.timing(thumbOpacity, { Animated.timing(thumbOpacity, {
@@ -146,10 +148,10 @@ export const BigVideoCard = React.memo(function BigVideoCard({
useNativeDriver: true, useNativeDriver: true,
}).start(); }).start();
} }
}, [isVisible, isScrolling, videoUrl]); }, [isVisible, isScrolling, videoUrl, trafficSaving]);
const handleVideoReady = () => { const handleVideoReady = () => {
if (!isVisible || isScrolling) return; if (!isVisible || isScrolling || trafficSaving) return;
setPaused(false); setPaused(false);
Animated.timing(thumbOpacity, { Animated.timing(thumbOpacity, {
toValue: 0, toValue: 0,
@@ -223,7 +225,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0; const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
return ( return (
<View style={styles.card}> <View style={[styles.card, { backgroundColor: theme.card }]}>
{/* Media area */} {/* Media area */}
<View style={[mediaDimensions, { position: "relative" }]}> <View style={[mediaDimensions, { position: "relative" }]}>
{/* Video player — rendered first so it sits behind the thumbnail */} {/* Video player — rendered first so it sits behind the thumbnail */}
@@ -260,7 +262,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
pointerEvents="none" pointerEvents="none"
> >
<Image <Image
source={{ uri: coverImageUrl(item.pic, coverQuality) }} source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={mediaDimensions} style={mediaDimensions}
resizeMode="cover" resizeMode="cover"
/> />
@@ -338,11 +340,11 @@ export const BigVideoCard = React.memo(function BigVideoCard({
{/* Info */} {/* Info */}
<TouchableOpacity onPress={onPress} activeOpacity={0.85}> <TouchableOpacity onPress={onPress} activeOpacity={0.85}>
<View style={styles.info}> <View style={styles.info}>
<Text style={styles.title} numberOfLines={2}> <Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
{item.title} {item.title}
</Text> </Text>
<Text style={styles.owner} numberOfLines={1}> <Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}>
{item.owner?.name ?? ""} {item.owner?.name ?? ""}
</Text> </Text>
</View> </View>

View File

@@ -12,6 +12,7 @@ import { LivePulse } from "./LivePulse";
import type { LiveRoom } from "../services/types"; import type { LiveRoom } from "../services/types";
import { formatCount } from "../utils/format"; import { formatCount } from "../utils/format";
import { proxyImageUrl } from "../utils/imageUrl"; import { proxyImageUrl } from "../utils/imageUrl";
import { useTheme } from "../utils/theme";
const { width } = Dimensions.get("window"); const { width } = Dimensions.get("window");
const CARD_WIDTH = (width - 14) / 2; const CARD_WIDTH = (width - 14) / 2;
@@ -30,9 +31,10 @@ export const LiveCard = React.memo(function LiveCard({
isLivePulse = false, isLivePulse = false,
}: Props) { }: Props) {
const cardWidth = fullWidth ? width - 8 : CARD_WIDTH; const cardWidth = fullWidth ? width - 8 : CARD_WIDTH;
const theme = useTheme();
return ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.card, { width: cardWidth }]} style={[styles.card, { width: cardWidth, backgroundColor: theme.card }]}
onPress={onPress} onPress={onPress}
activeOpacity={0.85} activeOpacity={0.85}
> >
@@ -58,7 +60,7 @@ export const LiveCard = React.memo(function LiveCard({
</View> </View>
</View> </View>
<View style={styles.info}> <View style={styles.info}>
<Text style={styles.title} numberOfLines={2}> <Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
{item.title} {item.title}
</Text> </Text>
<View style={styles.ownerRow}> <View style={styles.ownerRow}>
@@ -66,7 +68,7 @@ export const LiveCard = React.memo(function LiveCard({
source={{ uri: proxyImageUrl(item.face) }} source={{ uri: proxyImageUrl(item.face) }}
style={styles.avatar} style={styles.avatar}
/> />
<Text style={styles.owner} numberOfLines={1}> <Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}>
{item.uname} {item.uname}
</Text> </Text>
</View> </View>

View File

@@ -12,6 +12,7 @@ import type { VideoItem } from "../services/types";
import { formatCount, formatDuration } from "../utils/format"; import { formatCount, formatDuration } from "../utils/format";
import { coverImageUrl } from "../utils/imageUrl"; import { coverImageUrl } from "../utils/imageUrl";
import { useSettingsStore } from "../store/settingsStore"; import { useSettingsStore } from "../store/settingsStore";
import { useTheme } from "../utils/theme";
const { width } = Dimensions.get("window"); const { width } = Dimensions.get("window");
const CARD_WIDTH = (width - 14) / 2; const CARD_WIDTH = (width - 14) / 2;
@@ -22,16 +23,17 @@ interface Props {
} }
export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) { export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) {
const coverQuality = useSettingsStore(s => s.coverQuality); const trafficSaving = useSettingsStore(s => s.trafficSaving);
const theme = useTheme();
return ( return (
<TouchableOpacity <TouchableOpacity
style={styles.card} style={[styles.card, { backgroundColor: theme.card }]}
onPress={onPress} onPress={onPress}
activeOpacity={0.85} activeOpacity={0.85}
> >
<View style={styles.thumbContainer}> <View style={styles.thumbContainer}>
<Image <Image
source={{ uri: coverImageUrl(item.pic, coverQuality) }} source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={styles.thumb} style={styles.thumb}
resizeMode="cover" resizeMode="cover"
/> />
@@ -48,10 +50,10 @@ export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props)
</View> </View>
</View> </View>
<View style={styles.info}> <View style={styles.info}>
<Text style={styles.title} numberOfLines={2}> <Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
{item.title} {item.title}
</Text> </Text>
<Text style={styles.owner} numberOfLines={1}> <Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}>
{item.owner?.name ?? ""} {item.owner?.name ?? ""}
</Text> </Text>
</View> </View>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { getVideoDetail, getPlayUrl } from '../services/bilibili'; import { getVideoDetail, getPlayUrl } from '../services/bilibili';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore';
import type { VideoItem, PlayUrlResponse } from '../services/types'; import type { VideoItem, PlayUrlResponse } from '../services/types';
export function useVideoDetail(bvid: string) { export function useVideoDetail(bvid: string) {
@@ -12,6 +13,8 @@ export function useVideoDetail(bvid: string) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const cidRef = useRef<number>(0); const cidRef = useRef<number>(0);
const isLoggedIn = useAuthStore(s => s.isLoggedIn); const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const trafficSaving = useSettingsStore(s => s.trafficSaving);
const defaultQn = trafficSaving ? 16 : 126;
async function fetchPlayData(cid: number, qn: number, updateList = false) { async function fetchPlayData(cid: number, qn: number, updateList = false) {
const data = await getPlayUrl(bvid, cid, qn); const data = await getPlayUrl(bvid, cid, qn);
@@ -39,7 +42,7 @@ export function useVideoDetail(bvid: string) {
setVideo(detail); setVideo(detail);
const cid = detail.pages?.[0]?.cid ?? detail.cid as number; const cid = detail.pages?.[0]?.cid ?? detail.cid as number;
cidRef.current = cid; cidRef.current = cid;
await fetchPlayData(cid, 126, true); await fetchPlayData(cid, defaultQn, true);
} catch (e: any) { } catch (e: any) {
setError(e.message ?? 'Load failed'); setError(e.message ?? 'Load failed');
} finally { } finally {
@@ -52,7 +55,7 @@ export function useVideoDetail(bvid: string) {
// 登录状态变化时重新拉取清晰度列表(登录后可能获得更高画质) // 登录状态变化时重新拉取清晰度列表(登录后可能获得更高画质)
useEffect(() => { useEffect(() => {
if (cidRef.current) { if (cidRef.current) {
fetchPlayData(cidRef.current, 126, true).catch((e) => { fetchPlayData(cidRef.current, defaultQn, true).catch((e) => {
console.warn('Failed to refresh quality list after login change:', e); console.warn('Failed to refresh quality list after login change:', e);
}); });
} }

View File

@@ -2,21 +2,35 @@ import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
interface SettingsState { interface SettingsState {
coverQuality: 'hd' | 'normal'; darkMode: boolean;
setCoverQuality: (q: 'hd' | 'normal') => Promise<void>; trafficSaving: boolean;
setDarkMode: (v: boolean) => Promise<void>;
setTrafficSaving: (v: boolean) => Promise<void>;
restore: () => Promise<void>; restore: () => Promise<void>;
} }
export const useSettingsStore = create<SettingsState>((set) => ({ export const useSettingsStore = create<SettingsState>((set) => ({
coverQuality: 'hd', darkMode: false,
trafficSaving: false,
setCoverQuality: async (q) => { setDarkMode: async (v) => {
await AsyncStorage.setItem('COVER_QUALITY', q); await AsyncStorage.setItem('DARK_MODE', v ? '1' : '0');
set({ coverQuality: q }); set({ darkMode: v });
},
setTrafficSaving: async (v) => {
await AsyncStorage.setItem('TRAFFIC_SAVING', v ? '1' : '0');
set({ trafficSaving: v });
}, },
restore: async () => { restore: async () => {
const q = await AsyncStorage.getItem('COVER_QUALITY'); const [dm, ts] = await Promise.all([
if (q === 'hd' || q === 'normal') set({ coverQuality: q }); AsyncStorage.getItem('DARK_MODE'),
AsyncStorage.getItem('TRAFFIC_SAVING'),
]);
set({
darkMode: dm === '1',
trafficSaving: ts === '1',
});
}, },
})); }));

33
utils/theme.ts Normal file
View File

@@ -0,0 +1,33 @@
import { useSettingsStore } from '../store/settingsStore';
export type ThemeColors = {
bg: string; // 页面/列表背景
card: string; // 卡片/section 背景
text: string; // 主文字
textSub: string; // 次要文字
border: string; // 分割线
inputBg: string; // 输入框背景
};
const light: ThemeColors = {
bg: '#f4f4f4',
card: '#fff',
text: '#212121',
textSub: '#999',
border: '#eee',
inputBg: '#f0f0f0',
};
const dark: ThemeColors = {
bg: '#0f0f0f',
card: '#1a1a1a',
text: '#e0e0e0',
textSub: '#666',
border: '#2a2a2a',
inputBg: '#2a2a2a',
};
export function useTheme(): ThemeColors {
const darkMode = useSettingsStore(s => s.darkMode);
return darkMode ? dark : light;
}