From e4760b1f070f3b97051148dd078e9f969e496f5d Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 17 Mar 2026 19:52:26 +0800 Subject: [PATCH] 1 --- app/live/[roomId].tsx | 3 +- components/DanmakuList.tsx | 371 +++++++++++++++++++------ dev-proxy.js | 33 ++- docs/bilibili-live-danmaku-research.md | 218 +++++++++++++++ hooks/useLiveDanmaku.ts | 238 +++++----------- services/bilibili.ts | 37 ++- services/types.ts | 1 + 7 files changed, 633 insertions(+), 268 deletions(-) create mode 100644 docs/bilibili-live-danmaku-research.md diff --git a/app/live/[roomId].tsx b/app/live/[roomId].tsx index 73be452..53753f5 100644 --- a/app/live/[roomId].tsx +++ b/app/live/[roomId].tsx @@ -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 ( {/* TopBar */} @@ -156,6 +156,7 @@ export default function LiveDetailScreen() { style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]} hideHeader maxItems={500} + giftCounts={giftCounts} /> )} diff --git a/components/DanmakuList.tsx b/components/DanmakuList.tsx index e68a525..dd97a20 100644 --- a/components/DanmakuList.tsx +++ b/components/DanmakuList.tsx @@ -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; } 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 = { + 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(null); const [displayedItems, setDisplayedItems] = useState([]); @@ -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 ( - - {!hideHeader && ( - - )} - - {item.uname && ( - - {item.isAdmin && ( - - 房管 - - )} - {item.guardLevel === 1 && ( - - 总督 - - )} - {item.guardLevel === 2 && ( - - 提督 - - )} - {item.guardLevel === 3 && ( - - 舰长 - - )} - {item.medalLevel != null && ( - - {item.medalName} {item.medalLevel} - - )} - {item.uname} + + {timeStr ? ( + {timeStr} + ) : null} + + {guard && ( + + {guard.text} )} - + {item.isAdmin && ( + + 房管 + + )} + {item.medalLevel != null && item.medalName && ( + + {item.medalName} + + {item.medalLevel} + + + )} + + {item.uname ?? "匿名"} + + + {item.text} - {!hideHeader && ( - {formatTimestamp(item.time)} - )} ); - }, [hideHeader]); + }, []); + + // ─── Video mode render (original bubble) ─────────────────────────────────── + const renderVideoItem = useCallback(({ item }: { item: DisplayedDanmaku }) => { + const dotColor = danmakuColorToCss(item.color); + return ( + + + + {item.text} + + {formatTimestamp(item.time)} + + ); + }, []); 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({ )} )} + + {/* 常见礼物栏 — 仅直播模式显示 */} + {hideHeader && visible && ( + + + {COMMON_GIFTS.map((g) => { + const count = giftCounts?.[g.name] ?? 0; + return ( + + + {count > 0 && ( + + +{count} + + )} + {g.icon} + + {g.name} + {g.price} + + ); + })} + + + )} ); } +// ─── 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, + }, +}); diff --git a/dev-proxy.js b/dev-proxy.js index a8819e5..239e9af 100644 --- a/dev-proxy.js +++ b/dev-proxy.js @@ -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://:${PORT}/bilibili-danmaku-ws`) +); diff --git a/docs/bilibili-live-danmaku-research.md b/docs/bilibili-live-danmaku-research.md new file mode 100644 index 0000000..a47ca0c --- /dev/null +++ b/docs/bilibili-live-danmaku-research.md @@ -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": "", + "buvid": "" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| 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舰长) +``` diff --git a/hooks/useLiveDanmaku.ts b/hooks/useLiveDanmaku.ts index d7eb9e3..4e6d271 100644 --- a/hooks/useLiveDanmaku.ts +++ b/hooks/useLiveDanmaku.ts @@ -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; +} { const [danmakus, setDanmakus] = useState([]); - const heartbeatRef = useRef | null>(null); - const wsRef = useRef(null); + const [giftCounts, setGiftCounts] = useState>({}); + const seenTextsRef = useRef>(new Set()); + const seenAdminRef = useRef>(new Set()); + const timerRef = useRef | 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 = {}; + 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 }; } diff --git a/services/bilibili.ts b/services/bilibili.ts index 4191d03..3716633 100644 --- a/services/bilibili.ts +++ b/services/bilibili.ts @@ -337,19 +337,32 @@ export async function searchVideos(keyword: string, page = 1): Promise { - 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 { diff --git a/services/types.ts b/services/types.ts index e91bb99..1589e23 100644 --- a/services/types.ts +++ b/services/types.ts @@ -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 {