mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-08 07:28:37 +08:00
1
This commit is contained in:
@@ -27,6 +27,14 @@ export default function RootLayout() {
|
|||||||
gestureDirection: "horizontal",
|
gestureDirection: "horizontal",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="live"
|
||||||
|
options={{
|
||||||
|
animation: "slide_from_right",
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: "horizontal",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<MiniPlayer />
|
<MiniPlayer />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
ViewToken,
|
ViewToken,
|
||||||
FlatList,
|
FlatList,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Linking,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import PagerView from "react-native-pager-view";
|
import PagerView from "react-native-pager-view";
|
||||||
import {
|
import {
|
||||||
@@ -214,9 +213,7 @@ export default function HomeScreen() {
|
|||||||
isLivePulse
|
isLivePulse
|
||||||
item={row.left}
|
item={row.left}
|
||||||
fullWidth
|
fullWidth
|
||||||
onPress={() =>
|
onPress={() => router.push(`/live/${row.left.roomid}` as any)}
|
||||||
Linking.openURL(`https://live.bilibili.com/${row.left.roomid}`)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -248,11 +245,17 @@ export default function HomeScreen() {
|
|||||||
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
|
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={styles.leftCol}>
|
<View style={styles.leftCol}>
|
||||||
<LiveCard item={item.left} />
|
<LiveCard
|
||||||
|
item={item.left}
|
||||||
|
onPress={() => router.push(`/live/${item.left.roomid}` as any)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
{item.right && (
|
{item.right && (
|
||||||
<View style={styles.rightCol}>
|
<View style={styles.rightCol}>
|
||||||
<LiveCard item={item.right} />
|
<LiveCard
|
||||||
|
item={item.right}
|
||||||
|
onPress={() => router.push(`/live/${item.right!.roomid}` as any)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
187
app/live/[roomId].tsx
Normal file
187
app/live/[roomId].tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Image,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useLiveDetail } from '../../hooks/useLiveDetail';
|
||||||
|
import { LivePlayer } from '../../components/LivePlayer';
|
||||||
|
import { formatCount } from '../../utils/format';
|
||||||
|
import { proxyImageUrl } from '../../utils/imageUrl';
|
||||||
|
|
||||||
|
export default function LiveDetailScreen() {
|
||||||
|
const { roomId } = useLocalSearchParams<{ roomId: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const id = parseInt(roomId ?? '0', 10);
|
||||||
|
const { room, anchor, stream, loading, error } = useLiveDetail(id);
|
||||||
|
|
||||||
|
const isLive = room?.live_status === 1;
|
||||||
|
const hlsUrl = stream?.hlsUrl ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safe}>
|
||||||
|
{/* TopBar */}
|
||||||
|
<View style={styles.topBar}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="#212121" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.topTitle} numberOfLines={1}>
|
||||||
|
{room?.title ?? '直播间'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Player */}
|
||||||
|
<LivePlayer hlsUrl={hlsUrl} isLive={isLive} />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||||
|
) : error ? (
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
) : room ? (
|
||||||
|
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||||
|
{/* Room title */}
|
||||||
|
<View style={styles.titleSection}>
|
||||||
|
<Text style={styles.title}>{room.title}</Text>
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
{/* Live status */}
|
||||||
|
{isLive ? (
|
||||||
|
<View style={styles.livePill}>
|
||||||
|
<View style={styles.liveDot} />
|
||||||
|
<Text style={styles.livePillText}>直播中</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.livePill, styles.offlinePill]}>
|
||||||
|
<Text style={[styles.livePillText, styles.offlinePillText]}>未开播</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* Online count */}
|
||||||
|
<View style={styles.onlineRow}>
|
||||||
|
<Ionicons name="eye-outline" size={13} color="#999" />
|
||||||
|
<Text style={styles.onlineText}>{formatCount(room.online)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{/* Area tags */}
|
||||||
|
<View style={styles.areaRow}>
|
||||||
|
{room.parent_area_name ? (
|
||||||
|
<Text style={styles.areaTag}>{room.parent_area_name}</Text>
|
||||||
|
) : null}
|
||||||
|
{room.area_name ? (
|
||||||
|
<Text style={styles.areaTag}>{room.area_name}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
{/* Anchor row */}
|
||||||
|
{anchor && (
|
||||||
|
<View style={styles.anchorRow}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: proxyImageUrl(anchor.face) }}
|
||||||
|
style={styles.avatar}
|
||||||
|
/>
|
||||||
|
<Text style={styles.anchorName}>{anchor.uname}</Text>
|
||||||
|
<TouchableOpacity style={styles.followBtn}>
|
||||||
|
<Text style={styles.followTxt}>+ 关注</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Room description */}
|
||||||
|
{!!room.description && (
|
||||||
|
<View style={styles.descBox}>
|
||||||
|
<Text style={styles.descText}>{room.description}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
) : null}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 4,
|
||||||
|
color: '#212121',
|
||||||
|
},
|
||||||
|
loader: { marginVertical: 30 },
|
||||||
|
errorText: { textAlign: 'center', color: '#f00', padding: 20 },
|
||||||
|
scroll: { flex: 1 },
|
||||||
|
titleSection: { padding: 14 },
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#212121',
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 },
|
||||||
|
livePill: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#fff0f0',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 4,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
offlinePill: { backgroundColor: '#f5f5f5' },
|
||||||
|
liveDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#f00' },
|
||||||
|
livePillText: { fontSize: 12, color: '#f00', fontWeight: '600' },
|
||||||
|
offlinePillText: { color: '#999' },
|
||||||
|
onlineRow: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
||||||
|
onlineText: { fontSize: 12, color: '#999' },
|
||||||
|
areaRow: { flexDirection: 'row', gap: 6 },
|
||||||
|
areaTag: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#00AEEC',
|
||||||
|
backgroundColor: '#e8f7fd',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
marginHorizontal: 14,
|
||||||
|
},
|
||||||
|
anchorRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
avatar: { width: 40, height: 40, borderRadius: 20, marginRight: 10 },
|
||||||
|
anchorName: { flex: 1, fontSize: 14, color: '#212121', fontWeight: '500' },
|
||||||
|
followBtn: {
|
||||||
|
backgroundColor: '#00AEEC',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 14,
|
||||||
|
},
|
||||||
|
followTxt: { color: '#fff', fontSize: 12, fontWeight: '600' },
|
||||||
|
descBox: { padding: 14, paddingTop: 4 },
|
||||||
|
descText: { fontSize: 14, color: '#555', lineHeight: 22 },
|
||||||
|
});
|
||||||
5
app/live/_layout.tsx
Normal file
5
app/live/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Slot } from 'expo-router';
|
||||||
|
|
||||||
|
export default function LiveLayout() {
|
||||||
|
return <Slot />;
|
||||||
|
}
|
||||||
232
components/LivePlayer.tsx
Normal file
232
components/LivePlayer.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -57,6 +57,7 @@ function makeProxy(targetHost) {
|
|||||||
|
|
||||||
app.use('/bilibili-api', makeProxy('api.bilibili.com'));
|
app.use('/bilibili-api', makeProxy('api.bilibili.com'));
|
||||||
app.use('/bilibili-passport', makeProxy('passport.bilibili.com'));
|
app.use('/bilibili-passport', makeProxy('passport.bilibili.com'));
|
||||||
|
app.use('/bilibili-live', makeProxy('api.live.bilibili.com'));
|
||||||
|
|
||||||
// Dedicated comment proxy: buffer response and decompress by magic bytes (not Content-Encoding header)
|
// Dedicated comment proxy: buffer response and decompress by magic bytes (not Content-Encoding header)
|
||||||
app.use('/bilibili-comment', (req, res) => {
|
app.use('/bilibili-comment', (req, res) => {
|
||||||
|
|||||||
54
hooks/useLiveDetail.ts
Normal file
54
hooks/useLiveDetail.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getLiveRoomDetail, getLiveAnchorInfo, getLiveStreamUrl } from '../services/bilibili';
|
||||||
|
import type { LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from '../services/types';
|
||||||
|
|
||||||
|
interface LiveDetailState {
|
||||||
|
room: LiveRoomDetail | null;
|
||||||
|
anchor: LiveAnchorInfo | null;
|
||||||
|
stream: LiveStreamInfo | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLiveDetail(roomId: number) {
|
||||||
|
const [state, setState] = useState<LiveDetailState>({
|
||||||
|
room: null,
|
||||||
|
anchor: null,
|
||||||
|
stream: null,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roomId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
setState({ room: null, anchor: null, stream: null, loading: true, error: null });
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
try {
|
||||||
|
const [room, anchor] = await Promise.all([
|
||||||
|
getLiveRoomDetail(roomId),
|
||||||
|
getLiveAnchorInfo(roomId),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0 };
|
||||||
|
if (room.live_status === 1) {
|
||||||
|
stream = await getLiveStreamUrl(roomId);
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setState({ room, anchor, stream, loading: false, error: null });
|
||||||
|
} catch (e: any) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState(prev => ({ ...prev, loading: false, error: e?.message ?? '加载失败' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
106
livePlan.md
Normal file
106
livePlan.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# B站直播详情页 实现计划
|
||||||
|
|
||||||
|
## Context
|
||||||
|
现有应用已有直播列表(LiveCard + useLiveList),但点击卡片只是用 `Linking.openURL` 跳转到外部浏览器,没有应用内直播详情页。本次目标是实现一个完整的 in-app 直播详情页,包括 HLS 播放器、主播信息、房间简介。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 直播详情页功能需求(MVP)
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 直播播放器 | HLS (m3u8) 流,`react-native-video` 播放,支持全屏 |
|
||||||
|
| 房间信息 | 标题、分类(父/子分区)、在线人数 |
|
||||||
|
| 主播信息 | 头像、昵称 |
|
||||||
|
| 关注按钮 | 展示样式,不调 API(bili_jct 不在当前登录流程中) |
|
||||||
|
| 离线状态 | 若 `live_status !== 1` 或无流 URL,显示"暂未开播" |
|
||||||
|
|
||||||
|
**跳过(Post-MVP):** 实时弹幕WebSocket、质量切换、送礼、直播间聊天
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bilibili 直播 API
|
||||||
|
|
||||||
|
| 函数 | 接口 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `getLiveRoomDetail(roomId)` | `LIVE_BASE/room/v1/Room/get_info` | 房间详情(标题、分区、live_status、在线人数) |
|
||||||
|
| `getLiveAnchorInfo(roomId)` | `LIVE_BASE/live_user/v1/UserInfo/get_anchor_in_room` | 主播信息(uid、uname、face) |
|
||||||
|
| `getLiveStreamUrl(roomId)` | `LIVE_BASE/xlive/web-room/v2/index/getRoomPlayInfo` | 获取 HLS/FLV 流 URL |
|
||||||
|
|
||||||
|
**流 URL 提取路径:**
|
||||||
|
```
|
||||||
|
data.playurl_info.playurl.stream[]
|
||||||
|
→ protocol_name === 'http_hls'
|
||||||
|
→ format[].format_name === 'fmp4' or 'ts'
|
||||||
|
→ codec[].codec_name === 'avc'
|
||||||
|
→ url_info[0].host + codec.base_url
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件变更清单(按依赖顺序)
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
1. `livePlan.md` — 本文件
|
||||||
|
2. `services/types.ts` — 添加 `LiveRoomDetail`、`LiveAnchorInfo`、`LiveStreamInfo` 类型
|
||||||
|
3. `services/bilibili.ts` — 添加 `getLiveRoomDetail`、`getLiveAnchorInfo`、`getLiveStreamUrl`
|
||||||
|
4. `hooks/useLiveDetail.ts` — 直播详情 hook(并行拉取房间信息+主播信息)
|
||||||
|
5. `components/LivePlayer.tsx` — 直播播放器(web 提示 / native 用 react-native-video HLS)
|
||||||
|
6. `app/live/_layout.tsx` — Stack layout(同 `app/video/_layout.tsx`)
|
||||||
|
7. `app/live/[roomId].tsx` — 直播详情页主文件
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
8. `dev-proxy.js` — 添加 `/bilibili-live` 代理路由(修复 web 端直播 API)
|
||||||
|
9. `app/_layout.tsx` — 注册 `live` Stack.Screen(动画同 video)
|
||||||
|
10. `app/index.tsx` — 替换 `Linking.openURL` 为 `router.push('/live/...')`,给直播 tab 的卡片加 `onPress`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 组件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/live/[roomId].tsx
|
||||||
|
TopBar (返回 + 标题)
|
||||||
|
LivePlayer
|
||||||
|
├── web: "请在手机端观看直播"
|
||||||
|
└── native: <Video source={{uri: hlsUrl}} /> + 简单控制栏 (LIVE 标牌 / 全屏)
|
||||||
|
ScrollView
|
||||||
|
房间标题
|
||||||
|
在线人数 + LivePulse
|
||||||
|
分区标签 (parent_area_name > area_name)
|
||||||
|
分割线
|
||||||
|
主播行 (头像 + uname + 关注按钮)
|
||||||
|
房间描述文本
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
LiveCard onPress → router.push('/live/[roomId]')
|
||||||
|
useLiveDetail(roomId)
|
||||||
|
Promise.all([getLiveRoomDetail, getLiveAnchorInfo])
|
||||||
|
if live_status===1 → getLiveStreamUrl → hlsUrl
|
||||||
|
→ LivePlayer <Video source={{uri: hlsUrl}} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **`roomId` 类型**:`useLocalSearchParams` 返回 string,需 `parseInt(roomId, 10)`
|
||||||
|
2. **router.push 类型**:使用 `as any` 规避 TypeScript 动态路由报错
|
||||||
|
3. **流 URL 提取**:用可选链 (`?.`) 兜底,失败时返回 `{ hlsUrl: '', flvUrl: '', qn: 0 }`,让 LivePlayer 显示离线状态
|
||||||
|
4. **live 无 onEnd**:直播流没有结束,不处理 `onEnd` 事件
|
||||||
|
5. **web 端 HLS**:`<video>` 标签仅 Safari 原生支持 m3u8,Chrome 不支持;web 端显示提示文字即可
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
1. `expo start` → 扫码打开 → 首页直播 tab → 点击直播卡片 → 进入详情页
|
||||||
|
2. 验证:播放器加载 → 视频流开始缓冲 → 显示主播信息与房间标题
|
||||||
|
3. 验证:点击全屏 → 横屏全屏播放
|
||||||
|
4. 验证:关闭直播间(或选一个离线房间)→ 显示"暂未开播"占位图
|
||||||
|
5. web 端:`expo start --web` → 直播详情页显示"请在手机端观看直播"
|
||||||
@@ -2,7 +2,7 @@ import axios from 'axios';
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import pako from 'pako';
|
import pako from 'pako';
|
||||||
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom } from './types';
|
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom, LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from './types';
|
||||||
import { signWbi } from '../utils/wbi';
|
import { signWbi } from '../utils/wbi';
|
||||||
import { parseDanmakuXml } from '../utils/danmaku';
|
import { parseDanmakuXml } from '../utils/danmaku';
|
||||||
|
|
||||||
@@ -244,6 +244,58 @@ export async function getLiveList(page = 1, parentAreaId = 0): Promise<LiveRoom[
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getLiveRoomDetail(roomId: number): Promise<LiveRoomDetail> {
|
||||||
|
const res = await api.get(`${LIVE_BASE}/room/v1/Room/get_info`, {
|
||||||
|
params: { room_id: roomId },
|
||||||
|
});
|
||||||
|
return res.data.data as LiveRoomDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLiveAnchorInfo(roomId: number): Promise<LiveAnchorInfo> {
|
||||||
|
const res = await api.get(`${LIVE_BASE}/live_user/v1/UserInfo/get_anchor_in_room`, {
|
||||||
|
params: { roomid: roomId },
|
||||||
|
});
|
||||||
|
const info = res.data.data?.info ?? {};
|
||||||
|
return { uid: info.uid, uname: info.uname, face: info.face } as LiveAnchorInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLiveStreamUrl(roomId: number): Promise<LiveStreamInfo> {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`${LIVE_BASE}/xlive/web-room/v2/index/getRoomPlayInfo`, {
|
||||||
|
params: { room_id: roomId, protocol: '0,1', format: '0,1,2', codec: '0', qn: 10000 },
|
||||||
|
});
|
||||||
|
const streams: any[] = res.data?.data?.playurl_info?.playurl?.stream ?? [];
|
||||||
|
let hlsUrl = '';
|
||||||
|
let flvUrl = '';
|
||||||
|
let qn = 0;
|
||||||
|
|
||||||
|
const hlsStream = streams.find(s => s.protocol_name === 'http_hls');
|
||||||
|
if (hlsStream) {
|
||||||
|
const fmt = hlsStream.format?.find((f: any) => f.format_name === 'fmp4') ?? hlsStream.format?.[0];
|
||||||
|
const codec = fmt?.codec?.find((c: any) => c.codec_name === 'avc') ?? fmt?.codec?.[0];
|
||||||
|
const urlInfo = codec?.url_info?.[0];
|
||||||
|
if (urlInfo) {
|
||||||
|
hlsUrl = urlInfo.host + codec.base_url;
|
||||||
|
qn = codec.current_qn ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flvStream = streams.find(s => s.protocol_name === 'http_stream');
|
||||||
|
if (flvStream) {
|
||||||
|
const fmt = flvStream.format?.[0];
|
||||||
|
const codec = fmt?.codec?.find((c: any) => c.codec_name === 'avc') ?? fmt?.codec?.[0];
|
||||||
|
const urlInfo = codec?.url_info?.[0];
|
||||||
|
if (urlInfo) {
|
||||||
|
flvUrl = urlInfo.host + codec.base_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hlsUrl, flvUrl, qn };
|
||||||
|
} catch {
|
||||||
|
return { hlsUrl: '', flvUrl: '', qn: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
||||||
try {
|
try {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
|
|||||||
@@ -130,3 +130,27 @@ export interface LiveRoom {
|
|||||||
area_name: string;
|
area_name: string;
|
||||||
parent_area_name: string;
|
parent_area_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LiveRoomDetail {
|
||||||
|
roomid: number;
|
||||||
|
uid: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
live_status: number; // 1=直播中, 0=未开播
|
||||||
|
online: number;
|
||||||
|
area_name: string;
|
||||||
|
parent_area_name: string;
|
||||||
|
keyframe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveAnchorInfo {
|
||||||
|
uid: number;
|
||||||
|
uname: string;
|
||||||
|
face: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveStreamInfo {
|
||||||
|
hlsUrl: string;
|
||||||
|
flvUrl: string;
|
||||||
|
qn: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user