オフライン ストリーミングを使用する PWA

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

プログレッシブ ウェブアプリは、これまでネイティブ アプリ向けに確保されていた多くの機能をウェブに導入します。PWA に関連する最も顕著な機能の一つは、オフライン エクスペリエンスです。

さらに良いのはオフライン ストリーミング メディア エクスペリエンスです。これはいくつかの方法でユーザーに提供できる拡張機能です。ただし、メディア ファイルが非常に大きいという、特有の問題があります。たとえば、次のように疑問に思うかもしれません。

  • サイズの大きな動画ファイルをダウンロードして保存するにはどうすればよいですか?
  • どのようにユーザーに提示すればよいでしょうか。

この記事では、これらの質問への回答について説明します。また、機能フレームワークやプレゼンテーション フレームワークを使用せずにオフライン ストリーミング メディア エクスペリエンスを実装する方法の実例を示す、Kino デモ PWA も参考にしています。以下の例は主に学習を目的としています。これらの機能を提供するには、ほとんどの場合、既存のメディア フレームワークを使用する必要があるためです。

独自の開発に適したビジネスケースがない限り、オフライン ストリーミングを使用した PWA の構築には課題があります。この記事では、ユーザーに高品質のオフライン メディア エクスペリエンスを提供するための API と手法について説明します。

大容量のメディア ファイルをダウンロードして保存する

プログレッシブ ウェブアプリでは通常、オフライン エクスペリエンスを提供するために必要なアセット(ドキュメント、スタイルシート、画像など)のダウンロードと保存の両方に、便利な 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 を使用してデータのチャンクを読み取ることができるようになったため、それらも保存する必要があります。名前、説明、ランタイムの長さ、カテゴリなど、メディア ファイルに関連付けられたメタデータが多数存在する可能性があります。

1 つのメディア ファイルだけを保存するのではなく、構造化されたオブジェクトを格納します。メディア ファイルは、そのプロパティの 1 つにすぎません。

この場合、IndexedDB API がメディアデータとメタデータの両方を保存するための優れたソリューションとなります。大量のバイナリデータを簡単に保持でき、非常に高速なデータ検索を可能にするインデックスも用意されています。

Fetch API を使用したメディア ファイルのダウンロード

Kino と名付けたデモ PWA では、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 の書き込み頻度が低下し、書き込みパフォーマンスが大幅に向上しています。

オフライン ストレージからメディア ファイルを提供する

メディア ファイルをダウンロードしたら、ネットワークからファイルを取得するのではなく、Service Worker で 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 の Service Worker ソースコード をご覧いただくと、実際のアプリケーションで IndexedDB からファイルデータを読み取ってストリームを構築する方法を確認できます。

その他の考慮事項

これまでの大きな課題がなくなったところで、動画アプリケーションに便利な機能を追加できるようになりました。Kino デモ PWA には次のような機能が用意されています。

  • Media Session API の統合。ユーザーは、専用のハードウェア メディアキーまたはメディア通知のポップアップを使用して、メディアの再生を制御できます。
  • 字幕やポスター画像など、メディア ファイルに関連付けられたその他のアセットを、旧式の Cache API を使用してキャッシュに保存する。
  • アプリ内での動画ストリーム(DASH、HLS)のダウンロードをサポートします。ストリーム マニフェストは通常、ビットレートの異なる複数のソースを宣言します。そのため、マニフェスト ファイルを変換し、オフラインで視聴するために保存する前に 1 つのメディア バージョンのみをダウンロードする必要があります。

次は、音声と動画のプリロードによる高速再生について説明します。