mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-08 23:43:52 +08:00
视频清晰度
This commit is contained in:
@@ -7,6 +7,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
|||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { VideoPlayer } from '../../components/VideoPlayer';
|
import { VideoPlayer } from '../../components/VideoPlayer';
|
||||||
|
import { HeatProgressBar } from '../../components/HeatProgressBar';
|
||||||
import { CommentItem } from '../../components/CommentItem';
|
import { CommentItem } from '../../components/CommentItem';
|
||||||
import { useVideoDetail } from '../../hooks/useVideoDetail';
|
import { useVideoDetail } from '../../hooks/useVideoDetail';
|
||||||
import { useComments } from '../../hooks/useComments';
|
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 { video, playData, loading: videoLoading, qualities, currentQn, changeQuality } = useVideoDetail(bvid as string);
|
||||||
const { comments, loading: cmtLoading, load: loadComments } = useComments(video?.aid ?? 0);
|
const { comments, loading: cmtLoading, load: loadComments } = useComments(video?.aid ?? 0);
|
||||||
const [tab, setTab] = useState<Tab>('comments');
|
const [tab, setTab] = useState<Tab>('comments');
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [seekCmd, setSeekCmd] = useState<{ t: number; v: number } | undefined>();
|
||||||
const { setVideo, clearVideo } = useVideoStore();
|
const { setVideo, clearVideo } = useVideoStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,8 +60,20 @@ export default function VideoDetailScreen() {
|
|||||||
currentQn={currentQn}
|
currentQn={currentQn}
|
||||||
onQualityChange={changeQuality}
|
onQualityChange={changeQuality}
|
||||||
onMiniPlayer={handleMiniPlayer}
|
onMiniPlayer={handleMiniPlayer}
|
||||||
|
onProgress={(ct, dur) => { setCurrentTime(ct); setDuration(dur); }}
|
||||||
|
seekTo={seekCmd}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{video?.cid && duration > 0 && (
|
||||||
|
<HeatProgressBar
|
||||||
|
bvid={bvid as string}
|
||||||
|
cid={video.cid}
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
onSeek={(t) => setSeekCmd(s => ({ t, v: (s?.v ?? 0) + 1 }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||||
{videoLoading ? (
|
{videoLoading ? (
|
||||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||||
|
|||||||
289
components/HeatProgressBar.tsx
Normal file
289
components/HeatProgressBar.tsx
Normal file
@@ -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<number[]>([]);
|
||||||
|
const [shots, setShots] = useState<VideoShotData | null>(null);
|
||||||
|
const [touchX, setTouchX] = useState<number | null>(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 (
|
||||||
|
<View style={[styles.thumbContainer, { left: thumbLeft, width: THUMB_W }]}>
|
||||||
|
<View style={{ width: THUMB_W, height: THUMB_H, overflow: 'hidden', borderRadius: 4 }}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: shots.image[sheetIdx] }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: THUMB_W * shots.img_x_len,
|
||||||
|
height: THUMB_H * shots.img_y_len,
|
||||||
|
left: -col * THUMB_W,
|
||||||
|
top: -row * THUMB_H,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.timeLabel}>{timeLabel}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTouchIndicator = () => {
|
||||||
|
if (touchRatio === null) return null;
|
||||||
|
return (
|
||||||
|
<View style={[styles.touchBall, { left: touchRatio * BAR_WIDTH - BALL_SIZE / 2 }]} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={styles.wrapper}
|
||||||
|
onLayout={e => { barX.current = e.nativeEvent.layout.x + 16; }}
|
||||||
|
>
|
||||||
|
{renderThumbnail()}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={styles.barArea}
|
||||||
|
{...panResponder.panHandlers}
|
||||||
|
>
|
||||||
|
<View style={styles.barTrack}>
|
||||||
|
{segments.length > 0 ? (
|
||||||
|
segments.map((v, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
styles.segment,
|
||||||
|
{ backgroundColor: heatColor(v), width: `${100 / SEGMENTS}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View style={[styles.segment, { flex: 1, backgroundColor: '#00AEEC' }]} />
|
||||||
|
)}
|
||||||
|
{/* played overlay */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.playedOverlay,
|
||||||
|
{ width: `${progressRatio * 100}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* progress ball */}
|
||||||
|
<View
|
||||||
|
style={[styles.progressBall, { left: progressRatio * BAR_WIDTH - BALL_SIZE / 2 }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{renderTouchIndicator()}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -13,9 +13,11 @@ interface Props {
|
|||||||
currentQn: number;
|
currentQn: number;
|
||||||
onQualityChange: (qn: number) => void;
|
onQualityChange: (qn: number) => void;
|
||||||
onMiniPlayer?: () => 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);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
||||||
if (!playData) {
|
if (!playData) {
|
||||||
@@ -49,6 +51,8 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
|||||||
onQualityChange={onQualityChange}
|
onQualityChange={onQualityChange}
|
||||||
onFullscreen={() => setFullscreen(true)}
|
onFullscreen={() => setFullscreen(true)}
|
||||||
onMiniPlayer={onMiniPlayer}
|
onMiniPlayer={onMiniPlayer}
|
||||||
|
onProgress={onProgress}
|
||||||
|
seekTo={seekTo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal visible={fullscreen} animationType="fade" statusBarTranslucent>
|
<Modal visible={fullscreen} animationType="fade" statusBarTranslucent>
|
||||||
@@ -61,6 +65,8 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
|||||||
onQualityChange={onQualityChange}
|
onQualityChange={onQualityChange}
|
||||||
onFullscreen={() => setFullscreen(false)}
|
onFullscreen={() => setFullscreen(false)}
|
||||||
style={{ width: '100%', height: '100%' } as any}
|
style={{ width: '100%', height: '100%' } as any}
|
||||||
|
onProgress={onProgress}
|
||||||
|
seekTo={seekTo}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity style={styles.closeBtn} onPress={() => setFullscreen(false)}>
|
<TouchableOpacity style={styles.closeBtn} onPress={() => setFullscreen(false)}>
|
||||||
<Ionicons name="close" size={28} color="#fff" />
|
<Ionicons name="close" size={28} color="#fff" />
|
||||||
|
|||||||
Reference in New Issue
Block a user