mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-15 23:40:50 +08:00
681 lines
20 KiB
TypeScript
681 lines
20 KiB
TypeScript
import type { AudioOutputDevice } from '@/types/audio';
|
||
import type { SongResult } from '@/types/music';
|
||
import { 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<typeof setTimeout> | 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;
|
||
const artwork = ['96', '128', '192', '256', '384', '512'].map((size) => ({
|
||
src: `${track.picUrl}?param=${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<HTMLAudioElement> {
|
||
// 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<HTMLAudioElement>((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<AudioOutputDevice[]> {
|
||
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<boolean> {
|
||
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<void> {
|
||
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();
|