diff --git a/src/renderer/App.vue b/src/renderer/App.vue index 9fde7eb..f0438b2 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -125,6 +125,19 @@ onMounted(async () => { if (isLyricWindow.value) { return; } + + // 检查网络状态,离线时自动跳转到本地音乐页面 + if (!navigator.onLine) { + console.log('检测到无网络连接,跳转到本地音乐页面'); + router.push('/local-music'); + } + + // 监听网络状态变化,断网时跳转到本地音乐页面 + window.addEventListener('offline', () => { + console.log('网络连接断开,跳转到本地音乐页面'); + router.push('/local-music'); + }); + // 初始化 MusicHook,注入 playerStore initMusicHook(playerStore); // 初始化播放状态 diff --git a/src/renderer/hooks/MusicHook.ts b/src/renderer/hooks/MusicHook.ts index 954005a..c0b4ea7 100644 --- a/src/renderer/hooks/MusicHook.ts +++ b/src/renderer/hooks/MusicHook.ts @@ -397,6 +397,8 @@ const setupMusicWatchers = () => { const setupAudioListeners = () => { let interval: any = null; + // 播放状态恢复定时器:当 interval 因异常被清除时,自动恢复 + let recoveryTimer: any = null; const clearInterval = () => { if (interval) { @@ -405,9 +407,91 @@ const setupAudioListeners = () => { } }; + const stopRecovery = () => { + if (recoveryTimer) { + window.clearInterval(recoveryTimer); + recoveryTimer = null; + } + }; + + /** + * 启动进度更新 interval + * 从 audioService 实时获取 sound 引用,避免闭包中 sound.value 过期 + */ + const startProgressInterval = () => { + clearInterval(); + interval = window.setInterval(() => { + try { + // 每次从 audioService 获取最新的 sound 引用,而不是依赖闭包中的 sound.value + const currentSound = audioService.getCurrentSound(); + if (!currentSound) { + // sound 暂时为空(可能在切歌/重建中),不清除 interval,等待恢复 + return; + } + + if (typeof currentSound.seek !== 'function') { + // seek 方法不可用,跳过本次更新,不清除 interval + return; + } + + const currentTime = currentSound.seek() as number; + if (typeof currentTime !== 'number' || Number.isNaN(currentTime)) { + // 无效时间,跳过本次更新 + return; + } + + // 同步 sound.value 引用(确保外部也能拿到最新的) + if (sound.value !== currentSound) { + sound.value = currentSound; + } + + nowTime.value = currentTime; + allTime.value = currentSound.duration() as number; + const newIndex = getLrcIndex(nowTime.value); + if (newIndex !== nowIndex.value) { + nowIndex.value = newIndex; + if (isElectron && isLyricWindowOpen.value) { + sendLyricToWin(); + } + } + if (isElectron && isLyricWindowOpen.value) { + sendLyricToWin(); + } + } catch (error) { + console.error('进度更新 interval 出错:', error); + // 出错时不清除 interval,让下一次 tick 继续尝试 + } + }, 50); + }; + + /** + * 启动播放状态恢复监控 + * 每 500ms 检查一次:如果 store 认为在播放但 interval 已丢失,则恢复 + */ + const startRecoveryMonitor = () => { + stopRecovery(); + recoveryTimer = window.setInterval(() => { + try { + const store = getPlayerStore(); + if (store.play && !interval) { + const currentSound = audioService.getCurrentSound(); + if (currentSound && currentSound.playing()) { + console.warn('[MusicHook] 检测到播放中但 interval 丢失,自动恢复'); + startProgressInterval(); + } + } + } catch { + // 静默忽略 + } + }, 500); + }; + // 清理所有事件监听器 audioService.clearAllListeners(); + // 启动恢复监控 + startRecoveryMonitor(); + // 监听seek开始事件,立即更新UI audioService.on('seek_start', (time) => { // 直接更新显示位置,不检查拖动状态 @@ -417,7 +501,7 @@ const setupAudioListeners = () => { // 监听seek完成事件 audioService.on('seek', () => { try { - const currentSound = sound.value; + const currentSound = audioService.getCurrentSound(); if (currentSound) { // 立即更新显示时间,不进行任何检查 const currentTime = currentSound.seek() as number; @@ -465,49 +549,8 @@ const setupAudioListeners = () => { if (isElectron) { window.api.sendSong(cloneDeep(getPlayerStore().playMusic)); } - clearInterval(); - interval = window.setInterval(() => { - try { - const currentSound = sound.value; - if (!currentSound) { - console.error('Invalid sound object: sound is null or undefined'); - clearInterval(); - return; - } - - // 确保 seek 方法存在且可调用 - if (typeof currentSound.seek !== 'function') { - console.error('Invalid sound object: seek function not available'); - clearInterval(); - return; - } - - const currentTime = currentSound.seek() as number; - if (typeof currentTime !== 'number' || Number.isNaN(currentTime)) { - console.error('Invalid current time:', currentTime); - clearInterval(); - return; - } - - nowTime.value = currentTime; - allTime.value = currentSound.duration() as number; - const newIndex = getLrcIndex(nowTime.value); - if (newIndex !== nowIndex.value) { - nowIndex.value = newIndex; - // 注意:我们不在这里设置 currentLrcProgress 为 0 - // 因为这会与全局进度更新冲突 - if (isElectron && isLyricWindowOpen.value) { - sendLyricToWin(); - } - } - if (isElectron && isLyricWindowOpen.value) { - sendLyricToWin(); - } - } catch (error) { - console.error('Error in interval:', error); - clearInterval(); - } - }, 50); + // 启动进度更新 + startProgressInterval(); }); // 监听暂停 @@ -520,14 +563,16 @@ const setupAudioListeners = () => { } }); - const replayMusic = async () => { + const replayMusic = async (retryCount: number = 0) => { + const MAX_REPLAY_RETRIES = 3; try { // 如果当前有音频实例,先停止并销毁 - if (sound.value) { - sound.value.stop(); - sound.value.unload(); - sound.value = null; + const currentSound = audioService.getCurrentSound(); + if (currentSound) { + currentSound.stop(); + currentSound.unload(); } + sound.value = null; // 重新播放当前歌曲 if (getPlayerStore().playMusicUrl && playMusic.value) { @@ -535,12 +580,18 @@ const setupAudioListeners = () => { sound.value = newSound as Howl; setupAudioListeners(); } else { - console.error('No music URL or playMusic data available'); + console.error('单曲循环:无可用 URL 或歌曲数据'); getPlayerStore().nextPlay(); } } catch (error) { - console.error('Error replaying song:', error); - getPlayerStore().nextPlay(); + console.error('单曲循环重播失败:', error); + if (retryCount < MAX_REPLAY_RETRIES) { + console.log(`单曲循环重试 ${retryCount + 1}/${MAX_REPLAY_RETRIES}`); + setTimeout(() => replayMusic(retryCount + 1), 1000 * (retryCount + 1)); + } else { + console.error('单曲循环重试次数用尽,切换下一首'); + getPlayerStore().nextPlay(); + } } }; @@ -551,9 +602,7 @@ const setupAudioListeners = () => { if (getPlayerStore().playMode === 1) { // 单曲循环模式 - if (sound.value) { - replayMusic(); - } + replayMusic(); } else { // 顺序播放、列表循环、随机播放模式都使用统一的nextPlay方法 getPlayerStore().nextPlay(); @@ -568,7 +617,10 @@ const setupAudioListeners = () => { getPlayerStore().nextPlay(); }); - return clearInterval; + return () => { + clearInterval(); + stopRecovery(); + }; }; export const play = () => { diff --git a/src/renderer/hooks/usePlayerHooks.ts b/src/renderer/hooks/usePlayerHooks.ts index d5e94bc..52dd73f 100644 --- a/src/renderer/hooks/usePlayerHooks.ts +++ b/src/renderer/hooks/usePlayerHooks.ts @@ -315,8 +315,11 @@ export const useSongDetail = () => { } if (playMusic.expiredAt && playMusic.expiredAt < Date.now()) { - console.info(`歌曲已过期,重新获取: ${playMusic.name}`); - playMusic.playMusicUrl = undefined; + // 本地音乐(local:// 协议)不会过期,跳过清除 + if (!playMusic.playMusicUrl?.startsWith('local://')) { + console.info(`歌曲已过期,重新获取: ${playMusic.name}`); + playMusic.playMusicUrl = undefined; + } } try { diff --git a/src/renderer/services/audioService.ts b/src/renderer/services/audioService.ts index 90207b3..a35266e 100644 --- a/src/renderer/services/audioService.ts +++ b/src/renderer/services/audioService.ts @@ -513,66 +513,27 @@ class AudioService { seekTime: number = 0, existingSound?: Howl ): Promise { - // 每次调用play方法时,尝试强制重置锁(注意:仅在页面刷新后的第一次播放时应用) - if (!this.currentSound) { - console.log('首次播放请求,强制重置操作锁'); - this.forceResetOperationLock(); - } - - // 如果有操作锁,且不是同一个 track 的操作,则等待 - if (this.operationLock) { - console.log('audioService: 操作锁激活中,等待...'); - return Promise.reject(new Error('操作锁激活中')); - } - - if (!this.setOperationLock()) { - console.log('audioService: 获取操作锁失败'); - return Promise.reject(new Error('操作锁激活中')); - } - - // 如果操作锁已激活,但持续时间超过安全阈值,强制重置 - if (this.operationLock) { - const currentTime = Date.now(); - const lockDuration = currentTime - this.operationLockStartTime; - - if (lockDuration > 2000) { - console.warn(`操作锁已激活 ${lockDuration}ms,超过安全阈值,强制重置`); - this.forceResetOperationLock(); - } - } - - // 获取锁 - if (!this.setOperationLock()) { - console.log('audioService: 操作锁激活,强制执行当前播放请求'); - - // 如果只是要继续播放当前音频,直接执行 - if (this.currentSound && !url && !track) { - if (this.seekLock && this.seekDebounceTimer) { - clearTimeout(this.seekDebounceTimer); - this.seekLock = false; - } - this.currentSound.play(); - return Promise.resolve(this.currentSound); - } - - // 强制释放锁并继续执行 - this.forceResetOperationLock(); - - // 这里不再返回错误,而是继续执行播放逻辑 - } - - // 如果没有提供新的 URL 和 track,且当前有音频实例,则继续播放 + // 如果没有提供新的 URL 和 track,且当前有音频实例,则继续播放当前音频 if (this.currentSound && !url && !track) { - // 如果有进行中的seek操作,等待其完成 if (this.seekLock && this.seekDebounceTimer) { clearTimeout(this.seekDebounceTimer); this.seekLock = false; } this.currentSound.play(); - this.releaseOperationLock(); return Promise.resolve(this.currentSound); } + // 新播放请求:强制重置旧锁,确保不会被遗留锁阻塞 + this.forceResetOperationLock(); + + // 获取操作锁 + if (!this.setOperationLock()) { + // 理论上不会到这里(刚刚 forceReset 过),但作为防御性编程 + console.warn('audioService: 获取操作锁失败,强制继续'); + this.forceResetOperationLock(); + this.setOperationLock(); + } + // 如果没有提供必要的参数,返回错误 if (!url || !track) { this.releaseOperationLock(); @@ -649,11 +610,6 @@ class AudioService { this.currentTrack = track; } - // 如果不是热切换,立即更新 currentTrack - if (!isHotSwap) { - this.currentTrack = track; - } - let newSound: Howl; if (existingSound) { @@ -1061,6 +1017,8 @@ class AudioService { * 验证音频图是否正确连接 * 用于检测音频播放前的图状态 */ + // 检查音频图是否连接(调试用,保留供 EQ 诊断) + // @ts-ignore 保留供调试使用 private isAudioGraphConnected(): boolean { if (!this.context || !this.gainNode || !this.source) { return false; @@ -1150,18 +1108,14 @@ class AudioService { if (!this.currentSound) return false; try { - // 综合判断: - // 1. Howler API是否报告正在播放 - // 2. 是否不在加载状态 - // 3. 确保音频上下文状态正常 - // 4. 确保音频图正确连接(在 Electron 环境中) + // 核心判断:Howler API 是否报告正在播放 + 音频上下文是否正常 + // 注意:不再检查 isAudioGraphConnected(),因为 EQ 重建期间 + // source/gainNode 会暂时为 null,导致误判为未播放 const isPlaying = this.currentSound.playing(); const isLoading = this.isLoading(); const contextRunning = Howler.ctx && Howler.ctx.state === 'running'; - const graphConnected = isElectron ? this.isAudioGraphConnected() : true; - // 只有在所有条件都满足时才认为是真正在播放 - return isPlaying && !isLoading && contextRunning && graphConnected; + return isPlaying && !isLoading && contextRunning; } catch (error) { console.error('检查播放状态出错:', error); return false; diff --git a/src/renderer/services/preloadService.ts b/src/renderer/services/preloadService.ts index c532895..4e1630a 100644 --- a/src/renderer/services/preloadService.ts +++ b/src/renderer/services/preloadService.ts @@ -64,12 +64,29 @@ class PreloadService { const duration = sound.duration(); const expectedDuration = (song.dt || 0) / 1000; - // 时长差异只记录警告,不自动触发重新解析 - // 用户可以通过 ReparsePopover 手动选择正确的音源 - if (expectedDuration > 0 && Math.abs(duration - expectedDuration) > 5) { - console.warn( - `[PreloadService] 时长差异警告:实际 ${duration.toFixed(1)}s, 预期 ${expectedDuration.toFixed(1)}s (${song.name})` - ); + if (expectedDuration > 0 && duration > 0) { + const durationDiff = Math.abs(duration - expectedDuration); + // 如果实际时长远小于预期(可能是试听版),记录警告 + if (duration < expectedDuration * 0.5 && durationDiff > 10) { + console.warn( + `[PreloadService] 时长严重不足:实际 ${duration.toFixed(1)}s, 预期 ${expectedDuration.toFixed(1)}s (${song.name}),可能是试听版` + ); + // 通过自定义事件通知上层,可用于后续自动切换音源 + window.dispatchEvent( + new CustomEvent('audio-duration-mismatch', { + detail: { + songId: song.id, + songName: song.name, + actualDuration: duration, + expectedDuration + } + }) + ); + } else if (durationDiff > 5) { + console.warn( + `[PreloadService] 时长差异警告:实际 ${duration.toFixed(1)}s, 预期 ${expectedDuration.toFixed(1)}s (${song.name})` + ); + } } return sound; diff --git a/src/renderer/store/modules/playerCore.ts b/src/renderer/store/modules/playerCore.ts index 6e3f30b..aa63692 100644 --- a/src/renderer/store/modules/playerCore.ts +++ b/src/renderer/store/modules/playerCore.ts @@ -110,8 +110,9 @@ export const usePlayerCoreStore = defineStore( /** * 播放状态检测 + * 在播放开始后延迟检查音频是否真正在播放,防止无声播放 */ - const checkPlaybackState = (song: SongResult, requestId?: string, timeout: number = 4000) => { + const checkPlaybackState = (song: SongResult, requestId?: string, timeout: number = 6000) => { if (checkPlayTime) { clearTimeout(checkPlayTime); } @@ -125,6 +126,10 @@ export const usePlayerCoreStore = defineStore( console.log(`[${actualRequestId}] 播放事件触发,歌曲成功开始播放`); audioService.off('play', onPlayHandler); audioService.off('playerror', onPlayErrorHandler); + if (checkPlayTime) { + clearTimeout(checkPlayTime); + checkPlayTime = null; + } }; const onPlayErrorHandler = async () => { @@ -140,7 +145,10 @@ export const usePlayerCoreStore = defineStore( if (userPlayIntent.value && play.value) { console.log('播放失败,尝试刷新URL并重新播放'); - playMusic.value.playMusicUrl = undefined; + // 本地音乐不需要刷新 URL + if (!playMusic.value.playMusicUrl?.startsWith('local://')) { + playMusic.value.playMusicUrl = undefined; + } const refreshedSong = { ...song, isFirstPlay: true }; await handlePlayMusic(refreshedSong, true); } @@ -158,16 +166,46 @@ export const usePlayerCoreStore = defineStore( return; } + // 双重确认:Howler 报告未播放 + 用户仍想播放 + // 额外检查底层 HTMLAudioElement 的状态,避免 EQ 重建期间的误判 + const currentSound = audioService.getCurrentSound(); + let htmlPlaying = false; + if (currentSound) { + try { + const sounds = (currentSound as any)._sounds as any[]; + if (sounds?.[0]?._node instanceof HTMLMediaElement) { + const node = sounds[0]._node as HTMLMediaElement; + htmlPlaying = !node.paused && !node.ended && node.readyState > 2; + } + } catch { + // 静默忽略 + } + } + + if (htmlPlaying) { + // 底层 HTMLAudioElement 实际在播放,不需要重试 + console.log('底层音频元素正在播放,跳过超时重试'); + audioService.off('play', onPlayHandler); + audioService.off('playerror', onPlayErrorHandler); + return; + } + if (!audioService.isActuallyPlaying() && userPlayIntent.value && play.value) { console.log(`${timeout}ms后歌曲未真正播放且用户仍希望播放,尝试重新获取URL`); audioService.off('play', onPlayHandler); audioService.off('playerror', onPlayErrorHandler); - playMusic.value.playMusicUrl = undefined; + // 本地音乐不需要刷新 URL + if (!playMusic.value.playMusicUrl?.startsWith('local://')) { + playMusic.value.playMusicUrl = undefined; + } (async () => { const refreshedSong = { ...song, isFirstPlay: true }; await handlePlayMusic(refreshedSong, true); })(); + } else { + audioService.off('play', onPlayHandler); + audioService.off('playerror', onPlayErrorHandler); } }, timeout); }; @@ -418,11 +456,10 @@ export const usePlayerCoreStore = defineStore( return newSound; } catch (error) { console.error('播放音频失败:', error); - setPlayMusic(false); const errorMsg = error instanceof Error ? error.message : String(error); - // 操作锁错误处理 + // 操作锁错误不应该停止播放状态,只需要重试 if (errorMsg.includes('操作锁激活')) { console.log('由于操作锁正在使用,将在1000ms后重试'); @@ -442,14 +479,17 @@ export const usePlayerCoreStore = defineStore( if (userPlayIntent.value && play.value) { playAudio(requestId).catch((e) => { console.error('重试播放失败:', e); + setPlayMusic(false); }); } }, 1000); } else { + // 非操作锁错误,停止播放并通知用户 + setPlayMusic(false); console.warn('播放音频失败(非操作锁错误),由调用方处理重试'); + message.error(i18n.global.t('player.playFailed')); } - message.error(i18n.global.t('player.playFailed')); return null; } }; @@ -556,8 +596,15 @@ export const usePlayerCoreStore = defineStore( console.log('恢复上次播放的音乐:', playMusic.value.name); const isPlaying = settingStore.setData.autoPlay; + // 本地音乐(local:// 协议)不需要重新获取 URL,保留原始路径 + const isLocalMusic = playMusic.value.playMusicUrl?.startsWith('local://'); + await handlePlayMusic( - { ...playMusic.value, isFirstPlay: true, playMusicUrl: undefined }, + { + ...playMusic.value, + isFirstPlay: true, + playMusicUrl: isLocalMusic ? playMusic.value.playMusicUrl : undefined + }, isPlaying ); } catch (error) { diff --git a/src/renderer/store/modules/playlist.ts b/src/renderer/store/modules/playlist.ts index 7805d30..cea300c 100644 --- a/src/renderer/store/modules/playlist.ts +++ b/src/renderer/store/modules/playlist.ts @@ -563,9 +563,12 @@ export const usePlaylistStore = defineStore( // 检查URL是否已过期 if (song.expiredAt && song.expiredAt < Date.now()) { - console.info(`歌曲URL已过期,重新获取: ${song.name}`); - song.playMusicUrl = undefined; - song.expiredAt = undefined; + // 本地音乐(local:// 协议)不会过期 + if (!song.playMusicUrl?.startsWith('local://')) { + console.info(`歌曲URL已过期,重新获取: ${song.name}`); + song.playMusicUrl = undefined; + song.expiredAt = undefined; + } } // 如果是当前正在播放的音乐,则切换播放/暂停状态