编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • 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.异步订阅流式解析 (opens new window) ——本案例直接复用它的 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×  字幕▼  画质▼  画中画  全屏        │
└──────────────────────────────────────────────────┘

真实可用的能力:

  • 加载 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

每阶段保留前两篇案例的统一骨架:🎯 目标卡片 / 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                                       │
└──────────────────────────────────────────────────┘

# 2.1 目录结构搭建

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

📁 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>

📁 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);

📁 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; }

# 2.2 起静态服务器

cd jsplayer
npx --yes serve -p 5173 .

访问 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"   │
└──────────────────────────────────────────────────┘

# 03.Web Component 封装

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

# 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. 样式污染 ——页面其它 .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   # 自定义元素本体

# 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);

📁 src/main.js:

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

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

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

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

npx --yes serve -p 5173 .

预期行为:

  • 视频自动播放
  • 控制台输出 [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; }

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

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

✅ 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));
  }

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

🧪 验证(控制台):

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

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

# 3.6 故意造 bug

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

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

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

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

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

video { border: 5px solid red; }

看到红框了——只有 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 组件"    │
└──────────────────────────────────────────────────┘

# 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)            │
└──────────────────────────────────────────────────┘

# 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)         │  ← 传输层
└─────────────────────────────────────────┘

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

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

❓ 状态用什么表达? 答:显式状态机(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);
  }
}

设计要点:

要点 写法 作用
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 次验收)

刷新 → 控制台依次打印:

[JsPlayerApp] idle → loading
[JsPlayerApp] loading → ready
[JsPlayerApp] ready → playing

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

# 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 —— 私有字段保护住了

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

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

控制台:

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

💡 这就是状态机的价值——任何企图破坏状态完整性的调用都会被白名单拦下,让 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"       │
└──────────────────────────────────────────────────┘

# 05.MSE 流水线 ⭐核心

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

# 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:...">

核心翻译: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

💡 没装 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;
  }
}

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

# 5.5 MSE 的 4

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

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

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

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

new MediaSource().addSourceBuffer('video/mp4');  // ❌ 报错
new MediaSource().addSourceBuffer('video/mp4; codecs="avc1.42E01E,mp4a.40.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

🪤 陷阱 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);
    }
  }
}

📚 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 次验收)

把 index.html 改成:

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

刷新 → 控制台依次看到:

[JsPlayerApp] idle → loading
[JsPlayerApp] loading → ready
[JsPlayerApp] ready → playing

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

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

# 5.8 故意造 bug

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

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

刷新 → 控制台立刻报:

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

✅ 看到这个错就对了——这是初学者第一次接 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);
    }
  }
}

⚠️ 这里的"回压"是简化版(只等了一个微任务)。真实工程里要监听 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>

刷新 → 视频应该能完整播完两段(≈ 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 整个直播栈的根基"   │
└──────────────────────────────────────────────────┘

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

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

# 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

翻译:

  • #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 };
}

设计要点:

要点 写法 作用
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'));
});

预期输出:

{
  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 },
  ]
}

✅ 解析器工作了——这是 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 后再问
    }
  }
}

为什么要回压? 不加的话,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;
}

📚 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

⚠️ 这只是教学用法。真生产环境的 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>

刷新 → 控制台:

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

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

# 6.7 故意造 bug

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

InvalidStateError: SourceBuffer is removed from MediaSource

原因:旧 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 排队。你已经写出了一个可以打实战的微缩版"      │
└──────────────────────────────────────────────────┘

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

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

# 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 的范围(蓝/隐藏)

直播的 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>
`;

📚 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')}`;
}

# 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();
}

并把 <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);
}

打开 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"    │
└──────────────────────────────────────────────────┘

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

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

# 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. 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" }
]

📁 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; }
}

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

# 8.3 对象池——Tra

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

📁 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();
  }
}

对象池 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>

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 次验收)

刷新 → 视频上叠了横向滚动的弹幕。打开 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);

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 + 对象池 + 索引    │
│    这三板斧打遍弹幕 / 编辑器 / 游戏 / 数据可视化"     │
└──────────────────────────────────────────────────┘

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

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

# 9.1 宿主 API 凭

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

它们都遵循同一套套路:

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

这就是宿主 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. 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,
    });
  });
}

在 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
这是字幕第二行

📁 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;
}

📁 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 ?? '';
  }
}

在 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 都长一个样:特性检测→多前缀→降级"     │
└──────────────────────────────────────────────────┘

# 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

# 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 行

# 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(事件)
   主任务级               协程级                      帧级

任意一层错位就出 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/28, 17:55:19
异步订阅流式解析
图表看板全栈开发

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

最近更新
01
科学方法实践论法
06-28
02
辩证思维矛盾论法
06-28
03
毛选中的调查观念
06-28
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式