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:
@@ -4,13 +4,16 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|||||||
import { View } from 'react-native';
|
import { View } from 'react-native';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useDownloadStore } from '../store/downloadStore';
|
||||||
import { MiniPlayer } from '../components/MiniPlayer';
|
import { MiniPlayer } from '../components/MiniPlayer';
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const restore = useAuthStore(s => s.restore);
|
const restore = useAuthStore(s => s.restore);
|
||||||
|
const loadDownloads = useDownloadStore(s => s.loadFromStorage);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restore();
|
restore();
|
||||||
|
loadDownloads();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,6 +45,14 @@ export default function RootLayout() {
|
|||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="downloads"
|
||||||
|
options={{
|
||||||
|
animation: "slide_from_right",
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: "horizontal",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<MiniPlayer />
|
<MiniPlayer />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
248
app/downloads.tsx
Normal file
248
app/downloads.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<SafeAreaView style={styles.safe}>
|
||||||
|
<View style={styles.topBar}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="#212121" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.topTitle}>我的下载</Text>
|
||||||
|
<View style={{ width: 32 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{sections.length === 0 ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Ionicons name="cloud-download-outline" size={56} color="#ccc" />
|
||||||
|
<Text style={styles.emptyTxt}>暂无下载记录</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<SectionList
|
||||||
|
sections={sections}
|
||||||
|
keyExtractor={(item) => item.key}
|
||||||
|
renderSectionHeader={({ section }) => (
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<DownloadRow
|
||||||
|
task={item}
|
||||||
|
onPlay={() => {
|
||||||
|
if (item.localUri) openPlayer(item.localUri, item.title);
|
||||||
|
}}
|
||||||
|
onDelete={() => removeTask(item.key)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||||
|
contentContainerStyle={{ paddingBottom: 32 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Local file player modal */}
|
||||||
|
<Modal
|
||||||
|
visible={!!playingUri}
|
||||||
|
animationType="fade"
|
||||||
|
statusBarTranslucent
|
||||||
|
onRequestClose={closePlayer}
|
||||||
|
>
|
||||||
|
<StatusBar hidden />
|
||||||
|
<View style={styles.playerBg}>
|
||||||
|
{playingUri && (
|
||||||
|
<Video
|
||||||
|
source={{ uri: playingUri }}
|
||||||
|
style={isLandscape
|
||||||
|
? { width, height }
|
||||||
|
: { width, height: width * 0.5625 }}
|
||||||
|
resizeMode="contain"
|
||||||
|
controls
|
||||||
|
paused={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isLandscape && (
|
||||||
|
<View style={styles.playerBar}>
|
||||||
|
<TouchableOpacity onPress={closePlayer} style={styles.closeBtn}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.playerTitle} numberOfLines={1}>{playingTitle}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadRow({
|
||||||
|
task,
|
||||||
|
onPlay,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
task: DownloadTask & { key: string };
|
||||||
|
onPlay: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: proxyImageUrl(task.cover) }}
|
||||||
|
style={styles.cover}
|
||||||
|
/>
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={styles.title} numberOfLines={2}>{task.title}</Text>
|
||||||
|
<Text style={styles.qdesc}>
|
||||||
|
{task.qdesc}{task.fileSize ? ` · ${formatFileSize(task.fileSize)}` : ''}
|
||||||
|
</Text>
|
||||||
|
{task.status === 'downloading' && (
|
||||||
|
<View style={styles.progressWrap}>
|
||||||
|
<View style={styles.progressTrack}>
|
||||||
|
<View style={[styles.progressFill, { width: `${Math.round(task.progress * 100)}%` as any }]} />
|
||||||
|
</View>
|
||||||
|
<ActivityIndicator size="small" color="#00AEEC" style={{ marginLeft: 6 }} />
|
||||||
|
<Text style={styles.progressTxt}>{Math.round(task.progress * 100)}%</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{task.status === 'error' && (
|
||||||
|
<Text style={styles.errorTxt} numberOfLines={1}>{task.error ?? '下载失败'}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{task.status === 'done' && (
|
||||||
|
<TouchableOpacity style={styles.playBtn} onPress={onPlay}>
|
||||||
|
<Ionicons name="play-circle" size={20} color="#00AEEC" />
|
||||||
|
<Text style={styles.playTxt}>播放</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity style={styles.deleteBtn} onPress={onDelete}>
|
||||||
|
<Ionicons name="trash-outline" size={18} color="#bbb" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { VideoCard } from "../components/VideoCard";
|
import { VideoCard } from "../components/VideoCard";
|
||||||
import { LiveCard } from "../components/LiveCard";
|
import { LiveCard } from "../components/LiveCard";
|
||||||
import { LoginModal } from "../components/LoginModal";
|
import { LoginModal } from "../components/LoginModal";
|
||||||
|
import { DownloadProgressBtn } from "../components/DownloadProgressBtn";
|
||||||
import { useVideoList } from "../hooks/useVideoList";
|
import { useVideoList } from "../hooks/useVideoList";
|
||||||
import { useLiveList } from "../hooks/useLiveList";
|
import { useLiveList } from "../hooks/useLiveList";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
@@ -431,9 +432,7 @@ export default function HomeScreen() {
|
|||||||
<Ionicons name="search" size={14} color="#999" />
|
<Ionicons name="search" size={14} color="#999" />
|
||||||
<Text style={styles.searchPlaceholder}>搜索视频、UP主...</Text>
|
<Text style={styles.searchPlaceholder}>搜索视频、UP主...</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.downloadBtn} activeOpacity={0.7}>
|
<DownloadProgressBtn onPress={() => router.push('/downloads' as any)} />
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="#999" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.tabRow}>
|
<View style={styles.tabRow}>
|
||||||
@@ -536,11 +535,11 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
tabText: {
|
tabText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: "500",
|
fontWeight: "450",
|
||||||
color: "#999",
|
color: "#999",
|
||||||
},
|
},
|
||||||
tabTextActive: {
|
tabTextActive: {
|
||||||
fontWeight: "700",
|
fontWeight: "500",
|
||||||
color: "#00AEEC",
|
color: "#00AEEC",
|
||||||
},
|
},
|
||||||
tabUnderline: {
|
tabUnderline: {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import { DanmakuItem } from "../../services/types";
|
|||||||
import DanmakuList from "../../components/DanmakuList";
|
import DanmakuList from "../../components/DanmakuList";
|
||||||
import { useVideoDetail } from "../../hooks/useVideoDetail";
|
import { useVideoDetail } from "../../hooks/useVideoDetail";
|
||||||
import { useComments } from "../../hooks/useComments";
|
import { useComments } from "../../hooks/useComments";
|
||||||
import { useVideoStore } from "../../store/videoStore";
|
|
||||||
import { formatCount } from "../../utils/format";
|
import { formatCount } from "../../utils/format";
|
||||||
import { proxyImageUrl } from "../../utils/imageUrl";
|
import { proxyImageUrl } from "../../utils/imageUrl";
|
||||||
|
import { DownloadSheet } from "../../components/DownloadSheet";
|
||||||
|
|
||||||
type Tab = "intro" | "comments" | "danmaku";
|
type Tab = "intro" | "comments" | "danmaku";
|
||||||
|
|
||||||
@@ -46,11 +46,7 @@ export default function VideoDetailScreen() {
|
|||||||
const [tab, setTab] = useState<Tab>("intro");
|
const [tab, setTab] = useState<Tab>("intro");
|
||||||
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const { setVideo, clearVideo } = useVideoStore();
|
const [showDownload, setShowDownload] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
clearVideo();
|
|
||||||
}, [bvid]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (video?.aid) loadComments();
|
if (video?.aid) loadComments();
|
||||||
@@ -61,13 +57,6 @@ export default function VideoDetailScreen() {
|
|||||||
getDanmaku(video.cid).then(setDanmakus);
|
getDanmaku(video.cid).then(setDanmakus);
|
||||||
}, [video?.cid]);
|
}, [video?.cid]);
|
||||||
|
|
||||||
function handleMiniPlayer() {
|
|
||||||
if (video) {
|
|
||||||
setVideo(bvid as string, video.title, video.pic);
|
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe}>
|
<SafeAreaView style={styles.safe}>
|
||||||
{/* TopBar */}
|
{/* TopBar */}
|
||||||
@@ -78,8 +67,11 @@ export default function VideoDetailScreen() {
|
|||||||
<Text style={styles.topTitle} numberOfLines={1}>
|
<Text style={styles.topTitle} numberOfLines={1}>
|
||||||
{video?.title ?? "视频详情"}
|
{video?.title ?? "视频详情"}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity style={styles.miniBtn} onPress={handleMiniPlayer}>
|
<TouchableOpacity
|
||||||
<Ionicons name="copy-outline" size={22} color="#212121" />
|
style={styles.miniBtn}
|
||||||
|
onPress={() => setShowDownload(true)}
|
||||||
|
>
|
||||||
|
<Ionicons name="cloud-download-outline" size={22} color="#212121" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -89,12 +81,20 @@ export default function VideoDetailScreen() {
|
|||||||
qualities={qualities}
|
qualities={qualities}
|
||||||
currentQn={currentQn}
|
currentQn={currentQn}
|
||||||
onQualityChange={changeQuality}
|
onQualityChange={changeQuality}
|
||||||
onMiniPlayer={handleMiniPlayer}
|
|
||||||
bvid={bvid as string}
|
bvid={bvid as string}
|
||||||
cid={video?.cid}
|
cid={video?.cid}
|
||||||
danmakus={danmakus}
|
danmakus={danmakus}
|
||||||
onTimeUpdate={setCurrentTime}
|
onTimeUpdate={setCurrentTime}
|
||||||
/>
|
/>
|
||||||
|
<DownloadSheet
|
||||||
|
visible={showDownload}
|
||||||
|
onClose={() => 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 */}
|
{/* TabBar — sits directly below player, always visible once video loads */}
|
||||||
{video && (
|
{video && (
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
NativeScrollEvent,
|
NativeScrollEvent,
|
||||||
ScrollView,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { DanmakuItem } from "../services/types";
|
import { DanmakuItem } from "../services/types";
|
||||||
@@ -30,25 +29,11 @@ interface DisplayedDanmaku extends DanmakuItem {
|
|||||||
_fadeAnim: Animated.Value;
|
_fadeAnim: Animated.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_DISPLAYED = 100;
|
|
||||||
const DRIP_INTERVAL = 250;
|
const DRIP_INTERVAL = 250;
|
||||||
const FAST_DRIP_INTERVAL = 100;
|
const FAST_DRIP_INTERVAL = 100;
|
||||||
const QUEUE_FAST_THRESHOLD = 50;
|
const QUEUE_FAST_THRESHOLD = 50;
|
||||||
const SEEK_THRESHOLD = 2;
|
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 }> = {
|
const GUARD_LABELS: Record<number, { text: string; color: string }> = {
|
||||||
1: { text: "总督", color: "#E13979" },
|
1: { text: "总督", color: "#E13979" },
|
||||||
@@ -337,34 +322,6 @@ export default function DanmakuList({
|
|||||||
</View>
|
</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>
|
</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 React, { useEffect, useState, useRef } from "react";
|
||||||
import { Modal, View, Text, StyleSheet, TouchableOpacity, Image, ActivityIndicator } from 'react-native';
|
import {
|
||||||
import { generateQRCode, pollQRCode, getUserInfo } from '../services/bilibili';
|
Modal,
|
||||||
import { useAuthStore } from '../store/authStore';
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Image,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { generateQRCode, pollQRCode, getUserInfo } from "../services/bilibili";
|
||||||
|
import { useAuthStore } from "../store/authStore";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -11,61 +19,91 @@ interface Props {
|
|||||||
export function LoginModal({ visible, onClose }: Props) {
|
export function LoginModal({ visible, onClose }: Props) {
|
||||||
const [qrUrl, setQrUrl] = useState<string | null>(null);
|
const [qrUrl, setQrUrl] = useState<string | null>(null);
|
||||||
const [qrKey, setQrKey] = 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 pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const login = useAuthStore(s => s.login);
|
const login = useAuthStore((s) => s.login);
|
||||||
const setProfile = useAuthStore(s => s.setProfile);
|
const setProfile = useAuthStore((s) => s.setProfile);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
setStatus('loading');
|
setStatus("loading");
|
||||||
setQrUrl(null);
|
setQrUrl(null);
|
||||||
setQrKey(null);
|
setQrKey(null);
|
||||||
generateQRCode().then(data => {
|
generateQRCode()
|
||||||
setQrUrl(`https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(data.url)}&size=200x200`);
|
.then((data) => {
|
||||||
|
setQrUrl(
|
||||||
|
`https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(data.url)}&size=200x200`,
|
||||||
|
);
|
||||||
setQrKey(data.qrcode_key);
|
setQrKey(data.qrcode_key);
|
||||||
setStatus('waiting');
|
setStatus("waiting");
|
||||||
}).catch(() => setStatus('error'));
|
})
|
||||||
|
.catch(() => setStatus("error"));
|
||||||
|
|
||||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
return () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!qrKey || status !== 'waiting') return;
|
if (!qrKey || status !== "waiting") return;
|
||||||
pollRef.current = setInterval(async () => {
|
pollRef.current = setInterval(async () => {
|
||||||
const result = await pollQRCode(qrKey);
|
const result = await pollQRCode(qrKey);
|
||||||
if (result.code === 86038) { setStatus('error'); clearInterval(pollRef.current!); }
|
if (result.code === 86038) {
|
||||||
if (result.code === 86090) setStatus('scanned');
|
setStatus("error");
|
||||||
|
clearInterval(pollRef.current!);
|
||||||
|
}
|
||||||
|
if (result.code === 86090) setStatus("scanned");
|
||||||
if (result.code === 0 && result.cookie) {
|
if (result.code === 0 && result.cookie) {
|
||||||
clearInterval(pollRef.current!);
|
clearInterval(pollRef.current!);
|
||||||
try {
|
try {
|
||||||
await login(result.cookie, '', '');
|
await login(result.cookie, "", "");
|
||||||
setStatus('done');
|
setStatus("done");
|
||||||
// 登录后异步拉取用户头像和昵称
|
// 登录后异步拉取用户头像和昵称
|
||||||
const info = await getUserInfo();
|
const info = await getUserInfo();
|
||||||
setProfile(info.face, info.uname, String(info.mid));
|
setProfile(info.face, info.uname, String(info.mid));
|
||||||
} catch {
|
} catch {
|
||||||
setStatus('error');
|
setStatus("error");
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
return () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
}, [qrKey, status]);
|
}, [qrKey, status]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="none"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<View style={styles.sheet}>
|
<View style={styles.sheet}>
|
||||||
<Text style={styles.title}>扫码登录</Text>
|
<Text style={styles.title}>扫码登录</Text>
|
||||||
{status === 'loading' && <ActivityIndicator size="large" color="#00AEEC" style={styles.loader} />}
|
{status === "loading" && (
|
||||||
{(status === 'waiting' || status === 'scanned') && qrUrl && (
|
<ActivityIndicator
|
||||||
|
size="large"
|
||||||
|
color="#00AEEC"
|
||||||
|
style={styles.loader}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(status === "waiting" || status === "scanned") && qrUrl && (
|
||||||
<>
|
<>
|
||||||
<Image source={{ uri: qrUrl }} style={styles.qr} />
|
<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}>
|
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
||||||
<Text style={styles.closeTxt}>关闭</Text>
|
<Text style={styles.closeTxt}>关闭</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -76,12 +114,22 @@ export function LoginModal({ visible, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
|
overlay: {
|
||||||
sheet: { backgroundColor: '#fff', borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 24, alignItems: 'center' },
|
flex: 1,
|
||||||
title: { fontSize: 18, fontWeight: '600', marginBottom: 20 },
|
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 },
|
loader: { marginVertical: 40 },
|
||||||
qr: { width: 200, height: 200, marginBottom: 12 },
|
qr: { width: 200, height: 200, marginBottom: 12 },
|
||||||
hint: { fontSize: 13, color: '#666', marginBottom: 20 },
|
hint: { fontSize: 13, color: "#666", marginBottom: 20 },
|
||||||
closeBtn: { padding: 12 },
|
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 { formatDuration } from "../utils/format";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@@ -61,7 +68,6 @@ interface Props {
|
|||||||
currentQn: number;
|
currentQn: number;
|
||||||
onQualityChange: (qn: number) => void;
|
onQualityChange: (qn: number) => void;
|
||||||
onFullscreen: () => void;
|
onFullscreen: () => void;
|
||||||
onMiniPlayer?: () => void;
|
|
||||||
style?: object;
|
style?: object;
|
||||||
bvid?: string;
|
bvid?: string;
|
||||||
cid?: number;
|
cid?: number;
|
||||||
@@ -72,13 +78,14 @@ interface Props {
|
|||||||
forcePaused?: boolean;
|
forcePaused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(function NativeVideoPlayer({
|
export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||||
|
function NativeVideoPlayer(
|
||||||
|
{
|
||||||
playData,
|
playData,
|
||||||
qualities,
|
qualities,
|
||||||
currentQn,
|
currentQn,
|
||||||
onQualityChange,
|
onQualityChange,
|
||||||
onFullscreen,
|
onFullscreen,
|
||||||
onMiniPlayer,
|
|
||||||
style,
|
style,
|
||||||
bvid,
|
bvid,
|
||||||
cid,
|
cid,
|
||||||
@@ -87,7 +94,9 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(functio
|
|||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
initialTime,
|
initialTime,
|
||||||
forcePaused,
|
forcePaused,
|
||||||
}: Props, ref) {
|
}: Props,
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
|
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
|
||||||
const VIDEO_H = SCREEN_W * 0.5625;
|
const VIDEO_H = SCREEN_W * 0.5625;
|
||||||
|
|
||||||
@@ -120,7 +129,9 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(functio
|
|||||||
const videoRef = useRef<VideoRef>(null);
|
const videoRef = useRef<VideoRef>(null);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
seek: (t: number) => { videoRef.current?.seek(t); },
|
seek: (t: number) => {
|
||||||
|
videoRef.current?.seek(t);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const currentDesc =
|
const currentDesc =
|
||||||
@@ -164,7 +175,10 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(functio
|
|||||||
const resetHideTimer = useCallback(() => {
|
const resetHideTimer = useCallback(() => {
|
||||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
if (!isSeekingRef.current) {
|
if (!isSeekingRef.current) {
|
||||||
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
|
hideTimer.current = setTimeout(
|
||||||
|
() => setShowControls(false),
|
||||||
|
HIDE_DELAY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
// 显示控制栏并重置隐藏计时器,确保用户每次交互后都有足够时间查看控制栏。依赖 resetHideTimer 保持稳定引用,避免不必要的重新渲染。
|
// 显示控制栏并重置隐藏计时器,确保用户每次交互后都有足够时间查看控制栏。依赖 resetHideTimer 保持稳定引用,避免不必要的重新渲染。
|
||||||
@@ -267,7 +281,8 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(functio
|
|||||||
// 进度条上触摸位置对应的时间点比例,0-1。非拖动状态为 null
|
// 进度条上触摸位置对应的时间点比例,0-1。非拖动状态为 null
|
||||||
const touchRatio =
|
const touchRatio =
|
||||||
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
|
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
|
||||||
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
|
const progressRatio =
|
||||||
|
duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
|
||||||
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
|
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
|
||||||
|
|
||||||
const THUMB_DISPLAY_W = 120; // scaled display width
|
const THUMB_DISPLAY_W = 120; // scaled display width
|
||||||
@@ -289,7 +304,11 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(functio
|
|||||||
const frameIdx =
|
const frameIdx =
|
||||||
index?.length && duration > 0
|
index?.length && duration > 0
|
||||||
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
|
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
|
||||||
: clamp(Math.floor(touchRatio * (totalFrames - 1)), 0, totalFrames - 1);
|
: clamp(
|
||||||
|
Math.floor(touchRatio * (totalFrames - 1)),
|
||||||
|
0,
|
||||||
|
totalFrames - 1,
|
||||||
|
);
|
||||||
|
|
||||||
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
|
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
|
||||||
const local = frameIdx % framesPerSheet;
|
const local = frameIdx % framesPerSheet;
|
||||||
@@ -311,7 +330,11 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(functio
|
|||||||
const DH = Math.round(TH * scale);
|
const DH = Math.round(TH * scale);
|
||||||
|
|
||||||
const trackLeft = barOffsetX.current;
|
const trackLeft = barOffsetX.current;
|
||||||
const absLeft = clamp(trackLeft + (touchX ?? 0) - DW / 2, 0, SCREEN_W - DW);
|
const absLeft = clamp(
|
||||||
|
trackLeft + (touchX ?? 0) - DW / 2,
|
||||||
|
0,
|
||||||
|
SCREEN_W - DW,
|
||||||
|
);
|
||||||
// 兼容处理图床地址,确保以 http(s) 协议开头
|
// 兼容处理图床地址,确保以 http(s) 协议开头
|
||||||
const sheetUrl = image[sheetIdx].startsWith("//")
|
const sheetUrl = image[sheetIdx].startsWith("//")
|
||||||
? `https:${image[sheetIdx]}`
|
? `https:${image[sheetIdx]}`
|
||||||
@@ -322,7 +345,12 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(functio
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{ width: DW, height: DH, overflow: "hidden", borderRadius: 4 }}
|
style={{
|
||||||
|
width: DW,
|
||||||
|
height: DH,
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: sheetUrl, headers: HEADERS }}
|
source={{ uri: sheetUrl, headers: HEADERS }}
|
||||||
@@ -400,20 +428,12 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(functio
|
|||||||
|
|
||||||
{showControls && (
|
{showControls && (
|
||||||
<>
|
<>
|
||||||
|
{/* 小窗口 */}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={["rgba(0,0,0,0.55)", "transparent"]}
|
colors={["rgba(0,0,0,0.55)", "transparent"]}
|
||||||
style={styles.topBar}
|
style={styles.topBar}
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
>
|
>
|
||||||
{onMiniPlayer && (
|
|
||||||
<TouchableOpacity onPress={onMiniPlayer} style={styles.topBtn}>
|
|
||||||
<Ionicons
|
|
||||||
name="tablet-portrait-outline"
|
|
||||||
size={20}
|
|
||||||
color="#fff"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -564,7 +584,8 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(functio
|
|||||||
</Modal>
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { backgroundColor: "#000" },
|
container: { backgroundColor: "#000" },
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ interface Props {
|
|||||||
qualities: { qn: number; desc: string }[];
|
qualities: { qn: number; desc: string }[];
|
||||||
currentQn: number;
|
currentQn: number;
|
||||||
onQualityChange: (qn: number) => void;
|
onQualityChange: (qn: number) => void;
|
||||||
onMiniPlayer?: () => void;
|
|
||||||
bvid?: string;
|
bvid?: string;
|
||||||
cid?: number;
|
cid?: number;
|
||||||
danmakus?: DanmakuItem[];
|
danmakus?: DanmakuItem[];
|
||||||
onTimeUpdate?: (t: number) => void;
|
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 [fullscreen, setFullscreen] = useState(false);
|
||||||
const { width, height } = useWindowDimensions();
|
const { width, height } = useWindowDimensions();
|
||||||
const VIDEO_HEIGHT = width * 0.5625;
|
const VIDEO_HEIGHT = width * 0.5625;
|
||||||
@@ -80,7 +79,6 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
|||||||
currentQn={currentQn}
|
currentQn={currentQn}
|
||||||
onQualityChange={onQualityChange}
|
onQualityChange={onQualityChange}
|
||||||
onFullscreen={handleEnterFullscreen}
|
onFullscreen={handleEnterFullscreen}
|
||||||
onMiniPlayer={onMiniPlayer}
|
|
||||||
bvid={bvid}
|
bvid={bvid}
|
||||||
cid={cid}
|
cid={cid}
|
||||||
isFullscreen={false}
|
isFullscreen={false}
|
||||||
|
|||||||
165
hooks/useDownload.ts
Normal file
165
hooks/useDownload.ts
Normal file
@@ -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<string, number> = {};
|
||||||
|
|
||||||
|
const QUALITY_LABELS: Record<number, string> = {
|
||||||
|
16: '360P',
|
||||||
|
32: '480P',
|
||||||
|
64: '720P',
|
||||||
|
80: '1080P',
|
||||||
|
112: '1080P+',
|
||||||
|
116: '1080P60',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 等待 App 回到前台 */
|
||||||
|
function waitForActive(): Promise<void> {
|
||||||
|
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<number | undefined> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -123,6 +123,24 @@ export async function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<Pl
|
|||||||
return res.data.data as PlayUrlResponse;
|
return res.data.data as PlayUrlResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPlayUrlForDownload(
|
||||||
|
bvid: string,
|
||||||
|
cid: number,
|
||||||
|
qn = 64,
|
||||||
|
): Promise<string> {
|
||||||
|
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 }> {
|
export async function getUserInfo(): Promise<{ face: string; uname: string; mid: number }> {
|
||||||
const res = await api.get('/x/web-interface/nav');
|
const res = await api.get('/x/web-interface/nav');
|
||||||
const { face, uname, mid } = res.data.data;
|
const { face, uname, mid } = res.data.data;
|
||||||
|
|||||||
96
store/downloadStore.ts
Normal file
96
store/downloadStore.ts
Normal file
@@ -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<string, DownloadTask>; // key: `${bvid}_${qn}`
|
||||||
|
hasLoaded: boolean;
|
||||||
|
addTask: (key: string, task: DownloadTask) => void;
|
||||||
|
updateTask: (key: string, patch: Partial<DownloadTask>) => void;
|
||||||
|
removeTask: (key: string) => void;
|
||||||
|
loadFromStorage: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'download_tasks';
|
||||||
|
|
||||||
|
export const useDownloadStore = create<DownloadStore>((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<string, DownloadTask> = 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<string, DownloadTask>) {
|
||||||
|
// 只持久化 done/error,downloading 重启后无法续传无需保存
|
||||||
|
const toSave: Record<string, DownloadTask> = {};
|
||||||
|
Object.keys(tasks).forEach((k) => {
|
||||||
|
if (tasks[k].status !== 'downloading') {
|
||||||
|
toSave[k] = tasks[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)).catch(() => {});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user