feat:下载管理页面,集成分享功能

This commit is contained in:
Developer
2026-03-19 20:11:05 +08:00
parent 0fbfdadf6a
commit c0b7b8e974
8 changed files with 363 additions and 9 deletions

View File

@@ -18,6 +18,7 @@ import Video from 'react-native-video';
let ScreenOrientation: typeof import('expo-screen-orientation') | null = null; let ScreenOrientation: typeof import('expo-screen-orientation') | null = null;
try { ScreenOrientation = require('expo-screen-orientation'); } catch {} try { ScreenOrientation = require('expo-screen-orientation'); } catch {}
import { useDownloadStore, DownloadTask } from '../store/downloadStore'; import { useDownloadStore, DownloadTask } from '../store/downloadStore';
import { LanShareModal } from '../components/LanShareModal';
function formatFileSize(bytes?: number): string { function formatFileSize(bytes?: number): string {
if (!bytes || bytes <= 0) return ''; if (!bytes || bytes <= 0) return '';
@@ -32,6 +33,7 @@ export default function DownloadsScreen() {
const { tasks, loadFromStorage, removeTask } = useDownloadStore(); const { tasks, loadFromStorage, removeTask } = useDownloadStore();
const [playingUri, setPlayingUri] = useState<string | null>(null); const [playingUri, setPlayingUri] = useState<string | null>(null);
const [playingTitle, setPlayingTitle] = useState(''); const [playingTitle, setPlayingTitle] = useState('');
const [shareTask, setShareTask] = useState<(DownloadTask & { key: string }) | null>(null);
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
const isLandscape = width > height; const isLandscape = width > height;
@@ -89,6 +91,7 @@ export default function DownloadsScreen() {
if (item.localUri) openPlayer(item.localUri, item.title); if (item.localUri) openPlayer(item.localUri, item.title);
}} }}
onDelete={() => removeTask(item.key)} onDelete={() => removeTask(item.key)}
onShare={() => setShareTask(item)}
/> />
)} )}
ItemSeparatorComponent={() => <View style={styles.separator} />} ItemSeparatorComponent={() => <View style={styles.separator} />}
@@ -96,6 +99,12 @@ export default function DownloadsScreen() {
/> />
)} )}
<LanShareModal
visible={!!shareTask}
task={shareTask}
onClose={() => setShareTask(null)}
/>
{/* Local file player modal */} {/* Local file player modal */}
<Modal <Modal
visible={!!playingUri} visible={!!playingUri}
@@ -134,10 +143,12 @@ function DownloadRow({
task, task,
onPlay, onPlay,
onDelete, onDelete,
onShare,
}: { }: {
task: DownloadTask & { key: string }; task: DownloadTask & { key: string };
onPlay: () => void; onPlay: () => void;
onDelete: () => void; onDelete: () => void;
onShare: () => void;
}) { }) {
return ( return (
<View style={styles.row}> <View style={styles.row}>
@@ -165,10 +176,15 @@ function DownloadRow({
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
{task.status === 'done' && ( {task.status === 'done' && (
<>
<TouchableOpacity style={styles.playBtn} onPress={onPlay}> <TouchableOpacity style={styles.playBtn} onPress={onPlay}>
<Ionicons name="play-circle" size={20} color="#00AEEC" /> <Ionicons name="play-circle" size={20} color="#00AEEC" />
<Text style={styles.playTxt}></Text> <Text style={styles.playTxt}></Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.shareBtn} onPress={onShare}>
<Ionicons name="share-social-outline" size={20} color="#00AEEC" />
</TouchableOpacity>
</>
)} )}
<TouchableOpacity style={styles.deleteBtn} onPress={onDelete}> <TouchableOpacity style={styles.deleteBtn} onPress={onDelete}>
<Ionicons name="trash-outline" size={18} color="#bbb" /> <Ionicons name="trash-outline" size={18} color="#bbb" />
@@ -230,6 +246,7 @@ const styles = StyleSheet.create({
actions: { alignItems: 'center', gap: 8 }, actions: { alignItems: 'center', gap: 8 },
playBtn: { flexDirection: 'row', alignItems: 'center', gap: 3 }, playBtn: { flexDirection: 'row', alignItems: 'center', gap: 3 },
playTxt: { fontSize: 13, color: '#00AEEC' }, playTxt: { fontSize: 13, color: '#00AEEC' },
shareBtn: { padding: 4 },
deleteBtn: { padding: 4 }, deleteBtn: { padding: 4 },
separator: { height: StyleSheet.hairlineWidth, backgroundColor: '#f0f0f0', marginLeft: 108 }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: '#f0f0f0', marginLeft: 108 },
// player modal // player modal

View File

@@ -0,0 +1,175 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Modal,
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
ActivityIndicator,
Animated,
ScrollView,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { DownloadTask } from '../store/downloadStore';
import { startLanServer, stopLanServer, buildVideoUrl } from '../utils/lanServer';
interface Props {
visible: boolean;
task: DownloadTask | null;
onClose: () => void;
}
export function LanShareModal({ visible, task, onClose }: Props) {
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [qrImageLoaded, setQrImageLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const slideY = useRef(new Animated.Value(400)).current;
useEffect(() => {
if (visible) {
Animated.spring(slideY, { toValue: 0, useNativeDriver: true, bounciness: 4 }).start();
} else {
slideY.setValue(400);
}
}, [visible]);
useEffect(() => {
if (!visible || !task) return;
setVideoUrl(null);
setQrImageLoaded(false);
setLoading(true);
console.log(123123,'baseUrlbaseUrl')
startLanServer()
.then((baseUrl) => {
console.log(baseUrl,'baseUrl')
setVideoUrl(buildVideoUrl(baseUrl, task.bvid, task.qn));
})
.catch(() => setVideoUrl(null))
.finally(() => setLoading(false));
return () => {
stopLanServer();
};
}, [visible, task?.bvid, task?.qn]);
async function handleCopy() {
if (!videoUrl) return;
await Clipboard.setStringAsync(videoUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function handleClose() {
stopLanServer();
onClose();
}
const qrSrc = videoUrl
? `https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(videoUrl)}&size=400x400`
: null;
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={handleClose}>
<View style={styles.overlay} pointerEvents="box-none" />
<Animated.View style={[styles.sheetWrapper, { transform: [{ translateY: slideY }] }]}>
<View style={styles.sheet}>
<Text style={styles.title}></Text>
{task && (
<Text style={styles.taskTitle} numberOfLines={2}>
{task.title} · {task.qdesc}
</Text>
)}
{loading ? (
<ActivityIndicator size="large" color="#00AEEC" style={styles.loader} />
) : qrSrc ? (
<>
<View style={styles.qrWrapper}>
<Image
source={{ uri: qrSrc }}
style={styles.qr}
onLoad={() => setQrImageLoaded(true)}
/>
{!qrImageLoaded && (
<View style={styles.qrLoader}>
<ActivityIndicator size="large" color="#00AEEC" />
</View>
)}
</View>
<TouchableOpacity style={styles.urlRow} onPress={handleCopy} activeOpacity={0.7}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.urlScroll}>
<Text style={styles.urlText}>{videoUrl}</Text>
</ScrollView>
<Ionicons
name={copied ? 'checkmark-circle' : 'copy-outline'}
size={20}
color={copied ? '#4caf50' : '#00AEEC'}
style={{ marginLeft: 8 }}
/>
</TouchableOpacity>
<Text style={styles.hint}> WiFi </Text>
</>
) : (
<Text style={styles.hint}> WiFi12312</Text>
)}
<TouchableOpacity style={styles.closeBtn} onPress={handleClose}>
<Text style={styles.closeTxt}></Text>
</TouchableOpacity>
</View>
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
sheetWrapper: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
sheet: {
backgroundColor: '#fff',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
padding: 24,
alignItems: 'center',
},
title: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
taskTitle: { fontSize: 13, color: '#666', marginBottom: 16, textAlign: 'center' },
loader: { marginVertical: 40 },
qrWrapper: { width: 200, height: 200, marginBottom: 16 },
qr: { width: 200, height: 200 },
qrLoader: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f4f4f4',
},
urlRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f4f4f4',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
marginBottom: 12,
maxWidth: '100%',
},
urlScroll: { maxWidth: 260 },
urlText: { fontSize: 12, color: '#333', fontFamily: 'monospace' },
hint: { fontSize: 12, color: '#999', marginBottom: 20, textAlign: 'center' },
closeBtn: { padding: 12 },
closeTxt: { fontSize: 14, color: '#00AEEC' },
});

View File

@@ -112,8 +112,8 @@ export function LoginModal({ visible, onClose }: Props) {
Alert.alert("已保存", "二维码已存入相册,请用哔哩哔哩扫码登录", [ Alert.alert("已保存", "二维码已存入相册,请用哔哩哔哩扫码登录", [
{ text: "关闭", style: "cancel" }, { text: "关闭", style: "cancel" },
{ {
text: "打开哔哩哔哩扫一扫", text: "打开哔哩哔哩",
onPress: () => Linking.openURL("bilibili://scan"), onPress: () => Linking.openURL("bilibili://"),
}, },
]); ]);
} catch { } catch {

View File

@@ -60,6 +60,7 @@ function findFrameByTime(index: number[], seekTime: number): number {
export interface NativeVideoPlayerRef { export interface NativeVideoPlayerRef {
seek: (t: number) => void; seek: (t: number) => void;
setPaused: (v: boolean) => void;
} }
interface Props { interface Props {
@@ -132,6 +133,9 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
seek: (t: number) => { seek: (t: number) => {
videoRef.current?.seek(t); videoRef.current?.seek(t);
}, },
setPaused: (v: boolean) => {
setPaused(v);
},
})); }));
const currentDesc = const currentDesc =
@@ -547,7 +551,7 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
</TouchableOpacity> </TouchableOpacity>
)} )}
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}> <TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
<Ionicons name="expand" size={16} color="#fff" /> <Ionicons name="expand" size={18} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</LinearGradient> </LinearGradient>

