From 0cfec3dd821e329c8e6710a5ea6cfd1d1ccfc9de Mon Sep 17 00:00:00 2001 From: alger Date: Sun, 29 Mar 2026 13:18:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor(player):=20=E9=87=8D=E6=9E=84=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E6=8E=A7=E5=88=B6=E7=B3=BB=E7=BB=9F=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20Howler.js=20=E6=94=B9=E7=94=A8=E5=8E=9F=E7=94=9F=20?= =?UTF-8?q?HTMLAudioElement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 playbackController.ts,使用 generation-based 取消替代 playbackRequestManager 状态机 - audioService 重写:单一持久 HTMLAudioElement + Web Audio API,createMediaElementSource 只调一次 - playerCore 瘦身为纯状态管理,移除 handlePlayMusic/playAudio/checkPlaybackState - playlist next/prev 简化,区分用户手动切歌和歌曲自然播完 - MusicHook 适配 HTMLAudioElement API(.currentTime/.duration/.paused) - preloadService 从 Howl 实例缓存改为 URL 可用性验证 - 所有 view/component 调用者迁移到 playbackController.playTrack() 修复:快速切歌竞态、seek 到未缓冲位置失败、重启后自动播放循环提示、EQ 重建崩溃 --- src/renderer/App.vue | 3 + .../components/lyric/MusicFullMobile.vue | 7 +- .../components/player/ReparsePopover.vue | 7 +- src/renderer/hooks/MusicHook.ts | 94 +- src/renderer/services/audioService.ts | 1155 +++++------------ src/renderer/services/playbackController.ts | 531 ++++++++ .../services/playbackRequestManager.ts | 275 +--- src/renderer/services/preloadService.ts | 210 ++- .../store/modules/intelligenceMode.ts | 5 +- src/renderer/store/modules/player.ts | 7 +- src/renderer/store/modules/playerCore.ts | 531 +------- src/renderer/store/modules/playlist.ts | 209 ++- src/renderer/views/album/index.vue | 7 +- .../home/components/HomeAlbumSection.vue | 5 +- .../home/components/HomeDailyRecommend.vue | 10 +- .../views/home/components/HomeHero.vue | 8 +- .../home/components/HomePlaylistSection.vue | 5 +- 17 files changed, 1150 insertions(+), 1919 deletions(-) create mode 100644 src/renderer/services/playbackController.ts diff --git a/src/renderer/App.vue b/src/renderer/App.vue index 58889fc..c5726ad 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -140,6 +140,9 @@ onMounted(async () => { // 初始化 MusicHook,注入 playerStore initMusicHook(playerStore); + // 设置 URL 过期自动续播处理器 + const { setupUrlExpiredHandler } = await import('@/services/playbackController'); + setupUrlExpiredHandler(); // 初始化播放状态 await playerStore.initializePlayState(); diff --git a/src/renderer/components/lyric/MusicFullMobile.vue b/src/renderer/components/lyric/MusicFullMobile.vue index e067582..1c0a8c1 100644 --- a/src/renderer/components/lyric/MusicFullMobile.vue +++ b/src/renderer/components/lyric/MusicFullMobile.vue @@ -409,6 +409,7 @@ import { } from '@/hooks/MusicHook'; import { useArtist } from '@/hooks/useArtist'; import { usePlayMode } from '@/hooks/usePlayMode'; +import { audioService } from '@/services/audioService'; import { usePlayerStore } from '@/store/modules/player'; import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric'; import { getImgUrl, secondToMinute } from '@/utils'; @@ -757,7 +758,7 @@ const handleProgressBarClick = (e: MouseEvent) => { console.log(`进度条点击: ${percentage.toFixed(2)}, 新时间: ${newTime.toFixed(2)}`); - sound.value.seek(newTime); + audioService.seek(newTime); nowTime.value = newTime; }; @@ -817,7 +818,7 @@ const handleMouseUp = (e: MouseEvent) => { e.preventDefault(); // 释放时跳转到指定位置 - sound.value.seek(nowTime.value); + audioService.seek(nowTime.value); console.log(`鼠标释放,跳转到: ${nowTime.value.toFixed(2)}秒`); isMouseDragging.value = false; @@ -871,7 +872,7 @@ const handleThumbTouchEnd = (e: TouchEvent) => { // 拖动结束时执行seek操作 console.log(`拖动结束,跳转到: ${nowTime.value.toFixed(2)}秒`); - sound.value.seek(nowTime.value); + audioService.seek(nowTime.value); isThumbDragging.value = false; }; diff --git a/src/renderer/components/player/ReparsePopover.vue b/src/renderer/components/player/ReparsePopover.vue index aee5748..cf39024 100644 --- a/src/renderer/components/player/ReparsePopover.vue +++ b/src/renderer/components/player/ReparsePopover.vue @@ -100,9 +100,9 @@ import { useI18n } from 'vue-i18n'; import { CacheManager } from '@/api/musicParser'; import { playMusic } from '@/hooks/MusicHook'; import { initLxMusicRunner, setLxMusicRunner } from '@/services/LxMusicSourceRunner'; +import { reparseCurrentSong } from '@/services/playbackController'; import { SongSourceConfigManager } from '@/services/SongSourceConfigManager'; import { useSettingsStore } from '@/store'; -import { usePlayerStore } from '@/store/modules/player'; import type { LxMusicScriptConfig } from '@/types/lxMusic'; import type { Platform } from '@/types/music'; import { type MusicSourceGroup, useMusicSources } from '@/utils/musicSourceConfig'; @@ -119,7 +119,6 @@ type ReparseSourceItem = { lxScriptId?: string; }; -const playerStore = usePlayerStore(); const settingsStore = useSettingsStore(); const { t } = useI18n(); const message = useMessage(); @@ -253,7 +252,7 @@ const reparseWithLxScript = async (source: ReparseSourceItem) => { selectedSourceId.value = source.id; SongSourceConfigManager.setConfig(songId, ['lxMusic'], 'manual'); - const success = await playerStore.reparseCurrentSong('lxMusic', false); + const success = await reparseCurrentSong('lxMusic', false); if (success) { message.success(t('player.reparse.success')); @@ -283,7 +282,7 @@ const directReparseMusic = async (source: ReparseSourceItem) => { selectedSourceId.value = source.id; SongSourceConfigManager.setConfig(songId, [source.platform], 'manual'); - const success = await playerStore.reparseCurrentSong(source.platform, false); + const success = await reparseCurrentSong(source.platform, false); if (success) { message.success(t('player.reparse.success')); diff --git a/src/renderer/hooks/MusicHook.ts b/src/renderer/hooks/MusicHook.ts index 3a31a79..789e10d 100644 --- a/src/renderer/hooks/MusicHook.ts +++ b/src/renderer/hooks/MusicHook.ts @@ -1,5 +1,4 @@ import { cloneDeep } from 'lodash'; -import { createDiscreteApi } from 'naive-ui'; import { computed, type ComputedRef, nextTick, onUnmounted, ref, watch } from 'vue'; import useIndexedDB from '@/hooks/IndexDBHook'; @@ -45,7 +44,7 @@ export const nowTime = ref(0); // 当前播放时间 export const allTime = ref(0); // 总播放时间 export const nowIndex = ref(0); // 当前播放歌词 export const currentLrcProgress = ref(0); // 来存储当前歌词的进度 -export const sound = ref(audioService.getCurrentSound()); +export const sound = ref(audioService.getCurrentSound()); export const isLyricWindowOpen = ref(false); // 新增状态 export const textColors = ref(getTextColors()); @@ -86,8 +85,6 @@ const setupKeyboardListeners = () => { }; }; -const { message } = createDiscreteApi(['message']); - let audioListenersInitialized = false; /** @@ -307,12 +304,7 @@ const setupAudioListeners = () => { return; } - if (typeof currentSound.seek !== 'function') { - // seek 方法不可用,跳过本次更新,不清除 interval - return; - } - - const currentTime = currentSound.seek() as number; + const currentTime = currentSound.currentTime; if (typeof currentTime !== 'number' || Number.isNaN(currentTime)) { // 无效时间,跳过本次更新 return; @@ -324,7 +316,7 @@ const setupAudioListeners = () => { } nowTime.value = currentTime; - allTime.value = currentSound.duration() as number; + allTime.value = currentSound.duration; // === 歌词索引更新 === const newIndex = getLrcIndex(nowTime.value); @@ -396,7 +388,7 @@ const setupAudioListeners = () => { const store = getPlayerStore(); if (store.play && !interval) { const currentSound = audioService.getCurrentSound(); - if (currentSound && currentSound.playing()) { + if (currentSound && !currentSound.paused) { console.warn('[MusicHook] 检测到播放中但 interval 丢失,自动恢复'); startProgressInterval(); } @@ -422,7 +414,7 @@ const setupAudioListeners = () => { const currentSound = audioService.getCurrentSound(); if (currentSound) { // 立即更新显示时间,不进行任何检查 - const currentTime = currentSound.seek() as number; + const currentTime = currentSound.currentTime; if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) { nowTime.value = currentTime; @@ -447,10 +439,10 @@ const setupAudioListeners = () => { if (currentSound) { try { // 更新当前时间和总时长 - const currentTime = currentSound.seek() as number; + const currentTime = currentSound.currentTime; if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) { nowTime.value = currentTime; - allTime.value = currentSound.duration() as number; + allTime.value = currentSound.duration; } } catch (error) { console.error('初始化时间和进度失败:', error); @@ -481,34 +473,25 @@ const setupAudioListeners = () => { } }); - const replayMusic = async (retryCount: number = 0) => { + const replayMusic = async (retryCount = 0) => { const MAX_REPLAY_RETRIES = 3; try { - // 如果当前有音频实例,先停止并销毁 - const currentSound = audioService.getCurrentSound(); - if (currentSound) { - currentSound.stop(); - currentSound.unload(); - } - sound.value = null; - - // 重新播放当前歌曲 if (getPlayerStore().playMusicUrl && playMusic.value) { - const newSound = await audioService.play(getPlayerStore().playMusicUrl, playMusic.value); - sound.value = newSound as Howl; + await audioService.play(getPlayerStore().playMusicUrl, playMusic.value); + sound.value = audioService.getCurrentSound(); setupAudioListeners(); } else { console.error('单曲循环:无可用 URL 或歌曲数据'); - getPlayerStore().nextPlay(); + const { usePlaylistStore } = await import('@/store/modules/playlist'); + usePlaylistStore().nextPlayOnEnd(); } } catch (error) { 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(); + const { usePlaylistStore } = await import('@/store/modules/playlist'); + usePlaylistStore().nextPlayOnEnd(); } } }; @@ -544,7 +527,8 @@ const setupAudioListeners = () => { const playlistStore = usePlaylistStore(); playlistStore.setPlayList([fmSong], false, false); getPlayerStore().isFmPlaying = true; // setPlayList 会清除,需重设 - await getPlayerStore().handlePlayMusic(fmSong, true); + const { playTrack } = await import('@/services/playbackController'); + await playTrack(fmSong, true); } else { getPlayerStore().setIsPlay(false); } @@ -553,8 +537,9 @@ const setupAudioListeners = () => { getPlayerStore().setIsPlay(false); } } else { - // 顺序播放、列表循环、随机播放模式都使用统一的nextPlay方法 - getPlayerStore().nextPlay(); + // 顺序播放、列表循环、随机播放模式:歌曲自然结束 + const { usePlaylistStore } = await import('@/store/modules/playlist'); + usePlaylistStore().nextPlayOnEnd(); } }); @@ -576,8 +561,6 @@ export const play = () => { const currentSound = audioService.getCurrentSound(); if (currentSound) { currentSound.play(); - // 在播放时也进行状态检测,防止URL已过期导致无声 - getPlayerStore().checkPlaybackState(getPlayerStore().playMusic); } }; @@ -586,7 +569,7 @@ export const pause = () => { if (currentSound) { try { // 保存当前播放进度 - const currentTime = currentSound.seek() as number; + const currentTime = currentSound.currentTime; if (getPlayerStore().playMusic && getPlayerStore().playMusic.id) { localStorage.setItem( 'playProgress', @@ -739,7 +722,7 @@ export const setAudioTime = (index: number) => { const currentSound = sound.value; if (!currentSound) return; - currentSound.seek(lrcTimeArray.value[index]); + audioService.seek(lrcTimeArray.value[index]); currentSound.play(); }; @@ -1042,45 +1025,24 @@ export const initAudioListeners = async () => { } }; -// 监听URL过期事件,自动重新获取URL并恢复播放 -audioService.on('url_expired', async (expiredTrack) => { - if (!expiredTrack) return; - - console.log('检测到URL过期事件,准备重新获取URL', expiredTrack.name); - - try { - // 使用 handlePlayMusic 重新播放,它会自动处理 URL 获取和状态跟踪 - // 我们将 isFirstPlay 设为 true 以强制获取新 URL - const trackToPlay = { - ...expiredTrack, - isFirstPlay: true, - playMusicUrl: undefined - }; - - await getPlayerStore().handlePlayMusic(trackToPlay, getPlayerStore().play); - - message.success('已自动恢复播放'); - } catch (error) { - console.error('处理URL过期事件失败:', error); - message.error('恢复播放失败,请手动点击播放'); - } -}); - // 添加音频就绪事件监听器 window.addEventListener('audio-ready', ((event: CustomEvent) => { try { const { sound: newSound } = event.detail; if (newSound) { // 更新本地 sound 引用 - sound.value = newSound as Howl; + sound.value = audioService.getCurrentSound(); // 设置音频监听器 setupAudioListeners(); // 获取当前播放位置并更新显示 - const currentPosition = newSound.seek() as number; - if (typeof currentPosition === 'number' && !Number.isNaN(currentPosition)) { - nowTime.value = currentPosition; + const currentSound = audioService.getCurrentSound(); + if (currentSound) { + const currentPosition = currentSound.currentTime; + if (typeof currentPosition === 'number' && !Number.isNaN(currentPosition)) { + nowTime.value = currentPosition; + } } console.log('音频就绪,已设置监听器并更新进度显示'); diff --git a/src/renderer/services/audioService.ts b/src/renderer/services/audioService.ts index b63dc42..141fc59 100644 --- a/src/renderer/services/audioService.ts +++ b/src/renderer/services/audioService.ts @@ -1,35 +1,26 @@ -import { Howl, Howler } from 'howler'; - import type { AudioOutputDevice } from '@/types/audio'; import type { SongResult } from '@/types/music'; -import { isElectron } from '@/utils'; // 导入isElectron常量 +import { isElectron } from '@/utils'; class AudioService { - private currentSound: Howl | null = null; - private pendingSound: Howl | null = null; - + private audio: HTMLAudioElement; private currentTrack: SongResult | null = null; private context: AudioContext | null = null; - + private sourceNode: MediaElementAudioSourceNode | null = null; private filters: BiquadFilterNode[] = []; - - private source: MediaElementAudioSourceNode | null = null; - private gainNode: GainNode | null = null; - private bypass = false; - private playbackRate = 1.0; // 添加播放速度属性 - + private playbackRate = 1.0; private currentSinkId: string = 'default'; + private _isLoading = false; - private contextStateMonitoringInitialized = false; + private operationLock = false; + private operationLockTimer: ReturnType | null = null; - // 预设的 EQ 频段 private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; - // 默认的 EQ 设置 private defaultEQSettings: { [key: string]: number } = { '31': 0, '62': 0, @@ -43,43 +34,80 @@ class AudioService { '16000': 0 }; - private retryCount = 0; - - private seekLock = false; - - private seekDebounceTimer: NodeJS.Timeout | null = null; - - // 添加操作锁防止并发操作 - private operationLock = false; - private operationLockTimer: NodeJS.Timeout | null = null; - private operationLockTimeout = 5000; // 5秒超时 - private operationLockStartTime: number = 0; - private operationLockId: string = ''; + // Event system + private callbacks: { [key: string]: Function[] } = {}; constructor() { + // Create persistent audio element + this.audio = new Audio(); + this.audio.crossOrigin = 'anonymous'; + this.audio.preload = 'auto'; + + // Bind native DOM events + this.bindAudioEvents(); + if ('mediaSession' in navigator) { this.initMediaSession(); } - // 从本地存储加载 EQ 开关状态 + + // Restore EQ bypass state const bypassState = localStorage.getItem('eqBypass'); this.bypass = bypassState ? JSON.parse(bypassState) : false; - // 页面加载时立即强制重置操作锁 this.forceResetOperationLock(); + window.addEventListener('beforeunload', () => this.forceResetOperationLock()); + } - // 添加页面卸载事件,确保离开页面时清除锁 - window.addEventListener('beforeunload', () => { - this.forceResetOperationLock(); + // ==================== Native DOM Event Binding ==================== + + private bindAudioEvents() { + this.audio.addEventListener('play', () => { + this.updateMediaSessionState(true); + this.emit('play'); + }); + + this.audio.addEventListener('pause', () => { + this.updateMediaSessionState(false); + this.emit('pause'); + }); + + this.audio.addEventListener('ended', () => { + this.emit('end'); + }); + + this.audio.addEventListener('seeked', () => { + this.updateMediaSessionPositionState(); + this.emit('seek'); + }); + + this.audio.addEventListener('timeupdate', () => { + // Consumers can listen to this if needed; mainly for MediaSession sync + }); + + this.audio.addEventListener('waiting', () => { + this._isLoading = true; + }); + + this.audio.addEventListener('canplay', () => { + this._isLoading = false; + }); + + this.audio.addEventListener('error', () => { + const error = this.audio.error; + console.error('Audio element error:', error?.code, error?.message); + this.emit('audio_error', { type: 'media_error', error }); }); } + // ==================== MediaSession ==================== + private initMediaSession() { navigator.mediaSession.setActionHandler('play', () => { - this.currentSound?.play(); + this.audio.play(); }); navigator.mediaSession.setActionHandler('pause', () => { - this.currentSound?.pause(); + this.audio.pause(); }); navigator.mediaSession.setActionHandler('stop', () => { @@ -87,33 +115,24 @@ class AudioService { }); navigator.mediaSession.setActionHandler('seekto', (event) => { - if (event.seekTime && this.currentSound) { - // this.currentSound.seek(event.seekTime); + if (event.seekTime !== undefined) { this.seek(event.seekTime); } }); navigator.mediaSession.setActionHandler('seekbackward', (event) => { - if (this.currentSound) { - const currentTime = this.currentSound.seek() as number; - this.seek(currentTime - (event.seekOffset || 10)); - } + this.seek(this.audio.currentTime - (event.seekOffset || 10)); }); navigator.mediaSession.setActionHandler('seekforward', (event) => { - if (this.currentSound) { - const currentTime = this.currentSound.seek() as number; - this.seek(currentTime + (event.seekOffset || 10)); - } + this.seek(this.audio.currentTime + (event.seekOffset || 10)); }); navigator.mediaSession.setActionHandler('previoustrack', () => { - // 这里需要通过回调通知外部 this.emit('previoustrack'); }); navigator.mediaSession.setActionHandler('nexttrack', () => { - // 这里需要通过回调通知外部 this.emit('nexttrack'); }); } @@ -131,14 +150,13 @@ class AudioService { type: 'image/jpg', sizes: `${size}x${size}` })); - const metadata = { + + navigator.mediaSession.metadata = new window.MediaMetadata({ title: track.name || '', artist: artists ? artists.join(',') : '', album: album || '', artwork - }; - - navigator.mediaSession.metadata = new window.MediaMetadata(metadata); + }); } catch (error) { console.error('更新媒体会话元数据时出错:', error); } @@ -146,19 +164,20 @@ class AudioService { private updateMediaSessionState(isPlaying: boolean) { if (!('mediaSession' in navigator)) return; - navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused'; this.updateMediaSessionPositionState(); } private updateMediaSessionPositionState() { try { - if (!this.currentSound || !('mediaSession' in navigator)) return; + if (!('mediaSession' in navigator)) return; + if (!this.audio.duration || !isFinite(this.audio.duration)) return; + if ('setPositionState' in navigator.mediaSession) { navigator.mediaSession.setPositionState({ - duration: this.currentSound.duration(), + duration: this.audio.duration, playbackRate: this.playbackRate, - position: this.currentSound.seek() as number + position: this.audio.currentTime }); } } catch (error) { @@ -166,8 +185,7 @@ class AudioService { } } - // 事件处理相关 - private callbacks: { [key: string]: Function[] } = {}; + // ==================== Event Emitter ==================== private emit(event: string, ...args: any[]) { const eventCallbacks = this.callbacks[event]; @@ -190,7 +208,110 @@ class AudioService { } } - // EQ 相关方法 + clearAllListeners() { + this.callbacks = {}; + } + + // ==================== EQ ==================== + + private setupEQ() { + if (this.sourceNode) return; // Already initialized + + if (!isElectron) { + console.log('Web环境中跳过EQ设置,避免CORS问题'); + this.bypass = true; + return; + } + + try { + this.context = new AudioContext(); + this.sourceNode = this.context.createMediaElementSource(this.audio); + this.gainNode = this.context.createGain(); + + // Create 10-band filter chain + const savedSettings = this.loadEQSettings(); + this.filters = this.frequencies.map((freq) => { + const filter = this.context!.createBiquadFilter(); + filter.type = 'peaking'; + filter.frequency.value = freq; + filter.Q.value = 1; + filter.gain.value = savedSettings[freq.toString()] || 0; + return filter; + }); + + // Wire up the graph + this.applyBypassState(); + + // Apply saved volume + const savedVolume = localStorage.getItem('volume'); + this.applyVolume(savedVolume ? parseFloat(savedVolume) : 1); + + // Monitor context state + this.setupContextStateMonitoring(); + + // Restore saved audio device + this.restoreSavedAudioDevice(); + + console.log('EQ initialization successful'); + } catch (error) { + console.error('EQ initialization failed:', error); + // Fallback: connect audio directly (no EQ) + this.sourceNode = null; + this.context = null; + } + } + + private applyBypassState() { + if (!this.sourceNode || !this.gainNode || !this.context) return; + + try { + // Disconnect all + try { + this.sourceNode.disconnect(); + } catch { + /* already disconnected */ + } + this.filters.forEach((filter) => { + try { + filter.disconnect(); + } catch { + /* already disconnected */ + } + }); + try { + this.gainNode.disconnect(); + } catch { + /* already disconnected */ + } + + if (this.bypass) { + // EQ disabled: source -> gain -> destination + this.sourceNode.connect(this.gainNode); + this.gainNode.connect(this.context.destination); + } else { + // EQ enabled: source -> filters[0] -> ... -> filters[9] -> gain -> destination + this.sourceNode.connect(this.filters[0]); + this.filters.forEach((filter, index) => { + if (index < this.filters.length - 1) { + filter.connect(this.filters[index + 1]); + } + }); + this.filters[this.filters.length - 1].connect(this.gainNode); + this.gainNode.connect(this.context.destination); + } + } catch (error) { + console.error('Error applying EQ state, attempting fallback:', error); + try { + if (this.sourceNode && this.context) { + this.sourceNode.connect(this.context.destination); + } + } catch (fallbackError) { + console.error('Fallback connection also failed:', fallbackError); + this.emit('audio_error', { type: 'graph_disconnected', error: fallbackError }); + } + } + } + public isEQEnabled(): boolean { return !this.bypass; } @@ -199,7 +320,7 @@ class AudioService { this.bypass = !enabled; localStorage.setItem('eqBypass', JSON.stringify(this.bypass)); - if (this.source && this.gainNode && this.context) { + if (this.sourceNode && this.gainNode && this.context) { this.applyBypassState(); } } @@ -223,6 +344,14 @@ class AudioService { return this.loadEQSettings(); } + public getCurrentPreset(): string | null { + return localStorage.getItem('currentPreset'); + } + + public setCurrentPreset(preset: string): void { + localStorage.setItem('currentPreset', preset); + } + private saveEQSettings(frequency: string, gain: number) { const settings = this.loadEQSettings(); settings[frequency] = gain; @@ -234,695 +363,253 @@ class AudioService { return savedSettings ? JSON.parse(savedSettings) : { ...this.defaultEQSettings }; } - private async disposeEQ(keepContext = false) { - try { - // 清理音频节点连接 - if (this.source) { - this.source.disconnect(); - this.source = null; - } + // ==================== Operation Lock ==================== - // 清理滤波器 - this.filters.forEach((filter) => { - try { - filter.disconnect(); - } catch (e) { - console.warn('清理滤波器时出错:', e); - } - }); - this.filters = []; - - // 清理增益节点 - if (this.gainNode) { - this.gainNode.disconnect(); - this.gainNode = null; - } - - // 如果不需要保持上下文,则关闭它 - if (!keepContext && this.context) { - try { - await this.context.close(); - this.context = null; - } catch (e) { - console.warn('关闭音频上下文时出错:', e); - } - } - } catch (error) { - console.error('清理EQ资源时出错:', error); - } - } - - private async setupEQ(sound: Howl) { - try { - if (!isElectron) { - console.log('Web环境中跳过EQ设置,避免CORS问题'); - this.bypass = true; - return; - } - const howl = sound as any; - - const audioNode = howl._sounds?.[0]?._node; - - if (!audioNode || !(audioNode instanceof HTMLMediaElement)) { - if (this.retryCount < 3) { - console.warn('等待音频节点初始化,重试次数:', this.retryCount + 1); - await new Promise((resolve) => setTimeout(resolve, 100)); - this.retryCount++; - return await this.setupEQ(sound); - } - throw new Error('无法获取音频节点,请重试'); - } - - this.retryCount = 0; - - // 确保使用 Howler 的音频上下文 - this.context = Howler.ctx as AudioContext; - - if (!this.context || this.context.state === 'closed') { - Howler.ctx = new AudioContext(); - this.context = Howler.ctx; - Howler.masterGain = this.context.createGain(); - Howler.masterGain.connect(this.context.destination); - } - - if (this.context.state === 'suspended') { - await this.context.resume(); - } - - // 设置 AudioContext 状态监控 - this.setupContextStateMonitoring(); - - // 恢复保存的音频输出设备 - this.restoreSavedAudioDevice(); - - // 清理现有连接 - await this.disposeEQ(true); - - try { - // 检查节点是否已经有源 - const existingSource = (audioNode as any).source as MediaElementAudioSourceNode; - if (existingSource?.context === this.context) { - console.log('复用现有音频源节点'); - this.source = existingSource; - } else { - // 创建新的源节点 - console.log('创建新的音频源节点'); - this.source = this.context.createMediaElementSource(audioNode); - (audioNode as any).source = this.source; - } - } catch (e) { - console.error('创建音频源节点失败:', e); - throw e; - } - - // 创建增益节点 - this.gainNode = this.context.createGain(); - - // 创建滤波器 - this.filters = this.frequencies.map((freq) => { - const filter = this.context!.createBiquadFilter(); - filter.type = 'peaking'; - filter.frequency.value = freq; - filter.Q.value = 1; - filter.gain.value = this.loadEQSettings()[freq.toString()] || 0; - return filter; - }); - - // 应用EQ状态 - this.applyBypassState(); - - // 从 localStorage 应用音量到增益节点 - const savedVolume = localStorage.getItem('volume'); - if (savedVolume) { - this.applyVolume(parseFloat(savedVolume)); - } else { - this.applyVolume(1); - } - - console.log('EQ initialization successful'); - } catch (error) { - console.error('EQ initialization failed:', error); - await this.disposeEQ(); - throw error; - } - } - - private applyBypassState() { - if (!this.source || !this.gainNode || !this.context) return; - - try { - // 断开所有现有连接(捕获已断开的错误) - try { - this.source.disconnect(); - } catch { - /* already disconnected */ - } - this.filters.forEach((filter) => { - try { - filter.disconnect(); - } catch { - /* already disconnected */ - } - }); - try { - this.gainNode.disconnect(); - } catch { - /* already disconnected */ - } - - if (this.bypass) { - // EQ被禁用时,直接连接到输出 - this.source.connect(this.gainNode); - this.gainNode.connect(this.context.destination); - } else { - // EQ启用时,通过滤波器链连接 - this.source.connect(this.filters[0]); - this.filters.forEach((filter, index) => { - if (index < this.filters.length - 1) { - filter.connect(this.filters[index + 1]); - } - }); - this.filters[this.filters.length - 1].connect(this.gainNode); - this.gainNode.connect(this.context.destination); - } - } catch (error) { - console.error('Error applying EQ state, attempting fallback:', error); - // Fallback: connect source directly to destination - try { - if (this.source && this.context) { - this.source.connect(this.context.destination); - console.log('Fallback: connected source directly to destination'); - } - } catch (fallbackError) { - console.error('Fallback connection also failed:', fallbackError); - this.emit('audio_error', { type: 'graph_disconnected', error: fallbackError }); - } - } - } - - // 设置操作锁,带超时自动释放 private setOperationLock(): boolean { - // 生成唯一的锁ID - const lockId = Date.now().toString() + Math.random().toString(36).substring(2, 9); - - // 如果锁已经存在,检查是否超时 if (this.operationLock) { - const currentTime = Date.now(); - const lockDuration = currentTime - this.operationLockStartTime; - - // 如果锁持续时间超过2秒,直接强制重置 - if (lockDuration > 2000) { - console.warn(`操作锁已激活 ${lockDuration}ms,超过安全阈值,强制重置`); - this.forceResetOperationLock(); - } else { - console.log(`操作锁激活中,持续时间 ${lockDuration}ms`); - return false; - } + return false; } - this.operationLock = true; - this.operationLockStartTime = Date.now(); - this.operationLockId = lockId; - // 将锁信息存储到 localStorage(仅用于调试,实际不依赖此值) - try { - localStorage.setItem( - 'audioOperationLock', - JSON.stringify({ - id: this.operationLockId, - startTime: this.operationLockStartTime - }) - ); - } catch (error) { - console.error('存储操作锁信息失败:', error); - } - - // 清除之前的定时器 - if (this.operationLockTimer) { - clearTimeout(this.operationLockTimer); - } - - // 设置超时自动释放锁 + if (this.operationLockTimer) clearTimeout(this.operationLockTimer); this.operationLockTimer = setTimeout(() => { console.warn('操作锁超时自动释放'); this.releaseOperationLock(); - }, this.operationLockTimeout); + }, 5000); return true; } - // 释放操作锁 public releaseOperationLock(): void { this.operationLock = false; - this.operationLockStartTime = 0; - - // 从 localStorage 中移除锁信息 - try { - localStorage.removeItem('audioOperationLock'); - } catch (error) { - console.error('清除存储的操作锁信息失败:', error); - } - if (this.operationLockTimer) { clearTimeout(this.operationLockTimer); this.operationLockTimer = null; } } - // 强制重置操作锁,用于特殊情况 public forceResetOperationLock(): void { - console.log('强制重置操作锁'); this.operationLock = false; - this.operationLockStartTime = 0; - this.operationLockId = ''; - if (this.operationLockTimer) { clearTimeout(this.operationLockTimer); this.operationLockTimer = null; } - - // 清除存储的锁 - localStorage.removeItem('audioOperationLock'); } - // 播放控制相关 + // ==================== Playback Control ==================== + public play( url: string, track: SongResult, isPlay: boolean = true, seekTime: number = 0, - existingSound?: Howl - ): Promise { - // 如果没有提供新的 URL 和 track,且当前有音频实例,则继续播放当前音频 - if (this.currentSound && !url && !track) { - if (this.seekLock && this.seekDebounceTimer) { - clearTimeout(this.seekDebounceTimer); - this.seekLock = false; - } - this.currentSound.play(); - return Promise.resolve(this.currentSound); + _existingSound?: HTMLAudioElement + ): Promise { + // Resume current playback if no new URL/track provided + if (this.audio.src && !url && !track) { + this.audio.play(); + return Promise.resolve(this.audio); } - // 新播放请求:强制重置旧锁,确保不会被遗留锁阻塞 this.forceResetOperationLock(); + this.setOperationLock(); - // 获取操作锁 - if (!this.setOperationLock()) { - // 理论上不会到这里(刚刚 forceReset 过),但作为防御性编程 - console.warn('audioService: 获取操作锁失败,强制继续'); - this.forceResetOperationLock(); - this.setOperationLock(); - } - - // 如果没有提供必要的参数,返回错误 if (!url || !track) { this.releaseOperationLock(); return Promise.reject(new Error('缺少必要参数: url和track')); } - // 检查是否是同一首歌曲的无缝切换(Hot-Swap) - const isHotSwap = - this.currentTrack && track && this.currentTrack.id === track.id && this.currentSound; + // Check if same URL — just resume/seek + const currentSrc = this.audio.src; + const isSameUrl = currentSrc && currentSrc === url; - if (isHotSwap) { - console.log('audioService: 检测到同一首歌曲的源切换,启用无缝切换模式'); + if (isSameUrl) { + this.currentTrack = track; + if (seekTime > 0) this.audio.currentTime = seekTime; + if (isPlay) this.audio.play(); + this.updateMediaSessionMetadata(track); + this.releaseOperationLock(); + return Promise.resolve(this.audio); } - return new Promise((resolve, reject) => { + 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 = () => { + this._isLoading = true; + this.currentTrack = track; - const tryPlay = async () => { - try { - console.log('audioService: 开始创建音频对象'); + // Ensure EQ/AudioContext is set up (only runs once) + this.setupEQ(); - // 确保 Howler 上下文已初始化 - if (!Howler.ctx) { - console.log('audioService: 初始化 Howler 上下文'); - Howler.ctx = new (window.AudioContext || (window as any).webkitAudioContext)(); - } - - // 确保使用同一个音频上下文 - if (Howler.ctx.state === 'closed') { - console.log('audioService: 重新创建音频上下文'); - Howler.ctx = new (window.AudioContext || (window as any).webkitAudioContext)(); - this.context = Howler.ctx; - Howler.masterGain = this.context.createGain(); - Howler.masterGain.connect(this.context.destination); - // 重新创建上下文后恢复输出设备 - this.restoreSavedAudioDevice(); - } - - // 恢复上下文状态 - if (Howler.ctx.state === 'suspended') { - console.log('audioService: 恢复暂停的音频上下文'); - await Howler.ctx.resume(); - } - - // 非热切换模式下,先停止并清理现有的音频实例 - if (!isHotSwap && this.currentSound) { - console.log('audioService: 停止并清理现有的音频实例'); - // 确保任何进行中的seek操作被取消 - if (this.seekLock && this.seekDebounceTimer) { - clearTimeout(this.seekDebounceTimer); - this.seekLock = false; - } - this.currentSound.stop(); - this.currentSound.unload(); - this.currentSound = null; - } - - // 清理 EQ 但保持上下文 (热切换时暂时不清理,等切换完成后再处理) - if (!isHotSwap) { - console.log('audioService: 清理 EQ'); - await this.disposeEQ(true); - } - - // 如果不是热切换,立即更新 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); - this.emit('loaderror', { track, error }); - if (retryCount < maxRetries && !existingSound) { - // 预加载的音频通常已经 loaded,不应重试 - retryCount++; - console.log(`Retrying playback (${retryCount}/${maxRetries})...`); - setTimeout(tryPlay, 1000 * retryCount); - } else { - this.emit('url_expired', track); - this.releaseOperationLock(); - if (isHotSwap) this.pendingSound = null; - reject(new Error('音频加载失败,请尝试切换其他歌曲')); - } - }); - - newSound.on('playerror', (_, error) => { - console.error('Audio play error:', error); - this.emit('playerror', { track, error }); - if (retryCount < maxRetries) { - retryCount++; - console.log(`Retrying playback (${retryCount}/${maxRetries})...`); - setTimeout(tryPlay, 1000 * retryCount); - } else { - this.emit('url_expired', track); - this.releaseOperationLock(); - if (isHotSwap) this.pendingSound = null; - reject(new Error('音频播放失败,请尝试切换其他歌曲')); - } - }); - - const onLoaded = async () => { - try { - // 如果是热切换,现在执行切换逻辑 - if (isHotSwap) { - console.log('audioService: 执行无缝切换'); - - // 1. 获取当前播放进度或使用指定的 seekTime - let targetPos = 0; - if (seekTime > 0) { - // 如果有指定的 seekTime(如恢复播放进度),优先使用 - targetPos = seekTime; - console.log(`audioService: 使用指定的 seekTime: ${seekTime}s`); - } else if (this.currentSound) { - // 否则同步当前进度 - targetPos = this.currentSound.seek() as number; - } - - // 2. 同步新音频进度 - newSound.seek(targetPos); - - // 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: 无缝切换完成,进度同步至 ${targetPos}s`); - } else { - // 普通加载逻辑 - await this.setupEQ(newSound); - this.currentSound = newSound; - } - - // 重新应用已保存的音量 - const savedVolume = localStorage.getItem('volume'); - if (savedVolume) { - this.applyVolume(parseFloat(savedVolume)); - } - - if (this.currentSound) { - try { - if (!isHotSwap && seekTime > 0) { - this.currentSound.seek(seekTime); - } - - console.log('audioService: 音频加载成功,设置 EQ'); - this.updateMediaSessionMetadata(track); - this.updateMediaSessionPositionState(); - this.emit('load'); - - if (!isHotSwap) { - console.log('audioService: 音频完全初始化,isPlay =', isPlay); - if (isPlay) { - console.log('audioService: 开始播放'); - this.currentSound.play(); - } - } - - resolve(this.currentSound); - } catch (error) { - console.error('Audio initialization failed:', error); - reject(error); - } - } - } catch (error) { - console.error('Audio initialization failed:', error); - reject(error); - } - }; - - if (newSound.state() === 'loaded') { - onLoaded(); - } else { - newSound.once('load', onLoaded); - } - }; - - 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'); - } - }); - - soundInstance.on('pause', () => { - if (this.currentSound === soundInstance) { - this.updateMediaSessionState(false); - this.emit('pause'); - } - }); - - soundInstance.on('end', () => { - if (this.currentSound === soundInstance) { - this.emit('end'); - } - }); - - soundInstance.on('seek', () => { - if (this.currentSound === soundInstance) { - this.updateMediaSessionPositionState(); - this.emit('seek'); - } - }); - } - } catch (error) { - console.error('Error creating audio instance:', error); - this.releaseOperationLock(); - reject(error); + // Resume AudioContext if suspended (user gesture requirement) + if (this.context && this.context.state === 'suspended') { + this.context.resume().catch((e) => console.warn('Failed to resume AudioContext:', e)); } + + const onCanPlay = () => { + cleanup(); + this._isLoading = false; + + if (seekTime > 0) { + this.audio.currentTime = seekTime; + } + + if (isPlay) { + this.audio.play().catch((err) => { + console.error('Audio play failed:', err); + this.emit('playerror', { track, error: err }); + }); + } + + // Apply volume (use GainNode if available, else direct) + const savedVolume = localStorage.getItem('volume'); + this.applyVolume(savedVolume ? parseFloat(savedVolume) : 1); + + this.audio.playbackRate = this.playbackRate; + this.updateMediaSessionMetadata(track); + this.updateMediaSessionPositionState(); + this.emit('load'); + this.releaseOperationLock(); + resolve(this.audio); + }; + + const onError = () => { + cleanup(); + this._isLoading = false; + const error = this.audio.error; + console.error('Audio load error:', error?.code, error?.message); + this.emit('loaderror', { track, error }); + + if (retryCount < maxRetries) { + retryCount++; + console.log(`Retrying playback (${retryCount}/${maxRetries})...`); + setTimeout(tryPlay, 1000 * retryCount); + } else { + this.emit('url_expired', track); + this.releaseOperationLock(); + reject(new Error('音频加载失败,请尝试切换其他歌曲')); + } + }; + + const cleanup = () => { + this.audio.removeEventListener('canplay', onCanPlay); + this.audio.removeEventListener('error', onError); + }; + + this.audio.addEventListener('canplay', onCanPlay, { once: true }); + this.audio.addEventListener('error', onError, { once: true }); + + // Change source and load + this.audio.src = url; + this.audio.load(); }; tryPlay(); }).finally(() => { - // 无论成功或失败都解除操作锁 this.releaseOperationLock(); }); } - getCurrentSound() { - return this.currentSound; - } - - getCurrentTrack() { - return this.currentTrack; - } - - stop() { - // 强制重置操作锁并继续执行 + public pause() { this.forceResetOperationLock(); - try { - if (this.currentSound) { - try { - // 确保任何进行中的seek操作被取消 - if (this.seekLock && this.seekDebounceTimer) { - clearTimeout(this.seekDebounceTimer); - this.seekLock = false; - } - this.currentSound.stop(); - this.currentSound.unload(); - } catch (error) { - console.error('停止音频失败:', error); - } - this.currentSound = null; - } - - this.currentTrack = null; - if ('mediaSession' in navigator) { - navigator.mediaSession.playbackState = 'none'; - } - this.disposeEQ(); + this.audio.pause(); } catch (error) { - console.error('停止音频时发生错误:', error); + console.error('暂停音频失败:', error); } } - setVolume(volume: number) { + public stop() { + this.forceResetOperationLock(); + try { + this.audio.pause(); + this.audio.removeAttribute('src'); + this.audio.load(); // Reset the element + } catch (error) { + console.error('停止音频失败:', error); + } + this.currentTrack = null; + if ('mediaSession' in navigator) { + navigator.mediaSession.playbackState = 'none'; + } + } + + public seek(time: number) { + this.forceResetOperationLock(); + try { + this.emit('seek_start', time); + this.audio.currentTime = Math.max(0, time); + this.updateMediaSessionPositionState(); + } catch (error) { + console.error('Seek操作失败:', error); + } + } + + public setVolume(volume: number) { this.applyVolume(volume); } - seek(time: number) { - // 直接强制重置操作锁 - this.forceResetOperationLock(); + private applyVolume(volume: number) { + const normalizedVolume = Math.max(0, Math.min(1, volume)); - if (this.currentSound) { - try { - // 直接执行seek操作 - this.currentSound.seek(time); - // 触发seek事件 - this.updateMediaSessionPositionState(); - this.emit('seek', time); - } catch (error) { - console.error('Seek操作失败:', error); - } + if (this.gainNode && this.context) { + this.gainNode.gain.cancelScheduledValues(this.context.currentTime); + this.gainNode.gain.setValueAtTime(normalizedVolume, this.context.currentTime); + } else { + // Fallback: direct volume (no Web Audio context) + this.audio.volume = normalizedVolume; + } + + localStorage.setItem('volume', normalizedVolume.toString()); + } + + public setPlaybackRate(rate: number) { + this.playbackRate = rate; + this.audio.playbackRate = rate; + this.updateMediaSessionPositionState(); + } + + public getPlaybackRate(): number { + return this.playbackRate; + } + + // ==================== State Queries ==================== + + getCurrentSound(): HTMLAudioElement | null { + return this.audio.src ? this.audio : null; + } + + getCurrentTrack(): SongResult | null { + return this.currentTrack; + } + + isLoading(): boolean { + return this._isLoading || this.operationLock; + } + + isActuallyPlaying(): boolean { + if (!this.audio.src) return false; + try { + const isPlaying = !this.audio.paused && !this.audio.ended; + const contextOk = !this.context || this.context.state === 'running'; + return isPlaying && !this._isLoading && contextOk; + } catch (error) { + console.error('检查播放状态出错:', error); + return false; } } - pause() { - this.forceResetOperationLock(); + // ==================== Audio Output Devices ==================== - if (this.currentSound) { - try { - // 确保任何进行中的seek操作被取消 - if (this.seekLock && this.seekDebounceTimer) { - clearTimeout(this.seekDebounceTimer); - this.seekLock = false; - } - this.currentSound.pause(); - } catch (error) { - console.error('暂停音频失败:', error); - } - } - } - - clearAllListeners() { - this.callbacks = {}; - } - - public getCurrentPreset(): string | null { - return localStorage.getItem('currentPreset'); - } - - public setCurrentPreset(preset: string): void { - localStorage.setItem('currentPreset', preset); - } - - // ==================== 音频输出设备管理 ==================== - - /** - * 获取可用的音频输出设备列表 - */ public async getAudioOutputDevices(): Promise { try { - // 先尝试获取一个临时音频流来触发权限授予 - // 确保 enumerateDevices 返回完整的设备信息(包括 label) try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); stream.getTracks().forEach((track) => track.stop()); } catch { - // 即使失败也继续,可能已有权限 + // Continue even if permission denied } const devices = await navigator.mediaDevices.enumerateDevices(); @@ -939,12 +626,6 @@ class AudioService { } } - /** - * 设置音频输出设备 - * 使用 AudioContext.setSinkId() 而不是 HTMLMediaElement.setSinkId() - * 因为音频通过 MediaElementAudioSourceNode 进入 Web Audio 图后, - * HTMLMediaElement.setSinkId() 不再生效 - */ public async setAudioOutputDevice(deviceId: string): Promise { try { if (this.context && typeof (this.context as any).setSinkId === 'function') { @@ -963,16 +644,10 @@ class AudioService { } } - /** - * 获取当前输出设备ID - */ public getCurrentSinkId(): string { return this.currentSinkId; } - /** - * 恢复保存的音频输出设备设置 - */ private async restoreSavedAudioDevice(): Promise { const savedDeviceId = localStorage.getItem('audioOutputDeviceId'); if (savedDeviceId && savedDeviceId !== 'default') { @@ -986,17 +661,13 @@ class AudioService { } } - /** - * 设置 AudioContext 状态监控 - * 监听上下文状态变化,自动恢复 suspended 状态 - */ private setupContextStateMonitoring() { - if (!this.context || this.contextStateMonitoringInitialized) return; + if (!this.context) return; this.context.addEventListener('statechange', async () => { console.log('AudioContext state changed:', this.context?.state); - if (this.context?.state === 'suspended' && this.currentSound?.playing()) { + if (this.context?.state === 'suspended' && !this.audio.paused) { console.log('AudioContext suspended while playing, attempting to resume...'); try { await this.context.resume(); @@ -1010,118 +681,6 @@ class AudioService { this.emit('audio_error', { type: 'context_closed' }); } }); - - this.contextStateMonitoringInitialized = true; - console.log('AudioContext state monitoring initialized'); - } - - /** - * 验证音频图是否正确连接 - * 用于检测音频播放前的图状态 - */ - // 检查音频图是否连接(调试用,保留供 EQ 诊断) - // @ts-ignore 保留供调试使用 - private isAudioGraphConnected(): boolean { - if (!this.context || !this.gainNode || !this.source) { - return false; - } - - try { - // 检查 context 是否运行 - if (this.context.state !== 'running') { - console.warn('AudioContext is not running, state:', this.context.state); - return false; - } - - // Web Audio API 不直接暴露连接状态, - // 但我们可以验证节点存在且 context 有效 - return true; - } catch (e) { - console.error('Error checking audio graph:', e); - return false; - } - } - - public setPlaybackRate(rate: number) { - if (!this.currentSound) return; - this.playbackRate = rate; - - // Howler 的 rate() 在 html5 模式下不生效 - this.currentSound.rate(rate); - - // 取出底层 HTMLAudioElement,改原生 playbackRate - const sounds = (this.currentSound as any)._sounds as any[]; - sounds.forEach(({ _node }) => { - if (_node instanceof HTMLAudioElement) { - _node.playbackRate = rate; - } - }); - - // 同步给 Media Session UI - if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) { - navigator.mediaSession.setPositionState({ - duration: this.currentSound.duration(), - playbackRate: rate, - position: this.currentSound.seek() as number - }); - } - } - - public getPlaybackRate(): number { - return this.playbackRate; - } - - // 新的音量调节方法 - private applyVolume(volume: number) { - // 确保值在0到1之间 - const normalizedVolume = Math.max(0, Math.min(1, volume)); - - // 使用线性缩放音量 - const linearVolume = normalizedVolume; - - // 将音量应用到所有相关节点 - if (this.gainNode) { - // 立即设置音量 - this.gainNode.gain.cancelScheduledValues(this.context!.currentTime); - this.gainNode.gain.setValueAtTime(linearVolume, this.context!.currentTime); - } else { - this.currentSound?.volume(linearVolume); - } - - // 保存值 - localStorage.setItem('volume', linearVolume.toString()); - - console.log('Volume applied (linear):', linearVolume); - } - - // 添加方法检查当前音频是否在加载状态 - isLoading(): boolean { - if (!this.currentSound) return false; - - // 检查Howl对象的内部状态 - // 如果状态为1表示已经加载但未完成,状态为2表示正在加载 - const state = (this.currentSound as any)._state; - // 如果操作锁激活也认为是加载状态 - return this.operationLock || state === 'loading' || state === 1; - } - - // 检查音频是否真正在播放 - isActuallyPlaying(): boolean { - if (!this.currentSound) return false; - - try { - // 核心判断:Howler API 是否报告正在播放 + 音频上下文是否正常 - // 注意:不再检查 isAudioGraphConnected(),因为 EQ 重建期间 - // source/gainNode 会暂时为 null,导致误判为未播放 - const isPlaying = this.currentSound.playing(); - const isLoading = this.isLoading(); - const contextRunning = Howler.ctx && Howler.ctx.state === 'running'; - - return isPlaying && !isLoading && contextRunning; - } catch (error) { - console.error('检查播放状态出错:', error); - return false; - } } } diff --git a/src/renderer/services/playbackController.ts b/src/renderer/services/playbackController.ts new file mode 100644 index 0000000..1af5313 --- /dev/null +++ b/src/renderer/services/playbackController.ts @@ -0,0 +1,531 @@ +/** + * 播放控制器 + * + * 核心播放流程管理,使用 generation-based 取消模式替代原 playerCore.ts 中的控制流。 + * 每次 playTrack() 调用递增 generation,所有异步操作在 await 后检查 generation 是否过期。 + * + * 导出:playTrack, reparseCurrentSong, initializePlayState, setupUrlExpiredHandler, getCurrentGeneration + */ + +import { cloneDeep } from 'lodash'; +import { createDiscreteApi } from 'naive-ui'; + +import i18n from '@/../i18n/renderer'; +import { getParsingMusicUrl } from '@/api/music'; +import { loadLrc, useSongDetail } from '@/hooks/usePlayerHooks'; +import { audioService } from '@/services/audioService'; +import { playbackRequestManager } from '@/services/playbackRequestManager'; +// preloadService 用于预加载下一首的 URL 验证(triggerPreload 中使用) +import { SongSourceConfigManager } from '@/services/SongSourceConfigManager'; +import type { Platform, SongResult } from '@/types/music'; +import { getImgUrl } from '@/utils'; +import { getImageLinearBackground } from '@/utils/linearColor'; + +const { message } = createDiscreteApi(['message']); + +// Generation counter for cancellation +let generation = 0; + +/** + * 获取当前 generation(用于外部检查) + */ +export const getCurrentGeneration = (): number => generation; + +// ==================== 懒加载 Store(避免循环依赖) ==================== + +const getPlayerCoreStore = async () => { + const { usePlayerCoreStore } = await import('@/store/modules/playerCore'); + return usePlayerCoreStore(); +}; + +const getPlaylistStore = async () => { + const { usePlaylistStore } = await import('@/store/modules/playlist'); + return usePlaylistStore(); +}; + +const getPlayHistoryStore = async () => { + const { usePlayHistoryStore } = await import('@/store/modules/playHistory'); + return usePlayHistoryStore(); +}; + +const getSettingsStore = async () => { + const { useSettingsStore } = await import('@/store/modules/settings'); + return useSettingsStore(); +}; + +// ==================== 内部辅助函数 ==================== + +/** + * 加载元数据(歌词 + 背景色),并行执行 + */ +const loadMetadata = async ( + music: SongResult +): Promise<{ + lyrics: SongResult['lyric']; + backgroundColor: string; + primaryColor: string; +}> => { + const [lyrics, { backgroundColor, primaryColor }] = await Promise.all([ + (async () => { + if (music.lyric && music.lyric.lrcTimeArray.length > 0) { + return music.lyric; + } + return await loadLrc(music.id); + })(), + (async () => { + if (music.backgroundColor && music.primaryColor) { + return { backgroundColor: music.backgroundColor, primaryColor: music.primaryColor }; + } + return await getImageLinearBackground(getImgUrl(music?.picUrl, '30y30')); + })() + ]); + + return { lyrics, backgroundColor, primaryColor }; +}; + +/** + * 加载并播放音频 + */ +const loadAndPlayAudio = async (song: SongResult, shouldPlay: boolean): Promise => { + if (!song.playMusicUrl) { + throw new Error('歌曲没有播放URL'); + } + + // 检查保存的进度 + let initialPosition = 0; + const savedProgress = JSON.parse(localStorage.getItem('playProgress') || '{}'); + if (savedProgress.songId === song.id) { + initialPosition = savedProgress.progress; + console.log('[playbackController] 恢复播放进度:', initialPosition); + } + + // 直接通过 audioService 播放(单一 audio 元素,换 src 即可) + console.log(`[playbackController] 开始播放: ${song.name}`); + await audioService.play(song.playMusicUrl, song, shouldPlay, initialPosition || 0); + + // 发布音频就绪事件 + window.dispatchEvent( + new CustomEvent('audio-ready', { + detail: { sound: audioService.getCurrentSound(), shouldPlay } + }) + ); + + return true; +}; + +/** + * 触发预加载下一首/下下首歌曲 + */ +const triggerPreload = async (song: SongResult): Promise => { + try { + const playlistStore = await getPlaylistStore(); + const list = playlistStore.playList; + if (Array.isArray(list) && list.length > 0) { + const idx = list.findIndex( + (item: SongResult) => item.id === song.id && item.source === song.source + ); + if (idx !== -1) { + setTimeout(() => { + playlistStore.preloadNextSongs(idx); + }, 3000); + } + } + } catch (e) { + console.warn('预加载触发失败(可能是依赖未加载或循环依赖),已忽略:', e); + } +}; + +/** + * 更新文档标题 + */ +const updateDocumentTitle = (music: SongResult): void => { + let title = music.name; + if (music.source === 'netease' && music?.song?.artists) { + title += ` - ${music.song.artists.reduce( + (prev: string, curr: any) => `${prev}${curr.name}/`, + '' + )}`; + } + document.title = 'AlgerMusic - ' + title; +}; + +// ==================== 导出函数 ==================== + +/** + * 核心播放函数 + * + * @param music 要播放的歌曲 + * @param shouldPlay 是否立即播放(默认 true) + * @returns 是否成功 + */ +export const playTrack = async ( + music: SongResult, + shouldPlay: boolean = true +): Promise => { + // 1. 递增 generation,创建 requestId + const gen = ++generation; + const requestId = playbackRequestManager.createRequest(music); + console.log( + `[playbackController] playTrack gen=${gen}, 歌曲: ${music.name}, requestId: ${requestId}` + ); + + // 如果是新歌曲,重置已尝试的音源 + const playerCore = await getPlayerCoreStore(); + if (music.id !== playerCore.playMusic.id) { + SongSourceConfigManager.clearTriedSources(music.id); + } + + // 2. 停止当前音频 + audioService.stop(); + + // 验证 & 激活请求 + if (!playbackRequestManager.isRequestValid(requestId)) { + console.log(`[playbackController] 请求创建后即失效: ${requestId}`); + return false; + } + if (!playbackRequestManager.activateRequest(requestId)) { + console.log(`[playbackController] 无法激活请求: ${requestId}`); + return false; + } + + // 3. 更新播放意图状态(不设置 playMusic,等歌词加载完再设置以触发 watcher) + playerCore.play = shouldPlay; + playerCore.isPlay = shouldPlay; + playerCore.userPlayIntent = shouldPlay; + + // 4. 加载元数据(歌词 + 背景色) + try { + const { lyrics, backgroundColor, primaryColor } = await loadMetadata(music); + + // 检查 generation + if (gen !== generation) { + console.log(`[playbackController] gen=${gen} 已过期(加载元数据后),当前 gen=${generation}`); + return false; + } + + music.lyric = lyrics; + music.backgroundColor = backgroundColor; + music.primaryColor = primaryColor; + } catch (error) { + if (gen !== generation) return false; + console.error('[playbackController] 加载元数据失败:', error); + // 元数据加载失败不阻塞播放,继续执行 + } + + // 5. 歌词已加载,现在设置 playMusic(触发 MusicHook 的歌词 watcher) + music.playLoading = true; + playerCore.playMusic = music; + updateDocumentTitle(music); + + const originalMusic = { ...music }; + + // 5. 添加到播放历史 + try { + const playHistoryStore = await getPlayHistoryStore(); + if (music.isPodcast) { + if (music.program) { + playHistoryStore.addPodcast(music.program); + } + } else { + playHistoryStore.addMusic(music); + } + } catch (e) { + console.warn('[playbackController] 添加播放历史失败:', e); + } + + // 6. 获取歌曲详情(解析 URL) + try { + const { getSongDetail } = useSongDetail(); + const updatedPlayMusic = await getSongDetail(originalMusic, requestId); + + // 检查 generation + if (gen !== generation) { + console.log(`[playbackController] gen=${gen} 已过期(获取详情后),当前 gen=${generation}`); + return false; + } + + updatedPlayMusic.lyric = music.lyric; + playerCore.playMusic = updatedPlayMusic; + playerCore.playMusicUrl = updatedPlayMusic.playMusicUrl as string; + music.playMusicUrl = updatedPlayMusic.playMusicUrl as string; + } catch (error) { + if (gen !== generation) return false; + console.error('[playbackController] 获取歌曲详情失败:', error); + message.error(i18n.global.t('player.playFailed')); + if (playerCore.playMusic) { + playerCore.playMusic.playLoading = false; + } + playbackRequestManager.failRequest(requestId); + return false; + } + + // 7. 触发预加载下一首(异步,不阻塞) + triggerPreload(playerCore.playMusic); + + // 8. 加载并播放音频 + try { + const success = await loadAndPlayAudio(playerCore.playMusic, shouldPlay); + + // 检查 generation + if (gen !== generation) { + console.log(`[playbackController] gen=${gen} 已过期(播放音频后),当前 gen=${generation}`); + audioService.stop(); + return false; + } + + if (success) { + // 9. 播放成功 + playerCore.playMusic.playLoading = false; + playerCore.playMusic.isFirstPlay = false; + playbackRequestManager.completeRequest(requestId); + console.log(`[playbackController] gen=${gen} 播放成功: ${music.name}`); + return true; + } else { + playbackRequestManager.failRequest(requestId); + return false; + } + } catch (error) { + // 10. 播放失败 + if (gen !== generation) { + console.log(`[playbackController] gen=${gen} 已过期(播放异常),静默返回`); + return false; + } + + console.error('[playbackController] 播放音频失败:', error); + + const errorMsg = error instanceof Error ? error.message : String(error); + + // 操作锁错误:强制重置后重试一次 + if (errorMsg.includes('操作锁激活')) { + try { + audioService.forceResetOperationLock(); + console.log('[playbackController] 已强制重置操作锁'); + } catch (e) { + console.error('[playbackController] 重置操作锁失败:', e); + } + } + + message.error(i18n.global.t('player.playFailed')); + if (playerCore.playMusic) { + playerCore.playMusic.playLoading = false; + } + playerCore.setIsPlay(false); + playbackRequestManager.failRequest(requestId); + return false; + } +}; + +/** + * 使用指定音源重新解析当前歌曲 + * + * @param sourcePlatform 目标音源平台 + * @param isAuto 是否为自动切换 + * @returns 是否成功 + */ +export const reparseCurrentSong = async ( + sourcePlatform: Platform, + isAuto: boolean = false +): Promise => { + try { + const playerCore = await getPlayerCoreStore(); + const currentSong = playerCore.playMusic; + + if (!currentSong || !currentSong.id) { + console.warn('[playbackController] 没有有效的播放对象'); + return false; + } + + // 使用 SongSourceConfigManager 保存配置 + SongSourceConfigManager.setConfig(currentSong.id, [sourcePlatform], isAuto ? 'auto' : 'manual'); + + const currentSound = audioService.getCurrentSound(); + if (currentSound) { + currentSound.pause(); + } + + const numericId = + typeof currentSong.id === 'string' ? parseInt(currentSong.id, 10) : currentSong.id; + + console.log(`[playbackController] 使用音源 ${sourcePlatform} 重新解析歌曲 ${numericId}`); + + const songData = cloneDeep(currentSong); + const res = await getParsingMusicUrl(numericId, songData); + + if (res && res.data && res.data.data && res.data.data.url) { + const newUrl = res.data.data.url; + console.log(`[playbackController] 解析成功,获取新URL: ${newUrl.substring(0, 50)}...`); + + const updatedMusic: SongResult = { + ...currentSong, + playMusicUrl: newUrl, + expiredAt: Date.now() + 1800000 + }; + + await playTrack(updatedMusic, true); + + // 更新播放列表中的歌曲信息 + const playlistStore = await getPlaylistStore(); + playlistStore.updateSong(updatedMusic); + + return true; + } else { + console.warn(`[playbackController] 使用音源 ${sourcePlatform} 解析失败`); + return false; + } + } catch (error) { + console.error('[playbackController] 重新解析失败:', error); + return false; + } +}; + +/** + * 设置 URL 过期事件处理器 + * 监听 audioService 的 url_expired 事件,自动重新获取 URL 并恢复播放 + */ +export const setupUrlExpiredHandler = (): void => { + audioService.on('url_expired', async (expiredTrack: SongResult) => { + if (!expiredTrack) return; + + console.log('[playbackController] 检测到URL过期事件,准备重新获取URL', expiredTrack.name); + + const playerCore = await getPlayerCoreStore(); + + // 只在用户有播放意图或正在播放时处理 + if (!playerCore.userPlayIntent && !playerCore.play) { + console.log('[playbackController] 用户无播放意图,跳过URL过期处理'); + return; + } + + // 保存当前播放位置 + const currentSound = audioService.getCurrentSound(); + let seekPosition = 0; + if (currentSound) { + try { + seekPosition = currentSound.currentTime; + const duration = currentSound.duration; + if (duration > 0 && seekPosition > 0 && duration - seekPosition < 5) { + console.log('[playbackController] 歌曲接近末尾,跳过URL过期处理'); + return; + } + } catch { + // 静默忽略 + } + } + + try { + const trackToPlay: SongResult = { + ...expiredTrack, + isFirstPlay: true, + playMusicUrl: undefined + }; + + const success = await playTrack(trackToPlay, true); + + if (success) { + // 恢复播放位置 + if (seekPosition > 0) { + // 延迟一小段时间确保音频已就绪 + setTimeout(() => { + try { + audioService.seek(seekPosition); + } catch { + console.warn('[playbackController] 恢复播放位置失败'); + } + }, 300); + } + message.success(i18n.global.t('player.autoResumed') || '已自动恢复播放'); + } else { + // 检查歌曲是否仍然是当前歌曲 + const currentPlayerCore = await getPlayerCoreStore(); + if (currentPlayerCore.playMusic?.id === expiredTrack.id) { + message.error(i18n.global.t('player.resumeFailed') || '恢复播放失败,请手动点击播放'); + } + } + } catch (error) { + console.error('[playbackController] 处理URL过期事件失败:', error); + message.error(i18n.global.t('player.resumeFailed') || '恢复播放失败,请手动点击播放'); + } + }); +}; + +/** + * 初始化播放状态 + * 应用启动时恢复上次的播放状态 + */ +export const initializePlayState = async (): Promise => { + const playerCore = await getPlayerCoreStore(); + const settingsStore = await getSettingsStore(); + + if (!playerCore.playMusic || Object.keys(playerCore.playMusic).length === 0) { + console.log('[playbackController] 没有保存的播放状态,跳过初始化'); + // 设置播放速率 + setTimeout(() => { + audioService.setPlaybackRate(playerCore.playbackRate); + }, 2000); + return; + } + + try { + console.log('[playbackController] 恢复上次播放的音乐:', playerCore.playMusic.name); + const isPlaying = settingsStore.setData.autoPlay; + + if (!isPlaying) { + // 自动播放禁用:仅加载元数据,不播放 + console.log('[playbackController] 自动播放已禁用,仅加载元数据'); + + try { + const { lyrics, backgroundColor, primaryColor } = await loadMetadata(playerCore.playMusic); + playerCore.playMusic.lyric = lyrics; + playerCore.playMusic.backgroundColor = backgroundColor; + playerCore.playMusic.primaryColor = primaryColor; + } catch (e) { + console.warn('[playbackController] 加载元数据失败:', e); + } + + playerCore.play = false; + playerCore.isPlay = false; + playerCore.userPlayIntent = false; + + updateDocumentTitle(playerCore.playMusic); + + // 恢复上次保存的播放进度(仅UI显示) + try { + const savedProgress = JSON.parse(localStorage.getItem('playProgress') || '{}'); + if (savedProgress.songId === playerCore.playMusic.id && savedProgress.progress > 0) { + const { nowTime, allTime } = await import('@/hooks/MusicHook'); + nowTime.value = savedProgress.progress; + // 用歌曲时长设置 allTime(dt 单位是毫秒) + if (playerCore.playMusic.dt) { + allTime.value = playerCore.playMusic.dt / 1000; + } + } + } catch (e) { + console.warn('[playbackController] 恢复播放进度失败:', e); + } + } else { + // 自动播放启用:调用 playTrack 恢复播放 + // 本地音乐(local:// 协议)不需要重新获取 URL,保留原始路径 + const isLocalMusic = playerCore.playMusic.playMusicUrl?.startsWith('local://'); + + await playTrack( + { + ...playerCore.playMusic, + isFirstPlay: true, + playMusicUrl: isLocalMusic ? playerCore.playMusic.playMusicUrl : undefined + }, + true + ); + } + } catch (error) { + console.error('[playbackController] 恢复播放状态失败:', error); + playerCore.play = false; + playerCore.isPlay = false; + playerCore.playMusic = {} as SongResult; + playerCore.playMusicUrl = ''; + } + + // 延迟设置播放速率 + setTimeout(() => { + audioService.setPlaybackRate(playerCore.playbackRate); + }, 2000); +}; diff --git a/src/renderer/services/playbackRequestManager.ts b/src/renderer/services/playbackRequestManager.ts index 98c9e58..194350b 100644 --- a/src/renderer/services/playbackRequestManager.ts +++ b/src/renderer/services/playbackRequestManager.ts @@ -1,208 +1,51 @@ /** - * 播放请求管理器 - * 负责管理播放请求的队列、取消、状态跟踪,防止竞态条件 + * 薄请求 ID 追踪器 + * 用于 usePlayerHooks.ts 内部检查请求是否仍为最新。 + * 实际的取消逻辑在 playbackController.ts 中(generation ID)。 */ 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; + private counter = 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); + const requestId = `req_${Date.now()}_${++this.counter}`; this.currentRequestId = requestId; - - console.log(`[PlaybackRequestManager] 创建新请求: ${requestId}, 歌曲: ${song.name}`); - + console.log(`[RequestManager] 新请求: ${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; + isRequestValid(requestId: string): boolean { + return this.currentRequestId === requestId; } /** - * 完成请求 - * @param requestId 请求ID + * 激活请求(兼容旧调用,直接返回 isRequestValid 结果) + */ + activateRequest(requestId: string): boolean { + return this.isRequestValid(requestId); + } + + /** + * 标记请求完成 */ completeRequest(requestId: string): void { - const request = this.requestMap.get(requestId); - if (!request) { - return; - } - - request.status = RequestStatus.COMPLETED; - console.log(`[PlaybackRequestManager] 完成请求: ${requestId}`); - - // 清理旧请求(保留最近3个) - this.cleanupOldRequests(); + console.log(`[RequestManager] 完成: ${requestId}`); } /** * 标记请求失败 - * @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; + console.log(`[RequestManager] 失败: ${requestId}`); } /** @@ -211,84 +54,6 @@ class PlaybackRequestManager { 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 index 4e1630a..5514f43 100644 --- a/src/renderer/services/preloadService.ts +++ b/src/renderer/services/preloadService.ts @@ -1,152 +1,150 @@ -import { Howl } from 'howler'; - import type { SongResult } from '@/types/music'; +/** + * 预加载服务 + * + * 新架构下 audioService 使用单一 HTMLAudioElement(换歌改 src), + * 不再需要预创建 Howl 实例。PreloadService 改为验证 URL 可用性并缓存元数据。 + */ class PreloadService { - private loadingPromises: Map> = new Map(); - private preloadedSounds: Map = new Map(); + private validatedUrls: Map = new Map(); + private loadingPromises: Map> = new Map(); /** - * 加载并验证音频 - * 如果已经在加载中,返回现有的 Promise - * 如果已经加载完成,返回缓存的 Howl 实例 + * 验证歌曲 URL 可用性 + * 通过 HEAD 请求检查 URL 是否可访问,并缓存验证结果 */ - public async load(song: SongResult): Promise { + public async load(song: SongResult): Promise { if (!song || !song.id) { throw new Error('无效的歌曲对象'); } - // 1. 检查是否有正在进行的加载 + if (!song.playMusicUrl) { + throw new Error('歌曲没有 URL'); + } + + // 已验证过的 URL + if (this.validatedUrls.has(song.id)) { + console.log(`[PreloadService] 歌曲 ${song.name} URL 已验证,直接使用`); + return this.validatedUrls.get(song.id)!; + } + + // 正在验证中 if (this.loadingPromises.has(song.id)) { - console.log(`[PreloadService] 歌曲 ${song.name} 正在加载中,复用现有请求`); + 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); - } - } + console.log(`[PreloadService] 开始验证歌曲: ${song.name}`); - // 3. 开始新的加载过程 - const loadPromise = this._performLoad(song); + const url = song.playMusicUrl; + const loadPromise = this._validate(url, song); this.loadingPromises.set(song.id, loadPromise); try { - const sound = await loadPromise; - this.preloadedSounds.set(song.id, sound); - return sound; + const validatedUrl = await loadPromise; + this.validatedUrls.set(song.id, validatedUrl); + return validatedUrl; } finally { this.loadingPromises.delete(song.id); } } /** - * 执行实际的加载和验证逻辑 + * 验证 URL 可用性(通过创建临时 Audio 元素检测是否可加载) */ - private async _performLoad(song: SongResult): Promise { - console.log(`[PreloadService] 开始加载歌曲: ${song.name}`); + private async _validate(url: string, song: SongResult): Promise { + return new Promise((resolve, reject) => { + const testAudio = new Audio(); + testAudio.crossOrigin = 'anonymous'; + testAudio.preload = 'metadata'; - if (!song.playMusicUrl) { - throw new Error('歌曲没有 URL'); - } + const cleanup = () => { + testAudio.removeEventListener('loadedmetadata', onLoaded); + testAudio.removeEventListener('error', onError); + testAudio.src = ''; + testAudio.load(); + }; - // 创建初始音频实例 - const sound = await this._createSound(song.playMusicUrl); + const onLoaded = () => { + // 检查时长 + const duration = testAudio.duration; + const expectedDuration = (song.dt || 0) / 1000; - // 检查时长 - const duration = sound.duration(); - const expectedDuration = (song.dt || 0) / 1000; + if (expectedDuration > 0 && duration > 0 && isFinite(duration)) { + 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 + } + }) + ); + } + } - 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})` - ); - } - } + cleanup(); + resolve(url); + }; - return sound; - } + const onError = () => { + cleanup(); + reject(new Error(`URL 验证失败: ${song.name}`)); + }; - 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) - }); + testAudio.addEventListener('loadedmetadata', onLoaded); + testAudio.addEventListener('error', onError); + testAudio.src = url; + testAudio.load(); + + // 5秒超时 + setTimeout(() => { + cleanup(); + // 超时不算失败,URL 可能是可用的只是服务器慢 + resolve(url); + }, 5000); }); } /** - * 取消特定歌曲的预加载(如果可能) - * 注意:Promise 无法真正取消,但我们可以清理结果 + * 消耗已验证的 URL(从缓存移除) */ - 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); - } - - /** - * 消耗(使用)已预加载的音频 - * 从缓存中移除但不 unload(由调用方管理生命周期) - * @returns 预加载的 Howl 实例,如果没有则返回 undefined - */ - public consume(songId: string | number): Howl | undefined { - const sound = this.preloadedSounds.get(songId); - if (sound) { - this.preloadedSounds.delete(songId); - console.log(`[PreloadService] 消耗预加载的歌曲: ${songId}`); - return sound; + public consume(songId: string | number): string | undefined { + const url = this.validatedUrls.get(songId); + if (url) { + this.validatedUrls.delete(songId); + console.log(`[PreloadService] 消耗预验证的歌曲: ${songId}`); + return url; } return undefined; } /** - * 清理所有预加载资源 + * 取消预加载 + */ + public cancel(songId: string | number) { + this.validatedUrls.delete(songId); + } + + /** + * 获取已验证的 URL + */ + public getPreloadedSound(songId: string | number): string | undefined { + return this.validatedUrls.get(songId); + } + + /** + * 清理所有缓存 */ public clearAll() { - this.preloadedSounds.forEach((sound) => sound.unload()); - this.preloadedSounds.clear(); + this.validatedUrls.clear(); this.loadingPromises.clear(); } } diff --git a/src/renderer/store/modules/intelligenceMode.ts b/src/renderer/store/modules/intelligenceMode.ts index 2ba0dda..3654718 100644 --- a/src/renderer/store/modules/intelligenceMode.ts +++ b/src/renderer/store/modules/intelligenceMode.ts @@ -28,11 +28,9 @@ export const useIntelligenceModeStore = defineStore('intelligenceMode', () => { */ const playIntelligenceMode = async () => { const { useUserStore } = await import('./user'); - const { usePlayerCoreStore } = await import('./playerCore'); const { usePlaylistStore } = await import('./playlist'); const userStore = useUserStore(); - const playerCore = usePlayerCoreStore(); const playlistStore = usePlaylistStore(); const { t } = i18n.global; @@ -101,7 +99,8 @@ export const useIntelligenceModeStore = defineStore('intelligenceMode', () => { // 替换播放列表并开始播放 playlistStore.setPlayList(intelligenceSongs, false, true); - await playerCore.handlePlayMusic(intelligenceSongs[0], true); + const { playTrack } = await import('@/services/playbackController'); + await playTrack(intelligenceSongs[0], true); } else { message.error(t('player.playBar.intelligenceMode.failed')); } diff --git a/src/renderer/store/modules/player.ts b/src/renderer/store/modules/player.ts index 739e64c..1f7cc85 100644 --- a/src/renderer/store/modules/player.ts +++ b/src/renderer/store/modules/player.ts @@ -75,7 +75,8 @@ export const usePlayerStore = defineStore('player', () => { const playHistoryStore = usePlayHistoryStore(); playHistoryStore.migrateFromLocalStorage(); - await playerCore.initializePlayState(); + const { initializePlayState: initPlayState } = await import('@/services/playbackController'); + await initPlayState(); await playlist.initializePlaylist(); }; @@ -112,11 +113,7 @@ export const usePlayerStore = defineStore('player', () => { getVolume: playerCore.getVolume, increaseVolume: playerCore.increaseVolume, decreaseVolume: playerCore.decreaseVolume, - handlePlayMusic: playerCore.handlePlayMusic, - playAudio: playerCore.playAudio, handlePause: playerCore.handlePause, - checkPlaybackState: playerCore.checkPlaybackState, - reparseCurrentSong: playerCore.reparseCurrentSong, // ========== 播放列表管理 (Playlist) ========== playList, diff --git a/src/renderer/store/modules/playerCore.ts b/src/renderer/store/modules/playerCore.ts index 893681e..aa8f967 100644 --- a/src/renderer/store/modules/playerCore.ts +++ b/src/renderer/store/modules/playerCore.ts @@ -1,23 +1,9 @@ -import { cloneDeep } from 'lodash'; -import { createDiscreteApi } from 'naive-ui'; import { defineStore } from 'pinia'; import { computed, ref } from 'vue'; -import i18n from '@/../i18n/renderer'; -import { getParsingMusicUrl } from '@/api/music'; -import { useLyrics, useSongDetail } from '@/hooks/usePlayerHooks'; import { audioService } from '@/services/audioService'; -import { playbackRequestManager } from '@/services/playbackRequestManager'; -import { preloadService } from '@/services/preloadService'; -import { SongSourceConfigManager } from '@/services/SongSourceConfigManager'; import type { AudioOutputDevice } from '@/types/audio'; -import type { Platform, SongResult } from '@/types/music'; -import { getImgUrl } from '@/utils'; -import { getImageLinearBackground } from '@/utils/linearColor'; - -import { usePlayHistoryStore } from './playHistory'; - -const { message } = createDiscreteApi(['message']); +import type { SongResult } from '@/types/music'; /** * 核心播放控制 Store @@ -43,10 +29,6 @@ export const usePlayerCoreStore = defineStore( ); const availableAudioDevices = ref([]); - let checkPlayTime: NodeJS.Timeout | null = null; - let checkPlaybackRetryCount = 0; - const MAX_CHECKPLAYBACK_RETRIES = 3; - // ==================== Computed ==================== const currentSong = computed(() => playMusic.value); const isPlaying = computed(() => isPlay.value); @@ -109,413 +91,6 @@ export const usePlayerCoreStore = defineStore( return newVolume; }; - /** - * 播放状态检测 - * 在播放开始后延迟检查音频是否真正在播放,防止无声播放 - */ - const checkPlaybackState = (song: SongResult, requestId?: string, timeout: number = 6000) => { - if (checkPlayTime) { - clearTimeout(checkPlayTime); - } - const sound = audioService.getCurrentSound(); - if (!sound) return; - - // 如果没有提供 requestId,创建一个临时标识 - const actualRequestId = requestId || `check_${Date.now()}`; - - const onPlayHandler = () => { - console.log(`[${actualRequestId}] 播放事件触发,歌曲成功开始播放`); - audioService.off('play', onPlayHandler); - audioService.off('playerror', onPlayErrorHandler); - checkPlaybackRetryCount = 0; // 播放成功,重置重试计数 - if (checkPlayTime) { - clearTimeout(checkPlayTime); - checkPlayTime = null; - } - }; - - const onPlayErrorHandler = async () => { - console.log('播放错误事件触发,检查是否需要重新获取URL'); - audioService.off('play', onPlayHandler); - audioService.off('playerror', onPlayErrorHandler); - - // 如果有 requestId,验证其有效性 - if (requestId && !playbackRequestManager.isRequestValid(requestId)) { - console.log('请求已过期,跳过重试'); - return; - } - - // 检查重试次数限制 - if (checkPlaybackRetryCount >= MAX_CHECKPLAYBACK_RETRIES) { - console.warn(`播放重试已达上限 (${MAX_CHECKPLAYBACK_RETRIES} 次),停止重试`); - checkPlaybackRetryCount = 0; - setPlayMusic(false); - return; - } - - if (userPlayIntent.value && play.value) { - checkPlaybackRetryCount++; - console.log( - `播放失败,尝试刷新URL并重新播放 (重试 ${checkPlaybackRetryCount}/${MAX_CHECKPLAYBACK_RETRIES})` - ); - // 本地音乐不需要刷新 URL - if (!playMusic.value.playMusicUrl?.startsWith('local://')) { - playMusic.value.playMusicUrl = undefined; - } - const refreshedSong = { ...song, isFirstPlay: true }; - await handlePlayMusic(refreshedSong, true); - } - }; - - audioService.on('play', onPlayHandler); - audioService.on('playerror', onPlayErrorHandler); - - checkPlayTime = setTimeout(() => { - // 如果有 requestId,验证其有效性 - if (requestId && !playbackRequestManager.isRequestValid(requestId)) { - console.log('请求已过期,跳过超时重试'); - audioService.off('play', onPlayHandler); - audioService.off('playerror', onPlayErrorHandler); - 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) { - audioService.off('play', onPlayHandler); - audioService.off('playerror', onPlayErrorHandler); - - // 检查重试次数限制 - if (checkPlaybackRetryCount >= MAX_CHECKPLAYBACK_RETRIES) { - console.warn(`超时重试已达上限 (${MAX_CHECKPLAYBACK_RETRIES} 次),停止重试`); - checkPlaybackRetryCount = 0; - setPlayMusic(false); - return; - } - - checkPlaybackRetryCount++; - console.log( - `${timeout}ms后歌曲未真正播放,尝试重新获取URL (重试 ${checkPlaybackRetryCount}/${MAX_CHECKPLAYBACK_RETRIES})` - ); - - // 本地音乐不需要刷新 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); - }; - - /** - * 核心播放处理函数 - */ - const handlePlayMusic = async (music: SongResult, shouldPlay: boolean = true) => { - // 如果是新歌曲,重置已尝试的音源和重试计数 - if (music.id !== playMusic.value.id) { - SongSourceConfigManager.clearTriedSources(music.id); - checkPlaybackRetryCount = 0; - } - - // 创建新的播放请求并取消之前的所有请求 - const requestId = playbackRequestManager.createRequest(music); - console.log(`[handlePlayMusic] 开始处理歌曲: ${music.name}, 请求ID: ${requestId}`); - - const currentSound = audioService.getCurrentSound(); - if (currentSound) { - console.log('主动停止并卸载当前音频实例'); - currentSound.stop(); - 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(); - - // 并行加载歌词和背景色 - const [lyrics, { backgroundColor, primaryColor }] = await Promise.all([ - (async () => { - if (music.lyric && music.lyric.lrcTimeArray.length > 0) { - return music.lyric; - } - return await loadLrc(music.id); - })(), - (async () => { - if (music.backgroundColor && music.primaryColor) { - return { backgroundColor: music.backgroundColor, primaryColor: music.primaryColor }; - } - return await getImageLinearBackground(getImgUrl(music?.picUrl, '30y30')); - })() - ]); - - // 在更新状态前再次验证请求 - if (!playbackRequestManager.isRequestValid(requestId)) { - console.log(`[handlePlayMusic] 加载歌词/背景色后请求已失效: ${requestId}`); - return false; - } - - // 设置歌词和背景色 - music.lyric = lyrics; - music.backgroundColor = backgroundColor; - music.primaryColor = primaryColor; - music.playLoading = true; - - // 更新 playMusic 和播放状态 - playMusic.value = music; - play.value = shouldPlay; - isPlay.value = shouldPlay; - userPlayIntent.value = shouldPlay; - - // 更新标题 - let title = music.name; - if (music.source === 'netease' && music?.song?.artists) { - title += ` - ${music.song.artists.reduce( - (prev: string, curr: any) => `${prev}${curr.name}/`, - '' - )}`; - } - document.title = 'AlgerMusic - ' + title; - - try { - // 添加到历史记录 - const playHistoryStore = usePlayHistoryStore(); - if (music.isPodcast) { - if (music.program) { - playHistoryStore.addPodcast(music.program); - } - } else { - playHistoryStore.addMusic(music); - } - - // 获取歌曲详情 - 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; - playMusicUrl.value = updatedPlayMusic.playMusicUrl as string; - music.playMusicUrl = updatedPlayMusic.playMusicUrl as string; - - // 在拆分后补充:触发预加载下一首/下下首(与 playlist store 保持一致) - try { - const { usePlaylistStore } = await import('./playlist'); - const playlistStore = usePlaylistStore(); - // 基于当前歌曲在播放列表中的位置来预加载 - const list = playlistStore.playList; - if (Array.isArray(list) && list.length > 0) { - const idx = list.findIndex( - (item: SongResult) => - item.id === updatedPlayMusic.id && item.source === updatedPlayMusic.source - ); - if (idx !== -1) { - setTimeout(() => { - playlistStore.preloadNextSongs(idx); - }, 3000); - } - } - } catch (e) { - console.warn('预加载触发失败(可能是依赖未加载或循环依赖),已忽略:', e); - } - - try { - const result = await playAudio(requestId); - - if (result) { - // 播放成功,清除 isFirstPlay 标记,避免暂停时被误判为新歌 - playMusic.value.isFirstPlay = false; - playbackRequestManager.completeRequest(requestId); - return true; - } else { - playbackRequestManager.failRequest(requestId); - return false; - } - } catch (error) { - console.error('自动播放音频失败:', error); - playbackRequestManager.failRequest(requestId); - return false; - } - } catch (error) { - console.error('处理播放音乐失败:', error); - message.error(i18n.global.t('player.playFailed')); - if (playMusic.value) { - playMusic.value.playLoading = false; - } - playbackRequestManager.failRequest(requestId); - - return false; - } - }; - - /** - * 播放音频 - */ - 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 ? '播放' : '暂停'); - - // 检查保存的进度 - let initialPosition = 0; - const savedProgress = JSON.parse(localStorage.getItem('playProgress') || '{}'); - console.log( - '[playAudio] 读取保存的进度:', - savedProgress, - '当前歌曲ID:', - playMusic.value.id - ); - if (savedProgress.songId === playMusic.value.id) { - initialPosition = savedProgress.progress; - console.log('[playAudio] 恢复播放进度:', initialPosition); - } - - // 使用 PreloadService 获取音频 - // 优先使用已预加载的 sound(通过 consume 获取并从缓存中移除) - // 如果没有预加载,则进行加载 - let sound: Howl; - try { - // 先尝试消耗预加载的 sound - const preloadedSound = preloadService.consume(playMusic.value.id); - if (preloadedSound && preloadedSound.state() === 'loaded') { - console.log(`[playAudio] 使用预加载的音频: ${playMusic.value.name}`); - sound = preloadedSound; - } else { - // 没有预加载或预加载状态不正常,需要加载 - console.log(`[playAudio] 没有预加载,开始加载: ${playMusic.value.name}`); - 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, - sound - ); - - // 播放后再次验证请求 - if (requestId && !playbackRequestManager.isRequestValid(requestId)) { - console.log(`[playAudio] 播放后请求已失效: ${requestId}`); - newSound.stop(); - newSound.unload(); - return null; - } - - // 添加播放状态检测 - if (shouldPlay && requestId) { - checkPlaybackState(playMusic.value, requestId); - } - - // 发布音频就绪事件 - window.dispatchEvent( - new CustomEvent('audio-ready', { detail: { sound: newSound, shouldPlay } }) - ); - - // 时长检查已在 preloadService.ts 中完成 - - return newSound; - } catch (error) { - console.error('播放音频失败:', error); - - const errorMsg = error instanceof Error ? error.message : String(error); - - // 操作锁错误不应该停止播放状态,只需要重试 - if (errorMsg.includes('操作锁激活')) { - console.log('由于操作锁正在使用,将在1000ms后重试'); - - try { - audioService.forceResetOperationLock(); - console.log('已强制重置操作锁'); - } catch (e) { - console.error('重置操作锁失败:', e); - } - - setTimeout(() => { - // 验证请求是否仍然有效再重试 - if (requestId && !playbackRequestManager.isRequestValid(requestId)) { - console.log('重试时请求已失效,跳过重试'); - return; - } - 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')); - } - - return null; - } - }; - /** * 暂停播放 */ @@ -540,109 +115,14 @@ export const usePlayerCoreStore = defineStore( setIsPlay(value); userPlayIntent.value = value; } else { - await handlePlayMusic(value); + const { playTrack } = await import('@/services/playbackController'); + await playTrack(value); play.value = true; isPlay.value = true; userPlayIntent.value = true; } }; - /** - * 使用指定音源重新解析当前歌曲 - */ - const reparseCurrentSong = async (sourcePlatform: Platform, isAuto: boolean = false) => { - try { - const currentSong = playMusic.value; - if (!currentSong || !currentSong.id) { - console.warn('没有有效的播放对象'); - return false; - } - - // 使用 SongSourceConfigManager 保存配置 - SongSourceConfigManager.setConfig( - currentSong.id, - [sourcePlatform], - isAuto ? 'auto' : 'manual' - ); - - const currentSound = audioService.getCurrentSound(); - if (currentSound) { - currentSound.pause(); - } - - const numericId = - typeof currentSong.id === 'string' ? parseInt(currentSong.id, 10) : currentSong.id; - - console.log(`使用音源 ${sourcePlatform} 重新解析歌曲 ${numericId}`); - - const songData = cloneDeep(currentSong); - const res = await getParsingMusicUrl(numericId, songData); - - if (res && res.data && res.data.data && res.data.data.url) { - const newUrl = res.data.data.url; - console.log(`解析成功,获取新URL: ${newUrl.substring(0, 50)}...`); - - const updatedMusic = { - ...currentSong, - playMusicUrl: newUrl, - expiredAt: Date.now() + 1800000 - }; - - await handlePlayMusic(updatedMusic, true); - - // 更新播放列表中的歌曲信息 - const { usePlaylistStore } = await import('./playlist'); - const playlistStore = usePlaylistStore(); - playlistStore.updateSong(updatedMusic); - - return true; - } else { - console.warn(`使用音源 ${sourcePlatform} 解析失败`); - return false; - } - } catch (error) { - console.error('重新解析失败:', error); - return false; - } - }; - - /** - * 初始化播放状态 - */ - const initializePlayState = async () => { - const { useSettingsStore } = await import('./settings'); - const settingStore = useSettingsStore(); - - if (playMusic.value && Object.keys(playMusic.value).length > 0) { - try { - 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: isLocalMusic ? playMusic.value.playMusicUrl : undefined - }, - isPlaying - ); - } catch (error) { - console.error('重新获取音乐链接失败:', error); - play.value = false; - isPlay.value = false; - playMusic.value = {} as SongResult; - playMusicUrl.value = ''; - } - } - - setTimeout(() => { - audioService.setPlaybackRate(playbackRate.value); - }, 2000); - }; - // ==================== 音频输出设备管理 ==================== /** @@ -707,12 +187,7 @@ export const usePlayerCoreStore = defineStore( getVolume, increaseVolume, decreaseVolume, - handlePlayMusic, - playAudio, handlePause, - checkPlaybackState, - reparseCurrentSong, - initializePlayState, refreshAudioDevices, setAudioOutputDevice, initAudioDeviceListener diff --git a/src/renderer/store/modules/playlist.ts b/src/renderer/store/modules/playlist.ts index c058ac2..194bacb 100644 --- a/src/renderer/store/modules/playlist.ts +++ b/src/renderer/store/modules/playlist.ts @@ -87,7 +87,6 @@ export const usePlaylistStore = defineStore( // 连续失败计数器(用于防止无限循环) const consecutiveFailCount = ref(0); const MAX_CONSECUTIVE_FAILS = 5; // 最大连续失败次数 - const SINGLE_TRACK_MAX_RETRIES = 3; // 单曲最大重试次数 // ==================== Computed ==================== const currentPlayList = computed(() => playList.value); @@ -416,103 +415,104 @@ export const usePlaylistStore = defineStore( } }; - /** - * 下一首 - * @param singleTrackRetryCount 单曲重试次数(同一首歌的重试) - */ - const _nextPlay = async (singleTrackRetryCount: number = 0) => { + let nextPlayRetryTimer: ReturnType | null = null; + + const cancelRetryTimer = () => { + if (nextPlayRetryTimer) { + clearTimeout(nextPlayRetryTimer); + nextPlayRetryTimer = null; + } + }; + + const _nextPlay = async (retryCount: number = 0, autoEnd: boolean = false) => { try { - if (playList.value.length === 0) { - return; + if (playList.value.length === 0) return; + + // User-initiated (retryCount=0): reset state + if (retryCount === 0) { + cancelRetryTimer(); + consecutiveFailCount.value = 0; } const playerCore = usePlayerCoreStore(); const sleepTimerStore = useSleepTimerStore(); - // 检查是否超过最大连续失败次数 if (consecutiveFailCount.value >= MAX_CONSECUTIVE_FAILS) { - console.error(`[nextPlay] 连续${MAX_CONSECUTIVE_FAILS}首歌曲播放失败,停止播放`); + console.error(`[nextPlay] 连续${MAX_CONSECUTIVE_FAILS}首播放失败,停止`); getMessage().warning(i18n.global.t('player.consecutiveFailsError')); - consecutiveFailCount.value = 0; // 重置计数器 + consecutiveFailCount.value = 0; playerCore.setIsPlay(false); return; } - // 顺序播放模式:播放到最后一首后停止 + // Sequential mode: at the last song if (playMode.value === 0 && playListIndex.value >= playList.value.length - 1) { - if (sleepTimerStore.sleepTimer.type === 'end') { - sleepTimerStore.stopPlayback(); + if (autoEnd) { + // 歌曲自然播放结束:停止播放 + console.log('[nextPlay] 顺序播放:最后一首播放完毕,停止'); + if (sleepTimerStore.sleepTimer.type === 'end') { + sleepTimerStore.stopPlayback(); + } + getMessage().info(i18n.global.t('player.playListEnded')); + playerCore.setIsPlay(false); + const { audioService } = await import('@/services/audioService'); + audioService.pause(); + } else { + // 用户手动点击下一首:保持当前播放,只提示 + console.log('[nextPlay] 顺序播放:已是最后一首,保持当前播放'); + getMessage().info(i18n.global.t('player.playListEnded')); } - console.log('[nextPlay] 顺序播放模式:已播放到最后一首,停止播放'); - playerCore.setIsPlay(false); - const { audioService } = await import('@/services/audioService'); - audioService.pause(); return; } - const currentIndex = playListIndex.value; const nowPlayListIndex = (playListIndex.value + 1) % playList.value.length; const nextSong = { ...playList.value[nowPlayListIndex] }; - // 同一首歌重试时强制刷新在线 URL,避免卡在失效链接上 - if (singleTrackRetryCount > 0 && !nextSong.playMusicUrl?.startsWith('local://')) { + // Force refresh URL on retry + if (retryCount > 0 && !nextSong.playMusicUrl?.startsWith('local://')) { nextSong.playMusicUrl = undefined; nextSong.expiredAt = undefined; } console.log( - `[nextPlay] 尝试播放: ${nextSong.name}, 索引: ${currentIndex} -> ${nowPlayListIndex}, 单曲重试: ${singleTrackRetryCount}/${SINGLE_TRACK_MAX_RETRIES}, 连续失败: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}` - ); - console.log( - '[nextPlay] Current mode:', - playMode.value, - 'Playlist length:', - playList.value.length + `[nextPlay] ${nextSong.name}, 索引: ${playListIndex.value} -> ${nowPlayListIndex}, 重试: ${retryCount}/1` ); - // 先尝试播放歌曲 - const success = await playerCore.handlePlayMusic(nextSong, true); + const { playTrack } = await import('@/services/playbackController'); + const success = await playTrack(nextSong, true); + + // Check if we were superseded by a newer operation + if (playerCore.playMusic.id !== nextSong.id) { + console.log('[nextPlay] 被新操作取代,静默退出'); + return; + } if (success) { - // 播放成功,重置所有计数器并更新索引 consecutiveFailCount.value = 0; playListIndex.value = nowPlayListIndex; - console.log(`[nextPlay] 播放成功,索引已更新为: ${nowPlayListIndex}`); - console.log( - '[nextPlay] New current song in list:', - playList.value[playListIndex.value]?.name - ); + console.log(`[nextPlay] 播放成功,索引: ${nowPlayListIndex}`); sleepTimerStore.handleSongChange(); } else { - console.error(`[nextPlay] 播放失败: ${nextSong.name}`); - - // 单曲重试逻辑 - if (singleTrackRetryCount < SINGLE_TRACK_MAX_RETRIES) { - console.log( - `[nextPlay] 单曲重试 ${singleTrackRetryCount + 1}/${SINGLE_TRACK_MAX_RETRIES}` - ); - // 不更新索引,重试同一首歌 - setTimeout(() => { - _nextPlay(singleTrackRetryCount + 1); + // Retry once, then skip to next + if (retryCount < 1) { + console.log(`[nextPlay] 播放失败,1秒后重试`); + nextPlayRetryTimer = setTimeout(() => { + nextPlayRetryTimer = null; + _nextPlay(retryCount + 1); }, 1000); } else { - // 单曲重试次数用尽,递增连续失败计数,尝试下一首 consecutiveFailCount.value++; console.log( - `[nextPlay] 单曲重试用尽,连续失败计数: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}` + `[nextPlay] 重试用尽,连续失败: ${consecutiveFailCount.value}/${MAX_CONSECUTIVE_FAILS}` ); - if (playList.value.length > 1) { - // 更新索引到失败的歌曲位置,这样下次递归调用会继续往下 playListIndex.value = nowPlayListIndex; getMessage().warning(i18n.global.t('player.parseFailedPlayNext')); - - // 延迟后尝试下一首(重置单曲重试计数) - setTimeout(() => { + nextPlayRetryTimer = setTimeout(() => { + nextPlayRetryTimer = null; _nextPlay(0); }, 500); } else { - // 只有一首歌且失败 getMessage().error(i18n.global.t('player.playFailed')); playerCore.setIsPlay(false); } @@ -525,73 +525,33 @@ export const usePlaylistStore = defineStore( const nextPlay = useThrottleFn(_nextPlay, 500); - /** - * 上一首 - */ + /** 歌曲自然播放结束时调用,顺序模式最后一首会停止 */ + const nextPlayOnEnd = () => { + _nextPlay(0, true); + }; + const _prevPlay = async () => { try { - if (playList.value.length === 0) { - return; - } + if (playList.value.length === 0) return; + cancelRetryTimer(); const playerCore = usePlayerCoreStore(); - const currentIndex = playListIndex.value; const nowPlayListIndex = (playListIndex.value - 1 + playList.value.length) % playList.value.length; - const prevSong = { ...playList.value[nowPlayListIndex] }; console.log( - `[prevPlay] 尝试播放上一首: ${prevSong.name}, 索引: ${currentIndex} -> ${nowPlayListIndex}` + `[prevPlay] ${prevSong.name}, 索引: ${playListIndex.value} -> ${nowPlayListIndex}` ); - let success = false; - let retryCount = 0; - const maxRetries = 2; - - // 先尝试播放歌曲,成功后再更新索引 - while (!success && retryCount < maxRetries) { - success = await playerCore.handlePlayMusic(prevSong); - - if (!success) { - retryCount++; - console.error(`播放上一首失败,尝试 ${retryCount}/${maxRetries}`); - - if (retryCount >= maxRetries) { - console.error('多次尝试播放失败,将从播放列表中移除此歌曲'); - const newPlayList = [...playList.value]; - newPlayList.splice(nowPlayListIndex, 1); - - if (newPlayList.length > 0) { - const keepCurrentIndexPosition = true; - setPlayList(newPlayList, keepCurrentIndexPosition); - - if (newPlayList.length === 1) { - playListIndex.value = 0; - } else { - const newPrevIndex = - (playListIndex.value - 1 + newPlayList.length) % newPlayList.length; - playListIndex.value = newPrevIndex; - } - - setTimeout(() => { - prevPlay(); - }, 300); - return; - } else { - console.error('播放列表为空,停止尝试'); - break; - } - } - } - } + const { playTrack } = await import('@/services/playbackController'); + const success = await playTrack(prevSong); if (success) { - // 播放成功,更新索引 playListIndex.value = nowPlayListIndex; - console.log(`[prevPlay] 播放成功,索引已更新为: ${nowPlayListIndex}`); - } else { - console.error(`[prevPlay] 播放上一首失败,保持当前索引: ${currentIndex}`); + console.log(`[prevPlay] 播放成功,索引: ${nowPlayListIndex}`); + } else if (playerCore.playMusic.id === prevSong.id) { + // Only show error if not superseded playerCore.setIsPlay(false); getMessage().error(i18n.global.t('player.playFailed')); } @@ -609,16 +569,12 @@ export const usePlaylistStore = defineStore( playListDrawerVisible.value = value; }; - /** - * 设置播放(兼容旧API) - */ const setPlay = async (song: SongResult) => { try { const playerCore = usePlayerCoreStore(); - // 检查URL是否已过期 + // Check URL expiration if (song.expiredAt && song.expiredAt < Date.now()) { - // 本地音乐(local:// 协议)不会过期 if (!song.playMusicUrl?.startsWith('local://')) { console.info(`歌曲URL已过期,重新获取: ${song.name}`); song.playMusicUrl = undefined; @@ -626,7 +582,7 @@ export const usePlaylistStore = defineStore( } } - // 如果是当前正在播放的音乐,则切换播放/暂停状态 + // Toggle play/pause for current song if ( playerCore.playMusic.id === song.id && playerCore.playMusic.playMusicUrl === song.playMusicUrl && @@ -644,10 +600,9 @@ export const usePlaylistStore = defineStore( const sound = audioService.getCurrentSound(); if (sound) { sound.play(); - // 在恢复播放时也进行状态检测,防止URL已过期导致无声 - playerCore.checkPlaybackState(playerCore.playMusic); } else { - console.warn('[PlaylistStore.setPlay] 无可用音频实例,尝试重建播放链路'); + // No audio instance, rebuild via playTrack + const { playTrack } = await import('@/services/playbackController'); const recoverSong = { ...playerCore.playMusic, isFirstPlay: true, @@ -655,7 +610,7 @@ export const usePlaylistStore = defineStore( ? playerCore.playMusic.playMusicUrl : undefined }; - const recovered = await playerCore.handlePlayMusic(recoverSong, true); + const recovered = await playTrack(recoverSong, true); if (!recovered) { playerCore.setIsPlay(false); getMessage().error(i18n.global.t('player.playFailed')); @@ -665,33 +620,24 @@ export const usePlaylistStore = defineStore( return; } - if (song.isFirstPlay) { - song.isFirstPlay = false; - } + if (song.isFirstPlay) song.isFirstPlay = false; - // 查找歌曲在播放列表中的索引 + // Update playlist index const songIndex = playList.value.findIndex( (item: SongResult) => item.id === song.id && item.source === song.source ); - - // 更新播放索引 if (songIndex !== -1 && songIndex !== playListIndex.value) { console.log('歌曲索引不匹配,更新为:', songIndex); playListIndex.value = songIndex; } - const success = await playerCore.handlePlayMusic(song); - - // playerCore 的状态由其自己的 store 管理 + const { playTrack } = await import('@/services/playbackController'); + const success = await playTrack(song); if (success) { playerCore.isPlay = true; - - // 预加载下一首歌曲 if (songIndex !== -1) { - setTimeout(() => { - preloadNextSongs(playListIndex.value); - }, 3000); + setTimeout(() => preloadNextSongs(playListIndex.value), 3000); } } return success; @@ -740,6 +686,7 @@ export const usePlaylistStore = defineStore( restoreOriginalOrder, preloadNextSongs, nextPlay: nextPlay as unknown as typeof _nextPlay, + nextPlayOnEnd, prevPlay: prevPlay as unknown as typeof _prevPlay, setPlayListDrawerVisible, setPlay, diff --git a/src/renderer/views/album/index.vue b/src/renderer/views/album/index.vue index 755861d..2259a55 100644 --- a/src/renderer/views/album/index.vue +++ b/src/renderer/views/album/index.vue @@ -99,9 +99,9 @@ import { useRoute, useRouter } from 'vue-router'; import { getNewAlbums } from '@/api/album'; import { getAlbum } from '@/api/list'; -import StickyTabPage from '@/components/common/StickyTabPage.vue'; import { navigateToMusicList } from '@/components/common/MusicListNavigator'; -import { usePlayerCoreStore } from '@/store/modules/playerCore'; +import StickyTabPage from '@/components/common/StickyTabPage.vue'; +import { playTrack } from '@/services/playbackController'; import { usePlaylistStore } from '@/store/modules/playlist'; import { calculateAnimationDelay, getImgUrl } from '@/utils'; @@ -213,7 +213,6 @@ const playAlbum = async (album: any) => { try { const { data } = await getAlbum(album.id); if (data.code === 200 && data.songs?.length > 0) { - const playerCore = usePlayerCoreStore(); const playlistStore = usePlaylistStore(); const albumCover = data.album?.picUrl || album.picUrl; @@ -228,7 +227,7 @@ const playAlbum = async (album: any) => { })); playlistStore.setPlayList(playlist, false, false); - await playerCore.handlePlayMusic(playlist[0], true); + await playTrack(playlist[0], true); } } catch (error) { console.error('Failed to play album:', error); diff --git a/src/renderer/views/home/components/HomeAlbumSection.vue b/src/renderer/views/home/components/HomeAlbumSection.vue index 7ff739c..d6a8572 100644 --- a/src/renderer/views/home/components/HomeAlbumSection.vue +++ b/src/renderer/views/home/components/HomeAlbumSection.vue @@ -61,7 +61,7 @@ import { useRouter } from 'vue-router'; import { getTopAlbum } from '@/api/home'; import { getAlbum } from '@/api/list'; import { navigateToMusicList } from '@/components/common/MusicListNavigator'; -import { usePlayerCoreStore } from '@/store/modules/playerCore'; +import { playTrack } from '@/services/playbackController'; import { usePlaylistStore } from '@/store/modules/playlist'; import { calculateAnimationDelay, isElectron, isMobile } from '@/utils'; @@ -178,7 +178,6 @@ const playAlbum = async (album: any) => { try { const { data } = await getAlbum(album.id); if (data.code === 200 && data.songs?.length > 0) { - const playerCore = usePlayerCoreStore(); const playlistStore = usePlaylistStore(); const albumCover = data.album?.picUrl || album.picUrl; @@ -193,7 +192,7 @@ const playAlbum = async (album: any) => { })); playlistStore.setPlayList(playlist, false, false); - await playerCore.handlePlayMusic(playlist[0], true); + await playTrack(playlist[0], true); } } catch (error) { console.error('Failed to play album:', error); diff --git a/src/renderer/views/home/components/HomeDailyRecommend.vue b/src/renderer/views/home/components/HomeDailyRecommend.vue index 8bf890c..cc30ddc 100644 --- a/src/renderer/views/home/components/HomeDailyRecommend.vue +++ b/src/renderer/views/home/components/HomeDailyRecommend.vue @@ -146,10 +146,9 @@ const getArtistNames = (song: any) => { }; const handleSongClick = async (_song: any, index: number) => { - const { usePlayerCoreStore } = await import('@/store/modules/playerCore'); const { usePlaylistStore } = await import('@/store/modules/playlist'); + const { playTrack } = await import('@/services/playbackController'); - const playerCore = usePlayerCoreStore(); const playlistStore = usePlaylistStore(); const playlist = songs.value.map((s: any) => ({ @@ -163,16 +162,15 @@ const handleSongClick = async (_song: any, index: number) => { })); playlistStore.setPlayList(playlist, false, false); - await playerCore.handlePlayMusic(playlist[index], true); + await playTrack(playlist[index], true); }; const playAll = async () => { if (songs.value.length === 0) return; - const { usePlayerCoreStore } = await import('@/store/modules/playerCore'); const { usePlaylistStore } = await import('@/store/modules/playlist'); + const { playTrack } = await import('@/services/playbackController'); - const playerCore = usePlayerCoreStore(); const playlistStore = usePlaylistStore(); const playlist = songs.value.map((s: any) => ({ @@ -186,7 +184,7 @@ const playAll = async () => { })); playlistStore.setPlayList(playlist, false, false); - await playerCore.handlePlayMusic(playlist[0], true); + await playTrack(playlist[0], true); }; diff --git a/src/renderer/views/home/components/HomeHero.vue b/src/renderer/views/home/components/HomeHero.vue index 00dead1..68c6768 100644 --- a/src/renderer/views/home/components/HomeHero.vue +++ b/src/renderer/views/home/components/HomeHero.vue @@ -441,7 +441,8 @@ const handleFmPlay = async () => { ]; playlistStore.setPlayList(playlist, false, false); playerCore.isFmPlaying = true; - await playerCore.handlePlayMusic(playlist[0], true); + const { playTrack } = await import('@/services/playbackController'); + await playTrack(playlist[0], true); } catch (error) { console.error('Failed to play Personal FM:', error); } @@ -597,9 +598,7 @@ const showDayRecommend = () => { const playDayRecommend = async () => { if (dayRecommendSongs.value.length === 0) return; try { - const { usePlayerCoreStore } = await import('@/store/modules/playerCore'); const { usePlaylistStore } = await import('@/store/modules/playlist'); - const playerCore = usePlayerCoreStore(); const playlistStore = usePlaylistStore(); const songs = dayRecommendSongs.value.map((s: any) => ({ id: s.id, @@ -611,7 +610,8 @@ const playDayRecommend = async () => { playLoading: false })); playlistStore.setPlayList(songs, false, false); - await playerCore.handlePlayMusic(songs[0], true); + const { playTrack } = await import('@/services/playbackController'); + await playTrack(songs[0], true); } catch (error) { console.error('Failed to play daily recommend:', error); } diff --git a/src/renderer/views/home/components/HomePlaylistSection.vue b/src/renderer/views/home/components/HomePlaylistSection.vue index 4f6d087..b57e133 100644 --- a/src/renderer/views/home/components/HomePlaylistSection.vue +++ b/src/renderer/views/home/components/HomePlaylistSection.vue @@ -60,7 +60,7 @@ import { useRouter } from 'vue-router'; import { getPersonalizedPlaylist } from '@/api/home'; import { getListDetail } from '@/api/list'; import { navigateToMusicList } from '@/components/common/MusicListNavigator'; -import { usePlayerCoreStore } from '@/store/modules/playerCore'; +import { playTrack } from '@/services/playbackController'; import { usePlaylistStore } from '@/store/modules/playlist'; import { calculateAnimationDelay, isElectron, isMobile } from '@/utils'; @@ -154,7 +154,6 @@ const playPlaylist = async (item: any) => { try { const { data } = await getListDetail(item.id); if (data.playlist?.tracks?.length > 0) { - const playerCore = usePlayerCoreStore(); const playlistStore = usePlaylistStore(); const playlist = data.playlist.tracks.map((s: any) => ({ @@ -168,7 +167,7 @@ const playPlaylist = async (item: any) => { })); playlistStore.setPlayList(playlist, false, false); - await playerCore.handlePlayMusic(playlist[0], true); + await playTrack(playlist[0], true); } } catch (error) { console.error('Failed to play playlist:', error);