diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index a836e79..6b1e17d 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -1,21 +1,75 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; import { - View, StyleSheet, Dimensions, TouchableOpacity, - Text, Modal, + View, StyleSheet, Dimensions, TouchableOpacity, TouchableWithoutFeedback, + Text, Modal, Image, PanResponder, } 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 } from '../services/types'; +import type { PlayUrlResponse, VideoShotData } from '../services/types'; import { buildDashMpdUri } from '../utils/dash'; +import { getHeatmap, getVideoShot } from '../services/bilibili'; -const { width } = Dimensions.get('window'); -const VIDEO_HEIGHT = width * 0.5625; +const { width: SCREEN_W } = Dimensions.get('window'); +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 BILIBILI_HEADERS = { +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 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 }[]; @@ -24,101 +78,355 @@ interface Props { onFullscreen: () => void; onMiniPlayer?: () => void; style?: object; - onProgress?: (currentTime: number, duration: number) => void; - seekTo?: { t: number; v: number }; + bvid?: string; + cid?: number; } export function NativeVideoPlayer({ playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style, - onProgress, seekTo, + bvid, cid, }: Props) { - const [showQuality, setShowQuality] = useState(false); const [resolvedUrl, setResolvedUrl] = useState(); - const videoRef = useRef(null); - const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? (currentQn ? String(currentQn) : 'HD'); 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(SCREEN_W); + const trackRef = useRef(null); + + const [heatSegments, setHeatSegments] = useState([]); + const [shots, setShots] = useState(null); + + 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); - }); + buildDashMpdUri(playData, currentQn) + .then(setResolvedUrl) + .catch(() => setResolvedUrl(playData.dash!.video[0]?.baseUrl)); } else { setResolvedUrl(playData.durl?.[0]?.url); } }, [playData, currentQn]); + // Heatmap + shots useEffect(() => { - if (seekTo !== undefined) videoRef.current?.seek(seekTo.t); - }, [seekTo]); + 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); + }); + 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 renderThumbnail = () => { + if (touchRatio === null || !shots) 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 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); + return ( + + + + + {formatTime((touchRatio ?? 0) * duration)} + + ); + }; return ( - - {resolvedUrl ? ( -