diff --git a/.gitignore b/.gitignore index 1e88ecf..dc9b4d1 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,5 @@ docs/ feature.md livePlan.md Promotion.md +wordsFilter.md .env.local diff --git a/app/_layout.tsx b/app/_layout.tsx index b89e078..3058fb9 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -6,6 +6,7 @@ import { useEffect } from 'react'; import { useAuthStore } from '../store/authStore'; import { useDownloadStore } from '../store/downloadStore'; import { useSettingsStore } from '../store/settingsStore'; +import { useTheme } from '../utils/theme'; import { MiniPlayer } from '../components/MiniPlayer'; import * as Sentry from '@sentry/react-native'; import { ErrorBoundary } from '@sentry/react-native'; @@ -21,6 +22,7 @@ function RootLayout() { const restore = useAuthStore(s => s.restore); const loadDownloads = useDownloadStore(s => s.loadFromStorage); const restoreSettings = useSettingsStore(s => s.restore); + const darkMode = useSettingsStore(s => s.darkMode); useEffect(() => { restore(); @@ -31,7 +33,7 @@ function RootLayout() { return ( - + 发生错误,请重启 App}> diff --git a/app/downloads.tsx b/app/downloads.tsx index 72c5e4d..0f19eaf 100644 --- a/app/downloads.tsx +++ b/app/downloads.tsx @@ -27,9 +27,11 @@ function formatFileSize(bytes?: number): string { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } import { proxyImageUrl } from '../utils/imageUrl'; +import { useTheme } from '../utils/theme'; export default function DownloadsScreen() { const router = useRouter(); + const theme = useTheme(); const { tasks, loadFromStorage, removeTask } = useDownloadStore(); const [playingUri, setPlayingUri] = useState(null); const [playingTitle, setPlayingTitle] = useState(''); @@ -61,12 +63,12 @@ export default function DownloadsScreen() { if (done.length > 0) sections.push({ title: '已下载', data: done }); return ( - - + + router.back()} style={styles.backBtn}> - + - 我的下载 + 我的下载 diff --git a/app/index.tsx b/app/index.tsx index 2521408..0350335 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -40,6 +40,7 @@ import { } from "../utils/videoRows"; import { BigVideoCard } from "../components/BigVideoCard"; import { FollowedLiveStrip } from "../components/FollowedLiveStrip"; +import { useTheme } from "../utils/theme"; import type { LiveRoom } from "../services/types"; const HEADER_H = 44; @@ -83,6 +84,7 @@ export default function HomeScreen() { const [activeTab, setActiveTab] = useState("hot"); const [liveAreaId, setLiveAreaId] = useState(0); + const theme = useTheme(); const [visibleBigKey, setVisibleBigKey] = useState(null); const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]); const pagerRef = useRef(null); @@ -286,7 +288,7 @@ export default function HomeScreen() { activeTab === "hot" ? headerOpacity : liveHeaderOpacity; return ( - + {/* 滑动切换容器 */} @@ -439,19 +443,19 @@ export default function HomeScreen() { router.push("/search" as any)} activeOpacity={0.7} > - - 搜索视频、UP主... + + 搜索视频、UP主... router.push("/downloads" as any)} /> - + {TABS.map((tab) => ( diff --git a/app/search.tsx b/app/search.tsx index 12324ee..ff20aa2 100644 --- a/app/search.tsx +++ b/app/search.tsx @@ -13,11 +13,13 @@ import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { VideoCard } from '../components/VideoCard'; import { useSearch } from '../hooks/useSearch'; +import { useTheme } from '../utils/theme'; import type { VideoItem } from '../services/types'; export default function SearchScreen() { const router = useRouter(); const { keyword, setKeyword, results, loading, hasMore, search, loadMore } = useSearch(); + const theme = useTheme(); const inputRef = useRef(null); const handleSearch = useCallback(() => { @@ -64,7 +66,7 @@ export default function SearchScreen() { return ( - + {results.length === 0 && keyword.trim() ? '没有找到相关视频' : '输入关键词搜索'} @@ -74,16 +76,16 @@ export default function SearchScreen() { }; return ( - + {/* Search header */} - + router.back()} style={styles.backBtn}> - + - + { @@ -19,25 +21,25 @@ export default function SettingsScreen() { }; return ( - - + + router.back()} style={styles.backBtn}> - + - 设置 + 设置 - - 版本信息 + + 版本信息 - 当前版本 - v{currentVersion} + 当前版本 + v{currentVersion} - - 更新 + + 更新 - - 封面图清晰度 + + 外观 setCoverQuality('hd')} + style={[styles.option, !darkMode && styles.optionActive]} + onPress={() => setDarkMode(false)} activeOpacity={0.7} > - - 高清 - + 浅色 setCoverQuality('normal')} + style={[styles.option, darkMode && styles.optionActive]} + onPress={() => setDarkMode(true)} activeOpacity={0.7} > - - 普通 - + 深色 + + 流量 + + setTrafficSaving(false)} + activeOpacity={0.7} + > + 标准 + + setTrafficSaving(true)} + activeOpacity={0.7} + > + 节流 + + + {trafficSaving && ( + + 封面低画质 · 首页视频不自动播放 · 视频默认 360p + + )} + + {isLoggedIn && ( 退出登录 @@ -91,15 +114,13 @@ export default function SettingsScreen() { } const styles = StyleSheet.create({ - safe: { flex: 1, backgroundColor: '#f4f4f4' }, + safe: { flex: 1 }, topBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8, - backgroundColor: '#fff', borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#eee', }, backBtn: { padding: 4, width: 32 }, spacer: { width: 32 }, @@ -107,20 +128,16 @@ const styles = StyleSheet.create({ flex: 1, fontSize: 16, fontWeight: '600', - color: '#212121', textAlign: 'center', }, section: { - backgroundColor: '#fff', marginTop: 16, paddingHorizontal: 16, paddingVertical: 14, borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: '#eee', borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#eee', }, - sectionLabel: { fontSize: 13, color: '#999', marginBottom: 10 }, + sectionLabel: { fontSize: 13, marginBottom: 10 }, optionRow: { flexDirection: 'row', gap: 10 }, option: { paddingHorizontal: 20, @@ -128,18 +145,19 @@ const styles = StyleSheet.create({ borderRadius: 20, borderWidth: 1, borderColor: '#e0e0e0', - backgroundColor: '#fff', + backgroundColor: 'transparent', }, optionActive: { borderColor: '#00AEEC', backgroundColor: '#e8f7fd' }, optionText: { fontSize: 14, color: '#666' }, optionTextActive: { color: '#00AEEC', fontWeight: '600' }, + hint: { fontSize: 12, marginTop: 8 }, versionRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, - versionLabel: { fontSize: 14, color: '#212121' }, - versionValue: { fontSize: 14, color: '#999' }, + versionLabel: { fontSize: 14 }, + versionValue: { fontSize: 14 }, updateBtn: { flexDirection: 'row', alignItems: 'center', @@ -150,7 +168,6 @@ const styles = StyleSheet.create({ margin: 24, paddingVertical: 12, borderRadius: 8, - backgroundColor: '#fff', borderWidth: 1, borderColor: '#ff4757', alignItems: 'center', diff --git a/components/BigVideoCard.tsx b/components/BigVideoCard.tsx index d6956ff..4199f06 100644 --- a/components/BigVideoCard.tsx +++ b/components/BigVideoCard.tsx @@ -22,6 +22,7 @@ import { buildDashMpdUri } from "../utils/dash"; import { getPlayUrl, getVideoDetail } from "../services/bilibili"; import { coverImageUrl } from "../utils/imageUrl"; import { useSettingsStore } from "../store/settingsStore"; +import { useTheme } from "../utils/theme"; import { formatCount, formatDuration } from "../utils/format"; import type { VideoItem } from "../services/types"; @@ -55,7 +56,8 @@ export const BigVideoCard = React.memo(function BigVideoCard({ onPress, }: Props) { 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 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) useEffect(() => { - if (videoUrl) return; + if (videoUrl || trafficSaving) return; let cancelled = false; (async () => { try { @@ -125,8 +127,8 @@ export const BigVideoCard = React.memo(function BigVideoCard({ // Pause/resume based on visibility and scroll state useEffect(() => { if (!videoUrl) return; - if (!isVisible) { - // Off-screen: pause, mute, show thumbnail + if (!isVisible || trafficSaving) { + // Off-screen or traffic saving: pause, mute, show thumbnail setPaused(true); setMuted(true); Animated.timing(thumbOpacity, { @@ -146,10 +148,10 @@ export const BigVideoCard = React.memo(function BigVideoCard({ useNativeDriver: true, }).start(); } - }, [isVisible, isScrolling, videoUrl]); + }, [isVisible, isScrolling, videoUrl, trafficSaving]); const handleVideoReady = () => { - if (!isVisible || isScrolling) return; + if (!isVisible || isScrolling || trafficSaving) return; setPaused(false); Animated.timing(thumbOpacity, { toValue: 0, @@ -223,7 +225,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({ const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0; return ( - + {/* Media area */} {/* Video player — rendered first so it sits behind the thumbnail */} @@ -260,7 +262,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({ pointerEvents="none" > @@ -338,11 +340,11 @@ export const BigVideoCard = React.memo(function BigVideoCard({ {/* Info */} - + {item.title} - + {item.owner?.name ?? ""} diff --git a/components/LiveCard.tsx b/components/LiveCard.tsx index ba335d7..6e24058 100644 --- a/components/LiveCard.tsx +++ b/components/LiveCard.tsx @@ -12,6 +12,7 @@ import { LivePulse } from "./LivePulse"; import type { LiveRoom } from "../services/types"; import { formatCount } from "../utils/format"; import { proxyImageUrl } from "../utils/imageUrl"; +import { useTheme } from "../utils/theme"; const { width } = Dimensions.get("window"); const CARD_WIDTH = (width - 14) / 2; @@ -30,9 +31,10 @@ export const LiveCard = React.memo(function LiveCard({ isLivePulse = false, }: Props) { const cardWidth = fullWidth ? width - 8 : CARD_WIDTH; + const theme = useTheme(); return ( @@ -58,7 +60,7 @@ export const LiveCard = React.memo(function LiveCard({ - + {item.title} @@ -66,7 +68,7 @@ export const LiveCard = React.memo(function LiveCard({ source={{ uri: proxyImageUrl(item.face) }} style={styles.avatar} /> - + {item.uname} diff --git a/components/VideoCard.tsx b/components/VideoCard.tsx index 7e7b999..153fd7d 100644 --- a/components/VideoCard.tsx +++ b/components/VideoCard.tsx @@ -12,6 +12,7 @@ import type { VideoItem } from "../services/types"; import { formatCount, formatDuration } from "../utils/format"; import { coverImageUrl } from "../utils/imageUrl"; import { useSettingsStore } from "../store/settingsStore"; +import { useTheme } from "../utils/theme"; const { width } = Dimensions.get("window"); const CARD_WIDTH = (width - 14) / 2; @@ -22,16 +23,17 @@ interface 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 ( @@ -48,10 +50,10 @@ export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) - + {item.title} - + {item.owner?.name ?? ""} diff --git a/hooks/useVideoDetail.ts b/hooks/useVideoDetail.ts index 7bf0386..e0075b9 100644 --- a/hooks/useVideoDetail.ts +++ b/hooks/useVideoDetail.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { getVideoDetail, getPlayUrl } from '../services/bilibili'; import { useAuthStore } from '../store/authStore'; +import { useSettingsStore } from '../store/settingsStore'; import type { VideoItem, PlayUrlResponse } from '../services/types'; export function useVideoDetail(bvid: string) { @@ -12,6 +13,8 @@ export function useVideoDetail(bvid: string) { const [error, setError] = useState(null); const cidRef = useRef(0); 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) { const data = await getPlayUrl(bvid, cid, qn); @@ -39,7 +42,7 @@ export function useVideoDetail(bvid: string) { setVideo(detail); const cid = detail.pages?.[0]?.cid ?? detail.cid as number; cidRef.current = cid; - await fetchPlayData(cid, 126, true); + await fetchPlayData(cid, defaultQn, true); } catch (e: any) { setError(e.message ?? 'Load failed'); } finally { @@ -52,7 +55,7 @@ export function useVideoDetail(bvid: string) { // 登录状态变化时重新拉取清晰度列表(登录后可能获得更高画质) useEffect(() => { 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); }); } diff --git a/store/settingsStore.ts b/store/settingsStore.ts index b8c4f64..39ab39a 100644 --- a/store/settingsStore.ts +++ b/store/settingsStore.ts @@ -2,21 +2,35 @@ import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; interface SettingsState { - coverQuality: 'hd' | 'normal'; - setCoverQuality: (q: 'hd' | 'normal') => Promise; + darkMode: boolean; + trafficSaving: boolean; + setDarkMode: (v: boolean) => Promise; + setTrafficSaving: (v: boolean) => Promise; restore: () => Promise; } export const useSettingsStore = create((set) => ({ - coverQuality: 'hd', + darkMode: false, + trafficSaving: false, - setCoverQuality: async (q) => { - await AsyncStorage.setItem('COVER_QUALITY', q); - set({ coverQuality: q }); + setDarkMode: async (v) => { + await AsyncStorage.setItem('DARK_MODE', v ? '1' : '0'); + set({ darkMode: v }); + }, + + setTrafficSaving: async (v) => { + await AsyncStorage.setItem('TRAFFIC_SAVING', v ? '1' : '0'); + set({ trafficSaving: v }); }, restore: async () => { - const q = await AsyncStorage.getItem('COVER_QUALITY'); - if (q === 'hd' || q === 'normal') set({ coverQuality: q }); + const [dm, ts] = await Promise.all([ + AsyncStorage.getItem('DARK_MODE'), + AsyncStorage.getItem('TRAFFIC_SAVING'), + ]); + set({ + darkMode: dm === '1', + trafficSaving: ts === '1', + }); }, })); diff --git a/utils/theme.ts b/utils/theme.ts new file mode 100644 index 0000000..7265ef5 --- /dev/null +++ b/utils/theme.ts @@ -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; +}