This commit is contained in:
2023-12-17 18:54:55 +08:00
parent db17cffa30
commit 7ae3fdfe99
15 changed files with 993 additions and 372 deletions

View File

@@ -422,6 +422,7 @@ export interface EventModel {
fans_medal_level: number
fans_medal_name: string
fans_medal_wearing_status: boolean
emoji?: string
}
export enum EventDataTypes {
Guard,

View File

@@ -1,4 +1,4 @@
import { OpenLiveInfo } from '@/api/api-models'
import { EventDataTypes, EventModel, OpenLiveInfo } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import ChatClientDirectOpenLive from '@/data/chat/ChatClientDirectOpenLive.js'
import { OPEN_LIVE_API_URL } from './constants'
@@ -67,6 +67,22 @@ export interface SCInfo {
fans_medal_name: string // 对应房间勋章名字 (新增)
fans_medal_wearing_status: boolean // 该房间粉丝勋章佩戴情况 (新增)
}
interface GuardInfo {
user_info: {
uid: number // 用户uid
uname: string // 用户昵称
uface: string // 用户头像
}
guard_level: number // 对应的大航海等级 1总督 2提督 3舰长
guard_num: number
guard_unit: string // (个月)
fans_medal_level: number // 粉丝勋章等级
fans_medal_name: string // 粉丝勋章名
fans_medal_wearing_status: boolean // 该房间粉丝勋章佩戴情况
timestamp: number
room_id: number
msg_id: string // 消息唯一id
}
export interface AuthInfo {
Timestamp: string
Code: string
@@ -137,6 +153,7 @@ interface DanmakuEventsMap {
danmaku: (arg1: DanmakuInfo, arg2?: any) => void
gift: (arg1: GiftInfo, arg2?: any) => void
sc: (arg1: SCInfo, arg2?: any) => void
guard: (arg1: GuardInfo, arg2?: any) => void
}
export default class DanmakuClient {
@@ -155,10 +172,23 @@ export default class DanmakuClient {
danmaku: ((arg1: DanmakuInfo, arg2?: any) => void)[]
gift: ((arg1: GiftInfo, arg2?: any) => void)[]
sc: ((arg1: SCInfo, arg2?: any) => void)[]
guard: ((arg1: GuardInfo, arg2?: any) => void)[]
} = {
danmaku: [],
gift: [],
sc: [],
guard: [],
}
private eventsAsModel: {
danmaku: ((arg1: EventModel, arg2?: any) => void)[]
gift: ((arg1: EventModel, arg2?: any) => void)[]
sc: ((arg1: EventModel, arg2?: any) => void)[]
guard: ((arg1: EventModel, arg2?: any) => void)[]
} = {
danmaku: [],
gift: [],
sc: [],
guard: [],
}
public async Start(): Promise<{ success: boolean; message: string }> {
@@ -193,6 +223,13 @@ export default class DanmakuClient {
danmaku: [],
gift: [],
sc: [],
guard: [],
}
this.eventsAsModel = {
danmaku: [],
gift: [],
sc: [],
guard: [],
}
}
private sendHeartbeat() {
@@ -210,31 +247,130 @@ export default class DanmakuClient {
}
private onDanmaku = (command: any) => {
const data = command.data as DanmakuInfo
if (this.events.danmaku) {
this.events.danmaku.forEach((d) => {
d(data, command)
})
}
this.events.danmaku?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.danmaku?.forEach((d) => {
d(
{
type: EventDataTypes.Message,
name: data.uname,
uid: data.uid,
msg: data.msg,
price: 0,
num: 0,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
emoji: data.dm_type == 1 ? data.emoji_img_url : undefined,
avatar: data.uface,
},
command
)
})
}
private onGift = (command: any) => {
const data = command.data as GiftInfo
if (this.events.gift) {
this.events.gift.forEach((d) => {
d(data, command)
})
}
const price = (data.price * data.gift_num) / 1000
this.events.gift?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.gift?.forEach((d) => {
d(
{
type: EventDataTypes.Gift,
name: data.uname,
uid: data.uid,
msg: data.gift_name,
price: data.paid ? price : -price,
num: data.gift_num,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
avatar: data.uface,
},
command
)
})
}
private onSC = (command: any) => {
const data = command.data as SCInfo
this.events.sc?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.sc?.forEach((d) => {
d(
{
type: EventDataTypes.SC,
name: data.uname,
uid: data.uid,
msg: data.message,
price: data.rmb,
num: 1,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
avatar: data.uface,
},
command
)
})
}
private onGuard = (command: any) => {
const data = command.data as GuardInfo
this.events.guard?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.guard?.forEach((d) => {
d(
{
type: EventDataTypes.Guard,
name: data.user_info.uname,
uid: data.user_info.uid,
msg: data.guard_level == 1 ? '总督' : data.guard_level == 2 ? '提督' : data.guard_level == 3 ? '舰长' : '',
price: 0,
num: data.guard_num,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
avatar: data.user_info.uface,
},
command
)
})
}
public on(eventName: 'danmaku', listener: DanmakuEventsMap['danmaku']): this
public on(eventName: 'gift', listener: DanmakuEventsMap['gift']): this
public on(eventName: 'sc', listener: DanmakuEventsMap['sc']): this
public on(eventName: 'danmaku' | 'gift' | 'sc', listener: (...args: any[]) => void): this {
public on(eventName: 'guard', listener: DanmakuEventsMap['guard']): this
public on(eventName: 'danmaku' | 'gift' | 'sc' | 'guard', listener: (...args: any[]) => void): this {
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(listener)
return this
}
public off(eventName: 'danmaku' | 'gift' | 'sc', listener: (...args: any[]) => void): this {
public onEvent(eventName: 'danmaku', listener: (arg1: EventModel, arg2?: any) => void): this
public onEvent(eventName: 'gift', listener: (arg1: EventModel, arg2?: any) => void): this
public onEvent(eventName: 'sc', listener: (arg1: EventModel, arg2?: any) => void): this
public onEvent(eventName: 'guard', listener: (arg1: EventModel, arg2?: any) => void): this
public onEvent(eventName: 'danmaku' | 'gift' | 'sc' | 'guard', listener: (...args: any[]) => void): this {
if (!this.eventsAsModel[eventName]) {
this.eventsAsModel[eventName] = []
}
this.eventsAsModel[eventName].push(listener)
return this
}
public off(eventName: 'danmaku' | 'gift' | 'sc' | 'guard', listener: (...args: any[]) => void): this {
if (this.events[eventName]) {
const index = this.events[eventName].indexOf(listener)
if (index > -1) {
@@ -243,6 +379,15 @@ export default class DanmakuClient {
}
return this
}
public offEvent(eventName: 'danmaku' | 'gift' | 'sc' | 'guard', listener: (...args: any[]) => void): this {
if (this.eventsAsModel[eventName]) {
const index = this.eventsAsModel[eventName].indexOf(listener)
if (index > -1) {
this.eventsAsModel[eventName].splice(index, 1)
}
}
return this
}
private async initClient(): Promise<{ success: boolean; message: string }> {
const auth = await this.getAuthInfo()
if (auth.data) {
@@ -291,5 +436,7 @@ export default class DanmakuClient {
private CMD_CALLBACK_MAP = {
LIVE_OPEN_PLATFORM_DM: this.onDanmaku.bind(this),
LIVE_OPEN_PLATFORM_SEND_GIFT: this.onGift.bind(this),
LIVE_OPEN_PLATFORM_SUPER_CHAT: this.onSC.bind(this),
LIVE_OPEN_PLATFORM_GUARD: this.onGuard.bind(this),
}
}

2
src/data/Speech.ts Normal file
View File

@@ -0,0 +1,2 @@
import EasySpeech from 'easy-speech'

View File

@@ -30,6 +30,7 @@ export const QUEUE_API_URL = { toString: () => `${BASE_API()}queue/` }
export const EVENT_API_URL = { toString: () => `${BASE_API()}event/` }
export const LIVE_API_URL = { toString: () => `${BASE_API()}live/` }
export const FEEDBACK_API_URL = { toString: () => `${BASE_API()}feedback/` }
export const VTSURU_API_URL = { toString: () => `${BASE_API()}vtsuru/` }
export const ScheduleTemplateMap = {
'': { name: '默认', compoent: defineAsyncComponent(() => import('@/views/view/scheduleTemplate/DefaultScheduleTemplate.vue')) },

View File

@@ -6,6 +6,7 @@ import router from './router'
import { GetSelfAccount, UpdateAccountLoop } from './api/account'
import { GetNotifactions } from './data/notifactions'
import { NText, createDiscreteApi } from 'naive-ui'
import EasySpeech from 'easy-speech'
createApp(App).use(router).mount('#app')
@@ -39,4 +40,19 @@ QueryGetAPI<string>(BASE_API() + 'vtsuru/version')
GetSelfAccount()
GetNotifactions()
UpdateAccountLoop()
InitTTS();
})
function InitTTS() {
try {
const result = EasySpeech.detect()
if (result.speechSynthesis) {
EasySpeech.init({ maxTimeout: 5000, interval: 250 })
.then(() => console.log('[SpeechSynthesis] 已加载tts服务'))
.catch((e) => console.error(e))
} else {
console.log('[SpeechSynthesis] 当前浏览器不支持tts服务')
}
} catch (e) {
console.log('[SpeechSynthesis] 当前浏览器不支持tts服务')
}
}

View File

@@ -210,6 +210,16 @@ const routes: Array<RouteRecordRaw> = [
danmaku: true,
},
},
{
path: 'speech',
name: 'manage-speech',
component: () => import('@/views/open_live/ReadDanmaku.vue'),
meta: {
title: '读弹幕',
keepAlive: true,
danmaku: true,
},
},
{
path: 'live',
name: 'manage-live',
@@ -274,6 +284,14 @@ const routes: Array<RouteRecordRaw> = [
title: '排队',
},
},
{
path: 'speech',
name: 'open-live-speech',
component: () => import('@/views/open_live/ReadDanmaku.vue'),
meta: {
title: '读弹幕',
},
},
],
},
{

View File

@@ -35,6 +35,7 @@ import { NButton, NCard, NDivider, NLayoutContent, NSpace, NText, NTimeline, NTi
</NSpace>
<NDivider title-placement="left"> 更新日志 </NDivider>
<NTimeline>
<NTimelineItem type="success" title="功能添加" content="读弹幕" time="2023-12-17" />
<NTimelineItem type="success" title="功能添加" content="直播记录" time="2023-12-3" />
<NTimelineItem type="info" title="功能更新" content="歌单添加 '简单' 模板" time="2023-11-30" />
<NTimelineItem type="success" title="功能添加" content="排队" time="2023-11-25" />

View File

@@ -3,7 +3,7 @@ import { NCard, NDivider, NGradientText, NSpace, NText, NIcon, NGrid, NGridItem,
import vtb from '@/svgs/ic_vtuber.svg'
import { AnalyticsSharp, Calendar, Chatbox, ListCircle, MusicalNote } from '@vicons/ionicons5'
import { useWindowSize } from '@vueuse/core'
import { Lottery24Filled, MoneyOff24Filled, MoreHorizontal24Filled, VehicleShip24Filled, VideoAdd20Filled } from '@vicons/fluent'
import { Lottery24Filled, MoneyOff24Filled, MoreHorizontal24Filled, TabletSpeaker24Filled, VehicleShip24Filled, VideoAdd20Filled } from '@vicons/fluent'
const { width } = useWindowSize()
@@ -53,6 +53,11 @@ const functions = [
desc: '通过发送弹幕和礼物加入队列, 允许设置多种条件',
icon: ListCircle,
},
{
name: '读弹幕',
desc: '通过浏览器自带的tts服务念出弹幕 (此功能需要 Chrome, Edge 等现代浏览器!)',
icon: TabletSpeaker24Filled,
},
{
name: '视频征集',
desc: '创建用来收集视频链接的页面, 可以从动态爬取, 也可以提前对视频进行筛选',

View File

@@ -22,7 +22,7 @@ import {
} from 'naive-ui'
import { h, onMounted, ref } from 'vue'
import { BrowsersOutline, Chatbox, Moon, MusicalNote, Sunny, AnalyticsSharp } from '@vicons/ionicons5'
import { CalendarClock24Filled, Chat24Filled, Info24Filled, Live24Filled, Lottery24Filled, PeopleQueue24Filled, PersonFeedback24Filled, VehicleShip24Filled, VideoAdd20Filled } from '@vicons/fluent'
import { CalendarClock24Filled, Chat24Filled, Info24Filled, Live24Filled, Lottery24Filled, PeopleQueue24Filled, PersonFeedback24Filled, TabletSpeaker24Filled, VehicleShip24Filled, VideoAdd20Filled } from '@vicons/fluent'
import { isLoadingAccount, useAccount } from '@/api/account'
import RegisterAndLogin from '@/components/RegisterAndLogin.vue'
import { RouterLink, useRoute } from 'vue-router'
@@ -280,6 +280,21 @@ const menuOptions = [
icon: renderIcon(PeopleQueue24Filled),
//disabled: accountInfo.value?.isEmailVerified == false,
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'manage-speech',
},
},
{ default: () => '读弹幕' }
),
key: 'manage-speech',
icon: renderIcon(TabletSpeaker24Filled),
//disabled: accountInfo.value?.isEmailVerified == false,
},
],
},
]

View File

@@ -2,7 +2,7 @@
import { isDarkMode } from '@/Utils'
import { OpenLiveInfo, ThemeType } from '@/api/api-models'
import DanmakuClient, { AuthInfo } from '@/data/DanmakuClient'
import { Lottery24Filled, PeopleQueue24Filled } from '@vicons/fluent'
import { Lottery24Filled, PeopleQueue24Filled, TabletSpeaker24Filled } from '@vicons/fluent'
import { Moon, MusicalNote, Sunny } from '@vicons/ionicons5'
import { useElementSize, useStorage } from '@vueuse/core'
import {
@@ -74,7 +74,8 @@ const menuOptions = [
),
key: 'open-live-song-request',
icon: renderIcon(MusicalNote),
},{
},
{
label: () =>
h(
RouterLink,
@@ -89,6 +90,21 @@ const menuOptions = [
key: 'open-live-queue',
icon: renderIcon(PeopleQueue24Filled),
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'open-live-speech',
query: route.query,
},
},
{ default: () => '读弹幕' }
),
key: 'open-live-speech',
icon: renderIcon(TabletSpeaker24Filled),
},
]
function renderIcon(icon: unknown) {

View File

@@ -33,6 +33,13 @@ const accountInfo = useAccount()
<NButton @click="$router.push({ name: 'open-live-queue', query: $route.query })" type="primary"> 前往使用 </NButton>
</template>
</NCard>
<NCard hoverable embedded size="small" title="读弹幕" style="width: 300px">
通过浏览器自带的tts服务读弹幕 (此功能需要 Chrome, Edge 等现代浏览器!)
<template #footer>
<NButton @click="$router.push({ name: 'open-live-speech', query: $route.query })" type="primary"> 前往使用 </NButton>
</template>
</NCard>
</NSpace>
<br />
<NAlert v-if="accountInfo?.eventFetcherOnline != true" type="warning" title="可用性警告" style="max-width: 600px; margin: 0 auto">

View File

@@ -8,29 +8,20 @@ import {
QueueGiftFilterType,
QueueSortType,
Setting_Queue,
Setting_SongRequest,
SongFrom,
QueueFrom,
SongRequestInfo,
QueueStatus,
DanmakuUserInfo,
SongsInfo,
ResponseQueueModel,
} from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI, QueryPostAPIWithParams } from '@/api/query'
import DanmakuClient, { AuthInfo, DanmakuInfo, GiftInfo, RoomAuthInfo, SCInfo } from '@/data/DanmakuClient'
import { OPEN_LIVE_API_URL, SONG_API_URL, QUEUE_API_URL } from '@/data/constants'
import { QUEUE_API_URL } from '@/data/constants'
import {
Check24Filled,
Checkmark12Regular,
ClipboardTextLtr24Filled,
Delete24Filled,
Dismiss12Filled,
Dismiss16Filled,
Info24Filled,
Mic24Filled,
PeopleQueue24Filled,
Play24Filled,
PresenceBlocked16Regular,
} from '@vicons/fluent'
import { ReloadCircleSharp } from '@vicons/ionicons5'
@@ -47,7 +38,6 @@ import {
NCollapseItem,
NDataTable,
NDivider,
NEllipsis,
NEmpty,
NIcon,
NInput,
@@ -59,7 +49,6 @@ import {
NListItem,
NModal,
NPopconfirm,
NRadio,
NRadioButton,
NRadioGroup,
NSelect,
@@ -78,7 +67,6 @@ import {
} from 'naive-ui'
import { computed, h, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import SongRequestOBS from '../obs/SongRequestOBS.vue'
import QueueOBS from '../obs/QueueOBS.vue'
const defaultSettings = {

View File

@@ -0,0 +1,438 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import EasySpeech from 'easy-speech'
import { NButton, NDivider, NIcon, NInput, NInputGroup, NInputGroupLabel, NPopconfirm, NSelect, NSlider, NSpace, NTag, NText, NTooltip, useMessage } from 'naive-ui'
import { useRoute } from 'vue-router'
import { useStorage } from '@vueuse/core'
import { Queue } from 'queue-typescript'
import DanmakuClient, { DanmakuInfo, RoomAuthInfo } from '@/data/DanmakuClient'
import { EventDataTypes, EventModel } from '@/api/api-models'
import { useAccount } from '@/api/account'
import { Mic24Filled } from '@vicons/fluent'
import { copyToClipboard } from '@/Utils'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import { VTSURU_API_URL } from '@/data/constants'
const props = defineProps<{
client: DanmakuClient
roomInfo: RoomAuthInfo
code: string | undefined
isOpenLive?: boolean
}>()
type SpeechSettings = {
speechInfo: SpeechInfo
danmakuTemplate: string
scTemplate: string
guardTemplate: string
giftTemplate: string
}
type SpeechInfo = {
volume: number
pitch: number
rate: number
voice: string
}
const accountInfo = useAccount()
const message = useMessage()
const route = useRoute()
const settings = useStorage<SpeechSettings>('Setting.Speech.Settings', {
speechInfo: {
volume: 1,
pitch: 1,
rate: 1,
voice: '',
},
danmakuTemplate: '{name} 说: {message}',
scTemplate: '{name} 发送了醒目留言: {message}',
guardTemplate: '感谢 {name} 的 {count} 个月 {guard_level}',
giftTemplate: '感谢 {name} 赠送的 {count} 个 {gift_name}',
})
const speechSynthesisInfo = ref<{
speechSynthesis: SpeechSynthesis | undefined
speechSynthesisUtterance: SpeechSynthesisUtterance | undefined
speechSynthesisVoice: SpeechSynthesisVoice | undefined
speechSynthesisEvent: SpeechSynthesisEvent | undefined
speechSynthesisErrorEvent: SpeechSynthesisErrorEvent | undefined
onvoiceschanged: boolean
onboundary: boolean
onend: boolean
onerror: boolean
onmark: boolean
onpause: boolean
onresume: boolean
onstart: boolean
}>()
const languageDisplayName = new Intl.DisplayNames(['zh'], { type: 'language' })
const voiceOptions = computed(() => {
return EasySpeech.voices().map((v) => {
return {
label: `[${languageDisplayName.of(v.lang)}] ${v.name}`,
value: v.name,
}
})
})
const isSpeaking = ref(false)
const speakQueue = new Queue<string>()
const canSpeech = ref(false)
const readedDanmaku = ref(0)
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,
},
}
const speechCount = ref(0)
async function speak() {
if (isSpeaking.value) {
return
}
const text = speakQueue.dequeue()
if (text) {
isSpeaking.value = true
speechCount.value--
readedDanmaku.value++
console.log(`[TTS] 正在朗读: ${text}`)
await EasySpeech.speak({
text: text,
volume: settings.value.speechInfo.volume,
pitch: settings.value.speechInfo.pitch,
rate: settings.value.speechInfo.rate,
voice: EasySpeech.voices().find((v) => v.name == settings.value.speechInfo.voice) ?? undefined,
})
.then(() => {})
.catch((error) => {
if (error.error == 'interrupted') {
//被中断
return
}
console.log(error)
message.error('无法播放语音: ' + error.error)
})
.finally(() => {
isSpeaking.value = false
})
}
}
function onGetEvent(data: EventModel) {
if (!canSpeech.value) {
return
}
if (data.type == EventDataTypes.Message && (data.emoji || /^(?:\[\w+\])+$/.test(data.msg))) {
// 不支持表情
return
}
onGetEventInternal(data)
}
function onGetEventInternal(data: EventModel) {
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
}
text = text
.replace(templateConstants.name.regex, data.name)
.replace(templateConstants.count.regex, data.num.toString())
.replace(templateConstants.price.regex, data.price.toString())
.replace(templateConstants.message.regex, data.msg)
.replace(templateConstants.guard_level.regex, data.guard_level == 1 ? '总督' : data.guard_level == 2 ? '提督' : data.guard_level == 3 ? '舰长' : '')
.replace(templateConstants.fans_medal_level.regex, data.fans_medal_level.toString())
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.toString())
}
speakQueue.enqueue(text)
speechCount.value++
}
function startSpeech() {
canSpeech.value = true
message.success('服务已启动')
}
function stopSpeech() {
canSpeech.value = false
message.success('已停止监听')
}
function cancelSpeech() {
EasySpeech.cancel()
}
async function uploadConfig() {
await QueryPostAPI(VTSURU_API_URL + 'set-config', {
name: 'Speech',
json: JSON.stringify(settings.value),
})
.then((data) => {
if (data.code == 200) {
message.success('已保存至服务器')
} else {
message.error('保存失败: ' + data.message)
}
})
.catch((err) => {
message.error('保存失败')
})
}
async function downloadConfig() {
await QueryGetAPI<string>(VTSURU_API_URL + 'get-config', {
name: 'Speech',
})
.then((data) => {
if (data.code == 200) {
settings.value = JSON.parse(data.data)
message.success('已获取配置文件')
} else if (data.code == 404) {
message.error('未上传配置文件')
} else {
message.error('获取失败: ' + data.message)
}
})
.catch((err) => {
message.error('获取失败')
})
}
function test(type: EventDataTypes) {
switch (type) {
case EventDataTypes.Message:
onGetEventInternal({
type: EventDataTypes.Message,
name: 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,
avatar: '',
})
break
case EventDataTypes.SC:
onGetEventInternal({
type: EventDataTypes.SC,
name: accountInfo.value?.name ?? '未知用户',
uid: accountInfo.value?.biliId ?? 0,
msg: '测试sc',
price: 30,
num: 1,
time: Date.now(),
guard_level: 0,
fans_medal_level: 1,
fans_medal_name: '',
fans_medal_wearing_status: false,
emoji: undefined,
avatar: '',
})
break
case EventDataTypes.Guard:
onGetEventInternal({
type: EventDataTypes.Guard,
name: accountInfo.value?.name ?? '未知用户',
uid: accountInfo.value?.biliId ?? 0,
msg: '舰长',
price: 0,
num: 1,
time: Date.now(),
guard_level: 3,
fans_medal_level: 1,
fans_medal_name: '',
fans_medal_wearing_status: false,
emoji: undefined,
avatar: '',
})
break
case EventDataTypes.Gift:
onGetEventInternal({
type: EventDataTypes.Gift,
name: accountInfo.value?.name ?? '未知用户',
uid: accountInfo.value?.biliId ?? 0,
msg: '测试礼物',
price: 5,
num: 5,
time: Date.now(),
guard_level: 0,
fans_medal_level: 1,
fans_medal_name: '',
fans_medal_wearing_status: false,
emoji: undefined,
avatar: '',
})
break
}
}
let speechQueueTimer: any
onMounted(() => {
speechSynthesisInfo.value = EasySpeech.detect()
speechQueueTimer = setInterval(() => {
speak()
}, 100)
props.client.onEvent('danmaku', onGetEvent)
props.client.onEvent('sc', onGetEvent)
props.client.onEvent('guard', onGetEvent)
props.client.onEvent('gift', onGetEvent)
})
onUnmounted(() => {
clearInterval(speechQueueTimer)
props.client.offEvent('danmaku', onGetEvent)
props.client.offEvent('sc', onGetEvent)
props.client.offEvent('guard', onGetEvent)
props.client.offEvent('gift', onGetEvent)
})
</script>
<template>
<NSpace>
<NButton @click="canSpeech ? stopSpeech() : startSpeech()" :type="canSpeech ? 'error' : 'primary'"> {{ canSpeech ? '停止监听' : '开始监听' }} </NButton>
<NButton @click="uploadConfig" type="primary" secondary> 保存配置到服务器 </NButton>
<NPopconfirm @positive-click="downloadConfig">
<template #trigger>
<NButton type="primary" secondary> 从服务器获取配置 </NButton>
</template>
这将覆盖当前设置, 确定?
</NPopconfirm>
</NSpace>
<template v-if="canSpeech">
<NDivider> 状态 </NDivider>
<NSpace vertical align="center">
<NTooltip>
<template #trigger>
<NButton circle :disabled="!isSpeaking" @click="cancelSpeech" :style="`animation: ${isSpeaking ? 'animated-border 2.5s infinite;' : ''}`">
<template #icon>
<NIcon :component="Mic24Filled" :color="isSpeaking ? 'green' : 'gray'" />
</template>
</NButton>
</template>
{{ isSpeaking ? '取消朗读' : '未朗读' }}
</NTooltip>
<NText depth="3"> 队列: {{ speechCount }} <NDivider vertical /> 已读: {{readedDanmaku }} </NText>
</NSpace>
</template>
<NDivider />
<NSpace vertical>
<NSelect v-model:value="settings.speechInfo.voice" :options="voiceOptions" :fallback-option="() => ({ label: '未选择, 将使用默认语音', value: '' })" />
<span style="width: 100%">
<NText> 音量 </NText>
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.volume" :min="0" :max="1" :step="0.01" />
</span>
<span style="width: 100%">
<NText> 音调 </NText>
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.pitch" :min="0" :max="2" :step="0.01" />
</span>
<span style="width: 100%">
<NText> 语速 </NText>
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.rate" :min="0" :max="2" :step="0.01" />
</span>
</NSpace>
<NDivider> 自定义内容 </NDivider>
<NSpace vertical>
<NSpace>
支持的变量:
<NButton size="tiny" secondary v-for="item in Object.values(templateConstants)" :key="item.name" @click="copyToClipboard(item.words)"> {{ item.words }} | {{ item.name }} </NButton>
</NSpace>
<NInputGroup>
<NInputGroupLabel> 弹幕模板 </NInputGroupLabel>
<NInput v-model:value="settings.danmakuTemplate" placeholder="弹幕消息" />
<NButton @click="test(EventDataTypes.Message)" type="info"> 测试 </NButton>
</NInputGroup>
<NInputGroup>
<NInputGroupLabel> 礼物模板 </NInputGroupLabel>
<NInput v-model:value="settings.giftTemplate" placeholder="礼物消息" />
<NButton @click="test(EventDataTypes.Gift)" type="info"> 测试 </NButton>
</NInputGroup>
<NInputGroup>
<NInputGroupLabel> SC模板 </NInputGroupLabel>
<NInput v-model:value="settings.scTemplate" placeholder="SC消息" />
<NButton @click="test(EventDataTypes.SC)" type="info"> 测试 </NButton>
</NInputGroup>
<NInputGroup>
<NInputGroupLabel> 上舰模板 </NInputGroupLabel>
<NInput v-model:value="settings.guardTemplate" placeholder="上舰消息" />
<NButton @click="test(EventDataTypes.Guard)" type="info"> 测试 </NButton>
</NInputGroup>
</NSpace>
<NDivider> 设置 </NDivider>
<NText depth="3">
没想好需要什么, 有建议的话可以和我说
</NText>
</template>
<style>
@keyframes animated-border {
0% {
box-shadow: 0 0 0px #589580;
}
100% {
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0);
}
}
</style>