diff --git a/app/_layout.tsx b/app/_layout.tsx index 6a1b379..f7468ea 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,13 +4,16 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import { View } from 'react-native'; import { useEffect } from 'react'; import { useAuthStore } from '../store/authStore'; +import { useDownloadStore } from '../store/downloadStore'; import { MiniPlayer } from '../components/MiniPlayer'; export default function RootLayout() { const restore = useAuthStore(s => s.restore); + const loadDownloads = useDownloadStore(s => s.loadFromStorage); useEffect(() => { restore(); + loadDownloads(); }, []); return ( @@ -42,6 +45,14 @@ export default function RootLayout() { gestureEnabled: true, }} /> + diff --git a/app/downloads.tsx b/app/downloads.tsx new file mode 100644 index 0000000..9ae4471 --- /dev/null +++ b/app/downloads.tsx @@ -0,0 +1,248 @@ +import React, { useEffect, useState } from 'react'; +import { + View, + Text, + SectionList, + StyleSheet, + TouchableOpacity, + Image, + ActivityIndicator, + Modal, + StatusBar, + useWindowDimensions, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import Video from 'react-native-video'; +let ScreenOrientation: typeof import('expo-screen-orientation') | null = null; +try { ScreenOrientation = require('expo-screen-orientation'); } catch {} +import { useDownloadStore, DownloadTask } from '../store/downloadStore'; + +function formatFileSize(bytes?: number): string { + if (!bytes || bytes <= 0) return ''; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} +import { proxyImageUrl } from '../utils/imageUrl'; + +export default function DownloadsScreen() { + const router = useRouter(); + const { tasks, loadFromStorage, removeTask } = useDownloadStore(); + const [playingUri, setPlayingUri] = useState(null); + const [playingTitle, setPlayingTitle] = useState(''); + const { width, height } = useWindowDimensions(); + const isLandscape = width > height; + + async function openPlayer(uri: string, title: string) { + setPlayingTitle(title); + setPlayingUri(uri); + await ScreenOrientation?.unlockAsync(); + } + + async function closePlayer() { + setPlayingUri(null); + await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); + } + + useEffect(() => { + loadFromStorage(); + }, []); + + const all = Object.entries(tasks).map(([key, task]) => ({ key, ...task })); + const downloading = all.filter((t) => t.status === 'downloading' || t.status === 'error'); + const done = all.filter((t) => t.status === 'done'); + + const sections = []; + if (downloading.length > 0) sections.push({ title: '下载中', data: downloading }); + if (done.length > 0) sections.push({ title: '已下载', data: done }); + + return ( + + + router.back()} style={styles.backBtn}> + + + 我的下载 + + + + {sections.length === 0 ? ( + + + 暂无下载记录 + + ) : ( + item.key} + renderSectionHeader={({ section }) => ( + + {section.title} + + )} + renderItem={({ item }) => ( + { + if (item.localUri) openPlayer(item.localUri, item.title); + }} + onDelete={() => removeTask(item.key)} + /> + )} + ItemSeparatorComponent={() => } + contentContainerStyle={{ paddingBottom: 32 }} + /> + )} + + {/* Local file player modal */} + + + + ); +} + +function DownloadRow({ + task, + onPlay, + onDelete, +}: { + task: DownloadTask & { key: string }; + onPlay: () => void; + onDelete: () => void; +}) { + return ( + + + + {task.title} + + {task.qdesc}{task.fileSize ? ` · ${formatFileSize(task.fileSize)}` : ''} + + {task.status === 'downloading' && ( + + + + + + {Math.round(task.progress * 100)}% + + )} + {task.status === 'error' && ( + {task.error ?? '下载失败'} + )} + + + {task.status === 'done' && ( + + + 播放 + + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: '#fff' }, + topBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#eee', + }, + backBtn: { padding: 4 }, + topTitle: { + flex: 1, + fontSize: 16, + fontWeight: '700', + color: '#212121', + marginLeft: 4, + }, + empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }, + emptyTxt: { fontSize: 14, color: '#bbb' }, + sectionHeader: { + backgroundColor: '#f4f4f4', + paddingHorizontal: 16, + paddingVertical: 8, + }, + sectionTitle: { fontSize: 13, fontWeight: '600', color: '#555' }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#fff', + gap: 12, + }, + cover: { width: 80, height: 54, borderRadius: 6, backgroundColor: '#eee' }, + info: { flex: 1 }, + title: { fontSize: 13, color: '#212121', lineHeight: 18, marginBottom: 4 }, + qdesc: { fontSize: 12, color: '#999', marginBottom: 4 }, + progressWrap: { flexDirection: 'row', alignItems: 'center', marginTop: 2 }, + progressTrack: { + flex: 1, + height: 3, + borderRadius: 2, + backgroundColor: '#e0e0e0', + overflow: 'hidden', + }, + progressFill: { height: 3, backgroundColor: '#00AEEC', borderRadius: 2 }, + progressTxt: { fontSize: 11, color: '#999', marginLeft: 4 }, + errorTxt: { fontSize: 12, color: '#f44', marginTop: 2 }, + actions: { alignItems: 'center', gap: 8 }, + playBtn: { flexDirection: 'row', alignItems: 'center', gap: 3 }, + playTxt: { fontSize: 13, color: '#00AEEC' }, + deleteBtn: { padding: 4 }, + separator: { height: StyleSheet.hairlineWidth, backgroundColor: '#f0f0f0', marginLeft: 108 }, + // player modal + playerBg: { flex: 1, backgroundColor: '#000', justifyContent: 'center' }, + playerBar: { + position: 'absolute', + top: 44, + left: 0, + right: 0, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + }, + closeBtn: { padding: 6 }, + playerTitle: { flex: 1, color: '#fff', fontSize: 14, fontWeight: '600', marginLeft: 4 }, +}); diff --git a/app/index.tsx b/app/index.tsx index 53cdff6..dcfa51c 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -28,6 +28,7 @@ import { Ionicons } from "@expo/vector-icons"; import { VideoCard } from "../components/VideoCard"; import { LiveCard } from "../components/LiveCard"; import { LoginModal } from "../components/LoginModal"; +import { DownloadProgressBtn } from "../components/DownloadProgressBtn"; import { useVideoList } from "../hooks/useVideoList"; import { useLiveList } from "../hooks/useLiveList"; import { useAuthStore } from "../store/authStore"; @@ -431,9 +432,7 @@ export default function HomeScreen() { 搜索视频、UP主... - - - + router.push('/downloads' as any)} /> @@ -536,11 +535,11 @@ const styles = StyleSheet.create({ }, tabText: { fontSize: 15, - fontWeight: "500", + fontWeight: "450", color: "#999", }, tabTextActive: { - fontWeight: "700", + fontWeight: "500", color: "#00AEEC", }, tabUnderline: { diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index 8aee03a..4145d6a 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -19,9 +19,9 @@ import { DanmakuItem } from "../../services/types"; import DanmakuList from "../../components/DanmakuList"; import { useVideoDetail } from "../../hooks/useVideoDetail"; import { useComments } from "../../hooks/useComments"; -import { useVideoStore } from "../../store/videoStore"; import { formatCount } from "../../utils/format"; import { proxyImageUrl } from "../../utils/imageUrl"; +import { DownloadSheet } from "../../components/DownloadSheet"; type Tab = "intro" | "comments" | "danmaku"; @@ -46,11 +46,7 @@ export default function VideoDetailScreen() { const [tab, setTab] = useState("intro"); const [danmakus, setDanmakus] = useState([]); const [currentTime, setCurrentTime] = useState(0); - const { setVideo, clearVideo } = useVideoStore(); - - useEffect(() => { - clearVideo(); - }, [bvid]); + const [showDownload, setShowDownload] = useState(false); useEffect(() => { if (video?.aid) loadComments(); @@ -61,13 +57,6 @@ export default function VideoDetailScreen() { getDanmaku(video.cid).then(setDanmakus); }, [video?.cid]); - function handleMiniPlayer() { - if (video) { - setVideo(bvid as string, video.title, video.pic); - router.back(); - } - } - return ( {/* TopBar */} @@ -78,8 +67,11 @@ export default function VideoDetailScreen() { {video?.title ?? "视频详情"} - - + setShowDownload(true)} + > + @@ -89,12 +81,20 @@ export default function VideoDetailScreen() { qualities={qualities} currentQn={currentQn} onQualityChange={changeQuality} - onMiniPlayer={handleMiniPlayer} bvid={bvid as string} cid={video?.cid} danmakus={danmakus} onTimeUpdate={setCurrentTime} /> + setShowDownload(false)} + bvid={bvid as string} + cid={video?.cid ?? 0} + title={video?.title ?? ""} + cover={video?.pic ?? ""} + qualities={qualities} + /> {/* TabBar — sits directly below player, always visible once video loads */} {video && ( diff --git a/components/DanmakuList.tsx b/components/DanmakuList.tsx index dd97a20..a6dbd88 100644 --- a/components/DanmakuList.tsx +++ b/components/DanmakuList.tsx @@ -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 = { 1: { text: "总督", color: "#E13979" }, @@ -337,34 +322,6 @@ export default function DanmakuList({ )} - {/* 常见礼物栏 — 仅直播模式显示 */} - {hideHeader && visible && ( - - - {COMMON_GIFTS.map((g) => { - const count = giftCounts?.[g.name] ?? 0; - return ( - - - {count > 0 && ( - - +{count} - - )} - {g.icon} - - {g.name} - {g.price} - - ); - })} - - - )} ); } diff --git a/components/DownloadProgressBtn.tsx b/components/DownloadProgressBtn.tsx new file mode 100644 index 0000000..28ba5dd --- /dev/null +++ b/components/DownloadProgressBtn.tsx @@ -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 ( + + {/* 进度环,绝对定位居中 */} + {/* {hasDownloading && ( + + + + )} */} + + + ); +} + +/** + * 双半圆裁剪法绘制圆弧进度 + * + * 两个「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 ( + + {/* 灰色背景环 */} + + + {/* ── 右裁剪:左边缘 = 圆心,只露右半 ── */} + + {/* left: -SIZE/2 → D 形中心落在容器左边缘 = 外圆圆心 */} + + + + {/* ── 左裁剪:右边缘 = 圆心,只露左半 ── */} + + {/* left: 0 → D 形中心在容器右边缘 = 外圆圆心 */} + + + + {/* 白色内圆,把实心扇区变成环形 */} + + + ); +} + +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', + }, +}); diff --git a/components/DownloadSheet.tsx b/components/DownloadSheet.tsx new file mode 100644 index 0000000..f52a796 --- /dev/null +++ b/components/DownloadSheet.tsx @@ -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 ( + + + + + 下载视频 + + + + + + {qualities.map((q) => { + const key = taskKey(bvid, q.qn); + const task = tasks[key]; + return ( + + {q.desc} + + {!task && ( + + startDownload(bvid, cid, q.qn, q.desc, title, cover) + } + > + 下载 + + )} + {task?.status === "downloading" && ( + + + + + + {Math.round(task.progress * 100)}% + + + )} + {task?.status === "done" && ( + + + 已下载 + + )} + {task?.status === "error" && ( + + {!!task.error && ( + + {task.error} + + )} + + startDownload(bvid, cid, q.qn, q.desc, title, cover) + } + > + + 重试 + + + )} + + + ); + })} + + + + ); +} + +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 }, +}); diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index 4a9ea5d..c1e7e9b 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -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(null); const [qrKey, setQrKey] = useState(null); - const [status, setStatus] = useState<'loading' | 'waiting' | 'scanned' | 'done' | 'error'>('loading'); + const [status, setStatus] = useState< + "loading" | "waiting" | "scanned" | "done" | "error" + >("loading"); const pollRef = useRef | 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 ( - + 扫码登录 - {status === 'loading' && } - {(status === 'waiting' || status === 'scanned') && qrUrl && ( + {status === "loading" && ( + + )} + {(status === "waiting" || status === "scanned") && qrUrl && ( <> - {status === 'scanned' ? '扫描成功,请在手机确认' : '使用 B站 APP 扫一扫'} + + {status === "scanned" + ? "扫描成功,请在手机确认" + : "使用 B站 APP 扫一扫"} + )} - {status === 'error' && 二维码已过期,请关闭重试} + {status === "error" && ( + 二维码已过期,请关闭重试 + )} 关闭 @@ -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" }, }); diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index 637aace..7841c2a 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -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(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( + 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(); - const isDash = !!playData?.dash; + const [resolvedUrl, setResolvedUrl] = useState(); + const isDash = !!playData?.dash; - const [showControls, setShowControls] = useState(true); - const hideTimer = useRef | null>(null); + const [showControls, setShowControls] = useState(true); + const hideTimer = useRef | null>(null); - const [paused, setPaused] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const durationRef = useRef(0); + const [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(null); - const touchXRef = useRef(null); - const rafRef = useRef(null); - const barOffsetX = useRef(0); - const barWidthRef = useRef(300); - const trackRef = useRef(null); + const [buffered, setBuffered] = useState(0); + const [isSeeking, setIsSeeking] = useState(false); + const isSeekingRef = useRef(false); + const [touchX, setTouchX] = useState(null); + const touchXRef = useRef(null); + const rafRef = useRef(null); + const barOffsetX = useRef(0); + const barWidthRef = useRef(300); + const trackRef = useRef(null); - const [shots, setShots] = useState(null); - const [showDanmaku, setShowDanmaku] = useState(true); + const [shots, setShots] = useState(null); + const [showDanmaku, setShowDanmaku] = useState(true); - const videoRef = useRef(null); + const videoRef = useRef(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 ( + + + + + + {formatDuration(Math.floor(seekTime))} + + + ); + }; - 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 ( - - { + setCurrentTime(ct); + if (dur > 0) setDuration(dur); + setBuffered(buf); + onTimeUpdate?.(ct); + }} + onLoad={() => { + if (initialTime && initialTime > 0) { + videoRef.current?.seek(initialTime); + } }} /> - - - {formatDuration(Math.floor(seekTime))} - - - ); - }; + ) : ( + + )} - return ( - - {resolvedUrl ? ( -