播放状态

This commit is contained in:
Developer
2026-03-17 17:26:43 +08:00
parent 6275fd0930
commit 2e47871689
8 changed files with 364 additions and 190 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { import {
View, View,
Text, Text,
@@ -7,26 +7,29 @@ import {
TouchableOpacity, TouchableOpacity,
Image, Image,
ActivityIndicator, ActivityIndicator,
} from 'react-native'; } from "react-native";
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from "expo-router";
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from "@expo/vector-icons";
import { useLiveDetail } from '../../hooks/useLiveDetail'; import { useLiveDetail } from "../../hooks/useLiveDetail";
import { useLiveDanmaku } from '../../hooks/useLiveDanmaku'; import { useLiveDanmaku } from "../../hooks/useLiveDanmaku";
import { LivePlayer } from '../../components/LivePlayer'; import { LivePlayer } from "../../components/LivePlayer";
import DanmakuList from '../../components/DanmakuList'; import DanmakuList from "../../components/DanmakuList";
import { formatCount } from '../../utils/format'; import { formatCount } from "../../utils/format";
import { proxyImageUrl } from '../../utils/imageUrl'; import { proxyImageUrl } from "../../utils/imageUrl";
type Tab = "intro" | "danmaku";
export default function LiveDetailScreen() { export default function LiveDetailScreen() {
const { roomId } = useLocalSearchParams<{ roomId: string }>(); const { roomId } = useLocalSearchParams<{ roomId: string }>();
const router = useRouter(); const router = useRouter();
const id = parseInt(roomId ?? '0', 10); const id = parseInt(roomId ?? "0", 10);
const { room, anchor, stream, loading, error, changeQuality } = useLiveDetail(id); const { room, anchor, stream, loading, error, changeQuality } =
const [danmakuVisible, setDanmakuVisible] = useState(true); useLiveDetail(id);
const [tab, setTab] = useState<Tab>("intro");
const isLive = room?.live_status === 1; const isLive = room?.live_status === 1;
const hlsUrl = stream?.hlsUrl ?? ''; const hlsUrl = stream?.hlsUrl ?? "";
const qualities = stream?.qualities ?? []; const qualities = stream?.qualities ?? [];
const currentQn = stream?.qn ?? 0; const currentQn = stream?.qn ?? 0;
@@ -41,7 +44,7 @@ export default function LiveDetailScreen() {
<Ionicons name="chevron-back" size={24} color="#212121" /> <Ionicons name="chevron-back" size={24} color="#212121" />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.topTitle} numberOfLines={1}> <Text style={styles.topTitle} numberOfLines={1}>
{room?.title ?? '直播间'} {room?.title ?? "直播间"}
</Text> </Text>
</View> </View>
@@ -54,19 +57,44 @@ export default function LiveDetailScreen() {
onQualityChange={changeQuality} onQualityChange={changeQuality}
/> />
{/* Content */} {/* TabBar */}
<View style={styles.tabBar}>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("intro")}
>
<Text style={[styles.tabLabel, tab === "intro" && styles.tabActive]}>
</Text>
{tab === "intro" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("danmaku")}
>
<Text
style={[styles.tabLabel, tab === "danmaku" && styles.tabActive]}
>
{danmakus.length > 0 ? ` ${danmakus.length}` : ""}
</Text>
{tab === "danmaku" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
</View>
{/* Content — 两个面板始终挂载,通过 display 切换,保留弹幕列表状态 */}
{loading ? ( {loading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" /> <ActivityIndicator style={styles.loader} color="#00AEEC" />
) : error ? ( ) : error ? (
<Text style={styles.errorText}>{error}</Text> <Text style={styles.errorText}>{error}</Text>
) : room ? ( ) : (
<View style={styles.content}> <>
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}> <ScrollView
{/* Room title */} style={[styles.scroll, tab !== "intro" && styles.hidden]}
showsVerticalScrollIndicator={false}
>
<View style={styles.titleSection}> <View style={styles.titleSection}>
<Text style={styles.title}>{room.title}</Text> <Text style={styles.title}>{room?.title}</Text>
<View style={styles.metaRow}> <View style={styles.metaRow}>
{/* Live status */}
{isLive ? ( {isLive ? (
<View style={styles.livePill}> <View style={styles.livePill}>
<View style={styles.liveDot} /> <View style={styles.liveDot} />
@@ -74,30 +102,30 @@ export default function LiveDetailScreen() {
</View> </View>
) : ( ) : (
<View style={[styles.livePill, styles.offlinePill]}> <View style={[styles.livePill, styles.offlinePill]}>
<Text style={[styles.livePillText, styles.offlinePillText]}></Text> <Text style={[styles.livePillText, styles.offlinePillText]}>
</Text>
</View> </View>
)} )}
{/* Online count */}
<View style={styles.onlineRow}> <View style={styles.onlineRow}>
<Ionicons name="eye-outline" size={13} color="#999" /> <Ionicons name="eye-outline" size={13} color="#999" />
<Text style={styles.onlineText}>{formatCount(room.online)}</Text> <Text style={styles.onlineText}>
{formatCount(room?.online ?? 0)}
</Text>
</View> </View>
</View> </View>
{/* Area tags */}
<View style={styles.areaRow}> <View style={styles.areaRow}>
{room.parent_area_name ? ( {room?.parent_area_name ? (
<Text style={styles.areaTag}>{room.parent_area_name}</Text> <Text style={styles.areaTag}>{room.parent_area_name}</Text>
) : null} ) : null}
{room.area_name ? ( {room?.area_name ? (
<Text style={styles.areaTag}>{room.area_name}</Text> <Text style={styles.areaTag}>{room.area_name}</Text>
) : null} ) : null}
</View> </View>
</View> </View>
{/* Divider */}
<View style={styles.divider} /> <View style={styles.divider} />
{/* Anchor row */}
{anchor && ( {anchor && (
<View style={styles.anchorRow}> <View style={styles.anchorRow}>
<Image <Image
@@ -111,104 +139,131 @@ export default function LiveDetailScreen() {
</View> </View>
)} )}
{/* Room description */} {!!room?.description && (
{!!room.description && (
<View style={styles.descBox}> <View style={styles.descBox}>
<Text style={styles.descText}>{room.description}</Text> <Text style={styles.descText}>{room.description}</Text>
</View> </View>
)} )}
</ScrollView> </ScrollView>
{/* Live danmaku list */}
<DanmakuList <DanmakuList
danmakus={danmakus} danmakus={danmakus}
currentTime={999999} currentTime={999999}
visible={danmakuVisible} visible
onToggle={() => setDanmakuVisible(v => !v)} onToggle={() => {}}
style={styles.danmakuList} style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]}
hideHeader
maxItems={500}
/> />
</View> </>
) : null} )}
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#fff' }, safe: { flex: 1, backgroundColor: "#fff" },
topBar: { topBar: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 8, paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee', borderBottomColor: "#eee",
}, },
backBtn: { padding: 4 }, backBtn: { padding: 4 },
topTitle: { topTitle: {
flex: 1, flex: 1,
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: "600",
marginLeft: 4, marginLeft: 4,
color: '#212121', color: "#212121",
}, },
loader: { marginVertical: 30 }, loader: { marginVertical: 30 },
errorText: { textAlign: 'center', color: '#f00', padding: 20 }, errorText: { textAlign: "center", color: "#f00", padding: 20 },
content: { flex: 1 }, 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 }, scroll: { flex: 1 },
titleSection: { padding: 14 }, titleSection: { padding: 14 },
title: { title: {
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: "600",
color: '#212121', color: "#212121",
lineHeight: 22, lineHeight: 22,
marginBottom: 8, marginBottom: 8,
}, },
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }, metaRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
marginBottom: 8,
},
livePill: { livePill: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
backgroundColor: '#fff0f0', backgroundColor: "#fff0f0",
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 3, paddingVertical: 3,
borderRadius: 4, borderRadius: 4,
gap: 4, gap: 4,
}, },
offlinePill: { backgroundColor: '#f5f5f5' }, offlinePill: { backgroundColor: "#f5f5f5" },
liveDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#f00' }, liveDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: "#f00" },
livePillText: { fontSize: 12, color: '#f00', fontWeight: '600' }, livePillText: { fontSize: 12, color: "#f00", fontWeight: "600" },
offlinePillText: { color: '#999' }, offlinePillText: { color: "#999" },
onlineRow: { flexDirection: 'row', alignItems: 'center', gap: 3 }, onlineRow: { flexDirection: "row", alignItems: "center", gap: 3 },
onlineText: { fontSize: 12, color: '#999' }, onlineText: { fontSize: 12, color: "#999" },
areaRow: { flexDirection: 'row', gap: 6 }, areaRow: { flexDirection: "row", gap: 6 },
areaTag: { areaTag: {
fontSize: 11, fontSize: 11,
color: '#00AEEC', color: "#00AEEC",
backgroundColor: '#e8f7fd', backgroundColor: "#e8f7fd",
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 2, paddingVertical: 2,
borderRadius: 10, borderRadius: 10,
}, },
divider: { divider: {
height: StyleSheet.hairlineWidth, height: StyleSheet.hairlineWidth,
backgroundColor: '#f0f0f0', backgroundColor: "#f0f0f0",
marginHorizontal: 14, marginHorizontal: 14,
}, },
anchorRow: { anchorRow: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 12, paddingVertical: 12,
}, },
avatar: { width: 40, height: 40, borderRadius: 20, marginRight: 10 }, 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: { followBtn: {
backgroundColor: '#00AEEC', backgroundColor: "#00AEEC",
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 5, paddingVertical: 5,
borderRadius: 14, borderRadius: 14,
}, },
followTxt: { color: '#fff', fontSize: 12, fontWeight: '600' }, followTxt: { color: "#fff", fontSize: 12, fontWeight: "600" },
descBox: { padding: 14, paddingTop: 4 }, descBox: { padding: 14, paddingTop: 4 },
descText: { fontSize: 14, color: '#555', lineHeight: 22 }, descText: { fontSize: 14, color: "#555", lineHeight: 22 },
danmakuList: { maxHeight: 220 }, danmakuFull: { flex: 1 },
hidden: { display: "none" },
}); });

