refactor(player): 重构播放控制系统,移除 Howler.js 改用原生 HTMLAudioElement

- 新建 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 重建崩溃
This commit is contained in:
alger
2026-03-29 13:18:05 +08:00
parent 167f081ee6
commit 0cfec3dd82
17 changed files with 1150 additions and 1919 deletions
File diff suppressed because it is too large Load Diff
+531
View File
@@ -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<boolean> => {
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<void> => {
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<boolean> => {
// 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<boolean> => {
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<void> => {
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);
};
+20 -255
View File
@@ -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<string, PlaybackRequest> = 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<string, number>;
} {
const requestsByStatus: Record<string, number> = {
[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();
+104 -106
View File
@@ -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<string | number, Promise<Howl>> = new Map();
private preloadedSounds: Map<string | number, Howl> = new Map();
private validatedUrls: Map<string | number, string> = new Map();
private loadingPromises: Map<string | number, Promise<string>> = new Map();
/**
* 加载并验证音频
* 如果已经在加载中,返回现有的 Promise
* 如果已经加载完成,返回缓存的 Howl 实例
* 验证歌曲 URL 可用性
* 通过 HEAD 请求检查 URL 是否可访问,并缓存验证结果
*/
public async load(song: SongResult): Promise<Howl> {
public async load(song: SongResult): Promise<string> {
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<Howl> {
console.log(`[PreloadService] 开始加载歌曲: ${song.name}`);
private async _validate(url: string, song: SongResult): Promise<string> {
return new Promise<string>((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<Howl> {
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();
}
}