编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

      • README
      • 待办清单事件驱动
      • 异步订阅流式解析
      • 视频播放器自实现
        • 目录快速导航
        • 01.项目简介
          • 1.1 学完做出什么
          • 1.2 为什么是这套技术
          • 1.3 八阶段渐进路线
          • 1.4 卷一锚点速查
        • 02.项目启动
          • 2.1 目录结构搭建
          • 2.2 起静态服务器
          • 2.3 灵魂三问 · 自
        • 03.Web Component 封装
          • 3.1 为什么要做自定义
          • 3.2 目录结构搭建
          • 3.3 最小自定义元素骨架
          • 3.4 暴露属性 / 方
          • 3.5 自定义事件——`
          • 3.6 故意造 bug
          • 3.7 Light DO
        • 04.Player 核心:状态机 + EventTarget
          • 4.1 为什么 UI 和
          • 4.2 Step 1
          • 4.3 把 UI 接到
          • 4.4 故意造 bug
          • 4.5 EventTar
        • 05.MSE 流水线 ⭐核心
          • 5.1 MSE 到底是什么
          • 5.2 MSE 4 件套
          • 5.3 准备测试素材
          • 5.4 最小 MSE 跑通
          • 5.5 MSE 的 4
          • 5.6 用 fetch
          • 5.7 UI 接入 +
          • 5.8 故意造 bug
          • 5.9 拼接两段 fmp4
        • 06.迷你 HLS:m3u8 + ts 分片
          • 6.1 m3u8 长什么样
          • 6.2 m3u8 解析器
          • 6.3 HlsLoader
          • 6.4 错误降级链(iO
          • 6.5 备一份测试 m3
          • 6.6 测试 + 故意触
          • 6.7 故意造 bug
        • 07.控制层:进度条 + rVFC + 24 个 video 事件
          • 7.1 HTMLVide
          • 7.2 Step 1
          • 7.3 进度条三色绘制
          • 7.4 把 Contro
          • 7.5 rVFC vs
          • 7.6 故意造 bug
        • 08.Canvas 弹幕:对象池 + 时间索引
          • 8.1 弹幕为什么不能用
          • 8.2 弹幕数据 + 时
          • 8.3 对象池——Tra
          • 8.4 把 Engine
          • 8.5 故意造 bug
          • 8.6 切后台 dt 跳
        • 09.Web 平台 API 大全:PiP / Media Session / WebVTT
          • 9.1 宿主 API 凭
          • 9.2 画中画(PiP)
          • 9.3 Media Se
          • 9.4 WebVTT 字
          • 9.5 宿主 API 全
        • 10.项目总结
          • 10.1 你写下的 ~20
          • 10.2 一图看完八阶段成长
          • 10.3 与上一案例 js
          • 10.4 这个 demo
        • 11.项目技术思考
          • 11.1 EventTar
          • 11.2 为什么 MSE
          • 11.3 跨端思考:同一份
        • 12.衔接与延伸
          • 12.1 与下一案例的关系
          • 12.2 三个延伸挑战
      • 图表看板全栈开发
    • 专栏博客

  • CodeX
  • JavaScript入门
  • 综合案例
杨充
2026-06-11
目录

视频播放器自实现

# 案例 03 · jsplayer 视频播放器

案例定位:⭐⭐⭐⭐⭐ 难度 / 12 小时学习预算 / ~2000 行 JS / 多模块工程

本案例的灵魂:Web 平台 API 大全——这是 JS 区别于 C++/Go 的根本。前端工程师 80% 的时间在"驯服浏览器",而视频播放器是把 MSE / Canvas / Fullscreen / PiP / WebVTT / Media Session / Range / Web Component / EventTarget / requestVideoFrameCallback 十大宿主 API 一次性集中演练的最佳载体。

前置依赖:必须先完成 02.异步订阅流式解析 ——本案例直接复用它的 http.js / pLimit.js / AbortController 取消链;如果跳过 02,会卡在 §03 视频分片下载。


# 目录快速导航

点击以下条目即可跳转。

  • 01. 项目简介
    • 1.1 学完做出什么
    • 1.2 为什么是这套技术
    • 1.4 卷一锚点速查
  • 02. 项目启动
    • 2.1 目录结构搭建
    • 2.2 起静态服务器
    • 2.3 灵魂三问 · 自
  • 03. Web Component 封装
    • 3.1 为什么要做自定义
    • 3.2 目录结构搭建
    • 3.3 最小自定义元素骨架
    • 3.4 暴露属性 / 方
    • 3.5 自定义事件——`
    • 3.6 故意造 bug
    • 3.7 Light DO
  • 04. Player 核心:状态机 + EventTarget
    • 4.1 为什么 UI 和
    • 4.2 Step 1
    • 4.3 把 UI 接到
    • 4.4 故意造 bug
    • 4.5 EventTar
  • 05. MSE 流水线 ⭐核心
    • 5.1 MSE 到底是什么
    • 5.2 MSE 4 件套
    • 5.3 准备测试素材
    • 5.4 最小 MSE 跑通
    • 5.5 MSE 的 4
    • 5.6 用 fetch
    • 5.7 UI 接入 +
    • 5.8 故意造 bug
    • 5.9 拼接两段 fmp4
  • 06. 迷你 HLS:m3u8 + ts 分片
    • 6.1 m3u8 长什么样
    • 6.2 m3u8 解析器
    • 6.3 HlsLoader
    • 6.4 错误降级链(iO
    • 6.5 备一份测试 m3
    • 6.6 测试 + 故意触
    • 6.7 故意造 bug
  • 07. 控制层:进度条 + rVFC + 24 个 video 事件
    • 7.1 HTMLVide
    • 7.2 Step 1
    • 7.3 进度条三色绘制
    • 7.4 把 Contro
    • 7.5 rVFC vs
    • 7.6 故意造 bug
  • 08. Canvas 弹幕:对象池 + 时间索引
    • 8.1 弹幕为什么不能用
    • 8.2 弹幕数据 + 时
    • 8.3 对象池——Tra
    • 8.4 把 Engine
    • 8.5 故意造 bug
    • 8.6 切后台 dt 跳
  • 09. Web 平台 API 大全:PiP / Media Session / WebVTT
    • 9.1 宿主 API 凭
    • 9.2 画中画(PiP)
    • 9.3 Media Se
    • 9.4 WebVTT 字
    • 9.5 宿主 API 全
  • 10. 项目总结
    • 10.1 你写下的 ~20
    • 10.3 与上一案例 js
    • 10.4 这个 demo
  • 11. 项目技术思考
    • 11.1 EventTar
    • 11.2 为什么 MSE
    • 11.3 跨端思考:同一份
  • 12. 衔接与延伸
    • 12.1 与下一案例的关系
    • 12.2 三个延伸挑战

# 01.项目简介

# 1.1 学完做出什么

写一个带自定义皮肤的 H5 视频播放器,不引入 video.js / hls.js / shaka-player。最终形态:

┌──────────────────────────────────────────────────┐
│  ╔════════════════════════════════════╗   弹幕滚动→     │
│  ║                                    ║              │
│  ║         <video> 视频区             ║              │
│  ║       (Canvas 弹幕层叠加于上)       ║              │
│  ║                                    ║              │
│  ╚════════════════════════════════════╝              │
│  ─────●──────────────────────  01:23 / 12:34  ⛶ 🎯   │
│   ▶  🔊──○─  1.0×  字幕▼  画质▼  画中画  全屏        │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10

真实可用的能力:

  • 加载 m3u8 / fmp4,自己实现迷你 HLS(解析 m3u8 → 顺序拉 ts → 喂 MSE)
  • 进度条三色:已播 / 已缓冲 / 可 seek 区间(区分 played / buffered / seekable)
  • Canvas 弹幕层(单画布 RAF 调度,而不是 N 个 div + CSS 动画)
  • 画中画(PiP)/ 全屏(多浏览器前缀)/ 系统媒体控件(Media Session)
  • WebVTT 字幕(手写解析器,不依赖 <track>)
  • 切换清晰度:链式 AbortController 取消旧请求——切换瞬时响应
  • 媒体错误降级链:MSE → 原生 <video src> → 提示用户

# 1.2 为什么是这套技术

❓ 为什么不直接用 <video src="x.mp4"> 一行解决?

可以解决"播放",但解决不了:

  • m3u8 / DASH 分片协议——浏览器原生只支持完整 mp4/webm,不会自己解析 m3u8
  • 自适应码率——根据网速切 480p / 1080p
  • 首帧秒开——边下边播 vs 等下完才播
  • 弹幕——<video> 上叠 1000 个 div 必卡,必须 Canvas

而这一切的钥匙是 MSE(Media Source Extensions)——它让 JS 可以像写文件一样把视频字节流喂给 <video>。MSE 是 video.js / hls.js / DASH.js / Shaka 这些库的共同地基,学完 MSE 你就能秒读这些库的源码。

❓ 为什么 ⭐⭐⭐⭐⭐ 比 jsfeed 还难?

维度 jsfeed jsplayer
数据形态 文本(字符串友好) 二进制(ArrayBuffer / Uint8Array)
异步粒度 Promise(整体) 流式 + 帧级(rVFC 每秒 60 次)
状态空间 "已加载/未加载" 二态 <video> 24 个事件 × 4 个 readyState
错误恢复 重试就行 媒体管线一坏全坏,需要 reset MediaSource
跨平台 桌面浏览器 iOS Safari 完全不支持 MSE,必须降级

# 1.3 八阶段渐进路线

阶段 ① 启动        最小骨架 + 原生 &lt;video> 跑通                 30 min
阶段 ② Web Component  封装 &lt;jsplayer-app> 自定义元素 + Shadow DOM   60 min
阶段 ③ Player 核心  class Player extends EventTarget               60 min
阶段 ④ MSE 流水线   ⭐ MediaSource + SourceBuffer + 队列化           120 min
阶段 ⑤ 迷你 HLS     ⭐ m3u8 解析 + ts 分片下载 + 错误降级            120 min
阶段 ⑥ 控制层      24 个 video 事件 + 进度条三色 + rVFC             90 min
阶段 ⑦ Canvas 弹幕  ⭐⭐ 对象池 + 时间索引 + RAF                    120 min
阶段 ⑧ 平台 API 大全 PiP + Fullscreen + Media Session + WebVTT       60 min
                                                       合计 ≈ 12 h
1
2
3
4
5
6
7
8
9

每阶段保留前两篇案例的统一骨架:🎯 目标卡片 / Step 拆分 / 🧪 测试 / 故意造 bug → 修复 / 📌 阶段小结。

# 1.4 卷一锚点速查

本案例段落 对应卷一章节 核心知识点
§02 自定义元素 第 14.7 Web Component customElements.define
§03 EventTarget 第 05 面向对象 + 第 08 事件 class extends EventTarget
§03 私有字段 第 05.4 私有字段 #state
§04 MSE 第 15 网络 + 第 02 数据类型 ArrayBuffer / Blob
§05 m3u8 解析 第 11 字符串处理 split + URL
§06 视频事件 第 08 事件设计 节流 / once / passive
§06 rVFC 第 07 异步操作 帧回调替代 timeupdate
§07 Canvas 第 14 DOM + 第 04 闭包 RAF + 对象池
§07 弹幕索引 第 02 数据类型 桶排序 + Map
§08 PiP / 全屏 第 14.10 浏览器 API 多前缀兼容

# 02.项目启动

┌─ 🎯 阶段 ① 目标 ────────────────────────────────────┐
│ 完成什么:起一个静态服务器,&lt;video> 标签播一个 mp4 跑通 │
│ 不做什么:先不用 MSE / 不写自定义元素                  │
│ 验收标准:访问 http://localhost:5173 → 视频自动播放    │
│ 预计耗时:30 min                                       │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6

# 2.1 目录结构搭建

jsplayer/
├── index.html
├── style.css
├── public/
│   ├── demo.mp4              # 找一段任意 mp4(&lt; 10MB 即可)
│   └── hls/                  # §05 用,先空着
│       ├── index.m3u8
│       └── seg-001.ts ...
└── src/
    └── main.js
1
2
3
4
5
6
7
8
9
10

📁 index.html(极简):

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>jsplayer</title>
  <link rel="stylesheet" href="./style.css" />
</head>
<body>
  <main id="app"></main>
  <script type="module" src="./src/main.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13

📁 src/main.js:

const app = document.querySelector('#app');
const video = document.createElement('video');
video.src = '/demo.mp4';
video.controls = true;          // 暂时用浏览器原生控制条
video.playsInline = true;       // ⭐ iOS 必需,否则全屏劫持
video.muted = true;             // ⭐ Chrome 自动播放策略:必须静音
video.autoplay = true;
app.appendChild(video);
1
2
3
4
5
6
7
8

📁 style.css:

:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #0a0a0a;
  display: grid; place-items: center; font: 14px/1.5 system-ui; color: #eee; }
video { width: min(960px, 90vw); aspect-ratio: 16/9; background: #000; }
1
2
3
4
5

# 2.2 起静态服务器

cd jsplayer
npx --yes serve -p 5173 .
1
2

访问 http://localhost:5173 → 看到视频自动播放。

# 2.3 灵魂三问 · 自

❓ 为什么必须 muted = true 才能 autoplay?

Chrome 66+ / Safari 11+ 的"用户优先"政策:带声音的视频不允许自动播放——避免广告打扰用户。muted 让你绕过这条规则。点击播放按钮后才能 video.muted = false 解除静音。

❓ playsInline 是干嘛的?

iOS Safari 默认行为是视频开始播放就强制全屏(用 QuickTime 接管)。加 playsInline 才能让视频在页面内播。这一行能救命——很多新手在 PC 上一切正常,到了 iPhone 上视频一播就强制全屏,原因就是漏写它。

❓ controls 不是已经够用了吗?为什么还要自己写?

原生 controls 不能定制——

  • UI 跟着浏览器走,每个浏览器长得不一样
  • 没有弹幕、字幕样式、画质切换
  • 移动端原生 controls 会遮住自定义按钮

所以本案例的 §06-§08 会关掉 controls自己写。

┌─ 📌 阶段 ① 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • 视频 3 件套属性:muted / playsInline / autoplay    │
│   • 浏览器自动播放策略                                  │
│   • 原生 controls 的局限                                │
│ ⏸ 还没碰的:                                           │
│   • MSE 流式喂数据(§04)                               │
│   • 自定义控制条(§06)                                 │
│ 📌 进入下阶段前:                                       │
│   git add . &amp;&amp; git commit -m "stage1: native video"   │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11

# 03.Web Component 封装

┌─ 🎯 阶段 ② 目标 ────────────────────────────────────┐
│ 完成什么:把整个播放器封装成 &lt;jsplayer-app> 自定义元素      │
│ 不做什么:不写 MSE、不写控制条逻辑(先放占位)              │
│ 验收标准:HTML 写一行 &lt;jsplayer-app src="..."> 就能播     │
│ 预计耗时:60 min                                         │
│ 关键思路:浏览器原生 Web Component 4 件套——               │
│   customElements / Shadow DOM / template / 生命周期回调  │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8

# 3.1 为什么要做自定义

❓ 直接 app.appendChild(video) 不就够了吗,为什么要绕一层?

来看反例——如果把所有 DOM 散在 main.js 里:

const root = document.querySelector('#app');
root.innerHTML = `<video></video><div class="bar">...</div><canvas></canvas>...`;
const video  = root.querySelector('video');
const bar    = root.querySelector('.bar');
const canvas = root.querySelector('canvas');
// 然后是 200 行事件绑定
1
2
3
4
5
6

问题暴露:

  1. 样式污染 ——页面其它 .bar 也会被命中(没有作用域隔离)
  2. 不可复用 ——别人想嵌入你的播放器,得复制一大段 HTML+JS
  3. 依赖耦合 ——main.js 既管业务又管 DOM,改一处怕牵动十处

✅ 正确做法:自定义元素 + Shadow DOM——就像浏览器内置的 <video> 一样,对外只暴露标签和属性。

❓ Shadow DOM 是什么,跟 iframe 有什么区别?

维度 iframe Shadow DOM
进程 独立进程,重 同进程,轻
样式 完全隔离 完全隔离(CSS 不会泄漏进出)
JS 跨域受限 直接访问
用途 嵌别人的页面 封装组件内部结构

Shadow DOM 是给组件用的"低成本样式沙箱"——这是 jsplayer 必须用它的根本原因(<video> 上叠 canvas/字幕轨/弹幕层,样式一乱整个播放器就花了)。

❓ 为什么不用 React/Vue?

可以,但自定义元素是浏览器原生标准——不需要任何框架就能在 React/Vue/Angular/原生 HTML 里复用。video.js 6+ / shaka-player UI / Vime.js 都是自定义元素。学完本节,你写的播放器能直接被任何框架引用。

# 3.2 目录结构搭建

src/
├── main.js              # 仅一行:import './ui/JsPlayerApp.js'
└── ui/
    └── JsPlayerApp.js   # 自定义元素本体
1
2
3
4

# 3.3 最小自定义元素骨架

📁 src/ui/JsPlayerApp.js:

const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = /*html*/`
  <style>
    :host { display: block; position: relative; background: #000;
            width: 100%; aspect-ratio: 16/9; color: #eee;
            font: 14px/1.5 system-ui; user-select: none; }
    :host([hidden]) { display: none; }
    video { width: 100%; height: 100%; display: block; background: #000; }
    .placeholder { position: absolute; inset: 0; display: grid;
                   place-items: center; opacity: .5; pointer-events: none; }
  </style>

  <video playsinline></video>
  <div class="placeholder">jsplayer · 阶段 ② 占位</div>
`;

export class JsPlayerApp extends HTMLElement {
  // ⭐ 声明哪些属性变化要触发回调
  static get observedAttributes() { return ['src', 'poster', 'autoplay', 'muted']; }

  #video;             // 私有字段(卷一第 05.4)
  #shadow;

  constructor() {
    super();
    // ⭐ Shadow DOM——样式沙箱
    this.#shadow = this.attachShadow({ mode: 'open' });
    this.#shadow.appendChild(TEMPLATE.content.cloneNode(true));
    this.#video = this.#shadow.querySelector('video');
  }

  // ⭐ 生命周期 1:插入文档时调用
  connectedCallback() {
    console.log('[JsPlayerApp] connected');
    this.#syncAttrs();
  }

  // ⭐ 生命周期 2:从文档移除时调用
  disconnectedCallback() {
    console.log('[JsPlayerApp] disconnected');
  }

  // ⭐ 生命周期 3:observedAttributes 里的属性变化时调用
  attributeChangedCallback(name, _old, _new) {
    if (!this.#video) return;
    this.#syncAttrs();
  }

  #syncAttrs() {
    const v = this.#video;
    v.src      = this.getAttribute('src') ?? '';
    v.poster   = this.getAttribute('poster') ?? '';
    v.autoplay = this.hasAttribute('autoplay');
    v.muted    = this.hasAttribute('muted');
    v.controls = true;   // 阶段 ⑥ 自定义控制条之前先用原生
  }
}

// ⭐ 注册自定义元素——必须含连字符(HTML 规范)
customElements.define('jsplayer-app', JsPlayerApp);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

📁 src/main.js:

import './ui/JsPlayerApp.js';
console.log('[main] jsplayer 启动');
1
2

📁 index.html 里把 <main id="app"></main> 替换为:

<jsplayer-app src="/demo.mp4" muted autoplay></jsplayer-app>
1

🧪 立刻编译运行(阶段 ② 第 1 次验收):

npx --yes serve -p 5173 .
1

预期行为:

  • 视频自动播放
  • 控制台输出 [JsPlayerApp] connected
  • F12 → Elements → 展开 <jsplayer-app> → 看到 #shadow-root (open) 节点

✅ 看到 shadow-root 节点 = 自定义元素工作了——这是浏览器原生支持,不依赖任何框架。

❌ 常见错误:

现象 原因
Failed to construct 'CustomElement' 忘记写 super()
标签没生效,原样渲染 customElements.define 名字没含 -
属性改了不响应 没加进 observedAttributes

# 3.4 暴露属性 / 方

自定义元素要"用起来像 <video>"——必须把 attribute(HTML 属性) 和 property(JS 属性) 都接上。

在 JsPlayerApp 里补两段:

  // ⭐ JS property 镜像 attribute(让 jsPlayer.src = '...' 也能工作)
  get src() { return this.getAttribute('src'); }
  set src(v) { v == null ? this.removeAttribute('src') : this.setAttribute('src', v); }

  get muted() { return this.hasAttribute('muted'); }
  set muted(v) { v ? this.setAttribute('muted', '') : this.removeAttribute('muted'); }

  // ⭐ 公开方法(自定义元素就是普通的 class)
  play()  { return this.#video.play(); }
  pause() { return this.#video.pause(); }

  // ⭐ 透传 readyState / currentTime 这种只读属性
  get currentTime() { return this.#video.currentTime; }
  set currentTime(t) { this.#video.currentTime = t; }
  get duration()    { return this.#video.duration; }
  get paused()      { return this.#video.paused; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

🧪 立刻验证(在浏览器控制台敲):

const p = document.querySelector('jsplayer-app');
p.pause();                    // 视频暂停
p.currentTime = 5;            // 跳到 5 秒
p.muted = false;              // 解除静音(注意会被 autoplay 策略拦)
p.src = '/demo.mp4';          // 重新加载
1
2
3
4
5

✅ 5 行控制台命令全部生效 = 你的自定义元素 API 已经"像原生 <video>"——这是后续案例 04(小程序 / Hippy)能直接复用同一份逻辑的关键。

# 3.5 自定义事件——`

播放器要"被外部监听"——比如埋点:开始播放上报一次。这就要用 CustomEvent。

继续在 connectedCallback 里加:

  connectedCallback() {
    this.#syncAttrs();
    // ⭐ 把内层 video 的关键事件转发到自定义元素
    const forward = (type, detail = null) => {
      this.#video.addEventListener(type, () => {
        // bubbles + composed 才能穿透 Shadow DOM 边界
        this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
      });
    };
    ['play', 'pause', 'ended', 'error', 'loadedmetadata'].forEach(t => forward(t));
  }
1
2
3
4
5
6
7
8
9
10
11

📚 composed: true 的意义:默认事件不会穿透 Shadow DOM(这正是封装性的体现)。要让外部 document.addEventListener('play', ...) 能收到,必须加 composed: true——这是自定义元素必踩坑之一。

🧪 验证(控制台):

document.querySelector('jsplayer-app')
  .addEventListener('loadedmetadata', e => console.log('收到事件', e.detail));
location.reload();
1
2
3

刷新页面 → 控制台应打印 收到事件 null ——表示事件成功冒泡出 Shadow DOM。

# 3.6 故意造 bug

很多新手不信"Shadow DOM 真的隔离了"。让你亲眼看一次。

实验 A——在外层 style.css 写:

video { border: 5px solid red !important; }
1

刷新 → 没有红框!外层 CSS 选不到 Shadow DOM 内的 <video>。

实验 B——在 JsPlayerApp 的 <style> 里写:

video { border: 5px solid red; }
1

看到红框了——只有 Shadow DOM 内的样式生效。

💡 这就是组件库为什么爱用 Shadow DOM:你做的 jsplayer 嵌进任何项目都不会被对方的 * { margin: 0 } 弄花。

# 3.7 Light DO

维度 Light DOM(普通 div) Shadow DOM
document.querySelector('video') 能找到吗 ✅ ❌(要先穿 shadow-root)
外部 CSS 影响 ✅ ❌
:host / ::part 选择器 ❌ ✅
DevTools 调试 直接看 要展开 #shadow-root
适合场景 业务页面 通用组件
┌─ 📌 阶段 ② 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                      │
│   • customElements.define + class extends HTMLElement│
│   • Shadow DOM 样式沙箱(实验亲眼验证)              │
│   • 三大生命周期:connected/disconnected/attrChanged │
│   • observedAttributes + attribute/property 双向    │
│   • CustomEvent + composed:true 穿透 Shadow         │
│ ⏸ 还没碰的:                                         │
│   • Player 业务核心(§04)                            │
│   • MSE 流水线(§05)                                 │
│ 📌 进入下阶段前:                                      │
│   git add . &amp;&amp; git commit -m "stage2: web component"│
│ 💡 本阶段最大领悟:                                    │
│   "自定义元素 = 浏览器内置的、零依赖的 React 组件"    │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 04.Player 核心:状态机 + EventTarget

┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────┐
│ 完成什么:把"控制视频"的逻辑从 UI 抽出来 → class Player  │
│ 不做什么:仍然不接 MSE,先用原生 &lt;video src=...>          │
│ 验收标准:UI 调 player.load() / player.play() 能跑      │
│ 预计耗时:60 min                                         │
│ 关键思路:分层——                                         │
│   UI 层(JsPlayerApp):只管 DOM 和按钮                  │
│   核心层(Player)   :状态机 + 事件中枢                 │
│   传输层(loaders)  :§05 才做(MSE / m3u8)            │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10

# 4.1 为什么 UI 和

❓ 直接把 play() / pause() 写在 JsPlayerApp 里不行吗?

短期可以,到了 §05 加入 MSE 就崩——因为:

  1. MSE 状态(MediaSource.readyState)/ 网络状态(fetch 进度)/ UI 状态(按钮 loading)三股状态会互相影响
  2. 单元测试时你不可能 new 一个 JsDOM 来测播放器 ——必须把核心逻辑剥离出来跑在纯 JS 环境
  3. 将来要做"无 UI 后台预加载"(比如 feed 流自动预播 5 秒),UI 层根本不存在

✅ 正确分层:

┌─────────────────────────────────────────┐
│  JsPlayerApp(自定义元素 / DOM / 事件) │  ← UI 层
└─────────────────────────────────────────┘
              │ 调方法 / 监听事件
              ▼
┌─────────────────────────────────────────┐
│  Player(状态机 / 事件中枢)            │  ← 核心层
└─────────────────────────────────────────┘
              │ 调 SourceLoader
              ▼
┌─────────────────────────────────────────┐
│  HlsLoader / Mp4Loader (§05)         │  ← 传输层
└─────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13

❓ Player 该继承谁? 答:EventTarget ——它是 addEventListener / dispatchEvent 的祖先类,浏览器原生暴露给业务代码用。

class Player extends EventTarget {
  // 自带 addEventListener / removeEventListener / dispatchEvent
}
1
2
3

❓ 状态用什么表达? 答:显式状态机(idle / loading / ready / playing / paused / error / ended)——比 isLoading / isPlaying / hasError 一堆 bool 清晰 10 倍。

# 4.2 Step 1

📁 src/core/Player.js:

// ⭐ 显式状态枚举(虽然 JS 没有 enum,但用 const 对象语义等价)
export const PlayerState = Object.freeze({
  IDLE:    'idle',     // 还没 load
  LOADING: 'loading',  // 元数据加载中
  READY:   'ready',    // 可以播但还没播
  PLAYING: 'playing',
  PAUSED:  'paused',
  ENDED:   'ended',
  ERROR:   'error',
});

// ⭐ 合法状态转移表(状态机的灵魂)
const TRANSITIONS = {
  idle:    ['loading', 'error'],
  loading: ['ready', 'error'],
  ready:   ['playing', 'error', 'idle'],
  playing: ['paused', 'ended', 'error', 'idle'],
  paused:  ['playing', 'ended', 'error', 'idle'],
  ended:   ['playing', 'idle'],
  error:   ['idle'],
};

export class Player extends EventTarget {
  // 私有字段
  #video;
  #state = PlayerState.IDLE;
  #lastError = null;

  constructor(videoEl) {
    super();
    if (!(videoEl instanceof HTMLVideoElement)) {
      throw new TypeError('Player 需要一个 <video> 元素');
    }
    this.#video = videoEl;
    this.#bindMediaEvents();
  }

  get state()     { return this.#state; }
  get video()     { return this.#video; }
  get lastError() { return this.#lastError; }

  // ⭐ 状态转移——所有内部状态变更必须走这里
  #setState(next, payload = null) {
    const cur = this.#state;
    if (cur === next) return;
    const allowed = TRANSITIONS[cur] ?? [];
    if (!allowed.includes(next)) {
      console.warn(`[Player] 非法转移 ${cur} → ${next}`);
      return;
    }
    this.#state = next;
    this.dispatchEvent(new CustomEvent('statechange',
      { detail: { from: cur, to: next, payload } }));
  }

  // ⭐ 把 <video> 的 24 个原生事件折叠成"业务有意义"的状态转移
  #bindMediaEvents() {
    const v = this.#video;
    v.addEventListener('loadedmetadata', () => this.#setState(PlayerState.READY));
    v.addEventListener('playing', () => this.#setState(PlayerState.PLAYING));
    v.addEventListener('pause',   () => {
      if (this.#state === PlayerState.PLAYING) this.#setState(PlayerState.PAUSED);
    });
    v.addEventListener('ended',   () => this.#setState(PlayerState.ENDED));
    v.addEventListener('error',   () => {
      this.#lastError = v.error;
      this.#setState(PlayerState.ERROR, v.error);
    });
  }

  // ⭐ 公开 API(方法名故意和 <video> 一致——降低使用心智成本)
  async load(src) {
    this.#setState(PlayerState.LOADING);
    this.#video.src = src;       // 阶段 ⑤ 会换成 MSE
    this.#video.load();
  }

  async play()  { return this.#video.play(); }
  pause()       { this.#video.pause(); }
  seek(t)       { this.#video.currentTime = t; }

  destroy() {
    this.#video.removeAttribute('src');
    this.#video.load();
    this.#setState(PlayerState.IDLE);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87

设计要点:

要点 写法 作用
extends EventTarget 继承浏览器原生 白嫖 add/dispatch/removeEventListener
#state 私有字段(卷一第 05.4) 外部不能 player.#state = ... 绕过校验
Object.freeze(枚举) 不可变常量 防止业务代码误改 PlayerState.PLAYING = '...'
TRANSITIONS 白名单 状态机 "playing → loading" 这种非法跳转直接拦截
dispatchEvent('statechange') 单一事件总线 UI 层只听一个事件就能感知所有状态

# 4.3 把 UI 接到

回到 JsPlayerApp,把所有控制视频的代码都换成调 player.xxx:

import { Player, PlayerState } from '../core/Player.js';

export class JsPlayerApp extends HTMLElement {
  #shadow; #video; #player;

  constructor() {
    super();
    this.#shadow = this.attachShadow({ mode: 'open' });
    this.#shadow.appendChild(TEMPLATE.content.cloneNode(true));
    this.#video  = this.#shadow.querySelector('video');
    this.#player = new Player(this.#video);    // ⭐ 核心层
  }

  connectedCallback() {
    // ⭐ 监听核心层的状态变化 → 更新 UI / 转发给外部
    this.#player.addEventListener('statechange', (e) => {
      const { from, to } = e.detail;
      console.log(`[JsPlayerApp] ${from} → ${to}`);
      this.#updatePlaceholder(to);
      // 透传出去(Shadow 边界要 composed)
      this.dispatchEvent(new CustomEvent('player-state',
        { detail: e.detail, bubbles: true, composed: true }));
    });

    if (this.hasAttribute('src')) this.#player.load(this.getAttribute('src'));
  }

  attributeChangedCallback(name, _old, val) {
    if (name === 'src' && val) this.#player.load(val);
    if (name === 'muted') this.#video.muted = this.hasAttribute('muted');
    if (name === 'autoplay') this.#video.autoplay = this.hasAttribute('autoplay');
  }

  #updatePlaceholder(state) {
    const ph = this.#shadow.querySelector('.placeholder');
    const map = { idle: '待加载', loading: '加载中…', error: '播放失败',
                  ready: '', playing: '', paused: '', ended: '播放完毕' };
    ph.textContent = map[state] ?? '';
    ph.style.opacity = ph.textContent ? '0.7' : '0';
  }

  // ⭐ 公开方法直接转发到 player
  play()    { return this.#player.play(); }
  pause()   { this.#player.pause(); }
  get state() { return this.#player.state; }
  get player() { return this.#player; }   // 进阶用户可拿到核心层
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

🧪 立刻编译运行(阶段 ③ 第 1 次验收)

刷新 → 控制台依次打印:

[JsPlayerApp] idle → loading
[JsPlayerApp] loading → ready
[JsPlayerApp] ready → playing
1
2
3

✅ 看到三段状态转移 = 状态机工作了。

# 4.4 故意造 bug

为了让你真正信任状态机机制,我们故意触发一次非法转移。在控制台敲:

const p = document.querySelector('jsplayer-app').player;
// 当前 state 是 playing,强行让它跳到 loading
p.dispatchEvent(new CustomEvent('statechange', { detail: { from:'playing', to:'loading' }}));
// ↑ 这种是外部伪造事件,不会改 #state
console.log(p.state);   // 仍然是 playing —— 私有字段保护住了
1
2
3
4
5

再做一次"内部非法转移"测试。临时在 Player 里加一个调试方法:

// 临时调试用,验证完删掉
__debugForce(state) { this.#setState(state); }
1
2

控制台:

p.__debugForce('loading');   // 当前 playing → loading
// 控制台警告: [Player] 非法转移 playing → loading
console.log(p.state);   // 还是 playing
1
2
3

💡 这就是状态机的价值——任何企图破坏状态完整性的调用都会被白名单拦下,让 bug 在最早的位置暴露。

验证后删掉 __debugForce。

# 4.5 EventTar

JS 跨端工程师常遇到的混淆:

维度 EventTarget(浏览器) EventEmitter(Node.js)
事件对象 Event / CustomEvent 实例 直接 (arg1, arg2, ...)
监听 addEventListener('x', fn) on('x', fn)
派发 dispatchEvent(new Event('x')) emit('x', a, b)
一次性 { once: true } 选项 once('x', fn)
跨边界 composed: true 穿 Shadow 不存在边界
现代化 ✅ Node.js 14+ 也内置了 老 API

本案例用 EventTarget——因为它是浏览器和 Node.js 的最大公约数,将来同一份 Player 移植到 Workers / Node 端测试都不用改。

┌─ 📌 阶段 ③ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                      │
│   • class extends EventTarget                       │
│   • 私有字段 #state(强封装)                         │
│   • Object.freeze 常量枚举                          │
│   • 显式状态机 + TRANSITIONS 白名单                  │
│   • UI / 核心 / 传输 三层分离                        │
│ ⏸ 还没碰的:                                         │
│   • MSE 喂数据(§05)                                │
│   • 自定义控制条(§07)                              │
│ 📌 进入下阶段前:                                      │
│   git add . &amp;&amp; git commit -m "stage3: player core"  │
│ 💡 本阶段最大领悟:                                    │
│   "前端的 OOP 不是抄 Java,而是用 EventTarget 让组件 │
│    能像浏览器内置 API 一样被 addEventListener"       │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 05.MSE 流水线 ⭐核心

┌─ 🎯 阶段 ④ 目标 ────────────────────────────────────┐
│ 完成什么:MediaSource + SourceBuffer 把字节流喂给 video │
│ 不做什么:m3u8 解析放 §06;自定义控制条放 §07           │
│ 验收标准:用 fetch 把一个 fmp4 分两段下来 → 拼起来能播  │
│ 预计耗时:120 min(最长一节,要逐 Step 做)              │
│ 关键思路:MSE 状态机 ≠ Player 状态机——MSE 自己有       │
│   open/ended/closed + SourceBuffer.updating 双重状态  │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8

# 5.1 MSE 到底是什么

❓ <video src="x.mp4"> 不香吗?

香,但只解决"完整文件能播"。MSE(Media Source Extensions)解决的是 "边下边播 + 自适应码率 + 自定义协议"——这三件事是直播 / B 站 / Youtube / Twitch 的全部基础。

❓ 为什么 MSE 能做到?它的工作原理?

传统:    network ──[完整 mp4 文件]──> &lt;video>
                              ↑
                       浏览器自己解码

MSE:     network ──[任意分片字节]──> JS ──appendBuffer──> SourceBuffer
                                                                │
                                                          [组装成 mp4]
                                                                │
                                                          MediaSource
                                                                │
                                                          URL.createObjectURL
                                                                ↓
                                                            &lt;video src="blob:...">
1
2
3
4
5
6
7
8
9
10
11
12
13

核心翻译:MSE 给 JS **"伪造一个本地媒体文件"**的能力——你随时往里追加字节,浏览器随时拿去解码播放。

❓ iOS Safari 真的不支持 MSE 吗?

iPhone 上的 Safari 完全不支持 MSE(iPad Safari 13+ 支持)。这意味着 m3u8 必须走两条路:

  • 桌面 + Android Chrome → MSE + JS 自己解析
  • iOS iPhone → 直接 <video src="x.m3u8">(Safari 原生支持 HLS)

本节最后会写降级链。

# 5.2 MSE 4 件套

名词 类型 作用
MediaSource 接口 整个流的容器,相当于"虚拟文件"
SourceBuffer 接口 一个轨道(视频 / 音频),喂字节用
mime codec 字符串 video/mp4; codecs="avc1.42E01E,mp4a.40.2"
URL.createObjectURL(ms) API 把 MediaSource 包成 video 能用的 URL

# 5.3 准备测试素材

MSE 只接受 fragmented mp4 / WebM / TS,普通 mp4 不行。最简单:用 ffmpeg 切两段。

# 在 jsplayer/public 下
brew install ffmpeg            # macOS(其他系统类似)

# 切 fmp4:前 5 秒 + 后续——并强制 fragmented
ffmpeg -i demo.mp4 -t 5 -c copy -movflags frag_keyframe+empty_moov+default_base_moof seg-1.mp4
ffmpeg -i demo.mp4 -ss 5 -c copy -movflags frag_keyframe+empty_moov+default_base_moof seg-2.mp4
1
2
3
4
5
6

💡 没装 ffmpeg 也可以,文末挑战 D 给了备选——直接拆 demo.mp4 二进制成两段(虽然不是真 fmp4,但能跑通流程)。

# 5.4 最小 MSE 跑通

📁 src/core/MseEngine.js:

// MSE 引擎——只关心"喂字节",不关心"字节从哪来"
export class MseEngine extends EventTarget {
  #video;
  #ms = null;             // MediaSource
  #sb = null;             // SourceBuffer
  #queue = [];            // 待 append 的字节队列
  #mime;
  #ended = false;

  constructor(videoEl, mime) {
    super();
    this.#video = videoEl;
    this.#mime  = mime;
  }

  // 浏览器是否支持当前 mime
  static isSupported(mime) {
    return 'MediaSource' in window && MediaSource.isTypeSupported(mime);
  }

  // ⭐ Step A:开始一个新的 MediaSource
  open() {
    return new Promise((resolve, reject) => {
      if (!MseEngine.isSupported(this.#mime)) {
        return reject(new Error(`MSE 不支持 ${this.#mime}`));
      }
      const ms = new MediaSource();
      this.#ms = ms;
      this.#video.src = URL.createObjectURL(ms);
      // 注意:sourceopen 事件**只触发一次**——必须 once
      ms.addEventListener('sourceopen', () => {
        URL.revokeObjectURL(this.#video.src);   // 立刻回收 blob URL
        try {
          this.#sb = ms.addSourceBuffer(this.#mime);
          // ⭐ updateend ≈ "上一段字节消化完了"
          this.#sb.addEventListener('updateend', () => this.#drain());
          this.#sb.addEventListener('error', e => this.#fail(e));
          resolve();
        } catch (err) { reject(err); }
      }, { once: true });
    });
  }

  // ⭐ Step B:业务层喂数据进来
  appendChunk(arrayBuffer) {
    if (!this.#sb) throw new Error('MSE 未 open');
    if (this.#ended) return;
    this.#queue.push(arrayBuffer);
    this.#drain();
  }

  // ⭐ Step C:声明"我喂完了"
  endOfStream() {
    this.#ended = true;
    if (!this.#sb || this.#sb.updating || this.#queue.length) return;
    if (this.#ms.readyState === 'open') this.#ms.endOfStream();
  }

  // 排队消费 —— 因为 SourceBuffer.updating=true 时不能 append
  #drain() {
    if (!this.#sb || this.#sb.updating) return;
    if (this.#queue.length) {
      const buf = this.#queue.shift();
      try { this.#sb.appendBuffer(buf); }
      catch (err) { this.#fail(err); }
      return;
    }
    // 队列空且业务声明结束 → 关流
    if (this.#ended && this.#ms.readyState === 'open') {
      this.#ms.endOfStream();
    }
  }

  #fail(err) {
    this.dispatchEvent(new CustomEvent('mse-error', { detail: err }));
  }

  destroy() {
    if (this.#ms?.readyState === 'open') {
      try { this.#ms.endOfStream(); } catch {}
    }
    this.#queue.length = 0;
    this.#sb = null;
    this.#ms = null;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86

这里有 4 个新手必踩坑,必须逐个解释。

# 5.5 MSE 的 4

🪤 陷阱 1:SourceBuffer.updating 是异步状态

sb.appendBuffer(chunk1);
sb.appendBuffer(chunk2);   // ❌ 直接抛 InvalidStateError
1
2

原因:appendBuffer 是异步的——浏览器要先解析这段字节、写入解码器内部缓冲。第一段还没写完就喂第二段,浏览器直接拒绝。必须等 updateend 事件再喂下一段——这就是 #queue + #drain 的存在意义。

🪤 陷阱 2:MIME 必须精确到 codec

new MediaSource().addSourceBuffer('video/mp4');  // ❌ 报错
new MediaSource().addSourceBuffer('video/mp4; codecs="avc1.42E01E,mp4a.40.2"');  // ✅
1
2

怎么知道 codec? 用 MediaSource.isTypeSupported(...) 探测,或用 ffprobe 看:

ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,profile -of csv seg-1.mp4
# 输出:h264,Constrained Baseline → avc1.42E01E
1
2

🪤 陷阱 3:MediaSource 只能用一次

endOfStream() 之后这个 MediaSource 就永远关闭了——切清晰度(§06 §08)必须 new 一个新的 MediaSource,把 video.src 重新指过去。

🪤 陷阱 4:blob URL 必须 revokeObjectURL

URL.createObjectURL(ms) 创建的 URL 会永久占用内存,直到页面卸载。前面代码里在 sourceopen 之后立刻 revokeObjectURL——不影响已绑定的播放,但能让浏览器及早回收。

# 5.6 用 fetch

回到 Player.js,加一个新方法 loadMse(url, mime):

import { MseEngine } from './MseEngine.js';

export class Player extends EventTarget {
  // ... 已有字段 ...
  #mse = null;
  #abort = null;     // AbortController,§06 切清晰度时用

  async loadMse(url, mime = 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"') {
    this.#setState(PlayerState.LOADING);
    // 取消上一次加载(如果有)
    this.#abort?.abort('switch source');
    this.#abort = new AbortController();

    try {
      // 1) 打开 MSE
      this.#mse = new MseEngine(this.#video, mime);
      this.#mse.addEventListener('mse-error', e => {
        this.#lastError = e.detail;
        this.#setState(PlayerState.ERROR, e.detail);
      });
      await this.#mse.open();

      // 2) 流式拉取
      const resp = await fetch(url, { signal: this.#abort.signal });
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
      const reader = resp.body.getReader();   // ⭐ ReadableStream 流式 reader
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        // value 是 Uint8Array → 直接喂 MSE
        this.#mse.appendChunk(value.buffer);
      }
      this.#mse.endOfStream();
    } catch (err) {
      if (err.name === 'AbortError') return;   // 主动取消,不算错
      console.error('[Player] loadMse 失败', err);
      this.#lastError = err;
      this.#setState(PlayerState.ERROR, err);
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

📚 response.body.getReader():fetch 返回的 Response.body 是 ReadableStream——getReader() 让你一段一段取字节,而不是 await resp.arrayBuffer() 等所有字节下完。这是流媒体的关键——下来多少就喂多少给 MSE,浏览器立刻开始播。

# 5.7 UI 接入 +

修改 JsPlayerApp.attributeChangedCallback,让它检测扩展名走不同路径:

attributeChangedCallback(name, _old, val) {
  if (name === 'src' && val) {
    if (val.endsWith('.mp4') && val.includes('seg')) {
      // 测试用 fmp4 → 走 MSE
      this.#player.loadMse(val);
    } else {
      this.#player.load(val);   // 普通 mp4 走原生
    }
  }
}
1
2
3
4
5
6
7
8
9
10

🧪 立刻编译运行(阶段 ④ 第 1 次验收)

把 index.html 改成:

<jsplayer-app src="/seg-1.mp4" muted autoplay></jsplayer-app>
1

刷新 → 控制台依次看到:

[JsPlayerApp] idle → loading
[JsPlayerApp] loading → ready
[JsPlayerApp] ready → playing
1
2
3

视频前 5 秒能播 → 播完一会 video 应该自然停在 5 秒(因为 endOfStream)。

✅ 看到字节流通过 fetch + MSE 进入 video 元素 = MSE 闭环成功——这是 hls.js / shaka 的最小内核。

# 5.8 故意造 bug

为了让你感受 updating 异步,把 MseEngine.#drain 临时改成"不排队,直接喂":

appendChunk(arrayBuffer) {
  // 故意 bug:直接喂,不入队
  this.#sb.appendBuffer(arrayBuffer);
}
1
2
3
4

刷新 → 控制台立刻报:

DOMException: Failed to execute 'appendBuffer' on 'SourceBuffer':
This SourceBuffer is still processing an 'appendBuffer' operation.
1
2

✅ 看到这个错就对了——这是初学者第一次接 MSE 必踩的坑。改回 #queue + #drain 后 bug 消失。

💡 这就是 hls.js 源码里 class MSEController 整整 600 行的来历——队列、updating 检测、回压、错误恢复,每一行都对应一个真实的浏览器陷阱。

# 5.9 拼接两段 fmp4

把 loadMse 升级——接受 URL 数组,串行拉多段:

async loadMseSegments(urls, mime = 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"') {
  this.#setState(PlayerState.LOADING);
  this.#abort?.abort('switch');
  this.#abort = new AbortController();

  try {
    this.#mse = new MseEngine(this.#video, mime);
    await this.#mse.open();

    for (const url of urls) {
      const resp = await fetch(url, { signal: this.#abort.signal });
      if (!resp.ok) throw new Error(`HTTP ${resp.status} @ ${url}`);
      const buf = await resp.arrayBuffer();
      this.#mse.appendChunk(buf);
      // 等 MSE 把这段消化完再拉下一段(回压)
      await new Promise((res, rej) => {
        const sb = this.#mse.video.querySelector?.('source') ?? null;
        // 简化:等一个 microtask 让队列消费一次
        queueMicrotask(res);
      });
    }
    this.#mse.endOfStream();
  } catch (err) {
    if (err.name !== 'AbortError') {
      this.#lastError = err;
      this.#setState(PlayerState.ERROR, err);
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

⚠️ 这里的"回压"是简化版(只等了一个微任务)。真实工程里要监听 SourceBuffer 的 buffered ranges,超过 30 秒就停止下载——§06 m3u8 加载器会做正式版。

🧪 第 2 次验收:

<jsplayer-app src="seg" muted autoplay></jsplayer-app>
<script type="module">
  document.querySelector('jsplayer-app').addEventListener('player-state', e => {
    if (e.detail.to === 'ready') console.log('元数据 OK,duration =', e.target.player.video.duration);
  });
  // 直接调 player API
  customElements.whenDefined('jsplayer-app').then(() => {
    document.querySelector('jsplayer-app').player
      .loadMseSegments(['/seg-1.mp4', '/seg-2.mp4']);
  });
</script>
1
2
3
4
5
6
7
8
9
10
11

刷新 → 视频应该能完整播完两段(≈ demo.mp4 全长)。

┌─ 📌 阶段 ④ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的(这些是 80% 前端工程师从未碰过的):    │
│   • MediaSource / SourceBuffer / endOfStream        │
│   • MIME codecs 必须精确到字符串(avc1.42E01E)      │
│   • SourceBuffer.updating 异步状态 → 必须排队        │
│   • URL.createObjectURL + revokeObjectURL 内存回收  │
│   • response.body.getReader() 流式读字节             │
│   • AbortController 中断 fetch                       │
│ ⏸ 还没碰的:                                         │
│   • m3u8 解析 + ts 分片(§06)                       │
│   • 自适应码率 ABR(挑战)                           │
│ 📌 进入下阶段前:                                      │
│   git add . &amp;&amp; git commit -m "stage4: MSE pipeline" │
│ 💡 本阶段最大领悟:                                    │
│   "MSE 把 &lt;video> 从'文件播放器'升级成'字节流播放器'  │
│    这就是 B 站 / Youtube / Twitch 整个直播栈的根基"   │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 06.迷你 HLS:m3u8 + ts 分片

┌─ 🎯 阶段 ⑤ 目标 ────────────────────────────────────┐
│ 完成什么:手写 m3u8 解析器 + 分片下载器,串起 §05 MSE   │
│ 不做什么:不做加密 m3u8(AES-128 解密)                 │
│           不做 LL-HLS 低延迟(part 字段)               │
│ 验收标准:丢一个 index.m3u8 进去能边下边播              │
│ 预计耗时:120 min                                       │
│ 关键思路:m3u8 是"播放清单"——纯文本协议                │
│   解析它 = 字符串处理(卷一第 11 章)                   │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

# 6.1 m3u8 长什么样

❓ m3u8 协议是什么?

它是 Apple 设计的"视频清单格式"——一份纯文本文件,描述:"这部电影由哪些小片段组成、每片多长、在哪个 URL"。看一眼:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:9.967,
seg-001.ts
#EXTINF:9.967,
seg-002.ts
#EXTINF:5.123,
seg-003.ts
#EXT-X-ENDLIST
1
2
3
4
5
6
7
8
9
10
11

翻译:

  • #EXTM3U:固定文件头(必须)
  • #EXT-X-TARGETDURATION:10:每个分片最大 10 秒
  • #EXTINF:9.967,:下一行 ts 文件实际 9.967 秒
  • seg-001.ts:分片 URL(相对当前 m3u8)
  • #EXT-X-ENDLIST:点播(VOD)。直播没有这一行——拉的时候要不停轮询新清单

❓ HLS vs DASH 的区别?

维度 HLS DASH
出身 Apple MPEG 国际标准
清单 m3u8(文本) MPD(XML)
分片 ts / fmp4 fmp4
iOS 原生 ✅ ❌
中国直播 主流 少数

国内绝大多数视频网站走 HLS——本节学完你就能拆 B 站 / 优酷 / 爱奇艺的 m3u8 了。

❓ 为什么手写而不用 hls.js?

hls.js 25000 行——学透要 3 个月。本节实现最小可用版(200 行)——理解了再读 hls.js 一周搞定。

# 6.2 m3u8 解析器

📁 src/loaders/parseM3u8.js:

/**
 * 解析 m3u8 文本为结构化对象
 * @param {string} text  m3u8 原文
 * @param {string} baseUrl  m3u8 自身的 URL(用于把相对路径转成绝对)
 * @returns {{ live: boolean, targetDuration: number, segments: Array<{url:string, duration:number, seq:number}> }}
 */
export function parseM3u8(text, baseUrl) {
  const lines = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
  if (lines[0] !== '#EXTM3U') throw new Error('非合法 m3u8:缺 #EXTM3U');

  let live = true;            // 默认按直播处理,看到 ENDLIST 再改
  let targetDuration = 10;
  let mediaSequence = 0;
  const segments = [];
  let pendingDuration = null; // EXTINF 是"下一行的属性"

  for (const line of lines) {
    if (line.startsWith('#EXT-X-TARGETDURATION:')) {
      targetDuration = parseFloat(line.split(':')[1]);
    } else if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
      mediaSequence = parseInt(line.split(':')[1], 10);
    } else if (line === '#EXT-X-ENDLIST') {
      live = false;
    } else if (line.startsWith('#EXTINF:')) {
      // #EXTINF:9.967,  → 9.967
      pendingDuration = parseFloat(line.slice(8).split(',')[0]);
    } else if (!line.startsWith('#')) {
      // 不是注释 = URL
      if (pendingDuration == null) {
        console.warn('[m3u8] 跳过 URL(缺 EXTINF):', line);
        continue;
      }
      // ⭐ 用 URL 构造器把相对路径变绝对——不要自己拼字符串
      const abs = new URL(line, baseUrl).toString();
      segments.push({
        url: abs,
        duration: pendingDuration,
        seq: mediaSequence + segments.length,
      });
      pendingDuration = null;
    }
  }

  return { live, targetDuration, segments };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

设计要点:

要点 写法 作用
split(/\r?\n/) 容错 Windows / Mac 行尾差异
URL(rel, base) Web API 永远不要自己拼路径——baseUrl + rel 在带 ? 时会出错
pendingDuration 状态 暂存上一行 EXTINF 是 ts 行的"上一行"
直播 / 点播判断 EXT-X-ENDLIST 直播要轮询,点播不要

🧪 立刻验证(控制台)

import('/src/loaders/parseM3u8.js').then(({parseM3u8}) => {
  const sample = `#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXTINF:9.967,
seg-001.ts
#EXTINF:5.0,
seg-002.ts
#EXT-X-ENDLIST`;
  console.log(parseM3u8(sample, 'http://localhost:5173/hls/index.m3u8'));
});
1
2
3
4
5
6
7
8
9
10
11

预期输出:

{
  live: false,
  targetDuration: 10,
  segments: [
    { url: 'http://localhost:5173/hls/seg-001.ts', duration: 9.967, seq: 0 },
    { url: 'http://localhost:5173/hls/seg-002.ts', duration: 5.0,   seq: 1 },
  ]
}
1
2
3
4
5
6
7
8

✅ 解析器工作了——这是 200 行 hls.js 的核心 30 行。

# 6.3 HlsLoader

📁 src/loaders/HlsLoader.js:

import { parseM3u8 } from './parseM3u8.js';
import { MseEngine } from '../core/MseEngine.js';

// 注:真实 ts 分片需要 mux.js 之类的库做 ts→fmp4 转封装
// 本案例为简化教学,直接用 fmp4 分段 + 自定义清单(详见 §6.5 备注)
export class HlsLoader extends EventTarget {
  #video;
  #abort = null;
  #mime;

  constructor(videoEl, mime = 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"') {
    super();
    this.#video = videoEl;
    this.#mime  = mime;
  }

  cancel() { this.#abort?.abort('hls cancel'); }

  async load(playlistUrl) {
    this.cancel();
    this.#abort = new AbortController();
    const signal = this.#abort.signal;

    try {
      // 1) 拉清单
      const text = await fetch(playlistUrl, { signal }).then(r => {
        if (!r.ok) throw new Error('清单 HTTP ' + r.status);
        return r.text();
      });
      const playlist = parseM3u8(text, playlistUrl);
      this.dispatchEvent(new CustomEvent('manifest', { detail: playlist }));

      // 2) 打开 MSE
      const mse = new MseEngine(this.#video, this.#mime);
      await mse.open();

      // 3) 串行拉分片(带回压:buffered 超过 30s 就等)
      for (const seg of playlist.segments) {
        if (signal.aborted) return;
        await this.#waitBufferRoom(30);
        const buf = await fetch(seg.url, { signal }).then(r => r.arrayBuffer());
        mse.appendChunk(buf);
        this.dispatchEvent(new CustomEvent('segment', { detail: seg }));
      }
      mse.endOfStream();
      this.dispatchEvent(new CustomEvent('done'));
    } catch (err) {
      if (err.name === 'AbortError') return;
      this.dispatchEvent(new CustomEvent('hls-error', { detail: err }));
    }
  }

  // ⭐ 回压:让 buffered 末端最多领先当前播放时间 maxAhead 秒
  async #waitBufferRoom(maxAhead) {
    while (true) {
      const v = this.#video;
      if (!v.buffered.length) return;             // 还没开始播
      const end = v.buffered.end(v.buffered.length - 1);
      const ahead = end - v.currentTime;
      if (ahead < maxAhead) return;
      await new Promise(r => setTimeout(r, 500)); // 500ms 后再问
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

为什么要回压? 不加的话,10 分钟视频会一口气全下完到内存——手机用户骂街。hls.js 默认 buffer 不超过 60 秒——这里我们用 30 秒。

# 6.4 错误降级链(iO

Player.js 加一个统一入口 loadAuto:

import { HlsLoader } from '../loaders/HlsLoader.js';

async loadAuto(url) {
  this.#setState(PlayerState.LOADING);

  // 1) m3u8 + 浏览器原生支持(iOS Safari)→ 直接走原生
  const isHls = url.includes('.m3u8');
  if (isHls && this.#video.canPlayType('application/vnd.apple.mpegurl')) {
    console.log('[Player] iOS 原生 HLS 路径');
    this.#video.src = url;
    return;
  }

  // 2) m3u8 + MSE → 走自定义 HlsLoader
  if (isHls && 'MediaSource' in window) {
    console.log('[Player] MSE + 自定义 HLS 路径');
    this.#hls?.cancel();
    this.#hls = new HlsLoader(this.#video);
    this.#hls.addEventListener('hls-error', e => {
      this.#lastError = e.detail;
      this.#setState(PlayerState.ERROR, e.detail);
    });
    return this.#hls.load(url);
  }

  // 3) 兜底——普通 mp4 / webm
  console.log('[Player] 原生 src 路径');
  this.#video.src = url;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

📚 canPlayType() 返回值:'probably' / 'maybe' / ''——只有空字符串算"不支持"。这是判断浏览器格式支持的金标准。

# 6.5 备一份测试 m3

如果你不想真切 ts,最简办法:用 §05 切的两个 fmp4 + 自己写一份"伪 m3u8"。

📁 public/hls/index.m3u8:

#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-MAP:URI="../seg-1.mp4"
#EXTINF:5.0,
../seg-1.mp4
#EXTINF:5.0,
../seg-2.mp4
#EXT-X-ENDLIST
1
2
3
4
5
6
7
8
9
10

⚠️ 这只是教学用法。真生产环境的 m3u8 一定是 ts 分片——ts 喂 MSE 需要先用 mux.js (opens new window)(4KB gzip)把 ts 转 fmp4。挑战 B 让你接入 mux.js。

# 6.6 测试 + 故意触

index.html:

<jsplayer-app id="p" muted autoplay></jsplayer-app>
<script type="module">
  customElements.whenDefined('jsplayer-app').then(() => {
    const app = document.getElementById('p');
    app.player.loadAuto('/hls/index.m3u8');

    // 3 秒后切源——验证 AbortController 取消旧请求
    setTimeout(() => {
      console.log('[demo] 切到普通 mp4,旧 HLS 应被取消');
      app.player.loadAuto('/demo.mp4');
    }, 3000);
  });
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13

刷新 → 控制台:

[Player] MSE + 自定义 HLS 路径
[demo] 切到普通 mp4,旧 HLS 应被取消
[Player] 原生 src 路径
1
2
3

✅ 没有任何 "appendBuffer on closed MediaSource" 报错 = AbortController 取消链工作正常。

# 6.7 故意造 bug

把 loadAuto 里的 this.#hls?.cancel(); 临时注释掉,重新触发切源——控制台立刻报:

InvalidStateError: SourceBuffer is removed from MediaSource
1

原因:旧 HlsLoader 仍在 fetch 分片 → 拿到字节去 appendChunk → MSE 已经被新 loadAuto 替换 → 报错。

💡 这是前端"切源 / 切清晰度"的经典 bug——你随便打开 B 站切清晰度,F12 都能看到 hls.js 的 [hls.js] level 3 loaded 日志,正是同一套机制。

恢复后继续。

┌─ 📌 阶段 ⑤ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                      │
│   • m3u8 协议(手写解析器 30 行)                     │
│   • new URL(rel, base) 路径处理                     │
│   • HlsLoader:清单 → 分片串行 → MSE 喂数据           │
│   • buffered 回压(防内存爆炸)                       │
│   • canPlayType 探测原生 HLS(iOS 降级)              │
│   • AbortController 取消链(切源不报错)              │
│ ⏸ 还没碰的:                                         │
│   • 自定义控制条 UI(§07)                            │
│   • Canvas 弹幕(§07)                                │
│ 📌 进入下阶段前:                                      │
│   git add . &amp;&amp; git commit -m "stage5: mini HLS"     │
│ 💡 本阶段最大领悟:                                    │
│   "hls.js 不是黑魔法——核心 = m3u8 解析 + fetch +     │
│    MSE 排队。你已经写出了一个可以打实战的微缩版"      │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 07.控制层:进度条 + rVFC + 24 个 video 事件

┌─ 🎯 阶段 ⑥ 目标 ────────────────────────────────────┐
│ 完成什么:自定义控制条——播放/进度条/音量/倍速          │
│ 不做什么:不写弹幕(§08)、不写 PiP(§08)              │
│ 验收标准:关掉原生 controls,自己 UI 全功能可用         │
│ 预计耗时:90 min                                         │
│ 关键思路:进度条三色(played / buffered / seekable) │
│   时间更新用 requestVideoFrameCallback 替代 timeupdate│
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8

# 7.1 HTMLVide

❓ 为什么这一节叫"24 个事件"?

W3C 规范规定 <video> 派发 24 个事件,初学者最少要分清这 8 个:

事件 触发时机 用途
loadstart 开始下载 显示 loading
loadedmetadata duration 等元数据可用 初始化进度条
canplay 当前帧可播 隐藏 loading
playing 真正开始播 切到播放图标
pause 暂停 切到暂停图标
waiting 缓冲不足卡顿 显示 loading(再现)
timeupdate 时间更新(约 4 次/秒) 更新进度条
ended 播完 显示重播按钮

❓ timeupdate 不是更新进度条的最佳事件——为什么?

timeupdate 频率不稳——浏览器规范允许 4~66 ms 之间随便派发,60Hz 显示器上有时一帧不更新,导致进度条卡顿。正确做法:用 requestVideoFrameCallback(rVFC)—— 每帧解码完后精准回调一次。

❓ played / buffered / seekable 三个 TimeRanges 是什么?

0────●────●────●────●────●────●────●────●────●──── duration
     [   played   ]  ← 用户已观看的范围(红)
     [        buffered          ] ← 已下载到内存的范围(灰)
     [           seekable             ] ← 允许 seek 的范围(蓝/隐藏)
1
2
3
4

直播的 seekable 通常等于 buffered——你不能 seek 到"未来"。点播的 seekable 是 [0, duration]。

# 7.2 Step 1

把 JsPlayerApp 的 TEMPLATE.innerHTML 升级——加一整套控制条:

TEMPLATE.innerHTML = /*html*/`
  <style>
    :host { display: block; position: relative; background:#000; aspect-ratio:16/9;
            color:#eee; font:14px/1.5 system-ui; user-select:none; }
    video { width:100%; height:100%; display:block; background:#000; }
    .placeholder { position:absolute; inset:0; display:grid; place-items:center;
                   opacity:.5; pointer-events:none; }

    /* ─── 控制条 ─── */
    .ctrl { position:absolute; left:0; right:0; bottom:0; padding:8px 12px;
            background:linear-gradient(to top, rgba(0,0,0,.7), transparent);
            display:flex; align-items:center; gap:10px; opacity:0;
            transition:opacity .2s; }
    :host(:hover) .ctrl, :host([data-paused]) .ctrl { opacity:1; }

    button { background:transparent; border:0; color:#fff; font-size:16px;
             cursor:pointer; padding:4px 8px; }
    button:hover { color:#3ea6ff; }

    /* ─── 进度条三色 ─── */
    .bar { position:relative; flex:1; height:4px; background:rgba(255,255,255,.2);
           cursor:pointer; border-radius:2px; }
    .bar:hover { height:6px; }
    .seekable, .buffered, .played {
      position:absolute; left:0; top:0; bottom:0; border-radius:2px;
    }
    .seekable { background:rgba(255,255,255,.2); width:100%; }
    .buffered { background:rgba(255,255,255,.5); }
    .played   { background:#3ea6ff; }
    .thumb    { position:absolute; top:50%; width:12px; height:12px;
                margin:-6px 0 0 -6px; background:#3ea6ff; border-radius:50%;
                opacity:0; transition:opacity .15s; }
    .bar:hover .thumb { opacity:1; }

    .time { font-variant-numeric: tabular-nums; opacity:.85; min-width:96px; }
  </style>

  <video playsinline></video>
  <div class="placeholder"></div>

  <div class="ctrl" part="controls">
    <button class="play" title="播放/暂停">▶</button>
    <div class="bar">
      <div class="seekable"></div>
      <div class="buffered"></div>
      <div class="played"></div>
      <div class="thumb"></div>
    </div>
    <span class="time">00:00 / 00:00</span>
    <button class="speed" title="倍速">1.0×</button>
    <button class="mute" title="静音">🔊</button>
    <button class="full" title="全屏">⛶</button>
  </div>
`;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

📚 part="controls":让外部 CSS 可以用 jsplayer-app::part(controls) { ... } 调样式——这是 Shadow DOM 唯一允许的样式开放点,比全部塞 CSS 变量优雅。

# 7.3 进度条三色绘制

新建 Controls 类专门管控制条 UI——保持单一职责:

📁 src/ui/Controls.js:

export class Controls {
  #host;        // 自定义元素本体(Shadow root 的宿主)
  #player;
  #els;
  #raf = null;
  #rvfcId = null;

  constructor(hostEl, shadowRoot, player) {
    this.#host = hostEl;
    this.#player = player;
    this.#els = {
      play:     shadowRoot.querySelector('.play'),
      mute:     shadowRoot.querySelector('.mute'),
      speed:    shadowRoot.querySelector('.speed'),
      full:     shadowRoot.querySelector('.full'),
      bar:      shadowRoot.querySelector('.bar'),
      played:   shadowRoot.querySelector('.played'),
      buffered: shadowRoot.querySelector('.buffered'),
      thumb:    shadowRoot.querySelector('.thumb'),
      time:     shadowRoot.querySelector('.time'),
    };
    this.#bind();
  }

  #bind() {
    const v = this.#player.video;
    const e = this.#els;

    // ⭐ 按钮
    e.play.addEventListener('click', () =>
      v.paused ? this.#player.play() : this.#player.pause());

    e.mute.addEventListener('click', () => {
      v.muted = !v.muted;
      e.mute.textContent = v.muted ? '🔇' : '🔊';
    });

    e.speed.addEventListener('click', () => {
      const seq = [1.0, 1.25, 1.5, 2.0, 0.75];
      const idx = (seq.indexOf(v.playbackRate) + 1) % seq.length;
      v.playbackRate = seq[idx];
      e.speed.textContent = seq[idx].toFixed(2) + '×';
    });

    e.full.addEventListener('click', () => this.#toggleFullscreen());

    // ⭐ 进度条 seek
    e.bar.addEventListener('click', (ev) => {
      const rect = e.bar.getBoundingClientRect();
      const pct = (ev.clientX - rect.left) / rect.width;
      v.currentTime = pct * (v.duration || 0);
    });

    // ⭐ 状态镜像
    v.addEventListener('play',  () => { e.play.textContent = '⏸'; this.#host.removeAttribute('data-paused'); });
    v.addEventListener('pause', () => { e.play.textContent = '▶';  this.#host.setAttribute('data-paused', ''); });

    // ⭐ 关键:buffered 用 progress 事件(轻量)
    v.addEventListener('progress', () => this.#renderBuffered());
    v.addEventListener('loadedmetadata', () => this.#renderTime());

    // ⭐ played + currentTime 用 rVFC(高频且与帧同步)
    this.#startFrameLoop();
  }

  // ⭐ requestVideoFrameCallback——卷一第 14.10 浏览器 API
  #startFrameLoop() {
    const v = this.#player.video;
    if ('requestVideoFrameCallback' in v) {
      const tick = () => {
        this.#renderPlayed();
        this.#renderTime();
        this.#rvfcId = v.requestVideoFrameCallback(tick);
      };
      this.#rvfcId = v.requestVideoFrameCallback(tick);
    } else {
      // 降级:rAF
      const tick = () => {
        this.#renderPlayed();
        this.#renderTime();
        this.#raf = requestAnimationFrame(tick);
      };
      this.#raf = requestAnimationFrame(tick);
    }
  }

  #renderPlayed() {
    const v = this.#player.video;
    const pct = v.duration ? (v.currentTime / v.duration) * 100 : 0;
    this.#els.played.style.width = pct + '%';
    this.#els.thumb.style.left   = pct + '%';
  }

  #renderBuffered() {
    const v = this.#player.video;
    if (!v.duration || !v.buffered.length) return;
    // 拿"包含当前播放点"的那段 buffered
    let end = 0;
    for (let i = 0; i < v.buffered.length; i++) {
      if (v.buffered.start(i) <= v.currentTime && v.currentTime <= v.buffered.end(i)) {
        end = v.buffered.end(i); break;
      }
    }
    if (!end) end = v.buffered.end(v.buffered.length - 1);
    this.#els.buffered.style.width = (end / v.duration * 100) + '%';
  }

  #renderTime() {
    const v = this.#player.video;
    this.#els.time.textContent = `${fmt(v.currentTime)} / ${fmt(v.duration || 0)}`;
  }

  #toggleFullscreen() {
    // 多前缀兼容(Safari iOS 还有更特殊的 webkitEnterFullscreen)
    const enter = this.#host.requestFullscreen ?? this.#host.webkitRequestFullscreen;
    const exit  = document.exitFullscreen ?? document.webkitExitFullscreen;
    const fsEl  = document.fullscreenElement ?? document.webkitFullscreenElement;
    if (fsEl) exit.call(document); else enter.call(this.#host);
  }

  destroy() {
    if (this.#raf) cancelAnimationFrame(this.#raf);
    if (this.#rvfcId && this.#player.video.cancelVideoFrameCallback) {
      this.#player.video.cancelVideoFrameCallback(this.#rvfcId);
    }
  }
}

function fmt(sec) {
  if (!isFinite(sec)) return '00:00';
  const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
  return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133

# 7.4 把 Contro

import { Controls } from './Controls.js';

connectedCallback() {
  super.connectedCallback?.();
  this.#controls = new Controls(this, this.#shadow, this.#player);

  this.#player.addEventListener('statechange', e => {
    this.#updatePlaceholder(e.detail.to);
  });

  if (this.hasAttribute('src')) this.#player.loadAuto(this.getAttribute('src'));
}

disconnectedCallback() {
  this.#controls?.destroy();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

并把 <video> 的 controls = true 改回 false——现在我们自己渲染控制条。

🧪 立刻编译运行(阶段 ⑥ 验收)

刷新 → 应看到自定义控制条(鼠标悬停时浮现):

  • 进度条三色:蓝(已播)/ 浅白(已缓冲)/ 暗白(可 seek)
  • 时间显示 01:23 / 12:34 实时更新
  • 倍速按钮点一下:1.00× → 1.25× → 1.50× → 2.00× → 0.75× 循环
  • 全屏按钮:进 / 出全屏

# 7.5 rVFC vs

事件源 频率 与视频帧同步? 用途
timeupdate 4~66 ms 不固定 ❌ 老代码兼容,不推荐
requestAnimationFrame 60Hz(屏幕) ❌(屏幕帧≠视频帧) UI 动画
requestVideoFrameCallback 每解码一帧调一次 ✅ 弹幕同步 / 字幕 / 进度条

最强证据:B 站新版播放器 / Youtube 新版播放器 / shaka-player 全部用 rVFC——老逻辑用 timeupdate 的播放器在 4K HDR 视频上肉眼可见的卡顿。

# 7.6 故意造 bug

把 Controls.destroy 里的 cancelVideoFrameCallback 临时删掉。然后在控制台:

// 频繁创建销毁元素
for (let i = 0; i < 10; i++) {
  const el = document.createElement('jsplayer-app');
  el.src = '/demo.mp4';
  document.body.appendChild(el);
  setTimeout(() => el.remove(), 500);
}
1
2
3
4
5
6
7

打开 Performance 面板录制 5 秒 → 会看到 rVFC 回调一直在触发——内存涨、电量耗。这就是前端最经典的事件泄漏。

恢复 destroy 后再测 → 回调归零。

┌─ 📌 阶段 ⑥ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                      │
│   • 24 个 video 事件中最重要的 8 个                   │
│   • TimeRanges API(played / buffered / seekable)   │
│   • requestVideoFrameCallback(与帧同步)             │
│   • Controls 单一职责拆分                            │
│   • Fullscreen API 多前缀兼容                        │
│   • disconnectedCallback 释放资源                    │
│ ⏸ 还没碰的:                                         │
│   • Canvas 弹幕(§08)                                │
│   • PiP / Media Session(§08)                       │
│ 📌 进入下阶段前:                                      │
│   git add . &amp;&amp; git commit -m "stage6: controls"     │
│ 💡 本阶段最大领悟:                                    │
│   "高级前端 = 知道 timeupdate 不能用、必须用 rVFC"    │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 08.Canvas 弹幕:对象池 + 时间索引

┌─ 🎯 阶段 ⑦ 目标 ────────────────────────────────────┐
│ 完成什么:单 Canvas 渲染 1000+ 条弹幕滚动不卡             │
│ 不做什么:不写"高级弹幕"——抽屉特效、礼物动画都不做         │
│ 验收标准:1000 条弹幕,首屏 60fps、内存稳定                │
│ 预计耗时:120 min                                          │
│ 关键思路:弹幕 ≠ 1000 个 div + CSS 动画——必须 Canvas      │
│   核心:对象池 + 桶排序时间索引 + RAF                     │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8

# 8.1 弹幕为什么不能用

❓ B 站为什么不直接 1000 个 <div> + transform: translateX?

试一下你就知道:

for (let i = 0; i < 1000; i++) {
  const d = document.createElement('div');
  d.style.cssText = 'position:absolute; transform: translateX(0)';
  d.textContent = '弹幕 ' + i;
  document.body.appendChild(d);
}
1
2
3
4
5
6

性能炸点:

  1. DOM 节点 = 内存大户——1000 个 div 平均 1MB+ 内存
  2. 每帧 1000 次 layout / composite——主线程帧率瞬间从 60→ 20
  3. GPU 纹理上传——transform 触发 layer,每条弹幕一个 layer = 显存爆炸

❓ Canvas 凭什么能扛?

维度 1000 div 1 Canvas
DOM 节点 1000 个 1 个
layout / paint 1000 次/帧 0 次/帧
显存 N 个 layer 1 个纹理
文本渲染 浏览器原生 自己 fillText
命中事件 每个 div 自动 自己算

结论:Canvas 把"1000 条独立 DOM"压成"1 张图"——CPU 算 1000 个坐标 vs 浏览器排版 1000 个元素,差 50 倍。

❓ 现在第一步该做哪个?

回到三问"先做被依赖项"——先做"加载弹幕数据",再做"按时间索引",最后才是 Canvas 绘制。

# 8.2 弹幕数据 + 时

📁 测试数据 public/danmaku.json:

[
  { "t": 1.5, "text": "前方高能", "color": "#fff", "type": "scroll" },
  { "t": 2.0, "text": "23333",   "color": "#3ea6ff", "type": "scroll" },
  { "t": 2.0, "text": "笑死",    "color": "#ff5c5c", "type": "scroll" },
  { "t": 5.5, "text": "稳",      "color": "#fff", "type": "scroll" }
]
1
2
3
4
5
6

📁 src/danmaku/Index.js:

/**
 * 时间索引:按整数秒分桶
 * 查询"当前秒新出现的弹幕" 从 O(n) 变成 O(1)
 */
export class DanmakuIndex {
  #buckets = new Map();   // second(int) → DanmakuItem[]
  #total = 0;

  load(items) {
    this.#buckets.clear();
    this.#total = 0;
    for (const it of items) {
      const sec = Math.floor(it.t);
      let bucket = this.#buckets.get(sec);
      if (!bucket) { bucket = []; this.#buckets.set(sec, bucket); }
      bucket.push(it);
      this.#total++;
    }
  }

  // 取 [from, to) 区间内出现的所有弹幕
  range(from, to) {
    const out = [];
    for (let s = Math.floor(from); s < Math.ceil(to); s++) {
      const b = this.#buckets.get(s);
      if (b) for (const it of b) {
        if (it.t >= from && it.t < to) out.push(it);
      }
    }
    return out;
  }

  get total() { return this.#total; }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

📚 桶排序思路:弹幕的 t 是有限范围浮点数,按 Math.floor(t) 分组——查询当前播放秒时只看 1~2 个桶,O(1) 命中。这种"按时间维度建索引"的思路在卷一第 02.4 提过,第一次在工程里看到它的威力。

# 8.3 对象池——Tra

弹幕轨道(Track)= 横向条带
   ┌──────────────────────────────────┐
   │ Track 0: ──→ [前方高能]──────→     │
   │ Track 1: ────→ [23333]──────→     │
   │ Track 2: ────────→ [笑死]──→     │
   │ Track 3: ────────────→ [稳]─→     │
   └──────────────────────────────────┘
1
2
3
4
5
6
7

📁 src/danmaku/Engine.js:

const FONT_SIZE = 20;
const TRACK_HEIGHT = 28;
const SCROLL_DURATION = 8;   // 弹幕从右走到左 8 秒

class Bullet {
  text = ''; color = '#fff';
  x = 0; y = 0; w = 0;
  speed = 0;        // 像素/秒
  alive = false;

  reset(item, canvasW, ctx, trackY) {
    this.text  = item.text;
    this.color = item.color || '#fff';
    ctx.font   = `${FONT_SIZE}px system-ui`;
    this.w     = ctx.measureText(item.text).width;
    this.x     = canvasW;            // 从右边出场
    this.y     = trackY;
    this.speed = (canvasW + this.w) / SCROLL_DURATION;
    this.alive = true;
  }
}

export class DanmakuEngine {
  #canvas; #ctx;
  #pool = [];          // 对象池
  #active = [];        // 正在显示的 bullets
  #tracks;             // 每条 track 的"最右侧已占用 x"
  #lastRenderSec = -1;
  #index;

  constructor(canvas, index) {
    this.#canvas = canvas;
    this.#ctx    = canvas.getContext('2d');
    this.#index  = index;
    this.#initTracks();
    // 预分配 200 个对象
    for (let i = 0; i < 200; i++) this.#pool.push(new Bullet());
  }

  #initTracks() {
    const n = Math.floor(this.#canvas.height / TRACK_HEIGHT);
    this.#tracks = new Array(n).fill(0);
  }

  // ⭐ 核心:从池里取,没有就 new
  #acquire() {
    return this.#pool.pop() ?? new Bullet();
  }
  #release(b) {
    b.alive = false;
    this.#pool.push(b);
  }

  // ⭐ 找一条"右端不会撞车"的 track
  #pickTrack(bulletWidth) {
    const W = this.#canvas.width;
    for (let i = 0; i < this.#tracks.length; i++) {
      // 这条 track 上"最右弹幕的左端"已经离开屏幕右边一定距离
      if (this.#tracks[i] < W - 50) return i;
    }
    return -1;   // 全满,丢弃这条弹幕
  }

  // 视频时间推进 → 拉取新出现的弹幕
  spawnFor(currentTime) {
    const sec = Math.floor(currentTime);
    if (sec === this.#lastRenderSec) return;
    const items = this.#index.range(this.#lastRenderSec + 1, sec + 1);
    this.#lastRenderSec = sec;

    for (const item of items) {
      const tmpW = this.#ctx.measureText(item.text).width;
      const trackIdx = this.#pickTrack(tmpW);
      if (trackIdx < 0) continue;            // 满轨:丢弃
      const b = this.#acquire();
      b.reset(item, this.#canvas.width, this.#ctx, trackIdx * TRACK_HEIGHT + FONT_SIZE);
      this.#tracks[trackIdx] = this.#canvas.width + tmpW; // 占轨
      this.#active.push(b);
    }
  }

  // 每帧调一次:移动 + 绘制 + 回收
  render(dt) {
    const ctx = this.#ctx;
    ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height);
    ctx.font = `${FONT_SIZE}px system-ui`;
    ctx.textBaseline = 'alphabetic';

    for (let i = this.#active.length - 1; i >= 0; i--) {
      const b = this.#active[i];
      b.x -= b.speed * dt;
      // 出屏 → 回收
      if (b.x + b.w < 0) {
        this.#active.splice(i, 1);
        this.#release(b);
        continue;
      }
      ctx.fillStyle = b.color;
      ctx.fillText(b.text, b.x, b.y);
    }

    // 更新 track 占用(用 active 重新算)
    this.#tracks.fill(0);
    for (const b of this.#active) {
      const tIdx = Math.floor((b.y - FONT_SIZE) / TRACK_HEIGHT);
      this.#tracks[tIdx] = Math.max(this.#tracks[tIdx], b.x + b.w);
    }
  }

  resize(w, h) {
    this.#canvas.width = w; this.#canvas.height = h;
    this.#initTracks();
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114

对象池 4 大要点:

要点 写法 目的
#pool / #active 双数组 复用 vs 在播 避免 new/GC
预分配 200 个 启动时 跳过冷启动抖动
#release 不是 splice pop/push O(1) 操作
满轨直接丢 不挤 用户体验:宁缺毋滥

# 8.4 把 Engine

TEMPLATE 里 video 上叠 canvas:

<video playsinline></video>
<canvas class="danmu" style="position:absolute; inset:0; pointer-events:none;"></canvas>
<div class="placeholder"></div>
<div class="ctrl"> ...原内容... </div>
1
2
3
4

Controls 加一段:

import { DanmakuIndex } from '../danmaku/Index.js';
import { DanmakuEngine } from '../danmaku/Engine.js';

// 在 constructor 末尾
const canvas = shadowRoot.querySelector('.danmu');
this.#index  = new DanmakuIndex();
this.#engine = new DanmakuEngine(canvas, this.#index);
this.#syncCanvasSize();
new ResizeObserver(() => this.#syncCanvasSize()).observe(this.#host);

// 加载弹幕
fetch('/danmaku.json').then(r => r.json()).then(arr => {
  this.#index.load(arr);
  console.log('[Danmaku] loaded', arr.length);
});

// 把弹幕 render 接到 §07 的 frame loop
// 在原 #startFrameLoop 的 tick 里追加:
const v = this.#player.video;
const dt = (performance.now() - (this.#lastTs ?? performance.now())) / 1000;
this.#lastTs = performance.now();
this.#engine.spawnFor(v.currentTime);
this.#engine.render(Math.min(dt, 0.1));   // dt clamp 防止切后台后大跳

// #syncCanvasSize 实现
#syncCanvasSize() {
  const r = this.#host.getBoundingClientRect();
  this.#engine.resize(r.width, r.height);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

🧪 立刻编译运行(阶段 ⑦ 第 1 次验收)

刷新 → 视频上叠了横向滚动的弹幕。打开 Performance:

  • FPS 稳定 60
  • Memory 不爆涨
  • Main 线程 idle > 50%

✅ 看到弹幕滚动且不卡 = 对象池工作了。

# 8.5 故意造 bug

把 #acquire 改成永远 new Bullet()、#release 改成 // noop——再生成 1 万条弹幕:

const fake = []; for (let i=0;i<10000;i++) fake.push({t:i*0.05, text:'X'+i, color:'#fff'});
controls.index.load(fake);
1
2

DevTools Memory 录制 → 看到锯齿状 GC——每秒一次掉到 100MB 又回 200MB。这就是对象池的诞生原因:游戏 / 弹幕 / 动画 / WebGL 粒子全靠它压 GC。

恢复后再测 → 内存平稳。

# 8.6 切后台 dt 跳

❓ dt clamp 0.1 是干嘛的?

视频切到后台 tab 时浏览器把 rVFC 限到 1 秒/次——回到前台时一帧 dt 突然变 1 秒,所有弹幕瞬间飞出屏幕!Math.min(dt, 0.1) 把单帧最大跳变锁在 100ms 以内——这是真实播放器必须做的细节。

┌─ 📌 阶段 ⑦ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                      │
│   • Canvas 2D:fillText / measureText / clearRect   │
│   • 对象池模式(acquire/release)                    │
│   • 桶索引按整数秒分组                                │
│   • Track 防撞算法                                   │
│   • dt clamp 防后台切换抖动                          │
│   • ResizeObserver 响应尺寸变化                      │
│ ⏸ 还没碰的:                                         │
│   • PiP / Media Session(§09)                       │
│   • WebVTT 字幕(§09)                                │
│ 📌 进入下阶段前:                                      │
│   git add . &amp;&amp; git commit -m "stage7: danmaku"      │
│ 💡 本阶段最大领悟:                                    │
│   "前端高性能渲染的本质:单 Canvas + 对象池 + 索引    │
│    这三板斧打遍弹幕 / 编辑器 / 游戏 / 数据可视化"     │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 09.Web 平台 API 大全:PiP / Media Session / WebVTT

┌─ 🎯 阶段 ⑧ 目标 ────────────────────────────────────┐
│ 完成什么:把 4 个"提升档次"的浏览器 API 一次性接全     │
│ 不做什么:不做剧场模式 / 不做 DRM(EME)                │
│ 验收标准:PiP / 锁屏控制 / WebVTT 字幕 / 全屏全部生效  │
│ 预计耗时:60 min                                         │
│ 关键思路:浏览器宿主 API 都长得很像——                   │
│   特性检测 → 多前缀 → 优雅降级                         │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8

# 9.1 宿主 API 凭

❓ PiP / Fullscreen / Media Session / WebVTT 不是 4 个完全不同的东西吗?

它们都遵循同一套套路:

if ('FeatureName' in window || 'methodName' in element) {
  // 用法
  element.methodName(...);
} else {
  // 降级:要么不做,要么 polyfill
}
1
2
3
4
5
6

这就是宿主 API 的统一气质——MDN 上每个 API 都有一栏 "Browser compatibility",写代码时永远先看这栏,再看用法。

❓ 学完本节最大收获是什么?

学会"读 MDN 的方法论"——掌握 3 个 API,剩下 100 个 API 都是同一套(navigator.share / navigator.clipboard / Geolocation / Notification / WakeLock / Web Speech ……)。

# 9.2 画中画(PiP)

// 在 Controls 里加按钮
e.pip = shadow.querySelector('.pip');   // 模板里加 <button class="pip" title="画中画">🎯</button>

e.pip.addEventListener('click', async () => {
  const v = this.#player.video;
  try {
    if (document.pictureInPictureElement) {
      await document.exitPictureInPicture();
    } else if (document.pictureInPictureEnabled) {
      await v.requestPictureInPicture();
    } else {
      alert('当前浏览器不支持画中画');
    }
  } catch (err) {
    console.warn('[PiP] 失败', err);
  }
});

// 监听 PiP 进入 / 退出(用户手动关 PiP 窗口要同步 UI)
this.#player.video.addEventListener('enterpictureinpicture',  () => e.pip.classList.add('active'));
this.#player.video.addEventListener('leavepictureinpicture',  () => e.pip.classList.remove('active'));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

特性检测三层:

  1. document.pictureInPictureElement —— 当前是否已在 PiP(用来切换文案)
  2. document.pictureInPictureEnabled —— 浏览器是否允许 PiP(Firefox / iOS Safari = false)
  3. video.disablePictureInPicture —— 这个具体 video 是否禁用了

# 9.3 Media Se

🚀 效果:手机锁屏 / 蓝牙耳机 / Mac 控制中心 上能看到你的视频在播,封面、进度都对——一行 addEventListener 都不用,浏览器自动接管。

function setupMediaSession(player, meta) {
  if (!('mediaSession' in navigator)) return;

  navigator.mediaSession.metadata = new MediaMetadata({
    title:  meta.title  || 'jsplayer 演示',
    artist: meta.artist || '杨充',
    album:  meta.album  || 'YCBookBlog',
    artwork: [
      { src: meta.cover || '/cover-512.png', sizes: '512x512', type: 'image/png' },
    ],
  });

  // 锁屏按钮回调
  navigator.mediaSession.setActionHandler('play',  () => player.play());
  navigator.mediaSession.setActionHandler('pause', () => player.pause());
  navigator.mediaSession.setActionHandler('seekbackward', d => player.seek(player.video.currentTime - (d.seekOffset || 10)));
  navigator.mediaSession.setActionHandler('seekforward',  d => player.seek(player.video.currentTime + (d.seekOffset || 10)));

  // ⭐ 给系统报"当前时间"——让锁屏进度条能实时滚动
  player.video.addEventListener('timeupdate', () => {
    if (!isFinite(player.video.duration)) return;
    navigator.mediaSession.setPositionState({
      duration: player.video.duration,
      playbackRate: player.video.playbackRate,
      position: player.video.currentTime,
    });
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

在 JsPlayerApp.connectedCallback 里调一次 setupMediaSession(this.#player, { title: this.getAttribute('title') })。

🧪 验证:手机访问 → 播放 → 锁屏 / 拉下通知中心 → 看到正在播放卡片 → 点暂停按钮能控制视频。

# 9.4 WebVTT 字

WebVTT 是 W3C 标准字幕格式,长得像 m3u8——纯文本协议:

WEBVTT

00:00:01.000 --> 00:00:03.500
你好,世界

00:00:04.000 --> 00:00:06.000
这是字幕第二行
1
2
3
4
5
6
7

📁 src/subtitle/parseVtt.js:

export function parseVtt(text) {
  const lines = text.split(/\r?\n/);
  if (!lines[0].startsWith('WEBVTT')) throw new Error('非合法 WebVTT');

  const cues = [];
  let i = 1;
  while (i < lines.length) {
    const line = lines[i].trim();
    if (!line || line.startsWith('NOTE')) { i++; continue; }

    // 可能有 cue id(一整行非时间码),跳过
    const arrow = line.includes('-->') ? line : (i++, lines[i]?.trim() ?? '');
    if (!arrow.includes('-->')) { i++; continue; }

    const [a, b] = arrow.split('-->').map(s => parseTime(s.trim()));
    i++;
    const buf = [];
    while (i < lines.length && lines[i].trim()) {
      buf.push(lines[i]); i++;
    }
    cues.push({ start: a, end: b, text: buf.join('\n') });
  }
  return cues;
}

function parseTime(s) {
  // hh:mm:ss.ms 或 mm:ss.ms
  const parts = s.split(':');
  let h = 0, m = 0, sec = 0;
  if (parts.length === 3) [h, m, sec] = parts.map(Number);
  else                    [m, sec]    = parts.map(Number);
  return h * 3600 + m * 60 + sec;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

📁 src/subtitle/SubtitleLayer.js——简化版,复用 §08 的 DanmakuIndex 思路:

export class SubtitleLayer {
  #host; #cues = []; #cur = null;
  constructor(hostEl) {
    this.#host = document.createElement('div');
    Object.assign(this.#host.style, {
      position: 'absolute', left: '0', right: '0', bottom: '60px',
      textAlign: 'center', color: '#fff', fontSize: '20px',
      textShadow: '0 0 4px #000', pointerEvents: 'none', whiteSpace: 'pre-line',
    });
    hostEl.appendChild(this.#host);
  }
  load(cues) { this.#cues = cues; }
  update(t) {
    const c = this.#cues.find(x => t >= x.start && t < x.end);
    if (c === this.#cur) return;
    this.#cur = c;
    this.#host.textContent = c?.text ?? '';
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在 Controls frame loop 里追加 this.#subs?.update(v.currentTime)。

🧪 验证:放一份 demo.vtt 进 public,加载 → 关键时间点看到字幕浮现。

# 9.5 宿主 API 全

名字 入口 一句话 兼容
Fullscreen el.requestFullscreen() 全屏 iOS Safari 要 webkitEnterFullscreen 在 video 上
PiP video.requestPictureInPicture() 画中画 Firefox / iOS Safari 不支持
Media Session navigator.mediaSession 系统控制 iOS 14+
WebVTT 手写解析 + <track> 字幕 全平台
Wake Lock navigator.wakeLock.request('screen') 阻止息屏 移动端 87% 覆盖
Screen Orientation screen.orientation.lock('landscape') 强制横屏 Android 全屏后才生效
Battery navigator.getBattery() 看电量 大部分浏览器已废弃
Web Share navigator.share() 调起系统分享 移动端佳

💡 学习方法:每个新 API 第一时间查 caniuse.com——没 80% 覆盖就别上生产。

┌─ 📌 阶段 ⑧ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                      │
│   • Picture-in-Picture(多浏览器特性检测)            │
│   • Media Session(手机锁屏 / Mac 控制中心)          │
│   • WebVTT 手写解析器                                │
│   • SubtitleLayer DOM 文本叠加                       │
│   • caniuse 兼容性查表的方法论                        │
│ ⏸ 还没碰的(留作挑战 / 下一案例):                    │
│   • EME(DRM 加密视频)                              │
│   • WebRTC(直播推流)                               │
│   • WebCodecs(自定义解码器)                        │
│ 📌 进入下阶段前:                                      │
│   git add . &amp;&amp; git commit -m "stage8: platform APIs"│
│ 💡 本阶段最大领悟:                                    │
│   "Web 平台 API 都长一个样:特性检测→多前缀→降级"     │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 10.项目总结

# 10.1 你写下的 ~20

jsplayer/
├── index.html
├── style.css
├── public/
│   ├── seg-1.mp4 / seg-2.mp4    # fmp4 测试分段
│   ├── hls/index.m3u8           # 测试 HLS 清单
│   ├── danmaku.json             # 弹幕数据
│   ├── demo.vtt                 # 字幕
│   └── cover-512.png
└── src/
    ├── main.js                   # 入口
    ├── ui/
    │   ├── JsPlayerApp.js        # ⭐ 自定义元素 + Shadow DOM
    │   └── Controls.js           # 控制条 + rVFC 帧循环
    ├── core/
    │   ├── Player.js             # ⭐ 状态机 + EventTarget
    │   └── MseEngine.js          # ⭐ MediaSource + 队列
    ├── loaders/
    │   ├── parseM3u8.js          # ⭐ m3u8 字符串解析
    │   └── HlsLoader.js          # ⭐ 清单 → 分片 → MSE
    ├── danmaku/
    │   ├── Index.js              # 桶索引
    │   └── Engine.js             # ⭐ Canvas + 对象池
    └── subtitle/
        ├── parseVtt.js
        └── SubtitleLayer.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 10.2 一图看完八阶段成长

阶段 ①  &lt;video src=...>                            ← 30 行
阶段 ②  + JsPlayerApp + Shadow DOM                  ← 100 行
阶段 ③  + Player(状态机)                          ← +180 行
阶段 ④  + MseEngine(队列)                         ← +250 行
阶段 ⑤  + parseM3u8 + HlsLoader                     ← +250 行
阶段 ⑥  + Controls + rVFC 进度条三色                ← +400 行
阶段 ⑦  + Canvas 弹幕(对象池+索引)                ← +400 行
阶段 ⑧  + PiP + Media Session + WebVTT             ← +200 行
                                          合计 ≈ 1810 行
1
2
3
4
5
6
7
8
9

# 10.3 与上一案例 js

维度 jsfeed jsplayer
数据 文本(XML/JSON) 二进制(Uint8Array)
异步 Promise Promise + ReadableStream + rVFC
状态 已加载/未加载 7 态状态机 + MSE 双重 readyState
错误 网络重试 媒体管线 reset + 多浏览器降级
UI 简单列表 自定义元素 + Shadow DOM + Canvas
平台 API fetch / localStorage MSE / PiP / MediaSession / WebVTT / Fullscreen / rVFC

# 10.4 这个 demo

缺失能力 生产怎么补 难度
ts → fmp4 转封装 引入 mux.js ⭐⭐
自适应码率(ABR) 监听 buffered + 网速 → 切 level ⭐⭐⭐
加密视频 DRM EME API + Widevine/FairPlay ⭐⭐⭐⭐⭐
LL-HLS 低延迟 partial segment / blocking playlist ⭐⭐⭐⭐
弹幕特效 礼物/SVGA 动效 ⭐⭐⭐

# 11.项目技术思考

# 11.1 EventTar

很多人初学时纠结"用事件还是状态机"——答案是叠加:

  • 状态机 = 描述"我现在在哪"(idle / playing / error)
  • EventTarget = 描述"刚刚发生了什么"(statechange / segment / hls-error)

Player 同时是两者:内部用状态机自我约束,对外用事件广播。这正是 jQuery → React → Web Component 三代演进留下的最佳实践。

# 11.2 为什么 MSE

MSE 的难不在接口数量(就 5 个)——而在 3 重异步嵌套:

Promise(fetch)  ←  ReadableStream(getReader)  ←  SourceBuffer.updating(事件)
   主任务级               协程级                      帧级
1
2

任意一层错位就出 InvalidStateError / QuotaExceededError。写过 jsplayer 的人,再看 hls.js / shaka 源码会觉得"原来不过如此"。

# 11.3 跨端思考:同一份

这是本案例最深的一层——你写的 Player 类几乎能 1:1 移植到:

平台 替换什么 留下什么
浏览器 <video> Player / MseEngine / HlsLoader
React Native <Video> from react-native-video Player(状态机不变)/ HlsLoader
微信小程序 <video> 组件 Player(状态机)/ parseM3u8
Hippy / Kuikly <video> 跨端组件 Player + 时间索引 / 弹幕引擎
Electron 同浏览器 整套
Node.js(无头) ffmpeg-stream parseM3u8 / DanmakuIndex

核心层(Player + 状态机 + 解析器)和宿主层(DOM / MSE)严格隔离——这是真跨端工程师的设计直觉。


# 12.衔接与延伸

# 12.1 与下一案例的关系

📚 下一案例:本卷综合案例完结,衔接到第三卷《JS 进阶》——TypeScript 类型系统 / 工程化 / 测试 / 现代工具链。

本案例打下的基础:

本案例能力 下一阶段如何升级
自定义元素 + Shadow DOM TS 给 attribute / property 加类型
Player 状态机 上 XState / discriminated union
MSE 队列 RxJS Observable 重写
对象池弹幕 转 OffscreenCanvas + Worker

# 12.2 三个延伸挑战

⭐ 挑战 A · 基础:把 m3u8 解析做成可解析"主清单"

真实 HLS 是 两层 m3u8:主清单列出多个码率(1080p/720p/480p)→ 每个码率一份子清单。改造 parseM3u8.js 让它返回 { master: bool, levels: [...] },识别 #EXT-X-STREAM-INF 行。

⭐⭐ 挑战 B · 进阶:接 mux.js 真正播放 ts 分片

安装 npm i mux.js——把 ts 字节流先 transmux 成 fmp4 再喂 MSE。 关键:new muxjs.mp4.Transmuxer() → on('data', fmp4 → mse.appendChunk)

⭐⭐⭐ 挑战 C · 现代化:把弹幕 Engine 搬进 OffscreenCanvas + Worker

主线程负责 currentTime → postMessage,Worker 负责弹幕计算 + 绘制 → transferControlToOffscreen。 收益:主线程零渲染负担,4K 视频上仍稳 60fps——这就是抖音 / B 站新版做的事。


🎉 恭喜!你已经完成了 JS 综合案例的第三关(终极关)。

此时你应该有一个真正能用的 jsplayer——MSE 流水线、迷你 HLS、Canvas 弹幕、PiP、Media Session、WebVTT 全套。这份代码所覆盖的"Web 平台宿主 API 密度"已经超过 99% 的前端项目。

✅ 三大综合案例总成绩单:

  • 01.待办清单事件驱动 :JS 语法 + DOM + LocalStorage
  • 02.异步订阅流式解析 :异步 + 流式 + 限流 + 缓存
  • 03.视频播放器自实现:Web Component + MSE + Canvas + 平台 API

➡ 下一程:第三卷《JavaScript 进阶》——TypeScript / 工程化 / 测试 / Node.js 后端 / 跨端框架(React Native / 小程序 / Hippy / Kuikly)。

你写下的这套 jsplayer 在那里会被真正打磨成可发布的开源库。

#视频播放器#MSE#Canvas#Web平台API
上次更新: 2026/06/16, 14:18:46
异步订阅流式解析
图表看板全栈开发

← 异步订阅流式解析 图表看板全栈开发→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式