feat: 修复图片url, 开始弹幕机编写

This commit is contained in:
2025-04-13 21:59:47 +08:00
parent 2a022e0448
commit c13fcb90c8
27 changed files with 2331 additions and 915 deletions

View File

@@ -0,0 +1,289 @@
<script setup lang="ts">
import { useDanmakuClient } from '@/store/useDanmakuClient';
import { DANMAKU_WINDOW_BROADCAST_CHANNEL, DanmakuWindowBCData, DanmakuWindowSettings } from './store/useDanmakuWindow';
import { NSpin, NEmpty, NIcon } from 'naive-ui';
import { EventDataTypes, EventModel } from '@/api/api-models';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { TransitionGroup } from 'vue';
import { Money24Regular, VehicleShip24Filled } from '@vicons/fluent';
let bc: BroadcastChannel | undefined = undefined;
const setting = ref<DanmakuWindowSettings>();
const danmakuList = ref<EventModel[]>([]);
const maxItems = computed(() => setting.value?.maxDanmakuCount || 50);
const isConnected = computed(() => {
return setting.value !== undefined;
});
function GetSCColor(price: number): string {
if (price === 0) return `#2a60b2`;
if (price > 0 && price < 50) return `#2a60b2`;
if (price >= 50 && price < 100) return `#427d9e`;
if (price >= 100 && price < 500) return `#c99801`;
if (price >= 500 && price < 1000) return `#e09443`;
if (price >= 1000 && price < 2000) return `#e54d4d`;
if (price >= 2000) return `#ab1a32`;
return '';
}
function GetGuardColor(level: number | null | undefined): string {
if (level) {
switch (level) {
case 1: return 'rgb(122, 4, 35)';
case 2: return 'rgb(157, 155, 255)';
case 3: return 'rgb(104, 136, 241)';
}
}
return '';
}
function formatUsername(item: EventModel): string {
let result = item.name;
if (setting.value?.showFansMedal && item.fans_medal_wearing_status) {
result = `[${item.fans_medal_name} ${item.fans_medal_level}] ${result}`;
}
return result;
}
function addDanmaku(data: EventModel) {
if (!setting.value) return;
// 检查是否是需要过滤的消息类型
const typeMap: Record<number, string> = {
[EventDataTypes.Message]: "Message",
[EventDataTypes.Gift]: "Gift",
[EventDataTypes.SC]: "SC",
[EventDataTypes.Guard]: "Guard",
[EventDataTypes.Enter]: "Enter"
};
const typeStr = typeMap[data.type];
if (!typeStr || !setting.value.filterTypes.includes(typeStr)) {
return;
}
// 维护最大消息数量
if (setting.value.reverseOrder) {
danmakuList.value.unshift(data);
if (danmakuList.value.length > maxItems.value) {
danmakuList.value.pop();
}
} else {
danmakuList.value.push(data);
if (danmakuList.value.length > maxItems.value) {
danmakuList.value.shift();
}
}
}
onMounted(() => {
bc = new BroadcastChannel(DANMAKU_WINDOW_BROADCAST_CHANNEL);
bc.onmessage = (event) => {
const data = event.data as DanmakuWindowBCData;
switch (data.type) {
case 'danmaku':
addDanmaku(data.data);
break;
case 'update-setting':
setting.value = data.data;
break;
}
};
// Dispatch a request for settings
bc.postMessage({ type: 'request-settings' });
});
onUnmounted(() => {
if (bc) {
bc.close();
bc = undefined;
}
});
// 格式化弹幕消息
function formatMessage(item: EventModel): string {
switch(item.type) {
case EventDataTypes.Message:
return item.msg;
case EventDataTypes.Gift:
return `${item.msg} ${item.num > 1 ? 'x'+item.num : ''}`;
case EventDataTypes.SC:
return item.msg;
case EventDataTypes.Guard:
return `开通了${item.guard_level === 1 ? '总督' : item.guard_level === 2 ? '提督' : '舰长'}`;
case EventDataTypes.Enter:
return '进入直播间';
default:
return item.msg;
}
}
</script>
<template>
<NSpin
v-if="!isConnected"
show
/>
<div
v-else
class="danmaku-window"
:style="{
backgroundColor: setting?.backgroundColor || 'rgba(0,0,0,0.6)',
width: '100%',
height: '100%',
borderRadius: `${setting?.borderRadius || 0}px`,
opacity: setting?.opacity || 1,
color: setting?.textColor || '#ffffff',
fontSize: `${setting?.fontSize || 14}px`,
overflow: 'hidden',
boxShadow: setting?.enableShadow ? `0 0 10px ${setting?.shadowColor}` : 'none'
}"
>
<TransitionGroup
:class="['danmaku-list', {'reverse': setting?.reverseOrder}]"
name="danmaku-list"
tag="div"
:style="{
padding: '8px',
height: '100%',
overflowY: 'auto',
boxSizing: 'border-box',
display: 'flex',
flexDirection: setting?.reverseOrder ? 'column-reverse' : 'column'
}"
>
<div
v-for="(item, index) in danmakuList"
:key="`${item.time}-${index}`"
class="danmaku-item"
:style="{
marginBottom: `${setting?.itemSpacing || 5}px`,
padding: '6px',
borderRadius: '4px',
backgroundColor: item.type === EventDataTypes.SC ? GetSCColor(item.price) : 'transparent',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
color: item.type === EventDataTypes.SC ? '#ffffff' : undefined,
transition: `all ${setting?.animationDuration || 300}ms ease`
}"
>
<!-- 头像 -->
<img
v-if="setting?.showAvatar && item.uface"
:src="item.uface"
class="avatar"
:style="{
width: `${setting?.fontSize + 6 || 20}px`,
height: `${setting?.fontSize + 6 || 20}px`,
borderRadius: '50%',
marginRight: '6px'
}"
referrerpolicy="no-referrer"
>
<!-- 用户名 -->
<div
v-if="setting?.showUsername"
class="username"
:style="{
fontWeight: 'bold',
marginRight: '6px',
}"
>
<!-- 舰长图标 -->
<NIcon
v-if="setting?.showGuardIcon && item.guard_level > 0"
:component="VehicleShip24Filled"
:color="GetGuardColor(item.guard_level)"
:size="setting?.fontSize"
style="margin-right: 2px; vertical-align: middle;"
/>
{{ formatUsername(item) }}:
</div>
<!-- 消息内容 -->
<div class="message">
<!-- SC/礼物金额 -->
<NIcon
v-if="(item.type === EventDataTypes.Gift || item.type === EventDataTypes.SC) && item.price > 0"
:component="Money24Regular"
:color="item.type === EventDataTypes.SC ? '#ffffff' : '#dd2f2f'"
:size="setting?.fontSize"
style="margin-right: 4px; vertical-align: middle;"
/>
<span v-if="(item.type === EventDataTypes.Gift || item.type === EventDataTypes.SC) && item.price > 0">
{{ item.price }}
</span>
<!-- 消息内容 -->
{{ formatMessage(item) }}
</div>
</div>
<div
v-if="danmakuList.length === 0"
key="empty"
style="display: flex; align-items: center; justify-content: center; height: 100%;"
>
<NEmpty description="暂无弹幕" />
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.danmaku-list-enter-active,
.danmaku-list-leave-active {
transition: all 0.3s ease;
}
.danmaku-list-enter-from {
opacity: 0;
transform: translateY(30px);
}
.danmaku-list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
/* 滚动条样式 */
.danmaku-list::-webkit-scrollbar {
width: 4px;
}
.danmaku-list::-webkit-scrollbar-track {
background: transparent;
}
.danmaku-list::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
/* 拖动窗口时用于指示 */
.danmaku-window {
-webkit-app-region: drag;
}
.danmaku-item {
-webkit-app-region: no-drag;
}
/* 根据消息类型添加特殊样式 */
.danmaku-item[data-type="2"] { /* Gift */
color: #dd2f2f;
}
.danmaku-item[data-type="0"] { /* Guard */
color: #9d78c1;
}
.danmaku-item[data-type="4"] { /* Enter */
color: #4caf50;
}
</style>

