第九章:自定义播放器开发

浏览器原生的 <video> 控件在不同平台上样式各异,且功能有限——没有缩略图预览、没有画质切换面板、没有倍速菜单,更无法与业务 UI 深度融合。自定义播放器的本质是用原生 <video> 元素承载媒体能力,用 HTML/CSS/JS 重新构建整个交互层。本章从架构设计出发,逐步实现控制栏、进度条(含缩略图预览)、功能面板、手势系统,最终形成一套可扩展的插件化播放器框架。


第一部分:架构设计

隐藏原生控件,接管交互层

第一步是隐藏浏览器默认的控件。去掉 controls 属性即可隐藏原生 UI,但需要注意:即便没有 controls,在 iOS Safari 上视频仍然可能显示原生的全屏按钮或 AirPlay 按钮。通过 CSS 可以进一步屏蔽这些残留控件:

/* 隐藏 WebKit 原生媒体控件 */
video::-webkit-media-controls            { display: none !important; }
video::-webkit-media-controls-enclosure  { display: none !important; }
video::-webkit-media-controls-panel      { display: none !important; }
 
/* 防止 iOS 全屏按钮出现 */
video::-webkit-media-controls-fullscreen-button { display: none !important; }

整个播放器的 DOM 结构分为两层:底层是 <video> 元素,负责媒体解码和渲染;上层是完全由 HTML 构建的交互覆盖层(overlay),包含控制栏、加载指示器、错误提示、弹幕层等所有自定义 UI。

<div class="xplayer" id="player">
  <!-- 媒体层 -->
  <video class="xplayer__video" playsinline webkit-playsinline></video>
 
  <!-- 交互覆盖层 -->
  <div class="xplayer__overlay">
    <!-- 中央点击区域(播放/暂停) -->
    <div class="xplayer__click-area"></div>
 
    <!-- 加载指示器 -->
    <div class="xplayer__loading" hidden>
      <div class="xplayer__spinner"></div>
    </div>
 
    <!-- 控制栏(底部) -->
    <div class="xplayer__controls">
      <div class="xplayer__progress-bar">...</div>
      <div class="xplayer__buttons-row">...</div>
    </div>
  </div>
</div>

核心状态机设计

播放器在任意时刻都处于某个明确的状态,所有 UI 的显示/隐藏都由状态驱动,而不是分散在各个事件回调中。这是避免 UI 状态混乱的关键设计原则,也是 video.js、xgplayer 等开源播放器的共同选择。juejin.cn

// 播放器状态枚举
const PlayerState = {
  IDLE:      'idle',      // 初始状态,未加载
  LOADING:   'loading',   // 加载中
  READY:     'ready',     // 已就绪,可以播放
  PLAYING:   'playing',   // 播放中
  PAUSED:    'paused',    // 已暂停
  BUFFERING: 'buffering', // 缓冲等待
  ENDED:     'ended',     // 播放结束
  ERROR:     'error',     // 错误
};
 
class PlayerStateMachine {
  constructor() {
    this._state    = PlayerState.IDLE;
    this._listeners = new Map();
  }
 
  get state() { return this._state; }
 
  transition(newState) {
    const prevState = this._state;
    if (prevState === newState) return;
 
    this._state = newState;
    this._emit('statechange', { from: prevState, to: newState });
    this._emit(newState, { from: prevState });
  }
 
  on(event, handler) {
    if (!this._listeners.has(event)) {
      this._listeners.set(event, new Set());
    }
    this._listeners.get(event).add(handler);
    return () => this._listeners.get(event).delete(handler); // 返回取消订阅函数
  }
 
  _emit(event, data) {
    this._listeners.get(event)?.forEach(fn => fn(data));
  }
}

播放器核心类

&lt;video&gt; 元素的事件系统与状态机连接起来,形成播放器的核心驱动逻辑:

class XPlayer {
  constructor(containerEl, options = {}) {
    this.container = containerEl;
    this.options   = {
      autoplay:  false,
      muted:     false,
      loop:      false,
      volume:    1,
      playbackRate: 1,
      ...options
    };
 
    this.sm = new PlayerStateMachine(); // 状态机
    this._plugins = new Map();          // 插件注册表
    this._abortCtrl = new AbortController();
 
    this._initDOM();
    this._bindVideoEvents();
    this._initPlugins();
  }
 
  _initDOM() {
    this.container.classList.add('xplayer');
    this.container.innerHTML = `
      <video class="xplayer__video"
        ?autoplay=""
        ?muted=""
        playsinline webkit-playsinline>
      </video>
      <div class="xplayer__overlay" id="overlay"></div>
    `;
    this.video   = this.container.querySelector('.xplayer__video');
    this.overlay = this.container.querySelector('.xplayer__overlay');
 
    // 应用初始配置
    if (this.options.muted)   this.video.muted = true;
    if (this.options.autoplay) this.video.autoplay = true;
    if (this.options.loop)    this.video.loop = true;
    this.video.volume       = this.options.volume;
    this.video.playbackRate = this.options.playbackRate;
  }
 
  _bindVideoEvents() {
    const v = this.video;
    const sig = this._abortCtrl.signal;
    const { sm } = this;
 
    v.addEventListener('loadstart',    () => sm.transition(PlayerState.LOADING),   { signal: sig });
    v.addEventListener('canplay',      () => sm.transition(PlayerState.READY),     { signal: sig });
    v.addEventListener('play',         () => sm.transition(PlayerState.PLAYING),   { signal: sig });
    v.addEventListener('playing',      () => sm.transition(PlayerState.PLAYING),   { signal: sig });
    v.addEventListener('pause',        () => sm.transition(PlayerState.PAUSED),    { signal: sig });
    v.addEventListener('waiting',      () => sm.transition(PlayerState.BUFFERING), { signal: sig });
    v.addEventListener('ended',        () => sm.transition(PlayerState.ENDED),     { signal: sig });
    v.addEventListener('error',        () => sm.transition(PlayerState.ERROR),     { signal: sig });
    v.addEventListener('timeupdate',   () => this._emit('timeupdate'),             { signal: sig });
    v.addEventListener('volumechange', () => this._emit('volumechange'),           { signal: sig });
  }
 
  // 公开 API
  play()   { return this.video.play(); }
  pause()  { this.video.pause(); }
  toggle() { this.video.paused ? this.play() : this.pause(); }
 
  seek(time) {
    this.video.currentTime = Math.max(0, Math.min(time, this.video.duration || 0));
  }
 
  setVolume(v) {
    this.video.volume = Math.max(0, Math.min(1, v));
    this.video.muted  = (v === 0);
  }
 
  setPlaybackRate(rate) {
    this.video.playbackRate = rate;
  }
 
  setSrc(src) {
    this.video.src = src;
    this.video.load();
  }
 
