mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: 修复图片url, 开始弹幕机编写
This commit is contained in:
289
src/client/ClientDanmakuWindow.vue
Normal file
289
src/client/ClientDanmakuWindow.vue
Normal 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>
|
||||
@@ -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' } }, () => '设置'),
|
||||
|
||||
438
src/client/DanmakuWindowManager.vue
Normal file
438
src/client/DanmakuWindowManager.vue
Normal 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>
|
||||
@@ -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() {
|
||||
|
||||
189
src/client/store/useDanmakuWindow.ts
Normal file
189
src/client/store/useDanmakuWindow.ts
Normal 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));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -14,5 +14,13 @@ export default [
|
||||
meta: {
|
||||
title: '测试页'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/danmaku-window',
|
||||
name: 'client-danmaku-client',
|
||||
component: () => import('@/client/ClientDanmakuWindow.vue'),
|
||||
meta: {
|
||||
title: '弹幕窗口'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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, // 停止并清理客户端
|
||||
};
|
||||
});
|
||||
272
src/store/useDanmakuClient.ts.bak
Normal file
272
src/store/useDanmakuClient.ts.bak
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
};
|
||||
});
|
||||
@@ -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('你不是从幻星平台访问此页面, 或未提供对应参数, 无法使用此功能')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"env.d.ts",
|
||||
"default.d.ts",
|
||||
"src/data/chat/ChatClientDirectOpenLive.js",
|
||||
"src/data/chat/models.js",
|
||||
"src/data/chat/models.js", "src/store/useDanmakuClient.ts",
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user