PWA 离线流媒体

渐进式 Web 应用 将许多以前为原生应用保留的功能带到了 Web 上。与 PWA 关联的最突出的功能之一是离线体验。

更好的体验是离线流媒体体验,这是您可以以几种不同的方式为用户提供的增强功能。但是,这会产生一个真正独特的问题 - 媒体文件可能非常大。因此您可能会问

  • 如何下载和存储大型视频文件?
  • 以及如何将其提供给用户?

在本文中,我们将讨论这些问题的答案,同时参考我们构建的 Kino 演示 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 中围绕 Fetch API 构建了一些有趣的功能,我们将其命名为 Kino - 源代码 是公开的,请随时查看。

  • 暂停和恢复未完成下载的功能。
  • 用于在数据库中存储数据块的自定义缓冲区。

在展示如何实现这些功能之前,我们首先快速回顾一下如何使用 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 对象:BlobBufferSourceReadableStream 等。

  • 我们需要一个不将其所有数据都保存在内存中的对象,因此我们可能需要选择 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 中找到的一些功能示例

  • Media Session API 集成,允许您的用户使用专用硬件媒体键或从媒体通知弹出窗口控制媒体播放。
  • 使用旧的 Cache API 缓存与媒体文件关联的其他资源,如字幕和海报图像。
  • 支持在应用程序内下载视频流(DASH、HLS)。由于流清单通常声明多种不同比特率的源,因此您需要转换清单文件,并且仅下载一个媒体版本,然后将其存储以供离线观看。

接下来,您将了解 通过音频和视频预加载实现快速播放