diff --git a/app/_layout.tsx b/app/_layout.tsx
index e3a5715..6e525cc 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -27,6 +27,14 @@ export default function RootLayout() {
gestureDirection: "horizontal",
}}
/>
+
diff --git a/app/index.tsx b/app/index.tsx
index 31ac6b2..c6da0b3 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -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)}
/>
);
@@ -248,11 +245,17 @@ export default function HomeScreen() {
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
-
+ router.push(`/live/${item.left.roomid}` as any)}
+ />
{item.right && (
-
+ router.push(`/live/${item.right!.roomid}` as any)}
+ />
)}
diff --git a/app/live/[roomId].tsx b/app/live/[roomId].tsx
new file mode 100644
index 0000000..6251e33
--- /dev/null
+++ b/app/live/[roomId].tsx
@@ -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 (
+
+ {/* TopBar */}
+
+ router.back()} style={styles.backBtn}>
+
+
+
+ {room?.title ?? '直播间'}
+
+
+
+ {/* Player */}
+
+
+ {/* Content */}
+ {loading ? (
+
+ ) : error ? (
+ {error}
+ ) : room ? (
+
+ {/* Room title */}
+
+ {room.title}
+
+ {/* Live status */}
+ {isLive ? (
+
+
+ 直播中
+
+ ) : (
+
+ 未开播
+
+ )}
+ {/* Online count */}
+
+
+ {formatCount(room.online)}
+
+
+ {/* Area tags */}
+
+ {room.parent_area_name ? (
+ {room.parent_area_name}
+ ) : null}
+ {room.area_name ? (
+ {room.area_name}
+ ) : null}
+
+
+
+ {/* Divider */}
+
+
+ {/* Anchor row */}
+ {anchor && (
+
+
+ {anchor.uname}
+
+ + 关注
+
+
+ )}
+
+ {/* Room description */}
+ {!!room.description && (
+
+ {room.description}
+
+ )}
+
+ ) : null}
+
+ );
+}
+
+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 },
+});
diff --git a/app/live/_layout.tsx b/app/live/_layout.tsx
new file mode 100644
index 0000000..7f4479d
--- /dev/null
+++ b/app/live/_layout.tsx
@@ -0,0 +1,5 @@
+import { Slot } from 'expo-router';
+
+export default function LiveLayout() {
+ return ;
+}
diff --git a/components/LivePlayer.tsx b/components/LivePlayer.tsx
new file mode 100644
index 0000000..19f52f8
--- /dev/null
+++ b/components/LivePlayer.tsx
@@ -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 (
+
+ 请在手机端观看直播
+
+ );
+ }
+
+ if (!isLive || !hlsUrl) {
+ return (
+
+
+ 暂未开播
+
+ );
+ }
+
+ return ;
+}
+
+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 | 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 = (
+
+
+ );
+
+ if (isFullscreen) {
+ return (
+
+ {videoContent}
+
+ );
+ }
+
+ 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',
+ },
+});
diff --git a/dev-proxy.js b/dev-proxy.js
index c98a6f7..a8819e5 100644
--- a/dev-proxy.js
+++ b/dev-proxy.js
@@ -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) => {
diff --git a/hooks/useLiveDetail.ts b/hooks/useLiveDetail.ts
new file mode 100644
index 0000000..d81ba65
--- /dev/null
+++ b/hooks/useLiveDetail.ts
@@ -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({
+ 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;
+}
diff --git a/livePlan.md b/livePlan.md
new file mode 100644
index 0000000..5bb9e42
--- /dev/null
+++ b/livePlan.md
@@ -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: + 简单控制栏 (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
+```
+
+---
+
+## 注意事项
+
+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**:`