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)
|
||||
const actualRoomId = room?.roomid ?? id;
|
||||
const danmakus = useLiveDanmaku(isLive ? actualRoomId : 0);
|
||||
const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0);
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
{/* TopBar */}
|
||||
@@ -156,6 +156,7 @@ export default function LiveDetailScreen() {
|
||||
style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]}
|
||||
hideHeader
|
||||
maxItems={500}
|
||||
giftCounts={giftCounts}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Animated,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { DanmakuItem } from "../services/types";
|
||||
@@ -21,6 +22,7 @@ interface Props {
|
||||
style?: object | object[];
|
||||
hideHeader?: boolean;
|
||||
maxItems?: number;
|
||||
giftCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
interface DisplayedDanmaku extends DanmakuItem {
|
||||
@@ -34,12 +36,38 @@ const FAST_DRIP_INTERVAL = 100;
|
||||
const QUEUE_FAST_THRESHOLD = 50;
|
||||
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 {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
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({
|
||||
danmakus,
|
||||
currentTime,
|
||||
@@ -48,6 +76,7 @@ export default function DanmakuList({
|
||||
style,
|
||||
hideHeader,
|
||||
maxItems = 100,
|
||||
giftCounts,
|
||||
}: Props) {
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const [displayedItems, setDisplayedItems] = useState<DisplayedDanmaku[]>([]);
|
||||
@@ -60,27 +89,46 @@ export default function DanmakuList({
|
||||
const isAtBottomRef = useRef(true);
|
||||
const danmakusRef = useRef(danmakus);
|
||||
|
||||
// Reset everything when danmakus array reference changes (video switch)
|
||||
// Detect changes in the danmakus array
|
||||
useEffect(() => {
|
||||
if (danmakusRef.current !== danmakus) {
|
||||
const prev = danmakusRef.current;
|
||||
if (prev === danmakus) return;
|
||||
danmakusRef.current = danmakus;
|
||||
|
||||
if (danmakus.length === 0) {
|
||||
queueRef.current = [];
|
||||
processedIndexRef.current = 0;
|
||||
lastTimeRef.current = 0;
|
||||
setDisplayedItems([]);
|
||||
setUnseenCount(0);
|
||||
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(() => {
|
||||
if (!visible || danmakus.length === 0) return;
|
||||
if (!visible || danmakus.length === 0 || hideHeader) return;
|
||||
|
||||
const prevTime = lastTimeRef.current;
|
||||
lastTimeRef.current = currentTime;
|
||||
|
||||
// Seek detection
|
||||
if (Math.abs(currentTime - prevTime) > SEEK_THRESHOLD) {
|
||||
queueRef.current = [];
|
||||
processedIndexRef.current = 0;
|
||||
@@ -88,9 +136,7 @@ export default function DanmakuList({
|
||||
setUnseenCount(0);
|
||||
isAtBottomRef.current = true;
|
||||
|
||||
// Re-enqueue danmakus up to current time
|
||||
const catchUp = danmakus.filter((d) => d.time <= currentTime);
|
||||
// Only enqueue recent ones to avoid flooding
|
||||
const tail = catchUp.slice(-20);
|
||||
queueRef.current = tail;
|
||||
processedIndexRef.current = danmakus.findIndex(
|
||||
@@ -102,17 +148,16 @@ export default function DanmakuList({
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal progression: enqueue danmakus between prevTime and currentTime
|
||||
const sorted = danmakus; // assumed sorted by time
|
||||
const sorted = danmakus;
|
||||
let i = processedIndexRef.current;
|
||||
while (i < sorted.length && sorted[i].time <= currentTime) {
|
||||
queueRef.current.push(sorted[i]);
|
||||
i++;
|
||||
}
|
||||
processedIndexRef.current = i;
|
||||
}, [currentTime, danmakus, visible]);
|
||||
}, [currentTime, danmakus, visible, hideHeader]);
|
||||
|
||||
// Drip interval: pop from queue, append to displayed
|
||||
// Drip interval
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
@@ -136,14 +181,12 @@ export default function DanmakuList({
|
||||
|
||||
setDisplayedItems((prev) => {
|
||||
const next = [...prev, displayed];
|
||||
// 超出上限时批量裁剪到一半,减少频繁截断导致的抖动
|
||||
return next.length > maxItems
|
||||
? next.slice(-Math.floor(maxItems / 2))
|
||||
: next;
|
||||
});
|
||||
|
||||
if (isAtBottomRef.current) {
|
||||
// Auto-scroll on next frame
|
||||
requestAnimationFrame(() => {
|
||||
flatListRef.current?.scrollToEnd({ animated: true });
|
||||
});
|
||||
@@ -165,9 +208,7 @@ export default function DanmakuList({
|
||||
const distanceFromBottom =
|
||||
contentSize.height - layoutMeasurement.height - contentOffset.y;
|
||||
isAtBottomRef.current = distanceFromBottom < 40;
|
||||
if (isAtBottomRef.current) {
|
||||
setUnseenCount(0);
|
||||
}
|
||||
if (isAtBottomRef.current) setUnseenCount(0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -182,54 +223,59 @@ export default function DanmakuList({
|
||||
isAtBottomRef.current = true;
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(({ item }: { item: DisplayedDanmaku }) => {
|
||||
const dotColor = danmakuColorToCss(item.color);
|
||||
// ─── Live mode render (B站-style chat) ─────────────────────────────────────
|
||||
const renderLiveItem = useCallback(({ item }: { item: DisplayedDanmaku }) => {
|
||||
const guard = item.guardLevel ? GUARD_LABELS[item.guardLevel] : null;
|
||||
const timeStr = formatLiveTime(item.timeline);
|
||||
return (
|
||||
<Animated.View style={[styles.bubble, { opacity: item._fadeAnim }]}>
|
||||
{!hideHeader && (
|
||||
<View style={[styles.colorDot, { backgroundColor: dotColor }]} />
|
||||
<Animated.View style={[liveStyles.row, { opacity: item._fadeAnim }]}>
|
||||
{timeStr ? (
|
||||
<Text style={liveStyles.time}>{timeStr}</Text>
|
||||
) : null}
|
||||
<View style={liveStyles.msgBody}>
|
||||
{guard && (
|
||||
<View style={[liveStyles.guardTag, { backgroundColor: guard.color }]}>
|
||||
<Text style={liveStyles.guardTagText}>{guard.text}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.bubbleContent}>
|
||||
{item.uname && (
|
||||
<View style={styles.userRow}>
|
||||
{item.isAdmin && (
|
||||
<View style={[styles.badge, styles.badgeAdmin]}>
|
||||
<Text style={styles.badgeText}>房管</Text>
|
||||
<View style={[liveStyles.guardTag, { backgroundColor: "#e53935" }]}>
|
||||
<Text style={liveStyles.guardTagText}>房管</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.guardLevel === 1 && (
|
||||
<View style={[styles.badge, styles.badgeGuard1]}>
|
||||
<Text style={styles.badgeText}>总督</Text>
|
||||
{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>
|
||||
)}
|
||||
{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}>
|
||||
<Text style={liveStyles.uname} numberOfLines={1}>
|
||||
{item.uname ?? "匿名"}
|
||||
</Text>
|
||||
<Text style={liveStyles.colon}>:</Text>
|
||||
<Text style={liveStyles.text} numberOfLines={2}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</View>
|
||||
{!hideHeader && (
|
||||
<Text style={styles.timestamp}>{formatTimestamp(item.time)}</Text>
|
||||
)}
|
||||
</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(
|
||||
(item: DisplayedDanmaku) => String(item._key),
|
||||
@@ -266,9 +312,9 @@ export default function DanmakuList({
|
||||
ref={flatListRef}
|
||||
data={displayedItems}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={hideHeader ? renderLiveItem : renderVideoItem}
|
||||
style={hideHeader ? liveStyles.list : styles.list}
|
||||
contentContainerStyle={hideHeader ? liveStyles.listContent : styles.listContent}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
scrollEventThrottle={16}
|
||||
@@ -290,10 +336,40 @@ export default function DanmakuList({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Video mode styles ────────────────────────────────────────────────────────
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: "#fff",
|
||||
@@ -342,35 +418,8 @@ const styles = StyleSheet.create({
|
||||
marginTop: 6,
|
||||
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: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: "#333",
|
||||
lineHeight: 18,
|
||||
@@ -402,3 +451,147 @@ const styles = StyleSheet.create({
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
29
dev-proxy.js
29
dev-proxy.js
@@ -1,6 +1,8 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const zlib = require('zlib');
|
||||
const express = require('express');
|
||||
const WsLib = require('ws');
|
||||
const app = express();
|
||||
|
||||
// CORS: allow any local origin (Expo web dev server)
|
||||
@@ -104,4 +106,29 @@ app.use('/bilibili-img', (req, res) => {
|
||||
});
|
||||
|
||||
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 pako from 'pako';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { getLiveDanmakuInfo } from '../services/bilibili';
|
||||
import { getLiveDanmakuHistory } from '../services/bilibili';
|
||||
import type { DanmakuItem } from '../services/types';
|
||||
|
||||
// Read big-endian values directly from Uint8Array (avoids DataView offset issues)
|
||||
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;
|
||||
}
|
||||
const POLL_INTERVAL = 3000;
|
||||
|
||||
// Build packet using raw bit ops – avoids DataView / TextEncoder quirks in Hermes.
|
||||
// Returns Uint8Array so ws.send() takes the ArrayBufferView path directly.
|
||||
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;
|
||||
}
|
||||
// 匹配 admin 消息中的礼物信息,如 "xxx 赠送了 辣条 x5" 或 "xxx 投喂 小心心 ×1"
|
||||
const GIFT_PATTERN = /(?:赠送|投喂)\s*(?:了\s*)?(.+?)\s*[xX×]\s*(\d+)/;
|
||||
|
||||
interface RawPacket {
|
||||
op: number;
|
||||
ver: number;
|
||||
body: Uint8Array;
|
||||
}
|
||||
// 常见礼物名列表,用于匹配
|
||||
const KNOWN_GIFTS = new Set([
|
||||
'辣条', '小心心', '打call', '干杯', '比心',
|
||||
'吃瓜', '花式夸夸', '告白气球', '小电视飞船',
|
||||
]);
|
||||
|
||||
function parseRawPackets(buf: Uint8Array): RawPacket[] {
|
||||
const packets: RawPacket[] = [];
|
||||
let offset = 0;
|
||||
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[] {
|
||||
export function useLiveDanmaku(roomId: number): {
|
||||
danmakus: DanmakuItem[];
|
||||
giftCounts: Record<string, number>;
|
||||
} {
|
||||
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
||||
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [giftCounts, setGiftCounts] = useState<Record<string, number>>({});
|
||||
const seenTextsRef = useRef<Set<string>>(new Set());
|
||||
const seenAdminRef = useRef<Set<string>>(new Set());
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
setDanmakus([]);
|
||||
setGiftCounts({});
|
||||
seenTextsRef.current.clear();
|
||||
seenAdminRef.current.clear();
|
||||
let cancelled = false;
|
||||
|
||||
async function connect() {
|
||||
let token = '';
|
||||
let host = 'wss://broadcastlv.chat.bilibili.com/sub';
|
||||
async function poll() {
|
||||
try {
|
||||
const info = await getLiveDanmakuInfo(roomId);
|
||||
token = info.token;
|
||||
host = info.host;
|
||||
|
||||
|
||||
} catch (e) {
|
||||
console.warn('[danmaku] getDanmuInfo failed:', e);
|
||||
}
|
||||
const { danmakus: items, adminMsgs } = await getLiveDanmakuHistory(roomId);
|
||||
if (cancelled) return;
|
||||
|
||||
// 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 newItems = items.filter(item => {
|
||||
const key = `${item.uname ?? ''}:${item.text}`;
|
||||
if (seenTextsRef.current.has(key)) return false;
|
||||
seenTextsRef.current.add(key);
|
||||
return true;
|
||||
});
|
||||
const authPkt = buildPacket(authBody, 7);
|
||||
|
||||
|
||||
ws.send(authPkt.buffer as ArrayBuffer);
|
||||
|
||||
heartbeatRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(buildPacket('', 2));
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
const bytes = toBytes(e.data);
|
||||
if (!bytes) return;
|
||||
const items = extractDanmaku(bytes);
|
||||
if (items.length > 0) setDanmakus(prev => [...prev, ...items]);
|
||||
};
|
||||
|
||||
ws.onerror = () => {};
|
||||
ws.onclose = () => {
|
||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||
};
|
||||
if (newItems.length > 0) {
|
||||
setDanmakus(prev => [...prev, ...newItems]);
|
||||
}
|
||||
|
||||
connect();
|
||||
// 解析 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);
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// 防止 seen set 无限增长
|
||||
if (seenTextsRef.current.size > 2000) {
|
||||
const arr = Array.from(seenTextsRef.current);
|
||||
seenTextsRef.current = new Set(arr.slice(-1000));
|
||||
}
|
||||
if (seenAdminRef.current.size > 500) {
|
||||
const arr = Array.from(seenAdminRef.current);
|
||||
seenAdminRef.current = new Set(arr.slice(-250));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[danmaku] poll failed:', e);
|
||||
}
|
||||
if (!cancelled) {
|
||||
timerRef.current = setTimeout(poll, POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||
wsRef.current?.close();
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
return danmakus;
|
||||
return { danmakus, giftCounts };
|
||||
}
|
||||
|
||||
@@ -337,19 +337,32 @@ export async function searchVideos(keyword: string, page = 1): Promise<VideoItem
|
||||
} as VideoItem));
|
||||
}
|
||||
|
||||
export async function getLiveDanmakuInfo(roomId: number): Promise<{ token: string; host: string }> {
|
||||
const { imgKey, subKey } = await getWbiKeys();
|
||||
const signed = signWbi({ id: roomId, type: 0 }, imgKey, subKey);
|
||||
const res = await api.get(`${LIVE_BASE}/xlive/web-room/v1/index/getDanmuInfo`, {
|
||||
params: signed,
|
||||
export async function getLiveDanmakuHistory(roomId: number): Promise<{
|
||||
danmakus: DanmakuItem[];
|
||||
adminMsgs: string[];
|
||||
}> {
|
||||
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 hostInfo = hostList.find(h => h.wss_port === 443) ?? hostList[0];
|
||||
const host = hostInfo
|
||||
? `wss://${hostInfo.host}:${hostInfo.wss_port}/sub`
|
||||
: 'wss://broadcastlv.chat.bilibili.com/sub';
|
||||
return { token: data?.token ?? '', host };
|
||||
|
||||
const room: any[] = res.data?.data?.room ?? [];
|
||||
const admin: any[] = res.data?.data?.admin ?? [];
|
||||
const adminMsgs = admin.map((a: any) => a.text ?? '').filter(Boolean);
|
||||
console.log(adminMsgs,'adminMsgs')
|
||||
const danmakus = room.map((m: any) => ({
|
||||
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[]> {
|
||||
|
||||
@@ -122,6 +122,7 @@ export interface DanmakuItem {
|
||||
guardLevel?: number; // 0=无, 1=总督, 2=提督, 3=舰长
|
||||
medalLevel?: number;
|
||||
medalName?: string;
|
||||
timeline?: string; // "2024-01-01 12:00:00" 直播弹幕时间戳
|
||||
}
|
||||
|
||||
export interface LiveRoom {
|
||||
|
||||
Reference in New Issue
Block a user