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

Derek Herman 氏
Derek Herman
ヤロスラフ・ポラコビッチ
Jaroslav Polakoviwbr 氏

プログレッシブ ウェブアプリは、これまでネイティブ アプリケーション用に予約されていた多くの機能をウェブにもたらします。PWA に関する最も重要な機能の一つは、オフライン エクスペリエンスです。

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

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

この記事では、Google が作成した 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;

IndexedDB からファイルデータを読み取り、実際のアプリケーションでストリームを構築する方法については、Kino のデモ PWA Service Worker のソースコードをご覧ください。

その他の考慮事項

主な障害を克服したら、動画アプリに便利な機能を追加してみましょう。Kino デモ PWA の機能の例をいくつか示します。

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

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