PWA mit Offlinestreaming

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Progressive Web-Apps bieten viele Funktionen, die zuvor nativen Anwendungen im Web vorbehalten 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 lade ich eine große Videodatei herunter und speichere sie?
  • Und wie stelle ich sie den Nutzenden bereit?

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

Wenn du keine gute Idee hast, deine eigene zu entwickeln, bringt das Erstellen einer PWA mit Offlinestreaming einige Herausforderungen mit sich. In diesem Artikel erfährst du mehr über die APIs und Techniken, mit denen du Nutzern hochwertige Offlinemedien bieten kannst.

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 obige Beispiel funktioniert zwar technisch, aber die Cache API weist einige Einschränkungen auf, was die Verwendung mit großen Dateien unpraktisch macht.

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. Wahrscheinlich sind mit Ihrer Mediendatei verschiedene Metadaten verknüpft, z. B. Name, Beschreibung, Laufzeitlänge, Kategorie usw.

Sie speichern nicht nur die 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, 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 sich sehr schnelle Datenabfragen durchführen lassen.

Mediendateien mit der Fetch API herunterladen

Wir haben in unserer Demo-PWA, die wir Kino genannt haben, einige interessante Funktionen rund um die Fetch API entwickelt. Der Quellcode ist öffentlich, Sie können ihn sich also jederzeit ansehen.

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

Bevor wir zeigen, wie diese Funktionen implementiert werden, fassen wir zuerst kurz zusammen, wie Sie die Fetch API zum Herunterladen von Dateien verwenden 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 sich await reader.read() in einer Schleife befindet? 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 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. Daher können wir einfach ein Objekt mit einer geeigneten 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 Ihr Download. Das liegt nicht daran, dass IndexedDB-Schreibvorgänge langsam sind, sondern daran, dass wir einen großen Transaktionsoverhead hinzufügen, indem wir für jeden Datenblock, den wir von einem Netzwerk erhalten, eine neue Transaktion erstellen.

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 Response()-Konstruktor teilt uns mit, dass es mehrere Objekttypen gibt, die wir zur Instanziierung eines Response-Objekts verwenden 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;

In der Kino der PWA Quellcode für Service Worker können Sie nachlesen, wie Dateidaten aus IndexedDB gelesen und ein Stream in einer echten Anwendung erstellt wird.

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 der Kino:

  • 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 Schnelle Wiedergabe mit Vorabladen von Audio und Video.