feat: 重构弹幕组件和工具以改进结构和性能

- 更新 `useWebFetcher.ts`:将事件监听器从 `onEvent` 更改为 `on`,并修改了断开连接处理逻辑,增加了 30 秒后自动重连的功能。
- 增强 `MessageRender.vue`:为 `paidMessages` 使用 v-model,并将生命周期钩子更新为 `beforeUnmount`。
- 引入新组件 `ClientDanmakuItem.vue`:用于渲染具有卡片和文本样式的弹幕条目。
- 创建 `BaseDanmakuItem.vue`:封装弹幕条目的通用逻辑,包括表情符号解析和显示逻辑。
- 添加 `CardStyleDanmakuItem.vue` 和 `TextStyleDanmakuItem.vue`:用于实现不同显示样式的弹幕消息。
- 开发 `danmakuUtils.ts`:提供用于弹幕条目属性和样式的工具函数。
- 改进弹幕组件的 CSS 样式:确保外观统一和响应式布局。
This commit is contained in:
2025-04-15 22:18:47 +08:00
parent ff755afd99
commit 1ea4404307
18 changed files with 1898 additions and 433 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "bunx --bun vite",
"build": "vite build",
"lint": "vite lint"
"lint": "vite lint",
"knip": "knip"
},
"dependencies": {
"@antfu/ni": "^24.3.0",
@@ -53,6 +54,7 @@
"mitt": "^3.0.1",
"monaco-editor": "^0.52.2",
"music-metadata-browser": "^2.5.11",
"nanoid": "^5.1.5",
"oxlint": "^0.16.2",
"peerjs": "^1.5.4",
"pinia": "^3.0.1",
@@ -83,6 +85,7 @@
"@types/bun": "^1.2.5",
"@types/eslint": "^9.6.1",
"@types/file-saver": "^2.0.7",
"@types/node": "^22.14.1",
"@types/obs-studio": "^2.17.2",
"@types/uuid": "^10.0.0",
"@typescript-eslint/parser": "^8.27.0",
@@ -93,9 +96,10 @@
"eslint": "^9.23.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-vue": "^10.0.0",
"knip": "^5.50.4",
"naive-ui": "^2.41.0",
"stylus": "^0.64.0",
"typescript": "^5.8.2",
"typescript": "^5.8.3",
"vue-vine": "^0.3.19"
}
}

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { useDanmakuUtils, BaseDanmakuItemProps } from './components/danmaku/danmakuUtils';
import CardStyleDanmakuItem from './components/danmaku/CardStyleDanmakuItem.vue';
import TextStyleDanmakuItem from './components/danmaku/TextStyleDanmakuItem.vue';
import { useDanmakuWindow } from './store/useDanmakuWindow';
const props = defineProps<BaseDanmakuItemProps>();
// 使用工具函数获取基础计算属性
const emojiData = useDanmakuWindow().emojiData;
const { isDisappearing, typeClass } = useDanmakuUtils(props, emojiData);
</script>
<template>
<div
:class="['danmaku-item-content', typeClass, { 'disappearing': isDisappearing }]"
:data-disappear="item.disappearAt"
>
<!-- 根据设置选择显示风格 -->
<CardStyleDanmakuItem
v-if="setting.displayStyle === 'card'"
:item="item"
:setting="setting"
/>
<TextStyleDanmakuItem
v-else-if="setting.displayStyle === 'text' || !setting.displayStyle"
:item="item"
:setting="setting"
/>
</div>
</template>
<style scoped>
/* 基础布局 */
.danmaku-item-content {
display: flex;
width: 100%;
overflow: hidden;
will-change: transform, opacity;
margin-bottom: var(--dw-item-spacing, 4px);
padding: 0;
background: none;
border-radius: 0;
box-shadow: none;
min-height: 0;
font-size: var(--dw-font-size);
color: var(--dw-text-color);
}
.danmaku-item-content.disappearing {
animation: danmaku-out var(--dw-animation-duration, 300ms) ease forwards;
pointer-events: none;
}
/* 动画相关 */
@keyframes danmaku-out {
from {
opacity: var(--dw-opacity);
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(-30px);
}
}
</style>

View File

