发布时间:2021 年 7 月 5 日
渐进式 Web 应用将许多以前仅供原生应用使用的功能引入了 Web。与 PWA 关联的最突出功能之一是离线体验。
如果能提供离线流媒体体验,那就更好了。您可以通过多种不同的方式向用户提供此增强功能。不过,这会带来一个非常独特的问题,即媒体文件可能非常大。因此,您可能会问:
- 如何下载和存储大型视频文件?
- 以及如何向用户提供该内容?
在本文中,我们将讨论这些问题的答案,同时参考我们构建的 Kino 演示 PWA,该 PWA 为您提供了有关如何实现离线流式传输媒体体验的实用示例,而无需使用任何功能或展示框架。以下示例主要用于教学目的,因为在大多数情况下,您可能应该使用现有的某个媒体框架来提供这些功能。
除非您有充分的理由自行开发,否则构建具有离线流式传输功能的 PWA 会面临一些挑战。本文将介绍用于为用户提供优质离线媒体体验的 API 和技术。
下载和存储大型媒体文件
渐进式 Web 应用通常使用便捷的 Cache API 来下载和存储提供离线体验所需的资源:文档、样式表、图片等。
以下是在 Service Worker 中使用 Cache API 的基本示例:
const cacheStorageName = 'v1';
this.addEventListener('install', function(event) {
event.waitUntil(
caches.open(cacheStorageName).then(function(cache) {
return cache.addAll([
'index.html',
'style.css',
'scripts.js',
// Don't do this.
'very-large-video.mp4',
]);
})
);
});
虽然上述示例在技术上可行,但使用 Cache API 有一些限制,使其不适合用于处理大型文件。
例如,Cache API 不会:
- 让您可以轻松暂停和恢复下载
- 让您跟踪下载进度
- 提供一种正确响应 HTTP 范围请求的方法
对于任何视频应用而言,这些问题都是相当严重的限制。 我们来了解一下其他可能更适合的方案。
如今,Fetch API 是一种跨浏览器异步访问远程文件的方式。在我们的使用情形中,它允许您以流的形式访问大型视频文件,并使用 HTTP 范围请求以块的形式增量存储这些文件。
现在,您可以使用 Fetch API 读取数据块,接下来还需要存储这些数据块。您的媒体文件可能关联了许多元数据,例如名称、说明、播放时长、类别等。
您存储的不仅仅是一个媒体文件,而是一个结构化对象,媒体文件只是该对象的一个属性。
在这种情况下,IndexedDB API 提供了一个出色的解决方案,可用于存储媒体数据和元数据。它可以轻松存储大量二进制数据,还提供索引,让您能够非常快速地查找数据。
使用 Fetch API 下载媒体文件
我们在演示 PWA(名为 Kino)中围绕 Fetch API 构建了一些有趣的功能,源代码是公开的,欢迎随时查看。
- 能够暂停和恢复未完成的下载。
- 用于在数据库中存储数据块的自定义缓冲区。
在展示这些功能的实现方式之前,我们先简要回顾一下如何使用 Fetch API 下载文件。
/**
* Downloads a single file.
*
* @param {string} url URL of the file to be downloaded.
*/
async function downloadFile(url) {
const response = await fetch(url);
const reader = response.body.getReader();
do {
const { done, dataChunk } = await reader.read();
// Store the `dataChunk` to IndexedDB.
} while (!done);
}
请注意,await reader.read() 是否位于循环中?这样,您就可以在可读数据流从网络到达时接收其中的数据块。请考虑一下这有多么实用:即使数据尚未全部从网络到达,您也可以开始处理数据。
继续下载
当下载暂停或中断时,已到达的数据块将安全地存储在 IndexedDB 数据库中。然后,您可以在应用中显示一个按钮,用于恢复下载。由于 Kino 演示 PWA 服务器支持 HTTP 范围请求,因此恢复下载相对简单:
async downloadFile() {
// this.currentFileMeta contains data from IndexedDB.
const { bytesDownloaded, url, downloadUrl } = this.currentFileMeta;
const fetchOpts = {};
// If we already have some data downloaded,
// request everything from that position on.
if (bytesDownloaded) {
fetchOpts.headers = {
Range: `bytes=${bytesDownloaded}-`,
};
}
const response = await fetch(downloadUrl, fetchOpts);
const reader = response.body.getReader();
let dataChunk;
do {
dataChunk = await reader.read();
if (!dataChunk.done) this.buffer.add(dataChunk.value);
} while (!dataChunk.done && !this.paused);
}
IndexedDB 的自定义写入缓冲区
从理论上讲,将 dataChunk 值写入 IndexedDB 数据库的过程很简单。这些值已经是 ArrayBuffer 实例,可以直接存储在 IndexedDB 中,因此我们只需创建一个形状合适的对象并存储它即可。
const dataItem = {
url: fileUrl,
rangeStart: dataStartByte,
rangeEnd: dataEndByte,
data: dataChunk,
}
// Name of the store that will hold your data.
const storeName = 'fileChunksStorage'
// `db` is an instance of `IDBDatabase`.
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const putRequest = store.put(data);
putRequest.onsuccess = () => { ... }
虽然这种方法可行,但您可能会发现 IndexedDB 写入速度比下载速度慢得多。这并不是因为 IndexedDB 写入速度慢,而是因为我们为从网络接收的每个数据块创建了一个新事务,从而增加了大量事务开销。
下载的块可能非常小,并且可以由流快速连续地发出。您需要限制 IndexedDB 写入速率。在 Kino 演示 PWA 中,我们通过实现中间写入缓冲区来做到这一点。
当数据块从网络到达时,我们首先将其附加到缓冲区。如果传入的数据不适合,我们会将整个缓冲区刷新到数据库中,并在附加剩余数据之前清除缓冲区。这样一来,IndexedDB 写入的频率就会降低,从而显著提升写入性能。
从离线存储空间传送媒体文件
下载媒体文件后,您可能希望服务工作线程从 IndexedDB 提供该文件,而不是从网络中提取该文件。
/**
* The main service worker fetch handler.
*
* @param {FetchEvent} event Fetch event.
*/
const fetchHandler = async (event) => {
const getResponse = async () => {
// Omitted Cache API code used to serve static assets.
const videoResponse = await getVideoResponse(event);
if (videoResponse) return videoResponse;
// Fallback to network.
return fetch(event.request);
};
event.respondWith(getResponse());
};
self.addEventListener('fetch', fetchHandler);
那么,您需要在 getVideoResponse() 中执行什么操作?
event.respondWith()方法需要一个Response对象作为参数。Response() 构造函数表明,我们可以使用多种类型的对象来实例化
Response对象:Blob、BufferSource、ReadableStream等。我们需要一个不会将所有数据都保存在内存中的对象,因此我们可能需要选择
ReadableStream。
此外,由于我们要处理的是大型文件,并且希望允许浏览器仅请求当前需要的文件部分,因此我们需要实现对 HTTP 范围请求的一些基本支持。
/**
* Respond to a request to fetch offline video file and construct a response
* stream.
*
* Includes support for `Range` requests.
*
* @param {Request} request Request object.
* @param {Object} fileMeta File meta object.
*
* @returns {Response} Response object.
*/
const getVideoResponse = (request, fileMeta) => {
const rangeRequest = request.headers.get('range') || '';
const byteRanges = rangeRequest.match(/bytes=(?<from>[0-9]+)?-(?<to>[0-9]+)?/);
// Using the optional chaining here to access properties of
// possibly nullish objects.
const rangeFrom = Number(byteRanges?.groups?.from || 0);
const rangeTo = Number(byteRanges?.groups?.to || fileMeta.bytesTotal - 1);
// Omitting implementation for brevity.
const streamSource = {
pull(controller) {
// Read file data here and call `controller.enqueue`
// with every retrieved chunk, then `controller.close`
// once all data is read.
}
}
const stream = new ReadableStream(streamSource);
// Make sure to set proper headers when supporting range requests.
const responseOpts = {
status: rangeRequest ? 206 : 200,
statusText: rangeRequest ? 'Partial Content' : 'OK',
headers: {
'Accept-Ranges': 'bytes',
'Content-Length': rangeTo - rangeFrom + 1,
},
};
if (rangeRequest) {
responseOpts.headers['Content-Range'] = `bytes ${rangeFrom}-${rangeTo}/${fileMeta.bytesTotal}`;
}
const response = new Response(stream, responseOpts);
return response;
您可以随时查看 Kino 演示 PWA 服务工作线程源代码,了解我们如何在实际应用中从 IndexedDB 读取文件数据并构建流。
其他注意事项
现在,主要障碍已经清除,您可以开始向视频应用添加一些锦上添花的功能了。以下是您会在 Kino 演示 PWA 中看到的一些功能示例:
- 媒体会话 API 集成,可让用户使用专用硬件媒体键或通过媒体通知弹出式窗口控制媒体播放。
- 使用旧版 Cache API 缓存与媒体文件关联的其他资源,例如字幕和海报图片。
- 支持在应用内下载视频流(DASH、HLS)。由于流清单通常会声明多个不同比特率的来源,因此您需要转换清单文件,并且仅下载一个媒体版本,然后将其存储以供离线观看。
接下来,您将了解通过音频和视频预加载实现快速播放。