From b07cc2359a415a6e8d032ffaa33749597582780f Mon Sep 17 00:00:00 2001 From: sqj Date: Thu, 9 Oct 2025 01:47:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=AD=8C=E6=9B=B2?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E7=BC=93=E5=AD=98=E5=86=85=E5=AD=98=E6=B3=84?= =?UTF-8?q?=E9=9C=B2=E9=97=AE=E9=A2=98=20feat:=E6=AD=8C=E6=9B=B2=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=87=BA=E9=94=99=E8=87=AA=E5=8A=A8=E5=88=87=E6=AD=8C?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E6=9A=82=E5=81=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/sponsorship.md | 4 +- package.json | 2 +- src/renderer/auto-imports.d.ts | 144 ++++++++++-------- .../src/components/Play/AudioVisualizer.vue | 80 +++++----- src/renderer/src/components/Play/FullPlay.vue | 20 +-- .../src/components/Play/GlobalAudio.vue | 65 +++++++- .../src/components/Play/PlayMusic.vue | 115 +++++++------- src/renderer/src/utils/audio/audioManager.ts | 32 ++-- 8 files changed, 261 insertions(+), 201 deletions(-) diff --git a/docs/guide/sponsorship.md b/docs/guide/sponsorship.md index e28597d..4401191 100644 --- a/docs/guide/sponsorship.md +++ b/docs/guide/sponsorship.md @@ -12,7 +12,7 @@ | RiseSun | 9.9 | | **b站小友**:光牙阿普斯木兰 | 5 | | 青禾 | 8.8 | -| li peng | 200 | -| **群友**:XIZ | 3 | +| li peng | 200 | +| **群友**:XIZ | 3 | 据不完全统计 如有疏漏可联系sqj@shiqianjiang.cn diff --git a/package.json b/package.json index 9a2892e..523ba66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ceru-music", - "version": "1.4.0", + "version": "1.4.1", "description": "一款简洁优雅的音乐播放器", "main": "./out/main/index.js", "author": "sqj,wldss,star", diff --git a/src/renderer/auto-imports.d.ts b/src/renderer/auto-imports.d.ts index 1c212c9..e4a39de 100644 --- a/src/renderer/auto-imports.d.ts +++ b/src/renderer/auto-imports.d.ts @@ -7,72 +7,90 @@ export {} declare global { const DialogPlugin: (typeof import('tdesign-vue-next'))['DialogPlugin'] - const EffectScope: typeof import('vue')['EffectScope'] - const computed: typeof import('vue')['computed'] - const createApp: typeof import('vue')['createApp'] - const customRef: typeof import('vue')['customRef'] - const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] - const defineComponent: typeof import('vue')['defineComponent'] - const effectScope: typeof import('vue')['effectScope'] - const getCurrentInstance: typeof import('vue')['getCurrentInstance'] - const getCurrentScope: typeof import('vue')['getCurrentScope'] - const getCurrentWatcher: typeof import('vue')['getCurrentWatcher'] - const h: typeof import('vue')['h'] - const inject: typeof import('vue')['inject'] - const isProxy: typeof import('vue')['isProxy'] - const isReactive: typeof import('vue')['isReactive'] - const isReadonly: typeof import('vue')['isReadonly'] - const isRef: typeof import('vue')['isRef'] - const isShallow: typeof import('vue')['isShallow'] - const markRaw: typeof import('vue')['markRaw'] - const nextTick: typeof import('vue')['nextTick'] - const onActivated: typeof import('vue')['onActivated'] - const onBeforeMount: typeof import('vue')['onBeforeMount'] - const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] - const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] - const onDeactivated: typeof import('vue')['onDeactivated'] - const onErrorCaptured: typeof import('vue')['onErrorCaptured'] - const onMounted: typeof import('vue')['onMounted'] - const onRenderTracked: typeof import('vue')['onRenderTracked'] - const onRenderTriggered: typeof import('vue')['onRenderTriggered'] - const onScopeDispose: typeof import('vue')['onScopeDispose'] - const onServerPrefetch: typeof import('vue')['onServerPrefetch'] - const onUnmounted: typeof import('vue')['onUnmounted'] - const onUpdated: typeof import('vue')['onUpdated'] - const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] - const provide: typeof import('vue')['provide'] - const reactive: typeof import('vue')['reactive'] - const readonly: typeof import('vue')['readonly'] - const ref: typeof import('vue')['ref'] - const resolveComponent: typeof import('vue')['resolveComponent'] - const shallowReactive: typeof import('vue')['shallowReactive'] - const shallowReadonly: typeof import('vue')['shallowReadonly'] - const shallowRef: typeof import('vue')['shallowRef'] - const toRaw: typeof import('vue')['toRaw'] - const toRef: typeof import('vue')['toRef'] - const toRefs: typeof import('vue')['toRefs'] - const toValue: typeof import('vue')['toValue'] - const triggerRef: typeof import('vue')['triggerRef'] - const unref: typeof import('vue')['unref'] - const useAttrs: typeof import('vue')['useAttrs'] - const useCssModule: typeof import('vue')['useCssModule'] - const useCssVars: typeof import('vue')['useCssVars'] - const useDialog: typeof import('naive-ui')['useDialog'] - const useId: typeof import('vue')['useId'] - const useLoadingBar: typeof import('naive-ui')['useLoadingBar'] - const useMessage: typeof import('naive-ui')['useMessage'] - const useModel: typeof import('vue')['useModel'] - const useNotification: typeof import('naive-ui')['useNotification'] - const useSlots: typeof import('vue')['useSlots'] - const useTemplateRef: typeof import('vue')['useTemplateRef'] - const watch: typeof import('vue')['watch'] - const watchEffect: typeof import('vue')['watchEffect'] - const watchPostEffect: typeof import('vue')['watchPostEffect'] - const watchSyncEffect: typeof import('vue')['watchSyncEffect'] + const EffectScope: (typeof import('vue'))['EffectScope'] + const computed: (typeof import('vue'))['computed'] + const createApp: (typeof import('vue'))['createApp'] + const customRef: (typeof import('vue'))['customRef'] + const defineAsyncComponent: (typeof import('vue'))['defineAsyncComponent'] + const defineComponent: (typeof import('vue'))['defineComponent'] + const effectScope: (typeof import('vue'))['effectScope'] + const getCurrentInstance: (typeof import('vue'))['getCurrentInstance'] + const getCurrentScope: (typeof import('vue'))['getCurrentScope'] + const getCurrentWatcher: (typeof import('vue'))['getCurrentWatcher'] + const h: (typeof import('vue'))['h'] + const inject: (typeof import('vue'))['inject'] + const isProxy: (typeof import('vue'))['isProxy'] + const isReactive: (typeof import('vue'))['isReactive'] + const isReadonly: (typeof import('vue'))['isReadonly'] + const isRef: (typeof import('vue'))['isRef'] + const isShallow: (typeof import('vue'))['isShallow'] + const markRaw: (typeof import('vue'))['markRaw'] + const nextTick: (typeof import('vue'))['nextTick'] + const onActivated: (typeof import('vue'))['onActivated'] + const onBeforeMount: (typeof import('vue'))['onBeforeMount'] + const onBeforeUnmount: (typeof import('vue'))['onBeforeUnmount'] + const onBeforeUpdate: (typeof import('vue'))['onBeforeUpdate'] + const onDeactivated: (typeof import('vue'))['onDeactivated'] + const onErrorCaptured: (typeof import('vue'))['onErrorCaptured'] + const onMounted: (typeof import('vue'))['onMounted'] + const onRenderTracked: (typeof import('vue'))['onRenderTracked'] + const onRenderTriggered: (typeof import('vue'))['onRenderTriggered'] + const onScopeDispose: (typeof import('vue'))['onScopeDispose'] + const onServerPrefetch: (typeof import('vue'))['onServerPrefetch'] + const onUnmounted: (typeof import('vue'))['onUnmounted'] + const onUpdated: (typeof import('vue'))['onUpdated'] + const onWatcherCleanup: (typeof import('vue'))['onWatcherCleanup'] + const provide: (typeof import('vue'))['provide'] + const reactive: (typeof import('vue'))['reactive'] + const readonly: (typeof import('vue'))['readonly'] + const ref: (typeof import('vue'))['ref'] + const resolveComponent: (typeof import('vue'))['resolveComponent'] + const shallowReactive: (typeof import('vue'))['shallowReactive'] + const shallowReadonly: (typeof import('vue'))['shallowReadonly'] + const shallowRef: (typeof import('vue'))['shallowRef'] + const toRaw: (typeof import('vue'))['toRaw'] + const toRef: (typeof import('vue'))['toRef'] + const toRefs: (typeof import('vue'))['toRefs'] + const toValue: (typeof import('vue'))['toValue'] + const triggerRef: (typeof import('vue'))['triggerRef'] + const unref: (typeof import('vue'))['unref'] + const useAttrs: (typeof import('vue'))['useAttrs'] + const useCssModule: (typeof import('vue'))['useCssModule'] + const useCssVars: (typeof import('vue'))['useCssVars'] + const useDialog: (typeof import('naive-ui'))['useDialog'] + const useId: (typeof import('vue'))['useId'] + const useLoadingBar: (typeof import('naive-ui'))['useLoadingBar'] + const useMessage: (typeof import('naive-ui'))['useMessage'] + const useModel: (typeof import('vue'))['useModel'] + const useNotification: (typeof import('naive-ui'))['useNotification'] + const useSlots: (typeof import('vue'))['useSlots'] + const useTemplateRef: (typeof import('vue'))['useTemplateRef'] + const watch: (typeof import('vue'))['watch'] + const watchEffect: (typeof import('vue'))['watchEffect'] + const watchPostEffect: (typeof import('vue'))['watchPostEffect'] + const watchSyncEffect: (typeof import('vue'))['watchSyncEffect'] } // for type re-export declare global { // @ts-ignore - export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + export type { + Component, + Slot, + Slots, + ComponentPublicInstance, + ComputedRef, + DirectiveBinding, + ExtractDefaultPropTypes, + ExtractPropTypes, + ExtractPublicPropTypes, + InjectionKey, + PropType, + Ref, + ShallowRef, + MaybeRef, + MaybeRefOrGetter, + VNode, + WritableComputedRef + } from 'vue' import('vue') } diff --git a/src/renderer/src/components/Play/AudioVisualizer.vue b/src/renderer/src/components/Play/AudioVisualizer.vue index dd1d536..4e170df 100644 --- a/src/renderer/src/components/Play/AudioVisualizer.vue +++ b/src/renderer/src/components/Play/AudioVisualizer.vue @@ -28,6 +28,8 @@ const emit = defineEmits<{ const canvasRef = ref() const animationId = ref() const analyser = ref() +// 节流渲染,目标 ~30fps +const lastFrameTime = ref(0) const dataArray = ref() const resizeObserver = ref() const componentId = ref(`visualizer-${Date.now()}-${Math.random()}`) @@ -75,93 +77,87 @@ const initAudioAnalyser = () => { } // 绘制可视化 -const draw = () => { +const draw = (ts?: number) => { if (!canvasRef.value || !analyser.value || !dataArray.value) return + // 帧率节流 ~30fps + const now = ts ?? performance.now() + if (now - lastFrameTime.value < 33) { + animationId.value = requestAnimationFrame(draw) + return + } + lastFrameTime.value = now + const canvas = canvasRef.value const ctx = canvas.getContext('2d') - if (!ctx) return + if (!ctx) { + animationId.value = requestAnimationFrame(draw) + return + } // 获取频域数据或生成模拟数据 if (analyser.value && dataArray.value) { - // 有真实音频分析器,获取真实数据 analyser.value.getByteFrequencyData(dataArray.value as Uint8Array) } else { - // 没有音频分析器,生成模拟数据 - const time = Date.now() * 0.001 + const time = now * 0.001 for (let i = 0; i < dataArray.value.length; i++) { - // 生成基于时间的模拟频谱数据 const frequency = i / dataArray.value.length const amplitude = Math.sin(time * 2 + frequency * 10) * 0.5 + 0.5 - const bass = Math.sin(time * 4) * 0.3 + 0.7 // 低频变化 + const bass = Math.sin(time * 4) * 0.3 + 0.7 dataArray.value[i] = Math.floor(amplitude * bass * 255 * (1 - frequency * 0.7)) } } - // 计算低频音量 (80hz-120hz 范围) - // 假设采样率为 44100Hz,fftSize 为 256,则每个频率 bin 约为 172Hz - // 80-120Hz 大约对应前 1-2 个 bin - const lowFreqStart = 0 - const lowFreqEnd = Math.min(3, dataArray.value.length) // 取前几个低频 bin + // 计算低频音量(前 3 个 bin) let lowFreqSum = 0 - for (let i = lowFreqStart; i < lowFreqEnd; i++) { - lowFreqSum += dataArray.value[i] - } - const lowFreqVolume = lowFreqSum / (lowFreqEnd - lowFreqStart) / 255 + const lowBins = Math.min(3, dataArray.value.length) + for (let i = 0; i < lowBins; i++) lowFreqSum += dataArray.value[i] + emit('lowFreqUpdate', lowFreqSum / lowBins / 255) - // 发送低频音量给父组件 - emit('lowFreqUpdate', lowFreqVolume) - - // 完全清空画布 + // 清屏 ctx.clearRect(0, 0, canvas.width, canvas.height) - // 如果有背景色,再填充背景 + // 背景 if (props.backgroundColor !== 'transparent') { ctx.fillStyle = props.backgroundColor ctx.fillRect(0, 0, canvas.width, canvas.height) } - // 使用容器的实际尺寸进行计算,因为 ctx 已经缩放过了 + // 计算尺寸 const container = canvas.parentElement - if (!container) return - + if (!container) { + animationId.value = requestAnimationFrame(draw) + return + } const containerRect = container.getBoundingClientRect() const canvasWidth = containerRect.width const canvasHeight = props.height - // 计算对称柱状图参数 + // 柱状参数 const halfBarCount = Math.floor(props.barCount / 2) const barWidth = canvasWidth / 2 / halfBarCount const maxBarHeight = canvasHeight * 0.9 const centerX = canvasWidth / 2 - // 绘制左右对称的频谱柱状图 + // 每帧仅创建一次渐变(自底向上),减少对象分配 + const gradient = ctx.createLinearGradient(0, canvasHeight, 0, 0) + gradient.addColorStop(0, props.color) + gradient.addColorStop(1, props.color.replace(/[\d\.]+\)$/g, '0.3)')) + ctx.fillStyle = gradient + + // 绘制对称频谱 for (let i = 0; i < halfBarCount; i++) { - // 增强低频响应,让可视化更敏感 let barHeight = (dataArray.value[i] / 255) * maxBarHeight - - // 对数据进行增强处理,让变化更明显 barHeight = Math.pow(barHeight / maxBarHeight, 0.6) * maxBarHeight - const y = canvasHeight - barHeight - // 创建渐变色 - const gradient = ctx.createLinearGradient(0, canvasHeight, 0, y) - gradient.addColorStop(0, props.color) - gradient.addColorStop(1, props.color.replace(/[\d\.]+\)$/g, '0.3)')) - - ctx.fillStyle = gradient - - // 绘制左侧柱状图(从中心向左) const leftX = centerX - (i + 1) * barWidth ctx.fillRect(leftX, y, barWidth, barHeight) - // 绘制右侧柱状图(从中心向右) const rightX = centerX + i * barWidth ctx.fillRect(rightX, y, barWidth, barHeight) } - // 继续动画 if (props.show && Audio.value.isPlay) { animationId.value = requestAnimationFrame(draw) } @@ -286,6 +282,10 @@ onBeforeUnmount(() => { analyser.value.disconnect() analyser.value = undefined } + // 通知管理器移除对该分析器的引用,防止 Map 持有导致 GC 不回收 + try { + audioManager.removeAnalyser(componentId.value) + } catch {} } catch (error) { console.warn('清理音频资源时出错:', error) } diff --git a/src/renderer/src/components/Play/FullPlay.vue b/src/renderer/src/components/Play/FullPlay.vue index 17aecca..a3e28f2 100644 --- a/src/renderer/src/components/Play/FullPlay.vue +++ b/src/renderer/src/components/Play/FullPlay.vue @@ -103,7 +103,7 @@ watch( const isNetease = props.songInfo && 'source' in props.songInfo && props.songInfo.source === 'wy' const songinfo: any = _.cloneDeep(toRaw(props.songInfo)) - console.log(songinfo) + if (isNetease) { // 网易云音乐优先尝试ttml接口 try { @@ -112,30 +112,26 @@ watch( ).text()) as any if (!res || res.length < 100) throw new Error('ttml 无歌词') parsedLyrics = parseTTML(res).lines - console.log('搜索到ttml歌词', parsedLyrics) } catch { // ttml失败后使用新的歌词API const lyricData = await window.api.music.requestSdk('getLyric', { source: 'wy', songInfo: songinfo }) - console.log('网易云歌词数据:', lyricData) if (lyricData.crlyric) { // 使用逐字歌词 lyricText = lyricData.crlyric - console.log('网易云逐字歌词', lyricText) + parsedLyrics = parseYrc(lyricText) - console.log('使用网易云逐字歌词', parsedLyrics) } else if (lyricData.lyric) { lyricText = lyricData.lyric parsedLyrics = parseLrc(lyricText) - console.log('使用网易云普通歌词', parsedLyrics) } if (lyricData.tlyric) { const translatedline = parseLrc(lyricData.tlyric) - console.log('网易云翻译歌词:', translatedline) + for (let i = 0; i < parsedLyrics.length; i++) { if (translatedline[i] && translatedline[i].words[0]) { parsedLyrics[i].translatedLyric = translatedline[i].words[0].word @@ -152,7 +148,6 @@ watch( source: source, songInfo: cleanSongInfo }) - console.log(`${source}歌词数据:`, lyricData) if (lyricData.crlyric) { // 使用逐字歌词 @@ -162,16 +157,14 @@ watch( } else { parsedLyrics = parseYrc(lyricText) } - console.log(`使用${source}逐字歌词`, parsedLyrics) } else if (lyricData.lyric) { lyricText = lyricData.lyric parsedLyrics = parseLrc(lyricText) - console.log(`使用${source}普通歌词`, parsedLyrics) } if (lyricData.tlyric) { const translatedline = parseLrc(lyricData.tlyric) - console.log(`${source}翻译歌词:`, translatedline) + for (let i = 0; i < parsedLyrics.length; i++) { if (translatedline[i] && translatedline[i].words[0]) { parsedLyrics[i].translatedLyric = translatedline[i].words[0].word @@ -182,10 +175,8 @@ watch( if (parsedLyrics.length > 0) { state.lyricLines = parsedLyrics - console.log('歌词加载成功', parsedLyrics.length) } else { state.lyricLines = [] - console.log('未找到歌词或解析失败') } } catch (error) { console.error('获取歌词失败:', error) @@ -197,6 +188,7 @@ watch( const bgRef = ref(undefined) const lyricPlayerRef = ref(undefined) + // 订阅音频事件,保持数据同步 const unsubscribeTimeUpdate = ref<(() => void) | undefined>(undefined) const unsubscribePlay = ref<(() => void) | undefined>(undefined) @@ -214,7 +206,6 @@ const useBlackText = ref(false) async function updateTextColor() { try { useBlackText.value = await shouldUseBlackText(actualCoverImage.value) - console.log('使用黑色文本:', useBlackText.value) } catch (error) { console.error('获取对比色失败:', error) useBlackText.value = false // 默认使用白色文本 @@ -247,7 +238,6 @@ watch( // 组件挂载时初始化 onMounted(() => { updateTextColor() - console.log('组件挂载完成', bgRef.value, lyricPlayerRef.value) }) // 组件卸载前清理订阅 diff --git a/src/renderer/src/components/Play/GlobalAudio.vue b/src/renderer/src/components/Play/GlobalAudio.vue index aeee00d..bb6d6cf 100644 --- a/src/renderer/src/components/Play/GlobalAudio.vue +++ b/src/renderer/src/components/Play/GlobalAudio.vue @@ -1,5 +1,14 @@ diff --git a/src/renderer/src/components/Play/PlayMusic.vue b/src/renderer/src/components/Play/PlayMusic.vue index 347822f..471fc21 100644 --- a/src/renderer/src/components/Play/PlayMusic.vue +++ b/src/renderer/src/components/Play/PlayMusic.vue @@ -132,12 +132,6 @@ let isFull = false // 播放指定歌曲 const playSong = async (song: SongList) => { - // 保存当前播放状态,用于失败时恢复 - const previousSong = { ...songInfo.value } - const previousSongId = userInfo.value.lastPlaySongId - const previousTime = Audio.value.currentTime - const wasPlaying = Audio.value.isPlay - try { // 设置加载状态 isLoadingSong.value = true @@ -194,35 +188,21 @@ const playSong = async (song: SongList) => { try { urlToPlay = await getSongRealUrl(toRaw(song)) } catch (error: any) { - console.error('获取歌曲 URL 失败,恢复原歌曲:', error) + console.error('获取歌曲 URL 失败,播放下一首原歌曲:', error) isLoadingSong.value = false - MessagePlugin.error('播放失败,原因:' + error.message) - - // 恢复歌曲信息 - songInfo.value = { ...previousSong } - userInfo.value.lastPlaySongId = previousSongId - mediaSessionController.updateMetadata({ - title: previousSong.name, - artist: previousSong.singer, - album: previousSong.albumName || '未知专辑', - artworkUrl: previousSong.img || defaultCoverImg - }) - - // 如果原来在播放,恢复播放 - if (wasPlaying && previousSong.songmid && Audio.value.audio) { - try { - if (previousTime > 0) { - Audio.value.audio.currentTime = previousTime - } - await start() - } catch (resumeError) { - console.error('恢复播放失败:', resumeError) - } - } - + tryAutoNext('获取歌曲 URL 失败') return } + // 在切换前彻底重置旧音频,释放缓冲与解码器 + if (Audio.value.audio) { + const a = Audio.value.audio + try { + a.pause() + } catch {} + a.removeAttribute('src') + a.load() + } // 设置 URL(这会触发音频重新加载) setUrl(urlToPlay) @@ -242,49 +222,39 @@ const playSong = async (song: SongList) => { /** * 异步开始播放(不await,以免阻塞UI) */ - start().catch(async (error: any) => { - console.error('启动播放失败:', error) - MessagePlugin.error('播放失败,原因:' + error.message) - - // 恢复旧歌曲状态 - songInfo.value = { ...previousSong } - userInfo.value.lastPlaySongId = previousSongId - mediaSessionController.updateMetadata({ - title: previousSong.name, - artist: previousSong.singer, - album: previousSong.albumName || '未知专辑', - artworkUrl: previousSong.img || defaultCoverImg + start() + .catch(async (error: any) => { + console.error('启动播放失败:', error) + tryAutoNext('启动播放失败') + }) + .then(() => { + autoNextCount.value = 0 }) - - // 如果原来在播放,恢复旧播放 - if (wasPlaying && previousSong.songmid && Audio.value.audio) { - try { - if (previousTime > 0) { - Audio.value.audio.currentTime = previousTime - } - await start() - } catch (resumeError) { - console.error('恢复播放失败:', resumeError) - } - } - }) /** * 注册事件监听,确保浏览器播放事件触发时同步关闭loading * (多一道保险) */ if (Audio.value.audio) { - Audio.value.audio.addEventListener('playing', () => { - isLoadingSong.value = false - }) - Audio.value.audio.addEventListener('error', () => { - isLoadingSong.value = false - }) + Audio.value.audio.addEventListener( + 'playing', + () => { + isLoadingSong.value = false + }, + { once: true } + ) + Audio.value.audio.addEventListener( + 'error', + () => { + isLoadingSong.value = false + }, + { once: true } + ) } - } catch (error: any) { console.error('播放歌曲失败(外层捕获):', error) - MessagePlugin.error('播放失败,原因:' + error.message) + tryAutoNext('播放歌曲失败') + // MessagePlugin.error('播放失败,原因:' + error.message) isLoadingSong.value = false } finally { // 最后的保险,确保加载状态一定会被关闭 @@ -300,6 +270,23 @@ const playMode = ref(userInfo.value.playMode || PlayMode.SEQUENCE) // 歌曲加载状态 const isLoadingSong = ref(false) +// 自动下一首次数限制:不超过当前列表的30% +const autoNextCount = ref(0) +const getAutoNextLimit = () => Math.max(1, Math.floor(list.value.length * 0.3)) +const tryAutoNext = (reason: string) => { + const limit = getAutoNextLimit() + MessagePlugin.error(`自动跳过当前歌曲:原因:${reason}`) + + if (autoNextCount.value >= limit && autoNextCount.value > 2) { + MessagePlugin.error( + `自动下一首失败:超过当前列表30%限制(${autoNextCount.value}/${limit})。原因:${reason}` + ) + return + } + autoNextCount.value++ + playNext() +} + // 更新播放模式 const updatePlayMode = () => { const modes = [PlayMode.SEQUENCE, PlayMode.RANDOM, PlayMode.SINGLE] diff --git a/src/renderer/src/utils/audio/audioManager.ts b/src/renderer/src/utils/audio/audioManager.ts index 4f6e806..0b21946 100644 --- a/src/renderer/src/utils/audio/audioManager.ts +++ b/src/renderer/src/utils/audio/audioManager.ts @@ -4,6 +4,8 @@ class AudioManager { private audioSources = new WeakMap() private audioContexts = new WeakMap() private analysers = new Map() + // 为每个 audioElement 复用一个分流器,避免重复断开重连主链路 + private splitters = new WeakMap() static getInstance(): AudioManager { if (!AudioManager.instance) { @@ -60,16 +62,19 @@ class AudioManager { analyser.fftSize = fftSize analyser.smoothingTimeConstant = 0.6 - // 创建增益节点作为中介,避免直接断开主音频链 - const gainNode = context.createGain() - gainNode.gain.value = 1.0 + // 复用每个 audioElement 的分流器:source -> splitter -> destination + let splitter = this.splitters.get(audioElement) + if (!splitter) { + splitter = context.createGain() + splitter.gain.value = 1.0 + // 仅第一次建立主链路,不要断开已有连接,避免累积 + source.connect(splitter) + splitter.connect(context.destination) + this.splitters.set(audioElement, splitter) + } - // 连接:source -> gainNode -> analyser - // -> destination (保持音频播放) - source.disconnect() // 先断开所有连接 - source.connect(gainNode) - gainNode.connect(context.destination) // 确保音频继续播放 - gainNode.connect(analyser) // 连接到分析器 + // 将分析器挂到分流器上,不影响主链路 + splitter.connect(analyser) // 存储分析器引用 this.analysers.set(id, analyser) @@ -104,6 +109,15 @@ class AudioManager { context.close() } + // 断开并移除分流器 + const splitter = this.splitters.get(audioElement) + if (splitter) { + try { + splitter.disconnect() + } catch {} + this.splitters.delete(audioElement) + } + this.audioSources.delete(audioElement) this.audioContexts.delete(audioElement)