最完整的Web视频加密播放技术实现(含技术调研和Demo源码)
后台-插件-广告管理-内容页头部广告(手机) |
大厂技术 高级前端 Node进阶
- 点击上方 程序员成长指北,关注公众号
- 回复1,加入高级Node交流群
作者:然燃 (感谢小伙伴投稿分享)
原文链接: https://juejin.cn/post/7307934456995856419
最近又遇到了web视频化的场景,之前也有过调研:H5视频化调研浅析1
但这次稍微复杂一些,这次解决的是:
视频播放的技术方案调研
服务端实现:
视频转码
生成不同码率的视频
进行视频标准加密
不同码率视频合并,用于动态码率播放
web端实现
web端播放器的设计
web端播放器的自定义扩展
可拖拽进度条
音量控制
根据当前带宽自适应码率切换
手动清晰度切换
倍速播放
样式自定义覆盖
标准加密视频播放
基于原生开发,可在所有框架运行,统一跨框架情况
各浏览器控件统一
其中web端源码已添加MIT协议并完全开源,如果看完对大家有帮助的话,欢迎大家star,issue,pr,也希望能友好交流~
demo地址:⇲https://chaxus.github.io/ran/src/ranui/player/
源码地址:⇲https://github.com/chaxus/ran/tree/main/packages/ranui
demo文档做了国际化,可切换到中文
任何一个项目,立项肯定先是技术调研,我们先看看一些大公司的视频播放方案
#一.一些知名公司的web视频播放方案
#1.B站
我们先看看B站的,毕竟B站的主营业务就是视频弹幕网站,简直专业对口。
先找一个例子:⇲www.bilibili.com/video/BV1FM…⇲2 访问它。
打开控制台,可以看到,视频在播放的时候,会不断的请求m4s的视频文件。
毕竟一整个视频文件往往比较大,不可能先请求完视频文件,再进行播放。因此将一个大的视频文件分割成很多小的片段,边加载边播放,是一种更好的方式。
每次请求的m4s文件大概在几十kb到几百kb不等。
那为什么不采用http的range呢,可以请求一个文件的部分内容,而且粒度更细,可以设置字节范围。在http请求的header中,类似这样
Range: bytes=3171375-3203867我们可以检查这个链接请求https://upos-sz-mirror08c.bilivideo.com/upgcxcode/67/92/1008149267/1008149267-1-30064.m4s的请求头,就能发现,B站采用的是,即分片加载,同时还用了range的方式。
#2. 爱奇艺:(爱奇艺、土豆、优酷)
爱奇艺这里就不贴视频链接了,因为随便点一个视频,都要先看广告。
爱奇艺的视频主要请求的是f4v格式,也是分片加载。
播放一个视频时,请求多个f4v文件。
也采用Range。但和B站不一样的是,B站的Range属性是在m4s请求的请求头里面,而爱奇艺的看起来是在querystring上,在请求query上带着range参数。
因为没发现这个请求的header里面有range参数。比如: https://v-6fce1712.71edge.com/videos/other/20231113/6b/bb/3f3fe83b89124248c3216156dfe2f4c3.f4v?dis_k=2ba39ee8c55c4d23781e3fb9f91fa7a46&dis_t=1701439831&dis_dz=CNC-BeiJing&dis_st=46&src=iqiyi.com&dis_hit=0&dis_tag=01010000&uuid=72713f52-6569e957-351&cross-domain=1&ssl=1&pv=0.1&cphc=arta&range=0-9000
#3.抖音:
抖音的方案简单粗暴,访问的链接是这个: ⇲m.ixigua.com/douyin/shar…⇲3
通过查看控制台,我们可以发现,直接请求了一个视频的地址
没有进行分片,但用到了请求range,所以可以看到视频,是边播放边缓冲一部分。
不过我在开发的时候发现,目前租用的服务云厂商,默认会帮我们实现这项技术。
因为我把mp4视频上传到云服务器,通过链接进行播放的时候,就是边缓冲边播放的。
我们可以直接把这个视频地址拿出来,放到浏览器里面能直接播放,这样观察更明显。
但B站和爱奇艺却不能这样,因为他们采用的m4s和f4v都不是一种通用的视频格式,需要使用专门的软件或工具才能打开和编辑。
#4.小红书:
测试用的例子链接:⇲www.xiaohongshu.com/discovery/i…⇲4
小红书的方案更加简单粗暴,打开控制台,直接告诉你就是请求一个mp4,然后直接播放就完事了。
#5.总结
看完了以上的各家大厂的方案,我们可以看到,基本原理都是边播放边加载,减少直接加载大视频文本的成本。并且通过分片传输,还能动态控制视频码率(清晰度)。做到根据网速,加载不同码率的分片文件,做到动态码率适应。
同时采用的视频格式,比如f4v,m4s,都不是能直接播放的媒体格式,需要一定的处理。增加盗取视频的成本,增加一定的安全性。
如果没有强要求,也可以直接采用mp4,或者直接用video播放一个视频文件地址。
#二.常见的视频格式与协议
我们知道视频的常见格式有mp4,同时上面介绍了B站播放用的m4s格式,爱奇艺用的f4v格式
•除了这些还有哪些视频格式?
•为什么有这么多视频格式,有哪些不同点呢?
•为什么这些公司会采用这种格式来播放视频呢?
#1. B站用的m4s
M4S格式不是一种通用的视频格式,需要使用专门的软件或工具才能打开和编辑。
M4S 通常会和 MPEG-DASH 流媒体技术一起,通过流式传输的视频的一小部分。播放器会按接收顺序播放这些片段。第一个 M4S 段会包含一些初始化的数据标识。
MPEG-DASH 是一种自适应比特率流媒体技术,通过将内容分解为一系列不同码率的M4S片段,然后根据当前网络带宽进行自动调整。如果想在在web音视频中采用DASH技术,可以看下 ⇲github.com/Dash-Indust…⇲5
#2. 爱奇艺的f4v
F4V是一种流媒体格式,它是由Adobe公司推出的,继FLV格式之后支持H.264编码的流媒体格式。F4V格式的视频不是一种通用的视频格式,但通常情况下,都可以将文件后缀改为FLV,这样就可以使用支持FLV的播放器进行观看。
FLV格式跟常见的MP4格式比起来,结构更加简单,所以加载metadata(视频元数据,比如视频时长等信息)会更快。具体结构我们可以在这里查到:⇲en.wikipedia.org/wiki/Flash_…⇲6
比如,这是FLV文件的标准头,定义了从几个比特到几个比特之间,是什么含义。我们知道后,可以用MediaSource进行读取和转码。
Field | Data Type | Default | Details |
---|---|---|---|
Signature 签名 | byte[3] | "FLV" | 始终就是“FLV” |
Version 版本 | uint8 | 1 | 只有0x01才有效 |
Flags 标志 | uint8 位掩码 | 0x05 | 0x04是音频,0x01是视频(所以0x05是音频+视频) |
Header Size | uint32_be | 9 | 用于跳过较新的扩展标头 |
而MP4格式会稍微复杂一些,具体标准在 ⇲ISO/IEC 14496-12⇲7 大概有两百多页,这里放不下,对这方面有兴趣的可以自行查看。
然而这并不表示MP4更差,因为它是一种基础通用标准,所以定义上会留有很多空间,和各种情况,甚至允许在标准之内进行自行发挥和扩展。而FLV格式则更加固定,但优点也是更加简单。
对于FLV的视频播放,我们可以采用:⇲github.com/bilibili/fl…⇲8flvjs主要作用就是用MediaSource将flv转码成mp4从而喂给浏览器进行播放。
接下来是一些其他的视频格式,简单介绍一下:
#3.AVI
文件名以.avi结尾,AVI 最初由 Microsoft 于 1992 年开发,是 Windows 的标准视频格式。AVI 文件使用较少的压缩来存储文件,并且比许多其他视频格式占用更多空间,这导致文件大小非常大,每分钟视频大约 2-3 GB。
无损文件不会随着时间的推移而降低质量,无论您打开或保存文件多少次。此外,这允许在不使用任何编解码器的情况下播放。参考资料:⇲Audio Video Interleave⇲9
#4.MPEG
文件名以“.mpg”或“.mpeg”结尾,MPEG 是由 ISO 和 IEC 联合成立的工作组联盟,旨在制定媒体编码标准,包括音频、视频、图形和基因组数据的压缩编码;以及各种应用程序的传输和文件格式。MPEG 格式用于各种多媒体系统。最广为人知的旧 MPEG 媒体格式通常使用 MPEG-1、MPEG-2 和 MPEG-4 AVC 媒体编码,MPEG-2 系统传输流和节目流。较新的系统通常使用 MPEG 基本媒体文件格式和动态流式处理(又名 .MPEG-DASH)。参考资料:⇲Moving Picture Experts Group⇲10
#5.MP4
带有音频和视频的 MPEG-4 文件通常使用标准的 .mp4 扩展名。纯音频 MPEG-4 文件通常具有 .m4a 扩展名,原始 MPEG-4 可视比特流命名为 .m4v。Apple iPhone使用MPEG-4音频作为其铃声,但使用.m4r扩展名而不是.m4a扩展名。参考资料:⇲MPEG-4 Part 14⇲11
#6.QuickTime
文件名以“.mov”结尾,QuickTime 能够包含媒体数据的抽象数据引用,并将媒体数据与媒体偏移和轨道编辑列表分离,这意味着 QuickTime 特别适合编辑,因为它能够就地导入和编辑(无需数据复制)。由于 QuickTime 和 MP4 容器格式都可以使用相同的 MPEG-4 格式,因此在仅限 QuickTime 的环境中,它们大多可以互换。MP4作为国际标准,得到了更多的支持。参考资料:⇲QuickTime File Format⇲12
#7.TS
TS是MPEG2-TS的简称,是一种音视频封装格式。TS流的后缀通常是.ts、.mpg或者.mpeg,多数播放器直接支持这种格式的播放。TS格式主要用于直播的码流结构,具有很好的容错能力。
#三.浏览器对各种视频格式的兼容性
上面了解常用的视频格式,和适用范围之后,还需要看一下当前浏览器,对各种视频格式的支持程度,然后制定技术方案。
#1. Chrome
支持的视频格式从官方文档可以查到,主要有以下这些
•MP4 (QuickTime/ MOV / ISO-BMFF / CMAF)
•Ogg
•WebM
•WAV
•HLS [Only on Android and only single-origin manifests
官方文档如下:⇲www.chromium.org/audio-video…⇲13
#2. Safari
支持的视频格式有这些:
官方文档:⇲developer.apple.com/library/arc…⇲14
#3.Firefox
支持的视频格式:
官方文档:⇲support.mozilla.org/en-US/kb/ht…⇲15
#四.MediaSource和视频编码,解码,封装介绍
上面介绍了一些视频格式,和目前浏览器的一些兼容性问题。就能发现,在web上播放音视频其实限制还是很大的。如何解决这些限制,就会用到MediaSource。
视频其实是无数个图片的叠加,如果视频是一秒60帧,大约一秒中需要播放60张图片。这就导致一个几分钟的视频,就会非常大。比如上面介绍的无损格式,avi格式,每分钟视频大约 2-3 GB。这时候视频就需要进行编码。其实就是压缩。
编码分为视频编码和音频编码,常见的视频编码有:
•MPEG系列:MPEG-1第二部分、MPEG-2第二部分(等同于H.262)、MPEG-4第二部分、MPEG-4第十部分(等同于H.264,有时候也被叫做“MPEG-4 AVC”或“H.264/AVC”)。
•H.26x系列:H.261、H.262、H.263、H.264(等同于MPEG-4第十部分)、H.265/HEVC(ITU-T和ISO/IEC联合推出)。
•其它视频编码:WMV系列、RV系列、VC-1、DivX、XviD、X264、X265、VP8、VP9、Sorenson Video、AVS。
常见的音频编码有:AAC,MP3,AC-3等
编码之后,还需要将音频和视频合并在一个文件里,这就是封装。
所以相对的,播放一个视频,就需要解封装,解码,音视频同步喂给声卡和显卡进行播放。
MediaSource做的就是这个工作,读取视频流,转换成浏览器能播放的格式。
以下是flv.js的parseChunks部分内容。读取buffer,一个字节一个字节的根据标准进行解析。然后转码。
- if (byteStart === 0) { // buffer with FLV header
- if (chunk.byteLength > 13) {
- let probeData = FLVDemuxer.probe(chunk);
- offset = probeData.dataOffset;
- } else {
- return 0;
- }
- }
- if (this._firstParse) { // handle PreviousTagSize0 before Tag1
- this._firstParse = false;
- if (byteStart + offset !== this._dataOffset) {
- Log.w(this.TAG, 'First time parsing but chunk byteStart invalid!');
- }
- let v = new DataView(chunk, offset);
- let prevTagSize0 = v.getUint32(0, !le);
- if (prevTagSize0 !== 0) {
- Log.w(this.TAG, 'PrevTagSize0 !== 0 !!!');
- }
- offset += 4;
- }
#1.MediaSource兼容性
由此可见,基本都是绿色,但有一个特殊情况,就是Safari on IOS。这部分支持程度还是棕色。
#五. HLS 播放方案
采用HLS技术方案,有以下几个原因:
1.兼容性
上面介绍了各种视频格式,还有浏览器的兼容性
其中 HLS协议是Apple公司实现的,在 Apple 的全系列产品包括 iPhone、iPad、Safari 等都可以原生支持播放 HLS。
对于其他浏览器,可以通过MediaSource解封装,解码,转码,进行播放。
这样也就解决了MediaSource的兼容性问题。
业务场景需求
目前对于视频的加密有着强需求,比如需要用户付费才能观看一些视频。而HLS协议天然自带标准加密,同时也能基于HLS扩展私有加密。
HLS协议自带支持分片传输和动态码率自适应播放。
有现成的技术方案,Hls.js
#六. 服务端开发
选择了采用HLS协议的播放方式,那么首先需要处理视频,这部分目前是在服务端进行处理。利用ffmpeg的能力。
如果以后能将ffmpeg搬上浏览器,且没有性能问题就好了。现在有类似的webassembly的npm包,但性能有点小问题
#1.视频的转码
视频的转码的ffmpeg命令如下:
ffmpeg -i input.mp4 -hls_time 10 -hls_list_size 0 -c:v h264 -b:v 2M -hls_segment_filename output_%05d.ts output.m3u8 -y每个参数的解释:
•-i 指定输入的视频
•-hls_time 指定分片的时间,单位是秒
•-hls_list_size 指定hls列表的数量,这里不限制
•-c:v 指定视频的编码格式
•-b:v 指定视频的码率,这里是2M比特率
•-hls_segment_filename 指定输出的ts文件名字,这里表示是output_ + 五位数字
•output.m3u8指定输出m3u8文件的名字
•-y有些场景,比如是否覆盖,直接选择是,避免程序卡住
为了自动化执行,这里会用到node的spawn模块,创建一个子进程,在子进程中执行ffmpeg的命令。
- const exec = ({ params, data }: ExecOption): Promise
=> { - return new Promise((r, j) => {
- const cp = spawn('ffmpeg', params);
- cp.stderr.pipe(process.stdout);
- cp.on('error', (err) => {
- j(err);
- });
- cp.on('close', (code) => {
- r({ code, data });
- });
- cp.on('exit', (code) => {
- r({ code, data });
- });
- });
- };
这时候,视频就会在指定的位置输出了,会生成一个m3u8和多个ts
ts是视频文件,m3u8更像是索引文件,用来描述ts,比如在什么时间,播放什么ts。主要内容如下:
- #EXTM3U
- #EXT-X-VERSION:3
- #EXT-X-TARGETDURATION:10
- #EXT-X-MEDIA-SEQUENCE:0
- #EXTINF:10.380622,
- 5_00000.ts
- #EXTINF:10.380622,
- 5_00001.ts
- #EXTINF:10.380622,
- 5_00002.ts
- #EXTINF:10.380622,
- 5_00003.ts
- #EXTINF:6.560556,
- 5_00004.ts
- #EXTINF:1.619378,
- 5_00005.ts
- #EXTINF:5.024189,
- 5_00006.ts
- #EXT-X-ENDLIST
#2.视频的标准加密
HLS协议标准加密采用的是AES对称加密方案。先来实现一个最标准的加密:
首先通过node原生模块crypto生成加密密钥:
- import crypto from 'node:crypto';
- // 生成加密密钥
- const key = crypto
- .createHash('sha256')
- .update(crypto.randomBytes(32))
- .digest('base64');
- const filePathKey = path.join(__dirname,`../../public/uploads/hls/${dir}/${fileName}.key`);
- const content = `${ctx.origin}/uploads/hls/${dir}/${fileName}.key\n${filePathKey}\n`;
- // 密钥的文件
- const fileKey = await writeFile(path.join(__dirname, `../../public/uploads/hls/${dir}/${fileName}.key`),key);
- const keyInfoPath = path.join(__dirname,`../../public/uploads/hls/${dir}/${fileName}_key.bin`);
- // ffmpeg 需要的key.info
- const keyInfo = await writeFile(keyInfoPath, content);
然后再执行ffmpeg命令,这里同样需要用node的spawn模块进行封装成接口:
ffmpeg -i input.mp4 -hls_time 10 -hls_list_size 0 -c:v h264 -b:v 2M -hls_key_info_file keyInfoPath -hls_segment_filename output_%05d.ts output.m3u8 -y主要就增加了一个hls_key_info_file参数,表示加密密钥的地址。这时候,生成的m3u8文件就发生了变化,多了一行:
- #EXTM3U
- #EXT-X-VERSION:3
- #EXT-X-TARGETDURATION:10
- #EXT-X-MEDIA-SEQUENCE:0
- #EXT-X-KEY:METHOD=AES-128,URI="http://localhost:30103/uploads/hls/5_1701577743851/5.key",IV=0x00000000000000000000000000000000
- #EXTINF:10.380622,
- 5_00000.ts
- #EXTINF:10.380622,
- 5_00001.ts
- #EXTINF:10.380622,
- 5_00002.ts
- #EXTINF:10.380622,
- 5_00003.ts
- #EXTINF:6.560556,
- 5_00004.ts
- #EXTINF:1.619378,
- 5_00005.ts
- #EXTINF:5.024189,
- 5_00006.ts
- #EXT-X-ENDLIST
多了
#EXT-X-KEY:METHOD=AES-128,URI="http://localhost:30103/uploads/hls/5_1701577743851/5.key",IV=0x00000000000000000000000000000000•METHOD字段表示加密方式,这里是AES
•URI表示密钥地址,这里是http://localhost:30103/uploads/hls/5_1701577743851/5.key,
•IV是加密解密时的偏移量,现在是0
上述加密方式,虽然视频确实加密了,但会把密钥地址写在m3u8里。等于把房间上锁,然后在锁上贴一个纸条,上面写了密码。
#3.更好的安全方案
•有加密,必然需要解密
首先我们知道,视频要在web端进行播放,那么无论如何,都肯定需要先解密,再播放。
•肯定不能在web端放置密钥
•web端需要知道如何获取密钥
•密钥用一次即失效,每次加密视频都生成新的密钥
目前更好的安全性方式主要有两种:
在请求密钥的地址上进行加固:
•校验cookie,既然是发起请求,那么同域名会自动携带cookie,只有购买过的用户才能获取密钥。(总不能让付费的用户也不能看吧)
•生成密钥链接时,带上ticket,短时间失效,控制时效性
•请求头携带auth,进行用户校验。比如jwt方案就是如此
采用私有加密方式,比如m3u8里的METHOD,可能不再是AES这种对称加密。自定义一套加密规则,这种方式安全性会极大提高,但同时就不遵守HLS协议的标准了。但大多数浏览器支持MediaSource。可以读取文件内容,进行自定义加密和解密。根据上文的兼容性调研,MediaSource在IOS上将会有兼容性问题,所以这种方案在IOS上也会有兼容性问题。
#4.自适应码率播放
这里先介绍一下码率和清晰度的关系:
码率是指:
码率(也称为比特率)是指视频文件在单位时间内使用的数据流量。它反映了视频文件的数据压缩程度,码率越高,压缩比就越小,画面质量就越高,但文件体积也越大。通俗来说,码率可以看作是取样率,是视频编码中画面质量控制中最重要的部分。计算公式是文件体积=时间X码率/8。
所以简单来说,码率越高,清晰度就越好。成正比关系。
需要根据视频的质量,和业务场景,去定义,这里给出阿里云对码率和清晰度的定义,可供参考:
为了实现自适应码率播放,我们需要将不同码率的m3u8合并成一个多码率的m3u8。
这里我没找到合适的ffmpeg命令,但总有办法的,最万能的方法就是,查看HLS协议中多码率的m3u8格式标准,自己写一个。
目前用node去写一个这样的索引文件,具体内容如下:
- #EXTM3U
- #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1000000,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=1280x720,NAME="720"
- hls/5_1701577771368/5.m3u8
- #EXT-X-STREAM-INF:PROGRAM-ID=2,BANDWIDTH=50000,CODECS="mp4a.40.5,avc1.42000d",RESOLUTION=320x184,NAME="320"
- hls/5_1701577744714/5.m3u8
•#EXT-X-STREAM-INF: 流媒体的描述
•PROGRAM-ID: 表示唯一的ID。
•BANDWIDTH: 流媒体的带宽,即每秒传输的数据量。这里带宽为50000,意味着每秒传输的数据量大约为50kbps。
•CODECS: 流媒体使用的编解码器。这里是使用了mp4a.40.5(AAC音频编码)和avc1.42000d(AVC视频编码)。
•RESOLUTION: 这个字段指示了视频的分辨率,即宽度和高度。在这个例子中,视频分辨率为320x184。
•NAME: 这个字段为流媒体提供了一个名称,本例中名称为"320"。我会习惯把清晰度放在NAME这个字段这里,方便web端获取
实现一个接口,传入以上的参数,动态拼接字符串,写入文件
- async generateMasterPlayList(ctx: Context): Promise
{ - try {
- const { paths, filename = Date.now() } = ctx.request.body;
- let content = `#EXTM3U\n`;
- paths.forEach((item: MasterPlayListOption, index: number) => {
- const { id = index, bandWidth, codecs, resolution, name, url } = item;
- content += `#EXT-X-STREAM-INF:PROGRAM-ID=${id},BANDWIDTH=${bandWidth},CODECS="${codecs}",RESOLUTION=${resolution},NAME="${name}"\n${url}\n`;
- });
- const dir = path.join(__dirname, `../../public/uploads/hls/`);
- if (!existsSync(dir)) {
- await createDir(dir);
- }
- const filePath = dir + (filename.toString().endsWith('.m3u8') ? filename : `${filename}.m3u8`)
- const { success, error } = await writeFile(filePath, content);
- const basename = path.basename(filePath);
- return success
- ? ctx.successHandler({
- url: `${ctx.origin}/uploads/hls/${basename}`,
- })
- : ctx.failHandler(error);
- } catch (error) {
- ctx.errorHandler(error);
- }
- }
那么HLS是如何自适应码率的呢?
其实是根据BANDWIDTH这个字段,因为我们给不同的视频设置了不同的BANDWIDTH。那么就可以根据当前的网速,进行动态切换。
#七.web端的实现
上面做了大量的工作,主要是生成了HLS协议的视频播放的地址。接下来就是如何在web端进行播放。
#1.技术选型
我首先是看了现有的播放器npm,比较知名的
•有西瓜playler: ⇲github.com/bytedance/x…⇲16
•阿里云点播方案:⇲help.aliyun.com/zh/vod/deve…⇲17
•知乎的player:⇲github.com/zhihu/griff…⇲18
其中知乎和西瓜的播放器是开源的,阿里云点播方案没有开源代码,但是有开源demo。
但基本上实现的功能都很丰富,同时配置项会不断的增加。
如果只是简单的初始化一个播放器,那还好。但一般这种场景,我们都会对播放器进行一定程度的定制化。比如B站。
就多了很多自定义的控件。进度条也是小电视的形状。
我们这边也是如此,有自己的主题色,有自己的播放按钮等等,还有一些业务功能,也要放在控制条上。
上述的开源方案,都需要花时间去研究配置项,而且使用方法都是new Player(options)的形式。
但尽量视图的归视图,逻辑的归逻辑会更好些。
更何况实现一个播放器也不是很困难。
期望的player,能满足
配置够简单,最好看到就知道是怎么用的
方便扩展和样式覆盖
支持hls播放
尽量适配前端的各种框架
方便接入,实现价值
#2.播放器设计
由于现在既有react项目,也有vue项目,甚至还有一些老的jquery项目。为了做到一次开发,任何项目都可以使用和接入。采用了web components技术方案。
web components就不做介绍了,具体可以去看这篇文章:⇲手写web components组件⇲19简单来说就是可以自定义元素,让我们像使用section一样使用自定义元素。
播放器我们需要考虑的点有:
video生命周期:loadedmeta,canplay, ended, error等
video状态:播放,正在播放,暂停,静音等
video属性:总时长,当前时长,音量大小,倍速等
video交互:暂停,播放,知识点,清晰度,倍速,全屏,进度控制,音量控制等
#(1).video的生命周期
对于video的生命周期,我们期望做到两件事情:
我们能知道当前video处于什么生命周期
在不同的生命周期,能挂载自定义事件。比如视频触发了ended。我们需要在ended时期跳转下一个视频
因此,我们需要监听video的生命周期:
- listenEvent = () => {
- if (!this._video) return;
- this.clearListenerEvent();
- this._video.addEventListener('canplay', this.onCanplay);
- this._video.addEventListener('canplaythrough', this.onCanplaythrough);
- this._video.addEventListener('complete', this.onComplete);
- this._video.addEventListener('durationchange', this.onDurationchange);
- this._video.addEventListener('emptied', this.onEmptied);
- this._video.addEventListener('ended', this.onEnded);
- this._video.addEventListener('error', this.onError);
- this._video.addEventListener('loadeddata', this.onLoadeddata);
- this._video.addEventListener('loadedmetadata', this.onLoadedmetadata);
- this._video.addEventListener('loadstart', this.onLoadstart);
- this._video.addEventListener('pause', this.onPause);
- this._video.addEventListener('play', this.onPlay);
- this._video.addEventListener('playing', this.onPlaying);
- this._video.addEventListener('progress', this.onProgress);
- this._video.addEventListener('ratechange', this.onRatechange);
- this._video.addEventListener('seeked', this.onSeeked);
- this._video.addEventListener('seeking', this.onSeeking);
- this._video.addEventListener('stalled', this.onStalled);
- this._video.addEventListener('suspend', this.onSuspend);
- this._video.addEventListener('timeupdate', this.onTimeupdate);
- this._video.addEventListener('volumechange', this.onVolumechange);
- this._video.addEventListener('waiting', this.onWaiting);
- };
在触发不同的时期时,让开发者知道。所以我们要先自定义事件,进行触发
- const change = (name: string, value: unknown): void => {
- const currentTime = this.getCurrentTime();
- const duration = this.getTotalTime();
- this.dispatchEvent(
- new CustomEvent('change', {
- detail: {
- type: name,
- data: value,
- currentTime,
- duration,
- tag: this, // 整个player的实例
- },
- }),
- );
- };
- const onCanplaythrough = (e: Event) => {
- this.ctx.currentState = e.type;
- this.change('canplaythrough', e);
- };
这样就可以,当生命周期事件触发后,就会触发onchange。 在使用上,我们可以:
"hls/example.m3u8" > - const change = (e:CustomEvent) => {
- const { type, data, currentTime, duration, tag } = e.detail
- if(type === 'ended'){
- console.log('video ended')
- }
- }
其中type的类型有:
名称 | 说明 |
---|---|
canplay | 浏览器可以播放媒体文件了,但估计没有足够的数据来支撑播放到结束,不必停下来进一步缓冲内容。 |
canplaythrough | 浏览器估计它可以在不停止内容缓冲的情况下播放媒体直到结束。 |
complete | OfflineAudioContext 渲染完成。 |
durationchange | duration 属性的值改变时触发。 |
emptied | 媒体内容变为空;例如,当这个 media 已经加载完成(或者部分加载完成),则发送此事件,并调用 load() 方法重新加载它。 |
ended | 视频停止播放,因为 media 已经到达结束点。 |
loadedmetadata | 已加载元数据。 |
progress | 在浏览器加载资源时周期性触发。 |
ratechange | 播放速率发生变化。 |
seeked | 跳帧(seek)操作完成。 |
seeking | 跳帧(seek)操作开始。 |
stalled | 用户代理(user agent)正在尝试获取媒体数据,但数据意外未出现。 |
suspend | 媒体数据加载已暂停。 |
loadeddata | media 中的首帧已经完成加载。 |
timeupdate | currentTime 属性指定的时间发生变化。 |
volumechange | 音量发生变化。 |
waiting | 由于暂时缺少数据,播放已停止。 |
play | 播放已开始。 |
playing | 由于缺乏数据而暂停或延迟后,播放准备开始。 |
pause | 播放已暂停。 |
volume | 音量发生变化。 |
fullscreen | 触发全屏事件 |
在不同的生命周期,能挂载事情,因此我们需要一个发布订阅类。
- type Callback = (...args: unknown[]) => unknown;
- type EventName = string | symbol;
- type EventItem = {
- name?: string | symbol;
- callback: Callback;
- initialCallback?: Callback;
- };
- const NEW_LISTENER = 'NEW_LISTENER';
- export class SyncHook {
- private _events: Record
>; - constructor() {
- this._events = {};
- }
- on = (eventName: EventName, eventItem: EventItem | Callback): void => {
- if (this._events[eventName] && eventName !== Symbol.for(NEW_LISTENER)) {
- this.emit(Symbol.for(NEW_LISTENER), eventName);
- }
- const callbacks = this._events[eventName] || [];
- if (typeof eventItem === 'function') {
- callbacks.push({
- name: eventName,
- callback: eventItem,
- });
- } else {
- callbacks.push(eventItem);
- }
- this._events[eventName] = callbacks;
- };
- emit = (eventName: EventName, ...args: Array
): void => { - const callbacks = this._events[eventName] || [];
- callbacks.forEach((item) => {
- const { callback } = item;
- callback(...args);
- });
- };
- once = (eventName: EventName, eventItem: EventItem | Callback): void => {
- let one: EventItem;
- if (typeof eventItem === 'function') {
- one = {
- name: eventName,
- callback: (...args: Array
) => { - eventItem(...args);
- this.off(eventName, one);
- },
- initialCallback: eventItem,
- };
- } else {
- const { callback } = eventItem;
- one = {
- name: eventName,
- callback: (...args: Array
) => { - callback(...args);
- this.off(eventName, one);
- },
- initialCallback: callback,
- };
- }
- this.on(eventName, one);
- };
- off = (eventName: EventName, eventItem: EventItem | Callback): void => {
- const callbacks = this._events[eventName] || [];
- const newCallbacks = callbacks.filter((item) => {
- if (typeof eventItem === 'function') {
- return (
- item.callback !== eventItem && item.initialCallback !== eventItem
- );
- } else {
- const { callback } = eventItem;
- return item.callback !== callback && item.initialCallback !== callback;
- }
- });
- this._events[eventName] = newCallbacks;
- };
- }
因此会给player元素上增加一个ctx属性,作为全局的上下文。
- this.ctx = {
- currentTime: 0, // 当前时间
- duration: 0, // 总时长
- currentState: '', // 当前视频状态
- action: new SyncHook(), // 不同时期触发的状态
- };
我们想订阅视频的结束事件,我们可以
通过Ref的方式:
"hls/example.m3u8" > - const endedEvent = () => {
- console.log('video ended')
- }
- PlayerRef.current.ctx.action.off('ended',endedEvent)
- PlayerRef.current.ctx.action.on('ended',endedEvent)
通过change方法获取的实例:
"hls/example.m3u8" > - let player
- const endedEvent = () => {
- console.log('video ended')
- }
- const change = (e:CustomEvent) => {
- const { type, data, currentTime, duration, tag } = e.detail
- player = tag
- }
- player.action.off('ended',endedEvent)
- player.action.on('ended',endedEvent)
#(2).video的状态和属性
需要在全局上下文中记录下播放器的状态和属性:
- this.ctx = {
- currentTime: 0, // 当前时间
- duration: 0, // 总时长
- currentState: '', // 当前视频状态
- action: new SyncHook(), // 不同时期触发的状态
- volume: 0.5, // 当前音量
- playbackRate: 1, // 当前倍速
- clarity: '', // 当前清晰度
- fullScreen: false, // 是否全屏
- levels: [], // 清晰度列表
- url: '', // 当前播放的地址
- levelMap: new Map(), // 清晰度和名字的映射关系
- };
#(3).自定义video
默认长这个样子
demo地址:⇲chaxus.github.io/ran/src/ran…⇲20
源码地址:⇲github.com/chaxus/ran/…⇲21
如果不喜欢控制器或者按钮,直接样式覆盖,更符合直觉,没有学习成本。
- .ran-player-controller{
- display: none
- }
由于播放器本身就是一个元素,那么可以任意的在里面添加元素,添加逻辑。
"hls/example.m3u8" > -
111111
所以,这就解决配置项,长达好几页的问题,同时看到也就知道怎么配置,怎么开发了。
#八.总结
目前已经从前后端的角度,实现了
视频的标准加密
视频的动态码率播放
视频的分片加载
可拖拽进度条
音量控制
手动清晰度切换
倍速播放
样式自定义覆盖
基于原生开发,可在所有框架运行,统一跨框架情况,各浏览器控件统一
这是demo和源码地址:
demo和文档地址:⇲https://chaxus.github.io/ran/src/ranui/player/
源码地址:⇲https://github.com/chaxus/ran/tree/main/packages/ranui
demo文档做了国际化,可切换到中文
Node 社群
我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。
“分享、点赞、在看” 支持一下参考资料
[1]
H5视频化调研浅析:https://juejin.cn/post/7238739662822735933
[2]
www.bilibili.com/video/BV1FM…:https://www.bilibili.com/video/BV1FM411N7LJ
[3]
m.ixigua.com/douyin/shar…:https://m.ixigua.com/douyin/share/video/7206914252840370721?aweme_type=107&schema_type=1&utm_source=copy&utm_campaign=client_share&utm_medium=android&app=aweme
[4]
www.xiaohongshu.com/discovery/i…:https://www.xiaohongshu.com/discovery/item/63b286d1000000001f00b495
[5]
github.com/Dash-Indust…:https://github.com/Dash-Industry-Forum/dash.js
[6]
en.wikipedia.org/wiki/Flash_…:https://en.wikipedia.org/wiki/Flash_Video#Flash_Video_Structure
[7]
ISO/IEC 14496-12:https://www.iso.org/standard/83102.html
[8]
github.com/bilibili/fl…:https://github.com/bilibili/flv.js
[9]
Audio Video Interleave:https://en.wikipedia.org/wiki/Audio_Video_Interleave
[10]
Moving Picture Experts Group:https://en.wikipedia.org/wiki/Moving_Picture_Experts_Group
[11]
MPEG-4 Part 14:https://en.wikipedia.org/wiki/MP4_file_format
[12]
QuickTime File Format:https://en.wikipedia.org/wiki/QuickTime_File_Format
[13]
www.chromium.org/audio-video…:https://www.chromium.org/audio-video/
[14]
developer.apple.com/library/arc…:https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html
[15]
support.mozilla.org/en-US/kb/ht…:https://support.mozilla.org/en-US/kb/html5-audio-and-video-firefox
[16]
github.com/bytedance/x…:https://github.com/bytedance/xgplayer
[17]
help.aliyun.com/zh/vod/deve…:https://help.aliyun.com/zh/vod/developer-reference/overview-14
[18]
github.com/zhihu/griff…:https://github.com/zhihu/griffith
[19]
手写web components组件:https://juejin.cn/post/7170219296226803725
[20]
chaxus.github.io/ran/src/ran…:https://chaxus.github.io/ran/src/ranui/player/
[21]
github.com/chaxus/ran/…:https://github.com/chaxus/ran/tree/main/packages/ranui
1.本站遵循行业规范,任何转载的稿件都会明确标注作者和来源;2.本站的原创文章,请转载时务必注明文章作者和来源,不尊重原创的行为我们将追究责任;3.作者投稿可能会经我们编辑修改或补充。
在线投稿:投稿 站长QQ:1888636
后台-插件-广告管理-内容页尾部广告(手机) |