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

View File

@@ -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>

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
import { Slot } from 'expo-router';
export default function LiveLayout() {
return <Slot />;
}

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',
},
});

View File

@@ -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
View 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
View File

@@ -0,0 +1,106 @@
# B站直播详情页 实现计划
## Context
现有应用已有直播列表LiveCard + useLiveList但点击卡片只是用 `Linking.openURL` 跳转到外部浏览器,没有应用内直播详情页。本次目标是实现一个完整的 in-app 直播详情页,包括 HLS 播放器、主播信息、房间简介。
---
## 直播详情页功能需求MVP
| 功能 | 说明 |
|---|---|
| 直播播放器 | HLS (m3u8) 流,`react-native-video` 播放,支持全屏 |
| 房间信息 | 标题、分类(父/子分区)、在线人数 |
| 主播信息 | 头像、昵称 |
| 关注按钮 | 展示样式,不调 APIbili_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 原生支持 m3u8Chrome 不支持web 端显示提示文字即可
---
## 验证方式
1. `expo start` → 扫码打开 → 首页直播 tab → 点击直播卡片 → 进入详情页
2. 验证:播放器加载 → 视频流开始缓冲 → 显示主播信息与房间标题
3. 验证:点击全屏 → 横屏全屏播放
4. 验证:关闭直播间(或选一个离线房间)→ 显示"暂未开播"占位图
5. web 端:`expo start --web` → 直播详情页显示"请在手机端观看直播"

View File

@@ -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) {

View File

@@ -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;
}