add music request

This commit is contained in:
2023-12-24 17:57:23 +08:00
parent 8419a18b22
commit e615154149
10 changed files with 1019 additions and 46 deletions

View File

@@ -98,25 +98,39 @@ export function downloadConfigDirect<T>(name: string) {
name: name,
})
}
export async function downloadConfig<T>(name: string) {
export async function DownloadConfig<T>(name: string) {
try {
const resp = await QueryGetAPI<string>(VTSURU_API_URL + 'get-config', {
name: name,
})
if (resp.code == 200) {
console.log('已获取配置文件: ' + name)
return JSON.parse(resp.data) as T
return {
msg: undefined,
data: JSON.parse(resp.data) as T,
}
} else if (resp.code == 404) {
console.error(`未找到名为 ${name} 的配置文件`)
return {
msg: `未找到名为 ${name} 的配置文件, 需要先上传`,
data: undefined,
}
} else {
console.error(`无法获取配置文件 [${name}]: ` + resp.message)
return {
msg: `无法获取配置文件 [${name}]: ` + resp.message,
data: undefined,
}
}
} catch (err) {
console.error(`无法获取配置文件 [${name}]: ` + err)
return {
msg: `无法获取配置文件 [${name}]: ` + err,
data: undefined,
}
}
return null
}
export async function uploadConfig(name: string, data: unknown) {
export async function UploadConfig(name: string, data: unknown) {
try {
const resp = await QueryPostAPI(VTSURU_API_URL + 'set-config', {
name: name,

View File

@@ -178,6 +178,7 @@ export enum SongFrom {
Custom,
Netease,
FiveSing,
Kugou
}
export interface SongsInfo {
id: number

View File

@@ -31,7 +31,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 MUSIC_REQUEST_API_URL = { toString: () => `${BASE_API()}vtsuru/` }
export const MUSIC_REQUEST_API_URL = { toString: () => `${BASE_API()}music-request/` }
export const VTSURU_API_URL = { toString: () => `${BASE_API()}vtsuru/` }
export const ScheduleTemplateMap = {

View File

@@ -190,22 +190,12 @@ const routes: Array<RouteRecordRaw> = [
danmaku: true,
},
},
{
path: 'song-request',
name: 'manage-songRequest',
component: () => import('@/views/open_live/MusicRequest.vue'),
meta: {
title: '弹幕点歌',
keepAlive: true,
danmaku: true,
},
},
{
path: 'queue',
name: 'manage-liveQueue',
component: () => import('@/views/open_live/OpenQueue.vue'),
meta: {
title: '弹幕排队',
title: '排队',
keepAlive: true,
danmaku: true,
},
@@ -220,6 +210,26 @@ const routes: Array<RouteRecordRaw> = [
danmaku: true,
},
},
{
path: 'song-request',
name: 'manage-songRequest',
component: () => import('@/views/open_live/SongRequest.vue'),
meta: {
title: '点歌 (歌势',
keepAlive: true,
danmaku: true,
},
},
{
path: 'music-request',
name: 'manage-musicRequest',
component: () => import('@/views/open_live/MusicRequest.vue'),
meta: {
title: '点歌 (放歌',
keepAlive: true,
danmaku: true,
},
},
{
path: 'live',
name: 'manage-live',
@@ -270,7 +280,7 @@ const routes: Array<RouteRecordRaw> = [
{
path: 'song-request',
name: 'open-live-song-request',
component: () => import('@/views/open_live/MusicRequest.vue'),
component: () => import('@/views/open_live/SongRequest.vue'),
meta: {
title: '点歌',
},
@@ -310,7 +320,7 @@ const routes: Array<RouteRecordRaw> = [
name: 'obs-song-request',
component: () => import('@/views/obs/SongRequestOBS.vue'),
meta: {
title: '弹幕点歌',
title: '弹幕点歌 (歌势',
},
},
{
@@ -321,6 +331,14 @@ const routes: Array<RouteRecordRaw> = [
title: '弹幕排队',
},
},
{
path: 'music-request',
name: 'obs-music-request',
component: () => import('@/views/obs/MusicRequestOBS.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-24" />
<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" />

View File

@@ -44,8 +44,13 @@ const functions = [
icon: Lottery24Filled,
},
{
name: '弹幕点歌',
desc: '可以让弹幕进行点歌!',
name: '弹幕点歌 (歌势)',
desc: '可以让弹幕进行点歌, 然后自己唱',
icon: ListCircle,
},
{
name: '弹幕点歌 (点播)',
desc: '可以让弹幕进行点歌, 进行搜索后直接播放',
icon: ListCircle,
},
{

View File

@@ -293,6 +293,11 @@ const menuOptions = [
},
{
label: () =>
h(
NTooltip,
{},
{
trigger: () =>
h(
RouterLink,
{
@@ -300,12 +305,42 @@ const menuOptions = [
name: 'manage-songRequest',
},
},
{ default: () => '点歌' },
{
default: () => '点歌(歌势',
},
),
default: () => '歌势用的, 观众点歌之后需要自己唱',
},
),
key: 'manage-songRequest',
icon: renderIcon(MusicalNote),
//disabled: accountInfo.value?.isEmailVerified == false,
},
{
label: () =>
h(
NTooltip,
{},
{
trigger: () =>
h(
RouterLink,
{
to: {
name: 'manage-musicRequest',
},
},
{
default: () => '点歌(点播',
},
),
default: () => '就是传统的点歌机, 发弹幕后播放指定的歌曲',
},
),
key: 'manage-musicRequest',
icon: renderIcon(MusicalNote),
//disabled: accountInfo.value?.isEmailVerified == false,
},
{
label: () =>
h(

View File

@@ -0,0 +1,308 @@
<script setup lang="ts">
import { DanmakuUserInfo, SongsInfo } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
import { AVATAR_URL, MUSIC_REQUEST_API_URL } from '@/data/constants'
import { useElementSize } from '@vueuse/core'
import { NDivider, NEmpty, useMessage } from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Vue3Marquee } from 'vue3-marquee'
type WaitMusicInfo = {
from: DanmakuUserInfo
music: SongsInfo
}
const props = defineProps<{
id?: number
}>()
const message = useMessage()
const route = useRoute()
const currentId = computed(() => {
return props.id ?? route.query.id
})
const listContainerRef = ref()
const footerRef = ref()
const footerListRef = ref()
const { height, width } = useElementSize(listContainerRef)
const footerSize = useElementSize(footerRef)
const footerListSize = useElementSize(footerListRef)
const itemHeight = 40
const key = ref(Date.now())
const originSongs = ref<{ playing?: WaitMusicInfo; waiting: WaitMusicInfo[] }>({
waiting: [],
})
async function get() {
try {
const data = await QueryGetAPI<{ playing: WaitMusicInfo; waiting: WaitMusicInfo[] }>(MUSIC_REQUEST_API_URL + 'get-waiting', {
id: currentId.value,
})
if (data.code == 200) {
return data.data
}
} catch (err) {}
return {} as { playing: WaitMusicInfo; waiting: WaitMusicInfo[] }
}
const isMoreThanContainer = computed(() => {
return originSongs.value.waiting.length * itemHeight > height.value
})
async function update() {
const r = await get()
if (r) {
originSongs.value = r
}
}
let timer: any
onMounted(() => {
update()
timer = setInterval(update, 2000)
})
onUnmounted(() => {
clearInterval(timer)
})
</script>
<template>
<div class="music-request-background" v-bind="$attrs">
<p class="music-request-header">点歌</p>
<NDivider class="music-request-divider">
<p class="music-request-header-count">已有 {{ originSongs.waiting.length ?? 0 }} </p>
</NDivider>
<div class="music-request-singing-container" :playing="originSongs.playing ? 'true' : 'false'" :from="originSongs.playing?.music.from ?? -1">
<div class="music-request-singing-prefix"></div>
<template v-if="originSongs.playing">
<img class="music-request-singing-avatar" :src="AVATAR_URL + originSongs.playing.from?.uid" referrerpolicy="no-referrer" />
<p class="music-request-singing-song-name">{{ originSongs.playing.music.name }}</p>
<p class="music-request-singing-name">{{ originSongs.playing.from?.name }}</p>
</template>
<div v-else class="music-request-singing-empty">暂无点歌</div>
<div class="music-request-singing-suffix"></div>
</div>
<div class="music-request-content" ref="listContainerRef">
<template v-if="originSongs.waiting.length > 0">
<Vue3Marquee class="music-request-list" :key="key" vertical :pause="!isMoreThanContainer" :duration="20" :style="`height: ${height}px;width: ${width}px;`">
<span class="music-request-list-item" :from="item.music.from as number" v-for="(item, index) in originSongs.waiting" :key="item.music.id" :style="`height: ${itemHeight}px`">
<div class="music-request-list-item-index" :index="index + 1">
{{ index + 1 }}
</div>
<div class="music-request-list-item-song-name">
{{ item.music.name }}
</div>
<p class="music-request-list-item-name">{{ item.from ? item.from.name : '主播添加' }}</p>
<div class="music-request-list-item-level" :has-level="(item.from?.fans_medal_level ?? 0) > 0">
{{ `${item.from?.fans_medal_name} ${item.from?.fans_medal_level}` }}
</div>
</span>
</Vue3Marquee>
</template>
<div v-else style="position: relative; top: 20%">
<NEmpty class="music-request-empty" description="暂无人点歌" />
</div>
</div>
</div>
</template>
<style scoped>
.music-request-background {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
min-height: 100px;
min-width: 100px;
background-color: #0f0f0f48;
border-radius: 10px;
color: white;
}
.music-request-header {
margin: 0;
color: #fff;
text-align: center;
font-size: 20px;
font-weight: bold;
text-shadow:
0 0 10px #ca7b7b6e,
0 0 20px #ffffff8e,
0 0 30px #61606086,
0 0 40px rgba(64, 156, 179, 0.555);
}
.music-request-header-count {
color: #ffffffbd;
text-align: center;
font-size: 14px;
}
.music-request-divider {
margin: 0 auto;
margin-top: -15px;
margin-bottom: -15px;
width: 90%;
}
.music-request-singing-container {
height: 35px;
margin: 0 10px 0 10px;
display: flex;
align-items: center;
gap: 10px;
}
.music-request-singing-empty {
font-weight: bold;
font-style: italic;
color: #ffffffbe;
}
.music-request-singing-prefix {
border: 2px solid rgb(231, 231, 231);
height: 30px;
width: 10px;
border-radius: 10px;
}
.music-request-singing-container[playing='true'] .music-request-singing-prefix {
background-color: #75c37f;
animation: animated-border 3s linear infinite;
}
.music-request-singing-container[playing='false'] .music-request-singing-prefix {
background-color: #c37575;
}
.music-request-singing-avatar {
height: 30px;
border-radius: 50%;
/* 添加无限旋转动画 */
animation: rotate 20s linear infinite;
}
/* 网页点歌 */
.music-request-singing-container[from='3'] .music-request-singing-avatar {
display: none;
}
.music-request-singing-song-name {
font-size: large;
font-weight: bold;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80%;
}
.music-request-singing-name {
font-size: 12px;
font-style: italic;
}
@keyframes rotate {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
.n-divider__line {
background-color: #ffffffd5;
}
.music-request-content {
background-color: #0f0f0f4f;
margin: 10px;
padding: 10px;
height: 100%;
border-radius: 10px;
overflow-x: hidden;
}
.marquee {
justify-items: left;
}
.music-request-list-item {
display: flex;
width: 100%;
align-self: flex-start;
position: relative;
align-items: center;
justify-content: left;
gap: 10px;
}
.music-request-list-item-song-name {
font-size: 18px;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80%;
}
/* 手动添加 */
.music-request-list-item[from='0'] .music-request-list-item-name {
font-style: italic;
font-weight: bold;
color: #d2d8d6;
font-size: 12px;
}
.music-request-list-item[from='0'] .music-request-list-item-avatar {
display: none;
}
/* 弹幕点歌 */
.music-request-list-item[from='1'] {
}
.music-request-list-item-name {
font-style: italic;
font-size: 12px;
color: rgba(204, 204, 204, 0.993);
text-overflow: ellipsis;
white-space: nowrap;
margin-left: auto;
}
.music-request-list-item-index {
text-align: center;
height: 18px;
padding: 2px;
min-width: 15px;
border-radius: 5px;
background-color: #0f0f0f48;
color: rgba(204, 204, 204, 0.993);
font-size: 12px;
}
.music-request-list-item-level {
text-align: center;
height: 18px;
padding: 2px;
min-width: 15px;
border-radius: 5px;
background-color: #0f0f0f48;
color: rgba(204, 204, 204, 0.993);
font-size: 12px;
}
.music-request-list-item-level[has-level='false'] {
display: none;
}
.music-request-tag {
display: flex;
margin: 5px 0 5px 5px;
height: 40px;
border-radius: 3px;
background-color: #0f0f0f4f;
padding: 4px;
padding-right: 6px;
display: flex;
flex-direction: column;
justify-content: left;
}
.music-request-tag-key {
font-style: italic;
color: rgb(211, 211, 211);
font-size: 12px;
}
.music-request-tag-value {
font-size: 14px;
}
@keyframes animated-border {
0% {
box-shadow: 0 0 0px #589580;
}
100% {
box-shadow: 0 0 0 6px rgba(255, 255, 255, 0);
}
}
</style>

View File

@@ -1,26 +1,91 @@
<script setup lang="ts">
import { EventModel, SongsInfo } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
import { DownloadConfig, UploadConfig, useAccount } from '@/api/account'
import { DanmakuUserInfo, EventModel, FunctionTypes, SongFrom, SongsInfo } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import DanmakuClient, { RoomAuthInfo } from '@/data/DanmakuClient'
import { MUSIC_REQUEST_API_URL } from '@/data/constants'
import { MUSIC_REQUEST_API_URL, SONG_API_URL } from '@/data/constants'
import { useStorage } from '@vueuse/core'
import { onMounted, onUnmounted, ref } from 'vue'
import { List } from 'linqts'
import {
NAlert,
NButton,
NCheckbox,
NCollapse,
NCollapseItem,
NDivider,
NEmpty,
NInput,
NInputGroup,
NInputGroupLabel,
NInputNumber,
NLi,
NList,
NListItem,
NModal,
NPopconfirm,
NRadioButton,
NRadioGroup,
NSelect,
NSpace,
NTabPane,
NTabs,
NTag,
NText,
NTransfer,
NUl,
NVirtualList,
SelectOption,
useMessage,
} from 'naive-ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import APlayer from 'vue3-aplayer'
import { clearInterval, setInterval } from 'worker-timers'
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 = {
id: number
title: string
artist: string
src: string
pic: string
lrc: string
}
type WaitMusicInfo = {
from: DanmakuUserInfo
music: SongsInfo
}
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 cooldown = useStorage<{ [id: number]: number }>('Setting.MusicRequest.Cooldown', {})
const props = defineProps<{
client: DanmakuClient
@@ -29,35 +94,559 @@ const props = defineProps<{
isOpenLive?: boolean
}>()
const deviceList = ref<SelectOption[]>([])
const accountInfo = useAccount()
const message = useMessage()
const aplayer = ref()
const musics = ref<Music[]>([])
const listening = ref(false)
function onGetEvent(data: EventModel) {}
function searchMusic(keyword: string) {
QueryGetAPI<SongsInfo>(MUSIC_REQUEST_API_URL + 'search-kugou', {
keyword: keyword,
}).then((data) => {
if (data.code == 200) {
musics.value.push({
title: data.data.name,
artist: data.data.author?.join('/'),
src: data.data.url,
pic: data.data.cover ?? '',
const originMusics = ref<SongsInfo[]>(await get())
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 showNeteaseModal = ref(false)
const showOBSModal = ref(false)
const neteaseIdInput = ref('')
const neteaseSongListId = computed(() => {
try {
const url = new URL(neteaseIdInput.value)
console.log(url)
if (url.host == 'music.163.com') {
const regex = /id=(\d+)/
// 使用exec方法在链接中查找匹配项
const match = regex.exec(neteaseIdInput.value)
// 如果找到了匹配项那么match[1]就是分组1的值也就是id的值
if (match) {
return Number(match[1])
}
}
} catch (err) {}
try {
return Number(neteaseIdInput.value)
} catch {}
return null
})
const neteaseSongs = ref<SongsInfo[]>([])
const neteaseSongsOptions = computed(() => {
return neteaseSongs.value.map((s) => ({
label: `${s.name} - ${s.author.join('/')}`,
value: s.key,
disabled: originMusics.value.findIndex((exist) => exist.id == s.id) > -1,
}))
})
const selectedNeteaseSongs = ref<string[]>([])
async function get() {
try {
const data = await QueryGetAPI<SongsInfo[]>(MUSIC_REQUEST_API_URL + 'get')
if (data.code == 200) {
console.log('[OPEN-LIVE-Music-Request] 已获取所有数据')
return new List(data.data).OrderByDescending((s) => s.createTime).ToArray()
} else {
message.error('无法获取数据: ' + data.message)
return []
}
} catch (err) {
message.error('无法获取数据')
}
return []
}
async function searchMusic(keyword: string) {
const data = await QueryGetAPI<SongsInfo>(MUSIC_REQUEST_API_URL + 'search-' + settings.value.platform, {
keyword: keyword,
})
if (data.code == 200) {
return data.data
}
return undefined
}
function switchTo() {}
async function getNeteaseSongList() {
isLoading.value = true
await QueryGetAPI<SongsInfo[]>(SONG_API_URL + 'get-netease-list', {
id: neteaseSongListId.value,
})
.then((data) => {
if (data.code == 200) {
neteaseSongs.value = data.data
message.success(`成功获取歌曲信息, 共 ${data.data.length} 条, 歌单中已存在 ${neteaseSongsOptions.value.filter((s) => s.disabled).length}`)
} else {
message.error('获取歌单失败: ' + data.message)
}
})
.catch((err) => {
message.error('获取歌单失败: ' + err)
})
.finally(() => {
isLoading.value = false
})
}
async function addNeteaseSongs() {
isLoading.value = true
const selected = neteaseSongs.value.filter((s) => selectedNeteaseSongs.value.find((select) => s.key == select))
await addSongs(selected, SongFrom.Netease)
.then((data) => {
if (data.code == 200) {
message.success(`已添加 ${data.data.length} 首歌曲`)
originMusics.value.push(...data.data)
} else {
message.error('添加失败: ' + data.message)
}
})
.catch((err) => {
message.error('添加失败')
})
.finally(() => {
isLoading.value = false
})
}
async function addSongs(songsShoudAdd: SongsInfo[], from: SongFrom) {
return QueryPostAPI<SongsInfo[]>(
MUSIC_REQUEST_API_URL + 'add',
songsShoudAdd.map((s) => ({
Name: s.name,
Id: from == SongFrom.Custom ? -1 : s.id,
From: from,
Author: s.author,
Url: s.url,
Description: s.description,
Cover: s.cover,
})),
)
}
function delMusic(song: SongsInfo) {
QueryPostAPI(MUSIC_REQUEST_API_URL + 'del', [song.key])
.then((data) => {
if (data.code == 200) {
message.success('已删除')
originMusics.value = originMusics.value.filter((s) => s.key != song.key)
} else {
message.error('删除失败: ' + data.message)
}
})
.catch((err) => {
message.error('删除失败' + err)
})
}
function clearMusic() {
QueryGetAPI(MUSIC_REQUEST_API_URL + 'clear')
.then((data) => {
if (data.code == 200) {
message.success('已清空')
originMusics.value = []
} else {
message.error('清空失败: ' + data.message)
}
})
.catch((err) => {
message.error('清空失败' + err)
})
}
async function uploadConfig() {
await UploadConfig('MusicRequest', JSON.stringify(settings.value))
.then((data) => {
if (data) {
message.success('已保存至服务器')
} else {
message.error('保存失败')
}
})
.catch((err) => {
message.error('保存失败')
})
}
async function downloadConfig() {
await DownloadConfig<MusicRequestSettings>('MusicRequest')
.then((data) => {
if (data.msg) {
message.error('获取失败: ' + data.msg)
} else {
settings.value = data.data
message.success('已获取配置文件')
}
})
.catch((err) => {
message.error('获取失败')
})
}
function startListen() {
if (accountInfo.value?.settings.enableFunctions.includes(FunctionTypes.SongRequest)) {
message.warning('使用这个点歌则需要先关闭歌势点歌 (SongRequest)')
return
}
listening.value = true
message.success('开始监听')
}
function stopListen() {
listening.value = false
message.success('已停止监听')
}
const isPlayingOrderMusic = ref(false)
async function onGetEvent(data: EventModel) {
if (!listening.value || !checkMessage(data.msg)) return
if (settings.value.orderCooldown && cooldown.value[data.uid]) {
const lastRequest = cooldown.value[data.uid]
if (Date.now() - lastRequest < settings.value.orderCooldown * 1000) {
message.info(`[${data.name}] 冷却中,距离下次点歌还有 ${((settings.value.orderCooldown * 1000 - (Date.now() - lastRequest)) / 1000).toFixed(1)}`)
return
}
}
const name = data.msg.replace(new RegExp(settings.value.orderPrefix.trimStart()), '').trim()
const result = await searchMusic(name)
if (result) {
if (settings.value.blacklist.includes(result.name)) {
message.warning(`[${data.name}] 点歌失败,因为 ${result.name} 在黑名单中`)
return
}
cooldown.value[data.uid] = Date.now()
const music = {
from: {
name: data.name,
uid: data.uid,
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,
},
music: result,
} as WaitMusicInfo
if ((settings.value.orderMusicFirst && !isPlayingOrderMusic.value) || originMusics.value.length == 0 || aplayer.value?.audio.paused) {
playWaitingMusic(music)
console.log(`正在播放 [${data.name}] 点的 ${result.name} - ${result.author?.join('/')}`)
} else {
waitingMusics.value.push(music)
}
}
}
function checkMessage(msg: string) {
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) {
aplayer.value?.pause()
}
}, 10)
}
}
function playWaitingMusic(music: WaitMusicInfo) {
isPlayingOrderMusic.value = true
const index = waitingMusics.value.indexOf(music)
if (index > -1) {
waitingMusics.value.splice(index, 1)
}
aplayer.value?.pause()
currentOriginMusic.value = music
aplayer.value?.audio.addEventListener(
'loadeddata',
() => {
aplayer.value?.play()
},
{ once: true },
)
}
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) {
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
}
async function getOutputDevice() {
try {
await navigator.mediaDevices.getUserMedia({
// 请保留此代码以确保用户授权访问设备
audio: true,
})
const list = await navigator.mediaDevices.enumerateDevices()
deviceList.value = list.filter((device) => device.kind === 'audiooutput').map((d) => ({ label: d.label, value: d.deviceId }))
} catch (err) {
console.error(err)
message.error('获取音频输出设备失败: ' + err)
}
}
function blockMusic(song: SongsInfo) {
settings.value.blacklist.push(song.name)
waitingMusics.value.splice(
waitingMusics.value.findIndex((m) => m.music == song),
1,
)
message.success(`[${song.name}] 已添加到黑名单`)
}
function updateWaiting() {
QueryPostAPI(MUSIC_REQUEST_API_URL + 'update-waiting', {
playing: currentOriginMusic.value,
waiting: waitingMusics.value,
})
}
onMounted(() => {
let timer: number
onMounted(async () => {
props.client.onEvent('danmaku', onGetEvent)
if (originMusics.value.length > 0) {
currentMusic.value = songToMusic(originMusics.value[0])
}
await getOutputDevice()
if (deviceList.value.length > 0) {
if (!deviceList.value.find((d) => d.value == settings.value.deviceId)) {
settings.value.deviceId = undefined
} else {
setSinkId()
}
}
timer = setInterval(updateWaiting, 2000)
})
onUnmounted(() => {
props.client.offEvent('danmaku', onGetEvent)
if (timer) {
clearInterval(timer)
}
})
</script>
<template>
<APlayer :list="musics" ref="aplayer" />
<NSpace>
<NAlert type="info"> 搜索时会优先选择非VIP歌曲, 所以点到付费曲目时可能会是猴版或者各种奇怪的歌 </NAlert>
</NSpace>
<NDivider />
<NSpace>
<NButton
@click="listening ? stopListen() : startListen()"
:type="listening ? 'error' : 'primary'"
:style="{ animation: listening ? 'animated-border 2.5s infinite' : '' }"
data-umami-event="Use Music Request"
:data-umami-event-uid="accountInfo?.biliId"
>
{{ listening ? '停止监听' : '开始监听' }}
</NButton>
<NButton @click="showOBSModal = true" type="info"> OBS组件 </NButton>
<NButton @click="showNeteaseModal = true"> 从网易云歌单导入空闲歌单 </NButton>
<NButton @click="uploadConfig" type="primary" secondary :disabled="!accountInfo"> 保存配置到服务器 </NButton>
<NPopconfirm @positive-click="downloadConfig">
<template #trigger>
<NButton type="primary" secondary :disabled="!accountInfo"> 从服务器获取配置 </NButton>
</template>
这将覆盖当前设置, 确定?
</NPopconfirm>
</NSpace>
<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']">
<NCollapseItem title="队列" name="1">
<NEmpty v-if="waitingMusics.length == 0"> 暂无 </NEmpty>
<NList v-else size="small" bordered>
<NListItem v-for="item in waitingMusics">
<NSpace align="center">
<NButton @click="playWaitingMusic(item)" type="primary" secondary size="small"> 播放 </NButton>
<NButton @click="waitingMusics.splice(waitingMusics.indexOf(item), 1)" type="error" secondary size="small"> 取消 </NButton>
<NButton @click="blockMusic(item.music)" type="warning" secondary size="small"> 拉黑 </NButton>
<span>
<NTag v-if="item.music.from == SongFrom.Netease" type="success" size="small"> 网易</NTag>
<NTag v-else-if="item.music.from == SongFrom.Kugou" type="success" size="small"> 酷狗</NTag>
</span>
<NText>
{{ item.from.name }}
</NText>
<NText depth="3"> {{ item.music.name }} - {{ item.music.author?.join('/') }} </NText>
</NSpace>
</NListItem>
</NList>
</NCollapseItem>
</NCollapse>
<NDivider />
<NTabs>
<NTabPane name="settings" tab="设置">
<NSpace vertical>
<NSpace align="center">
<NRadioGroup v-model:value="settings.platform">
<NRadioButton value="netease"> 网易云 </NRadioButton>
<NRadioButton value="kugou"> 酷狗 </NRadioButton>
</NRadioGroup>
<NInputGroup style="width: 250px">
<NInputGroupLabel> 点歌弹幕前缀 </NInputGroupLabel>
<NInput v-model:value="settings.orderPrefix" />
</NInputGroup>
<NCheckbox
:checked="settings.orderCooldown != undefined"
@update:checked="
(checked: boolean) => {
settings.orderCooldown = checked ? 300 : undefined
}
"
>
是否启用点歌冷却
</NCheckbox>
<NInputGroup v-if="settings.orderCooldown" style="width: 200px">
<NInputGroupLabel> 冷却时间 () </NInputGroupLabel>
<NInputNumber
v-model:value="settings.orderCooldown"
@update:value="
(value) => {
if (!value || value <= 0) settings.orderCooldown = undefined
}
"
/>
</NInputGroup>
</NSpace>
<NSpace>
<NCheckbox v-model:checked="settings.playMusicWhenFree"> 空闲时播放空闲歌单 </NCheckbox>
<NCheckbox v-model:checked="settings.orderMusicFirst"> 优先播放点歌 </NCheckbox>
</NSpace>
<NSpace>
<NButton @click="getOutputDevice"> 获取输出设备 </NButton>
<NSelect v-model:value="settings.deviceId" :options="deviceList" :fallback-option="() => ({ label: '未选择', value: '' })" style="min-width: 200px" @update:value="setSinkId" />
</NSpace>
</NSpace>
</NTabPane>
<NTabPane name="list" tab="闲置歌单">
<NSpace>
<NPopconfirm @positive-click="clearMusic">
<template #trigger>
<NButton type="error"> 清空 </NButton>
</template>
确定清空吗?
</NPopconfirm>
<NButton @click="showNeteaseModal = true"> 从网易云歌单导入 </NButton>
</NSpace>
<NDivider style="margin: 15px 0 10px 0" />
<NEmpty v-if="musics.length == 0"> 暂无 </NEmpty>
<NVirtualList v-else :style="`max-height: 1000px`" :item-size="30" :items="originMusics" item-resizable>
<template #default="{ item, index }">
<p :style="`min-height: ${30}px;width:97%;display:flex;align-items:center;`">
<NSpace align="center" style="width: 100%">
<NPopconfirm @positive-click="delMusic(item)">
<template #trigger>
<NButton type="error" secondary size="small"> 删除 </NButton>
</template>
确定删除?
</NPopconfirm>
<NText> {{ item.name }} - {{ item.author?.join('/') }} </NText>
</NSpace>
</p>
</template>
</NVirtualList>
</NTabPane>
<NTabPane name="blacklist" tab="黑名单">
<NList>
<NListItem v-for="item in settings.blacklist">
<NSpace align="center" style="width: 100%">
<NButton @click="settings.blacklist.splice(settings.blacklist.indexOf(item), 1)" type="error" secondary size="small"> 删除 </NButton>
<NText> {{ item }} </NText>
</NSpace>
</NListItem>
</NList>
</NTabPane>
</NTabs>
<NDivider style="height: 100px" />
<NModal v-model:show="showNeteaseModal" preset="card" :title="`获取歌单`" style="max-width: 600px">
<NInput clearable style="width: 100%" autosize :status="neteaseSongListId ? 'success' : 'error'" v-model:value="neteaseIdInput" placeholder="直接输入歌单Id或者网页链接">
<template #suffix>
<NTag v-if="neteaseSongListId" type="success" size="small"> 歌单Id: {{ neteaseSongListId }} </NTag>
</template>
</NInput>
<NDivider style="margin: 10px" />
<NButton type="primary" @click="getNeteaseSongList" :disabled="!neteaseSongListId" :loading="isLoading"> 获取 </NButton>
<template v-if="neteaseSongsOptions.length > 0">
<NDivider style="margin: 10px" />
<NTransfer style="height: 500px" ref="transfer" v-model:value="selectedNeteaseSongs" :options="neteaseSongsOptions" source-filterable />
<NDivider style="margin: 10px" />
<NButton type="primary" @click="addNeteaseSongs" :loading="isLoading"> 添加到歌单 | {{ selectedNeteaseSongs.length }} 首 </NButton>
</template>
</NModal>
<NModal v-model:show="showOBSModal" title="OBS组件" preset="card" style="width: 800px">
<NAlert title="这是什么? " type="info"> 将等待队列以及结果显示在OBS中 </NAlert>
<NDivider> 浏览 </NDivider>
<div style="height: 500px; width: 280px; position: relative; margin: 0 auto">
<MusicRequestOBS :id="accountInfo?.id" />
</div>
<br />
<NInput :value="'https://vtsuru.live/obs/music-request?id=' + accountInfo?.id" />
<NDivider />
<NCollapse>
<NCollapseItem title="使用说明">
<NUl>
<NLi> OBS 来源中添加源, 选择 浏览器</NLi>
<NLi> URL 栏填入上方链接</NLi>
<NLi>根据自己的需要调整宽度和高度 (这里是宽 280px 500px)</NLi>
<NLi>完事</NLi>
</NUl>
</NCollapseItem>
</NCollapse>
</NModal>
</template>
<style>
.aplayer-list {
max-height: 300px;
overflow-y: auto;
}
@keyframes animated-border {
0% {
box-shadow: 0 0 0px #589580;
}
100% {
box-shadow: 0 0 0 8px rgba(255, 255, 255, 0);
}
}
</style>

View File

@@ -320,7 +320,6 @@ async function updateSongStatus(song: SongRequestInfo, status: SongRequestStatus
}
function onGetDanmaku(danmaku: DanmakuInfo) {
console.log(danmaku)
if (checkMessage(danmaku.msg)) {
addSong({
msg: danmaku.msg,
@@ -357,6 +356,9 @@ function onGetSC(danmaku: SCInfo) {
}
}
function checkMessage(msg: string) {
if (accountInfo.value?.settings.enableFunctions.includes(FunctionTypes.SongRequest) != true) {
return false
}
return msg
.trim()
.toLowerCase()