This commit is contained in:
Developer
2026-03-16 16:20:59 +08:00
parent 173b93c1d2
commit 829d175baa
10 changed files with 679 additions and 7 deletions

232
components/LivePlayer.tsx Normal file
View File

@@ -0,0 +1,232 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
Modal,
Platform,
useWindowDimensions,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
interface Props {
hlsUrl: string;
isLive: boolean; // false → show offline placeholder
}
const HIDE_DELAY = 3000;
const HEADERS = {
Referer: 'https://live.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',
};
export function LivePlayer({ hlsUrl, isLive }: Props) {
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
const VIDEO_H = SCREEN_W * 0.5625;
if (Platform.OS === 'web') {
return (
<View style={[styles.container, { width: SCREEN_W, height: VIDEO_H }]}>
<Text style={styles.webHint}></Text>
</View>
);
}
if (!isLive || !hlsUrl) {
return (
<View style={[styles.container, { width: SCREEN_W, height: VIDEO_H }]}>
<Ionicons name="tv-outline" size={40} color="#555" />
<Text style={styles.offlineText}></Text>
</View>
);
}
return <NativeLivePlayer hlsUrl={hlsUrl} screenW={SCREEN_W} screenH={SCREEN_H} videoH={VIDEO_H} />;
}
function NativeLivePlayer({
hlsUrl,
screenW,
screenH,
videoH,
}: {
hlsUrl: string;
screenW: number;
screenH: number;
videoH: number;
}) {
// Lazy import to avoid web bundle issues
const Video = require('react-native-video').default;
const [showControls, setShowControls] = useState(true);
const [paused, setPaused] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [buffering, setBuffering] = useState(true);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const resetHideTimer = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
}, []);
useEffect(() => {
resetHideTimer();
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
}, []);
const handleTap = useCallback(() => {
setShowControls(prev => {
if (!prev) { resetHideTimer(); return true; }
if (hideTimer.current) clearTimeout(hideTimer.current);
return false;
});
}, [resetHideTimer]);
const containerStyle = isFullscreen
? { width: screenH, height: screenW }
: { width: screenW, height: videoH };
const videoContent = (
<View style={[styles.container, containerStyle]}>
<Video
key={hlsUrl}
source={{ uri: hlsUrl, headers: HEADERS }}
style={StyleSheet.absoluteFill}
resizeMode="contain"
controls={false}
paused={paused}
onBuffer={({ isBuffering }: { isBuffering: boolean }) => setBuffering(isBuffering)}
onLoad={() => setBuffering(false)}
/>
{buffering && (
<View style={styles.bufferingOverlay} pointerEvents="none">
<Text style={styles.bufferingText}>...</Text>
</View>
)}
<TouchableWithoutFeedback onPress={handleTap}>
<View style={StyleSheet.absoluteFill} />
</TouchableWithoutFeedback>
{showControls && (
<>
{/* LIVE badge top-left */}
<View style={styles.liveBadge} pointerEvents="none">
<View style={styles.liveDot} />
<Text style={styles.liveText}>LIVE</Text>
</View>
{/* Center play/pause */}
<TouchableOpacity
style={styles.centerBtn}
onPress={() => { setPaused(p => !p); resetHideTimer(); }}
>
<View style={styles.centerBtnBg}>
<Ionicons name={paused ? 'play' : 'pause'} size={28} color="#fff" />
</View>
</TouchableOpacity>
{/* Bottom controls */}
<View style={styles.bottomBar}>
<TouchableOpacity
style={styles.ctrlBtn}
onPress={() => { setPaused(p => !p); resetHideTimer(); }}
>
<Ionicons name={paused ? 'play' : 'pause'} size={16} color="#fff" />
</TouchableOpacity>
<View style={{ flex: 1 }} />
<TouchableOpacity
style={styles.ctrlBtn}
onPress={() => setIsFullscreen(fs => !fs)}
>
<Ionicons name={isFullscreen ? 'contract' : 'expand'} size={16} color="#fff" />
</TouchableOpacity>
</View>
</>
)}
</View>
);
if (isFullscreen) {
return (
<Modal visible transparent animationType="fade" supportedOrientations={['landscape']}>
<View style={styles.fsModal}>{videoContent}</View>
</Modal>
);
}
return videoContent;
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#000',
alignItems: 'center',
justifyContent: 'center',
},
webHint: { color: '#fff', fontSize: 15 },
offlineText: { color: '#999', fontSize: 14, marginTop: 10 },
bufferingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
bufferingText: { color: '#fff', fontSize: 13, opacity: 0.8 },
liveBadge: {
position: 'absolute',
top: 10,
left: 12,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 4,
},
liveDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#f00',
marginRight: 5,
},
liveText: { color: '#fff', fontSize: 11, fontWeight: '700', letterSpacing: 1 },
centerBtn: {
position: 'absolute',
top: '50%',
left: '50%',
transform: [{ translateX: -28 }, { translateY: -28 }],
},
centerBtnBg: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(0,0,0,0.45)',
alignItems: 'center',
justifyContent: 'center',
},
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingBottom: 8,
paddingTop: 24,
backgroundColor: 'rgba(0,0,0,0)',
},
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
fsModal: {
flex: 1,
backgroundColor: '#000',
alignItems: 'center',
justifyContent: 'center',
},
});