手把手实现一个支持HLS和DASH的自定义视频播放器

シ淑怡 交互 阅读 2,953
赞 21 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年接了个教育类小程序的H5配套页,需要在微信内置浏览器里嵌一个「课程回放」模块。不是纯视频点播,而是带章节标记、拖拽定位、倍速、字幕切换、还有个“学习进度条”要和后端同步——听起来挺常规,但真做起来才发现,微信里 video 标签的兼容性比想象中更脆。

手把手实现一个支持HLS和DASH的自定义视频播放器

一开始想直接上 <video> + 自定义控件,毕竟轻量、可控、不用额外打包。结果第一天就卡在 iOS 微信里:自动播放被禁、静音下也不行、全屏后返回页面会白屏、甚至某些机型 touchstart 后 video 的 currentTime 会跳变……折腾半天发现,这不是 bug,是微信故意的(捂脸)。

后来查了一圈,还是决定自己封装一层,不碰原生 video 的 auto-play 和 full-screen 逻辑,用 poster + 按钮触发播放,再通过 play() + catch 错误兜底。顺手把 WebKit 内核的兼容问题也记下来了:iOS 15+ 对 webkit-playsinline 属性更严格,必须同时加 playsinlinex5-playsinline 才能内联播放。

最大的坑:性能问题

真正炸开是在接入字幕功能之后。我们用的是 WebVTT,通过 <track> 加载,本地测试没问题,一上预发环境就卡顿——不是卡 UI,是卡主线程,滑动进度条时肉眼可见掉帧,甚至触发了 Chrome 的“页面无响应”提示。

开始没想到是字幕解析的问题。查 Performance 面板才发现,每次 currentTime 变化,我们都在 JS 里手动遍历整个 VTT 文件找当前时间戳对应的 cue,而那个字幕文件有 3000+ 行……亲测有效?不,是亲测崩溃。

后来调整了方案:提前 parse 一次,把所有 cue 按 start time 存进数组,再用二分查找定位。代码不多,但效果立竿见影:

// 字幕预处理(只执行一次)
function parseVtt(vttText) {
  const cues = [];
  const lines = vttText.split('n');
  let i = 0;
  while (i < lines.length) {
    const line = lines[i].trim();
    if (/^d{2}:d{2}:d{2}.d{3} --> d{2}:d{2}:d{2}.d{3}$/.test(line)) {
      const [start, end] = line.split(' --> ').map(t => timeToSeconds(t.trim()));
      const text = lines[i + 1]?.trim() || '';
      if (text) cues.push({ start, end, text });
      i += 2;
    } else {
      i++;
    }
  }
  return cues.sort((a, b) => a.start - b.start);
}

// 二分查找当前时间对应的字幕
function findCurrentCue(cues, currentTime) {
  let left = 0;
  let right = cues.length - 1;
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    const cue = cues[mid];
    if (currentTime >= cue.start && currentTime < cue.end) {
      return cue;
    } else if (currentTime < cue.start) {
      right = mid - 1;
    } else {
      left = mid + 1;
    }
  }
  return null;
}

这里注意我踩过好几次坑:一是没做防抖,video 的 timeupdate 事件太密(iOS 下每 100ms 触发一次),直接查会导致重复计算;二是没缓存上次命中结果,连续几帧查同一个 cue 还是浪费 CPU。最后加了简单的时间窗口判断:Math.abs(currentTime - lastTime) < 0.1 就直接返回缓存。

播放状态同步的诡异行为

另一个头疼的是「播放进度同步」。需求是用户在手机 A 播放到 2:15,切到平板 B,应该从 2:15 继续播。我们用的是后端接口轮询 + localStorage 缓存双保险。

但上线后发现,iOS 微信里经常出现「明明保存了 2:15,加载后却从 0 开始播」。排查半天,发现是 video.load() 调用时机问题:在设置 src 后立刻 load(),然后马上 currentTime = 135,但此时 video 的 readyState 是 0,currentTime 设置无效,且不报错。

解决方案很简单粗暴:监听 loadedmetadata,等元数据加载完再设 time:

video.src = 'https://jztheme.com/videos/course-001.mp4';
video.addEventListener('loadedmetadata', () => {
  video.currentTime = savedTime || 0;
  if (shouldAutoPlay) {
    video.play().catch(e => console.warn('auto play failed:', e));
  }
});

不过这个方案在部分安卓低版本 WebView 里还是偶发失败(比如 OPPO 旧机型),最后妥协加了个 fallback:如果 1 秒后 currentTime 还是 0,就再手动 set 一次——虽然不优雅,但用户感知不到,也算过关了。

最终的解决方案

现在整个播放器是这样的结构:

  • 底层用原生 <video>,不引入任何第三方库(避免体积和兼容性叠加)
  • 自定义控件层完全接管 UI,隐藏原生 controls,用 CSS 实现进度条拖拽(注意 touch-action: none 防止 iOS 滚动冲突)
  • 字幕层用 <div class="subtitle"> 渲染,不依赖 <track>,方便样式定制和多语言切换
  • 播放状态用 localStorage + 30s 节流上报后端,断网时也能恢复最近一次记录

核心播放逻辑就这几行,跑得还算稳:

<video 
  id="main-video" 
  webkit-playsinline 
  playsinline 
  x5-playsinline 
  preload="metadata">
  <source src="https://jztheme.com/videos/course-001.mp4" type="video/mp4">
</video>
<div class="subtitle" id="subtitle"></div>
const video = document.getElementById('main-video');
const subtitleEl = document.getElementById('subtitle');

video.addEventListener('timeupdate', () => {
  const cue = findCurrentCue(cues, video.currentTime);
  subtitleEl.textContent = cue?.text || '';
});

// 手动拖拽进度条时,暂停再设置时间,避免跳帧
document.querySelector('.progress-bar').addEventListener('input', e => {
  video.pause();
  video.currentTime = parseFloat(e.target.value);
});

回顾与反思

这个播放器上线三个月,崩溃率低于 0.02%,用户反馈最多的是“字幕偶尔延迟半秒”,查了是 VTT 解析后二分查找耗时波动导致的,但实测平均在 0.8ms 内,不影响使用,就没再深挖——技术债要还,但得排优先级。

做得好的地方:没上 Video.js 或 Plyr,省了 300KB+ JS,首屏加载快;自定义控件和业务逻辑耦合紧,加个“章节弹窗”、“答题插入点”都很容易扩展;所有兼容性处理都写了单元测试(用 Jest + JSDOM 模拟不同 readyState)。

还能优化的:倍速切换目前是改 playbackRate,但 iOS Safari 不支持非 0.5~2.0 的值,比如 0.75 直接 fallback 到 1,这点还没补兼容方案;另外 WebVTT 的 CSS 样式支持有限,比如不能单独给某一行字幕加背景色,只能靠 JS 拆成 span 渲染——但用户没提,暂时搁置。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的字幕渲染方案,或者知道怎么绕过 iOS 倍速限制,欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论