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

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

公開日: 2021 年 7 月 5 日

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

さらに、オフラインのストリーミング メディア エクスペリエンスは、ユーザーにさまざまな方法で提供できる機能強化です。ただし、これにはメディア ファイルが非常に大きくなるという固有の問題があります。次のような疑問をお持ちかもしれません。

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

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

独自の 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 のカスタム書き込みバッファ

理論上、IndexedDB データベースに dataChunk 値を書き込むプロセスは単純です。これらの値はすでに 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)のダウンロードのサポート。ストリーム マニフェストでは通常、異なるビットレートの複数のソースが宣言されているため、マニフェスト ファイルを変換し、1 つのメディア バージョンのみをダウンロードしてから、オフライン視聴用に保存する必要があります。

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