From 095b5c30291e5c6989116769853b7aa667755bd3 Mon Sep 17 00:00:00 2001 From: alger Date: Sun, 5 Jul 2026 19:52:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D=E5=BF=AB?= =?UTF-8?q?=E9=80=9F=E5=88=87=E6=AD=8C=E5=8D=A1=E6=AD=BB=E3=80=81=E9=9A=8F?= =?UTF-8?q?=E6=9C=BA=E6=A8=A1=E5=BC=8F=E6=B4=97=E7=89=8C=E3=80=81=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E7=9D=A1=E7=9C=A0=E9=87=8D=E5=90=AF=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audioService: 切歌时清除上一首遗留的 canplay/error 监听器,避免过期回调 stop 掉正在播放的新歌导致静音卡死 - playlist: setPlayList 新增 preserveOrder,就地编辑(下一首播放/移除单曲)时随机模式下不再重新洗牌,并同步 originalPlayList - sleepTimer: store 初始化时重建定时器 interval,修复重启后定时关闭到点不停 - preloadService: 清理 3s 验证超时定时器、validatedUrls 加容量上限、预加载调用处补 catch --- src/renderer/services/audioService.ts | 22 +++++++++++ src/renderer/services/preloadService.ts | 18 ++++++++- src/renderer/store/modules/playlist.ts | 50 ++++++++++++++++++++---- src/renderer/store/modules/sleepTimer.ts | 24 ++++++++++++ 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/renderer/services/audioService.ts b/src/renderer/services/audioService.ts index 77d80d1..d425483 100644 --- a/src/renderer/services/audioService.ts +++ b/src/renderer/services/audioService.ts @@ -19,6 +19,11 @@ class AudioService { private operationLock = false; private operationLockTimer: ReturnType | null = null; + // 当前一次加载(play)挂载在共享 audio 元素上的 canplay/error 监听器的清理函数。 + // 快速切歌时,上一首尚未结算的监听器必须在新一轮加载或 stop() 时移除, + // 否则新歌 canplay 会同时触发旧歌的回调,导致"过期回调 stop 掉正在播放的新歌"卡死。 + private pendingLoadCleanup: (() => void) | null = null; + private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; private defaultEQSettings: { [key: string]: number } = { @@ -434,6 +439,12 @@ class AudioService { return Promise.resolve(this.audio); } + // 开始新一轮加载前,先移除上一轮尚未结算的加载监听器,避免污染新歌 + if (this.pendingLoadCleanup) { + this.pendingLoadCleanup(); + this.pendingLoadCleanup = null; + } + return new Promise((resolve, reject) => { let retryCount = 0; const maxRetries = 1; @@ -502,8 +513,14 @@ class AudioService { const cleanup = () => { this.audio.removeEventListener('canplay', onCanPlay); this.audio.removeEventListener('error', onError); + if (this.pendingLoadCleanup === cleanup) { + this.pendingLoadCleanup = null; + } }; + // 记录本轮清理函数,供下一次 play()/stop() 在结算前主动移除 + this.pendingLoadCleanup = cleanup; + this.audio.addEventListener('canplay', onCanPlay, { once: true }); this.audio.addEventListener('error', onError, { once: true }); @@ -529,6 +546,11 @@ class AudioService { public stop() { this.forceResetOperationLock(); + // 移除尚未结算的加载监听器,避免 stop 后旧的 canplay/error 仍触发过期回调 + if (this.pendingLoadCleanup) { + this.pendingLoadCleanup(); + this.pendingLoadCleanup = null; + } try { this.audio.pause(); this.audio.removeAttribute('src'); diff --git a/src/renderer/services/preloadService.ts b/src/renderer/services/preloadService.ts index d3d0369..f6eda71 100644 --- a/src/renderer/services/preloadService.ts +++ b/src/renderer/services/preloadService.ts @@ -10,6 +10,9 @@ class PreloadService { private validatedUrls: Map = new Map(); private loadingPromises: Map> = new Map(); + // 已验证 URL 缓存的最大条目数,超出后按插入顺序淘汰最旧项,避免长会话内无界增长 + private static readonly MAX_VALIDATED_URLS = 100; + /** * 验证歌曲 URL 可用性 * 通过 HEAD 请求检查 URL 是否可访问,并缓存验证结果 @@ -44,6 +47,13 @@ class PreloadService { try { const validatedUrl = await loadPromise; this.validatedUrls.set(song.id, validatedUrl); + // 超出容量上限时淘汰最旧插入的条目 + if (this.validatedUrls.size > PreloadService.MAX_VALIDATED_URLS) { + const oldestKey = this.validatedUrls.keys().next().value; + if (oldestKey !== undefined) { + this.validatedUrls.delete(oldestKey); + } + } return validatedUrl; } finally { this.loadingPromises.delete(song.id); @@ -59,7 +69,13 @@ class PreloadService { testAudio.crossOrigin = 'anonymous'; testAudio.preload = 'metadata'; + let timeoutId: ReturnType | null = null; + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } testAudio.removeEventListener('loadedmetadata', onLoaded); testAudio.removeEventListener('error', onError); testAudio.src = ''; @@ -105,7 +121,7 @@ class PreloadService { testAudio.load(); // 3秒超时(优化预加载速度) - setTimeout(() => { + timeoutId = setTimeout(() => { cleanup(); // 超时不算失败,URL 可能是可用的只是服务器慢 resolve(url); diff --git a/src/renderer/store/modules/playlist.ts b/src/renderer/store/modules/playlist.ts index a1f9232..26181e7 100644 --- a/src/renderer/store/modules/playlist.ts +++ b/src/renderer/store/modules/playlist.ts @@ -110,7 +110,10 @@ export const usePlaylistStore = defineStore( // 预加载下一首歌曲的音频和封面 if (nextSong) { if (nextSong.playMusicUrl) { - preloadService.load(nextSong); + // 预加载失败(URL 验证不过)不应影响主流程,捕获避免未处理的 Promise rejection + preloadService.load(nextSong).catch((err) => { + console.warn('预加载下一首失败:', err); + }); } if (nextSong.picUrl) { preloadCoverImage(nextSong.picUrl, getImgUrl); @@ -224,7 +227,10 @@ export const usePlaylistStore = defineStore( const setPlayList = ( list: SongResult[], keepIndex: boolean = false, - fromIntelligenceMode: boolean = false + fromIntelligenceMode: boolean = false, + // preserveOrder=true 表示"就地编辑当前队列"(下一首播放/移除单曲), + // 此时即使处于随机模式也不应重新洗牌,保持调用方给定的顺序。 + preserveOrder: boolean = false ) => { // 如果不是从心动模式调用,清除心动模式状态并切换播放模式 if (!fromIntelligenceMode) { @@ -260,9 +266,37 @@ export const usePlaylistStore = defineStore( const playerCore = usePlayerCoreStore(); const { playMusic } = storeToRefs(playerCore); - // 根据当前播放模式处理新的播放列表 - if (playMode.value === 2) { - // 随机模式 + if (preserveOrder) { + // 就地编辑当前队列(下一首播放 / 移除单曲):保留调用方给定的顺序,不重新洗牌。 + console.log('就地编辑播放列表,保持给定顺序'); + + if (playMode.value === 2) { + // 随机模式下同步原始顺序列表:删除已移除项、追加新加入项,保留既有原始顺序 + const idSet = new Set(list.map((song) => song.id)); + const reconciled = originalPlayList.value.filter((song) => idSet.has(song.id)); + const existingIds = new Set(reconciled.map((song) => song.id)); + for (const song of list) { + if (!existingIds.has(song.id)) { + reconciled.push(song); + } + } + originalPlayList.value = reconciled; + } else if (originalPlayList.value.length > 0) { + originalPlayList.value = []; + } + + // 修正当前索引,指向当前正在播放的歌曲 + const currentSong = playMusic.value; + const currentIndex = + currentSong && currentSong.id ? list.findIndex((song) => song.id === currentSong.id) : -1; + playListIndex.value = + currentIndex !== -1 + ? currentIndex + : Math.min(Math.max(0, playListIndex.value), list.length - 1); + + playList.value = list; + } else if (playMode.value === 2) { + // 随机模式:全新列表,保存原始顺序并洗牌 console.log('随机模式下设置新播放列表,保存原始顺序并洗牌'); originalPlayList.value = [...list]; @@ -315,7 +349,8 @@ export const usePlaylistStore = defineStore( const insertIndex = playListIndex.value + 1; list.splice(insertIndex, 0, song); - setPlayList(list, true); + // preserveOrder=true:随机模式下也不重新洗牌,确保"下一首播放"位置生效 + setPlayList(list, true, false, true); }; /** @@ -335,7 +370,8 @@ export const usePlaylistStore = defineStore( const newPlayList = [...playList.value]; newPlayList.splice(index, 1); - setPlayList(newPlayList); + // preserveOrder=true:随机模式下移除单曲不重新洗牌,仅从队列中删除 + setPlayList(newPlayList, false, false, true); }; /** diff --git a/src/renderer/store/modules/sleepTimer.ts b/src/renderer/store/modules/sleepTimer.ts index 53da90b..8e22ac8 100644 --- a/src/renderer/store/modules/sleepTimer.ts +++ b/src/renderer/store/modules/sleepTimer.ts @@ -240,6 +240,30 @@ export const useSleepTimerStore = defineStore('sleepTimer', () => { showSleepTimer.value = value; }; + /** + * 应用重启/刷新后恢复定时器: + * sleepTimer 状态会被持久化并在 ref 初始化时恢复,但 setInterval 不会, + * 导致 TIME 类型定时器到点后不再触发停止。这里在 store 创建时重建 interval。 + */ + const restoreTimerInterval = () => { + if (sleepTimer.value.type !== SleepTimerType.TIME || !sleepTimer.value.endTime) { + return; + } + if (Date.now() >= sleepTimer.value.endTime) { + // 重启时已过设定时间,直接停止播放 + stopPlayback(); + return; + } + if (!timerInterval.value) { + timerInterval.value = window.setInterval(() => { + checkSleepTimer(); + }, 1000) as unknown as number; + } + }; + + // store 首次实例化时立即尝试恢复(player store 在启动阶段即会实例化本 store) + restoreTimerInterval(); + return { // 状态 sleepTimer,