音乐播放器播完不切歌:ended 事件丢失的排查
自己手搓的网页音乐播放器上线后一直有个偶发毛病:一首歌播完,进度条走满了,却停在最后一秒不动,不自动切下一首。不是每次都出现,是"有时候"——这种偶发问题最烦人。这篇文章记录完整的排查过程、根因,以及最后用的"看门狗"兜底方案。
一、问题现象
复现时的画面有三个关键细节:
- 进度条走满,左侧时间
3:42,右侧总时长也是3:42——播放确实到了末尾; - 播放按钮显示的还是「暂停」图标(也就是 UI 认为它还在播放);
- 永远停在这一秒,既不重播,也不切下一首。
"有时候"三个字说明不是确定性 bug,大概率跟某一类文件或某一种状态有关。
二、切歌链路梳理
播放器的自动续播逻辑只有一个入口:监听 <audio> 的 ended 事件。
audio.addEventListener('ended', function () {
// repeat-one 由 audio.loop 处理;其余切下一首
next(1);
});
next(1) 做的事很朴素:算出队列下一首、换 src、调 play():
function next(dir) {
load(idx + dir, true);
}
function load(i, autoplay) {
idx = ((i % list.length) + list.length) % list.length;
var a = ensureAudio();
a.src = '/music-stream/' + list[idx].file;
renderTrack();
if (autoplay) a.play().catch(function () { /* 自动播放被拦截 */ });
}
链路总共三环:ended 触发 → load 换歌 → play 开播。卡住只可能是某一环断了。
三、逐环排查
第一环:切歌函数会抛异常吗?
load() 里换 src 之后、play() 之前,还夹着 renderTrack()(更新标题、封面、歌词)。如果它对某些歌曲抛异常,play() 就永远执行不到,表现恰好是"卡住"。逐个检查了 splitName、loadLyrics、saveState 这些函数,全都有防御式写法(空值兜底、try/catch),排除。
第二环:play() 被自动播放策略拦了?
浏览器确实可能拒绝无手势的 play()。但如果只是被拦:
- 新歌的
src已经换上,preload="metadata"会自动拉新歌的元数据,进度条会跳回 0; - 标题也应该换成下一首。
而实际现象是停在上一首的最后一步——不符合,排除主因。
第三环:ended 到底触发了没有?
回头再看现象里的细节 2:按钮还是「暂停」图标。这是破案的关键铁证。
查 HTML 规范会发现一个反直觉的事实:音频自然播放到结尾时,浏览器不会触发 pause 事件。paused 属性保持 false,元素进入 ended 状态,只派发 ended 事件。也就是说,正常流程下 UI 之所以能从「暂停图标」切回「播放图标」,完全依赖 ended → 切歌 → 新歌 play 事件这一整条链。
现在按钮停在「暂停」图标,说明:play 事件之后,pause 和 ended 一个都没来。结合进度条已满——结论只剩一个:ended 事件压根没有触发。
四、根因:VBR MP3 的"估算时长"比实际可播数据长
音频明明播到了最后,浏览器为什么认为还没播完?
关键在于 MP3 的两种编码方式:
- CBR(固定码率):每一秒的数据量恒定,元数据里的
duration算得很准; - VBR(可变码率):复杂段落多给码率、安静段落少给,
duration只能按平均码率估算。
估算就意味着误差。很多 VBR 文件的估算时长比实际可解码播放的数据长零点几秒。于是出现这样的死局:
实际可播数据: ────────────█████ 221.7s 播完,没有更多数据了
估算 duration: ────────────────── 222.1s(偏大)
currentTime 停在 221.7s < duration 222.1s
浏览器: "还没播完,继续等数据" → ended 永远不来
播放器把数据全部播完后,currentTime 停在 duration 之前一小截——浏览器的判定条件是 currentTime >= duration,差着这零点几秒,永远不满足。没有报错、没有事件、没有任何日志,元素就那么安静地挂在末尾。
这也完美解释了"有时候":只有某几首 VBR 编码的歌会中招,CBR 的歌每次都正常。
五、修复:不信任事件,上"续播看门狗"
思路转变:事件可能丢,那就别把宝全押在 ended 上,用一个低频定时器做兜底检查——"还在播、已经到末尾、却没切歌"就强制切。
var endedGuard = false, errStreak = 0; // 切歌防双触发 / 连续失败计数
function ensureAudio() {
if (audio) return audio;
audio = new Audio();
audio.preload = 'metadata';
audio.loop = (mode === 'one'); // 顺手修的另一个 bug,见后文
audio.addEventListener('play', function () {
endedGuard = false; // 新歌开播,看门狗重新武装
errStreak = 0;
// ... UI 状态、统计上报
});
audio.addEventListener('ended', function () {
endedGuard = true; // 已切歌,看门狗不再重复触发
next(1);
});
audio.addEventListener('error', function () {
// 下一首文件被删/网络抖动:自动跳过,连续 3 次失败就停,避免空转
if (++errStreak >= 3) { errStreak = 0; return; }
endedGuard = true;
next(1);
});
return audio;
}
/* 续播看门狗:VBR mp3 估算时长常比实际可播数据略长,播完时 currentTime
停在 duration 之前,ended 可能永远不触发(表现:卡在最后一秒不切歌)。
每 2 秒兜底检查:非单曲循环、还在播、已到末尾仍未切歌 → 强制切下一首 */
setInterval(function () {
if (!audio || audio.loop || audio.paused || audio.seeking) return;
if (!audio.duration || endedGuard) return;
if (audio.currentTime >= audio.duration - 0.4) {
endedGuard = true;
next(1);
}
}, 2000);
几个设计要点:
- 看门狗的判定条件是"距离末尾不足 0.4s"而不是"等于 duration"——恰好绕开了 VBR 的估算误差;
endedGuard防双切:正常ended和看门狗都可能触发切歌,一个布尔标志保证同一段结尾只切一次,新歌play事件到来时复位;- 排除了四种误触发:单曲循环(
loop交给浏览器原生处理)、已暂停(用户主动停在末尾,不该自作主张切歌)、拖动进度条中(seeking)、时长未知; error也纳入兜底:下一首文件缺失或加载失败时自动跳过,配合连续失败计数防空转——顺带把"点了一首被删掉的歌,播放器无声无息死掉"的问题一起修了;- 2 秒的检查频率对性能几乎零开销,却把"卡死"的最长持续时间压缩到 2 秒。
另外给 play() 的失败加了一次重试:
if (autoplay) a.play().catch(function () {
// 自动播放偶发被拦:1 秒后重试一次,仍失败则等用户手势
setTimeout(function () {
if (audio && audio.paused && audio.currentSrc) audio.play().catch(function () { });
}, 1000);
});
六、顺手修掉的连带 bug:刷新后单曲循环失效
排查时还发现一个同路径的隐患。播放模式的恢复代码在页面启动时执行:
function setMode(m) {
mode = m;
if (audio) audio.loop = (m === 'one'); // ← audio 不存在时直接跳过
}
启动流程是先 setMode(保存的模式)、后创建 <audio>。也就是说刷新页面后,即使保存的是"单曲循环",audio.loop 也永远是 false——界面显示单曲循环,实际却 behave 成列表循环。
修法很简单:在 ensureAudio() 创建播放器的那一刻补上 audio.loop = (mode === 'one'),以内存里已经恢复好的 mode 为准。
七、复盘:三条经验
-
媒体事件不可全信。
ended依赖"浏览器自己认为播完了",而"播完"的判定(currentTime >= duration)受文件编码质量影响。凡是依赖单一事件驱动的状态机,都值得配一个低频定时器做兜底校验——事件是通知,轮询是保险。 -
UI 状态是最诚实的日志。 这次能快速定位,靠的不是 console,而是"按钮为什么还显示暂停图标"这个反常细节。顺着"正常流程里谁负责改这个状态"反推,直接锁定了
ended丢失。排查 UI 异常时,先问"这个状态正常情况下由哪个事件维护、现在它没被维护说明了什么"。 -
偶发 bug 先找"哪一类输入会中招"。 "有时候"不是玄学,是分类变量:这次的分类维度是文件编码方式(VBR/CBR)。把出问题的样本和正常的样本找出来对比差异,往往一步就跳到根因。
评论区
共 0 条