引用
直播是脱离于文字、图片来说,另外一种社交的方式。各大平台也在深耕这一领域,淘宝直播,花椒,映客,Now 直播,企鹅电竞。本人就职于腾讯 Now 直播前端开发,感觉直播能够尝试的领域真的太多太多,但是,Web 在这块一直是一个痛点。由于没有现成操作流的接口,只能简简单单的通过添加 video.src 尴尬的播放几段回放...... 这样造成的后果就是,在 Web 上,我们根本体会不到实时流畅的观看体验。
而且,根据 8 月份腾讯财报内容,直播贡献的收入增长的飞快。现在,我们也想让 Web 体会一把能够实时观看直播的方式,这应该怎么做呢?W3C 提出了 MSE 的标准,表义上来说就是,让前端能够操作视频流。HLS.js,FLV.js 本身也是基于 MSE 开发的。MSE 的出现,不仅能让 Web 接上直播,而且还可以根据协议自己控制相关的延迟率。
那直播,又和我们今天的主题 MSE/video 有啥关系呢?
在没有 MSE 的时候,直播形式要么在 flash 中播放,要么在客户端播放,要么利用 HLS 来手机端播放。不仅 HTML5 原生播放器的场景几乎可以说是没有,而且 H5 播放的延时性还非常高。最多我们也只能控制一下 视频播放 的表层工作,比如,暂停,播放,快进。例如:
<audio id="demo" src="audio.mp3"></audio>
<div>
<button onclick="document.getElementById('demo').play()">播放声音</button>
<button onclick="document.getElementById('demo').pause()">暂停声音</button>
<button onclick="document.getElementById('demo').volume+=0.1">提高音量</button>
<button onclick="document.getElementById('demo').volume-=0.1">降低音量</button>
</div>
这样,感觉和写 HTML 没啥区别,我们也并不能对流做一下神奇的操作,比如,跳帧,音视频同步,拿到 I/B/P 帧生成视频图像之类的。这其实只是给了我们另外一个界面的 UI API 而已,并不能让 所有能用代码写的程序,都可以用 JavaScript 来写 这一非常宏伟的目标。
后面,各台平台支持了 MSE,前端开发者从此也可以进行音视频的相关开发。因为,MSE 的主要工作是可以创建 media stream,并且喂给 video/audio 进行播放。从此,前端可以和写 C++ Java 的人有了共同的话题--二进制流的操作。
MSE 简介
MSE 是实际上是一系列 API 的集合。它的全称为:Media Source Extensions,看名字差不多都可以知道,MSE 就是一系列接口的拓展集合,里面包括了一系列 API:Media Source,Source Buffer 等。
我们来看一下 MSE 是如何完成基本流的处理的。
var vidElement = document.querySelector('video');
if (window.MediaSource) {
var mediaSource = new MediaSource();
vidElement.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
console.log("The Media Source Extensions API is not supported.")
}
function sourceOpen(e) {
URL.revokeObjectURL(vidElement.src);
var mime = 'video/webm; codecs="opus, vp9"';
var mediaSource = e.target;
var sourceBuffer = mediaSource.addSourceBuffer(mime);
var videoUrl = 'droid.webm';
fetch(videoUrl)
.then(function(response) {
return response.arrayBuffer();
})
.then(function(arrayBuffer) {
sourceBuffer.addEventListener('updateend', function(e) {
if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
mediaSource.endOfStream();
}
});
sourceBuffer.appendBuffer(arrayBuffer);
});
}
上面的代码完成了相关的获取流和处理流的两个部分。其中,主要利用的是 MS 和 Source Buffer 来完成的。URL.revokeObjectURL 主要是用来生成一个内联链接的,例如:blob:http://villainhr/demo_123d1dticn1@df1。
MSE 中主要内容就是 MS 和 SourceBuffer,我们接下来着重介绍一下。
MediaSource
基本 API
整个 MS 内容可以直接参考 W3C:
[Constructor]
interface MediaSource : EventTarget {
readonly attribute SourceBufferList sourceBuffers;
readonly attribute SourceBufferList activeSourceBuffers;
readonly attribute ReadyState readyState;
attribute unrestricted double duration;
attribute EventHandler onsourceopen;
attribute EventHandler onsourceended;
attribute EventHandler onsourceclose;
SourceBuffer addSourceBuffer(DOMString type);
void removeSourceBuffer(SourceBuffer sourceBuffer);
void endOfStream(optional EndOfStreamError error);
void setLiveSeekableRange(double start, double end);
void clearLiveSeekableRange();
static boolean isTypeSupported(DOMString type);
};
我们先从静态属性来看一下。
isTypeSupported
isTypeSupported 主要是用来检测 MS 是否支持某个特定的编码和容器盒子。例如:
MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E, mp4a.40.2"')
那我怎么查看我想要使用到的 MIME 呢?
如果你有现成的 video 文件,可以直接使用 FFmpeg 进行分析:ffmpge -i video.mp4。不过,这个只是给你文件的相关描述,例如:
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'video.mp4':
Metadata:
major_brand : isom
minor_version : 1
compatible_brands: isomavc1
Duration: 00:00:03.94, start: 0.000000, bitrate: 69 kb/s
Stream #0:0(und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 61 kb/s (default)
Metadata:
handler_name : SoundHandler
那实际怎么得到,像上面一样的 video/mp4; codecs="avc1.42E01E, mp4a.40.2" 的 MIME 内容呢?具体映射主要参考:MIME doc 即可。
SourceBuffer 的处理
SourceBuffer 是 MS 下的一个子集,相当于就是具体的音视频轨道,具体内容是啥以及干啥的,我们在后面有专题进行介绍。在 MS 层,提供了相关的 API 可以直接对 SB 进行相关的创建,删除,查找等。
addSourceBuffer
该是用来返回一个具体的视频流 SB,接受一个 mimeType 表示该流的编码格式。例如:
var mimeType = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
var sourceBuffer = mediaSource.addSourceBuffer(mimeType);
实际上,SB 的操作才是真正影响到 video/audio 播放的内容。
function sourceOpen (_) {
var mediaSource = this;
var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
fetchAB(assetURL, function (buf) {
sourceBuffer.addEventListener('updateend', function (_) {
mediaSource.endOfStream();
video.play();
});
// 通过 fetch 添加视频 Buffer
sourceBuffer.appendBuffer(buf);
});
};
它通过 appendBuffer 直接添加视频流,实现播放。不过,在使用 addSourceBuffer 创建之前,还需要保证当前浏览器是否支持该编码格式。当然,不支持也行,顶多是当前 MS 报错,断掉当前 JS 线程。
removeSourceBuffer
用来移除某个 sourceBuffer。比如当前流已经结束,那么你就没必要再保留当前 SB 来占用空间,可以直接移除。具体格式为:
mediaSource.removeSourceBuffer(sourceBuffer);
sourceBuffers
sourceBuffers 是 MS 实例上的一个属性,它返回的是一个 SourceBufferList 的对象,里面可以获取当前 MS 上挂载的所有 SB。不过,只有当 MS 为 open 状态的时候,它才可以访问。具体使用为:
let SBs = mediaSource.sourceBuffers;
那我们怎么获取到具体的 SB 对象呢?因为,其返回值是 SourceBufferList 对象,具体格式为:
interface SourceBufferList : EventTarget {
readonly attribute unsigned long length;
attribute EventHandler onaddsourcebuffer;
attribute EventHandler onremovesourcebuffer;
getter SourceBuffer (unsigned long index);
};
简单来说,你可以直接通过 index 来访问具体的某个 SB:
let SBs = mediaSource.sourceBuffers;
let SB1 = SBs[0];
SBL 对象还提供了 addsourcebuffer 和 removesourcebuffer 事件,如果你想监听 SB 的变化,可以直接通过 SBL 来做。这也是为什么 MS 没有提供监听事件的一个原因。
所以,删除某一个 SB 就可以通过 SBL 查找,然后,利用 remove 方法移除即可:
let SBs = mediaSource.sourceBuffers;
let SB1 = SBs[0];
mediaSource.removeSourceBuffer(SB1);
另外,MS 上,还有另外一个 SBL。基本内容为:
activeSourceBuffers
activeSourceBuffers 实际上是 sourceBuffers 的子集,返回的同样也是 SBL 对象。为什么说也是子集呢?
因为 ASBs 包含的是当前正在使用的 SB。因为前面说了,每个 SB 实际上都可以具体代表一个 track,比如,video track,audio track,text track 等等,这些都算。那怎么标识正在使用的 SB 呢?
很简单,不用标识啊,因为,控制哪一个 SB 正在使用是你来决定的。如果非要标识,就需要使用到 HTML 中的 video 和 audio 节点。通过
audioTrack = media.audioTracks[index]
videoTrack = media.videoTracks[index]
// media 为具体的 video/audio 的节点
// 返回值就是 video/audio 的底层 tracks
audioTrack = media.audioTracks.getTrackById( id )
videoTrack = media.videoTracks.getTrackById( id )
videoTrack.selected // 返回 boolean 值,标识是否正在被使用
上面的代码只是告诉你,正在使用 的含义是什么。对于,我们实际编码的 SB 来说,并没有太多关系,了解就好。上面说了 ASBs 返回值也是一个 SBL。所以,使用方式可以直接参考 SBL 即可。
状态切换
要说道状态切换,我们得先知道 MS 一共有几个状态值。MS 本身状态并不复杂,一共只有三个状态值:
enum ReadyState {
"closed",
"open",
"ended"
};
- closed: 当前的 MS 并没有和 HTMLMedia 元素连接。
- open: MS 已经和 HTMLMedia 连接,并且等待新的数据被添加到 SB 中去。
- ended: 当调用 endOfStream 方法时会触发,并且此时依然和 HTMLMedia 元素连接。
记住,closed 和 ended 到的区别关键点在于有没有和 HTMLMedia 元素连接。
其对应的还有三个监听事件:
- sourceopen: 当状态变为 open 时触发。常常在 MS 和 HTMLMedia 绑定时触发。
- sourceended: 当状态变为 ended 时触发。
- sourceclose: 当状态变为 closed 时触发。
那哪种条件下会触发呢?
sourceopen 触发
sourceopen 事件相同于是一个总领事件,只有当 sourceopen 时间触发后,后续对于 MS 来说,才是一个可操作的对象。
通常来说,只有当 MS 和 video 元素成功绑定时,才会正常触发:
let mediaSource = new MediaSource();
vidElement.src = URL.createObjectURL(mediaSource);
其实这简单的来说,就是给 MS 添加 HTML media 元素。其整个过程为:
- 先延时 media 元素的 load 事件,将 delaying-the-load-event-flag 设置为 false
- 将 readyState 设置为 open。
- 触发 MS 的 sourceopen 事件
sourceended 触发
sourceended 的触发条件其实很简单,只有当你调用 endOfStream 的时候,会进行相关的触发。
mediaSource.endOfStream();
这个就没啥需要过多讲的了。
sourceclose 的触发
sourceclose 是在 media 元素和 MS 断开的时候,才会触发。那这个怎么断开呢?
难道直接将 media 的元素的 src 直接设置为 null 就 OK 了吗?
MS 会这么简单么?实际上并不,如果要手动触发 sourceclose 事件的话,则需要下列步骤:
- 将 readyState 设置为 closed
- 将 MS.duration 设置为 NaN
- 移除 activeSourceBuffers 上的所有 Buffer
- 触发 activeSourceBuffers 的 removesourcebuffer 事件
- 移除 sourceBuffers 上的 SourceBuffer。
- 触发 sourceBuffers 的 removesourcebuffer 事件
- 触发 MediaSource 的 sourceclose 事件
到这里,三个状态事件基本就介绍完了。不过,感觉只有 sourceopen 才是最有用的一个。
track 的切换
track 这个概念其实是音视频播放的轨道,它和 MS 没有太大的关系。不过,和 SB 还是有一点关系的。因为,某个一个 SB 里面可能会包含一个 track 或者说是几个 track。所以,推荐某一个 SB 最好包含一个值包含一个 track,这样,后面的 track 也方便更换。
在 track 中的替换里,有三种类型,audio,video,text 轨道。
video 切换
切换的含义有两种,一种是移除原有的,一种是添加新的。这里,我们需要分两部分来讲解。
移除原有不需要 track
从 activeSourceBuffers 移除与当前 track 相关的 SB
触发 activeSourceBuffers 的 removesourcebuffer 事件
添加指定的 track
从 activeSourceBuffers 添加指定的 SourceBuffer
触发 activeSourceBuffers 的 addsourcebuffer 事件
audio 切换
audio 的切换和 video 的过程一模一样。这里我就不过多赘述了。
MS duration 修正机制
MS 的 duration 实际上就是 media 中播放的时延。通常来说,A/V track 实际上是两个独立的播放流,这中间必定会存在先关的差异时间。但是,media 播放机制永远会以最长的 duration 为准。
这种情况对于 live stream 的播放,特别适合。因为 liveStream 是不断动态添加 buffer,但是 buffer 内部会有一定的时长的,而 MS 就需要针对这个 buffer 进行动态更新。
整个更新机制为:
- 当前 MS.duration 更新为 new duration。
- 如果 new duration 比 sourceBuffers 中的最大的 pts 小,这时候就会报错。
- 让最后一个的 sample 的 end time 为所后 timeRanges 的 end time。
- 将 new duration 设置为当前 SourceBuffer 中最大的 endTime。
- 将 video/audio 的播放时长(duration) 设置为最新的 new duration。
SourceBuffer
SourceBuffer 则是 MS 子属中最重要的内容。也就是说,所有的 media track 的内容都是存储在 SB 里面的。
那 SB 里面又有哪些内容呢?
直接看接口吧:
interface SourceBuffer : EventTarget {
attribute AppendMode mode;
readonly attribute boolean updating;
readonly attribute TimeRanges buffered;
attribute double timestampOffset;
readonly attribute AudioTrackList audioTracks;
readonly attribute VideoTrackList videoTracks;
readonly attribute TextTrackList textTracks;
attribute double appendWindowStart;
attribute unrestricted double appendWindowEnd;
attribute EventHandler onupdatestart;
attribute EventHandler onupdate;
attribute EventHandler onupdateend;
attribute EventHandler onerror;
attribute EventHandler onabort;
void appendBuffer(BufferSource data);
void abort();
void remove(double start, unrestricted double end);
};
其中,SB 中有一个很重要的概念--mode。该字段决定了 A/V segment 是怎样进行播放的。
播放模式
mode 的取值有两个,一个是 segments,一个是 sequence。
segments 表示 A/V 的播放时根据你视频播放流中的 pts 来决定,该模式也是最常使用的。因为音视频播放中,最重要的就是 pts 的排序。因为,pts 可以决定播放的时长和顺序,如果一旦 A/V 的 pts 错开,有可能就会造成 A/V sync drift。
sequence 则是根据空间上来进行播放的。每次通过 appendBuffer 来添加指定的 Buffer 的时候,实际上就是添加一段 A/V segment。此时,播放器会根据其添加的位置,来决定播放顺序。还需要注意,在播放的同时,你需要告诉 SB,这段 segment 有多长,也就是该段 Buffer 的实际偏移量。而该段偏移量就是由 timestampOffset 决定的。整个过程用代码描述一下就是:
sb.appendBuffer(media.segment);
sb.timestampOffset += media.duration;
另外,如果你想手动更改 mode 也是可以的,不过需要注意几个先决条件:
对应的 SB.updating 必须为 false.
如果该 parent MS 处于 ended 状态,则会手动将 MS readyState 变为 open 的状态。
如何界定 track
这里先声明一下,track 和 SB 并不是一一对应的关系。他们的关系只能是 SB : track = 1: 1 or 2 or 3。即,一个 SB可能包含,一个 A/V track(1),或者,一个 Video track ,一个Audio track(2),或者 再额外加一个 text track(3)。
上面也说过,推荐将 track 和 SB 设置为一一对应的关系,应该这样比较好控制,比如,移除或者同步等操作。具体编码细节我们有空再说,这里先来说一下,SB 里面怎么决定 track 的播放。
track 最重要的特性就是 pts ,duration,access point flag。track 中 最基本的单位叫做 Coded Frame,表示具体能够播放的音视频数据。它本身其实就是一些列的 media data,并且这些 media data 里面必须包含 pts,dts,sampleDuration 的相关信息。在 SB 中,有几个基本内部属性是用来标识前面两个字段的。
- last decode timestamp: 用来表示最新一个 frame 的编码时间(pts)。默认为 null 表示里面没有任何数据
- last frame duration: 表示 coded frame group 里面最新的 frame 时长。
- highest end timestamp: 相当于就是最后一个 frame 的 pts + duration
- need random access point flag: 这个就相当于是同步帧的意思。主要设置是根据音视频流 里面具体字段决定的,和前端这边编码没关系。
- track buffer ranges: 该字段表示的是 coded frame group 里面,每一帧对应存储的 pts 范围。
这里需要特别说一下 last frame duration 的概念,其实也就是 Coded Frame Duration 的内容。
Coded Frame Duration 针对不同的 track 有两种不同的含义。一种是针对 video/text 的 track,一种是针对 audio 的 track:
- video/text: 其播放时长(duration)直接是根据 pts 直接的差值来决定,和你具体播放的 samplerate 没啥关系。虽然,官方也有一个计算 refsampelDuration 的公式:duration = timescale / fps,不过,由于视频的帧率是动态变化的,没什么太大的作用。
- audio: audio 的播放时长必须是严格根据采样频率来的,即,其播放时间必须和你自己定制的 timescale 以及 sampleRate 一致才行。针对于 AAC,因为其采样频率常为 44100Hz,其固定播放时长则为:duration = 1024 / sampleRate * timescale
所以,如果你在针对 unstable stream 做同步的话,一定需要注意这个坑。有时候,dts 不同步,有可能才是真正的同步。
我们再回到上面的子 title 上-- 如果界定 track。一个 SB 里面是否拥有一个或者多个 track,主要是根据里面的视频格式来决定的。打个比方,比如,你是在编码 MP4 的流文件。它里面的 track 内容,则是根据 moov box 中的 trak box 来判断的。即,如果你的 MP4 文件只包含一个,那么,里面的 track 也有只有一个。
SB buffer 的管理
SB 内部的状态,通常根据一个属性:updating 值来更新。即,它只有 true 或者 false 两种状态:
true:当前 SB 正在处理添加或者移除的 segment
false:当前 SB 处于空闲状态。当且仅当 updating = false 的时候,才可以对 SB 进行额外的操作。
SB 内部的 buffer 管理主要是通过 appendBuffer(BufferSource data) 和 remote() 两个方法来实现的。当然,并不是所有的 Buffer 都能随便添加给指定的 SB,这里面是需要条件和相关顺序的。
该 buffer,必须满足 MIME 限定的类型
该 buffer,必须包含 initialization segments(IS) 和 media segments(MS).
下图是相关的支持 MIME:
这里需要提醒大家一点,MSE 只支持 fmp4 的格式。具体内容可以参考: FMP4 基本解析。上面提到的 IS 和 MS 实际上就是 FMP4 中不同盒子的集合而已。
这里简单阐述一下:
Initialization segments
FMP4 中的 IS 实际上就是:ftyp + moov。里面需要包含指定的 track ID,相关 media segment 的解码内容。下面为基本的格式内容:
[ftyp] size=8+24
major_brand = isom
minor_version = 200
compatible_brand = isom
compatible_brand = iso2
compatible_brand = avc1
compatible_brand = mp41
[mdat]
[moov]
[mvhd]
timescale = 1000
duration = 13686
duration(ms) = 13686
[trak]
[trak]
[udta]
具体内容编码内容,我们就放到后面来讲解,具体详情可以参考:W3C Byte Stream Formats。我们可以把 IS 类比为一个文件描述头,该头可以指定该音视频的类型,track 数,时长等。
Media Segment
MS 是具体的音视频流数据,在 FMP4 格式中,就相当于为 moof + mdat 两个 box。MS 需要包含已经打包和编码时间后的数据,其会参考最近的 IS 头内容。
相关格式内容,可以直接参考 MP4 格式解析。
在了解了 MS 和 IS 之后,我们就需要使用相应的 API 添加/移除 buffer 了。
这里,需要注意一下,在添加 Buffer 的时候,你需要了解你所采用的 mode 是哪种类型,sequence 或者 segments。这两种是完全两种不同的添加方式。
segments
这种方式是直接根据 MP4 文件中的 pts 来决定播放的位置和顺序,它的添加方式极其简单,只需要判断 updating === false,然后,直接通过 appendBuffer 添加即可。
if (!sb.updating) {
let MS = this._mergeBuffer(media.tmpBuffer);
sb.appendBuffer(MS); // ****
media.duration += lib.duration;
media.tmpBuffer = [];
}
sequence
如果你是采用这种方式进行添加 Buffer 进行播放的话,那么你也就没必要了解 FMP4 格式,而是了解 MP4 格式。因为,该模式下,SB 是根据具体添加的位置来进行播放的。所以,如果你是 FMP4 的话,有可能就有点不适合了。针对 sequence 来说,每段 buffer 都必须有自己本身的指定时长,每段 buffer 不需要参考的 baseDts,即,他们直接可以毫无关联。那 sequence 具体怎么操作呢?
简单来说,在每一次添加过后,都需要根据指定 SB 上的 timestampOffset。该属性,是用来控制具体 Buffer 的播放时长和位置的。
if (!sb.updating) {
let MS = this._mergeBuffer(media.tmpBuffer);
sb.appendBuffer(MS); // ****
sb.timestampOffset += lib.duration; // ****
media.tmpBuffer = [];
}
上面两端打 * 号的就是重点内容。该方式比较容易用来直接控制 buffer 片段的添加,而不用过度关注相对 baseDTS 的值。
控制播放片段
如果要在 video 标签中控制指定片段的播放,一般是不可能的。因为,在加载整个视频 buffer 的时候,视频长度就已经固定的,剩下的只是你如果在 video 标签中控制播放速度和音量大小。而在 MSE 中,如何在已获得整个视频流 Buffer 的前提下,完成底层视频 Buffer 的切割和指定时间段播放呢?
这里,需要利用 SB 下的 appendWindowStart 和 appendWindowEnd 这两个属性。
他们两个属性主要是为了设置,当有视频 Buffer 添加时,只有符合在 [start,end] 之间的 media frame 才能 append,否则,无法 append。例如:
sourceBuffer.appendWindowStart = 2.0;
sourceBuffer.appendWindowEnd = 5.0;
设置添加 Buffer 的时间戳为 [2s,5s] 之间。appendWindowStart 和 appendWindowEnd 的基准单位为 s。该属性值,通常在添加 Buffer 之前设置。
SB 内存释放
SB 内存释放其实就和在 JS 中,将一个变量指向 null 一样的过程。
var a = new ArrayBuffer(1024 * 1000);
a = null; // start garbage collection
在 SB 中,简单的来说,就是移除指定的 time ranges' buffer。需要用到的 API 为:
remove(double start, unrestricted double end);
具体的步骤为:
- 找到具体需要移除的 segment。
- 得到其开始(start)的时间戳(以 s 为单位)
- 得到其结束(end)的时间戳(以 s 为单位)
- 此时,updating 为 true,表明正在移除
- 完成之后,出发 updateend 事件
如果,你想直接清空 Buffer 重新添加的话,可以直接利用 abort() API 来做。它的工作是清空当前 SB 中所有的 segment,使用方法也很简单,不过就是需要注意不要和 remove 操作一起执行。更保险的做法就是直接,通过 updating===false 来完成:
if(sb.updating===false){
sb.abort();
}
这时候,abort 的主要流程为:
- 确保 MS.readyState==="open"
- 将 appendWindowStart 设置为 pts 原始值,比如,0
- 将 appendWindowEnd 设置为正无限大,即,Infinity。
到这里,整个流程差不多就已经介绍完了。实际代码,可以参考一下,w3c 的 example 下面,我们主要来检查一下,实际 HTMLMediaElement 和 MSE 之间又有啥不干净的关系。
HTMLMediaElement 播放设定
HTMLMediaElement 是一个集合概念,里面包含了 Video 和 Audio 元素。也可以说,A/V 两个元素其实就是继承了 HTMLMediaElement 的原型对象。
我们先来看一下 HTMLMediaElement 上面具有哪些属性:
interface HTMLMediaElement : HTMLElement {
// error state
readonly attribute MediaError? error;
// network state
attribute DOMString src;
attribute MediaProvider? srcObject;
readonly attribute DOMString currentSrc;
attribute DOMString? crossOrigin;
const unsigned short NETWORK_EMPTY = 0;
const unsigned short NETWORK_IDLE = 1;
const unsigned short NETWORK_LOADING = 2;
const unsigned short NETWORK_NO_SOURCE = 3;
readonly attribute unsigned short networkState;
attribute DOMString preload;
readonly attribute TimeRanges buffered;
void load();
CanPlayTypeResult canPlayType(DOMString type);
// ready state
const unsigned short HAVE_NOTHING = 0;
const unsigned short HAVE_METADATA = 1;
const unsigned short HAVE_CURRENT_DATA = 2;
const unsigned short HAVE_FUTURE_DATA = 3;
const unsigned short HAVE_ENOUGH_DATA = 4;
readonly attribute unsigned short readyState;
readonly attribute boolean seeking;
// playback state
attribute double currentTime;
void fastSeek(double time);
readonly attribute unrestricted double duration;
object getStartDate();
readonly attribute boolean paused;
attribute double defaultPlaybackRate;
attribute double playbackRate;
readonly attribute TimeRanges played;
readonly attribute TimeRanges seekable;
readonly attribute boolean ended;
attribute boolean autoplay;
attribute boolean loop;
void play();
void pause();
// controls
attribute boolean controls;
attribute double volume;
attribute boolean muted;
attribute boolean defaultMuted;
// tracks
[SameObject] readonly attribute AudioTrackList audioTracks;
[SameObject] readonly attribute VideoTrackList videoTracks;
[SameObject] readonly attribute TextTrackList textTracks;
TextTrack addTextTrack(TextTrackKind kind, optional DOMString label = "", optional DOMString language = "");
};
上面这些属性都是非常精华和重要的内容。
更完整的可以直接参考 W3C 官方文档的说明:HTMLMedia。
video 播放事件的迷
video 的播放事件可以说是比整个 HTMLMediaElment 属性更恶心的内容。大家可以先看一下基本事件:playing , waiting , seeking , seeked , ended , loadedmetadata , loadeddata , canplay , canplaythrough , durationchange , timeupdate , play , pause , ratechange , volumechange , suspend , emptied , stalled,canplaythrough。
有时候看见这些,脑子都是炸的。不过,说归说,生活还是要过的,就像娶了老婆,总不能说薪资保密不交钱吧。
这里的事件大部分是围绕的 HME(HTMLMediaElment)中的 readyState 的。
其基本的内容有:
// ready state
const unsigned short HAVE_NOTHING = 0;
const unsigned short HAVE_METADATA = 1;
const unsigned short HAVE_CURRENT_DATA = 2;
const unsigned short HAVE_FUTURE_DATA = 3;
const unsigned short HAVE_ENOUGH_DATA = 4;
readyState 本身是代表当前播放片段的 Buffer 内容。我们先来说一下,每个值代表的含义,这样,也就能够理解上面具体事件到底什么时候能够触发,以及为什么能够触发。
- HAVE_NOTHING = 0: 当前 video 没有包含任何可使用的数据。即,它没有和任何流绑定在一起。此时,啥事件都不会触发。
- HAVE_METADATA = 1: 得到视频流的基本数据,比如,视频编码格式,视频 duration 等。不过,还没有得到实际的数据,当前还不能无法播放。
- HAVECURRENTDATA = 2: 拥有当前视频播放数据,但并不包括下一帧的数据。即,很有可能 Video 在播放完当前的帧后就停止。并且,当且仅当 readyState >= HAVE_CURRENT_DATA 才可以完成播放。
- HAVEFUTUREDATA = 3: 这是比上一个状态,数据更丰富的一个状态。这时,不仅拥有当前视频播放数据,还包括下一帧的播放数据。
- HAVEENOUGHDATA = 4: 表示当前 mediaSource 中的视频流 Buffer 已经满了。即,可以流畅的播放一段时间的数据。
就这 5 个状态,实际上映射的是上面的 6 个事件(这里还不包括网络状态的事件。。。):loadedmetadata , loadeddata , canplay , canplaythrough ,playing,waiting。
这里简述一下,具体触发事件和其所对应的状态吧:
- loadedmetadata: 当 readyState === 1 时,触发。表示已经获得相关的视频元数据。
- loadeddata: 当 readyState === 2,触发。表示当前已经可以播放了。该事件其实和 loadedmetadata 区别不是特别大。
- canplay: 当 readyState === 3,触发。此时,已经有一部分数据,但并不代表可以完整的播放到音频的结束,中间可能会存在暂停和缓存的操作。
- canplaythrough: 当 readyState === 4,触发。此时,video 可以一直播放到视频流的结束。相当于已经下好一段完整的视频。
剩下两个事件则主要在视频播放途中会经常触发--waiting 和 playing,和具体某个 readyState 状态的联系就不太大了。
- playing: 当在视频由于缺少 media source 而暂停缓存以及手动暂停,又重新播放时,会触发该事件。简单来说就是,readyState >= HAVEFUTUREDATA 以及 paused=== false 条件下,playing 事件可以触发。
- waiting: 由于没有下一帧的数据,导致视频进行 buffer 加载,造成 Video 的暂停(但并不是用户的暂停)。该事件的触发条件为:readyState <= HAVECURRENTDATA && paused===false。需要注意的是,用户手动暂停是不会触发该事件的,这和 pause 事件有着本质的区别。
上面这 6 个事件是完全和 video readyState 底层状态相关的,而和上层用户操作没有任何关联的。除了这 6 个事件以外的其它事件,我们这里简单做个分类即可:
上面这些是一些比较常见和常用的事件,当然,还有一些错误处理的事件,这里就不赘述了。详情可以参考:Video 事件总结。
video 为啥不能自动播放?
在一些官网上,有时候需要在首屏加上一个 video,使其在罩层下面播放。非常简单的想法就是直接加上 autoplay 属性,例如:
<video src="audio.ogg" controls autoplay loop>
<p>你的浏览器不支持audio标签</p>
</video>
这样,在大部分 PC 上播放都没有问题。但是,In China,有两个天王 APP,一个是 QQ,一个是 WX。这两位爸爸里面用的是 TBS 和 QB 来做 webview,内核虽然用到了 blink,但是名字改了,改动总是有的。
所以,有时候在 WX 和 QQ 上,上面的 video 标签并不能直接播放。为什么呢?
母鸡。。。内核又不是我写的。但基本上可以根据官方文档上猜测一下:
因为,Video 内部有一个叫做 autoplaying flag,如果为 true 的话,它会阻塞 Document 的 load 事件,也就是延长渲染的时间。WX 可能为了用户体验,手动取消的 autoplay 这一过程。那我们有没有什么其他办法,做到强制触发呢?
嗯,有的。在前面我们了解过关于 readyState 相关的事件,借助他们可以完成自动播放的一个 trick。
video.addEventListener('canplay',function(e){
// now the readyState has already larger than 3
// then, using JS to play the video
video.play();
})
实际上,Video 自动播放,可以直接映射为 HTMLMedia 的 load 方法。如果,你没有给 Video 添加 autoplay 的属性,可以尝试使用 load 方法来直接播放。这样,既可以避免无意识延长 document.onload 的触发,又可以比较灵活的进行脚本配置。
window.onload = function(){
video.load();
}
当你调用 load 方法时,我们得清楚为什么它能这样做?难道它可以手动修改 readyState 状态,还是其它底层的操作呢?
使用 load() 方法进行加载时。
- 如果,video 元素已经开始加载其它数据,则中断当前 video 元素资源获取
- 移除当前 video 元素的所有处理程序。
- 开始通过 network 获取资源
- 将 playbackRate 设置为 defaultPlaybackRate
- 将 autoplaying flag 设置为 true(这就是重点!和手动设置 autoplay 效果一致)
- 触发后面资源获取的流程
用 JS 来 seek video
video 本身有 seeking 和 seeked 事件来作为用户 seek 操作的监听函数。
- seeking: 当用户开始拖动进度条的时候触发
- seeked: 当用户拖动完进度条时触发
不过,最常用的还是 seeked 时间,只需要监听用户最后一次松手的位置。
但是,这样平白无故监听两个事件干嘛呢?
吃饱了吗?
我们要先明确我们的需求,即,能不能用 JS 来完成 seek 的操作呢?
可以,HM(HTMLMedia) 提供给我们了几个非常有用的 API:
- seeking[boolean]:返回当前用户是否正在 seek 操作
- seekable[TimeRanges]: 返回可供用户 seek 的 timeRanges 范围。
- fastSeek(time): 跳转到指定时间附近。为啥说附近呢?因为,该 seek 的位置的精确度主要是根据 speed 来决定的。但是如果,当前 video 没有数据,该方法是不会生效的。
- currentTime: 如果想要 seek 到精确时间,可以直接利用该属性来设置。
上面几个 API 可以基本完成 seek 的逻辑判断和相关操作。最简单的 seek 代码为:
function playSound(time) {
sfx.currentTime = time;
sfx.play();
}
试一试控制条
如果你在添加 video 的使用,额外加上了 controls 的属性。那么,在 Video 播放的时候,会在下方出现一个控制条。这个就是我们接下来想要来探索的一块领域。
从原始播放条上,我们基本上可以了解到几个功能:
- 播放
- 暂停
- seek
- 音量控制
- 全屏(这个看情况实现)
考虑到有一些刷剧的同学喜欢用倍速来看剧,我们这里还可以提供变速播放的功能。综上所述,我们实现的基本功能有 5 个。
而 HM 也同样在 JS 上提供了相关的方法和属性给我们进行使用:
media.paused
media.ended
media.defaultPlaybackRate [ = value ]
media.playbackRate [ = value ]
media.played
media.play()
media.pause()
media.currentTime;
media.volume
media.muted
media.defaultMuted
利用上述这些方法,我们基本上可以完成自定义控制条的设置。后面,我们根据几个基本的场景来做一下 JS code 的模拟。
调节音量和静音
这里主要使用上面的 volume 和 muted 属性。
function volumeControl(degree,video) {
// the degree is always between, if not ,it will throw IndexSizeError exception
degree = degree < 0 ? 0 || degree;
degree = degree > 1 ? 1 || degree;
video.volume = degree;
}
function toggleMute(video) {
video = !video.muted;
}
这里,额外再介绍一个和静音有关的属性 defaultMuted,该属性是用来决定音频播放的默认属性。
设置倍速播放
在倍速播放的时候,需要了解三个播放模式。一个是 fast-forwarding,reversing,normal playback。看起来有点懵厚,我保证看了中文之后,你一定想说句 MMP。
- fast-forwarding:快进
- reversing:快退
- normal playback:正常播放
在设置倍速的时候,需要额外注意,当用户从 fast-forwarding 到 normal playback 时,播放器会直接读取默认的 defaultPlaybackRate 而不是你通常设置的 playbackRate。所以,我们不论是在 seek 还是在其它播放模式的时候,最好是将默认的 rate 一并设置。
function switchRate(ratetype,video) {
// ratetype could only be larger than 0
video.defaultPlaybackRate = video.playbackRate = ratetype;
}
模拟用户 seek
在用户 seek 过程中,最主要的问题在于,在 seek 完成之后,播放器的状态是不固定的,即,有可能播放器还在加载视频数据,并且加载完成之后不能继续播放。我们需要解决的一个逻辑问题主要在这里。
function seekTime(curTime,video) {
// curTime should always less than the duration of video
curTime = curTime > video.duration ? video.duration : curTime;
video.currentTime = curTime;
if(video.readyState <=2 ){
video.pause();
}
video.addEventListener('canplay',function(e){
e.target.play();
})
}
暂停/播放
这个算是 video 播放器的基本功能,实现起来也是很简单的。
function togglePlay(video) {
video.paused ? video.play() : video.pause();
}
media 与 MSE 的联系
前面简单说了一下,HTMLMediaElement 内部的相关属性和事件。但本文并不是为了教大家如何做一个 UI 控制,而是深入到 MSE 那一层,能够做到任意控制视频流 Buffer。虽然,HME 和 MSE 分别位于不同的层级,但是,内部还是有一定的联系。这两者之间联系的主要还是在于 Buffered 的 TimeRange 对象和相关的 track。
在 HME 中,提供了:
media.buffered:获取 HME 中,可播放的 time Range 片段。如果简简单单通过 src 来获取资源,那么,buffered 一般只会返回一个 range,从 0 ~ end of duration。如果,你在获取视频的时候,有涉及 seek 的操作,那么,这个就有有可能会返回多个 range。当然,如果你是通过 MSE 来完成 appendBuffer 的话,那么,这里的 HME.buffered 就和 MSE.SB.buffered 中的内容是完全一致的。
audioTracks/videoTracks: 返回 mediasource 中具体播放的 tracklist。这里的属性和 MSE.SB.audioTracks/videoTracks 一致。
上面这两个属性,就是 HME 和 MSE 的主要联系。不过,videoTracks 里面又有哪些内容可以给前端开发者使用呢?
audioTracks/videoTracks
这里,我们简单介绍一下里面的基本内容:
interface AudioTrackList : EventTarget {
readonly attribute unsigned long length;
getter AudioTrack (unsigned long index);
AudioTrack? getTrackById(DOMString id);
attribute EventHandler onchange;
attribute EventHandler onaddtrack;
attribute EventHandler onremovetrack;
};
interface AudioTrack {
readonly attribute DOMString id;
readonly attribute DOMString kind;
readonly attribute DOMString label;
readonly attribute DOMString language;
attribute boolean enabled;
};
interface VideoTrackList : EventTarget {
readonly attribute unsigned long length;
getter VideoTrack (unsigned long index);
VideoTrack? getTrackById(DOMString id);
readonly attribute long selectedIndex;
attribute EventHandler onchange;
attribute EventHandler onaddtrack;
attribute EventHandler onremovetrack;
};
interface VideoTrack {
readonly attribute DOMString id;
readonly attribute DOMString kind;
readonly attribute DOMString label;
readonly attribute DOMString language;
attribute boolean selected;
};
仔细看一下,AudioTrack 和 VideoTrack 内容其实没啥太大的差别,这两个就一起介绍了。接下来,我们从 List 到 Track 来进行介绍。
List
TrackList 本身是用来存放 mediasource 中的 tracks,里面可能包含不止一个 track。比如,audio 中,可能还分左声道,右声道等等。并且,有些 track 只是作为备用,并没有实际播放出来,这个也会存放在 List 当中。在 List 中获取 track 的方式有两种,一种是直接根据 track 的 id,还有一种是根据序号来获取:
audiolist[0]; // get the first track in the audio track list
audilist.getTrackById('12fncissa1@d3');
不过,说实在的,就算你获取到指定的 track 之后,你也并不能做啥子事情(除了 selected 属性)。顶多是获取相关 track 的信息内容。虽然说是鸡肋,但又有点用。。。
并且,List 上还提供了相关的事件,来对你的 track 操作进行监听。
Track
具体的 track 里面的内容全是一些描述属性,并没有啥可操作的内容。如下:
readonly attribute DOMString id;
readonly attribute DOMString kind;
readonly attribute DOMString label;
readonly attribute DOMString language;
attribute boolean selected;
selected 标识当前 track 是否被使用。该属性,也可以用来被设定。
track.selected = false;
kind 这个字段信息,实际上是从 mediasource,即,音视频源文件中提取出来的,比如,ftyp + moov box 中。主要内容有:
这里,其实看了也没啥用。。。你又不是真的做音视频开发的。过度了解真的有害身心健康。
label 是该 track 上的描述信息。
language字段属性,必须是 BCP 47 格式才行,比如:und 这样的格式。如果,不是 BCP 47 格式的话,则会被当作空值。
业务实践
到这里,相信大家已经对直播流所需要的技术都已经了解,我们接下来主要来实践一下 MSE 在 H5 播放器中具体的应用和实践。H5 播放器所需的流程其实就两个环节:
- websocket 提供原始的直播流。比如,RTMP 的直播流,或者 WS-FLV 的直播流。但是,里面得到的纯流大部分是 flv 格式,我们的 video 是不能直接播放的。这时候,就需要把纯流给 remux/demux 进行转换,生成可播放的 mp4 流。具体转流的过程我这里就介绍了,这不在本篇文章的范围之内。
- MSE 将可播放的流,在底层喂给 Video 进行播放。这一个环节,按理说我们是看不到的,不过可以直接映射到 SourceBuffer 添加 media segment 的环节上来。
这里,大家可能会有点懵,接下来,我们具体用代码来实践一下。
MSE 管理环节
前面已经介绍过,MSE 这里主要做的内容是生成指定 MIME 的 SourceBuffer,得到特定的 SB 后,就可以添加视频流进行播放了。模拟代码为:
// the implementation is as follows
class MSE {
constructor(video) {
this.videoEle = video;
this.mediaSource;
this.tmpBuffer = []; // in order to save video buffer
this.initMSE(); // get the global var of MSE
}
initMSE() {
let mediaSource = this.mediaSource = new MediaSource();
this.videoEle.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', e => {
URL.revokeObjectURL(this.videoEle.src);
});
}
// after getting the mime, then init the specific SB
initSB(mime) {
if (this.mediaSource.readyState === 'open') {
try {
this.SB = this.mediaSource.addSourceBuffer(MIME);
} catch (error) {
console.log(error)
throw new Error("MSE couldn't support the MIME: " + MIME);
}
}
}
// when getting the new video buffer, checking the sb.updating, if it isn't using, append new one
appendSB(buffer) {
this.tmpBuffer.push(buffer);
let sb = this.SB;
if (!sb.updating) {
sb.appendBuffer(this._mergeBuffer(this.tmpBuffer));
this.tmpBuffer = []; // clear the buffer
}
}
// just a cheap function
_mergeBuffer(boxes) {
let boxLength = boxes.reduce((pre, val) => {
return pre + val.byteLength;
}, 0);
let buffer = new Uint8Array(boxLength);
let offset = 0;
boxes.forEach(box => {
buffer.set(box, offset);
offset += box.byteLength;
});
return buffer;
}
}
// bind the video.src with MSE
let MSEController = new MSE(video);
ws.initMsg(MIME=>{
MSEController.initSB(MIME);
});
ws.laterMsg(buffer=>{
MSEController.appendSB(buffer);
})
主要,执行就是:
- let MSEController = new MSE(video): 将 MSE 和 video.src 绑定在一起
- MSEController.initSB(MIME);: 添加指定的 sourceBuffer
- MSEController.appendSB(buffer);: 当获得数据时,将 Buffer 添加到指定的 SB 中。
这里,关于 MSE 和 Video 的基本内容已经介绍完了。从 MSE 底层只是到 Video 基本流程控制,我们都已经完全过了一遍,剩下的内容主要就是格式之间的转换和 Buffer 相关处理。