  get currentTime()  { return this.video.currentTime; }
  get duration()     { return this.video.duration || 0; }
  get paused()       { return this.video.paused; }
  get buffered()     { return this.video.buffered; }
 
  // 插件系统
  use(Plugin) {
    const instance = new Plugin(this);
    this._plugins.set(Plugin.pluginName || Plugin.name, instance);
    instance.init?.();
    return this;
  }
 
  getPlugin(name) {
    return this._plugins.get(name);
  }
 
  _emit(event, data) {
    this.container.dispatchEvent(new CustomEvent(`xplayer:`, { detail: data, bubbles: true }));
  }
 
  destroy() {
    this._abortCtrl.abort();
    this._plugins.forEach(p => p.destroy?.());
    this.video.src = '';
    this.video.load();
  }
}

第二部分:控制栏实现

控制栏自动隐藏机制

控制栏在用户无操作时自动隐藏、鼠标移动或触摸时显示,是播放器最基础的交互设计。实现的关键是用防抖计时器管理隐藏延迟,并处理好"鼠标悬停在控制栏上时不隐藏"的细节:

class ControlsPlugin {
  static pluginName = 'controls';
 
  constructor(player) {
    this.player    = player;
    this.container = player.container;
    this._hideTimer = null;
    this._isControlsHovered = false;
    this._visible = false;
  }
 
  init() {
    this._render();
    this._bindEvents();
    this._bindStateEvents();
  }
 
  _render() {
    this.el = document.createElement('div');
    this.el.className = 'xplayer__controls';
    this.el.innerHTML = `
      <div class="xplayer__progress-wrap">
        <div class="xplayer__progress-track">
          <div class="xplayer__progress-buffer"></div>
          <div class="xplayer__progress-played"></div>
          <div class="xplayer__thumb"></div>
          <div class="xplayer__preview-tooltip" hidden>
            <canvas class="xplayer__preview-canvas"></canvas>
            <span class="xplayer__preview-time"></span>
          </div>
        </div>
      </div>
      <div class="xplayer__btns">
        <button class="xplayer__btn xplayer__play-btn" aria-label="播放">
          <svg class="icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
          <svg class="icon-pause" viewBox="0 0 24 24" hidden><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
        </button>
        <div class="xplayer__volume-wrap">
          <button class="xplayer__btn xplayer__mute-btn" aria-label="静音"></button>
          <div class="xplayer__volume-slider">
            <div class="xplayer__volume-track">
              <div class="xplayer__volume-fill"></div>
              <div class="xplayer__volume-thumb"></div>
            </div>
          </div>
        </div>
        <span class="xplayer__time">
          <span class="xplayer__time-current">0:00</span>
          <span> / </span>
          <span class="xplayer__time-duration">0:00</span>
        </span>
        <div class="xplayer__right-btns">
          <button class="xplayer__btn xplayer__speed-btn" aria-label="倍速">1x</button>
          <button class="xplayer__btn xplayer__quality-btn" aria-label="画质">HD</button>
          <button class="xplayer__btn xplayer__fullscreen-btn" aria-label="全屏"></button>
        </div>
      </div>
    `;
    this.player.overlay.appendChild(this.el);
  }
 
  _bindEvents() {
    const overlay   = this.player.overlay;
    const controls  = this.el;
    const sig       = this.player._abortCtrl.signal;
 
    // 鼠标移动/触摸:显示控制栏并重置计时器
    overlay.addEventListener('mousemove',  () => this._showAndScheduleHide(), { signal: sig });
    overlay.addEventListener('touchstart', () => this._showAndScheduleHide(), { signal: sig, passive: true });
 
    // 鼠标离开播放器区域:立即启动隐藏计时器
    overlay.addEventListener('mouseleave', () => this._scheduleHide(1000), { signal: sig });
 
    // 鼠标悬停在控制栏上:阻止隐藏
    controls.addEventListener('mouseenter', () => { this._isControlsHovered = true;  this._cancelHide(); }, { signal: sig });
    controls.addEventListener('mouseleave', () => { this._isControlsHovered = false; this._scheduleHide(2000); }, { signal: sig });
 
    // 点击中央区域:切换播放/暂停
    this.player.overlay.addEventListener('click', (e) => {
      if (e.target.closest('.xplayer__controls')) return;
      this.player.toggle();
    }, { signal: sig });
  }
 
  _bindStateEvents() {
    const { sm } = this.player;
    const playBtn = this.el.querySelector('.xplayer__play-btn');
 
    sm.on(PlayerState.PLAYING, () => {
      playBtn.querySelector('.icon-play').hidden  = true;
      playBtn.querySelector('.icon-pause').hidden = false;
      this._scheduleHide(3000);
    });
 
    sm.on(PlayerState.PAUSED, () => {
      playBtn.querySelector('.icon-play').hidden  = false;
      playBtn.querySelector('.icon-pause').hidden = true;
      this._cancelHide();
      this._show();
    });
 
    sm.on(PlayerState.ENDED, () => {
      this._show();
      this._cancelHide();
    });
 
    // timeupdate:更新时间显示和进度条
    this.player.container.addEventListener(`xplayer:timeupdate`, () => {
      this._updateProgress();
    });
  }
 
  _show() {
    this.el.classList.add('is-visible');
    this.player.container.classList.remove('xplayer--hide-cursor');
    this._visible = true;
  }
 
  _hide() {
    if (this._isControlsHovered) return;
    this.el.classList.remove('is-visible');
    if (!this.player.paused) {
      this.player.container.classList.add('xplayer--hide-cursor');
    }
    this._visible = false;
  }
 
  _showAndScheduleHide() {
    this._show();
    this._scheduleHide(3000);
  }
 
  _scheduleHide(delay) {
    this._cancelHide();
    this._hideTimer = setTimeout(() => this._hide(), delay);
  }
 
  _cancelHide() {
    clearTimeout(this._hideTimer);
    this._hideTimer = null;
  }
 
  _updateProgress() {
    const { currentTime, duration } = this.player;
    if (!duration) return;
 
    const pct = (currentTime / duration) * 100;
    this.el.querySelector('.xplayer__progress-played').style.width = `%`;
    this.el.querySelector('.xplayer__thumb').style.left = `%`;
 
    // 更新缓冲进度
    const buffered = this.player.buffered;
    if (buffered.length > 0) {
      const bufPct = (buffered.end(buffered.length - 1) / duration) * 100;
      this.el.querySelector('.xplayer__progress-buffer').style.width = `%`;
    }
 
    // 更新时间显示
    this.el.querySelector('.xplayer__time-current').textContent = formatTime(currentTime);
    this.el.querySelector('.xplayer__time-duration').textContent = formatTime(duration);
  }
 
  destroy() {
    this._cancelHide();
    this.el.remove();
  }
}
 
