Files
AlgerMusicPlayer/src/renderer/services/audioService.ts
T
alger fda79f2f8d fix(player): 系统媒体控件(SMTC)封面尺寸上限提升至 1024
artwork 增加 1024x1024 档位并改用 getImgUrl 生成 URL,正确处理
data:/local:// 封面与已带参数的图片地址,提升 AMLL 等 SMTC 监听端的封面清晰度。

Closes #595
2026-07-05 14:51:57 +08:00

683 lines
20 KiB
TypeScript

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<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;
// 上限提到 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<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();