@@ -1,349 +1,310 @@
<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 nextTick
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue';
import { TransitionGroup } from 'vue';
import { Money24Regular, VehicleShip24Filled } from '@vicons/fluent';
import { EventDataTypes, EventModel } from '@/api/api-models';
import { NSpin } from 'naive-ui';
import { DANMAKU_WINDOW_BROADCAST_CHANNEL, DanmakuWindowBCData, DanmakuWindowSettings } from './store/useDanmakuWindow';
import { nanoid } from 'nanoid';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import ClientDanmakuItem from './ClientDanmakuItem.vue';
let bc: BroadcastChannel | undefined = undefined;
const setting = ref<DanmakuWindowSettings>();
const danmakuList = ref<EventModel[]>([]);
const maxItems = computed(() => setting.value?.maxDanmakuCount || 50);
// Ref for the scroll container
const scrollContainerRef = ref<HTMLElement | null>(null);
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;
// Map EventDataTypes enum values to the string values used in filterTypes
const typeToStringMap: { [key in EventDataTypes]?: string } = {
[EventDataTypes.Message]: "Message",
[EventDataTypes.Gift]: "Gift",
[EventDataTypes.SC]: "SC",
[EventDataTypes.Guard]: "Guard",
[EventDataTypes.Enter]: "Enter"
type TempDanmakuType = EventModel & {
randomId: string;
isNew?: boolean; // 添加:标记是否为新弹幕
disappearAt?: number; // 消失时间戳
};
const typeStr = typeToStringMap[data.type];
let bc: BroadcastChannel | undefined = undefined;
const setting = ref<DanmakuWindowSettings>();
const danmakuList = ref<TempDanmakuType[]>([]);
const maxItems = computed(() => setting.value?.maxDanmakuCount || 50);
const hasItems = computed(() => danmakuList.value.length > 0);
// Check if the type should be filtered out
if (!typeStr || !setting.value.filterTypes.includes(typeStr)) {
return; // Don't add if filtered
// 动态设置CSS变量
function updateCssVariables() {
if (!setting.value) return;
const root = document.documentElement;
root.style.setProperty('--dw-direction', setting.value.reverseOrder ? 'column-reverse' : 'column');
// 背景和文字颜色
root.style.setProperty('--dw-bg-color', setting.value.backgroundColor || 'rgba(0,0,0,0.6)');
root.style.setProperty('--dw-text-color', setting.value.textColor || '#ffffff');
// 尺寸相关
root.style.setProperty('--dw-border-radius', `${setting.value.borderRadius || 0}px`);
root.style.setProperty('--dw-opacity', `${setting.value.opacity || 1}`);
root.style.setProperty('--dw-font-size', `${setting.value.fontSize || 14}px`);
root.style.setProperty('--dw-avatar-size', `${(setting.value.fontSize || 14) + 6}px`);
root.style.setProperty('--dw-emoji-size', `${(setting.value.fontSize || 14) + 10}px`);
root.style.setProperty('--dw-item-spacing', `${setting.value.itemSpacing || 5}px`);
// 动画和阴影
root.style.setProperty('--dw-animation-duration', `${setting.value.animationDuration || 300}ms`);
root.style.setProperty('--dw-shadow', setting.value.enableShadow ? `0 0 10px ${setting.value.shadowColor}` : 'none');
}
// --- Auto Scroll Logic ---
const el = scrollContainerRef.value;
let shouldScroll = false;
if (el) {
const threshold = 5; // Pixels threshold to consider "at the end"
if (setting.value?.reverseOrder) {
// Check if scrolled to the top before adding
shouldScroll = el.scrollTop <= threshold;
} else {
// Check if scrolled to the bottom before adding
shouldScroll = el.scrollHeight - el.scrollTop - el.clientHeight <= threshold;
function addDanmaku(data: EventModel) {
if (!setting.value) return;
// Map EventDataTypes enum values to the string values used in filterTypes
const typeToStringMap: { [key in EventDataTypes]?: string } = {
[EventDataTypes.Message]: "Message",
[EventDataTypes.Gift]: "Gift",
[EventDataTypes.SC]: "SC",
[EventDataTypes.Guard]: "Guard",
[EventDataTypes.Enter]: "Enter"
};
const typeStr = typeToStringMap[data.type];
// Check if the type should be filtered out
if (!typeStr || !setting.value.filterTypes.includes(typeStr)) {
return; // Don't add if filtered
}
}
// --- End Auto Scroll Logic ---
// 计算消失时间
let disappearAt: number | undefined = undefined;
if (setting.value.autoDisappearTime > 0) {
disappearAt = Date.now() + setting.value.autoDisappearTime * 1000;
}
// Maintain max message count
if (setting.value.reverseOrder) {
danmakuList.value.unshift(data);
if (danmakuList.value.length > maxItems.value) {
// 为传入的弹幕对象添加一个随机ID和isNew标记
const dataWithId = {
...data,
randomId: nanoid(), // 生成一个随机ID
disappearAt, // 添加消失时间
isNew: true, // 标记为新弹幕,用于动画
};
danmakuList.value.unshift(dataWithId);
// Limit the list size AFTER adding the new item
while (danmakuList.value.length > maxItems.value) {
danmakuList.value.pop();
}
} else {
danmakuList.value.push(data);
if (danmakuList.value.length > maxItems.value) {
danmakuList.value.shift();
}
// 设置一个定时器在动画完成后移除isNew标记
setTimeout(() => {
const index = danmakuList.value.findIndex(item => item.randomId === dataWithId.randomId);
if (index !== -1) {
danmakuList.value[index].isNew = false;
}
}, setting.value.animationDuration || 300);
console.log('[DanmakuWindow] 添加弹幕:', dataWithId);
}
// --- Auto Scroll Execution ---
if (shouldScroll && el) {
nextTick(() => {
if (setting.value?.reverseOrder) {
el.scrollTop = 0; // Scroll to top
} else {
el.scrollTop = el.scrollHeight; // Scroll to bottom
}
// 检查和移除过期弹幕
function checkAndRemoveExpiredDanmaku() {
if (!setting.value || setting.value.autoDisappearTime <= 0) return;
const now = Date.now();
// 让弹幕有足够时间完成消失动画后再从列表中移除
danmakuList.value = danmakuList.value.filter(item => {
// 如果设置了消失时间,则在消失时间+动画时长后才真正移除
const animationDuration = setting.value?.animationDuration || 300;
return !item.disappearAt || (item.disappearAt + animationDuration) > now;
});
}
// --- End Auto Scroll Execution ---
}
onMounted(() => {
bc = new BroadcastChannel(DANMAKU_WINDOW_BROADCAST_CHANNEL);
console.log(`[DanmakuWindow] BroadcastChannel 已创建: ${DANMAKU_WINDOW_BROADCAST_CHANNEL}`);
bc.postMessage({
type: 'window-ready',
})
bc.onmessage = (event) => {
const data = event.data as DanmakuWindowBCData;
switch (data.type) {
case 'danmaku':
addDanmaku(data.data); // addDanmaku now handles scrolling
// console.log('[DanmakuWindow] 收到弹幕:', data.data); // Keep console logs minimal if not debugging
break;
case 'update-setting':
setting.value = data.data;
console.log('[DanmakuWindow] 设置已更新:', data.data);
// Adjust scroll on setting change if needed (e.g., reverseOrder changes)
nextTick(() => {
const el = scrollContainerRef.value;
if (el) {
if (setting.value?.reverseOrder) {
el.scrollTop = 0;
} else {
el.scrollTop = el.scrollHeight;
}
}
});
break;
}
};
// Dispatch a request for settings
bc.postMessage({ type: 'request-settings' });
});
onUnmounted(() => {
if (bc) {
bc.close();
bc = undefined;
// 为弹幕项生成自定义属性值
function getSCColorAttribute(price: number): string {
if (price === 0) return `sc-0`;
if (price > 0 && price < 50) return `sc-50`;
if (price >= 50 && price < 100) return `sc-100`;
if (price >= 100 && price < 500) return `sc-500`;
if (price >= 500 && price < 1000) return `sc-1000`;
if (price >= 1000 && price < 2000) return `sc-2000`;
if (price >= 2000) return `sc-max`;
return '';
}
});
// 格式化弹幕消息
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;
}
}
onMounted(() => {
bc = new BroadcastChannel(DANMAKU_WINDOW_BROADCAST_CHANNEL);
console.log(`[DanmakuWindow] BroadcastChannel 已创建: ${DANMAKU_WINDOW_BROADCAST_CHANNEL}`);
bc.postMessage({
type: 'window-ready',
});
bc.onmessage = (event) => {
const data = event.data as DanmakuWindowBCData;
switch (data.type) {
case 'danmaku':
addDanmaku(data.data);
break;
case 'test-danmaku': // 处理测试弹幕
addDanmaku(data.data);
break;
case 'update-setting':
setting.value = data.data;
updateCssVariables();
console.log('[DanmakuWindow] 设置已更新:', data.data);
break;
case 'clear-danmaku': // 处理清空弹幕
danmakuList.value = [];
console.log('[DanmakuWindow] 弹幕已清空');
break;
}
};
// 初始化CSS变量
updateCssVariables();
// 启动定时器,定期检查过期弹幕
const checkInterval = setInterval(checkAndRemoveExpiredDanmaku, 1000);
onUnmounted(() => {
if (bc) {
bc.close();
bc = undefined;
}
clearInterval(checkInterval);
});
});
// 监听设置变化
watch(() => setting.value, () => {
updateCssVariables();
}, { deep: true });
</script>
<template>
<NSpin
v-if="!isConnected"
v-if="!setting"
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'
}"
:class="{ 'has-items': hasItems }"
>
<TransitionGroup
<div
ref="scrollContainerRef"
: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'
}"
class="danmaku-list"
>
<div
v-for="(item, index) in danmakuList"
:key="`${item.time}-${index}`"
:data-type="item.type"
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"
>
<!-- 用户名 -->
<!-- 移除 TransitionGroup使用普通 div -->
<div class="danmaku-list-container">
<div
v-if="setting?.showUsername"
class="username"
:style="{
fontWeight: 'bold',
marginRight: '6px',
}"
v-for="item in danmakuList"
:key="item.randomId"
:data-type="item.type"
class="danmaku-item"
:class="{ 'danmaku-item-new': item.isNew }"
>
<!-- 舰长图标 -->
<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;"
<ClientDanmakuItem
:item="item"
:setting="setting"
/>
{{ 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>
</div>
</template>
<style>
html, body{
background: transparent;
}
html,
body {
background: transparent;
overflow: hidden;
}
.danmaku-list-enter-active,
.danmaku-list-leave-active {
transition: all 0.3s ease;
}
.n-layout {
background: transparent;
}
.danmaku-list-enter-from {
opacity: 0;
transform: translateY(30px);
}
:root {
--dw-bg-color: rgba(0, 0, 0, 0.6);
--dw-text-color: #ffffff;
--dw-border-radius: 0px;
--dw-opacity: 1;
--dw-font-size: 14px;
--dw-avatar-size: 20px;
--dw-emoji-size: 24px;
--dw-item-spacing: 5px;
--dw-animation-duration: 300ms;
--dw-shadow: none;
}
.danmaku-list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.danmaku-window {
-webkit-app-region: drag;
overflow: hidden;
background-color: transparent; /* 完全透明背景 */
width: 100%;
height: 100%;
color: var(--dw-text-color);
font-size: var(--dw-font-size);
box-shadow: var(--dw-shadow);
overflow-x: hidden;
transition: opacity 0.3s ease;
}
/* 滚动条样式 */
.danmaku-list::-webkit-scrollbar {
width: 4px;
}
/* 没有弹幕时完全透明 */
.danmaku-window:not(.has-items) {
opacity: 0;
}
.danmaku-list::-webkit-scrollbar-track {
background: transparent;
}
.danmaku-list {
padding: 8px;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: var(--dw-direction);
box-sizing: border-box; /* 确保padding不会增加元素的实际尺寸 */
}
.danmaku-list::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.danmaku-list-container {
width: 100%;
display: flex;
flex-direction: inherit;
gap: var(--dw-item-spacing);
padding-bottom: 8px; /* 添加底部内边距以防止项目溢出 */
box-sizing: border-box; /* 确保padding不会增加元素的实际尺寸 */
}
/* 拖动窗口时用于指示 */
.danmaku-window {
-webkit-app-region: drag;
}
.danmaku-list.reverse {
flex-direction: column-reverse;
}
.danmaku-item {
-webkit-app-region: no-drag;
}
.danmaku-list::-webkit-scrollbar {
width: 4px;
}
/* 根据消息类型添加特殊样式 */
.danmaku-item[data-type="2"] { /* Gift */
color: #dd2f2f;
}
.danmaku-list::-webkit-scrollbar-track {
background: transparent;
}
.danmaku-item[data-type="0"] { /* Guard */
color: #9d78c1;
}
.danmaku-list::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.danmaku-item[data-type="3"] { /* Guard */
color: #9d78c1;
}
/* 弹幕进入动画 */
.danmaku-item {
transform-origin: center left;
transition: all var(--dw-animation-duration) ease;
}
.danmaku-item[data-type="4"] { /* Enter */
color: #4caf50;
}
.danmaku-item-new {
animation: danmaku-in var(--dw-animation-duration) ease-out forwards;
}
@keyframes danmaku-in {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes danmaku-out {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(20px);
}
}
</style>

View File

@@ -3,7 +3,7 @@
import { RouterLink, RouterView } from 'vue-router'; // 引入 Vue Router 组件
// 引入 Naive UI 组件 和 图标
import { NA, NButton, NCard, NInput, NLayout, NLayoutSider, NLayoutContent, NMenu, NSpace, NSpin, NText, NTooltip } from 'naive-ui';
import { NA, NButton, NCard, NInput, NLayout, NLayoutSider, NLayoutContent, NMenu, NSpace, NSpin, NText, NTooltip, MenuOption } from 'naive-ui';
import { CheckmarkCircle, CloseCircle, Home } from '@vicons/ionicons5';
// 引入 Tauri 插件
@@ -16,8 +16,9 @@
// 引入子组件
import WindowBar from './WindowBar.vue';
import { initAll, OnClientUnmounted } from './data/initialize';
import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
import { isTauri } from '@/data/constants';
import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
import { isTauri } from '@/data/constants';
import { useDanmakuWindow } from './store/useDanmakuWindow';
// --- 响应式状态 ---
@@ -25,6 +26,7 @@ import { isTauri } from '@/data/constants';
const webfetcher = useWebFetcher();
// 获取账户信息状态管理的实例 (如果 accountInfo 未使用,可以考虑移除)
const accountInfo = useAccount();
const danmakuWindow = useDanmakuWindow();
// 用于存储用户输入的 Token
const token = ref('');
@@ -88,32 +90,35 @@ import { isTauri } from '@/data/constants';
// --- 导航菜单配置 ---
// 将菜单项定义为常量,使模板更清晰
const menuOptions = [
{
label: () =>
h(RouterLink, { to: { name: 'client-index' } }, () => '主页'), // 使用 h 函数渲染 RouterLink
key: 'go-back-home',
icon: () => h(Home)
},
{
label: () =>
h(RouterLink, { to: { name: 'client-fetcher' } }, () => 'EventFetcher'),
key: 'fetcher',
icon: () => h(CloudArchive24Filled)
},
/*{
label: () =>
h(RouterLink, { to: { name: 'client-danmaku-window-manage' } }, () => '弹幕机管理'),
key: 'danmaku-window-manage',
icon: () => h(Settings24Filled)
},*/
{
label: () =>
h(RouterLink, { to: { name: 'client-settings' } }, () => '设置'),
key: 'settings',
icon: () => h(Settings24Filled)
},
];
const menuOptions = computed(() => {
return [
{
label: () =>
h(RouterLink, { to: { name: 'client-index' } }, () => '主页'), // 使用 h 函数渲染 RouterLink
key: 'go-back-home',
icon: () => h(Home)
},
{
label: () =>
h(RouterLink, { to: { name: 'client-fetcher' } }, () => 'EventFetcher'),
key: 'fetcher',
icon: () => h(CloudArchive24Filled)
},
{
label: () =>
h(RouterLink, { to: { name: 'client-danmaku-window-manage' } }, () => '弹幕机管理'),
key: 'danmaku-window-manage',
icon: () => h(Settings24Filled),
show: danmakuWindow.danmakuWindow != undefined
},
{
label: () =>
h(RouterLink, { to: { name: 'client-settings' } }, () => '设置'),
key: 'settings',
icon: () => h(Settings24Filled)
},
] as MenuOption[];
});
onMounted(() => {
window.addEventListener('beforeunload', (event) => {

View File

@@ -1,23 +1,11 @@
<script setup lang="ts">
import { useDanmakuWindow } from './store/useDanmakuWindow';
import { NAlert, NButton, NCard, NCheckbox, NCheckboxGroup, NColorPicker, NDivider, NFlex, NForm, NFormItem, NGi, NGrid, NIcon, NInputNumber, NRadioButton, NRadioGroup, NSelect, NSlider, NSpace, NSwitch, NTabPane, NTabs, NText, useMessage } from 'naive-ui';
import { ref } from 'vue';
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 { useDanmakuWindow } from './store/useDanmakuWindow';
import {
DesktopMac24Regular,
TextFont24Regular,
ColorFill24Regular,
AppsList24Regular,
DesignIdeas24Regular,
CheckmarkCircle24Regular,
ResizeTable24Filled,
ResizeTable24Filled
} from '@vicons/fluent';
const danmakuWindow = useDanmakuWindow();
@@ -55,6 +43,16 @@ const presets = {
}
};
// 自动消失时间选项
const autoDisappearOptions = [
{ label: '不自动消失', value: 0 },
{ label: '10秒', value: 10 },
{ label: '30秒', value: 30 },
{ label: '1分钟', value: 60 },
{ label: '3分钟', value: 180 },
{ label: '5分钟', value: 300 },
];
// 应用预设
function applyPreset(preset: 'dark' | 'light' | 'transparent') {
const presetData = presets[preset];
@@ -65,10 +63,27 @@ function applyPreset(preset: 'dark' | 'light' | 'transparent') {
}
// 重置位置到屏幕中央
async function resetPosition() {
async function resetPosition() {
console.log(danmakuWindow.danmakuWindowSetting.height)
danmakuWindow.setDanmakuWindowPosition(0, 0);
message.success('窗口位置已重置');
}
// 新增:弹幕展示风格选项
const displayStyleOptions = [
{ label: '卡片风格', value: 'card' },
{ label: '纯文本风格', value: 'text' }
];
// 新增:分隔符选项
const separatorOptions = [
{ label: ': (冒号+空格)', value: ': ' },
{ label: '(中文冒号)', value: '' },
{ label: '> ', value: '> ' },
{ label: '| ', value: '| ' },
{ label: '- ', value: '- ' },
{ label: '→ ', value: '→ ' },
];
</script>
<template>
@@ -192,7 +207,6 @@ async function resetPosition() {
<NGrid
:cols="2"
:x-gap="12"
:y-gap="12"
>
<NGi>
<NFormItem label="背景颜色">
@@ -256,23 +270,6 @@ async function resetPosition() {
justify="space-between"
align="center"
>
<NSpace>
<NFormItem label="启用阴影">
<NSwitch
v-model:value="danmakuWindow.danmakuWindowSetting.enableShadow"
/>
</NFormItem>
<NFormItem
v-if="danmakuWindow.danmakuWindowSetting.enableShadow"
label="阴影颜色"
>
<NColorPicker
v-model:value="danmakuWindow.danmakuWindowSetting.shadowColor"
:show-alpha="true"
/>
</NFormItem>
</NSpace>
<NSpace>
<NButton @click="applyPreset('dark')">
暗色主题
@@ -294,8 +291,25 @@ async function resetPosition() {
>
<NGrid
:cols="1"
:y-gap="12"
>
<!-- 新增弹幕展示风格 -->
<NGi>
<NFormItem label="展示风格">
<NRadioGroup v-model:value="danmakuWindow.danmakuWindowSetting.displayStyle">
<NSpace>
<NRadioButton
v-for="option in displayStyleOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</NRadioButton>
</NSpace>
</NRadioGroup>
</NFormItem>
</NGi>
<!-- 其他内容设置 -->
<NGi>
<NFormItem label="信息显示">
<NCheckboxGroup v-model:value="danmakuWindow.danmakuWindowSetting.filterTypes">
@@ -315,22 +329,22 @@ async function resetPosition() {
<NFormItem label="显示选项">
<NSpace>
<NCheckbox
:checked="danmakuWindow.danmakuWindowSetting.showAvatar"
v-model:checked="danmakuWindow.danmakuWindowSetting.showAvatar"
>
显示头像
</NCheckbox>
<NCheckbox
:checked="danmakuWindow.danmakuWindowSetting.showUsername"
v-model:checked="danmakuWindow.danmakuWindowSetting.showUsername"
>
显示用户名
</NCheckbox>
<NCheckbox
:checked="danmakuWindow.danmakuWindowSetting.showFansMedal"
v-model:checked="danmakuWindow.danmakuWindowSetting.showFansMedal"
>
显示粉丝牌
</NCheckbox>
<NCheckbox
:checked="danmakuWindow.danmakuWindowSetting.showGuardIcon"
v-model:checked="danmakuWindow.danmakuWindowSetting.showGuardIcon"
>
显示舰长图标
</NCheckbox>
@@ -338,6 +352,44 @@ async function resetPosition() {
</NFormItem>
</NGi>
<!-- 新增纯文本风格特定设置 -->
<NGi v-if="danmakuWindow.danmakuWindowSetting.displayStyle === 'text'">
<NDivider>纯文本风格设置</NDivider>
<NSpace vertical>
<NFormItem label="紧凑布局">
<NSwitch v-model:value="danmakuWindow.danmakuWindowSetting.textStyleCompact" />
<NText
depth="3"
style="margin-left: 8px"
>
启用后减少边距适合小窗口
</NText>
</NFormItem>
<NFormItem label="显示消息类型">
<NSwitch v-model:value="danmakuWindow.danmakuWindowSetting.textStyleShowType" />
<NText
depth="3"
style="margin-left: 8px"
>
显示礼物SC等类型标签
</NText>
</NFormItem>
<NFormItem label="用户名分隔符">
<NSelect
v-model:value="danmakuWindow.danmakuWindowSetting.textStyleNameSeparator"
:options="separatorOptions"
style="width: 160px"
/>
</NFormItem>
</NSpace>
</NGi>
<NGi>
<NDivider>弹幕行为</NDivider>
</NGi>
<NGi>
<NFormItem label="弹幕方向">
<NSpace align="center">
@@ -374,6 +426,95 @@ async function resetPosition() {
</NInputNumber>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="自动消失时间">
<NSpace vertical>
<NSlider
v-model:value="danmakuWindow.danmakuWindowSetting.autoDisappearTime"
:min="0"
:max="300"
:step="5"
:marks="{
0: '不消失',
60: '1分钟',
300: '5分钟'
}"
/>
<NSpace justify="space-between">
<NButton
v-for="option in autoDisappearOptions"
:key="option.value"
size="small"
:type="danmakuWindow.danmakuWindowSetting.autoDisappearTime === option.value ? 'primary' : 'default'"
@click="danmakuWindow.danmakuWindowSetting.autoDisappearTime = option.value"
>
{{ option.label }}
</NButton>
</NSpace>
<NText
v-if="danmakuWindow.danmakuWindowSetting.autoDisappearTime > 0"
depth="3"
>
弹幕将在 {{ danmakuWindow.danmakuWindowSetting.autoDisappearTime }} 秒后自动消失
</NText>
<NText
v-else
depth="3"
>
弹幕不会自动消失
</NText>
</NSpace>
</NFormItem>
</NGi>
</NGrid>
</NTabPane>
<!-- 添加新的设置选项卡高级设置 -->
<NTabPane
name="advanced"
tab="高级设置"
>
<NGrid
:cols="1"
:y-gap="4"
>
<NGi>
<NDivider>调试选项</NDivider>
<NSpace vertical>
<NButton
type="info"
@click="danmakuWindow.sendTestDanmaku && danmakuWindow.sendTestDanmaku()"
>
发送测试弹幕
</NButton>
<NButton
type="warning"
@click="danmakuWindow.clearAllDanmaku && danmakuWindow.clearAllDanmaku()"
>
清空弹幕
</NButton>
<NFlex>
<NText>
当前表情数据:
Inline: {{ Object.keys(danmakuWindow.emojiData.data.inline).length }}
<br>
Plain: {{ Object.keys(danmakuWindow.emojiData.data.plain).length }}
</NText>
<NButton
@click="async () => {
await danmakuWindow.getEmojiData()
message.success('表情数据已重新加载')
}"
>
<template #icon>
<NIcon :component="ResizeTable24Filled" />
</template>
重新加载表情数据
</NButton>
</NFlex>
</NSpace>
</NGi>
</NGrid>
</NTabPane>
</NTabs>
@@ -393,4 +534,22 @@ async function resetPosition() {
z-index: 9999;
background-color: rgba(245, 108, 108, 0.1);
}
/* 添加一些美化样式 */
:deep(.n-tabs-tab) {
padding: 12px 20px;
}
:deep(.n-divider) {
margin: 12px 0;
}
.n-alert {
margin-bottom: 16px;
}
:deep(.n-slider) {
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { EventDataTypes, EventModel } from '@/api/api-models';
import { DanmakuWindowSettings, useDanmakuWindow } from '../../store/useDanmakuWindow';
import { computed } from 'vue';
import { AVATAR_URL } from '@/data/constants';
import { GetGuardColor } from '@/Utils';
export interface BaseDanmakuItemProps {
item: EventModel & { randomId: string; isNew?: boolean; disappearAt?: number; };
setting: DanmakuWindowSettings;
}
const props = defineProps<BaseDanmakuItemProps>();
const emojiData = useDanmakuWindow().emojiData;
// 检查弹幕是否将要消失
const isDisappearing = computed(() => {
return props.item.disappearAt && Date.now() > props.item.disappearAt - 300; // 提前300ms进入消失动画
});
// 计算SC弹幕的颜色类
const scColorClass = computed(() => {
if (props.item.type === EventDataTypes.SC) {
const price = props.item?.price || 0;
if (price === 0) return 'sc-0';
if (price > 0 && price < 50) return 'sc-50';
if (price >= 50 && price < 100) return 'sc-100';
if (price >= 100 && price < 500) return 'sc-500';
if (price >= 500 && price < 1000) return 'sc-1000';
if (price >= 1000 && price < 2000) return 'sc-2000';
if (price >= 2000) return 'sc-max';
}
return '';
});
// 根据类型计算样式
const typeClass = computed(() => {
switch (props.item.type) {
case EventDataTypes.Message: return 'message-item';
case EventDataTypes.Gift: return 'gift-item';
case EventDataTypes.SC: return `sc-item ${scColorClass.value}`;
case EventDataTypes.Guard: return 'guard-item';
case EventDataTypes.Enter: return 'enter-item';
default: return '';
}
});
// 获取舰长颜色
const guardColor = computed(() => GetGuardColor(props.item.guard_level));
// 舰长样式类
const guardLevelClass = computed(() => {
if (props.item.type === EventDataTypes.Guard) {
return `guard-level-${props.item.guard_level || 0}`;
}
return '';
});
// 检查是否需要显示头像
const showAvatar = computed(() => props.setting.showAvatar);
// 解析包含内联表情的消息
const parsedMessage = computed<{ type: 'text' | 'emoji'; content?: string; url?: string; name?: string; }[]>(() => {
// 仅处理非纯表情的普通消息
if (props.item.type !== EventDataTypes.Message || props.item.emoji || !props.item.msg) {
return [];
}
const segments: { type: 'text' | 'emoji'; content?: string; url?: string; name?: string; }[] = [];
let lastIndex = 0;
const regex = /\[([^\]]+)\]/g; // 匹配 [表情名]
let match;
try {
const availableEmojis = emojiData.data || {}; // 确保 emojiData 已加载
while ((match = regex.exec(props.item.msg)) !== null) {
// 添加表情前的文本部分
if (match.index > lastIndex) {
segments.push({ type: 'text', content: props.item.msg.substring(lastIndex, match.index) });
}
const emojiFullName = match[0]; // 完整匹配,例如 "[哈哈]"
const emojiInfo = availableEmojis.inline[emojiFullName] || availableEmojis.plain[emojiFullName];
if (emojiInfo) {
// 找到了表情
segments.push({ type: 'emoji', url: emojiInfo, name: emojiFullName });
} else {
// 未找到表情,当作普通文本处理
segments.push({ type: 'text', content: emojiFullName });
}
lastIndex = regex.lastIndex;
}
// 添加最后一个表情后的文本部分
if (lastIndex < props.item.msg.length) {
segments.push({ type: 'text', content: props.item.msg.substring(lastIndex) });
}
} catch (error) {
console.error("Error parsing message for emojis:", error);
// 解析出错时,返回原始文本
return [{ type: 'text', content: props.item.msg }];
}
// 如果解析后为空(例如,消息只包含无法识别的[]),则返回原始文本
if (segments.length === 0 && props.item.msg) {
return [{ type: 'text', content: props.item.msg }];
}
return segments;
});
// 获取不同类型消息的显示标签
const typeLabel = computed(() => {
switch (props.item.type) {
case EventDataTypes.Message: return ''; // 普通消息不需要标签
case EventDataTypes.Gift: return '【礼物】';
case EventDataTypes.SC: return '【SC】';
case EventDataTypes.Guard: return '【舰长】';
case EventDataTypes.Enter: return '【进场】';
default: return '';
}
});
// 获取礼物或SC的价格文本
const priceText = computed(() => {
if (props.item.type === EventDataTypes.SC ||
(props.item.type === EventDataTypes.Gift && props.item.price > 0)) {
return `${props.item.price || 0}`;
}
return '';
});
// 获取用户名显示
const displayName = computed(() => {
return props.item.name || '匿名用户';
});
// 获取消息显示内容
const displayContent = computed(() => {
switch (props.item.type) {
case EventDataTypes.Message:
return props.item.msg || '';
case EventDataTypes.Gift:
return `${props.item.num || 1} × ${props.item.msg}`;
case EventDataTypes.SC:
return props.item.msg || '';
case EventDataTypes.Guard:
return props.item.msg || '开通了舰长';
case EventDataTypes.Enter:
return '进入了直播间';
default:
return '';
}
});
// 根据风格及类型获取文本颜色
const textModeColor = computed(() => {
if (props.item.type === EventDataTypes.SC) {
return '#FFD700'; // SC消息金色
} else if (props.item.type === EventDataTypes.Gift) {
return '#FF69B4'; // 礼物消息粉色
} else if (props.item.type === EventDataTypes.Guard) {
return guardColor.value; // 舰长消息使用舰长颜色
} else if (props.item.type === EventDataTypes.Enter) {
return '#67C23A'; // 入场消息绿色
}
return undefined; // 普通消息使用默认颜色
});
// 向外导出所有计算属性
defineExpose({
isDisappearing,
scColorClass,
typeClass,
guardColor,
guardLevelClass,
showAvatar,
parsedMessage,
typeLabel,
priceText,
displayName,
displayContent,
textModeColor
});
</script>
<template>
<slot />
</template>
<style scoped>
/* 公共基础动画样式 */
@keyframes danmaku-out {
from {
opacity: var(--dw-opacity);
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(-30px);
}
}
</style>

View File

@@ -0,0 +1,407 @@
<script setup lang="ts">
import { EventDataTypes } from '@/api/api-models';
import { AVATAR_URL } from '@/data/constants';
import { BaseDanmakuItemProps, useDanmakuUtils } from './danmakuUtils';
import { useDanmakuWindow } from '../../store/useDanmakuWindow';
import { VehicleShip24Filled } from '@vicons/fluent';
// 继承基础属性
const props = defineProps<BaseDanmakuItemProps>();
// 使用工具函数获取所有计算属性
const emojiData = useDanmakuWindow().emojiData;
const danmakuUtils = useDanmakuUtils(props, emojiData);
// 直接从工具函数获取计算属性不再需要getBaseProp方法
const {
typeClass,
guardLevelClass,
showAvatar,
guardColor,
scColorClass,
parsedMessage
} = danmakuUtils;
</script>
<template>
<!-- 卡片样式SC礼物上舰 -->
<template
v-if="item.type === EventDataTypes.SC || item.type === EventDataTypes.Gift || item.type === EventDataTypes.Guard"
>
<div
class="danmaku-card"
:class="[typeClass, guardLevelClass]"
>
<div class="card-header">
<img
v-if="showAvatar && item?.uface"
:src="item?.uface + (item.uface.startsWith(AVATAR_URL) ? '?size=64' : '@64w')"
alt="avatar"
class="avatar"
referrerpolicy="no-referrer"
>
<span
class="username"
:style="{ color: item.type === EventDataTypes.SC ? '#222' : '#fff' }"
>
{{ item?.name || '匿名用户' }}
</span>
<!-- 卡片右侧徽章 -->
<template v-if="item.type === EventDataTypes.SC">
<span
class="sc-badge"
:class="scColorClass"
>
{{ item?.price || 0 }}
</span>
</template>
<template v-else-if="item.type === EventDataTypes.Gift">
<span class="gift-badge">
{{ item?.num || 1 }} × {{ item?.msg }}
<span
v-if="item?.price"
class="gift-price"
>{{ (item.price || 0).toFixed(2) }}</span>
</span>
</template>
<template v-else-if="item.type === EventDataTypes.Guard">
<span
class="guard-badge"
:style="{ backgroundColor: guardColor }"
>
{{ item?.guard_level === 1 ? '总督' : item?.guard_level === 2 ? '提督' : '舰长' }}
<span
v-if="item?.num && item?.num > 1"
class="guard-num"
>x{{ item?.num }}</span>
</span>
</template>
</div>
<div
v-if="item.type === EventDataTypes.SC && item?.msg"
class="card-content"
>
<span class="sc-content">{{ item?.msg }}</span>
</div>
</div>
</template>
<!-- 普通消息/入场消息改为卡片样式以保持一致性 -->
<template v-else>
<div class="danmaku-card message-card">
<div class="card-header">
<img
v-if="showAvatar && item?.uface"
:src="item?.uface + (item.uface.startsWith(AVATAR_URL) ? '?size=64' : '@64w')"
alt="avatar"
class="avatar"
referrerpolicy="no-referrer"
>
<span
class="username"
:style="{ color: '#fff' }"
>
{{ item?.name || '匿名用户' }}
</span>
<span
v-if="item.guard_level && item.guard_level > 0"
class="guard-icon"
:style="{ backgroundColor: guardColor }"
>
<NIcon
:component="VehicleShip24Filled"
size="12"
/>
</span>
<template v-if="item.type === EventDataTypes.Enter">
<span class="enter-badge">进入了直播间</span>
</template>
</div>
<div
v-if="item.type === EventDataTypes.Message && (item?.msg || parsedMessage.length > 0 || item.emoji)"
class="card-content"
>
<span
v-if="!item.emoji && parsedMessage.length > 0"
class="message-text"
>
<template
v-for="(segment, index) in parsedMessage"
:key="index"
>
<span v-if="segment.type === 'text'">{{ segment.content }}</span>
<img
v-else-if="segment.type === 'emoji'"
:src="segment.url + '@64w'"
:alt="segment.name"
class="inline-emoji"
referrerpolicy="no-referrer"
>
</template>
</span>
<span
v-else-if="item.emoji"
class="message-text"
>
<img
:src="item.emoji + '@64w'"
alt="emoji"
class="emoji-image"
referrerpolicy="no-referrer"
>
</span>
<span
v-else
class="message-text"
>{{ item?.msg }}</span>
</div>
</div>
</template>
</template>
<style scoped>
/* 头像 */
.avatar {
flex-shrink: 0;
border-radius: 50%;
margin-right: 6px;
width: var(--dw-avatar-size);
height: var(--dw-avatar-size);
object-fit: cover;
vertical-align: middle;
}
/* 用户名 */
.username {
font-weight: bold;
margin-right: 6px;
word-break: keep-all;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
flex-shrink: 0;
vertical-align: middle;
}
/* 内联表情 */
.inline-emoji {
vertical-align: middle;
height: calc(var(--dw-font-size) * 1.4);
margin: 0 1px;
}
/* 纯表情消息 */
.emoji-image {
vertical-align: middle;
height: var(--dw-emoji-size, 32px);
}
/* --- 卡片样式 --- */
.danmaku-card {
border-radius: var(--dw-border-radius, 8px);
padding: 6px 10px;
margin: 2px 0;
display: flex;
flex-direction: column;
width: 100%;
border-left: 3px solid transparent;
background-color: rgba(0, 0, 0, calc(0.6 * var(--dw-opacity, 1)));
transition: background-color 0.2s;
box-sizing: border-box; /* 确保padding不会增加元素的实际尺寸 */
}
/* SC 卡片 */
.sc-item .danmaku-card {
border-left-color: #E6A23C;
background: linear-gradient(
90deg,
rgba(255, 243, 224, calc(0.85 * var(--dw-opacity, 1))) 0%,
rgba(255, 224, 178, calc(0.85 * var(--dw-opacity, 1))) 100%
);
}
.sc-item .username {
color: #A0522D;
}
.sc-item .card-content {
color: #A0522D;
}
/* 礼物 卡片 */
.gift-item .danmaku-card {
border-left-color: #F56C6C;
background: linear-gradient(
90deg,
rgba(255, 234, 234, calc(0.85 * var(--dw-opacity, 1))) 0%,
rgba(255, 240, 240, calc(0.85 * var(--dw-opacity, 1))) 100%
);
/* 礼物卡片垂直居中 */
justify-content: center;
min-height: 40px;
}
.gift-item .username {
color: #C04848;
}
/* 上舰 卡片 */
.guard-item .danmaku-card {
border-left-color: var(--guard-color, #673AB7);
background: linear-gradient(
90deg,
rgba(243, 234, 255, calc(0.85 * var(--dw-opacity, 1))) 0%,
rgba(237, 231, 246, calc(0.85 * var(--dw-opacity, 1))) 100%
);
}
.guard-item .username {
color: var(--guard-color, #673AB7);
}
.card-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
min-height: var(--dw-avatar-size);
}
/* 礼物卡片头部特殊处理 */
.gift-item .card-header {
margin-bottom: 0;
}
.card-content {
font-size: 0.95em;
word-break: break-word;
margin-left: calc(var(--dw-avatar-size) + 6px);
line-height: 1.4;
}
/* SC 徽章 */
.sc-badge {
font-weight: bold;
font-size: 0.85em;
border-radius: 4px;
padding: 1px 6px;
color: #fff;
margin-left: auto;
background: #E6A23C;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
white-space: nowrap;
}
/* SC 不同价格颜色 */
.sc-50 {
background: #E6A23C;
}
.sc-100 {
background: #F56C6C;
}
.sc-500 {
background: #f56c6c;
}
.sc-1000 {
background: #d32f2f;
}
.sc-2000 {
background: #7b1fa2;
}
.sc-max {
background: #212121;
}
/* 礼物 徽章 */
.gift-badge {
background: #F56C6C;
color: #fff;
border-radius: 4px;
padding: 1px 6px;
font-weight: bold;
font-size: 0.85em;
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.gift-price {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
padding: 0 4px;
font-size: 0.9em;
margin-left: 4px;
}
/* 上舰 徽章 */
.guard-badge {
color: #fff;
font-weight: bold;
border-radius: 4px;
padding: 1px 6px;
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
font-size: 0.85em;
white-space: nowrap;
}
.guard-num {
font-size: 0.9em;
opacity: 0.8;
margin-left: 2px;
}
/* --- 极简单行弹幕 --- */
.danmaku-simple-row {
display: none;
}
/* --- 普通消息卡片样式 --- */
.message-card {
border-left-color: #409EFF;
background: linear-gradient(
90deg,
rgba(40, 40, 40, calc(0.85 * var(--dw-opacity, 1))) 0%,
rgba(30, 30, 30, calc(0.85 * var(--dw-opacity, 1))) 100%
);
}
.guard-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 4px;
margin-left: 4px;
flex-shrink: 0;
}
.enter-badge {
color: #67C23A;
font-size: 0.85em;
font-weight: 500;
padding: 1px 6px;
background-color: rgba(103, 194, 58, 0.1);
border-radius: 4px;
margin-left: auto;
white-space: nowrap;
}
.message-text {
color: var(--dw-text-color);
word-break: break-all;
white-space: normal;
}
</style>

View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
import { AVATAR_URL } from '@/data/constants';
import { BaseDanmakuItemProps, useDanmakuUtils } from './danmakuUtils';
import { useDanmakuWindow } from '../../store/useDanmakuWindow';
// 继承基础属性
const props = defineProps<BaseDanmakuItemProps>();
// 使用工具函数获取所有计算属性
const emojiData = useDanmakuWindow().emojiData;
const danmakuUtils = useDanmakuUtils(props, emojiData);
// 直接从工具函数获取计算属性不再需要getBaseProp方法
const {
showAvatar,
typeLabel,
guardColor,
displayName,
displayContent,
priceText,
textModeColor,
parsedMessage
} = danmakuUtils;
</script>
<template>
<div
class="danmaku-text-mode"
:class="{ 'compact': setting.textStyleCompact }"
>
<!-- 头像 -->
<img
v-if="showAvatar && item?.uface"
:src="item?.uface + (item.uface.startsWith(AVATAR_URL) ? '?size=64' : '@64w')"
alt="avatar"
class="avatar-text-mode"
referrerpolicy="no-referrer"
>
<!-- 消息类型标签 -->
<span
v-if="setting.textStyleShowType && typeLabel"
class="text-mode-type"
:style="{ color: textModeColor }"
>{{ typeLabel }}</span>
<!-- 舰长标识 -->
<span
v-if="item.guard_level && item.guard_level > 0 && setting.showGuardIcon"
class="guard-icon-text-mode"
:style="{ backgroundColor: guardColor }"
/>
<!-- 用户名 -->
<span
v-if="setting.showUsername"
class="username-text-mode"
:style="{ color: textModeColor }"
>{{ displayName }}</span>
<!-- 分隔符 -->
<span
v-if="setting.showUsername && displayContent"
class="separator-text-mode"
>{{ setting.textStyleNameSeparator }}</span>
<!-- 价格信息(如果有) -->
<span
v-if="priceText"
class="price-text-mode"
:style="{ color: textModeColor }"
>{{ priceText }} </span>
<!-- 消息内容 -->
<template v-if="item.type === 0">
<span
v-if="!item.emoji && parsedMessage.length > 0"
class="content-text-mode"
>
<template
v-for="(segment, index) in parsedMessage"
:key="index"
>
<span v-if="segment.type === 'text'">{{ segment.content }}</span>
<img
v-else-if="segment.type === 'emoji'"
:src="segment.url + '@64w'"
:alt="segment.name"
class="inline-emoji-text-mode"
referrerpolicy="no-referrer"
>
</template>
</span>
<span
v-else-if="item.emoji"
class="content-text-mode"
>
<img
:src="item.emoji + '@64w'"
alt="emoji"
class="emoji-image-text-mode"
referrerpolicy="no-referrer"
>
</span>
<span
v-else
class="content-text-mode"
>{{ displayContent }}</span>
</template>
<span
v-else
class="content-text-mode"
>{{ displayContent }}</span>
</div>
</template>
<style scoped>
/* --- 纯文本风格样式 --- */
.danmaku-text-mode {
display: flex;
align-items: center;
flex-wrap: wrap;
width: 100%;
padding: 4px 8px;
background-color: rgba(var(--dw-bg-color-rgb, 0, 0, 0), calc(0.6 * var(--dw-opacity, 1)));
border-radius: var(--dw-border-radius);
line-height: 1.4;
gap: 2px;
box-sizing: border-box; /* 确保padding不会增加元素的实际尺寸 */
margin-bottom: 1px; /* 减少底部边距,防止溢出 */
}
.danmaku-text-mode.compact {
padding: 2px 6px;
line-height: 1.2;
}
.avatar-text-mode {
border-radius: 50%;
width: calc(var(--dw-font-size) * 1.5);
height: calc(var(--dw-font-size) * 1.5);
margin-right: 4px;
flex-shrink: 0;
}
.text-mode-type {
font-weight: bold;
margin-right: 4px;
flex-shrink: 0;
opacity: 1; /* 确保文本完全不透明 */
}
.guard-icon-text-mode {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 4px;
flex-shrink: 0;
}
.username-text-mode {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
flex-shrink: 0;
opacity: 1; /* 确保文本完全不透明 */
}
.separator-text-mode {
white-space: nowrap;
margin-right: 4px;
flex-shrink: 0;
opacity: 1; /* 确保文本完全不透明 */
}
.price-text-mode {
font-weight: bold;
margin-right: 4px;
flex-shrink: 0;
opacity: 1; /* 确保文本完全不透明 */
}
.content-text-mode {
word-break: break-word;
flex-grow: 1;
opacity: 1; /* 确保文本完全不透明 */
}
.inline-emoji-text-mode {
vertical-align: middle;
height: calc(var(--dw-font-size) * 1.2);
margin: 0 1px;
}
.emoji-image-text-mode {
vertical-align: middle;
height: var(--dw-emoji-size);
}
</style>

View File

@@ -0,0 +1,188 @@
import { EventDataTypes, EventModel } from '@/api/api-models';
import { DanmakuWindowSettings } from '../../store/useDanmakuWindow';
import { computed, ComputedRef } from 'vue';
import { GetGuardColor } from '@/Utils';
export interface BaseDanmakuItemProps {
item: EventModel & { randomId: string; isNew?: boolean; disappearAt?: number; };
setting: DanmakuWindowSettings;
}
export function useDanmakuUtils(
props: BaseDanmakuItemProps,
emojiData: { data: { inline: { [key: string]: string }; plain: { [key: string]: string } } }
) {
// 计算SC弹幕的颜色类
const scColorClass = computed(() => {
if (props.item.type === EventDataTypes.SC) {
const price = props.item?.price || 0;
if (price === 0) return 'sc-0';
if (price > 0 && price < 50) return 'sc-50';
if (price >= 50 && price < 100) return 'sc-100';
if (price >= 100 && price < 500) return 'sc-500';
if (price >= 500 && price < 1000) return 'sc-1000';
if (price >= 1000 && price < 2000) return 'sc-2000';
if (price >= 2000) return 'sc-max';
}
return '';
});
// 根据类型计算样式
const typeClass = computed(() => {
switch (props.item.type) {
case EventDataTypes.Message: return 'message-item';
case EventDataTypes.Gift: return 'gift-item';
case EventDataTypes.SC: return `sc-item ${scColorClass.value}`;
case EventDataTypes.Guard: return 'guard-item';
case EventDataTypes.Enter: return 'enter-item';
default: return '';
}
});
// 获取舰长颜色
const guardColor = computed(() => GetGuardColor(props.item.guard_level));
// 舰长样式类
const guardLevelClass = computed(() => {
if (props.item.type === EventDataTypes.Guard) {
return `guard-level-${props.item.guard_level || 0}`;
}
return '';
});
// 检查是否需要显示头像
const showAvatar = computed(() => props.setting.showAvatar);
// 解析包含内联表情的消息
const parsedMessage = computed<{ type: 'text' | 'emoji'; content?: string; url?: string; name?: string; }[]>(() => {
// 仅处理非纯表情的普通消息
if (props.item.type !== EventDataTypes.Message || props.item.emoji || !props.item.msg) {
return [];
}
const segments: { type: 'text' | 'emoji'; content?: string; url?: string; name?: string; }[] = [];
let lastIndex = 0;
const regex = /\[([^\]]+)\]/g; // 匹配 [表情名]
let match;
try {
const availableEmojis = emojiData.data || {}; // 确保 emojiData 已加载
while ((match = regex.exec(props.item.msg)) !== null) {
// 添加表情前的文本部分
if (match.index > lastIndex) {
segments.push({ type: 'text', content: props.item.msg.substring(lastIndex, match.index) });
}
const emojiFullName = match[0]; // 完整匹配,例如 "[哈哈]"
const emojiInfo = availableEmojis.inline[emojiFullName] || availableEmojis.plain[emojiFullName];
if (emojiInfo) {
// 找到了表情
segments.push({ type: 'emoji', url: emojiInfo, name: emojiFullName });
} else {
// 未找到表情,当作普通文本处理
segments.push({ type: 'text', content: emojiFullName });
}
lastIndex = regex.lastIndex;
}
// 添加最后一个表情后的文本部分
if (lastIndex < props.item.msg.length) {
segments.push({ type: 'text', content: props.item.msg.substring(lastIndex) });
}
} catch (error) {
console.error("Error parsing message for emojis:", error);
// 解析出错时,返回原始文本
return [{ type: 'text', content: props.item.msg }];
}
// 如果解析后为空(例如,消息只包含无法识别的[]),则返回原始文本
if (segments.length === 0 && props.item.msg) {
return [{ type: 'text', content: props.item.msg }];
}
return segments;
});
// 获取不同类型消息的显示标签
const typeLabel = computed(() => {
switch (props.item.type) {
case EventDataTypes.Message: return ''; // 普通消息不需要标签
case EventDataTypes.Gift: return '【礼物】';
case EventDataTypes.SC: return '【SC】';
case EventDataTypes.Guard: return '【舰长】';
case EventDataTypes.Enter: return '【进场】';
default: return '';
}
});
// 获取礼物或SC的价格文本
const priceText = computed(() => {
if (props.item.type === EventDataTypes.SC ||
(props.item.type === EventDataTypes.Gift && props.item.price > 0)) {
return `${props.item.price || 0}`;
}
return '';
});
// 获取用户名显示
const displayName = computed(() => {
return props.item.name || '匿名用户';
});
// 获取消息显示内容
const displayContent = computed(() => {
switch (props.item.type) {
case EventDataTypes.Message:
return props.item.msg || '';
case EventDataTypes.Gift:
return `${props.item.num || 1} × ${props.item.msg}`;
case EventDataTypes.SC:
return props.item.msg || '';
case EventDataTypes.Guard:
return props.item.msg || '开通了舰长';
case EventDataTypes.Enter:
return '进入了直播间';
default:
return '';
}
});
// 根据风格及类型获取文本颜色
const textModeColor = computed(() => {
if (props.item.type === EventDataTypes.SC) {
return '#FFD700'; // SC消息金色
} else if (props.item.type === EventDataTypes.Gift) {
return '#FF69B4'; // 礼物消息粉色
} else if (props.item.type === EventDataTypes.Guard) {
return guardColor.value; // 舰长消息使用舰长颜色
} else if (props.item.type === EventDataTypes.Enter) {
return '#67C23A'; // 入场消息绿色
}
return undefined; // 普通消息使用默认颜色
});
return {
scColorClass,
typeClass,
guardColor,
guardLevelClass,
showAvatar,
parsedMessage,
typeLabel,
priceText,
displayName,
displayContent,
textModeColor
};
}
// 返回类型定义便于TypeScript类型推断
export type DanmakuUtils = ReturnType<typeof useDanmakuUtils>;
// 类型别名,用于清晰表达每个计算属性的类型
export type ComputedDanmakuUtils = {
[K in keyof DanmakuUtils]: ComputedRef<any>
};

View File

@@ -20,6 +20,7 @@ import { invoke } from "@tauri-apps/api/core";
import { check } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { useDanmakuWindow } from "../store/useDanmakuWindow";
import { getAllWebviewWindows } from "@tauri-apps/api/webviewWindow";
const accountInfo = useAccount();
@@ -117,6 +118,23 @@ export async function initAll(isOnBoot: boolean) {
appWindow.setMinSize(new PhysicalSize(720, 480));
getAllWebviewWindows().then(async (windows) => {
const w = windows.find((win) => win.label === 'danmaku-window')
if (w) {
const useWindow = useDanmakuWindow();
useWindow.init();
if ((useWindow.emojiData?.updateAt ?? 0) < Date.now() - 1000 * 60 * 60 * 24) {
await useWindow.getEmojiData();
}
if (await w.isVisible()) {
//useWindow.isDanmakuWindowOpen = true;
console.log('弹幕窗口已打开');
}
}
});
// 监听f12事件
if (!isDev) {
window.addEventListener('keydown', (event) => {
@@ -135,7 +153,7 @@ export function OnClientUnmounted() {
}
tray.close();
useDanmakuWindow().closeWindow()
//useDanmakuWindow().closeWindow();
}
async function checkUpdate() {

View File

@@ -1,4 +1,6 @@
import { EventDataTypes, EventModel } from "@/api/api-models";
import { EventDataTypes, EventModel, GuardLevel } from "@/api/api-models";
import { QueryGetAPI } from "@/api/query";
import { VTSURU_API_URL } from "@/data/constants";
import { useDanmakuClient } from "@/store/useDanmakuClient";
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
import { getAllWebviewWindows, WebviewWindow } from "@tauri-apps/api/webviewWindow";
@@ -26,6 +28,11 @@ export type DanmakuWindowSettings = {
itemSpacing: number; // 项目间距
enableShadow: boolean; // 是否启用阴影
shadowColor: string; // 阴影颜色
autoDisappearTime: number; // 单位0表示不自动消失
displayStyle: string; // 新增:显示风格,可选值:'card'(卡片风格), 'text'(纯文本风格)
textStyleCompact: boolean; // 新增:纯文本模式下是否使用紧凑布局
textStyleShowType: boolean; // 新增:纯文本模式下是否显示消息类型标签
textStyleNameSeparator: string; // 新增:纯文本模式下用户名和消息之间的分隔符
};
export const DANMAKU_WINDOW_BROADCAST_CHANNEL = 'channel.danmaku.window';
@@ -37,8 +44,107 @@ export type DanmakuWindowBCData = {
data: DanmakuWindowSettings;
} | {
type: 'window-ready';
} | {
type: 'clear-danmaku'; // 新增:清空弹幕消息
} | {
type: 'test-danmaku', // 新增:测试弹幕消息
data: EventModel;
};
// Helper function to generate random test data
function generateTestDanmaku(): EventModel {
const types = [
EventDataTypes.Message,
EventDataTypes.Gift,
EventDataTypes.SC,
EventDataTypes.Guard,
EventDataTypes.Enter,
];
const randomType = types[Math.floor(Math.random() * types.length)];
const randomUid = Math.floor(Math.random() * 1000000);
const randomName = `测试用户${randomUid % 100}`;
const randomTime = Date.now();
const randomOuid = `oid_${randomUid}`;
const baseEvent: Partial<EventModel> = {
name: randomName,
uface: `https://i0.hdslb.com/bfs/face/member/noface.jpg`, // Placeholder for user avatar
uid: randomUid,
open_id: randomOuid, // Assuming open_id is same as ouid for test
time: randomTime,
guard_level: Math.floor(Math.random() * 4) as GuardLevel,
fans_medal_level: Math.floor(Math.random() * 41),
fans_medal_name: '测试牌',
fans_medal_wearing_status: Math.random() > 0.5,
ouid: randomOuid,
};
switch (randomType) {
case EventDataTypes.Message:
return {
...baseEvent,
type: EventDataTypes.Message,
msg: `这是一条测试弹幕消息 ${Math.random().toString(36).substring(7)}`,
num: 0, // Not applicable
price: 0, // Not applicable
emoji: Math.random() > 0.8 ? '😀' : undefined, // Randomly add emoji
} as EventModel;
case EventDataTypes.Gift:
const giftNames = ['小花花', '辣条', '能量饮料', '小星星'];
const giftNums = [1, 5, 10];
const giftPrices = [100, 1000, 5000]; // Price in copper coins (100 = 0.1 yuan)
return {
...baseEvent,
type: EventDataTypes.Gift,
msg: giftNames[Math.floor(Math.random() * giftNames.length)],
num: giftNums[Math.floor(Math.random() * giftNums.length)],
price: giftPrices[Math.floor(Math.random() * giftPrices.length)],
} as EventModel;
case EventDataTypes.SC:
const scPrices = [30, 50, 100, 500, 1000, 2000]; // Price in yuan
return {
...baseEvent,
type: EventDataTypes.SC,
msg: `这是一条测试SC消息感谢老板`,
num: 1, // Not applicable
price: scPrices[Math.floor(Math.random() * scPrices.length)],
} as EventModel;
case EventDataTypes.Guard:
const guardLevels = [GuardLevel.Jianzhang, GuardLevel.Tidu, GuardLevel.Zongdu];
const guardPrices = {
[GuardLevel.Jianzhang]: 198,
[GuardLevel.Tidu]: 1998,
[GuardLevel.Zongdu]: 19998,
[GuardLevel.None]: 0, // Add missing GuardLevel.None case
};
const selectedGuardLevel = guardLevels[Math.floor(Math.random() * guardLevels.length)];
return {
...baseEvent,
type: EventDataTypes.Guard,
msg: `开通了${selectedGuardLevel === GuardLevel.Jianzhang ? '舰长' : selectedGuardLevel === GuardLevel.Tidu ? '提督' : '总督'}`,
num: 1, // Represents 1 month usually
price: guardPrices[selectedGuardLevel],
guard_level: selectedGuardLevel, // Ensure guard level matches
} as EventModel;
case EventDataTypes.Enter:
return {
...baseEvent,
type: EventDataTypes.Enter,
msg: '进入了直播间',
num: 0, // Not applicable
price: 0, // Not applicable
} as EventModel;
default: // Fallback to Message
return {
...baseEvent,
type: EventDataTypes.Message,
msg: `默认测试弹幕`,
num: 0,
price: 0,
} as EventModel;
}
}
export const useDanmakuWindow = defineStore('danmakuWindow', () => {
const danmakuWindow = ref<WebviewWindow>();
const danmakuWindowSetting = useStorage<DanmakuWindowSettings>('Setting.DanmakuWindow', {
@@ -52,7 +158,7 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
showFansMedal: true,
showGuardIcon: true,
fontSize: 14,
maxDanmakuCount: 50,
maxDanmakuCount: 30,
reverseOrder: false,
filterTypes: ["Message", "Gift", "SC", "Guard"],
animationDuration: 300,
@@ -63,7 +169,25 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
borderRadius: 8,
itemSpacing: 5,
enableShadow: true,
shadowColor: 'rgba(0,0,0,0.5)'
shadowColor: 'rgba(0,0,0,0.5)',
autoDisappearTime: 0, // 默认不自动消失
displayStyle: 'card', // 新增:默认使用卡片风格
textStyleCompact: false, // 新增:默认不使用紧凑布局
textStyleShowType: true, // 新增:默认显示消息类型标签
textStyleNameSeparator: ': ', // 新增:默认用户名和消息之间的分隔符为冒号+空格
});
const emojiData = useStorage<{
updateAt: number,
data: {
inline: { [key: string]: string; },
plain: { [key: string]: string; },
};
}>('Data.Emoji', {
updateAt: 0,
data: {
inline: {},
plain: {},
}
});
const danmakuClient = useDanmakuClient();
const isWindowOpened = ref(false);
@@ -77,6 +201,7 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
if (!isInited) {
init();
}
checkAndUseSetting(danmakuWindowSetting.value);
danmakuWindow.value?.show();
isWindowOpened.value = true;
}
@@ -105,15 +230,9 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
return;
}
console.log('打开弹幕窗口', danmakuWindow.value.label, danmakuWindowSetting.value);
danmakuWindow.value.onCloseRequested(() => {
danmakuWindow.value = undefined;
bc?.close();
bc = undefined;
});
await danmakuWindow.value.setIgnoreCursorEvents(false);
await danmakuWindow.value.show();
danmakuWindow.value.onCloseRequested(() => {
danmakuWindow.value.onCloseRequested((event) => {
event.preventDefault(); // 阻止默认关闭行为
closeWindow();
console.log('弹幕窗口关闭');
});
@@ -165,41 +284,97 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
type: 'update-setting',
data: toRaw(newValue.value),
});
if (newValue.value.alwaysOnTop) {
await danmakuWindow.value.setAlwaysOnTop(true);
}
else {
await danmakuWindow.value.setAlwaysOnTop(false);
}
if (newValue.value.interactive) {
await danmakuWindow.value.setIgnoreCursorEvents(true);
} else {
await danmakuWindow.value.setIgnoreCursorEvents(false);
}
await checkAndUseSetting(newValue.value);
}
}, { deep: true });
console.log('[danmaku-window] 初始化完成');
isInited = true;
}
async function checkAndUseSetting(setting: DanmakuWindowSettings) {
if (setting.alwaysOnTop) {
await danmakuWindow.value?.setAlwaysOnTop(true);
}
else {
await danmakuWindow.value?.setAlwaysOnTop(false);
}
if (setting.interactive) {
await danmakuWindow.value?.setIgnoreCursorEvents(true);
} else {
await danmakuWindow.value?.setIgnoreCursorEvents(false);
}
}
async function getEmojiData() {
try {
const resp = await QueryGetAPI<{
inline: { [key: string]: string; },
plain: { [key: string]: string; },
}>(VTSURU_API_URL + 'client/live-emoji');
if (resp.code == 200) {
emojiData.value = {
updateAt: Date.now(),
data: resp.data,
};
console.log(`已获取表情数据, 共 ${Object.keys(resp.data.inline).length + Object.keys(resp.data.plain).length}`, resp.data);
}
else {
console.error('获取表情数据失败:', resp.message);
}
} catch (error) {
console.error('无法获取表情数据:', error);
}
}
function onGetDanmakus(data: EventModel) {
bc?.postMessage({
if (!isWindowOpened.value || !bc) return;
bc.postMessage({
type: 'danmaku',
data,
});
}
// 新增:清空弹幕函数
function clearAllDanmaku() {
if (!isWindowOpened.value || !bc) {
console.warn('[danmaku-window] 窗口未打开或 BC 未初始化,无法清空弹幕');
return;
}
bc.postMessage({
type: 'clear-danmaku',
});
console.log('[danmaku-window] 发送清空弹幕指令');
}
// 新增:发送测试弹幕函数
function sendTestDanmaku() {
if (!isWindowOpened.value || !bc) {
console.warn('[danmaku-window] 窗口未打开或 BC 未初始化,无法发送测试弹幕');
return;
}
const testData = generateTestDanmaku();
bc.postMessage({
type: 'test-danmaku',
data: testData,
});
console.log('[danmaku-window] 发送测试弹幕指令:', testData);
}
return {
danmakuWindow,
danmakuWindowSetting,
emojiData,
setDanmakuWindowSize,
setDanmakuWindowPosition,
updateWindowPosition,
getEmojiData,
isDanmakuWindowOpen: isWindowOpened,
openWindow,
closeWindow,
init,
clearAllDanmaku, // 导出新函数
sendTestDanmaku, // 导出新函数
};
});

View File

@@ -152,8 +152,8 @@ export default abstract class BaseDanmakuClient {
console.warn(`[${this.type}] 弹幕客户端未被启动, 忽略停止操作`);
}
// 注意: 清空所有事件监听器
this.eventsAsModel = this.createEmptyEventModelListeners();
this.eventsRaw = this.createEmptyRawEventlisteners();
//this.eventsAsModel = this.createEmptyEventModelListeners();
//this.eventsRaw = this.createEmptyRawEventlisteners();
}
/**

View File

@@ -53,9 +53,8 @@ export default class DirectClient extends BaseDanmakuClient {
}
}
public onDanmaku(command: any): void {
const data = command.data;
const info = data.info;
this.eventsRaw?.danmaku?.forEach((d) => { d(data, command); });
const info = command.info;
this.eventsRaw?.danmaku?.forEach((d) => { d(info, command); });
this.eventsAsModel.danmaku?.forEach((d) => {
d(
{
@@ -89,7 +88,7 @@ export default class DirectClient extends BaseDanmakuClient {
name: data.uname,
uid: data.uid,
msg: data.giftName,
price: data.giftId,
price: data.price / 1000,
num: data.num,
time: Date.now(),
guard_level: data.guard_level,
@@ -171,7 +170,7 @@ export default class DirectClient extends BaseDanmakuClient {
fans_medal_level: data.fans_medal?.medal_level || 0,
fans_medal_name: data.fans_medal?.medal_name || '',
fans_medal_wearing_status: false,
uface: getUserAvatarUrl(data.uid),
uface: AVATAR_URL + data.uid,
open_id: '',
ouid: GuidUtils.numToGuid(data.uid)
},

View File

@@ -4,6 +4,23 @@ import { VNode } from "vue";
import { FETCH_API } from "./constants";
export const updateNotes: updateNoteType[] = [
{
ver: 3,
date: '2025.4.15',
items: [
{
type: 'new',
title: 'Tauri 客户端新增弹幕机功能',
content: [
[
'Tauri 客户端新增弹幕机功能, 可以在自己电脑上显示弹幕礼物等. ',
'客户端需更新至0.1.2版本, 重启客户端后会自动更新',
() => h(NImage, { src: 'https://pan.suki.club/d/vtsuru/imgur/81d76a89-96b8-44e9-be79-6caaa5741f64.png', width: 200 }),
]
],
},
]
},
{
ver: 2,
date: '2025.4.8',

View File

@@ -173,8 +173,10 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => {
*/
async function initClient(client: BaseDanmakuClient) { // 返回 Promise<boolean> 表示最终是否成功
// 防止重复初始化或在非等待状态下初始化
if (isInitializing || state.value !== 'waiting') {
console.warn(`[DanmakuClient] 初始化尝试被阻止。 isInitializing: ${isInitializing}, state: ${state.value}`);
if (isInitializing) {
while (isInitializing) {
await new Promise((resolve) => setTimeout(resolve, 100)); // 等待初始化完成
}
return useDanmakuClient(); // 如果已连接,则视为“成功”
}
@@ -217,7 +219,7 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => {
authInfo.value = danmakuClient.value instanceof OpenLiveClient ? danmakuClient.value.roomAuthInfo : undefined;
state.value = 'connected';
// 将 Store 中存储的监听器 (来自 onEvent) 附加到新连接的客户端的 eventsAsModel
console.log('[DanmakuClient] 初始化成功');
console.log('[DanmakuClient] 初始化成功');
connectSuccess = true;
return true; // 连接成功, 退出重试循环
} else {
@@ -293,7 +295,7 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => {
if (danmakuClient.value) {
await disposeClientInstance(danmakuClient.value);
danmakuClient.value = undefined; // 解除对旧客户端实例的引用
//danmakuClient.value = undefined; // 保留, 用户再次获取event
}
state.value = 'waiting'; // 重置状态为等待
authInfo.value = undefined; // 清理认证信息

View File

@@ -186,11 +186,10 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
return { success: false, message: '未提供弹幕客户端认证信息' };
}
await client.initDirect(directConnectInfo);
return { success: true, message: '弹幕客户端已启动' };
}
// 监听所有事件,用于处理和转发
client?.onEvent('all', onGetDanmakus);
client?.on('all', onGetDanmakus);
if (client.connected) {
console.log(prefix.value + '弹幕客户端连接成功, 开始监听弹幕');
@@ -262,7 +261,12 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
connection.on('Disconnect', (reason: unknown) => {
console.log(prefix.value + '被服务器断开连接: ' + reason);
disconnectedByServer = true; // 标记是服务器主动断开
Stop(); // 服务器要求断开,调用 Stop 清理所有资源
window.$message.error(`被服务器要求断开连接: ${reason}, 为保证可用性, 30秒后将自动重启`);
//Stop(); // 服务器要求断开,调用 Stop 清理所有资源
setTimeout(() => {
console.log(prefix.value + '尝试重启...');
connectSignalR(); // 30秒后尝试重启
}, 30 * 1000); // 30秒后自动重启
});
connection.on('Request', async (url: string, method: string, body: string, useCookie: boolean) => onRequest(url, method, body, useCookie));
connection.on('Notification', (type: string, data: any) => { onReceivedNotification(type, data); });

View File

@@ -1,32 +1,80 @@
<template>
<yt-live-chat-renderer class="style-scope yt-live-chat-app" style="--scrollbar-width:11px;" hide-timestamps
@mousemove="refreshCantScrollStartTime">
<ticker class="style-scope yt-live-chat-renderer" :messages.sync="paidMessages" :showGiftName="showGiftName || undefined">
</ticker>
<yt-live-chat-item-list-renderer class="style-scope yt-live-chat-renderer" allow-scroll>
<div ref="scroller" id="item-scroller" class="style-scope yt-live-chat-item-list-renderer animated"
@scroll="onScroll">
<div ref="itemOffset" id="item-offset" class="style-scope yt-live-chat-item-list-renderer">
<div ref="items" id="items" class="style-scope yt-live-chat-item-list-renderer" style="overflow: hidden"
:style="{ transform: `translateY(${Math.floor(scrollPixelsRemaining)}px)` }">
<template v-for="message in messages" :key="message.id">
<text-message v-if="message.type === MESSAGE_TYPE_TEXT"
class="style-scope yt-live-chat-item-list-renderer" :time="message.time" :avatarUrl="message.avatarUrl"
:authorName="message.authorName" :authorType="message.authorType" :privilegeType="message.privilegeType"
:richContent="getShowRichContent(message)" :repeated="message.repeated"></text-message>
<paid-message v-else-if="message.type === MESSAGE_TYPE_GIFT"
class="style-scope yt-live-chat-item-list-renderer" :time="message.time" :avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)" :price="message.price"
:priceText="message.price <= 0 ? getGiftShowNameAndNum(message) : ''"
:content="message.price <= 0 ? '' : getGiftShowContent(message, showGiftName)"></paid-message>
<membership-item v-else-if="message.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-item-list-renderer" :time="message.time" :avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)" :privilegeType="message.privilegeType"
:title="message.title"></membership-item>
<paid-message v-else-if="message.type === MESSAGE_TYPE_SUPER_CHAT"
class="style-scope yt-live-chat-item-list-renderer" :time="message.time" :avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)" :price="message.price"
:content="getShowContent(message)"></paid-message>
<yt-live-chat-renderer
class="style-scope yt-live-chat-app"
style="--scrollbar-width:11px;"
hide-timestamps
@mousemove="refreshCantScrollStartTime"
>
<ticker
v-model:messages="paidMessages"
class="style-scope yt-live-chat-renderer"
:show-gift-name="showGiftName || undefined"
/>
<yt-live-chat-item-list-renderer
class="style-scope yt-live-chat-renderer"
allow-scroll
>
<div
id="item-scroller"
ref="scroller"
class="style-scope yt-live-chat-item-list-renderer animated"
@scroll="onScroll"
>
<div
id="item-offset"
ref="itemOffset"
class="style-scope yt-live-chat-item-list-renderer"
>
<div
id="items"
ref="items"
class="style-scope yt-live-chat-item-list-renderer"
style="overflow: hidden"
:style="{ transform: `translateY(${Math.floor(scrollPixelsRemaining)}px)` }"
>
<template
v-for="message in messages"
:key="message.id"
>
<text-message
v-if="message.type === MESSAGE_TYPE_TEXT"
class="style-scope yt-live-chat-item-list-renderer"
:time="message.time"
:avatar-url="message.avatarUrl"
:author-name="message.authorName"
:author-type="message.authorType"
:privilege-type="message.privilegeType"
:rich-content="getShowRichContent(message)"
:repeated="message.repeated"
/>
<paid-message
v-else-if="message.type === MESSAGE_TYPE_GIFT"
class="style-scope yt-live-chat-item-list-renderer"
:time="message.time"
:avatar-url="message.avatarUrl"
:author-name="getShowAuthorName(message)"
:price="message.price"
:price-text="message.price <= 0 ? getGiftShowNameAndNum(message) : ''"
:content="message.price <= 0 ? '' : getGiftShowContent(message, showGiftName)"
/>
<membership-item
v-else-if="message.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-item-list-renderer"
:time="message.time"
:avatar-url="message.avatarUrl"
:author-name="getShowAuthorName(message)"
:privilege-type="message.privilegeType"
:title="message.title"
/>
<paid-message
v-else-if="message.type === MESSAGE_TYPE_SUPER_CHAT"
class="style-scope yt-live-chat-item-list-renderer"
:time="message.time"
:avatar-url="message.avatarUrl"
:author-name="getShowAuthorName(message)"
:price="message.price"
:content="getShowContent(message)"
/>
</template>
</div>
</div>
@@ -143,7 +191,7 @@ export default defineComponent({
mounted() {
this.scrollToBottom()
},
beforeDestroy() {
beforeUnmount() {
if (this.emitSmoothedMessageTimerId) {
window.clearTimeout(this.emitSmoothedMessageTimerId)
this.emitSmoothedMessageTimerId = null