View File

@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useCallback } from 'react'; import React, { useRef, useState, useEffect, useCallback } from "react";
import { import {
View, View,
Text, Text,
@@ -8,17 +8,19 @@ import {
Animated, Animated,
NativeSyntheticEvent, NativeSyntheticEvent,
NativeScrollEvent, NativeScrollEvent,
} from 'react-native'; } from "react-native";
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from "@expo/vector-icons";
import { DanmakuItem } from '../services/types'; import { DanmakuItem } from "../services/types";
import { danmakuColorToCss } from '../utils/danmaku'; import { danmakuColorToCss } from "../utils/danmaku";
interface Props { interface Props {
danmakus: DanmakuItem[]; danmakus: DanmakuItem[];
currentTime: number; currentTime: number;
visible: boolean; visible: boolean;
onToggle: () => void; onToggle: () => void;
style?: object; style?: object | object[];
hideHeader?: boolean;
maxItems?: number;
} }
interface DisplayedDanmaku extends DanmakuItem { interface DisplayedDanmaku extends DanmakuItem {
@@ -35,10 +37,18 @@ const SEEK_THRESHOLD = 2;
function formatTimestamp(seconds: number): string { function formatTimestamp(seconds: number): string {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = 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<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const [displayedItems, setDisplayedItems] = useState<DisplayedDanmaku[]>([]); const [displayedItems, setDisplayedItems] = useState<DisplayedDanmaku[]>([]);
const [unseenCount, setUnseenCount] = useState(0); const [unseenCount, setUnseenCount] = useState(0);
@@ -79,12 +89,12 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle,
isAtBottomRef.current = true; isAtBottomRef.current = true;
// Re-enqueue danmakus up to current time // 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 // Only enqueue recent ones to avoid flooding
const tail = catchUp.slice(-20); const tail = catchUp.slice(-20);
queueRef.current = tail; queueRef.current = tail;
processedIndexRef.current = danmakus.findIndex( processedIndexRef.current = danmakus.findIndex(
d => d.time > currentTime (d) => d.time > currentTime,
); );
if (processedIndexRef.current === -1) { if (processedIndexRef.current === -1) {
processedIndexRef.current = danmakus.length; processedIndexRef.current = danmakus.length;
@@ -106,37 +116,45 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle,
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
const id = setInterval(() => { const id = setInterval(
if (queueRef.current.length === 0) return; () => {
if (queueRef.current.length === 0) return;
const item = queueRef.current.shift()!; const item = queueRef.current.shift()!;
const fadeAnim = new Animated.Value(0); const fadeAnim = new Animated.Value(0);
const displayed: DisplayedDanmaku = { const displayed: DisplayedDanmaku = {
...item, ...item,
_key: keyCounterRef.current++, _key: keyCounterRef.current++,
_fadeAnim: fadeAnim, _fadeAnim: fadeAnim,
}; };
Animated.timing(fadeAnim, { Animated.timing(fadeAnim, {
toValue: 1, toValue: 1,
duration: 200, duration: 200,
useNativeDriver: true, useNativeDriver: true,
}).start(); }).start();
setDisplayedItems(prev => { setDisplayedItems((prev) => {
const next = [...prev, displayed]; const next = [...prev, displayed];
return next.length > MAX_DISPLAYED ? next.slice(-MAX_DISPLAYED) : next; // 超出上限时批量裁剪到一半,减少频繁截断导致的抖动
}); return next.length > maxItems
? next.slice(-Math.floor(maxItems / 2))
if (isAtBottomRef.current) { : next;
// Auto-scroll on next frame
requestAnimationFrame(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}); });
} else {
setUnseenCount(c => c + 1); if (isAtBottomRef.current) {
} // Auto-scroll on next frame
}, queueRef.current.length > QUEUE_FAST_THRESHOLD ? FAST_DRIP_INTERVAL : DRIP_INTERVAL); 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); return () => clearInterval(id);
}, [visible]); }, [visible]);
@@ -151,7 +169,7 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle,
setUnseenCount(0); setUnseenCount(0);
} }
}, },
[] [],
); );
const handleScrollBeginDrag = useCallback(() => { const handleScrollBeginDrag = useCallback(() => {
@@ -168,34 +186,79 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle,
const dotColor = danmakuColorToCss(item.color); const dotColor = danmakuColorToCss(item.color);
return ( return (
<Animated.View style={[styles.bubble, { opacity: item._fadeAnim }]}> <Animated.View style={[styles.bubble, { opacity: item._fadeAnim }]}>
<View style={[styles.colorDot, { backgroundColor: dotColor }]} /> {!hideHeader && (
<Text style={styles.bubbleText} numberOfLines={3}> <View style={[styles.colorDot, { backgroundColor: dotColor }]} />
{item.text} )}
</Text> <View style={styles.bubbleContent}>
<Text style={styles.timestamp}>{formatTimestamp(item.time)}</Text> {item.uname && (
<View style={styles.userRow}>
{item.isAdmin && (
<View style={[styles.badge, styles.badgeAdmin]}>
<Text style={styles.badgeText}></Text>
</View>
)}
{item.guardLevel === 1 && (
<View style={[styles.badge, styles.badgeGuard1]}>
<Text style={styles.badgeText}></Text>
</View>
)}
{item.guardLevel === 2 && (
<View style={[styles.badge, styles.badgeGuard2]}>
<Text style={styles.badgeText}></Text>
</View>
)}
{item.guardLevel === 3 && (
<View style={[styles.badge, styles.badgeGuard3]}>
<Text style={styles.badgeText}></Text>
</View>
)}
{item.medalLevel != null && (
<View style={styles.medal}>
<Text style={styles.medalText}>{item.medalName} {item.medalLevel}</Text>
</View>
)}
<Text style={styles.uname}>{item.uname}</Text>
</View>
)}
<Text style={styles.bubbleText} numberOfLines={3}>
{item.text}
</Text>
</View>
{!hideHeader && (
<Text style={styles.timestamp}>{formatTimestamp(item.time)}</Text>
)}
</Animated.View> </Animated.View>
); );
}, []); }, [hideHeader]);
const keyExtractor = useCallback((item: DisplayedDanmaku) => String(item._key), []); const keyExtractor = useCallback(
(item: DisplayedDanmaku) => String(item._key),
[],
);
return ( return (
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
<TouchableOpacity style={styles.header} onPress={onToggle} activeOpacity={0.7}> {!hideHeader && (
<Ionicons <TouchableOpacity
name={visible ? 'chatbubbles' : 'chatbubbles-outline'} style={styles.header}
size={16} onPress={onToggle}
color="#00AEEC" activeOpacity={0.7}
/> >
<Text style={styles.headerText}> <Ionicons
{danmakus.length > 0 ? `(${danmakus.length})` : ''} name={visible ? "chatbubbles" : "chatbubbles-outline"}
</Text> size={16}
<Ionicons color="#00AEEC"
name={visible ? 'chevron-up' : 'chevron-down'} />
size={14} <Text style={styles.headerText}>
color="#999" {danmakus.length > 0 ? `(${danmakus.length})` : ""}
/> </Text>
</TouchableOpacity> <Ionicons
name={visible ? "chevron-up" : "chevron-down"}
size={14}
color="#999"
/>
</TouchableOpacity>
)}
{visible && ( {visible && (
<View style={styles.listWrapper}> <View style={styles.listWrapper}>
@@ -212,7 +275,7 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle,
removeClippedSubviews={true} removeClippedSubviews={true}
ListEmptyComponent={ ListEmptyComponent={
<Text style={styles.empty}> <Text style={styles.empty}>
{danmakus.length === 0 ? '暂无弹幕' : '弹幕将随视频播放显示'} {danmakus.length === 0 ? "暂无弹幕" : "弹幕将随视频播放显示"}
</Text> </Text>
} }
/> />
@@ -233,13 +296,13 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle,
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
backgroundColor: '#fff', backgroundColor: "#fff",
borderTopWidth: StyleSheet.hairlineWidth, borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#eee', borderTopColor: "#eee",
}, },
header: { header: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 8, paddingVertical: 8,
gap: 6, gap: 6,
@@ -247,25 +310,25 @@ const styles = StyleSheet.create({
headerText: { headerText: {
flex: 1, flex: 1,
fontSize: 13, fontSize: 13,
color: '#212121', color: "#212121",
fontWeight: '500', fontWeight: "500",
}, },
listWrapper: { listWrapper: {
flex: 1, flex: 1,
position: 'relative', position: "relative",
}, },
list: { list: {
flex: 1, flex: 1,
backgroundColor: '#fafafa', backgroundColor: "#fafafa",
}, },
listContent: { listContent: {
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 4, paddingVertical: 4,
}, },
bubble: { bubble: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'flex-start', alignItems: "flex-start",
backgroundColor: '#f8f8f8', backgroundColor: "#f8f8f8",
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 6, paddingVertical: 6,
@@ -279,36 +342,63 @@ const styles = StyleSheet.create({
marginTop: 6, marginTop: 6,
flexShrink: 0, flexShrink: 0,
}, },
bubbleText: { bubbleContent: {
flex: 1, 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, fontSize: 13,
color: '#333', color: "#333",
lineHeight: 18, lineHeight: 18,
}, },
timestamp: { timestamp: {
fontSize: 11, fontSize: 11,
color: '#bbb', color: "#bbb",
marginTop: 1, marginTop: 1,
flexShrink: 0, flexShrink: 0,
}, },
pill: { pill: {
position: 'absolute', position: "absolute",
bottom: 8, bottom: 8,
alignSelf: 'center', alignSelf: "center",
backgroundColor: '#00AEEC', backgroundColor: "#00AEEC",
borderRadius: 16, borderRadius: 16,
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 6, paddingVertical: 6,
}, },
pillText: { pillText: {
fontSize: 12, fontSize: 12,
color: '#fff', color: "#fff",
fontWeight: '600', fontWeight: "600",
}, },
empty: { empty: {
fontSize: 12, fontSize: 12,
color: '#999', color: "#999",
textAlign: 'center', textAlign: "center",
paddingVertical: 20, paddingVertical: 20,
}, },
}); });

View File

@@ -86,6 +86,8 @@ function NativeLivePlayer({
const [buffering, setBuffering] = useState(true); const [buffering, setBuffering] = useState(true);
const [showQualityPanel, setShowQualityPanel] = useState(false); const [showQualityPanel, setShowQualityPanel] = useState(false);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const videoRef = useRef<any>(null);
const currentTimeRef = useRef(0);
const resetHideTimer = useCallback(() => { const resetHideTimer = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current); if (hideTimer.current) clearTimeout(hideTimer.current);
@@ -137,8 +139,10 @@ function NativeLivePlayer({
}); });
}, [resetHideTimer]); }, [resetHideTimer]);
const fsW = Math.max(screenW, screenH);
const fsH = Math.min(screenW, screenH);
const containerStyle = isFullscreen const containerStyle = isFullscreen
? { width: screenH, height: screenW } ? { width: fsW, height: fsH }
: { width: screenW, height: videoH }; : { width: screenW, height: videoH };
const currentQnDesc = qualities.find(q => q.qn === currentQn)?.desc ?? ''; const currentQnDesc = qualities.find(q => q.qn === currentQn)?.desc ?? '';
@@ -146,14 +150,21 @@ function NativeLivePlayer({
const videoContent = ( const videoContent = (
<View style={[styles.container, containerStyle]}> <View style={[styles.container, containerStyle]}>
<Video <Video
key={hlsUrl} key={hlsUrl + (isFullscreen ? '_fs' : '_normal')}
ref={videoRef}
source={{ uri: hlsUrl, headers: HEADERS }} source={{ uri: hlsUrl, headers: HEADERS }}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
resizeMode="contain" resizeMode="contain"
controls={false} controls={false}
paused={paused} paused={paused}
onProgress={({ currentTime: ct }: { currentTime: number }) => { currentTimeRef.current = ct; }}
onBuffer={({ isBuffering }: { isBuffering: boolean }) => setBuffering(isBuffering)} onBuffer={({ isBuffering }: { isBuffering: boolean }) => setBuffering(isBuffering)}
onLoad={() => setBuffering(false)} onLoad={() => {
setBuffering(false);
if (currentTimeRef.current > 0) {
videoRef.current?.seek(currentTimeRef.current);
}
}}
/> />
{buffering && ( {buffering && (
@@ -245,7 +256,7 @@ function NativeLivePlayer({
if (isFullscreen) { if (isFullscreen) {
return ( return (
<Modal visible transparent animationType="fade" supportedOrientations={['landscape']}> <Modal visible transparent animationType="fade" supportedOrientations={['landscape']} onRequestClose={() => setIsFullscreen(false)}>
<View style={styles.fsModal}>{videoContent}</View> <View style={styles.fsModal}>{videoContent}</View>
</Modal> </Modal>
); );

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useCallback } from "react"; import React, { useState, useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from "react";
import { formatDuration } from "../utils/format"; import { formatDuration } from "../utils/format";
import { import {
View, View,
@@ -51,6 +51,10 @@ function findFrameByTime(index: number[], seekTime: number): number {
return lo; return lo;
} }
export interface NativeVideoPlayerRef {
seek: (t: number) => void;
}
interface Props { interface Props {
playData: PlayUrlResponse | null; playData: PlayUrlResponse | null;
qualities: { qn: number; desc: string }[]; qualities: { qn: number; desc: string }[];
@@ -65,9 +69,10 @@ interface Props {
isFullscreen?: boolean; isFullscreen?: boolean;
onTimeUpdate?: (t: number) => void; onTimeUpdate?: (t: number) => void;
initialTime?: number; initialTime?: number;
forcePaused?: boolean;
} }
export function NativeVideoPlayer({ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(function NativeVideoPlayer({
playData, playData,
qualities, qualities,
currentQn, currentQn,
@@ -81,7 +86,8 @@ export function NativeVideoPlayer({
isFullscreen, isFullscreen,
onTimeUpdate, onTimeUpdate,
initialTime, initialTime,
}: Props) { forcePaused,
}: Props, ref) {
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions(); const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
const VIDEO_H = SCREEN_W * 0.5625; const VIDEO_H = SCREEN_W * 0.5625;
@@ -112,6 +118,11 @@ export function NativeVideoPlayer({
const [showDanmaku, setShowDanmaku] = useState(true); const [showDanmaku, setShowDanmaku] = useState(true);
const videoRef = useRef<VideoRef>(null); const videoRef = useRef<VideoRef>(null);
useImperativeHandle(ref, () => ({
seek: (t: number) => { videoRef.current?.seek(t); },
}));
const currentDesc = const currentDesc =
qualities.find((q) => q.qn === currentQn)?.desc ?? qualities.find((q) => q.qn === currentQn)?.desc ??
String(currentQn || "HD"); String(currentQn || "HD");
@@ -352,7 +363,7 @@ export function NativeVideoPlayer({
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
resizeMode="contain" resizeMode="contain"
controls={false} controls={false}
paused={paused} paused={!!(forcePaused || paused)}
onProgress={({ onProgress={({
currentTime: ct, currentTime: ct,
seekableDuration: dur, seekableDuration: dur,
@@ -553,7 +564,7 @@ export function NativeVideoPlayer({
</Modal> </Modal>
</View> </View>
); );
} });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { backgroundColor: "#000" }, container: { backgroundColor: "#000" },

View File

@@ -3,7 +3,7 @@ import { View, StyleSheet, Text, Platform, Modal, StatusBar, useWindowDimensions
// expo-screen-orientation requires a dev build; gracefully degrade in Expo Go // expo-screen-orientation requires a dev build; gracefully degrade in Expo Go
let ScreenOrientation: typeof import('expo-screen-orientation') | null = null; let ScreenOrientation: typeof import('expo-screen-orientation') | null = null;
try { ScreenOrientation = require('expo-screen-orientation'); } catch {} try { ScreenOrientation = require('expo-screen-orientation'); } catch {}
import { NativeVideoPlayer } from './NativeVideoPlayer'; import { NativeVideoPlayer, type NativeVideoPlayerRef } from './NativeVideoPlayer';
import type { PlayUrlResponse, DanmakuItem } from '../services/types'; import type { PlayUrlResponse, DanmakuItem } from '../services/types';
interface Props { 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 // In Expo Go ScreenOrientation is unavailable; simulate landscape via CSS transform
const needsRotation = !ScreenOrientation && fullscreen; const needsRotation = !ScreenOrientation && fullscreen;
const lastTimeRef = useRef(0); const lastTimeRef = useRef(0);
const portraitRef = useRef<NativeVideoPlayerRef>(null);
const handleEnterFullscreen = async () => { const handleEnterFullscreen = async () => {
if (Platform.OS !== 'web') if (Platform.OS !== 'web')
@@ -33,6 +34,8 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
}; };
const handleExitFullscreen = async () => { const handleExitFullscreen = async () => {
// Seek portrait player to current position before it becomes visible again
portraitRef.current?.seek(lastTimeRef.current);
setFullscreen(false); setFullscreen(false);
if (Platform.OS !== 'web') if (Platform.OS !== 'web')
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
@@ -69,21 +72,22 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
return ( return (
<> <>
{/* Portrait player: unmount when fullscreen */} {/* Portrait player: always mounted, force-paused while fullscreen is active */}
{!fullscreen && ( <NativeVideoPlayer
<NativeVideoPlayer ref={portraitRef}
playData={playData} playData={playData}
qualities={qualities} qualities={qualities}
currentQn={currentQn} currentQn={currentQn}
onQualityChange={onQualityChange} onQualityChange={onQualityChange}
onFullscreen={handleEnterFullscreen} onFullscreen={handleEnterFullscreen}
onMiniPlayer={onMiniPlayer} onMiniPlayer={onMiniPlayer}
bvid={bvid} bvid={bvid}
cid={cid} cid={cid}
isFullscreen={false} isFullscreen={false}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }} forcePaused={fullscreen}
/> initialTime={lastTimeRef.current}
)} onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
/>
<Modal visible={fullscreen} animationType="none" statusBarTranslucent> <Modal visible={fullscreen} animationType="none" statusBarTranslucent>
<StatusBar hidden /> <StatusBar hidden />

View File

@@ -28,9 +28,9 @@ function buildPacket(body: string, op: number): Uint8Array {
// ver = 1 // ver = 1
pkt[6] = 0x00; pkt[7] = 0x01; pkt[6] = 0x00; pkt[7] = 0x01;
// op (big-endian uint32) // op (big-endian uint32)
pkt[8] = (op >>> 24) & 0xff; pkt[8] = (op >>> 24) & 0xff;
pkt[9] = (op >>> 16) & 0xff; pkt[9] = (op >>> 16) & 0xff;
pkt[10] = (op >>> 8) & 0xff; pkt[10] = (op >>> 8) & 0xff;
pkt[11] = op & 0xff; pkt[11] = op & 0xff;
// seq = 1 // seq = 1
pkt[12] = 0x00; pkt[13] = 0x00; pkt[14] = 0x00; pkt[15] = 0x01; pkt[12] = 0x00; pkt[13] = 0x00; pkt[14] = 0x00; pkt[15] = 0x01;
@@ -78,7 +78,12 @@ function extractDanmaku(buf: Uint8Array): DanmakuItem[] {
const text = info[1] as string; const text = info[1] as string;
// color is at info[0][2] (decimal 0xRRGGBB) // color is at info[0][2] (decimal 0xRRGGBB)
const color = (info[0]?.[2] as number) ?? 0xffffff; const color = (info[0]?.[2] as number) ?? 0xffffff;
result.push({ time: 0, mode: 1, fontSize: 25, color, text }); const uname = info[2]?.[1] as string | undefined;
const isAdmin = info[2]?.[2] === 1;
const guardLevel = (info[7] as number) ?? 0;
const medalLevel = Array.isArray(info[3]) && info[3].length > 0 ? info[3][0] as number : undefined;
const medalName = Array.isArray(info[3]) && info[3].length > 1 ? info[3][1] as string : undefined;
result.push({ time: 0, mode: 1, fontSize: 25, color, text, uname, isAdmin, guardLevel, medalLevel, medalName });
} }
} catch { /* ignore parse errors */ } } catch { /* ignore parse errors */ }
} }
@@ -118,7 +123,6 @@ export function useLiveDanmaku(roomId: number): DanmakuItem[] {
const info = await getLiveDanmakuInfo(roomId); const info = await getLiveDanmakuInfo(roomId);
token = info.token; token = info.token;
host = info.host; host = info.host;
console.log('[danmaku] getDanmuInfo ok, host:', host, 'token:', token.slice(0, 10) + '...');
} catch (e) { } catch (e) {
console.warn('[danmaku] getDanmuInfo failed:', e); console.warn('[danmaku] getDanmuInfo failed:', e);
} }
@@ -133,7 +137,6 @@ export function useLiveDanmaku(roomId: number): DanmakuItem[] {
const cookieParts = buvid3 ? [`buvid3=${buvid3}`] : []; const cookieParts = buvid3 ? [`buvid3=${buvid3}`] : [];
if (sessdata) cookieParts.push(`SESSDATA=${sessdata}`); if (sessdata) cookieParts.push(`SESSDATA=${sessdata}`);
console.log('[danmaku] connecting to', host, 'cookie len:', cookieParts.join('; ').length);
// React Native supports non-standard options.headers in the WebSocket constructor // React Native supports non-standard options.headers in the WebSocket constructor
const ws = new (WebSocket as any)(host, [], { const ws = new (WebSocket as any)(host, [], {
headers: cookieParts.length ? { Cookie: cookieParts.join('; ') } : {}, headers: cookieParts.length ? { Cookie: cookieParts.join('; ') } : {},
@@ -152,12 +155,9 @@ export function useLiveDanmaku(roomId: number): DanmakuItem[] {
const authPkt = buildPacket(authBody, 7); const authPkt = buildPacket(authBody, 7);
const hdr = Array.from(authPkt.slice(0, 16)) const hdr = Array.from(authPkt.slice(0, 16))
.map(b => b.toString(16).padStart(2, '0')).join(' '); .map(b => b.toString(16).padStart(2, '0')).join(' ');
console.log('[danmaku] auth header hex:', hdr);
console.log('[danmaku] auth body len:', authPkt.length - 16);
// Send as ArrayBuffer (more reliable than Uint8Array in some RN/Hermes versions) // Send as ArrayBuffer (more reliable than Uint8Array in some RN/Hermes versions)
const t0 = Date.now(); const t0 = Date.now();
ws.send(authPkt.buffer as ArrayBuffer); ws.send(authPkt.buffer as ArrayBuffer);
console.log('[danmaku] send done, readyState:', ws.readyState, 'at', t0);
heartbeatRef.current = setInterval(() => { heartbeatRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(buildPacket('', 2)); if (ws.readyState === WebSocket.OPEN) ws.send(buildPacket('', 2));
@@ -173,10 +173,8 @@ export function useLiveDanmaku(roomId: number): DanmakuItem[] {
ws.onerror = (e: Event) => { ws.onerror = (e: Event) => {
const ws2 = (e as any).currentTarget as WebSocket; const ws2 = (e as any).currentTarget as WebSocket;
console.warn('[danmaku] ws error, readyState:', ws2?.readyState, 'url:', ws2?.url);
}; };
ws.onclose = (e: CloseEvent) => { ws.onclose = (e: CloseEvent) => {
console.log('[danmaku] ws closed, code:', e.code, 'reason:', e.reason, 'wasClean:', e.wasClean);
if (heartbeatRef.current) clearInterval(heartbeatRef.current); if (heartbeatRef.current) clearInterval(heartbeatRef.current);
}; };
} }

View File

@@ -278,7 +278,7 @@ export async function getLiveStreamUrl(roomId: number, qn = 10000): Promise<Live
const codec = fmt?.codec?.find((c: any) => c.codec_name === 'avc') ?? fmt?.codec?.[0]; const codec = fmt?.codec?.find((c: any) => c.codec_name === 'avc') ?? fmt?.codec?.[0];
const urlInfo = codec?.url_info?.[0]; const urlInfo = codec?.url_info?.[0];
if (urlInfo) { if (urlInfo) {
hlsUrl = urlInfo.host + codec.base_url; hlsUrl = urlInfo.host + codec.base_url + (urlInfo.extra ?? '');
currentQn = codec.current_qn ?? 0; currentQn = codec.current_qn ?? 0;
} }
} }
@@ -289,7 +289,7 @@ export async function getLiveStreamUrl(roomId: number, qn = 10000): Promise<Live
const codec = fmt?.codec?.find((c: any) => c.codec_name === 'avc') ?? fmt?.codec?.[0]; const codec = fmt?.codec?.find((c: any) => c.codec_name === 'avc') ?? fmt?.codec?.[0];
const urlInfo = codec?.url_info?.[0]; const urlInfo = codec?.url_info?.[0];
if (urlInfo) { if (urlInfo) {
flvUrl = urlInfo.host + codec.base_url; flvUrl = urlInfo.host + codec.base_url + (urlInfo.extra ?? '');
} }
} }

View File

@@ -117,6 +117,11 @@ export interface DanmakuItem {
fontSize: number; fontSize: number;
color: number; // 0xRRGGBB 十进制整数 color: number; // 0xRRGGBB 十进制整数
text: string; text: string;
uname?: string;
isAdmin?: boolean;
guardLevel?: number; // 0=无, 1=总督, 2=提督, 3=舰长
medalLevel?: number;
medalName?: string;
} }
export interface LiveRoom { export interface LiveRoom {