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 = ( + +