mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
播放状态
This commit is contained in:
@@ -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" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user