// 时间格式化工具
function formatTime(seconds) {
  if (isNaN(seconds)) return '0:00';
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
  const mm = String(m).padStart(h > 0 ? 2 : 1, '0');
  const ss = String(s).padStart(2, '0');
  return h > 0 ? `` : ``;
}

对应的 CSS 核心样式:

.xplayer {
  position: relative;
  overflow: hidden;
  background: #000;
  user-select: none;
}
 
.xplayer--hide-cursor { cursor: none; }
 
.xplayer__video {
  width: 100%;
  height: 100%;
  display: block;
}
 
.xplayer__overlay {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
}
 
/* 控制栏淡入淡出 */
.xplayer__controls {
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.25s ease, transform 0.25s ease;
  pointer-events: none;
  background: linear-gradient(transparent, rgba(0,0,0,0.75));
  padding: 24px 12px 12px;
}
 
.xplayer__controls.is-visible {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

第三部分:进度条与缩略图预览

进度条拖拽:鼠标与触摸的统一处理

进度条拖拽需要同时支持鼠标(mousedown/mousemove/mouseup)和触摸(touchstart/touchmove/touchend)两套事件。关键点在于:拖拽过程中要阻止视频跳转(避免频繁 seek 造成卡顿),只在松手时执行最终 seek。

class ProgressPlugin {
  static pluginName = 'progress';
 
  constructor(player) {
    this.player    = player;
    this._dragging = false;
    this._previewTime = 0;
  }
 
  init() {
    this.track   = this.player.overlay.querySelector('.xplayer__progress-track');
    this.tooltip = this.player.overlay.querySelector('.xplayer__preview-tooltip');
    this.previewCanvas = this.tooltip.querySelector('.xplayer__preview-canvas');
    this.previewTimeEl = this.tooltip.querySelector('.xplayer__preview-time');
 
    this._bindDrag();
    this._bindHover();
  }
 
  _bindDrag() {
    const track = this.track;
 
    // 鼠标事件
    track.addEventListener('mousedown', (e) => {
      e.preventDefault();
      this._startDrag(this._getPositionFromEvent(e));
 
      const onMove = (e) => this._onDragMove(this._getPositionFromEvent(e));
      const onUp   = (e) => {
        this._endDrag(this._getPositionFromEvent(e));
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup', onUp);
      };
 
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    });
 
    // 触摸事件
    track.addEventListener('touchstart', (e) => {
      e.preventDefault();
      this._startDrag(this._getPositionFromEvent(e.touches[0]));
    }, { passive: false });
 
    track.addEventListener('touchmove', (e) => {
      e.preventDefault();
      this._onDragMove(this._getPositionFromEvent(e.touches[0]));
    }, { passive: false });
 
    track.addEventListener('touchend', (e) => {
      this._endDrag(this._getPositionFromEvent(e.changedTouches[0]));
    });
  }
 
  _getPositionFromEvent(e) {
    const rect = this.track.getBoundingClientRect();
    const x    = e.clientX - rect.left;
    return Math.max(0, Math.min(1, x / rect.width)); // 归一化到 [0, 1]
  }
 
  _startDrag(ratio) {
    this._dragging = true;
    this.player.container.classList.add('xplayer--dragging');
    this._updatePreview(ratio);
  }
 
  _onDragMove(ratio) {
    if (!this._dragging) return;
    // 拖拽过程中只更新 UI,不执行 seek(避免频繁网络请求)
    this._updateDragUI(ratio);
    this._updatePreview(ratio);
  }
 
  _endDrag(ratio) {
    if (!this._dragging) return;
    this._dragging = false;
    this.player.container.classList.remove('xplayer--dragging');
 
    // 松手时执行最终 seek
    const targetTime = ratio * this.player.duration;
    this.player.seek(targetTime);
    this.tooltip.hidden = true;
  }
 
  _updateDragUI(ratio) {
    const pct = ratio * 100;
    this.track.querySelector('.xplayer__progress-played').style.width = `%`;
    this.track.querySelector('.xplayer__thumb').style.left = `%`;
  }
 
  // ─── 缩略图预览 ────────────────────────────────────────────
  _bindHover() {
    this.track.addEventListener('mousemove', (e) => {
      const ratio = this._getPositionFromEvent(e);
      this._updatePreview(ratio);
      this.tooltip.hidden = false;
    });
 
    this.track.addEventListener('mouseleave', () => {
      if (!this._dragging) this.tooltip.hidden = true;
    });
  }
 
  _updatePreview(ratio) {
    const time = ratio * this.player.duration;
    if (isNaN(time)) return;
 
    this._previewTime = time;
    this.previewTimeEl.textContent = formatTime(time);
 
    // 定位 tooltip(防止超出边界)
    const trackRect  = this.track.getBoundingClientRect();
    const tooltipW   = 160; // tooltip 宽度
    const x          = ratio * trackRect.width;
    const left       = Math.max(0, Math.min(x - tooltipW / 2, trackRect.width - tooltipW));
    this.tooltip.style.left = `px`;
 
    // 绘制预览帧(见下节)
    this._drawPreviewFrame(time);
  }
 
  _drawPreviewFrame(time) {
    // 使用隐藏 video 元素截帧(详见下方实现)
    if (!this._previewVideo) return;
 
    // 设置预览 video 的时间,等待 seeked 后绘制到 canvas
    this._previewVideo.currentTime = time;
  }
 
  // 初始化预览 video(在 ProgressPlugin 外部调用)
  initPreviewVideo(src) {
    const previewVideo = document.createElement('video');
    previewVideo.src     = src;
    previewVideo.preload = 'auto';
    previewVideo.muted   = true;
    previewVideo.style.display = 'none';
    document.body.appendChild(previewVideo);
 
    previewVideo.addEventListener('seeked', () => {
      const ctx = this.previewCanvas.getContext('2d');
      this.previewCanvas.width  = 160;
      this.previewCanvas.height = 90;
      ctx.drawImage(previewVideo, 0, 0, 160, 90);
    });
 
    this._previewVideo = previewVideo;
  }
 
  destroy() {
    this._previewVideo?.remove();
  }
}

基于 VTT 雪碧图的高效缩略图预览

上面的方案用第二个 &lt;video&gt; 元素实时截帧,对带宽消耗较大。生产环境更常用的方案是预先生成雪碧图(Sprite Sheet)+ VTT 索引文件。服务端每隔固定时间(如每 10 秒)截取一帧,拼合成一张大图,再用 WebVTT 文件描述每个时间点对应雪碧图的坐标。blog.zwt.io volcengine.com

WEBVTT

00:00:00.000 --> 00:00:10.000
thumbnails.jpg#xywh=0,0,160,90

00:00:10.000 --> 00:00:20.000
thumbnails.jpg#xywh=160,0,160,90

00:00:20.000 --> 00:00:30.000
thumbnails.jpg#xywh=320,0,160,90

前端解析 VTT 并根据当前时间查找对应坐标,用 background-position 显示雪碧图的对应区域:

class ThumbnailPlugin {
  static pluginName = 'thumbnail';
 
  constructor(player) {
    this.player  = player;
    this.cues    = []; // VTT 解析后的 cue 列表
  }
 
  async loadVTT(vttUrl) {
    const text = await fetch(vttUrl).then(r => r.text());
    this.cues   = this._parseVTT(text);
  }
 
  _parseVTT(text) {
    const cues = [];
    const lines = text.split('\n');
    let i = 0;
 
    while (i < lines.length) {
      const line = lines[i].trim();
 
      // 查找时间行(格式:00:00:00.000 --> 00:00:10.000)
      if (line.includes('-->')) {
        const [startStr, endStr] = line.split('-->').map(s => s.trim());
        const url = lines[i + 1]?.trim();
 
        if (url) {
          const [src, hash] = url.split('#');
          const xywh = hash?.replace('xywh=', '').split(',').map(Number);
 
          cues.push({
            start: this._parseTime(startStr),
            end:   this._parseTime(endStr),
            src,
            x:     xywh?.[0] ?? 0,
            y:     xywh?.[1] ?? 0,
            w:     xywh?.[2] ?? 160,
            h:     xywh?.[3] ?? 90,
          });
        }
      }
      i++;
    }
 
    return cues;
  }
 
  _parseTime(str) {
    const parts = str.split(':').map(parseFloat);
    if (parts.length === 3) {
      return parts[0] * 3600 + parts[1] * 60 + parts[2];
    }
    return parts[0] * 60 + parts[1];
  }
 
  // 根据时间获取对应的缩略图信息
  getCueAt(time) {
    return this.cues.find(c => time >= c.start && time < c.end) || null;
  }
 
  // 将缩略图绘制到指定 DOM 元素
  renderToEl(el, time) {
    const cue = this.getCueAt(time);
    if (!cue) return;
 
    el.style.cssText = `
      width: px;
      height: px;
      background-image: url();
      background-position: -px -px;
      background-size: auto;
    `;
  }
}

第四部分:功能面板

倍速控制面板

  _setRate(rate) {
    this.current = rate;
    this.player.setPlaybackRate(rate);
    this.btn.textContent = rate === 1 ? '1x' : `${rate}x`;
 
    // 更新选中状态
    this.panel.querySelectorAll('.xplayer__panel-item').forEach(el => {
      el.classList.toggle('is-active', parseFloat(el.dataset.rate) === rate);
    });
  }
 
  _togglePanel() {
    this.panel.hidden = !this.panel.hidden;
  }
 
  _closePanel() {
    this.panel.hidden = true;
  }
 
  destroy() {
    this.panel.remove();
  }
}

