diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index 6b1e17d..17f3e7e 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -11,11 +11,16 @@ import { buildDashMpdUri } from '../utils/dash'; import { getHeatmap, getVideoShot } from '../services/bilibili'; const { width: SCREEN_W } = Dimensions.get('window'); +// 16:9 视频区域高度,适配不同屏幕宽度 const VIDEO_H = SCREEN_W * 0.5625; const BAR_H = 3; +// 进度球尺寸 const BALL = 12; +// 活跃状态下的拖动球增大尺寸,提升触控体验 const BALL_ACTIVE = 16; +// 进度条分段数,越大热力图越精细但性能越差 const SEGMENTS = 100; +// 热力图颜色从蓝(冷)到红(热) const HIDE_DELAY = 3000; const HEADERS = { @@ -58,6 +63,41 @@ function decodeFloats(base64: string): number[] { return floats; } +function decodePvData(base64: string): number[] { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const view = new DataView(bytes.buffer); + const timestamps: number[] = []; + let i = 0; + while (i < bytes.length) { + const tag = bytes[i++]; + const wt = tag & 0x7; + if (wt === 5) { timestamps.push(view.getFloat32(i, true)); i += 4; } + else if (wt === 2) { + // Packed floats — read length prefix then extract each float32 + let len = 0, shift = 0; + do { const b = bytes[i++]; len |= (b & 0x7f) << shift; shift += 7; if (!(b & 0x80)) break; } while (true); + const end = i + len; + while (i < end) { timestamps.push(view.getFloat32(i, true)); i += 4; } + } + else if (wt === 0) { while (i < bytes.length && (bytes[i++] & 0x80)); } + else if (wt === 1) { i += 8; } + else break; + } + return timestamps; +} + +function findFrameIdx(timestamps: number[], seekTime: number): number { + if (!timestamps.length) return 0; + let lo = 0, hi = timestamps.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (timestamps[mid] <= seekTime) lo = mid; else hi = mid - 1; + } + return lo; +} + function downsample(data: number[], n: number): number[] { if (!data.length) return Array(n).fill(0); const out = Array.from({ length: n }, (_, i) => data[Math.floor((i / n) * data.length)]); @@ -108,6 +148,7 @@ export function NativeVideoPlayer({ const [heatSegments, setHeatSegments] = useState([]); const [shots, setShots] = useState(null); + const [shotTimestamps, setShotTimestamps] = useState([]); const videoRef = useRef(null); const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? String(currentQn || 'HD'); @@ -134,7 +175,13 @@ export function NativeVideoPlayer({ try { setHeatSegments(downsample(decodeFloats(heatmap.pb_data), SEGMENTS)); } catch { setHeatSegments([]); } } - if (shotData?.image?.length) setShots(shotData); + if (shotData?.image?.length) { + setShots(shotData); + if (shotData.pvdata) { + try { setShotTimestamps(decodePvData(shotData.pvdata)); } + catch { setShotTimestamps([]); } + } + } }); return () => { cancelled = true; }; }, [bvid, cid]); @@ -212,160 +259,179 @@ export function NativeVideoPlayer({ const touchRatio = touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null; const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0; + const THUMB_DISPLAY_W = 120; // scaled display width + const renderThumbnail = () => { - if (touchRatio === null || !shots) return null; + if (touchRatio === null || !shots || !isSeeking) return null; const { img_x_size: TW, img_y_size: TH, img_x_len, img_y_len, image } = shots; - const totalFrames = img_x_len * img_y_len * image.length; const framesPerSheet = img_x_len * img_y_len; - const frameIdx = Math.floor(touchRatio * (totalFrames - 1)); + const totalFrames = framesPerSheet * image.length; + + // Use pvdata timestamps for accurate frame lookup; fall back to linear interpolation + const seekTime = touchRatio * duration; + const frameIdx = shotTimestamps.length > 0 + ? findFrameIdx(shotTimestamps, seekTime) + : Math.floor(touchRatio * (totalFrames - 1)); + const sheetIdx = Math.floor(frameIdx / framesPerSheet); const local = frameIdx % framesPerSheet; const col = local % img_x_len; const row = Math.floor(local / img_x_len); - const left = clamp((touchX ?? 0) - TW / 2, 0, barWidthRef.current - TW); + + // Scale sprite frame to display size + const scale = THUMB_DISPLAY_W / TW; + const DW = THUMB_DISPLAY_W; + const DH = Math.round(TH * scale); + + const trackLeft = barOffsetX.current; + const absLeft = clamp(trackLeft + (touchX ?? 0) - DW / 2, 0, SCREEN_W - DW); + + // Protocol-relative URLs from B站 API need explicit https: + const sheetUrl = image[sheetIdx].startsWith('//') ? `https:${image[sheetIdx]}` : image[sheetIdx]; return ( - - + + - {formatTime((touchRatio ?? 0) * duration)} + {formatTime(seekTime)} ); }; return ( - - - {resolvedUrl ? ( - + + {/* Bottom bar */} + + {/* Progress track */} + + + {heatSegments.length > 0 + ? heatSegments.map((v, i) => ( + + )) + : + } + + + {isSeeking && touchX !== null ? ( + + ) : ( + + )} + + + {/* Controls row */} + + { setPaused(p => !p); showAndReset(); }} style={styles.ctrlBtn}> + + + {formatTime(currentTime)} + + {formatTime(duration)} + setShowQuality(true)}> + {currentDesc} + + + + + + + + )} + + {/* Thumbnail preview — absolute on container to avoid clipping */} + {renderThumbnail()} + + {/* Quality modal */} + + setShowQuality(false)}> + + 选择清晰度 + {qualities.map(q => ( + { setShowQuality(false); onQualityChange(q.qn); showAndReset(); }} + > + + {q.desc} + + {q.qn === currentQn && } + + ))} + + + + ); } @@ -391,8 +457,7 @@ const styles = StyleSheet.create({ position: 'absolute', bottom: 0, left: 0, right: 0, paddingBottom: 8, paddingTop: 32, }, - thumbArea: { position: 'relative', height: 80, marginHorizontal: 8 }, - thumbPreview: { position: 'absolute', bottom: 4, alignItems: 'center' }, + thumbPreview: { position: 'absolute', bottom: 64, alignItems: 'center' }, thumbTime: { color: '#fff', fontSize: 11, fontWeight: '600', marginTop: 2, textShadowColor: 'rgba(0,0,0,0.7)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2, diff --git a/docs/superpowers/bug.md b/docs/superpowers/bug.md new file mode 100644 index 0000000..64f3bd0 --- /dev/null +++ b/docs/superpowers/bug.md @@ -0,0 +1,196 @@ +# Bug Report + +> 最后更新:2026-03-10 + +--- + +## CRITICAL + +### BUG-01 · `video.stat` 空指针崩溃 +**文件**: `app/video/[bvid].tsx` 第 72–75、94 行 +**类型**: Logic Bug + +`VideoItem.stat` 在类型定义中为可选字段(`stat?: {...}`),但代码直接访问 `video.stat.view` 等属性,无任何空值保护。当 B站 API 返回的视频数据不含 `stat` 字段时,页面会崩溃。 + +```tsx +// 当前(危险) + +评论 {video.stat.reply > 0 ? ...} + +// 修复 + +评论 {(video.stat?.reply ?? 0) > 0 ? ...} +``` + +--- + +### BUG-02 · 切换视频时评论不重置 +**文件**: `hooks/useComments.ts`、`app/video/[bvid].tsx` 第 31–33 行 +**类型**: Logic Bug + +`useComments` hook 内部没有重置机制。当用户从视频 A(aid=123,已加载 3 页)跳转到视频 B(aid=456)时: +- `comments` 数组仍含视频 A 的内容 +- `page` 仍为 4,视频 B 的评论从第 4 页开始请求 +- 结果:视频 A 和视频 B 的评论混在一起显示 + +`useComments` 缺少一个 `reset()` 方法,`[bvid].tsx` 也没有在 `bvid` 变化时调用清理。 + +```ts +// hooks/useComments.ts 需要增加 +const reset = useCallback(() => { + setComments([]); + setPage(1); + setHasMore(true); +}, []); + +// app/video/[bvid].tsx 需要在 bvid 变化时调用 +useEffect(() => { + clearVideo(); + resetComments(); // 缺失 +}, [bvid]); +``` + +--- + +### BUG-03 · `useComments` 跨视频 `page` 状态污染 +**文件**: `hooks/useComments.ts` 第 24 行 +**类型**: Race Condition + +`load` 回调将 `page`、`loading`、`hasMore` 列入 `useCallback` 依赖数组。当 `aid` 变化(切换视频)时,`page` 仍是上一个视频的值,新 `load` 调用会从错误的页码请求评论。与 BUG-02 同根,但原因不同:即使父组件调用 `reset`,旧 `load` 闭包内的 `page` 可能仍是旧值。 + +```ts +// 当前 +}, [aid, page, loading, hasMore]); // page 污染导致跨视频分页错误 + +// 修复:用 ref 追踪 page,不纳入依赖 +const pageRef = useRef(1); +const load = useCallback(async () => { + ... + const data = await getComments(aid, pageRef.current); + pageRef.current += 1; +}, [aid]); // 只依赖 aid +``` + +--- + +## MAJOR + +### BUG-04 · 视频加载失败无任何用户反馈 +**文件**: `app/video/[bvid].tsx` 第 22 行 +**类型**: UX + +`useVideoDetail` 返回 `error` 字段,但 `[bvid].tsx` 完全忽略它。网络错误、API 403、视频下架等场景下,用户看到的是永久的 loading 动画或空白页,无法知晓发生了什么。 + +```tsx +// 当前(error 被解构但从未使用) +const { video, playData, loading: videoLoading, qualities, currentQn, changeQuality } = useVideoDetail(bvid); + +// 修复:增加 error 处理 +const { video, playData, loading: videoLoading, error, qualities, currentQn, changeQuality } = useVideoDetail(bvid); +// 在 ScrollView 内渲染 +{error && 加载失败:{error}} +``` + +--- + +### BUG-05 · `Promise.all` 导致热力图和缩略图互相阻断 +**文件**: `components/NativeVideoPlayer.tsx`(heatmap useEffect) +**类型**: Error Handling + +热力图(`getHeatmap`)和视频截图(`getVideoShot`)使用 `Promise.all` 并行请求。任意一个失败,另一个的结果也会被丢弃。对于没有热力图的视频,缩略图也无法加载。 + +```ts +// 当前 +Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then(...) + +// 修复:独立处理各自失败 +Promise.allSettled([getHeatmap(bvid), getVideoShot(bvid, cid)]).then(([heatRes, shotRes]) => { + if (heatRes.status === 'fulfilled' && heatRes.value?.pb_data) { ... } + if (shotRes.status === 'fulfilled' && shotRes.value?.image?.length) { ... } +}); +``` + +--- + +### BUG-06 · LoginModal `pollQRCode` 无错误处理 +**文件**: `components/LoginModal.tsx`(setInterval 回调) +**类型**: Error Handling + +`setInterval` 内的 `await pollQRCode(qrKey)` 若抛出异常(网络断开、超时等),错误被静默丢弃,定时器继续运行。用户看到的是二维码一直停在"等待扫码"状态,没有任何提示。 + +```ts +// 修复:增加 try-catch +pollRef.current = setInterval(async () => { + try { + const result = await pollQRCode(qrKey); + ... + } catch { + // 网络异常时更新状态提示用户 + setStatus('error'); + } +}, 2000); +``` + +--- + +### BUG-07 · `utils/buildMpd.ts` 死代码 +**文件**: `utils/buildMpd.ts` +**类型**: Code Quality + +该文件定义了 `buildMpd()` 函数,但全项目无任何 import。实际 MPD 构建逻辑在 `utils/dash.ts` 中实现。`buildMpd.ts` 中的 `SegmentBase` 使用硬编码的 `range="0-999"` 而非真实的 byte range,直接使用会导致 ExoPlayer 播放失败。应删除该文件。 + +--- + +## MINOR + +### BUG-08 · `expo-file-system/legacy` 废弃导入 +**文件**: `utils/dash.ts` 第 1 行 +**类型**: Deprecation + +```ts +import * as FileSystem from 'expo-file-system/legacy'; // 废弃路径 +``` + +新版 expo-file-system 将 legacy API 移至子路径是临时过渡方案,后续版本可能移除。需跟进 expo-file-system 更新,迁移至正式 API。 + +--- + +### BUG-09 · 进度条 `measureInWindow` 异步竞态 +**文件**: `components/NativeVideoPlayer.tsx`(`measureTrack`) +**类型**: Race Condition + +控制层从隐藏变为显示时,`trackWrapper` 重新挂载,`onLayout` 触发 `measureInWindow`(异步)。若用户在 `measureInWindow` 回调返回前立即拖动进度条,`barOffsetX.current` 为旧值(0 或上次值),导致 `touchX` 计算偏差,缩略图位置和 seek 位置偏移。 + +--- + +### BUG-10 · "加载更多评论"按钮在首次加载前不可见 +**文件**: `app/video/[bvid].tsx` 第 108–112 行 +**类型**: UX + +按钮渲染条件为 `!cmtLoading && comments.length > 0`。首次进入视频详情时,评论需要用户手动点击"加载更多"触发,但在 `loadComments` 被 `useEffect` 自动调用(第 32 行)完成前,按钮不存在。若自动加载失败,用户无法重试。 + +--- + +### BUG-11 · `useVideoDetail` 登录态变化时不重置 loading +**文件**: `hooks/useVideoDetail.ts` 第 53–57 行 +**类型**: UX + +登录/登出时触发 `fetchPlayData` 重新拉取清晰度,但未设置 `loading = true`。用户看不到加载状态变化,若请求耗时较长,可能出现短暂的旧清晰度视频在播放的情况。 + +--- + +## 汇总 + +| ID | 文件 | 严重度 | 类型 | 一句话描述 | +|----|------|--------|------|-----------| +| BUG-01 | `app/video/[bvid].tsx` | CRITICAL | Logic Bug | `video.stat` 无空值保护,可能崩溃 | +| BUG-02 | `hooks/useComments.ts` | CRITICAL | Logic Bug | 切换视频时评论状态不清空 | +| BUG-03 | `hooks/useComments.ts` | CRITICAL | Race Condition | `page` 状态跨视频污染 | +| BUG-04 | `app/video/[bvid].tsx` | MAJOR | UX | 视频加载失败无任何用户反馈 | +| BUG-05 | `components/NativeVideoPlayer.tsx` | MAJOR | Error Handling | `Promise.all` 导致热力图/缩略图互相阻断 | +| BUG-06 | `components/LoginModal.tsx` | MAJOR | Error Handling | 二维码轮询网络异常被静默吞掉 | +| BUG-07 | `utils/buildMpd.ts` | MAJOR | Code Quality | 死代码,硬编码错误 byte range | +| BUG-08 | `utils/dash.ts` | MINOR | Deprecation | `expo-file-system/legacy` 废弃导入 | +| BUG-09 | `components/NativeVideoPlayer.tsx` | MINOR | Race Condition | 进度条测量异步竞态导致拖动偏差 | +| BUG-10 | `app/video/[bvid].tsx` | MINOR | UX | 首次加载失败无重试入口 | +| BUG-11 | `hooks/useVideoDetail.ts` | MINOR | UX | 登录态变化时不显示重新加载状态 | diff --git a/docs/superpowers/plans/2026-03-10-player-ui-redesign.md b/docs/superpowers/plans/2026-03-10-player-ui-redesign.md index 62e1849..7503d9d 100644 --- a/docs/superpowers/plans/2026-03-10-player-ui-redesign.md +++ b/docs/superpowers/plans/2026-03-10-player-ui-redesign.md @@ -623,3 +623,5 @@ git commit -m "feat: unified player controls with heatmap progress bar and thumb - [ ] 拖拽进度条时显示缩略图预览(需要视频有截图数据) - [ ] 切换清晰度正常工作 - [ ] 播放器下方无独立的 HeatProgressBar + + diff --git a/services/types.ts b/services/types.ts index 660c1e3..345178c 100644 --- a/services/types.ts +++ b/services/types.ts @@ -88,6 +88,7 @@ export interface VideoShotData { img_x_size: number; img_y_size: number; image: string[]; + pvdata?: string; // base64 protobuf: packed float32 timestamps (seconds) per frame } export interface HeatmapResponse {