From 2e478716898d46efc6fae55bddf19c52c439adad Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 17 Mar 2026 17:26:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=92=AD=E6=94=BE=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/live/[roomId].tsx | 195 +++++++++++++++--------- components/DanmakuList.tsx | 254 +++++++++++++++++++++---------- components/LivePlayer.tsx | 19 ++- components/NativeVideoPlayer.tsx | 21 ++- components/VideoPlayer.tsx | 36 +++-- hooks/useLiveDanmaku.ts | 20 ++- services/bilibili.ts | 4 +- services/types.ts | 5 + 8 files changed, 364 insertions(+), 190 deletions(-) diff --git a/app/live/[roomId].tsx b/app/live/[roomId].tsx index b5df9a1..0b90f1f 100644 --- a/app/live/[roomId].tsx +++ b/app/live/[roomId].tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState } from "react"; import { View, Text, @@ -7,26 +7,29 @@ import { 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 { useLiveDanmaku } from '../../hooks/useLiveDanmaku'; -import { LivePlayer } from '../../components/LivePlayer'; -import DanmakuList from '../../components/DanmakuList'; -import { formatCount } from '../../utils/format'; -import { proxyImageUrl } from '../../utils/imageUrl'; +} 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 { useLiveDanmaku } from "../../hooks/useLiveDanmaku"; +import { LivePlayer } from "../../components/LivePlayer"; +import DanmakuList from "../../components/DanmakuList"; +import { formatCount } from "../../utils/format"; +import { proxyImageUrl } from "../../utils/imageUrl"; + +type Tab = "intro" | "danmaku"; export default function LiveDetailScreen() { const { roomId } = useLocalSearchParams<{ roomId: string }>(); const router = useRouter(); - const id = parseInt(roomId ?? '0', 10); - const { room, anchor, stream, loading, error, changeQuality } = useLiveDetail(id); - const [danmakuVisible, setDanmakuVisible] = useState(true); + const id = parseInt(roomId ?? "0", 10); + const { room, anchor, stream, loading, error, changeQuality } = + useLiveDetail(id); + const [tab, setTab] = useState("intro"); const isLive = room?.live_status === 1; - const hlsUrl = stream?.hlsUrl ?? ''; + const hlsUrl = stream?.hlsUrl ?? ""; const qualities = stream?.qualities ?? []; const currentQn = stream?.qn ?? 0; @@ -41,7 +44,7 @@ export default function LiveDetailScreen() { - {room?.title ?? '直播间'} + {room?.title ?? "直播间"} @@ -54,19 +57,44 @@ export default function LiveDetailScreen() { onQualityChange={changeQuality} /> - {/* Content */} + {/* TabBar */} + + setTab("intro")} + > + + 简介 + + {tab === "intro" && } + + setTab("danmaku")} + > + + 弹幕{danmakus.length > 0 ? ` ${danmakus.length}` : ""} + + {tab === "danmaku" && } + + + + {/* Content — 两个面板始终挂载,通过 display 切换,保留弹幕列表状态 */} {loading ? ( ) : error ? ( {error} - ) : room ? ( - - - {/* Room title */} + ) : ( + <> + - {room.title} + {room?.title} - {/* Live status */} {isLive ? ( @@ -74,30 +102,30 @@ export default function LiveDetailScreen() { ) : ( - 未开播 + + 未开播 + )} - {/* Online count */} - {formatCount(room.online)} + + {formatCount(room?.online ?? 0)} + - {/* Area tags */} - {room.parent_area_name ? ( + {room?.parent_area_name ? ( {room.parent_area_name} ) : null} - {room.area_name ? ( + {room?.area_name ? ( {room.area_name} ) : null} - {/* Divider */} - {/* Anchor row */} {anchor && ( )} - {/* Room description */} - {!!room.description && ( + {!!room?.description && ( {room.description} )} - {/* Live danmaku list */} setDanmakuVisible(v => !v)} - style={styles.danmakuList} + visible + onToggle={() => {}} + style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]} + hideHeader + maxItems={500} /> - - ) : null} + + )} ); } const styles = StyleSheet.create({ - safe: { flex: 1, backgroundColor: '#fff' }, + safe: { flex: 1, backgroundColor: "#fff" }, topBar: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", paddingHorizontal: 8, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#eee', + borderBottomColor: "#eee", }, backBtn: { padding: 4 }, topTitle: { flex: 1, fontSize: 15, - fontWeight: '600', + fontWeight: "600", marginLeft: 4, - color: '#212121', + color: "#212121", }, loader: { marginVertical: 30 }, - errorText: { textAlign: 'center', color: '#f00', padding: 20 }, - content: { flex: 1 }, + errorText: { textAlign: "center", color: "#f00", padding: 20 }, + tabBar: { + flexDirection: "row", + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#eee", + backgroundColor: "#fff", + }, + tabItem: { + paddingHorizontal: 20, + paddingVertical: 10, + alignItems: "center", + position: "relative", + }, + tabLabel: { fontSize: 14, color: "#999", fontWeight: "500" }, + tabActive: { color: "#00AEEC", fontWeight: "700" }, + tabUnderline: { + position: "absolute", + bottom: 0, + width: 24, + height: 2, + backgroundColor: "#00AEEC", + borderRadius: 2, + }, scroll: { flex: 1 }, titleSection: { padding: 14 }, title: { fontSize: 16, - fontWeight: '600', - color: '#212121', + fontWeight: "600", + color: "#212121", lineHeight: 22, marginBottom: 8, }, - metaRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }, + metaRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + marginBottom: 8, + }, livePill: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#fff0f0', + 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 }, + 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', + color: "#00AEEC", + backgroundColor: "#e8f7fd", paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, }, divider: { height: StyleSheet.hairlineWidth, - backgroundColor: '#f0f0f0', + backgroundColor: "#f0f0f0", marginHorizontal: 14, }, anchorRow: { - flexDirection: 'row', - alignItems: 'center', + 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' }, + anchorName: { flex: 1, fontSize: 14, color: "#212121", fontWeight: "500" }, followBtn: { - backgroundColor: '#00AEEC', + backgroundColor: "#00AEEC", paddingHorizontal: 14, paddingVertical: 5, borderRadius: 14, }, - followTxt: { color: '#fff', fontSize: 12, fontWeight: '600' }, + followTxt: { color: "#fff", fontSize: 12, fontWeight: "600" }, descBox: { padding: 14, paddingTop: 4 }, - descText: { fontSize: 14, color: '#555', lineHeight: 22 }, - danmakuList: { maxHeight: 220 }, + descText: { fontSize: 14, color: "#555", lineHeight: 22 }, + danmakuFull: { flex: 1 }, + hidden: { display: "none" }, }); diff --git a/components/DanmakuList.tsx b/components/DanmakuList.tsx index 91b6c34..e68a525 100644 --- a/components/DanmakuList.tsx +++ b/components/DanmakuList.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react'; +import React, { useRef, useState, useEffect, useCallback } from "react"; import { View, Text, @@ -8,17 +8,19 @@ import { Animated, NativeSyntheticEvent, NativeScrollEvent, -} from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { DanmakuItem } from '../services/types'; -import { danmakuColorToCss } from '../utils/danmaku'; +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { DanmakuItem } from "../services/types"; +import { danmakuColorToCss } from "../utils/danmaku"; interface Props { danmakus: DanmakuItem[]; currentTime: number; visible: boolean; onToggle: () => void; - style?: object; + style?: object | object[]; + hideHeader?: boolean; + maxItems?: number; } interface DisplayedDanmaku extends DanmakuItem { @@ -35,10 +37,18 @@ const SEEK_THRESHOLD = 2; function formatTimestamp(seconds: number): string { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, '0')}`; + return `${m}:${s.toString().padStart(2, "0")}`; } -export default function DanmakuList({ danmakus, currentTime, visible, onToggle, style }: Props) { +export default function DanmakuList({ + danmakus, + currentTime, + visible, + onToggle, + style, + hideHeader, + maxItems = 100, +}: Props) { const flatListRef = useRef(null); const [displayedItems, setDisplayedItems] = useState([]); const [unseenCount, setUnseenCount] = useState(0); @@ -79,12 +89,12 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle, isAtBottomRef.current = true; // Re-enqueue danmakus up to current time - const catchUp = danmakus.filter(d => d.time <= currentTime); + const catchUp = danmakus.filter((d) => d.time <= currentTime); // Only enqueue recent ones to avoid flooding const tail = catchUp.slice(-20); queueRef.current = tail; processedIndexRef.current = danmakus.findIndex( - d => d.time > currentTime + (d) => d.time > currentTime, ); if (processedIndexRef.current === -1) { processedIndexRef.current = danmakus.length; @@ -106,37 +116,45 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle, useEffect(() => { if (!visible) return; - const id = setInterval(() => { - if (queueRef.current.length === 0) return; + const id = setInterval( + () => { + if (queueRef.current.length === 0) return; - const item = queueRef.current.shift()!; - const fadeAnim = new Animated.Value(0); - const displayed: DisplayedDanmaku = { - ...item, - _key: keyCounterRef.current++, - _fadeAnim: fadeAnim, - }; + const item = queueRef.current.shift()!; + const fadeAnim = new Animated.Value(0); + const displayed: DisplayedDanmaku = { + ...item, + _key: keyCounterRef.current++, + _fadeAnim: fadeAnim, + }; - Animated.timing(fadeAnim, { - toValue: 1, - duration: 200, - useNativeDriver: true, - }).start(); + Animated.timing(fadeAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }).start(); - setDisplayedItems(prev => { - const next = [...prev, displayed]; - return next.length > MAX_DISPLAYED ? next.slice(-MAX_DISPLAYED) : next; - }); - - if (isAtBottomRef.current) { - // Auto-scroll on next frame - requestAnimationFrame(() => { - flatListRef.current?.scrollToEnd({ animated: true }); + setDisplayedItems((prev) => { + const next = [...prev, displayed]; + // 超出上限时批量裁剪到一半,减少频繁截断导致的抖动 + return next.length > maxItems + ? next.slice(-Math.floor(maxItems / 2)) + : next; }); - } else { - setUnseenCount(c => c + 1); - } - }, queueRef.current.length > QUEUE_FAST_THRESHOLD ? FAST_DRIP_INTERVAL : DRIP_INTERVAL); + + if (isAtBottomRef.current) { + // Auto-scroll on next frame + requestAnimationFrame(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }); + } else { + setUnseenCount((c) => c + 1); + } + }, + queueRef.current.length > QUEUE_FAST_THRESHOLD + ? FAST_DRIP_INTERVAL + : DRIP_INTERVAL, + ); return () => clearInterval(id); }, [visible]); @@ -151,7 +169,7 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle, setUnseenCount(0); } }, - [] + [], ); const handleScrollBeginDrag = useCallback(() => { @@ -168,34 +186,79 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle, const dotColor = danmakuColorToCss(item.color); return ( - - - {item.text} - - {formatTimestamp(item.time)} + {!hideHeader && ( + + )} + + {item.uname && ( + + {item.isAdmin && ( + + 房管 + + )} + {item.guardLevel === 1 && ( + + 总督 + + )} + {item.guardLevel === 2 && ( + + 提督 + + )} + {item.guardLevel === 3 && ( + + 舰长 + + )} + {item.medalLevel != null && ( + + {item.medalName} {item.medalLevel} + + )} + {item.uname} + + )} + + {item.text} + + + {!hideHeader && ( + {formatTimestamp(item.time)} + )} ); - }, []); + }, [hideHeader]); - const keyExtractor = useCallback((item: DisplayedDanmaku) => String(item._key), []); + const keyExtractor = useCallback( + (item: DisplayedDanmaku) => String(item._key), + [], + ); return ( - - - - 弹幕 {danmakus.length > 0 ? `(${danmakus.length})` : ''} - - - + {!hideHeader && ( + + + + 弹幕 {danmakus.length > 0 ? `(${danmakus.length})` : ""} + + + + )} {visible && ( @@ -212,7 +275,7 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle, removeClippedSubviews={true} ListEmptyComponent={ - {danmakus.length === 0 ? '暂无弹幕' : '弹幕将随视频播放显示'} + {danmakus.length === 0 ? "暂无弹幕" : "弹幕将随视频播放显示"} } /> @@ -233,13 +296,13 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle, const styles = StyleSheet.create({ container: { - backgroundColor: '#fff', + backgroundColor: "#fff", borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: '#eee', + borderTopColor: "#eee", }, header: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", paddingHorizontal: 12, paddingVertical: 8, gap: 6, @@ -247,25 +310,25 @@ const styles = StyleSheet.create({ headerText: { flex: 1, fontSize: 13, - color: '#212121', - fontWeight: '500', + color: "#212121", + fontWeight: "500", }, listWrapper: { flex: 1, - position: 'relative', + position: "relative", }, list: { flex: 1, - backgroundColor: '#fafafa', + backgroundColor: "#fafafa", }, listContent: { paddingHorizontal: 8, paddingVertical: 4, }, bubble: { - flexDirection: 'row', - alignItems: 'flex-start', - backgroundColor: '#f8f8f8', + flexDirection: "row", + alignItems: "flex-start", + backgroundColor: "#f8f8f8", borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, @@ -279,36 +342,63 @@ const styles = StyleSheet.create({ marginTop: 6, flexShrink: 0, }, - bubbleText: { + bubbleContent: { flex: 1, + }, + userRow: { + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", + gap: 4, + marginBottom: 2, + }, + badge: { + borderRadius: 3, + paddingHorizontal: 4, + paddingVertical: 1, + }, + badgeAdmin: { backgroundColor: "#e53935" }, + badgeGuard1: { backgroundColor: "#9c27b0" }, + badgeGuard2: { backgroundColor: "#1565c0" }, + badgeGuard3: { backgroundColor: "#1976d2" }, + badgeText: { color: "#fff", fontSize: 10, fontWeight: "600" }, + medal: { + borderRadius: 3, + paddingHorizontal: 5, + paddingVertical: 1, + backgroundColor: "#ff6d9d", + }, + medalText: { color: "#fff", fontSize: 10, fontWeight: "600" }, + uname: { fontSize: 11, color: "#999" }, + bubbleText: { fontSize: 13, - color: '#333', + color: "#333", lineHeight: 18, }, timestamp: { fontSize: 11, - color: '#bbb', + color: "#bbb", marginTop: 1, flexShrink: 0, }, pill: { - position: 'absolute', + position: "absolute", bottom: 8, - alignSelf: 'center', - backgroundColor: '#00AEEC', + alignSelf: "center", + backgroundColor: "#00AEEC", borderRadius: 16, paddingHorizontal: 14, paddingVertical: 6, }, pillText: { fontSize: 12, - color: '#fff', - fontWeight: '600', + color: "#fff", + fontWeight: "600", }, empty: { fontSize: 12, - color: '#999', - textAlign: 'center', + color: "#999", + textAlign: "center", paddingVertical: 20, }, }); diff --git a/components/LivePlayer.tsx b/components/LivePlayer.tsx index 8860d4b..29a1a45 100644 --- a/components/LivePlayer.tsx +++ b/components/LivePlayer.tsx @@ -86,6 +86,8 @@ function NativeLivePlayer({ const [buffering, setBuffering] = useState(true); const [showQualityPanel, setShowQualityPanel] = useState(false); const hideTimer = useRef | null>(null); + const videoRef = useRef(null); + const currentTimeRef = useRef(0); const resetHideTimer = useCallback(() => { if (hideTimer.current) clearTimeout(hideTimer.current); @@ -137,8 +139,10 @@ function NativeLivePlayer({ }); }, [resetHideTimer]); + const fsW = Math.max(screenW, screenH); + const fsH = Math.min(screenW, screenH); const containerStyle = isFullscreen - ? { width: screenH, height: screenW } + ? { width: fsW, height: fsH } : { width: screenW, height: videoH }; const currentQnDesc = qualities.find(q => q.qn === currentQn)?.desc ?? ''; @@ -146,14 +150,21 @@ function NativeLivePlayer({ const videoContent = ( ); -} +}); const styles = StyleSheet.create({ container: { backgroundColor: "#000" }, diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 60ad1f9..19b8e29 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -3,7 +3,7 @@ import { View, StyleSheet, Text, Platform, Modal, StatusBar, useWindowDimensions // expo-screen-orientation requires a dev build; gracefully degrade in Expo Go let ScreenOrientation: typeof import('expo-screen-orientation') | null = null; try { ScreenOrientation = require('expo-screen-orientation'); } catch {} -import { NativeVideoPlayer } from './NativeVideoPlayer'; +import { NativeVideoPlayer, type NativeVideoPlayerRef } from './NativeVideoPlayer'; import type { PlayUrlResponse, DanmakuItem } from '../services/types'; interface Props { @@ -25,6 +25,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o // In Expo Go ScreenOrientation is unavailable; simulate landscape via CSS transform const needsRotation = !ScreenOrientation && fullscreen; const lastTimeRef = useRef(0); + const portraitRef = useRef(null); const handleEnterFullscreen = async () => { if (Platform.OS !== 'web') @@ -33,6 +34,8 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o }; const handleExitFullscreen = async () => { + // Seek portrait player to current position before it becomes visible again + portraitRef.current?.seek(lastTimeRef.current); setFullscreen(false); if (Platform.OS !== 'web') await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); @@ -69,21 +72,22 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o return ( <> - {/* Portrait player: unmount when fullscreen */} - {!fullscreen && ( - { lastTimeRef.current = t; onTimeUpdate?.(t); }} - /> - )} + {/* Portrait player: always mounted, force-paused while fullscreen is active */} + { lastTimeRef.current = t; onTimeUpdate?.(t); }} + />