View File

@@ -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' } }, () => '设置'),

View File

@@ -0,0 +1,438 @@
<script setup lang="ts">
import { useDanmakuWindow } from './store/useDanmakuWindow';
import { useRoute } from 'vue-router';
import { EventDataTypes } from '@/api/api-models';
import { useMessage } from 'naive-ui';
import { ref, computed } from 'vue';
import {
NButton, NCard, NSpace, NSlider, NSwitch, NSelect, NInputNumber,
NColorPicker, NDivider, NGrid, NGi, NFlex, NCheckbox, NCheckboxGroup,
NIcon, NTooltip, NSpin, NText, NAlert, NTabs, NTabPane, NForm, NFormItem
} from 'naive-ui';
import {
DesktopMac24Regular,
TextFont24Regular,
ColorFill24Regular,
AppsList24Regular,
DesignIdeas24Regular,
CheckmarkCircle24Regular,
ResizeTable24Filled,
} from '@vicons/fluent';
const danmakuWindow = useDanmakuWindow();
const message = useMessage();
const route = useRoute();
const isSettingPositionMode = ref(false);
const isLoading = ref(false);
// 计算属性,类型映射
const filterTypeOptions = [
{ label: '弹幕消息', value: 'Message' },
{ label: '礼物', value: 'Gift' },
{ label: 'SC', value: 'SC' },
{ label: '舰长', value: 'Guard' },
{ label: '进场', value: 'Enter' }
];
// 分组预设
const presets = {
dark: {
backgroundColor: 'rgba(0,0,0,0.8)',
textColor: '#ffffff',
shadowColor: 'rgba(0,0,0,0.7)'
},
light: {
backgroundColor: 'rgba(255,255,255,0.8)',
textColor: '#333333',
shadowColor: 'rgba(0,0,0,0.2)'
},
transparent: {
backgroundColor: 'rgba(0,0,0,0.3)',
textColor: '#ffffff',
shadowColor: 'rgba(0,0,0,0.0)'
}
};
// 应用预设
function applyPreset(preset: 'dark' | 'light' | 'transparent') {
const presetData = presets[preset];
danmakuWindow.updateSetting('backgroundColor', presetData.backgroundColor);
danmakuWindow.updateSetting('textColor', presetData.textColor);
danmakuWindow.updateSetting('shadowColor', presetData.shadowColor);
message.success(`已应用${preset === 'dark' ? '暗黑' : preset === 'light' ? '明亮' : '透明'}主题预设`);
}
// 重置位置到屏幕中央
async function resetPosition() {
// 假设屏幕尺寸为 1920x1080将窗口居中
const width = danmakuWindow.danmakuWindowSetting.width;
const height = danmakuWindow.danmakuWindowSetting.height;
// 计算居中位置
const x = Math.floor((1920 - width) / 2);
const y = Math.floor((1080 - height) / 2);
danmakuWindow.setDanmakuWindowPosition(x, y);
message.success('窗口位置已重置');
}
// 更新设置包装了updateSetting方法
function updateSetting<K extends keyof typeof danmakuWindow.danmakuWindowSetting>(
key: K,
value: typeof danmakuWindow.danmakuWindowSetting[K]
) {
danmakuWindow.updateSetting(key, value);
}
</script>
<template>
<NCard
title="弹幕窗口管理"
bordered
>
<template #header-extra>
<NButton
:type="danmakuWindow.isDanmakuWindowOpen ? 'warning' : 'primary'"
@click="danmakuWindow.isDanmakuWindowOpen ? danmakuWindow.closeWindow() : danmakuWindow.createWindow()"
>
{{ danmakuWindow.isDanmakuWindowOpen ? '关闭弹幕窗口' : '打开弹幕窗口' }}
</NButton>
</template>
<NFlex
vertical
:size="20"
>
<NAlert
v-if="!danmakuWindow.isDanmakuWindowOpen"
title="弹幕窗口未打开"
type="info"
>
请先打开弹幕窗口后再进行设置
</NAlert>
<NTabs
type="line"
animated
>
<NTabPane
name="position"
tab="布局与位置"
>
<NFlex vertical>
<NForm
label-placement="left"
label-width="100"
>
<NGrid
:cols="2"
:x-gap="12"
>
<NGi>
<NFormItem label="窗口宽度">
<NInputNumber
v-model:value="danmakuWindow.danmakuWindowSetting.width"
:min="200"
:max="2000"
@update:value="val => updateSetting('width', val || 400)"
/>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="窗口高度">
<NInputNumber
v-model:value="danmakuWindow.danmakuWindowSetting.height"
:min="200"
:max="2000"
@update:value="val => updateSetting('height', val || 600)"
/>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="X位置">
<NInputNumber
v-model:value="danmakuWindow.danmakuWindowSetting.x"
:min="0"
@update:value="val => updateSetting('x', val || 0)"
/>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="Y位置">
<NInputNumber
v-model:value="danmakuWindow.danmakuWindowSetting.y"
:min="0"
@update:value="val => updateSetting('y', val || 0)"
/>
</NFormItem>
</NGi>
</NGrid>
</NForm>
<NDivider />
<NFlex
justify="space-between"
align="center"
>
<NButton
secondary
@click="resetPosition"
>
<template #icon>
<NIcon :component="ResizeTable24Filled" />
</template>
重置位置
</NButton>
<NSpace>
<NFormItem label="总是置顶">
<NSwitch
v-model:value="danmakuWindow.danmakuWindowSetting.alwaysOnTop"
@update:value="val => updateSetting('alwaysOnTop', val)"
/>
</NFormItem>
<NFormItem label="鼠标穿透">
<NSwitch
v-model:value="danmakuWindow.danmakuWindowSetting.interactive"
@update:value="val => updateSetting('interactive', val)"
/>
</NFormItem>
</NSpace>
</NFlex>
</NFlex>
</NTabPane>
<NTabPane
name="appearance"
tab="外观"
>
<NFlex vertical>
<NGrid
:cols="2"
:x-gap="12"
:y-gap="12"
>
<NGi>
<NFormItem label="背景颜色">
<NColorPicker
v-model:value="danmakuWindow.danmakuWindowSetting.backgroundColor"
:show-alpha="true"
@update:value="val => updateSetting('backgroundColor', val)"
/>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="文字颜色">
<NColorPicker
v-model:value="danmakuWindow.danmakuWindowSetting.textColor"
:show-alpha="true"
@update:value="val => updateSetting('textColor', val)"
/>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="透明度">
<NSlider
v-model:value="danmakuWindow.danmakuWindowSetting.opacity"
:min="0.1"
:max="1"
:step="0.05"
@update:value="val => updateSetting('opacity', val)"
/>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="字体大小">
<NSlider
v-model:value="danmakuWindow.danmakuWindowSetting.fontSize"
:min="10"
:max="24"
:step="1"
@update:value="val => updateSetting('fontSize', val)"
/>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="圆角大小">
<NSlider
v-model:value="danmakuWindow.danmakuWindowSetting.borderRadius"
:min="0"
:max="20"
:step="1"
@update:value="val => updateSetting('borderRadius', val)"
/>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="项目间距">
<NSlider
v-model:value="danmakuWindow.danmakuWindowSetting.itemSpacing"
:min="0"
:max="20"
:step="1"
@update:value="val => updateSetting('itemSpacing', val)"
/>
</NFormItem>
</NGi>
</NGrid>
<NFlex
justify="space-between"
align="center"
>
<NSpace>
<NFormItem label="启用阴影">
<NSwitch
v-model:value="danmakuWindow.danmakuWindowSetting.enableShadow"
@update:value="val => updateSetting('enableShadow', val)"
/>
</NFormItem>
<NFormItem
v-if="danmakuWindow.danmakuWindowSetting.enableShadow"
label="阴影颜色"
>
<NColorPicker
v-model:value="danmakuWindow.danmakuWindowSetting.shadowColor"
:show-alpha="true"
@update:value="val => updateSetting('shadowColor', val)"
/>
</NFormItem>
</NSpace>
<NSpace>
<NButton @click="applyPreset('dark')">
暗色主题
</NButton>
<NButton @click="applyPreset('light')">
亮色主题
</NButton>
<NButton @click="applyPreset('transparent')">
透明主题
</NButton>
</NSpace>
</NFlex>
</NFlex>
</NTabPane>
<NTabPane
name="content"
tab="内容设置"
>
<NGrid
:cols="1"
:y-gap="12"
>
<NGi>
<NFormItem label="信息显示">
<NCheckboxGroup v-model:value="danmakuWindow.danmakuWindowSetting.filterTypes">
<NSpace>
<NCheckbox
v-for="option in filterTypeOptions"
:key="option.value"
:value="option.value"
:label="option.label"
@update:checked="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);
}"
/>
</NSpace>
</NCheckboxGroup>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="显示选项">
<NSpace>
<NCheckbox
:checked="danmakuWindow.danmakuWindowSetting.showAvatar"
@update:checked="val => updateSetting('showAvatar', val)"
>
显示头像
</NCheckbox>
<NCheckbox
:checked="danmakuWindow.danmakuWindowSetting.showUsername"
@update:checked="val => updateSetting('showUsername', val)"
>
显示用户名
</NCheckbox>
<NCheckbox
:checked="danmakuWindow.danmakuWindowSetting.showFansMedal"
@update:checked="val => updateSetting('showFansMedal', val)"
>
显示粉丝牌
</NCheckbox>
<NCheckbox
:checked="danmakuWindow.danmakuWindowSetting.showGuardIcon"
@update:checked="val => updateSetting('showGuardIcon', val)"
>
显示舰长图标
</NCheckbox>
</NSpace>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="弹幕方向">
<NSpace align="center">
<NText>从上往下</NText>
<NSwitch
v-model:value="danmakuWindow.danmakuWindowSetting.reverseOrder"
@update:value="val => updateSetting('reverseOrder', val)"
/>
<NText>从下往上</NText>
</NSpace>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="最大弹幕数量">
<NInputNumber
v-model:value="danmakuWindow.danmakuWindowSetting.maxDanmakuCount"
:min="10"
:max="200"
@update:value="val => updateSetting('maxDanmakuCount', val || 50)"
/>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="动画持续时间">
<NInputNumber
v-model:value="danmakuWindow.danmakuWindowSetting.animationDuration"
:min="0"
:max="1000"
:step="50"
@update:value="val => updateSetting('animationDuration', val || 300)"
>
<template #suffix>
ms
</template>
</NInputNumber>
</NFormItem>
</NGi>
</NGrid>
</NTabPane>
</NTabs>
</NFlex>
</NCard>
</template>
<style scoped>
.n-form-item {
margin-bottom: 8px;
}
.position-indicator {
position: fixed;
pointer-events: none;
border: 2px dashed #f56c6c;
z-index: 9999;
background-color: rgba(245, 108, 108, 0.1);
}
</style>

