mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,4 +49,5 @@ docs/
|
|||||||
feature.md
|
feature.md
|
||||||
livePlan.md
|
livePlan.md
|
||||||
Promotion.md
|
Promotion.md
|
||||||
|
wordsFilter.md
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
33
utils/theme.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user