录制文件播放:自定义视频播放器

好,我们进入第25章。前面我们搞定了录制和截图,现在轮到播放了。你可能会想:「播放视频而已,用浏览器自带的<video>标签不就行了?」嗯,理论上可以,但实际项目中你会发现——默认播放器丑、功能少、没法定制。尤其我们要叠加字幕、控制倍速,原生控件根本不够用。

所以这一章,我带你手写一个自定义播放器。说白了,就是把播放/暂停、进度条、倍速、字幕这些功能,全部自己控制。听起来复杂?其实核心就几个API。

播放器整体架构

先看一张图,理清播放器的模块关系:

自定义视频播放器架构 视频渲染层 播放/暂停控制 进度条控制 倍速切换 字幕叠加层 音量控制 全屏切换 事件总线 / 状态管理

你看,所有控制模块都通过事件总线与视频渲染层通信。这样做的好处是——每个模块可以独立开发、独立测试。我在项目中就吃过亏,一开始把所有逻辑写在一个大函数里,后来改倍速功能时差点把进度条搞崩。所以,模块化是必须的。

核心实现:播放/暂停

先来最简单的。播放和暂停,本质上就是调用 video.play()video.pause()。但我们要做的是——用一个按钮切换两种状态。

// 播放器核心状态
const player = {
  video: document.getElementById('myVideo'),
  isPlaying: false,
  playBtn: document.getElementById('playBtn'),
  
  togglePlay() {
    if (this.isPlaying) {
      this.video.pause();
      this.playBtn.textContent = '▶';
    } else {
      this.video.play();
      this.playBtn.textContent = '⏸';
    }
    this.isPlaying = !this.isPlaying;
  },
  
  init() {
    this.playBtn.addEventListener('click', () => this.togglePlay());
    // 处理视频播放结束
    this.video.addEventListener('ended', () => {
      this.isPlaying = false;
      this.playBtn.textContent = '▶';
    });
  }
};

player.init();
小技巧: 我习惯用 video.paused 属性来判断状态,而不是自己维护一个 isPlaying 变量。但如果你要扩展功能(比如做播放列表),自己维护状态会更灵活。

进度条控制

进度条是播放器里最容易出bug的地方。为什么?因为有两个方向的数据流:

  • 视频播放时:进度条要跟着走(timeupdate 事件)
  • 用户拖拽进度条时:视频要跳到对应位置(input 事件)

这两者如果处理不好,就会「打架」。我曾经遇到过一个bug:用户拖拽进度条时,timeupdate 事件又把进度条拉回去了,导致进度条来回抖动。解决方案很简单——加一个锁:

let isSeeking = false;

progressBar.addEventListener('input', (e) => {
  isSeeking = true;
  const percent = e.target.value;
  const time = (percent / 100) * video.duration;
  video.currentTime = time;
});

video.addEventListener('timeupdate', () => {
  if (!isSeeking) {
    const percent = (video.currentTime / video.duration) * 100;
    progressBar.value = percent;
  }
});

progressBar.addEventListener('change', () => {
  isSeeking = false;
});
注意: input 事件在拖拽过程中会频繁触发,而 change 事件只在松开鼠标时触发。所以用 change 来解锁是最合适的。

倍速播放

倍速播放其实就一行代码:video.playbackRate = 2。但实际项目中,我们需要提供一个选择器,让用户选 0.5x、1x、1.5x、2x 等。

我建议用 <select> 或者一组按钮来实现。这里我展示按钮方案:

const speeds = [0.5, 1, 1.5, 2];
let currentSpeed = 1;

speedBtns.forEach(btn => {
  btn.addEventListener('click', () => {
    const rate = parseFloat(btn.dataset.speed);
    video.playbackRate = rate;
    currentSpeed = rate;
    
    // 高亮当前选中的按钮
    speedBtns.forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
  });
});

避坑指南: 我曾经遇到过一个问题——倍速播放时音频会变调。后来发现是浏览器默认的音频处理方式导致的。解决方案是:在 <video> 标签上加 preservesPitch="true" 属性。这样即使 2 倍速,声音也不会变成「小黄人」。

字幕叠加

字幕叠加有两种方式:

  1. WebVTT 字幕轨道:原生支持,但样式定制有限
  2. 自定义叠加层:完全控制样式,但需要自己解析字幕文件

我个人更推荐第二种,尤其是你要做直播录制回放时。因为直播的字幕往往是实时生成的,没法提前做成 VTT 文件。

下面是一个简单的字幕叠加实现:

// 假设字幕数据格式:[{start: 0, end: 5, text: '你好'}, ...]
const subtitles = [
  { start: 0, end: 3, text: '欢迎收看 WebRTC 实战课程' },
  { start: 3, end: 6, text: '今天我们讲自定义播放器' },
  { start: 6, end: 10, text: '字幕叠加其实很简单' }
];

const subtitleEl = document.getElementById('subtitle');

video.addEventListener('timeupdate', () => {
  const currentTime = video.currentTime;
  const activeSub = subtitles.find(
    sub => currentTime >= sub.start && currentTime <= sub.end
  );
  
  if (activeSub) {
    subtitleEl.textContent = activeSub.text;
    subtitleEl.style.display = 'block';
  } else {
    subtitleEl.style.display = 'none';
  }
});
性能优化: 如果字幕数据很大(比如一部电影),用 find 遍历会卡顿。我建议用二分查找,或者维护一个「当前字幕索引」变量,每次只检查前后几条。

完整播放器代码结构

最后,我把所有模块整合成一个类。这样代码更清晰:

class CustomPlayer {
  constructor(videoEl) {
    this.video = videoEl;
    this.subtitles = [];
    this.initControls();
    this.initEvents();
  }
  
  initControls() {
    // 初始化所有控制按钮
    this.playBtn = document.querySelector('.play-btn');
    this.progressBar = document.querySelector('.progress-bar');
    this.speedBtns = document.querySelectorAll('.speed-btn');
    this.subtitleEl = document.querySelector('.subtitle-overlay');
  }
  
  initEvents() {
    // 绑定所有事件
    this.playBtn.addEventListener('click', () => this.togglePlay());
    this.video.addEventListener('timeupdate', () => this.updateProgress());
    this.video.addEventListener('timeupdate', () => this.updateSubtitle());
    // ... 其他事件
  }
  
  togglePlay() { /* 前面实现 */ }
  updateProgress() { /* 前面实现 */ }
  updateSubtitle() { /* 前面实现 */ }
  setSpeed(rate) { /* 前面实现 */ }
}

const player = new CustomPlayer(document.getElementById('myVideo'));

嗯,到这里,一个完整的自定义播放器就搭好了。你可能会问:「为什么不用现成的库,比如 Video.js?」我的回答是:如果你只需要基础功能,用库没问题。但如果你要做 WebRTC 录制回放、叠加实时字幕、对接自己的信令系统,自己写一个反而更可控。

我在做某个在线教育项目时,就遇到过 Video.js 和 WebRTC 的兼容性问题——播放器内部的事件循环和 WebRTC 的 MediaStream 冲突了。最后花了三天才定位到问题。从那以后,凡是涉及 WebRTC 的播放器,我都自己写。

这一章的内容就到这里。代码都在上面了,你可以直接复制到项目里试试。有问题的话,欢迎在评论区交流。


公众号:蓝海资料掘金营,微信deep3321