View File

@@ -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() {

View File

@@ -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<Webview>();
const danmakuWindowSetting = useStorage<DanmakuWindowSettings>('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<K extends keyof DanmakuWindowSettings>(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<EventModel>,
});
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));
}

View File

@@ -1,19 +1,26 @@
<script setup lang="ts">
import { GoodsTypes, ResponsePointGoodModel } from '@/api/api-models'
import { FILE_BASE_URL, IMGUR_URL } from '@/data/constants'
import { NAlert, NCard, NEllipsis, NEmpty, NFlex, NIcon, NImage, NTag, NText } from 'naive-ui'
import { VehicleShip20Filled } from '@vicons/fluent'
import { GoodsTypes, ResponsePointGoodModel } from '@/api/api-models';
import { FILE_BASE_URL, IMGUR_URL } from '@/data/constants';
import { NAlert, NCard, NEllipsis, NEmpty, NFlex, NIcon, NImage, NTag, NText } from 'naive-ui';
import { VehicleShip20Filled } from '@vicons/fluent';
const props = defineProps<{
goods: ResponsePointGoodModel | undefined
contentStyle?: string | undefined
}>()
const emptyCover = IMGUR_URL + 'None.png'
const props = defineProps<{
goods: ResponsePointGoodModel | undefined;
contentStyle?: string | undefined;
}>();
const emptyCover = IMGUR_URL + 'None.png';
</script>
<template>
<NEmpty v-if="!goods" description="已失效" />
<NCard v-else embedded :style="props.contentStyle">
<NEmpty
v-if="!goods"
description="已失效"
/>
<NCard
v-else
embedded
:style="props.contentStyle"
>
<template #cover>
<NImage
:src="goods.cover ? FILE_BASE_URL + goods.cover : emptyCover"
@@ -27,19 +34,41 @@ const emptyCover = IMGUR_URL + 'None.png'
<template #header-extra>
<NFlex justify="space-between">
<NFlex>
<NText depth="3"> 库存: </NText>
<NText depth="3">
库存:
</NText>
<NText v-if="goods.count && goods.count > 0">
{{ goods.count }}
</NText>
<NText v-else-if="goods.count == 0" style="color: #5f5f5f;"> </NText>
<NText v-else> </NText>
<NText
v-else-if="goods.count == 0"
style="color: #5f5f5f;"
>
</NText>
<NText v-else>
</NText>
</NFlex>
</NFlex>
</template>
<template #header>
<NFlex align="center" :size="5">
<NTag v-if="goods.count == 0" size="small" type="error" :bordered="false"> 已售完 </NTag>
<NTag size="small" :bordered="goods.type != GoodsTypes.Physical">
<NFlex
align="center"
:size="5"
>
<NTag
v-if="goods.count == 0"
size="small"
type="error"
:bordered="false"
>
已售完
</NTag>
<NTag
size="small"
:bordered="goods.type != GoodsTypes.Physical"
>
{{ goods.type == GoodsTypes.Physical ? '实物' : '虚拟' }}
</NTag>
<NEllipsis>
@@ -49,22 +78,36 @@ const emptyCover = IMGUR_URL + 'None.png'
</template>
<NFlex vertical>
<NEllipsis :line-clamp="2">
<NText :depth="goods.description ? 1 : 3" :italic="!goods.description">
<NText
:depth="goods.description ? 1 : 3"
:italic="!goods.description"
>
{{ goods.description ? goods.description : '暂无描述' }}
</NText>
</NEllipsis>
<NFlex>
<NTag v-if="goods.allowGuardLevel > 0" size="tiny" :color="{ color: '#5f5f5f', textColor: 'gold' }">
<NTag
v-if="goods.allowGuardLevel > 0"
size="tiny"
:color="{ color: '#5f5f5f', textColor: 'gold' }"
>
<template #icon>
<NIcon :component="VehicleShip20Filled" />
</template>
仅限舰长
</NTag>
<NTag v-for="tag in goods.tags" :key="tag" :bordered="false" size="tiny">{{ tag }}</NTag>
<NTag
v-for="tag in goods.tags"
:key="tag"
:bordered="false"
size="tiny"
>
{{ tag }}
</NTag>
</NFlex>
</NFlex>
<template #footer>
<slot name="footer"></slot>
<slot name="footer" />
</template>
</NCard>
</template>

