diff --git a/src/client/ClientDanmakuWindow.vue b/src/client/ClientDanmakuWindow.vue new file mode 100644 index 0000000..a014e14 --- /dev/null +++ b/src/client/ClientDanmakuWindow.vue @@ -0,0 +1,289 @@ + + + + + + + + + + + + + + + + {{ formatUsername(item) }}: + + + + + + + + {{ item.price }}¥ + + + + {{ formatMessage(item) }} + + + + + + + + + + + \ No newline at end of file diff --git a/src/client/ClientLayout.vue b/src/client/ClientLayout.vue index 73d26ae..a22508e 100644 --- a/src/client/ClientLayout.vue +++ b/src/client/ClientLayout.vue @@ -101,6 +101,12 @@ import { isTauri } from '@/data/constants'; key: 'fetcher', icon: () => h(CloudArchive24Filled) }, + /*{ + label: () => + h(RouterLink, { to: { name: 'client-danmaku-window-manage' } }, () => '弹幕机管理'), + key: 'danmaku-window-manage', + icon: () => h(Settings24Filled) + },*/ { label: () => h(RouterLink, { to: { name: 'client-settings' } }, () => '设置'), diff --git a/src/client/DanmakuWindowManager.vue b/src/client/DanmakuWindowManager.vue new file mode 100644 index 0000000..c998113 --- /dev/null +++ b/src/client/DanmakuWindowManager.vue @@ -0,0 +1,438 @@ + + + + + + + {{ danmakuWindow.isDanmakuWindowOpen ? '关闭弹幕窗口' : '打开弹幕窗口' }} + + + + + + 请先打开弹幕窗口后再进行设置 + + + + + + + + + + updateSetting('width', val || 400)" + /> + + + + + updateSetting('height', val || 600)" + /> + + + + + updateSetting('x', val || 0)" + /> + + + + + updateSetting('y', val || 0)" + /> + + + + + + + + + + + 重置位置 + + + + updateSetting('alwaysOnTop', val)" + /> + + + updateSetting('interactive', val)" + /> + + + + + + + + + + + + updateSetting('backgroundColor', val)" + /> + + + + + updateSetting('textColor', val)" + /> + + + + + updateSetting('opacity', val)" + /> + + + + + updateSetting('fontSize', val)" + /> + + + + + updateSetting('borderRadius', val)" + /> + + + + + updateSetting('itemSpacing', val)" + /> + + + + + + + + updateSetting('enableShadow', val)" + /> + + + updateSetting('shadowColor', val)" + /> + + + + + + 暗色主题 + + + 亮色主题 + + + 透明主题 + + + + + + + + + + + + + { + let types = [...danmakuWindow.danmakuWindowSetting.filterTypes]; + if (val) { + if (!types.includes(option.value)) types.push(option.value); + } else { + types = types.filter(t => t !== option.value); + } + updateSetting('filterTypes', types); + }" + /> + + + + + + + + + updateSetting('showAvatar', val)" + > + 显示头像 + + updateSetting('showUsername', val)" + > + 显示用户名 + + updateSetting('showFansMedal', val)" + > + 显示粉丝牌 + + updateSetting('showGuardIcon', val)" + > + 显示舰长图标 + + + + + + + + + 从上往下 + updateSetting('reverseOrder', val)" + /> + 从下往上 + + + + + + + updateSetting('maxDanmakuCount', val || 50)" + /> + + + + + + updateSetting('animationDuration', val || 300)" + > + + ms + + + + + + + + + + + + diff --git a/src/client/data/initialize.ts b/src/client/data/initialize.ts index 2f44092..1a0d313 100644 --- a/src/client/data/initialize.ts +++ b/src/client/data/initialize.ts @@ -19,6 +19,7 @@ import { CN_HOST, isDev } from "@/data/constants"; import { invoke } from "@tauri-apps/api/core"; import { check } from '@tauri-apps/plugin-updater'; import { relaunch } from '@tauri-apps/plugin-process'; +import { useDanmakuWindow } from "../store/useDanmakuWindow"; const accountInfo = useAccount(); @@ -134,6 +135,7 @@ export function OnClientUnmounted() { } tray.close(); + useDanmakuWindow().closeWindow() } async function checkUpdate() { diff --git a/src/client/store/useDanmakuWindow.ts b/src/client/store/useDanmakuWindow.ts new file mode 100644 index 0000000..9fc675d --- /dev/null +++ b/src/client/store/useDanmakuWindow.ts @@ -0,0 +1,189 @@ +import { EventDataTypes, EventModel } from "@/api/api-models"; +import { CURRENT_HOST } from "@/data/constants"; +import { useDanmakuClient } from "@/store/useDanmakuClient"; +import { useWebFetcher } from "@/store/useWebFetcher"; +import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi"; +import { Webview } from "@tauri-apps/api/webview"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { Window } from "@tauri-apps/api/window"; + +export type DanmakuWindowSettings = { + width: number; + height: number; + x: number; + y: number; + opacity: number; // 窗口透明度 + showAvatar: boolean; // 是否显示头像 + showUsername: boolean; // 是否显示用户名 + showFansMedal: boolean; // 是否显示粉丝牌 + showGuardIcon: boolean; // 是否显示舰长图标 + fontSize: number; // 弹幕字体大小 + maxDanmakuCount: number; // 最大显示的弹幕数量 + reverseOrder: boolean; // 是否倒序显示(从下往上) + filterTypes: string[]; // 要显示的弹幕类型 + animationDuration: number; // 动画持续时间 + backgroundColor: string; // 背景色 + textColor: string; // 文字颜色 + alwaysOnTop: boolean; // 是否总在最前 + interactive: boolean; // 是否可交互(穿透鼠标点击) + borderRadius: number; // 边框圆角 + itemSpacing: number; // 项目间距 + enableShadow: boolean; // 是否启用阴影 + shadowColor: string; // 阴影颜色 +}; + +export const DANMAKU_WINDOW_BROADCAST_CHANNEL = 'channel.danmaku.window'; +export type DanmakuWindowBCData = { + type: 'danmaku', + data: EventModel; +} | { + type: 'update-setting', + data: DanmakuWindowSettings; +}; + +export const useDanmakuWindow = defineStore('danmakuWindow', () => { + const danmakuWindow = ref(); + const danmakuWindowSetting = useStorage('Setting.DanmakuWindow', { + width: 400, + height: 600, + x: 100, + y: 100, + opacity: 0.9, + showAvatar: true, + showUsername: true, + showFansMedal: true, + showGuardIcon: true, + fontSize: 14, + maxDanmakuCount: 50, + reverseOrder: false, + filterTypes: ["Message", "Gift", "SC", "Guard"], + animationDuration: 300, + backgroundColor: 'rgba(0,0,0,0.6)', + textColor: '#ffffff', + alwaysOnTop: true, + interactive: false, + borderRadius: 8, + itemSpacing: 5, + enableShadow: true, + shadowColor: 'rgba(0,0,0,0.5)' + }); + const danmakuClient = useDanmakuClient(); + let bc: BroadcastChannel | undefined = undefined; + + function hideWindow() { + danmakuWindow.value?.window.hide(); + danmakuWindow.value = undefined; + } + function closeWindow() { + danmakuWindow.value?.close(); + danmakuWindow.value = undefined; + } + + function setDanmakuWindowSize(width: number, height: number) { + danmakuWindowSetting.value.width = width; + danmakuWindowSetting.value.height = height; + danmakuWindow.value?.setSize(new PhysicalSize(width, height)); + } + + function setDanmakuWindowPosition(x: number, y: number) { + danmakuWindowSetting.value.x = x; + danmakuWindowSetting.value.y = y; + danmakuWindow.value?.setPosition(new PhysicalPosition(x, y)); + } + + function updateSetting(key: K, value: DanmakuWindowSettings[K]) { + danmakuWindowSetting.value[key] = value; + // 特定设置需要直接应用到窗口 + if (key === 'alwaysOnTop' && danmakuWindow.value) { + danmakuWindow.value.window.setAlwaysOnTop(value as boolean); + } + if (key === 'interactive' && danmakuWindow.value) { + danmakuWindow.value.window.setIgnoreCursorEvents(value as boolean); + } + } + + async function createWindow() { + const appWindow = new Window('uniqueLabel', { + decorations: true, + resizable: true, + transparent: true, + fullscreen: false, + alwaysOnTop: danmakuWindowSetting.value.alwaysOnTop, + title: "VTsuru 弹幕窗口", + }); + + // loading embedded asset: + danmakuWindow.value = new Webview(appWindow, 'theUniqueLabel', { + url: `${CURRENT_HOST}/client/danaku-window-manage`, + width: danmakuWindowSetting.value.width, + height: danmakuWindowSetting.value.height, + x: danmakuWindowSetting.value.x, + y: danmakuWindowSetting.value.y, + }); + + appWindow.onCloseRequested(() => { + danmakuWindow.value = undefined; + bc?.close(); + bc = undefined; + }); + + danmakuWindow.value.once('tauri://window-created', async () => { + console.log('弹幕窗口已创建'); + await danmakuWindow.value?.window.setIgnoreCursorEvents(true); + }); + bc?.postMessage({ + type: 'danmaku', + data: { + type: EventDataTypes.Message, + msg: '弹幕窗口已打开', + } as Partial, + }); + + bc = new BroadcastChannel(DANMAKU_WINDOW_BROADCAST_CHANNEL); + + if (danmakuClient.connected) { + danmakuClient.onEvent('danmaku', (event) => onGetDanmakus(event)); + danmakuClient.onEvent('gift', (event) => onGetDanmakus(event)); + danmakuClient.onEvent('sc', (event) => onGetDanmakus(event)); + danmakuClient.onEvent('guard', (event) => onGetDanmakus(event)); + danmakuClient.onEvent('enter', (event) => onGetDanmakus(event)); + danmakuClient.onEvent('scDel', (event) => onGetDanmakus(event)); + } + } + + function onGetDanmakus(data: EventModel) { + bc?.postMessage({ + type: 'danmaku', + data, + }); + } + + watch(danmakuWindowSetting, async (newValue) => { + if (danmakuWindow.value) { + if (await danmakuWindow.value.window.isVisible()) { + danmakuWindow.value.setSize(new PhysicalSize(newValue.width, newValue.height)); + danmakuWindow.value.setPosition(new PhysicalPosition(newValue.x, newValue.y)); + } + bc?.postMessage({ + type: 'update-setting', + data: newValue, + }); + } + }, { deep: true }); + + return { + danmakuWindow, + danmakuWindowSetting, + setDanmakuWindowSize, + setDanmakuWindowPosition, + updateSetting, + isDanmakuWindowOpen: computed(() => !!danmakuWindow.value), + createWindow, + closeWindow, + hideWindow + }; +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useDanmakuWindow, import.meta.hot)); +} \ No newline at end of file diff --git a/src/components/manage/PointGoodsItem.vue b/src/components/manage/PointGoodsItem.vue index cf1723f..a4fb171 100644 --- a/src/components/manage/PointGoodsItem.vue +++ b/src/components/manage/PointGoodsItem.vue @@ -1,19 +1,26 @@ - - + + - 库存: + + 库存: + {{ goods.count }} - 无 - ∞ + + 无 + + + ∞ + - - 已售完 - + + + 已售完 + + {{ goods.type == GoodsTypes.Physical ? '实物' : '虚拟' }} @@ -49,22 +78,36 @@ const emptyCover = IMGUR_URL + 'None.png' - + {{ goods.description ? goods.description : '暂无描述' }} - + 仅限舰长 - {{ tag }} + + {{ tag }} + - + diff --git a/src/data/DanmakuClients/BaseDanmakuClient.ts b/src/data/DanmakuClients/BaseDanmakuClient.ts index 352ee4d..2607504 100644 --- a/src/data/DanmakuClients/BaseDanmakuClient.ts +++ b/src/data/DanmakuClients/BaseDanmakuClient.ts @@ -1,178 +1,350 @@ -import { EventModel } from '@/api/api-models' -import { KeepLiveWS } from 'bilibili-live-ws/browser' +// BaseDanmakuClient.ts +import { EventModel, EventDataTypes } from '@/api/api-models'; // 导入事件模型和类型枚举 +import { KeepLiveWS } from 'bilibili-live-ws/browser'; // 导入 bilibili-live-ws 库 +// 定义基础弹幕客户端抽象类 export default abstract class BaseDanmakuClient { constructor() { - this.client = null + this.client = null; // 初始化客户端实例为 null + // 初始化两套事件监听器存储 + this.eventsAsModel = this.createEmptyEventModelListeners(); + this.eventsRaw = this.createEmptyRawEventlisteners(); } - public client: KeepLiveWS | null + // WebSocket 客户端实例 + public client: KeepLiveWS | null; + // 客户端连接状态 public state: 'padding' | 'connected' | 'connecting' | 'disconnected' = - 'padding' + 'padding'; - public abstract type: 'openlive' | 'direct' - public abstract serverUrl: string + // 客户端类型 (由子类实现) + public abstract type: 'openlive' | 'direct'; + // 目标服务器地址 (由子类实现) + public abstract serverUrl: string; + // --- 事件系统 1: 使用 EventModel --- + // 事件监听器集合 (统一使用 EventModel) 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: [] - } + danmaku: ((arg1: EventModel, arg2?: any) => void)[]; + gift: ((arg1: EventModel, arg2?: any) => void)[]; + sc: ((arg1: EventModel, arg2?: any) => void)[]; + guard: ((arg1: EventModel, arg2?: any) => void)[]; + enter: ((arg1: EventModel, arg2?: any) => void)[]; // 新增: 用户进入事件 + scDel: ((arg1: EventModel, arg2?: any) => void)[]; // 新增: SC 删除事件 + all: ((arg1: any) => void)[]; // '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) - this.state = 'disconnected' - 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 = { + // --- 事件系统 2: 使用原始数据类型 --- + // 事件监听器集合 (使用原始数据结构, 类型设为 any, 由具体实现和调用者保证) + public eventsRaw: { + danmaku: ((arg1: any, arg2?: any) => void)[]; + gift: ((arg1: any, arg2?: any) => void)[]; + sc: ((arg1: any, arg2?: any) => void)[]; + guard: ((arg1: any, arg2?: any) => void)[]; + enter: ((arg1: any, arg2?: any) => void)[]; // 新增: 用户进入事件 + scDel: ((arg1: any, arg2?: any) => void)[]; // 新增: SC 删除事件 + all: ((arg1: any) => void)[]; // 'all' 事件监听器接收原始消息或特定事件包 + }; + + // 创建空的 EventModel 监听器对象 + public createEmptyEventModelListeners() { + return { danmaku: [], gift: [], sc: [], guard: [], - all: [] + enter: [], + scDel: [], + all: [], + }; + } + + // 创建空的 RawEvent 监听器对象 + public createEmptyRawEventlisteners() { + return { + danmaku: [], + gift: [], + sc: [], + guard: [], + enter: [], + scDel: [], + all: [], + }; + } + + /** + * 启动弹幕客户端连接 + * @returns Promise<{ success: boolean; message: string }> 启动结果 + */ + 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 { + // 确保 client 为 null 才初始化 + if (!this.client) { + console.log(`[${this.type}] 正在启动弹幕客户端`); + // 调用子类实现的初始化方法 + const result = await this.initClient(); + if (result.success) { + this.state = 'connected'; + console.log(`[${this.type}] 弹幕客户端启动成功`); + } else { + this.state = 'disconnected'; + console.error(`[${this.type}] 弹幕客户端启动失败: ${result.message}`); + } + return result; + } else { + console.warn(`[${this.type}] 客户端实例已存在但状态异常,尝试重置状态`); + this.state = 'disconnected'; + return { + success: false, + message: '客户端实例状态异常,请尝试重新启动', + }; + } + } catch (err: any) { + console.error(`[${this.type}] 启动过程中发生异常:`, err); + this.state = 'disconnected'; + if (this.client) { + try { this.client.close(); } catch { } + this.client = null; + } + return { + success: false, + message: err?.message || err?.toString() || '未知错误', + }; } } + + /** + * 停止弹幕客户端连接 + */ + public Stop() { + // 如果已断开,则无需操作 + if (this.state === 'disconnected') { + return; + } + // 设置状态为已断开 + this.state = 'disconnected'; + if (this.client) { + console.log(`[${this.type}] 正在停止弹幕客户端`); + try { + this.client.close(); // 关闭 WebSocket 连接 + } catch (err) { + console.error(`[${this.type}] 关闭客户端时发生错误:`, err); + } + this.client = null; // 将客户端实例置为 null + } else { + console.warn(`[${this.type}] 弹幕客户端未被启动, 忽略停止操作`); + } + // 注意: 清空所有事件监听器 + this.eventsAsModel = this.createEmptyEventModelListeners(); + this.eventsRaw = this.createEmptyRawEventlisteners(); + } + + /** + * 初始化客户端实例 (抽象方法,由子类实现具体的创建逻辑) + * @returns Promise<{ success: boolean; message: string }> 初始化结果 + */ protected abstract initClient(): Promise<{ - success: boolean - message: string - }> + success: boolean; + message: string; + }>; + + /** + * 内部通用的客户端事件绑定和连接状态等待逻辑 + * @param chatClient - 已创建的 KeepLiveWS 实例 + * @returns Promise<{ success: boolean; message: string }> 连接结果 + */ protected async initClientInner( chatClient: KeepLiveWS - ): Promise<{ success: boolean; message: string }> { - let isConnected = false - let isError = false - let errorMsg = '' + ): 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 - }) + console.error(`[${this.type}] 客户端发生错误:`, err); + isError = true; + errorMsg = err?.message || err?.toString() || '未知错误'; + }); + + // 监听连接成功事件 chatClient.on('live', () => { - isConnected = true - }) + console.log(`[${this.type}] 弹幕客户端连接成功`); + isConnected = true; + }); + + // 监听连接关闭事件 chatClient.on('close', () => { - console.log(`[${this.type}] 弹幕客户端已关闭`) - }) - chatClient.on('msg', (cmd) => this.onRawMessage(cmd)) + console.log(`[${this.type}] 弹幕客户端连接已关闭`); + if (this.state !== 'disconnected') { + this.state = 'disconnected'; + this.client = null; + } + isConnected = false; // 标记为未连接 + }); - this.client = chatClient + // 监听原始消息事件 (通用) + // 注意: 子类可能也会监听特定事件名, 这里的 'msg' 是备用或处理未被特定监听器捕获的事件 + chatClient.on('msg', (command: any) => this.onRawMessage(command)); + + this.client = chatClient; // 保存客户端实例 + + // 等待连接成功或发生错误 + const timeout = 30000; // 30 秒超时 + const startTime = Date.now(); while (!isConnected && !isError) { - await new Promise((resolve) => { - setTimeout(resolve, 1000) - }) + if (Date.now() - startTime > timeout) { + isError = true; + errorMsg = '连接超时'; + console.error(`[${this.type}] ${errorMsg}`); + break; + } + await new Promise((resolve) => { setTimeout(resolve, 500); }); } - if (isError) { - this.client.close() - this.client = null + + // 如果连接过程中发生错误,清理客户端实例 + if (isError && this.client) { + try { this.client.close(); } catch { } + this.client = null; + this.state = 'disconnected'; } - this.serverUrl = chatClient.connection.ws.ws.url + + // 返回连接结果 return { - success: !isError, - message: errorMsg - } + success: isConnected && !isError, + message: errorMsg, + }; } + /** + * 处理接收到的原始消息,并根据类型分发 (主要用于 'msg' 事件) + * @param command - 原始消息对象 (类型为 any) + */ public onRawMessage = (command: any) => { - this.eventsAsModel.all?.forEach((d) => { - d(command) - }) + // 触发 'all' 事件监听器 (两套系统都触发) + try { + this.eventsAsModel.all?.forEach((listener) => { listener(command); }); + this.eventsRaw.all?.forEach((listener) => { listener(command); }); + } catch (err) { + console.error(`[${this.type}] 处理 'all' 事件监听器时出错:`, err, command); + } + }; + + // --- 抽象处理方法 (子类实现) --- + // 这些方法负责接收原始数据, 触发 RawEvent, 转换数据, 触发 ModelEvent + + /** + * 处理弹幕消息 (子类实现) + * @param data - 原始消息数据部分 (any 类型) + * @param rawCommand - 完整的原始消息对象 (可选, any 类型) + */ + public abstract onDanmaku(comand: any): void; + /** + * 处理礼物消息 (子类实现) + * @param data - 原始消息数据部分 (any 类型) + * @param rawCommand - 完整的原始消息对象 (可选, any 类型) + */ + public abstract onGift(comand: any): void; + /** + * 处理 Super Chat 消息 (子类实现) + * @param data - 原始消息数据部分 (any 类型) + * @param rawCommand - 完整的原始消息对象 (可选, any 类型) + */ + public abstract onSC(comand: any): void; + /** + * 处理上舰/舰队消息 (子类实现) + * @param data - 原始消息数据部分 (any 类型) + * @param rawCommand - 完整的原始消息对象 (可选, any 类型) + */ + public abstract onGuard(comand: any): void; + /** + * 处理用户进入消息 (子类实现) + * @param data - 原始消息数据部分 (any 类型) + * @param rawCommand - 完整的原始消息对象 (可选, any 类型) + */ + public abstract onEnter(comand: any): void; + /** + * 处理 SC 删除消息 (子类实现) + * @param data - 原始消息数据部分 (any 类型) - 通常可能只包含 message_id + * @param rawCommand - 完整的原始消息对象 (可选, any 类型) + */ + public abstract onScDel(comand: any): void; + + + // --- 事件系统 1: on/off (使用 EventModel) --- + 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: 'enter', listener: (arg1: EventModel, arg2?: any) => void): this; // 新增 + public onEvent(eventName: 'scDel', listener: (arg1: EventModel, arg2?: any) => void): this; // 新增 + public onEvent(eventName: 'all', listener: (arg1: any) => void): this; + public onEvent(eventName: keyof BaseDanmakuClient['eventsAsModel'], listener: (...args: any[]) => void): this { + if (!this.eventsAsModel[eventName]) { + // @ts-ignore + this.eventsAsModel[eventName] = []; + } + // @ts-ignore + this.eventsAsModel[eventName].push(listener); + return this; } - 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) + public offEvent(eventName: keyof BaseDanmakuClient['eventsAsModel'], listener: (...args: any[]) => void): this { + if (this.eventsAsModel[eventName]?.length) { + // @ts-ignore + const index = this.eventsAsModel[eventName].indexOf(listener); if (index > -1) { - this.eventsAsModel[eventName].splice(index, 1) + this.eventsAsModel[eventName].splice(index, 1); } } - return this + return this; } -} + + + // --- 事件系统 2: on/off (使用原始数据) --- + // 注意: listener 的 arg1 类型为 any, 需要调用者根据 eventName 自行转换或处理 + public on(eventName: 'danmaku', listener: (arg1: any, arg2?: any) => void): this; + public on(eventName: 'gift', listener: (arg1: any, arg2?: any) => void): this; + public on(eventName: 'sc', listener: (arg1: any, arg2?: any) => void): this; + public on(eventName: 'guard', listener: (arg1: any, arg2?: any) => void): this; + public on(eventName: 'enter', listener: (arg1: any, arg2?: any) => void): this; // 新增 + public on(eventName: 'scDel', listener: (arg1: any, arg2?: any) => void): this; // 新增 + public on(eventName: 'all', listener: (arg1: any) => void): this; + public on(eventName: keyof BaseDanmakuClient['eventsRaw'], listener: (...args: any[]) => void): this { + if (!this.eventsRaw[eventName]) { + // @ts-ignore + this.eventsRaw[eventName] = []; + } + // @ts-ignore + this.eventsRaw[eventName].push(listener); + return this; + } + + public off(eventName: keyof BaseDanmakuClient['eventsRaw'], listener: (...args: any[]) => void): this { + if (this.eventsRaw[eventName]?.length) { + // @ts-ignore + const index = this.eventsRaw[eventName].indexOf(listener); + if (index > -1) { + this.eventsRaw[eventName].splice(index, 1); + } + } + return this; + } +} \ No newline at end of file diff --git a/src/data/DanmakuClients/DirectClient.ts b/src/data/DanmakuClients/DirectClient.ts index c9652e2..02acb29 100644 --- a/src/data/DanmakuClients/DirectClient.ts +++ b/src/data/DanmakuClients/DirectClient.ts @@ -1,66 +1,207 @@ -import { KeepLiveWS } from 'bilibili-live-ws/browser' -import BaseDanmakuClient from './BaseDanmakuClient' +import { KeepLiveWS } from 'bilibili-live-ws/browser'; +import BaseDanmakuClient from './BaseDanmakuClient'; +import { EventDataTypes } from '@/api/api-models'; +import { getUserAvatarUrl, GuidUtils } from '@/Utils'; +import { AVATAR_URL } from '../constants'; export type DirectClientAuthInfo = { - token: string - roomId: number - tokenUserId: number - buvid: string -} + token: string; + roomId: number; + tokenUserId: number; + buvid: string; +}; /** 直播间弹幕客户端, 只能在vtsuru.client环境使用 * - * 未实现除raw事件外的所有事件 */ export default class DirectClient extends BaseDanmakuClient { public serverUrl: string = 'wss://broadcastlv.chat.bilibili.com/sub'; - 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 + super(); + this.authInfo = auth; } - public type = 'direct' as const + public type = 'direct' as const; - public readonly authInfo: DirectClientAuthInfo + public readonly authInfo: DirectClientAuthInfo; - protected async initClient(): Promise<{ success: boolean; message: string }> { + 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) + console.log('[DIRECT] 已连接房间: ' + this.authInfo.roomId); + }); + chatClient.on('DANMU_MSG', (data) => this.onDanmaku(data)); + chatClient.on('SEND_GIFT', (data) => this.onGift(data)); + chatClient.on('GUARD_BUY', (data) => this.onGuard(data)); + chatClient.on('SUPER_CHAT_MESSAGE', (data) => this.onSC(data)); + chatClient.on('INTERACT_WORD', (data) => this.onEnter(data)); + chatClient.on('SUPER_CHAT_MESSAGE_DELETE', (data) => this.onScDel(data)); + + return await super.initClientInner(chatClient); } else { - console.log('[DIRECT] 无法开启场次, 未提供弹幕客户端认证信息') + console.log('[DIRECT] 无法开启场次, 未提供弹幕客户端认证信息'); return { success: false, message: '未提供弹幕客户端认证信息' - } + }; } } + public onDanmaku(command: any): void { + const data = command.data; + const info = data.info; + this.eventsRaw?.danmaku?.forEach((d) => { d(data, command); }); + this.eventsAsModel.danmaku?.forEach((d) => { + d( + { + type: EventDataTypes.Message, + name: info[2][1], + uid: info[2][0], + msg: info[1], + price: 0, + num: 1, + time: Date.now(), + guard_level: info[7], + fans_medal_level: info[0][15].medal?.level, + fans_medal_name: info[0][15].medal?.name, + fans_medal_wearing_status: info[0][15].medal?.is_light === 1, + emoji: info[0]?.[13]?.url?.replace("http://", "https://") || '', + uface: info[0][15].user.base.face.replace("http://", "https://"), + open_id: '', + ouid: GuidUtils.numToGuid(info[2][0]) + }, + command + ); + }); + } + public onGift(command: any): void { + const data = command.data; + this.eventsRaw?.gift?.forEach((d) => { d(data, command); }); + this.eventsAsModel.gift?.forEach((d) => { + d( + { + type: EventDataTypes.Gift, + name: data.uname, + uid: data.uid, + msg: data.giftName, + price: data.giftId, + num: data.num, + time: Date.now(), + guard_level: data.guard_level, + fans_medal_level: data.medal_info.medal_level, + fans_medal_name: data.medal_info.medal_name, + fans_medal_wearing_status: data.medal_info.is_lighted === 1, + uface: data.face.replace("http://", "https://"), + open_id: '', + ouid: GuidUtils.numToGuid(data.uid) + }, + command + ); + }); + } + public onSC(command: any): void { + const data = command.data; + this.eventsRaw?.sc?.forEach((d) => { d(data, command); }); + this.eventsAsModel.sc?.forEach((d) => { + d( + { + type: EventDataTypes.SC, + name: data.user_info.uname, + uid: data.uid, + msg: data.message, + price: data.price, + num: 1, + time: Date.now(), + guard_level: data.user_info.guard_level, + fans_medal_level: data.medal_info.medal_level, + fans_medal_name: data.medal_info.medal_name, + fans_medal_wearing_status: data.medal_info.is_lighted === 1, + uface: data.user_info.face.replace("http://", "https://"), + open_id: '', + ouid: GuidUtils.numToGuid(data.uid) + }, + command + ); + }); + } + public onGuard(command: any): void { + const data = command.data; + this.eventsRaw?.guard?.forEach((d) => { d(data, command); }); + this.eventsAsModel.guard?.forEach((d) => { + d( + { + type: EventDataTypes.Guard, + name: data.username, + uid: data.uid, + msg: data.gift_name, + price: data.price / 1000, + num: data.num, + time: Date.now(), + guard_level: data.guard_level, + fans_medal_level: 0, + fans_medal_name: '', + fans_medal_wearing_status: false, + uface: AVATAR_URL + data.uid, + open_id: '', + ouid: GuidUtils.numToGuid(data.uid) + }, + command + ); + }); + } + public onEnter(command: any): void { + const data = command.data; + this.eventsRaw?.enter?.forEach((d) => { d(data, command); }); + this.eventsAsModel.enter?.forEach((d) => { + d( + { + type: EventDataTypes.Enter, + name: data.uname, + uid: data.uid, + msg: '', + price: 0, + num: 1, + time: Date.now(), + guard_level: 0, + fans_medal_level: data.fans_medal?.medal_level || 0, + fans_medal_name: data.fans_medal?.medal_name || '', + fans_medal_wearing_status: false, + uface: getUserAvatarUrl(data.uid), + open_id: '', + ouid: GuidUtils.numToGuid(data.uid) + }, + command + ); + }); + } + public onScDel(command: any): void { + const data = command.data; + this.eventsRaw?.scDel?.forEach((d) => { d(data, command); }); + this.eventsAsModel.scDel?.forEach((d) => { + d( + { + type: EventDataTypes.SCDel, + name: '', + uid: 0, + msg: JSON.stringify(data.ids), + price: 0, + num: 1, + time: Date.now(), + guard_level: 0, + fans_medal_level: 0, + fans_medal_name: '', + fans_medal_wearing_status: false, + uface: '', + open_id: '', + ouid: '' + }, + command + ); + }); + } } diff --git a/src/data/DanmakuClients/OpenLiveClient.ts b/src/data/DanmakuClients/OpenLiveClient.ts index 3d5fe66..7d25106 100644 --- a/src/data/DanmakuClients/OpenLiveClient.ts +++ b/src/data/DanmakuClients/OpenLiveClient.ts @@ -1,143 +1,126 @@ -import { EventDataTypes, OpenLiveInfo } from '@/api/api-models' -import { QueryGetAPI, QueryPostAPI } from '@/api/query' -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 BaseDanmakuClient from './BaseDanmakuClient' +import { EventDataTypes, OpenLiveInfo } from '@/api/api-models'; +import { QueryGetAPI, QueryPostAPI } from '@/api/query'; +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 BaseDanmakuClient from './BaseDanmakuClient'; export default class OpenLiveClient extends BaseDanmakuClient { public serverUrl: string = ''; constructor(auth?: AuthInfo) { - super() - this.authInfo = auth - this.events = { danmaku: [], gift: [], sc: [], guard: [], all: [] } + super(); + this.authInfo = auth; } - public type = 'openlive' as const + public type = 'openlive' as const; - private timer: any | undefined + private timer: any | undefined; - public authInfo: AuthInfo | undefined - public roomAuthInfo: OpenLiveInfo | undefined - public authCode: string | undefined + public authInfo: AuthInfo | undefined; + public roomAuthInfo: OpenLiveInfo | 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() + public async Start(): Promise<{ success: boolean; message: string; }> { + const result = await super.Start(); if (result.success) { this.timer ??= setInterval(() => { - this.sendHeartbeat() - }, 20 * 1000) + this.sendHeartbeat(); + }, 20 * 1000); } - return result + return result; } public Stop() { - super.Stop() - this.events = { - danmaku: [], - gift: [], - sc: [], - guard: [], - all: [] - } + super.Stop(); + clearInterval(this.timer); + this.timer = undefined; + this.roomAuthInfo = undefined; } - protected async initClient(): Promise<{ success: boolean; message: string }> { - const auth = await this.getAuthInfo() + 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_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('LIVE_OPEN_PLATFORM_LIVE_ROOM_ENTER', (cmd) => this.onEnter(cmd)); + chatClient.on('LIVE_OPEN_PLATFORM_SUPER_CHAT_DEL', (cmd) => this.onScDel(cmd)); chatClient.on('live', () => { console.log( `[${this.type}] 已连接房间: ${auth.data?.anchor_info.room_id}` - ) - }) + ); + }); - this.roomAuthInfo = auth.data + this.roomAuthInfo = auth.data; - return await super.initClientInner(chatClient) + return await super.initClientInner(chatClient); } else { - console.log(`[${this.type}] 无法开启场次: ` + auth.message) + console.log(`[${this.type}] 无法开启场次: ` + auth.message); return { success: false, message: auth.message - } + }; } } private async getAuthInfo(): Promise<{ - data: OpenLiveInfo | null - message: string + 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}] 已获取场次信息`) + 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 + 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') + 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() + 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) - }) + const data = command.data as DanmakuInfo; + this.eventsRaw.danmaku?.forEach((d) => { + d(data, command); + }); this.eventsAsModel.danmaku?.forEach((d) => { d( { @@ -158,15 +141,15 @@ export default class OpenLiveClient extends BaseDanmakuClient { 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) - }) + const data = command.data as GiftInfo; + const price = (data.price * data.gift_num) / 1000; + this.eventsRaw.gift?.forEach((d) => { + d(data, command); + }); this.eventsAsModel.gift?.forEach((d) => { d( { @@ -186,14 +169,14 @@ export default class OpenLiveClient extends BaseDanmakuClient { 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) - }) + const data = command.data as SCInfo; + this.eventsRaw.sc?.forEach((d) => { + d(data, command); + }); this.eventsAsModel.sc?.forEach((d) => { d( { @@ -213,14 +196,14 @@ export default class OpenLiveClient extends BaseDanmakuClient { 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) - }) + const data = command.data as GuardInfo; + this.eventsRaw.guard?.forEach((d) => { + d(data, command); + }); this.eventsAsModel.guard?.forEach((d) => { d( { @@ -235,7 +218,7 @@ export default class OpenLiveClient extends BaseDanmakuClient { : data.guard_level == 3 ? '舰长' : '', - price: 0, + price: data.price / 1000, num: data.guard_num, time: data.timestamp, guard_level: data.guard_level, @@ -248,134 +231,165 @@ export default class OpenLiveClient extends BaseDanmakuClient { 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 onEnter(command: any): void { + const data = command.data as EnterInfo; + this.eventsRaw.enter?.forEach((d) => { + d(data); + }); + this.eventsAsModel.enter?.forEach((d) => { + d( + { + type: EventDataTypes.Enter, + name: data.uname, + msg: '', + price: 0, + num: 0, + time: data.timestamp, + guard_level: 0, + fans_medal_level: 0, + fans_medal_name: '', + fans_medal_wearing_status: false, + uface: data.uface, + open_id: data.open_id, + uid: 0, + ouid: data.open_id + }, + command + ); + }); } - 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 + public onScDel(command: any): void { + const data = command.data as SCDelInfo; + this.eventsRaw.scDel?.forEach((d) => { + d(data, command); + }); + this.eventsAsModel.scDel?.forEach((d) => { + d( + { + type: EventDataTypes.Enter, + name: '', + msg: JSON.stringify(data.message_ids), + price: 0, + num: 0, + time: Date.now(), + guard_level: 0, + fans_medal_level: 0, + fans_medal_name: '', + fans_medal_wearing_status: false, + uface: '', + open_id: '', + uid: 0, + ouid: '' + }, + command + ); + }); } } export interface DanmakuInfo { - room_id: number - uid: number - open_id: string - uname: string - msg: string - msg_id: string - fans_medal_level: number - fans_medal_name: string - fans_medal_wearing_status: boolean - guard_level: number - timestamp: number - uface: string - emoji_img_url: string - dm_type: number + room_id: number; + uid: number; + open_id: string; + uname: string; + msg: string; + msg_id: string; + fans_medal_level: number; + fans_medal_name: string; + fans_medal_wearing_status: boolean; + guard_level: number; + timestamp: number; + uface: string; + emoji_img_url: string; + dm_type: number; } export interface GiftInfo { - room_id: number - uid: number - open_id: string - uname: string - uface: string - gift_id: number - gift_name: string - gift_num: number - price: number - paid: boolean - fans_medal_level: number - fans_medal_name: string - fans_medal_wearing_status: boolean - guard_level: number - timestamp: number - msg_id: string + room_id: number; + uid: number; + open_id: string; + uname: string; + uface: string; + gift_id: number; + gift_name: string; + gift_num: number; + price: number; + paid: boolean; + fans_medal_level: number; + fans_medal_name: string; + fans_medal_wearing_status: boolean; + guard_level: number; + timestamp: number; + msg_id: string; anchor_info: { - uid: number - uname: string - uface: string - } - gift_icon: string - combo_gift: boolean + uid: number; + uname: string; + uface: string; + }; + gift_icon: string; + combo_gift: boolean; combo_info: { - combo_base_num: number - combo_count: number - combo_id: string - combo_timeout: number - } + combo_base_num: number; + combo_count: number; + combo_id: string; + combo_timeout: number; + }; } export interface SCInfo { - room_id: number // 直播间id - uid: number // 购买用户UID - open_id: string - uname: string // 购买的用户昵称 - uface: string // 购买用户头像 - message_id: number // 留言id(风控场景下撤回留言需要) - message: string // 留言内容 - msg_id: string // 消息唯一id - rmb: number // 支付金额(元) - timestamp: number // 赠送时间秒级 - start_time: number // 生效开始时间 - end_time: number // 生效结束时间 - guard_level: number // 对应房间大航海登记 (新增) - fans_medal_level: number // 对应房间勋章信息 (新增) - fans_medal_name: string // 对应房间勋章名字 (新增) - fans_medal_wearing_status: boolean // 该房间粉丝勋章佩戴情况 (新增) + room_id: number; // 直播间id + uid: number; // 购买用户UID + open_id: string; + uname: string; // 购买的用户昵称 + uface: string; // 购买用户头像 + message_id: number; // 留言id(风控场景下撤回留言需要) + message: string; // 留言内容 + msg_id: string; // 消息唯一id + rmb: number; // 支付金额(元) + timestamp: number; // 赠送时间秒级 + start_time: number; // 生效开始时间 + end_time: number; // 生效结束时间 + guard_level: number; // 对应房间大航海登记 (新增) + fans_medal_level: number; // 对应房间勋章信息 (新增) + fans_medal_name: string; // 对应房间勋章名字 (新增) + fans_medal_wearing_status: boolean; // 该房间粉丝勋章佩戴情况 (新增) } export interface GuardInfo { user_info: { - uid: number // 用户uid - open_id: string - uname: string // 用户昵称 - uface: string // 用户头像 - } - guard_level: number // 对应的大航海等级 1总督 2提督 3舰长 - guard_num: number - guard_unit: string // (个月) - fans_medal_level: number // 粉丝勋章等级 - fans_medal_name: string // 粉丝勋章名 - fans_medal_wearing_status: boolean // 该房间粉丝勋章佩戴情况 - timestamp: number - room_id: number - msg_id: string // 消息唯一id + uid: number; // 用户uid + open_id: string; + uname: string; // 用户昵称 + uface: string; // 用户头像 + }; + guard_level: number; // 对应的大航海等级 1总督 2提督 3舰长 + guard_num: number; + price: number; // 购买金额(1000=1元) + guard_unit: string; // (个月) + fans_medal_level: number; // 粉丝勋章等级 + fans_medal_name: string; // 粉丝勋章名 + fans_medal_wearing_status: boolean; // 该房间粉丝勋章佩戴情况 + timestamp: number; + room_id: number; + msg_id: string; // 消息唯一id +} +export interface EnterInfo { + open_id: string; + uname: string; + uface: string; + timestamp: number; + room_id: number; +} +// 假设的 SC 删除事件原始信息结构 (需要根据实际情况调整) +export interface SCDelInfo { + room_id: number; + message_ids: number[]; // 被删除的 SC 的 message_id + msg_id: string; // 删除操作的消息 ID } export interface AuthInfo { - Timestamp: string - Code: string - Mid: string - Caller: string - CodeSign: string -} -export interface DanmakuEventsMap { - 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 -} + Timestamp: string; + Code: string; + Mid: string; + Caller: string; + CodeSign: string; +} \ No newline at end of file diff --git a/src/data/constants.ts b/src/data/constants.ts index c4893f2..92628bc 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -14,7 +14,7 @@ export const isDev = import.meta.env.MODE === 'development'; export const isTauri = window.__TAURI__ !== undefined || window.__TAURI_INTERNAL__ !== undefined; export const AVATAR_URL = 'https://workers.vrp.moe/api/bilibili/avatar/'; -export const FILE_BASE_URL = 'https://files.vtsuru.live'; +export const FILE_BASE_URL = 'https://files.vtsuru.suki.club'; export const IMGUR_URL = FILE_BASE_URL + '/imgur/'; export const THINGS_URL = FILE_BASE_URL + '/things/'; export const apiFail = ref(false); diff --git a/src/router/client.ts b/src/router/client.ts index 9a902cd..c4a68a2 100644 --- a/src/router/client.ts +++ b/src/router/client.ts @@ -26,6 +26,14 @@ export default { title: '设置', } }, + { + path: 'danmaku-window-manage', + name: 'client-danmaku-window-manage', + component: () => import('@/client/DanmakuWindowManager.vue'), + meta: { + title: '弹幕窗口管理', + } + }, { path: 'test', name: 'client-test', diff --git a/src/router/singlePage.ts b/src/router/singlePage.ts index f32e4a0..bd8bdfb 100644 --- a/src/router/singlePage.ts +++ b/src/router/singlePage.ts @@ -14,5 +14,13 @@ export default [ meta: { title: '测试页' } + }, + { + path: '/danmaku-window', + name: 'client-danmaku-client', + component: () => import('@/client/ClientDanmakuWindow.vue'), + meta: { + title: '弹幕窗口' + } } ] diff --git a/src/store/useDanmakuClient.ts b/src/store/useDanmakuClient.ts index 84b168f..256a497 100644 --- a/src/store/useDanmakuClient.ts +++ b/src/store/useDanmakuClient.ts @@ -1,238 +1,316 @@ -import { useAccount } from '@/api/account' -import { OpenLiveInfo } from '@/api/api-models' -import OpenLiveClient, { AuthInfo } from '@/data/DanmakuClients/OpenLiveClient' -import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import { EventModel, OpenLiveInfo } from '@/api/api-models'; +import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient'; +import DirectClient, { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient'; +import OpenLiveClient, { AuthInfo } from '@/data/DanmakuClients/OpenLiveClient'; +import { defineStore } from 'pinia'; +import { computed, ref, shallowRef } from 'vue'; // 引入 shallowRef -export interface BCMessage { - type: string - data: string -} +// 定义支持的事件名称类型 +type EventName = 'danmaku' | 'gift' | 'sc' | 'guard' | 'enter' | 'scDel'; +type EventNameWithAll = EventName | 'all'; +// 定义监听器函数类型 +type Listener = (arg1: any, arg2: any) => void; +type EventListener = (arg1: EventModel, arg2: any) => void; +// --- 修正点: 确保 AllEventListener 定义符合要求 --- +// AllEventListener 明确只接受一个参数 +type AllEventListener = (arg1: any) => void; + +// --- 修正点: 定义一个统一的监听器类型,用于内部实现签名 --- +type GenericListener = Listener | AllEventListener; export const useDanmakuClient = defineStore('DanmakuClient', () => { - const danmakuClient = ref(new OpenLiveClient()) - let bc: BroadcastChannel - const isOwnedDanmakuClient = 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() + // 使用 shallowRef 存储 danmakuClient 实例, 性能稍好 + const danmakuClient = shallowRef(); - let existOtherClient = false - let isInitializing = false + // 连接状态: 'waiting'-等待初始化, 'connecting'-连接中, 'connected'-已连接 + const state = ref<'waiting' | 'connecting' | 'connected'>('waiting'); + // 计算属性, 判断是否已连接 + const connected = computed(() => state.value === 'connected'); + // 存储开放平台认证信息 (如果使用 OpenLiveClient) + const authInfo = ref(); - function on( - eventName: 'danmaku' | 'gift' | 'sc' | 'guard', - listener: (...args: any[]) => void - ) { - if (!danmakuClient.value.events[eventName]) { - danmakuClient.value.events[eventName] = [] + // 初始化锁, 防止并发初始化 + let isInitializing = false; + + /** + * @description 注册事件监听器 (特定于 OpenLiveClient 的原始事件 或 调用 onEvent) + * @param eventName 事件名称 ('danmaku', 'gift', 'sc', 'guard', 'enter', 'scDel') + * @param listener 回调函数 + * @remarks 对于 OpenLiveClient, 直接操作其内部 events; 对于其他客户端, 调用 onEvent. + */ + // --- 修正点: 保持重载签名不变 --- + function onEvent(eventName: 'all', listener: AllEventListener): void; + function onEvent(eventName: EventName, listener: Listener): void; + // --- 修正点: 实现签名使用联合类型,并在内部进行类型断言 --- + function onEvent(eventName: keyof BaseDanmakuClient['eventsAsModel'], listener: GenericListener): void { + if(!danmakuClient.value) { + console.warn("[DanmakuClient] 尝试在客户端初始化之前调用 'onEvent'。"); + return; } - 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] = [] + if (eventName === 'all') { + // 对于 'all' 事件, 直接使用 AllEventListener 类型 + danmakuClient.value.eventsAsModel[eventName].push(listener as AllEventListener); + } else { + danmakuClient.value.eventsAsModel[eventName].push(listener); } - 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) + /* + * @description 注册事件监听器 (模型化数据, 存储在 Store 中) + * @param eventName 事件名称 ('danmaku', 'gift', 'sc', 'guard', 'all') + * @param listener 回调函数 + * @remarks 监听器存储在 Store 中, 会在客户端重连后自动重新附加. + */ + // --- 修正点: 保持重载签名不变 --- + function on(eventName: 'all', listener: AllEventListener): void; + function on(eventName: EventName, listener: Listener): void; + // --- 修正点: 实现签名使用联合类型,并在内部进行类型断言 --- + function on(eventName: EventNameWithAll, listener: GenericListener): void { + if (!danmakuClient.value) { + console.warn("[DanmakuClient] 尝试在客户端初始化之前调用 'on'。"); + return; + } + danmakuClient.value.eventsRaw[eventName].push(listener); + } + + + /** + * @description 移除事件监听器 (模型化数据, 从 Store 中移除) + * @param eventName 事件名称 ('danmaku', 'gift', 'sc', 'guard', 'all') + * @param listener 要移除的回调函数 + */ + // --- 修正点: 保持重载签名不变 --- + function offEvent(eventName: 'all', listener: AllEventListener): void; + function offEvent(eventName: EventName, listener: Listener): void; + // --- 修正点: 实现签名使用联合类型,并在内部进行类型断言 --- + function offEvent(eventName: keyof BaseDanmakuClient['eventsRaw'], listener: GenericListener): void { + if (!danmakuClient.value) { + console.warn("[DanmakuClient] 尝试在客户端初始化之前调用 'offEvent'。"); + return; + } + if (eventName === 'all') { + // 对于 'all' 事件, 直接使用 AllEventListener 类型 + const modelListeners = danmakuClient.value.eventsAsModel[eventName] as AllEventListener[]; + const index = modelListeners.indexOf(listener as AllEventListener); 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('[DanmakuClient] 创建 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 = typeof data === 'string' ? JSON.parse(data) : 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('[DanmakuClient] 正在检查客户端状态...') - sendBCMessage('check-client') - setTimeout(() => { - if (!connected.value) { - isOwnedDanmakuClient.value = true - initClientInternal(auth) - } else { - console.log( - '[DanmakuClient] 已存在其他页面弹幕客户端, 开始监听 BroadcastChannel...' - ) - } - - setInterval(checkClientStatus, 500) - }, 1000) - } - } - ) - } - isInitializing = false - return useDanmakuClient() - } - function sendBCMessage(type: string, data?: any) { - bc.postMessage({ - type, - data: JSON.stringify(data) - }) - } - function checkClientStatus() { - if (!existOtherClient && !isOwnedDanmakuClient.value) { - //当不存在其他客户端, 且自己不是弹幕客户端 - //则自己成为新的弹幕客户端 - if (status.value != 'initializing') { - console.log('[DanmakuClient] 其他 Client 离线, 开始初始化...') - initClientInternal() + modelListeners.splice(index, 1); } } else { - existOtherClient = false //假设其他客户端不存在 - sendBCMessage('check-client') //检查其他客户端是否存在 + const index = danmakuClient.value.eventsAsModel[eventName].indexOf(listener); + if (index > -1) { + danmakuClient.value.eventsAsModel[eventName].splice(index, 1); + } else { + console.warn(`[DanmakuClient] 试图移除未注册的监听器: ${listener}`); + } } } - async function initClientInternal(auth?: AuthInfo) { - status.value = 'initializing' - await navigator.locks.request( - 'danmakuClientInitInternal', - { - ifAvailable: true - }, - async (lock) => { - if (lock) { - // 有锁 - isOwnedDanmakuClient.value = true - const events = danmakuClient.value.events - const eventsAsModel = danmakuClient.value.eventsAsModel + /* + * @description 移除事件监听器 (特定于 OpenLiveClient 或调用 offEvent) + * @param eventName 事件名称 ('danmaku', 'gift', 'sc', 'guard', 'all') + * @param listener 要移除的回调函数 + */ + // --- 修正点: 保持重载签名不变 --- + function off(eventName: 'all', listener: AllEventListener): void; + function off(eventName: EventName, listener: Listener): void; + // --- 修正点: 实现签名使用联合类型,并在内部进行类型断言 --- + function off(eventName: EventNameWithAll, listener: GenericListener): void { + if (!danmakuClient.value) { + console.warn("[DanmakuClient] 尝试在客户端初始化之前调用 'off'。"); + return; + } + const index = danmakuClient.value.eventsRaw[eventName].indexOf(listener); + if (index > -1) { + danmakuClient.value.eventsRaw[eventName].splice(index, 1); + } + // 直接从 eventsRaw 中移除监听器 + } - danmakuClient.value = new OpenLiveClient(auth) + /** + * @description 初始化 OpenLive 客户端 + * @param auth 认证信息 + * @returns + */ + async function initOpenlive(auth?: AuthInfo) { + return initClient(new OpenLiveClient(auth)); + } - 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('[DanmakuClient] 初始化成功') - sendBCMessage('response-client-status', { - status: 'running', - auth: authInfo.value - }) - danmakuClient.value.on('all', (data) => { - sendBCMessage('on-danmaku', data) - }) - return true - } else { - console.log( - '[DanmakuClient] 初始化失败, 5秒后重试: ' + result.message - ) - return false - } - } - while (!(await init())) { - await new Promise((resolve) => { - setTimeout(() => { - resolve(true) - }, 5000) - }) - } - } else { - // 无锁 - console.log('[DanmakuClient] 正在等待其他页面弹幕客户端初始化...') - status.value = 'listening' - isOwnedDanmakuClient.value = false + /** + * @description 初始化 Direct 客户端 + * @param auth 认证信息 + * @returns + */ + async function initDirect(auth: DirectClientAuthInfo) { + return initClient(new DirectClient(auth)); + } + + + // 辅助函数: 从客户端的 eventsAsModel 移除单个监听器 + // --- 修正点: 修正 detachListenerFromClient 的签名和实现以处理联合类型 --- + function detachListenerFromClient(client: BaseDanmakuClient, eventName: EventNameWithAll, listener: GenericListener): void { + if (client.eventsAsModel[eventName]) { + if (eventName === 'all') { + const modelListeners = client.eventsAsModel[eventName] as AllEventListener[]; + const index = modelListeners.indexOf(listener as AllEventListener); + if (index > -1) { + modelListeners.splice(index, 1); + } + } else { + const modelListeners = client.eventsAsModel[eventName] as Listener[]; + const index = modelListeners.indexOf(listener as Listener); + if (index > -1) { + modelListeners.splice(index, 1); } } - ) + } + } + + + /** + * @description 通用客户端初始化逻辑 + * @param client 要初始化的客户端实例 + * @returns Promise 是否初始化成功 (包括重试后最终成功) + */ + async function initClient(client: BaseDanmakuClient) { // 返回 Promise 表示最终是否成功 + // 防止重复初始化或在非等待状态下初始化 + if (isInitializing || state.value !== 'waiting') { + console.warn(`[DanmakuClient] 初始化尝试被阻止。 isInitializing: ${isInitializing}, state: ${state.value}`); + return useDanmakuClient(); // 如果已连接,则视为“成功” + } + + isInitializing = true; + state.value = 'connecting'; + console.log('[DanmakuClient] 开始初始化...'); + + + const oldEventsAsModel = danmakuClient.value?.eventsAsModel; + const oldEventsRaw = danmakuClient.value?.eventsRaw; + + // 先停止并清理旧客户端 (如果存在) + if (danmakuClient.value) { + console.log('[DanmakuClient] 正在处理旧的客户端实例...'); + await disposeClientInstance(danmakuClient.value); + danmakuClient.value = undefined; // 显式清除旧实例引用 + } + + // 设置新的客户端实例 + danmakuClient.value = client; + // 确保新客户端有空的监听器容器 (BaseDanmakuClient 应负责初始化) + danmakuClient.value.eventsAsModel = oldEventsAsModel || client.createEmptyEventModelListeners(); + danmakuClient.value.eventsRaw = oldEventsRaw || client.createEmptyRawEventlisteners(); + // 通常在 client 实例化或 Start 时处理,或者在 attachListenersToClient 中确保存在 + + + let connectSuccess = false; + const maxRetries = 5; // Example: Limit retries + let retryCount = 0; + + const attemptConnect = async () => { + if (!danmakuClient.value) return false; // Guard against client being disposed during wait + try { + console.log(`[DanmakuClient] 尝试连接 (第 ${retryCount + 1} 次)...`); + const result = await danmakuClient.value.Start(); // 启动连接 + if (result.success) { + // 连接成功 + authInfo.value = danmakuClient.value instanceof OpenLiveClient ? danmakuClient.value.roomAuthInfo : undefined; + state.value = 'connected'; + // 将 Store 中存储的监听器 (来自 onEvent) 附加到新连接的客户端的 eventsAsModel + console.log('[DanmakuClient] 初始化成功。'); + connectSuccess = true; + return true; // 连接成功, 退出重试循环 + } else { + // 连接失败 + console.error(`[DanmakuClient] 连接尝试失败: ${result.message}`); + return false; // 继续重试 + } + } catch (error) { + // 捕获 Start() 可能抛出的异常 + console.error(`[DanmakuClient] 连接尝试期间发生异常:`, error); + return false; // 继续重试 + } + }; + + // 循环尝试连接, 直到成功或达到重试次数 + while (!connectSuccess && retryCount < maxRetries) { + if (state.value !== 'connecting') { // 检查状态是否在循环开始时改变 + console.log('[DanmakuClient] 初始化被外部中止。'); + isInitializing = false; + //return false; // 初始化被中止 + break; + } + + if (!(await attemptConnect())) { + retryCount++; + if (retryCount < maxRetries && state.value === 'connecting') { + console.log(`[DanmakuClient] 5 秒后重试连接... (${retryCount}/${maxRetries})`); + await new Promise((resolve) => setTimeout(resolve, 5000)); + // 再次检查在等待期间状态是否改变 + if (state.value !== 'connecting') { + console.log('[DanmakuClient] 在重试等待期间初始化被中止。'); + isInitializing = false; + //return false; // 初始化被中止 + break; + } + } else if (state.value === 'connecting') { + console.error(`[DanmakuClient] 已达到最大连接重试次数 (${maxRetries})。初始化失败。`); + // 连接失败,重置状态 + await dispose(); // 清理资源 + // state.value = 'waiting'; // dispose 会设置状态 + // isInitializing = false; // dispose 会设置 + // return false; // 返回失败状态 + break; + } + } + } + + isInitializing = false; // 无论成功失败,初始化过程结束 + // 返回最终的连接状态 + return useDanmakuClient(); + } + + // 封装停止和清理客户端实例的逻辑 + async function disposeClientInstance(client: BaseDanmakuClient) { + try { + console.log('[DanmakuClient] 正在停止客户端实例...'); + client.Stop(); // 停止客户端连接和内部处理 + // 可能需要添加额外的清理逻辑,例如移除所有监听器 + // client.eventsAsModel = client.createEmptyEventModelListeners(); // 清空监听器 + // client.eventsRaw = client.createEmptyRawEventlisteners(); // 清空监听器 + console.log('[DanmakuClient] 客户端实例已停止。'); + } catch (error) { + console.error('[DanmakuClient] 停止客户端时出错:', error); + } + } + + /** + * @description 停止并清理当前客户端连接和资源 + */ + async function dispose() { + console.log('[DanmakuClient] 正在停止并清理客户端...'); + isInitializing = false; // 允许在 dispose 后重新初始化 + + if (danmakuClient.value) { + await disposeClientInstance(danmakuClient.value); + danmakuClient.value = undefined; // 解除对旧客户端实例的引用 + } + state.value = 'waiting'; // 重置状态为等待 + authInfo.value = undefined; // 清理认证信息 + // isInitializing = false; // 在函数开始处已设置 + console.log('[DanmakuClient] 已处理。'); + // 注意: Store 中 listeners.value (来自 onEvent) 默认不清空, 以便重连后恢复 } return { - danmakuClient, - isOwnedDanmakuClient, - status, - connected, - authInfo, - on, - off, - onEvent, - offEvent, - initClient - } -}) + danmakuClient, // 当前弹幕客户端实例 (shallowRef) + state, // 连接状态 ('waiting', 'connecting', 'connected') + authInfo, // OpenLive 认证信息 (ref) + connected, // 是否已连接 (computed) + onEvent, // 注册事件监听器 (模型化数据, 存储于 Store) + offEvent, // 移除事件监听器 (模型化数据, 从 Store 移除) + on, // 注册事件监听器 (直接操作 client.eventsRaw) + off, // 移除事件监听器 (直接操作 client.eventsRaw 或调用 offEvent) + initOpenlive, // 初始化 OpenLive 客户端 + initDirect, // 初始化 Direct 客户端 + dispose, // 停止并清理客户端 + }; +}); \ No newline at end of file diff --git a/src/store/useDanmakuClient.ts.bak b/src/store/useDanmakuClient.ts.bak new file mode 100644 index 0000000..91db23c --- /dev/null +++ b/src/store/useDanmakuClient.ts.bak @@ -0,0 +1,272 @@ +import { useAccount } from '@/api/account'; +import { OpenLiveInfo } from '@/api/api-models'; +import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient'; +import DirectClient from '@/data/DanmakuClients/DirectClient'; +import OpenLiveClient, { AuthInfo } from '@/data/DanmakuClients/OpenLiveClient'; +import client from '@/router/client'; +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; + +export interface BCMessage { + type: string; + data: string; +} + +export const useDanmakuClient = defineStore('DanmakuClient', () => { + const danmakuClient = ref(); + const clientType = ref<'openlive' | 'direct'>('openlive'); + let bc: BroadcastChannel | undefined = undefined; + const isOwnedDanmakuClient = ref(false); + const status = ref<'waiting' | 'initializing' | 'listening' | 'running'>( + 'waiting' + ); + const connected = computed( + () => status.value === 'running' || status.value === 'listening' + ); + 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 initOpenlive(auth?: AuthInfo) { + initInternal(() => { + initOpenliveClientInternal(auth); + }, 'openlive'); + } + async function initDirect(auth?: AuthInfo) { + initInternal(() => { + initDirectClientInternal(auth); + }); + } + async function initInternal(callback: () => void, type: 'openlive' | 'direct' = 'openlive') { + if (!isInitializing && !connected.value) { + isInitializing = true; + navigator.locks.request( + 'danmakuClientInit', + { ifAvailable: true }, + async (lock) => { + if (lock) { + status.value = 'initializing'; + if (!bc) { + bc = new BroadcastChannel( + 'vtsuru.danmaku.' + type + '.' + 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) : {}; + switch (message.type) { + case 'check-client': + sendBCMessage('response-client-status', { + status: status.value, + type: clientType, + auth: danmakuClient.value instanceof OpenLiveClient ? danmakuClient.value.roomAuthInfo : undefined + }); + break; + case 'response-client-status': + switch ( + data.status //如果存在已经在运行或者正在启动的客户端, 状态设为 listening + ) { + case 'running': + case 'initializing': + status.value = 'listening'; + existOtherClient = true; + clientType.value = data.type; + //authInfo.value = data.auth + break; + } + break; + case 'on-danmaku': + const danmaku = typeof data === 'string' ? JSON.parse(data) : data; + switch (danmaku.cmd) { + case 'LIVE_OPEN_PLATFORM_DM': + case 'DANMU_MSG': + danmakuClient.value?.onDanmaku(danmaku); + break; + case 'SEND_GIFT': + case 'LIVE_OPEN_PLATFORM_SEND_GIFT': + danmakuClient.value?.onGift(danmaku); + break; + case 'LIVE_OPEN_PLATFORM_SUPER_CHAT': + case 'SUPER_CHAT_MESSAGE': + danmakuClient.value?.onSC(danmaku); + break; + case 'LIVE_OPEN_PLATFORM_GUARD': + case 'GUARD_BUY': + danmakuClient.value?.onGuard(danmaku); + break; + default: + danmakuClient.value?.onRawMessage(danmaku); + break; + } + break; + } + }; + } + + console.log('[DanmakuClient] 正在检查客户端状态...'); + sendBCMessage('check-client'); + setTimeout(() => { + if (!connected.value) { + isOwnedDanmakuClient.value = true; + callback(); + } else { + console.log( + '[DanmakuClient] 已存在其他页面弹幕客户端, 开始监听 BroadcastChannel...' + ); + } + + setInterval(checkClientStatus, 500); + }, 1000); + } + } + ); + } + isInitializing = false; + return useDanmakuClient(); + } + async function initOpenliveClientInternal(auth?: AuthInfo) { + status.value = 'initializing'; + await navigator.locks.request( + 'danmakuClientInitInternal', + { + ifAvailable: true + }, + async (lock) => { + if (lock) { + // 有锁 + isOwnedDanmakuClient.value = true; + //const events = danmakuClient.value?.events + const eventsAsModel = danmakuClient.value?.eventsAsModel; + + danmakuClient.value = new OpenLiveClient( + auth + ); + + //danmakuClient.value?.events = events + if (eventsAsModel) { + 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('[DanmakuClient] 初始化成功'); + sendBCMessage('response-client-status', { + status: 'running', + auth: danmakuClient instanceof OpenLiveClient ? danmakuClient.roomAuthInfo : undefined + }); + danmakuClient.value?.on('all', (data) => { + sendBCMessage('on-danmaku', data); + }); + return true; + } else { + console.log( + '[DanmakuClient] 初始化失败, 5秒后重试: ' + result.message + ); + return false; + } + }; + while (!(await init())) { + await new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }, 5000); + }); + } + } else { + // 无锁 + console.log('[DanmakuClient] 正在等待其他页面弹幕客户端初始化...'); + status.value = 'listening'; + isOwnedDanmakuClient.value = false; + } + } + ); + } + async function initDirectClientInternal(auth?: AuthInfo) { + + } + function sendBCMessage(type: string, data?: any) { + bc?.postMessage({ + type, + data: JSON.stringify(data) + }); + } + function checkClientStatus() { + if (!existOtherClient && !isOwnedDanmakuClient.value) { + //当不存在其他客户端, 且自己不是弹幕客户端 + //则自己成为新的弹幕客户端 + if (status.value != 'initializing') { + console.log('[DanmakuClient] 其他 Client 离线, 开始初始化...'); + initClientInternal(); + } + } else { + existOtherClient = false; //假设其他客户端不存在 + sendBCMessage('check-client'); //检查其他客户端是否存在 + } + } + function dispose() { + if (bc) { + bc.close(); + bc = undefined; + } + danmakuClient.value?.Stop(); + danmakuClient.value = undefined; + } + + return { + danmakuClient, + isOwnedDanmakuClient, + status, + connected, + onEvent, + offEvent, + init: initOpenlive, + dispose, + }; +}); diff --git a/src/store/useDirectDanmakuClient.ts b/src/store/useDirectDanmakuClient.ts deleted file mode 100644 index 76380dc..0000000 --- a/src/store/useDirectDanmakuClient.ts +++ /dev/null @@ -1,234 +0,0 @@ -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 e0f25eb..256897f 100644 --- a/src/store/useWebFetcher.ts +++ b/src/store/useWebFetcher.ts @@ -17,6 +17,7 @@ import { ZstdCodec, ZstdInit } from '@oneidentity/zstd-js/wasm'; import { encode } from "@msgpack/msgpack"; import { getVersion } from '@tauri-apps/api/app'; import { onReceivedNotification } from '@/client/data/notification'; +import { useDanmakuClient } from './useDanmakuClient'; export const useWebFetcher = defineStore('WebFetcher', () => { const route = useRoute(); @@ -28,7 +29,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { const startedAt = ref(); // 本次启动时间 const signalRClient = shallowRef(); // SignalR 客户端实例 (浅响应) const signalRId = ref(); // SignalR 连接 ID - const client = shallowRef(); // 弹幕客户端实例 (浅响应) + const client = useDanmakuClient(); let timer: any; // 事件发送定时器 let disconnectedByServer = false; let isFromClient = false; // 是否由Tauri客户端启动 @@ -135,8 +136,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { if (timer) { clearInterval(timer); timer = undefined; } // 停止弹幕客户端 - client.value?.Stop(); - client.value = undefined; + client.dispose(); danmakuClientState.value = 'stopped'; danmakuServerUrl.value = undefined; @@ -169,7 +169,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => { type: 'openlive' | 'direct', directConnectInfo?: DirectClientAuthInfo ) { - if (client.value?.state === 'connected' || client.value?.state === 'connecting') { + if (client.state !== 'waiting') { console.log(prefix.value + '弹幕客户端已连接或正在连接'); return { success: true, message: '弹幕客户端已启动' }; } @@ -177,47 +177,32 @@ export const useWebFetcher = defineStore('WebFetcher', () => { console.log(prefix.value + '正在连接弹幕客户端...'); danmakuClientState.value = 'connecting'; - // 如果实例存在但已停止,先清理 - if (client.value?.state === 'disconnected') { - client.value = undefined; - } - - // 创建实例并添加事件监听 (仅在首次创建时) - if (!client.value) { - if (type === 'openlive') { - client.value = new OpenLiveClient(); - } else { - if (!directConnectInfo) { - danmakuClientState.value = 'stopped'; - console.error(prefix.value + '未提供直连弹幕客户端认证信息'); - return { success: false, message: '未提供弹幕客户端认证信息' }; - } - client.value = new DirectClient(directConnectInfo); - // 直连地址通常包含 host 和 port,可以从 directConnectInfo 获取 - //danmakuServerUrl.value = `${directConnectInfo.host}:${directConnectInfo.port}`; + if (type === 'openlive') { + await client.initOpenlive(); + } else { + if (!directConnectInfo) { + danmakuClientState.value = 'stopped'; + console.error(prefix.value + '未提供直连弹幕客户端认证信息'); + return { success: false, message: '未提供弹幕客户端认证信息' }; } - - // 监听所有事件,用于处理和转发 - client.value?.on('all', onGetDanmakus); + await client.initDirect(directConnectInfo); } + // 监听所有事件,用于处理和转发 + client?.onEvent('all', onGetDanmakus); - // 启动客户端连接 - const result = await client.value?.Start(); - - if (result?.success) { + if (client.connected) { console.log(prefix.value + '弹幕客户端连接成功, 开始监听弹幕'); danmakuClientState.value = 'connected'; // 明确设置状态 - danmakuServerUrl.value = client.value.serverUrl; // 获取服务器地址 + danmakuServerUrl.value = client.danmakuClient!.serverUrl; // 获取服务器地址 // 启动事件发送定时器 (如果之前没有启动) timer ??= setInterval(sendEvents, 1500); // 每 1.5 秒尝试发送一次事件 } else { - console.error(prefix.value + '弹幕客户端启动失败: ' + result?.message); + console.error(prefix.value + '弹幕客户端启动失败'); danmakuClientState.value = 'stopped'; danmakuServerUrl.value = undefined; - client.value = undefined; // 启动失败,清理实例,下次会重建 + client.dispose(); // 启动失败,清理实例,下次会重建 } - return result; } /** @@ -371,11 +356,18 @@ export const useWebFetcher = defineStore('WebFetcher', () => { } events.push(eventString); } - + let updateCount = 0; /** * 定期将队列中的事件发送到服务器 */ async function sendEvents() { + if (updateCount % 60 == 0) { + // 每60秒更新一次连接信息 + if (signalRClient.value) { + await sendSelfInfo(signalRClient.value); + } + } + updateCount++; // 确保 SignalR 已连接 if (!signalRClient.value || signalRClient.value.state !== signalR.HubConnectionState.Connected) { return; @@ -450,7 +442,5 @@ export const useWebFetcher = defineStore('WebFetcher', () => { // 实例 (谨慎暴露,主要用于调试或特定场景) signalRClient: computed(() => signalRClient.value), // 返回计算属性以防直接修改 - client: computed(() => client.value), - }; }); \ No newline at end of file diff --git a/src/views/OpenLiveLayout.vue b/src/views/OpenLiveLayout.vue index 21fe77b..4313146 100644 --- a/src/views/OpenLiveLayout.vue +++ b/src/views/OpenLiveLayout.vue @@ -2,7 +2,7 @@ import { isDarkMode } from '@/Utils' import { ThemeType } from '@/api/api-models' import { AuthInfo } from '@/data/DanmakuClients/OpenLiveClient' -import { useDanmakuClient } from '@/store/useDanmakuClient' +import { useDanmakuClient } from '@/store/useDanmakuClient'; import { Lottery24Filled, PeopleQueue24Filled, TabletSpeaker24Filled } from '@vicons/fluent' import { Moon, MusicalNote, Sunny } from '@vicons/ionicons5' import { useElementSize, useStorage } from '@vueuse/core' @@ -39,7 +39,7 @@ const sider = ref() const { width } = useElementSize(sider) const authInfo = ref() -const danmakuClient = await useDanmakuClient() +const danmakuClient = await useDanmakuClient().initOpenlive(); const menuOptions = [ { @@ -111,7 +111,7 @@ const danmakuClientError = ref() onMounted(async () => { authInfo.value = route.query as unknown as AuthInfo if (authInfo.value?.Code) { - danmakuClient.initClient(authInfo.value) + danmakuClient.initOpenlive(authInfo.value) } else { message.error('你不是从幻星平台访问此页面, 或未提供对应参数, 无法使用此功能') } diff --git a/src/views/TestView.vue b/src/views/TestView.vue index e5865b8..b02079c 100644 --- a/src/views/TestView.vue +++ b/src/views/TestView.vue @@ -1,12 +1,12 @@ diff --git a/src/views/manage/LiveLotteryManage.vue b/src/views/manage/LiveLotteryManage.vue index f2f4609..9513b8f 100644 --- a/src/views/manage/LiveLotteryManage.vue +++ b/src/views/manage/LiveLotteryManage.vue @@ -5,7 +5,7 @@ import { NAlert } from 'naive-ui' import OpenLottery from '../open_live/OpenLottery.vue' const accountInfo = useAccount() -const client = await useDanmakuClient().initClient() +const client = await useDanmakuClient().initOpenlive() diff --git a/src/views/manage/SongRequestManage.vue b/src/views/manage/SongRequestManage.vue index 5e9979a..c5a0f50 100644 --- a/src/views/manage/SongRequestManage.vue +++ b/src/views/manage/SongRequestManage.vue @@ -5,7 +5,7 @@ import { NAlert } from 'naive-ui' import MusicRequest from '../open_live/MusicRequest.vue' const accountInfo = useAccount() -const client = await useDanmakuClient().initClient() +const client = await useDanmakuClient().initOpenlive() diff --git a/src/views/obs/DanmujiOBS.vue b/src/views/obs/DanmujiOBS.vue index f33b4dd..3c30edc 100644 --- a/src/views/obs/DanmujiOBS.vue +++ b/src/views/obs/DanmujiOBS.vue @@ -49,7 +49,7 @@ const { customCss, isOBS = true } = defineProps<{ }>() const messageRender = ref() -const client = await useDanmakuClient().initClient() +const client = await useDanmakuClient().initOpenlive() const pronunciationConverter = new pronunciation.PronunciationConverter() const accountInfo = useAccount() const route = useRoute() diff --git a/src/views/open_live/LiveRequest.vue b/src/views/open_live/LiveRequest.vue index 38460ea..bf309e0 100644 --- a/src/views/open_live/LiveRequest.vue +++ b/src/views/open_live/LiveRequest.vue @@ -104,7 +104,7 @@ const route = useRoute() const accountInfo = useAccount() const message = useMessage() const notice = useNotification() -const client = await useDanmakuClient().initClient() +const client = await useDanmakuClient().initOpenlive() const isWarnMessageAutoClose = useStorage('SongRequest.Settings.WarnMessageAutoClose', false) const volumn = useStorage('Settings.Volumn', 0.5) diff --git a/src/views/open_live/MusicRequest.vue b/src/views/open_live/MusicRequest.vue index 51b62bc..d5c24d0 100644 --- a/src/views/open_live/MusicRequest.vue +++ b/src/views/open_live/MusicRequest.vue @@ -64,7 +64,7 @@ const settings = computed(() => { }) const cooldown = useStorage<{ [id: number]: number }>('Setting.MusicRequest.Cooldown', {}) const musicRquestStore = useMusicRequestProvider() -const client = await useDanmakuClient().initClient() +const client = await useDanmakuClient().initOpenlive() const props = defineProps<{ roomInfo?: OpenLiveInfo diff --git a/src/views/open_live/OpenLottery.vue b/src/views/open_live/OpenLottery.vue index 64f92b8..523254b 100644 --- a/src/views/open_live/OpenLottery.vue +++ b/src/views/open_live/OpenLottery.vue @@ -84,7 +84,7 @@ const route = useRoute() const message = useMessage() const accountInfo = useAccount() const notification = useNotification() -const client = await useDanmakuClient().initClient() +const client = await useDanmakuClient().initOpenlive() const originUsers = ref([]) const currentUsers = ref([]) diff --git a/src/views/open_live/OpenQueue.vue b/src/views/open_live/OpenQueue.vue index c0fc89f..ccdcdc5 100644 --- a/src/views/open_live/OpenQueue.vue +++ b/src/views/open_live/OpenQueue.vue @@ -113,7 +113,7 @@ const route = useRoute() const accountInfo = useAccount() const message = useMessage() const notice = useNotification() -const client = await useDanmakuClient().initClient() +const client = await useDanmakuClient().initOpenlive() const isWarnMessageAutoClose = useStorage('Queue.Settings.WarnMessageAutoClose', false) const isReverse = useStorage('Queue.Settings.Reverse', false) diff --git a/src/views/open_live/ReadDanmaku.vue b/src/views/open_live/ReadDanmaku.vue index 7111811..6551a12 100644 --- a/src/views/open_live/ReadDanmaku.vue +++ b/src/views/open_live/ReadDanmaku.vue @@ -73,7 +73,7 @@ type SpeechInfo = { const accountInfo = useAccount() const message = useMessage() const route = useRoute() -const client = await useDanmakuClient().initClient() +const client = await useDanmakuClient().initOpenlive() const settings = useStorage('Setting.Speech', { speechInfo: { volume: 1, diff --git a/tsconfig.json b/tsconfig.json index 2388432..5c54c6d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,7 @@ "env.d.ts", "default.d.ts", "src/data/chat/ChatClientDirectOpenLive.js", - "src/data/chat/models.js", + "src/data/chat/models.js", "src/store/useDanmakuClient.ts", ], "exclude": ["node_modules"] }