⌨ 技术

音乐播放器播完不切歌:ended 事件丢失的排查

自己手搓的网页音乐播放器上线后一直有个偶发毛病:一首歌播完,进度条走满了,却停在最后一秒不动,不自动切下一首。不是每次都出现,是"有时候"——这种偶发问题最烦人。这篇文章记录完整的排查过程、根因,以及最后用的"看门狗"兜底方案。

一、问题现象

复现时的画面有三个关键细节:

  1. 进度条走满,左侧时间 3:42,右侧总时长也是 3:42——播放确实到了末尾;
  2. 播放按钮显示的还是「暂停」图标(也就是 UI 认为它还在播放);
  3. 永远停在这一秒,既不重播,也不切下一首。

"有时候"三个字说明不是确定性 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() 就永远执行不到,表现恰好是"卡住"。逐个检查了 splitNameloadLyricssaveState 这些函数,全都有防御式写法(空值兜底、try/catch),排除。

第二环:play() 被自动播放策略拦了?

浏览器确实可能拒绝无手势的 play()。但如果只是被拦:

  • 新歌的 src 已经换上,preload="metadata" 会自动拉新歌的元数据,进度条会跳回 0
  • 标题也应该换成下一首。

而实际现象是停在上一首的最后一步——不符合,排除主因。

第三环:ended 到底触发了没有?

回头再看现象里的细节 2:按钮还是「暂停」图标。这是破案的关键铁证。

查 HTML 规范会发现一个反直觉的事实:音频自然播放到结尾时,浏览器不会触发 pause 事件paused 属性保持 false,元素进入 ended 状态,只派发 ended 事件。也就是说,正常流程下 UI 之所以能从「暂停图标」切回「播放图标」,完全依赖 ended → 切歌 → 新歌 play 事件这一整条链。

现在按钮停在「暂停」图标,说明:play 事件之后,pauseended 一个都没来。结合进度条已满——结论只剩一个: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);

几个设计要点:

  1. 看门狗的判定条件是"距离末尾不足 0.4s"而不是"等于 duration"——恰好绕开了 VBR 的估算误差;
  2. endedGuard 防双切:正常 ended 和看门狗都可能触发切歌,一个布尔标志保证同一段结尾只切一次,新歌 play 事件到来时复位;
  3. 排除了四种误触发:单曲循环(loop 交给浏览器原生处理)、已暂停(用户主动停在末尾,不该自作主张切歌)、拖动进度条中(seeking)、时长未知;
  4. error 也纳入兜底:下一首文件缺失或加载失败时自动跳过,配合连续失败计数防空转——顺带把"点了一首被删掉的歌,播放器无声无息死掉"的问题一起修了;
  5. 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 为准。

七、复盘:三条经验

  1. 媒体事件不可全信。 ended 依赖"浏览器自己认为播完了",而"播完"的判定(currentTime >= duration)受文件编码质量影响。凡是依赖单一事件驱动的状态机,都值得配一个低频定时器做兜底校验——事件是通知,轮询是保险。

  2. UI 状态是最诚实的日志。 这次能快速定位,靠的不是 console,而是"按钮为什么还显示暂停图标"这个反常细节。顺着"正常流程里谁负责改这个状态"反推,直接锁定了 ended 丢失。排查 UI 异常时,先问"这个状态正常情况下由哪个事件维护、现在它没被维护说明了什么"。

  3. 偶发 bug 先找"哪一类输入会中招"。 "有时候"不是玄学,是分类变量:这次的分类维度是文件编码方式(VBR/CBR)。把出问题的样本和正常的样本找出来对比差异,往往一步就跳到根因。

评论区

共 0 条
🍃 还没有评论,沙发虚位以待~
🚇

穿越虫洞?

虫洞会把你随机传送到另一位陌生博主的网站——
可能是技术大佬,也可能是诗歌爱好者,开盲盒的时间到了。

森林精灵