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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user