mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-08 07:28:37 +08:00
1
This commit is contained in:
@@ -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<number[]>([]);
|
||||
const [shots, setShots] = useState<VideoShotData | null>(null);
|
||||
const [shotTimestamps, setShotTimestamps] = useState<number[]>([]);
|
||||
|
||||
const videoRef = useRef<VideoRef>(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 (
|
||||
<View style={[styles.thumbPreview, { left, width: TW }]}>
|
||||
<View style={{ width: TW, height: TH, overflow: 'hidden', borderRadius: 4 }}>
|
||||
<View style={[styles.thumbPreview, { left: absLeft, width: DW }]} pointerEvents="none">
|
||||
<View style={{ width: DW, height: DH, overflow: 'hidden', borderRadius: 4 }}>
|
||||
<Image
|
||||
source={{ uri: image[sheetIdx] }}
|
||||
source={{ uri: sheetUrl, headers: HEADERS }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: TW * img_x_len,
|
||||
height: TH * img_y_len,
|
||||
left: -col * TW,
|
||||
top: -row * TH,
|
||||
width: TW * img_x_len * scale,
|
||||
height: TH * img_y_len * scale,
|
||||
left: -col * DW,
|
||||
top: -row * DH,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.thumbTime}>{formatTime((touchRatio ?? 0) * duration)}</Text>
|
||||
<Text style={styles.thumbTime}>{formatTime(seekTime)}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={handleTap}>
|
||||
<View style={[styles.container, style]}>
|
||||
{resolvedUrl ? (
|
||||
<Video
|
||||
key={resolvedUrl}
|
||||
ref={videoRef}
|
||||
source={isDash
|
||||
? { uri: resolvedUrl, type: 'mpd', headers: HEADERS }
|
||||
: { uri: resolvedUrl, headers: HEADERS }
|
||||
}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="contain"
|
||||
controls={false}
|
||||
paused={paused}
|
||||
onProgress={({ currentTime: ct, seekableDuration: dur }) => {
|
||||
setCurrentTime(ct);
|
||||
if (dur > 0) setDuration(dur);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholder} />
|
||||
)}
|
||||
<View style={[styles.container, style]}>
|
||||
{resolvedUrl ? (
|
||||
<Video
|
||||
key={resolvedUrl}
|
||||
ref={videoRef}
|
||||
source={isDash
|
||||
? { uri: resolvedUrl, type: 'mpd', headers: HEADERS }
|
||||
: { uri: resolvedUrl, headers: HEADERS }
|
||||
}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="contain"
|
||||
controls={false}
|
||||
paused={paused}
|
||||
onProgress={({ currentTime: ct, seekableDuration: dur }) => {
|
||||
setCurrentTime(ct);
|
||||
if (dur > 0) setDuration(dur);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholder} />
|
||||
)}
|
||||
|
||||
{showControls && (
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0.55)', 'transparent']}
|
||||
style={styles.topBar}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
{onMiniPlayer && (
|
||||
<TouchableOpacity onPress={onMiniPlayer} style={styles.topBtn}>
|
||||
<Ionicons name="tablet-portrait-outline" size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</LinearGradient>
|
||||
{/* Permanent transparent tap layer — always above Video so taps always reach it */}
|
||||
<TouchableWithoutFeedback onPress={handleTap}>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
{/* Center play/pause */}
|
||||
<TouchableOpacity style={styles.centerBtn} onPress={() => { setPaused(p => !p); showAndReset(); }}>
|
||||
<View style={styles.centerBtnBg}>
|
||||
<Ionicons name={paused ? 'play' : 'pause'} size={28} color="#fff" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{showControls && (
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0.55)', 'transparent']}
|
||||
style={styles.topBar}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
{onMiniPlayer && (
|
||||
<TouchableOpacity onPress={onMiniPlayer} style={styles.topBtn}>
|
||||
<Ionicons name="tablet-portrait-outline" size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</LinearGradient>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.7)']}
|
||||
style={styles.bottomBar}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
{/* Thumbnail area */}
|
||||
<View style={styles.thumbArea} pointerEvents="none">
|
||||
{renderThumbnail()}
|
||||
</View>
|
||||
|
||||
{/* Progress track */}
|
||||
<View
|
||||
ref={trackRef}
|
||||
style={styles.trackWrapper}
|
||||
onLayout={measureTrack}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<View style={styles.track}>
|
||||
{heatSegments.length > 0
|
||||
? heatSegments.map((v, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[styles.seg, { backgroundColor: heatColor(v), width: `${100 / SEGMENTS}%` as any }]}
|
||||
/>
|
||||
))
|
||||
: <View style={[styles.seg, { flex: 1, backgroundColor: '#00AEEC' }]} />
|
||||
}
|
||||
<View style={[styles.playedOverlay, { width: `${progressRatio * 100}%` as any }]} />
|
||||
</View>
|
||||
{isSeeking && touchX !== null ? (
|
||||
<View style={[styles.ball, styles.ballActive, { left: touchX - BALL_ACTIVE / 2 }]} />
|
||||
) : (
|
||||
<View style={[styles.ball, { left: progressRatio * barWidthRef.current - BALL / 2 }]} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Controls row */}
|
||||
<View style={styles.ctrlRow}>
|
||||
<TouchableOpacity onPress={() => { setPaused(p => !p); showAndReset(); }} style={styles.ctrlBtn}>
|
||||
<Ionicons name={paused ? 'play' : 'pause'} size={16} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.timeText}>{formatTime(currentTime)}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text style={styles.timeText}>{formatTime(duration)}</Text>
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowQuality(true)}>
|
||||
<Text style={styles.qualityText}>{currentDesc}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
|
||||
<Ionicons name="expand" size={16} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Quality modal */}
|
||||
<Modal visible={showQuality} transparent animationType="fade">
|
||||
<TouchableOpacity style={styles.modalOverlay} onPress={() => setShowQuality(false)}>
|
||||
<View style={styles.qualityList}>
|
||||
<Text style={styles.qualityTitle}>选择清晰度</Text>
|
||||
{qualities.map(q => (
|
||||
<TouchableOpacity
|
||||
key={q.qn}
|
||||
style={styles.qualityItem}
|
||||
onPress={() => { setShowQuality(false); onQualityChange(q.qn); showAndReset(); }}
|
||||
>
|
||||
<Text style={[styles.qualityItemText, q.qn === currentQn && styles.qualityItemActive]}>
|
||||
{q.desc}
|
||||
</Text>
|
||||
{q.qn === currentQn && <Ionicons name="checkmark" size={16} color="#00AEEC" />}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
{/* Center play/pause */}
|
||||
<TouchableOpacity style={styles.centerBtn} onPress={() => { setPaused(p => !p); showAndReset(); }}>
|
||||
<View style={styles.centerBtnBg}>
|
||||
<Ionicons name={paused ? 'play' : 'pause'} size={28} color="#fff" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.7)']}
|
||||
style={styles.bottomBar}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
{/* Progress track */}
|
||||
<View
|
||||
ref={trackRef}
|
||||
style={styles.trackWrapper}
|
||||
onLayout={measureTrack}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<View style={styles.track}>
|
||||
{heatSegments.length > 0
|
||||
? heatSegments.map((v, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[styles.seg, { backgroundColor: heatColor(v), width: `${100 / SEGMENTS}%` as any }]}
|
||||
/>
|
||||
))
|
||||
: <View style={[styles.seg, { flex: 1, backgroundColor: '#00AEEC' }]} />
|
||||
}
|
||||
<View style={[styles.playedOverlay, { width: `${progressRatio * 100}%` as any }]} />
|
||||
</View>
|
||||
{isSeeking && touchX !== null ? (
|
||||
<View style={[styles.ball, styles.ballActive, { left: touchX - BALL_ACTIVE / 2 }]} />
|
||||
) : (
|
||||
<View style={[styles.ball, { left: progressRatio * barWidthRef.current - BALL / 2 }]} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Controls row */}
|
||||
<View style={styles.ctrlRow}>
|
||||
<TouchableOpacity onPress={() => { setPaused(p => !p); showAndReset(); }} style={styles.ctrlBtn}>
|
||||
<Ionicons name={paused ? 'play' : 'pause'} size={16} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.timeText}>{formatTime(currentTime)}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text style={styles.timeText}>{formatTime(duration)}</Text>
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowQuality(true)}>
|
||||
<Text style={styles.qualityText}>{currentDesc}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
|
||||
<Ionicons name="expand" size={16} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Thumbnail preview — absolute on container to avoid clipping */}
|
||||
{renderThumbnail()}
|
||||
|
||||
{/* Quality modal */}
|
||||
<Modal visible={showQuality} transparent animationType="fade">
|
||||
<TouchableOpacity style={styles.modalOverlay} onPress={() => setShowQuality(false)}>
|
||||
<View style={styles.qualityList}>
|
||||
<Text style={styles.qualityTitle}>选择清晰度</Text>
|
||||
{qualities.map(q => (
|
||||
<TouchableOpacity
|
||||
key={q.qn}
|
||||
style={styles.qualityItem}
|
||||
onPress={() => { setShowQuality(false); onQualityChange(q.qn); showAndReset(); }}
|
||||
>
|
||||
<Text style={[styles.qualityItemText, q.qn === currentQn && styles.qualityItemActive]}>
|
||||
{q.desc}
|
||||
</Text>
|
||||
{q.qn === currentQn && <Ionicons name="checkmark" size={16} color="#00AEEC" />}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
196
docs/superpowers/bug.md
Normal file
196
docs/superpowers/bug.md
Normal file
@@ -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
|
||||
// 当前(危险)
|
||||
<StatBadge icon="play" count={video.stat.view} />
|
||||
评论 {video.stat.reply > 0 ? ...}
|
||||
|
||||
// 修复
|
||||
<StatBadge icon="play" count={video.stat?.view ?? 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 && <Text style={styles.errorTxt}>加载失败:{error}</Text>}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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 | 登录态变化时不显示重新加载状态 |
|
||||
@@ -623,3 +623,5 @@ git commit -m "feat: unified player controls with heatmap progress bar and thumb
|
||||
- [ ] 拖拽进度条时显示缩略图预览(需要视频有截图数据)
|
||||
- [ ] 切换清晰度正常工作
|
||||
- [ ] 播放器下方无独立的 HeatProgressBar
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user