This commit is contained in:
Developer
2026-03-10 19:04:18 +08:00
parent 18eebfb0d2
commit cf20b016ff
18 changed files with 1106 additions and 123 deletions

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import { Modal, View, Text, StyleSheet, TouchableOpacity, Image, ActivityIndicator } from 'react-native';
import { generateQRCode, pollQRCode } from '../services/bilibili';
import { generateQRCode, pollQRCode, getUserInfo } from '../services/bilibili';
import { useAuthStore } from '../store/authStore';
interface Props {
@@ -14,6 +14,7 @@ export function LoginModal({ visible, onClose }: Props) {
const [status, setStatus] = useState<'loading' | 'waiting' | 'scanned' | 'done' | 'error'>('loading');
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const login = useAuthStore(s => s.login);
const setProfile = useAuthStore(s => s.setProfile);
useEffect(() => {
if (!visible) return;
@@ -39,6 +40,8 @@ export function LoginModal({ visible, onClose }: Props) {
clearInterval(pollRef.current!);
await login(result.cookie, '', '');
setStatus('done');
// 登录后异步拉取用户头像和昵称
getUserInfo().then(info => setProfile(info.face, info.uname, String(info.mid))).catch(() => {});
onClose();
}
}, 2000);

90
components/MiniPlayer.tsx Normal file
View File

@@ -0,0 +1,90 @@
import React, { useRef } from 'react';
import {
View, Text, Image, StyleSheet, TouchableOpacity,
Animated, PanResponder,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useVideoStore } from '../store/videoStore';
export function MiniPlayer() {
const { isActive, bvid, title, cover, clearVideo } = useVideoStore();
const router = useRouter();
const insets = useSafeAreaInsets();
const pan = useRef(new Animated.ValueXY()).current;
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }),
onPanResponderRelease: () => {
pan.flattenOffset();
},
onPanResponderGrant: () => {
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
pan.setValue({ x: 0, y: 0 });
},
})
).current;
if (!isActive) return null;
const bottomOffset = insets.bottom + 16;
return (
<Animated.View
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
{...panResponder.panHandlers}
>
<TouchableOpacity
style={styles.main}
onPress={() => router.push(`/video/${bvid}` as any)}
activeOpacity={0.85}
>
<Image source={{ uri: cover }} style={styles.cover} />
<Text style={styles.title} numberOfLines={1}>{title}</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.closeBtn} onPress={clearVideo}>
<Ionicons name="close" size={14} color="#fff" />
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
right: 12,
width: 160,
height: 90,
borderRadius: 8,
backgroundColor: '#1a1a1a',
overflow: 'hidden',
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
main: { flex: 1 },
cover: { width: '100%', height: 64, backgroundColor: '#333' },
title: {
color: '#fff',
fontSize: 11,
paddingHorizontal: 6,
paddingVertical: 4,
lineHeight: 14,
},
closeBtn: {
position: 'absolute',
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: 'rgba(0,0,0,0.6)',
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -1,38 +1,87 @@
import React from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import React, { useState } from 'react';
import {
View, StyleSheet, Dimensions, TouchableOpacity,
Text, Modal, FlatList,
} from 'react-native';
import { WebView } from 'react-native-webview';
import { Ionicons } from '@expo/vector-icons';
import { buildMpd } from '../utils/buildMpd';
import type { PlayUrlResponse } from '../services/types';
const { width } = Dimensions.get('window');
const VIDEO_HEIGHT = width * 0.5625;
interface Props {
uri: string;
playData: PlayUrlResponse | null;
qualities: { qn: number; desc: string }[];
currentQn: number;
onQualityChange: (qn: number) => void;
onFullscreen: () => void;
onMiniPlayer?: () => void;
style?: object;
}
const buildHtml = (uri: string) => `
<!DOCTYPE html>
function buildMp4Html(url: string): string {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { margin:0; padding:0; box-sizing:border-box; background:#000; }
video { width:100vw; height:100vh; object-fit:contain; display:block; }
</style>
<style>* { margin:0; padding:0; box-sizing:border-box; background:#000; } video { width:100vw; height:100vh; object-fit:contain; display:block; }</style>
</head>
<body>
<video id="v" controls autoplay playsinline webkit-playsinline></video>
<script>document.getElementById('v').src = ${JSON.stringify(url)};</script>
</body>
</html>`;
}
function buildDashHtml(mpdStr: string): string {
const mpdBase64 = `data:application/dash+xml;base64,${btoa(unescape(encodeURIComponent(mpdStr)))}`;
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>* { margin:0; padding:0; box-sizing:border-box; background:#000; } video { width:100vw; height:100vh; object-fit:contain; display:block; }</style>
</head>
<body>
<video id="v" autoplay controls playsinline webkit-playsinline></video>
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
<script>
document.getElementById('v').src = ${JSON.stringify(uri)};
var player = dashjs.MediaPlayer().create();
player.initialize(document.getElementById('v'), ${JSON.stringify(mpdBase64)}, true);
player.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: false } } } });
</script>
</body>
</html>
`;
</html>`;
}
function getHtml(playData: PlayUrlResponse | null): string {
if (!playData) return '<html><body style="background:#000"></body></html>';
if (playData.dash) {
const v = playData.dash.video[0];
const a = playData.dash.audio[0];
if (v && a) {
const mpd = buildMpd(v.baseUrl, v.codecs, v.bandwidth, a.baseUrl, a.codecs, a.bandwidth);
return buildDashHtml(mpd);
}
}
const url = playData.durl?.[0]?.url;
if (url) return buildMp4Html(url);
return '<html><body style="background:#000"></body></html>';
}
export function NativeVideoPlayer({
playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style,
}: Props) {
const [showQuality, setShowQuality] = useState(false);
const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? (currentQn ? String(currentQn) : 'HD');
const html = getHtml(playData);
export function NativeVideoPlayer({ uri }: Props) {
return (
<View style={styles.container}>
<View style={[styles.container, style]}>
<WebView
source={{ html: buildHtml(uri) }}
key={html}
source={{ html }}
style={styles.webview}
allowsInlineMediaPlayback
mediaPlaybackRequiresUserAction={false}
@@ -40,11 +89,88 @@ export function NativeVideoPlayer({ uri }: Props) {
originWhitelist={['*']}
scrollEnabled={false}
/>
<View style={styles.controls}>
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowQuality(true)}>
<Text style={styles.qualityText}>{currentDesc}</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
<Ionicons name="expand" size={18} color="#fff" />
</TouchableOpacity>
{onMiniPlayer && (
<TouchableOpacity style={styles.ctrlBtn} onPress={onMiniPlayer}>
<Ionicons name="tablet-portrait-outline" size={18} color="#fff" />
</TouchableOpacity>
)}
</View>
<Modal visible={showQuality} transparent animationType="fade">
<TouchableOpacity style={styles.modalOverlay} onPress={() => setShowQuality(false)}>
<View style={styles.qualityList}>
<Text style={styles.qualityTitle}></Text>
{qualities.map(q => (
<TouchableOpacity
key={q.qn}
style={styles.qualityItem}
onPress={() => {
setShowQuality(false);
onQualityChange(q.qn);
}}
>
<Text style={[styles.qualityItemText, q.qn === currentQn && styles.qualityItemActive]}>
{q.desc}
</Text>
{q.qn === currentQn && <Ionicons name="checkmark" size={16} color="#00AEEC" />}
</TouchableOpacity>
))}
</View>
</TouchableOpacity>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: { width, height: VIDEO_HEIGHT, backgroundColor: '#000' },
webview: { width, height: VIDEO_HEIGHT, backgroundColor: '#000' },
webview: { flex: 1, backgroundColor: '#000' },
controls: {
position: 'absolute',
bottom: 8,
right: 8,
flexDirection: 'row',
gap: 8,
},
ctrlBtn: {
backgroundColor: 'rgba(0,0,0,0.55)',
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 4,
alignItems: 'center',
justifyContent: 'center',
},
qualityText: { color: '#fff', fontSize: 12, fontWeight: '600' },
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
qualityList: {
backgroundColor: '#fff',
borderRadius: 12,
paddingVertical: 8,
paddingHorizontal: 16,
minWidth: 180,
},
qualityTitle: { fontSize: 15, fontWeight: '700', color: '#212121', paddingVertical: 10, textAlign: 'center' },
qualityItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#eee',
},
qualityItemText: { fontSize: 14, color: '#333' },
qualityItemActive: { color: '#00AEEC', fontWeight: '700' },
});

