diff --git a/bun.lockb b/bun.lockb index 60ea595..0ed1661 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 8b769d3..ac9456e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "bunx --bun vite", "build": "vite build", - "lint": "vite lint" + "lint": "vite lint", + "knip": "knip" }, "dependencies": { "@antfu/ni": "^24.3.0", @@ -53,6 +54,7 @@ "mitt": "^3.0.1", "monaco-editor": "^0.52.2", "music-metadata-browser": "^2.5.11", + "nanoid": "^5.1.5", "oxlint": "^0.16.2", "peerjs": "^1.5.4", "pinia": "^3.0.1", @@ -83,6 +85,7 @@ "@types/bun": "^1.2.5", "@types/eslint": "^9.6.1", "@types/file-saver": "^2.0.7", + "@types/node": "^22.14.1", "@types/obs-studio": "^2.17.2", "@types/uuid": "^10.0.0", "@typescript-eslint/parser": "^8.27.0", @@ -93,9 +96,10 @@ "eslint": "^9.23.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-vue": "^10.0.0", + "knip": "^5.50.4", "naive-ui": "^2.41.0", "stylus": "^0.64.0", - "typescript": "^5.8.2", + "typescript": "^5.8.3", "vue-vine": "^0.3.19" } } diff --git a/src/client/ClientDanmakuItem.vue b/src/client/ClientDanmakuItem.vue new file mode 100644 index 0000000..5766abf --- /dev/null +++ b/src/client/ClientDanmakuItem.vue @@ -0,0 +1,68 @@ + + + + + + + + + + + + diff --git a/src/client/ClientDanmakuWindow.vue b/src/client/ClientDanmakuWindow.vue index 829aa81..8928517 100644 --- a/src/client/ClientDanmakuWindow.vue +++ b/src/client/ClientDanmakuWindow.vue @@ -1,349 +1,310 @@ - - - - - - + + - - - {{ 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 a22508e..852d01d 100644 --- a/src/client/ClientLayout.vue +++ b/src/client/ClientLayout.vue @@ -3,7 +3,7 @@ import { RouterLink, RouterView } from 'vue-router'; // 引入 Vue Router 组件 // 引入 Naive UI 组件 和 图标 - import { NA, NButton, NCard, NInput, NLayout, NLayoutSider, NLayoutContent, NMenu, NSpace, NSpin, NText, NTooltip } from 'naive-ui'; + import { NA, NButton, NCard, NInput, NLayout, NLayoutSider, NLayoutContent, NMenu, NSpace, NSpin, NText, NTooltip, MenuOption } from 'naive-ui'; import { CheckmarkCircle, CloseCircle, Home } from '@vicons/ionicons5'; // 引入 Tauri 插件 @@ -16,8 +16,9 @@ // 引入子组件 import WindowBar from './WindowBar.vue'; import { initAll, OnClientUnmounted } from './data/initialize'; -import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent'; -import { isTauri } from '@/data/constants'; + import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent'; + import { isTauri } from '@/data/constants'; +import { useDanmakuWindow } from './store/useDanmakuWindow'; // --- 响应式状态 --- @@ -25,6 +26,7 @@ import { isTauri } from '@/data/constants'; const webfetcher = useWebFetcher(); // 获取账户信息状态管理的实例 (如果 accountInfo 未使用,可以考虑移除) const accountInfo = useAccount(); + const danmakuWindow = useDanmakuWindow(); // 用于存储用户输入的 Token const token = ref(''); @@ -88,32 +90,35 @@ import { isTauri } from '@/data/constants'; // --- 导航菜单配置 --- // 将菜单项定义为常量,使模板更清晰 - const menuOptions = [ - { - label: () => - h(RouterLink, { to: { name: 'client-index' } }, () => '主页'), // 使用 h 函数渲染 RouterLink - key: 'go-back-home', - icon: () => h(Home) - }, - { - label: () => - h(RouterLink, { to: { name: 'client-fetcher' } }, () => 'EventFetcher'), - 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' } }, () => '设置'), - key: 'settings', - icon: () => h(Settings24Filled) - }, - ]; + const menuOptions = computed(() => { + return [ + { + label: () => + h(RouterLink, { to: { name: 'client-index' } }, () => '主页'), // 使用 h 函数渲染 RouterLink + key: 'go-back-home', + icon: () => h(Home) + }, + { + label: () => + h(RouterLink, { to: { name: 'client-fetcher' } }, () => 'EventFetcher'), + key: 'fetcher', + icon: () => h(CloudArchive24Filled) + }, + { + label: () => + h(RouterLink, { to: { name: 'client-danmaku-window-manage' } }, () => '弹幕机管理'), + key: 'danmaku-window-manage', + icon: () => h(Settings24Filled), + show: danmakuWindow.danmakuWindow != undefined + }, + { + label: () => + h(RouterLink, { to: { name: 'client-settings' } }, () => '设置'), + key: 'settings', + icon: () => h(Settings24Filled) + }, + ] as MenuOption[]; + }); onMounted(() => { window.addEventListener('beforeunload', (event) => { diff --git a/src/client/DanmakuWindowManager.vue b/src/client/DanmakuWindowManager.vue index 788f2e0..b9d9dfb 100644 --- a/src/client/DanmakuWindowManager.vue +++ b/src/client/DanmakuWindowManager.vue @@ -1,23 +1,11 @@ @@ -192,7 +207,6 @@ async function resetPosition() { @@ -256,23 +270,6 @@ async function resetPosition() { justify="space-between" align="center" > - - - - - - - - - 暗色主题 @@ -294,8 +291,25 @@ async function resetPosition() { > + + + + + + + {{ option.label }} + + + + + + + @@ -315,22 +329,22 @@ async function resetPosition() { 显示头像 显示用户名 显示粉丝牌 显示舰长图标 @@ -338,6 +352,44 @@ async function resetPosition() { + + + 纯文本风格设置 + + + + + 启用后减少边距,适合小窗口 + + + + + + + 显示【礼物】【SC】等类型标签 + + + + + + + + + + + 弹幕行为 + + @@ -374,6 +426,95 @@ async function resetPosition() { + + + + + + + + {{ option.label }} + + + + 弹幕将在 {{ danmakuWindow.danmakuWindowSetting.autoDisappearTime }} 秒后自动消失 + + + 弹幕不会自动消失 + + + + + + + + + + + + 调试选项 + + + 发送测试弹幕 + + + 清空弹幕 + + + + 当前表情数据: + Inline: {{ Object.keys(danmakuWindow.emojiData.data.inline).length }} 个 + + Plain: {{ Object.keys(danmakuWindow.emojiData.data.plain).length }} 个 + + { + await danmakuWindow.getEmojiData() + message.success('表情数据已重新加载') + }" + > + + + + 重新加载表情数据 + + + + @@ -393,4 +534,22 @@ async function resetPosition() { z-index: 9999; background-color: rgba(245, 108, 108, 0.1); } + +/* 添加一些美化样式 */ +:deep(.n-tabs-tab) { + padding: 12px 20px; +} + +:deep(.n-divider) { + margin: 12px 0; +} + +.n-alert { + margin-bottom: 16px; +} + +:deep(.n-slider) { + width: 100%; + max-width: 500px; +} diff --git a/src/client/components/danmaku/BaseDanmakuItem.vue b/src/client/components/danmaku/BaseDanmakuItem.vue new file mode 100644 index 0000000..657badf --- /dev/null +++ b/src/client/components/danmaku/BaseDanmakuItem.vue @@ -0,0 +1,208 @@ + + + + + + + diff --git a/src/client/components/danmaku/CardStyleDanmakuItem.vue b/src/client/components/danmaku/CardStyleDanmakuItem.vue new file mode 100644 index 0000000..2ca0030 --- /dev/null +++ b/src/client/components/danmaku/CardStyleDanmakuItem.vue @@ -0,0 +1,407 @@ + + + + + + + + + + {{ item?.name || '匿名用户' }} + + + + + ¥{{ item?.price || 0 }} + + + + + {{ item?.num || 1 }} × {{ item?.msg }} + ¥{{ (item.price || 0).toFixed(2) }} + + + + + {{ item?.guard_level === 1 ? '总督' : item?.guard_level === 2 ? '提督' : '舰长' }} + x{{ item?.num }} + + + + + {{ item?.msg }} + + + + + + + + + + + {{ item?.name || '匿名用户' }} + + + + + + 进入了直播间 + + + + + + {{ segment.content }} + + + + + + + {{ item?.msg }} + + + + + + diff --git a/src/client/components/danmaku/TextStyleDanmakuItem.vue b/src/client/components/danmaku/TextStyleDanmakuItem.vue new file mode 100644 index 0000000..03595e4 --- /dev/null +++ b/src/client/components/danmaku/TextStyleDanmakuItem.vue @@ -0,0 +1,202 @@ + + + + + + + + + {{ typeLabel }} + + + + + + {{ displayName }} + + + {{ setting.textStyleNameSeparator }} + + + {{ priceText }} + + + + + + {{ segment.content }} + + + + + + + {{ displayContent }} + + {{ displayContent }} + + + + diff --git a/src/client/components/danmaku/danmakuUtils.ts b/src/client/components/danmaku/danmakuUtils.ts new file mode 100644 index 0000000..97a02da --- /dev/null +++ b/src/client/components/danmaku/danmakuUtils.ts @@ -0,0 +1,188 @@ +import { EventDataTypes, EventModel } from '@/api/api-models'; +import { DanmakuWindowSettings } from '../../store/useDanmakuWindow'; +import { computed, ComputedRef } from 'vue'; +import { GetGuardColor } from '@/Utils'; + +export interface BaseDanmakuItemProps { + item: EventModel & { randomId: string; isNew?: boolean; disappearAt?: number; }; + setting: DanmakuWindowSettings; +} + +export function useDanmakuUtils( + props: BaseDanmakuItemProps, + emojiData: { data: { inline: { [key: string]: string }; plain: { [key: string]: string } } } +) { + // 计算SC弹幕的颜色类 + const scColorClass = computed(() => { + if (props.item.type === EventDataTypes.SC) { + const price = props.item?.price || 0; + if (price === 0) return 'sc-0'; + if (price > 0 && price < 50) return 'sc-50'; + if (price >= 50 && price < 100) return 'sc-100'; + if (price >= 100 && price < 500) return 'sc-500'; + if (price >= 500 && price < 1000) return 'sc-1000'; + if (price >= 1000 && price < 2000) return 'sc-2000'; + if (price >= 2000) return 'sc-max'; + } + return ''; + }); + + // 根据类型计算样式 + const typeClass = computed(() => { + switch (props.item.type) { + case EventDataTypes.Message: return 'message-item'; + case EventDataTypes.Gift: return 'gift-item'; + case EventDataTypes.SC: return `sc-item ${scColorClass.value}`; + case EventDataTypes.Guard: return 'guard-item'; + case EventDataTypes.Enter: return 'enter-item'; + default: return ''; + } + }); + + // 获取舰长颜色 + const guardColor = computed(() => GetGuardColor(props.item.guard_level)); + + // 舰长样式类 + const guardLevelClass = computed(() => { + if (props.item.type === EventDataTypes.Guard) { + return `guard-level-${props.item.guard_level || 0}`; + } + return ''; + }); + + // 检查是否需要显示头像 + const showAvatar = computed(() => props.setting.showAvatar); + + // 解析包含内联表情的消息 + const parsedMessage = computed<{ type: 'text' | 'emoji'; content?: string; url?: string; name?: string; }[]>(() => { + // 仅处理非纯表情的普通消息 + if (props.item.type !== EventDataTypes.Message || props.item.emoji || !props.item.msg) { + return []; + } + + const segments: { type: 'text' | 'emoji'; content?: string; url?: string; name?: string; }[] = []; + let lastIndex = 0; + const regex = /\[([^\]]+)\]/g; // 匹配 [表情名] + let match; + + try { + const availableEmojis = emojiData.data || {}; // 确保 emojiData 已加载 + + while ((match = regex.exec(props.item.msg)) !== null) { + // 添加表情前的文本部分 + if (match.index > lastIndex) { + segments.push({ type: 'text', content: props.item.msg.substring(lastIndex, match.index) }); + } + + const emojiFullName = match[0]; // 完整匹配,例如 "[哈哈]" + const emojiInfo = availableEmojis.inline[emojiFullName] || availableEmojis.plain[emojiFullName]; + + if (emojiInfo) { + // 找到了表情 + segments.push({ type: 'emoji', url: emojiInfo, name: emojiFullName }); + } else { + // 未找到表情,当作普通文本处理 + segments.push({ type: 'text', content: emojiFullName }); + } + + lastIndex = regex.lastIndex; + } + + // 添加最后一个表情后的文本部分 + if (lastIndex < props.item.msg.length) { + segments.push({ type: 'text', content: props.item.msg.substring(lastIndex) }); + } + } catch (error) { + console.error("Error parsing message for emojis:", error); + // 解析出错时,返回原始文本 + return [{ type: 'text', content: props.item.msg }]; + } + + // 如果解析后为空(例如,消息只包含无法识别的[]),则返回原始文本 + if (segments.length === 0 && props.item.msg) { + return [{ type: 'text', content: props.item.msg }]; + } + + return segments; + }); + + // 获取不同类型消息的显示标签 + const typeLabel = computed(() => { + switch (props.item.type) { + case EventDataTypes.Message: return ''; // 普通消息不需要标签 + case EventDataTypes.Gift: return '【礼物】'; + case EventDataTypes.SC: return '【SC】'; + case EventDataTypes.Guard: return '【舰长】'; + case EventDataTypes.Enter: return '【进场】'; + default: return ''; + } + }); + + // 获取礼物或SC的价格文本 + const priceText = computed(() => { + if (props.item.type === EventDataTypes.SC || + (props.item.type === EventDataTypes.Gift && props.item.price > 0)) { + return `¥${props.item.price || 0}`; + } + return ''; + }); + + // 获取用户名显示 + const displayName = computed(() => { + return props.item.name || '匿名用户'; + }); + + // 获取消息显示内容 + const displayContent = computed(() => { + switch (props.item.type) { + case EventDataTypes.Message: + return props.item.msg || ''; + case EventDataTypes.Gift: + return `${props.item.num || 1} × ${props.item.msg}`; + case EventDataTypes.SC: + return props.item.msg || ''; + case EventDataTypes.Guard: + return props.item.msg || '开通了舰长'; + case EventDataTypes.Enter: + return '进入了直播间'; + default: + return ''; + } + }); + + // 根据风格及类型获取文本颜色 + const textModeColor = computed(() => { + if (props.item.type === EventDataTypes.SC) { + return '#FFD700'; // SC消息金色 + } else if (props.item.type === EventDataTypes.Gift) { + return '#FF69B4'; // 礼物消息粉色 + } else if (props.item.type === EventDataTypes.Guard) { + return guardColor.value; // 舰长消息使用舰长颜色 + } else if (props.item.type === EventDataTypes.Enter) { + return '#67C23A'; // 入场消息绿色 + } + return undefined; // 普通消息使用默认颜色 + }); + + return { + scColorClass, + typeClass, + guardColor, + guardLevelClass, + showAvatar, + parsedMessage, + typeLabel, + priceText, + displayName, + displayContent, + textModeColor + }; +} + +// 返回类型定义,便于TypeScript类型推断 +export type DanmakuUtils = ReturnType; + +// 类型别名,用于清晰表达每个计算属性的类型 +export type ComputedDanmakuUtils = { + [K in keyof DanmakuUtils]: ComputedRef +}; diff --git a/src/client/data/initialize.ts b/src/client/data/initialize.ts index 1a0d313..ef05274 100644 --- a/src/client/data/initialize.ts +++ b/src/client/data/initialize.ts @@ -20,6 +20,7 @@ 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"; +import { getAllWebviewWindows } from "@tauri-apps/api/webviewWindow"; const accountInfo = useAccount(); @@ -117,6 +118,23 @@ export async function initAll(isOnBoot: boolean) { appWindow.setMinSize(new PhysicalSize(720, 480)); + getAllWebviewWindows().then(async (windows) => { + const w = windows.find((win) => win.label === 'danmaku-window') + if (w) { + const useWindow = useDanmakuWindow(); + useWindow.init(); + + if ((useWindow.emojiData?.updateAt ?? 0) < Date.now() - 1000 * 60 * 60 * 24) { + await useWindow.getEmojiData(); + } + if (await w.isVisible()) { + //useWindow.isDanmakuWindowOpen = true; + + console.log('弹幕窗口已打开'); + } + } + }); + // 监听f12事件 if (!isDev) { window.addEventListener('keydown', (event) => { @@ -135,7 +153,7 @@ export function OnClientUnmounted() { } tray.close(); - useDanmakuWindow().closeWindow() + //useDanmakuWindow().closeWindow(); } async function checkUpdate() { diff --git a/src/client/store/useDanmakuWindow.ts b/src/client/store/useDanmakuWindow.ts index bf62b1c..3ac73da 100644 --- a/src/client/store/useDanmakuWindow.ts +++ b/src/client/store/useDanmakuWindow.ts @@ -1,4 +1,6 @@ -import { EventDataTypes, EventModel } from "@/api/api-models"; +import { EventDataTypes, EventModel, GuardLevel } from "@/api/api-models"; +import { QueryGetAPI } from "@/api/query"; +import { VTSURU_API_URL } from "@/data/constants"; import { useDanmakuClient } from "@/store/useDanmakuClient"; import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi"; import { getAllWebviewWindows, WebviewWindow } from "@tauri-apps/api/webviewWindow"; @@ -26,6 +28,11 @@ export type DanmakuWindowSettings = { itemSpacing: number; // 项目间距 enableShadow: boolean; // 是否启用阴影 shadowColor: string; // 阴影颜色 + autoDisappearTime: number; // 单位:秒,0表示不自动消失 + displayStyle: string; // 新增:显示风格,可选值:'card'(卡片风格), 'text'(纯文本风格) + textStyleCompact: boolean; // 新增:纯文本模式下是否使用紧凑布局 + textStyleShowType: boolean; // 新增:纯文本模式下是否显示消息类型标签 + textStyleNameSeparator: string; // 新增:纯文本模式下用户名和消息之间的分隔符 }; export const DANMAKU_WINDOW_BROADCAST_CHANNEL = 'channel.danmaku.window'; @@ -37,8 +44,107 @@ export type DanmakuWindowBCData = { data: DanmakuWindowSettings; } | { type: 'window-ready'; +} | { + type: 'clear-danmaku'; // 新增:清空弹幕消息 +} | { + type: 'test-danmaku', // 新增:测试弹幕消息 + data: EventModel; }; +// Helper function to generate random test data +function generateTestDanmaku(): EventModel { + const types = [ + EventDataTypes.Message, + EventDataTypes.Gift, + EventDataTypes.SC, + EventDataTypes.Guard, + EventDataTypes.Enter, + ]; + const randomType = types[Math.floor(Math.random() * types.length)]; + const randomUid = Math.floor(Math.random() * 1000000); + const randomName = `测试用户${randomUid % 100}`; + const randomTime = Date.now(); + const randomOuid = `oid_${randomUid}`; + + const baseEvent: Partial = { + name: randomName, + uface: `https://i0.hdslb.com/bfs/face/member/noface.jpg`, // Placeholder for user avatar + uid: randomUid, + open_id: randomOuid, // Assuming open_id is same as ouid for test + time: randomTime, + guard_level: Math.floor(Math.random() * 4) as GuardLevel, + fans_medal_level: Math.floor(Math.random() * 41), + fans_medal_name: '测试牌', + fans_medal_wearing_status: Math.random() > 0.5, + ouid: randomOuid, + }; + + switch (randomType) { + case EventDataTypes.Message: + return { + ...baseEvent, + type: EventDataTypes.Message, + msg: `这是一条测试弹幕消息 ${Math.random().toString(36).substring(7)}`, + num: 0, // Not applicable + price: 0, // Not applicable + emoji: Math.random() > 0.8 ? '😀' : undefined, // Randomly add emoji + } as EventModel; + case EventDataTypes.Gift: + const giftNames = ['小花花', '辣条', '能量饮料', '小星星']; + const giftNums = [1, 5, 10]; + const giftPrices = [100, 1000, 5000]; // Price in copper coins (100 = 0.1 yuan) + return { + ...baseEvent, + type: EventDataTypes.Gift, + msg: giftNames[Math.floor(Math.random() * giftNames.length)], + num: giftNums[Math.floor(Math.random() * giftNums.length)], + price: giftPrices[Math.floor(Math.random() * giftPrices.length)], + } as EventModel; + case EventDataTypes.SC: + const scPrices = [30, 50, 100, 500, 1000, 2000]; // Price in yuan + return { + ...baseEvent, + type: EventDataTypes.SC, + msg: `这是一条测试SC消息!感谢老板!`, + num: 1, // Not applicable + price: scPrices[Math.floor(Math.random() * scPrices.length)], + } as EventModel; + case EventDataTypes.Guard: + const guardLevels = [GuardLevel.Jianzhang, GuardLevel.Tidu, GuardLevel.Zongdu]; + const guardPrices = { + [GuardLevel.Jianzhang]: 198, + [GuardLevel.Tidu]: 1998, + [GuardLevel.Zongdu]: 19998, + [GuardLevel.None]: 0, // Add missing GuardLevel.None case + }; + const selectedGuardLevel = guardLevels[Math.floor(Math.random() * guardLevels.length)]; + return { + ...baseEvent, + type: EventDataTypes.Guard, + msg: `开通了${selectedGuardLevel === GuardLevel.Jianzhang ? '舰长' : selectedGuardLevel === GuardLevel.Tidu ? '提督' : '总督'}`, + num: 1, // Represents 1 month usually + price: guardPrices[selectedGuardLevel], + guard_level: selectedGuardLevel, // Ensure guard level matches + } as EventModel; + case EventDataTypes.Enter: + return { + ...baseEvent, + type: EventDataTypes.Enter, + msg: '进入了直播间', + num: 0, // Not applicable + price: 0, // Not applicable + } as EventModel; + default: // Fallback to Message + return { + ...baseEvent, + type: EventDataTypes.Message, + msg: `默认测试弹幕`, + num: 0, + price: 0, + } as EventModel; + } +} + export const useDanmakuWindow = defineStore('danmakuWindow', () => { const danmakuWindow = ref(); const danmakuWindowSetting = useStorage('Setting.DanmakuWindow', { @@ -52,7 +158,7 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => { showFansMedal: true, showGuardIcon: true, fontSize: 14, - maxDanmakuCount: 50, + maxDanmakuCount: 30, reverseOrder: false, filterTypes: ["Message", "Gift", "SC", "Guard"], animationDuration: 300, @@ -63,7 +169,25 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => { borderRadius: 8, itemSpacing: 5, enableShadow: true, - shadowColor: 'rgba(0,0,0,0.5)' + shadowColor: 'rgba(0,0,0,0.5)', + autoDisappearTime: 0, // 默认不自动消失 + displayStyle: 'card', // 新增:默认使用卡片风格 + textStyleCompact: false, // 新增:默认不使用紧凑布局 + textStyleShowType: true, // 新增:默认显示消息类型标签 + textStyleNameSeparator: ': ', // 新增:默认用户名和消息之间的分隔符为冒号+空格 + }); + const emojiData = useStorage<{ + updateAt: number, + data: { + inline: { [key: string]: string; }, + plain: { [key: string]: string; }, + }; + }>('Data.Emoji', { + updateAt: 0, + data: { + inline: {}, + plain: {}, + } }); const danmakuClient = useDanmakuClient(); const isWindowOpened = ref(false); @@ -77,6 +201,7 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => { if (!isInited) { init(); } + checkAndUseSetting(danmakuWindowSetting.value); danmakuWindow.value?.show(); isWindowOpened.value = true; } @@ -105,15 +230,9 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => { return; } console.log('打开弹幕窗口', danmakuWindow.value.label, danmakuWindowSetting.value); - danmakuWindow.value.onCloseRequested(() => { - danmakuWindow.value = undefined; - bc?.close(); - bc = undefined; - }); - await danmakuWindow.value.setIgnoreCursorEvents(false); - await danmakuWindow.value.show(); - danmakuWindow.value.onCloseRequested(() => { + danmakuWindow.value.onCloseRequested((event) => { + event.preventDefault(); // 阻止默认关闭行为 closeWindow(); console.log('弹幕窗口关闭'); }); @@ -165,41 +284,97 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => { type: 'update-setting', data: toRaw(newValue.value), }); - if (newValue.value.alwaysOnTop) { - await danmakuWindow.value.setAlwaysOnTop(true); - } - else { - await danmakuWindow.value.setAlwaysOnTop(false); - } - if (newValue.value.interactive) { - await danmakuWindow.value.setIgnoreCursorEvents(true); - } else { - await danmakuWindow.value.setIgnoreCursorEvents(false); - } + await checkAndUseSetting(newValue.value); } }, { deep: true }); + console.log('[danmaku-window] 初始化完成'); + isInited = true; } + async function checkAndUseSetting(setting: DanmakuWindowSettings) { + if (setting.alwaysOnTop) { + await danmakuWindow.value?.setAlwaysOnTop(true); + } + else { + await danmakuWindow.value?.setAlwaysOnTop(false); + } + if (setting.interactive) { + await danmakuWindow.value?.setIgnoreCursorEvents(true); + } else { + await danmakuWindow.value?.setIgnoreCursorEvents(false); + } + } + + async function getEmojiData() { + try { + const resp = await QueryGetAPI<{ + inline: { [key: string]: string; }, + plain: { [key: string]: string; }, + }>(VTSURU_API_URL + 'client/live-emoji'); + if (resp.code == 200) { + emojiData.value = { + updateAt: Date.now(), + data: resp.data, + }; + console.log(`已获取表情数据, 共 ${Object.keys(resp.data.inline).length + Object.keys(resp.data.plain).length} 条`, resp.data); + } + else { + console.error('获取表情数据失败:', resp.message); + } + } catch (error) { + console.error('无法获取表情数据:', error); + } + } function onGetDanmakus(data: EventModel) { - bc?.postMessage({ + if (!isWindowOpened.value || !bc) return; + bc.postMessage({ type: 'danmaku', data, }); } + // 新增:清空弹幕函数 + function clearAllDanmaku() { + if (!isWindowOpened.value || !bc) { + console.warn('[danmaku-window] 窗口未打开或 BC 未初始化,无法清空弹幕'); + return; + } + bc.postMessage({ + type: 'clear-danmaku', + }); + console.log('[danmaku-window] 发送清空弹幕指令'); + } + // 新增:发送测试弹幕函数 + function sendTestDanmaku() { + if (!isWindowOpened.value || !bc) { + console.warn('[danmaku-window] 窗口未打开或 BC 未初始化,无法发送测试弹幕'); + return; + } + const testData = generateTestDanmaku(); + bc.postMessage({ + type: 'test-danmaku', + data: testData, + }); + console.log('[danmaku-window] 发送测试弹幕指令:', testData); + } return { danmakuWindow, danmakuWindowSetting, + emojiData, setDanmakuWindowSize, setDanmakuWindowPosition, updateWindowPosition, + getEmojiData, isDanmakuWindowOpen: isWindowOpened, openWindow, closeWindow, + init, + clearAllDanmaku, // 导出新函数 + sendTestDanmaku, // 导出新函数 }; }); diff --git a/src/data/DanmakuClients/BaseDanmakuClient.ts b/src/data/DanmakuClients/BaseDanmakuClient.ts index 0c68927..3a04746 100644 --- a/src/data/DanmakuClients/BaseDanmakuClient.ts +++ b/src/data/DanmakuClients/BaseDanmakuClient.ts @@ -152,8 +152,8 @@ export default abstract class BaseDanmakuClient { console.warn(`[${this.type}] 弹幕客户端未被启动, 忽略停止操作`); } // 注意: 清空所有事件监听器 - this.eventsAsModel = this.createEmptyEventModelListeners(); - this.eventsRaw = this.createEmptyRawEventlisteners(); + //this.eventsAsModel = this.createEmptyEventModelListeners(); + //this.eventsRaw = this.createEmptyRawEventlisteners(); } /** diff --git a/src/data/DanmakuClients/DirectClient.ts b/src/data/DanmakuClients/DirectClient.ts index 5b2c72e..3ba6ef4 100644 --- a/src/data/DanmakuClients/DirectClient.ts +++ b/src/data/DanmakuClients/DirectClient.ts @@ -53,9 +53,8 @@ export default class DirectClient extends BaseDanmakuClient { } } public onDanmaku(command: any): void { - const data = command.data; - const info = data.info; - this.eventsRaw?.danmaku?.forEach((d) => { d(data, command); }); + const info = command.info; + this.eventsRaw?.danmaku?.forEach((d) => { d(info, command); }); this.eventsAsModel.danmaku?.forEach((d) => { d( { @@ -89,7 +88,7 @@ export default class DirectClient extends BaseDanmakuClient { name: data.uname, uid: data.uid, msg: data.giftName, - price: data.giftId, + price: data.price / 1000, num: data.num, time: Date.now(), guard_level: data.guard_level, @@ -171,7 +170,7 @@ export default class DirectClient extends BaseDanmakuClient { 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), + uface: AVATAR_URL + data.uid, open_id: '', ouid: GuidUtils.numToGuid(data.uid) }, diff --git a/src/data/UpdateNote.ts b/src/data/UpdateNote.ts index 8414d5f..dae780f 100644 --- a/src/data/UpdateNote.ts +++ b/src/data/UpdateNote.ts @@ -4,6 +4,23 @@ import { VNode } from "vue"; import { FETCH_API } from "./constants"; export const updateNotes: updateNoteType[] = [ + { + ver: 3, + date: '2025.4.15', + items: [ + { + type: 'new', + title: 'Tauri 客户端新增弹幕机功能', + content: [ + [ + 'Tauri 客户端新增弹幕机功能, 可以在自己电脑上显示弹幕礼物等. ', + '客户端需更新至0.1.2版本, 重启客户端后会自动更新', + () => h(NImage, { src: 'https://pan.suki.club/d/vtsuru/imgur/81d76a89-96b8-44e9-be79-6caaa5741f64.png', width: 200 }), + ] + ], + }, + ] + }, { ver: 2, date: '2025.4.8', diff --git a/src/store/useDanmakuClient.ts b/src/store/useDanmakuClient.ts index 8084fc6..99fe234 100644 --- a/src/store/useDanmakuClient.ts +++ b/src/store/useDanmakuClient.ts @@ -173,8 +173,10 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => { */ async function initClient(client: BaseDanmakuClient) { // 返回 Promise 表示最终是否成功 // 防止重复初始化或在非等待状态下初始化 - if (isInitializing || state.value !== 'waiting') { - console.warn(`[DanmakuClient] 初始化尝试被阻止。 isInitializing: ${isInitializing}, state: ${state.value}`); + if (isInitializing) { + while (isInitializing) { + await new Promise((resolve) => setTimeout(resolve, 100)); // 等待初始化完成 + } return useDanmakuClient(); // 如果已连接,则视为“成功” } @@ -217,7 +219,7 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => { authInfo.value = danmakuClient.value instanceof OpenLiveClient ? danmakuClient.value.roomAuthInfo : undefined; state.value = 'connected'; // 将 Store 中存储的监听器 (来自 onEvent) 附加到新连接的客户端的 eventsAsModel - console.log('[DanmakuClient] 初始化成功。'); + console.log('[DanmakuClient] 初始化成功'); connectSuccess = true; return true; // 连接成功, 退出重试循环 } else { @@ -293,7 +295,7 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => { if (danmakuClient.value) { await disposeClientInstance(danmakuClient.value); - danmakuClient.value = undefined; // 解除对旧客户端实例的引用 + //danmakuClient.value = undefined; // 保留, 用户再次获取event } state.value = 'waiting'; // 重置状态为等待 authInfo.value = undefined; // 清理认证信息 diff --git a/src/store/useWebFetcher.ts b/src/store/useWebFetcher.ts index bf8ad98..9fab0ad 100644 --- a/src/store/useWebFetcher.ts +++ b/src/store/useWebFetcher.ts @@ -186,11 +186,10 @@ export const useWebFetcher = defineStore('WebFetcher', () => { return { success: false, message: '未提供弹幕客户端认证信息' }; } await client.initDirect(directConnectInfo); - return { success: true, message: '弹幕客户端已启动' }; } // 监听所有事件,用于处理和转发 - client?.onEvent('all', onGetDanmakus); + client?.on('all', onGetDanmakus); if (client.connected) { console.log(prefix.value + '弹幕客户端连接成功, 开始监听弹幕'); @@ -262,7 +261,12 @@ export const useWebFetcher = defineStore('WebFetcher', () => { connection.on('Disconnect', (reason: unknown) => { console.log(prefix.value + '被服务器断开连接: ' + reason); disconnectedByServer = true; // 标记是服务器主动断开 - Stop(); // 服务器要求断开,调用 Stop 清理所有资源 + window.$message.error(`被服务器要求断开连接: ${reason}, 为保证可用性, 30秒后将自动重启`); + //Stop(); // 服务器要求断开,调用 Stop 清理所有资源 + setTimeout(() => { + console.log(prefix.value + '尝试重启...'); + connectSignalR(); // 30秒后尝试重启 + }, 30 * 1000); // 30秒后自动重启 }); connection.on('Request', async (url: string, method: string, body: string, useCookie: boolean) => onRequest(url, method, body, useCookie)); connection.on('Notification', (type: string, data: any) => { onReceivedNotification(type, data); }); diff --git a/src/views/obs/blivechat/MessageRender.vue b/src/views/obs/blivechat/MessageRender.vue index 2ddaec6..d3bf0d2 100644 --- a/src/views/obs/blivechat/MessageRender.vue +++ b/src/views/obs/blivechat/MessageRender.vue @@ -1,32 +1,80 @@ - - - - - - - - - - - - + + + + + + + + + + + @@ -143,7 +191,7 @@ export default defineComponent({ mounted() { this.scrollToBottom() }, - beforeDestroy() { + beforeUnmount() { if (this.emitSmoothedMessageTimerId) { window.clearTimeout(this.emitSmoothedMessageTimerId) this.emitSmoothedMessageTimerId = null