PWA mit Offlinestreaming

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Progressive Web-Apps bieten viele Funktionen, die bisher nur für native Apps verfügbar waren. Eine der wichtigsten Funktionen von PWAs ist die Offlinenutzung.

Noch besser wäre ein Offline-Streaming von Medien. Diese Funktion können Sie Ihren Nutzern auf verschiedene Arten anbieten. Das führt jedoch zu einem ganz besonderen Problem: Mediendateien können sehr groß sein. Sie fragen sich vielleicht:

  • Wie kann ich eine große Videodatei herunterladen und speichern?
  • Und wie kann ich sie dem Nutzer präsentieren?

In diesem Artikel gehen wir auf diese Fragen ein und beziehen uns dabei auf die von uns erstellte Demo-PWA Kino, die praktische Beispiele dafür bietet, wie Sie Medien ohne Funktions- oder Präsentations-Frameworks offline streamen können. Die folgenden Beispiele dienen hauptsächlich zu Bildungszwecken, da Sie in den meisten Fällen eines der vorhandenen Media-Frameworks verwenden sollten, um diese Funktionen bereitzustellen.

Sofern Sie keinen guten Business Case für die Entwicklung einer eigenen PWA haben, ist das Erstellen einer PWA mit Offline-Streaming mit einigen Herausforderungen verbunden. In diesem Artikel erfahren Sie mehr über die APIs und Techniken, mit denen Nutzern hochwertige Offlinemedien zur Verfügung gestellt werden.

Große Mediendateien herunterladen und speichern

Progressive Web-Apps verwenden in der Regel die praktische Cache API, um die für die Offlinenutzung erforderlichen Assets herunterzuladen und zu speichern: Dokumente, Stylesheets, Bilder usw.

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

Das Beispiel oben funktioniert zwar technisch, die Verwendung der Cache API hat jedoch mehrere Einschränkungen, die ihre Verwendung bei großen Dateien unpraktisch machen.

Die Cache API bietet beispielsweise folgende Vorteile:

  • Sie können Downloads ganz einfach pausieren und fortsetzen.
  • Sie können den Fortschritt von Downloads verfolgen.
  • Sie müssen eine Möglichkeit bieten, richtig auf HTTP-Bereichsanfragen zu reagieren.

All diese Probleme sind ziemlich schwerwiegende Einschränkungen für jede Videoanwendung. Sehen wir uns einige andere Optionen an, die möglicherweise besser geeignet sind.

Heutzutage bietet die Fetch API eine plattformübergreifende Möglichkeit, asynchron auf Remotedateien zuzugreifen. In unserem Anwendungsfall können Sie mithilfe einer HTTP-Bereichsanfrage auf große Videodateien als Stream zugreifen und sie inkrementell als Chunks speichern.

Nachdem Sie die Datenblöcke mit der Fetch API gelesen haben, müssen Sie sie auch speichern. Es ist wahrscheinlich, dass deiner Mediendatei eine Reihe von Metadaten zugeordnet sind, z. B. Name, Beschreibung, Laufzeit und Kategorie.

Sie speichern nicht nur eine Mediendatei, sondern ein strukturiertes Objekt, bei dem die Mediendatei nur eine der Eigenschaften ist.

In diesem Fall bietet die IndexedDB API eine hervorragende Lösung, um sowohl die Mediendaten als auch die Metadaten zu speichern. Es kann problemlos große Mengen an Binärdaten aufnehmen und bietet auch Indexe, mit denen Sie sehr schnell Datenabfragen durchführen können.

Mediendateien mit der Fetch API herunterladen

In unserer Demo-PWA namens Kino haben wir einige interessante Funktionen rund um die Fetch API entwickelt. Der Quellcode ist öffentlich und kann jederzeit eingesehen werden.

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

Bevor wir zeigen, wie diese Funktionen implementiert werden, gehen wir kurz darauf ein, wie Sie mit der Fetch API Dateien herunterladen können.

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

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

