mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
1
This commit is contained in:
@@ -1,15 +1,27 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
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';
|
||||
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;
|
||||
// 进度球尺寸
|
||||
@@ -22,8 +34,9 @@ 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',
|
||||
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) {
|
||||
@@ -49,63 +62,145 @@ function decodeFloats(base64: string): number[] {
|
||||
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);
|
||||
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 decodePvData(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);
|
||||
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 wt = tag & 0x7;
|
||||
if (wt === 5) { timestamps.push(view.getFloat32(i, true)); i += 4; }
|
||||
else if (wt === 2) {
|
||||
// Packed floats — read length prefix then extract each float32
|
||||
let len = 0, shift = 0;
|
||||
do { const b = bytes[i++]; len |= (b & 0x7f) << shift; shift += 7; if (!(b & 0x80)) break; } while (true);
|
||||
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;
|
||||
while (i < end) { timestamps.push(view.getFloat32(i, true)); i += 4; }
|
||||
// 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; // 未知类型,停止
|
||||
}
|
||||
else if (wt === 0) { while (i < bytes.length && (bytes[i++] & 0x80)); }
|
||||
else if (wt === 1) { i += 8; }
|
||||
else break;
|
||||
}
|
||||
return timestamps;
|
||||
|
||||
// 过滤掉明显异常值(比如负数或极大值)
|
||||
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;
|
||||
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;
|
||||
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 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;
|
||||
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')}`;
|
||||
return `${m}:${Math.floor(s % 60)
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -125,8 +220,19 @@ interface Props {
|
||||
}
|
||||
|
||||
export function NativeVideoPlayer({
|
||||
playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style,
|
||||
bvid, cid, danmakus, isFullscreen, onTimeUpdate, initialTime,
|
||||
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;
|
||||
@@ -157,11 +263,16 @@ export function NativeVideoPlayer({
|
||||
const [showDanmaku, setShowDanmaku] = useState(true);
|
||||
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? String(currentQn || 'HD');
|
||||
const currentDesc =
|
||||
qualities.find((q) => q.qn === currentQn)?.desc ??
|
||||
String(currentQn || "HD");
|
||||
|
||||
// URL resolution
|
||||
useEffect(() => {
|
||||
if (!playData) { setResolvedUrl(undefined); return; }
|
||||
if (!playData) {
|
||||
setResolvedUrl(undefined);
|
||||
return;
|
||||
}
|
||||
if (isDash) {
|
||||
buildDashMpdUri(playData, currentQn)
|
||||
.then(setResolvedUrl)
|
||||
@@ -175,24 +286,41 @@ export function NativeVideoPlayer({
|
||||
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);
|
||||
if (shotData.pvdata) {
|
||||
try { setShotTimestamps(decodePvData(shotData.pvdata)); }
|
||||
catch { setShotTimestamps([]); }
|
||||
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([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
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]);
|
||||
useEffect(() => {
|
||||
durationRef.current = duration;
|
||||
}, [duration]);
|
||||
|
||||
const resetHideTimer = useCallback(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
@@ -207,8 +335,11 @@ export function NativeVideoPlayer({
|
||||
}, [resetHideTimer]);
|
||||
|
||||
const handleTap = useCallback(() => {
|
||||
setShowControls(prev => {
|
||||
if (!prev) { resetHideTimer(); return true; }
|
||||
setShowControls((prev) => {
|
||||
if (!prev) {
|
||||
resetHideTimer();
|
||||
return true;
|
||||
}
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
return false;
|
||||
});
|
||||
@@ -217,7 +348,9 @@ export function NativeVideoPlayer({
|
||||
// Start hide timer on mount
|
||||
useEffect(() => {
|
||||
resetHideTimer();
|
||||
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
|
||||
return () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const measureTrack = useCallback(() => {
|
||||
@@ -244,7 +377,11 @@ export function NativeVideoPlayer({
|
||||
setTouchX(clamp(gs.moveX - barOffsetX.current, 0, barWidthRef.current));
|
||||
},
|
||||
onPanResponderRelease: (_, gs) => {
|
||||
const ratio = clamp((gs.moveX - barOffsetX.current) / barWidthRef.current, 0, 1);
|
||||
const ratio = clamp(
|
||||
(gs.moveX - barOffsetX.current) / barWidthRef.current,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const t = ratio * durationRef.current;
|
||||
videoRef.current?.seek(t);
|
||||
setCurrentTime(t);
|
||||
@@ -252,32 +389,43 @@ export function NativeVideoPlayer({
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
|
||||
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 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 {
|
||||
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 frameIdx =
|
||||
shotTimestamps.length > 0
|
||||
? findFrameIdx(shotTimestamps, seekTime)
|
||||
: Math.floor(touchRatio * (totalFrames - 1));
|
||||
|
||||
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
|
||||
const local = frameIdx % framesPerSheet;
|
||||
@@ -293,14 +441,21 @@ export function NativeVideoPlayer({
|
||||
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];
|
||||
const sheetUrl = image[sheetIdx].startsWith("//")
|
||||
? `https:${image[sheetIdx]}`
|
||||
: image[sheetIdx];
|
||||
return (
|
||||
<View style={[styles.thumbPreview, { left: absLeft, width: DW }]} pointerEvents="none">
|
||||
<View style={{ width: DW, height: DH, overflow: 'hidden', borderRadius: 4 }}>
|
||||
<View
|
||||
style={[styles.thumbPreview, { left: absLeft, width: DW }]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<View
|
||||
style={{ width: DW, height: DH, overflow: "hidden", borderRadius: 4 }}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: sheetUrl, headers: HEADERS }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
width: TW * img_x_len * scale,
|
||||
height: TH * img_y_len * scale,
|
||||
left: -col * DW,
|
||||
@@ -314,14 +469,22 @@ export function NativeVideoPlayer({
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width: SCREEN_W, height: VIDEO_H }, style]}>
|
||||
<View
|
||||
style={[
|
||||
isFullscreen
|
||||
? styles.fsContainer
|
||||
: [styles.container, { width: SCREEN_W, height: VIDEO_H }],
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{resolvedUrl ? (
|
||||
<Video
|
||||
key={resolvedUrl}
|
||||
ref={videoRef}
|
||||
source={isDash
|
||||
? { uri: resolvedUrl, type: 'mpd', headers: HEADERS }
|
||||
: { uri: resolvedUrl, headers: HEADERS }
|
||||
source={
|
||||
isDash
|
||||
? { uri: resolvedUrl, type: "mpd", headers: HEADERS }
|
||||
: { uri: resolvedUrl, headers: HEADERS }
|
||||
}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="contain"
|
||||
@@ -361,27 +524,41 @@ export function NativeVideoPlayer({
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0.55)', 'transparent']}
|
||||
colors={["rgba(0,0,0,0.55)", "transparent"]}
|
||||
style={styles.topBar}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
{onMiniPlayer && (
|
||||
<TouchableOpacity onPress={onMiniPlayer} style={styles.topBtn}>
|
||||
<Ionicons name="tablet-portrait-outline" size={20} color="#fff" />
|
||||
<Ionicons
|
||||
name="tablet-portrait-outline"
|
||||
size={20}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</LinearGradient>
|
||||
|
||||
{/* Center play/pause */}
|
||||
<TouchableOpacity style={styles.centerBtn} onPress={() => { setPaused(p => !p); showAndReset(); }}>
|
||||
<TouchableOpacity
|
||||
style={styles.centerBtn}
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<View style={styles.centerBtnBg}>
|
||||
<Ionicons name={paused ? 'play' : 'pause'} size={28} color="#fff" />
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={28}
|
||||
color="#fff"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.7)']}
|
||||
colors={["transparent", "rgba(0,0,0,0.7)"]}
|
||||
style={styles.bottomBar}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
@@ -393,38 +570,86 @@ export function NativeVideoPlayer({
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<View style={styles.track}>
|
||||
{heatSegments.length > 0
|
||||
? heatSegments.map((v, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[styles.seg, { backgroundColor: heatColor(v), width: `${100 / SEGMENTS}%` as any }]}
|
||||
/>
|
||||
))
|
||||
: <View style={[styles.seg, { flex: 1, backgroundColor: '#00AEEC' }]} />
|
||||
}
|
||||
<View style={[styles.playedOverlay, { width: `${progressRatio * 100}%` as any }]} />
|
||||
{heatSegments.length > 0 ? (
|
||||
heatSegments.map((v, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
styles.seg,
|
||||
{
|
||||
backgroundColor: heatColor(v),
|
||||
width: `${100 / SEGMENTS}%` as any,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.seg,
|
||||
{ flex: 1, backgroundColor: "#00AEEC" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
styles.playedOverlay,
|
||||
{ width: `${progressRatio * 100}%` as any },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{isSeeking && touchX !== null ? (
|
||||
<View style={[styles.ball, styles.ballActive, { left: touchX - BALL_ACTIVE / 2 }]} />
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
styles.ballActive,
|
||||
{ left: touchX - BALL_ACTIVE / 2 },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.ball, { left: progressRatio * barWidthRef.current - BALL / 2 }]} />
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
{ left: progressRatio * barWidthRef.current - BALL / 2 },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Controls row */}
|
||||
<View style={styles.ctrlRow}>
|
||||
<TouchableOpacity onPress={() => { setPaused(p => !p); showAndReset(); }} style={styles.ctrlBtn}>
|
||||
<Ionicons name={paused ? 'play' : 'pause'} size={16} color="#fff" />
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
showAndReset();
|
||||
}}
|
||||
style={styles.ctrlBtn}
|
||||
>
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={16}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.timeText}>{formatTime(currentTime)}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text style={styles.timeText}>{formatTime(duration)}</Text>
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowQuality(true)}>
|
||||
<TouchableOpacity
|
||||
style={styles.ctrlBtn}
|
||||
onPress={() => setShowQuality(true)}
|
||||
>
|
||||
<Text style={styles.qualityText}>{currentDesc}</Text>
|
||||
</TouchableOpacity>
|
||||
{isFullscreen && (
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowDanmaku(v => !v)}>
|
||||
<Ionicons name={showDanmaku ? 'chatbubbles' : 'chatbubbles-outline'} size={16} color="#fff" />
|
||||
<TouchableOpacity
|
||||
style={styles.ctrlBtn}
|
||||
onPress={() => setShowDanmaku((v) => !v)}
|
||||
>
|
||||
<Ionicons
|
||||
name={showDanmaku ? "chatbubbles" : "chatbubbles-outline"}
|
||||
size={16}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
|
||||
@@ -440,19 +665,33 @@ export function NativeVideoPlayer({
|
||||
|
||||
{/* Quality modal */}
|
||||
<Modal visible={showQuality} transparent animationType="fade">
|
||||
<TouchableOpacity style={styles.modalOverlay} onPress={() => setShowQuality(false)}>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
onPress={() => setShowQuality(false)}
|
||||
>
|
||||
<View style={styles.qualityList}>
|
||||
<Text style={styles.qualityTitle}>选择清晰度</Text>
|
||||
{qualities.map(q => (
|
||||
{qualities.map((q) => (
|
||||
<TouchableOpacity
|
||||
key={q.qn}
|
||||
style={styles.qualityItem}
|
||||
onPress={() => { setShowQuality(false); onQualityChange(q.qn); showAndReset(); }}
|
||||
onPress={() => {
|
||||
setShowQuality(false);
|
||||
onQualityChange(q.qn);
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.qualityItemText, q.qn === currentQn && styles.qualityItemActive]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.qualityItemText,
|
||||
q.qn === currentQn && styles.qualityItemActive,
|
||||
]}
|
||||
>
|
||||
{q.desc}
|
||||
</Text>
|
||||
{q.qn === currentQn && <Ionicons name="checkmark" size={16} color="#00AEEC" />}
|
||||
{q.qn === currentQn && (
|
||||
<Ionicons name="checkmark" size={16} color="#00AEEC" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
@@ -463,66 +702,127 @@ export function NativeVideoPlayer({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { backgroundColor: '#000' },
|
||||
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' },
|
||||
container: { backgroundColor: "#000" },
|
||||
fsContainer: { flex: 1, backgroundColor: "#000" },
|
||||
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: "#000" },
|
||||
topBar: {
|
||||
position: 'absolute', top: 0, left: 0, right: 0, height: 56,
|
||||
paddingHorizontal: 12, paddingTop: 10,
|
||||
flexDirection: 'row', justifyContent: 'flex-end',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 56,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 10,
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
topBtn: { padding: 6 },
|
||||
centerBtn: {
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
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',
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: "rgba(0,0,0,0.45)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
bottomBar: {
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
paddingBottom: 8, paddingTop: 32,
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 32,
|
||||
},
|
||||
thumbPreview: { position: 'absolute', bottom: 64, alignItems: 'center' },
|
||||
thumbPreview: { position: "absolute", bottom: 64, 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,
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
marginTop: 2,
|
||||
textShadowColor: "rgba(0,0,0,0.7)",
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
trackWrapper: {
|
||||
marginHorizontal: 8,
|
||||
height: BAR_H + BALL_ACTIVE,
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
track: {
|
||||
height: BAR_H, flexDirection: 'row',
|
||||
borderRadius: 2, overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||
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)',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: BAR_H,
|
||||
backgroundColor: "rgba(255,255,255,0.3)",
|
||||
},
|
||||
ball: {
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: (BAR_H + BALL_ACTIVE) / 2 - BALL / 2,
|
||||
width: BALL, height: BALL, borderRadius: BALL / 2,
|
||||
backgroundColor: '#fff', elevation: 3,
|
||||
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,
|
||||
width: BALL_ACTIVE,
|
||||
height: BALL_ACTIVE,
|
||||
borderRadius: BALL_ACTIVE / 2,
|
||||
backgroundColor: "#00AEEC",
|
||||
top: 0,
|
||||
},
|
||||
ctrlRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
ctrlRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, marginTop: 4 },
|
||||
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
|
||||
timeText: { color: '#fff', fontSize: 11, marginHorizontal: 2 },
|
||||
qualityText: { color: '#fff', fontSize: 11, fontWeight: '600' },
|
||||
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' },
|
||||
timeText: { color: "#fff", fontSize: 11, marginHorizontal: 2 },
|
||||
qualityText: { color: "#fff", fontSize: 11, fontWeight: "600" },
|
||||
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" },
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatCount, formatDuration } from '../utils/format';
|
||||
import { proxyImageUrl } from '../utils/imageUrl';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const CARD_WIDTH = (width - 24) / 2;
|
||||
const CARD_WIDTH = (width - 14) / 2;
|
||||
|
||||
interface Props {
|
||||
item: VideoItem;
|
||||
@@ -39,7 +39,7 @@ export function VideoCard({ item, onPress }: Props) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { width: CARD_WIDTH, marginBottom: 12, backgroundColor: '#fff', borderRadius: 6, overflow: 'hidden' },
|
||||
card: { width: CARD_WIDTH, marginBottom: 6, backgroundColor: '#fff', borderRadius: 6, overflow: 'hidden' },
|
||||
thumbContainer: { position: 'relative' },
|
||||
thumb: { width: CARD_WIDTH, height: CARD_WIDTH * 0.5625 },
|
||||
durationBadge: { position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1 },
|
||||
|
||||
@@ -22,14 +22,14 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { width, height } = useWindowDimensions();
|
||||
const VIDEO_HEIGHT = width * 0.5625;
|
||||
// When ScreenOrientation is unavailable (Expo Go), simulate landscape via transform
|
||||
// In Expo Go ScreenOrientation is unavailable; simulate landscape via CSS transform
|
||||
const needsRotation = !ScreenOrientation && fullscreen;
|
||||
const lastTimeRef = useRef(0);
|
||||
|
||||
const handleEnterFullscreen = async () => {
|
||||
setFullscreen(true);
|
||||
if (Platform.OS !== 'web')
|
||||
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT);
|
||||
setFullscreen(true);
|
||||
};
|
||||
|
||||
const handleExitFullscreen = async () => {
|
||||
@@ -85,7 +85,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal visible={fullscreen} animationType="fade" statusBarTranslucent>
|
||||
<Modal visible={fullscreen} animationType="none" statusBarTranslucent>
|
||||
<StatusBar hidden />
|
||||
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<View style={needsRotation
|
||||
@@ -104,7 +104,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
||||
isFullscreen={true}
|
||||
initialTime={lastTimeRef.current}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
style={needsRotation ? { width: height, height: width } : { flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user