画质切换面板

画质切换的核心难点是切换时需要保持播放进度和播放状态不丢失。切换 src 会触发 loadstart,播放器会短暂进入 LOADING 状态,需要在 loadedmetadata 后恢复 currentTime,再根据切换前的状态决定是否继续播放:

class QualityPlugin {
  static pluginName = 'quality';
 
  constructor(player) {
    this.player   = player;
    this.levels   = []; // [{ label: '1080P', src: '...' }, ...]
    this.current  = null;
  }
 
  init() {
    this.btn   = this.player.overlay.querySelector('.xplayer__quality-btn');
    this.panel = this._createPanel();
    this.player.overlay.appendChild(this.panel);
 
    this.btn.addEventListener('click', (e) => {
      e.stopPropagation();
      this._togglePanel();
    });
 
    document.addEventListener('click', () => this._closePanel());
  }
 
  setLevels(levels, defaultLabel) {
    this.levels  = levels;
    this.current = levels.find(l => l.label === defaultLabel) || levels[0];
 
    // 重新渲染面板
    this._renderItems();
    this.btn.textContent = this.current?.label || 'AUTO';
  }
 
  _createPanel() {
    const panel = document.createElement('div');
    panel.className = 'xplayer__panel xplayer__quality-panel';
    panel.hidden    = true;
    return panel;
  }
 
  _renderItems() {
    this.panel.innerHTML = this.levels.map(l => `
      <div class="xplayer__panel-item ${l === this.current ? 'is-active' : ''}"
           data-label="${l.label}">
        ${l.label}
      </div>
    `).join('');
 
    this.panel.addEventListener('click', (e) => {
      const item = e.target.closest('.xplayer__panel-item');
      if (!item) return;
      const level = this.levels.find(l => l.label === item.dataset.label);
      if (level && level !== this.current) {
        this._switchTo(level);
      }
      this._closePanel();
    });
  }
 
  _switchTo(level) {
    const video      = this.player.video;
    const savedTime  = this.player.currentTime;
    const wasPaused  = this.player.paused;
 
    this.current = level;
    this.btn.textContent = level.label;
 
    // 切换 src
    video.src = level.src;
    video.load();
 
    // 恢复进度和播放状态
    video.addEventListener('loadedmetadata', () => {
      video.currentTime = savedTime;
      if (!wasPaused) {
        this.player.play().catch(() => {});
      }
    }, { once: true });
 
    // 更新选中态
    this.panel.querySelectorAll('.xplayer__panel-item').forEach(el => {
      el.classList.toggle('is-active', el.dataset.label === level.label);
    });
  }
 
  _togglePanel() { this.panel.hidden = !this.panel.hidden; }
  _closePanel()  { this.panel.hidden = true; }
  destroy()      { this.panel.remove(); }
}

字幕面板

字幕功能基于 HTML 原生的 &lt;track&gt; 元素和 TextTrack API,不需要自己解析 VTT——浏览器已经内置了 WebVTT 解析器。自定义播放器需要做的是:隐藏浏览器默认的字幕渲染(样式不可控),改用 JS 监听 cuechange 事件,自行渲染字幕文本:

class SubtitlePlugin {
  static pluginName = 'subtitle';
 
  constructor(player) {
    this.player   = player;
    this.tracks   = []; // [{ label: '中文', src: '...', lang: 'zh' }]
    this.activeTrack = null;
  }
 
  init() {
    // 创建字幕显示层
    this.display = document.createElement('div');
    this.display.className = 'xplayer__subtitle';
    this.player.overlay.insertBefore(
      this.display,
      this.player.overlay.querySelector('.xplayer__controls')
    );
 
    // 创建字幕选择面板(挂载到控制栏)
    this.btn   = this._createBtn();
    this.panel = this._createPanel();
  }
 
