From 1a0e449e13ed2a96dedcf2226a289070dcea2848 Mon Sep 17 00:00:00 2001 From: alger Date: Fri, 21 Nov 2025 01:18:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=80=E7=B3=BB=E5=88=97=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n/lang/en-US/settings.ts | 8 +- src/i18n/lang/ja-JP/settings.ts | 8 +- src/i18n/lang/ko-KR/settings.ts | 8 +- src/i18n/lang/zh-CN/settings.ts | 8 +- src/i18n/lang/zh-Hant/settings.ts | 8 +- src/main/unblockMusic.ts | 4 +- .../components/player/ReparsePopover.vue | 15 +- .../components/player/SimplePlayBar.vue | 94 +++- .../settings/MusicSourceSettings.vue | 404 +++++++++++++++--- src/renderer/hooks/usePlayerHooks.ts | 148 ++++--- src/renderer/services/audioService.ts | 234 +++++++--- .../services/playbackRequestManager.ts | 294 +++++++++++++ src/renderer/services/preloadService.ts | 273 ++++++++++++ src/renderer/store/modules/playerCore.ts | 260 ++++++++++- src/renderer/store/modules/playlist.ts | 73 +++- src/renderer/types/music.ts | 12 +- src/renderer/utils/appShortcuts.ts | 2 +- src/renderer/utils/yrcParser.ts | 3 - src/renderer/views/set/index.vue | 160 ++++--- 19 files changed, 1712 insertions(+), 304 deletions(-) create mode 100644 src/renderer/services/playbackRequestManager.ts create mode 100644 src/renderer/services/preloadService.ts diff --git a/src/i18n/lang/en-US/settings.ts b/src/i18n/lang/en-US/settings.ts index 5d36adf..00ac37e 100644 --- a/src/i18n/lang/en-US/settings.ts +++ b/src/i18n/lang/en-US/settings.ts @@ -10,7 +10,7 @@ export default { network: 'Network Settings', system: 'System Management', donation: 'Donation', - regard: 'About' + about: 'About' }, basic: { themeMode: 'Theme Mode', @@ -114,7 +114,11 @@ export default { notImported: 'No custom source imported yet.', importSuccess: 'Successfully imported source: {name}', importFailed: 'Import failed: {message}', - enableHint: 'Import a JSON config file to enable' + enableHint: 'Import a JSON config file to enable', + status: { + imported: 'Custom Source Imported', + notImported: 'Not Imported' + } } }, application: { diff --git a/src/i18n/lang/ja-JP/settings.ts b/src/i18n/lang/ja-JP/settings.ts index c40b889..078b562 100644 --- a/src/i18n/lang/ja-JP/settings.ts +++ b/src/i18n/lang/ja-JP/settings.ts @@ -10,7 +10,7 @@ export default { network: 'ネットワーク設定', system: 'システム管理', donation: '寄付サポート', - regard: 'について' + about: 'について' }, basic: { themeMode: 'テーマモード', @@ -111,7 +111,11 @@ export default { currentSource: '現在の音源', notImported: 'カスタム音源はまだインポートされていません。', importSuccess: '音源のインポートに成功しました: {name}', - importFailed: 'インポートに失敗しました: {message}' + importFailed: 'インポートに失敗しました: {message}', + status: { + imported: 'カスタム音源インポート済み', + notImported: '未インポート' + } } }, application: { diff --git a/src/i18n/lang/ko-KR/settings.ts b/src/i18n/lang/ko-KR/settings.ts index 783a254..f665e94 100644 --- a/src/i18n/lang/ko-KR/settings.ts +++ b/src/i18n/lang/ko-KR/settings.ts @@ -10,7 +10,7 @@ export default { network: '네트워크 설정', system: '시스템 관리', donation: '후원 지원', - regard: '정보' + about: '정보' }, basic: { themeMode: '테마 모드', @@ -112,7 +112,11 @@ export default { notImported: '아직 사용자 지정 음원을 가져오지 않았습니다.', importSuccess: '음원 가져오기 성공: {name}', importFailed: '가져오기 실패: {message}', - enableHint: '사용하려면 먼저 JSON 구성 파일을 가져오세요' + enableHint: '사용하려면 먼저 JSON 구성 파일을 가져오세요', + status: { + imported: '사용자 지정 음원 가져옴', + notImported: '가져오지 않음' + } } }, application: { diff --git a/src/i18n/lang/zh-CN/settings.ts b/src/i18n/lang/zh-CN/settings.ts index ebd8138..de444fe 100644 --- a/src/i18n/lang/zh-CN/settings.ts +++ b/src/i18n/lang/zh-CN/settings.ts @@ -10,7 +10,7 @@ export default { network: '网络设置', system: '系统管理', donation: '捐赠支持', - regard: '关于' + about: '关于' }, basic: { themeMode: '主题模式', @@ -111,7 +111,11 @@ export default { notImported: '尚未导入自定义音源。', importSuccess: '成功导入音源: {name}', importFailed: '导入失败: {message}', - enableHint: '请先导入 JSON 配置文件才能启用' + enableHint: '请先导入 JSON 配置文件才能启用', + status: { + imported: '已导入自定义音源', + notImported: '未导入' + } } }, application: { diff --git a/src/i18n/lang/zh-Hant/settings.ts b/src/i18n/lang/zh-Hant/settings.ts index 7205557..d97f874 100644 --- a/src/i18n/lang/zh-Hant/settings.ts +++ b/src/i18n/lang/zh-Hant/settings.ts @@ -10,7 +10,7 @@ export default { network: '網路設定', system: '系統管理', donation: '捐贈支持', - regard: '關於' + about: '關於' }, basic: { themeMode: '主題模式', @@ -108,7 +108,11 @@ export default { notImported: '尚未匯入自訂音源。', importSuccess: '成功匯入音源:{name}', importFailed: '匯入失敗:{message}', - enableHint: '請先匯入 JSON 設定檔才能啟用' + enableHint: '請先匯入 JSON 設定檔才能啟用', + status: { + imported: '已匯入自訂音源', + notImported: '未匯入' + } } }, application: { diff --git a/src/main/unblockMusic.ts b/src/main/unblockMusic.ts index 32d57cd..bbf4eaa 100644 --- a/src/main/unblockMusic.ts +++ b/src/main/unblockMusic.ts @@ -1,6 +1,6 @@ import match from '@unblockneteasemusic/server'; -type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'bilibili'; +type Platform = 'qq' | 'migu' | 'kugou' | 'kuwo' | 'pyncmd' | 'joox' | 'bilibili'; interface SongData { name: string; @@ -30,7 +30,7 @@ interface UnblockResult { } // 所有可用平台 -export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili']; +export const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili']; /** * 确保对象数据结构完整,处理null或undefined的情况 diff --git a/src/renderer/components/player/ReparsePopover.vue b/src/renderer/components/player/ReparsePopover.vue index d65b0bc..a70ad1f 100644 --- a/src/renderer/components/player/ReparsePopover.vue +++ b/src/renderer/components/player/ReparsePopover.vue @@ -76,7 +76,7 @@ + + diff --git a/src/renderer/hooks/usePlayerHooks.ts b/src/renderer/hooks/usePlayerHooks.ts index 5da59e4..41268c1 100644 --- a/src/renderer/hooks/usePlayerHooks.ts +++ b/src/renderer/hooks/usePlayerHooks.ts @@ -1,10 +1,10 @@ import { cloneDeep } from 'lodash'; import { createDiscreteApi } from 'naive-ui'; -import { ref } from 'vue'; import i18n from '@/../i18n/renderer'; import { getBilibiliAudioUrl } from '@/api/bilibili'; import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music'; +import { playbackRequestManager } from '@/services/playbackRequestManager'; import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music'; import { getImgUrl } from '@/utils'; import { getImageLinearBackground } from '@/utils/linearColor'; @@ -12,16 +12,14 @@ import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser'; const { message } = createDiscreteApi(['message']); -// 预加载的音频实例 -export const preloadingSounds = ref([]); - /** * 获取歌曲播放URL(独立函数) */ export const getSongUrl = async ( id: string | number, songData: SongResult, - isDownloaded: boolean = false + isDownloaded: boolean = false, + requestId?: string ) => { const numericId = typeof id === 'string' ? parseInt(id, 10) : id; @@ -30,6 +28,12 @@ export const getSongUrl = async ( const settingsStore = useSettingsStore(); try { + // 在开始处理前验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[getSongUrl] 请求已失效: ${requestId}`); + throw new Error('Request cancelled'); + } + if (songData.playMusicUrl) { return songData.playMusicUrl; } @@ -42,6 +46,11 @@ export const getSongUrl = async ( songData.bilibiliData.bvid, songData.bilibiliData.cid ); + // 验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[getSongUrl] 获取B站URL后请求已失效: ${requestId}`); + throw new Error('Request cancelled'); + } return songData.playMusicUrl; } catch (error) { console.error('重启后获取B站音频URL失败:', error); @@ -78,6 +87,12 @@ export const getSongUrl = async ( settingsStore.setData.musicQuality || 'higher' ); + // 验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[getSongUrl] 自定义API解析后请求已失效: ${requestId}`); + throw new Error('Request cancelled'); + } + if ( customResult && customResult.data && @@ -93,6 +108,9 @@ export const getSongUrl = async ( } } catch (error) { console.error('调用自定义API时发生错误:', error); + if ((error as Error).message === 'Request cancelled') { + throw error; + } message.error(i18n.global.t('player.reparse.customApiError')); } } @@ -103,18 +121,35 @@ export const getSongUrl = async ( console.log(`使用自定义音源解析歌曲 ID: ${songId}`); const res = await getParsingMusicUrl(numericId, cloneDeep(songData)); console.log('res', res); + + // 验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[getSongUrl] 自定义音源解析后请求已失效: ${requestId}`); + throw new Error('Request cancelled'); + } + if (res && res.data && res.data.data && res.data.data.url) { return res.data.data.url; } console.warn('自定义音源解析失败,使用默认音源'); } catch (error) { console.error('error', error); + if ((error as Error).message === 'Request cancelled') { + throw error; + } console.error('自定义音源解析出错:', error); } } // 正常获取URL流程 const { data } = await getMusicUrl(numericId, isDownloaded); + + // 验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[getSongUrl] 获取官方URL后请求已失效: ${requestId}`); + throw new Error('Request cancelled'); + } + if (data && data.data && data.data[0]) { const songDetail = data.data[0]; const hasNoUrl = !songDetail.url; @@ -123,6 +158,11 @@ export const getSongUrl = async ( if (hasNoUrl || isTrial) { console.log(`官方URL无效 (无URL: ${hasNoUrl}, 试听: ${isTrial}),进入内置备用解析...`); const res = await getParsingMusicUrl(numericId, cloneDeep(songData)); + // 验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[getSongUrl] 备用解析后请求已失效: ${requestId}`); + throw new Error('Request cancelled'); + } if (isDownloaded) return res?.data?.data as any; return res?.data?.data?.url || null; } @@ -134,9 +174,17 @@ export const getSongUrl = async ( console.log('官方API返回数据结构异常,进入内置备用解析...'); const res = await getParsingMusicUrl(numericId, cloneDeep(songData)); + // 验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[getSongUrl] 备用解析后请求已失效: ${requestId}`); + throw new Error('Request cancelled'); + } if (isDownloaded) return res?.data?.data as any; return res?.data?.data?.url || null; } catch (error) { + if ((error as Error).message === 'Request cancelled') { + throw error; + } console.error('官方API请求失败,进入内置备用解析流程:', error); const res = await getParsingMusicUrl(numericId, cloneDeep(songData)); if (isDownloaded) return res?.data?.data as any; @@ -299,7 +347,13 @@ export const useLyrics = () => { export const useSongDetail = () => { const { getSongUrl } = useSongUrl(); - const getSongDetail = async (playMusic: SongResult) => { + const getSongDetail = async (playMusic: SongResult, requestId?: string) => { + // 验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[getSongDetail] 请求已失效: ${requestId}`); + throw new Error('Request cancelled'); + } + if (playMusic.source === 'bilibili') { try { if (!playMusic.playMusicUrl && playMusic.bilibiliData) { @@ -309,6 +363,12 @@ export const useSongDetail = () => { ); } + // 验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[getSongDetail] B站URL获取后请求已失效: ${requestId}`); + throw new Error('Request cancelled'); + } + playMusic.playLoading = false; return { ...playMusic } as SongResult; } catch (error) { @@ -324,7 +384,15 @@ export const useSongDetail = () => { } try { - const playMusicUrl = playMusic.playMusicUrl || (await getSongUrl(playMusic.id, playMusic)); + const playMusicUrl = + playMusic.playMusicUrl || (await getSongUrl(playMusic.id, playMusic, false, requestId)); + + // 验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[getSongDetail] URL获取后请求已失效: ${requestId}`); + throw new Error('Request cancelled'); + } + playMusic.createdAt = Date.now(); // 半小时后过期 playMusic.expiredAt = playMusic.createdAt + 1800000; @@ -333,9 +401,18 @@ export const useSongDetail = () => { ? playMusic : await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30')); + // 验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[getSongDetail] 背景色获取后请求已失效: ${requestId}`); + throw new Error('Request cancelled'); + } + playMusic.playLoading = false; return { ...playMusic, playMusicUrl, backgroundColor, primaryColor } as SongResult; } catch (error) { + if ((error as Error).message === 'Request cancelled') { + throw error; + } console.error('获取音频URL失败:', error); playMusic.playLoading = false; throw error; @@ -344,60 +421,3 @@ export const useSongDetail = () => { return { getSongDetail }; }; - -/** - * 预加载下一首歌曲音频 - */ -export const preloadNextSong = (nextSongUrl: string): Howl | null => { - try { - // 清理多余的预加载实例,确保最多只有2个预加载音频 - while (preloadingSounds.value.length >= 2) { - const oldestSound = preloadingSounds.value.shift(); - if (oldestSound) { - try { - oldestSound.stop(); - oldestSound.unload(); - } catch (e) { - console.error('清理预加载音频实例失败:', e); - } - } - } - - // 检查这个URL是否已经在预加载列表中 - const existingPreload = preloadingSounds.value.find( - (sound) => (sound as any)._src === nextSongUrl - ); - if (existingPreload) { - console.log('该音频已在预加载列表中,跳过:', nextSongUrl); - return existingPreload; - } - - const sound = new Howl({ - src: [nextSongUrl], - html5: true, - preload: true, - autoplay: false - }); - - preloadingSounds.value.push(sound); - - sound.on('loaderror', () => { - console.error('预加载音频失败:', nextSongUrl); - const index = preloadingSounds.value.indexOf(sound); - if (index > -1) { - preloadingSounds.value.splice(index, 1); - } - try { - sound.stop(); - sound.unload(); - } catch (e) { - console.error('卸载预加载音频失败:', e); - } - }); - - return sound; - } catch (error) { - console.error('预加载音频出错:', error); - return null; - } -}; diff --git a/src/renderer/services/audioService.ts b/src/renderer/services/audioService.ts index 1c2d874..4981eef 100644 --- a/src/renderer/services/audioService.ts +++ b/src/renderer/services/audioService.ts @@ -5,6 +5,7 @@ import { isElectron } from '@/utils'; // 导入isElectron常量 class AudioService { private currentSound: Howl | null = null; + private pendingSound: Howl | null = null; private currentTrack: SongResult | null = null; @@ -470,11 +471,12 @@ class AudioService { } // 播放控制相关 - play( - url?: string, - track?: SongResult, + public play( + url: string, + track: SongResult, isPlay: boolean = true, - seekTime: number = 0 + seekTime: number = 0, + existingSound?: Howl ): Promise { // 每次调用play方法时,尝试强制重置锁(注意:仅在页面刷新后的第一次播放时应用) if (!this.currentSound) { @@ -482,6 +484,17 @@ class AudioService { 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(); @@ -531,10 +544,25 @@ class AudioService { return Promise.reject(new Error('缺少必要参数: url和track')); } + // 检查是否是同一首歌曲的无缝切换(Hot-Swap) + const isHotSwap = + this.currentTrack && track && this.currentTrack.id === track.id && this.currentSound; + + if (isHotSwap) { + console.log('audioService: 检测到同一首歌曲的源切换,启用无缝切换模式'); + } + return new Promise((resolve, reject) => { let retryCount = 0; const maxRetries = 1; + // 如果有正在加载的 pendingSound,先清理掉 + if (this.pendingSound) { + console.log('audioService: 清理正在加载的 pendingSound'); + this.pendingSound.unload(); + this.pendingSound = null; + } + const tryPlay = async () => { try { console.log('audioService: 开始创建音频对象'); @@ -560,8 +588,8 @@ class AudioService { await Howler.ctx.resume(); } - // 先停止并清理现有的音频实例 - if (this.currentSound) { + // 非热切换模式下,先停止并清理现有的音频实例 + if (!isHotSwap && this.currentSound) { console.log('audioService: 停止并清理现有的音频实例'); // 确保任何进行中的seek操作被取消 if (this.seekLock && this.seekDebounceTimer) { @@ -573,49 +601,122 @@ class AudioService { this.currentSound = null; } - // 清理 EQ 但保持上下文 - console.log('audioService: 清理 EQ'); - await this.disposeEQ(true); + // 清理 EQ 但保持上下文 (热切换时暂时不清理,等切换完成后再处理) + if (!isHotSwap) { + console.log('audioService: 清理 EQ'); + await this.disposeEQ(true); + } - this.currentTrack = track; - console.log('audioService: 创建新的 Howl 对象'); - this.currentSound = new Howl({ - src: [url], - html5: true, - autoplay: false, - volume: 1, // 禁用 Howler.js 音量控制 - rate: this.playbackRate, - format: ['mp3', 'aac'], - onloaderror: (_, error) => { + // 如果不是热切换,立即更新 currentTrack + if (!isHotSwap) { + this.currentTrack = track; + } + + // 如果不是热切换,立即更新 currentTrack + if (!isHotSwap) { + this.currentTrack = track; + } + + let newSound: Howl; + + if (existingSound) { + console.log('audioService: 使用预加载的 Howl 对象'); + newSound = existingSound; + // 确保 volume 和 rate 正确 + newSound.volume(1); // 内部 volume 设为 1,由 Howler.masterGain 控制实际音量 + newSound.rate(this.playbackRate); + + // 重新绑定事件监听器,因为 PreloadService 可能没有绑定这些 + // 注意:Howler 允许重复绑定,但最好先清理(如果无法清理,就直接绑定,Howler 是 EventEmitter) + // 这里我们假设 existingSound 是干净的或者我们只绑定我们需要关心的 + } else { + console.log('audioService: 创建新的 Howl 对象'); + newSound = new Howl({ + src: [url], + html5: true, + autoplay: false, + volume: 1, // 禁用 Howler.js 音量控制 + rate: this.playbackRate, + format: ['mp3', 'aac'] + }); + } + + // 统一设置事件处理 + const setupEvents = () => { + newSound.off('loaderror'); + newSound.off('playerror'); + newSound.off('load'); + + newSound.on('loaderror', (_, error) => { console.error('Audio load error:', error); - if (retryCount < maxRetries) { + if (retryCount < maxRetries && !existingSound) { + // 预加载的音频通常已经 loaded,不应重试 retryCount++; console.log(`Retrying playback (${retryCount}/${maxRetries})...`); setTimeout(tryPlay, 1000 * retryCount); } else { - // 发送URL过期事件,通知外部需要重新获取URL - this.emit('url_expired', this.currentTrack); + this.emit('url_expired', track); this.releaseOperationLock(); + if (isHotSwap) this.pendingSound = null; reject(new Error('音频加载失败,请尝试切换其他歌曲')); } - }, - onplayerror: (_, error) => { + }); + + newSound.on('playerror', (_, error) => { console.error('Audio play error:', error); if (retryCount < maxRetries) { retryCount++; console.log(`Retrying playback (${retryCount}/${maxRetries})...`); setTimeout(tryPlay, 1000 * retryCount); } else { - // 发送URL过期事件,通知外部需要重新获取URL - this.emit('url_expired', this.currentTrack); + this.emit('url_expired', track); this.releaseOperationLock(); + if (isHotSwap) this.pendingSound = null; reject(new Error('音频播放失败,请尝试切换其他歌曲')); } - }, - onload: async () => { + }); + + const onLoaded = async () => { try { - // 初始化音频管道 - await this.setupEQ(this.currentSound!); + // 如果是热切换,现在执行切换逻辑 + if (isHotSwap) { + console.log('audioService: 执行无缝切换'); + + // 1. 获取当前播放进度 + let currentPos = 0; + if (this.currentSound) { + currentPos = this.currentSound.seek() as number; + } + + // 2. 同步新音频进度 + newSound.seek(currentPos); + + // 3. 初始化新音频的 EQ + await this.disposeEQ(true); + await this.setupEQ(newSound); + + // 4. 播放新音频 + if (isPlay) { + newSound.play(); + } + + // 5. 停止旧音频 + if (this.currentSound) { + this.currentSound.stop(); + this.currentSound.unload(); + } + + // 6. 更新引用 + this.currentSound = newSound; + this.currentTrack = track; + this.pendingSound = null; + + console.log(`audioService: 无缝切换完成,进度同步至 ${currentPos}s`); + } else { + // 普通加载逻辑 + await this.setupEQ(newSound); + this.currentSound = newSound; + } // 重新应用已保存的音量 const savedVolume = localStorage.getItem('volume'); @@ -623,22 +724,23 @@ class AudioService { this.applyVolume(parseFloat(savedVolume)); } - // 音频加载成功后设置 EQ 和更新媒体会话 if (this.currentSound) { try { - if (seekTime > 0) { + if (!isHotSwap && seekTime > 0) { this.currentSound.seek(seekTime); } + console.log('audioService: 音频加载成功,设置 EQ'); this.updateMediaSessionMetadata(track); this.updateMediaSessionPositionState(); this.emit('load'); - // 此时音频已完全初始化,根据 isPlay 参数决定是否播放 - console.log('audioService: 音频完全初始化,isPlay =', isPlay); - if (isPlay) { - console.log('audioService: 开始播放'); - this.currentSound.play(); + if (!isHotSwap) { + console.log('audioService: 音频完全初始化,isPlay =', isPlay); + if (isPlay) { + console.log('audioService: 开始播放'); + this.currentSound.play(); + } } resolve(this.currentSound); @@ -651,28 +753,58 @@ class AudioService { console.error('Audio initialization failed:', error); reject(error); } + }; + + if (newSound.state() === 'loaded') { + onLoaded(); + } else { + newSound.once('load', onLoaded); } - }); + }; - // 设置音频事件监听 - if (this.currentSound) { - this.currentSound.on('play', () => { - this.updateMediaSessionState(true); - this.emit('play'); + setupEvents(); + + if (isHotSwap) { + this.pendingSound = newSound; + } else { + this.currentSound = newSound; + } + + // 设置音频事件监听 (play, pause, end, seek) + // ... (保持原有的事件监听逻辑不变,但需要确保绑定到 newSound) + const soundInstance = newSound; + if (soundInstance) { + // 清除旧的监听器以防重复 + soundInstance.off('play'); + soundInstance.off('pause'); + soundInstance.off('end'); + soundInstance.off('seek'); + + soundInstance.on('play', () => { + if (this.currentSound === soundInstance) { + this.updateMediaSessionState(true); + this.emit('play'); + } }); - this.currentSound.on('pause', () => { - this.updateMediaSessionState(false); - this.emit('pause'); + soundInstance.on('pause', () => { + if (this.currentSound === soundInstance) { + this.updateMediaSessionState(false); + this.emit('pause'); + } }); - this.currentSound.on('end', () => { - this.emit('end'); + soundInstance.on('end', () => { + if (this.currentSound === soundInstance) { + this.emit('end'); + } }); - this.currentSound.on('seek', () => { - this.updateMediaSessionPositionState(); - this.emit('seek'); + soundInstance.on('seek', () => { + if (this.currentSound === soundInstance) { + this.updateMediaSessionPositionState(); + this.emit('seek'); + } }); } } catch (error) { diff --git a/src/renderer/services/playbackRequestManager.ts b/src/renderer/services/playbackRequestManager.ts new file mode 100644 index 0000000..98c9e58 --- /dev/null +++ b/src/renderer/services/playbackRequestManager.ts @@ -0,0 +1,294 @@ +/** + * 播放请求管理器 + * 负责管理播放请求的队列、取消、状态跟踪,防止竞态条件 + */ + +import type { SongResult } from '@/types/music'; + +/** + * 请求状态枚举 + */ +export enum RequestStatus { + PENDING = 'pending', + ACTIVE = 'active', + COMPLETED = 'completed', + CANCELLED = 'cancelled', + FAILED = 'failed' +} + +/** + * 播放请求接口 + */ +export interface PlaybackRequest { + id: string; + song: SongResult; + status: RequestStatus; + timestamp: number; + abortController?: AbortController; +} + +/** + * 播放请求管理器类 + */ +class PlaybackRequestManager { + private currentRequestId: string | null = null; + private requestMap: Map = new Map(); + private requestCounter = 0; + + /** + * 生成唯一的请求ID + */ + private generateRequestId(): string { + return `playback_${Date.now()}_${++this.requestCounter}`; + } + + /** + * 创建新的播放请求 + * @param song 要播放的歌曲 + * @returns 新请求的ID + */ + createRequest(song: SongResult): string { + // 取消所有之前的请求 + this.cancelAllRequests(); + + const requestId = this.generateRequestId(); + const abortController = new AbortController(); + + const request: PlaybackRequest = { + id: requestId, + song, + status: RequestStatus.PENDING, + timestamp: Date.now(), + abortController + }; + + this.requestMap.set(requestId, request); + this.currentRequestId = requestId; + + console.log(`[PlaybackRequestManager] 创建新请求: ${requestId}, 歌曲: ${song.name}`); + + return requestId; + } + + /** + * 激活请求(标记为正在处理) + * @param requestId 请求ID + */ + activateRequest(requestId: string): boolean { + const request = this.requestMap.get(requestId); + if (!request) { + console.warn(`[PlaybackRequestManager] 请求不存在: ${requestId}`); + return false; + } + + if (request.status === RequestStatus.CANCELLED) { + console.warn(`[PlaybackRequestManager] 请求已被取消: ${requestId}`); + return false; + } + + request.status = RequestStatus.ACTIVE; + console.log(`[PlaybackRequestManager] 激活请求: ${requestId}`); + return true; + } + + /** + * 完成请求 + * @param requestId 请求ID + */ + completeRequest(requestId: string): void { + const request = this.requestMap.get(requestId); + if (!request) { + return; + } + + request.status = RequestStatus.COMPLETED; + console.log(`[PlaybackRequestManager] 完成请求: ${requestId}`); + + // 清理旧请求(保留最近3个) + this.cleanupOldRequests(); + } + + /** + * 标记请求失败 + * @param requestId 请求ID + */ + failRequest(requestId: string): void { + const request = this.requestMap.get(requestId); + if (!request) { + return; + } + + request.status = RequestStatus.FAILED; + console.log(`[PlaybackRequestManager] 请求失败: ${requestId}`); + } + + /** + * 取消指定请求 + * @param requestId 请求ID + */ + cancelRequest(requestId: string): void { + const request = this.requestMap.get(requestId); + if (!request) { + return; + } + + if (request.status === RequestStatus.CANCELLED) { + return; + } + + // 取消AbortController + if (request.abortController && !request.abortController.signal.aborted) { + request.abortController.abort(); + } + + request.status = RequestStatus.CANCELLED; + console.log(`[PlaybackRequestManager] 取消请求: ${requestId}, 歌曲: ${request.song.name}`); + + // 如果是当前请求,清除当前请求ID + if (this.currentRequestId === requestId) { + this.currentRequestId = null; + } + } + + /** + * 取消所有请求 + */ + cancelAllRequests(): void { + console.log(`[PlaybackRequestManager] 取消所有请求,当前请求数: ${this.requestMap.size}`); + + this.requestMap.forEach((request) => { + if ( + request.status !== RequestStatus.COMPLETED && + request.status !== RequestStatus.CANCELLED + ) { + this.cancelRequest(request.id); + } + }); + } + + /** + * 检查请求是否仍然有效(是当前活动请求) + * @param requestId 请求ID + * @returns 是否有效 + */ + isRequestValid(requestId: string): boolean { + // 检查是否是当前请求 + if (this.currentRequestId !== requestId) { + console.warn( + `[PlaybackRequestManager] 请求已过期: ${requestId}, 当前请求: ${this.currentRequestId}` + ); + return false; + } + + const request = this.requestMap.get(requestId); + if (!request) { + console.warn(`[PlaybackRequestManager] 请求不存在: ${requestId}`); + return false; + } + + // 检查请求状态 + if (request.status === RequestStatus.CANCELLED) { + console.warn(`[PlaybackRequestManager] 请求已被取消: ${requestId}`); + return false; + } + + return true; + } + + /** + * 检查请求是否应该中止(用于 AbortController) + * @param requestId 请求ID + * @returns AbortSignal 或 undefined + */ + getAbortSignal(requestId: string): AbortSignal | undefined { + const request = this.requestMap.get(requestId); + return request?.abortController?.signal; + } + + /** + * 获取当前请求ID + */ + getCurrentRequestId(): string | null { + return this.currentRequestId; + } + + /** + * 获取请求信息 + * @param requestId 请求ID + */ + getRequest(requestId: string): PlaybackRequest | undefined { + return this.requestMap.get(requestId); + } + + /** + * 清理旧请求(保留最近3个) + */ + private cleanupOldRequests(): void { + if (this.requestMap.size <= 3) { + return; + } + + // 按时间戳排序,保留最新的3个 + const sortedRequests = Array.from(this.requestMap.values()).sort( + (a, b) => b.timestamp - a.timestamp + ); + + const toKeep = new Set(sortedRequests.slice(0, 3).map((r) => r.id)); + const toDelete: string[] = []; + + this.requestMap.forEach((_, id) => { + if (!toKeep.has(id)) { + toDelete.push(id); + } + }); + + toDelete.forEach((id) => { + this.requestMap.delete(id); + }); + + if (toDelete.length > 0) { + console.log(`[PlaybackRequestManager] 清理了 ${toDelete.length} 个旧请求`); + } + } + + /** + * 重置管理器(用于调试或特殊情况) + */ + reset(): void { + console.log('[PlaybackRequestManager] 重置管理器'); + this.cancelAllRequests(); + this.requestMap.clear(); + this.currentRequestId = null; + this.requestCounter = 0; + } + + /** + * 获取调试信息 + */ + getDebugInfo(): { + currentRequestId: string | null; + totalRequests: number; + requestsByStatus: Record; + } { + const requestsByStatus: Record = { + [RequestStatus.PENDING]: 0, + [RequestStatus.ACTIVE]: 0, + [RequestStatus.COMPLETED]: 0, + [RequestStatus.CANCELLED]: 0, + [RequestStatus.FAILED]: 0 + }; + + this.requestMap.forEach((request) => { + requestsByStatus[request.status]++; + }); + + return { + currentRequestId: this.currentRequestId, + totalRequests: this.requestMap.size, + requestsByStatus + }; + } +} + +// 导出单例实例 +export const playbackRequestManager = new PlaybackRequestManager(); diff --git a/src/renderer/services/preloadService.ts b/src/renderer/services/preloadService.ts new file mode 100644 index 0000000..b477408 --- /dev/null +++ b/src/renderer/services/preloadService.ts @@ -0,0 +1,273 @@ +import { Howl } from 'howler'; +import { cloneDeep } from 'lodash'; + +import { getParsingMusicUrl } from '@/api/music'; +import type { SongResult } from '@/types/music'; + +class PreloadService { + private loadingPromises: Map> = new Map(); + private preloadedSounds: Map = new Map(); + + /** + * 加载并验证音频 + * 如果已经在加载中,返回现有的 Promise + * 如果已经加载完成,返回缓存的 Howl 实例 + */ + public async load(song: SongResult): Promise { + if (!song || !song.id) { + throw new Error('无效的歌曲对象'); + } + + // 1. 检查是否有正在进行的加载 + if (this.loadingPromises.has(song.id)) { + console.log(`[PreloadService] 歌曲 ${song.name} 正在加载中,复用现有请求`); + return this.loadingPromises.get(song.id)!; + } + + // 2. 检查是否有已完成的缓存 + if (this.preloadedSounds.has(song.id)) { + const sound = this.preloadedSounds.get(song.id)!; + if (sound.state() === 'loaded') { + console.log(`[PreloadService] 歌曲 ${song.name} 已预加载完成,直接使用`); + return sound; + } else { + // 如果缓存的音频状态不正常,清理并重新加载 + this.preloadedSounds.delete(song.id); + } + } + + // 3. 开始新的加载过程 + const loadPromise = this._performLoad(song); + this.loadingPromises.set(song.id, loadPromise); + + try { + const sound = await loadPromise; + this.preloadedSounds.set(song.id, sound); + return sound; + } finally { + this.loadingPromises.delete(song.id); + } + } + + /** + * 执行实际的加载和验证逻辑 + */ + private async _performLoad(song: SongResult): Promise { + console.log(`[PreloadService] 开始加载歌曲: ${song.name}`); + + if (!song.playMusicUrl) { + throw new Error('歌曲没有 URL'); + } + + // 创建初始音频实例 + let sound = await this._createSound(song.playMusicUrl); + + // 检查时长 + const duration = sound.duration(); + const expectedDuration = (song.dt || 0) / 1000; + + // 如果时长差异超过5秒,且不是B站视频,且预期时长大于0 + if ( + expectedDuration > 0 && + Math.abs(duration - expectedDuration) > 5 && + song.source !== 'bilibili' + ) { + const songId = String(song.id); + const sourceType = localStorage.getItem(`song_source_type_${songId}`); + + // 如果不是用户手动锁定的音源,尝试自动重新解析 + if (sourceType !== 'manual') { + console.warn( + `[PreloadService] 时长不匹配 (实际: ${duration}s, 预期: ${expectedDuration}s),尝试智能解析` + ); + + // 动态导入 store + const { useSettingsStore } = await import('@/store/modules/settings'); + const { usePlaylistStore } = await import('@/store/modules/playlist'); + const settingsStore = useSettingsStore(); + const playlistStore = usePlaylistStore(); + + const enabledSources = settingsStore.setData.enabledMusicSources || [ + 'migu', + 'kugou', + 'pyncmd', + 'gdmusic' + ]; + const availableSources = enabledSources.filter((s: string) => s !== 'bilibili'); + + const triedSources = new Set(); + const triedSourceDiffs = new Map(); + + // 记录当前音源 + let currentSource = 'unknown'; + const currentSavedSource = localStorage.getItem(`song_source_${songId}`); + if (currentSavedSource) { + try { + const sources = JSON.parse(currentSavedSource); + if (Array.isArray(sources) && sources.length > 0) { + currentSource = sources[0]; + } + } catch (e) { + console.log( + `[PreloadService] 时长不匹配 (实际: ${duration}s, 预期: ${expectedDuration}s),尝试智能解析`, + e + ); + } + } + + triedSources.add(currentSource); + triedSourceDiffs.set(currentSource, Math.abs(duration - expectedDuration)); + + // 卸载当前不匹配的音频 + sound.unload(); + + // 尝试其他音源 + for (const source of availableSources) { + if (triedSources.has(source)) continue; + + console.log(`[PreloadService] 尝试音源: ${source}`); + triedSources.add(source); + + try { + const songData = cloneDeep(song); + // 临时保存设置以便 getParsingMusicUrl 使用 + localStorage.setItem(`song_source_${songId}`, JSON.stringify([source])); + + const res = await getParsingMusicUrl( + typeof song.id === 'string' ? parseInt(song.id) : song.id, + songData + ); + + if (res && res.data && res.data.data && res.data.data.url) { + const newUrl = res.data.data.url; + const tempSound = await this._createSound(newUrl); + const newDuration = tempSound.duration(); + const diff = Math.abs(newDuration - expectedDuration); + + triedSourceDiffs.set(source, diff); + + if (diff <= 5) { + console.log(`[PreloadService] 找到匹配音源: ${source}, 更新歌曲信息`); + + // 更新歌曲信息 + const updatedSong = { + ...song, + playMusicUrl: newUrl, + expiredAt: Date.now() + 1800000 + }; + + // 更新 store + playlistStore.updateSong(updatedSong); + + // 记录新的音源设置 + localStorage.setItem(`song_source_${songId}`, JSON.stringify([source])); + localStorage.setItem(`song_source_type_${songId}`, 'auto'); + + return tempSound; + } else { + tempSound.unload(); + } + } + } catch (e) { + console.error(`[PreloadService] 尝试音源 ${source} 失败:`, e); + } + } + + // 如果没有找到完美匹配,使用最佳匹配 + console.warn('[PreloadService] 未找到完美匹配,寻找最佳匹配'); + let bestSource = ''; + let minDiff = Infinity; + + for (const [source, diff] of triedSourceDiffs.entries()) { + if (diff < minDiff) { + minDiff = diff; + bestSource = source; + } + } + + if (bestSource && bestSource !== currentSource) { + console.log(`[PreloadService] 使用最佳匹配音源: ${bestSource} (差异: ${minDiff}s)`); + try { + const songData = cloneDeep(song); + localStorage.setItem(`song_source_${songId}`, JSON.stringify([bestSource])); + + const res = await getParsingMusicUrl( + typeof song.id === 'string' ? parseInt(song.id) : song.id, + songData + ); + + if (res && res.data && res.data.data && res.data.data.url) { + const newUrl = res.data.data.url; + const bestSound = await this._createSound(newUrl); + + const updatedSong = { + ...song, + playMusicUrl: newUrl, + expiredAt: Date.now() + 1800000 + }; + + playlistStore.updateSong(updatedSong); + localStorage.setItem(`song_source_type_${songId}`, 'auto'); + + return bestSound; + } + } catch (e) { + console.error(`[PreloadService] 获取最佳匹配音源失败:`, e); + } + } + } + } + + // 如果不需要修复或修复失败,重新加载原始音频(因为上面可能unload了) + if (sound.state() === 'unloaded') { + sound = await this._createSound(song.playMusicUrl); + } + + return sound; + } + + private _createSound(url: string): Promise { + return new Promise((resolve, reject) => { + const sound = new Howl({ + src: [url], + html5: true, + preload: true, + autoplay: false, + onload: () => resolve(sound), + onloaderror: (_, err) => reject(err) + }); + }); + } + + /** + * 取消特定歌曲的预加载(如果可能) + * 注意:Promise 无法真正取消,但我们可以清理结果 + */ + public cancel(songId: string | number) { + if (this.preloadedSounds.has(songId)) { + const sound = this.preloadedSounds.get(songId)!; + sound.unload(); + this.preloadedSounds.delete(songId); + } + // loadingPromises 中的任务会继续执行,但因为 preloadedSounds 中没有记录, + // 下次请求时会重新加载(或者我们可以让 _performLoad 检查一个取消标记,但这增加了复杂性) + } + + /** + * 获取已预加载的音频实例(如果存在) + */ + public getPreloadedSound(songId: string | number): Howl | undefined { + return this.preloadedSounds.get(songId); + } + + /** + * 清理所有预加载资源 + */ + public clearAll() { + this.preloadedSounds.forEach((sound) => sound.unload()); + this.preloadedSounds.clear(); + this.loadingPromises.clear(); + } +} + +export const preloadService = new PreloadService(); diff --git a/src/renderer/store/modules/playerCore.ts b/src/renderer/store/modules/playerCore.ts index 2cbeedc..75e6f1a 100644 --- a/src/renderer/store/modules/playerCore.ts +++ b/src/renderer/store/modules/playerCore.ts @@ -9,6 +9,8 @@ import { getParsingMusicUrl } from '@/api/music'; import { useMusicHistory } from '@/hooks/MusicHistoryHook'; import { useLyrics, useSongDetail } from '@/hooks/usePlayerHooks'; import { audioService } from '@/services/audioService'; +import { playbackRequestManager } from '@/services/playbackRequestManager'; +import { preloadService } from '@/services/preloadService'; import type { Platform, SongResult } from '@/types/music'; import { getImgUrl } from '@/utils'; import { getImageLinearBackground } from '@/utils/linearColor'; @@ -28,10 +30,12 @@ export const usePlayerCoreStore = defineStore( const isPlay = ref(false); const playMusic = ref({} as SongResult); const playMusicUrl = ref(''); + const triedSources = ref>(new Set()); + const triedSourceDiffs = ref>(new Map()); const musicFull = ref(false); const playbackRate = ref(1.0); const volume = ref(1); - const userPlayIntent = ref(true); + const userPlayIntent = ref(false); // 用户是否想要播放 let checkPlayTime: NodeJS.Timeout | null = null; @@ -100,7 +104,7 @@ export const usePlayerCoreStore = defineStore( /** * 播放状态检测 */ - const checkPlaybackState = (song: SongResult, timeout: number = 4000) => { + const checkPlaybackState = (song: SongResult, requestId: string, timeout: number = 4000) => { if (checkPlayTime) { clearTimeout(checkPlayTime); } @@ -114,10 +118,16 @@ export const usePlayerCoreStore = defineStore( }; const onPlayErrorHandler = async () => { - console.log('播放错误事件触发,尝试重新获取URL'); + console.log('播放错误事件触发,检查是否需要重新获取URL'); audioService.off('play', onPlayHandler); audioService.off('playerror', onPlayErrorHandler); + // 验证请求是否仍然有效 + if (!playbackRequestManager.isRequestValid(requestId)) { + console.log('请求已过期,跳过重试'); + return; + } + if (userPlayIntent.value && play.value) { playMusic.value.playMusicUrl = undefined; const refreshedSong = { ...song, isFirstPlay: true }; @@ -129,6 +139,14 @@ export const usePlayerCoreStore = defineStore( audioService.on('playerror', onPlayErrorHandler); checkPlayTime = setTimeout(() => { + // 验证请求是否仍然有效 + if (!playbackRequestManager.isRequestValid(requestId)) { + 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); @@ -147,6 +165,16 @@ export const usePlayerCoreStore = defineStore( * 核心播放处理函数 */ const handlePlayMusic = async (music: SongResult, isPlay: boolean = true) => { + // 如果是新歌曲,重置已尝试的音源 + if (music.id !== playMusic.value.id) { + triedSources.value.clear(); + triedSourceDiffs.value.clear(); + } + + // 创建新的播放请求并取消之前的所有请求 + const requestId = playbackRequestManager.createRequest(music); + console.log(`[handlePlayMusic] 开始处理歌曲: ${music.name}, 请求ID: ${requestId}`); + const currentSound = audioService.getCurrentSound(); if (currentSound) { console.log('主动停止并卸载当前音频实例'); @@ -154,6 +182,18 @@ export const usePlayerCoreStore = defineStore( currentSound.unload(); } + // 验证请求是否仍然有效 + if (!playbackRequestManager.isRequestValid(requestId)) { + console.log(`[handlePlayMusic] 请求已失效: ${requestId}`); + return false; + } + + // 激活请求 + if (!playbackRequestManager.activateRequest(requestId)) { + console.log(`[handlePlayMusic] 无法激活请求: ${requestId}`); + return false; + } + const originalMusic = { ...music }; const { loadLrc } = useLyrics(); const { getSongDetail } = useSongDetail(); @@ -174,6 +214,12 @@ export const usePlayerCoreStore = defineStore( })() ]); + // 在更新状态前再次验证请求 + if (!playbackRequestManager.isRequestValid(requestId)) { + console.log(`[handlePlayMusic] 加载歌词/背景色后请求已失效: ${requestId}`); + return false; + } + // 设置歌词和背景色 music.lyric = lyrics; music.backgroundColor = backgroundColor; @@ -201,7 +247,15 @@ export const usePlayerCoreStore = defineStore( musicHistory.addMusic(music); // 获取歌曲详情 - const updatedPlayMusic = await getSongDetail(originalMusic); + const updatedPlayMusic = await getSongDetail(originalMusic, requestId); + + // 在获取详情后再次验证请求 + if (!playbackRequestManager.isRequestValid(requestId)) { + console.log(`[handlePlayMusic] 获取歌曲详情后请求已失效: ${requestId}`); + playbackRequestManager.failRequest(requestId); + return false; + } + updatedPlayMusic.lyric = lyrics; playMusic.value = updatedPlayMusic; @@ -238,12 +292,20 @@ export const usePlayerCoreStore = defineStore( } playInProgress = true; - const result = await playAudio(); + const result = await playAudio(requestId); playInProgress = false; - return !!result; + + if (result) { + playbackRequestManager.completeRequest(requestId); + return true; + } else { + playbackRequestManager.failRequest(requestId); + return false; + } } catch (error) { console.error('自动播放音频失败:', error); playInProgress = false; + playbackRequestManager.failRequest(requestId); return false; } } catch (error) { @@ -252,6 +314,22 @@ export const usePlayerCoreStore = defineStore( if (playMusic.value) { playMusic.value.playLoading = false; } + playbackRequestManager.failRequest(requestId); + + // 通知外部播放失败,需要跳到下一首 + try { + const { usePlaylistStore } = await import('./playlist'); + const playlistStore = usePlaylistStore(); + if (Array.isArray(playlistStore.playList) && playlistStore.playList.length > 1) { + message.warning('歌曲解析失败 播放下一首'); + setTimeout(() => { + playlistStore.nextPlay(); + }, 500); + } + } catch (e) { + console.warn('切换下一首时发生问题:', e); + } + return false; } }; @@ -259,9 +337,15 @@ export const usePlayerCoreStore = defineStore( /** * 播放音频 */ - const playAudio = async () => { + const playAudio = async (requestId?: string) => { if (!playMusicUrl.value || !playMusic.value) return null; + // 如果提供了 requestId,验证请求是否仍然有效 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[playAudio] 请求已失效: ${requestId}`); + return null; + } + try { const shouldPlay = play.value; console.log('播放音频,当前播放状态:', shouldPlay ? '播放' : '暂停'); @@ -287,6 +371,12 @@ export const usePlayerCoreStore = defineStore( playMusic.value.bilibiliData.cid ); + // 再次验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[playAudio] 获取B站URL后请求已失效: ${requestId}`); + return null; + } + (playMusic.value as any).playMusicUrl = proxyUrl; playMusicUrl.value = proxyUrl; } catch (error) { @@ -297,17 +387,39 @@ export const usePlayerCoreStore = defineStore( } } - // 播放新音频 + // 使用 PreloadService 加载音频 + // 这将确保如果正在进行预加载修复,我们会等待它完成 + // 同时也处理了时长检查和自动修复逻辑 + let sound: Howl; + try { + sound = await preloadService.load(playMusic.value); + } catch (error) { + console.error('PreloadService 加载失败:', error); + // 如果 PreloadService 失败,尝试直接播放作为回退 + // 但通常 PreloadService 失败意味着 URL 问题 + throw error; + } + + // 播放新音频,传入已加载的 sound 实例 const newSound = await audioService.play( playMusicUrl.value, playMusic.value, shouldPlay, - initialPosition || 0 + initialPosition || 0, + sound ); + // 播放后再次验证请求 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log(`[playAudio] 播放后请求已失效: ${requestId}`); + newSound.stop(); + newSound.unload(); + return null; + } + // 添加播放状态检测 - if (shouldPlay) { - checkPlaybackState(playMusic.value); + if (shouldPlay && requestId) { + checkPlaybackState(playMusic.value, requestId); } // 发布音频就绪事件 @@ -315,6 +427,111 @@ export const usePlayerCoreStore = defineStore( new CustomEvent('audio-ready', { detail: { sound: newSound, shouldPlay } }) ); + // 检查时长是否匹配,如果不匹配则尝试自动重新解析 + const duration = newSound.duration(); + const expectedDuration = (playMusic.value.dt || 0) / 1000; + + // 如果时长差异超过5秒,且不是B站视频,且预期时长大于0 + if ( + expectedDuration > 0 && + Math.abs(duration - expectedDuration) > 5 && + playMusic.value.source !== 'bilibili' && + playMusic.value.id + ) { + const songId = String(playMusic.value.id); + const sourceType = localStorage.getItem(`song_source_type_${songId}`); + + // 如果不是用户手动锁定的音源 + if (sourceType !== 'manual') { + console.warn( + `时长不匹配 (实际: ${duration}s, 预期: ${expectedDuration}s),尝试自动切换音源` + ); + + // 记录当前失败的音源 + // 注意:这里假设当前使用的音源是 playMusic.value.source,或者是刚刚解析出来的 + // 但实际上我们需要知道当前具体是用哪个平台解析成功的,这可能需要从 getSongUrl 的结果中获取 + // 暂时简单处理,将当前配置的来源加入已尝试列表 + + // 获取所有可用音源 + const { useSettingsStore } = await import('./settings'); + const settingsStore = useSettingsStore(); + const enabledSources = settingsStore.setData.enabledMusicSources || [ + 'migu', + 'kugou', + 'pyncmd', + 'gdmusic' + ]; + const availableSources: Platform[] = enabledSources.filter( + (s: string) => s !== 'bilibili' + ); + + // 将当前正在使用的音源加入已尝试列表 + let currentSource = 'unknown'; + const currentSavedSource = localStorage.getItem(`song_source_${songId}`); + if (currentSavedSource) { + try { + const sources = JSON.parse(currentSavedSource); + if (Array.isArray(sources) && sources.length > 0) { + currentSource = sources[0]; + triedSources.value.add(currentSource); + } + } catch { + console.error(`解析当前音源失败: ${currentSource}`); + } + } + + // 找到下一个未尝试的音源 + const nextSource = availableSources.find((s) => !triedSources.value.has(s)); + + // 记录当前音源的时间差 + if (currentSource !== 'unknown') { + triedSourceDiffs.value.set(currentSource, Math.abs(duration - expectedDuration)); + } + + if (nextSource) { + console.log(`自动切换到音源: ${nextSource}`); + newSound.stop(); + newSound.unload(); + + // 递归调用 reparseCurrentSong + // 注意:这里是异步调用,不会阻塞当前函数返回,但我们已经停止了播放 + const success = await reparseCurrentSong(nextSource, true); + if (success) { + return audioService.getCurrentSound(); + } + return null; + } else { + console.warn('所有音源都已尝试,寻找最接近时长的版本'); + + // 找出时间差最小的音源 + let bestSource = ''; + let minDiff = Infinity; + + for (const [source, diff] of triedSourceDiffs.value.entries()) { + if (diff < minDiff) { + minDiff = diff; + bestSource = source; + } + } + + // 如果找到了最佳音源,且不是当前正在播放的音源 + if (bestSource && bestSource !== currentSource) { + console.log(`切换到最佳匹配音源: ${bestSource} (差异: ${minDiff}s)`); + newSound.stop(); + newSound.unload(); + + const success = await reparseCurrentSong(bestSource as Platform, true); + if (success) { + return audioService.getCurrentSound(); + } + return null; + } + + console.log(`当前音源 ${currentSource} 已经是最佳匹配 (差异: ${minDiff}s),保留播放`); + } + } + } + return newSound; } catch (error) { console.error('播放音频失败:', error); @@ -334,21 +551,27 @@ export const usePlayerCoreStore = defineStore( } setTimeout(() => { + // 验证请求是否仍然有效再重试 + if (requestId && !playbackRequestManager.isRequestValid(requestId)) { + console.log('重试时请求已失效,跳过重试'); + return; + } if (userPlayIntent.value && play.value) { - playAudio().catch((e) => { + playAudio(requestId).catch((e) => { console.error('重试播放失败:', e); }); } }, 1000); } else { // 非操作锁错误:尝试切到下一首,避免在解析失败时卡住 + message.warning('歌曲解析失败 播放下一首'); try { const { usePlaylistStore } = await import('./playlist'); const playlistStore = usePlaylistStore(); if (Array.isArray(playlistStore.playList) && playlistStore.playList.length > 1) { setTimeout(() => { playlistStore.nextPlay(); - }, 300); + }, 500); } } catch (e) { console.warn('播放失败回退到下一首时发生问题(可能依赖未加载):', e); @@ -394,7 +617,7 @@ export const usePlayerCoreStore = defineStore( /** * 使用指定音源重新解析当前歌曲 */ - const reparseCurrentSong = async (sourcePlatform: Platform) => { + const reparseCurrentSong = async (sourcePlatform: Platform, isAuto: boolean = false) => { try { const currentSong = playMusic.value; if (!currentSong || !currentSong.id) { @@ -410,6 +633,9 @@ export const usePlayerCoreStore = defineStore( const songId = String(currentSong.id); localStorage.setItem(`song_source_${songId}`, JSON.stringify([sourcePlatform])); + // 记录音源设置类型(自动/手动) + localStorage.setItem(`song_source_type_${songId}`, isAuto ? 'auto' : 'manual'); + const currentSound = audioService.getCurrentSound(); if (currentSound) { currentSound.pause(); @@ -434,6 +660,12 @@ export const usePlayerCoreStore = defineStore( }; await handlePlayMusic(updatedMusic, true); + + // 更新播放列表中的歌曲信息 + const { usePlaylistStore } = await import('./playlist'); + const playlistStore = usePlaylistStore(); + playlistStore.updateSong(updatedMusic); + return true; } else { console.warn(`使用音源 ${sourcePlatform} 解析失败`); diff --git a/src/renderer/store/modules/playlist.ts b/src/renderer/store/modules/playlist.ts index 545e348..de9a6a5 100644 --- a/src/renderer/store/modules/playlist.ts +++ b/src/renderer/store/modules/playlist.ts @@ -4,7 +4,8 @@ import { defineStore, storeToRefs } from 'pinia'; import { computed, ref, shallowRef } from 'vue'; import i18n from '@/../i18n/renderer'; -import { preloadNextSong, useSongDetail } from '@/hooks/usePlayerHooks'; +import { useSongDetail } from '@/hooks/usePlayerHooks'; +import { preloadService } from '@/services/preloadService'; import type { SongResult } from '@/types/music'; import { getImgUrl } from '@/utils'; import { performShuffle, preloadCoverImage } from '@/utils/playerUtils'; @@ -81,7 +82,7 @@ export const usePlaylistStore = defineStore( // 预加载下一首歌曲的音频和封面 if (nextSong) { if (nextSong.playMusicUrl) { - preloadNextSong(nextSong.playMusicUrl); + preloadService.load(nextSong); } if (nextSong.picUrl) { preloadCoverImage(nextSong.picUrl, getImgUrl); @@ -343,7 +344,7 @@ export const usePlaylistStore = defineStore( /** * 下一首 */ - const _nextPlay = async () => { + const _nextPlay = async (retryCount: number = 0, maxRetries: number = 3) => { try { if (playList.value.length === 0) { return; @@ -366,17 +367,44 @@ export const usePlaylistStore = defineStore( const nowPlayListIndex = (playListIndex.value + 1) % playList.value.length; const nextSong = { ...playList.value[nowPlayListIndex] }; - playListIndex.value = nowPlayListIndex; + console.log( + `[nextPlay] 尝试播放下一首: ${nextSong.name}, 索引: ${currentIndex} -> ${nowPlayListIndex}, 重试次数: ${retryCount}/${maxRetries}` + ); + // 先尝试播放歌曲,成功后再更新索引 const success = await playerCore.handlePlayMusic(nextSong, true); if (success) { + // 播放成功,更新索引并重置重试计数 + playListIndex.value = nowPlayListIndex; + console.log(`[nextPlay] 播放成功,索引已更新为: ${nowPlayListIndex}`); sleepTimerStore.handleSongChange(); } else { - console.error('播放下一首失败'); - playListIndex.value = currentIndex; - playerCore.setIsPlay(false); - message.error(i18n.global.t('player.playFailed')); + console.error(`[nextPlay] 播放下一首失败,当前索引: ${currentIndex}`); + + // 如果还有重试次数,先更新索引再重试下一首 + if (retryCount < maxRetries && playList.value.length > 1) { + console.log( + `[nextPlay] 跳过失败的歌曲,尝试播放下下首,重试 ${retryCount + 1}/${maxRetries}` + ); + + // 更新索引到失败的歌曲位置,这样下次递归调用会继续往下 + playListIndex.value = nowPlayListIndex; + + // 延迟后递归调用,尝试播放下一首 + setTimeout(() => { + _nextPlay(retryCount + 1, maxRetries); + }, 500); + } else { + // 重试次数用尽或只有一首歌 + if (retryCount >= maxRetries) { + console.error(`[nextPlay] 连续${maxRetries}首歌曲播放失败,停止尝试`); + message.error('连续多首歌曲播放失败,请检查网络或音源设置'); + } else { + message.error(i18n.global.t('player.playFailed')); + } + playerCore.setIsPlay(false); + } } } catch (error) { console.error('切换下一首出错:', error); @@ -400,12 +428,16 @@ export const usePlaylistStore = defineStore( (playListIndex.value - 1 + playList.value.length) % playList.value.length; const prevSong = { ...playList.value[nowPlayListIndex] }; - playListIndex.value = nowPlayListIndex; + + console.log( + `[prevPlay] 尝试播放上一首: ${prevSong.name}, 索引: ${currentIndex} -> ${nowPlayListIndex}` + ); let success = false; let retryCount = 0; const maxRetries = 2; + // 先尝试播放歌曲,成功后再更新索引 while (!success && retryCount < maxRetries) { success = await playerCore.handlePlayMusic(prevSong); @@ -442,9 +474,12 @@ export const usePlaylistStore = defineStore( } } - if (!success) { - console.error('所有尝试都失败,无法播放上一首歌曲'); - playListIndex.value = currentIndex; + if (success) { + // 播放成功,更新索引 + playListIndex.value = nowPlayListIndex; + console.log(`[prevPlay] 播放成功,索引已更新为: ${nowPlayListIndex}`); + } else { + console.error(`[prevPlay] 播放上一首失败,保持当前索引: ${currentIndex}`); playerCore.setIsPlay(false); message.error(i18n.global.t('player.playFailed')); } @@ -494,7 +529,7 @@ export const usePlaylistStore = defineStore( const sound = audioService.getCurrentSound(); if (sound) { sound.play(); - playerCore.checkPlaybackState(playerCore.playMusic); + // checkPlaybackState 已在 playAudio 中自动调用,无需在这里重复调用 } } return; @@ -579,7 +614,17 @@ export const usePlaylistStore = defineStore( setPlayListDrawerVisible, setPlay, initializePlaylist, - fetchSongs + fetchSongs, + updateSong: (song: SongResult) => { + const index = playList.value.findIndex( + (item) => item.id === song.id && item.source === song.source + ); + if (index !== -1) { + playList.value[index] = song; + // 触发响应式更新 + playList.value = [...playList.value]; + } + } }; }, { diff --git a/src/renderer/types/music.ts b/src/renderer/types/music.ts index 5fe8e44..f38bd4f 100644 --- a/src/renderer/types/music.ts +++ b/src/renderer/types/music.ts @@ -1,8 +1,16 @@ // 音乐平台类型 -export type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'bilibili' | 'gdmusic'; +export type Platform = + | 'qq' + | 'migu' + | 'kugou' + | 'kuwo' + | 'pyncmd' + | 'joox' + | 'bilibili' + | 'gdmusic'; // 默认平台列表 -export const DEFAULT_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili']; +export const DEFAULT_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili']; export interface IRecommendMusic { code: number; diff --git a/src/renderer/utils/appShortcuts.ts b/src/renderer/utils/appShortcuts.ts index e15caf1..79ab7b0 100644 --- a/src/renderer/utils/appShortcuts.ts +++ b/src/renderer/utils/appShortcuts.ts @@ -83,7 +83,7 @@ export async function handleShortcutAction(action: string) { await audioService.pause(); showToast(t('player.playBar.pause'), 'ri-pause-circle-line'); } else { - await audioService.play(); + await audioService.getCurrentSound()?.play(); showToast(t('player.playBar.play'), 'ri-play-circle-line'); } break; diff --git a/src/renderer/utils/yrcParser.ts b/src/renderer/utils/yrcParser.ts index 952ff35..c717c3f 100644 --- a/src/renderer/utils/yrcParser.ts +++ b/src/renderer/utils/yrcParser.ts @@ -272,9 +272,6 @@ const parseWordByWordLine = (line: string): ParseResult => { currentPos = wordEndPos; } - console.log('fullText', fullText); - console.log('words', words); - return { success: true, data: { diff --git a/src/renderer/views/set/index.vue b/src/renderer/views/set/index.vue index 0f81cfd..450730d 100644 --- a/src/renderer/views/set/index.vue +++ b/src/renderer/views/set/index.vue @@ -520,7 +520,7 @@
-
{{ t('settings.regard') }}
+
{{ t('settings.sections.about') }}
@@ -644,35 +644,31 @@ import { checkUpdate, UpdateResult } from '@/utils/update'; import config from '../../../../package.json'; // 所有平台默认值 -const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili']; +const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'kuwo', 'pyncmd', 'bilibili']; const platform = window.electron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web'; const settingsStore = useSettingsStore(); const userStore = useUserStore(); -// 创建一个本地缓存的setData,避免频繁更新 -const localSetData = ref({ ...settingsStore.setData }); - -// 在组件卸载时保存设置 -onUnmounted(() => { - settingsStore.setSetData(localSetData.value); -}); - -const checking = ref(false); -const updateInfo = ref({ - hasUpdate: false, - latestVersion: '', - currentVersion: config.version, - releaseInfo: null -}); - -const { t } = useI18n(); - +/** + * 防抖保存设置 + * 避免频繁写入导致性能问题 + */ const saveSettings = useDebounceFn((data) => { settingsStore.setSetData(data); }, 500); +/** + * 本地缓存的设置数据 + * 使用本地副本避免直接操作 store,提升性能 + */ +const localSetData = ref({ ...settingsStore.setData }); + +/** + * 设置数据的计算属性 + * 提供响应式的读写接口 + */ const setData = computed({ get: () => localSetData.value, set: (newData) => { @@ -680,7 +676,10 @@ const setData = computed({ } }); -// 监听localSetData变化,保存设置 +/** + * 监听本地设置变化,自动保存 + * 使用防抖避免频繁保存 + */ watch( () => localSetData.value, (newValue) => { @@ -689,11 +688,14 @@ watch( { deep: true } ); -// 监听store中setData的变化,同步到本地 +/** + * 监听 store 中设置的变化,同步到本地 + * 避免外部修改导致的数据不一致 + */ watch( () => settingsStore.setData, (newValue) => { - // 只在初始加载时更新本地数据,避免循环更新 + // 只在数据不同时更新,避免循环触发 if (JSON.stringify(localSetData.value) !== JSON.stringify(newValue)) { localSetData.value = { ...newValue }; } @@ -701,6 +703,26 @@ watch( { deep: true, immediate: true } ); +/** + * 组件卸载时确保设置已保存 + */ +onUnmounted(() => { + settingsStore.setSetData(localSetData.value); +}); + +// ==================== 更新检查相关 ==================== +const checking = ref(false); +const updateInfo = ref({ + hasUpdate: false, + latestVersion: '', + currentVersion: config.version, + releaseInfo: null +}); + +// ==================== i18n ==================== +const { t } = useI18n(); + +// ==================== 主题和界面设置 ==================== const isDarkTheme = computed({ get: () => settingsStore.theme === 'dark', set: () => settingsStore.toggleTheme() @@ -1008,16 +1030,30 @@ const handleShortcutsChange = (shortcuts: any) => { console.log('快捷键已更新:', shortcuts); }; -// 定义设置分类 -const settingSections = [ - { id: 'basic', title: t('settings.sections.basic') }, - { id: 'playback', title: t('settings.sections.playback') }, - { id: 'application', title: t('settings.sections.application'), electron: true }, - { id: 'network', title: t('settings.sections.network'), electron: true }, - { id: 'system', title: t('settings.sections.system'), electron: true }, - { id: 'regard', title: t('settings.sections.regard') }, - { id: 'donation', title: t('settings.sections.donation') } -]; +/** + * 设置分类配置 + * 定义左侧导航的所有分类项 + */ +interface SettingSection { + id: string; + title?: string; // 可选,在模板中动态获取 i18n 标题 + electron?: boolean; +} + +const settingSections = computed(() => { + const sections: SettingSection[] = [ + { id: 'basic' }, + { id: 'playback' }, + { id: 'application', electron: true }, + { id: 'network', electron: true }, + { id: 'system', electron: true }, + { id: 'about' }, + { id: 'donation' } + ]; + + // 过滤非 Electron 环境下的专属分类 + return sections.filter((section) => !section.electron || isElectron); +}); // 当前激活的分类 const currentSection = ref('basic'); @@ -1032,18 +1068,27 @@ const systemRef = ref(); const aboutRef = ref(); const donationRef = ref(); -// 滚动到指定分类 +/** + * Section refs 映射表 + * 用于滚动定位和状态追踪 + */ +const SECTION_REFS_MAP = computed(() => ({ + basic: basicRef, + playback: playbackRef, + application: applicationRef, + network: networkRef, + system: systemRef, + about: aboutRef, + donation: donationRef +})); + +/** + * 滚动到指定分类 + * @param sectionId - 分类 ID + */ const scrollToSection = async (sectionId: string) => { currentSection.value = sectionId; - const sectionRef = { - basic: basicRef, - playback: playbackRef, - application: applicationRef, - network: networkRef, - system: systemRef, - about: aboutRef, - donation: donationRef - }[sectionId]; + const sectionRef = SECTION_REFS_MAP.value[sectionId]; if (sectionRef?.value) { await nextTick(); @@ -1054,27 +1099,30 @@ const scrollToSection = async (sectionId: string) => { } }; -// 处理滚动,更新当前激活的分类 +/** + * 滚动偏移阈值(px) + * 用于判断当前激活的分类 + */ +const SCROLL_OFFSET_THRESHOLD = 100; + +/** + * 处理滚动事件,更新当前激活的分类 + * 根据滚动位置自动高亮左侧导航 + */ const handleScroll = (e: any) => { const { scrollTop } = e.target; - const sections = [ - { id: 'basic', ref: basicRef }, - { id: 'playback', ref: playbackRef }, - { id: 'application', ref: applicationRef }, - { id: 'network', ref: networkRef }, - { id: 'system', ref: systemRef }, - { id: 'about', ref: aboutRef }, - { id: 'donation', ref: donationRef } - ]; + const sections = Object.entries(SECTION_REFS_MAP.value).map(([id, ref]) => ({ + id, + ref + })); - const activeSection = sections[0].id; - let lastValidSection = activeSection; + let lastValidSection = sections[0]?.id || 'basic'; for (const section of sections) { if (section.ref?.value) { const { offsetTop } = section.ref.value; - if (scrollTop >= offsetTop - 100) { + if (scrollTop >= offsetTop - SCROLL_OFFSET_THRESHOLD) { lastValidSection = section.id; } }