mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
1
This commit is contained in:
@@ -27,6 +27,14 @@ export default function RootLayout() {
|
||||
gestureDirection: "horizontal",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="live"
|
||||
options={{
|
||||
animation: "slide_from_right",
|
||||
gestureEnabled: true,
|
||||
gestureDirection: "horizontal",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<MiniPlayer />
|
||||
</View>
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
ViewToken,
|
||||
FlatList,
|
||||
ScrollView,
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import PagerView from "react-native-pager-view";
|
||||
import {
|
||||
@@ -214,9 +213,7 @@ export default function HomeScreen() {
|
||||
isLivePulse
|
||||
item={row.left}
|
||||
fullWidth
|
||||
onPress={() =>
|
||||
Linking.openURL(`https://live.bilibili.com/${row.left.roomid}`)
|
||||
}
|
||||
onPress={() => router.push(`/live/${row.left.roomid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -248,11 +245,17 @@ export default function HomeScreen() {
|
||||
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.leftCol}>
|
||||
<LiveCard item={item.left} />
|
||||
<LiveCard
|
||||
item={item.left}
|
||||
onPress={() => router.push(`/live/${item.left.roomid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
{item.right && (
|
||||
<View style={styles.rightCol}>
|
||||
<LiveCard item={item.right} />
|
||||
<LiveCard
|
||||
item={item.right}
|
||||
onPress={() => router.push(`/live/${item.right!.roomid}` as any)}
|
||||
/>
|
||||
</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-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)
|
||||
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 { Platform } from 'react-native';
|
||||
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 { 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[]> {
|
||||
try {
|
||||
if (isWeb) {
|
||||
|
||||
@@ -130,3 +130,27 @@ export interface LiveRoom {
|
||||
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