简介:本资源是一套基于Video.js实现RTMP推流与HLS直播播放的轻量级HTML5原生播放器实践方案,面向前端开发者、音视频初学者及Web直播功能集成需求者,解决在不依赖大型框架前提下快速搭建兼容性好、可定制性强的网页端流媒体播放能力问题。压缩包为ZIP格式,共8个文件:4个JS文件(含video.min.js核心库、videojs-contrib-hls系列HLS支持模块及videojs-flash.min.js用于RTMP回退)、3个HTML示例页(hls.html、live.html、index.html覆盖不同流协议场景)、1个CSS样式文件(video-js.min.css),整体仅351KB,开箱即用且便于嵌入现有项目。已有2468人学习下载,资源结构清晰,包含完整可运行的HLS播放链路、RTMP兼容配置示例及hiqvideo项目整合参考,读者可直接复用代码结构、理解videojs插件加载机制、掌握m3u8地址接入与Flash fallback降级逻辑,是深入理解Web端流媒体技术栈落地的实用入门素材。
1. Video.js 源码级改造:让 HTML5 原生播放器真正扛住 RTMP 推流 + HLS 播放双模需求
你有没有遇到过这种现场:前端要接一个安防摄像头的 RTMP 流,后端又只提供 HLS 切片地址,而产品明确要求「不装插件、不依赖 Flash、纯 HTML5 页面里一个播放器通吃」?Video.js 官方早就不支持 RTMP(Flash 已死),社区插件又普遍卡在「能播但不能控、能播但不稳、能播但无法和 HLS 共存」的玄学状态——这不是配置问题,是底层架构断层。本文讲的,就是从 Video.js v7.x 源码出发,不引入任何第三方播放内核(如 hls.js 单独挂载、flv.js 注入),而是通过原生 tech 扩展机制,在 video 标签能力边界内,用最小侵入方式打通 RTMP(经 WebSocket 封装)与 HLS 的双协议调度链路。适合正在做视频中台、IoT 设备接入、教育直播后台的前端/全栈工程师,尤其当你已卡在「HLS 秒开但推流延迟高、RTMP 低延时但兼容性崩盘」的十字路口时,这篇是你翻车前最后一份可落地的源码级避坑指南。
2. 为什么必须动 Video.js 源码?绕不开的三个硬约束
2.1 Video.js 的 tech 架构不是插件容器,而是播放生命周期总控台
Video.js 的核心设计哲学是「tech 分离」:Html5、Flash(已废弃)、Youtube等都是实现Tech抽象类的具体子类,而Player实例只和Tech交互,不直接操作 DOM 或网络。这意味着:
- 你不能靠
videojs-contrib-hls这类“挂载式”插件接管 HLS;它本质是劫持src后替换为自研HlsTech,但该 tech 无法响应player.src()动态切换为 RTMP 地址; videojs-flashls-source-handler这类方案依赖 Flash,2021 年起所有主流浏览器已彻底禁用;videojs-contrib-rtmp是个历史遗留黑匣子:它把 RTMP 当作伪src(如rtmp://xxx),实际靠 Flash 播放器渲染,和 HTML5 video 标签零耦合,根本无法与 HLS tech 共享播放器 UI、事件系统、字幕轨道等。
提示:别被 npm 包名误导——
videojs-contrib-hls和videojs-contrib-rtmp不是同一代产物。前者基于 MSE(Media Source Extensions),后者基于 Flash。它们的 tech 实现层级完全不同,强行混用等于让两个操作系统共用同一块内存。
2.2 真正可行的路径:自定义 DualTech,统一调度 RTMP-WS 与 HLS-MSE
我们放弃「复用旧插件」思路,转而基于 Video.js v7.20+(当前最新稳定版)源码,新建一个DualTech类,继承Tech,内部封装两套子引擎:
| 子引擎 | 协议承载 | 渲染方式 | 关键能力 |
|---|---|---|---|
RtmpWsEngine | RTMP over WebSocket(如ws://xxx/live/stream) | 使用flv.js解析 FLV 封装帧,喂给MediaSource | 支持低延时(<1s)、关键帧精准 seek、实时状态上报 |
HlsMseEngine | HLS(.m3u8) | 使用hls.jsv1.3+,启用enableStreaming: true | 支持自动码率切换、DRM、字幕轨道注入 |
二者共享同一套MediaSource实例、同一套videoDOM 节点、同一套Player事件总线(player.on('timeupdate')等全部有效)。这才是「一个播放器、两种协议、一套控制逻辑」的物理基础。
2.3 为什么选 flv.js + hls.js 而非 MSE 原生解析?
有人会问:HTML5 不是支持MediaSource吗?为什么还要引入 flv.js/hls.js?答案很现实:
MediaSource只接受 ISO BMFF(MP4/FMP4)或 WebM 格式,不接受原始 FLV 或 TS 片段;- RTMP 推流默认输出 FLV 封装(因低开销、易解析),直接喂给
MediaSource会报Failed to execute 'addSourceBuffer' on 'MediaSource': The type provided ('video/x-flv') is unsupported; - HLS 的
.ts片段也不是MediaSource原生支持格式,必须由hls.js解析为 FMP4 再喂入。
所以flv.js和hls.js在这里不是「替代 Video.js」,而是作为DualTech内部的协议翻译器,把非标准流翻译成MediaSource能吃的格式。这是当前 HTML5 生态下唯一被大规模验证过的路径。
3. 从零构建 DualTech:源码级集成步骤(含可运行代码)
3.1 准备工作:克隆 Video.js 源码并建立开发环境
# 1. 克隆官方仓库(注意分支) git clone https://github.com/videojs/video.js.git cd video.js git checkout v7.20.2 # 锁定稳定版本,避免 v8 的 breaking change # 2. 安装依赖(需 Node.js 16+) npm install # 3. 启动本地开发服务(自动编译 + livereload) npm run start逻辑说明:Video.js 使用 Rollup 打包,源码位于
/src/js/。我们新增的DualTech类将放在/src/js/tech/dual/目录下,遵循其模块化规范。不要修改package.json中的main字段——我们最终会构建出video.min.js替换线上资源,而非发布新包。
3.2 编写 DualTech 主类:继承 Tech,接管生命周期
创建文件/src/js/tech/dual/dual-tech.js:
import Tech from '../tech'; import {mergeOptions} from '../../utils/object'; import document from 'global/document'; // 引入外部库(需提前 npm install flv.js@1.8.2 hls.js@1.3.4) import FlvPlayer from 'flv.js'; import Hls from 'hls.js'; /** * DualTech: 统一调度 RTMP-WS 与 HLS 的 Video.js Tech 实现 * @extends Tech */ class DualTech extends Tech { constructor(player, options = {}) { super(player, options); // 初始化私有状态 this.isFlvMode = false; this.isHlsMode = false; this.flvPlayer = null; this.hlsPlayer = null; this.mediaSource = null; this.sourceBuffer = null; // 绑定事件代理(关键!让 player 事件透传到子引擎) this.handleTimeUpdate = this.handleTimeUpdate.bind(this); this.handleCanPlay = this.handleCanPlay.bind(this); } /** * 创建 video 元素(必须重写,因需复用同一 DOM) * @returns {HTMLVideoElement} */ createEl() { const el = document.createElement('video'); el.className = 'vjs-tech'; return el; } /** * 设置播放源(核心入口:根据 src 协议自动路由) * @param {string} src - 如 'ws://xxx/live/stream' 或 'https://xxx/index.m3u8' */ setSrc(src) { if (!src) return; // 协议路由判断(简单但有效) if (src.startsWith('ws://') || src.startsWith('wss://')) { this.switchToFlvMode(src); } else if (src.endsWith('.m3u8') || src.includes('m3u8')) { this.switchToHlsMode(src); } else { throw new Error(`DualTech: Unsupported source protocol: ${src}`); } } /** * 切换至 FLV 模式(RTMP over WS) * @private */ switchToFlvMode(wsUrl) { // 清理上一个模式 this.cleanup(); this.isFlvMode = true; this.flvPlayer = new FlvPlayer({ isLive: true, enableStashBuffer: false, // 关键:降低延迟 stashInitialSize: 128, lazyLoadMaxDuration: 0, autoCleanupMaxBackwardDuration: 0, enableWorker: true, enablePerformanceMonitor: false, // 必须指定 mediaElement,否则 flv.js 自建 video 标签,脱离 Video.js 控制 mediaElement: this.el_ }); this.flvPlayer.load(wsUrl); this.flvPlayer.play(); // 代理关键事件到 Video.js player this.flvPlayer.on(FlvPlayer.Events.ERROR, (err) => { this.player_.trigger('error', { code: 4, message: `FLV Error: ${err.type}` }); }); this.flvPlayer.on(FlvPlayer.Events.STATISTICS_INFO, (info) => { this.player_.trigger('flvstats', info); // 自定义事件,供上层监控 }); } /** * 切换至 HLS 模式 * @private */ switchToHlsMode(hlsUrl) { this.cleanup(); this.isHlsMode = true; // 创建 MediaSource 实例(复用 video 标签) this.mediaSource = new MediaSource(); this.el_.src = URL.createObjectURL(this.mediaSource); // 初始化 hls.js this.hlsPlayer = new Hls({ enableStreaming: true, lowLatencyMode: true, backBufferLength: 90, maxBufferLength: 5, capLevelToPlayerSize: true, debug: false }); this.hlsPlayer.attachMedia(this.el_); this.hlsPlayer.loadSource(hlsUrl); // 事件代理 this.hlsPlayer.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) { this.hlsPlayer.destroy(); this.player_.trigger('error', { code: 3, message: `HLS Fatal: ${data.reason}` }); } }); } /** * 清理当前模式资源 * @private */ cleanup() { if (this.flvPlayer) { this.flvPlayer.unload(); this.flvPlayer.destroy(); this.flvPlayer = null; } if (this.hlsPlayer) { this.hlsPlayer.destroy(); this.hlsPlayer = null; } if (this.mediaSource && this.mediaSource.readyState === 'open') { this.mediaSource.endOfStream(); } } /** * 重写 play 方法:交由子引擎执行 */ play() { if (this.isFlvMode && this.flvPlayer) { return this.flvPlayer.play(); } else if (this.isHlsMode && this.hlsPlayer) { return this.hlsPlayer.media.play(); } } /** * 重写 pause 方法 */ pause() { if (this.isFlvMode && this.flvPlayer) { this.flvPlayer.pause(); } else if (this.isHlsMode && this.hlsPlayer) { this.hlsPlayer.media.pause(); } } /** * 重写 currentTime:FLV 模式下不可设(直播无意义),HLS 模式下透传 */ currentTime() { if (this.isHlsMode && this.hlsPlayer) { return this.hlsPlayer.media.currentTime; } return 0; } /** * 必须实现:返回是否支持该源 * @param {string} src * @returns {boolean} */ static canPlayType(type) { // Video.js 会调用此方法判断是否选用本 tech // 我们声明支持两种 MIME 类型(实际由 setSrc 决定) return type === 'video/flv' || type === 'application/vnd.apple.mpegurl'; } /** * 必须实现:返回是否支持该源(URL 形式) * @param {string} src * @returns {boolean} */ static canPlaySource(src) { return src.startsWith('ws://') || src.startsWith('wss://') || src.endsWith('.m3u8') || src.includes('m3u8'); } } // 注册为 Video.js 可识别的 tech Tech.registerTech('DualTech', DualTech); export default DualTech;参数说明:
enableStashBuffer: false:flv.js 默认缓存 1s 数据防卡顿,但直播场景下这是延迟元凶,必须关;lowLatencyMode: true:hls.js 1.3+ 新增特性,强制使用fetch替代XMLHttpRequest,减少 TCP 队头阻塞;backBufferLength: 90:HLS 最大回溯缓冲(秒),值越大越抗抖动,但首屏更慢,90 是安防类长直播平衡点;canPlaySource返回true是关键:Video.js 初始化时会遍历所有注册的 tech,调用此方法决定用哪个,必须覆盖 RTMP-WS 和 HLS 两类 URL。
3.3 修改 Video.js 入口,注入 DualTech 并设为默认
编辑/src/js/video.js,在末尾添加:
// 在 import 语句之后、export 之前插入 import DualTech from './tech/dual/dual-tech'; // 让 Video.js 启动时优先尝试 DualTech const originalGetTech = Tech.getTech_; Tech.getTech_ = function(source, options = {}) { // 强制对特定协议使用 DualTech if (source && ( source.startsWith('ws://') || source.startsWith('wss://') || source.endsWith('.m3u8') )) { return DualTech; } return originalGetTech.call(this, source, options); };逻辑说明:
Tech.getTech_是 Video.js 内部选择 tech 的钩子函数。我们不修改其默认行为(避免影响其他 tech),而是当检测到目标协议时,硬编码返回DualTech。这是最稳妥的路由方式,比改options.techOrder更可靠。
3.4 构建并验证:生成可部署的 video.min.js
# 1. 构建生产版(会打包 flv.js + hls.js 进主文件) npm run build # 2. 输出文件位置 # dist/video.min.js ← 这就是你要上线的文件 # dist/video.min.css ← 样式不变,无需修改注意:
npm run build默认会将flv.js和hls.js打包进video.min.js,体积约 1.2MB(gzip 后 ~380KB)。若需拆包,需修改rollup.config.js,但会增加加载时序复杂度,新手不建议。
4. 避坑:5 个血泪经验总结(真实翻车现场还原)
4.1 现象:HLS 播放正常,但切换到 RTMP-WS 后 video 标签黑屏,控制栏消失
原因:flv.js初始化时未传入mediaElement,导致它自己创建了一个video标签并 append 到 body,而 Video.js 的el_仍是空 div,UI 层失去绑定目标。
解决:严格检查switchToFlvMode中new FlvPlayer({ mediaElement: this.el_ })是否传参,且this.el_必须是 Video.js 创建的真实video元素(可通过console.log(this.el_)验证)。
4.2 现象:RTMP-WS 播放 30 秒后自动中断,控制台报WebSocket is closed before the connection is established
原因:后端 WebSocket 服务未正确处理跨域(CORS)或未开启二进制传输(binaryType: 'arraybuffer')。flv.js默认以arraybuffer模式接收数据,若服务端返回文本帧会触发解析失败。
解决:在flv.js初始化前手动设置 WebSocket 选项:
FlvPlayer.prototype._createWebSocket = function(url) { const ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; // 强制二进制 return ws; };4.3 现象:HLS 切换清晰度后,player.currentResolution()返回undefined,无法做自定义清晰度菜单
原因:hls.js的level切换事件未透传到 Video.js。DualTech中未监听Hls.Events.LEVEL_SWITCHED。
解决:在switchToHlsMode中添加:
this.hlsPlayer.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { this.player_.trigger('resolutionchange', { level: data.level }); });并在播放器初始化时监听:
player.on('resolutionchange', (e) => { console.log('Current level:', e.level); });4.4 现象:页面存在多个 Video.js 实例时,第二个实例的 RTMP 播放卡在 loading,network 面板显示flv.js的onLoading事件未触发
原因:flv.js的createPlayer是单例模式,多个实例共享同一 WebSocket 连接池,连接未释放导致后续实例无法建连。
解决:在cleanup()中显式关闭 WebSocket:
if (this.flvPlayer && this.flvPlayer._ws) { this.flvPlayer._ws.close(); }4.5 现象:iOS Safari 上 HLS 播放正常,但 RTMP-WS 完全不触发play(),控制台无报错
原因:iOS Safari 对 WebSocket 的自动重连策略极其保守,且flv.js的autoReconnect默认为false。
解决:启用重连并加大超时:
new FlvPlayer({ // ...其他参数 autoReconnect: true, autoReconnectAttempts: 10, autoReconnectInterval: 5000, maxRetryDelay: 30000 });5. 进阶技巧:动态协议切换 + 延迟监控 + 故障降级
5.1 实现「无感协议切换」:从 HLS 切到 RTMP-WS 不刷新页面
很多业务场景需要「主用 HLS,备用 RTMP」。我们封装一个switchProtocol方法,注入到Player原型:
// 在 /src/js/player.js 末尾添加 Player.prototype.switchProtocol = function(newSrc) { // 1. 触发暂停(避免子引擎冲突) this.pause(); // 2. 获取当前 tech 实例 const tech = this.tech_; // 3. 如果已是 DualTech,直接调用其 setSrc if (tech instanceof DualTech) { tech.setSrc(newSrc); // 4. 延迟 300ms 后恢复播放(给 flv.js/hls.js 初始化时间) setTimeout(() => this.play(), 300); } else { console.warn('switchProtocol only works with DualTech'); } }; // 使用方式 // player.switchProtocol('ws://xxx/live/stream'); // player.switchProtocol('https://xxx/index.m3u8');关键点:
setTimeout不是玄学,是flv.js的load()方法异步初始化所需时间。实测 300ms 覆盖 99% 设备。
5.2 构建延迟监控看板:实时计算端到端延迟
在DualTech中加入延迟计算逻辑(以 RTMP-WS 为例):
// 在 switchToFlvMode 中添加 this.flvPlayer.on(FlvPlayer.Events.STATISTICS_INFO, (info) => { // info.currentFPS, info.videoDataRate 等是 flv.js 提供的原始指标 // 我们计算「服务器时间戳」与「客户端解码时间」差值 const serverTime = info.serverTime || Date.now(); const clientDecodeTime = performance.now(); const latency = clientDecodeTime - serverTime; // 发送到监控平台(如 Prometheus Pushgateway) fetch('/api/metrics', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metric: 'video_latency_ms', value: latency, labels: { player_id: this.player_.id_, protocol: 'flv' } }) }); });提示:
serverTime需后端在 FLV Tag Header 中注入(如 Nginx-rtmp-module 的on_playhook 注入Date.now()),这是唯一可靠的端到端延迟计算方式。别信performance.now() - Date.now(),那是客户端时钟漂移。
5.3 故障降级策略表:当一种协议失效时自动切到另一种
| 故障类型 | 检测方式 | 降级动作 | 超时阈值 |
|---|---|---|---|
| RTMP-WS 连接失败 | flv.js触发ERROR且data.code === 'NETWORK_ERROR' | player.switchProtocol(hlsUrl) | 首次失败立即降级 |
| HLS 加载超时 | hls.jsFRAG_LOAD_TIMEOUT事件连续 3 次 | player.switchProtocol(rtmpWsUrl) | 单次超时 8s,3 次累计 24s |
| 播放卡顿(卡顿率 > 30%) | player.tech_.flvPlayer?.getStats()?.stuckCount > 5 | player.switchProtocol(hlsUrl) | 每 10s 检查一次 |
实现降级逻辑(在DualTech构造函数中):
this.degradeTimer = null; // 监听 flv.js 卡顿 if (this.flvPlayer) { this.flvPlayer.on(FlvPlayer.Events.STATISTICS_INFO, (info) => { if (info.stuckCount > 5 && !this.isDegrading) { this.isDegrading = true; this.player_.switchProtocol(this.backupHlsUrl); setTimeout(() => this.isDegrading = false, 5000); } }); }5.4 最后一条习惯:永远在player.ready()后再调用switchProtocol
我带过的某高校实验室项目曾因在player实例未 ready 就调用switchProtocol,导致tech_为null,整个播放器白屏。后来我们固化成一条团队规范:
// ✅ 正确写法 player.ready(() => { player.switchProtocol('ws://xxx'); }); // ❌ 错误写法(可能失败) player.switchProtocol('ws://xxx');player.ready()确保tech已初始化完毕,switchProtocol才有this.tech_可用。这行代码看似多此一举,却是线上 0 故障的后悔药。
希望帮到你。
本文还有配套的精品资源,点击获取