diff --git a/app/index.tsx b/app/index.tsx index 6115efd..31ac6b2 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -18,8 +18,8 @@ import { FlatList, ScrollView, Linking, - useWindowDimensions, } from "react-native"; +import PagerView from "react-native-pager-view"; import { SafeAreaView, useSafeAreaInsets, @@ -83,10 +83,9 @@ export default function HomeScreen() { const [activeTab, setActiveTab] = useState("hot"); const [liveAreaId, setLiveAreaId] = useState(0); - const { width: SCREEN_W } = useWindowDimensions(); const [visibleBigKey, setVisibleBigKey] = useState(null); const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]); - const tabAnim = useRef(new Animated.Value(0)).current; + const pagerRef = useRef(null); const hotListRef = useRef(null); const liveListRef = useRef(null); @@ -163,20 +162,22 @@ export default function HomeScreen() { return; } // 切换 tab + pagerRef.current?.setPage(key === "hot" ? 0 : 1); setActiveTab(key); - Animated.timing(tabAnim, { - toValue: key === "hot" ? 0 : 1, - duration: 250, - useNativeDriver: true, - }).start(); if (key === "live" && rooms.length === 0) { liveLoad(true, liveAreaId); } - // 重置 header 动画 - if (key === "hot") { - scrollY.setValue(0); - } else { - liveScrollY.setValue(0); + }, + [activeTab, rooms.length, liveAreaId], + ); + + const onPageSelected = useCallback( + (e: any) => { + const key: TabKey = e.nativeEvent.position === 0 ? "hot" : "live"; + if (key === activeTab) return; + setActiveTab(key); + if (key === "live" && rooms.length === 0) { + liveLoad(true, liveAreaId); } }, [activeTab, rooms.length, liveAreaId], @@ -192,13 +193,16 @@ export default function HomeScreen() { [liveAreaId, liveLoad], ); + const visibleBigKeyRef = useRef(visibleBigKey); + visibleBigKeyRef.current = visibleBigKey; + const renderItem = useCallback( ({ item: row }: { item: ListRow }) => { if (row.type === "big") { return ( router.push(`/video/${row.item.bvid}` as any)} /> ); @@ -237,18 +241,18 @@ export default function HomeScreen() { ); }, - [visibleBigKey], + [], ); const renderLiveItem = useCallback( ({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => ( - + {item.right && ( - + )} @@ -270,127 +274,121 @@ export default function HomeScreen() { const currentHeaderOpacity = activeTab === "hot" ? headerOpacity : liveHeaderOpacity; - const tabSlideX = tabAnim.interpolate({ - inputRange: [0, 1], - outputRange: [0, -SCREEN_W], - }); - return ( {/* 滑动切换容器 */} - - - {/* 热门列表 */} - - - row.type === "big" - ? `big-${row.item.bvid}` - : row.type === "live" - ? `live-${row.left.roomid}-${row.right?.roomid ?? "empty"}` - : `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}` - } - contentContainerStyle={{ - paddingTop: insets.top + NAV_H + 6, - paddingBottom: insets.bottom + 16, - }} - renderItem={renderItem} - refreshControl={ - - } - onEndReached={() => load()} - onEndReachedThreshold={0.5} - extraData={visibleBigKey} - viewabilityConfig={VIEWABILITY_CONFIG} - onViewableItemsChanged={onViewableItemsChangedRef} - ListFooterComponent={ - - {loading && } - - } - onScroll={onScroll} - scrollEventThrottle={16} - /> - + + {/* 热门列表 */} + + + row.type === "big" + ? `big-${row.item.bvid}` + : row.type === "live" + ? `live-${row.left.roomid}-${row.right?.roomid ?? "empty"}` + : `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}` + } + contentContainerStyle={{ + paddingTop: insets.top + NAV_H + 6, + paddingBottom: insets.bottom + 16, + }} + renderItem={renderItem} + refreshControl={ + + } + onEndReached={() => load()} + onEndReachedThreshold={0.5} + extraData={visibleBigKey} + viewabilityConfig={VIEWABILITY_CONFIG} + onViewableItemsChanged={onViewableItemsChangedRef} + ListFooterComponent={ + + {loading && } + + } + onScroll={onScroll} + scrollEventThrottle={16} + /> + - {/* 直播列表 */} - - - `live-${index}-${item.left.roomid}-${item.right?.roomid ?? "empty"}` - } - contentContainerStyle={{ - paddingTop: insets.top + NAV_H + 6, - paddingBottom: insets.bottom + 16, - }} - renderItem={renderLiveItem} - ListHeaderComponent={ - - {LIVE_AREAS.map((area) => ( - + + `live-${index}-${item.left.roomid}-${item.right?.roomid ?? "empty"}` + } + contentContainerStyle={{ + paddingTop: insets.top + NAV_H + 6, + paddingBottom: insets.bottom + 16, + }} + renderItem={renderLiveItem} + ListHeaderComponent={ + + {LIVE_AREAS.map((area) => ( + handleLiveAreaPress(area.id)} + activeOpacity={0.7} + > + handleLiveAreaPress(area.id)} - activeOpacity={0.7} > - - {area.name} - - - ))} - - } - refreshControl={ - liveRefresh(liveAreaId)} - progressViewOffset={insets.top + NAV_H} - /> - } - onEndReached={() => liveLoad()} - onEndReachedThreshold={1.5} - ListFooterComponent={ - liveLoading ? ( - - - 加载中... - - ) : null - } - onScroll={onLiveScroll} - scrollEventThrottle={16} - /> - - - + {area.name} + + + ))} + + } + refreshControl={ + liveRefresh(liveAreaId)} + progressViewOffset={insets.top + NAV_H} + /> + } + onEndReached={() => liveLoad()} + onEndReachedThreshold={1.5} + ListFooterComponent={ + liveLoading ? ( + + + 加载中... + + ) : null + } + onScroll={onLiveScroll} + scrollEventThrottle={16} + /> + + {/* 绝对定位导航栏 */} void; } -export function BigVideoCard({ item, isVisible, onPress }: Props) { +export const BigVideoCard = React.memo(function BigVideoCard({ + item, + isVisible, + isScrolling, + onPress, +}: Props) { const { width: SCREEN_W } = useWindowDimensions(); const THUMB_H = SCREEN_W * 0.5625; const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H }; @@ -74,9 +86,9 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { thumbOpacity.setValue(1); }, [item.bvid]); - // Fetch play URL when visible for the first time + // Preload: fetch play URL on mount (before card is visible) useEffect(() => { - if (!isVisible || videoUrl) return; + if (videoUrl) return; let cancelled = false; (async () => { try { @@ -106,24 +118,36 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { return () => { cancelled = true; }; - }, [isVisible, item.bvid]); + }, [item.bvid]); - // Pause/resume when visibility changes + // Pause/resume based on visibility and scroll state useEffect(() => { if (!videoUrl) return; - setPaused(!isVisible); if (!isVisible) { + // Off-screen: pause, mute, show thumbnail + setPaused(true); setMuted(true); Animated.timing(thumbOpacity, { toValue: 1, duration: 150, useNativeDriver: true, }).start(); + } else if (isScrolling) { + // Visible but scrolling: just pause (keep thumbnail hidden, keep mute state) + setPaused(true); + } else { + // Visible and not scrolling: play, fade out thumbnail + setPaused(false); + Animated.timing(thumbOpacity, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(); } - }, [isVisible, videoUrl]); + }, [isVisible, isScrolling, videoUrl]); const handleVideoReady = () => { - if (!isVisible) return; + if (!isVisible || isScrolling) return; setPaused(false); Animated.timing(thumbOpacity, { toValue: 0, @@ -142,44 +166,62 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { // Horizontal swipe to seek const swipeStartTime = useRef(0); - const panResponder = useRef( - PanResponder.create({ - onMoveShouldSetPanResponder: (_, gs) => - Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy), - onMoveShouldSetPanResponderCapture: (_, gs) => - Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy), - onPanResponderGrant: () => { - seekingRef.current = true; - swipeStartTime.current = currentTimeRef.current; - }, - onPanResponderMove: (_, gs) => { - if (durationRef.current <= 0) return; - const delta = (gs.dx / SCREEN_W) * SWIPE_SECONDS; - const target = clamp(swipeStartTime.current + delta, 0, durationRef.current); - setSeekLabel(formatDuration(Math.floor(target))); - }, - onPanResponderRelease: (_, gs) => { - if (durationRef.current > 0) { - const delta = (gs.dx / SCREEN_W) * SWIPE_SECONDS; - const target = clamp(swipeStartTime.current + delta, 0, durationRef.current); - videoRef.current?.seek(target); - setCurrentTime(target); - } - seekingRef.current = false; - setSeekLabel(null); - }, - onPanResponderTerminate: () => { - seekingRef.current = false; - setSeekLabel(null); - }, - }), - ).current; + const screenWRef = useRef(SCREEN_W); + useEffect(() => { + screenWRef.current = SCREEN_W; + }, [SCREEN_W]); + + const panResponder = useMemo( + () => + PanResponder.create({ + onStartShouldSetPanResponderCapture: () => true, + onMoveShouldSetPanResponder: (_, gs) => + Math.abs(gs.dx) > SWIPE_THRESHOLD && + Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5, + onPanResponderGrant: () => { + seekingRef.current = true; + swipeStartTime.current = currentTimeRef.current; + }, + onMoveShouldSetPanResponderCapture: (_, gs) => + Math.abs(gs.dx) > SWIPE_THRESHOLD && + Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5, + onPanResponderMove: (_, gs) => { + if (durationRef.current <= 0) return; + const delta = (gs.dx / screenWRef.current) * SWIPE_SECONDS; + const target = clamp( + swipeStartTime.current + delta, + 0, + durationRef.current, + ); + setSeekLabel(formatDuration(Math.floor(target))); + }, + onPanResponderRelease: (_, gs) => { + if (durationRef.current > 0) { + const delta = (gs.dx / screenWRef.current) * SWIPE_SECONDS; + const target = clamp( + swipeStartTime.current + delta, + 0, + durationRef.current, + ); + videoRef.current?.seek(target); + setCurrentTime(target); + } + seekingRef.current = false; + setSeekLabel(null); + }, + onPanResponderTerminate: () => { + seekingRef.current = false; + setSeekLabel(null); + }, + }), + [], + ); const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0; const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0; return ( - + {/* Media area */} {/* Video player — rendered first so it sits behind the thumbnail */} @@ -194,11 +236,15 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { style={StyleSheet.absoluteFill} resizeMode="cover" muted={muted} - paused={paused} + paused={paused || seekingRef.current} repeat controls={false} onReadyForDisplay={handleVideoReady} - onProgress={({ currentTime: ct, seekableDuration: dur, playableDuration: buf }) => { + onProgress={({ + currentTime: ct, + seekableDuration: dur, + playableDuration: buf, + }) => { if (!seekingRef.current) setCurrentTime(ct); if (dur > 0) setDuration(dur); setBuffered(buf); @@ -219,12 +265,17 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { {/* Swipe gesture layer */} - + {/* Seek time label */} {seekLabel && ( - {seekLabel} + + {seekLabel} / {formatDuration(durationRef.current)} + )} @@ -264,32 +315,39 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { )} - {/* Info */} - - - {item.title} - + + + + {item.title} + - - {item.owner?.name ?? ""} - - - + + {item.owner?.name ?? ""} + + + + ); -} +}); const styles = StyleSheet.create({ card: { @@ -320,7 +378,7 @@ const styles = StyleSheet.create({ height: 28, alignItems: "center", justifyContent: "center", - zIndex: 3, + zIndex: 6, }, seekBadge: { position: "absolute", diff --git a/components/LiveCard.tsx b/components/LiveCard.tsx index 08878f9..ba335d7 100644 --- a/components/LiveCard.tsx +++ b/components/LiveCard.tsx @@ -23,7 +23,7 @@ interface Props { fullWidth?: boolean; } -export function LiveCard({ +export const LiveCard = React.memo(function LiveCard({ item, onPress, fullWidth, @@ -73,7 +73,7 @@ export function LiveCard({ ); -} +}); const styles = StyleSheet.create({ card: { diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index 69b21c6..4a9ea5d 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -38,10 +38,15 @@ export function LoginModal({ visible, onClose }: Props) { if (result.code === 86090) setStatus('scanned'); if (result.code === 0 && result.cookie) { clearInterval(pollRef.current!); - await login(result.cookie, '', ''); - setStatus('done'); - // 登录后异步拉取用户头像和昵称 - getUserInfo().then(info => setProfile(info.face, info.uname, String(info.mid))).catch(() => {}); + try { + await login(result.cookie, '', ''); + setStatus('done'); + // 登录后异步拉取用户头像和昵称 + const info = await getUserInfo(); + setProfile(info.face, info.uname, String(info.mid)); + } catch { + setStatus('error'); + } onClose(); } }, 2000); diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx index 0e892d1..17a87b8 100644 --- a/components/MiniPlayer.tsx +++ b/components/MiniPlayer.tsx @@ -1,7 +1,7 @@ import React, { useRef } from 'react'; import { View, Text, Image, StyleSheet, TouchableOpacity, - Animated, PanResponder, + Animated, PanResponder, Dimensions, } from 'react-native'; import { useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -9,6 +9,9 @@ import { Ionicons } from '@expo/vector-icons'; import { useVideoStore } from '../store/videoStore'; import { proxyImageUrl } from '../utils/imageUrl'; +const MINI_W = 160; +const MINI_H = 90; + export function MiniPlayer() { const { isActive, bvid, title, cover, clearVideo } = useVideoStore(); const router = useRouter(); @@ -18,14 +21,23 @@ export function MiniPlayer() { const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, - onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }), - onPanResponderRelease: () => { - pan.flattenOffset(); - }, onPanResponderGrant: () => { pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value }); pan.setValue({ x: 0, y: 0 }); }, + onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }), + onPanResponderRelease: () => { + pan.flattenOffset(); + // Clamp to screen bounds + const { width: sw, height: sh } = Dimensions.get('window'); + const curX = (pan.x as any)._value; + const curY = (pan.y as any)._value; + const clampedX = Math.max(-sw + MINI_W + 12, Math.min(12, curX)); + const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY)); + if (curX !== clampedX || curY !== clampedY) { + Animated.spring(pan, { toValue: { x: clampedX, y: clampedY }, useNativeDriver: false }).start(); + } + }, }) ).current; diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index 5cc2b37..e0d2ae8 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -41,94 +41,31 @@ function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); } -function decodePvBuffer(buffer: ArrayBuffer): number[] { - const bytes = new Uint8Array(buffer); - const view = new DataView(buffer); - const timestamps: number[] = []; +function decodePvBuffer(bytes: Uint8Array): number[] { + // B站 pvdata 格式:packed uint16 little-endian,每个值 = 帧时间戳(单位:10ms) + const result: number[] = []; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - let i = 0; - while (i < bytes.length) { - const tag = bytes[i++]; - const wireType = tag & 0x07; - const fieldNum = tag >> 3; - - // 我们主要关心 repeated float32,通常 field 1 或直接数据 - if (wireType === 5) { - // fixed32 / float32 - if (i + 4 > bytes.length) break; - timestamps.push(view.getFloat32(i, true)); // little-endian - i += 4; - } else if (wireType === 2) { - // length-delimited → 进入子消息或 packed repeated - let len = 0; - let shift = 0; - while (true) { - if (i >= bytes.length) break; - const b = bytes[i++]; - len |= (b & 0x7f) << shift; - shift += 7; - if (!(b & 0x80)) break; - } - const end = i + len; - // packed repeated float32 最常见情况:直接连续 float32 - while (i + 4 <= end) { - timestamps.push(view.getFloat32(i, true)); - i += 4; - } - // 如果不是 packed,也跳过 - } else if (wireType === 0) { - // varint - while (i < bytes.length && bytes[i++] & 0x80); - } else if (wireType === 1) { - // fixed64 - i += 8; - } else { - break; // 未知类型,停止 - } + for (let i = 0; i + 1 < bytes.byteLength; i += 2) { + // uint16 值除以 100 转换为秒 + result.push(view.getUint16(i, true) / 100); } - // 过滤掉明显异常值(比如负数或极大值) - return timestamps.filter((t) => t >= 0 && t < 86400); // 视频不会超过24小时 + return result; } -async function loadPvData(url: string) { +async function loadPvData(url: string): Promise { const realUrl = url.startsWith("//") ? `https:${url}` : url; - - try { - // 选择缓存目录下的一个子目录(避免污染根缓存) - const cacheDir = new Directory(Paths.cache, "bili_pvdata"); - - // 如果目录不存在,创建(intermediates: true 自动创建父目录) - if (!cacheDir.exists) { - await cacheDir.create({ intermediates: true }); - } - - // 下载文件到这个目录(会自动用远程文件名,或你可以指定 File) - // 这里用 Directory 作为 destination,SDK 会从 URL 或 header 推导文件名 - const downloadedFile: File = await File.downloadFileAsync( - realUrl, - cacheDir, - { - headers: HEADERS, - idempotent: true, // 如果文件已存在,覆盖(避免重复下载失败) - }, - ); - console.log("Downloaded to:", downloadedFile.uri); - // 读取为 base64(如果你原来的 decodeFloats/decodePvBuffer 用 base64) - // const base64 = await downloadedFile.base64(); - // 更好:直接读 binary 为 Uint8Array,然后转 ArrayBuffer - const bytes: Uint8Array = await downloadedFile.bytes(); - const nums = new Uint16Array( - bytes.buffer, - bytes.byteOffset, - bytes.byteLength / 2, - ); - - return nums; - } catch (error) { - console.error("loadPvData failed:", error); - throw error; + const cacheDir = new Directory(Paths.cache, "bili_pvdata"); + if (!cacheDir.exists) { + await cacheDir.create({ intermediates: true }); } + const downloadedFile = await File.downloadFileAsync(realUrl, cacheDir, { + headers: HEADERS, + idempotent: true, + }); + const bytes: Uint8Array = await downloadedFile.bytes(); + return decodePvBuffer(bytes); } function findFrameIdx(timestamps: number[], seekTime: number): number { @@ -231,13 +168,9 @@ export function NativeVideoPlayer({ if (shotData?.image?.length) { setShots(shotData); if (shotData.pvdata) { - try { - loadPvData(shotData.pvdata).then((r) => { - setShotTimestamps(r); - }); - } catch { - setShotTimestamps([]); - } + loadPvData(shotData.pvdata) + .then((r) => setShotTimestamps(r)) + .catch(() => setShotTimestamps([])); } } }); @@ -351,10 +284,11 @@ export function NativeVideoPlayer({ // Use pvdata timestamps for accurate frame lookup; fall back to linear interpolation const seekTime = touchRatio * duration; - const frameIdx = + const rawIdx = shotTimestamps.length > 0 ? findFrameIdx(shotTimestamps, seekTime) : Math.floor(touchRatio * (totalFrames - 1)); + const frameIdx = clamp(rawIdx, 0, totalFrames - 1); const sheetIdx = Math.floor(frameIdx / framesPerSheet); const local = frameIdx % framesPerSheet; @@ -421,7 +355,11 @@ export function NativeVideoPlayer({ resizeMode="contain" controls={false} paused={paused} - onProgress={({ currentTime: ct, seekableDuration: dur, playableDuration: buf }) => { + onProgress={({ + currentTime: ct, + seekableDuration: dur, + playableDuration: buf, + }) => { setCurrentTime(ct); if (dur > 0) setDuration(dur); setBuffered(buf); @@ -505,14 +443,20 @@ export function NativeVideoPlayer({ {/* Played: accent color on top */} diff --git a/components/VideoCard.tsx b/components/VideoCard.tsx index 35f0398..e694de1 100644 --- a/components/VideoCard.tsx +++ b/components/VideoCard.tsx @@ -20,7 +20,7 @@ interface Props { onPress: () => void; } -export function VideoCard({ item, onPress }: Props) { +export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) { return ( ); -} +}); const styles = StyleSheet.create({ card: { diff --git a/feature.md b/feature.md new file mode 100644 index 0000000..805722a --- /dev/null +++ b/feature.md @@ -0,0 +1,154 @@ +# 代码审查:优化建议与 Bug 清单 + +## 严重 Bug + +### 1. ~~useComments 竞态条件~~ [已修复] +- **文件**: `hooks/useComments.ts` +- **问题**: `load()` 依赖 `[aid, page, loading, hasMore]`,`setPage(p => p + 1)` 触发新的 load 回调,可能重复请求或跳页 +- **修复**: 改用 `pageRef` + `loadingRef` 避免依赖循环 + +### 2. ~~LoginModal 错误处理缺失~~ [已修复] +- **文件**: `components/LoginModal.tsx` +- **问题**: `login(result.cookie, '', '')` 传入空 uid/username;`getUserInfo()` 调用无错误处理 +- **修复**: 添加 try-catch 包裹登录+拉取用户信息流程 + +### 3. ~~NativeVideoPlayer loadPvData 未捕获 Promise 异常~~ [已修复] +- **文件**: `components/NativeVideoPlayer.tsx` +- **问题**: `loadPvData().then(...)` 没有 `.catch()`,且原来的 try-catch 无法捕获异步 Promise +- **修复**: 改为 `.then().catch()` 链式调用 + +### 4. BigVideoCard PanResponder 闭包陈旧 +- **文件**: `components/BigVideoCard.tsx` +- **问题**: `useMemo` 依赖为 `[]`,但内部使用 `currentTimeRef`、`durationRef`、`screenWRef`,重构时容易引入 bug +- **状态**: 当前使用 ref 所以不会出错,但属于脆弱模式 + +## 性能问题 + +### 5. ~~index.tsx renderItem 未稳定~~ [已修复] +- **文件**: `app/index.tsx` +- **问题**: `renderItem` 依赖 `[visibleBigKey]`,每次可见项变化都重建回调 +- **修复**: 改用 `visibleBigKeyRef` + 空依赖 `useCallback` + +### 6. BigVideoCard 视频预加载无取消机制 +- **文件**: `components/BigVideoCard.tsx` +- **问题**: 快速滑动时,所有可见卡片同时发起 `getPlayUrl` 请求,无取消/去抖 +- **修复**: 增加可见性停留阈值或 AbortController + +### 7. DanmakuList 动画值频繁创建 +- **文件**: `components/DanmakuList.tsx` +- **问题**: 每次 drip interval 都创建新的 `Animated.Value`,弹幕密集时影响性能 +- **修复**: 使用对象池复用 Animated.Value + +### 8. LivePulse 动画堆叠 +- **文件**: `components/LivePulse.tsx` +- **问题**: 组件快速卸载/重新挂载时,`Animated.loop()` 动画可能堆叠 + +## 错误处理 + +### 9. 缺少全局错误边界 +- **文件**: `app/_layout.tsx` +- **问题**: 没有 React Error Boundary,任何组件抛异常会导致白屏 +- **修复**: 在根布局包裹 ErrorBoundary 组件 + +### 10. ~~WBI Keys 缓存无过期~~ [已修复] +- **文件**: `services/bilibili.ts` +- **问题**: `wbiKeys` 模块级变量缓存后永不过期,B站 WBI 密钥每日更换 +- **修复**: 添加 12 小时 TTL,过期后自动刷新;网络错误时回退到陈旧缓存 + +### 11. ~~getWbiKeys 无容错~~ [已修复] +- **文件**: `services/bilibili.ts` +- **问题**: 直接访问 `res.data.data.wbi_img`,结构不匹配时直接崩溃 +- **修复**: 添加可选链检查 + try-catch + 陈旧缓存回退 + +### 12. ~~Cookie 解析脆弱~~ [已修复] +- **文件**: `services/bilibili.ts` +- **问题**: `split(';')[0].replace('SESSDATA=', '')` 假设 SESSDATA 是第一个 cookie 属性 +- **修复**: 改为 `.find()` 查找包含 `SESSDATA=` 的 part + +### 13. ~~useVideoDetail 静默吞错~~ [已修复] +- **文件**: `hooks/useVideoDetail.ts` +- **问题**: `.catch(() => {})` 吞掉重新获取播放数据时的错误,用户无感知 +- **修复**: 改为 `console.warn` 输出 + +## 代码质量 + +### 14. ~~残留调试代码~~ [已修复] +- **文件**: `components/NativeVideoPlayer.tsx` +- **问题**: `console.log(r, "ddddddddddddd")` 和未使用的 `axios` 导入 +- **修复**: 已移除 + +### 15. 类型安全问题 +- **文件**: `app/index.tsx` +- **问题**: `router.push(\`/video/${bvid}\` as any)` 使用 `as any` 绕过类型检查 +- **修复**: 正确定义路由类型 + +### 16. 未使用的导入 +- **文件**: `components/VideoPlayer.tsx` +- **问题**: `Paths` 从 `expo-file-system` 导入但未使用 + +### 17. 魔法数字散落 +- **文件**: 多处 +- **问题**: `SWIPE_SECONDS = 90`、`HIDE_DELAY = 3000`、`DRIP_INTERVAL = 250` 等硬编码 +- **修复**: 统一到 constants 文件 + +## API 使用 + +### 18. 无请求去重 +- **文件**: `services/bilibili.ts` +- **问题**: BigVideoCard 和 NativeVideoPlayer 可能同时为同一视频发起 `getPlayUrl` 请求 +- **修复**: 实现请求去重(相同参数复用 Promise) + +### 19. 无请求超时重试 +- **文件**: `services/bilibili.ts` +- **问题**: 设置了 `timeout: 10000` 但无重试逻辑,弱网下直接失败 + +## UI/UX + +### 20. ~~MiniPlayer 拖拽无边界~~ [已修复] +- **文件**: `components/MiniPlayer.tsx` +- **问题**: 拖拽可将迷你播放器移出屏幕外,无边界限制 +- **修复**: 释放时 clamp 到屏幕范围内,超出部分用 spring 动画弹回 + +### 21. 无网络状态检测 +- **问题**: 离线时所有 API 静默失败,用户无反馈 +- **修复**: 使用 `@react-native-community/netinfo` 检测网络并提示 + +### 22. BigVideoCard 预加载无 Loading 态 +- **问题**: 视频 URL 加载期间画面冻结,无骨架屏或加载动画 + +## 安全 + +### 23. SESSDATA 明文存储 +- **文件**: `services/bilibili.ts` / `store/authStore.ts` +- **问题**: Cookie 存储在 AsyncStorage(明文),设备被攻破可泄露 +- **修复**: 使用 `expo-secure-store` 存储敏感凭证 + +--- + +## 修复进度 + +| 优先级 | 项目 | 状态 | +|--------|------|------| +| **P0** | #1 竞态条件 | 已修复 | +| **P0** | #3 Promise 未捕获 | 已修复 | +| **P0** | #14 调试代码 | 已修复 | +| **P0** | #10 WBI 过期 | 已修复 | +| **P1** | #2 登录错误处理 | 已修复 | +| **P1** | #5 renderItem 稳定化 | 已修复 | +| **P1** | #11 getWbiKeys 容错 | 已修复 | +| **P1** | #12 Cookie 解析 | 已修复 | +| **P1** | #13 静默吞错 | 已修复 | +| **P2** | #20 MiniPlayer 边界 | 已修复 | +| **P1** | #9 错误边界 | 待修复 | +| **P2** | #6 预加载取消 | 待修复 | +| **P2** | #18 请求去重 | 待修复 | +| **P2** | #21 网络检测 | 待修复 | +| **P3** | #4 闭包陈旧 | 暂不修复(ref模式可用) | +| **P3** | #7 动画池化 | 待修复 | +| **P3** | #8 动画堆叠 | 待修复 | +| **P3** | #15 类型安全 | 待修复 | +| **P3** | #16 未使用导入 | 待修复 | +| **P3** | #17 魔法数字 | 待修复 | +| **P3** | #19 超时重试 | 待修复 | +| **P3** | #22 Loading 态 | 待修复 | +| **P3** | #23 安全存储 | 待修复 | diff --git a/hooks/useComments.ts b/hooks/useComments.ts index 8b6bb0d..ec152ff 100644 --- a/hooks/useComments.ts +++ b/hooks/useComments.ts @@ -1,27 +1,30 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { getComments } from '../services/bilibili'; import type { Comment } from '../services/types'; export function useComments(aid: number) { const [comments, setComments] = useState([]); - const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); + const pageRef = useRef(1); + const loadingRef = useRef(false); const load = useCallback(async () => { - if (loading || !hasMore || !aid) return; + if (loadingRef.current || !hasMore || !aid) return; + loadingRef.current = true; setLoading(true); try { - const data = await getComments(aid, page); + const data = await getComments(aid, pageRef.current); if (data.length === 0) { setHasMore(false); return; } setComments(prev => [...prev, ...data]); - setPage(p => p + 1); + pageRef.current += 1; } catch (e) { console.error('Failed to load comments', e); } finally { + loadingRef.current = false; setLoading(false); } - }, [aid, page, loading, hasMore]); + }, [aid, hasMore]); return { comments, loading, hasMore, load }; } diff --git a/hooks/useVideoDetail.ts b/hooks/useVideoDetail.ts index 31d51aa..a943cdb 100644 --- a/hooks/useVideoDetail.ts +++ b/hooks/useVideoDetail.ts @@ -52,7 +52,9 @@ export function useVideoDetail(bvid: string) { // 登录状态变化时重新拉取清晰度列表(登录后可能获得更高画质) useEffect(() => { if (cidRef.current) { - fetchPlayData(cidRef.current, 120, true).catch(() => {}); + fetchPlayData(cidRef.current, 120, true).catch((e) => { + console.warn('Failed to refresh quality list after login change:', e); + }); } }, [isLoggedIn]); diff --git a/package-lock.json b/package-lock.json index 0eb1ae9..9b4cbc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", + "react-native-pager-view": "8.0.0", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", "react-native-video": "^6.19.0", @@ -7841,6 +7842,16 @@ "react-native": "*" } }, + "node_modules/react-native-pager-view": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-8.0.0.tgz", + "integrity": "sha512-oAwlWT1lhTkIs9HhODnjNNl/owxzn9DP1MbP+az6OTUdgbmzA16Up83sBH8NRKwrH8rNm7iuWnX1qMqiiWOLhg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", diff --git a/package.json b/package.json index af08b63..543357e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", + "react-native-pager-view": "8.0.0", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", "react-native-video": "^6.19.0", diff --git a/services/bilibili.ts b/services/bilibili.ts index e3738c3..1c5cf9b 100644 --- a/services/bilibili.ts +++ b/services/bilibili.ts @@ -61,16 +61,28 @@ api.interceptors.request.use(async (config) => { return config; }); -// WBI key cache (rotates ~daily, reuse within process lifetime) +// WBI key cache (rotates ~daily) let wbiKeys: { imgKey: string; subKey: string } | null = null; +let wbiKeysTimestamp = 0; +const WBI_KEYS_TTL = 12 * 60 * 60 * 1000; // 12 hours async function getWbiKeys(): Promise<{ imgKey: string; subKey: string }> { - if (wbiKeys) return wbiKeys; - const res = await api.get('/x/web-interface/nav'); - const { img_url, sub_url } = res.data.data.wbi_img; - const extract = (url: string) => url.split('/').pop()!.replace(/\.\w+$/, ''); - wbiKeys = { imgKey: extract(img_url), subKey: extract(sub_url) }; - return wbiKeys; + if (wbiKeys && Date.now() - wbiKeysTimestamp < WBI_KEYS_TTL) return wbiKeys; + try { + const res = await api.get('/x/web-interface/nav'); + const wbiImg = res.data?.data?.wbi_img; + if (!wbiImg?.img_url || !wbiImg?.sub_url) { + if (wbiKeys) return wbiKeys; // fallback to stale cache + throw new Error('Failed to get WBI keys: missing wbi_img data'); + } + const extract = (url: string) => url.split('/').pop()!.replace(/\.\w+$/, ''); + wbiKeys = { imgKey: extract(wbiImg.img_url), subKey: extract(wbiImg.sub_url) }; + wbiKeysTimestamp = Date.now(); + return wbiKeys; + } catch (e) { + if (wbiKeys) return wbiKeys; // fallback to stale cache on network error + throw e; + } } export async function getRecommendFeed(freshIdx = 0): Promise { @@ -157,9 +169,12 @@ export async function pollQRCode(qrcode_key: string): Promise<{ code: number; co cookie = res.headers['x-sessdata'] as string | undefined; } else { const setCookie = res.headers['set-cookie']; - const match = setCookie?.find((c: string) => c.includes('SESSDATA')); + const match = setCookie?.find((c: string) => c.includes('SESSDATA=')); if (match) { - cookie = match.split(';')[0].replace('SESSDATA=', ''); + const sessPart = match.split(';').find((p: string) => p.trim().startsWith('SESSDATA=')); + if (sessPart) { + cookie = sessPart.trim().replace('SESSDATA=', ''); + } } } }