diff --git a/app/_layout.tsx b/app/_layout.tsx
index 6c36a55..d836add 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -85,6 +85,14 @@ function RootLayout() {
gestureDirection: "horizontal",
}}
/>
+
diff --git a/app/creator/[mid].tsx b/app/creator/[mid].tsx
new file mode 100644
index 0000000..b7a7fe5
--- /dev/null
+++ b/app/creator/[mid].tsx
@@ -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([]);
+ 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 (
+
+ {/* Top bar */}
+
+ router.back()} style={styles.backBtn}>
+
+
+
+ {info?.name ?? 'UP主主页'}
+
+
+
+
+ item.bvid}
+ showsVerticalScrollIndicator={false}
+ onEndReached={() => { if (hasMore && !loading) loadVideos(page + 1); }}
+ onEndReachedThreshold={0.3}
+ windowSize={7}
+ maxToRenderPerBatch={6}
+ removeClippedSubviews
+ ListHeaderComponent={
+ infoLoading ? (
+
+ ) : info ? (
+
+
+ {info.name}
+ {info.sign ? (
+ {info.sign}
+ ) : null}
+
+
+ {formatCount(info.follower)}
+ 粉丝
+
+
+
+ {formatCount(info.archiveCount)}
+ 视频
+
+
+
+ 全部视频({total})
+
+
+ ) : null
+ }
+ renderItem={({ item }) => (
+ router.push(`/video/${item.bvid}` as any)}
+ activeOpacity={0.85}
+ >
+
+
+
+ {formatDuration(item.duration)}
+
+
+
+
+ {item.title}
+
+
+
+ {formatCount(item.stat.view)}
+
+
+
+ )}
+ ListEmptyComponent={
+ !loading && !infoLoading ? (
+ 暂无视频
+ ) : null
+ }
+ ListFooterComponent={
+ loading ? : null
+ }
+ />
+
+ );
+}
+
+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 },
+});
diff --git a/app/creator/_layout.tsx b/app/creator/_layout.tsx
new file mode 100644
index 0000000..59b0236
--- /dev/null
+++ b/app/creator/_layout.tsx
@@ -0,0 +1,5 @@
+import { Stack } from 'expo-router';
+
+export default function CreatorLayout() {
+ return ;
+}
diff --git a/app/settings.tsx b/app/settings.tsx
index 33a0463..4e35dd4 100644
--- a/app/settings.tsx
+++ b/app/settings.tsx
@@ -1,5 +1,5 @@
-import React from 'react';
-import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
+import React, { useEffect, useState, useCallback } from 'react';
+import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@@ -7,6 +7,7 @@ import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore';
import { useTheme } from '../utils/theme';
import { useCheckUpdate } from '../hooks/useCheckUpdate';
+import { getImageCacheSize, clearImageCache, formatBytes } from '../utils/cache';
export default function SettingsScreen() {
const router = useRouter();
@@ -14,6 +15,34 @@ export default function SettingsScreen() {
const { darkMode, setDarkMode, trafficSaving, setTrafficSaving } = useSettingsStore();
const theme = useTheme();
const { currentVersion, isChecking, downloadProgress, checkUpdate } = useCheckUpdate();
+ const [cacheSize, setCacheSize] = useState(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 () => {
await logout();
@@ -63,18 +92,18 @@ export default function SettingsScreen() {
外观
setDarkMode(false)}
activeOpacity={0.7}
>
- 浅色
+ 浅色
setDarkMode(true)}
activeOpacity={0.7}
>
- 深色
+ 深色
@@ -83,18 +112,18 @@ export default function SettingsScreen() {
流量
setTrafficSaving(false)}
activeOpacity={0.7}
>
- 标准
+ 标准
setTrafficSaving(true)}
activeOpacity={0.7}
>
- 节流
+ 节流
{trafficSaving && (
@@ -104,9 +133,32 @@ export default function SettingsScreen() {
)}
+
+ 存储
+
+
+ 缓存大小
+
+ {cacheSize === null ? '计算中...' : formatBytes(cacheSize)}
+
+
+
+ {clearingCache
+ ?
+ : 清除缓存
+ }
+
+
+
+
{isLoggedIn && (
-
- 退出登录
+
+ 退出登录
)}
@@ -143,10 +195,9 @@ const styles = StyleSheet.create({
paddingHorizontal: 10,
paddingVertical: 2,
borderRadius: 16,
- backgroundColor: '#f0f0f0',
},
optionActive: { backgroundColor: '#00AEEC' },
- optionText: { fontSize: 13, color: '#333', fontWeight: '500' },
+ optionText: { fontSize: 13, fontWeight: '500' },
optionTextActive: { color: '#fff', fontWeight: '600' },
hint: { fontSize: 12, marginTop: 8 },
versionRow: {
@@ -162,13 +213,28 @@ const styles = StyleSheet.create({
paddingVertical: 6,
},
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: {
margin: 24,
paddingVertical: 12,
borderRadius: 8,
borderWidth: 1,
- borderColor: '#ff4757',
alignItems: 'center',
},
- logoutText: { fontSize: 15, color: '#ff4757', fontWeight: '600' },
+ logoutText: { fontSize: 15, fontWeight: '600' },
});
diff --git a/services/bilibili.ts b/services/bilibili.ts
index f6824fc..2a225dd 100644
--- a/services/bilibili.ts
+++ b/services/bilibili.ts
@@ -2,9 +2,10 @@ import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
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 { parseDanmakuXml } from '../utils/danmaku';
+import { getSecure } from '../utils/secureStorage';
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';
@@ -46,7 +47,7 @@ const api = axios.create({
api.interceptors.request.use(async (config) => {
const [sessdata, buvid3] = await Promise.all([
- AsyncStorage.getItem('SESSDATA'),
+ getSecure('SESSDATA'),
getBuvid3(),
]);
if (isWeb) {
@@ -61,6 +62,23 @@ api.interceptors.request.use(async (config) => {
return config;
});
+// ─── Request deduplication ──────────────────────────────────────────────────
+// Prevents identical concurrent requests (same URL + params) from hitting the network twice.
+const inflightRequests = new Map>();
+
+function dedupeKey(url: string, params?: Record): 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(key: string, fn: () => Promise): Promise {
+ const existing = inflightRequests.get(key);
+ if (existing) return existing as Promise;
+ const promise = fn().finally(() => { inflightRequests.delete(key); });
+ inflightRequests.set(key, promise);
+ return promise;
+}
+
// WBI key cache (rotates ~daily)
let wbiKeys: { imgKey: string; subKey: string } | null = null;
let wbiKeysTimestamp = 0;
@@ -109,9 +127,11 @@ export async function getPopularVideos(pn = 1): Promise {
return res.data.data.list as VideoItem[];
}
-export async function getVideoDetail(bvid: string): Promise {
- const res = await api.get('/x/web-interface/view', { params: { bvid } });
- return res.data.data as VideoItem;
+export function getVideoDetail(bvid: string): Promise {
+ return dedupe(dedupeKey('/x/web-interface/view', { bvid }), async () => {
+ const res = await api.get('/x/web-interface/view', { params: { bvid } });
+ return res.data.data as VideoItem;
+ });
}
export async function getVideoRelated(bvid: string): Promise {
@@ -120,15 +140,17 @@ export async function getVideoRelated(bvid: string): Promise {
return items as VideoItem[];
}
-export async function getPlayUrl(bvid: string, cid: number, qn = 64): Promise {
+export function getPlayUrl(bvid: string, cid: number, qn = 64): Promise {
const isAndroid = Platform.OS === 'android';
// 1488 = 16(DASH)|64(HDR)|128(4K)|256(杜比全景声)|1024(杜比视界)
const FNVAL_ANDROID = 16 | 64 | 128 | 256 | 1024;
const params = isAndroid
? { bvid, cid, qn, fnval: FNVAL_ANDROID, fourk: 1 }
: { bvid, cid, qn, fnval: 0, platform: 'html5', fourk: 1 };
- const res = await api.get('/x/player/playurl', { params });
- return res.data.data as PlayUrlResponse;
+ return dedupe(dedupeKey('/x/player/playurl', params), async () => {
+ const res = await api.get('/x/player/playurl', { params });
+ return res.data.data as PlayUrlResponse;
+ });
}
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 }> {
const res = await api.get('/x/web-interface/nav');
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];
}
-export async function searchVideos(keyword: string, page = 1): Promise {
+export async function searchVideos(keyword: string, page = 1, order = ''): Promise {
const { imgKey, subKey } = await getWbiKeys();
+ const params: Record = { keyword, search_type: 'video', page, page_size: 20 };
+ if (order) params.order = order;
const signed = signWbi(
- { keyword, search_type: 'video', page, page_size: 20 },
+ params,
imgKey,
subKey,
);
@@ -385,7 +450,6 @@ export async function getLiveDanmakuHistory(roomId: number): Promise<{
const room: any[] = res.data?.data?.room ?? [];
const admin: any[] = res.data?.data?.admin ?? [];
const adminMsgs = admin.map((a: any) => a.text ?? '').filter(Boolean);
- console.log(adminMsgs,'adminMsgs')
const danmakus = room.map((m: any) => ({
time: 0,
mode: 1 as const,
@@ -460,3 +524,31 @@ export async function getFollowedLiveRooms(): Promise {
parent_area_name: r.area_v2_parent_name ?? '',
}));
}
+
+export async function getSearchSuggest(term: string): Promise {
+ 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 {
+ 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 [];
+ }
+}
diff --git a/utils/cache.ts b/utils/cache.ts
new file mode 100644
index 0000000..c2335b5
--- /dev/null
+++ b/utils/cache.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {}
+}
diff --git a/utils/secureStorage.ts b/utils/secureStorage.ts
new file mode 100644
index 0000000..67c15c5
--- /dev/null
+++ b/utils/secureStorage.ts
@@ -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 {
+ if (SecureStore) {
+ return SecureStore.getItemAsync(key);
+ }
+ return AsyncStorage.getItem(key);
+}
+
+export async function setSecure(key: string, value: string): Promise {
+ if (SecureStore) {
+ await SecureStore.setItemAsync(key, value);
+ } else {
+ await AsyncStorage.setItem(key, value);
+ }
+}
+
+export async function deleteSecure(key: string): Promise {
+ if (SecureStore) {
+ await SecureStore.deleteItemAsync(key);
+ } else {
+ await AsyncStorage.removeItem(key);
+ }
+}