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 */}
+
+
+
+ {playingUri && (
+
+ )}
+ {!isLandscape && (
+
+
+
+
+ {playingTitle}
+
+ )}
+
+
+
+ );
+}
+
+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 ? (
-
- );
-});
+
+
+
+
+
+ >
+ )}
+
+ {renderThumbnail()}
+ {/* 选清晰度 */}
+
+ setShowQuality(false)}
+ >
+
+ 选择清晰度
+ {qualities.map((q) => (
+ {
+ setShowQuality(false);
+ onQualityChange(q.qn);
+ showAndReset();
+ }}
+ >
+
+ {q.desc}
+
+ {q.qn === currentQn && (
+
+ )}
+
+ ))}
+
+
+
+
+ );
+ },
+);
const styles = StyleSheet.create({
container: { backgroundColor: "#000" },
diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx
index 19b8e29..49832f2 100644
--- a/components/VideoPlayer.tsx
+++ b/components/VideoPlayer.tsx
@@ -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}
diff --git a/hooks/useDownload.ts b/hooks/useDownload.ts
new file mode 100644
index 0000000..0d7c762
--- /dev/null
+++ b/hooks/useDownload.ts
@@ -0,0 +1,165 @@
+import * as FileSystem from 'expo-file-system/legacy';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { AppState } from 'react-native';
+import { useDownloadStore } from '../store/downloadStore';
+import { getPlayUrlForDownload } from '../services/bilibili';
+
+// 模块级进度节流
+const lastReportedProgress: Record = {};
+
+const QUALITY_LABELS: Record = {
+ 16: '360P',
+ 32: '480P',
+ 64: '720P',
+ 80: '1080P',
+ 112: '1080P+',
+ 116: '1080P60',
+};
+
+/** 等待 App 回到前台 */
+function waitForActive(): Promise {
+ return new Promise((resolve) => {
+ if (AppState.currentState === 'active') {
+ resolve();
+ return;
+ }
+ const sub = AppState.addEventListener('change', (nextState) => {
+ if (nextState === 'active') {
+ sub.remove();
+ resolve();
+ }
+ });
+ });
+}
+
+/** 读取本地文件实际大小 */
+async function readFileSize(uri: string): Promise {
+ try {
+ const info = await FileSystem.getInfoAsync(uri, { size: true });
+ if (info.exists) return (info as any).size as number;
+ } catch {}
+ return undefined;
+}
+
+export function useDownload() {
+ const { tasks, addTask, updateTask, removeTask } = useDownloadStore();
+
+ function taskKey(bvid: string, qn: number) {
+ return `${bvid}_${qn}`;
+ }
+
+ function localPath(bvid: string, qn: number) {
+ return `${FileSystem.documentDirectory}${bvid}_${qn}.mp4`;
+ }
+
+ async function startDownload(
+ bvid: string,
+ cid: number,
+ qn: number,
+ qdesc: string,
+ title: string,
+ cover: string,
+ ) {
+ const key = taskKey(bvid, qn);
+ if (tasks[key]?.status === 'downloading') return;
+
+ addTask(key, {
+ bvid, title, cover, qn,
+ qdesc: qdesc || QUALITY_LABELS[qn] || String(qn),
+ status: 'downloading',
+ progress: 0,
+ createdAt: Date.now(),
+ });
+
+ try {
+ const [url, buvid3, sessdata] = await Promise.all([
+ getPlayUrlForDownload(bvid, cid, qn),
+ AsyncStorage.getItem('buvid3'),
+ AsyncStorage.getItem('SESSDATA'),
+ ]);
+ const dest = localPath(bvid, qn);
+
+ const cookies: string[] = [];
+ if (buvid3) cookies.push(`buvid3=${buvid3}`);
+ if (sessdata) cookies.push(`SESSDATA=${sessdata}`);
+
+ const headers = {
+ Referer: 'https://www.bilibili.com',
+ Origin: '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',
+ ...(cookies.length > 0 && { Cookie: cookies.join('; ') }),
+ };
+
+ const progressCallback = (p: FileSystem.DownloadProgressData) => {
+ const { totalBytesWritten, totalBytesExpectedToWrite } = p;
+ const progress = totalBytesExpectedToWrite > 0
+ ? totalBytesWritten / totalBytesExpectedToWrite : 0;
+ const last = lastReportedProgress[key] ?? -1;
+ if (progress - last >= 0.01) {
+ lastReportedProgress[key] = progress;
+ updateTask(key, { progress });
+ }
+ };
+
+ const resumable = FileSystem.createDownloadResumable(url, dest, { headers }, progressCallback);
+
+ // ── 后台暂停 / 前台续传 ──
+ let pausedByBackground = false;
+ const appStateSub = AppState.addEventListener('change', async (nextState) => {
+ if ((nextState === 'background' || nextState === 'inactive') && !pausedByBackground) {
+ pausedByBackground = true;
+ try { await resumable.pauseAsync(); } catch {}
+ }
+ });
+
+ let result = await resumable.downloadAsync();
+
+ // 如果是因为进入后台被暂停(result 为 null 或抛出连接中断),等回前台续传
+ if (!result?.uri && pausedByBackground) {
+ await waitForActive();
+ pausedByBackground = false;
+ try {
+ result = await resumable.resumeAsync();
+ } catch {
+ result = null;
+ }
+ }
+
+ appStateSub.remove();
+ delete lastReportedProgress[key];
+
+ if (result?.uri) {
+ const fileSize = await readFileSize(result.uri);
+ updateTask(key, {
+ status: 'done', progress: 1, localUri: result.uri,
+ ...(fileSize ? { fileSize } : {}),
+ });
+ } else {
+ updateTask(key, { status: 'error', error: '下载失败' });
+ }
+
+ } catch (e: any) {
+ delete lastReportedProgress[key];
+ console.error('[Download] failed:', e);
+
+ // 连接中断 + 已被后台暂停 → 不报错,等回前台后让用户手动重试即可
+ const isConnectionAbort = (e?.message ?? '').includes('connection a');
+ const msg = isConnectionAbort ? '已暂停,返回应用后可重试' : (e?.message ?? '下载失败');
+ updateTask(key, {
+ status: 'error',
+ error: msg.length > 40 ? msg.slice(0, 40) + '...' : msg,
+ });
+ }
+ }
+
+ function getLocalUri(bvid: string, qn: number): string | undefined {
+ return tasks[taskKey(bvid, qn)]?.localUri;
+ }
+
+ function cancelDownload(bvid: string, qn: number) {
+ removeTask(taskKey(bvid, qn));
+ }
+
+ return { tasks, startDownload, getLocalUri, cancelDownload, taskKey };
+}
diff --git a/services/bilibili.ts b/services/bilibili.ts
index 3716633..489031b 100644
--- a/services/bilibili.ts
+++ b/services/bilibili.ts
@@ -123,6 +123,24 @@ export async function getPlayUrl(bvid: string, cid: number, qn = 64): Promise {
+ const res = await api.get('/x/player/playurl', {
+ params: { bvid, cid, qn, fnval: 0, platform: 'html5' },
+ });
+ if (res.data?.code !== 0) {
+ throw new Error(`API ${res.data?.code}: ${res.data?.message ?? '请求失败'}`);
+ }
+ const durlItem = res.data?.data?.durl?.[0];
+ // 优先用主 URL,主 URL 失效时退到 backup_url
+ const url: string | undefined = durlItem?.url || (durlItem?.backup_url as string[] | undefined)?.[0];
+ if (!url) throw new Error('无法获取下载地址(durl 为空)');
+ return url;
+}
+
export async function getUserInfo(): Promise<{ face: string; uname: string; mid: number }> {
const res = await api.get('/x/web-interface/nav');
const { face, uname, mid } = res.data.data;
diff --git a/store/downloadStore.ts b/store/downloadStore.ts
new file mode 100644
index 0000000..56ab956
--- /dev/null
+++ b/store/downloadStore.ts
@@ -0,0 +1,96 @@
+import { create } from 'zustand';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+export interface DownloadTask {
+ bvid: string;
+ title: string;
+ cover: string;
+ qn: number;
+ qdesc: string;
+ status: 'downloading' | 'done' | 'error';
+ progress: number; // 0-1
+ fileSize?: number; // bytes
+ localUri?: string;
+ error?: string;
+ createdAt: number;
+}
+
+interface DownloadStore {
+ tasks: Record; // key: `${bvid}_${qn}`
+ hasLoaded: boolean;
+ addTask: (key: string, task: DownloadTask) => void;
+ updateTask: (key: string, patch: Partial) => void;
+ removeTask: (key: string) => void;
+ loadFromStorage: () => Promise;
+}
+
+const STORAGE_KEY = 'download_tasks';
+
+export const useDownloadStore = create((set, get) => ({
+ tasks: {},
+ hasLoaded: false,
+
+ addTask: (key, task) => {
+ const next = { ...get().tasks, [key]: task };
+ set({ tasks: next });
+ persistTasks(next);
+ },
+
+ updateTask: (key, patch) => {
+ set((s) => {
+ const prev = s.tasks[key];
+ if (!prev) return s;
+ const updated = { ...prev, ...patch };
+ const next = { ...s.tasks, [key]: updated };
+ // 只在状态/localUri变化时持久化,progress 不持久化(避免高频写 AsyncStorage)
+ if (patch.status !== undefined || patch.localUri !== undefined || patch.error !== undefined || patch.fileSize !== undefined) {
+ persistTasks(next);
+ }
+ return { tasks: next };
+ });
+ },
+
+ removeTask: (key) => {
+ set((s) => {
+ const next = { ...s.tasks };
+ delete next[key];
+ persistTasks(next);
+ return { tasks: next };
+ });
+ },
+
+ loadFromStorage: async () => {
+ // 已加载过则跳过,防止重复调用覆盖正在进行的下载状态
+ if (get().hasLoaded) return;
+ try {
+ const raw = await AsyncStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const saved: Record = JSON.parse(raw);
+ // 重启后 downloading 任务无法续传,标记为 error
+ Object.keys(saved).forEach((k) => {
+ if (saved[k].status === 'downloading') {
+ saved[k].status = 'error';
+ saved[k].error = '下载被中断,请重试';
+ saved[k].progress = 0;
+ }
+ });
+ set({ tasks: saved, hasLoaded: true });
+ } else {
+ set({ hasLoaded: true });
+ }
+ } catch {
+ set({ hasLoaded: true });
+ }
+ },
+}));
+
+function persistTasks(tasks: Record) {
+ // 只持久化 done/error,downloading 重启后无法续传无需保存
+ const toSave: Record = {};
+ Object.keys(tasks).forEach((k) => {
+ if (tasks[k].status !== 'downloading') {
+ toSave[k] = tasks[k];
+ }
+ });
+ AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)).catch(() => {});
+}