diff --git a/app/downloads.tsx b/app/downloads.tsx index 9ae4471..72c5e4d 100644 --- a/app/downloads.tsx +++ b/app/downloads.tsx @@ -18,6 +18,7 @@ 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'; +import { LanShareModal } from '../components/LanShareModal'; function formatFileSize(bytes?: number): string { if (!bytes || bytes <= 0) return ''; @@ -32,6 +33,7 @@ export default function DownloadsScreen() { const { tasks, loadFromStorage, removeTask } = useDownloadStore(); const [playingUri, setPlayingUri] = useState(null); const [playingTitle, setPlayingTitle] = useState(''); + const [shareTask, setShareTask] = useState<(DownloadTask & { key: string }) | null>(null); const { width, height } = useWindowDimensions(); const isLandscape = width > height; @@ -89,6 +91,7 @@ export default function DownloadsScreen() { if (item.localUri) openPlayer(item.localUri, item.title); }} onDelete={() => removeTask(item.key)} + onShare={() => setShareTask(item)} /> )} ItemSeparatorComponent={() => } @@ -96,6 +99,12 @@ export default function DownloadsScreen() { /> )} + setShareTask(null)} + /> + {/* Local file player modal */} void; onDelete: () => void; + onShare: () => void; }) { return ( @@ -165,10 +176,15 @@ function DownloadRow({ {task.status === 'done' && ( - - - 播放 - + <> + + + 播放 + + + + + )} @@ -230,6 +246,7 @@ const styles = StyleSheet.create({ actions: { alignItems: 'center', gap: 8 }, playBtn: { flexDirection: 'row', alignItems: 'center', gap: 3 }, playTxt: { fontSize: 13, color: '#00AEEC' }, + shareBtn: { padding: 4 }, deleteBtn: { padding: 4 }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: '#f0f0f0', marginLeft: 108 }, // player modal diff --git a/components/LanShareModal.tsx b/components/LanShareModal.tsx new file mode 100644 index 0000000..230db41 --- /dev/null +++ b/components/LanShareModal.tsx @@ -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(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 ( + + + + + 局域网分享 + + {task && ( + + {task.title} · {task.qdesc} + + )} + + {loading ? ( + + ) : qrSrc ? ( + <> + + setQrImageLoaded(true)} + /> + {!qrImageLoaded && ( + + + + )} + + + + + {videoUrl} + + + + + 同一 WiFi 下,用浏览器扫码或输入链接即可播放 + + ) : ( + 启动服务器失败,请确认已连接 WiFi12312 + )} + + + 关闭 + + + + + ); +} + +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' }, +}); diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index 001b075..e6b2b48 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -112,8 +112,8 @@ export function LoginModal({ visible, onClose }: Props) { Alert.alert("已保存", "二维码已存入相册,请用哔哩哔哩扫码登录", [ { text: "关闭", style: "cancel" }, { - text: "打开哔哩哔哩扫一扫", - onPress: () => Linking.openURL("bilibili://scan"), + text: "打开哔哩哔哩", + onPress: () => Linking.openURL("bilibili://"), }, ]); } catch { diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index a1448b1..3855936 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -60,6 +60,7 @@ function findFrameByTime(index: number[], seekTime: number): number { export interface NativeVideoPlayerRef { seek: (t: number) => void; + setPaused: (v: boolean) => void; } interface Props { @@ -132,6 +133,9 @@ export const NativeVideoPlayer = forwardRef( seek: (t: number) => { videoRef.current?.seek(t); }, + setPaused: (v: boolean) => { + setPaused(v); + }, })); const currentDesc = @@ -547,7 +551,7 @@ export const NativeVideoPlayer = forwardRef( )} - + diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 49832f2..9e265f1 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -21,7 +21,6 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b const [fullscreen, setFullscreen] = useState(false); const { width, height } = useWindowDimensions(); const VIDEO_HEIGHT = width * 0.5625; - // In Expo Go ScreenOrientation is unavailable; simulate landscape via CSS transform const needsRotation = !ScreenOrientation && fullscreen; const lastTimeRef = useRef(0); const portraitRef = useRef(null); @@ -33,8 +32,9 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b }; const handleExitFullscreen = async () => { - // Seek portrait player to current position before it becomes visible again + // 退出全屏:同步进度,竖屏一律暂停 portraitRef.current?.seek(lastTimeRef.current); + portraitRef.current?.setPaused(true); setFullscreen(false); if (Platform.OS !== 'web') await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); diff --git a/package-lock.json b/package-lock.json index 325138c..68f2430 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,18 @@ "name": "reactbilibiliapp", "version": "1.0.0", "dependencies": { + "@dr.pogodin/react-native-static-server": "^0.26.0", "@expo/vector-icons": "^15.0.2", "@react-native-async-storage/async-storage": "2.2.0", "axios": "^1.13.6", "expo": "~55.0.5", "expo-av": "^16.0.8", + "expo-clipboard": "~55.0.9", "expo-dev-client": "~55.0.11", "expo-file-system": "~55.0.10", "expo-linear-gradient": "~55.0.8", "expo-media-library": "~55.0.10", + "expo-network": "~55.0.9", "expo-router": "~55.0.4", "expo-screen-orientation": "~55.0.8", "expo-status-bar": "~55.0.4", @@ -1461,6 +1464,58 @@ "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": { "version": "0.4.25", "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_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": { "version": "1.1.2", "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": { "version": "55.0.7", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.7.tgz", @@ -4562,6 +4653,16 @@ "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": { "version": "55.0.4", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-55.0.4.tgz", @@ -5681,6 +5782,13 @@ "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -5713,6 +5821,27 @@ "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": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 7473c00..c004550 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,18 @@ "proxy": "node dev-proxy.js" }, "dependencies": { + "@dr.pogodin/react-native-static-server": "^0.26.0", "@expo/vector-icons": "^15.0.2", "@react-native-async-storage/async-storage": "2.2.0", "axios": "^1.13.6", "expo": "~55.0.5", "expo-av": "^16.0.8", + "expo-clipboard": "~55.0.9", "expo-dev-client": "~55.0.11", "expo-file-system": "~55.0.10", "expo-linear-gradient": "~55.0.8", "expo-media-library": "~55.0.10", + "expo-network": "~55.0.9", "expo-router": "~55.0.4", "expo-screen-orientation": "~55.0.8", "expo-status-bar": "~55.0.4", diff --git a/utils/lanServer.ts b/utils/lanServer.ts new file mode 100644 index 0000000..d87498a --- /dev/null +++ b/utils/lanServer.ts @@ -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 { + 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 { + if (server) { + await server.stop(); + server = null; + } +} + +export function buildVideoUrl(baseUrl: string, bvid: string, qn: number): string { + return `${baseUrl}/${bvid}_${qn}.mp4`; +}