PWA mit Offlinestreaming

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Progressive Web-Apps bieten im Web viele Funktionen, die zuvor nativen Anwendungen vorbehalten waren. Eine der auffälligsten Funktionen von PWAs ist die Offlinenutzung.

Noch besser wäre ein Offline-Streaming von Medien, das Sie Ihren Nutzern auf verschiedene Arten bieten können. Dies stellt jedoch ein wirklich einzigartiges Problem dar, da Mediendateien sehr groß sein können. Sie könnten sich also fragen:

  • Wie lade ich eine große Videodatei herunter und speichere sie?
  • Und wie stelle ich sie dem Nutzer zur Verfügung?

In diesem Artikel finden Sie Antworten auf diese Fragen. Außerdem wird auf die von uns erstellte Kino-Demo-PWA verwiesen. Sie enthält praktische Beispiele dafür, wie Sie eine Offline-Streaming-Medienerfahrung ohne funktionale oder Präsentationsframeworks implementieren können. Die folgenden Beispiele dienen hauptsächlich Informationszwecken, da Sie in den meisten Fällen wahrscheinlich eines der vorhandenen Medienrahmen verwenden sollten, um diese Funktionen bereitzustellen.

Sofern Sie keinen guten Business Case für die Entwicklung eines eigenen haben, bringt das Erstellen einer PWA mit Offlinestreaming einige Herausforderungen mit sich. In diesem Artikel erfahren Sie mehr über die APIs und Techniken, mit denen Sie Nutzern eine qualitativ hochwertige Offline-Mediennutzung ermöglichen.

Große Mediendatei herunterladen und speichern

Progressive Web-Apps verwenden in der Regel die praktische Cache API zum Herunterladen und Speichern der Assets, die für die Offlinenutzung erforderlich sind, z. B. Dokumente, Stylesheets und Bilder.

Hier ist ein einfaches Beispiel für die Verwendung der Cache API in einem Service Worker:

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',
      ]);
    })
  );
});

Obwohl das obige Beispiel technisch funktioniert, hat die Verwendung der Cache API einige Einschränkungen, sodass die Verwendung mit großen Dateien nicht praktikabel ist.

Die Cache API kann beispielsweise Folgendes nicht:

  • Ermöglicht das einfache Anhalten und Fortsetzen von Downloads
  • Verfolgen des Fortschritts von Downloads
  • Eine Möglichkeit bieten, auf HTTP-Bereichsanfragen ordnungsgemäß zu reagieren

All diese Probleme stellen erhebliche Einschränkungen bei jeder Videoanwendung dar. Sehen wir uns einige andere Optionen an, die möglicherweise besser geeignet sind.

Heutzutage ist die Fetch API eine browserübergreifende Möglichkeit, asynchron auf Remote-Dateien zuzugreifen. In unserem Anwendungsfall können Sie auf große Videodateien als Stream zugreifen und sie mithilfe einer HTTP-Bereichsanfrage inkrementell als Blöcke speichern.

Da Sie die Datenblöcke nun mit der Fetch API lesen können, müssen Sie diese auch speichern. Wahrscheinlich sind mit Ihrer Mediendatei viele Metadaten verknüpft, z. B. Name, Beschreibung, Laufzeitlänge, Kategorie usw.

Sie speichern nicht nur eine Mediendatei, sondern ein strukturiertes Objekt, und die Mediendatei ist nur eines ihrer Eigenschaften.

In diesem Fall bietet die IndexedDB API eine hervorragende Lösung zum Speichern von Mediendaten und Metadaten. Sie kann problemlos große Mengen an Binärdaten enthalten und bietet Indexe, mit denen Sie sehr schnelle Datensuchen ausführen können.

Mediendateien mit der Fetch API herunterladen

In unserer Demo-PWA mit dem Namen Kino haben wir einige interessante Funktionen rund um die Fetch API entwickelt. Der Quellcode ist öffentlich. Sie können ihn sich also gern ansehen.

  • Die Möglichkeit, unvollständige Downloads zu pausieren und fortzusetzen
  • Ein benutzerdefinierter Puffer zum Speichern von Datenblöcken in der Datenbank.

Bevor wir zeigen, wie diese Funktionen implementiert werden, wiederholen wir kurz, wie sich mit der Fetch API Dateien herunterladen lassen.