  addTrack(config) {
    // 向 video 元素添加 <track>
    const track = document.createElement('track');
    track.kind    = 'subtitles';
    track.label   = config.label;
    track.srclang = config.lang;
    track.src     = config.src;
 
    // 禁用浏览器默认渲染
    track.default = false;
    this.player.video.appendChild(track);
 
    this.tracks.push({ ...config, trackEl: track });
    this._renderPanel();
    return this;
  }
 
  activateTrack(label) {
    const video = this.player.video;
 
    // 禁用所有 TextTrack
    Array.from(video.textTracks).forEach(t => {
      t.mode = 'disabled';
    });
 
    if (label === 'off') {
      this.activeTrack = null;
      this.display.textContent = '';
      return;
    }
 
    const config = this.tracks.find(t => t.label === label);
    if (!config) return;
 
    // 找到对应的 TextTrack 并激活(hidden 模式:解析但不渲染)
    const textTrack = Array.from(video.textTracks)
      .find(t => t.label === label);
 
    if (textTrack) {
      textTrack.mode = 'hidden'; // 解析 cue,但不显示原生字幕
 
      // 监听 cue 变化,自行渲染
      textTrack.addEventListener('cuechange', () => {
        const activeCues = textTrack.activeCues;
        if (activeCues?.length > 0) {
          // 将 VTT cue 的 HTML 内容渲染到自定义层
          this.display.innerHTML = Array.from(activeCues)
            .map(cue => `<p>${cue.text.replace(/\n/g, '<br>')}</p>`)
            .join('');
        } else {
          this.display.innerHTML = '';
        }
      });
 
      this.activeTrack = textTrack;
    }
  }
 
  _createBtn() {
    const btn = document.createElement('button');
    btn.className   = 'xplayer__btn xplayer__subtitle-btn';
    btn.textContent = 'CC';
    btn.setAttribute('aria-label', '字幕');
 
    const rightBtns = this.player.overlay.querySelector('.xplayer__right-btns');
    rightBtns?.insertBefore(btn, rightBtns.firstChild);
 
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      this.panel.hidden = !this.panel.hidden;
    });
 
    return btn;
  }
 
  _createPanel() {
    const panel = document.createElement('div');
    panel.className = 'xplayer__panel xplayer__subtitle-panel';
    panel.hidden    = true;
    this.player.overlay.appendChild(panel);
    return panel;
  }
 
  _renderPanel() {
    const items = [
      { label: 'off', display: '关闭' },
      ...this.tracks.map(t => ({ label: t.label, display: t.label }))
    ];
 
    this.panel.innerHTML = items.map(item => `
      <div class="xplayer__panel-item" data-label="${item.label}">
        ${item.display}
      </div>
    `).join('');
 
    this.panel.addEventListener('click', (e) => {
      const item = e.target.closest('.xplayer__panel-item');
      if (!item) return;
      this.activateTrack(item.dataset.label);
      this.panel.querySelectorAll('.xplayer__panel-item').forEach(el => {
        el.classList.toggle('is-active', el.dataset.label === item.dataset.label);
      });
      this.panel.hidden = true;
    });
  }
 
  destroy() {
    this.display.remove();
    this.panel.remove();
    this.btn.remove();
  }
}

字幕显示层的 CSS,确保字幕居中显示且不遮挡控制栏:

.xplayer__subtitle {
  position: absolute;
  bottom: 64px;   /* 控制栏高度 + 间距 */
  left: 50%;
  transform: translateX(-50%);
  text-align: center;
  pointer-events: none;
  z-index: 10;
  max-width: 80%;
}
 
.xplayer__subtitle p {
  display: inline-block;
  background: rgba(0, 0, 0, 0.75);
  color: #fff;
  font-size: 16px;
  line-height: 1.5;
  padding: 2px 8px;
  border-radius: 2px;
  margin: 2px 0;
}

第五部分:手势系统(移动端)

移动端播放器需要支持一套完整的手势操作:单击切换控制栏显示/隐藏、双击播放/暂停、左右滑动快进快退、左侧上下滑动调节亮度、右侧上下滑动调节音量。这套手势系统的实现关键是区分单击与双击(需要等待 300ms 判断是否有第二次点击),以及区分横向与纵向滑动(根据初始滑动方向锁定手势类型)。

class GesturePlugin {
  static pluginName = 'gesture';
 
  constructor(player) {
    this.player = player;
 
    // 手势状态
    this._touch      = null;   // 起始触摸信息
    this._gestureType = null;  // 'seek' | 'volume' | 'brightness'
    this._tapTimer   = null;
    this._tapCount   = 0;
 
    // 反馈提示元素
    this._indicator  = null;
  }
 
  init() {
    this._createIndicator();
    this._bindTouchEvents();
  }
 
  _createIndicator() {
    this.indicator = document.createElement('div');
    this.indicator.className = 'xplayer__gesture-indicator';
    this.indicator.hidden = true;
    this.player.overlay.appendChild(this.indicator);
  }
 
  _bindTouchEvents() {
    const overlay = this.player.overlay;
    const sig     = this.player._abortCtrl.signal;
 
    overlay.addEventListener('touchstart', (e) => {
      // 触摸在控制栏上时不处理手势
      if (e.target.closest('.xplayer__controls')) return;
      this._onTouchStart(e);
    }, { signal: sig, passive: true });
 
    overlay.addEventListener('touchmove', (e) => {
      this._onTouchMove(e);
    }, { signal: sig, passive: false });
 
    overlay.addEventListener('touchend', (e) => {
      this._onTouchEnd(e);
    }, { signal: sig });
  }
 
  _onTouchStart(e) {
    const touch = e.touches[0];
    this._touch = {
      startX:    touch.clientX,
      startY:    touch.clientY,
      startTime: this.player.currentTime,
      startVol:  this.player.video.volume,
      side:      touch.clientX < this.player.container.offsetWidth / 2 ? 'left' : 'right',
      timestamp: Date.now(),
    };
    this._gestureType = null;
  }
 