View File

@@ -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;
}
}

View File

@@ -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
);
});
}
}

View File

@@ -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<OpenLiveInfo>(
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<OpenLiveInfo>(
OPEN_LIVE_API_URL + 'heartbeat',
this.authInfo
)
: QueryGetAPI<OpenLiveInfo>(OPEN_LIVE_API_URL + 'heartbeat-internal')
OPEN_LIVE_API_URL + 'heartbeat',
this.authInfo
)
: QueryGetAPI<OpenLiveInfo>(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;
}

View File

@@ -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);

View File

@@ -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',

View File

@@ -14,5 +14,13 @@ export default [
meta: {
title: '测试页'
}
},
{
path: '/danmaku-window',
name: 'client-danmaku-client',
component: () => import('@/client/ClientDanmakuWindow.vue'),
meta: {
title: '弹幕窗口'
}
}
]

View File

@@ -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<OpenLiveClient>(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<OpenLiveInfo>()
const accountInfo = useAccount()
// 使用 shallowRef 存储 danmakuClient 实例, 性能稍好
const danmakuClient = shallowRef<BaseDanmakuClient>();
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<OpenLiveInfo>();
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<boolean> 是否初始化成功 (包括重试后最终成功)
*/
async function initClient(client: BaseDanmakuClient) { // 返回 Promise<boolean> 表示最终是否成功
// 防止重复初始化或在非等待状态下初始化
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, // 停止并清理客户端
};
});

