From ee213347c7efb145e6dcd2723ee52775b4f5412f Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 10 Mar 2026 21:48:23 +0800 Subject: [PATCH] feat: unified player controls with heatmap progress + thumbnail preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VideoPlayer: 移除 onProgress/seekTo props,新增 bvid/cid 向下透传 - [bvid].tsx: 删除 HeatProgressBar 及 currentTime/duration/seekCmd state - HeatProgressBar.tsx: 删除(逻辑已合并进 NativeVideoPlayer) - 计划文档已保存到 docs/superpowers/plans/ --- app/video/[bvid].tsx | 18 +- components/HeatProgressBar.tsx | 289 -------- components/VideoPlayer.tsx | 28 +- .../plans/2026-03-10-player-ui-redesign.md | 625 ++++++++++++++++++ services/types.ts | 1 + utils/buildMpd.ts | 1 + 6 files changed, 637 insertions(+), 325 deletions(-) delete mode 100644 components/HeatProgressBar.tsx create mode 100644 docs/superpowers/plans/2026-03-10-player-ui-redesign.md diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index c49e653..dff70fe 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -7,7 +7,6 @@ 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'; @@ -23,9 +22,6 @@ 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(() => { @@ -61,20 +57,10 @@ export default function VideoDetailScreen() { currentQn={currentQn} onQualityChange={changeQuality} onMiniPlayer={handleMiniPlayer} - onProgress={(ct, dur) => { setCurrentTime(ct); setDuration(dur); }} - seekTo={seekCmd} + bvid={bvid as string} + cid={video?.cid} /> - {video?.cid && duration > 0 && ( - setSeekCmd(s => ({ t, v: (s?.v ?? 0) + 1 }))} - /> - )} - {videoLoading ? ( diff --git a/components/HeatProgressBar.tsx b/components/HeatProgressBar.tsx deleted file mode 100644 index 2d15dc2..0000000 --- a/components/HeatProgressBar.tsx +++ /dev/null @@ -1,289 +0,0 @@ -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 6ff0064..264a025 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; -import { View, StyleSheet, Dimensions, Text, Platform, Modal, TouchableOpacity, StatusBar } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; +import { View, StyleSheet, Dimensions, Text, Platform, Modal, StatusBar } from 'react-native'; import { NativeVideoPlayer } from './NativeVideoPlayer'; import type { PlayUrlResponse } from '../services/types'; @@ -13,11 +12,11 @@ interface Props { currentQn: number; onQualityChange: (qn: number) => void; onMiniPlayer?: () => void; - onProgress?: (currentTime: number, duration: number) => void; - seekTo?: { t: number; v: number }; + bvid?: string; + cid?: number; } -export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, onMiniPlayer, onProgress, seekTo }: Props) { +export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, onMiniPlayer, bvid, cid }: Props) { const [fullscreen, setFullscreen] = useState(false); if (!playData) { @@ -51,8 +50,8 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o onQualityChange={onQualityChange} onFullscreen={() => setFullscreen(true)} onMiniPlayer={onMiniPlayer} - onProgress={onProgress} - seekTo={seekTo} + bvid={bvid} + cid={cid} /> @@ -64,13 +63,10 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o currentQn={currentQn} onQualityChange={onQualityChange} onFullscreen={() => setFullscreen(false)} + bvid={bvid} + cid={cid} style={{ width: '100%', height: '100%' } as any} - onProgress={onProgress} - seekTo={seekTo} /> - setFullscreen(false)}> - - @@ -82,12 +78,4 @@ const styles = StyleSheet.create({ placeholder: { justifyContent: 'center', alignItems: 'center' }, placeholderText: { color: '#fff', fontSize: 14 }, fullscreenContainer: { flex: 1, backgroundColor: '#000' }, - closeBtn: { - position: 'absolute', - top: 40, - right: 16, - padding: 8, - backgroundColor: 'rgba(0,0,0,0.4)', - borderRadius: 20, - }, }); diff --git a/docs/superpowers/plans/2026-03-10-player-ui-redesign.md b/docs/superpowers/plans/2026-03-10-player-ui-redesign.md new file mode 100644 index 0000000..62e1849 --- /dev/null +++ b/docs/superpowers/plans/2026-03-10-player-ui-redesign.md @@ -0,0 +1,625 @@ +# Player UI Redesign Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 重写 NativeVideoPlayer,将热度进度条 + 缩略图预览整合进统一自定义控制层(tap-to-show/3s 自动隐藏),删除播放器下方独立的 HeatProgressBar。 + +**Architecture:** 关闭 react-native-video 原生控制栏(`controls={false}`),用绝对定位 LinearGradient overlay 实现顶部栏 + 中心播放按钮 + 底部进度条控制栏。HeatProgressBar 的热度图解码和缩略图逻辑搬入 NativeVideoPlayer,`bvid`/`cid` 新增为 props 向下透传。VideoPlayer 和 [bvid].tsx 相应删减 state 和旧 props。 + +**Tech Stack:** React Native 0.83, react-native-video 6.x, expo-linear-gradient, PanResponder, Ionicons + +--- + +## Task 1: 重写 NativeVideoPlayer + +**Files:** +- Modify: `components/NativeVideoPlayer.tsx` (完全重写) + +- [ ] **Step 1: 用以下完整代码替换 NativeVideoPlayer.tsx** + +```tsx +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { + 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, VideoShotData } from '../services/types'; +import { buildDashMpdUri } from '../utils/dash'; +import { getHeatmap, getVideoShot } from '../services/bilibili'; + +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 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 }[]; + currentQn: number; + onQualityChange: (qn: number) => void; + onFullscreen: () => void; + onMiniPlayer?: () => void; + style?: object; + bvid?: string; + cid?: number; +} + +export function NativeVideoPlayer({ + playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style, + bvid, cid, +}: Props) { + 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(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)); + } 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); + }); + 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) => { + 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); + // use setTimeout to avoid stale resetHideTimer closure + setTimeout(() => { + if (hideTimer.current) clearTimeout(hideTimer.current); + hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY); + }, 0); + }, + 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 ? ( + + ); +} + +const styles = StyleSheet.create({ + container: { width: SCREEN_W, height: VIDEO_H, backgroundColor: '#000' }, + placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' }, + // Top bar + topBar: { position: 'absolute', top: 0, left: 0, right: 0, height: 56, paddingHorizontal: 12, paddingTop: 10, flexDirection: 'row', justifyContent: 'flex-end' }, + topBtn: { padding: 6 }, + // Center + centerBtn: { position: 'absolute', top: '50%', left: '50%', transform: [{ translateX: -28 }, { translateY: -28 }] }, + centerBtnBg: { width: 56, height: 56, borderRadius: 28, backgroundColor: 'rgba(0,0,0,0.45)', alignItems: 'center', justifyContent: 'center' }, + // Bottom bar + bottomBar: { 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' }, + thumbTime: { color: '#fff', fontSize: 11, fontWeight: '600', marginTop: 2, textShadowColor: 'rgba(0,0,0,0.7)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2 }, + // Track + trackWrapper: { marginHorizontal: 8, height: BAR_H + BALL_ACTIVE, justifyContent: 'center', position: 'relative' }, + track: { height: BAR_H, flexDirection: 'row', borderRadius: 2, overflow: 'hidden', backgroundColor: 'rgba(255,255,255,0.3)' }, + seg: { height: BAR_H }, + playedOverlay: { position: 'absolute', top: 0, left: 0, height: BAR_H, backgroundColor: 'rgba(255,255,255,0.3)' }, + ball: { position: 'absolute', top: (BAR_H + BALL_ACTIVE) / 2 - BALL / 2, width: BALL, height: BALL, borderRadius: BALL / 2, backgroundColor: '#fff', elevation: 3 }, + ballActive: { width: BALL_ACTIVE, height: BALL_ACTIVE, borderRadius: BALL_ACTIVE / 2, backgroundColor: '#00AEEC', top: 0 }, + // Controls row + ctrlRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, marginTop: 4 }, + ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 }, + timeText: { color: '#fff', fontSize: 11, marginHorizontal: 4 }, + qualityText: { color: '#fff', fontSize: 11, fontWeight: '600' }, + // Quality modal + modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center' }, + qualityList: { backgroundColor: '#fff', borderRadius: 12, paddingVertical: 8, paddingHorizontal: 16, minWidth: 180 }, + qualityTitle: { fontSize: 15, fontWeight: '700', color: '#212121', paddingVertical: 10, textAlign: 'center' }, + qualityItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#eee' }, + qualityItemText: { fontSize: 14, color: '#333' }, + qualityItemActive: { color: '#00AEEC', fontWeight: '700' }, +}); +``` + +- [ ] **Step 2: 验证 TypeScript 无报错** + +```bash +cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | grep NativeVideoPlayer +``` + +Expected: no output (no errors) + +- [ ] **Step 3: Commit** + +```bash +git add components/NativeVideoPlayer.tsx +git commit -m "feat: rewrite NativeVideoPlayer with unified custom controls overlay" +``` + +--- + +## Task 2: 更新 VideoPlayer.tsx + +**Files:** +- Modify: `components/VideoPlayer.tsx` + +- [ ] **Step 1: 添加 bvid/cid props,移除 onProgress/seekTo,向下透传** + +将 VideoPlayer.tsx 完整替换为: + +```tsx +import React, { useState } from 'react'; +import { View, StyleSheet, Dimensions, Text, Platform, Modal, TouchableOpacity, StatusBar } from 'react-native'; +import { NativeVideoPlayer } from './NativeVideoPlayer'; +import type { PlayUrlResponse } from '../services/types'; + +const { width } = Dimensions.get('window'); +const VIDEO_HEIGHT = width * 0.5625; + +interface Props { + playData: PlayUrlResponse | null; + qualities: { qn: number; desc: string }[]; + currentQn: number; + onQualityChange: (qn: number) => void; + onMiniPlayer?: () => void; + bvid?: string; + cid?: number; +} + +export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, onMiniPlayer, bvid, cid }: Props) { + const [fullscreen, setFullscreen] = useState(false); + + if (!playData) { + return ( + + 视频加载中... + + ); + } + + if (Platform.OS === 'web') { + const url = playData.durl?.[0]?.url ?? ''; + return ( + + + ); + } + + return ( + <> + setFullscreen(true)} + onMiniPlayer={onMiniPlayer} + bvid={bvid} + cid={cid} + /> + + + + + ); +} + +const styles = StyleSheet.create({ + container: { width, height: VIDEO_HEIGHT, backgroundColor: '#000' }, + placeholder: { justifyContent: 'center', alignItems: 'center' }, + placeholderText: { color: '#fff', fontSize: 14 }, + fullscreenContainer: { flex: 1, backgroundColor: '#000' }, +}); +``` + +- [ ] **Step 2: 验证 TypeScript 无报错** + +```bash +cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | grep -E "VideoPlayer|NativeVideoPlayer" +``` + +Expected: no new errors + +- [ ] **Step 3: Commit** + +```bash +git add components/VideoPlayer.tsx +git commit -m "refactor: VideoPlayer adds bvid/cid props, removes onProgress/seekTo" +``` + +--- + +## Task 3: 清理 [bvid].tsx + +**Files:** +- Modify: `app/video/[bvid].tsx` + +- [ ] **Step 1: 删除 HeatProgressBar 相关内容** + +1. 删除 import 行:`import { HeatProgressBar } from '../../components/HeatProgressBar';` +2. 删除 3 个 state:`currentTime`, `duration`, `seekCmd` +3. 删除 `onProgress` 和 `seekTo` props(从 VideoPlayer 调用处) +4. 新增 `bvid={bvid as string}` 和 `cid={video?.cid}` 到 VideoPlayer +5. 删除整个 `` JSX 块 + +VideoPlayer 调用改为: +```tsx + +``` + +- [ ] **Step 2: 验证 TypeScript 无报错** + +```bash +cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | grep bvid +``` + +Expected: no errors referencing bvid.tsx + +- [ ] **Step 3: Commit** + +```bash +git add app/video/[bvid].tsx +git commit -m "refactor: remove HeatProgressBar from video detail page, pass bvid/cid to VideoPlayer" +``` + +--- + +## Task 4: 删除 HeatProgressBar.tsx + +**Files:** +- Delete: `components/HeatProgressBar.tsx` + +- [ ] **Step 1: 删除文件** + +```bash +cd C:/claude-code-studly/reactBilibiliApp && rm components/HeatProgressBar.tsx +``` + +- [ ] **Step 2: 确认无残留引用** + +```bash +cd C:/claude-code-studly/reactBilibiliApp && grep -r "HeatProgressBar" --include="*.tsx" --include="*.ts" . +``` + +Expected: no output + +- [ ] **Step 3: 全量 TypeScript 检查** + +```bash +cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | grep -v "video.stat" +``` + +Expected: 只有既有的 `video.stat` 错误,无新增错误 + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat: unified player controls with heatmap progress bar and thumbnail preview" +``` + +--- + +## 完成标志 + +- [ ] 打开视频详情页,播放器内有控制层(顶部 pip 按钮 + 中心播放/暂停 + 底部进度条 + 清晰度 + 全屏) +- [ ] 3 秒后控制层自动隐藏,点击视频区域重新显示 +- [ ] 拖拽进度条时显示缩略图预览(需要视频有截图数据) +- [ ] 切换清晰度正常工作 +- [ ] 播放器下方无独立的 HeatProgressBar diff --git a/services/types.ts b/services/types.ts index 8afa297..660c1e3 100644 --- a/services/types.ts +++ b/services/types.ts @@ -47,6 +47,7 @@ export interface DashVideoItem { codecs: string; width: number; height: number; + stat:any; frameRate: string; segment_base?: DashSegmentBase; } diff --git a/utils/buildMpd.ts b/utils/buildMpd.ts index 9fa9592..23f6aa7 100644 --- a/utils/buildMpd.ts +++ b/utils/buildMpd.ts @@ -1,3 +1,4 @@ +// 构建 DASH MPD 文件内容 export function buildMpd( videoUrl: string, videoCodecs: string,