import type { AudioOutputDevice } from '@/types/audio'; import type { SongResult } from '@/types/music'; import { getImgUrl, isElectron } from '@/utils'; class AudioService { private audio: HTMLAudioElement; private currentTrack: SongResult | null = null; private context: AudioContext | null = null; private sourceNode: MediaElementAudioSourceNode | null = null; private filters: BiquadFilterNode[] = []; private gainNode: GainNode | null = null; private bypass = false; private playbackRate = 1.0; private currentSinkId: string = 'default'; private _isLoading = false; private operationLock = false; private operationLockTimer: ReturnType | null = null; private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; private defaultEQSettings: { [key: string]: number } = { '31': 0, '62': 0, '125': 0, '250': 0, '500': 0, '1000': 0, '2000': 0, '4000': 0, '8000': 0, '16000': 0 }; // 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(); } // Restore EQ bypass state const bypassState = localStorage.getItem('eqBypass'); this.bypass = bypassState ? JSON.parse(bypassState) : false; 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.audio.play(); }); navigator.mediaSession.setActionHandler('pause', () => { this.audio.pause(); }); navigator.mediaSession.setActionHandler('stop', () => { this.stop(); }); navigator.mediaSession.setActionHandler('seekto', (event) => { if (event.seekTime !== undefined) { this.seek(event.seekTime); } }); navigator.mediaSession.setActionHandler('seekbackward', (event) => { this.seek(this.audio.currentTime - (event.seekOffset || 10)); }); navigator.mediaSession.setActionHandler('seekforward', (event) => { this.seek(this.audio.currentTime + (event.seekOffset || 10)); }); navigator.mediaSession.setActionHandler('previoustrack', () => { this.emit('previoustrack'); }); navigator.mediaSession.setActionHandler('nexttrack', () => { this.emit('nexttrack'); }); } private updateMediaSessionMetadata(track: SongResult) { try { if (!('mediaSession' in navigator)) return; const artists = track.ar ? track.ar.map((a) => a.name) : track.song.artists?.map((a) => a.name); const album = track.al ? track.al.name : track.song.album.name; // 上限提到 1024 提升 SMTC/AMLL 等系统媒体控件的封面清晰度(#595); // 走 getImgUrl 以正确处理 data:/local:// 封面与已带参数的 URL const artwork = ['96', '128', '192', '256', '384', '512', '1024'].map((size) => ({ src: getImgUrl(track.picUrl, `${size}y${size}`), type: 'image/jpg', sizes: `${size}x${size}` })); navigator.mediaSession.metadata = new window.MediaMetadata({ title: track.name || '', artist: artists ? artists.join(',') : '', album: album || '', artwork }); } catch (error) { console.error('更新媒体会话元数据时出错:', error); } } private updateMediaSessionState(isPlaying: boolean) { if (!('mediaSession' in navigator)) return; navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused'; this.updateMediaSessionPositionState(); } private updateMediaSessionPositionState() { try { if (!('mediaSession' in navigator)) return; if (!this.audio.duration || !isFinite(this.audio.duration)) return; if ('setPositionState' in navigator.mediaSession) { navigator.mediaSession.setPositionState({ duration: this.audio.duration, playbackRate: this.playbackRate, position: this.audio.currentTime }); } } catch (error) { console.error('更新媒体会话位置状态时出错:', error); } } // ==================== Event Emitter ==================== private emit(event: string, ...args: any[]) { const eventCallbacks = this.callbacks[event]; if (eventCallbacks) { eventCallbacks.forEach((callback) => callback(...args)); } } on(event: string, callback: Function) { if (!this.callbacks[event]) { this.callbacks[event] = []; } this.callbacks[event].push(callback); } off(event: string, callback: Function) { const eventCallbacks = this.callbacks[event]; if (eventCallbacks) { this.callbacks[event] = eventCallbacks.filter((cb) => cb !== callback); } } 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; } public setEQEnabled(enabled: boolean) { this.bypass = !enabled; localStorage.setItem('eqBypass', JSON.stringify(this.bypass)); if (this.sourceNode && this.gainNode && this.context) { this.applyBypassState(); } } public setEQFrequencyGain(frequency: string, gain: number) { const filterIndex = this.frequencies.findIndex((f) => f.toString() === frequency); if (filterIndex !== -1 && this.filters[filterIndex]) { this.filters[filterIndex].gain.setValueAtTime(gain, this.context?.currentTime || 0); this.saveEQSettings(frequency, gain); } } public resetEQ() { this.filters.forEach((filter) => { filter.gain.setValueAtTime(0, this.context?.currentTime || 0); }); localStorage.removeItem('eqSettings'); } public getAllEQSettings(): { [key: string]: number } { 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; localStorage.setItem('eqSettings', JSON.stringify(settings)); } private loadEQSettings(): { [key: string]: number } { const savedSettings = localStorage.getItem('eqSettings'); return savedSettings ? JSON.parse(savedSettings) : { ...this.defaultEQSettings }; } // ==================== Operation Lock ==================== private setOperationLock(): boolean { if (this.operationLock) { return false; } this.operationLock = true; if (this.operationLockTimer) clearTimeout(this.operationLockTimer); this.operationLockTimer = setTimeout(() => { console.warn('操作锁超时自动释放'); this.releaseOperationLock(); }, 5000); return true; } public releaseOperationLock(): void { this.operationLock = false; if (this.operationLockTimer) { clearTimeout(this.operationLockTimer); this.operationLockTimer = null; } } public forceResetOperationLock(): void { this.operationLock = false; if (this.operationLockTimer) { clearTimeout(this.operationLockTimer); this.operationLockTimer = null; } } // ==================== Playback Control ==================== public play( url: string, track: SongResult, isPlay: boolean = true, seekTime: number = 0, _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 (!url || !track) { this.releaseOperationLock(); return Promise.reject(new Error('缺少必要参数: url和track')); } // Check if same URL — just resume/seek const currentSrc = this.audio.src; const isSameUrl = currentSrc && currentSrc === url; 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) => { let retryCount = 0; const maxRetries = 1; const tryPlay = () => { this._isLoading = true; this.currentTrack = track; // Ensure EQ/AudioContext is set up (only runs once) this.setupEQ(); // 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(); }); } public pause() { this.forceResetOperationLock(); try { this.audio.pause(); } catch (error) { console.error('暂停音频失败:', error); } } 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); } private applyVolume(volume: number) { const normalizedVolume = Math.max(0, Math.min(1, volume)); 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; } } // ==================== Audio Output Devices ==================== public async getAudioOutputDevices(): Promise { try { const devices = await navigator.mediaDevices.enumerateDevices(); const audioOutputs = devices.filter((d) => d.kind === 'audiooutput'); return audioOutputs.map((device, index) => ({ deviceId: device.deviceId, label: device.label || `Speaker ${index + 1}`, isDefault: device.deviceId === 'default' || device.deviceId === '' })); } catch (error) { console.error('枚举音频设备失败:', error); return [{ deviceId: 'default', label: 'Default', isDefault: true }]; } } public async setAudioOutputDevice(deviceId: string): Promise { try { if (this.context && typeof (this.context as any).setSinkId === 'function') { await (this.context as any).setSinkId(deviceId); this.currentSinkId = deviceId; localStorage.setItem('audioOutputDeviceId', deviceId); console.log('音频输出设备已切换:', deviceId); return true; } else { console.warn('AudioContext.setSinkId 不可用'); return false; } } catch (error) { console.error('设置音频输出设备失败:', error); return false; } } public getCurrentSinkId(): string { return this.currentSinkId; } private async restoreSavedAudioDevice(): Promise { const savedDeviceId = localStorage.getItem('audioOutputDeviceId'); if (savedDeviceId && savedDeviceId !== 'default') { try { await this.setAudioOutputDevice(savedDeviceId); } catch (error) { console.warn('恢复音频输出设备失败,回退到默认设备:', error); localStorage.removeItem('audioOutputDeviceId'); this.currentSinkId = 'default'; } } } private setupContextStateMonitoring() { if (!this.context) return; this.context.addEventListener('statechange', async () => { console.log('AudioContext state changed:', this.context?.state); if (this.context?.state === 'suspended' && !this.audio.paused) { console.log('AudioContext suspended while playing, attempting to resume...'); try { await this.context.resume(); console.log('AudioContext resumed successfully'); } catch (e) { console.error('Failed to resume AudioContext:', e); this.emit('audio_error', { type: 'context_suspended', error: e }); } } else if (this.context?.state === 'closed') { console.warn('AudioContext was closed unexpectedly'); this.emit('audio_error', { type: 'context_closed' }); } }); } } export const audioService = new AudioService();