From 4c72ff3cdd7d62067be8f7424ff85a82e6d37c9e Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 10 Mar 2026 20:21:22 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=B8=85=E6=99=B0=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/video/[bvid].tsx | 16 ++ components/HeatProgressBar.tsx | 289 +++++++++++++++++++++++++++++++++ components/VideoPlayer.tsx | 8 +- 3 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 components/HeatProgressBar.tsx diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index 91f4f8a..025de5d 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -7,6 +7,7 @@ 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 { HeatProgressBar } from '../../components/HeatProgressBar'; import { CommentItem } from '../../components/CommentItem'; import { useVideoDetail } from '../../hooks/useVideoDetail'; import { useComments } from '../../hooks/useComments'; @@ -21,6 +22,9 @@ export default function VideoDetailScreen() { 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 [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [seekCmd, setSeekCmd] = useState<{ t: number; v: number } | undefined>(); const { setVideo, clearVideo } = useVideoStore(); useEffect(() => { @@ -56,8 +60,20 @@ export default function VideoDetailScreen() { currentQn={currentQn} onQualityChange={changeQuality} onMiniPlayer={handleMiniPlayer} + onProgress={(ct, dur) => { setCurrentTime(ct); setDuration(dur); }} + seekTo={seekCmd} /> + {video?.cid && duration > 0 && ( + setSeekCmd(s => ({ t, v: (s?.v ?? 0) + 1 }))} + /> + )} + {videoLoading ? ( diff --git a/components/HeatProgressBar.tsx b/components/HeatProgressBar.tsx new file mode 100644 index 0000000..2d15dc2 --- /dev/null +++ b/components/HeatProgressBar.tsx @@ -0,0 +1,289 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { + View, Image, Text, StyleSheet, Dimensions, PanResponder, +} from 'react-native'; +import { getHeatmap, getVideoShot } from '../services/bilibili'; +import type { VideoShotData } from '../services/types'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const BAR_H = 4; +const BALL_SIZE = 12; +const THUMB_PREVIEW_H = 60; // space above bar for thumbnail +const CONTAINER_H = THUMB_PREVIEW_H + 16 + BAR_H + BALL_SIZE; +const SEGMENTS = 120; + +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; + const r = Math.round(t * 255); + return `rgb(${r},174,236)`; + } + const t = (v - 0.5) * 2; + const g = Math.round((1 - t) * 114); + const b = Math.round((1 - t) * 153); + return `rgb(251,${g},${b})`; +} + +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 wireType = tag & 0x7; + if (wireType === 5) { + floats.push(view.getFloat32(i, true)); + i += 4; + } else if (wireType === 0) { + while (i < bytes.length && (bytes[i++] & 0x80)); + } else if (wireType === 1) { + i += 8; + } else if (wireType === 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 === 0) return Array(n).fill(0); + const result: number[] = []; + for (let i = 0; i < n; i++) { + const idx = Math.floor((i / n) * data.length); + result.push(data[idx]); + } + const max = Math.max(...result); + if (max === 0) return result; + return result.map(v => v / max); +} + +interface Props { + bvid: string; + cid: number; + currentTime: number; + duration: number; + onSeek: (t: number) => void; +} + +export function HeatProgressBar({ bvid, cid, currentTime, duration, onSeek }: Props) { + const [segments, setSegments] = useState([]); + const [shots, setShots] = useState(null); + const [touchX, setTouchX] = useState(null); + const barX = useRef(0); + + useEffect(() => { + let cancelled = false; + Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then(([heatmap, shotData]) => { + if (cancelled) return; + if (heatmap?.pb_data) { + try { + const floats = decodeFloats(heatmap.pb_data); + setSegments(downsample(floats, SEGMENTS)); + } catch { + setSegments([]); + } + } + if (shotData?.image?.length) setShots(shotData); + }); + return () => { cancelled = true; }; + }, [bvid, cid]); + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderGrant: (_, gs) => { + setTouchX(gs.x0 - barX.current); + }, + onPanResponderMove: (_, gs) => { + setTouchX(gs.moveX - barX.current); + }, + onPanResponderRelease: (_, gs) => { + const relX = gs.moveX - barX.current; + const ratio = clamp(relX / BAR_WIDTH, 0, 1); + onSeek(ratio * duration); + setTouchX(null); + }, + onPanResponderTerminate: () => setTouchX(null), + }) + ).current; + + const BAR_WIDTH = SCREEN_WIDTH - 32; + const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0; + const touchRatio = touchX !== null ? clamp(touchX / BAR_WIDTH, 0, 1) : null; + + const renderThumbnail = () => { + if (touchRatio === null || !shots) return null; + const THUMB_W = shots.img_x_size; + const THUMB_H = shots.img_y_size; + const totalFrames = shots.img_x_len * shots.img_y_len * shots.image.length; + const framesPerSheet = shots.img_x_len * shots.img_y_len; + const frameIdx = Math.floor(touchRatio * (totalFrames - 1)); + const sheetIdx = Math.floor(frameIdx / framesPerSheet); + const local = frameIdx % framesPerSheet; + const col = local % shots.img_x_len; + const row = Math.floor(local / shots.img_x_len); + const thumbLeft = clamp((touchX ?? 0) - THUMB_W / 2, 0, BAR_WIDTH - THUMB_W); + + const timeLabel = formatTime(touchRatio * duration); + + return ( + + + + + {timeLabel} + + ); + }; + + const renderTouchIndicator = () => { + if (touchRatio === null) return null; + return ( + + ); + }; + + return ( + { barX.current = e.nativeEvent.layout.x + 16; }} + > + {renderThumbnail()} + + + + {segments.length > 0 ? ( + segments.map((v, i) => ( + + )) + ) : ( + + )} + {/* played overlay */} + + + + {/* progress ball */} + + + {renderTouchIndicator()} + + + ); +} + +function formatTime(s: number): string { + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${sec.toString().padStart(2, '0')}`; +} + +const BAR_WIDTH = Dimensions.get('window').width - 32; + +const styles = StyleSheet.create({ + wrapper: { + backgroundColor: '#fff', + paddingHorizontal: 16, + paddingBottom: 8, + paddingTop: 4, + overflow: 'visible', + }, + barArea: { + height: BAR_H + BALL_SIZE, + justifyContent: 'center', + position: 'relative', + }, + barTrack: { + height: BAR_H, + flexDirection: 'row', + borderRadius: 2, + overflow: 'hidden', + backgroundColor: '#e0e0e0', + }, + segment: { + height: BAR_H, + }, + playedOverlay: { + position: 'absolute', + top: 0, + left: 0, + height: BAR_H, + backgroundColor: 'rgba(255,255,255,0.3)', + }, + progressBall: { + position: 'absolute', + top: (BAR_H + BALL_SIZE) / 2 - BALL_SIZE / 2, + width: BALL_SIZE, + height: BALL_SIZE, + borderRadius: BALL_SIZE / 2, + backgroundColor: '#fff', + borderWidth: 2, + borderColor: '#00AEEC', + shadowColor: '#000', + shadowOpacity: 0.2, + shadowRadius: 2, + elevation: 2, + }, + touchBall: { + position: 'absolute', + top: (BAR_H + BALL_SIZE) / 2 - BALL_SIZE / 2, + width: BALL_SIZE, + height: BALL_SIZE, + borderRadius: BALL_SIZE / 2, + backgroundColor: '#00AEEC', + opacity: 0.8, + }, + thumbContainer: { + position: 'absolute', + top: -THUMB_PREVIEW_H - 4, + alignItems: 'center', + }, + timeLabel: { + fontSize: 11, + color: '#212121', + marginTop: 2, + fontWeight: '600', + }, +}); diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 0944ae5..6ff0064 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -13,9 +13,11 @@ interface Props { currentQn: number; onQualityChange: (qn: number) => void; onMiniPlayer?: () => void; + onProgress?: (currentTime: number, duration: number) => void; + seekTo?: { t: number; v: number }; } -export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, onMiniPlayer }: Props) { +export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, onMiniPlayer, onProgress, seekTo }: Props) { const [fullscreen, setFullscreen] = useState(false); if (!playData) { @@ -49,6 +51,8 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o onQualityChange={onQualityChange} onFullscreen={() => setFullscreen(true)} onMiniPlayer={onMiniPlayer} + onProgress={onProgress} + seekTo={seekTo} /> @@ -61,6 +65,8 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o onQualityChange={onQualityChange} onFullscreen={() => setFullscreen(false)} style={{ width: '100%', height: '100%' } as any} + onProgress={onProgress} + seekTo={seekTo} /> setFullscreen(false)}>