/**
 * 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);
}

Sehen Sie, dass await reader.read() in einer Schleife ist? So erhalten Sie Datenblöcke aus einem lesbaren Stream aus dem Netzwerk und empfangen sie. Überlegen Sie, wie nützlich dies ist: Sie können mit der Verarbeitung Ihrer Daten beginnen, noch bevor sie alle aus dem Netzwerk eintreffen.

Downloads fortsetzen

Wenn ein Download angehalten oder unterbrochen wird, werden die eingegangenen Datenblöcke sicher in einer IndexedDB-Datenbank gespeichert. Anschließend können Sie in Ihrer App eine Schaltfläche einblenden lassen, um den Download fortzusetzen. Da der Kino-Demo-PWA-Server HTTP-Bereichsanfragen unterstützt, ist das Fortsetzen eines Downloads etwas unkompliziert:

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);
}

Benutzerdefinierter Schreibpuffer für IndexedDB

Auf dem Papier ist das Schreiben von dataChunk-Werten in eine IndexedDB-Datenbank einfach. Diese Werte sind bereits ArrayBuffer-Instanzen, die direkt in IndexedDB gespeichert werden können, sodass wir einfach ein Objekt einer geeigneten Form erstellen und speichern können.

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 = () => { ... }

Obwohl dieser Ansatz funktioniert, werden Sie wahrscheinlich feststellen, dass Ihre IndexedDB-Schreibvorgänge deutlich langsamer sind als Ihr Download. Dies liegt nicht daran, dass IndexedDB-Schreibvorgänge langsam sind, sondern daran, dass wir viel Transaktionsaufwand hinzufügen, indem wir für jeden Datenblock, den wir von einem Netzwerk erhalten, eine neue Transaktion erstellen.

Die heruntergeladenen Blöcke können ziemlich klein sein und vom Stream in kurzer Abfolge ausgegeben werden. Sie müssen die Rate der IndexedDB-Schreibvorgänge begrenzen. In der Kino-Demo-PWA implementieren wir dafür einen intermediary (Schreibpuffer).

Wenn Datenblöcke aus dem Netzwerk eingehen, hängen sie zuerst an unseren Puffer an. Wenn die eingehenden Daten nicht passen, wird der vollständige Puffer in die Datenbank geleert und gelöscht, bevor die restlichen Daten angefügt werden. Dadurch erfolgen unsere IndexedDB-Schreibvorgänge seltener, was zu einer deutlich verbesserten Schreibleistung führt.

Mediendatei aus einem Offlinespeicher bereitstellen

Nachdem Sie eine Mediendatei heruntergeladen haben, möchten Sie wahrscheinlich, dass Ihr Service Worker diese aus IndexedDB bereitstellt, anstatt sie aus dem Netzwerk abzurufen.

/**
 * 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);

Was müssen Sie also in getVideoResponse() tun?

  • Die Methode event.respondWith() erwartet ein Response-Objekt als Parameter.

  • Der Response()-Konstruktor teilt uns mit, dass es mehrere Objekttypen gibt, die wir zum Instanziieren eines Response-Objekts verwenden könnten: Blob, BufferSource, ReadableStream und mehr.

  • Wir benötigen ein Objekt, das nicht alle seine Daten im Arbeitsspeicher enthält, also wählen wir wahrscheinlich ReadableStream aus.

Da es sich um große Dateien handelt und wir zulassen möchten, dass Browser nur den Teil der Datei anfordern können, den sie derzeit benötigen, mussten wir einige grundlegende Unterstützung für HTTP-Bereichsanfragen implementieren.

/**
 * 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;

In der Kino-Demo-PWA des Service Worker-Quellcodes können Sie sich ansehen, wie wir Dateidaten aus IndexedDB lesen und einen Stream in einer echten Anwendung erstellen.

Weitere Aspekte

Nachdem die größten Hindernisse im Weg stehen, können Sie nun damit beginnen, Ihrer Videoanwendung einige praktische Funktionen hinzuzufügen. Hier sind einige Beispiele für Funktionen, die in der Kino-Demo-PWA zu finden sind:

  • Media Session API-Integration, mit der Nutzer die Medienwiedergabe über dedizierte Hardware-Medienschlüssel oder über Pop-ups für Medienbenachrichtigungen steuern können
  • Caching von anderen Assets, die mit Mediendateien verknüpft sind, z. B. Untertitel und Posterbilder, mit der guten alten Cache API.
  • Unterstützung für den Download von Videostreams (DASH, HLS) in der App. Da Streammanifeste mehrere Quellen mit unterschiedlichen Bitraten angeben, müssen Sie die Manifestdatei umwandeln und nur eine Medienversion herunterladen, bevor Sie sie für die Offlinewiedergabe speichern.

Als Nächstes erfahren Sie mehr über Schnelle Wiedergabe mit vorab geladenem Audio und Video.