mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: implement speech synthesis for live danmaku events with customizable templates and API support
This commit is contained in:
@@ -14,12 +14,14 @@ import {
|
||||
BarChartOutline,
|
||||
CheckmarkCircleOutline,
|
||||
CloseCircleOutline,
|
||||
CloudDownloadOutline,
|
||||
HardwareChipOutline,
|
||||
HelpCircle,
|
||||
LogInOutline,
|
||||
LogOutOutline,
|
||||
PeopleOutline,
|
||||
PersonCircleOutline,
|
||||
RefreshOutline,
|
||||
TimeOutline,
|
||||
TimerOutline,
|
||||
TrendingUpOutline,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
NInput,
|
||||
NInputGroup,
|
||||
NInputGroupLabel,
|
||||
NPopconfirm,
|
||||
NQrCode,
|
||||
NRadioButton,
|
||||
NRadioGroup,
|
||||
@@ -155,6 +158,35 @@ const cookieCloud = useTauriStore().getTarget<CookieCloudConfig>(COOKIE_CLOUD_KE
|
||||
const cookieCloudData = ref((await cookieCloud.get())!)
|
||||
const isLoadingCookiecloud = ref(false)
|
||||
|
||||
// Cookie Cloud 手动操作相关状态
|
||||
const isSyncingFromCloud = ref(false)
|
||||
const isCheckingCookie = ref(false)
|
||||
const lastSyncTime = ref<number>(0)
|
||||
const lastCheckTime = ref<number>(0)
|
||||
const COOLDOWN_DURATION = 5 * 1000 // 30秒冷却时间
|
||||
|
||||
// 计算剩余冷却时间
|
||||
const syncCooldownRemaining = ref(0)
|
||||
const checkCooldownRemaining = ref(0)
|
||||
let syncCooldownTimer: number | undefined
|
||||
let checkCooldownTimer: number | undefined
|
||||
|
||||
// 更新冷却时间显示
|
||||
function updateCooldowns() {
|
||||
const now = Date.now()
|
||||
syncCooldownRemaining.value = Math.max(0, Math.ceil((lastSyncTime.value + COOLDOWN_DURATION - now) / 1000))
|
||||
checkCooldownRemaining.value = Math.max(0, Math.ceil((lastCheckTime.value + COOLDOWN_DURATION - now) / 1000))
|
||||
}
|
||||
|
||||
// 启动冷却计时器
|
||||
function startCooldownTimers() {
|
||||
if (syncCooldownTimer) clearInterval(syncCooldownTimer)
|
||||
if (checkCooldownTimer) clearInterval(checkCooldownTimer)
|
||||
|
||||
syncCooldownTimer = window.setInterval(updateCooldowns, 1000)
|
||||
checkCooldownTimer = window.setInterval(updateCooldowns, 1000)
|
||||
}
|
||||
|
||||
async function setCookieCloud() {
|
||||
try {
|
||||
isLoadingCookiecloud.value = true
|
||||
@@ -167,6 +199,60 @@ async function setCookieCloud() {
|
||||
}
|
||||
}
|
||||
|
||||
// 手动从 Cookie Cloud 同步
|
||||
async function manualSyncFromCloud() {
|
||||
const now = Date.now()
|
||||
if (now - lastSyncTime.value < COOLDOWN_DURATION) {
|
||||
message.warning(`请等待 ${syncCooldownRemaining.value} 秒后再试`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSyncingFromCloud.value = true
|
||||
await biliCookie.check(true) // 强制从 CookieCloud 同步
|
||||
lastSyncTime.value = Date.now()
|
||||
updateCooldowns()
|
||||
|
||||
if (biliCookie.isCookieValid) {
|
||||
message.success('Cookie 同步成功')
|
||||
} else {
|
||||
message.error('Cookie 同步失败或无效')
|
||||
}
|
||||
} catch (err: any) {
|
||||
logError(`手动同步 Cookie 失败: ${err}`)
|
||||
message.error(`同步失败: ${err.message || '未知错误'}`)
|
||||
} finally {
|
||||
isSyncingFromCloud.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 手动检查 Cookie 有效性
|
||||
async function manualCheckCookie() {
|
||||
const now = Date.now()
|
||||
if (now - lastCheckTime.value < COOLDOWN_DURATION) {
|
||||
message.warning(`请等待 ${checkCooldownRemaining.value} 秒后再试`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isCheckingCookie.value = true
|
||||
await biliCookie.check(false) // 只检查本地 Cookie
|
||||
lastCheckTime.value = Date.now()
|
||||
updateCooldowns()
|
||||
|
||||
if (biliCookie.isCookieValid) {
|
||||
message.success('Cookie 有效')
|
||||
} else {
|
||||
message.error('Cookie 已失效')
|
||||
}
|
||||
} catch (err: any) {
|
||||
logError(`手动检查 Cookie 失败: ${err}`)
|
||||
message.error(`检查失败: ${err.message || '未知错误'}`)
|
||||
} finally {
|
||||
isCheckingCookie.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Timers ---
|
||||
let uptimeTimer: number | undefined
|
||||
let epsTimer: number | undefined
|
||||
@@ -206,8 +292,8 @@ const danmakuClientStateText = computed(() => {
|
||||
const danmakuClientStateType = computed(() => {
|
||||
switch (webfetcher.danmakuClientState) { // Replace with actual exposed state
|
||||
case 'connected': return 'success'
|
||||
case 'connecting': 'info'
|
||||
case 'stopped': 'error'
|
||||
case 'connecting': return 'info'
|
||||
case 'stopped': return 'error'
|
||||
default: return 'default'
|
||||
}
|
||||
})
|
||||
@@ -594,6 +680,9 @@ onMounted(async () => {
|
||||
|
||||
// Initialize statistics logic (ensure it runs)
|
||||
// initInfo(); // Assuming this is called elsewhere or on app startup
|
||||
|
||||
// 启动冷却计时器
|
||||
startCooldownTimers()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -603,6 +692,9 @@ onUnmounted(() => {
|
||||
clearInterval(timer.value)
|
||||
clearTimeout(expiredTimer.value)
|
||||
clearInterval(countdownTimer.value)
|
||||
// Clean up cooldown timers
|
||||
clearInterval(syncCooldownTimer)
|
||||
clearInterval(checkCooldownTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -940,8 +1032,14 @@ onUnmounted(() => {
|
||||
style="width: 100%; max-width: 800px; margin-bottom: 1rem;"
|
||||
>
|
||||
<template #header-extra>
|
||||
<NTag>
|
||||
{{ }}
|
||||
<NTag
|
||||
:type="biliCookie.cookieCloudState === 'valid' ? 'success' : biliCookie.cookieCloudState === 'syncing' ? 'info' : biliCookie.cookieCloudState === 'invalid' ? 'error' : 'default'"
|
||||
>
|
||||
{{
|
||||
biliCookie.cookieCloudState === 'valid' ? '已配置' :
|
||||
biliCookie.cookieCloudState === 'syncing' ? '同步中' :
|
||||
biliCookie.cookieCloudState === 'invalid' ? '配置无效' : '未配置'
|
||||
}}
|
||||
</NTag>
|
||||
</template>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
@@ -980,30 +1078,67 @@ onUnmounted(() => {
|
||||
placeholder="请输入 Host (可选)"
|
||||
/>
|
||||
</NInputGroup>
|
||||
<NButton
|
||||
v-if="biliCookie.cookieCloudState === 'invalid' || biliCookie.cookieCloudState === 'unset'"
|
||||
type="primary"
|
||||
:loading="isLoadingCookiecloud"
|
||||
@click="setCookieCloud"
|
||||
>
|
||||
保存配置
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
v-else
|
||||
type="error"
|
||||
@positive-click="async () => {
|
||||
await biliCookie.clearCookieCloudConfig();
|
||||
cookieCloudData = { key: '', password: '' };
|
||||
message.success('配置已清除');
|
||||
}"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton type="error">
|
||||
清除配置
|
||||
</NButton>
|
||||
</template>
|
||||
确定要清除配置吗?
|
||||
</NPopconfirm>
|
||||
<NFlex gap="small">
|
||||
<NButton
|
||||
v-if="biliCookie.cookieCloudState === 'invalid' || biliCookie.cookieCloudState === 'unset'"
|
||||
type="primary"
|
||||
:loading="isLoadingCookiecloud"
|
||||
@click="setCookieCloud"
|
||||
>
|
||||
保存配置
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
v-else
|
||||
type="error"
|
||||
@positive-click="async () => {
|
||||
await biliCookie.clearCookieCloudConfig();
|
||||
cookieCloudData = { key: '', password: '', host: 'https://cookie.vtsuru.live' };
|
||||
message.success('配置已清除');
|
||||
}"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton type="error">
|
||||
清除配置
|
||||
</NButton>
|
||||
</template>
|
||||
确定要清除配置吗?
|
||||
</NPopconfirm>
|
||||
</NFlex>
|
||||
<NDivider style="margin: 8px 0;">
|
||||
手动操作
|
||||
</NDivider>
|
||||
<NFlex gap="small">
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
:disabled="biliCookie.cookieCloudState !== 'valid' || syncCooldownRemaining > 0"
|
||||
:loading="isSyncingFromCloud"
|
||||
@click="manualSyncFromCloud"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon :component="CloudDownloadOutline" />
|
||||
</template>
|
||||
{{ syncCooldownRemaining > 0 ? `同步 Cookie (${syncCooldownRemaining}s)` : '从云端同步 Cookie' }}
|
||||
</NButton>
|
||||
</template>
|
||||
{{ biliCookie.cookieCloudState !== 'valid' ? '请先配置有效的 Cookie Cloud' : syncCooldownRemaining > 0 ? `请等待 ${syncCooldownRemaining} 秒` : '手动从 Cookie Cloud 拉取最新的 Cookie' }}
|
||||
</NTooltip>
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
:disabled="!biliCookie.hasBiliCookie || checkCooldownRemaining > 0"
|
||||
:loading="isCheckingCookie"
|
||||
@click="manualCheckCookie"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon :component="RefreshOutline" />
|
||||
</template>
|
||||
{{ checkCooldownRemaining > 0 ? `检查状态 (${checkCooldownRemaining}s)` : '检查 Cookie 状态' }}
|
||||
</NButton>
|
||||
</template>
|
||||
{{ !biliCookie.hasBiliCookie ? '当前没有 Cookie' : checkCooldownRemaining > 0 ? `请等待 ${checkCooldownRemaining} 秒` : '手动检查当前 Cookie 的有效性' }}
|
||||
</NTooltip>
|
||||
</NFlex>
|
||||
</div>
|
||||
</NCard>
|
||||
</NTabPane>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { MenuOption } from 'naive-ui'
|
||||
// 引入 Tauri 插件
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
|
||||
import { Chat24Filled, CloudArchive24Filled, FlashAuto24Filled, Settings24Filled } from '@vicons/fluent'
|
||||
import { Chat24Filled, CloudArchive24Filled, FlashAuto24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent'
|
||||
import { CheckmarkCircle, CloseCircle, Home } from '@vicons/ionicons5'
|
||||
import { NA, NButton, NCard, NInput, NLayout, NLayoutContent, NLayoutSider, NMenu, NSpace, NSpin, NText, NTooltip } from 'naive-ui'
|
||||
|
||||
@@ -116,6 +116,12 @@ const menuOptions = computed(() => {
|
||||
key: 'danmaku-auto-action-manage',
|
||||
icon: () => h(FlashAuto24Filled),
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: { name: 'client-read-danmaku' } }, () => '读弹幕'),
|
||||
key: 'read-danmaku',
|
||||
icon: () => h(Mic24Filled),
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: { name: 'client-settings' } }, () => '设置'),
|
||||
|
||||
1246
src/client/ClientReadDanmaku.vue
Normal file
1246
src/client/ClientReadDanmaku.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -173,7 +173,7 @@ const highlightPatterns = computed(() => {
|
||||
return allPatterns
|
||||
})
|
||||
|
||||
const MAX_LENGTH = 20
|
||||
const MAX_LENGTH = 40
|
||||
const WARNING_THRESHOLD = 16
|
||||
|
||||
function evaluateTemplateForUI(template: string): string {
|
||||
|
||||
@@ -36,7 +36,9 @@ let updateNotificationRef: any = null
|
||||
|
||||
async function sendHeartbeat() {
|
||||
try {
|
||||
await invoke('heartbeat')
|
||||
await invoke('heartbeat', undefined, {
|
||||
headers: [['Origin', location.host]]
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('发送心跳失败:', error)
|
||||
}
|
||||
@@ -88,10 +90,10 @@ async function checkUpdatePeriodically() {
|
||||
try {
|
||||
info('[更新检查] 开始检查更新...')
|
||||
const update = await check()
|
||||
|
||||
|
||||
if (update) {
|
||||
info(`[更新检查] 发现新版本: ${update.version}`)
|
||||
|
||||
|
||||
// 发送 Windows 通知
|
||||
const permissionGranted = await isPermissionGranted()
|
||||
if (permissionGranted) {
|
||||
@@ -100,7 +102,7 @@ async function checkUpdatePeriodically() {
|
||||
body: `发现新版本 ${update.version},点击通知查看详情`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 显示不可关闭的 NaiveUI notification
|
||||
if (!updateNotificationRef) {
|
||||
updateNotificationRef = window.$notification.warning({
|
||||
@@ -113,9 +115,11 @@ async function checkUpdatePeriodically() {
|
||||
'button',
|
||||
{
|
||||
class: 'n-button n-button--primary-type n-button--small-type',
|
||||
onClick: () => { void handleUpdateInstall(update) },
|
||||
onClick: () => {
|
||||
void handleUpdateInstall(update)
|
||||
},
|
||||
},
|
||||
'立即更新'
|
||||
'立即更新',
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
@@ -123,7 +127,7 @@ async function checkUpdatePeriodically() {
|
||||
class: 'n-button n-button--default-type n-button--small-type',
|
||||
onClick: () => handleUpdateDismiss(),
|
||||
},
|
||||
'稍后提醒'
|
||||
'稍后提醒',
|
||||
),
|
||||
])
|
||||
},
|
||||
@@ -146,7 +150,7 @@ async function handleUpdateInstall(update: any) {
|
||||
updateNotificationRef.destroy()
|
||||
updateNotificationRef = null
|
||||
}
|
||||
|
||||
|
||||
// 显示下载进度通知
|
||||
let downloaded = 0
|
||||
let contentLength = 0
|
||||
@@ -156,7 +160,7 @@ async function handleUpdateInstall(update: any) {
|
||||
closable: false,
|
||||
duration: 0,
|
||||
})
|
||||
|
||||
|
||||
info('[更新] 开始下载并安装更新')
|
||||
await update.downloadAndInstall((event: any) => {
|
||||
switch (event.event) {
|
||||
@@ -177,16 +181,16 @@ async function handleUpdateInstall(update: any) {
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
progressNotification.destroy()
|
||||
info('[更新] 更新安装完成,准备重启应用')
|
||||
|
||||
|
||||
window.$notification.success({
|
||||
title: '更新完成',
|
||||
content: '应用将在 3 秒后重启',
|
||||
duration: 3000,
|
||||
})
|
||||
|
||||
|
||||
// 延迟 3 秒后重启
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
await relaunch()
|
||||
@@ -328,13 +332,13 @@ export async function initAll(isOnBoot: boolean) {
|
||||
useAutoAction().init()
|
||||
useBiliFunction().init()
|
||||
|
||||
//startHeartbeat()
|
||||
|
||||
// startHeartbeat()
|
||||
|
||||
// 启动定期更新检查
|
||||
if (!isDev) {
|
||||
startUpdateCheck()
|
||||
}
|
||||
|
||||
|
||||
clientInited.value = true
|
||||
}
|
||||
export function OnClientUnmounted() {
|
||||
|
||||
6
src/components.d.ts
vendored
6
src/components.d.ts
vendored
@@ -19,6 +19,9 @@ declare module 'vue' {
|
||||
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
||||
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
@@ -26,7 +29,10 @@ declare module 'vue' {
|
||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
NTime: typeof import('naive-ui')['NTime']
|
||||
|
||||
@@ -47,6 +47,15 @@ export default {
|
||||
forceReload: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'read-danmaku',
|
||||
name: 'client-read-danmaku',
|
||||
component: async () => import('@/client/ClientReadDanmaku.vue'),
|
||||
meta: {
|
||||
title: '读弹幕',
|
||||
forceReload: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'danmaku-window',
|
||||
name: 'client-danmaku-window-redirect',
|
||||
|
||||
699
src/store/useSpeechService.ts
Normal file
699
src/store/useSpeechService.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import EasySpeech from 'easy-speech'
|
||||
import GraphemeSplitter from 'grapheme-splitter'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { clearInterval, setInterval } from 'worker-timers'
|
||||
import type { EventModel } from '@/api/api-models'
|
||||
import { DownloadConfig, UploadConfig, useAccount } from '@/api/account'
|
||||
import { EventDataTypes } from '@/api/api-models'
|
||||
import { FETCH_API } from '@/data/constants'
|
||||
|
||||
export interface SpeechSettings {
|
||||
speechInfo: SpeechInfo
|
||||
danmakuTemplate: string
|
||||
scTemplate: string
|
||||
guardTemplate: string
|
||||
giftTemplate: string
|
||||
enterTemplate: string
|
||||
voiceType: 'local' | 'api'
|
||||
voiceAPISchemeType: 'http' | 'https'
|
||||
voiceAPI: string
|
||||
splitText: boolean
|
||||
useAPIDirectly: boolean
|
||||
combineGiftDelay: number | undefined
|
||||
}
|
||||
|
||||
export interface SpeechInfo {
|
||||
volume: number
|
||||
pitch: number
|
||||
rate: number
|
||||
voice: string
|
||||
}
|
||||
|
||||
export interface SpeechState {
|
||||
isSpeaking: boolean
|
||||
speakingText: string
|
||||
isApiAudioLoading: boolean
|
||||
apiAudioSrc: string
|
||||
canSpeech: boolean
|
||||
isInitialized: boolean
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
updateAt: number
|
||||
combineCount?: number
|
||||
data: EventModel
|
||||
}
|
||||
|
||||
const MAX_QUEUE_SIZE = 50
|
||||
const DEFAULT_SETTINGS: SpeechSettings = {
|
||||
speechInfo: {
|
||||
volume: 1,
|
||||
pitch: 1,
|
||||
rate: 1,
|
||||
voice: '',
|
||||
},
|
||||
danmakuTemplate: '{name} 说: {message}',
|
||||
scTemplate: '{name} 发送了醒目留言: {message}',
|
||||
guardTemplate: '感谢 {name} 的 {count} 个月 {guard_level}',
|
||||
giftTemplate: '感谢 {name} 赠送的 {count} 个 {gift_name}',
|
||||
enterTemplate: '欢迎 {name} 进入直播间',
|
||||
voiceType: 'local',
|
||||
voiceAPISchemeType: 'https',
|
||||
voiceAPI: 'voice.vtsuru.live/voice/bert-vits2?text={{text}}&id=1&format=mp3&streaming=true',
|
||||
useAPIDirectly: false,
|
||||
splitText: false,
|
||||
combineGiftDelay: 2,
|
||||
}
|
||||
|
||||
export const templateConstants = {
|
||||
name: {
|
||||
name: '用户名',
|
||||
words: '{name}',
|
||||
regex: /\{\s*name\s*\}/gi,
|
||||
},
|
||||
message: {
|
||||
name: '弹幕内容',
|
||||
words: '{message}',
|
||||
regex: /\{\s*message\s*\}/gi,
|
||||
},
|
||||
guard_level: {
|
||||
name: '舰长等级',
|
||||
words: '{guard_level}',
|
||||
regex: /\{\s*guard_level\s*\}/gi,
|
||||
},
|
||||
guard_num: {
|
||||
name: '上舰数量',
|
||||
words: '{guard_num}',
|
||||
regex: /\{\s*guard_num\s*\}/gi,
|
||||
},
|
||||
fans_medal_level: {
|
||||
name: '粉丝勋章等级',
|
||||
words: '{fans_medal_level}',
|
||||
regex: /\{\s*fans_medal_level\s*\}/gi,
|
||||
},
|
||||
price: {
|
||||
name: '价格',
|
||||
words: '{price}',
|
||||
regex: /\{\s*price\s*\}/gi,
|
||||
},
|
||||
count: {
|
||||
name: '数量',
|
||||
words: '{count}',
|
||||
regex: /\{\s*count\s*\}/gi,
|
||||
},
|
||||
gift_name: {
|
||||
name: '礼物名称',
|
||||
words: '{gift_name}',
|
||||
regex: /\{\s*gift_name\s*\}/gi,
|
||||
},
|
||||
}
|
||||
|
||||
// Singleton state
|
||||
let speechServiceInstance: ReturnType<typeof createSpeechService> | null = null
|
||||
|
||||
function createSpeechService() {
|
||||
const message = useMessage()
|
||||
const accountInfo = useAccount()
|
||||
const splitter = new GraphemeSplitter()
|
||||
|
||||
const settings = useStorage<SpeechSettings>('Setting.Speech', DEFAULT_SETTINGS)
|
||||
const speechState = reactive<SpeechState>({
|
||||
isSpeaking: false,
|
||||
speakingText: '',
|
||||
isApiAudioLoading: false,
|
||||
apiAudioSrc: '',
|
||||
canSpeech: false,
|
||||
isInitialized: false,
|
||||
})
|
||||
|
||||
const speakQueue = ref<QueueItem[]>([])
|
||||
const giftCombineMap = new Map<string, number>()
|
||||
const readedDanmaku = ref(0)
|
||||
|
||||
const apiAudio = ref<HTMLAudioElement>()
|
||||
let checkTimer: number | undefined
|
||||
let speechQueueTimer: number | undefined
|
||||
|
||||
const speechSynthesisInfo = ref<{
|
||||
speechSynthesis: SpeechSynthesis | undefined
|
||||
speechSynthesisUtterance: SpeechSynthesisUtterance | undefined
|
||||
speechSynthesisVoice: SpeechSynthesisVoice | undefined
|
||||
onvoiceschanged: boolean
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 初始化语音服务
|
||||
*/
|
||||
async function initialize() {
|
||||
if (speechState.isInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await EasySpeech.init({ maxTimeout: 5000, interval: 250 })
|
||||
speechSynthesisInfo.value = EasySpeech.detect() as any
|
||||
|
||||
// 自动选择默认语音
|
||||
const checkAndSetDefaultVoice = () => {
|
||||
const voices = EasySpeech.voices()
|
||||
if (voices.length > 0 && !settings.value.speechInfo.voice) {
|
||||
const chineseVoice = voices.find(v => v.lang.startsWith('zh'))
|
||||
settings.value.speechInfo.voice = chineseVoice?.name || voices[0].name
|
||||
console.log(`[TTS] 自动选择默认语音: ${settings.value.speechInfo.voice}`)
|
||||
}
|
||||
}
|
||||
|
||||
checkAndSetDefaultVoice()
|
||||
|
||||
if (EasySpeech.voices().length === 0) {
|
||||
const voiceCheckTimer = setInterval(() => {
|
||||
if (EasySpeech.voices().length > 0) {
|
||||
checkAndSetDefaultVoice()
|
||||
clearInterval(voiceCheckTimer)
|
||||
}
|
||||
}, 100)
|
||||
setTimeout(() => clearInterval(voiceCheckTimer), 10000)
|
||||
}
|
||||
|
||||
// 启动队列处理
|
||||
speechQueueTimer = setInterval(() => {
|
||||
processQueue()
|
||||
}, 250)
|
||||
|
||||
speechState.isInitialized = true
|
||||
console.log('[TTS] 语音服务初始化完成')
|
||||
} catch (error) {
|
||||
console.error('[TTS] 初始化失败:', error)
|
||||
message.error('语音服务初始化失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁语音服务
|
||||
*/
|
||||
function destroy() {
|
||||
if (speechQueueTimer) {
|
||||
clearInterval(speechQueueTimer)
|
||||
speechQueueTimer = undefined
|
||||
}
|
||||
|
||||
if (checkTimer) {
|
||||
clearInterval(checkTimer)
|
||||
checkTimer = undefined
|
||||
}
|
||||
|
||||
cancelSpeech()
|
||||
giftCombineMap.clear()
|
||||
speakQueue.value = []
|
||||
speechState.isInitialized = false
|
||||
console.log('[TTS] 语音服务已销毁')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据舰长等级数字返回对应的中文名称
|
||||
*/
|
||||
function getGuardLevelName(guardLevel: number): string {
|
||||
switch (guardLevel) {
|
||||
case 1: return '总督'
|
||||
case 2: return '提督'
|
||||
case 3: return '舰长'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全角转半角
|
||||
*/
|
||||
function fullWidthToHalfWidth(str: string) {
|
||||
let result = str.replace(/[\uFF01-\uFF5E]/g, (ch) => {
|
||||
return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0)
|
||||
})
|
||||
result = result.replace(/\u3000/g, ' ')
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 从事件数据生成要朗读的文本
|
||||
*/
|
||||
function getTextFromDanmaku(data: EventModel | undefined): string | undefined {
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
let text: string = ''
|
||||
switch (data.type) {
|
||||
case EventDataTypes.Message:
|
||||
if (!settings.value.danmakuTemplate) return
|
||||
text = settings.value.danmakuTemplate
|
||||
break
|
||||
case EventDataTypes.SC:
|
||||
if (!settings.value.scTemplate) return
|
||||
text = settings.value.scTemplate
|
||||
break
|
||||
case EventDataTypes.Guard:
|
||||
if (!settings.value.guardTemplate) return
|
||||
text = settings.value.guardTemplate
|
||||
break
|
||||
case EventDataTypes.Gift:
|
||||
if (!settings.value.giftTemplate) return
|
||||
text = settings.value.giftTemplate
|
||||
break
|
||||
case EventDataTypes.Enter:
|
||||
if (!settings.value.enterTemplate) return
|
||||
text = settings.value.enterTemplate
|
||||
break
|
||||
case EventDataTypes.Like:
|
||||
case EventDataTypes.SCDel:
|
||||
case EventDataTypes.Follow:
|
||||
// 这些事件类型不需要语音播报
|
||||
return
|
||||
}
|
||||
|
||||
text = text
|
||||
.replace(
|
||||
templateConstants.name.regex,
|
||||
settings.value.voiceType == 'api' && settings.value.splitText ? `'${data.uname || ''}'` : (data.uname || ''),
|
||||
)
|
||||
.replace(templateConstants.count.regex, (data.num ?? 0).toString())
|
||||
.replace(templateConstants.price.regex, (data.price ?? 0).toString())
|
||||
.replace(templateConstants.message.regex, data.msg || '')
|
||||
.replace(
|
||||
templateConstants.guard_level.regex,
|
||||
getGuardLevelName(data.guard_level),
|
||||
)
|
||||
.replace(templateConstants.fans_medal_level.regex, (data.fans_medal_level ?? 0).toString())
|
||||
.trim()
|
||||
|
||||
if (data.type === EventDataTypes.Message) {
|
||||
text = text.replace(/\[.*?\]/g, ' ')
|
||||
} else if (data.type === EventDataTypes.Gift) {
|
||||
text = text.replace(templateConstants.gift_name.regex, data.msg || '')
|
||||
} else if (data.type === EventDataTypes.Guard) {
|
||||
text = text.replace(templateConstants.guard_num.regex, (data.num ?? 0).toString())
|
||||
}
|
||||
|
||||
text = fullWidthToHalfWidth(text)
|
||||
.replace(/[^0-9a-z\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF,.:'"\s]/gi, '')
|
||||
.normalize('NFKC')
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入空格(用于拆分文本)
|
||||
*/
|
||||
function insertSpaces(sentence: string) {
|
||||
sentence = sentence.replace(/\b[A-Z]{2,}\b/g, (match) => {
|
||||
return match.split('').join(' ')
|
||||
})
|
||||
sentence = sentence.replace(/\s+/g, ' ').trim()
|
||||
return sentence
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用本地TTS朗读
|
||||
*/
|
||||
function speakDirect(text: string) {
|
||||
try {
|
||||
const synth = window.speechSynthesis
|
||||
if (!synth) {
|
||||
console.error('[TTS] 当前浏览器不支持语音合成')
|
||||
return
|
||||
}
|
||||
|
||||
synth.cancel()
|
||||
const u = new SpeechSynthesisUtterance()
|
||||
u.text = text
|
||||
const voices = synth.getVoices()
|
||||
const voice = voices.find(v => v.name === settings.value.speechInfo.voice)
|
||||
|
||||
if (voice) {
|
||||
u.voice = voice
|
||||
u.volume = settings.value.speechInfo.volume
|
||||
u.rate = settings.value.speechInfo.rate
|
||||
u.pitch = settings.value.speechInfo.pitch
|
||||
synth.speak(u)
|
||||
|
||||
u.onend = () => {
|
||||
cancelSpeech()
|
||||
}
|
||||
|
||||
u.onerror = (err) => {
|
||||
if (err.error == 'interrupted') {
|
||||
return
|
||||
}
|
||||
console.error('[TTS] 播放错误:', err)
|
||||
message.error(`无法播放语音: ${err.error}`)
|
||||
cancelSpeech()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TTS] 本地语音合成失败:', err)
|
||||
cancelSpeech()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建API请求URL
|
||||
*/
|
||||
function buildApiUrl(text: string): string | null {
|
||||
if (!settings.value.voiceAPI) {
|
||||
message.error('未设置语音API')
|
||||
return null
|
||||
}
|
||||
|
||||
const scheme
|
||||
= settings.value.voiceAPISchemeType === 'https'
|
||||
? 'https://'
|
||||
: settings.value.useAPIDirectly
|
||||
? 'http://'
|
||||
: `${FETCH_API}http://`
|
||||
|
||||
const url = `${scheme}${settings.value.voiceAPI.trim().replace(/^https?:\/\//, '')}`.replace(
|
||||
/\{\{\s*text\s*\}\}/,
|
||||
encodeURIComponent(text),
|
||||
)
|
||||
|
||||
try {
|
||||
const tempURL = new URL(url)
|
||||
const isVtsuruAPI = settings.value.voiceAPI.toLowerCase().trim().startsWith('voice.vtsuru.live')
|
||||
|
||||
if (isVtsuruAPI) {
|
||||
tempURL.searchParams.set('vtsuruId', accountInfo.value?.id.toString() ?? '-1')
|
||||
if (splitter.countGraphemes(tempURL.searchParams.get('text') ?? '') > 100) {
|
||||
message.error(`本站提供的测试接口字数不允许超过 100 字`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return tempURL.toString()
|
||||
} catch (err) {
|
||||
console.error('[TTS] 无效的API地址:', err)
|
||||
message.error(`无效的API地址: ${url}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用API TTS朗读
|
||||
*/
|
||||
function speakFromAPI(text: string) {
|
||||
const url = buildApiUrl(text)
|
||||
if (!url) {
|
||||
cancelSpeech()
|
||||
return
|
||||
}
|
||||
|
||||
speechState.isSpeaking = true
|
||||
speechState.isApiAudioLoading = true
|
||||
speechState.apiAudioSrc = url
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理队列中的下一个事件
|
||||
*/
|
||||
async function processQueue() {
|
||||
if (speechState.isSpeaking || speakQueue.value.length == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let targetIndex = -1
|
||||
const now = Date.now()
|
||||
const combineDelay = (settings.value.combineGiftDelay ?? 0) * 1000
|
||||
|
||||
for (let i = 0; i < speakQueue.value.length; i++) {
|
||||
const item = speakQueue.value[i]
|
||||
|
||||
if (item.data.type == EventDataTypes.Gift
|
||||
&& combineDelay > 0
|
||||
&& item.updateAt > now - combineDelay) {
|
||||
continue
|
||||
}
|
||||
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
|
||||
if (targetIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetItem = speakQueue.value.splice(targetIndex, 1)[0]
|
||||
|
||||
if (targetItem.data.type !== EventDataTypes.Gift) {
|
||||
giftCombineMap.clear()
|
||||
speakQueue.value.forEach((item, index) => {
|
||||
if (item.data.type === EventDataTypes.Gift) {
|
||||
const giftKey = `${item.data.uid}-${item.data.msg}`
|
||||
giftCombineMap.set(giftKey, index)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let text = getTextFromDanmaku(targetItem.data)
|
||||
if (text) {
|
||||
speechState.isSpeaking = true
|
||||
readedDanmaku.value++
|
||||
speechState.speakingText = text
|
||||
|
||||
if (checkTimer) {
|
||||
clearInterval(checkTimer)
|
||||
}
|
||||
|
||||
checkTimer = setInterval(() => {
|
||||
message.error('语音播放超时')
|
||||
cancelSpeech()
|
||||
}, 30000)
|
||||
|
||||
if (settings.value.voiceType == 'local') {
|
||||
speakDirect(text)
|
||||
} else {
|
||||
text = settings.value.splitText ? insertSpaces(text) : text
|
||||
speakFromAPI(text)
|
||||
}
|
||||
|
||||
console.log(`[TTS] 正在朗读: ${text}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前语音播放
|
||||
*/
|
||||
function cancelSpeech() {
|
||||
speechState.isSpeaking = false
|
||||
|
||||
if (checkTimer) {
|
||||
clearInterval(checkTimer)
|
||||
checkTimer = undefined
|
||||
}
|
||||
|
||||
speechState.isApiAudioLoading = false
|
||||
|
||||
if (apiAudio.value && !apiAudio.value.paused) {
|
||||
apiAudio.value.pause()
|
||||
}
|
||||
|
||||
EasySpeech.cancel()
|
||||
speechState.speakingText = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收事件并添加到队列
|
||||
*/
|
||||
function addToQueue(data: EventModel) {
|
||||
if (!speechState.canSpeech) {
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type == EventDataTypes.Message && (data.emoji || /^(?:\[\w+\])+$/.test(data.msg))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type == EventDataTypes.Enter && !settings.value.enterTemplate) {
|
||||
return
|
||||
}
|
||||
|
||||
// 礼物合并逻辑
|
||||
if (data.type == EventDataTypes.Gift && settings.value.combineGiftDelay) {
|
||||
const giftKey = `${data.uid}-${data.msg}`
|
||||
const existIndex = giftCombineMap.get(giftKey)
|
||||
|
||||
if (existIndex !== undefined && existIndex < speakQueue.value.length) {
|
||||
const exist = speakQueue.value[existIndex]
|
||||
if (exist
|
||||
&& exist.data.type == EventDataTypes.Gift
|
||||
&& exist.updateAt > Date.now() - (settings.value.combineGiftDelay * 1000)) {
|
||||
exist.updateAt = Date.now()
|
||||
exist.data.num += data.num
|
||||
exist.data.price += data.price
|
||||
exist.combineCount = (exist.combineCount ?? 0) + data.num
|
||||
console.log(`[TTS] ${data.uname} 增加礼物数量: ${data.msg} [${exist.data.num - data.num} => ${exist.data.num}]`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const newIndex = speakQueue.value.length
|
||||
giftCombineMap.set(giftKey, newIndex)
|
||||
|
||||
setTimeout(() => {
|
||||
if (giftCombineMap.get(giftKey) === newIndex) {
|
||||
giftCombineMap.delete(giftKey)
|
||||
}
|
||||
}, (settings.value.combineGiftDelay + 1) * 1000)
|
||||
}
|
||||
|
||||
speakQueue.value.push({
|
||||
data,
|
||||
updateAt: data.type == EventDataTypes.Gift ? Date.now() : 0,
|
||||
})
|
||||
|
||||
// 队列清理
|
||||
if (speakQueue.value.length > MAX_QUEUE_SIZE) {
|
||||
const removed = speakQueue.value.splice(0, speakQueue.value.length - MAX_QUEUE_SIZE)
|
||||
console.warn(`[TTS] 队列过长,已移除 ${removed.length} 个旧项目`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制播放指定事件(插队)
|
||||
*/
|
||||
function forceSpeak(data: EventModel) {
|
||||
cancelSpeech()
|
||||
|
||||
const index = speakQueue.value.findIndex(v => v.data == data)
|
||||
if (index !== -1) {
|
||||
speakQueue.value.splice(index, 1)
|
||||
}
|
||||
|
||||
speakQueue.value.unshift({
|
||||
updateAt: 0,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 从队列中移除指定项
|
||||
*/
|
||||
function removeFromQueue(item: QueueItem) {
|
||||
const index = speakQueue.value.indexOf(item)
|
||||
if (index !== -1) {
|
||||
speakQueue.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空队列
|
||||
*/
|
||||
function clearQueue() {
|
||||
speakQueue.value = []
|
||||
giftCombineMap.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始监听
|
||||
*/
|
||||
function startSpeech() {
|
||||
speechState.canSpeech = true
|
||||
message.success('服务已启动')
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监听
|
||||
*/
|
||||
function stopSpeech() {
|
||||
speechState.canSpeech = false
|
||||
// 清空队列
|
||||
speakQueue.value = []
|
||||
// 取消当前正在播放的语音
|
||||
cancelSpeech()
|
||||
message.success('已停止监听')
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传配置到服务器
|
||||
*/
|
||||
async function uploadConfig() {
|
||||
try {
|
||||
const result = await UploadConfig('Speech', settings.value)
|
||||
if (result) {
|
||||
message.success('已保存至服务器')
|
||||
} else {
|
||||
message.error('保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TTS] 上传配置失败:', error)
|
||||
message.error(`保存失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务器下载配置
|
||||
*/
|
||||
async function downloadConfig() {
|
||||
try {
|
||||
const result = await DownloadConfig<SpeechSettings>('Speech')
|
||||
if (result.status === 'success' && result.data) {
|
||||
settings.value = result.data
|
||||
message.success('已获取配置文件')
|
||||
} else if (result.status === 'notfound') {
|
||||
message.error('未上传配置文件')
|
||||
} else {
|
||||
message.error(`获取失败: ${result.msg}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TTS] 下载配置失败:', error)
|
||||
message.error(`获取失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的语音列表
|
||||
*/
|
||||
function getAvailableVoices() {
|
||||
const languageDisplayName = new Intl.DisplayNames(['zh'], { type: 'language' })
|
||||
return EasySpeech.voices().map((v) => {
|
||||
return {
|
||||
label: `[${languageDisplayName.of(v.lang)}] ${v.name}`,
|
||||
value: v.name,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
settings,
|
||||
speechState,
|
||||
speakQueue,
|
||||
readedDanmaku,
|
||||
speechSynthesisInfo,
|
||||
apiAudio,
|
||||
|
||||
// Methods
|
||||
initialize,
|
||||
destroy,
|
||||
addToQueue,
|
||||
forceSpeak,
|
||||
removeFromQueue,
|
||||
clearQueue,
|
||||
startSpeech,
|
||||
stopSpeech,
|
||||
cancelSpeech,
|
||||
uploadConfig,
|
||||
downloadConfig,
|
||||
getTextFromDanmaku,
|
||||
getAvailableVoices,
|
||||
buildApiUrl,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用语音服务(单例模式)
|
||||
*/
|
||||
export function useSpeechService() {
|
||||
if (!speechServiceInstance) {
|
||||
speechServiceInstance = createSpeechService()
|
||||
}
|
||||
return speechServiceInstance
|
||||
}
|
||||
@@ -463,7 +463,7 @@ onMounted(async () => {
|
||||
background: cardBgMedium,
|
||||
border: borderSystem.light,
|
||||
borderRadius: borderRadius.large,
|
||||
boxShadow: shadowSystem.light,
|
||||
boxShadow: 'none',
|
||||
}" hoverable class="feature-card">
|
||||
<NFlex vertical>
|
||||
<NFlex align="center" style="margin-bottom: 10px;">
|
||||
@@ -486,7 +486,7 @@ onMounted(async () => {
|
||||
background: cardBgMedium,
|
||||
border: borderSystem.light,
|
||||
borderRadius: borderRadius.large,
|
||||
boxShadow: shadowSystem.light,
|
||||
boxShadow: 'none',
|
||||
}" hoverable class="feature-card">
|
||||
<NFlex vertical>
|
||||
<NFlex align="center" style="margin-bottom: 10px;">
|
||||
@@ -534,7 +534,7 @@ onMounted(async () => {
|
||||
width: '90vw',
|
||||
maxWidth: '1400px',
|
||||
borderRadius: borderRadius.xlarge,
|
||||
boxShadow: shadowSystem.light,
|
||||
boxShadow: 'none',
|
||||
}">
|
||||
<NFlex vertical>
|
||||
<NFlex justify="center" align="center" style="margin-bottom: 30px;">
|
||||
@@ -677,7 +677,7 @@ onMounted(async () => {
|
||||
|
||||
&:hover
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
|
||||
&::before
|
||||
left: 100%;
|
||||
@@ -700,14 +700,14 @@ onMounted(async () => {
|
||||
|
||||
&:hover
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.entry-card
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.streamer-avatar
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -738,7 +738,7 @@ onMounted(async () => {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
|
||||
:deep(.n-button--small)
|
||||
border-radius: 8px;
|
||||
@@ -843,7 +843,7 @@ onMounted(async () => {
|
||||
|
||||
&:hover
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1363
src/views/open_live/ReadDanmaku.vue.backup
Normal file
1363
src/views/open_live/ReadDanmaku.vue.backup
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user