Downloads fortsetzen

Wenn ein Download pausiert oder unterbrochen wird, werden die empfangenen Datenblöcke sicher in einer IndexedDB-Datenbank gespeichert. Sie können dann eine Schaltfläche anzeigen, um einen Download in Ihrer Anwendung fortzusetzen. Da der PWA-Server der Kino-Demo HTTP-Bereichsanfragen unterstützt, ist das Fortsetzen eines Downloads recht einfach:

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 ganz einfach. Diese Werte sind bereits ArrayBuffer-Instanzen, die direkt in IndexedDB gespeichert werden können. Wir können also einfach ein Objekt mit der entsprechenden Form erstellen und speichern.

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

Dieser Ansatz funktioniert zwar, aber Sie werden wahrscheinlich feststellen, dass Ihre IndexedDB-Schreibvorgänge deutlich langsamer sind als der Download. Das liegt nicht daran, dass IndexedDB-Schreibvorgänge langsam sind, sondern daran, dass wir durch das Erstellen einer neuen Transaktion für jeden Datenblock, den wir von einem Netzwerk erhalten, einen großen Transaktionsoverhead hinzufügen.

Die heruntergeladenen Chunks können recht klein sein und vom Stream in schneller Folge gesendet werden. Sie müssen die Rate der IndexedDB-Schreibvorgänge begrenzen. In der Kino-Demo-PWA wird dies durch die Implementierung eines Zwischenspeichers für Schreibvorgänge erreicht.

Wenn Datenblöcke aus dem Netzwerk eintreffen, werden sie zuerst an unseren Puffer angehängt. Wenn die eingehenden Daten nicht passen, leeren wir den gesamten Puffer in die Datenbank und löschen ihn, bevor wir den Rest der Daten anhängen. Daher sind unsere IndexedDB-Schreibvorgänge seltener, was zu einer deutlich verbesserten Schreibleistung führt.

Mediendatei aus dem Offlinespeicher bereitstellen

Nachdem Sie eine Mediendatei heruntergeladen haben, möchten Sie wahrscheinlich, dass Ihr Service Worker sie aus IndexedDB bereitstellt, anstatt die Datei 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 Konstruktor Response() gibt an, dass es mehrere Arten von Objekten gibt, mit denen wir ein Response-Objekt instanziieren können: Blob, BufferSource, ReadableStream und mehr.

  • Wir benötigen ein Objekt, das nicht alle Daten im Arbeitsspeicher speichert. Daher sollten wir wahrscheinlich die ReadableStream auswählen.

Da es sich um große Dateien handelt und wir Browsern nur den Teil der Datei anfordern lassen wollten, den sie gerade benötigen, mussten wir eine 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;

Im Service Worker-Quellcode der Kino-Demo-PWA erfahren Sie, wie wir Dateidaten aus IndexedDB lesen und einen Stream in einer echten Anwendung erstellen.

Weitere Hinweise

Nachdem Sie die wichtigsten Hindernisse aus dem Weg geräumt haben, können Sie Ihrer Videoanwendung jetzt einige nützliche Funktionen hinzufügen. Hier sind einige Beispiele für Funktionen, die Sie in der Demo-PWA von Kino finden:

  • Einbindung der Media Session API, mit der Nutzer die Medienwiedergabe über spezielle Hardware-Medientasten oder über Pop-ups für Medienbenachrichtigungen steuern können.
  • Caching anderer mit den Mediendateien verknüpfter Assets wie Untertitel und Posterbilder mit der guten alten Cache API
  • Unterstützung für den Download von Videostreams (DASH, HLS) in der App. Da in Stream-Manifesten in der Regel mehrere Quellen mit unterschiedlichen Bitraten deklariert werden, musst du die Manifestdatei transformieren und nur eine Medienversion herunterladen, bevor du sie zur Offlinewiedergabe speicherst.

Als Nächstes geht es um die schnelle Wiedergabe mit Audio- und Video-Preload.