在没有 Streams API 之前,我们必须在播放视频之前下载完整的视频文件。
但是在有 Streams API 之后, 我们可以边下载视频,边观看视频,就像我们现在在视频网站中观看视频一样。
基于 xhr 播放视频
在 XHR 中,我们要观看视频,首先要等待整个视频文件都下载完成,然后把整个视频转换为 Blob URL 才能播放这个视频。
首先。我们先用 XHR 创建这个例子。
第一步先创建一个简单的 HTML 文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body></body>
</html>
我们利用的是 MediaSource 来进行视频的加载,所以,我们要先判断视频能不能加载当前视频。
const mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
if ("MediaSource" in window && MediaSource.isTypeSupported(mimeCodec)) {
// 进行操作
} else {
throw new Error(`Unsupported MIME type or codec: ${mimeCodec}`);
}
我们在 HTML 中再创建一个 video 标签
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<video controls id='video'>
</body>
</html>
创建一个简单的 xhr 请求,用于下载视频。
function xhr(url, cb) {
const xhr = new XMLHttpRequest();
xhr.open("get", url);
xhr.responseType = "arraybuffer";
xhr.onload = function () {
cb(xhr.response);
};
xhr.send();
}
接下来我们应该创建一个 MediaSource 来用于加载视频
function sourceOpen(_) {
//console.log(this.readyState); // open
var mediaSource = this;
var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
xhr(assetURL, function (buf) {
sourceBuffer.addEventListener("updateend", function (_) {
mediaSource.endOfStream();
video.play();
//console.log(mediaSource.readyState); // ended
});
sourceBuffer.appendBuffer(buf);
});
}
const mediaSource = new MediaSource();
//console.log(mediaSource.readyState); // closed
video.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener("sourceopen", sourceOpen);
上面的例子是当 mediaSource 已经被 video 标签加载之后,进行视频资源的下载。
下载完成之后,调用 mediaSource.endOfStream() 方法进行视频的加载完成广播。
sourceBuffer.appendBuffer(buf) 是加载我们在 xhr 拿到视频的 arrayBuffer。
完整代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<video controls></video>
<script>
var video = document.querySelector("video");
var assetURL = "frag_bunny.mp4";
// Need to be specific for Blink regarding codecs
// ./mp4info frag_bunny.mp4 | grep Codec
var mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
if (
"MediaSource" in window &&
MediaSource.isTypeSupported(mimeCodec)
) {
var mediaSource = new MediaSource();
//console.log(mediaSource.readyState); // closed
video.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener("sourceopen", sourceOpen);
} else {
console.error("Unsupported MIME type or codec: ", mimeCodec);
}
function sourceOpen(_) {
//console.log(this.readyState); // open
var mediaSource = this;
var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
fetchAB(assetURL, function (buf) {
sourceBuffer.addEventListener("updateend", function (_) {
mediaSource.endOfStream();
video.play();
//console.log(mediaSource.readyState); // ended
});
sourceBuffer.appendBuffer(buf);
});
}
function fetchAB(url, cb) {
console.log(url);
var xhr = new XMLHttpRequest();
xhr.open("get", url);
xhr.responseType = "arraybuffer";
xhr.onload = function () {
cb(xhr.response);
};
xhr.send();
}
</script>
</body>
</html>
从例子的代码里可以看到,我们是有几个步骤
-
创建 MediaSource 实例
-
创建 ObjectURL 来加载 MediaSource
-
通过 xhr 把整个视频给下载完成,拿到 arrayBuffer
-
调用 mediaSource.endOfStream() 通知加载完成
-
播放视频
在这几个步骤中,最耗时间的是下载视频,我们要下载整个视频,意味着如果视频很大,或者网络不好,我们无法有很长一段时间无法看到这个视频,这样的体验相当不好。
基于 fetch 播放视频
我们将使用 fatcher来进行 fetch 请求。
跟 xhr 不一样的地方,我们在页面加载之后,立即请求下载这个视频。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<!--使用 fatcher 进行 fetch 请求 -->
<script src="https://cdn.jsdelivr.net/npm/fatcher/dist/fatcher.min.js"></script>
<!--查看下载进度 -->
<script src="https://cdn.jsdelivr.net/npm/@fatcherjs/middleware-progress/dist/progress.min.js"></script>
</head>
<body>
<video id="video" controls />
<script>
(async function () {
let { data: stream } = await Fatcher.fatcher({
url: `https://nickdesaulniers.github.io/netfix/demo/frag_bunny.mp4?t=${Date.now()}`,
middlewares: [
// 用于查看下载进度
FatcherMiddlewareProgress.progress({
onDownloadProgress(loaded, total) {
console.log(
"已经加载数据大小",
loaded,
"总共数据大小",
total,
"当前进度",
`${((loaded / total) * 100).toFixed(2)}%`
);
},
}),
],
});
})();
</script>
</body>
</html>
我们通过请求拿到一段 ReadableStream, 这段 ReadableStream 就是 这个视频的 arrayBuffer。 但是这段 arrayBuffer 不是一次性能拿到,而是会一段一段的推送过来。
我们使用了一个 FatcherMiddleware 来查看我们 ReadableStream 的读取进度。
const video = document.querySelector("#video");
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
当获取视频成功之后,我们就像上面 xhr 的例子一样,创建一个 ObjectURL 供 video 标签读取。
上面的操作看上去和 xhr 的流程一样,他有什么特别之处呢?
// 转换流,可以用于转码之类的转换操作
const transformStream = new TransformStream({
transform(chunk, controller) {
// 可以用于转码
controller.enqueue(chunk);
},
});
stream = stream.pipeThrough(transformStream);
这两行代码是把我们拿到的 ReadableStream 交给一个 TransformStream 来进行流式转换,我们可以在转换流中进行转码等操作。
相比于 xhr 的完整下载后操作(串行),stream 可以边读取边操作(并行),大大省下处理的时间。
我们也需要监听 MediaSource 被开始读取的事件,在被读取的时候,我们要对流进行读取并加载。
mediaSource.onsourceopen = async () => {
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E, mp4a.40.2"');
let buffers = []; // 缓冲区
await Fatcher.readStreamByChunk(stream, (chunk) => {
if (!sourceBuffer.updating) {
// 加上缓冲区的数据长度
const total = buffers.reduce((t, c) => c.length + t, 0) + chunk.length;
// 新建一个 ArrayBuffer 基座
const arrayBuffer = new ArrayBuffer(total);
// ArrayBuffer 数据操作台
const dataView = new DataView(arrayBuffer);
// 合并缓存区加当前块的数据
for (let i = 0, offset = 0, chunks = [...buffers, chunk];i < chunks.length;i++) {
for (let j = 0; j < chunks[i].length; j++) {
dataView.setUint8(offset, chunks[i][j]);
offset++;
}
}
// 加载视频流
sourceBuffer.appendBuffer(dataView.buffer);
// 清空缓冲区
buffers = [];
return;
}
// 正在加载别的流,加入缓冲区,等下一次一起加载
buffers.push(chunk);
});
};
上面的例子,通过不断的获取 ReadableStream 中的数据,一块一块的供 MediaSource 读取。
因为 SourceBuffer 在 appendBuffer 的时候,并不是立即完成,如果我们对一个进行加载中的 SourceBuffer 调用 appendBuffer,会抛出错误。所以我们要设立缓冲区,只有在空闲的时候才进行加载。
// 加载完成 告诉 mediaSource 已经加载完成了。
sourceBuffer.onupdateend = () => {
console.log("视频加载完成");
mediaSource.endOfStream();
};
视频加载完成后,调用 mediaSource.endOfStream 广播视频完成加载的事件。
完整例子如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/fatcher/dist/fatcher.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@fatcherjs/middleware-progress/dist/progress.min.js"></script>
</head>
<body>
<video id="video" controls></video>
<script>
(async function () {
let { data: stream } = await Fatcher.fatcher({
url: `https://nickdesaulniers.github.io/netfix/demo/frag_bunny.mp4?t=${Date.now()}`,
middlewares: [
// 用于查看下载进度
FatcherMiddlewareProgress.progress({
onDownloadProgress(loaded, total) {
console.log(
"已经加载数据大小",
loaded,
"总共数据大小",
total,
"当前进度",
`${((loaded / total) * 100).toFixed(2)}%`
);
},
}),
],
});
const video = document.querySelector("#video");
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
// 转换流,可以用于转码之类的转换操作
const transformStream = new TransformStream({
transform(chunk, controller) {
// 可以用于转码
controller.enqueue(chunk);
},
});
stream = stream.pipeThrough(transformStream);
mediaSource.onsourceopen = async () => {
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E, mp4a.40.2"');
let buffers = [];
await Fatcher.readStreamByChunk(stream, (chunk) => {
if (!sourceBuffer.updating) {
// 加上缓冲区的数据长度
const total =buffers.reduce((t, c) => c.length + t, 0) + chunk.length;
// 新建一个 ArrayBuffer 基座
const arrayBuffer = new ArrayBuffer(total);
// ArrayBuffer 数据操作台
const dataView = new DataView(arrayBuffer);
// 合并缓存区加当前块的数据
for (let i = 0, offset = 0, chunks = [...buffers, chunk]; i < chunks.length; i++) {
for (let j = 0; j < chunks[i].length; j++) {
dataView.setUint8(offset, chunks[i][j]);
offset++;
}
}
// 加载视频流
sourceBuffer.appendBuffer(dataView.buffer);
// 清空缓冲区
buffers = [];
return;
}
// 正在加载别的流,加入缓冲区,等下一次一起加载
buffers.push(chunk);
});
console.log("视频下载完成");
// 加载完成 告诉 mediaSource 已经加载完成了。
sourceBuffer.onupdateend = () => {
console.log("视频加载完成");
mediaSource.endOfStream();
};
};
})();
</script>
</body>
</html>
从例子的代码里可以看到,我们是有几个步骤
-
获取视频,得到 ReadableStream
-
创建 MediaSource 实例
-
创建 ObjectURL 来加载 MediaSource
-
并行加载 arrayBuffer
-
播放视频
因为我们是按块进行加载 arrayBuffer, 所以我们可以边观看视频边下载视频,相对比 xhr 的完整视频下载,体检大大提升。
总结
基于 xhr 的方式观看视频,需要完整下载视频文件后进行操作后才可以观看,属于串行操作。
基于 Streams 的方式观看视频,只需要获取到 ReadableStream 后就可以开始观看视频,属于并行操作。
从体验上来说,Streams 方式会优于 xhr 的方式,而且 Streams 可以边下载边操作,减少了等待的时间。
虽然 Streams 支持的比较晚,但是相信未来是 Streams 的天下。