feat: 一系列播放优化

This commit is contained in:
alger
2025-11-21 01:18:19 +08:00
parent 07f6152c56
commit 1a0e449e13
19 changed files with 1712 additions and 304 deletions
+183 -51
View File
@@ -5,6 +5,7 @@ import { isElectron } from '@/utils'; // 导入isElectron常量
class AudioService {
private currentSound: Howl | null = null;
private pendingSound: Howl | null = null;
private currentTrack: SongResult | null = null;
@@ -470,11 +471,12 @@ class AudioService {
}
// 播放控制相关
play(
url?: string,
track?: SongResult,
public play(
url: string,
track: SongResult,
isPlay: boolean = true,
seekTime: number = 0
seekTime: number = 0,
existingSound?: Howl
): Promise<Howl> {
// 每次调用play方法时,尝试强制重置锁(注意:仅在页面刷新后的第一次播放时应用)
if (!this.currentSound) {
@@ -482,6 +484,17 @@ class AudioService {
this.forceResetOperationLock();
}
// 如果有操作锁,且不是同一个 track 的操作,则等待
if (this.operationLock) {
console.log('audioService: 操作锁激活中,等待...');
return Promise.reject(new Error('操作锁激活中'));
}
if (!this.setOperationLock()) {
console.log('audioService: 获取操作锁失败');
return Promise.reject(new Error('操作锁激活中'));
}
// 如果操作锁已激活,但持续时间超过安全阈值,强制重置
if (this.operationLock) {
const currentTime = Date.now();
@@ -531,10 +544,25 @@ class AudioService {
return Promise.reject(new Error('缺少必要参数: url和track'));
}
// 检查是否是同一首歌曲的无缝切换(Hot-Swap)
const isHotSwap =
this.currentTrack && track && this.currentTrack.id === track.id && this.currentSound;
if (isHotSwap) {
console.log('audioService: 检测到同一首歌曲的源切换,启用无缝切换模式');
}
return new Promise<Howl>((resolve, reject) => {
let retryCount = 0;
const maxRetries = 1;
// 如果有正在加载的 pendingSound,先清理掉
if (this.pendingSound) {
console.log('audioService: 清理正在加载的 pendingSound');
this.pendingSound.unload();
this.pendingSound = null;
}
const tryPlay = async () => {
try {
console.log('audioService: 开始创建音频对象');
@@ -560,8 +588,8 @@ class AudioService {
await Howler.ctx.resume();
}
// 先停止并清理现有的音频实例
if (this.currentSound) {
// 非热切换模式下,先停止并清理现有的音频实例
if (!isHotSwap && this.currentSound) {
console.log('audioService: 停止并清理现有的音频实例');
// 确保任何进行中的seek操作被取消
if (this.seekLock && this.seekDebounceTimer) {
@@ -573,49 +601,122 @@ class AudioService {
this.currentSound = null;
}
// 清理 EQ 但保持上下文
console.log('audioService: 清理 EQ');
await this.disposeEQ(true);
// 清理 EQ 但保持上下文 (热切换时暂时不清理,等切换完成后再处理)
if (!isHotSwap) {
console.log('audioService: 清理 EQ');
await this.disposeEQ(true);
}
this.currentTrack = track;
console.log('audioService: 创建新的 Howl 对象');
this.currentSound = new Howl({
src: [url],
html5: true,
autoplay: false,
volume: 1, // 禁用 Howler.js 音量控制
rate: this.playbackRate,
format: ['mp3', 'aac'],
onloaderror: (_, error) => {
// 如果不是热切换,立即更新 currentTrack
if (!isHotSwap) {
this.currentTrack = track;
}
// 如果不是热切换,立即更新 currentTrack
if (!isHotSwap) {
this.currentTrack = track;
}
let newSound: Howl;
if (existingSound) {
console.log('audioService: 使用预加载的 Howl 对象');
newSound = existingSound;
// 确保 volume 和 rate 正确
newSound.volume(1); // 内部 volume 设为 1,由 Howler.masterGain 控制实际音量
newSound.rate(this.playbackRate);
// 重新绑定事件监听器,因为 PreloadService 可能没有绑定这些
// 注意:Howler 允许重复绑定,但最好先清理(如果无法清理,就直接绑定,Howler 是 EventEmitter
// 这里我们假设 existingSound 是干净的或者我们只绑定我们需要关心的
} else {
console.log('audioService: 创建新的 Howl 对象');
newSound = new Howl({
src: [url],
html5: true,
autoplay: false,
volume: 1, // 禁用 Howler.js 音量控制
rate: this.playbackRate,
format: ['mp3', 'aac']
});
}
// 统一设置事件处理
const setupEvents = () => {
newSound.off('loaderror');
newSound.off('playerror');
newSound.off('load');
newSound.on('loaderror', (_, error) => {
console.error('Audio load error:', error);
if (retryCount < maxRetries) {
if (retryCount < maxRetries && !existingSound) {
// 预加载的音频通常已经 loaded,不应重试
retryCount++;
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
setTimeout(tryPlay, 1000 * retryCount);
} else {
// 发送URL过期事件,通知外部需要重新获取URL
this.emit('url_expired', this.currentTrack);
this.emit('url_expired', track);
this.releaseOperationLock();
if (isHotSwap) this.pendingSound = null;
reject(new Error('音频加载失败,请尝试切换其他歌曲'));
}
},
onplayerror: (_, error) => {
});
newSound.on('playerror', (_, error) => {
console.error('Audio play error:', error);
if (retryCount < maxRetries) {
retryCount++;
console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
setTimeout(tryPlay, 1000 * retryCount);
} else {
// 发送URL过期事件,通知外部需要重新获取URL
this.emit('url_expired', this.currentTrack);
this.emit('url_expired', track);
this.releaseOperationLock();
if (isHotSwap) this.pendingSound = null;
reject(new Error('音频播放失败,请尝试切换其他歌曲'));
}
},
onload: async () => {
});
const onLoaded = async () => {
try {
// 初始化音频管道
await this.setupEQ(this.currentSound!);
// 如果是热切换,现在执行切换逻辑
if (isHotSwap) {
console.log('audioService: 执行无缝切换');
// 1. 获取当前播放进度
let currentPos = 0;
if (this.currentSound) {
currentPos = this.currentSound.seek() as number;
}
// 2. 同步新音频进度
newSound.seek(currentPos);
// 3. 初始化新音频的 EQ
await this.disposeEQ(true);
await this.setupEQ(newSound);
// 4. 播放新音频
if (isPlay) {
newSound.play();
}
// 5. 停止旧音频
if (this.currentSound) {
this.currentSound.stop();
this.currentSound.unload();
}
// 6. 更新引用
this.currentSound = newSound;
this.currentTrack = track;
this.pendingSound = null;
console.log(`audioService: 无缝切换完成,进度同步至 ${currentPos}s`);
} else {
// 普通加载逻辑
await this.setupEQ(newSound);
this.currentSound = newSound;
}
// 重新应用已保存的音量
const savedVolume = localStorage.getItem('volume');
@@ -623,22 +724,23 @@ class AudioService {
this.applyVolume(parseFloat(savedVolume));
}
// 音频加载成功后设置 EQ 和更新媒体会话
if (this.currentSound) {
try {
if (seekTime > 0) {
if (!isHotSwap && seekTime > 0) {
this.currentSound.seek(seekTime);
}
console.log('audioService: 音频加载成功,设置 EQ');
this.updateMediaSessionMetadata(track);
this.updateMediaSessionPositionState();
this.emit('load');
// 此时音频已完全初始化,根据 isPlay 参数决定是否播放
console.log('audioService: 音频完全初始化,isPlay =', isPlay);
if (isPlay) {
console.log('audioService: 开始播放');
this.currentSound.play();
if (!isHotSwap) {
console.log('audioService: 音频完全初始化,isPlay =', isPlay);
if (isPlay) {
console.log('audioService: 开始播放');
this.currentSound.play();
}
}
resolve(this.currentSound);
@@ -651,28 +753,58 @@ class AudioService {
console.error('Audio initialization failed:', error);
reject(error);
}
};
if (newSound.state() === 'loaded') {
onLoaded();
} else {
newSound.once('load', onLoaded);
}
});
};
// 设置音频事件监听
if (this.currentSound) {
this.currentSound.on('play', () => {
this.updateMediaSessionState(true);
this.emit('play');
setupEvents();
if (isHotSwap) {
this.pendingSound = newSound;
} else {
this.currentSound = newSound;
}
// 设置音频事件监听 (play, pause, end, seek)
// ... (保持原有的事件监听逻辑不变,但需要确保绑定到 newSound)
const soundInstance = newSound;
if (soundInstance) {
// 清除旧的监听器以防重复
soundInstance.off('play');
soundInstance.off('pause');
soundInstance.off('end');
soundInstance.off('seek');
soundInstance.on('play', () => {
if (this.currentSound === soundInstance) {
this.updateMediaSessionState(true);
this.emit('play');
}
});
this.currentSound.on('pause', () => {
this.updateMediaSessionState(false);
this.emit('pause');
soundInstance.on('pause', () => {
if (this.currentSound === soundInstance) {
this.updateMediaSessionState(false);
this.emit('pause');
}
});
this.currentSound.on('end', () => {
this.emit('end');
soundInstance.on('end', () => {
if (this.currentSound === soundInstance) {
this.emit('end');
}
});
this.currentSound.on('seek', () => {
this.updateMediaSessionPositionState();
this.emit('seek');
soundInstance.on('seek', () => {
if (this.currentSound === soundInstance) {
this.updateMediaSessionPositionState();
this.emit('seek');
}
});
}
} catch (error) {
@@ -0,0 +1,294 @@
/**
* 播放请求管理器
* 负责管理播放请求的队列、取消、状态跟踪,防止竞态条件
*/
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;
/**
* 生成唯一的请求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);
this.currentRequestId = requestId;
console.log(`[PlaybackRequestManager] 创建新请求: ${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;
}
/**
* 完成请求
* @param requestId 请求ID
*/
completeRequest(requestId: string): void {
const request = this.requestMap.get(requestId);
if (!request) {
return;
}
request.status = RequestStatus.COMPLETED;
console.log(`[PlaybackRequestManager] 完成请求: ${requestId}`);
// 清理旧请求(保留最近3个)
this.cleanupOldRequests();
}
/**
* 标记请求失败
* @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;
}
/**
* 获取当前请求ID
*/
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();
+273
View File
@@ -0,0 +1,273 @@
import { Howl } from 'howler';
import { cloneDeep } from 'lodash';
import { getParsingMusicUrl } from '@/api/music';
import type { SongResult } from '@/types/music';
class PreloadService {
private loadingPromises: Map<string | number, Promise<Howl>> = new Map();
private preloadedSounds: Map<string | number, Howl> = new Map();
/**
* 加载并验证音频
* 如果已经在加载中,返回现有的 Promise
* 如果已经加载完成,返回缓存的 Howl 实例
*/
public async load(song: SongResult): Promise<Howl> {
if (!song || !song.id) {
throw new Error('无效的歌曲对象');
}
// 1. 检查是否有正在进行的加载
if (this.loadingPromises.has(song.id)) {
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);
}
}
// 3. 开始新的加载过程
const loadPromise = this._performLoad(song);
this.loadingPromises.set(song.id, loadPromise);
try {
const sound = await loadPromise;
this.preloadedSounds.set(song.id, sound);
return sound;
} finally {
this.loadingPromises.delete(song.id);
}
}
/**
* 执行实际的加载和验证逻辑
*/
private async _performLoad(song: SongResult): Promise<Howl> {
console.log(`[PreloadService] 开始加载歌曲: ${song.name}`);
if (!song.playMusicUrl) {
throw new Error('歌曲没有 URL');
}
// 创建初始音频实例
let sound = await this._createSound(song.playMusicUrl);
// 检查时长
const duration = sound.duration();
const expectedDuration = (song.dt || 0) / 1000;
// 如果时长差异超过5秒,且不是B站视频,且预期时长大于0
if (
expectedDuration > 0 &&
Math.abs(duration - expectedDuration) > 5 &&
song.source !== 'bilibili'
) {
const songId = String(song.id);
const sourceType = localStorage.getItem(`song_source_type_${songId}`);
// 如果不是用户手动锁定的音源,尝试自动重新解析
if (sourceType !== 'manual') {
console.warn(
`[PreloadService] 时长不匹配 (实际: ${duration}s, 预期: ${expectedDuration}s),尝试智能解析`
);
// 动态导入 store
const { useSettingsStore } = await import('@/store/modules/settings');
const { usePlaylistStore } = await import('@/store/modules/playlist');
const settingsStore = useSettingsStore();
const playlistStore = usePlaylistStore();
const enabledSources = settingsStore.setData.enabledMusicSources || [
'migu',
'kugou',
'pyncmd',
'gdmusic'
];
const availableSources = enabledSources.filter((s: string) => s !== 'bilibili');
const triedSources = new Set<string>();
const triedSourceDiffs = new Map<string, number>();
// 记录当前音源
let currentSource = 'unknown';
const currentSavedSource = localStorage.getItem(`song_source_${songId}`);
if (currentSavedSource) {
try {
const sources = JSON.parse(currentSavedSource);
if (Array.isArray(sources) && sources.length > 0) {
currentSource = sources[0];
}
} catch (e) {
console.log(
`[PreloadService] 时长不匹配 (实际: ${duration}s, 预期: ${expectedDuration}s),尝试智能解析`,
e
);
}
}
triedSources.add(currentSource);
triedSourceDiffs.set(currentSource, Math.abs(duration - expectedDuration));
// 卸载当前不匹配的音频
sound.unload();
// 尝试其他音源
for (const source of availableSources) {
if (triedSources.has(source)) continue;
console.log(`[PreloadService] 尝试音源: ${source}`);
triedSources.add(source);
try {
const songData = cloneDeep(song);
// 临时保存设置以便 getParsingMusicUrl 使用
localStorage.setItem(`song_source_${songId}`, JSON.stringify([source]));
const res = await getParsingMusicUrl(
typeof song.id === 'string' ? parseInt(song.id) : song.id,
songData
);
if (res && res.data && res.data.data && res.data.data.url) {
const newUrl = res.data.data.url;
const tempSound = await this._createSound(newUrl);
const newDuration = tempSound.duration();
const diff = Math.abs(newDuration - expectedDuration);
triedSourceDiffs.set(source, diff);
if (diff <= 5) {
console.log(`[PreloadService] 找到匹配音源: ${source}, 更新歌曲信息`);
// 更新歌曲信息
const updatedSong = {
...song,
playMusicUrl: newUrl,
expiredAt: Date.now() + 1800000
};
// 更新 store
playlistStore.updateSong(updatedSong);
// 记录新的音源设置
localStorage.setItem(`song_source_${songId}`, JSON.stringify([source]));
localStorage.setItem(`song_source_type_${songId}`, 'auto');
return tempSound;
} else {
tempSound.unload();
}
}
} catch (e) {
console.error(`[PreloadService] 尝试音源 ${source} 失败:`, e);
}
}
// 如果没有找到完美匹配,使用最佳匹配
console.warn('[PreloadService] 未找到完美匹配,寻找最佳匹配');
let bestSource = '';
let minDiff = Infinity;
for (const [source, diff] of triedSourceDiffs.entries()) {
if (diff < minDiff) {
minDiff = diff;
bestSource = source;
}
}
if (bestSource && bestSource !== currentSource) {
console.log(`[PreloadService] 使用最佳匹配音源: ${bestSource} (差异: ${minDiff}s)`);
try {
const songData = cloneDeep(song);
localStorage.setItem(`song_source_${songId}`, JSON.stringify([bestSource]));
const res = await getParsingMusicUrl(
typeof song.id === 'string' ? parseInt(song.id) : song.id,
songData
);
if (res && res.data && res.data.data && res.data.data.url) {
const newUrl = res.data.data.url;
const bestSound = await this._createSound(newUrl);
const updatedSong = {
...song,
playMusicUrl: newUrl,
expiredAt: Date.now() + 1800000
};
playlistStore.updateSong(updatedSong);
localStorage.setItem(`song_source_type_${songId}`, 'auto');
return bestSound;
}
} catch (e) {
console.error(`[PreloadService] 获取最佳匹配音源失败:`, e);
}
}
}
}
// 如果不需要修复或修复失败,重新加载原始音频(因为上面可能unload了)
if (sound.state() === 'unloaded') {
sound = await this._createSound(song.playMusicUrl);
}
return sound;
}
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)
});
});
}
/**
* 取消特定歌曲的预加载(如果可能)
* 注意:Promise 无法真正取消,但我们可以清理结果
*/
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);
}
/**
* 清理所有预加载资源
*/
public clearAll() {
this.preloadedSounds.forEach((sound) => sound.unload());
this.preloadedSounds.clear();
this.loadingPromises.clear();
}
}
export const preloadService = new PreloadService();