View File

@@ -21,7 +21,6 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
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;
// In Expo Go ScreenOrientation is unavailable; simulate landscape via CSS transform
const needsRotation = !ScreenOrientation && fullscreen; const needsRotation = !ScreenOrientation && fullscreen;
const lastTimeRef = useRef(0); const lastTimeRef = useRef(0);
const portraitRef = useRef<NativeVideoPlayerRef>(null); const portraitRef = useRef<NativeVideoPlayerRef>(null);
@@ -33,8 +32,9 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
}; };
const handleExitFullscreen = async () => { const handleExitFullscreen = async () => {
// Seek portrait player to current position before it becomes visible again // 退出全屏:同步进度,竖屏一律暂停
portraitRef.current?.seek(lastTimeRef.current); portraitRef.current?.seek(lastTimeRef.current);
portraitRef.current?.setPaused(true);
setFullscreen(false); setFullscreen(false);
if (Platform.OS !== 'web') if (Platform.OS !== 'web')
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);

129
package-lock.json generated
View File

@@ -8,15 +8,18 @@
"name": "reactbilibiliapp", "name": "reactbilibiliapp",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dr.pogodin/react-native-static-server": "^0.26.0",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"axios": "^1.13.6", "axios": "^1.13.6",
"expo": "~55.0.5", "expo": "~55.0.5",
"expo-av": "^16.0.8", "expo-av": "^16.0.8",
"expo-clipboard": "~55.0.9",
"expo-dev-client": "~55.0.11", "expo-dev-client": "~55.0.11",
"expo-file-system": "~55.0.10", "expo-file-system": "~55.0.10",
"expo-linear-gradient": "~55.0.8", "expo-linear-gradient": "~55.0.8",
"expo-media-library": "~55.0.10", "expo-media-library": "~55.0.10",
"expo-network": "~55.0.9",
"expo-router": "~55.0.4", "expo-router": "~55.0.4",
"expo-screen-orientation": "~55.0.8", "expo-screen-orientation": "~55.0.8",
"expo-status-bar": "~55.0.4", "expo-status-bar": "~55.0.4",
@@ -1461,6 +1464,58 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dr.pogodin/js-utils": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@dr.pogodin/js-utils/-/js-utils-0.1.6.tgz",
"integrity": "sha512-v4hYKgHEL9Nfzoe4Vn4Sq2y7To46Q8V6vEBMpteghGOvitXmbhgmYV03GPh9SUuu9CV6vT+4u4GBnhBcK2m9vQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6"
}
},
"node_modules/@dr.pogodin/react-native-fs": {
"version": "2.37.0",
"resolved": "https://registry.npmjs.org/@dr.pogodin/react-native-fs/-/react-native-fs-2.37.0.tgz",
"integrity": "sha512-0LtRxZ+yJm3z4eiNmwkYvdMvglq74MZcqQBBJwaUwILqfSBNYAkO3jJZOvJdIR2+e06FJBoxuQ1P34mSDj8T8w==",
"license": "MIT",
"peer": true,
"workspaces": [
"example"
],
"dependencies": {
"buffer": "^6.0.3",
"http-status-codes": "^2.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/birdofpreyru"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@dr.pogodin/react-native-static-server": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@dr.pogodin/react-native-static-server/-/react-native-static-server-0.26.0.tgz",
"integrity": "sha512-BDvi9L6SvH3lJFq5mJWO8FMHBWk9C+PHruniT+XVSKusiuL9cVXxNHyjE5aS/+ZzDiqEQKStexDPsiUZE2m3nw==",
"license": "MIT",
"workspaces": [
"example"
],
"dependencies": {
"@dr.pogodin/js-utils": "^0.1.5"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/birdofpreyru"
},
"peerDependencies": {
"@dr.pogodin/react-native-fs": ">= 2.22.0",
"react": "*",
"react-native": "*"
}
},
"node_modules/@expo-google-fonts/material-symbols": { "node_modules/@expo-google-fonts/material-symbols": {
"version": "0.4.25", "version": "0.4.25",
"resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.25.tgz", "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.25.tgz",
@@ -3596,6 +3651,31 @@
"node-int64": "^0.4.0" "node-int64": "^0.4.0"
} }
}, },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -4359,6 +4439,17 @@
} }
} }
}, },
"node_modules/expo-clipboard": {
"version": "55.0.9",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-55.0.9.tgz",
"integrity": "sha512-WJ9ougE8fEDu3/RV5Vz3gE5aNgnj1C0WByXCz3WUj8y/06bJyaIUDMq8FDLv3n0AO3guiErmkUunsD0EzSDUUw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-constants": { "node_modules/expo-constants": {
"version": "55.0.7", "version": "55.0.7",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.7.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.7.tgz",
@@ -4562,6 +4653,16 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-network": {
"version": "55.0.9",
"resolved": "https://registry.npmjs.org/expo-network/-/expo-network-55.0.9.tgz",
"integrity": "sha512-vuL7s+Zbsbcqh2XxhaZ45mfuYZVn7ikukL27be7hN3zkLxoABNPgbVt+/gEKOxyv2Gzr/Q5JZvfVxCuwDDh0Fg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*"
}
},
"node_modules/expo-router": { "node_modules/expo-router": {
"version": "55.0.4", "version": "55.0.4",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-55.0.4.tgz", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-55.0.4.tgz",
@@ -5681,6 +5782,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/http-status-codes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
"license": "MIT",
"peer": true
},
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -5713,6 +5821,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",

View File

@@ -10,15 +10,18 @@
"proxy": "node dev-proxy.js" "proxy": "node dev-proxy.js"
}, },
"dependencies": { "dependencies": {
"@dr.pogodin/react-native-static-server": "^0.26.0",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"axios": "^1.13.6", "axios": "^1.13.6",
"expo": "~55.0.5", "expo": "~55.0.5",
"expo-av": "^16.0.8", "expo-av": "^16.0.8",
"expo-clipboard": "~55.0.9",
"expo-dev-client": "~55.0.11", "expo-dev-client": "~55.0.11",
"expo-file-system": "~55.0.10", "expo-file-system": "~55.0.10",
"expo-linear-gradient": "~55.0.8", "expo-linear-gradient": "~55.0.8",
"expo-media-library": "~55.0.10", "expo-media-library": "~55.0.10",
"expo-network": "~55.0.9",
"expo-router": "~55.0.4", "expo-router": "~55.0.4",
"expo-screen-orientation": "~55.0.8", "expo-screen-orientation": "~55.0.8",
"expo-status-bar": "~55.0.4", "expo-status-bar": "~55.0.4",

26
utils/lanServer.ts Normal file
View File

@@ -0,0 +1,26 @@
import StaticServer from '@dr.pogodin/react-native-static-server';
import * as FileSystem from 'expo-file-system/legacy';
import * as Network from 'expo-network';
const PORT = 18080;
let server: StaticServer | null = null;
export async function startLanServer(): Promise<string> {
if (server) await server.stop();
const root = FileSystem.documentDirectory!.replace('file://', '');
server = new StaticServer({ fileDir: root, port: PORT, nonLocal: true });
await server.start();
const ip = await Network.getIpAddressAsync();
return `http://${ip}:${PORT}`;
}
export async function stopLanServer(): Promise<void> {
if (server) {
await server.stop();
server = null;
}
}
export function buildVideoUrl(baseUrl: string, bvid: string, qn: number): string {
return `${baseUrl}/${bvid}_${qn}.mp4`;
}