diff --git a/eslint.config.mjs b/eslint.config.mjs index 922e009..591e0e7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -84,6 +84,8 @@ export default antfu( 'ts/switch-exhaustiveness-check': 'warn', // 允许 switch 不覆盖所有情况 'ts/restrict-template-expressions': 'off', // 允许模板字符串表达式不受限制 + 'perfectionist/sort-imports': 'off', + // JSON 相关规则 'jsonc/sort-keys': 'off', // 关闭 JSON key 排序要求 }, diff --git a/src/client/ClientFetcher.vue b/src/client/ClientFetcher.vue index 8b3691d..2b0b570 100644 --- a/src/client/ClientFetcher.vue +++ b/src/client/ClientFetcher.vue @@ -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(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(0) +const lastCheckTime = ref(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) }) @@ -940,8 +1032,14 @@ onUnmounted(() => { style="width: 100%; max-width: 800px; margin-bottom: 1rem;" >
@@ -980,30 +1078,67 @@ onUnmounted(() => { placeholder="请输入 Host (可选)" /> - - 保存配置 - - - - 确定要清除配置吗? - + + + 保存配置 + + + + 确定要清除配置吗? + + + + 手动操作 + + + + + {{ biliCookie.cookieCloudState !== 'valid' ? '请先配置有效的 Cookie Cloud' : syncCooldownRemaining > 0 ? `请等待 ${syncCooldownRemaining} 秒` : '手动从 Cookie Cloud 拉取最新的 Cookie' }} + + + + {{ !biliCookie.hasBiliCookie ? '当前没有 Cookie' : checkCooldownRemaining > 0 ? `请等待 ${checkCooldownRemaining} 秒` : '手动检查当前 Cookie 的有效性' }} + +
diff --git a/src/client/ClientLayout.vue b/src/client/ClientLayout.vue index 49e59d4..52e815b 100644 --- a/src/client/ClientLayout.vue +++ b/src/client/ClientLayout.vue @@ -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' } }, () => '设置'), diff --git a/src/client/ClientReadDanmaku.vue b/src/client/ClientReadDanmaku.vue new file mode 100644 index 0000000..c90f65d --- /dev/null +++ b/src/client/ClientReadDanmaku.vue @@ -0,0 +1,1246 @@ + + + + + diff --git a/src/client/components/autoaction/TemplateEditor.vue b/src/client/components/autoaction/TemplateEditor.vue index 68d050c..abd01df 100644 --- a/src/client/components/autoaction/TemplateEditor.vue +++ b/src/client/components/autoaction/TemplateEditor.vue @@ -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 { diff --git a/src/client/data/initialize.ts b/src/client/data/initialize.ts index f57d807..e7be31f 100644 --- a/src/client/data/initialize.ts +++ b/src/client/data/initialize.ts @@ -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() { diff --git a/src/components.d.ts b/src/components.d.ts index e0226fc..c97f3f1 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -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'] diff --git a/src/router/client.ts b/src/router/client.ts index 0e002d8..10dd0de 100644 --- a/src/router/client.ts +++ b/src/router/client.ts @@ -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', diff --git a/src/store/useSpeechService.ts b/src/store/useSpeechService.ts new file mode 100644 index 0000000..a47b55b --- /dev/null +++ b/src/store/useSpeechService.ts @@ -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 | null = null + +function createSpeechService() { + const message = useMessage() + const accountInfo = useAccount() + const splitter = new GraphemeSplitter() + + const settings = useStorage('Setting.Speech', DEFAULT_SETTINGS) + const speechState = reactive({ + isSpeaking: false, + speakingText: '', + isApiAudioLoading: false, + apiAudioSrc: '', + canSpeech: false, + isInitialized: false, + }) + + const speakQueue = ref([]) + const giftCombineMap = new Map() + const readedDanmaku = ref(0) + + const apiAudio = ref() + 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('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 +} diff --git a/src/views/IndexView.vue b/src/views/IndexView.vue index 8746f96..318d451 100644 --- a/src/views/IndexView.vue +++ b/src/views/IndexView.vue @@ -463,7 +463,7 @@ onMounted(async () => { background: cardBgMedium, border: borderSystem.light, borderRadius: borderRadius.large, - boxShadow: shadowSystem.light, + boxShadow: 'none', }" hoverable class="feature-card"> @@ -486,7 +486,7 @@ onMounted(async () => { background: cardBgMedium, border: borderSystem.light, borderRadius: borderRadius.large, - boxShadow: shadowSystem.light, + boxShadow: 'none', }" hoverable class="feature-card"> @@ -534,7 +534,7 @@ onMounted(async () => { width: '90vw', maxWidth: '1400px', borderRadius: borderRadius.xlarge, - boxShadow: shadowSystem.light, + boxShadow: 'none', }"> @@ -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); diff --git a/src/views/open_live/ReadDanmaku.vue b/src/views/open_live/ReadDanmaku.vue index aa4d9fd..54aa365 100644 --- a/src/views/open_live/ReadDanmaku.vue +++ b/src/views/open_live/ReadDanmaku.vue @@ -1,616 +1,139 @@ - diff --git a/src/views/open_live/ReadDanmaku.vue.backup b/src/views/open_live/ReadDanmaku.vue.backup new file mode 100644 index 0000000..aa4d9fd --- /dev/null +++ b/src/views/open_live/ReadDanmaku.vue.backup @@ -0,0 +1,1363 @@ + + + + +