  _onTouchMove(e) {
    if (!this._touch) return;
 
    const touch  = e.touches[0];
    const dx     = touch.clientX - this._touch.startX;
    const dy     = touch.clientY - this._touch.startY;
    const absDx  = Math.abs(dx);
    const absDy  = Math.abs(dy);
 
    // 移动超过阈值后才锁定手势类型(避免误触)
    if (!this._gestureType && (absDx > 10 || absDy > 10)) {
      if (absDx > absDy) {
        this._gestureType = 'seek';
      } else {
        this._gestureType = this._touch.side === 'right' ? 'volume' : 'brightness';
      }
    }
 
    if (!this._gestureType) return;
    e.preventDefault(); // 锁定手势后阻止页面滚动
 
    if (this._gestureType === 'seek') {
      // 每 100px 对应 10 秒
      const delta = (dx / 100) * 10;
      const target = Math.max(0, Math.min(
        this._touch.startTime + delta,
        this.player.duration
      ));
      this._showIndicator(delta > 0
        ? `▶▶ ${formatTime(target)}`
        : `◀◀ ${formatTime(target)}`
      );
      // 拖拽过程中实时更新进度条 UI(不执行 seek)
      this.player.container.dispatchEvent(new CustomEvent('xplayer:previewseek', {
        detail: { time: target }
      }));
 
    } else if (this._gestureType === 'volume') {
      // 向上滑动增大音量,每 100px 对应 20% 音量
      const delta  = -(dy / 100) * 0.2;
      const newVol = Math.max(0, Math.min(1, this._touch.startVol + delta));
      this.player.setVolume(newVol);
      this._showIndicator(`音量 ${Math.round(newVol * 100)}%`);
 
    } else if (this._gestureType === 'brightness') {
      // 亮度调节(通过 CSS filter 模拟,实际亮度由系统控制)
      const delta  = -(dy / 100) * 0.3;
      const current = parseFloat(this.player.video.style.filter?.match(/brightness\(([\d.]+)\)/)?.[1] || 1);
      const newBrightness = Math.max(0.2, Math.min(2, current + delta));
      this.player.video.style.filter = `brightness(${newBrightness})`;
      this._showIndicator(`亮度 ${Math.round(newBrightness * 100)}%`);
    }
  }
 
  _onTouchEnd(e) {
    if (!this._touch) return;
 
    const dx    = Math.abs(e.changedTouches[0].clientX - this._touch.startX);
    const dy    = Math.abs(e.changedTouches[0].clientY - this._touch.startY);
    const moved = dx > 10 || dy > 10;
 
    if (this._gestureType === 'seek') {
      // 松手时执行最终 seek
      const finalDelta = (e.changedTouches[0].clientX - this._touch.startX) / 100 * 10;
      const target = Math.max(0, Math.min(
        this._touch.startTime + finalDelta,
        this.player.duration
      ));
      this.player.seek(target);
    }
 
    this._hideIndicator();
 
    // 没有发生滑动:判断单击/双击
    if (!moved && !this._gestureType) {
      this._tapCount++;
 
      if (this._tapCount === 1) {
        // 等待 300ms,看是否有第二次点击
        this._tapTimer = setTimeout(() => {
          if (this._tapCount === 1) {
            // 单击:切换控制栏
            this.player.getPlugin('controls')?._showAndScheduleHide();
          }
          this._tapCount = 0;
        }, 300);
 
      } else if (this._tapCount === 2) {
        // 双击:播放/暂停
        clearTimeout(this._tapTimer);
        this._tapCount = 0;
        this.player.toggle();
        this._showDoubleTapFeedback(this._touch.side);
      }
    }
 
    this._touch      = null;
    this._gestureType = null;
  }
 
  _showIndicator(text) {
    this.indicator.textContent = text;
    this.indicator.hidden = false;
  }
 
  _hideIndicator() {
    this.indicator.hidden = true;
  }
 
  _showDoubleTapFeedback(side) {
    // 双击快进/快退的视觉反馈(仿 YouTube 双击效果)
    const el = document.createElement('div');
    el.className = `xplayer__doubletap-feedback xplayer__doubletap-feedback--`;
    el.textContent = this.player.paused ? '暂停' : '播放';
    this.player.overlay.appendChild(el);
    setTimeout(() => el.remove(), 600);
  }
 
  destroy() {
    this.indicator.remove();
    clearTimeout(this._tapTimer);
  }
}

手势反馈指示器的样式:

.xplayer__gesture-indicator {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(0, 0, 0, 0.65);
  color: #fff;
  font-size: 15px;
  padding: 8px 16px;
  border-radius: 4px;
  pointer-events: none;
  white-space: nowrap;
  z-index: 20;
}
 
.xplayer__doubletap-feedback {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: rgba(255,255,255,0.2);
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  font-size: 13px;
  animation: doubletap-fade 0.6s ease forwards;
  pointer-events: none;
}
 
.xplayer__doubletap-feedback--left  { left: 15%; }
.xplayer__doubletap-feedback--right { right: 15%; }
 
@keyframes doubletap-fade {
  0%   { opacity: 1; transform: translateY(-50%) scale(1); }
  100% { opacity: 0; transform: translateY(-50%) scale(1.3); }
}

第六部分:键盘快捷键

桌面端的键盘快捷键是提升专业用户体验的重要细节,参考 YouTube 的键盘交互规范:

class KeyboardPlugin {
  static pluginName = 'keyboard';
 
  constructor(player) {
    this.player = player;
  }
 
  init() {
    document.addEventListener('keydown', this._onKeyDown.bind(this), {
      signal: this.player._abortCtrl.signal
    });
  }
 
  _onKeyDown(e) {
    // 焦点在输入框时不响应快捷键
    const tag = document.activeElement?.tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
 
    // 播放器必须在视口内才响应
    const rect = this.player.container.getBoundingClientRect();
    const inView = rect.bottom > 0 && rect.top < window.innerHeight;
    if (!inView) return;
 
    let handled = true;
 
    switch (e.key) {
      case ' ':
      case 'k':
        this.player.toggle();
        break;
 
      case 'ArrowRight':
        // Shift + → : 快进 10s;普通 →: 快进 5s
        this.player.seek(this.player.currentTime + (e.shiftKey ? 10 : 5));
        this._showSeekFeedback(e.shiftKey ? '+10s' : '+5s');
        break;
 
      case 'ArrowLeft':
        this.player.seek(this.player.currentTime - (e.shiftKey ? 10 : 5));
        this._showSeekFeedback(e.shiftKey ? '-10s' : '-5s');
        break;
 
      case 'ArrowUp':
        this.player.setVolume(Math.min(1, this.player.video.volume + 0.1));
        break;
 
      case 'ArrowDown':
        this.player.setVolume(Math.max(0, this.player.video.volume - 0.1));
        break;
 
      case 'm':
        this.player.video.muted = !this.player.video.muted;
        break;
 
      case 'f':
        this._toggleFullscreen();
        break;
 
      case '0': case '1': case '2': case '3': case '4':
      case '5': case '6': case '7': case '8': case '9':
        // 数字键:跳转到对应百分比位置
        this.player.seek(this.player.duration * parseInt(e.key) / 10);
        break;
 
      case '>':
        // 加速
        this.player.setPlaybackRate(Math.min(2, this.player.video.playbackRate + 0.25));
        break;
 
      case '<':
        // 减速
        this.player.setPlaybackRate(Math.max(0.25, this.player.video.playbackRate - 0.25));
        break;
 
      default:
        handled = false;
    }
 
    if (handled) {
      e.preventDefault();
      // 显示控制栏并重置隐藏计时器
      this.player.getPlugin('controls')?._showAndScheduleHide();
    }
  }
 
