window[Plugin.id] = window[Plugin.id] || initMusicClient() const onRun = async () => { await startMusicServer() window[Plugin.id].modal.open() } const startMusicServer = async () => { await stopMusicServer().catch((err) => { console.log(`[${Plugin.name}]`, '停止失败', err) }) await Plugins.StartServer( '127.0.0.1:53421', Plugin.id, (req, res) => { res.end('音乐静态资源服务器运行中') }, { StaticPath: Plugin.MusicDir, StaticHeaders: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*' } } ) } const stopMusicServer = async () => { await Plugins.StopServer(Plugin.id) } function createUIModal() { const component = { template: `
`, setup(_, { expose }) { const { analyser, dataArray, playList, isPlaying, title, percent, play, index, toggle, prev, next, refreshPlayList, modal } = window[Plugin.id] const { h, ref, resolveComponent, onMounted, onUnmounted } = Vue let animationId const canvas = ref() const appSettings = Plugins.useAppSettingsStore() onMounted(() => { const ctx = canvas.value.getContext('2d') canvas.value.width = canvas.value.clientWidth canvas.value.height = canvas.value.clientHeight let t = 0 let rot = 0 function draw() { animationId = requestAnimationFrame(draw) analyser.getByteFrequencyData(dataArray) rot += 0.001 // 自转速度 t += 0.005 const style = getComputedStyle(document.documentElement) const primaryColor = style.getPropertyValue('--primary-color') const secondaryColor = style.getPropertyValue('--secondary-color') ctx.fillStyle = appSettings.themeMode === 'light' ? '#F6F6F6' : '#343434' ctx.fillRect(0, 0, canvas.value.width, canvas.value.height) const cx = canvas.value.width / 2 const cy = canvas.value.height / 2 let energy = 0 for (let i = 0; i < 50; i++) energy += dataArray[i] energy = energy / 50 / 255 let planet = 80 + energy * 50 + Math.sin(t) * 8 // 星球 let planetGrad = ctx.createRadialGradient(cx - 40, cy - 40, 20, cx, cy, planet) planetGrad.addColorStop(0, primaryColor || '#d6f0ff') planetGrad.addColorStop(1, secondaryColor || '#3a5cff') ctx.beginPath() ctx.fillStyle = planetGrad ctx.arc(cx, cy, planet, 0, Math.PI * 2) ctx.fill() // 星环 const rings = 200 for (let i = 0; i < rings; i++) { let angle = (i / rings) * Math.PI * 2 + rot let dataIdx = i > rings / 2 ? rings - i : i let f = dataArray[dataIdx] / 255 let wave = Math.sin(angle * 3 + t * 5) * 12 let r = planet + 30 + f * 40 + wave let x = cx + Math.cos(angle) * r let y = cy + Math.sin(angle) * r ctx.fillStyle = secondaryColor || `rgba(180,210,255,0.7)` ctx.fillRect(x, y, 2, 2) } // 星尘 for (let i = 0; i < 50; i++) { let a = (i / 50) * Math.PI * 2 + rot * 2 let r = 200 + Math.sin(t + i) * 50 let x = cx + Math.cos(a) * r let y = cy + Math.sin(a) * r ctx.fillStyle = primaryColor || 'rgba(100,150,255,0.3)' ctx.fillRect(x, y, 2, 2) } } draw() }) onUnmounted(() => { cancelAnimationFrame(animationId) }) refreshPlayList() const Dropdown = resolveComponent('Dropdown') const Button = resolveComponent('Button') expose({ modalSlots: { default: () => h(component), toolbar: () => h( Button, { type: 'text', onClick() { modal.close() } }, () => '进入后台' ), action: () => h( 'div', { class: 'mr-auto' }, h( Dropdown, { placement: 'top' }, { overlay: ({ close }) => h( 'div', { class: 'flex flex-col gap-4 min-w-64 p-4' }, playList.value.map((v, i) => { return h( Button, { type: index.value === i ? 'link' : 'text', onClick() { index.value = i play() close() } }, () => v.name ) }) ), default: () => h( Button, { type: 'link' }, () => title.value ) } ) ) } }) return { canvas, isPlaying, percent, toggle, prev, next } } } const modal = Plugins.modal({ title: Plugin.name, width: '90', height: '90', submit: false, cancelText: '退出', afterClose() { window[Plugin.id].destroy() window[Plugin.id] = null modal.destroy() stopMusicServer() } }) modal.setContent(component) return modal } function initMusicClient() { const audioCtx = new AudioContext() const analyser = audioCtx.createAnalyser() const bufferLength = analyser.frequencyBinCount const dataArray = new Uint8Array(bufferLength) const audio = new Audio() analyser.fftSize = 1024 audio.crossOrigin = 'anonymous' audio.playbackRate = 2 const src = audioCtx.createMediaElementSource(audio) src.connect(analyser) analyser.connect(audioCtx.destination) const { ref, computed } = Vue const loopMode = ref('list') // 'one' | 'list' | 'none' const playList = ref([]) const playing = ref(false) const waiting = ref(false) const isPlaying = computed(() => playing.value && !waiting.value) const index = ref(0) const percent = ref(0) const title = ref('歌曲列表') audio.onplay = () => { playing.value = true } audio.onplaying = () => { playing.value = true waiting.value = false } audio.onpause = () => { playing.value = false } audio.onended = () => { playing.value = false if (loopMode.value === 'one') { play() return } if (loopMode.value === 'list') { const nextIndex = index.value + 1 if (nextIndex < playList.value.length) { index.value = nextIndex } else { index.value = 0 } play() } } audio.onwaiting = () => { waiting.value = true } audio.ontimeupdate = (e) => { const { currentTime, duration } = e.target percent.value = (currentTime / duration) * 100 } const play = () => { const song = playList.value[index.value] if (song) { title.value = song.name audio.src = song.url audio.play() audioCtx.resume() } } const toggle = () => { if (!audio.src) { play() } else { audio.paused ? audio.play() : audio.pause() } } const prev = () => { index.value = Math.max(index.value - 1, 0) play() } const next = () => { index.value = Math.min(index.value + 1, playList.value.length - 1) play() } const refreshPlayList = async () => { if (!Plugin.MusicDir) { Plugins.message.info('请配置音乐文件夹') return } const files = await Plugins.ReadDir(Plugin.MusicDir) playList.value = files .filter((file) => !file.isDir) .map((file) => { file.url = 'http://127.0.0.1:53421/static/' + file.name return file }) } const destroy = () => { // 停止音频播放 audio.pause() audio.src = '' audio.onplay = null audio.onpause = null audio.onplaying = null audio.onended = null audio.ontimeupdate = null audio.onwaiting = null // 断开 AudioContext 节点 try { src.disconnect() analyser.disconnect() audioCtx.close() } catch (err) { console.warn('AudioContext already closed or disconnect failed', err) } } return { modal: createUIModal(), playList, playing, waiting, isPlaying, index, percent, title, audioCtx, analyser, dataArray, audio, src, play, prev, next, toggle, destroy, refreshPlayList } }