View File

@@ -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<BaseDanmakuClient>();
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,
};
});

View File

@@ -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<OpenLiveClient>(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<RoomAuthInfo>()
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
}
})

View File

@@ -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<Date>(); // 本次启动时间
const signalRClient = shallowRef<signalR.HubConnection>(); // SignalR 客户端实例 (浅响应)
const signalRId = ref<string>(); // SignalR 连接 ID
const client = shallowRef<BaseDanmakuClient>(); // 弹幕客户端实例 (浅响应)
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),
};
});

View File

@@ -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<AuthInfo>()
const danmakuClient = await useDanmakuClient()
const danmakuClient = await useDanmakuClient().initOpenlive();
const menuOptions = [
{
@@ -111,7 +111,7 @@ const danmakuClientError = ref<string>()
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('你不是从幻星平台访问此页面, 或未提供对应参数, 无法使用此功能')
}

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { useAccount } from '@/api/account';
import { BaseRTCClient, MasterRTCClient, SlaveRTCClient } from '@/data/RTCClient';
import { useDanmakuClient } from '@/store/useDanmakuClient';
import { useWebRTC } from '@/store/useRTC';
import { NButton, NInput, NSpin } from 'naive-ui';
import { computed, Ref, ref } from 'vue';
import { useRoute } from 'vue-router';
import DanmujiOBS from './obs/DanmujiOBS.vue';
import { useDanmakuClient } from '@/store/useDanmakuClient';
const target = ref('');
const accountInfo = useAccount()
@@ -16,7 +16,7 @@ const inputMsg = ref('')
const isMaster = computed(() => {
return route.query.slave == null || route.query.slave == undefined
})
const dc = await useDanmakuClient().initClient()
const dc = useDanmakuClient()
const customCss = ref('')
let rtc= useWebRTC()
@@ -24,7 +24,7 @@ const danmujiRef = ref()
async function mount() {
rtc.Init(isMaster.value ? 'master' : 'slave')
dc.initClient()
dc.initOpenlive()
}
</script>

View File

@@ -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()
</script>

View File

@@ -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()
</script>
<template>

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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<OpenLiveLotteryUserInfo[]>([])
const currentUsers = ref<OpenLiveLotteryUserInfo[]>([])

View File

@@ -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)

View File

@@ -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<SpeechSettings>('Setting.Speech', {
speechInfo: {
volume: 1,