mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
feat: 深色模式完善 + UP主主页 + 缓存管理
- 深色模式:补全 settings 页选项按钮、退出登录按钮主题色 - UP主主页:新增 /creator/[mid] 路由,展示 UP 主信息、粉丝数、视频列表 - bilibili.ts 新增 getUploaderInfo / getUploaderVideos API - 视频详情页 UP 主行可点击跳转主页 - 缓存管理:settings 页新增「存储」分区,显示缓存大小并支持一键清除 - 新增 utils/cache.ts(计算大小 + 清除 expo-image/文件系统缓存)
This commit is contained in:
@@ -85,6 +85,14 @@ function RootLayout() {
|
|||||||
gestureDirection: "horizontal",
|
gestureDirection: "horizontal",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="creator"
|
||||||
|
options={{
|
||||||
|
animation: "slide_from_right",
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: "horizontal",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<MiniPlayer />
|
<MiniPlayer />
|
||||||
|
|||||||
209
app/creator/[mid].tsx
Normal file
209
app/creator/[mid].tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { getUploaderInfo, getUploaderVideos } from '../../services/bilibili';
|
||||||
|
import type { VideoItem } from '../../services/types';
|
||||||
|
import { useTheme } from '../../utils/theme';
|
||||||
|
import { formatCount, formatDuration } from '../../utils/format';
|
||||||
|
import { proxyImageUrl, coverImageUrl } from '../../utils/imageUrl';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export default function CreatorScreen() {
|
||||||
|
const { mid: midStr } = useLocalSearchParams<{ mid: string }>();
|
||||||
|
const mid = Number(midStr);
|
||||||
|
const router = useRouter();
|
||||||
|
const theme = useTheme();
|
||||||
|
const trafficSaving = useSettingsStore(s => s.trafficSaving);
|
||||||
|
|
||||||
|
const [info, setInfo] = useState<{
|
||||||
|
name: string; face: string; sign: string; follower: number; archiveCount: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [videos, setVideos] = useState<VideoItem[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [infoLoading, setInfoLoading] = useState(true);
|
||||||
|
const loadingRef = React.useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUploaderInfo(mid)
|
||||||
|
.then(setInfo)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setInfoLoading(false));
|
||||||
|
loadVideos(1, true);
|
||||||
|
}, [mid]);
|
||||||
|
|
||||||
|
const loadVideos = useCallback(async (pn: number, reset = false) => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { videos: newVideos, total: t } = await getUploaderVideos(mid, pn, PAGE_SIZE);
|
||||||
|
setTotal(t);
|
||||||
|
setVideos(prev => reset ? newVideos : [...prev, ...newVideos]);
|
||||||
|
setPage(pn);
|
||||||
|
} catch {}
|
||||||
|
finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [mid]);
|
||||||
|
|
||||||
|
const hasMore = videos.length < total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['top', 'left', 'right']}>
|
||||||
|
{/* Top bar */}
|
||||||
|
<View style={[styles.topBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={theme.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={[styles.topTitle, { color: theme.text }]} numberOfLines={1}>
|
||||||
|
{info?.name ?? 'UP主主页'}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.backBtn} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={videos}
|
||||||
|
keyExtractor={item => item.bvid}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onEndReached={() => { if (hasMore && !loading) loadVideos(page + 1); }}
|
||||||
|
onEndReachedThreshold={0.3}
|
||||||
|
windowSize={7}
|
||||||
|
maxToRenderPerBatch={6}
|
||||||
|
removeClippedSubviews
|
||||||
|
ListHeaderComponent={
|
||||||
|
infoLoading ? (
|
||||||
|
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||||
|
) : info ? (
|
||||||
|
<View style={[styles.profileCard, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: proxyImageUrl(info.face) }}
|
||||||
|
style={styles.avatar}
|
||||||
|
contentFit="cover"
|
||||||
|
recyclingKey={String(mid)}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.name, { color: theme.text }]}>{info.name}</Text>
|
||||||
|
{info.sign ? (
|
||||||
|
<Text style={[styles.sign, { color: theme.textSub }]} numberOfLines={2}>{info.sign}</Text>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={[styles.statNum, { color: theme.text }]}>{formatCount(info.follower)}</Text>
|
||||||
|
<Text style={[styles.statLabel, { color: theme.textSub }]}>粉丝</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statDivider, { backgroundColor: theme.border }]} />
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={[styles.statNum, { color: theme.text }]}>{formatCount(info.archiveCount)}</Text>
|
||||||
|
<Text style={[styles.statLabel, { color: theme.textSub }]}>视频</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.videoListHeader, { color: theme.textSub }]}>
|
||||||
|
全部视频({total})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.videoRow, { backgroundColor: theme.card, borderBottomColor: theme.border }]}
|
||||||
|
onPress={() => router.push(`/video/${item.bvid}` as any)}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<View style={styles.thumbWrap}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
|
||||||
|
style={styles.thumb}
|
||||||
|
contentFit="cover"
|
||||||
|
recyclingKey={item.bvid}
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
<View style={styles.durationBadge}>
|
||||||
|
<Text style={styles.durationText}>{formatDuration(item.duration)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.videoInfo}>
|
||||||
|
<Text style={[styles.videoTitle, { color: theme.text }]} numberOfLines={2}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.videoMeta}>
|
||||||
|
<Ionicons name="play" size={11} color={theme.textSub} />
|
||||||
|
<Text style={[styles.metaText, { color: theme.textSub }]}>{formatCount(item.stat.view)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
!loading && !infoLoading ? (
|
||||||
|
<Text style={[styles.emptyTxt, { color: theme.textSub }]}>暂无视频</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
loading ? <ActivityIndicator style={styles.loader} color="#00AEEC" /> : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: { flex: 1 },
|
||||||
|
topBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
backBtn: { padding: 4, width: 32 },
|
||||||
|
topTitle: { flex: 1, fontSize: 16, fontWeight: '600', textAlign: 'center' },
|
||||||
|
profileCard: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 12,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
avatar: { width: 72, height: 72, borderRadius: 36, marginBottom: 10 },
|
||||||
|
name: { fontSize: 18, fontWeight: '700', marginBottom: 6 },
|
||||||
|
sign: { fontSize: 13, textAlign: 'center', paddingHorizontal: 24, marginBottom: 12, lineHeight: 19 },
|
||||||
|
statsRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16 },
|
||||||
|
statItem: { alignItems: 'center', paddingHorizontal: 24 },
|
||||||
|
statNum: { fontSize: 18, fontWeight: '700' },
|
||||||
|
statLabel: { fontSize: 12, marginTop: 2 },
|
||||||
|
statDivider: { width: 1, height: 28 },
|
||||||
|
videoListHeader: { alignSelf: 'flex-start', paddingHorizontal: 14, fontSize: 13, paddingBottom: 8 },
|
||||||
|
loader: { marginVertical: 24 },
|
||||||
|
videoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
thumbWrap: { width: 120, height: 68, borderRadius: 4, overflow: 'hidden', flexShrink: 0, position: 'relative' },
|
||||||
|
thumb: { width: 120, height: 68 },
|
||||||
|
durationBadge: {
|
||||||
|
position: 'absolute', bottom: 3, right: 3,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1,
|
||||||
|
},
|
||||||
|
durationText: { color: '#fff', fontSize: 10 },
|
||||||
|
videoInfo: { flex: 1, justifyContent: 'space-between', paddingVertical: 2 },
|
||||||
|
videoTitle: { fontSize: 13, lineHeight: 18 },
|
||||||
|
videoMeta: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
||||||
|
metaText: { fontSize: 12 },
|
||||||
|
emptyTxt: { textAlign: 'center', padding: 40 },
|
||||||
|
});
|
||||||
5
app/creator/_layout.tsx
Normal file
5
app/creator/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function CreatorLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
|
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { useSettingsStore } from '../store/settingsStore';
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
import { useTheme } from '../utils/theme';
|
import { useTheme } from '../utils/theme';
|
||||||
import { useCheckUpdate } from '../hooks/useCheckUpdate';
|
import { useCheckUpdate } from '../hooks/useCheckUpdate';
|
||||||
|
import { getImageCacheSize, clearImageCache, formatBytes } from '../utils/cache';
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -14,6 +15,34 @@ export default function SettingsScreen() {
|
|||||||
const { darkMode, setDarkMode, trafficSaving, setTrafficSaving } = useSettingsStore();
|
const { darkMode, setDarkMode, trafficSaving, setTrafficSaving } = useSettingsStore();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { currentVersion, isChecking, downloadProgress, checkUpdate } = useCheckUpdate();
|
const { currentVersion, isChecking, downloadProgress, checkUpdate } = useCheckUpdate();
|
||||||
|
const [cacheSize, setCacheSize] = useState<number | null>(null);
|
||||||
|
const [clearingCache, setClearingCache] = useState(false);
|
||||||
|
|
||||||
|
const refreshCacheSize = useCallback(async () => {
|
||||||
|
const size = await getImageCacheSize();
|
||||||
|
setCacheSize(size);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshCacheSize();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearCache = async () => {
|
||||||
|
Alert.alert('清除缓存', '确定要清除所有缓存吗?', [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '清除',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
setClearingCache(true);
|
||||||
|
await clearImageCache();
|
||||||
|
setClearingCache(false);
|
||||||
|
setCacheSize(0);
|
||||||
|
Alert.alert('已完成', '缓存已清除');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
@@ -63,18 +92,18 @@ export default function SettingsScreen() {
|
|||||||
<Text style={[styles.sectionLabel, { color: theme.textSub }]}>外观</Text>
|
<Text style={[styles.sectionLabel, { color: theme.textSub }]}>外观</Text>
|
||||||
<View style={styles.optionRow}>
|
<View style={styles.optionRow}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.option, !darkMode && styles.optionActive]}
|
style={[styles.option, { backgroundColor: theme.inputBg }, !darkMode && styles.optionActive]}
|
||||||
onPress={() => setDarkMode(false)}
|
onPress={() => setDarkMode(false)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.optionText, !darkMode && styles.optionTextActive]}>浅色</Text>
|
<Text style={[styles.optionText, { color: theme.text }, !darkMode && styles.optionTextActive]}>浅色</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.option, darkMode && styles.optionActive]}
|
style={[styles.option, { backgroundColor: theme.inputBg }, darkMode && styles.optionActive]}
|
||||||
onPress={() => setDarkMode(true)}
|
onPress={() => setDarkMode(true)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.optionText, darkMode && styles.optionTextActive]}>深色</Text>
|
<Text style={[styles.optionText, { color: theme.text }, darkMode && styles.optionTextActive]}>深色</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -83,18 +112,18 @@ export default function SettingsScreen() {
|
|||||||
<Text style={[styles.sectionLabel, { color: theme.textSub }]}>流量</Text>
|
<Text style={[styles.sectionLabel, { color: theme.textSub }]}>流量</Text>
|
||||||
<View style={styles.optionRow}>
|
<View style={styles.optionRow}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.option, !trafficSaving && styles.optionActive]}
|
style={[styles.option, { backgroundColor: theme.inputBg }, !trafficSaving && styles.optionActive]}
|
||||||
onPress={() => setTrafficSaving(false)}
|
onPress={() => setTrafficSaving(false)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.optionText, !trafficSaving && styles.optionTextActive]}>标准</Text>
|
<Text style={[styles.optionText, { color: theme.text }, !trafficSaving && styles.optionTextActive]}>标准</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.option, trafficSaving && styles.optionActive]}
|
style={[styles.option, { backgroundColor: theme.inputBg }, trafficSaving && styles.optionActive]}
|
||||||
onPress={() => setTrafficSaving(true)}
|
onPress={() => setTrafficSaving(true)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.optionText, trafficSaving && styles.optionTextActive]}>节流</Text>
|
<Text style={[styles.optionText, { color: theme.text }, trafficSaving && styles.optionTextActive]}>节流</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
{trafficSaving && (
|
{trafficSaving && (
|
||||||
@@ -104,9 +133,32 @@ export default function SettingsScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
|
<Text style={[styles.sectionLabel, { color: theme.textSub }]}>存储</Text>
|
||||||
|
<View style={styles.cacheRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.cacheLabel, { color: theme.text }]}>缓存大小</Text>
|
||||||
|
<Text style={[styles.cacheValue, { color: theme.textSub }]}>
|
||||||
|
{cacheSize === null ? '计算中...' : formatBytes(cacheSize)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.clearBtn, clearingCache && { opacity: 0.5 }]}
|
||||||
|
onPress={handleClearCache}
|
||||||
|
disabled={clearingCache}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{clearingCache
|
||||||
|
? <ActivityIndicator size="small" color="#fff" />
|
||||||
|
: <Text style={styles.clearBtnText}>清除缓存</Text>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{isLoggedIn && (
|
{isLoggedIn && (
|
||||||
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout} activeOpacity={0.8}>
|
<TouchableOpacity style={[styles.logoutBtn, { borderColor: theme.danger }]} onPress={handleLogout} activeOpacity={0.8}>
|
||||||
<Text style={styles.logoutText}>退出登录</Text>
|
<Text style={[styles.logoutText, { color: theme.danger }]}>退出登录</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@@ -143,10 +195,9 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
backgroundColor: '#f0f0f0',
|
|
||||||
},
|
},
|
||||||
optionActive: { backgroundColor: '#00AEEC' },
|
optionActive: { backgroundColor: '#00AEEC' },
|
||||||
optionText: { fontSize: 13, color: '#333', fontWeight: '500' },
|
optionText: { fontSize: 13, fontWeight: '500' },
|
||||||
optionTextActive: { color: '#fff', fontWeight: '600' },
|
optionTextActive: { color: '#fff', fontWeight: '600' },
|
||||||
hint: { fontSize: 12, marginTop: 8 },
|
hint: { fontSize: 12, marginTop: 8 },
|
||||||
versionRow: {
|
versionRow: {
|
||||||
@@ -162,13 +213,28 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
},
|
},
|
||||||
updateBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' },
|
updateBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' },
|
||||||
|
cacheRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
cacheLabel: { fontSize: 14 },
|
||||||
|
cacheValue: { fontSize: 12, marginTop: 2 },
|
||||||
|
clearBtn: {
|
||||||
|
backgroundColor: '#00AEEC',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
minWidth: 80,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
clearBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||||
logoutBtn: {
|
logoutBtn: {
|
||||||
margin: 24,
|
margin: 24,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#ff4757',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
logoutText: { fontSize: 15, color: '#ff4757', fontWeight: '600' },
|
logoutText: { fontSize: 15, fontWeight: '600' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import axios from 'axios';
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import pako from 'pako';
|
import pako from 'pako';
|
||||||
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom, LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from './types';
|
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom, LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo, SearchSuggestItem, HotSearchItem } from './types';
|
||||||
import { signWbi } from '../utils/wbi';
|
import { signWbi } from '../utils/wbi';
|
||||||
import { parseDanmakuXml } from '../utils/danmaku';
|
import { parseDanmakuXml } from '../utils/danmaku';
|
||||||
|
import { getSecure } from '../utils/secureStorage';
|
||||||
|
|
||||||
const isWeb = Platform.OS === 'web';
|
const isWeb = Platform.OS === 'web';
|
||||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
@@ -46,7 +47,7 @@ const api = axios.create({
|
|||||||
|
|
||||||
api.interceptors.request.use(async (config) => {
|
api.interceptors.request.use(async (config) => {
|
||||||
const [sessdata, buvid3] = await Promise.all([
|
const [sessdata, buvid3] = await Promise.all([
|
||||||
AsyncStorage.getItem('SESSDATA'),
|
getSecure('SESSDATA'),
|
||||||
getBuvid3(),
|
getBuvid3(),
|
||||||
]);
|
]);
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
@@ -61,6 +62,23 @@ api.interceptors.request.use(async (config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Request deduplication ──────────────────────────────────────────────────
|
||||||
|
// Prevents identical concurrent requests (same URL + params) from hitting the network twice.
|
||||||
|
const inflightRequests = new Map<string, Promise<any>>();
|
||||||
|
|
||||||
|
function dedupeKey(url: string, params?: Record<string, any>): string {
|
||||||
|
return params ? `${url}?${JSON.stringify(params)}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps an async API call so that concurrent calls with the same key share one promise. */
|
||||||
|
function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const existing = inflightRequests.get(key);
|
||||||
|
if (existing) return existing as Promise<T>;
|
||||||
|
const promise = fn().finally(() => { inflightRequests.delete(key); });
|
||||||
|
inflightRequests.set(key, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
// WBI key cache (rotates ~daily)
|
// WBI key cache (rotates ~daily)
|
||||||
let wbiKeys: { imgKey: string; subKey: string } | null = null;
|
let wbiKeys: { imgKey: string; subKey: string } | null = null;
|
||||||
let wbiKeysTimestamp = 0;
|
let wbiKeysTimestamp = 0;
|
||||||
@@ -109,9 +127,11 @@ export async function getPopularVideos(pn = 1): Promise<VideoItem[]> {
|
|||||||
return res.data.data.list as VideoItem[];
|
return res.data.data.list as VideoItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVideoDetail(bvid: string): Promise<VideoItem> {
|
export function getVideoDetail(bvid: string): Promise<VideoItem> {
|
||||||
const res = await api.get('/x/web-interface/view', { params: { bvid } });
|
return dedupe(dedupeKey('/x/web-interface/view', { bvid }), async () => {
|
||||||
return res.data.data as VideoItem;
|
const res = await api.get('/x/web-interface/view', { params: { bvid } });
|
||||||
|
return res.data.data as VideoItem;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVideoRelated(bvid: string): Promise<VideoItem[]> {
|
export async function getVideoRelated(bvid: string): Promise<VideoItem[]> {
|
||||||
@@ -120,15 +140,17 @@ export async function getVideoRelated(bvid: string): Promise<VideoItem[]> {
|
|||||||
return items as VideoItem[];
|
return items as VideoItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlResponse> {
|
export function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlResponse> {
|
||||||
const isAndroid = Platform.OS === 'android';
|
const isAndroid = Platform.OS === 'android';
|
||||||
// 1488 = 16(DASH)|64(HDR)|128(4K)|256(杜比全景声)|1024(杜比视界)
|
// 1488 = 16(DASH)|64(HDR)|128(4K)|256(杜比全景声)|1024(杜比视界)
|
||||||
const FNVAL_ANDROID = 16 | 64 | 128 | 256 | 1024;
|
const FNVAL_ANDROID = 16 | 64 | 128 | 256 | 1024;
|
||||||
const params = isAndroid
|
const params = isAndroid
|
||||||
? { bvid, cid, qn, fnval: FNVAL_ANDROID, fourk: 1 }
|
? { bvid, cid, qn, fnval: FNVAL_ANDROID, fourk: 1 }
|
||||||
: { bvid, cid, qn, fnval: 0, platform: 'html5', fourk: 1 };
|
: { bvid, cid, qn, fnval: 0, platform: 'html5', fourk: 1 };
|
||||||
const res = await api.get('/x/player/playurl', { params });
|
return dedupe(dedupeKey('/x/player/playurl', params), async () => {
|
||||||
return res.data.data as PlayUrlResponse;
|
const res = await api.get('/x/player/playurl', { params });
|
||||||
|
return res.data.data as PlayUrlResponse;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlayUrlForDownload(
|
export async function getPlayUrlForDownload(
|
||||||
@@ -158,6 +180,47 @@ export async function getUploaderStat(mid: number): Promise<{ follower: number;
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUploaderInfo(mid: number): Promise<{ name: string; face: string; sign: string; follower: number; archiveCount: number }> {
|
||||||
|
const res = await api.get('/x/web-interface/card', { params: { mid } });
|
||||||
|
const data = res.data.data ?? {};
|
||||||
|
return {
|
||||||
|
name: data.card?.name ?? '',
|
||||||
|
face: data.card?.face ?? '',
|
||||||
|
sign: data.card?.sign ?? '',
|
||||||
|
follower: data.follower ?? 0,
|
||||||
|
archiveCount: data.archive_count ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUploaderVideos(mid: number, pn = 1, ps = 20): Promise<{ videos: VideoItem[]; total: number }> {
|
||||||
|
const res = await api.get('/x/space/wbi/arc/search', {
|
||||||
|
params: { mid, pn, ps, order: 'pubdate', platform: 'web' },
|
||||||
|
});
|
||||||
|
const vlist: any[] = res.data?.data?.list?.vlist ?? [];
|
||||||
|
const total: number = res.data?.data?.page?.count ?? 0;
|
||||||
|
const videos: VideoItem[] = vlist.map((v: any) => ({
|
||||||
|
bvid: v.bvid,
|
||||||
|
aid: v.aid ?? 0,
|
||||||
|
title: v.title,
|
||||||
|
pic: v.pic ? (v.pic.startsWith('//') ? `https:${v.pic}` : v.pic) : '',
|
||||||
|
owner: { mid, name: v.author ?? '', face: '' },
|
||||||
|
stat: {
|
||||||
|
view: v.play ?? 0,
|
||||||
|
danmaku: v.video_review ?? 0,
|
||||||
|
reply: v.comment ?? 0,
|
||||||
|
like: 0,
|
||||||
|
coin: 0,
|
||||||
|
favorite: 0,
|
||||||
|
},
|
||||||
|
duration: v.length ? parseDuration(v.length) : 0,
|
||||||
|
desc: v.description ?? '',
|
||||||
|
cid: v.cid ?? 0,
|
||||||
|
pages: [],
|
||||||
|
ugc_season: undefined,
|
||||||
|
}));
|
||||||
|
return { videos, total };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserInfo(): Promise<{ face: string; uname: string; mid: number }> {
|
export async function getUserInfo(): Promise<{ face: string; uname: string; mid: number }> {
|
||||||
const res = await api.get('/x/web-interface/nav');
|
const res = await api.get('/x/web-interface/nav');
|
||||||
const { face, uname, mid } = res.data.data;
|
const { face, uname, mid } = res.data.data;
|
||||||
@@ -341,10 +404,12 @@ function parseDuration(s: string): number {
|
|||||||
return parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0] * 3600 + parts[1] * 60 + parts[2];
|
return parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchVideos(keyword: string, page = 1): Promise<VideoItem[]> {
|
export async function searchVideos(keyword: string, page = 1, order = ''): Promise<VideoItem[]> {
|
||||||
const { imgKey, subKey } = await getWbiKeys();
|
const { imgKey, subKey } = await getWbiKeys();
|
||||||
|
const params: Record<string, any> = { keyword, search_type: 'video', page, page_size: 20 };
|
||||||
|
if (order) params.order = order;
|
||||||
const signed = signWbi(
|
const signed = signWbi(
|
||||||
{ keyword, search_type: 'video', page, page_size: 20 },
|
params,
|
||||||
imgKey,
|
imgKey,
|
||||||
subKey,
|
subKey,
|
||||||
);
|
);
|
||||||
@@ -385,7 +450,6 @@ export async function getLiveDanmakuHistory(roomId: number): Promise<{
|
|||||||
const room: any[] = res.data?.data?.room ?? [];
|
const room: any[] = res.data?.data?.room ?? [];
|
||||||
const admin: any[] = res.data?.data?.admin ?? [];
|
const admin: any[] = res.data?.data?.admin ?? [];
|
||||||
const adminMsgs = admin.map((a: any) => a.text ?? '').filter(Boolean);
|
const adminMsgs = admin.map((a: any) => a.text ?? '').filter(Boolean);
|
||||||
console.log(adminMsgs,'adminMsgs')
|
|
||||||
const danmakus = room.map((m: any) => ({
|
const danmakus = room.map((m: any) => ({
|
||||||
time: 0,
|
time: 0,
|
||||||
mode: 1 as const,
|
mode: 1 as const,
|
||||||
@@ -460,3 +524,31 @@ export async function getFollowedLiveRooms(): Promise<LiveRoom[]> {
|
|||||||
parent_area_name: r.area_v2_parent_name ?? '',
|
parent_area_name: r.area_v2_parent_name ?? '',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSearchSuggest(term: string): Promise<SearchSuggestItem[]> {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/x/web-interface/search/suggest', {
|
||||||
|
params: { term, main_ver: 'v1', highlight: '' },
|
||||||
|
});
|
||||||
|
const tags: any[] = res.data?.result?.tag ?? [];
|
||||||
|
return tags.map((t: any) => ({ value: t.value ?? '', ref: t.ref ?? 0 }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHotSearch(): Promise<HotSearchItem[]> {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/x/web-interface/wbi/search/square', {
|
||||||
|
params: { limit: 10 },
|
||||||
|
});
|
||||||
|
const trending: any[] = res.data?.data?.trending?.list ?? [];
|
||||||
|
return trending.map((t: any) => ({
|
||||||
|
keyword: t.keyword ?? '',
|
||||||
|
show_name: t.show_name ?? t.keyword ?? '',
|
||||||
|
icon: t.icon,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
63
utils/cache.ts
Normal file
63
utils/cache.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
|
||||||
|
/** Get total size (bytes) of a directory recursively */
|
||||||
|
async function dirSize(dirUri: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const info = await FileSystem.getInfoAsync(dirUri);
|
||||||
|
if (!info.exists) return 0;
|
||||||
|
const entries = await FileSystem.readDirectoryAsync(dirUri);
|
||||||
|
let total = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryUri = dirUri.endsWith('/') ? `${dirUri}${entry}` : `${dirUri}/${entry}`;
|
||||||
|
const entryInfo = await FileSystem.getInfoAsync(entryUri, { size: true });
|
||||||
|
if (!entryInfo.exists) continue;
|
||||||
|
if (entryInfo.isDirectory) {
|
||||||
|
total += await dirSize(entryUri);
|
||||||
|
} else {
|
||||||
|
total += (entryInfo as any).size ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format bytes to human-readable string */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate image cache size (expo-image uses its own cache dirs) */
|
||||||
|
export async function getImageCacheSize(): Promise<number> {
|
||||||
|
if (Platform.OS === 'web') return 0;
|
||||||
|
const cacheDir = FileSystem.cacheDirectory ?? '';
|
||||||
|
return dirSize(cacheDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear expo-image disk cache + expo-file-system cache directory */
|
||||||
|
export async function clearImageCache(): Promise<void> {
|
||||||
|
if (Platform.OS === 'web') return;
|
||||||
|
try {
|
||||||
|
const { Image } = require('expo-image');
|
||||||
|
await Image.clearDiskCache();
|
||||||
|
await Image.clearMemoryCache();
|
||||||
|
} catch {}
|
||||||
|
// Also clear the general cache directory (MPD files, QR codes, etc.)
|
||||||
|
try {
|
||||||
|
const cacheDir = FileSystem.cacheDirectory ?? '';
|
||||||
|
const entries = await FileSystem.readDirectoryAsync(cacheDir);
|
||||||
|
await Promise.all(
|
||||||
|
entries.map(async entry => {
|
||||||
|
const uri = `${cacheDir}${entry}`;
|
||||||
|
try {
|
||||||
|
await FileSystem.deleteAsync(uri, { idempotent: true });
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
32
utils/secureStorage.ts
Normal file
32
utils/secureStorage.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
// expo-secure-store is only available on native platforms
|
||||||
|
let SecureStore: typeof import('expo-secure-store') | null = null;
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
try { SecureStore = require('expo-secure-store'); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a sensitive value from SecureStore (native) or AsyncStorage (web fallback). */
|
||||||
|
export async function getSecure(key: string): Promise<string | null> {
|
||||||
|
if (SecureStore) {
|
||||||
|
return SecureStore.getItemAsync(key);
|
||||||
|
}
|
||||||
|
return AsyncStorage.getItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSecure(key: string, value: string): Promise<void> {
|
||||||
|
if (SecureStore) {
|
||||||
|
await SecureStore.setItemAsync(key, value);
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSecure(key: string): Promise<void> {
|
||||||
|
if (SecureStore) {
|
||||||
|
await SecureStore.deleteItemAsync(key);
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user