  _toggleFullscreen() {
    if (!document.fullscreenElement) {
      this.player.container.requestFullscreen().catch(() => {});
    } else {
      document.exitFullscreen();
    }
  }
 
  _showSeekFeedback(text) {
    const indicator = this.player.overlay.querySelector('.xplayer__gesture-indicator');
    if (indicator) {
      indicator.textContent = text;
      indicator.hidden = false;
      clearTimeout(this._feedbackTimer);
      this._feedbackTimer = setTimeout(() => { indicator.hidden = true; }, 800);
    }
  }
}

第七部分:插件化架构总结与完整组装

插件系统设计原则

西瓜播放器(xgplayer)的插件架构给出了一个很好的参考模型:播放器核心(Player)只负责媒体能力的封装和事件总线,所有 UI 功能都通过插件注册的方式挂载。zhuanlan.zhihu.com github.com 每个插件遵循统一的生命周期接口:

// 插件基类(可选继承)
class BasePlugin {
  // 插件唯一标识,用于 player.getPlugin(name) 获取实例
  static pluginName = '';
 
  constructor(player) {
    this.player = player;
  }
 
  // 插件挂载时调用(DOM 已就绪)
  init() {}
 
  // 插件销毁时调用(清理 DOM、事件、定时器)
  destroy() {}
}

插件之间通过播放器实例互相访问,避免直接耦合:

// 插件 A 访问插件 B
const controlsPlugin = this.player.getPlugin('controls');
controlsPlugin._showAndScheduleHide();

完整组装示例

// 创建播放器实例
const player = new XPlayer(document.getElementById('player-container'), {
  autoplay: false,
  muted:    false,
  volume:   0.8,
});
 
// 注册插件(顺序影响 DOM 层级)
player
  .use(ControlsPlugin)
  .use(ProgressPlugin)
  .use(PlaybackRatePlugin)
  .use(QualityPlugin)
  .use(SubtitlePlugin)
  .use(GesturePlugin)
  .use(KeyboardPlugin);
 
// 设置视频源
player.setSrc('https://example.com/video.mp4');
 
// 配置画质列表
player.getPlugin('quality').setLevels([
  { label: '1080P', src: 'https://cdn.example.com/1080p.mp4' },
  { label: '720P',  src: 'https://cdn.example.com/720p.mp4'  },
  { label: '480P',  src: 'https://cdn.example.com/480p.mp4'  },
], '720P');
 
// 配置字幕
player.getPlugin('subtitle')
  .addTrack({ label: '中文', src: '/subtitles/zh.vtt', lang: 'zh' })
  .addTrack({ label: 'English', src: '/subtitles/en.vtt', lang: 'en' });
 
// 配置进度条缩略图
player.getPlugin('progress').initPreviewVideo('https://example.com/video.mp4');
 
// 监听状态变化
player.sm.on('statechange', ({ from, to }) => {
  console.log(`状态变化: ${from} → ${to}`);
});
 
// 页面卸载时销毁
window.addEventListener('beforeunload', () => player.destroy());

播放器 CSS 完整骨架

.xplayer__btn svg {
  width: 20px;
  height: 20px;
  fill: currentColor;
}
 
/* ── 控制栏底部行 ── */
.xplayer__btns {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 4px 0;
}
 
.xplayer__right-btns {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 4px;
}
 
/* ── 时间显示 ── */
.xplayer__time {
  color: rgba(255,255,255,0.85);
  font-size: 13px;
  white-space: nowrap;
  padding: 0 4px;
  font-variant-numeric: tabular-nums;
}
 
/* ── 音量滑块 ── */
.xplayer__volume-wrap {
  display: flex;
  align-items: center;
  gap: 4px;
}
 
.xplayer__volume-slider {
  width: 0;
  overflow: hidden;
  transition: width 0.2s ease;
}
 
.xplayer__volume-wrap:hover .xplayer__volume-slider,
.xplayer__volume-wrap:focus-within .xplayer__volume-slider {
  width: 64px;
}
 
.xplayer__volume-track {
  position: relative;
  height: 3px;
  width: 60px;
  background: rgba(255,255,255,0.25);
  border-radius: 2px;
  cursor: pointer;
}
 
.xplayer__volume-fill {
  position: absolute;
  left: 0; top: 0;
  height: 100%;
  background: #fff;
  border-radius: inherit;
  pointer-events: none;
}
 
.xplayer__volume-thumb {
  position: absolute;
  top: 50%;
  width: 10px; height: 10px;
  background: #fff;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  pointer-events: none;
}
 
/* ── 缩略图预览 tooltip ── */
.xplayer__preview-tooltip {
  position: absolute;
  bottom: calc(100% + 10px);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  pointer-events: none;
}
 
.xplayer__preview-canvas {
  width: 160px;
  height: 90px;
  border: 1px solid rgba(255,255,255,0.2);
  border-radius: 3px;
  background: #111;
  display: block;
}
 
.xplayer__preview-time {
  color: #fff;
  font-size: 12px;
  background: rgba(0,0,0,0.6);
  padding: 2px 6px;
  border-radius: 2px;
  font-variant-numeric: tabular-nums;
}
 
