第九章:自定义播放器开发
浏览器原生的
<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));
}
}播放器核心类
将 <video> 元素的事件系统与状态机连接起来,形成播放器的核心驱动逻辑:
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 雪碧图的高效缩略图预览
上面的方案用第二个 <video> 元素实时截帧,对带宽消耗较大。生产环境更常用的方案是预先生成雪碧图(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 原生的 <track> 元素和 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 的帧级解码能力。需要继续请告诉我。