mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
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:
68
src/client/ClientDanmakuItem.vue
Normal file
68
src/client/ClientDanmakuItem.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
208
src/client/components/danmaku/BaseDanmakuItem.vue
Normal file
208
src/client/components/danmaku/BaseDanmakuItem.vue
Normal 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>
|
||||
407
src/client/components/danmaku/CardStyleDanmakuItem.vue
Normal file
407
src/client/components/danmaku/CardStyleDanmakuItem.vue
Normal 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>
|
||||
202
src/client/components/danmaku/TextStyleDanmakuItem.vue
Normal file
202
src/client/components/danmaku/TextStyleDanmakuItem.vue
Normal 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>
|
||||
188
src/client/components/danmaku/danmakuUtils.ts
Normal file
188
src/client/components/danmaku/danmakuUtils.ts
Normal 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>
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
@@ -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, // 导出新函数
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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; // 清理认证信息
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user