/* ── 加载 spinner ── */
.xplayer__loading {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
 
.xplayer__spinner {
  width: 40px; height: 40px;
  border: 3px solid rgba(255,255,255,0.2);
  border-top-color: #fff;
  border-radius: 50%;
  animation: xplayer-spin 0.8s linear infinite;
}
 
@keyframes xplayer-spin {
  to { transform: rotate(360deg); }
}
 
/* ── 全屏状态 ── */
.xplayer:fullscreen {
  width: 100vw;
  height: 100vh;
}
 
.xplayer:fullscreen .xplayer__video {
  height: 100vh;
  object-fit: contain;
}
 
/* ── 响应式:移动端隐藏部分控件 ── */
@media (max-width: 480px) {
  .xplayer__quality-btn,
  .xplayer__speed-btn {
    display: none;
  }
 
  .xplayer__time {
    font-size: 11px;
  }
}

第九部分:加载状态与错误处理插件

播放器 UI 中还有两个不可缺少的状态层:缓冲加载指示器和错误提示界面。它们同样以插件形式实现,由状态机驱动显示/隐藏:

class LoadingPlugin {
  static pluginName = 'loading';
 
  constructor(player) {
    this.player = player;
  }
 
  init() {
    this.el = document.createElement('div');
    this.el.className = 'xplayer__loading';
    this.el.hidden    = true;
    this.el.innerHTML = `<div class="xplayer__spinner"></div>`;
    this.player.overlay.appendChild(this.el);
 
    const { sm } = this.player;
 
    // 缓冲和加载状态:显示 spinner
    sm.on(PlayerState.LOADING,   () => this._show());
    sm.on(PlayerState.BUFFERING, () => this._show());
 
    // 其他状态:隐藏 spinner
    sm.on(PlayerState.PLAYING, () => this._hide());
    sm.on(PlayerState.PAUSED,  () => this._hide());
    sm.on(PlayerState.READY,   () => this._hide());
    sm.on(PlayerState.ERROR,   () => this._hide());
    sm.on(PlayerState.ENDED,   () => this._hide());
  }
 
  _show() { this.el.hidden = false; }
  _hide() { this.el.hidden = true; }
  destroy() { this.el.remove(); }
}
 
 
class ErrorPlugin {
  static pluginName = 'error';
 
  // 错误码对应的用户友好文案
  static ERROR_MESSAGES = {
    1: '加载已中止',
    2: '网络错误,请检查网络连接',
    3: '视频解码失败,格式可能不支持',
    4: '视频格式不支持或地址无效',
  };
 
  constructor(player) {
    this.player = player;
  }
 
  init() {
    this.el = document.createElement('div');
    this.el.className = 'xplayer__error';
    this.el.hidden    = true;
    this.player.overlay.appendChild(this.el);
 
    this.player.sm.on(PlayerState.ERROR, () => {
      const code    = this.player.video.error?.code || 0;
      const message = ErrorPlugin.ERROR_MESSAGES[code] || '播放出错,请刷新重试';
      this._show(message, code);
    });
 
    // 其他状态恢复时隐藏错误界面
    this.player.sm.on(PlayerState.LOADING, () => this._hide());
  }
 
  _show(message, code) {
    this.el.hidden = false;
    this.el.innerHTML = `
      <div class="xplayer__error-inner">
        <svg viewBox="0 0 24 24" class="xplayer__error-icon">
          <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10
                   10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
        </svg>
        <p class="xplayer__error-msg"></p>
        <button class="xplayer__error-retry">重试</button>
      </div>
    `;
 
    this.el.querySelector('.xplayer__error-retry')
      .addEventListener('click', () => {
        this._hide();
        this.player.video.load();
        this.player.play().catch(() => {});
      }, { once: true });
  }
 
  _hide() { this.el.hidden = true; }
  destroy() { this.el.remove(); }
}

对应样式:

.xplayer__error {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0,0,0,0.7);
  z-index: 25;
}
 
.xplayer__error-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  color: #fff;
  text-align: center;
  padding: 24px;
}
 
.xplayer__error-icon {
  width: 48px;
  height: 48px;
  fill: rgba(255,255,255,0.6);
}
 
.xplayer__error-msg {
  font-size: 14px;
  color: rgba(255,255,255,0.8);
  max-width: 240px;
  line-height: 1.5;
}
 
.xplayer__error-retry {
  padding: 8px 24px;
  background: rgba(255,255,255,0.15);
  border: 1px solid rgba(255,255,255,0.3);
  border-radius: 4px;
  color: #fff;
  cursor: pointer;
  font-size: 14px;
  transition: background 0.15s;
}
 
.xplayer__error-retry:hover {
  background: rgba(255,255,255,0.25);
}

第十部分:Web Components 封装

如果需要将播放器作为可复用组件在多个项目中使用,可以用 Web Components 的 Custom Element + Shadow DOM 封装,实现样式隔离和声明式使用:

class XPlayerElement extends HTMLElement {
  // 监听的属性变化
  static observedAttributes = ['src', 'autoplay', 'muted', 'loop', 'poster'];
 
  constructor() {
    super();
    // Shadow DOM:样式完全隔离
    this._shadow = this.attachShadow({ mode: 'open' });
    this._player = null;
  }
 
  connectedCallback() {
    // 注入样式
    const style = document.createElement('style');
    style.textContent = PLAYER_CSS; // 将前面所有 CSS 合并为字符串
    this._shadow.appendChild(style);
 
    // 创建容器
    const container = document.createElement('div');
    container.style.cssText = 'width:100%;height:100%;';
    this._shadow.appendChild(container);
 
    // 初始化播放器
    this._player = new XPlayer(container, {
      autoplay: this.hasAttribute('autoplay'),
      muted:    this.hasAttribute('muted'),
      loop:     this.hasAttribute('loop'),
    });
 
    this._player
      .use(ControlsPlugin)
      .use(ProgressPlugin)
      .use(PlaybackRatePlugin)
      .use(QualityPlugin)
      .use(SubtitlePlugin)
      .use(GesturePlugin)
      .use(KeyboardPlugin)
      .use(LoadingPlugin)
      .use(ErrorPlugin);
 
    // 应用初始 src
    if (this.getAttribute('src')) {
      this._player.setSrc(this.getAttribute('src'));
    }
    if (this.getAttribute('poster')) {
      this._player.video.poster = this.getAttribute('poster');
    }
 
    // 将播放器事件转发为 CustomEvent,供外部监听
    this._player.sm.on('statechange', ({ from, to }) => {
      this.dispatchEvent(new CustomEvent('statechange', {
        detail: { from, to }, bubbles: true
      }));
    });
  }
 
  disconnectedCallback() {
    this._player?.destroy();
    this._player = null;
  }
 
  attributeChangedCallback(name, oldVal, newVal) {
    if (!this._player) return;
    if (name === 'src' && newVal !== oldVal) {
      this._player.setSrc(newVal);
    }
  }
 
  // 暴露公开 API
  play()   { return this._player?.play(); }
  pause()  { this._player?.pause(); }
  seek(t)  { this._player?.seek(t); }
}
 
// 注册自定义元素
customElements.define('x-player', XPlayerElement);

注册后即可在 HTML 中声明式使用:

<!-- 声明式使用,与原生 <video> 语法一致 -->
<x-player
  src="https://example.com/video.mp4"
  poster="cover.jpg"
  muted
  autoplay
  style="width: 100%; aspect-ratio: 16/9;"
></x-player>
 
<script>
  const player = document.querySelector('x-player');
 
  // 外部控制
  player.play();
  player.seek(30);
 
  // 监听状态变化
  player.addEventListener('statechange', (e) => {
    console.log('状态:', e.detail.to);
  });
</script>

至此,第九章《自定义播放器开发》完整收尾。本章从核心状态机架构设计出发,实现了控制栏自动隐藏机制、进度条拖拽与缩略图预览(实时截帧和 VTT 雪碧图两种方案)、倍速/画质/字幕三大功能面板、移动端完整手势系统(单击/双击/滑动快进/音量/亮度)、桌面端键盘快捷键,以及加载/错误两个状态插件,最终通过 Web Components 封装为可复用的自定义元素。

第十章将进入 Canvas 视频处理专题,涵盖视频帧截图、实时滤镜、绿幕抠图(Chroma Key)、视频转 GIF,以及 WebCodecs API 的帧级解码能力。需要继续请告诉我。