2025-11-21 01:18:19 +08:00
|
|
|
import type { SongResult } from '@/types/music';
|
|
|
|
|
|
2026-03-29 13:18:05 +08:00
|
|
|
/**
|
|
|
|
|
* 预加载服务
|
|
|
|
|
*
|
|
|
|
|
* 新架构下 audioService 使用单一 HTMLAudioElement(换歌改 src),
|
|
|
|
|
* 不再需要预创建 Howl 实例。PreloadService 改为验证 URL 可用性并缓存元数据。
|
|
|
|
|
*/
|
2025-11-21 01:18:19 +08:00
|
|
|
class PreloadService {
|
2026-03-29 13:18:05 +08:00
|
|
|
private validatedUrls: Map<string | number, string> = new Map();
|
|
|
|
|
private loadingPromises: Map<string | number, Promise<string>> = new Map();
|
2025-11-21 01:18:19 +08:00
|
|
|
|
2026-07-05 19:52:49 +08:00
|
|
|
// 已验证 URL 缓存的最大条目数,超出后按插入顺序淘汰最旧项,避免长会话内无界增长
|
|
|
|
|
private static readonly MAX_VALIDATED_URLS = 100;
|
|
|
|
|
|
2025-11-21 01:18:19 +08:00
|
|
|
/**
|
2026-03-29 13:18:05 +08:00
|
|
|
* 验证歌曲 URL 可用性
|
|
|
|
|
* 通过 HEAD 请求检查 URL 是否可访问,并缓存验证结果
|
2025-11-21 01:18:19 +08:00
|
|
|
*/
|
2026-03-29 13:18:05 +08:00
|
|
|
public async load(song: SongResult): Promise<string> {
|
2025-11-21 01:18:19 +08:00
|
|
|
if (!song || !song.id) {
|
|
|
|
|
throw new Error('无效的歌曲对象');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 13:18:05 +08:00
|
|
|
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)!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 正在验证中
|
2025-11-21 01:18:19 +08:00
|
|
|
if (this.loadingPromises.has(song.id)) {
|
2026-03-29 13:18:05 +08:00
|
|
|
console.log(`[PreloadService] 歌曲 ${song.name} 正在验证中,复用现有请求`);
|
2025-11-21 01:18:19 +08:00
|
|
|
return this.loadingPromises.get(song.id)!;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 13:18:05 +08:00
|
|
|
console.log(`[PreloadService] 开始验证歌曲: ${song.name}`);
|
2025-11-21 01:18:19 +08:00
|
|
|
|
2026-03-29 13:18:05 +08:00
|
|
|
const url = song.playMusicUrl;
|
|
|
|
|
const loadPromise = this._validate(url, song);
|
2025-11-21 01:18:19 +08:00
|
|
|
this.loadingPromises.set(song.id, loadPromise);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-29 13:18:05 +08:00
|
|
|
const validatedUrl = await loadPromise;
|
|
|
|
|
this.validatedUrls.set(song.id, validatedUrl);
|
2026-07-05 19:52:49 +08:00
|
|
|
// 超出容量上限时淘汰最旧插入的条目
|
|
|
|
|
if (this.validatedUrls.size > PreloadService.MAX_VALIDATED_URLS) {
|
|
|
|
|
const oldestKey = this.validatedUrls.keys().next().value;
|
|
|
|
|
if (oldestKey !== undefined) {
|
|
|
|
|
this.validatedUrls.delete(oldestKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-29 13:18:05 +08:00
|
|
|
return validatedUrl;
|
2025-11-21 01:18:19 +08:00
|
|
|
} finally {
|
|
|
|
|
this.loadingPromises.delete(song.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-29 13:18:05 +08:00
|
|
|
* 验证 URL 可用性(通过创建临时 Audio 元素检测是否可加载)
|
2025-11-21 01:18:19 +08:00
|
|
|
*/
|
2026-03-29 13:18:05 +08:00
|
|
|
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';
|
|
|
|
|
|
2026-07-05 19:52:49 +08:00
|
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
2026-03-29 13:18:05 +08:00
|
|
|
const cleanup = () => {
|
2026-07-05 19:52:49 +08:00
|
|
|
if (timeoutId) {
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
timeoutId = null;
|
|
|
|
|
}
|
2026-03-29 13:18:05 +08:00
|
|
|
testAudio.removeEventListener('loadedmetadata', onLoaded);
|
|
|
|
|
testAudio.removeEventListener('error', onError);
|
|
|
|
|
testAudio.src = '';
|
|
|
|
|
testAudio.load();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onLoaded = () => {
|
|
|
|
|
// 检查时长
|
|
|
|
|
const duration = testAudio.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
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cleanup();
|
|
|
|
|
resolve(url);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onError = () => {
|
|
|
|
|
cleanup();
|
|
|
|
|
reject(new Error(`URL 验证失败: ${song.name}`));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
testAudio.addEventListener('loadedmetadata', onLoaded);
|
|
|
|
|
testAudio.addEventListener('error', onError);
|
|
|
|
|
testAudio.src = url;
|
|
|
|
|
testAudio.load();
|
|
|
|
|
|
2026-06-25 07:41:15 +00:00
|
|
|
// 3秒超时(优化预加载速度)
|
2026-07-05 19:52:49 +08:00
|
|
|
timeoutId = setTimeout(() => {
|
2026-03-29 13:18:05 +08:00
|
|
|
cleanup();
|
|
|
|
|
// 超时不算失败,URL 可能是可用的只是服务器慢
|
|
|
|
|
resolve(url);
|
2026-06-25 07:41:15 +00:00
|
|
|
}, 3000);
|
2025-11-21 01:18:19 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-29 13:18:05 +08:00
|
|
|
* 消耗已验证的 URL(从缓存移除)
|
2025-11-21 01:18:19 +08:00
|
|
|
*/
|
2026-03-29 13:18:05 +08:00
|
|
|
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;
|
2025-11-21 01:18:19 +08:00
|
|
|
}
|
2026-03-29 13:18:05 +08:00
|
|
|
return undefined;
|
2025-11-21 01:18:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-29 13:18:05 +08:00
|
|
|
* 取消预加载
|
2025-11-21 01:18:19 +08:00
|
|
|
*/
|
2026-03-29 13:18:05 +08:00
|
|
|
public cancel(songId: string | number) {
|
|
|
|
|
this.validatedUrls.delete(songId);
|
2025-11-21 01:18:19 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-17 15:05:40 +08:00
|
|
|
/**
|
2026-03-29 13:18:05 +08:00
|
|
|
* 获取已验证的 URL
|
2025-12-17 15:05:40 +08:00
|
|
|
*/
|
2026-03-29 13:18:05 +08:00
|
|
|
public getPreloadedSound(songId: string | number): string | undefined {
|
|
|
|
|
return this.validatedUrls.get(songId);
|
2025-12-17 15:05:40 +08:00
|
|
|
}
|
|
|
|
|
|
2025-11-21 01:18:19 +08:00
|
|
|
/**
|
2026-03-29 13:18:05 +08:00
|
|
|
* 清理所有缓存
|
2025-11-21 01:18:19 +08:00
|
|
|
*/
|
|
|
|
|
public clearAll() {
|
2026-03-29 13:18:05 +08:00
|
|
|
this.validatedUrls.clear();
|
2025-11-21 01:18:19 +08:00
|
|
|
this.loadingPromises.clear();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const preloadService = new PreloadService();
|