This commit is contained in:
Developer
2026-03-17 19:52:26 +08:00
parent 714d28358f
commit e4760b1f07
7 changed files with 633 additions and 268 deletions

View File

@@ -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}
/>
</>
)}

View File

@@ -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) {
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 }]} />
)}
<View style={styles.bubbleContent}>
{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>
<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>
)}
<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}
</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,
},
});

View File

@@ -1,6 +1,8 @@
const http = require('http');
const https = require('https');
const zlib = require('zlib');
const zlib = require('zlib');
const express = require('express');
const WsLib = require('ws');
const app = express();
// CORS: allow any local origin (Expo web dev server)
@@ -103,5 +105,30 @@ app.use('/bilibili-img', (req, res) => {
makeProxy(host)(req, res);
});
const PORT = process.env.PROXY_PORT || 3001;
app.listen(PORT, () => console.log(`[Proxy] http://localhost:${PORT}`));
const PORT = process.env.PROXY_PORT || 3001;
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`)
);

View 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字节包头
```
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│ 03 │ 45 │ 67 │ 811 │ 1215 │
│ 包总长度 │ 头部长度 │ 协议版本 │ 操作码 │ 序列号 │
│ 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 | 是 | 推荐 3brotli或 2zlib |
| 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` | 2zlib | 3brotli或 2 | 先用 2brotli 需 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=2zlibpako 已支持。
---
## 八、弹幕消息 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舰长
```

View File

@@ -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;
const { danmakus: items, adminMsgs } = await getLiveDanmakuHistory(roomId);
if (cancelled) return;
} catch (e) {
console.warn('[danmaku] getDanmuInfo failed:', e);
}
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);
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(() => {
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);
};
// 防止 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);
}
}
connect();
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 };
}

View File

@@ -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[]> {

View File

@@ -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 {