オフライン ストリーミングを使用する 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 を使用したメディア ファイルのダウンロード

デモ用 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 データベースに書き込むプロセスは簡単です。これらの値はすでに IndexedDB に直接保存できる ArrayBuffer インスタンスであるため、適切なシェイプのオブジェクトを作成して保存するだけで済みます。

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 からファイルを提供するように Service Worker を設定します。

/**
 * 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 つのメディア バージョンのみをダウンロードする必要があります。

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