mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
1
This commit is contained in:
@@ -36,7 +36,7 @@ export default function LiveDetailScreen() {
|
|||||||
|
|
||||||
// Use actual roomid from room detail (not the short/alias ID from the URL)
|
// Use actual roomid from room detail (not the short/alias ID from the URL)
|
||||||
const actualRoomId = room?.roomid ?? id;
|
const actualRoomId = room?.roomid ?? id;
|
||||||
const danmakus = useLiveDanmaku(isLive ? actualRoomId : 0);
|
const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0);
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe}>
|
<SafeAreaView style={styles.safe}>
|
||||||
{/* TopBar */}
|
{/* TopBar */}
|
||||||
@@ -156,6 +156,7 @@ export default function LiveDetailScreen() {
|
|||||||
style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]}
|
style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]}
|
||||||
hideHeader
|
hideHeader
|
||||||
maxItems={500}
|
maxItems={500}
|
||||||
|
giftCounts={giftCounts}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
NativeScrollEvent,
|
NativeScrollEvent,
|
||||||
|
ScrollView,
|
||||||
} 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";
|
||||||
@@ -21,6 +22,7 @@ interface Props {
|
|||||||
style?: object | object[];
|
style?: object | object[];
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
maxItems?: number;
|
maxItems?: number;
|
||||||
|
giftCounts?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DisplayedDanmaku extends DanmakuItem {
|
interface DisplayedDanmaku extends DanmakuItem {
|
||||||
@@ -34,12 +36,38 @@ const FAST_DRIP_INTERVAL = 100;
|
|||||||
const QUEUE_FAST_THRESHOLD = 50;
|
const QUEUE_FAST_THRESHOLD = 50;
|
||||||
const SEEK_THRESHOLD = 2;
|
const SEEK_THRESHOLD = 2;
|
||||||
|
|
||||||
|
// ─── 常见礼物 ──────────────────────────────────────────────────────────────────
|
||||||
|
const COMMON_GIFTS = [
|
||||||
|
{ name: "辣条", icon: "🌶️", price: "1 银瓜子" },
|
||||||
|
{ name: "小心心", icon: "💗", price: "5 银瓜子" },
|
||||||
|
{ name: "打call", icon: "📣", price: "500 银瓜子" },
|
||||||
|
{ name: "干杯", icon: "🍻", price: "1 电池" },
|
||||||
|
{ name: "比心", icon: "💕", price: "10 金瓜子" },
|
||||||
|
{ name: "吃瓜", icon: "🍉", price: "100 金瓜子" },
|
||||||
|
{ name: "花式夸夸", icon: "🌸", price: "1000 金瓜子" },
|
||||||
|
{ name: "告白气球", icon: "🎈", price: "5200 金瓜子" },
|
||||||
|
{ name: "小电视飞船", icon: "🚀", price: "1245 电池" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 舰长等级 ───────────────────────────────────────────────────────────────────
|
||||||
|
const GUARD_LABELS: Record<number, { text: string; color: string }> = {
|
||||||
|
1: { text: "总督", color: "#E13979" },
|
||||||
|
2: { text: "提督", color: "#7B68EE" },
|
||||||
|
3: { text: "舰长", color: "#00D1F1" },
|
||||||
|
};
|
||||||
|
|
||||||
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")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLiveTime(timeline?: string): string {
|
||||||
|
if (!timeline) return "";
|
||||||
|
const parts = timeline.split(" ");
|
||||||
|
return parts[1]?.slice(0, 5) ?? ""; // "HH:MM"
|
||||||
|
}
|
||||||
|
|
||||||
export default function DanmakuList({
|
export default function DanmakuList({
|
||||||
danmakus,
|
danmakus,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -48,6 +76,7 @@ export default function DanmakuList({
|
|||||||
style,
|
style,
|
||||||
hideHeader,
|
hideHeader,
|
||||||
maxItems = 100,
|
maxItems = 100,
|
||||||
|
giftCounts,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const [displayedItems, setDisplayedItems] = useState<DisplayedDanmaku[]>([]);
|
const [displayedItems, setDisplayedItems] = useState<DisplayedDanmaku[]>([]);
|
||||||
@@ -60,27 +89,46 @@ export default function DanmakuList({
|
|||||||
const isAtBottomRef = useRef(true);
|
const isAtBottomRef = useRef(true);
|
||||||
const danmakusRef = useRef(danmakus);
|
const danmakusRef = useRef(danmakus);
|
||||||
|
|
||||||
// Reset everything when danmakus array reference changes (video switch)
|
// Detect changes in the danmakus array
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (danmakusRef.current !== danmakus) {
|
const prev = danmakusRef.current;
|
||||||
danmakusRef.current = danmakus;
|
if (prev === danmakus) return;
|
||||||
|
danmakusRef.current = danmakus;
|
||||||
|
|
||||||
|
if (danmakus.length === 0) {
|
||||||
queueRef.current = [];
|
queueRef.current = [];
|
||||||
processedIndexRef.current = 0;
|
processedIndexRef.current = 0;
|
||||||
lastTimeRef.current = 0;
|
lastTimeRef.current = 0;
|
||||||
setDisplayedItems([]);
|
setDisplayedItems([]);
|
||||||
setUnseenCount(0);
|
setUnseenCount(0);
|
||||||
isAtBottomRef.current = true;
|
isAtBottomRef.current = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [danmakus]);
|
|
||||||
|
|
||||||
// Watch currentTime, enqueue new danmakus
|
if (hideHeader) {
|
||||||
|
const newStart = processedIndexRef.current;
|
||||||
|
if (danmakus.length > newStart) {
|
||||||
|
queueRef.current.push(...danmakus.slice(newStart));
|
||||||
|
processedIndexRef.current = danmakus.length;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueRef.current = [];
|
||||||
|
processedIndexRef.current = 0;
|
||||||
|
lastTimeRef.current = 0;
|
||||||
|
setDisplayedItems([]);
|
||||||
|
setUnseenCount(0);
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
}, [danmakus, hideHeader]);
|
||||||
|
|
||||||
|
// Watch currentTime — only used in video mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || danmakus.length === 0) return;
|
if (!visible || danmakus.length === 0 || hideHeader) return;
|
||||||
|
|
||||||
const prevTime = lastTimeRef.current;
|
const prevTime = lastTimeRef.current;
|
||||||
lastTimeRef.current = currentTime;
|
lastTimeRef.current = currentTime;
|
||||||
|
|
||||||
// Seek detection
|
|
||||||
if (Math.abs(currentTime - prevTime) > SEEK_THRESHOLD) {
|
if (Math.abs(currentTime - prevTime) > SEEK_THRESHOLD) {
|
||||||
queueRef.current = [];
|
queueRef.current = [];
|
||||||
processedIndexRef.current = 0;
|
processedIndexRef.current = 0;
|
||||||
@@ -88,9 +136,7 @@ export default function DanmakuList({
|
|||||||
setUnseenCount(0);
|
setUnseenCount(0);
|
||||||
isAtBottomRef.current = true;
|
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);
|
const tail = catchUp.slice(-20);
|
||||||
queueRef.current = tail;
|
queueRef.current = tail;
|
||||||
processedIndexRef.current = danmakus.findIndex(
|
processedIndexRef.current = danmakus.findIndex(
|
||||||
@@ -102,17 +148,16 @@ export default function DanmakuList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal progression: enqueue danmakus between prevTime and currentTime
|
const sorted = danmakus;
|
||||||
const sorted = danmakus; // assumed sorted by time
|
|
||||||
let i = processedIndexRef.current;
|
let i = processedIndexRef.current;
|
||||||
while (i < sorted.length && sorted[i].time <= currentTime) {
|
while (i < sorted.length && sorted[i].time <= currentTime) {
|
||||||
queueRef.current.push(sorted[i]);
|
queueRef.current.push(sorted[i]);
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
processedIndexRef.current = i;
|
processedIndexRef.current = i;
|
||||||
}, [currentTime, danmakus, visible]);
|
}, [currentTime, danmakus, visible, hideHeader]);
|
||||||
|
|
||||||
// Drip interval: pop from queue, append to displayed
|
// Drip interval
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
|
|
||||||
@@ -136,14 +181,12 @@ export default function DanmakuList({
|
|||||||
|
|
||||||
setDisplayedItems((prev) => {
|
setDisplayedItems((prev) => {
|
||||||
const next = [...prev, displayed];
|
const next = [...prev, displayed];
|
||||||
// 超出上限时批量裁剪到一半,减少频繁截断导致的抖动
|
|
||||||
return next.length > maxItems
|
return next.length > maxItems
|
||||||
? next.slice(-Math.floor(maxItems / 2))
|
? next.slice(-Math.floor(maxItems / 2))
|
||||||
: next;
|
: next;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isAtBottomRef.current) {
|
if (isAtBottomRef.current) {
|
||||||
// Auto-scroll on next frame
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
flatListRef.current?.scrollToEnd({ animated: true });
|
flatListRef.current?.scrollToEnd({ animated: true });
|
||||||
});
|
});
|
||||||
@@ -165,9 +208,7 @@ export default function DanmakuList({
|
|||||||
const distanceFromBottom =
|
const distanceFromBottom =
|
||||||
contentSize.height - layoutMeasurement.height - contentOffset.y;
|
contentSize.height - layoutMeasurement.height - contentOffset.y;
|
||||||
isAtBottomRef.current = distanceFromBottom < 40;
|
isAtBottomRef.current = distanceFromBottom < 40;
|
||||||
if (isAtBottomRef.current) {
|
if (isAtBottomRef.current) setUnseenCount(0);
|
||||||
setUnseenCount(0);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -182,54 +223,59 @@ export default function DanmakuList({
|
|||||||
isAtBottomRef.current = true;
|
isAtBottomRef.current = true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderItem = useCallback(({ item }: { item: DisplayedDanmaku }) => {
|
// ─── Live mode render (B站-style chat) ─────────────────────────────────────
|
||||||
const dotColor = danmakuColorToCss(item.color);
|
const renderLiveItem = useCallback(({ item }: { item: DisplayedDanmaku }) => {
|
||||||
|
const guard = item.guardLevel ? GUARD_LABELS[item.guardLevel] : null;
|
||||||
|
const timeStr = formatLiveTime(item.timeline);
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.bubble, { opacity: item._fadeAnim }]}>
|
<Animated.View style={[liveStyles.row, { opacity: item._fadeAnim }]}>
|
||||||
{!hideHeader && (
|
{timeStr ? (
|
||||||
<View style={[styles.colorDot, { backgroundColor: dotColor }]} />
|
<Text style={liveStyles.time}>{timeStr}</Text>
|
||||||
)}
|
) : null}
|
||||||
<View style={styles.bubbleContent}>
|
<View style={liveStyles.msgBody}>
|
||||||
{item.uname && (
|
{guard && (
|
||||||
<View style={styles.userRow}>
|
<View style={[liveStyles.guardTag, { backgroundColor: guard.color }]}>
|
||||||
{item.isAdmin && (
|
<Text style={liveStyles.guardTagText}>{guard.text}</Text>
|
||||||
<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>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.bubbleText} numberOfLines={3}>
|
{item.isAdmin && (
|
||||||
|
<View style={[liveStyles.guardTag, { backgroundColor: "#e53935" }]}>
|
||||||
|
<Text style={liveStyles.guardTagText}>房管</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.medalLevel != null && item.medalName && (
|
||||||
|
<View style={liveStyles.medalTag}>
|
||||||
|
<Text style={liveStyles.medalName}>{item.medalName}</Text>
|
||||||
|
<View style={liveStyles.medalLvBox}>
|
||||||
|
<Text style={liveStyles.medalLv}>{item.medalLevel}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={liveStyles.uname} numberOfLines={1}>
|
||||||
|
{item.uname ?? "匿名"}
|
||||||
|
</Text>
|
||||||
|
<Text style={liveStyles.colon}>:</Text>
|
||||||
|
<Text style={liveStyles.text} numberOfLines={2}>
|
||||||
{item.text}
|
{item.text}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{!hideHeader && (
|
|
||||||
<Text style={styles.timestamp}>{formatTimestamp(item.time)}</Text>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}, [hideHeader]);
|
}, []);
|
||||||
|
|
||||||
|
// ─── Video mode render (original bubble) ───────────────────────────────────
|
||||||
|
const renderVideoItem = useCallback(({ item }: { item: DisplayedDanmaku }) => {
|
||||||
|
const dotColor = danmakuColorToCss(item.color);
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.bubble, { opacity: item._fadeAnim }]}>
|
||||||
|
<View style={[styles.colorDot, { backgroundColor: dotColor }]} />
|
||||||
|
<Text style={styles.bubbleText} numberOfLines={3}>
|
||||||
|
{item.text}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.timestamp}>{formatTimestamp(item.time)}</Text>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const keyExtractor = useCallback(
|
const keyExtractor = useCallback(
|
||||||
(item: DisplayedDanmaku) => String(item._key),
|
(item: DisplayedDanmaku) => String(item._key),
|
||||||
@@ -266,9 +312,9 @@ export default function DanmakuList({
|
|||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
data={displayedItems}
|
data={displayedItems}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
renderItem={renderItem}
|
renderItem={hideHeader ? renderLiveItem : renderVideoItem}
|
||||||
style={styles.list}
|
style={hideHeader ? liveStyles.list : styles.list}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={hideHeader ? liveStyles.listContent : styles.listContent}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
onScrollBeginDrag={handleScrollBeginDrag}
|
onScrollBeginDrag={handleScrollBeginDrag}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
@@ -290,10 +336,40 @@ export default function DanmakuList({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 常见礼物栏 — 仅直播模式显示 */}
|
||||||
|
{hideHeader && visible && (
|
||||||
|
<View style={giftStyles.bar}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={giftStyles.scroll}
|
||||||
|
>
|
||||||
|
{COMMON_GIFTS.map((g) => {
|
||||||
|
const count = giftCounts?.[g.name] ?? 0;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity key={g.name} style={giftStyles.item} activeOpacity={0.7}>
|
||||||
|
<View style={giftStyles.iconWrap}>
|
||||||
|
{count > 0 && (
|
||||||
|
<View style={giftStyles.badge}>
|
||||||
|
<Text style={giftStyles.badgeText}>+{count}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={giftStyles.icon}>{g.icon}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={giftStyles.name} numberOfLines={1}>{g.name}</Text>
|
||||||
|
<Text style={giftStyles.price}>{g.price}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Video mode styles ────────────────────────────────────────────────────────
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
backgroundColor: "#fff",
|
backgroundColor: "#fff",
|
||||||
@@ -342,35 +418,8 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
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: {
|
bubbleText: {
|
||||||
|
flex: 1,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: "#333",
|
color: "#333",
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
@@ -402,3 +451,147 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 20,
|
paddingVertical: 20,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Live mode styles (B站-style chat) ────────────────────────────────────────
|
||||||
|
const liveStyles = StyleSheet.create({
|
||||||
|
list: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#f9f9fb",
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: "#f0f0f2",
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#c0c0c0",
|
||||||
|
width: 34,
|
||||||
|
marginTop: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
msgBody: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guardTag: {
|
||||||
|
borderRadius: 3,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingVertical: 1,
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
guardTagText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
medalTag: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: 3,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#e891ab",
|
||||||
|
overflow: "hidden",
|
||||||
|
marginRight: 4,
|
||||||
|
height: 16,
|
||||||
|
},
|
||||||
|
medalName: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: "#e891ab",
|
||||||
|
paddingHorizontal: 3,
|
||||||
|
},
|
||||||
|
medalLvBox: {
|
||||||
|
backgroundColor: "#e891ab",
|
||||||
|
paddingHorizontal: 3,
|
||||||
|
height: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
medalLv: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
uname: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#666",
|
||||||
|
fontWeight: "500",
|
||||||
|
maxWidth: 90,
|
||||||
|
},
|
||||||
|
colon: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#666",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#212121",
|
||||||
|
lineHeight: 18,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Gift bar styles ──────────────────────────────────────────────────────────
|
||||||
|
const giftStyles = StyleSheet.create({
|
||||||
|
bar: {
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: "#eee",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
height: 72,
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 60,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
position: "relative",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: -8,
|
||||||
|
alignSelf: "center",
|
||||||
|
backgroundColor: "#FF6B81",
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingVertical: 1,
|
||||||
|
zIndex: 1,
|
||||||
|
minWidth: 20,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: "700",
|
||||||
|
lineHeight: 13,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: 22,
|
||||||
|
lineHeight: 28,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#333",
|
||||||
|
fontWeight: "500",
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: "#aaa",
|
||||||
|
marginTop: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
33
dev-proxy.js
33
dev-proxy.js
@@ -1,6 +1,8 @@
|
|||||||
|
const http = require('http');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const WsLib = require('ws');
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// CORS: allow any local origin (Expo web dev server)
|
// CORS: allow any local origin (Expo web dev server)
|
||||||
@@ -103,5 +105,30 @@ app.use('/bilibili-img', (req, res) => {
|
|||||||
makeProxy(host)(req, res);
|
makeProxy(host)(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PROXY_PORT || 3001;
|
const PORT = process.env.PROXY_PORT || 3001;
|
||||||
app.listen(PORT, () => console.log(`[Proxy] http://localhost:${PORT}`));
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
// WebSocket relay — Android Expo Go often can't reach *.chat.bilibili.com directly.
|
||||||
|
// Device connects here; proxy opens the upstream WSS connection and relays all frames.
|
||||||
|
const wss = new WsLib.Server({ server, path: '/bilibili-danmaku-ws' });
|
||||||
|
wss.on('connection', (clientWs, req) => {
|
||||||
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||||
|
const target = url.searchParams.get('host');
|
||||||
|
if (!target || !target.includes('bilibili.com')) {
|
||||||
|
clientWs.close(4001, 'invalid target');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[ws-relay] →', target);
|
||||||
|
const upstream = new WsLib(target, { headers: { Origin: 'https://live.bilibili.com' }, perMessageDeflate: false });
|
||||||
|
upstream.on('open', () => console.log('[ws-relay] upstream open'));
|
||||||
|
upstream.on('message', data => { if (clientWs.readyState === 1) clientWs.send(data, { binary: true }); });
|
||||||
|
upstream.on('error', err => { console.error('[ws-relay] upstream error:', err.message); clientWs.close(); });
|
||||||
|
upstream.on('close', () => clientWs.close());
|
||||||
|
clientWs.on('message', data => { if (upstream.readyState === 1) upstream.send(data); });
|
||||||
|
clientWs.on('close', () => upstream.close());
|
||||||
|
clientWs.on('error', () => upstream.close());
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '0.0.0.0', () =>
|
||||||
|
console.log(`[Proxy] http://localhost:${PORT} ws://<LAN-IP>:${PORT}/bilibili-danmaku-ws`)
|
||||||
|
);
|
||||||
|
|||||||
218
docs/bilibili-live-danmaku-research.md
Normal file
218
docs/bilibili-live-danmaku-research.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# B站直播弹幕连接 — 学习笔记
|
||||||
|
|
||||||
|
> 参考项目:https://github.com/Minteea/bilibili-live-danmaku (v0.7.15)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、完整连接流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 初始化 Cookie
|
||||||
|
GET https://www.bilibili.com/ → 种植初始 cookie
|
||||||
|
POST .../bilibili.api.ticket.v1.Ticket/GenWebTicket → 获取 bili_ticket + WBI 签名密钥
|
||||||
|
GET .../x/frontend/finger/spi → 获取 buvid4
|
||||||
|
POST .../x/internal/gaia-gateway/ExClimbWuzhi → 激活 buvid(模拟指纹)
|
||||||
|
|
||||||
|
2. 获取弹幕服务器信息(需要 WBI 签名)
|
||||||
|
GET https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={roomId}
|
||||||
|
响应:{ token, host_list: [{ host, wss_port, ws_port }] }
|
||||||
|
|
||||||
|
3. 建立 WebSocket 连接
|
||||||
|
wss://{host_list[0].host}/sub
|
||||||
|
fallback: wss://broadcastlv.chat.bilibili.com/sub
|
||||||
|
|
||||||
|
4. onopen → 发送 AUTH 包(op=7)
|
||||||
|
5. 收到 op=8 (CONNECT_SUCCESS) → 立即发送一次心跳(op=2)
|
||||||
|
6. 每次收到 op=3 (HEARTBEAT_REPLY) → 30秒后发下一次心跳
|
||||||
|
7. 收到 op=5 (MESSAGE) → 解析弹幕数据
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、二进制包格式(16字节包头)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┬──────────┬──────────┬──────────┬──────────┐
|
||||||
|
│ 0–3 │ 4–5 │ 6–7 │ 8–11 │ 12–15 │
|
||||||
|
│ 包总长度 │ 头部长度 │ 协议版本 │ 操作码 │ 序列号 │
|
||||||
|
│ uint32 │ uint16 │ uint16 │ uint32 │ uint32 │
|
||||||
|
│ (BE) │ 固定=16 │ ver │ op │ 固定=1 │
|
||||||
|
└──────────┴──────────┴──────────┴──────────┴──────────┘
|
||||||
|
│ 16+ bytes: body (JSON 字符串 或 压缩数据) │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 操作码 (op)
|
||||||
|
| op | 名称 | 方向 | 说明 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| 2 | HEARTBEAT | 客→服 | 心跳包,body 为空 |
|
||||||
|
| 3 | HEARTBEAT_REPLY | 服→客 | 心跳回应,body 是 uint32 在线人数 |
|
||||||
|
| 5 | MESSAGE | 服→客 | 弹幕/通知消息 |
|
||||||
|
| 7 | USER_AUTHENTICATION | 客→服 | 进房认证包 |
|
||||||
|
| 8 | CONNECT_SUCCESS | 服→客 | 认证成功 |
|
||||||
|
|
||||||
|
### 协议版本 (ver)
|
||||||
|
| ver | 说明 | 解压方式 |
|
||||||
|
|-----|------|---------|
|
||||||
|
| 0 | 原始 JSON | 直接 `JSON.parse` |
|
||||||
|
| 1 | uint32 | `DataView.getUint32(0)` |
|
||||||
|
| 2 | zlib 压缩包束 | `pako.inflate()` → 递归解包 |
|
||||||
|
| 3 | brotli 压缩包束 | `BrotliDecode()` → 递归解包 |
|
||||||
|
|
||||||
|
> ver=2/3 解压后包含**多个子包**拼接,需按每个包头的 totalLen 逐个切割。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、认证包 (op=7) 的 body 字段
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uid": 0,
|
||||||
|
"roomid": 12345678,
|
||||||
|
"protover": 3,
|
||||||
|
"platform": "web",
|
||||||
|
"type": 2,
|
||||||
|
"key": "<token from getDanmuInfo>",
|
||||||
|
"buvid": "<buvid3 cookie>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| uid | 是 | 未登录填 0 |
|
||||||
|
| roomid | 是 | 真实房间号(非短号) |
|
||||||
|
| protover | 是 | 推荐 3(brotli),或 2(zlib) |
|
||||||
|
| platform | 是 | 固定 `"web"` |
|
||||||
|
| type | 是 | 固定 `2` |
|
||||||
|
| key | 是 | getDanmuInfo 返回的 token |
|
||||||
|
| buvid | 推荐 | buvid3 cookie 值,不填可能被限流 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、心跳逻辑
|
||||||
|
|
||||||
|
```
|
||||||
|
onopen
|
||||||
|
└─ 发送 AUTH (op=7)
|
||||||
|
|
||||||
|
收到 op=8 (CONNECT_SUCCESS)
|
||||||
|
└─ 立即发送第一次心跳 (op=2, body='')
|
||||||
|
|
||||||
|
收到 op=3 (HEARTBEAT_REPLY)
|
||||||
|
├─ 清除上次计时器
|
||||||
|
└─ 30秒后发送下次心跳 (op=2)
|
||||||
|
```
|
||||||
|
|
||||||
|
**重点**:心跳是在收到 op=3 后才重排计时,而不是固定 setInterval。
|
||||||
|
这样即使因网络延迟导致心跳回复慢,也不会提前发第二次心跳。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、getDanmuInfo API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo
|
||||||
|
?id={roomId}
|
||||||
|
&w_rid=xxx ← WBI 签名参数
|
||||||
|
&wts=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"token": "L_DSj3cX...",
|
||||||
|
"host_list": [
|
||||||
|
{ "host": "tx-gz-live-comet.chat.bilibili.com", "wss_port": 443, "ws_port": 2244 },
|
||||||
|
{ "host": "broadcastlv.chat.bilibili.com", "wss_port": 443, "ws_port": 2244 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
也可使用旧版 `getConf`(无需 WBI 签名,但已被标记为 deprecated):
|
||||||
|
```
|
||||||
|
GET https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id={roomId}&platform=h5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、与我们现有实现的差异
|
||||||
|
|
||||||
|
| 对比项 | 我们现在 | 参考项目 | 建议 |
|
||||||
|
|--------|---------|---------|------|
|
||||||
|
| API | `getConf`(老接口) | `getDanmuInfo`(新接口) | 可选,两个都能用 |
|
||||||
|
| WBI 签名 | 有(之前因 -352 移除) | 有 | 如果遇到 -352 可用 getConf |
|
||||||
|
| `protover` | 2(zlib) | 3(brotli)或 2 | 先用 2,brotli 需 polyfill |
|
||||||
|
| auth 字段 | 缺少 `platform`、`buvid` | 有 `platform: "web"`、`buvid` | **需补充** |
|
||||||
|
| 心跳方式 | `setInterval` 固定 30s | 收到 op=3 后计时 30s | 建议改为 op=3 驱动 |
|
||||||
|
| buvid3 | 未传入 auth | 作为 `buvid` 字段传入 auth | **需补充** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、我们代码需要的改进
|
||||||
|
|
||||||
|
### 1. 补充 auth 字段
|
||||||
|
```ts
|
||||||
|
const authBody = JSON.stringify({
|
||||||
|
uid: 0,
|
||||||
|
roomid: roomId,
|
||||||
|
protover: 2,
|
||||||
|
platform: 'web', // ← 缺少
|
||||||
|
type: 2,
|
||||||
|
key: token,
|
||||||
|
buvid: buvid3, // ← 缺少(从 AsyncStorage 读取)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 心跳改为 op=3 驱动
|
||||||
|
```ts
|
||||||
|
// 收到 op=8 → 立即发第一次心跳
|
||||||
|
if (pkt.op === 8) {
|
||||||
|
sendHeartbeat();
|
||||||
|
}
|
||||||
|
// 收到 op=3 → 30秒后再发
|
||||||
|
if (pkt.op === 3) {
|
||||||
|
clearTimeout(heartbeatTimer);
|
||||||
|
heartbeatTimer = setTimeout(sendHeartbeat, 30000);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ver=3 (brotli) 解压支持
|
||||||
|
如果 protover 设为 3,需要引入 brotli 解码库(React Native 需要 polyfill)。
|
||||||
|
建议暂时保持 protover=2(zlib),pako 已支持。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、弹幕消息 cmd 类型
|
||||||
|
|
||||||
|
op=5 的 body 是 JSON,`cmd` 字段标识消息类型:
|
||||||
|
|
||||||
|
| cmd | 说明 |
|
||||||
|
|-----|------|
|
||||||
|
| `DANMU_MSG` | 弹幕消息,`info[1]` 是文字 |
|
||||||
|
| `SEND_GIFT` | 送礼物 |
|
||||||
|
| `GUARD_BUY` | 上舰 |
|
||||||
|
| `SUPER_CHAT_MESSAGE` | SC 醒目留言 |
|
||||||
|
| `INTERACT_WORD` | 进入直播间/关注 |
|
||||||
|
| `WATCHED_CHANGE` | 观看人数变化 |
|
||||||
|
| `ONLINE_RANK_COUNT` | 在线排名人数 |
|
||||||
|
| `ROOM_CHANGE` | 直播间信息变更 |
|
||||||
|
| `LIVE` | 开播 |
|
||||||
|
| `PREPARING` | 下播 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、DANMU_MSG info 数组结构
|
||||||
|
|
||||||
|
```
|
||||||
|
info[0][2] 弹幕颜色(十进制 RGB)
|
||||||
|
info[1] 弹幕文字内容
|
||||||
|
info[2][0] 用户 uid
|
||||||
|
info[2][1] 用户名
|
||||||
|
info[2][2] 是否房管(1=是)
|
||||||
|
info[3][0] 粉丝勋章等级
|
||||||
|
info[3][1] 粉丝勋章名
|
||||||
|
info[7] 舰长等级(0无,1总督,2提督,3舰长)
|
||||||
|
```
|
||||||
@@ -1,191 +1,103 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import pako from 'pako';
|
import { getLiveDanmakuHistory } from '../services/bilibili';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { getLiveDanmakuInfo } from '../services/bilibili';
|
|
||||||
import type { DanmakuItem } from '../services/types';
|
import type { DanmakuItem } from '../services/types';
|
||||||
|
|
||||||
// Read big-endian values directly from Uint8Array (avoids DataView offset issues)
|
const POLL_INTERVAL = 3000;
|
||||||
function r32(b: Uint8Array, o: number): number {
|
|
||||||
return ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0;
|
|
||||||
}
|
|
||||||
function r16(b: Uint8Array, o: number): number {
|
|
||||||
return ((b[o] << 8) | b[o + 1]) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build packet using raw bit ops – avoids DataView / TextEncoder quirks in Hermes.
|
// 匹配 admin 消息中的礼物信息,如 "xxx 赠送了 辣条 x5" 或 "xxx 投喂 小心心 ×1"
|
||||||
// Returns Uint8Array so ws.send() takes the ArrayBufferView path directly.
|
const GIFT_PATTERN = /(?:赠送|投喂)\s*(?:了\s*)?(.+?)\s*[xX×]\s*(\d+)/;
|
||||||
function buildPacket(body: string, op: number): Uint8Array {
|
|
||||||
const n = body.length; // JSON is ASCII-safe
|
|
||||||
const total = 16 + n;
|
|
||||||
const pkt = new Uint8Array(total);
|
|
||||||
// total_len (big-endian uint32)
|
|
||||||
pkt[0] = (total >>> 24) & 0xff;
|
|
||||||
pkt[1] = (total >>> 16) & 0xff;
|
|
||||||
pkt[2] = (total >>> 8) & 0xff;
|
|
||||||
pkt[3] = total & 0xff;
|
|
||||||
// header_len = 16 (big-endian uint16)
|
|
||||||
pkt[4] = 0x00; pkt[5] = 0x10;
|
|
||||||
// ver = 1
|
|
||||||
pkt[6] = 0x00; pkt[7] = 0x01;
|
|
||||||
// op (big-endian uint32)
|
|
||||||
pkt[8] = (op >>> 24) & 0xff;
|
|
||||||
pkt[9] = (op >>> 16) & 0xff;
|
|
||||||
pkt[10] = (op >>> 8) & 0xff;
|
|
||||||
pkt[11] = op & 0xff;
|
|
||||||
// seq = 1
|
|
||||||
pkt[12] = 0x00; pkt[13] = 0x00; pkt[14] = 0x00; pkt[15] = 0x01;
|
|
||||||
// body (ASCII chars)
|
|
||||||
for (let i = 0; i < n; i++) pkt[16 + i] = body.charCodeAt(i) & 0xff;
|
|
||||||
return pkt;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawPacket {
|
// 常见礼物名列表,用于匹配
|
||||||
op: number;
|
const KNOWN_GIFTS = new Set([
|
||||||
ver: number;
|
'辣条', '小心心', '打call', '干杯', '比心',
|
||||||
body: Uint8Array;
|
'吃瓜', '花式夸夸', '告白气球', '小电视飞船',
|
||||||
}
|
]);
|
||||||
|
|
||||||
function parseRawPackets(buf: Uint8Array): RawPacket[] {
|
export function useLiveDanmaku(roomId: number): {
|
||||||
const packets: RawPacket[] = [];
|
danmakus: DanmakuItem[];
|
||||||
let offset = 0;
|
giftCounts: Record<string, number>;
|
||||||
while (offset + 16 <= buf.length) {
|
} {
|
||||||
const totalLen = r32(buf, offset);
|
|
||||||
const headerLen = r16(buf, offset + 4);
|
|
||||||
const ver = r16(buf, offset + 6);
|
|
||||||
const op = r32(buf, offset + 8);
|
|
||||||
if (totalLen < headerLen || offset + totalLen > buf.length) break;
|
|
||||||
packets.push({ op, ver, body: buf.slice(offset + headerLen, offset + totalLen) });
|
|
||||||
offset += totalLen;
|
|
||||||
}
|
|
||||||
return packets;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDanmaku(buf: Uint8Array): DanmakuItem[] {
|
|
||||||
const result: DanmakuItem[] = [];
|
|
||||||
for (const pkt of parseRawPackets(buf)) {
|
|
||||||
if (pkt.op !== 5) continue;
|
|
||||||
if (pkt.ver === 2) {
|
|
||||||
// ver=2: zlib-compressed JSON array of messages
|
|
||||||
try {
|
|
||||||
result.push(...extractDanmaku(pako.inflate(pkt.body)));
|
|
||||||
} catch { /* ignore decompression errors */ }
|
|
||||||
} else {
|
|
||||||
// ver=0 or ver=1: raw JSON
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(new TextDecoder().decode(pkt.body));
|
|
||||||
if (msg.cmd === 'DANMU_MSG') {
|
|
||||||
const info = msg.info;
|
|
||||||
const text = info[1] as string;
|
|
||||||
// color is at info[0][2] (decimal 0xRRGGBB)
|
|
||||||
const color = (info[0]?.[2] as number) ?? 0xffffff;
|
|
||||||
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 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize message data to Uint8Array.
|
|
||||||
// React Native may deliver binary WebSocket frames as base64 strings instead of ArrayBuffer.
|
|
||||||
function toBytes(data: unknown): Uint8Array | null {
|
|
||||||
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
try {
|
|
||||||
const bStr = atob(data);
|
|
||||||
const bytes = new Uint8Array(bStr.length);
|
|
||||||
for (let i = 0; i < bStr.length; i++) bytes[i] = bStr.charCodeAt(i);
|
|
||||||
return bytes;
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLiveDanmaku(roomId: number): DanmakuItem[] {
|
|
||||||
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
||||||
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const [giftCounts, setGiftCounts] = useState<Record<string, number>>({});
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const seenTextsRef = useRef<Set<string>>(new Set());
|
||||||
|
const seenAdminRef = useRef<Set<string>>(new Set());
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
setDanmakus([]);
|
setDanmakus([]);
|
||||||
|
setGiftCounts({});
|
||||||
|
seenTextsRef.current.clear();
|
||||||
|
seenAdminRef.current.clear();
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function connect() {
|
async function poll() {
|
||||||
let token = '';
|
|
||||||
let host = 'wss://broadcastlv.chat.bilibili.com/sub';
|
|
||||||
try {
|
try {
|
||||||
const info = await getLiveDanmakuInfo(roomId);
|
const { danmakus: items, adminMsgs } = await getLiveDanmakuHistory(roomId);
|
||||||
token = info.token;
|
if (cancelled) return;
|
||||||
host = info.host;
|
|
||||||
|
|
||||||
|
// 去重弹幕
|
||||||
} catch (e) {
|
const newItems = items.filter(item => {
|
||||||
console.warn('[danmaku] getDanmuInfo failed:', e);
|
const key = `${item.uname ?? ''}:${item.text}`;
|
||||||
}
|
if (seenTextsRef.current.has(key)) return false;
|
||||||
if (cancelled) return;
|
seenTextsRef.current.add(key);
|
||||||
|
return true;
|
||||||
// Bilibili requires buvid3 cookie for auth; React Native doesn't send cookies
|
|
||||||
// automatically, so we pass it via the non-standard options.headers argument.
|
|
||||||
const [buvid3, sessdata] = await Promise.all([
|
|
||||||
AsyncStorage.getItem('buvid3'),
|
|
||||||
AsyncStorage.getItem('SESSDATA'),
|
|
||||||
]);
|
|
||||||
const cookieParts = buvid3 ? [`buvid3=${buvid3}`] : [];
|
|
||||||
if (sessdata) cookieParts.push(`SESSDATA=${sessdata}`);
|
|
||||||
|
|
||||||
// React Native supports non-standard options.headers in the WebSocket constructor
|
|
||||||
const ws = new (WebSocket as any)(host, [], {
|
|
||||||
headers: cookieParts.length ? { Cookie: cookieParts.join('; ') } : {},
|
|
||||||
}) as WebSocket;
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
|
|
||||||
const authBody = JSON.stringify({
|
|
||||||
uid: 0,
|
|
||||||
roomid: roomId,
|
|
||||||
protover: 2,
|
|
||||||
platform: 'h5',
|
|
||||||
type: 2,
|
|
||||||
key: token,
|
|
||||||
});
|
});
|
||||||
const authPkt = buildPacket(authBody, 7);
|
if (newItems.length > 0) {
|
||||||
|
setDanmakus(prev => [...prev, ...newItems]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 admin 消息中的礼物
|
||||||
|
const newGifts: Record<string, number> = {};
|
||||||
|
for (const msg of adminMsgs) {
|
||||||
|
console.log(msg,123)
|
||||||
|
// 去重 admin 消息
|
||||||
|
if (seenAdminRef.current.has(msg)) continue;
|
||||||
|
seenAdminRef.current.add(msg);
|
||||||
|
|
||||||
ws.send(authPkt.buffer as ArrayBuffer);
|
const match = msg.match(GIFT_PATTERN);
|
||||||
|
if (match) {
|
||||||
|
const giftName = match[1].trim();
|
||||||
|
const count = parseInt(match[2], 10);
|
||||||
|
if (KNOWN_GIFTS.has(giftName) && count > 0) {
|
||||||
|
newGifts[giftName] = (newGifts[giftName] ?? 0) + count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(newGifts).length > 0) {
|
||||||
|
setGiftCounts(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const [name, count] of Object.entries(newGifts)) {
|
||||||
|
next[name] = (next[name] ?? 0) + count;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
heartbeatRef.current = setInterval(() => {
|
// 防止 seen set 无限增长
|
||||||
if (ws.readyState === WebSocket.OPEN) ws.send(buildPacket('', 2));
|
if (seenTextsRef.current.size > 2000) {
|
||||||
}, 30000);
|
const arr = Array.from(seenTextsRef.current);
|
||||||
};
|
seenTextsRef.current = new Set(arr.slice(-1000));
|
||||||
|
}
|
||||||
ws.onmessage = (e: MessageEvent) => {
|
if (seenAdminRef.current.size > 500) {
|
||||||
const bytes = toBytes(e.data);
|
const arr = Array.from(seenAdminRef.current);
|
||||||
if (!bytes) return;
|
seenAdminRef.current = new Set(arr.slice(-250));
|
||||||
const items = extractDanmaku(bytes);
|
}
|
||||||
if (items.length > 0) setDanmakus(prev => [...prev, ...items]);
|
} catch (e) {
|
||||||
};
|
console.warn('[danmaku] poll failed:', e);
|
||||||
|
}
|
||||||
ws.onerror = () => {};
|
if (!cancelled) {
|
||||||
ws.onclose = () => {
|
timerRef.current = setTimeout(poll, POLL_INTERVAL);
|
||||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connect();
|
poll();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
wsRef.current?.close();
|
|
||||||
};
|
};
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
return danmakus;
|
return { danmakus, giftCounts };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,19 +337,32 @@ export async function searchVideos(keyword: string, page = 1): Promise<VideoItem
|
|||||||
} as VideoItem));
|
} as VideoItem));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLiveDanmakuInfo(roomId: number): Promise<{ token: string; host: string }> {
|
export async function getLiveDanmakuHistory(roomId: number): Promise<{
|
||||||
const { imgKey, subKey } = await getWbiKeys();
|
danmakus: DanmakuItem[];
|
||||||
const signed = signWbi({ id: roomId, type: 0 }, imgKey, subKey);
|
adminMsgs: string[];
|
||||||
const res = await api.get(`${LIVE_BASE}/xlive/web-room/v1/index/getDanmuInfo`, {
|
}> {
|
||||||
params: signed,
|
const res = await api.get(`${LIVE_BASE}/xlive/web-room/v1/dM/gethistory`, {
|
||||||
|
params: { roomid: roomId },
|
||||||
});
|
});
|
||||||
const data = res.data.data;
|
|
||||||
const hostList: any[] = data?.host_list ?? [];
|
const room: any[] = res.data?.data?.room ?? [];
|
||||||
const hostInfo = hostList.find(h => h.wss_port === 443) ?? hostList[0];
|
const admin: any[] = res.data?.data?.admin ?? [];
|
||||||
const host = hostInfo
|
const adminMsgs = admin.map((a: any) => a.text ?? '').filter(Boolean);
|
||||||
? `wss://${hostInfo.host}:${hostInfo.wss_port}/sub`
|
console.log(adminMsgs,'adminMsgs')
|
||||||
: 'wss://broadcastlv.chat.bilibili.com/sub';
|
const danmakus = room.map((m: any) => ({
|
||||||
return { token: data?.token ?? '', host };
|
time: 0,
|
||||||
|
mode: 1 as const,
|
||||||
|
fontSize: 25,
|
||||||
|
color: m.text_color ? parseInt(m.text_color.replace('#', ''), 16) : 0xffffff,
|
||||||
|
text: m.text ?? '',
|
||||||
|
uname: m.nickname ?? m.uname,
|
||||||
|
isAdmin: m.isadmin === 1,
|
||||||
|
guardLevel: m.guard_level ?? 0,
|
||||||
|
medalLevel: Array.isArray(m.medal) && m.medal.length > 0 ? m.medal[0] as number : undefined,
|
||||||
|
medalName: Array.isArray(m.medal) && m.medal.length > 1 ? m.medal[1] as string : undefined,
|
||||||
|
timeline: m.timeline as string | undefined,
|
||||||
|
}));
|
||||||
|
return { danmakus, adminMsgs };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export interface DanmakuItem {
|
|||||||
guardLevel?: number; // 0=无, 1=总督, 2=提督, 3=舰长
|
guardLevel?: number; // 0=无, 1=总督, 2=提督, 3=舰长
|
||||||
medalLevel?: number;
|
medalLevel?: number;
|
||||||
medalName?: string;
|
medalName?: string;
|
||||||
|
timeline?: string; // "2024-01-01 12:00:00" 直播弹幕时间戳
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveRoom {
|
export interface LiveRoom {
|
||||||
|
|||||||
Reference in New Issue
Block a user