diff --git a/bun.lockb b/bun.lockb index 4828188..e1c531f 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 427ef01..a66c73c 100644 --- a/package.json +++ b/package.json @@ -11,22 +11,24 @@ "dependencies": { "@microsoft/signalr": "^8.0.7", "@microsoft/signalr-protocol-msgpack": "^8.0.7", - "@types/node": "^22.9.0", - "@typescript-eslint/eslint-plugin": "^8.13.0", + "@mixer/postmessage-rpc": "^1.1.4", + "@typescript-eslint/eslint-plugin": "^8.17.0", "@vicons/fluent": "^0.12.0", - "@vitejs/plugin-basic-ssl": "^1.1.0", - "@vitejs/plugin-vue": "^5.1.4", + "@vitejs/plugin-basic-ssl": "^1.2.0", + "@vitejs/plugin-vue": "^5.2.1", "@vue/cli": "^5.0.8", - "@vueuse/core": "^11.2.0", - "@vueuse/router": "^11.2.0", + "@vueuse/core": "^12.0.0", + "@vueuse/router": "^12.0.0", "@wangeditor/editor": "^5.1.23", "@wangeditor/editor-for-vue": "^5.1.12", + "bilibili-live-ws": "^6.3.1", + "brotli-compress": "^1.3.3", "date-fns": "^4.1.0", "easy-speech": "^2.4.0", "echarts": "^5.5.1", - "eslint": "^9.14.0", + "eslint": "^9.16.0", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-oxlint": "^0.11.0", + "eslint-plugin-oxlint": "^0.14.0", "eslint-plugin-prettier": "^5.2.1", "fast-xml-parser": "^4.5.0", "file-saver": "^2.0.5", @@ -37,19 +39,19 @@ "monaco-editor": "^0.52.0", "music-metadata-browser": "^2.5.11", "peerjs": "^1.5.4", - "pinia": "^2.2.6", - "prettier": "^3.3.3", + "pinia": "^2.2.8", + "prettier": "^3.4.1", "qrcode.vue": "^3.6.0", "queue-typescript": "^1.0.1", - "unplugin-vue-markdown": "^0.26.2", - "uuid": "^11.0.2", - "vite": "^5.4.10", + "unplugin-vue-markdown": "^0.27.1", + "uuid": "^11.0.3", + "vite": "5.4.11", "vite-plugin-monaco-editor": "^1.1.0", "vite-svg-loader": "^5.1.0", - "vue": "3.5.12", + "vue": "3.5.13", "vue-echarts": "^7.0.3", "vue-request": "^2.0.4", - "vue-router": "^4.4.5", + "vue-router": "^4.5.0", "vue-turnstile": "^1.0.11", "vue3-aplayer": "^1.7.3", "vue3-marquee": "^4.2.2", @@ -58,19 +60,19 @@ "xlsx": "^0.18.5" }, "devDependencies": { - "@eslint/eslintrc": "^3.1.0", - "@types/bun": "^1.1.13", + "@eslint/eslintrc": "^3.2.0", + "@types/bun": "^1.1.14", "@types/eslint": "^9.6.1", "@types/obs-studio": "^2.17.2", "@types/uuid": "^10.0.0", - "@typescript-eslint/parser": "^8.13.0", + "@typescript-eslint/parser": "^8.17.0", "@vicons/ionicons5": "^0.12.0", - "@vitejs/plugin-vue-jsx": "^4.0.1", - "@vue/eslint-config-typescript": "^14.1.3", + "@vitejs/plugin-vue-jsx": "^4.1.1", + "@vue/eslint-config-typescript": "^14.1.4", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-vue": "^9.30.0", - "naive-ui": "^2.40.1", + "eslint-plugin-vue": "^9.32.0", + "naive-ui": "^2.40.3", "stylus": "^0.64.0", - "typescript": "^5.6.3" + "typescript": "^5.7.2" } } \ No newline at end of file diff --git a/src/api/account.ts b/src/api/account.ts index d5311fa..80b34fd 100644 --- a/src/api/account.ts +++ b/src/api/account.ts @@ -9,6 +9,7 @@ import { useRoute } from 'vue-router' export const ACCOUNT = ref({} as AccountInfo) export const isLoadingAccount = ref(true) +const route = useRoute() const { message } = createDiscreteApi(['message']) const cookie = useLocalStorage('JWT_Token', '') @@ -46,7 +47,6 @@ export async function GetSelfAccount() { } export function UpdateAccountLoop() { setInterval(() => { - const route = useRoute() if (ACCOUNT.value && route?.name != 'question-display') { // 防止在问题详情页刷新 GetSelfAccount() diff --git a/src/data/DanmakuClients/BaseDanmakuClient.ts b/src/data/DanmakuClients/BaseDanmakuClient.ts new file mode 100644 index 0000000..322c17c --- /dev/null +++ b/src/data/DanmakuClients/BaseDanmakuClient.ts @@ -0,0 +1,175 @@ +import { EventModel } from '@/api/api-models' +import { KeepLiveWS } from 'bilibili-live-ws/browser' + +export default abstract class BaseDanmakuClient { + constructor() { + this.client = null + } + + public client: KeepLiveWS | null + + public state: 'padding' | 'connected' | 'connecting' | 'disconnected' = + 'padding' + + public abstract type: 'openlive' | 'direct' + + public eventsAsModel: { + danmaku: ((arg1: EventModel, arg2?: any) => void)[] + gift: ((arg1: EventModel, arg2?: any) => void)[] + sc: ((arg1: EventModel, arg2?: any) => void)[] + guard: ((arg1: EventModel, arg2?: any) => void)[] + all: ((arg1: any) => void)[] + } = { + danmaku: [], + gift: [], + sc: [], + guard: [], + all: [] + } + + public async Start(): Promise<{ success: boolean; message: string }> { + if (this.state == 'connected') { + return { + success: true, + message: '弹幕客户端已启动' + } + } + if (this.state == 'connecting') { + return { + success: false, + message: '弹幕客户端正在启动' + } + } + this.state = 'connecting' + try { + if (!this.client) { + console.log(`[${this.type}] 正在启动弹幕客户端`) + const result = await this.initClient() + if (result.success) { + this.state = 'connected' + } + return result + } else { + console.warn(`[${this.type}] 弹幕客户端已被启动过`) + this.state = 'connected' + return { + success: false, + message: '弹幕客户端已被启动过' + } + } + } catch (err) { + console.error(err) + return { + success: false, + message: err ? err.toString() : '未知错误' + } + } + } + public Stop() { + if (this.state === 'disconnected') { + return + } + this.state = 'disconnected' + if (this.client) { + console.log(`[${this.type}] 正在停止弹幕客户端`) + this.client.close() + } else { + console.warn(`[${this.type}] 弹幕客户端未被启动, 忽略`) + } + this.eventsAsModel = { + danmaku: [], + gift: [], + sc: [], + guard: [], + all: [] + } + } + protected abstract initClient(): Promise<{ + success: boolean + message: string + }> + protected async initClientInner( + chatClient: KeepLiveWS + ): Promise<{ success: boolean; message: string }> { + let isConnected = false + let isError = false + let errorMsg = '' + chatClient.on('error', (err: any) => { + console.error(err) + isError = true + errorMsg = err + }) + chatClient.on('live', () => { + isConnected = true + }) + chatClient.on('close', () => { + console.log(`[${this.type}] 弹幕客户端已关闭`) + }) + chatClient.on('msg', (cmd) => this.onRawMessage(cmd)) + + this.client = chatClient + while (!isConnected && !isError) { + await new Promise((resolve) => { + setTimeout(resolve, 1000) + }) + } + if (isError) { + this.client.close() + this.client = null + } + return { + success: !isError, + message: errorMsg + } + } + + public onRawMessage = (command: any) => { + this.eventsAsModel.all?.forEach((d) => { + d(command) + }) + } + + public abstract onDanmaku(command: any): void + public abstract onGift(command: any): void + public abstract onSC(command: any): void + public abstract onGuard(command: any): void + public on( + eventName: 'danmaku', + listener: (arg1: EventModel, arg2?: any) => void + ): this + public on( + eventName: 'gift', + listener: (arg1: EventModel, arg2?: any) => void + ): this + public on( + eventName: 'sc', + listener: (arg1: EventModel, arg2?: any) => void + ): this + public on( + eventName: 'guard', + listener: (arg1: EventModel, arg2?: any) => void + ): this + public on(eventName: 'all', listener: (arg1: any) => void): this + public on( + eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all', + listener: (...args: any[]) => void + ): this { + if (!this.eventsAsModel[eventName]) { + this.eventsAsModel[eventName] = [] + } + this.eventsAsModel[eventName].push(listener) + return this + } + public off( + eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all', + listener: (...args: any[]) => void + ): this { + if (this.eventsAsModel[eventName]) { + const index = this.eventsAsModel[eventName].indexOf(listener) + if (index > -1) { + this.eventsAsModel[eventName].splice(index, 1) + } + } + return this + } +} diff --git a/src/data/DanmakuClients/DirectClient.ts b/src/data/DanmakuClients/DirectClient.ts new file mode 100644 index 0000000..b2ade79 --- /dev/null +++ b/src/data/DanmakuClients/DirectClient.ts @@ -0,0 +1,65 @@ +import { KeepLiveWS } from 'bilibili-live-ws/browser' +import BaseDanmakuClient from './BaseDanmakuClient' +export type DirectClientAuthInfo = { + token: string + roomId: number + tokenUserId: number + buvid: string +} +/** 直播间弹幕客户端, 只能在vtsuru.client环境使用 + * + * 未实现除raw事件外的所有事件 + */ +export default class DirectClient extends BaseDanmakuClient { + public onDanmaku(command: any): void { + throw new Error('Method not implemented.') + } + public onGift(command: any): void { + throw new Error('Method not implemented.') + } + public onSC(command: any): void { + throw new Error('Method not implemented.') + } + public onGuard(command: any): void { + throw new Error('Method not implemented.') + } + constructor(auth: DirectClientAuthInfo) { + super() + this.authInfo = auth + } + + public type = 'direct' as const + + public readonly authInfo: DirectClientAuthInfo + + protected async initClient(): Promise<{ success: boolean; message: string }> { + if (this.authInfo) { + const chatClient = new KeepLiveWS(this.authInfo.roomId, { + key: this.authInfo.token, + buvid: this.authInfo.buvid, + uid: this.authInfo.tokenUserId, + protover: 3 + }) + + chatClient.on('live', () => { + console.log('[DIRECT] 已连接房间: ' + this.authInfo.roomId) + }) + /*chatClient.on('DANMU_MSG', this.onDanmaku) + chatClient.on('SEND_GIFT', this.onGift) + chatClient.on('GUARD_BUY', this.onGuard) + chatClient.on('SUPER_CHAT_MESSAGE', this.onSC) + chatClient.on('msg', (data) => { + this.events.all?.forEach((d) => { + d(data) + }) + })*/ + return await super.initClientInner(chatClient) + } else { + console.log('[DIRECT] 无法开启场次, 未提供弹幕客户端认证信息') + return { + success: false, + message: '未提供弹幕客户端认证信息' + } + } + } +} diff --git a/src/data/DanmakuClient.ts b/src/data/DanmakuClients/OpenLiveClient.ts similarity index 66% rename from src/data/DanmakuClient.ts rename to src/data/DanmakuClients/OpenLiveClient.ts index d33f1dd..66e97a6 100644 --- a/src/data/DanmakuClient.ts +++ b/src/data/DanmakuClients/OpenLiveClient.ts @@ -1,10 +1,284 @@ -import { EventDataTypes, EventModel, OpenLiveInfo } from '@/api/api-models' +import { EventDataTypes, OpenLiveInfo } from '@/api/api-models' import { QueryGetAPI, QueryPostAPI } from '@/api/query' -// @ts-expect-error 忽略js错误 -import ChatClientDirectOpenLive from '@/data/chat/ChatClientDirectOpenLive.js' import { GuidUtils } from '@/Utils' +import { KeepLiveWS } from 'bilibili-live-ws/browser' import { clearInterval, setInterval } from 'worker-timers' -import { OPEN_LIVE_API_URL } from './constants' +import { OPEN_LIVE_API_URL } from '../constants' +import BaseDanmakuClient from './BaseDanmakuClient' + +export default class OpenLiveClient extends BaseDanmakuClient { + constructor(auth?: AuthInfo) { + super() + this.authInfo = auth + this.events = { danmaku: [], gift: [], sc: [], guard: [], all: [] } + } + + public type = 'openlive' as const + + private timer: any | undefined + + public authInfo: AuthInfo | undefined + public roomAuthInfo: RoomAuthInfo | undefined + public authCode: string | undefined + + public events: { + danmaku: ((arg1: DanmakuInfo, arg2?: any) => void)[] + gift: ((arg1: GiftInfo, arg2?: any) => void)[] + sc: ((arg1: SCInfo, arg2?: any) => void)[] + guard: ((arg1: GuardInfo, arg2?: any) => void)[] + all: ((arg1: any) => void)[] + } + + public async Start(): Promise<{ success: boolean; message: string }> { + const result = await super.Start() + if (result.success) { + this.timer ??= setInterval(() => { + this.sendHeartbeat() + }, 20 * 1000) + } + return result + } + public Stop() { + super.Stop() + this.events = { + danmaku: [], + gift: [], + sc: [], + guard: [], + all: [] + } + } + + protected async initClient(): Promise<{ success: boolean; message: string }> { + const auth = await this.getAuthInfo() + if (auth.data) { + const chatClient = new KeepLiveWS(auth.data.anchor_info.room_id, { + authBody: JSON.parse(auth.data.websocket_info.auth_body), + address: auth.data.websocket_info.wss_link[0] + }) + chatClient.on('LIVE_OPEN_PLATFORM_DM', (cmd) => this.onDanmaku(cmd)) + chatClient.on('LIVE_OPEN_PLATFORM_GIFT', (cmd) => this.onGift(cmd)) + chatClient.on('LIVE_OPEN_PLATFORM_GUARD', (cmd) => this.onGuard(cmd)) + chatClient.on('LIVE_OPEN_PLATFORM_SC', (cmd) => this.onSC(cmd)) + chatClient.on('msg', (data) => { + this.events.all?.forEach((d) => { + d(data) + }) + }) // 广播所有事件 + chatClient.on('live', () => { + console.log( + `[${this.type}] 已连接房间: ${auth.data?.anchor_info.room_id}` + ) + }) + + return await super.initClientInner(chatClient) + } else { + console.log(`[${this.type}] 无法开启场次: ` + auth.message) + return { + success: false, + message: auth.message + } + } + } + private async getAuthInfo(): Promise<{ + data: OpenLiveInfo | null + message: string + }> { + try { + const data = await QueryPostAPI( + OPEN_LIVE_API_URL + 'start', + this.authInfo?.Code ? this.authInfo : undefined + ) + if (data.code == 200) { + console.log(`[${this.type}] 已获取场次信息`) + return { + data: data.data, + message: '' + } + } else { + return { + data: null, + message: data.message + } + } + } catch (err) { + return { + data: null, + message: err?.toString() || '未知错误' + } + } + } + private sendHeartbeat() { + if (this.state !== 'connected') { + clearInterval(this.timer) + this.timer = undefined + return + } + const query = this.authInfo + ? QueryPostAPI( + OPEN_LIVE_API_URL + 'heartbeat', + this.authInfo + ) + : QueryGetAPI(OPEN_LIVE_API_URL + 'heartbeat-internal') + query.then((data) => { + if (data.code != 200) { + console.error(`[${this.type}] 心跳失败, 将重新连接`) + this.client?.close() + this.client = null + this.initClient() + } + }) + } + + public onDanmaku(command: any) { + const data = command.data as DanmakuInfo + this.events.danmaku?.forEach((d) => { + d(data, command) + }) + this.eventsAsModel.danmaku?.forEach((d) => { + d( + { + type: EventDataTypes.Message, + name: data.uname, + uid: data.uid, + msg: data.msg, + price: 0, + num: 0, + time: data.timestamp, + guard_level: data.guard_level, + fans_medal_level: data.fans_medal_level, + fans_medal_name: data.fans_medal_name, + fans_medal_wearing_status: data.fans_medal_wearing_status, + emoji: data.dm_type == 1 ? data.emoji_img_url : undefined, + uface: data.uface, + open_id: data.open_id, + ouid: data.open_id ?? GuidUtils.numToGuid(data.uid) + }, + command + ) + }) + } + public onGift(command: any) { + const data = command.data as GiftInfo + const price = (data.price * data.gift_num) / 1000 + this.events.gift?.forEach((d) => { + d(data, command) + }) + this.eventsAsModel.gift?.forEach((d) => { + d( + { + type: EventDataTypes.Gift, + name: data.uname, + uid: data.uid, + msg: data.gift_name, + price: data.paid ? price : -price, + num: data.gift_num, + time: data.timestamp, + guard_level: data.guard_level, + fans_medal_level: data.fans_medal_level, + fans_medal_name: data.fans_medal_name, + fans_medal_wearing_status: data.fans_medal_wearing_status, + uface: data.uface, + open_id: data.open_id, + ouid: data.open_id ?? GuidUtils.numToGuid(data.uid) + }, + command + ) + }) + } + public onSC(command: any) { + const data = command.data as SCInfo + this.events.sc?.forEach((d) => { + d(data, command) + }) + this.eventsAsModel.sc?.forEach((d) => { + d( + { + type: EventDataTypes.SC, + name: data.uname, + uid: data.uid, + msg: data.message, + price: data.rmb, + num: 1, + time: data.timestamp, + guard_level: data.guard_level, + fans_medal_level: data.fans_medal_level, + fans_medal_name: data.fans_medal_name, + fans_medal_wearing_status: data.fans_medal_wearing_status, + uface: data.uface, + open_id: data.open_id, + ouid: data.open_id ?? GuidUtils.numToGuid(data.uid) + }, + command + ) + }) + } + public onGuard(command: any) { + const data = command.data as GuardInfo + this.events.guard?.forEach((d) => { + d(data, command) + }) + this.eventsAsModel.guard?.forEach((d) => { + d( + { + type: EventDataTypes.Guard, + name: data.user_info.uname, + uid: data.user_info.uid, + msg: + data.guard_level == 1 + ? '总督' + : data.guard_level == 2 + ? '提督' + : data.guard_level == 3 + ? '舰长' + : '', + price: 0, + num: data.guard_num, + time: data.timestamp, + guard_level: data.guard_level, + fans_medal_level: data.fans_medal_level, + fans_medal_name: data.fans_medal_name, + fans_medal_wearing_status: data.fans_medal_wearing_status, + uface: data.user_info.uface, + open_id: data.user_info.open_id, + ouid: + data.user_info.open_id ?? GuidUtils.numToGuid(data.user_info.uid) + }, + command + ) + }) + } + public onEvent( + eventName: 'danmaku', + listener: DanmakuEventsMap['danmaku'] + ): this + public onEvent(eventName: 'gift', listener: DanmakuEventsMap['gift']): this + public onEvent(eventName: 'sc', listener: DanmakuEventsMap['sc']): this + public onEvent(eventName: 'guard', listener: DanmakuEventsMap['guard']): this + public onEvent(eventName: 'all', listener: (arg1: any) => void): this + public onEvent( + eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all', + listener: (...args: any[]) => void + ): this { + if (!this.events[eventName]) { + this.events[eventName] = [] + } + this.events[eventName].push(listener) + return this + } + public offEvent( + eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all', + listener: (...args: any[]) => void + ): this { + if (this.events[eventName]) { + const index = this.events[eventName].indexOf(listener) + if (index > -1) { + this.events[eventName].splice(index, 1) + } + } + return this + } +} export interface DanmakuInfo { room_id: number @@ -162,386 +436,3 @@ export interface DanmakuEventsMap { guard: (arg1: GuardInfo, arg2?: any) => void all: (arg1: any) => void } - -export default class DanmakuClient { - constructor(auth: AuthInfo | null) { - this.authInfo = auth - } - - private client: any - private timer: any | undefined - private isStarting = false - - public authInfo: AuthInfo | null - public roomAuthInfo: RoomAuthInfo | undefined - public authCode: string | undefined - - public isRunning: boolean = false - - public events: { - danmaku: ((arg1: DanmakuInfo, arg2?: any) => void)[] - gift: ((arg1: GiftInfo, arg2?: any) => void)[] - sc: ((arg1: SCInfo, arg2?: any) => void)[] - guard: ((arg1: GuardInfo, arg2?: any) => void)[] - all: ((arg1: any) => void)[] - } = { - danmaku: [], - gift: [], - sc: [], - guard: [], - all: [] - } - public eventsAsModel: { - danmaku: ((arg1: EventModel, arg2?: any) => void)[] - gift: ((arg1: EventModel, arg2?: any) => void)[] - sc: ((arg1: EventModel, arg2?: any) => void)[] - guard: ((arg1: EventModel, arg2?: any) => void)[] - all: ((arg1: any) => void)[] - } = { - danmaku: [], - gift: [], - sc: [], - guard: [], - all: [] - } - - public async Start(): Promise<{ success: boolean; message: string }> { - if (this.isRunning) { - return { - success: true, - message: '弹幕客户端已启动' - } - } - if (this.isStarting) { - return { - success: false, - message: '弹幕客户端正在启动' - } - } - this.isStarting = true - try { - if (!this.client) { - console.log('[OPEN-LIVE] 正在启动弹幕客户端') - const result = await this.initClient() - if (result.success) { - this.isRunning = true - this.timer ??= setInterval(() => { - this.sendHeartbeat() - }, 20 * 1000) - } - return result - } else { - console.warn('[OPEN-LIVE] 弹幕客户端已被启动过') - return { - success: false, - message: '弹幕客户端已被启动过' - } - } - } finally { - this.isStarting = false - } - } - public Stop() { - if (!this.isRunning) { - return - } - this.isRunning = false - if (this.client) { - console.log('[OPEN-LIVE] 正在停止弹幕客户端') - this.client.stop() - } else { - console.warn('[OPEN-LIVE] 弹幕客户端未被启动, 忽略') - } - if (this.timer) { - clearInterval(this.timer) - this.timer = undefined - } - this.events = { - danmaku: [], - gift: [], - sc: [], - guard: [], - all: [] - } - this.eventsAsModel = { - danmaku: [], - gift: [], - sc: [], - guard: [], - all: [] - } - } - private sendHeartbeat() { - if (!this.isRunning) { - clearInterval(this.timer) - this.timer = undefined - return - } - const query = this.authInfo - ? QueryPostAPI( - OPEN_LIVE_API_URL + 'heartbeat', - this.authInfo - ) - : QueryGetAPI(OPEN_LIVE_API_URL + 'heartbeat-internal') - query.then((data) => { - if (data.code != 200) { - console.error('[OPEN-LIVE] 心跳失败, 将重新连接') - this.client?.stop() - this.client = null - this.initClient() - } - }) - } - - public onRawMessage = (command: any) => { - this.eventsAsModel.all?.forEach((d) => { - d(command) - }) - this.events.all?.forEach((d) => { - d(command) - }) - } - - public onDanmaku = (command: any) => { - const data = command.data as DanmakuInfo - - this.events.danmaku?.forEach((d) => { - d(data, command) - }) - this.eventsAsModel.danmaku?.forEach((d) => { - d( - { - type: EventDataTypes.Message, - name: data.uname, - uid: data.uid, - msg: data.msg, - price: 0, - num: 0, - time: data.timestamp, - guard_level: data.guard_level, - fans_medal_level: data.fans_medal_level, - fans_medal_name: data.fans_medal_name, - fans_medal_wearing_status: data.fans_medal_wearing_status, - emoji: data.dm_type == 1 ? data.emoji_img_url : undefined, - uface: data.uface, - open_id: data.open_id, - ouid: data.open_id ?? GuidUtils.numToGuid(data.uid) - }, - command - ) - }) - } - public onGift = (command: any) => { - const data = command.data as GiftInfo - const price = (data.price * data.gift_num) / 1000 - this.events.gift?.forEach((d) => { - d(data, command) - }) - this.eventsAsModel.gift?.forEach((d) => { - d( - { - type: EventDataTypes.Gift, - name: data.uname, - uid: data.uid, - msg: data.gift_name, - price: data.paid ? price : -price, - num: data.gift_num, - time: data.timestamp, - guard_level: data.guard_level, - fans_medal_level: data.fans_medal_level, - fans_medal_name: data.fans_medal_name, - fans_medal_wearing_status: data.fans_medal_wearing_status, - uface: data.uface, - open_id: data.open_id, - ouid: data.open_id ?? GuidUtils.numToGuid(data.uid) - }, - command - ) - }) - } - public onSC = (command: any) => { - const data = command.data as SCInfo - this.events.sc?.forEach((d) => { - d(data, command) - }) - this.eventsAsModel.sc?.forEach((d) => { - d( - { - type: EventDataTypes.SC, - name: data.uname, - uid: data.uid, - msg: data.message, - price: data.rmb, - num: 1, - time: data.timestamp, - guard_level: data.guard_level, - fans_medal_level: data.fans_medal_level, - fans_medal_name: data.fans_medal_name, - fans_medal_wearing_status: data.fans_medal_wearing_status, - uface: data.uface, - open_id: data.open_id, - ouid: data.open_id ?? GuidUtils.numToGuid(data.uid) - }, - command - ) - }) - } - public onGuard = (command: any) => { - const data = command.data as GuardInfo - this.events.guard?.forEach((d) => { - d(data, command) - }) - this.eventsAsModel.guard?.forEach((d) => { - d( - { - type: EventDataTypes.Guard, - name: data.user_info.uname, - uid: data.user_info.uid, - msg: - data.guard_level == 1 - ? '总督' - : data.guard_level == 2 - ? '提督' - : data.guard_level == 3 - ? '舰长' - : '', - price: 0, - num: data.guard_num, - time: data.timestamp, - guard_level: data.guard_level, - fans_medal_level: data.fans_medal_level, - fans_medal_name: data.fans_medal_name, - fans_medal_wearing_status: data.fans_medal_wearing_status, - uface: data.user_info.uface, - open_id: data.user_info.open_id, - ouid: - data.user_info.open_id ?? GuidUtils.numToGuid(data.user_info.uid) - }, - command - ) - }) - } - public on(eventName: 'danmaku', listener: DanmakuEventsMap['danmaku']): this - public on(eventName: 'gift', listener: DanmakuEventsMap['gift']): this - public on(eventName: 'sc', listener: DanmakuEventsMap['sc']): this - public on(eventName: 'guard', listener: DanmakuEventsMap['guard']): this - public on( - eventName: 'danmaku' | 'gift' | 'sc' | 'guard', - listener: (...args: any[]) => void - ): this { - if (!this.events[eventName]) { - this.events[eventName] = [] - } - this.events[eventName].push(listener) - return this - } - public onEvent( - eventName: 'danmaku', - listener: (arg1: EventModel, arg2?: any) => void - ): this - public onEvent( - eventName: 'gift', - listener: (arg1: EventModel, arg2?: any) => void - ): this - public onEvent( - eventName: 'sc', - listener: (arg1: EventModel, arg2?: any) => void - ): this - public onEvent( - eventName: 'guard', - listener: (arg1: EventModel, arg2?: any) => void - ): this - public onEvent(eventName: 'all', listener: (arg1: any) => void): this - public onEvent( - eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all', - listener: (...args: any[]) => void - ): this { - if (!this.eventsAsModel[eventName]) { - this.eventsAsModel[eventName] = [] - } - this.eventsAsModel[eventName].push(listener) - return this - } - public off( - eventName: 'danmaku' | 'gift' | 'sc' | 'guard', - listener: (...args: any[]) => void - ): this { - if (this.events[eventName]) { - const index = this.events[eventName].indexOf(listener) - if (index > -1) { - this.events[eventName].splice(index, 1) - } - } - return this - } - public offEvent( - eventName: 'danmaku' | 'gift' | 'sc' | 'guard', - listener: (...args: any[]) => void - ): this { - if (this.eventsAsModel[eventName]) { - const index = this.eventsAsModel[eventName].indexOf(listener) - if (index > -1) { - this.eventsAsModel[eventName].splice(index, 1) - } - } - return this - } - private async initClient(): Promise<{ success: boolean; message: string }> { - const auth = await this.getAuthInfo() - if (auth.data) { - const chatClient = new ChatClientDirectOpenLive(auth.data) - //chatClient.msgHandler = this; - chatClient.CMD_CALLBACK_MAP = this.CMD_CALLBACK_MAP - chatClient.start() - this.roomAuthInfo = auth.data as RoomAuthInfo - this.client = chatClient - console.log('[OPEN-LIVE] 已连接房间: ' + auth.data.anchor_info.room_id) - return { - success: true, - message: '' - } - } else { - console.log('[OPEN-LIVE] 无法开启场次: ' + auth.message) - return { - success: false, - message: auth.message - } - } - } - private async getAuthInfo(): Promise<{ - data: OpenLiveInfo | null - message: string - }> { - try { - const data = await QueryPostAPI( - OPEN_LIVE_API_URL + 'start', - this.authInfo?.Code ? this.authInfo : undefined - ) - if (data.code == 200) { - console.log('[OPEN-LIVE] 已获取场次信息') - return { - data: data.data, - message: '' - } - } else { - return { - data: null, - message: data.message - } - } - } catch (err) { - return { - data: null, - message: err?.toString() || '未知错误' - } - } - } - - private CMD_CALLBACK_MAP = { - LIVE_OPEN_PLATFORM_DM: this.onDanmaku.bind(this), - LIVE_OPEN_PLATFORM_SEND_GIFT: this.onGift.bind(this), - LIVE_OPEN_PLATFORM_SUPER_CHAT: this.onSC.bind(this), - LIVE_OPEN_PLATFORM_GUARD: this.onGuard.bind(this), - RAW_MESSAGE: this.onRawMessage.bind(this) - } -} diff --git a/src/data/chat/ChatClientDirectOpenLive.js b/src/data/chat/ChatClientDirectOpenLive.js index 8d2f56c..f634e9e 100644 --- a/src/data/chat/ChatClientDirectOpenLive.js +++ b/src/data/chat/ChatClientDirectOpenLive.js @@ -1,4 +1,5 @@ import ChatClientOfficialBase, * as base from './ChatClientOfficialBase' +import { processAvatarUrl } from './models' export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { constructor(authInfo) { @@ -49,7 +50,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { } data = { - avatarUrl: chat.processAvatarUrl(data.uface), + avatarUrl: processAvatarUrl(data.uface), timestamp: data.timestamp, authorName: data.uname, authorType: authorType, @@ -79,7 +80,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { data = { id: data.msg_id, - avatarUrl: chat.processAvatarUrl(data.uface), + avatarUrl: processAvatarUrl(data.uface), timestamp: data.timestamp, authorName: data.uname, totalCoin: data.price, @@ -97,7 +98,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { let data = command.data data = { id: data.msg_id, - avatarUrl: chat.processAvatarUrl(data.user_info.uface), + avatarUrl: processAvatarUrl(data.user_info.uface), timestamp: data.timestamp, authorName: data.user_info.uname, privilegeType: data.guard_level, @@ -113,7 +114,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase { let data = command.data data = { id: data.message_id.toString(), - avatarUrl: chat.processAvatarUrl(data.uface), + avatarUrl: processAvatarUrl(data.uface), timestamp: data.start_time, authorName: data.uname, price: data.rmb, diff --git a/src/data/chat/ChatClientDirectWeb.js b/src/data/chat/ChatClientDirectWeb.js new file mode 100644 index 0000000..3bdc6c9 --- /dev/null +++ b/src/data/chat/ChatClientDirectWeb.js @@ -0,0 +1,176 @@ +import * as chat from './ChatClientOfficialBase' +import * as chatModels from './models.js' +import * as base from './ChatClientOfficialBase' +import ChatClientOfficialBase from './ChatClientOfficialBase' + +export default class ChatClientDirectWeb extends ChatClientOfficialBase { + constructor(roomId) { + super() + this.CMD_CALLBACK_MAP = CMD_CALLBACK_MAP + + // 调用initRoom后初始化,如果失败,使用这里的默认值 + this.roomId = roomId + this.roomOwnerUid = -1 + this.hostServerList = [ + { + host: 'broadcastlv.chat.bilibili.com', + port: 2243, + wss_port: 443, + ws_port: 2244 + } + ] + this.hostServerToken = null + this.buvid = '' + } + + async initRoom() { + let res + try { + res = await ( + await fetch('/api/room_info?room_id=' + this.roomId, { method: 'GET' }) + ).json() + } catch { + return true + } + this.roomId = res.roomId + this.roomOwnerUid = res.ownerUid + if (res.hostServerList.length !== 0) { + this.hostServerList = res.hostServerList + } + this.hostServerToken = res.hostServerToken + this.buvid = res.buvid + return true + } + + async onBeforeWsConnect() { + // 重连次数太多则重新init_room,保险 + let reinitPeriod = Math.max(3, (this.hostServerList || []).length) + if (this.retryCount > 0 && this.retryCount % reinitPeriod === 0) { + this.needInitRoom = true + } + return super.onBeforeWsConnect() + } + + getWsUrl() { + let hostServer = + this.hostServerList[this.retryCount % this.hostServerList.length] + return `wss://${hostServer.host}:${hostServer.wss_port}/sub` + } + + sendAuth() { + let authParams = { + uid: 0, + roomid: this.roomId, + protover: 3, + platform: 'web', + type: 2, + buvid: this.buvid + } + if (this.hostServerToken !== null) { + authParams.key = this.hostServerToken + } + this.websocket.send(this.makePacket(authParams, base.OP_AUTH)) + } + + async danmuMsgCallback(command) { + let info = command.info + + let roomId, medalLevel + if (info[3]) { + roomId = info[3][3] + medalLevel = info[3][0] + } else { + roomId = medalLevel = 0 + } + + let uid = info[2][0] + let isAdmin = info[2][2] + let privilegeType = info[7] + let authorType + if (uid === this.roomOwnerUid) { + authorType = 3 + } else if (isAdmin) { + authorType = 2 + } else if (privilegeType !== 0) { + authorType = 1 + } else { + authorType = 0 + } + + let authorName = info[2][1] + let content = info[1] + let data = new chatModels.AddTextMsg({ + avatarUrl: await chat.getAvatarUrl(uid, authorName), + timestamp: info[0][4] / 1000, + authorName: authorName, + authorType: authorType, + content: content, + privilegeType: privilegeType, + isGiftDanmaku: + Boolean(info[0][9]) || chat.isGiftDanmakuByContent(content), + authorLevel: info[4][0], + isNewbie: info[2][5] < 10000, + isMobileVerified: Boolean(info[2][6]), + medalLevel: roomId === this.roomId ? medalLevel : 0, + emoticon: info[0][13].url || null + }) + this.msgHandler.onAddText(data) + } + + sendGiftCallback(command) { + let data = command.data + let isPaidGift = data.coin_type === 'gold' + data = new chatModels.AddGiftMsg({ + avatarUrl: chat.processAvatarUrl(data.face), + timestamp: data.timestamp, + authorName: data.uname, + totalCoin: isPaidGift ? data.total_coin : 0, + totalFreeCoin: !isPaidGift ? data.total_coin : 0, + giftName: data.giftName, + num: data.num + }) + this.msgHandler.onAddGift(data) + } + + async guardBuyCallback(command) { + let data = command.data + data = new chatModels.AddMemberMsg({ + avatarUrl: await chat.getAvatarUrl(data.uid, data.username), + timestamp: data.start_time, + authorName: data.username, + privilegeType: data.guard_level + }) + this.msgHandler.onAddMember(data) + } + + superChatMessageCallback(command) { + let data = command.data + data = new chatModels.AddSuperChatMsg({ + id: data.id.toString(), + avatarUrl: chat.processAvatarUrl(data.user_info.face), + timestamp: data.start_time, + authorName: data.user_info.uname, + price: data.price, + content: data.message + }) + this.msgHandler.onAddSuperChat(data) + } + + superChatMessageDeleteCallback(command) { + let ids = [] + for (let id of command.data.ids) { + ids.push(id.toString()) + } + let data = new chatModels.DelSuperChatMsg({ ids }) + this.msgHandler.onDelSuperChat(data) + } +} + +const CMD_CALLBACK_MAP = { + DANMU_MSG: ChatClientDirectWeb.prototype.danmuMsgCallback, + SEND_GIFT: ChatClientDirectWeb.prototype.sendGiftCallback, + GUARD_BUY: ChatClientDirectWeb.prototype.guardBuyCallback, + SUPER_CHAT_MESSAGE: ChatClientDirectWeb.prototype.superChatMessageCallback, + SUPER_CHAT_MESSAGE_DELETE: + ChatClientDirectWeb.prototype.superChatMessageDeleteCallback +} diff --git a/src/data/chat/ChatClientOfficialBase/index.js.new b/src/data/chat/ChatClientOfficialBase/index.js.new new file mode 100644 index 0000000..972210b --- /dev/null +++ b/src/data/chat/ChatClientOfficialBase/index.js.new @@ -0,0 +1,374 @@ +import { BrotliDecode } from './brotli_decode' + +import { + setInterval, + clearInterval, + setTimeout, + clearTimeout +} from 'worker-timers' +import * as chatModels from '../models' + +const HEADER_SIZE = 16 + +export const WS_BODY_PROTOCOL_VERSION_NORMAL = 0 +export const WS_BODY_PROTOCOL_VERSION_HEARTBEAT = 1 +export const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2 +export const WS_BODY_PROTOCOL_VERSION_BROTLI = 3 + +export const OP_HANDSHAKE = 0 +export const OP_HANDSHAKE_REPLY = 1 +export const OP_HEARTBEAT = 2 +export const OP_HEARTBEAT_REPLY = 3 +export const OP_SEND_MSG = 4 +export const OP_SEND_MSG_REPLY = 5 +export const OP_DISCONNECT_REPLY = 6 +export const OP_AUTH = 7 +export const OP_AUTH_REPLY = 8 +export const OP_RAW = 9 +export const OP_PROTO_READY = 10 +export const OP_PROTO_FINISH = 11 +export const OP_CHANGE_ROOM = 12 +export const OP_CHANGE_ROOM_REPLY = 13 +export const OP_REGISTER = 14 +export const OP_REGISTER_REPLY = 15 +export const OP_UNREGISTER = 16 +export const OP_UNREGISTER_REPLY = 17 +// B站业务自定义OP +// export const MinBusinessOp = 1000 +// export const MaxBusinessOp = 10000 + +export const AUTH_REPLY_CODE_OK = 0 +export const AUTH_REPLY_CODE_TOKEN_ERROR = -101 + +const HEARTBEAT_INTERVAL = 10 * 1000 +const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + (5 * 1000) + +let textEncoder = new TextEncoder() +let textDecoder = new TextDecoder() + +export default class ChatClientOfficialBase { + constructor() { + this.CMD_CALLBACK_MAP = {} + + this.msgHandler = chat.getDefaultMsgHandler() + + this.needInitRoom = true + this.websocket = null + this.retryCount = 0 + this.totalRetryCount = 0 + this.isDestroying = false + this.heartbeatTimerId = null + this.receiveTimeoutTimerId = null + } + + start() { + this.wsConnect() + } + + stop() { + this.isDestroying = true + if (this.websocket) { + this.websocket.close() + } + } + + async initRoom() { + throw new Error('Not implemented') + } + + makePacket(data, operation) { + if (typeof data === 'object') { + data = JSON.stringify(data) + } + let bodyArr = textEncoder.encode(data) + + let headerBuf = new ArrayBuffer(HEADER_SIZE) + let headerView = new DataView(headerBuf) + headerView.setUint32(0, HEADER_SIZE + bodyArr.byteLength) // pack_len + headerView.setUint16(4, HEADER_SIZE) // raw_header_size + headerView.setUint16(6, 1) // ver + headerView.setUint32(8, operation) // operation + headerView.setUint32(12, 1) // seq_id + + // 这里如果直接返回 new Blob([headerBuf, bodyArr]),在Chrome抓包会显示空包,实际上是有数据的,为了调试体验最好还是拷贝一遍 + let headerArr = new Uint8Array(headerBuf) + let packetArr = new Uint8Array(bodyArr.length + headerArr.length) + packetArr.set(headerArr) + packetArr.set(bodyArr, headerArr.length) + return packetArr + } + + sendAuth() { + throw new Error('Not implemented') + } + + addDebugMsg(content) { + this.msgHandler.onDebugMsg(new chatModels.DebugMsg({ content })) + } + + async wsConnect() { + if (this.isDestroying) { + return + } + + this.addDebugMsg('Connecting') + await this.onBeforeWsConnect() + if (this.isDestroying) { + return + } + + this.websocket = new WebSocket(this.getWsUrl()) + this.websocket.binaryType = 'arraybuffer' + this.websocket.onopen = this.onWsOpen.bind(this) + this.websocket.onclose = this.onWsClose.bind(this) + this.websocket.onmessage = this.onWsMessage.bind(this) + } + + async onBeforeWsConnect() { + if (!this.needInitRoom) { + return + } + + let res + try { + res = await this.initRoom() + } catch (e) { + res = false + console.error('initRoom exception:', e) + if (e instanceof chatModels.ChatClientFatalError) { + this.stop() + this.msgHandler.onFatalError(e) + } + } + + if (!res) { + setTimeout(() => this.onWsClose(), 0) + throw new Error('initRoom failed') + } + this.needInitRoom = false + } + + getWsUrl() { + throw new Error('Not implemented') + } + + onWsOpen() { + this.addDebugMsg('Connected and authenticating') + + this.sendAuth() + if (this.heartbeatTimerId === null) { + this.heartbeatTimerId = setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL) + } + this.refreshReceiveTimeoutTimer() + } + + sendHeartbeat() { + this.websocket.send(this.makePacket({}, OP_HEARTBEAT)) + } + + refreshReceiveTimeoutTimer() { + if (this.receiveTimeoutTimerId) { + clearTimeout(this.receiveTimeoutTimerId) + } + this.receiveTimeoutTimerId = setTimeout(this.onReceiveTimeout.bind(this), RECEIVE_TIMEOUT) + } + + onReceiveTimeout() { + console.warn('接收消息超时') + this.addDebugMsg('Receiving message timed out') + + this.discardWebsocket() + } + + discardWebsocket() { + if (this.receiveTimeoutTimerId) { + clearTimeout(this.receiveTimeoutTimerId) + this.receiveTimeoutTimerId = null + } + + if (this.websocket) { + if (this.websocket.onclose) { + setTimeout(() => this.onWsClose(), 0) + } + // 直接丢弃阻塞的websocket,不等onclose回调了 + this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null + this.websocket.close() + } + } + + onWsClose() { + this.addDebugMsg('Disconnected') + + this.websocket = null + if (this.heartbeatTimerId) { + clearInterval(this.heartbeatTimerId) + this.heartbeatTimerId = null + } + if (this.receiveTimeoutTimerId) { + clearTimeout(this.receiveTimeoutTimerId) + this.receiveTimeoutTimerId = null + } + + if (this.isDestroying) { + return + } + this.retryCount++ + this.totalRetryCount++ + console.warn(`掉线重连中 retryCount=${this.retryCount}, totalRetryCount=${this.totalRetryCount}`) + + // 防止无限重连的保险措施。30次重连大概会断线500秒,应该够了 + if (this.totalRetryCount > 30) { + this.stop() + let error = new chatModels.ChatClientFatalError( + chatModels.FATAL_ERROR_TYPE_TOO_MANY_RETRIES, 'The connection has lost too many times' + ) + this.msgHandler.onFatalError(error) + return + } + + this.delayReconnect() + } + + delayReconnect() { + this.addDebugMsg(`Scheduling reconnection. The page is ${document.visibilityState === 'visible' ? 'visible' : 'invisible'}`) + + if (document.visibilityState === 'visible') { + setTimeout(this.wsConnect.bind(this), this.getReconnectInterval()) + return + } + + // 页面不可见就先不重连了,即使重连也会心跳超时 + let listener = () => { + if (document.visibilityState !== 'visible') { + return + } + document.removeEventListener('visibilitychange', listener) + this.wsConnect() + } + document.addEventListener('visibilitychange', listener) + } + + getReconnectInterval() { + // 不用retryCount了,防止意外的连接成功,导致retryCount重置 + let interval = Math.min(1000 + ((this.totalRetryCount - 1) * 2000), 20 * 1000) + // 加上随机延迟,防止同时请求导致雪崩 + interval += Math.random() * 3000 + return interval + } + + onWsMessage(event) { + if (!(event.data instanceof ArrayBuffer)) { + console.warn('未知的websocket消息类型,data=', event.data) + return + } + + let data = new Uint8Array(event.data) + this.parseWsMessage(data) + + // 至少成功处理1条消息 + this.retryCount = 0 + } + + parseWsMessage(data) { + let offset = 0 + let dataView = new DataView(data.buffer) + let packLen = dataView.getUint32(0) + let rawHeaderSize = dataView.getUint16(4) + // let ver = dataView.getUint16(6) + let operation = dataView.getUint32(8) + // let seqId = dataView.getUint32(12) + + switch (operation) { + case OP_AUTH_REPLY: + case OP_SEND_MSG_REPLY: { + // 业务消息,可能有多个包一起发,需要分包 + while (true) { // eslint-disable-line no-constant-condition + let body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize) + this.parseBusinessMessage(dataView, body) + + offset += packLen + if (offset >= data.byteLength) { + break + } + + dataView = new DataView(data.buffer, offset) + packLen = dataView.getUint32(0) + rawHeaderSize = dataView.getUint16(4) + } + break + } + case OP_HEARTBEAT_REPLY: { + // 服务器心跳包,包含人气值,这里没用 + this.refreshReceiveTimeoutTimer() + break + } + default: { + // 未知消息 + let body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize) + console.warn('未知包类型,operation=', operation, dataView, body) + break + } + } + } + + parseBusinessMessage(dataView, body) { + let ver = dataView.getUint16(6) + let operation = dataView.getUint32(8) + + switch (operation) { + case OP_SEND_MSG_REPLY: { + // 业务消息 + if (ver == WS_BODY_PROTOCOL_VERSION_BROTLI) { + // 压缩过的先解压 + body = BrotliDecode(body) + this.parseWsMessage(body) + } /*else if (ver == WS_BODY_PROTOCOL_VERSION_DEFLATE) { + // web端已经不用zlib压缩了,但是开放平台会用 + body = inflate(body) + this.parseWsMessage(body) + } */else { + // 没压缩过的直接反序列化 + if (body.length !== 0) { + try { + body = JSON.parse(textDecoder.decode(body)) + this.handlerCommand(body) + } catch (e) { + console.error('body=', body) + throw e + } + } + } + break + } + case OP_AUTH_REPLY: { + // 认证响应 + body = JSON.parse(textDecoder.decode(body)) + if (body.code !== AUTH_REPLY_CODE_OK) { + console.error('认证响应错误,body=', body) + this.needInitRoom = true + this.discardWebsocket() + throw new Error('认证响应错误') + } + this.sendHeartbeat() + break + } + default: { + // 未知消息 + console.warn('未知包类型,operation=', operation, dataView, body) + break + } + } + } + + handlerCommand(command) { + let cmd = command.cmd || '' + let pos = cmd.indexOf(':') + if (pos != -1) { + cmd = cmd.substr(0, pos) + } + let callback = this.CMD_CALLBACK_MAP[cmd] + if (callback) { + callback.call(this, command) + } + } +} diff --git a/src/views/obs/blivechat/models.js b/src/data/chat/models.js similarity index 79% rename from src/views/obs/blivechat/models.js rename to src/data/chat/models.js index 821c9a6..19877c5 100644 --- a/src/views/obs/blivechat/models.js +++ b/src/data/chat/models.js @@ -1,7 +1,23 @@ -import { getUuid4Hex } from './utils' -import * as constants from './constants' +import { getUuid4Hex } from '../../views/obs/blivechat/utils' +import * as constants from '../../views/obs/blivechat/constants' -export const DEFAULT_AVATAR_URL = 'https://i0.hdslb.com/bfs/face/member/noface.jpg@64w_64h' +export function getDefaultMsgHandler() { + let dummyFunc = () => {} + return { + onAddText: dummyFunc, + onAddGift: dummyFunc, + onAddMember: dummyFunc, + onAddSuperChat: dummyFunc, + onDelSuperChat: dummyFunc, + onUpdateTranslation: dummyFunc, + + onFatalError: dummyFunc, + onDebugMsg: dummyFunc + } +} + +export const DEFAULT_AVATAR_URL = + 'https://i0.hdslb.com/bfs/face/member/noface.jpg@64w_64h' export class AddTextMsg { constructor({ @@ -124,3 +140,11 @@ export class DebugMsg { this.content = content } } +export function processAvatarUrl(avatarUrl) { + // 去掉协议,兼容HTTP、HTTPS + let m = avatarUrl.match(/(?:https?:)?(.*)/) + if (m) { + avatarUrl = m[1] + } + return avatarUrl +} diff --git a/src/store/useDanmakuClient.ts b/src/store/useDanmakuClient.ts index 413f235..cdaa2dd 100644 --- a/src/store/useDanmakuClient.ts +++ b/src/store/useDanmakuClient.ts @@ -1,5 +1,8 @@ import { useAccount } from '@/api/account' -import DanmakuClient, { AuthInfo, RoomAuthInfo } from '@/data/DanmakuClient' +import OpenLiveClient, { + AuthInfo, + RoomAuthInfo +} from '@/data/DanmakuClients/OpenLiveClient' import { defineStore } from 'pinia' import { computed, ref } from 'vue' @@ -9,7 +12,7 @@ export interface BCMessage { } export const useDanmakuClient = defineStore('DanmakuClient', () => { - const danmakuClient = ref(new DanmakuClient(null)) + const danmakuClient = ref(new OpenLiveClient()) let bc: BroadcastChannel const isOwnedDanmakuClient = ref(false) const status = ref<'waiting' | 'initializing' | 'listening' | 'running'>( @@ -77,8 +80,10 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => { async (lock) => { if (lock) { status.value = 'initializing' - bc = new BroadcastChannel('vtsuru.danmaku.' + accountInfo.value?.id) - console.log('[DanmakuClient] 创建 BroadcastChannel: ' + bc.name) + bc = new BroadcastChannel( + 'vtsuru.danmaku.open-live' + accountInfo.value?.id + ) + console.log('[DanmakuClient] 创建 BroadcastChannel: ' + bc.name) bc.onmessage = (event) => { const message: BCMessage = event.data as BCMessage const data = message.data ? JSON.parse(message.data) : {} @@ -86,7 +91,7 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => { case 'check-client': sendBCMessage('response-client-status', { status: status.value, - auth: authInfo.value, + auth: authInfo.value }) break case 'response-client-status': @@ -178,7 +183,7 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => { const events = danmakuClient.value.events const eventsAsModel = danmakuClient.value.eventsAsModel - danmakuClient.value = new DanmakuClient(auth || null) + danmakuClient.value = new OpenLiveClient(auth) danmakuClient.value.events = events danmakuClient.value.eventsAsModel = eventsAsModel @@ -192,7 +197,7 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => { status: 'running', auth: authInfo.value }) - danmakuClient.value.onEvent('all', (data) => { + danmakuClient.value.on('all', (data) => { sendBCMessage('on-danmaku', data) }) return true diff --git a/src/store/useDirectDanmakuClient.ts b/src/store/useDirectDanmakuClient.ts new file mode 100644 index 0000000..76380dc --- /dev/null +++ b/src/store/useDirectDanmakuClient.ts @@ -0,0 +1,234 @@ +import { useAccount } from '@/api/account' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +export interface BCMessage { + type: string + data: string +} + +export const useDirectDanmakuClient = defineStore('DirectDanmakuClient', () => { + const danmakuClient = ref(new OpenLiveClient(null)) + let bc: BroadcastChannel + const isOwnedDirectDanmakuClient = ref(false) + const status = ref<'waiting' | 'initializing' | 'listening' | 'running'>( + 'waiting' + ) + const connected = computed( + () => status.value === 'running' || status.value === 'listening' + ) + const authInfo = ref() + const accountInfo = useAccount() + + let existOtherClient = false + let isInitializing = false + + function on( + eventName: 'danmaku' | 'gift' | 'sc' | 'guard', + listener: (...args: any[]) => void + ) { + if (!danmakuClient.value.events[eventName]) { + danmakuClient.value.events[eventName] = [] + } + danmakuClient.value.events[eventName].push(listener) + } + function onEvent( + eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all', + listener: (...args: any[]) => void + ) { + if (!danmakuClient.value.eventsAsModel[eventName]) { + danmakuClient.value.eventsAsModel[eventName] = [] + } + danmakuClient.value.eventsAsModel[eventName].push(listener) + } + + function off( + eventName: 'danmaku' | 'gift' | 'sc' | 'guard', + listener: (...args: any[]) => void + ) { + if (danmakuClient.value.events[eventName]) { + const index = danmakuClient.value.events[eventName].indexOf(listener) + if (index > -1) { + danmakuClient.value.events[eventName].splice(index, 1) + } + } + } + + function offEvent( + eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all', + listener: (...args: any[]) => void + ) { + if (danmakuClient.value.eventsAsModel[eventName]) { + const index = + danmakuClient.value.eventsAsModel[eventName].indexOf(listener) + if (index > -1) { + danmakuClient.value.eventsAsModel[eventName].splice(index, 1) + } + } + } + + async function initClient(auth?: AuthInfo) { + if (!isInitializing && !connected.value) { + isInitializing = true + navigator.locks.request( + 'danmakuClientInit', + { ifAvailable: true }, + async (lock) => { + if (lock) { + status.value = 'initializing' + bc = new BroadcastChannel('vtsuru.danmaku.open-live' + accountInfo.value?.id) + console.log('[DirectDanmakuClient] 创建 BroadcastChannel: ' + bc.name) + bc.onmessage = (event) => { + const message: BCMessage = event.data as BCMessage + const data = message.data ? JSON.parse(message.data) : {} + switch (message.type) { + case 'check-client': + sendBCMessage('response-client-status', { + status: status.value, + auth: authInfo.value, + }) + break + case 'response-client-status': + switch ( + data.status //如果存在已经在运行或者正在启动的客户端, 状态设为 listening + ) { + case 'running': + case 'initializing': + status.value = 'listening' + existOtherClient = true + authInfo.value = data.auth + break + } + break + case 'on-danmaku': + const danmaku = JSON.parse(data) + switch (danmaku.cmd) { + case 'LIVE_OPEN_PLATFORM_DM': + danmakuClient.value.onDanmaku(danmaku) + break + case 'LIVE_OPEN_PLATFORM_SEND_GIFT': + danmakuClient.value.onGift(danmaku) + break + case 'LIVE_OPEN_PLATFORM_SUPER_CHAT': + danmakuClient.value.onSC(danmaku) + break + case 'LIVE_OPEN_PLATFORM_GUARD': + danmakuClient.value.onGuard(danmaku) + break + default: + danmakuClient.value.onRawMessage(danmaku) + break + } + break + } + } + console.log('[DirectDanmakuClient] 正在检查客户端状态...') + sendBCMessage('check-client') + setTimeout(() => { + if (!connected.value) { + isOwnedDirectDanmakuClient.value = true + initClientInternal(auth) + } else { + console.log( + '[DirectDanmakuClient] 已存在其他页面弹幕客户端, 开始监听 BroadcastChannel...' + ) + } + + setInterval(checkClientStatus, 500) + }, 1000) + } + } + ) + } + isInitializing = false + return useDirectDanmakuClient() + } + function sendBCMessage(type: string, data?: any) { + bc.postMessage({ + type, + data: JSON.stringify(data) + }) + } + function checkClientStatus() { + if (!existOtherClient && !isOwnedDirectDanmakuClient.value) { + //当不存在其他客户端, 且自己不是弹幕客户端 + //则自己成为新的弹幕客户端 + if (status.value != 'initializing') { + console.log('[DirectDanmakuClient] 其他 Client 离线, 开始初始化...') + initClientInternal() + } + } else { + existOtherClient = false //假设其他客户端不存在 + sendBCMessage('check-client') //检查其他客户端是否存在 + } + } + + async function initClientInternal(auth?: AuthInfo) { + status.value = 'initializing' + await navigator.locks.request( + 'danmakuClientInitInternal', + { + ifAvailable: true + }, + async (lock) => { + if (lock) { + // 有锁 + isOwnedDirectDanmakuClient.value = true + const events = danmakuClient.value.events + const eventsAsModel = danmakuClient.value.eventsAsModel + + danmakuClient.value = new OpenLiveClient(auth || null) + + danmakuClient.value.events = events + danmakuClient.value.eventsAsModel = eventsAsModel + const init = async () => { + const result = await danmakuClient.value.Start() + if (result.success) { + authInfo.value = danmakuClient.value.roomAuthInfo + status.value = 'running' + console.log('[DirectDanmakuClient] 初始化成功') + sendBCMessage('response-client-status', { + status: 'running', + auth: authInfo.value + }) + danmakuClient.value.onEvent('all', (data) => { + sendBCMessage('on-danmaku', data) + }) + return true + } else { + console.log( + '[DirectDanmakuClient] 初始化失败, 5秒后重试: ' + result.message + ) + return false + } + } + while (!(await init())) { + await new Promise((resolve) => { + setTimeout(() => { + resolve(true) + }, 5000) + }) + } + } else { + // 无锁 + console.log('[DirectDanmakuClient] 正在等待其他页面弹幕客户端初始化...') + status.value = 'listening' + isOwnedDirectDanmakuClient.value = false + } + } + ) + } + + return { + danmakuClient, + isOwnedDirectDanmakuClient, + status, + connected, + authInfo, + on, + off, + onEvent, + offEvent, + initClient + } +}) diff --git a/src/store/useWebFetcher.ts b/src/store/useWebFetcher.ts index 434d143..04fdeff 100644 --- a/src/store/useWebFetcher.ts +++ b/src/store/useWebFetcher.ts @@ -1,102 +1,200 @@ -import DanmakuClient from '@/data/DanmakuClient' import { BASE_HUB_URL } from '@/data/constants' +import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient' +import DirectClient, { + DirectClientAuthInfo +} from '@/data/DanmakuClients/DirectClient' +import OpenLiveClient from '@/data/DanmakuClients/OpenLiveClient' import * as signalR from '@microsoft/signalr' import * as msgpack from '@microsoft/signalr-protocol-msgpack' import { useLocalStorage } from '@vueuse/core' import { format } from 'date-fns' import { defineStore } from 'pinia' -import { ref } from 'vue' +import { computed, ref } from 'vue' import { useRoute } from 'vue-router' +import { compress } from 'brotli-compress' export const useWebFetcher = defineStore('WebFetcher', () => { const cookie = useLocalStorage('JWT_Token', '') const route = useRoute() + const startedAt = ref() - const client = new DanmakuClient(null) + const client = ref() + const signalRClient = ref() const events: string[] = [] const isStarted = ref(false) let timer: any - let signalRClient: signalR.HubConnection | null = null let disconnectedByServer = false - - async function Start() { + let useCookie = false + /** + * 是否来自Tauri客户端 + */ + let isFromClient = false + const prefix = computed(() => { + if (isFromClient) { + return '[web-fetcher-iframe] ' + } + return '[web-fetcher] ' + }) + async function restartDanmakuClient( + type: 'openlive' | 'direct', + directAuthInfo?: DirectClientAuthInfo + ) { + console.log(prefix.value + '正在重启弹幕客户端...') + if ( + client.value?.state === 'connected' || + client.value?.state === 'connecting' + ) { + client.value.Stop() + } + return await connectDanmakuClient(type, directAuthInfo) + } + async function Start( + type: 'openlive' | 'direct' = 'openlive', + directAuthInfo?: DirectClientAuthInfo, + _isFromClient: boolean = false + ): Promise<{ success: boolean; message: string }> { if (isStarted.value) { - return - } - while (!(await connectSignalR())) { - console.log('[WEB-FETCHER] 连接失败, 5秒后重试') - await new Promise((resolve) => setTimeout(resolve, 5000)) + startedAt.value = new Date() + return { success: true, message: '已启动' } } + const result = await navigator.locks.request( + 'webFetcherStart', + async () => { + isFromClient = _isFromClient + while (!(await connectSignalR())) { + console.log(prefix.value + '连接失败, 5秒后重试') + await new Promise((resolve) => setTimeout(resolve, 5000)) + } + let result = await connectDanmakuClient(type, directAuthInfo) + while (!result?.success) { + console.log(prefix.value + '弹幕客户端启动失败, 5秒后重试') + await new Promise((resolve) => setTimeout(resolve, 5000)) + result = await connectDanmakuClient(type, directAuthInfo) + } + isStarted.value = true + disconnectedByServer = false + return result + } + ) + return result } function Stop() { if (!isStarted.value) { return } isStarted.value = false - client.Stop() + client.value?.Stop() + client.value = undefined if (timer) { clearInterval(timer) timer = undefined } - signalRClient?.stop() - signalRClient = null + signalRClient.value?.stop() + signalRClient.value = undefined + startedAt.value = undefined } - async function connectDanmakuClient() { - console.log('[WEB-FETCHER] 正在连接弹幕客户端...') - const result = await client.Start() - if (result.success) { - console.log('[WEB-FETCHER] 加载完成, 开始监听弹幕') - client.onEvent('all', onGetDanmakus) + /************* ✨ Codeium Command ⭐ *************/ + /** + * Connects to the danmaku client based on the specified type. + * + * @param type - The type of danmaku client to connect, either 'openlive' or 'direct'. + * @param directConnectInfo - Optional authentication information required when connecting to a 'direct' type client. + * It should include a token, roomId, tokenUserId, and buvid. + * + * @returns A promise that resolves to an object containing a success flag and a message. + * If the connection and client start are successful, the client starts listening to danmaku events. + * If the connection fails or the authentication information is not provided for a 'direct' type client, + * the function returns with a failure message. + */ + /****** 3431380f-29f6-41b0-801a-7f081b59b4ff *******/ + async function connectDanmakuClient( + type: 'openlive' | 'direct', + directConnectInfo?: { + token: string + roomId: number + tokenUserId: number + buvid: string + } + ) { + if ( + client.value?.state === 'connected' || + client.value?.state === 'connecting' + ) { + return { success: true, message: '弹幕客户端已启动' } + } + console.log(prefix.value + '正在连接弹幕客户端...') + if (!client.value) { + //只有在没有客户端的时候才创建, 并添加事件 + if (type == 'openlive') { + client.value = new OpenLiveClient() + } else { + if (!directConnectInfo) { + return { success: false, message: '未提供弹幕客户端认证信息' } + } + client.value = new DirectClient(directConnectInfo) + } + + client.value?.on('all', (data) => onGetDanmakus(data)) + } + + const result = await client.value?.Start() + if (result?.success) { + console.log(prefix.value + '加载完成, 开始监听弹幕') timer ??= setInterval(() => { sendEvents() }, 1500) } else { - console.log('[WEB-FETCHER] 弹幕客户端启动失败: ' + result.message) + console.log(prefix.value + '弹幕客户端启动失败: ' + result?.message) } return result } async function connectSignalR() { - console.log('[WEB-FETCHER] 正在连接到 vtsuru 服务器...') + console.log(prefix.value + '正在连接到 vtsuru 服务器...') const connection = new signalR.HubConnectionBuilder() .withUrl(BASE_HUB_URL + 'web-fetcher?token=' + route.query.token, { headers: { - Authorization: `Bearer ${cookie.value}`, + Authorization: `Bearer ${cookie.value}` }, skipNegotiation: true, - transport: signalR.HttpTransportType.WebSockets, + transport: signalR.HttpTransportType.WebSockets }) .withAutomaticReconnect([0, 2000, 10000, 30000]) .withHubProtocol(new msgpack.MessagePackHubProtocol()) .build() connection.on('Disconnect', (reason: unknown) => { - console.log('[WEB-FETCHER] 被服务器断开连接: ' + reason) + console.log(prefix.value + '被服务器断开连接: ' + reason) disconnectedByServer = true connection.stop() - signalRClient = null + signalRClient.value = undefined }) - connection.on('ConnectClient', async () => { - if (client.isRunning) { + /*connection.on('ConnectClient', async () => { + if (client?.state === 'connected') { return } let result = await connectDanmakuClient() - while (!result.success) { - console.log('[WEB-FETCHER] 弹幕客户端启动失败, 5秒后重试') + while (!result?.success) { + console.log(prefix.value + '弹幕客户端启动失败, 5秒后重试') await new Promise((resolve) => setTimeout(resolve, 5000)) result = await connectDanmakuClient() } isStarted.value = true disconnectedByServer = false - }) + })*/ connection.onclose(reconnect) try { await connection.start() - console.log('[WEB-FETCHER] 已连接到 vtsuru 服务器') - signalRClient = connection + console.log(prefix.value + '已连接到 vtsuru 服务器') + await connection.send('Finished') + if (isFromClient) { + // 如果来自Tauri客户端,设置自己为VTsuru客户端 + await connection.send('SetAsVTsuruClient') + } + signalRClient.value = connection return true } catch (e) { - console.log('[WEB-FETCHER] 无法连接到 vtsuru 服务器: ' + e) + console.log(prefix.value + '无法连接到 vtsuru 服务器: ' + e) return false } } @@ -105,8 +203,12 @@ export const useWebFetcher = defineStore('WebFetcher', () => { return } try { - await signalRClient?.start() - console.log('[WEB-FETCHER] 已重新连接') + await signalRClient.value?.start() + await signalRClient.value?.send('Reconnected') + if (isFromClient) { + await signalRClient.value?.send('SetAsVTsuruClient') + } + console.log(prefix.value + '已重新连接') } catch (err) { console.log(err) setTimeout(reconnect, 5000) // 如果连接失败,则每5秒尝试一次重新启动连接 @@ -116,7 +218,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { events.push(command) } async function sendEvents() { - if (signalRClient?.state !== 'Connected') { + if (signalRClient.value?.state !== 'Connected') { return } let tempEvents: string[] = [] @@ -129,15 +231,29 @@ export const useWebFetcher = defineStore('WebFetcher', () => { count = events.length } if (tempEvents.length > 0) { - const result = await signalRClient?.invoke<{ + const compressed = await compress( + new TextEncoder().encode( + JSON.stringify({ + Events: tempEvents.map((e) => + typeof e === 'string' ? e : JSON.stringify(e) + ), + Version: '1.0.0', + OSInfo: navigator.userAgent, + UseCookie: useCookie + }) + ) + ) + const result = await signalRClient.value?.invoke<{ Success: boolean Message: string - }>('UploadEvents', tempEvents, false) + }>('UploadEventsCompressed', compressed) if (result?.Success) { events.splice(0, count) - console.log(`[WEB-FETCHER] <${format(new Date(), 'HH:mm:ss')}> 上传了 ${count} 条弹幕`) + console.log( + `[WEB-FETCHER] <${format(new Date(), 'HH:mm:ss')}> 上传了 ${count} 条弹幕` + ) } else { - console.error('[WEB-FETCHER] 上传弹幕失败: ' + result?.Message) + console.error(prefix.value + '上传弹幕失败: ' + result?.Message) } } } @@ -145,7 +261,10 @@ export const useWebFetcher = defineStore('WebFetcher', () => { return { Start, Stop, + restartDanmakuClient, client, + signalRClient, isStarted, + startedAt } }) diff --git a/src/views/IndexView.vue b/src/views/IndexView.vue index 9ea2e39..96f50ea 100644 --- a/src/views/IndexView.vue +++ b/src/views/IndexView.vue @@ -6,17 +6,15 @@ import { BookCoins20Filled, Info24Filled, Lottery24Filled, - MoneyOff24Filled, MoreHorizontal24Filled, TabletSpeaker24Filled, VehicleShip24Filled, - VideoAdd20Filled, + VideoAdd20Filled } from '@vicons/fluent' import { AnalyticsSharp, Calendar, Chatbox, ListCircle, MusicalNote } from '@vicons/ionicons5' import { useWindowSize } from '@vueuse/core' -import { NButton, NCard, NDivider, NEllipsis, NFlex, NGradientText, NGrid, NGridItem, NIcon, NNumberAnimation, NSpace, NText, NTooltip } from 'naive-ui' +import { NButton, NDivider, NEllipsis, NFlex, NGradientText, NGrid, NGridItem, NIcon, NNumberAnimation, NSpace, NText, NTooltip } from 'naive-ui' import { onMounted, ref } from 'vue' -import { stream } from 'xlsx' const { width } = useWindowSize() diff --git a/src/views/obs/DanmujiOBS.vue b/src/views/obs/DanmujiOBS.vue index dc6c171..00b76b5 100644 --- a/src/views/obs/DanmujiOBS.vue +++ b/src/views/obs/DanmujiOBS.vue @@ -5,7 +5,7 @@ import { useDanmakuClient } from '@/store/useDanmakuClient'; // @ts-ignore import * as constants from './blivechat/constants'; // @ts-ignore -import * as chatModels from './blivechat/models'; +import * as chatModels from '../../data/chat/models'; // @ts-ignore import * as pronunciation from './blivechat/utils/pronunciation' // @ts-ignore diff --git a/src/views/obs/LiveRequestOBS.vue b/src/views/obs/LiveRequestOBS.vue index 333fa0d..7542767 100644 --- a/src/views/obs/LiveRequestOBS.vue +++ b/src/views/obs/LiveRequestOBS.vue @@ -12,7 +12,7 @@ import { useElementSize } from '@vueuse/core' import { computed, onMounted, onUnmounted, ref } from 'vue' import { useRoute } from 'vue-router' import { Vue3Marquee } from 'vue3-marquee' -import { NCard, NDivider, NEmpty, NSpace, NText, useMessage } from 'naive-ui' +import { NCard, NDivider, NEmpty, NMessageProvider, NSpace, NText, useMessage } from 'naive-ui' import { List } from 'linqts' const props = defineProps<{ @@ -25,6 +25,7 @@ const currentId = computed(() => { return props.id ?? route.query.id }) +const cardRef = ref() const listContainerRef = ref() const { height, width } = useElementSize(listContainerRef) const itemHeight = 40 @@ -140,7 +141,8 @@ onUnmounted(() => {