From 8f733b6c34803fffbd2577736c6cc02fa2574952 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 26 Mar 2026 00:06:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=B1=E8=89=B2=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=AE=8C=E5=96=84=20+=20UP=E4=B8=BB=E4=B8=BB=E9=A1=B5=20+=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 深色模式:补全 settings 页选项按钮、退出登录按钮主题色 - UP主主页:新增 /creator/[mid] 路由,展示 UP 主信息、粉丝数、视频列表 - bilibili.ts 新增 getUploaderInfo / getUploaderVideos API - 视频详情页 UP 主行可点击跳转主页 - 缓存管理:settings 页新增「存储」分区,显示缓存大小并支持一键清除 - 新增 utils/cache.ts(计算大小 + 清除 expo-image/文件系统缓存) --- app/_layout.tsx | 8 ++ app/creator/[mid].tsx | 209 ++++++++++++++++++++++++++++++++++++++++ app/creator/_layout.tsx | 5 + app/settings.tsx | 98 ++++++++++++++++--- services/bilibili.ts | 114 +++++++++++++++++++--- utils/cache.ts | 63 ++++++++++++ utils/secureStorage.ts | 32 ++++++ 7 files changed, 502 insertions(+), 27 deletions(-) create mode 100644 app/creator/[mid].tsx create mode 100644 app/creator/_layout.tsx create mode 100644 utils/cache.ts create mode 100644 utils/secureStorage.ts 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); + } +}