Files
vtsuru.live/src/views/open_live/ReadDanmaku.vue

1141 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { EventModel } from '@/api/api-models'
import {
CheckmarkCircle20Filled,
Dismiss20Filled,
Info24Filled,
Mic24Filled,
MicOff24Filled,
Play20Filled,
Settings20Filled,
} from '@vicons/fluent'
import {
NAlert,
NButton,
NCard,
NCheckbox,
NCollapse,
NCollapseItem,
NDivider,
NEmpty,
NGrid,
NGi,
NIcon,
NInput,
NInputGroup,
NInputGroupLabel,
NInputNumber,
NList,
NListItem,
NPopconfirm,
NRadioButton,
NRadioGroup,
NScrollbar,
NSelect,
NSlider,
NSpace,
NSpin,
NStatistic,
NTag,
NText,
NTooltip,
useMessage,
} from 'naive-ui'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useAccount } from '@/api/account'
import { EventDataTypes } from '@/api/api-models'
import { useDanmakuClient } from '@/store/useDanmakuClient'
import { templateConstants, useSpeechService } from '@/store/useSpeechService'
import { copyToClipboard } from '@/Utils'
const props = defineProps<{
roomInfo?: any
code?: string | undefined
isOpenLive?: boolean
}>()
const message = useMessage()
const accountInfo = useAccount()
const client = await useDanmakuClient().initOpenlive()
const speechService = useSpeechService()
const {
settings,
speechState,
speakQueue,
readedDanmaku,
speechSynthesisInfo,
apiAudio,
} = speechService
// 计算属性
const isVtsuruVoiceAPI = computed(() => {
return (
settings.value.voiceType == 'api'
&& settings.value.voiceAPI?.toLowerCase().trim().startsWith('voice.vtsuru.live')
)
})
const voiceOptions = computed(() => {
return speechService.getAvailableVoices()
})
const queueStats = computed(() => {
const total = speakQueue.value.length
const gifts = speakQueue.value.filter(item => item.data.type === EventDataTypes.Gift).length
const messages = speakQueue.value.filter(item => item.data.type === EventDataTypes.Message).length
const waiting = speakQueue.value.filter(
item =>
item.data.type === EventDataTypes.Gift
&& settings.value.combineGiftDelay
&& item.updateAt > Date.now() - settings.value.combineGiftDelay * 1000,
).length
return { total, gifts, messages, waiting }
})
// 方法
function onGetEvent(data: EventModel) {
speechService.addToQueue(data)
}
function startSpeech() {
speechService.startSpeech()
}
function stopSpeech() {
speechService.stopSpeech()
}
function cancelSpeech() {
speechService.cancelSpeech()
}
function forceSpeak(data: EventModel) {
speechService.forceSpeak(data)
}
function removeFromQueue(item: any) {
speechService.removeFromQueue(item)
}
function clearQueue() {
speakQueue.value = []
message.success('队列已清空')
}
async function uploadConfig() {
await speechService.uploadConfig()
}
async function downloadConfig() {
await speechService.downloadConfig()
}
/**
* 创建测试事件数据
*/
function createTestEventData(type: EventDataTypes, overrides: Partial<EventModel>): EventModel {
const baseData = {
type,
uname: accountInfo.value?.name ?? '测试用户',
uid: accountInfo.value?.biliId ?? 0,
msg: '',
price: 0,
num: 0,
time: Date.now(),
guard_level: 0,
fans_medal_level: 1,
fans_medal_name: '',
fans_medal_wearing_status: false,
emoji: undefined,
uface: '',
open_id: '00000000-0000-0000-0000-000000000000',
ouid: '00000000-0000-0000-0000-000000000000',
}
return { ...baseData, ...overrides }
}
/**
* 测试不同类型的事件
*/
function test(type: EventDataTypes) {
let testData: EventModel
switch (type) {
case EventDataTypes.Message:
testData = createTestEventData(EventDataTypes.Message, { msg: '测试弹幕' })
break
case EventDataTypes.Enter:
testData = createTestEventData(EventDataTypes.Enter, {})
break
case EventDataTypes.SC:
testData = createTestEventData(EventDataTypes.SC, { msg: '测试留言', price: 30, num: 1 })
break
case EventDataTypes.Guard:
testData = createTestEventData(EventDataTypes.Guard, { msg: '舰长', num: 1, guard_level: 3 })
break
case EventDataTypes.Gift:
testData = createTestEventData(EventDataTypes.Gift, { msg: '测试礼物', price: 5, num: 5 })
break
default:
return
}
if (speechState.canSpeech) {
onGetEvent(testData)
} else {
forceSpeak(testData)
}
}
function testAPI() {
const url = speechService.buildApiUrl('这是一条测试弹幕')
if (url) {
speechState.isSpeaking = true
speechState.isApiAudioLoading = true
speechState.apiAudioSrc = url
}
}
function getEventTypeTag(type: EventDataTypes) {
switch (type) {
case EventDataTypes.Message:
return { text: '弹幕', type: 'info' as const }
case EventDataTypes.Gift:
return { text: '礼物', type: 'success' as const }
case EventDataTypes.Guard:
return { text: '舰长', type: 'warning' as const }
case EventDataTypes.SC:
return { text: 'SC', type: 'error' as const }
case EventDataTypes.Enter:
return { text: '进入', type: 'default' as const }
default:
return { text: '未知', type: 'default' as const }
}
}
function onAPIError(_e: Event) {
if (!speechState.apiAudioSrc) return
message.error('音频加载失败, 请检查API是否可用以及网络连接')
cancelSpeech()
}
// 生命周期
onMounted(async () => {
await speechService.initialize()
client.onEvent('danmaku', onGetEvent)
client.onEvent('sc', onGetEvent)
client.onEvent('guard', onGetEvent)
client.onEvent('gift', onGetEvent)
client.onEvent('enter', onGetEvent)
})
onUnmounted(() => {
client.offEvent('danmaku', onGetEvent)
client.offEvent('sc', onGetEvent)
client.offEvent('guard', onGetEvent)
client.offEvent('gift', onGetEvent)
client.offEvent('enter', onGetEvent)
speechService.stopSpeech()
})
</script>
<template>
<div class="read-danmaku-container">
<NAlert
v-if="!speechSynthesisInfo || !speechSynthesisInfo.speechSynthesis"
type="error"
title="不支持语音功能"
>
你的浏览器不支持语音功能请使用现代浏览器如 ChromeEdge
</NAlert>
<template v-else>
<!-- 顶部提示区域 -->
<NSpace
vertical
:size="12"
>
<NAlert
v-if="settings.voiceType == 'local'"
type="info"
closable
>
<template #icon>
<NIcon :component="Info24Filled" />
</template>
建议在 Edge 浏览器使用
<NTooltip>
<template #trigger>
<NText
strong
type="primary"
style="cursor: help"
>
Microsoft 某某 Online (Natural)
</NText>
</template>
例如 Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland)各种营销号就用的这些配音
</NTooltip>
系列语音效果<NText strong>好很多</NText>
</NAlert>
<NAlert
type="warning"
closable
>
<template #icon>
<NIcon :component="Info24Filled" />
</template>
<NText strong>重要</NText> 当在后台运行时请关闭浏览器的页面休眠/内存节省功能
<NDivider vertical />
<NButton
tag="a"
type="info"
href="https://support.google.com/chrome/answer/12929150?hl=zh-Hans"
target="_blank"
text
size="small"
>
Chrome 设置
</NButton>
<NButton
tag="a"
type="info"
href="https://support.microsoft.com/zh-cn/topic/%E4%BA%86%E8%A7%A3-microsoft-edge-%E4%B8%AD%E7%9A%84%E6%80%A7%E8%83%BD%E5%8A%9F%E8%83%BD-7b36f363-2119-448a-8de6-375cfd88ab25"
target="_blank"
text
size="small"
>
Edge 设置
</NButton>
</NAlert>
</NSpace>
<!-- 主控制区域 -->
<NCard
:bordered="false"
style="margin-top: 16px"
>
<NSpace
align="center"
justify="space-between"
:wrap="false"
>
<NSpace align="center">
<NButton
:type="speechState.canSpeech ? 'error' : 'primary'"
size="large"
:loading="speechState.isApiAudioLoading"
data-umami-event="Use TTS"
:data-umami-event-uid="accountInfo?.id"
@click="speechState.canSpeech ? stopSpeech() : startSpeech()"
>
<template #icon>
<NIcon :component="speechState.canSpeech ? MicOff24Filled : Mic24Filled" />
</template>
{{ speechState.canSpeech ? '停止监听' : '开始监听' }}
</NButton>
<NDivider vertical />
<NButton
:type="speechState.isSpeaking ? 'error' : 'default'"
:disabled="!speechState.isSpeaking"
@click="cancelSpeech"
>
<template #icon>
<NIcon :component="Dismiss20Filled" />
</template>
取消当前
</NButton>
<NButton
type="warning"
secondary
:disabled="speakQueue.length === 0"
@click="clearQueue"
>
<template #icon>
<NIcon :component="Dismiss20Filled" />
</template>
清空队列
</NButton>
</NSpace>
<NSpace align="center">
<NPopconfirm @positive-click="downloadConfig">
<template #trigger>
<NButton
type="primary"
secondary
size="small"
:disabled="!accountInfo"
>
<template #icon>
<NIcon :component="Settings20Filled" />
</template>
获取配置
</NButton>
</template>
这将覆盖当前设置确定
</NPopconfirm>
<NButton
type="primary"
secondary
size="small"
:disabled="!accountInfo"
@click="uploadConfig"
>
<template #icon>
<NIcon :component="CheckmarkCircle20Filled" />
</template>
保存配置
</NButton>
</NSpace>
</NSpace>
</NCard>
<!-- 状态统计区域 -->
<NCard
v-if="speechState.canSpeech"
title="实时状态"
:bordered="false"
style="margin-top: 16px"
>
<NGrid
:cols="4"
:x-gap="12"
:y-gap="12"
responsive="screen"
>
<NGi>
<NStatistic label="当前状态">
<template #prefix>
<NTooltip v-if="speechState.isApiAudioLoading">
<template #trigger>
<NSpin :size="20" />
</template>
加载中
</NTooltip>
<NIcon
v-else
:component="Mic24Filled"
:color="speechState.isSpeaking ? '#18a058' : '#d0d0d0'"
:size="20"
:style="`animation: ${speechState.isSpeaking ? 'pulse 2s infinite' : 'none'}`"
/>
</template>
<NText :type="speechState.isSpeaking ? 'success' : 'default'">
{{ speechState.isSpeaking ? '朗读中' : '待机' }}
</NText>
</NStatistic>
<NText
v-if="speechState.isSpeaking"
depth="3"
style="font-size: 12px; display: block; margin-top: 4px"
>
{{ speechState.speakingText }}
</NText>
</NGi>
<NGi>
<NStatistic
label="队列长度"
:value="queueStats.total"
>
<template #suffix>
<NText depth="3">
</NText>
</template>
</NStatistic>
</NGi>
<NGi>
<NStatistic
label="已读取"
:value="readedDanmaku"
>
<template #suffix>
<NText depth="3">
</NText>
</template>
</NStatistic>
</NGi>
<NGi>
<NStatistic label="队列分布">
<NSpace
:size="8"
style="margin-top: 4px"
>
<NTooltip v-if="queueStats.messages > 0">
<template #trigger>
<NTag
:bordered="false"
type="info"
size="small"
>
弹幕 {{ queueStats.messages }}
</NTag>
</template>
弹幕消息数量
</NTooltip>
<NTooltip v-if="queueStats.gifts > 0">
<template #trigger>
<NTag
:bordered="false"
type="success"
size="small"
>
礼物 {{ queueStats.gifts }}
</NTag>
</template>
礼物消息数量
</NTooltip>
<NTooltip v-if="queueStats.waiting > 0">
<template #trigger>
<NTag
:bordered="false"
type="warning"
size="small"
style="animation: pulse 2s infinite"
>
等待 {{ queueStats.waiting }}
</NTag>
</template>
等待合并的礼物
</NTooltip>
</NSpace>
</NStatistic>
</NGi>
</NGrid>
<!-- 队列详情 -->
<NDivider style="margin: 16px 0" />
<NCollapse>
<NCollapseItem
title="队列详情"
name="queue"
>
<template #header-extra>
<NTag
:bordered="false"
size="small"
>
{{ speakQueue.length }}
</NTag>
</template>
<NEmpty
v-if="speakQueue.length === 0"
description="队列为空"
size="small"
/>
<NScrollbar
v-else
style="max-height: 300px"
>
<NList
size="small"
bordered
>
<NListItem
v-for="(item, index) in speakQueue"
:key="`${item.data.time}-${index}`"
>
<NSpace
align="center"
:size="8"
>
<NButton
type="primary"
size="tiny"
circle
@click="forceSpeak(item.data)"
>
<template #icon>
<NIcon :component="Play20Filled" />
</template>
</NButton>
<NButton
type="error"
size="tiny"
circle
@click="removeFromQueue(item)"
>
<template #icon>
<NIcon :component="Dismiss20Filled" />
</template>
</NButton>
<NTag
v-if="item.data.type == EventDataTypes.Gift && item.combineCount"
type="info"
size="small"
:bordered="false"
style="animation: pulse 2s infinite"
>
连续赠送中
</NTag>
<NTag
v-else-if="item.data.type == EventDataTypes.Gift && settings.combineGiftDelay"
type="success"
size="small"
:bordered="false"
>
等待合并
</NTag>
<NTag
:type="getEventTypeTag(item.data.type).type"
size="small"
:bordered="false"
>
{{ getEventTypeTag(item.data.type).text }}
</NTag>
<NText strong>
{{ item.data.uname }}
</NText>
<NText depth="3">
{{ speechService.getTextFromDanmaku(item.data) }}
</NText>
</NSpace>
</NListItem>
</NList>
</NScrollbar>
</NCollapseItem>
</NCollapse>
</NCard>
<!-- 语音设置区域 -->
<NCard
title="语音设置"
:bordered="false"
style="margin-top: 16px"
>
<NSpace
vertical
:size="16"
>
<NRadioGroup
v-model:value="settings.voiceType"
size="large"
>
<NRadioButton value="local">
<NSpace :size="4">
<span>本地语音</span>
<NTooltip>
<template #trigger>
<NIcon
:component="Info24Filled"
:size="16"
/>
</template>
使用浏览器内置的语音合成功能
</NTooltip>
</NSpace>
</NRadioButton>
<NRadioButton value="api">
<NSpace :size="4">
<span>API 语音</span>
<NTooltip>
<template #trigger>
<NIcon
:component="Info24Filled"
:size="16"
/>
</template>
自定义语音API可以播放自己训练的模型或其他TTS
</NTooltip>
</NSpace>
</NRadioButton>
</NRadioGroup>
<Transition
name="fade"
mode="out-in"
>
<!-- 本地语音设置 -->
<NSpace
v-if="settings.voiceType === 'local'"
vertical
:size="16"
>
<div>
<NText strong>选择语音</NText>
<NSelect
v-model:value="settings.speechInfo.voice"
:options="voiceOptions"
:fallback-option="() => ({
label: settings.speechInfo.voice ? `已选择: ${settings.speechInfo.voice}` : '未选择, 将使用默认语音',
value: settings.speechInfo.voice || '',
})"
style="margin-top: 8px"
filterable
/>
</div>
<div>
<NSpace
justify="space-between"
align="center"
>
<NText>音量</NText>
<NText depth="3">
{{ (settings.speechInfo.volume * 100).toFixed(0) }}%
</NText>
</NSpace>
<NSlider
v-model:value="settings.speechInfo.volume"
:min="0"
:max="1"
:step="0.01"
style="margin-top: 8px"
/>
</div>
<div>
<NSpace
justify="space-between"
align="center"
>
<NText>音调</NText>
<NText depth="3">
{{ settings.speechInfo.pitch.toFixed(2) }}
</NText>
</NSpace>
<NSlider
v-model:value="settings.speechInfo.pitch"
:min="0"
:max="2"
:step="0.01"
style="margin-top: 8px"
/>
</div>
<div>
<NSpace
justify="space-between"
align="center"
>
<NText>语速</NText>
<NText depth="3">
{{ settings.speechInfo.rate.toFixed(2) }}
</NText>
</NSpace>
<NSlider
v-model:value="settings.speechInfo.rate"
:min="0"
:max="2"
:step="0.01"
style="margin-top: 8px"
/>
</div>
</NSpace>
<!-- API 语音设置 -->
<NSpace
v-else
vertical
:size="16"
>
<NCollapse>
<NCollapseItem
title="📖 使用说明"
name="requirements"
>
<NSpace
vertical
:size="8"
>
<NText>API 要求:</NText>
<ul style="margin: 0; padding-left: 24px">
<li>直接返回音频数据wav, mp3, m4a 等)</li>
<li>建议使用 HTTPSHTTP 将通过 Cloudflare Workers 代理,会较慢)</li>
<li>确保 API 可以被外部访问</li>
</ul>
<NDivider style="margin: 8px 0" />
<NText>推荐项目(可本地部署):</NText>
<NButton
text
type="info"
tag="a"
href="https://github.com/Artrajz/vits-simple-api"
target="_blank"
>
vits-simple-api
</NButton>
</NSpace>
</NCollapseItem>
</NCollapse>
<NAlert
v-if="isVtsuruVoiceAPI"
type="success"
closable
>
<template #icon>
<NIcon :component="Info24Filled" />
</template>
你正在使用本站提供的测试 API (voice.vtsuru.live)仅用于测试不保证可用性
</NAlert>
<NAlert type="info">
地址中的
<NButton
size="tiny"
type="primary"
text
@click="copyToClipboard('{{text}}')"
v-text="'{{ text }}'"
/>
将被替换为要念的文本
</NAlert>
<div>
<NText strong>API 地址</NText>
<NInputGroup style="margin-top: 8px">
<NSelect
v-model:value="settings.voiceAPISchemeType"
:options="[
{ label: 'https://', value: 'https' },
{ label: 'http://', value: 'http' },
]"
style="width: 110px"
/>
<NInput
v-model:value="settings.voiceAPI"
placeholder="例如: xxx.com/voice/bert-vits2?text={{text}}&id=0"
:status="/^(?:https?:\/\/)/.test(settings.voiceAPI?.toLowerCase() ?? '') ? 'error' : undefined"
/>
<NButton
type="info"
:loading="speechState.isApiAudioLoading"
@click="testAPI"
>
测试
</NButton>
</NInputGroup>
</div>
<NAlert
v-if="settings.voiceAPISchemeType === 'http'"
type="warning"
>
<template #icon>
<NIcon :component="Info24Filled" />
</template>
<NSpace
vertical
:size="8"
>
<NText>不使用 HTTPS 将通过 Cloudflare Workers 代理速度会慢很多</NText>
<NCheckbox v-model:checked="settings.useAPIDirectly">
不使用代理需要了解可能产生的影响
</NCheckbox>
</NSpace>
</NAlert>
<div>
<NSpace
justify="space-between"
align="center"
>
<NText>音量</NText>
<NText depth="3">
{{ (settings.speechInfo.volume * 100).toFixed(0) }}%
</NText>
</NSpace>
<NSlider
v-model:value="settings.speechInfo.volume"
:min="0"
:max="1"
:step="0.01"
style="margin-top: 8px"
/>
</div>
<!-- 隐藏的音频元素 -->
<audio
ref="apiAudio"
:src="speechState.apiAudioSrc"
:volume="settings.speechInfo.volume"
style="display: none"
@ended="cancelSpeech"
@canplay="speechState.isApiAudioLoading = false"
@error="onAPIError"
/>
</NSpace>
</Transition>
</NSpace>
</NCard>
<!-- 模板设置区域 -->
<NCard
title="消息模板"
:bordered="false"
style="margin-top: 16px"
>
<NSpace
vertical
:size="16"
>
<NAlert
type="info"
:bordered="false"
>
<template #icon>
<NIcon :component="Info24Filled" />
</template>
<NText>支持的变量点击复制</NText>
<NDivider style="margin: 8px 0" />
<NSpace :size="8">
<NButton
v-for="item in Object.values(templateConstants)"
:key="item.name"
size="tiny"
secondary
@click="copyToClipboard(item.words)"
>
{{ item.words }}
<NDivider vertical />
{{ item.name }}
</NButton>
</NSpace>
</NAlert>
<NText depth="3" style="font-size: 12px; margin-bottom: 8px;">
提示模板留空则不播报对应类型的事件
</NText>
<div>
<NInputGroup>
<NInputGroupLabel style="min-width: 120px">
弹幕模板
</NInputGroupLabel>
<NInput
v-model:value="settings.danmakuTemplate"
/>
<NButton
type="info"
:loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Message)"
>
测试
</NButton>
</NInputGroup>
</div>
<div>
<NInputGroup>
<NInputGroupLabel style="min-width: 120px">
礼物模板
</NInputGroupLabel>
<NInput
v-model:value="settings.giftTemplate"
/>
<NButton
type="info"
:loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Gift)"
>
测试
</NButton>
</NInputGroup>
</div>
<div>
<NInputGroup>
<NInputGroupLabel style="min-width: 120px">
SC 模板
</NInputGroupLabel>
<NInput
v-model:value="settings.scTemplate"
/>
<NButton
type="info"
:loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.SC)"
>
测试
</NButton>
</NInputGroup>
</div>
<div>
<NInputGroup>
<NInputGroupLabel style="min-width: 120px">
上舰模板
</NInputGroupLabel>
<NInput
v-model:value="settings.guardTemplate"
/>
<NButton
type="info"
:loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Guard)"
>
测试
</NButton>
</NInputGroup>
</div>
<div>
<NInputGroup>
<NInputGroupLabel style="min-width: 120px">
进入直播间模板
</NInputGroupLabel>
<NInput
v-model:value="settings.enterTemplate"
/>
<NButton
type="info"
:loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Enter)"
>
测试
</NButton>
</NInputGroup>
</div>
</NSpace>
</NCard>
<!-- 高级设置区域 -->
<NCard
title="高级设置"
:bordered="false"
style="margin-top: 16px"
>
<NSpace
vertical
:size="16"
>
<NSpace align="center">
<NCheckbox
:checked="settings.combineGiftDelay !== undefined"
@update:checked="(checked: boolean) => {
settings.combineGiftDelay = checked ? 2 : undefined
}"
>
<NSpace
:size="4"
align="center"
>
<span>礼物合并</span>
<NTooltip>
<template #trigger>
<NIcon
:component="Info24Filled"
:size="16"
/>
</template>
在指定时间内连续送相同礼物会等停止送礼物之后才会念
<br>
这也会导致送的礼物会等待指定时间之后才会念即使没有连续赠送
</NTooltip>
</NSpace>
</NCheckbox>
<NInputGroup
v-if="settings.combineGiftDelay !== undefined"
style="width: 200px"
>
<NInputGroupLabel>延迟</NInputGroupLabel>
<NInputNumber
v-model:value="settings.combineGiftDelay"
:min="1"
:max="10"
@update:value="(value) => {
if (!value || value <= 0) settings.combineGiftDelay = undefined
}"
/>
</NInputGroup>
</NSpace>
<NCheckbox v-model:checked="settings.splitText">
<NSpace
:size="4"
align="center"
>
<span>启用句子拆分</span>
<NTooltip>
<template #trigger>
<NIcon
:component="Info24Filled"
:size="16"
/>
</template>
API 方式可用为英文用户名用引号包裹起来并将所有大写单词拆分成单个单词以防止部分单词念不出来
<br>
原文: Megghy : UPPERCASE单词
<br>
结果: 'Megghy' : U P P E R C A S E 单词
</NTooltip>
</NSpace>
</NCheckbox>
</NSpace>
</NCard>
</template>
</div>
</template>
<style scoped>
.read-danmaku-container {
width: 100%;
padding: 16px;
background: var(--n-color);
border-radius: 8px;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(0.95);
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.read-danmaku-container {
padding: 12px;
}
}
/* 暗色模式优化 */
@media (prefers-color-scheme: dark) {
.read-danmaku-container {
background: var(--n-color);
}
}
</style>