update music request footer

This commit is contained in:
2023-12-28 12:25:27 +08:00
parent 8c482f8f19
commit 0baa458e89
6 changed files with 333 additions and 218 deletions

View File

@@ -1,9 +1,16 @@
import mitt, { Emitter } from 'mitt' import mitt, { Emitter } from 'mitt'
import { Music } from './store/useMusicRequest';
declare type MittType<T = any> = { declare type MittType<T = any> = {
onOpenTemplateSettings: { onOpenTemplateSettings: {
template: string, template: string,
},
onMusicRequestPlayerEnded: {
music: Music
}
onMusicRequestPlayNextWaitingMusic: {
} }
}; };
// 类型 // 类型

View File

@@ -226,7 +226,7 @@ const routes: Array<RouteRecordRaw> = [
name: 'manage-musicRequest', name: 'manage-musicRequest',
component: () => import('@/views/open_live/MusicRequest.vue'), component: () => import('@/views/open_live/MusicRequest.vue'),
meta: { meta: {
title: '点歌 (放歌', title: '点歌 (点播',
keepAlive: true, keepAlive: true,
danmaku: true, danmaku: true,
}, },

View File

@@ -0,0 +1,160 @@
import { DanmakuUserInfo, SongFrom, SongsInfo } from '@/api/api-models'
import { useStorage } from '@vueuse/core'
import { useMessage } from 'naive-ui'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export type WaitMusicInfo = {
from: DanmakuUserInfo
music: SongsInfo
}
export type Music = {
id: number
title: string
artist: string
src: string
pic: string
lrc: string
}
export type MusicRequestSettings = {
playMusicWhenFree: boolean
repeat: 'repeat-one' | 'repeat-all' | 'no-repeat'
listMaxHeight: string
shuffle: boolean
volume: number
orderPrefix: string
orderCooldown?: number
orderMusicFirst: boolean
platform: 'netease' | 'kugou'
deviceId?: string
blacklist: string[]
}
export const useMusicRequestProvider = defineStore('MusicRequest', () => {
const waitingMusics = useStorage<WaitMusicInfo[]>('Setting.MusicRequest.Waiting', [])
const originMusics = ref<SongsInfo[]>([])
const aplayerMusics = computed(() => originMusics.value.map((m) => songToMusic(m)))
const currentMusic = ref<Music>({
id: -1,
title: '',
artist: '',
src: '',
pic: '',
lrc: '',
} as Music)
const currentOriginMusic = ref<WaitMusicInfo>()
const isPlayingOrderMusic = ref(false)
const aplayerRef = ref()
const settings = useStorage<MusicRequestSettings>('Setting.MusicRequest', {
playMusicWhenFree: true,
repeat: 'repeat-all',
listMaxHeight: '300',
shuffle: true,
volume: 0.5,
orderPrefix: '点歌',
orderCooldown: 600,
orderMusicFirst: true,
platform: 'netease',
blacklist: [],
})
const message = useMessage()
function addWaitingMusic(info: WaitMusicInfo) {
if ((settings.value.orderMusicFirst && !isPlayingOrderMusic.value) || originMusics.value.length == 0 || aplayerRef.value?.audio.paused) {
playMusic(info.music)
console.log(`正在播放 [${info.from.name}] 点的 ${info.music.name} - ${info.music.author?.join('/')}`)
} else {
waitingMusics.value.push(info)
message.success(`[${info.from.name}] 点了一首 ${info.music.name} - ${info.music.author?.join('/')}`)
}
}
function onMusicEnd() {
if (!playWaitingMusic()) {
isPlayingOrderMusic.value = false
if (currentOriginMusic) {
currentOriginMusic.value = undefined
}
setTimeout(() => {
if (!settings.value.playMusicWhenFree) {
message.info('根据配置,已暂停播放音乐')
currentMusic.value = aplayerMusics.value[0]
pauseMusic()
}
}, 1)
}
}
function onMusicPlay() {}
function playWaitingMusic() {
const info = waitingMusics.value.shift()
if (info) {
message.success(`正在播放 [${info.from.name}] 点的 ${info.music.name}`)
console.log(`正在播放 [${info.from.name}] 点的 ${info.music.name}`)
setTimeout(() => {
isPlayingOrderMusic.value = true
const index = waitingMusics.value.indexOf(info)
if (index > -1) {
waitingMusics.value.splice(index, 1)
}
currentOriginMusic.value = info
aplayerRef.value?.pause()
playMusic(info.music)
}, 10)
return true
} else {
return false
}
}
function playMusic(music: SongsInfo) {
//pauseMusic()
currentMusic.value = songToMusic(music)
aplayerRef.value?.thenPlay()
}
function pauseMusic() {
if (!aplayerRef.value?.audio.paused) {
aplayerRef.value?.pause()
}
}
function songToMusic(s: SongsInfo) {
return {
id: s.id,
title: s.name,
artist: s.author?.join('/'),
src: s.from == SongFrom.Netease ? `https://music.163.com/song/media/outer/url?id=${s.id}.mp3` : s.url,
pic: s.cover ?? '',
lrc: '',
} as Music
}
function setSinkId() {
try {
aplayerRef.value?.audio.setSinkId(settings.value.deviceId ?? 'default')
console.log('设置音频输出设备为 ' + (settings.value.deviceId ?? '默认'))
} catch (err) {
console.error(err)
message.error('设置音频输出设备失败: ' + err)
}
}
return {
waitingMusics,
originMusics,
aplayerMusics,
currentMusic,
currentOriginMusic,
isPlayingOrderMusic,
settings,
setSinkId,
playWaitingMusic,
playMusic,
addWaitingMusic,
onMusicEnd,
onMusicPlay,
pauseMusic,
aplayerRef,
}
})

View File

@@ -5,6 +5,7 @@ import { ThemeType } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query' import { QueryGetAPI } from '@/api/query'
import RegisterAndLogin from '@/components/RegisterAndLogin.vue' import RegisterAndLogin from '@/components/RegisterAndLogin.vue'
import { ACCOUNT_API_URL } from '@/data/constants' import { ACCOUNT_API_URL } from '@/data/constants'
import { useMusicRequestProvider } from '@/store/useMusicRequest'
import { import {
CalendarClock24Filled, CalendarClock24Filled,
Chat24Filled, Chat24Filled,
@@ -28,6 +29,7 @@ import {
NIcon, NIcon,
NLayout, NLayout,
NLayoutContent, NLayoutContent,
NLayoutFooter,
NLayoutHeader, NLayoutHeader,
NLayoutSider, NLayoutSider,
NMenu, NMenu,
@@ -37,12 +39,14 @@ import {
NSpace, NSpace,
NSpin, NSpin,
NSwitch, NSwitch,
NTag,
NText, NText,
NTooltip, NTooltip,
useMessage, useMessage,
} from 'naive-ui' } from 'naive-ui'
import { computed, h, onMounted, ref } from 'vue' import { computed, h, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import APlayer from 'vue3-aplayer'
import DanmakuLayout from './manage/DanmakuLayout.vue' import DanmakuLayout from './manage/DanmakuLayout.vue'
const accountInfo = useAccount() const accountInfo = useAccount()
@@ -60,9 +64,18 @@ const type = computed(() => {
return '' return ''
}) })
const cookie = useStorage('JWT_Token', '') const cookie = useStorage('JWT_Token', '')
const musicRquestStore = useMusicRequestProvider()
const canResendEmail = ref(false) const canResendEmail = ref(false)
const aplayerHeight = computed(() => {
return musicRquestStore.originMusics.length == 0 ? '0' : '80'
})
const aplayer = ref()
watch(aplayer, () => {
musicRquestStore.aplayerRef = aplayer.value
})
function renderIcon(icon: unknown) { function renderIcon(icon: unknown) {
return () => h(NIcon, null, { default: () => h(icon as any) }) return () => h(NIcon, null, { default: () => h(icon as any) })
} }
@@ -276,7 +289,7 @@ const menuOptions = [
}, },
), ),
]), ]),
default: () => accountInfo.value?.isBiliVerified ? '需要使用直播弹幕的功能' : '你尚未进行 Bilibili 认证, 请前往面板进行绑定', default: () => (accountInfo.value?.isBiliVerified ? '需要使用直播弹幕的功能' : '你尚未进行 Bilibili 认证, 请前往面板进行绑定'),
}, },
), ),
key: 'manage-danmaku', key: 'manage-danmaku',
@@ -428,7 +441,6 @@ onMounted(() => {
</template> </template>
</NPageHeader> </NPageHeader>
</NLayoutHeader> </NLayoutHeader>
<NScrollbar x-scrollable>
<NLayout has-sider> <NLayout has-sider>
<NLayoutSider ref="sider" bordered show-trigger collapse-mode="width" :default-collapsed="windowWidth < 750" :collapsed-width="64" :width="180" :native-scrollbar="false"> <NLayoutSider ref="sider" bordered show-trigger collapse-mode="width" :default-collapsed="windowWidth < 750" :collapsed-width="64" :width="180" :native-scrollbar="false">
<NSpace justify="center" style="margin-top: 16px"> <NSpace justify="center" style="margin-top: 16px">
@@ -467,9 +479,9 @@ onMounted(() => {
</NText> </NText>
</NSpace> </NSpace>
</NLayoutSider> </NLayoutSider>
<NScrollbar style="height: calc(100vh - 50px)">
<NLayout> <NLayout>
<div style="box-sizing: border-box; padding: 20px; min-width: 300px"> <NScrollbar :style="`height: calc(100vh - 50px - ${aplayerHeight}px)`">
<NLayoutContent style="box-sizing: border-box; padding: 20px; min-width: 300px">
<RouterView v-if="accountInfo?.isEmailVerified" v-slot="{ Component, route }"> <RouterView v-if="accountInfo?.isEmailVerified" v-slot="{ Component, route }">
<KeepAlive> <KeepAlive>
<DanmakuLayout v-if="route.meta.danmaku" :component="Component" /> <DanmakuLayout v-if="route.meta.danmaku" :component="Component" />
@@ -499,11 +511,33 @@ onMounted(() => {
</NAlert> </NAlert>
</template> </template>
<NBackTop /> <NBackTop />
</NLayoutContent>
</NScrollbar>
<NLayoutFooter :style="`height: ${aplayerHeight}px;overflow: auto`">
<div style="display: flex; align-items: center; margin: 0 10px 0 10px">
<APlayer
v-if="musicRquestStore.aplayerMusics.length > 0"
ref="aplayer"
:list="musicRquestStore.aplayerMusics"
v-model:music="musicRquestStore.currentMusic"
v-model:volume="musicRquestStore.settings.volume"
v-model:shuffle="musicRquestStore.settings.shuffle"
v-model:repeat="musicRquestStore.settings.repeat"
:listMaxHeight="'200'"
mutex
listFolded
@ended="musicRquestStore.onMusicEnd"
@play="musicRquestStore.onMusicPlay"
style="flex: 1;min-width: 400px;"
/>
<NSpace vertical>
<NTag :bordered="false" type="info" size="small"> 队列: {{ musicRquestStore.waitingMusics.length }} </NTag>
<NButton size="small" type="info" @click="musicRquestStore.waitingMusics.length > 0 ? musicRquestStore.onMusicEnd() : musicRquestStore.aplayerRef?.onAudioEnded()"> 下一首 </NButton>
</NSpace>
</div> </div>
</NLayoutFooter>
</NLayout> </NLayout>
</NScrollbar>
</NLayout> </NLayout>
</NScrollbar>
</NLayout> </NLayout>
<template v-else> <template v-else>
<NLayoutContent style="display: flex; justify-content: center; align-items: center; flex-direction: column; padding: 50px; height: 100%; box-sizing: border-box"> <NLayoutContent style="display: flex; justify-content: center; align-items: center; flex-direction: column; padding: 50px; height: 100%; box-sizing: border-box">

View File

@@ -4,6 +4,7 @@ import { DanmakuUserInfo, EventModel, FunctionTypes, SongFrom, SongsInfo } from
import { QueryGetAPI, QueryPostAPI } from '@/api/query' import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import DanmakuClient, { RoomAuthInfo } from '@/data/DanmakuClient' import DanmakuClient, { RoomAuthInfo } from '@/data/DanmakuClient'
import { MUSIC_REQUEST_API_URL, SONG_API_URL } from '@/data/constants' import { MUSIC_REQUEST_API_URL, SONG_API_URL } from '@/data/constants'
import { MusicRequestSettings, useMusicRequestProvider } from '@/store/useMusicRequest'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { List } from 'linqts' import { List } from 'linqts'
import { import {
@@ -37,27 +38,10 @@ import {
SelectOption, SelectOption,
useMessage, useMessage,
} from 'naive-ui' } from 'naive-ui'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import APlayer from 'vue3-aplayer'
import { clearInterval, setInterval } from 'worker-timers' import { clearInterval, setInterval } from 'worker-timers'
import MusicRequestOBS from '../obs/MusicRequestOBS.vue' import MusicRequestOBS from '../obs/MusicRequestOBS.vue'
type MusicRequestSettings = {
playMusicWhenFree: boolean
repeat: 'repeat-one' | 'repeat-all' | 'no-repeat'
listMaxHeight: string
shuffle: boolean
volume: number
orderPrefix: string
orderCooldown?: number
orderMusicFirst: boolean
platform: 'netease' | 'kugou'
deviceId?: string
blacklist: string[]
}
type Music = { type Music = {
id: number id: number
title: string title: string
@@ -71,21 +55,11 @@ type WaitMusicInfo = {
music: SongsInfo music: SongsInfo
} }
const settings = useStorage<MusicRequestSettings>('Setting.MusicRequest', { const settings = computed(() => {
playMusicWhenFree: true, return musicRquestStore.settings
repeat: 'repeat-all',
listMaxHeight: '300',
shuffle: true,
volume: 0.5,
orderPrefix: '点歌',
orderCooldown: 600,
orderMusicFirst: true,
platform: 'netease',
blacklist: [],
}) })
const cooldown = useStorage<{ [id: number]: number }>('Setting.MusicRequest.Cooldown', {}) const cooldown = useStorage<{ [id: number]: number }>('Setting.MusicRequest.Cooldown', {})
const musicRquestStore = useMusicRequestProvider()
const props = defineProps<{ const props = defineProps<{
client: DanmakuClient client: DanmakuClient
@@ -99,28 +73,9 @@ const deviceList = ref<SelectOption[]>([])
const accountInfo = useAccount() const accountInfo = useAccount()
const message = useMessage() const message = useMessage()
const aplayer = ref()
const listening = ref(false) const listening = ref(false)
const originMusics = computed(() => {
const originMusics = ref<SongsInfo[]>(await get()) return musicRquestStore.originMusics
const waitingMusics = useStorage<WaitMusicInfo[]>('Setting.MusicRequest.Waiting', [])
const musics = computed(() => {
return originMusics.value.map((s) => songToMusic(s))
})
const currentMusic = ref<Music>({
id: -1,
title: '',
artist: '',
src: '',
pic: '',
lrc: '',
} as Music)
const currentOriginMusic = ref<WaitMusicInfo>()
watch(currentOriginMusic, (music) => {
if (music) {
currentMusic.value = songToMusic(music.music)
}
}) })
const isLoading = ref(false) const isLoading = ref(false)
@@ -242,7 +197,7 @@ function delMusic(song: SongsInfo) {
.then((data) => { .then((data) => {
if (data.code == 200) { if (data.code == 200) {
message.success('已删除') message.success('已删除')
originMusics.value = originMusics.value.filter((s) => s.key != song.key) musicRquestStore.originMusics = originMusics.value.filter((s) => s.key != song.key)
} else { } else {
message.error('删除失败: ' + data.message) message.error('删除失败: ' + data.message)
} }
@@ -256,7 +211,7 @@ function clearMusic() {
.then((data) => { .then((data) => {
if (data.code == 200) { if (data.code == 200) {
message.success('已清空') message.success('已清空')
originMusics.value = [] musicRquestStore.originMusics = []
} else { } else {
message.error('清空失败: ' + data.message) message.error('清空失败: ' + data.message)
} }
@@ -284,7 +239,7 @@ async function downloadConfig() {
if (data.msg) { if (data.msg) {
message.error('获取失败: ' + data.msg) message.error('获取失败: ' + data.msg)
} else { } else {
settings.value = data.data musicRquestStore.settings = data.data ?? ({} as MusicRequestSettings)
message.success('已获取配置文件') message.success('已获取配置文件')
} }
}) })
@@ -304,10 +259,9 @@ function stopListen() {
listening.value = false listening.value = false
message.success('已停止监听') message.success('已停止监听')
} }
const isPlayingOrderMusic = ref(false)
async function onGetEvent(data: EventModel) { async function onGetEvent(data: EventModel) {
if (!listening.value || !checkMessage(data.msg)) return if (!listening.value || !checkMessage(data.msg)) return
if (settings.value.orderCooldown && cooldown.value[data.uid]) { if (settings.value.orderCooldown && cooldown.value[data.uid] && data.uid != (accountInfo.value?.biliId ?? -1)) {
const lastRequest = cooldown.value[data.uid] const lastRequest = cooldown.value[data.uid]
if (Date.now() - lastRequest < settings.value.orderCooldown * 1000) { if (Date.now() - lastRequest < settings.value.orderCooldown * 1000) {
message.info(`[${data.name}] 冷却中,距离下次点歌还有 ${((settings.value.orderCooldown * 1000 - (Date.now() - lastRequest)) / 1000).toFixed(1)}`) message.info(`[${data.name}] 冷却中,距离下次点歌还有 ${((settings.value.orderCooldown * 1000 - (Date.now() - lastRequest)) / 1000).toFixed(1)}`)
@@ -333,63 +287,13 @@ async function onGetEvent(data: EventModel) {
}, },
music: result, music: result,
} as WaitMusicInfo } as WaitMusicInfo
if ((settings.value.orderMusicFirst && !isPlayingOrderMusic.value) || originMusics.value.length == 0 || aplayer.value?.audio.paused) { musicRquestStore.addWaitingMusic(music)
playWaitingMusic(music)
console.log(`正在播放 [${data.name}] 点的 ${result.name} - ${result.author?.join('/')}`)
} else {
waitingMusics.value.push(music)
message.success(`[${data.name}] 点了一首 ${result.name} - ${result.author?.join('/')}`)
}
} }
} }
function checkMessage(msg: string) { function checkMessage(msg: string) {
return msg.trim().toLowerCase().startsWith(settings.value.orderPrefix.trimStart()) return msg.trim().toLowerCase().startsWith(settings.value.orderPrefix.trimStart())
} }
function onMusicEnd() {
const data = waitingMusics.value.shift()
if (data) {
setTimeout(() => {
playWaitingMusic(data)
message.success(`正在播放 [${data.from.name}] 点的 ${data.music.name}`)
console.log(`正在播放 [${data.from.name}] 点的 ${data.music.name}`)
}, 10) //逆天
} else {
isPlayingOrderMusic.value = false
currentOriginMusic.value = undefined
setTimeout(() => {
if (!settings.value.playMusicWhenFree) {
message.info('根据配置,已暂停播放音乐')
pauseMusic()
}
}, 10)
}
}
function playWaitingMusic(music: WaitMusicInfo) {
isPlayingOrderMusic.value = true
const index = waitingMusics.value.indexOf(music)
if (index > -1) {
waitingMusics.value.splice(index, 1)
}
pauseMusic()
currentOriginMusic.value = music
nextTick(() => {
aplayer.value?.play()
})
}
function pauseMusic() {
if (!aplayer.value?.audio.paused) {
aplayer.value?.pause()
}
}
function setSinkId() {
try {
aplayer.value?.audio.setSinkId(settings.value.deviceId ?? 'default')
console.log('设置音频输出设备为 ' + (settings.value.deviceId ?? '默认'))
} catch (err) {
console.error(err)
message.error('设置音频输出设备失败: ' + err)
}
}
function songToMusic(s: SongsInfo) { function songToMusic(s: SongsInfo) {
return { return {
id: s.id, id: s.id,
@@ -417,32 +321,34 @@ async function getOutputDevice() {
} }
function blockMusic(song: SongsInfo) { function blockMusic(song: SongsInfo) {
settings.value.blacklist.push(song.name) settings.value.blacklist.push(song.name)
waitingMusics.value.splice( musicRquestStore.waitingMusics.splice(
waitingMusics.value.findIndex((m) => m.music == song), musicRquestStore.waitingMusics.findIndex((m) => m.music == song),
1, 1,
) )
message.success(`[${song.name}] 已添加到黑名单`) message.success(`[${song.name}] 已添加到黑名单`)
} }
function updateWaiting() { function updateWaiting() {
QueryPostAPI(MUSIC_REQUEST_API_URL + 'update-waiting', { QueryPostAPI(MUSIC_REQUEST_API_URL + 'update-waiting', {
playing: currentOriginMusic.value, playing: musicRquestStore.currentOriginMusic,
waiting: waitingMusics.value, waiting: musicRquestStore.waitingMusics,
}) })
} }
let timer: number let timer: number
onMounted(async () => { onMounted(async () => {
props.client.onEvent('danmaku', onGetEvent) props.client.onEvent('danmaku', onGetEvent)
if (musicRquestStore.originMusics.length == 0) {
musicRquestStore.originMusics = await get()
}
if (originMusics.value.length > 0) { if (originMusics.value.length > 0) {
currentMusic.value = songToMusic(originMusics.value[0]) musicRquestStore.currentMusic = songToMusic(originMusics.value[0])
} }
await getOutputDevice() await getOutputDevice()
if (deviceList.value.length > 0) { if (deviceList.value.length > 0) {
if (!deviceList.value.find((d) => d.value == settings.value.deviceId)) { if (!deviceList.value.find((d) => d.value == settings.value.deviceId)) {
settings.value.deviceId = undefined settings.value.deviceId = undefined
} else { } else {
setSinkId() musicRquestStore.setSinkId()
} }
} }
timer = setInterval(updateWaiting, 2000) timer = setInterval(updateWaiting, 2000)
@@ -483,26 +389,14 @@ onUnmounted(() => {
</NPopconfirm> </NPopconfirm>
</NSpace> </NSpace>
<NDivider /> <NDivider />
<APlayer
v-if="musics.length > 0 || currentMusic.src"
:list="musics"
v-model:music="currentMusic"
ref="aplayer"
v-model:volume="settings.volume"
v-model:shuffle="settings.shuffle"
v-model:repeat="settings.repeat"
:listMaxHeight="'200'"
mutex
@ended="onMusicEnd"
/>
<NCollapse :default-expanded-names="['1']"> <NCollapse :default-expanded-names="['1']">
<NCollapseItem title="队列" name="1"> <NCollapseItem title="队列" name="1">
<NEmpty v-if="waitingMusics.length == 0"> 暂无 </NEmpty> <NEmpty v-if="musicRquestStore.waitingMusics.length == 0"> 暂无 </NEmpty>
<NList v-else size="small" bordered> <NList v-else size="small" bordered>
<NListItem v-for="item in waitingMusics"> <NListItem v-for="item in musicRquestStore.waitingMusics">
<NSpace align="center"> <NSpace align="center">
<NButton @click="playWaitingMusic(item)" type="primary" secondary size="small"> 播放 </NButton> <NButton @click="musicRquestStore.playMusic(item.music)" type="primary" secondary size="small"> 播放 </NButton>
<NButton @click="waitingMusics.splice(waitingMusics.indexOf(item), 1)" type="error" secondary size="small"> 取消 </NButton> <NButton @click="musicRquestStore.waitingMusics.splice(musicRquestStore.waitingMusics.indexOf(item), 1)" type="error" secondary size="small"> 取消 </NButton>
<NButton @click="blockMusic(item.music)" type="warning" secondary size="small"> 拉黑 </NButton> <NButton @click="blockMusic(item.music)" type="warning" secondary size="small"> 拉黑 </NButton>
<span> <span>
<NTag v-if="item.music.from == SongFrom.Netease" type="success" size="small"> 网易</NTag> <NTag v-if="item.music.from == SongFrom.Netease" type="success" size="small"> 网易</NTag>
@@ -558,7 +452,13 @@ onUnmounted(() => {
</NSpace> </NSpace>
<NSpace> <NSpace>
<NButton @click="getOutputDevice"> 获取输出设备 </NButton> <NButton @click="getOutputDevice"> 获取输出设备 </NButton>
<NSelect v-model:value="settings.deviceId" :options="deviceList" :fallback-option="() => ({ label: '未选择', value: '' })" style="min-width: 200px" @update:value="setSinkId" /> <NSelect
v-model:value="settings.deviceId"
:options="deviceList"
:fallback-option="() => ({ label: '未选择', value: '' })"
style="min-width: 200px"
@update:value="musicRquestStore.setSinkId"
/>
</NSpace> </NSpace>
</NSpace> </NSpace>
</NTabPane> </NTabPane>
@@ -573,7 +473,7 @@ onUnmounted(() => {
<NButton @click="showNeteaseModal = true"> 从网易云歌单导入 </NButton> <NButton @click="showNeteaseModal = true"> 从网易云歌单导入 </NButton>
</NSpace> </NSpace>
<NDivider style="margin: 15px 0 10px 0" /> <NDivider style="margin: 15px 0 10px 0" />
<NEmpty v-if="musics.length == 0"> 暂无 </NEmpty> <NEmpty v-if="musicRquestStore.originMusics.length == 0"> 暂无 </NEmpty>
<NVirtualList v-else :style="`max-height: 1000px`" :item-size="30" :items="originMusics" item-resizable> <NVirtualList v-else :style="`max-height: 1000px`" :item-size="30" :items="originMusics" item-resizable>
<template #default="{ item, index }"> <template #default="{ item, index }">
<p :style="`min-height: ${30}px;width:97%;display:flex;align-items:center;`"> <p :style="`min-height: ${30}px;width:97%;display:flex;align-items:center;`">
@@ -584,6 +484,8 @@ onUnmounted(() => {
</template> </template>
确定删除? 确定删除?
</NPopconfirm> </NPopconfirm>
<NButton type="info" secondary size="small" @click="musicRquestStore.playMusic(item)"> 播放 </NButton>
<NText> {{ item.name }} - {{ item.author?.join('/') }} </NText> <NText> {{ item.name }} - {{ item.author?.join('/') }} </NText>
</NSpace> </NSpace>
</p> </p>

View File

@@ -117,6 +117,7 @@ const voiceOptions = computed(() => {
.ToArray() .ToArray()
}) })
const isSpeaking = ref(false) const isSpeaking = ref(false)
const speakingText = ref('')
const speakQueue = ref<{ updateAt: number; combineCount?: number; data: EventModel }[]>([]) const speakQueue = ref<{ updateAt: number; combineCount?: number; data: EventModel }[]>([])
const isVtsuruVoiceAPI = computed(() => { const isVtsuruVoiceAPI = computed(() => {
return settings.value.voiceType == 'api' && settings.value.voiceAPI?.toLowerCase().trim().startsWith('voice.vtsuru.live') return settings.value.voiceType == 'api' && settings.value.voiceAPI?.toLowerCase().trim().startsWith('voice.vtsuru.live')
@@ -191,6 +192,7 @@ async function speak() {
if (text) { if (text) {
isSpeaking.value = true isSpeaking.value = true
readedDanmaku.value++ readedDanmaku.value++
speakingText.value = text
console.log(`[TTS] 正在朗读: ${text}`) console.log(`[TTS] 正在朗读: ${text}`)
if (checkTimer) { if (checkTimer) {
clearInterval(checkTimer) clearInterval(checkTimer)
@@ -258,7 +260,7 @@ function speakFromAPI(text: string) {
.replace(/\{\{\s*text\s*\}\}/, encodeURIComponent(text))}` .replace(/\{\{\s*text\s*\}\}/, encodeURIComponent(text))}`
const tempURL = new URL(url) const tempURL = new URL(url)
if (isVtsuruVoiceAPI.value && splitter.countGraphemes(tempURL.searchParams.get('text') ?? '') > 50) { if (isVtsuruVoiceAPI.value && splitter.countGraphemes(tempURL.searchParams.get('text') ?? '') > 50) {
message.error('本站提供的测试接口字数不允许超过 50 字. 内容: [' + tempURL.searchParams.get('text') + ']') message.error('本站提供的测试接口字数不允许超过 100 字. 内容: [' + tempURL.searchParams.get('text') + ']')
cancelSpeech() cancelSpeech()
return return
} }
@@ -266,6 +268,10 @@ function speakFromAPI(text: string) {
nextTick(() => { nextTick(() => {
//apiAudio.value?.load() //apiAudio.value?.load()
apiAudio.value?.play().catch((err) => { apiAudio.value?.play().catch((err) => {
if (err.toString().startsWith('AbortError')) {
return
}
console.log(err)
console.log(err) console.log(err)
message.error('无法播放语音:' + err) message.error('无法播放语音:' + err)
cancelSpeech() cancelSpeech()
@@ -283,8 +289,14 @@ function cancelSpeech() {
checkTimer = undefined checkTimer = undefined
} }
isApiAudioLoading.value = false isApiAudioLoading.value = false
apiAudio.value?.pause() pauseAPI()
EasySpeech.cancel() EasySpeech.cancel()
speakingText.value = ''
}
function pauseAPI() {
if (!apiAudio.value?.paused) {
apiAudio.value?.pause()
}
} }
function onGetEvent(data: EventModel) { function onGetEvent(data: EventModel) {
if (!canSpeech.value) { if (!canSpeech.value) {
@@ -671,7 +683,7 @@ onUnmounted(() => {
<NAlert v-if="isVtsuruVoiceAPI" type="success" closable> <NAlert v-if="isVtsuruVoiceAPI" type="success" closable>
看起来你正在使用本站提供的测试API (voice.vtsuru.live), 这个接口将会返回 看起来你正在使用本站提供的测试API (voice.vtsuru.live), 这个接口将会返回
<NButton text type="info" tag="a" href="https://space.bilibili.com/5859321" target="_blank"> Xz乔希 </NButton> <NButton text type="info" tag="a" href="https://space.bilibili.com/5859321" target="_blank"> Xz乔希 </NButton>
训练的 Taffy 模型结果, 不支持部分英文, 仅用于测试 侵删 训练的 Taffy 模型结果, 不支持部分英文, 仅用于测试, 不保证可用性. 侵删
</NAlert> </NAlert>
</NSpace> </NSpace>
<br /> <br />
@@ -689,7 +701,7 @@ onUnmounted(() => {
placeholder="API 地址, 例如 xxx.com/voice/bert-vits2?text={{text}}&id=0 (前面不要带https://)" placeholder="API 地址, 例如 xxx.com/voice/bert-vits2?text={{text}}&id=0 (前面不要带https://)"
:status="/^(?:https?:\/\/)/.test(settings.voiceAPI?.toLowerCase() ?? '') ? 'error' : 'success'" :status="/^(?:https?:\/\/)/.test(settings.voiceAPI?.toLowerCase() ?? '') ? 'error' : 'success'"
/> />
<NButton @click="testAPI" type="info"> 测试 </NButton> <NButton @click="testAPI" type="info" :loading="isApiAudioLoading"> 测试 </NButton>
</NInputGroup> </NInputGroup>
<br /><br /> <br /><br />
<NSpace vertical> <NSpace vertical>
@@ -720,22 +732,22 @@ onUnmounted(() => {
<NInputGroup> <NInputGroup>
<NInputGroupLabel> 弹幕模板 </NInputGroupLabel> <NInputGroupLabel> 弹幕模板 </NInputGroupLabel>
<NInput v-model:value="settings.danmakuTemplate" placeholder="弹幕消息" /> <NInput v-model:value="settings.danmakuTemplate" placeholder="弹幕消息" />
<NButton @click="test(EventDataTypes.Message)" type="info"> 测试 </NButton> <NButton @click="test(EventDataTypes.Message)" type="info" :loading="isApiAudioLoading"> 测试 </NButton>
</NInputGroup> </NInputGroup>
<NInputGroup> <NInputGroup>
<NInputGroupLabel> 礼物模板 </NInputGroupLabel> <NInputGroupLabel> 礼物模板 </NInputGroupLabel>
<NInput v-model:value="settings.giftTemplate" placeholder="礼物消息" /> <NInput v-model:value="settings.giftTemplate" placeholder="礼物消息" />
<NButton @click="test(EventDataTypes.Gift)" type="info"> 测试 </NButton> <NButton @click="test(EventDataTypes.Gift)" type="info" :loading="isApiAudioLoading"> 测试 </NButton>
</NInputGroup> </NInputGroup>
<NInputGroup> <NInputGroup>
<NInputGroupLabel> SC模板 </NInputGroupLabel> <NInputGroupLabel> SC模板 </NInputGroupLabel>
<NInput v-model:value="settings.scTemplate" placeholder="SC消息" /> <NInput v-model:value="settings.scTemplate" placeholder="SC消息" />
<NButton @click="test(EventDataTypes.SC)" type="info"> 测试 </NButton> <NButton @click="test(EventDataTypes.SC)" type="info" :loading="isApiAudioLoading"> 测试 </NButton>
</NInputGroup> </NInputGroup>
<NInputGroup> <NInputGroup>
<NInputGroupLabel> 上舰模板 </NInputGroupLabel> <NInputGroupLabel> 上舰模板 </NInputGroupLabel>
<NInput v-model:value="settings.guardTemplate" placeholder="上舰消息" /> <NInput v-model:value="settings.guardTemplate" placeholder="上舰消息" />
<NButton @click="test(EventDataTypes.Guard)" type="info"> 测试 </NButton> <NButton @click="test(EventDataTypes.Guard)" type="info" :loading="isApiAudioLoading"> 测试 </NButton>
</NInputGroup> </NInputGroup>
</NSpace> </NSpace>
<NDivider> 设置 </NDivider> <NDivider> 设置 </NDivider>