mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
视频缓存
This commit is contained in:
@@ -8,7 +8,6 @@ import {
|
||||
Animated,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { DanmakuItem } from "../services/types";
|
||||
@@ -30,25 +29,11 @@ interface DisplayedDanmaku extends DanmakuItem {
|
||||
_fadeAnim: Animated.Value;
|
||||
}
|
||||
|
||||
const MAX_DISPLAYED = 100;
|
||||
const DRIP_INTERVAL = 250;
|
||||
const FAST_DRIP_INTERVAL = 100;
|
||||
const QUEUE_FAST_THRESHOLD = 50;
|
||||
const SEEK_THRESHOLD = 2;
|
||||
|
||||
// ─── 常见礼物 ──────────────────────────────────────────────────────────────────
|
||||
const COMMON_GIFTS = [
|
||||
{ name: "辣条", icon: "🌶️", price: "1 银瓜子" },
|
||||
{ name: "小心心", icon: "💗", price: "5 银瓜子" },
|
||||
{ name: "打call", icon: "📣", price: "500 银瓜子" },
|
||||
{ name: "干杯", icon: "🍻", price: "1 电池" },
|
||||
{ name: "比心", icon: "💕", price: "10 金瓜子" },
|
||||
{ name: "吃瓜", icon: "🍉", price: "100 金瓜子" },
|
||||
{ name: "花式夸夸", icon: "🌸", price: "1000 金瓜子" },
|
||||
{ name: "告白气球", icon: "🎈", price: "5200 金瓜子" },
|
||||
{ name: "小电视飞船", icon: "🚀", price: "1245 电池" },
|
||||
];
|
||||
|
||||
// ─── 舰长等级 ───────────────────────────────────────────────────────────────────
|
||||
const GUARD_LABELS: Record<number, { text: string; color: string }> = {
|
||||
1: { text: "总督", color: "#E13979" },
|
||||
@@ -337,34 +322,6 @@ export default function DanmakuList({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 常见礼物栏 — 仅直播模式显示 */}
|
||||
{hideHeader && visible && (
|
||||
<View style={giftStyles.bar}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={giftStyles.scroll}
|
||||
>
|
||||
{COMMON_GIFTS.map((g) => {
|
||||
const count = giftCounts?.[g.name] ?? 0;
|
||||
return (
|
||||
<TouchableOpacity key={g.name} style={giftStyles.item} activeOpacity={0.7}>
|
||||
<View style={giftStyles.iconWrap}>
|
||||
{count > 0 && (
|
||||
<View style={giftStyles.badge}>
|
||||
<Text style={giftStyles.badgeText}>+{count}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={giftStyles.icon}>{g.icon}</Text>
|
||||
</View>
|
||||
<Text style={giftStyles.name} numberOfLines={1}>{g.name}</Text>
|
||||
<Text style={giftStyles.price}>{g.price}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
160
components/DownloadProgressBtn.tsx
Normal file
160
components/DownloadProgressBtn.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useDownloadStore } from '../store/downloadStore';
|
||||
|
||||
const SIZE = 32; // 环外径
|
||||
const RING = 3; // 环宽
|
||||
const BLUE = '#00AEEC';
|
||||
const INNER = SIZE - RING * 2;
|
||||
|
||||
interface Props {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function DownloadProgressBtn({ onPress }: Props) {
|
||||
const tasks = useDownloadStore((s) => s.tasks);
|
||||
const downloading = Object.values(tasks).filter((t) => t.status === 'downloading');
|
||||
const hasDownloading = downloading.length > 0;
|
||||
|
||||
const avgProgress = hasDownloading
|
||||
? downloading.reduce((sum, t) => sum + t.progress, 0) / downloading.length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.btn} activeOpacity={0.7}>
|
||||
{/* 进度环,绝对定位居中 */}
|
||||
{/* {hasDownloading && (
|
||||
<View style={styles.ringWrap} pointerEvents="none">
|
||||
<RingProgress progress={avgProgress} />
|
||||
</View>
|
||||
)} */}
|
||||
<Ionicons
|
||||
name="cloud-download-outline"
|
||||
size={22}
|
||||
color={hasDownloading ? BLUE : '#999'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 双半圆裁剪法绘制圆弧进度
|
||||
*
|
||||
* 两个「D 形」(实心半圆) 分别放在左/右裁剪容器中,
|
||||
* 旋转 D 形就能在容器内扫出弧形,配合白色内圆变成环形。
|
||||
*
|
||||
* 旋转轴 = D 形 View 的默认中心 = 外圆圆心,无需 transformOrigin。
|
||||
*/
|
||||
function RingProgress({ progress }: { progress: number }) {
|
||||
const p = Math.max(0, Math.min(1, progress));
|
||||
|
||||
// 右半弧:进度 0→50%,右 D 从 -180°→0°(顺时针)
|
||||
const rightAngle = -180 + Math.min(p * 2, 1) * 180;
|
||||
// 左半弧:进度 50%→100%,左 D 从 180°→0°
|
||||
const leftAngle = 180 - Math.max(0, p * 2 - 1) * 180;
|
||||
|
||||
return (
|
||||
<View style={styles.ring}>
|
||||
{/* 灰色背景环 */}
|
||||
<View style={styles.ringBg} />
|
||||
|
||||
{/* ── 右裁剪:左边缘 = 圆心,只露右半 ── */}
|
||||
<View style={styles.rightClip}>
|
||||
{/* left: -SIZE/2 → D 形中心落在容器左边缘 = 外圆圆心 */}
|
||||
<View style={[styles.dRight, { transform: [{ rotate: `${rightAngle}deg` }] }]} />
|
||||
</View>
|
||||
|
||||
{/* ── 左裁剪:右边缘 = 圆心,只露左半 ── */}
|
||||
<View style={styles.leftClip}>
|
||||
{/* left: 0 → D 形中心在容器右边缘 = 外圆圆心 */}
|
||||
<View style={[styles.dLeft, { transform: [{ rotate: `${leftAngle}deg` }] }]} />
|
||||
</View>
|
||||
|
||||
{/* 白色内圆,把实心扇区变成环形 */}
|
||||
<View style={styles.inner} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
btn: {
|
||||
width: SIZE + 8,
|
||||
height: SIZE + 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// 绝对定位居中,overflow:hidden 防止 D 形溢出
|
||||
ringWrap: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
left: 4,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
ring: {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
ringBg: {
|
||||
position: 'absolute',
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
borderRadius: SIZE / 2,
|
||||
borderWidth: RING,
|
||||
borderColor: '#e0e0e0',
|
||||
},
|
||||
|
||||
// 右裁剪容器:left = SIZE/2(圆心处),宽 SIZE/2,只露右半
|
||||
rightClip: {
|
||||
position: 'absolute',
|
||||
left: SIZE / 2,
|
||||
top: 0,
|
||||
width: SIZE / 2,
|
||||
height: SIZE,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// 右 D 形(右半圆):left = -SIZE/2 → center = (0, SIZE/2) in clip = 外圆圆心
|
||||
dRight: {
|
||||
position: 'absolute',
|
||||
left: -SIZE / 2,
|
||||
top: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
borderTopRightRadius: SIZE / 2,
|
||||
borderBottomRightRadius: SIZE / 2,
|
||||
backgroundColor: BLUE,
|
||||
},
|
||||
|
||||
// 左裁剪容器:right = SIZE/2(圆心处),宽 SIZE/2,只露左半
|
||||
leftClip: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: SIZE / 2,
|
||||
height: SIZE,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// 左 D 形(左半圆):left = 0 → center = (SIZE/2, SIZE/2) in clip = 外圆圆心
|
||||
dLeft: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
borderTopLeftRadius: SIZE / 2,
|
||||
borderBottomLeftRadius: SIZE / 2,
|
||||
backgroundColor: BLUE,
|
||||
},
|
||||
|
||||
// 白色内圆(遮住 D 形中心,留出环宽)
|
||||
inner: {
|
||||
width: INNER,
|
||||
height: INNER,
|
||||
borderRadius: INNER / 2,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
});
|
||||
200
components/DownloadSheet.tsx
Normal file
200
components/DownloadSheet.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
StyleSheet,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useDownload } from "../hooks/useDownload";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
bvid: string;
|
||||
cid: number;
|
||||
title: string;
|
||||
cover: string;
|
||||
qualities: { qn: number; desc: string }[];
|
||||
}
|
||||
|
||||
export function DownloadSheet({
|
||||
visible,
|
||||
onClose,
|
||||
bvid,
|
||||
cid,
|
||||
title,
|
||||
cover,
|
||||
qualities,
|
||||
}: Props) {
|
||||
const { tasks, startDownload, taskKey } = useDownload();
|
||||
const slideAnim = useRef(new Animated.Value(300)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: visible ? 0 : 300,
|
||||
duration: 260,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [visible]);
|
||||
|
||||
if (qualities.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.overlay}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[styles.sheet, { transform: [{ translateY: slideAnim }] }]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>下载视频</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
|
||||
<Ionicons name="close" size={20} color="#555" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.divider} />
|
||||
{qualities.map((q) => {
|
||||
const key = taskKey(bvid, q.qn);
|
||||
const task = tasks[key];
|
||||
return (
|
||||
<View key={q.qn} style={styles.row}>
|
||||
<Text style={styles.qualityLabel}>{q.desc}</Text>
|
||||
<View style={styles.right}>
|
||||
{!task && (
|
||||
<TouchableOpacity
|
||||
style={styles.downloadBtn}
|
||||
onPress={() =>
|
||||
startDownload(bvid, cid, q.qn, q.desc, title, cover)
|
||||
}
|
||||
>
|
||||
<Text style={styles.downloadBtnTxt}>下载</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{task?.status === "downloading" && (
|
||||
<View style={styles.progressWrap}>
|
||||
<View style={styles.progressTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
width: `${Math.round(task.progress * 100)}%` as any,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.progressTxt}>
|
||||
{Math.round(task.progress * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{task?.status === "done" && (
|
||||
<View style={styles.doneRow}>
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={16}
|
||||
color="#00AEEC"
|
||||
/>
|
||||
<Text style={styles.doneTxt}>已下载</Text>
|
||||
</View>
|
||||
)}
|
||||
{task?.status === "error" && (
|
||||
<View style={styles.errorWrap}>
|
||||
{!!task.error && (
|
||||
<Text style={styles.errorMsg} numberOfLines={2}>
|
||||
{task.error}
|
||||
</Text>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.retryBtn}
|
||||
onPress={() =>
|
||||
startDownload(bvid, cid, q.qn, q.desc, title, cover)
|
||||
}
|
||||
>
|
||||
<Ionicons name="refresh" size={14} color="#f44" />
|
||||
<Text style={styles.retryTxt}>重试</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View style={styles.footer} />
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
},
|
||||
sheet: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#fff",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
},
|
||||
headerTitle: { fontSize: 16, fontWeight: "700", color: "#212121" },
|
||||
closeBtn: { padding: 4 },
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: "#eee",
|
||||
marginBottom: 4,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 14,
|
||||
},
|
||||
qualityLabel: { fontSize: 15, color: "#212121" },
|
||||
right: { flexDirection: "row", alignItems: "center" },
|
||||
downloadBtn: {
|
||||
backgroundColor: "#00AEEC",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 14,
|
||||
},
|
||||
downloadBtnTxt: { color: "#fff", fontSize: 13, fontWeight: "600" },
|
||||
progressWrap: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
progressTrack: {
|
||||
width: 80,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: "#e0e0e0",
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressFill: { height: 4, backgroundColor: "#00AEEC", borderRadius: 2 },
|
||||
progressTxt: { fontSize: 12, color: "#666", minWidth: 32 },
|
||||
doneRow: { flexDirection: "row", alignItems: "center", gap: 4 },
|
||||
doneTxt: { fontSize: 13, color: "#00AEEC" },
|
||||
errorWrap: { alignItems: "flex-end", gap: 2 },
|
||||
errorMsg: { fontSize: 11, color: "#f44", maxWidth: 160, textAlign: "right" },
|
||||
retryBtn: { flexDirection: "row", alignItems: "center", gap: 4 },
|
||||
retryTxt: { fontSize: 13, color: "#f44" },
|
||||
footer: { height: 24 },
|
||||
});
|
||||
@@ -1,7 +1,15 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Modal, View, Text, StyleSheet, TouchableOpacity, Image, ActivityIndicator } from 'react-native';
|
||||
import { generateQRCode, pollQRCode, getUserInfo } from '../services/bilibili';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { generateQRCode, pollQRCode, getUserInfo } from "../services/bilibili";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -11,61 +19,91 @@ interface Props {
|
||||
export function LoginModal({ visible, onClose }: Props) {
|
||||
const [qrUrl, setQrUrl] = useState<string | null>(null);
|
||||
const [qrKey, setQrKey] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'waiting' | 'scanned' | 'done' | 'error'>('loading');
|
||||
const [status, setStatus] = useState<
|
||||
"loading" | "waiting" | "scanned" | "done" | "error"
|
||||
>("loading");
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const login = useAuthStore(s => s.login);
|
||||
const setProfile = useAuthStore(s => s.setProfile);
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const setProfile = useAuthStore((s) => s.setProfile);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setStatus('loading');
|
||||
setStatus("loading");
|
||||
setQrUrl(null);
|
||||
setQrKey(null);
|
||||
generateQRCode().then(data => {
|
||||
setQrUrl(`https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(data.url)}&size=200x200`);
|
||||
setQrKey(data.qrcode_key);
|
||||
setStatus('waiting');
|
||||
}).catch(() => setStatus('error'));
|
||||
generateQRCode()
|
||||
.then((data) => {
|
||||
setQrUrl(
|
||||
`https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(data.url)}&size=200x200`,
|
||||
);
|
||||
setQrKey(data.qrcode_key);
|
||||
setStatus("waiting");
|
||||
})
|
||||
.catch(() => setStatus("error"));
|
||||
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!qrKey || status !== 'waiting') return;
|
||||
if (!qrKey || status !== "waiting") return;
|
||||
pollRef.current = setInterval(async () => {
|
||||
const result = await pollQRCode(qrKey);
|
||||
if (result.code === 86038) { setStatus('error'); clearInterval(pollRef.current!); }
|
||||
if (result.code === 86090) setStatus('scanned');
|
||||
if (result.code === 86038) {
|
||||
setStatus("error");
|
||||
clearInterval(pollRef.current!);
|
||||
}
|
||||
if (result.code === 86090) setStatus("scanned");
|
||||
if (result.code === 0 && result.cookie) {
|
||||
clearInterval(pollRef.current!);
|
||||
try {
|
||||
await login(result.cookie, '', '');
|
||||
setStatus('done');
|
||||
await login(result.cookie, "", "");
|
||||
setStatus("done");
|
||||
// 登录后异步拉取用户头像和昵称
|
||||
const info = await getUserInfo();
|
||||
setProfile(info.face, info.uname, String(info.mid));
|
||||
} catch {
|
||||
setStatus('error');
|
||||
setStatus("error");
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
}, 2000);
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [qrKey, status]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.title}>扫码登录</Text>
|
||||
{status === 'loading' && <ActivityIndicator size="large" color="#00AEEC" style={styles.loader} />}
|
||||
{(status === 'waiting' || status === 'scanned') && qrUrl && (
|
||||
{status === "loading" && (
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color="#00AEEC"
|
||||
style={styles.loader}
|
||||
/>
|
||||
)}
|
||||
{(status === "waiting" || status === "scanned") && qrUrl && (
|
||||
<>
|
||||
<Image source={{ uri: qrUrl }} style={styles.qr} />
|
||||
<Text style={styles.hint}>{status === 'scanned' ? '扫描成功,请在手机确认' : '使用 B站 APP 扫一扫'}</Text>
|
||||
<Text style={styles.hint}>
|
||||
{status === "scanned"
|
||||
? "扫描成功,请在手机确认"
|
||||
: "使用 B站 APP 扫一扫"}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && <Text style={styles.hint}>二维码已过期,请关闭重试</Text>}
|
||||
{status === "error" && (
|
||||
<Text style={styles.hint}>二维码已过期,请关闭重试</Text>
|
||||
)}
|
||||
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
||||
<Text style={styles.closeTxt}>关闭</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -76,12 +114,22 @@ export function LoginModal({ visible, onClose }: Props) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
|
||||
sheet: { backgroundColor: '#fff', borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 24, alignItems: 'center' },
|
||||
title: { fontSize: 18, fontWeight: '600', marginBottom: 20 },
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: "#fff",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
padding: 24,
|
||||
alignItems: "center",
|
||||
},
|
||||
title: { fontSize: 18, fontWeight: "600", marginBottom: 20 },
|
||||
loader: { marginVertical: 40 },
|
||||
qr: { width: 200, height: 200, marginBottom: 12 },
|
||||
hint: { fontSize: 13, color: '#666', marginBottom: 20 },
|
||||
hint: { fontSize: 13, color: "#666", marginBottom: 20 },
|
||||
closeBtn: { padding: 12 },
|
||||
closeTxt: { fontSize: 14, color: '#00AEEC' },
|
||||
closeTxt: { fontSize: 14, color: "#00AEEC" },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { formatDuration } from "../utils/format";
|
||||
import {
|
||||
View,
|
||||
@@ -61,7 +68,6 @@ interface Props {
|
||||
currentQn: number;
|
||||
onQualityChange: (qn: number) => void;
|
||||
onFullscreen: () => void;
|
||||
onMiniPlayer?: () => void;
|
||||
style?: object;
|
||||
bvid?: string;
|
||||
cid?: number;
|
||||
@@ -72,499 +78,514 @@ interface Props {
|
||||
forcePaused?: boolean;
|
||||
}
|
||||
|
||||
export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(function NativeVideoPlayer({
|
||||
playData,
|
||||
qualities,
|
||||
currentQn,
|
||||
onQualityChange,
|
||||
onFullscreen,
|
||||
onMiniPlayer,
|
||||
style,
|
||||
bvid,
|
||||
cid,
|
||||
danmakus,
|
||||
isFullscreen,
|
||||
onTimeUpdate,
|
||||
initialTime,
|
||||
forcePaused,
|
||||
}: Props, ref) {
|
||||
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
|
||||
const VIDEO_H = SCREEN_W * 0.5625;
|
||||
export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
function NativeVideoPlayer(
|
||||
{
|
||||
playData,
|
||||
qualities,
|
||||
currentQn,
|
||||
onQualityChange,
|
||||
onFullscreen,
|
||||
style,
|
||||
bvid,
|
||||
cid,
|
||||
danmakus,
|
||||
isFullscreen,
|
||||
onTimeUpdate,
|
||||
initialTime,
|
||||
forcePaused,
|
||||
}: Props,
|
||||
ref,
|
||||
) {
|
||||
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
|
||||
const VIDEO_H = SCREEN_W * 0.5625;
|
||||
|
||||
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
|
||||
const isDash = !!playData?.dash;
|
||||
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
|
||||
const isDash = !!playData?.dash;
|
||||
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const durationRef = useRef(0);
|
||||
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 [showQuality, setShowQuality] = useState(false);
|
||||
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const isSeekingRef = useRef(false);
|
||||
const [touchX, setTouchX] = useState<number | null>(null);
|
||||
const touchXRef = useRef<number | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const barOffsetX = useRef(0);
|
||||
const barWidthRef = useRef(300);
|
||||
const trackRef = useRef<View>(null);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const isSeekingRef = useRef(false);
|
||||
const [touchX, setTouchX] = useState<number | null>(null);
|
||||
const touchXRef = useRef<number | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const barOffsetX = useRef(0);
|
||||
const barWidthRef = useRef(300);
|
||||
const trackRef = useRef<View>(null);
|
||||
|
||||
const [shots, setShots] = useState<VideoShotData | null>(null);
|
||||
const [showDanmaku, setShowDanmaku] = useState(true);
|
||||
const [shots, setShots] = useState<VideoShotData | null>(null);
|
||||
const [showDanmaku, setShowDanmaku] = useState(true);
|
||||
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek: (t: number) => { videoRef.current?.seek(t); },
|
||||
}));
|
||||
|
||||
const currentDesc =
|
||||
qualities.find((q) => q.qn === currentQn)?.desc ??
|
||||
String(currentQn || "HD");
|
||||
|
||||
// 解析播放链接,dash 需要构建 mpd uri,普通链接直接取第一个 durl。使用 useEffect 监听 playData 和 currentQn 变化,确保每次切换视频或清晰度时都能正确更新播放链接。错误处理逻辑保证即使 dash mpd 构建失败也能回退到普通链接,提升兼容性。
|
||||
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]);
|
||||
// 获取视频截图数据,供进度条预览使用。依赖 bvid 和 cid,确保在视频切换时重新获取截图。使用 cancelled 标志避免在组件卸载后更新状态,防止内存泄漏和潜在的错误。
|
||||
useEffect(() => {
|
||||
if (!bvid || !cid) return;
|
||||
let cancelled = false;
|
||||
getVideoShot(bvid, cid).then((shotData) => {
|
||||
if (cancelled) return;
|
||||
if (shotData?.image?.length) {
|
||||
setShots(shotData);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bvid, cid]);
|
||||
|
||||
useEffect(() => {
|
||||
durationRef.current = duration;
|
||||
}, [duration]);
|
||||
|
||||
// 控制栏自动隐藏逻辑:每次用户交互后重置计时器,3秒无交互则隐藏。使用 useRef 存储计时器 ID 和拖动状态,避免闭包问题导致的计时器失效或误触发。
|
||||
const resetHideTimer = useCallback(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (!isSeekingRef.current) {
|
||||
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
|
||||
}
|
||||
}, []);
|
||||
// 显示控制栏并重置隐藏计时器,确保用户每次交互后都有足够时间查看控制栏。依赖 resetHideTimer 保持稳定引用,避免不必要的重新渲染。
|
||||
const showAndReset = useCallback(() => {
|
||||
setShowControls(true);
|
||||
resetHideTimer();
|
||||
}, [resetHideTimer]);
|
||||
|
||||
// 点击视频区域切换控制栏显示状态,显示时重置隐藏计时器,隐藏时直接隐藏。使用 useCallback 优化性能,避免不必要的函数重新创建。
|
||||
const handleTap = useCallback(() => {
|
||||
setShowControls((prev) => {
|
||||
if (!prev) {
|
||||
resetHideTimer();
|
||||
return true;
|
||||
}
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
return false;
|
||||
});
|
||||
}, [resetHideTimer]);
|
||||
|
||||
// 组件卸载时清理隐藏计时器,避免内存泄漏和潜在的状态更新错误。依赖项为空数组确保只在挂载和卸载时执行一次。
|
||||
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;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
// 使用 PanResponder 实现进度条拖动,支持在拖动过程中显示预览图。通过 touchXRef 和 rafRef 优化拖动性能,避免频繁更新状态导致的卡顿。用户松开拖动时,根据最终位置计算对应的时间点并跳转,同时清理状态和隐藏预览图。
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: (_, gs) => {
|
||||
isSeekingRef.current = true;
|
||||
setIsSeeking(true);
|
||||
setShowControls(true);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
const x = clamp(gs.x0 - barOffsetX.current, 0, barWidthRef.current);
|
||||
touchXRef.current = x;
|
||||
setTouchX(x);
|
||||
},
|
||||
onPanResponderMove: (_, gs) => {
|
||||
touchXRef.current = clamp(
|
||||
gs.moveX - barOffsetX.current,
|
||||
0,
|
||||
barWidthRef.current,
|
||||
);
|
||||
if (!rafRef.current) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setTouchX(touchXRef.current);
|
||||
rafRef.current = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
// 用户松开拖动,或拖动被中断(如来电),都视为结束拖动,需要清理状态和隐藏预览
|
||||
onPanResponderRelease: (_, gs) => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
const ratio = clamp(
|
||||
(gs.moveX - barOffsetX.current) / barWidthRef.current,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const t = ratio * durationRef.current;
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek: (t: number) => {
|
||||
videoRef.current?.seek(t);
|
||||
setCurrentTime(t);
|
||||
touchXRef.current = null;
|
||||
setTouchX(null);
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
},
|
||||
}));
|
||||
|
||||
const currentDesc =
|
||||
qualities.find((q) => q.qn === currentQn)?.desc ??
|
||||
String(currentQn || "HD");
|
||||
|
||||
// 解析播放链接,dash 需要构建 mpd uri,普通链接直接取第一个 durl。使用 useEffect 监听 playData 和 currentQn 变化,确保每次切换视频或清晰度时都能正确更新播放链接。错误处理逻辑保证即使 dash mpd 构建失败也能回退到普通链接,提升兼容性。
|
||||
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]);
|
||||
// 获取视频截图数据,供进度条预览使用。依赖 bvid 和 cid,确保在视频切换时重新获取截图。使用 cancelled 标志避免在组件卸载后更新状态,防止内存泄漏和潜在的错误。
|
||||
useEffect(() => {
|
||||
if (!bvid || !cid) return;
|
||||
let cancelled = false;
|
||||
getVideoShot(bvid, cid).then((shotData) => {
|
||||
if (cancelled) return;
|
||||
if (shotData?.image?.length) {
|
||||
setShots(shotData);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bvid, cid]);
|
||||
|
||||
useEffect(() => {
|
||||
durationRef.current = duration;
|
||||
}, [duration]);
|
||||
|
||||
// 控制栏自动隐藏逻辑:每次用户交互后重置计时器,3秒无交互则隐藏。使用 useRef 存储计时器 ID 和拖动状态,避免闭包问题导致的计时器失效或误触发。
|
||||
const resetHideTimer = useCallback(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (!isSeekingRef.current) {
|
||||
hideTimer.current = setTimeout(
|
||||
() => setShowControls(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
// 显示控制栏并重置隐藏计时器,确保用户每次交互后都有足够时间查看控制栏。依赖 resetHideTimer 保持稳定引用,避免不必要的重新渲染。
|
||||
const showAndReset = useCallback(() => {
|
||||
setShowControls(true);
|
||||
resetHideTimer();
|
||||
}, [resetHideTimer]);
|
||||
|
||||
// 点击视频区域切换控制栏显示状态,显示时重置隐藏计时器,隐藏时直接隐藏。使用 useCallback 优化性能,避免不必要的函数重新创建。
|
||||
const handleTap = useCallback(() => {
|
||||
setShowControls((prev) => {
|
||||
if (!prev) {
|
||||
resetHideTimer();
|
||||
return true;
|
||||
}
|
||||
touchXRef.current = null;
|
||||
setTouchX(null);
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
},
|
||||
}),
|
||||
).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 bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
return false;
|
||||
});
|
||||
}, [resetHideTimer]);
|
||||
|
||||
const THUMB_DISPLAY_W = 120; // scaled display width
|
||||
// 组件卸载时清理隐藏计时器,避免内存泄漏和潜在的状态更新错误。依赖项为空数组确保只在挂载和卸载时执行一次。
|
||||
useEffect(() => {
|
||||
resetHideTimer();
|
||||
return () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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,
|
||||
index,
|
||||
} = shots;
|
||||
const framesPerSheet = img_x_len * img_y_len;
|
||||
const totalFrames = framesPerSheet * image.length;
|
||||
const seekTime = touchRatio * duration;
|
||||
// 通过时间戳索引找到最接近的帧,若无索引则均匀映射到总帧数上
|
||||
const frameIdx =
|
||||
index?.length && duration > 0
|
||||
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
|
||||
: clamp(Math.floor(touchRatio * (totalFrames - 1)), 0, totalFrames - 1);
|
||||
const measureTrack = useCallback(() => {
|
||||
trackRef.current?.measureInWindow((x, _y, w) => {
|
||||
if (w > 0) {
|
||||
barOffsetX.current = x;
|
||||
barWidthRef.current = w;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
// 使用 PanResponder 实现进度条拖动,支持在拖动过程中显示预览图。通过 touchXRef 和 rafRef 优化拖动性能,避免频繁更新状态导致的卡顿。用户松开拖动时,根据最终位置计算对应的时间点并跳转,同时清理状态和隐藏预览图。
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: (_, gs) => {
|
||||
isSeekingRef.current = true;
|
||||
setIsSeeking(true);
|
||||
setShowControls(true);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
const x = clamp(gs.x0 - barOffsetX.current, 0, barWidthRef.current);
|
||||
touchXRef.current = x;
|
||||
setTouchX(x);
|
||||
},
|
||||
onPanResponderMove: (_, gs) => {
|
||||
touchXRef.current = clamp(
|
||||
gs.moveX - barOffsetX.current,
|
||||
0,
|
||||
barWidthRef.current,
|
||||
);
|
||||
if (!rafRef.current) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setTouchX(touchXRef.current);
|
||||
rafRef.current = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
// 用户松开拖动,或拖动被中断(如来电),都视为结束拖动,需要清理状态和隐藏预览
|
||||
onPanResponderRelease: (_, gs) => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
const ratio = clamp(
|
||||
(gs.moveX - barOffsetX.current) / barWidthRef.current,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const t = ratio * durationRef.current;
|
||||
videoRef.current?.seek(t);
|
||||
setCurrentTime(t);
|
||||
touchXRef.current = null;
|
||||
setTouchX(null);
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setShowControls(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
touchXRef.current = null;
|
||||
setTouchX(null);
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
},
|
||||
}),
|
||||
).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 bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
|
||||
|
||||
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
|
||||
const local = frameIdx % framesPerSheet;
|
||||
const col = local % img_x_len;
|
||||
const row = Math.floor(local / img_x_len);
|
||||
console.log("[thumb]", {
|
||||
seekTime,
|
||||
duration,
|
||||
indexLen: index?.length,
|
||||
frameIdx,
|
||||
totalFrames,
|
||||
sheetIdx,
|
||||
col,
|
||||
row,
|
||||
});
|
||||
// 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比
|
||||
const scale = THUMB_DISPLAY_W / TW;
|
||||
const DW = THUMB_DISPLAY_W;
|
||||
const DH = Math.round(TH * scale);
|
||||
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,
|
||||
index,
|
||||
} = shots;
|
||||
const framesPerSheet = img_x_len * img_y_len;
|
||||
const totalFrames = framesPerSheet * image.length;
|
||||
const seekTime = touchRatio * duration;
|
||||
// 通过时间戳索引找到最接近的帧,若无索引则均匀映射到总帧数上
|
||||
const frameIdx =
|
||||
index?.length && duration > 0
|
||||
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
|
||||
: clamp(
|
||||
Math.floor(touchRatio * (totalFrames - 1)),
|
||||
0,
|
||||
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);
|
||||
console.log("[thumb]", {
|
||||
seekTime,
|
||||
duration,
|
||||
indexLen: index?.length,
|
||||
frameIdx,
|
||||
totalFrames,
|
||||
sheetIdx,
|
||||
col,
|
||||
row,
|
||||
});
|
||||
// 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比
|
||||
const scale = THUMB_DISPLAY_W / TW;
|
||||
const DW = THUMB_DISPLAY_W;
|
||||
const DH = Math.round(TH * scale);
|
||||
|
||||
const trackLeft = barOffsetX.current;
|
||||
const absLeft = clamp(
|
||||
trackLeft + (touchX ?? 0) - DW / 2,
|
||||
0,
|
||||
SCREEN_W - DW,
|
||||
);
|
||||
// 兼容处理图床地址,确保以 http(s) 协议开头
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: sheetUrl, headers: HEADERS }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: TW * img_x_len * scale,
|
||||
height: TH * img_y_len * scale,
|
||||
left: -col * DW,
|
||||
top: -row * DH,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.thumbTime}>
|
||||
{formatDuration(Math.floor(seekTime))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const trackLeft = barOffsetX.current;
|
||||
const absLeft = clamp(trackLeft + (touchX ?? 0) - DW / 2, 0, SCREEN_W - DW);
|
||||
// 兼容处理图床地址,确保以 http(s) 协议开头
|
||||
const sheetUrl = image[sheetIdx].startsWith("//")
|
||||
? `https:${image[sheetIdx]}`
|
||||
: image[sheetIdx];
|
||||
return (
|
||||
<View
|
||||
style={[styles.thumbPreview, { left: absLeft, width: DW }]}
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
isFullscreen
|
||||
? styles.fsContainer
|
||||
: [styles.container, { width: SCREEN_W, height: VIDEO_H }],
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{ width: DW, height: DH, overflow: "hidden", borderRadius: 4 }}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: sheetUrl, headers: HEADERS }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: TW * img_x_len * scale,
|
||||
height: TH * img_y_len * scale,
|
||||
left: -col * DW,
|
||||
top: -row * DH,
|
||||
{resolvedUrl ? (
|
||||
<Video
|
||||
key={resolvedUrl}
|
||||
ref={videoRef}
|
||||
source={
|
||||
isDash
|
||||
? { uri: resolvedUrl, type: "mpd", headers: HEADERS }
|
||||
: { uri: resolvedUrl, headers: HEADERS }
|
||||
}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="contain"
|
||||
controls={false}
|
||||
paused={!!(forcePaused || paused)}
|
||||
onProgress={({
|
||||
currentTime: ct,
|
||||
seekableDuration: dur,
|
||||
playableDuration: buf,
|
||||
}) => {
|
||||
setCurrentTime(ct);
|
||||
if (dur > 0) setDuration(dur);
|
||||
setBuffered(buf);
|
||||
onTimeUpdate?.(ct);
|
||||
}}
|
||||
onLoad={() => {
|
||||
if (initialTime && initialTime > 0) {
|
||||
videoRef.current?.seek(initialTime);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.thumbTime}>
|
||||
{formatDuration(Math.floor(seekTime))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
) : (
|
||||
<View style={styles.placeholder} />
|
||||
)}
|
||||
|
||||
return (
|
||||
<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 }
|
||||
}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="contain"
|
||||
controls={false}
|
||||
paused={!!(forcePaused || paused)}
|
||||
onProgress={({
|
||||
currentTime: ct,
|
||||
seekableDuration: dur,
|
||||
playableDuration: buf,
|
||||
}) => {
|
||||
setCurrentTime(ct);
|
||||
if (dur > 0) setDuration(dur);
|
||||
setBuffered(buf);
|
||||
onTimeUpdate?.(ct);
|
||||
}}
|
||||
onLoad={() => {
|
||||
if (initialTime && initialTime > 0) {
|
||||
videoRef.current?.seek(initialTime);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholder} />
|
||||
)}
|
||||
{isFullscreen && !!danmakus?.length && (
|
||||
<DanmakuOverlay
|
||||
danmakus={danmakus}
|
||||
currentTime={currentTime}
|
||||
screenWidth={SCREEN_W}
|
||||
screenHeight={SCREEN_H}
|
||||
visible={showDanmaku}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFullscreen && !!danmakus?.length && (
|
||||
<DanmakuOverlay
|
||||
danmakus={danmakus}
|
||||
currentTime={currentTime}
|
||||
screenWidth={SCREEN_W}
|
||||
screenHeight={SCREEN_H}
|
||||
visible={showDanmaku}
|
||||
/>
|
||||
)}
|
||||
<TouchableWithoutFeedback onPress={handleTap}>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
<TouchableWithoutFeedback onPress={handleTap}>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
{showControls && (
|
||||
<>
|
||||
<LinearGradient
|
||||
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"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</LinearGradient>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.centerBtn}
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<View style={styles.centerBtnBg}>
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={28}
|
||||
color="#fff"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.7)"]}
|
||||
style={styles.bottomBar}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View
|
||||
ref={trackRef}
|
||||
style={styles.trackWrapper}
|
||||
onLayout={measureTrack}
|
||||
{...panResponder.panHandlers}
|
||||
{showControls && (
|
||||
<>
|
||||
{/* 小窗口 */}
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.55)", "transparent"]}
|
||||
style={styles.topBar}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View style={styles.track}>
|
||||
<View
|
||||
style={[
|
||||
styles.trackLayer,
|
||||
{
|
||||
width: `${bufferedRatio * 100}%` as any,
|
||||
backgroundColor: "rgba(255,255,255,0.35)",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.trackLayer,
|
||||
{
|
||||
width: `${progressRatio * 100}%` as any,
|
||||
backgroundColor: "#00AEEC",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{isSeeking && touchX !== null ? (
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
styles.ballActive,
|
||||
{ left: touchX - BALL_ACTIVE / 2 },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
{ left: progressRatio * barWidthRef.current - BALL / 2 },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{/* Controls */}
|
||||
</LinearGradient>
|
||||
|
||||
<View style={styles.ctrlRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
showAndReset();
|
||||
}}
|
||||
style={styles.ctrlBtn}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.centerBtn}
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<View style={styles.centerBtnBg}>
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={16}
|
||||
size={28}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.timeText}>
|
||||
{formatDuration(Math.floor(currentTime))}
|
||||
</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text style={styles.timeText}>{formatDuration(duration)}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.ctrlBtn}
|
||||
onPress={() => setShowQuality(true)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.7)"]}
|
||||
style={styles.bottomBar}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View
|
||||
ref={trackRef}
|
||||
style={styles.trackWrapper}
|
||||
onLayout={measureTrack}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<Text style={styles.qualityText}>{currentDesc}</Text>
|
||||
</TouchableOpacity>
|
||||
{isFullscreen && (
|
||||
<View style={styles.track}>
|
||||
<View
|
||||
style={[
|
||||
styles.trackLayer,
|
||||
{
|
||||
width: `${bufferedRatio * 100}%` as any,
|
||||
backgroundColor: "rgba(255,255,255,0.35)",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.trackLayer,
|
||||
{
|
||||
width: `${progressRatio * 100}%` as any,
|
||||
backgroundColor: "#00AEEC",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{isSeeking && touchX !== null ? (
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
styles.ballActive,
|
||||
{ left: touchX - BALL_ACTIVE / 2 },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
{ left: progressRatio * barWidthRef.current - BALL / 2 },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{/* Controls */}
|
||||
|
||||
<View style={styles.ctrlRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
showAndReset();
|
||||
}}
|
||||
style={styles.ctrlBtn}
|
||||
onPress={() => setShowDanmaku((v) => !v)}
|
||||
>
|
||||
<Ionicons
|
||||
name={showDanmaku ? "chatbubbles" : "chatbubbles-outline"}
|
||||
name={paused ? "play" : "pause"}
|
||||
size={16}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
|
||||
<Ionicons name="expand" size={16} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderThumbnail()}
|
||||
{/* 选清晰度 */}
|
||||
<Modal visible={showQuality} transparent animationType="fade">
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
onPress={() => setShowQuality(false)}
|
||||
>
|
||||
<View style={styles.qualityList}>
|
||||
<Text style={styles.qualityTitle}>选择清晰度</Text>
|
||||
{qualities.map((q) => (
|
||||
<TouchableOpacity
|
||||
key={q.qn}
|
||||
style={styles.qualityItem}
|
||||
onPress={() => {
|
||||
setShowQuality(false);
|
||||
onQualityChange(q.qn);
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.qualityItemText,
|
||||
q.qn === currentQn && styles.qualityItemActive,
|
||||
]}
|
||||
>
|
||||
{q.desc}
|
||||
<Text style={styles.timeText}>
|
||||
{formatDuration(Math.floor(currentTime))}
|
||||
</Text>
|
||||
{q.qn === currentQn && (
|
||||
<Ionicons name="checkmark" size={16} color="#00AEEC" />
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text style={styles.timeText}>{formatDuration(duration)}</Text>
|
||||
<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>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
|
||||
<Ionicons name="expand" size={16} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderThumbnail()}
|
||||
{/* 选清晰度 */}
|
||||
<Modal visible={showQuality} transparent animationType="fade">
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
onPress={() => setShowQuality(false)}
|
||||
>
|
||||
<View style={styles.qualityList}>
|
||||
<Text style={styles.qualityTitle}>选择清晰度</Text>
|
||||
{qualities.map((q) => (
|
||||
<TouchableOpacity
|
||||
key={q.qn}
|
||||
style={styles.qualityItem}
|
||||
onPress={() => {
|
||||
setShowQuality(false);
|
||||
onQualityChange(q.qn);
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.qualityItemText,
|
||||
q.qn === currentQn && styles.qualityItemActive,
|
||||
]}
|
||||
>
|
||||
{q.desc}
|
||||
</Text>
|
||||
{q.qn === currentQn && (
|
||||
<Ionicons name="checkmark" size={16} color="#00AEEC" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { backgroundColor: "#000" },
|
||||
|
||||
@@ -11,14 +11,13 @@ interface Props {
|
||||
qualities: { qn: number; desc: string }[];
|
||||
currentQn: number;
|
||||
onQualityChange: (qn: number) => void;
|
||||
onMiniPlayer?: () => void;
|
||||
bvid?: string;
|
||||
cid?: number;
|
||||
danmakus?: DanmakuItem[];
|
||||
onTimeUpdate?: (t: number) => void;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, onMiniPlayer, bvid, cid, danmakus, onTimeUpdate }: Props) {
|
||||
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, bvid, cid, danmakus, onTimeUpdate }: Props) {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { width, height } = useWindowDimensions();
|
||||
const VIDEO_HEIGHT = width * 0.5625;
|
||||
@@ -80,7 +79,6 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={handleEnterFullscreen}
|
||||
onMiniPlayer={onMiniPlayer}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
isFullscreen={false}
|
||||
|
||||
Reference in New Issue
Block a user