mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-07-07 18:07:32 +08:00
fix(player): 修复快速切歌卡死、随机模式洗牌、定时睡眠重启失效
- audioService: 切歌时清除上一首遗留的 canplay/error 监听器,避免过期回调 stop 掉正在播放的新歌导致静音卡死 - playlist: setPlayList 新增 preserveOrder,就地编辑(下一首播放/移除单曲)时随机模式下不再重新洗牌,并同步 originalPlayList - sleepTimer: store 初始化时重建定时器 interval,修复重启后定时关闭到点不停 - preloadService: 清理 3s 验证超时定时器、validatedUrls 加容量上限、预加载调用处补 catch
This commit is contained in:
@@ -19,6 +19,11 @@ class AudioService {
|
||||
private operationLock = false;
|
||||
private operationLockTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 当前一次加载(play)挂载在共享 audio 元素上的 canplay/error 监听器的清理函数。
|
||||
// 快速切歌时,上一首尚未结算的监听器必须在新一轮加载或 stop() 时移除,
|
||||
// 否则新歌 canplay 会同时触发旧歌的回调,导致"过期回调 stop 掉正在播放的新歌"卡死。
|
||||
private pendingLoadCleanup: (() => void) | null = null;
|
||||
|
||||
private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||||
|
||||
private defaultEQSettings: { [key: string]: number } = {
|
||||
@@ -434,6 +439,12 @@ class AudioService {
|
||||
return Promise.resolve(this.audio);
|
||||
}
|
||||
|
||||
// 开始新一轮加载前,先移除上一轮尚未结算的加载监听器,避免污染新歌
|
||||
if (this.pendingLoadCleanup) {
|
||||
this.pendingLoadCleanup();
|
||||
this.pendingLoadCleanup = null;
|
||||
}
|
||||
|
||||
return new Promise<HTMLAudioElement>((resolve, reject) => {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 1;
|
||||
@@ -502,8 +513,14 @@ class AudioService {
|
||||
const cleanup = () => {
|
||||
this.audio.removeEventListener('canplay', onCanPlay);
|
||||
this.audio.removeEventListener('error', onError);
|
||||
if (this.pendingLoadCleanup === cleanup) {
|
||||
this.pendingLoadCleanup = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 记录本轮清理函数,供下一次 play()/stop() 在结算前主动移除
|
||||
this.pendingLoadCleanup = cleanup;
|
||||
|
||||
this.audio.addEventListener('canplay', onCanPlay, { once: true });
|
||||
this.audio.addEventListener('error', onError, { once: true });
|
||||
|
||||
@@ -529,6 +546,11 @@ class AudioService {
|
||||
|
||||
public stop() {
|
||||
this.forceResetOperationLock();
|
||||
// 移除尚未结算的加载监听器,避免 stop 后旧的 canplay/error 仍触发过期回调
|
||||
if (this.pendingLoadCleanup) {
|
||||
this.pendingLoadCleanup();
|
||||
this.pendingLoadCleanup = null;
|
||||
}
|
||||
try {
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
|
||||
@@ -10,6 +10,9 @@ class PreloadService {
|
||||
private validatedUrls: Map<string | number, string> = new Map();
|
||||
private loadingPromises: Map<string | number, Promise<string>> = new Map();
|
||||
|
||||
// 已验证 URL 缓存的最大条目数,超出后按插入顺序淘汰最旧项,避免长会话内无界增长
|
||||
private static readonly MAX_VALIDATED_URLS = 100;
|
||||
|
||||
/**
|
||||
* 验证歌曲 URL 可用性
|
||||
* 通过 HEAD 请求检查 URL 是否可访问,并缓存验证结果
|
||||
@@ -44,6 +47,13 @@ class PreloadService {
|
||||
try {
|
||||
const validatedUrl = await loadPromise;
|
||||
this.validatedUrls.set(song.id, validatedUrl);
|
||||
// 超出容量上限时淘汰最旧插入的条目
|
||||
if (this.validatedUrls.size > PreloadService.MAX_VALIDATED_URLS) {
|
||||
const oldestKey = this.validatedUrls.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
this.validatedUrls.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
return validatedUrl;
|
||||
} finally {
|
||||
this.loadingPromises.delete(song.id);
|
||||
@@ -59,7 +69,13 @@ class PreloadService {
|
||||
testAudio.crossOrigin = 'anonymous';
|
||||
testAudio.preload = 'metadata';
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
testAudio.removeEventListener('loadedmetadata', onLoaded);
|
||||
testAudio.removeEventListener('error', onError);
|
||||
testAudio.src = '';
|
||||
@@ -105,7 +121,7 @@ class PreloadService {
|
||||
testAudio.load();
|
||||
|
||||
// 3秒超时(优化预加载速度)
|
||||
setTimeout(() => {
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
// 超时不算失败,URL 可能是可用的只是服务器慢
|
||||
resolve(url);
|
||||
|
||||
@@ -110,7 +110,10 @@ export const usePlaylistStore = defineStore(
|
||||
// 预加载下一首歌曲的音频和封面
|
||||
if (nextSong) {
|
||||
if (nextSong.playMusicUrl) {
|
||||
preloadService.load(nextSong);
|
||||
// 预加载失败(URL 验证不过)不应影响主流程,捕获避免未处理的 Promise rejection
|
||||
preloadService.load(nextSong).catch((err) => {
|
||||
console.warn('预加载下一首失败:', err);
|
||||
});
|
||||
}
|
||||
if (nextSong.picUrl) {
|
||||
preloadCoverImage(nextSong.picUrl, getImgUrl);
|
||||
@@ -224,7 +227,10 @@ export const usePlaylistStore = defineStore(
|
||||
const setPlayList = (
|
||||
list: SongResult[],
|
||||
keepIndex: boolean = false,
|
||||
fromIntelligenceMode: boolean = false
|
||||
fromIntelligenceMode: boolean = false,
|
||||
// preserveOrder=true 表示"就地编辑当前队列"(下一首播放/移除单曲),
|
||||
// 此时即使处于随机模式也不应重新洗牌,保持调用方给定的顺序。
|
||||
preserveOrder: boolean = false
|
||||
) => {
|
||||
// 如果不是从心动模式调用,清除心动模式状态并切换播放模式
|
||||
if (!fromIntelligenceMode) {
|
||||
@@ -260,9 +266,37 @@ export const usePlaylistStore = defineStore(
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const { playMusic } = storeToRefs(playerCore);
|
||||
|
||||
// 根据当前播放模式处理新的播放列表
|
||||
if (playMode.value === 2) {
|
||||
// 随机模式
|
||||
if (preserveOrder) {
|
||||
// 就地编辑当前队列(下一首播放 / 移除单曲):保留调用方给定的顺序,不重新洗牌。
|
||||
console.log('就地编辑播放列表,保持给定顺序');
|
||||
|
||||
if (playMode.value === 2) {
|
||||
// 随机模式下同步原始顺序列表:删除已移除项、追加新加入项,保留既有原始顺序
|
||||
const idSet = new Set(list.map((song) => song.id));
|
||||
const reconciled = originalPlayList.value.filter((song) => idSet.has(song.id));
|
||||
const existingIds = new Set(reconciled.map((song) => song.id));
|
||||
for (const song of list) {
|
||||
if (!existingIds.has(song.id)) {
|
||||
reconciled.push(song);
|
||||
}
|
||||
}
|
||||
originalPlayList.value = reconciled;
|
||||
} else if (originalPlayList.value.length > 0) {
|
||||
originalPlayList.value = [];
|
||||
}
|
||||
|
||||
// 修正当前索引,指向当前正在播放的歌曲
|
||||
const currentSong = playMusic.value;
|
||||
const currentIndex =
|
||||
currentSong && currentSong.id ? list.findIndex((song) => song.id === currentSong.id) : -1;
|
||||
playListIndex.value =
|
||||
currentIndex !== -1
|
||||
? currentIndex
|
||||
: Math.min(Math.max(0, playListIndex.value), list.length - 1);
|
||||
|
||||
playList.value = list;
|
||||
} else if (playMode.value === 2) {
|
||||
// 随机模式:全新列表,保存原始顺序并洗牌
|
||||
console.log('随机模式下设置新播放列表,保存原始顺序并洗牌');
|
||||
|
||||
originalPlayList.value = [...list];
|
||||
@@ -315,7 +349,8 @@ export const usePlaylistStore = defineStore(
|
||||
const insertIndex = playListIndex.value + 1;
|
||||
list.splice(insertIndex, 0, song);
|
||||
|
||||
setPlayList(list, true);
|
||||
// preserveOrder=true:随机模式下也不重新洗牌,确保"下一首播放"位置生效
|
||||
setPlayList(list, true, false, true);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -335,7 +370,8 @@ export const usePlaylistStore = defineStore(
|
||||
|
||||
const newPlayList = [...playList.value];
|
||||
newPlayList.splice(index, 1);
|
||||
setPlayList(newPlayList);
|
||||
// preserveOrder=true:随机模式下移除单曲不重新洗牌,仅从队列中删除
|
||||
setPlayList(newPlayList, false, false, true);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -240,6 +240,30 @@ export const useSleepTimerStore = defineStore('sleepTimer', () => {
|
||||
showSleepTimer.value = value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用重启/刷新后恢复定时器:
|
||||
* sleepTimer 状态会被持久化并在 ref 初始化时恢复,但 setInterval 不会,
|
||||
* 导致 TIME 类型定时器到点后不再触发停止。这里在 store 创建时重建 interval。
|
||||
*/
|
||||
const restoreTimerInterval = () => {
|
||||
if (sleepTimer.value.type !== SleepTimerType.TIME || !sleepTimer.value.endTime) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= sleepTimer.value.endTime) {
|
||||
// 重启时已过设定时间,直接停止播放
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
if (!timerInterval.value) {
|
||||
timerInterval.value = window.setInterval(() => {
|
||||
checkSleepTimer();
|
||||
}, 1000) as unknown as number;
|
||||
}
|
||||
};
|
||||
|
||||
// store 首次实例化时立即尝试恢复(player store 在启动阶段即会实例化本 store)
|
||||
restoreTimerInterval();
|
||||
|
||||
return {
|
||||
// 状态
|
||||
sleepTimer,
|
||||
|
||||
Reference in New Issue
Block a user