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 Offlinestreaming von Medien, eine Erweiterung, die Sie Ihren Nutzern auf verschiedene Arten bieten können. Dies stellt jedoch ein wirklich einzigartiges Problem dar – 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 werden wir auf die Antworten auf diese Fragen eingehen und auf die von uns entwickelte Kino-Demo-PWA zurückgreifen. Sie bietet praktische Beispiele dafür, wie Sie Offline-Streaming-Medien implementieren können, ohne Funktions- oder Präsentations-Frameworks zu verwenden. 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 erfahren Sie mehr über die APIs und Techniken, mit denen Sie Nutzern hochwertige Offlinemedien bieten können.

Große Mediendatei herunterladen und speichern

Progressive Web-Apps verwenden in der Regel die praktische Cache API, um die für die Offline-Nutzung erforderlichen Assets herunterzuladen und zu speichern: Dokumente, Style Sheets, Bilder und andere.

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

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 tut z. B. Folgendes nicht:

  • Ermöglicht es, Downloads ganz einfach zu pausieren und fortzusetzen
  • Sie können den Fortschritt von Downloads verfolgen.
  • Anbieten einer Möglichkeit, ordnungsgemäß auf HTTP-Bereichsanfragen zu antworten

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

Die Fetch API ist heute eine browserübergreifende Möglichkeit für den asynchronen Zugriff auf Remote-Dateien. In unserem Anwendungsfall können Sie damit auf große Videodateien als Stream zugreifen und sie mithilfe einer HTTP-Bereichsanfrage inkrementell als Blöcke speichern.

Da Sie die Datenblöcke jetzt mit der Fetch API lesen können, müssen Sie sie auch speichern. Wahrscheinlich sind mit Ihrer Mediendatei mehrere 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 zum Speichern von Mediendaten und Metadaten. Es kann einfach riesige Mengen an Binärdaten aufnehmen und bietet auch Indexe, mit denen Sie sehr schnelle Datensuchen durchführen können.

Mediendateien mithilfe 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.

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

Siehst du, dass sich await reader.read() in einer Schleife befindet? So erhalten Sie Datenblöcke, die aus einem lesbaren Stream aus dem Netzwerk eingehen. Ü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 pausiert oder unterbrochen wird, werden die eingegangenen Datenblöcke sicher in einer IndexedDB-Datenbank gespeichert. Sie können dann eine Schaltfläche zum Fortsetzen eines Downloads in Ihrer Anwendung einblenden. Da der Kino-Demo-PWA-Server 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 = () => { ... }

Obwohl dieser Ansatz funktioniert, werden Sie wahrscheinlich feststellen, dass Ihre IndexedDB-Schreibvorgänge erheblich langsamer sind als Ihr Download. Das liegt nicht daran, dass IndexedDB-Schreibvorgänge langsam sind. Es liegt daran, dass wir einen hohen Transaktionsaufwand verursachen, 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 in schneller Abfolge vom Stream ausgegeben werden. Sie müssen die Rate der IndexedDB-Schreibvorgänge begrenzen. In der Kino-Demo-PWA wird dafür ein intermediärer Schreibpuffer implementiert.

Wenn Datenblöcke aus dem Netzwerk eintreffen, hängen wir sie zuerst an unseren Zwischenspeicher an. Wenn die eingehenden Daten nicht passen, wird der gesamte Zwischenspeicher in die Datenbank geleert und gelöscht, bevor die restlichen Daten angehängt werden. Infolgedessen sind IndexedDB-Schreibvorgänge weniger häufig, was zu einer erheblich verbesserten Schreibleistung führt.

Bereitstellen einer Mediendatei aus dem Offlinespeicher

Nachdem Sie eine Mediendatei heruntergeladen haben, möchten Sie wahrscheinlich, dass Ihr Service Worker sie über 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 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, deshalb sollten wir wahrscheinlich ReadableStream auswählen.

Da es sich um große Dateien handelt und wir Browsern erlauben wollten, nur den Teil der Datei anzufordern, der aktuell benötigt wird, 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;

Sie können sich die Kino-Demo der PWA Quellcode des Service Workers ansehen, um zu erfahren, wie Dateidaten aus IndexedDB gelesen und ein Stream in einer echten Anwendung erstellt wird.

Weitere Aspekte

Jetzt können Sie Ihrer Videoanwendung einige praktische Funktionen hinzufügen. Hier sind einige Beispiele für Funktionen der Kino-Demo-PWA:

  • Durch die Integration der Media Session API können Nutzer die Medienwiedergabe über spezielle Hardware-Medienschlüssel oder über Medienbenachrichtigungs-Pop-ups steuern.
  • Caching von anderen mit Mediendateien verbundenen Inhalten wie Untertiteln und Posterbildern unter Verwendung der guten alten Cache API.
  • Unterstützung für den Download von Videostreams (DASH, HLS) in der App. Da Streammanifeste in der Regel 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 geht es um Schnelle Wiedergabe mit Vorabladen von Audio und Video.