feat: 深色模式完善 + UP主主页 + 缓存管理

- 深色模式:补全 settings 页选项按钮、退出登录按钮主题色
- UP主主页:新增 /creator/[mid] 路由,展示 UP 主信息、粉丝数、视频列表
  - bilibili.ts 新增 getUploaderInfo / getUploaderVideos API
  - 视频详情页 UP 主行可点击跳转主页
- 缓存管理:settings 页新增「存储」分区,显示缓存大小并支持一键清除
  - 新增 utils/cache.ts(计算大小 + 清除 expo-image/文件系统缓存)
This commit is contained in:
Developer
2026-03-26 00:06:41 +08:00
parent 8a45211fd6
commit 8f733b6c34
7 changed files with 502 additions and 27 deletions

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function CreatorLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -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' },
}); });

View File

@@ -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> {
return dedupe(dedupeKey('/x/web-interface/view', { bvid }), async () => {
const res = await api.get('/x/web-interface/view', { params: { bvid } }); const res = await api.get('/x/web-interface/view', { params: { bvid } });
return res.data.data as VideoItem; 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 };
return dedupe(dedupeKey('/x/player/playurl', params), async () => {
const res = await api.get('/x/player/playurl', { params }); const res = await api.get('/x/player/playurl', { params });
return res.data.data as PlayUrlResponse; 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
View 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
View 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);
}
}