From 9347c8752dbede77ebec91e94f2ea86558febec9 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 11 Mar 2026 20:53:18 +0800 Subject: [PATCH] 1 --- app/index.tsx | 1 + app/video/[bvid].tsx | 206 +++++++---- components/NativeVideoPlayer.tsx | 580 +++++++++++++++++++++++-------- components/VideoCard.tsx | 4 +- components/VideoPlayer.tsx | 8 +- dev-proxy.js | 41 ++- hooks/useVideoList.ts | 17 +- package-lock.json | 148 ++++++-- package.json | 6 +- services/bilibili.ts | 58 +++- utils/danmaku.ts | 2 + utils/videoRows.ts | 26 +- 12 files changed, 836 insertions(+), 261 deletions(-) diff --git a/app/index.tsx b/app/index.tsx index 0b8bef1..8ecba22 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -118,6 +118,7 @@ export default function HomeScreen() { } onEndReached={() => load()} onEndReachedThreshold={0.5} + extraData={visibleBigKey} viewabilityConfig={VIEWABILITY_CONFIG} onViewableItemsChanged={onViewableItemsChangedRef} ListFooterComponent={ diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index ad36dbd..0a26de7 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -1,30 +1,46 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect } from "react"; import { - View, Text, ScrollView, StyleSheet, - TouchableOpacity, Image, ActivityIndicator -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { Ionicons } from '@expo/vector-icons'; -import { VideoPlayer } from '../../components/VideoPlayer'; -import { CommentItem } from '../../components/CommentItem'; -import { getDanmaku } from '../../services/bilibili'; -import { DanmakuItem } from '../../services/types'; -import DanmakuList from '../../components/DanmakuList'; -import { useVideoDetail } from '../../hooks/useVideoDetail'; -import { useComments } from '../../hooks/useComments'; -import { useVideoStore } from '../../store/videoStore'; -import { formatCount } from '../../utils/format'; -import { proxyImageUrl } from '../../utils/imageUrl'; + View, + Text, + ScrollView, + StyleSheet, + TouchableOpacity, + Image, + ActivityIndicator, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { VideoPlayer } from "../../components/VideoPlayer"; +import { CommentItem } from "../../components/CommentItem"; +import { getDanmaku } from "../../services/bilibili"; +import { DanmakuItem } from "../../services/types"; +import DanmakuList from "../../components/DanmakuList"; +import { useVideoDetail } from "../../hooks/useVideoDetail"; +import { useComments } from "../../hooks/useComments"; +import { useVideoStore } from "../../store/videoStore"; +import { formatCount } from "../../utils/format"; +import { proxyImageUrl } from "../../utils/imageUrl"; -type Tab = 'intro' | 'comments'; +type Tab = "intro" | "comments"; export default function VideoDetailScreen() { const { bvid } = useLocalSearchParams<{ bvid: string }>(); const router = useRouter(); - const { video, playData, loading: videoLoading, qualities, currentQn, changeQuality } = useVideoDetail(bvid as string); - const { comments, loading: cmtLoading, load: loadComments } = useComments(video?.aid ?? 0); - const [tab, setTab] = useState('comments'); + const { + video, + playData, + loading: videoLoading, + qualities, + currentQn, + changeQuality, + } = useVideoDetail(bvid as string); + const { + comments, + loading: cmtLoading, + load: loadComments, + } = useComments(video?.aid ?? 0); + const [tab, setTab] = useState("comments"); const [danmakus, setDanmakus] = useState([]); const [currentTime, setCurrentTime] = useState(0); const [showDanmakuList, setShowDanmakuList] = useState(true); @@ -56,7 +72,9 @@ export default function VideoDetailScreen() { router.back()} style={styles.backBtn}> - {video?.title ?? '视频详情'} + + {video?.title ?? "视频详情"} + @@ -73,12 +91,11 @@ export default function VideoDetailScreen() { danmakus={danmakus} onTimeUpdate={setCurrentTime} /> - setShowDanmakuList(v => !v)} + onToggle={() => setShowDanmakuList((v) => !v)} /> @@ -97,7 +114,10 @@ export default function VideoDetailScreen() { - + {video.owner.name} + 关注 @@ -105,28 +125,51 @@ export default function VideoDetailScreen() { - setTab('intro')}> - 简介 - {tab === 'intro' && } - - setTab('comments')}> - - 评论 {video.stat.reply > 0 ? formatCount(video.stat.reply) : ''} + setTab("intro")} + > + + 简介 - {tab === 'comments' && } + {tab === "intro" && } + + setTab("comments")} + > + + 评论{" "} + {video.stat.reply > 0 ? formatCount(video.stat.reply) : ""} + + {tab === "comments" && } - {tab === 'intro' ? ( + {tab === "intro" ? ( - {video.desc || '暂无简介'} + {video.desc || "暂无简介"} ) : ( <> - {comments.map(c => )} - {cmtLoading && } + {comments.map((c) => ( + + ))} + {cmtLoading && ( + + )} {!cmtLoading && comments.length > 0 && ( - + 加载更多评论 )} @@ -152,31 +195,78 @@ function StatBadge({ icon, count }: { icon: string; count: number }) { } const styles = StyleSheet.create({ - safe: { flex: 1, backgroundColor: '#fff' }, - topBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' }, + safe: { flex: 1, backgroundColor: "#fff" }, + topBar: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 8, + paddingVertical: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#eee", + }, backBtn: { padding: 4 }, - topTitle: { flex: 1, fontSize: 15, fontWeight: '600', marginLeft: 4, color: '#212121' }, + topTitle: { + flex: 1, + fontSize: 15, + fontWeight: "600", + marginLeft: 4, + color: "#212121", + }, miniBtn: { padding: 4 }, scroll: { flex: 1 }, loader: { marginVertical: 30 }, titleSection: { padding: 14 }, - title: { fontSize: 16, fontWeight: '600', color: '#212121', lineHeight: 22, marginBottom: 8 }, - statsRow: { flexDirection: 'row', gap: 16 }, - stat: { flexDirection: 'row', alignItems: 'center', gap: 3 }, - statText: { fontSize: 12, color: '#999' }, - upRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingBottom: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#f0f0f0' }, + title: { + fontSize: 16, + fontWeight: "600", + color: "#212121", + lineHeight: 22, + marginBottom: 8, + }, + statsRow: { flexDirection: "row", gap: 16 }, + stat: { flexDirection: "row", alignItems: "center", gap: 3 }, + statText: { fontSize: 12, color: "#999" }, + upRow: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 14, + paddingBottom: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#f0f0f0", + }, avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10 }, - upName: { flex: 1, fontSize: 14, color: '#212121', fontWeight: '500' }, - followBtn: { backgroundColor: '#00AEEC', paddingHorizontal: 14, paddingVertical: 5, borderRadius: 14 }, - followTxt: { color: '#fff', fontSize: 12, fontWeight: '600' }, - tabBar: { flexDirection: 'row', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' }, - tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12, position: 'relative' }, - tabLabel: { fontSize: 14, color: '#999' }, - tabActive: { color: '#00AEEC', fontWeight: '700' }, - tabUnderline: { position: 'absolute', bottom: 0, width: 24, height: 2, backgroundColor: '#00AEEC', borderRadius: 1 }, + upName: { flex: 1, fontSize: 14, color: "#212121", fontWeight: "500" }, + followBtn: { + backgroundColor: "#00AEEC", + paddingHorizontal: 14, + paddingVertical: 5, + borderRadius: 14, + }, + followTxt: { color: "#fff", fontSize: 12, fontWeight: "600" }, + tabBar: { + flexDirection: "row", + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#eee", + }, + tabItem: { + flex: 1, + alignItems: "center", + paddingVertical: 12, + position: "relative", + }, + tabLabel: { fontSize: 14, color: "#999" }, + tabActive: { color: "#00AEEC", fontWeight: "700" }, + tabUnderline: { + position: "absolute", + bottom: 0, + width: 24, + height: 2, + backgroundColor: "#00AEEC", + borderRadius: 1, + }, descBox: { padding: 16 }, - descText: { fontSize: 14, color: '#555', lineHeight: 22 }, - loadMore: { alignItems: 'center', padding: 16 }, - loadMoreTxt: { color: '#00AEEC', fontSize: 13 }, - emptyTxt: { textAlign: 'center', color: '#bbb', padding: 30 }, + descText: { fontSize: 14, color: "#555", lineHeight: 22 }, + loadMore: { alignItems: "center", padding: 16 }, + loadMoreTxt: { color: "#00AEEC", fontSize: 13 }, + emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 }, }); diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index de71311..4f9696f 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -1,15 +1,27 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { File, Directory, Paths } from "expo-file-system"; import { - View, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, - Text, Modal, Image, PanResponder, useWindowDimensions, -} from 'react-native'; -import Video, { VideoRef } from 'react-native-video'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Ionicons } from '@expo/vector-icons'; -import type { PlayUrlResponse, VideoShotData, DanmakuItem } from '../services/types'; -import { buildDashMpdUri } from '../utils/dash'; -import { getHeatmap, getVideoShot } from '../services/bilibili'; -import DanmakuOverlay from './DanmakuOverlay'; + View, + StyleSheet, + TouchableOpacity, + TouchableWithoutFeedback, + Text, + Modal, + Image, + PanResponder, + useWindowDimensions, +} from "react-native"; +import Video, { VideoRef } from "react-native-video"; +import { LinearGradient } from "expo-linear-gradient"; +import { Ionicons } from "@expo/vector-icons"; +import type { + PlayUrlResponse, + VideoShotData, + DanmakuItem, +} from "../services/types"; +import { buildDashMpdUri } from "../utils/dash"; +import { getHeatmap, getVideoShot } from "../services/bilibili"; +import DanmakuOverlay from "./DanmakuOverlay"; const BAR_H = 3; // 进度球尺寸 @@ -22,8 +34,9 @@ const SEGMENTS = 100; const HIDE_DELAY = 3000; const HEADERS = { - Referer: 'https://www.bilibili.com', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Referer: "https://www.bilibili.com", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", }; function clamp(v: number, lo: number, hi: number) { @@ -49,63 +62,145 @@ function decodeFloats(base64: string): number[] { while (i < bytes.length) { const tag = bytes[i++]; const wt = tag & 0x7; - if (wt === 5) { floats.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 if (wt === 2) { - let len = 0, shift = 0; - do { const b = bytes[i++]; len |= (b & 0x7f) << shift; shift += 7; if (!(b & 0x80)) break; } while (true); + if (wt === 5) { + floats.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 if (wt === 2) { + let len = 0, + shift = 0; + do { + const b = bytes[i++]; + len |= (b & 0x7f) << shift; + shift += 7; + if (!(b & 0x80)) break; + } while (true); i += len; } else break; } 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); +function decodePvBuffer(buffer: ArrayBuffer): number[] { + const bytes = new Uint8Array(buffer); + const view = new DataView(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 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; - while (i < end) { timestamps.push(view.getFloat32(i, true)); i += 4; } + // 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; // 未知类型,停止 } - else if (wt === 0) { while (i < bytes.length && (bytes[i++] & 0x80)); } - else if (wt === 1) { i += 8; } - else break; } - return timestamps; + + // 过滤掉明显异常值(比如负数或极大值) + return timestamps.filter((t) => t >= 0 && t < 86400); // 视频不会超过24小时 +} + +async function loadPvData(url: string) { + 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; + } } function findFrameIdx(timestamps: number[], seekTime: number): number { if (!timestamps.length) return 0; - let lo = 0, hi = timestamps.length - 1; + 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; + 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)]); + const out = Array.from( + { length: n }, + (_, i) => data[Math.floor((i / n) * data.length)], + ); const max = Math.max(...out); - return max ? out.map(v => v / max) : out; + return max ? out.map((v) => v / max) : out; } function formatTime(s: number): string { const m = Math.floor(s / 60); - return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`; + return `${m}:${Math.floor(s % 60) + .toString() + .padStart(2, "0")}`; } interface Props { @@ -125,8 +220,19 @@ interface Props { } export function NativeVideoPlayer({ - playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style, - bvid, cid, danmakus, isFullscreen, onTimeUpdate, initialTime, + playData, + qualities, + currentQn, + onQualityChange, + onFullscreen, + onMiniPlayer, + style, + bvid, + cid, + danmakus, + isFullscreen, + onTimeUpdate, + initialTime, }: Props) { const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions(); const VIDEO_H = SCREEN_W * 0.5625; @@ -157,11 +263,16 @@ export function NativeVideoPlayer({ const [showDanmaku, setShowDanmaku] = useState(true); const videoRef = useRef(null); - const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? String(currentQn || 'HD'); + const currentDesc = + qualities.find((q) => q.qn === currentQn)?.desc ?? + String(currentQn || "HD"); // URL resolution useEffect(() => { - if (!playData) { setResolvedUrl(undefined); return; } + if (!playData) { + setResolvedUrl(undefined); + return; + } if (isDash) { buildDashMpdUri(playData, currentQn) .then(setResolvedUrl) @@ -175,24 +286,41 @@ export function NativeVideoPlayer({ useEffect(() => { if (!bvid || !cid) return; let cancelled = false; - Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then(([heatmap, shotData]) => { - if (cancelled) return; - if (heatmap?.pb_data) { - try { setHeatSegments(downsample(decodeFloats(heatmap.pb_data), SEGMENTS)); } - catch { setHeatSegments([]); } - } - if (shotData?.image?.length) { - setShots(shotData); - if (shotData.pvdata) { - try { setShotTimestamps(decodePvData(shotData.pvdata)); } - catch { setShotTimestamps([]); } + Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then( + ([heatmap, shotData]) => { + if (cancelled) return; + if (heatmap?.pb_data) { + try { + setHeatSegments( + downsample(decodeFloats(heatmap.pb_data), SEGMENTS), + ); + } catch { + setHeatSegments([]); + } } - } - }); - return () => { cancelled = true; }; + if (shotData?.image?.length) { + setShots(shotData); + console.log(shotData.pvdata, "pvdata"); + if (shotData.pvdata) { + try { + loadPvData(shotData.pvdata).then((r) => { + setShotTimestamps(r); + }); + } catch { + setShotTimestamps([]); + } + } + } + }, + ); + return () => { + cancelled = true; + }; }, [bvid, cid]); - useEffect(() => { durationRef.current = duration; }, [duration]); + useEffect(() => { + durationRef.current = duration; + }, [duration]); const resetHideTimer = useCallback(() => { if (hideTimer.current) clearTimeout(hideTimer.current); @@ -207,8 +335,11 @@ export function NativeVideoPlayer({ }, [resetHideTimer]); const handleTap = useCallback(() => { - setShowControls(prev => { - if (!prev) { resetHideTimer(); return true; } + setShowControls((prev) => { + if (!prev) { + resetHideTimer(); + return true; + } if (hideTimer.current) clearTimeout(hideTimer.current); return false; }); @@ -217,7 +348,9 @@ export function NativeVideoPlayer({ // Start hide timer on mount useEffect(() => { resetHideTimer(); - return () => { if (hideTimer.current) clearTimeout(hideTimer.current); }; + return () => { + if (hideTimer.current) clearTimeout(hideTimer.current); + }; }, []); const measureTrack = useCallback(() => { @@ -244,7 +377,11 @@ export function NativeVideoPlayer({ setTouchX(clamp(gs.moveX - barOffsetX.current, 0, barWidthRef.current)); }, onPanResponderRelease: (_, gs) => { - const ratio = clamp((gs.moveX - barOffsetX.current) / barWidthRef.current, 0, 1); + const ratio = clamp( + (gs.moveX - barOffsetX.current) / barWidthRef.current, + 0, + 1, + ); const t = ratio * durationRef.current; videoRef.current?.seek(t); setCurrentTime(t); @@ -252,32 +389,43 @@ export function NativeVideoPlayer({ isSeekingRef.current = false; setIsSeeking(false); if (hideTimer.current) clearTimeout(hideTimer.current); - hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY); + hideTimer.current = setTimeout( + () => setShowControls(false), + HIDE_DELAY, + ); }, onPanResponderTerminate: () => { setTouchX(null); isSeekingRef.current = false; setIsSeeking(false); }, - }) + }), ).current; - const touchRatio = touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null; + 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 || !isSeeking) return null; - const { img_x_size: TW, img_y_size: TH, img_x_len, img_y_len, image } = shots; + const { + img_x_size: TW, + img_y_size: TH, + img_x_len, + img_y_len, + image, + } = shots; const framesPerSheet = img_x_len * img_y_len; 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 frameIdx = + shotTimestamps.length > 0 + ? findFrameIdx(shotTimestamps, seekTime) + : Math.floor(touchRatio * (totalFrames - 1)); const sheetIdx = Math.floor(frameIdx / framesPerSheet); const local = frameIdx % framesPerSheet; @@ -293,14 +441,21 @@ export function NativeVideoPlayer({ 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]; + const sheetUrl = image[sheetIdx].startsWith("//") + ? `https:${image[sheetIdx]}` + : image[sheetIdx]; return ( - - + + + {resolvedUrl ? (