View File

@@ -1,16 +1,24 @@
import React from 'react';
import { View, StyleSheet, Dimensions, Text, Platform } from 'react-native';
import React, { useState } from 'react';
import { View, StyleSheet, Dimensions, Text, Platform, Modal, TouchableOpacity, StatusBar } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { NativeVideoPlayer } from './NativeVideoPlayer';
import type { PlayUrlResponse } from '../services/types';
const { width } = Dimensions.get('window');
const VIDEO_HEIGHT = width * 0.5625;
interface Props {
uri: string | null;
playData: PlayUrlResponse | null;
qualities: { qn: number; desc: string }[];
currentQn: number;
onQualityChange: (qn: number) => void;
onMiniPlayer?: () => void;
}
export function VideoPlayer({ uri }: Props) {
if (!uri) {
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, onMiniPlayer }: Props) {
const [fullscreen, setFullscreen] = useState(false);
if (!playData) {
return (
<View style={[styles.container, styles.placeholder]}>
<Text style={styles.placeholderText}>...</Text>
@@ -19,10 +27,11 @@ export function VideoPlayer({ uri }: Props) {
}
if (Platform.OS === 'web') {
const url = playData.durl?.[0]?.url ?? '';
return (
<View style={styles.container}>
<video
src={uri}
src={url}
style={{ width: '100%', height: '100%', backgroundColor: '#000' } as any}
controls
playsInline
@@ -31,11 +40,48 @@ export function VideoPlayer({ uri }: Props) {
);
}
return <NativeVideoPlayer uri={uri} />;
return (
<>
<NativeVideoPlayer
playData={playData}
qualities={qualities}
currentQn={currentQn}
onQualityChange={onQualityChange}
onFullscreen={() => setFullscreen(true)}
onMiniPlayer={onMiniPlayer}
/>
<Modal visible={fullscreen} animationType="fade" statusBarTranslucent>
<StatusBar hidden />
<View style={styles.fullscreenContainer}>
<NativeVideoPlayer
playData={playData}
qualities={qualities}
currentQn={currentQn}
onQualityChange={onQualityChange}
onFullscreen={() => setFullscreen(false)}
style={{ width: '100%', height: '100%' } as any}
/>
<TouchableOpacity style={styles.closeBtn} onPress={() => setFullscreen(false)}>
<Ionicons name="close" size={28} color="#fff" />
</TouchableOpacity>
</View>
</Modal>
</>
);
}
const styles = StyleSheet.create({
container: { width, height: VIDEO_HEIGHT, backgroundColor: '#000' },
placeholder: { justifyContent: 'center', alignItems: 'center' },
placeholderText: { color: '#fff', fontSize: 14 },
fullscreenContainer: { flex: 1, backgroundColor: '#000' },
closeBtn: {
position: 'absolute',
top: 40,
right: 16,
padding: 8,
backgroundColor: 'rgba(0,0,0,0.4)',
borderRadius: 20,
},
});