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"; const BAR_H = 3; // 进度球尺寸 const BALL = 12; // 活跃状态下的拖动球增大尺寸,提升触控体验 const BALL_ACTIVE = 16; // 进度条分段数,越大热力图越精细但性能越差 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", }; function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); } function heatColor(v: number): string { if (v < 0.5) { const t = v * 2; return `rgb(${Math.round(t * 255)},174,236)`; } const t = (v - 0.5) * 2; return `rgb(251,${Math.round((1 - t) * 114)},${Math.round((1 - t) * 153)})`; } function decodeFloats(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 floats: number[] = []; let i = 0; 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); i += len; } else break; } return floats; } 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 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; // 未知类型,停止 } } // 过滤掉明显异常值(比如负数或极大值) 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; 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)], ); const max = Math.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")}`; } interface Props { playData: PlayUrlResponse | null; qualities: { qn: number; desc: string }[]; currentQn: number; onQualityChange: (qn: number) => void; onFullscreen: () => void; onMiniPlayer?: () => void; style?: object; bvid?: string; cid?: number; danmakus?: DanmakuItem[]; isFullscreen?: boolean; onTimeUpdate?: (t: number) => void; initialTime?: number; } export function NativeVideoPlayer({ 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; const [resolvedUrl, setResolvedUrl] = useState(); const isDash = !!playData?.dash; const [showControls, setShowControls] = useState(true); const hideTimer = useRef | null>(null); const [paused, setPaused] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const durationRef = useRef(0); const [showQuality, setShowQuality] = useState(false); const [isSeeking, setIsSeeking] = useState(false); const isSeekingRef = useRef(false); const [touchX, setTouchX] = useState(null); const barOffsetX = useRef(0); const barWidthRef = useRef(300); const trackRef = useRef(null); const [heatSegments, setHeatSegments] = useState([]); const [shots, setShots] = useState(null); const [shotTimestamps, setShotTimestamps] = useState([]); const [showDanmaku, setShowDanmaku] = useState(true); const videoRef = useRef(null); const currentDesc = qualities.find((q) => q.qn === currentQn)?.desc ?? String(currentQn || "HD"); // URL resolution useEffect(() => { if (!playData) { setResolvedUrl(undefined); return; } if (isDash) { buildDashMpdUri(playData, currentQn) .then(setResolvedUrl) .catch(() => setResolvedUrl(playData.dash!.video[0]?.baseUrl)); } else { setResolvedUrl(playData.durl?.[0]?.url); } }, [playData, currentQn]); // Heatmap + shots 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); 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]); const resetHideTimer = useCallback(() => { if (hideTimer.current) clearTimeout(hideTimer.current); if (!isSeekingRef.current) { hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY); } }, []); const showAndReset = useCallback(() => { setShowControls(true); resetHideTimer(); }, [resetHideTimer]); const handleTap = useCallback(() => { setShowControls((prev) => { if (!prev) { resetHideTimer(); return true; } if (hideTimer.current) clearTimeout(hideTimer.current); return false; }); }, [resetHideTimer]); // Start hide timer on mount useEffect(() => { resetHideTimer(); return () => { if (hideTimer.current) clearTimeout(hideTimer.current); }; }, []); const measureTrack = useCallback(() => { trackRef.current?.measureInWindow((x, _y, w) => { if (w > 0) { barOffsetX.current = x; barWidthRef.current = w; } }); }, []); const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderGrant: (_, gs) => { isSeekingRef.current = true; setIsSeeking(true); setShowControls(true); if (hideTimer.current) clearTimeout(hideTimer.current); setTouchX(clamp(gs.x0 - barOffsetX.current, 0, barWidthRef.current)); }, onPanResponderMove: (_, gs) => { setTouchX(clamp(gs.moveX - barOffsetX.current, 0, barWidthRef.current)); }, onPanResponderRelease: (_, gs) => { const ratio = clamp( (gs.moveX - barOffsetX.current) / barWidthRef.current, 0, 1, ); const t = ratio * durationRef.current; videoRef.current?.seek(t); setCurrentTime(t); setTouchX(null); isSeekingRef.current = false; setIsSeeking(false); if (hideTimer.current) clearTimeout(hideTimer.current); 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 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 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 sheetIdx = Math.floor(frameIdx / framesPerSheet); const local = frameIdx % framesPerSheet; const col = local % img_x_len; const row = Math.floor(local / img_x_len); // 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(seekTime)} ); }; return ( {resolvedUrl ? (