Veröffentlicht am 5. Juli 2021
Progressive Web-Apps bieten viele Funktionen, die bisher nativen Anwendungen vorbehalten waren. Eines der wichtigsten Merkmale von PWAs ist die Offline-Nutzung.
Noch besser wäre es, wenn Sie Ihren Nutzern die Möglichkeit bieten, Streamingmedien offline zu nutzen. 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 stelle ich sie dem Nutzer zur Verfügung?
In diesem Artikel werden Antworten auf diese Fragen behandelt. Dabei wird auf die von uns entwickelte Kino-Demo-PWA verwiesen, die Ihnen praktische Beispiele für die Implementierung von Offline-Streamingmedien ohne funktionale oder präsentationsbezogene Frameworks bietet. Die folgenden Beispiele dienen hauptsächlich zu Schulungszwecken, da Sie in den meisten Fällen wahrscheinlich 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 die Entwicklung einer PWA mit Offline-Streaming mit einigen Herausforderungen verbunden. In diesem Artikel erfahren Sie mehr über die APIs und Techniken, die verwendet werden, um Nutzern eine hochwertige Offline-Mediawiedergabe zu ermöglichen.
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, Stylesheets, Bilder usw.
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, die Verwendung der Cache API hat jedoch mehrere Einschränkungen, die die Verwendung mit großen Dateien unpraktisch machen.
Die Cache API bietet beispielsweise nicht:
- Downloads lassen sich ganz einfach pausieren und fortsetzen.
- Sie können den Fortschritt von Downloads verfolgen.
- Eine Möglichkeit bieten, richtig auf HTTP-Bereichsanfragen zu reagieren
Alle 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 ist die Fetch API eine browserübergreifende Möglichkeit, asynchron auf Remotedateien zuzugreifen. In unserem Anwendungsfall können Sie so auf große Videodateien als Stream zugreifen und sie inkrementell als Chunks mithilfe einer HTTP-Bereichsanfrage speichern.
Nachdem Sie die Datenblöcke mit der Fetch API lesen können, müssen Sie sie auch speichern. Wahrscheinlich sind mit Ihrer Mediendatei eine Reihe von Metadaten verknüpft, z. B. Name, Beschreibung, Laufzeit, Kategorie usw.
Sie speichern nicht nur eine einzelne Mediendatei, sondern ein strukturiertes Objekt, und die Mediendatei ist nur eine seiner Eigenschaften.
In diesem Fall bietet die IndexedDB API eine hervorragende Lösung zum Speichern von Media-Daten und Metadaten. Sie kann problemlos große Mengen an Binärdaten aufnehmen und bietet außerdem Indexe, mit denen Sie sehr schnelle Datensuchen durchführen können.
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 gern ansehen.
- Unvollständige Downloads können pausiert und fortgesetzt werden.
- Ein benutzerdefinierter Puffer zum Speichern von Datenblöcken in der Datenbank.
Bevor wir zeigen, wie diese Funktionen implementiert werden, fassen wir 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 await reader.read() in einer Schleife enthalten ist. So erhalten Sie Datenblöcke aus einem lesbaren Stream, sobald sie aus dem Netzwerk eintreffen. Das ist sehr praktisch: Sie können mit der Verarbeitung Ihrer Daten beginnen, noch bevor alle Daten aus dem Netzwerk eingegangen sind.
Downloads fortsetzen
Wenn ein Download pausiert oder unterbrochen wird, werden die eingegangenen Datenblöcke sicher in einer IndexedDB-Datenbank gespeichert. Anschließend können Sie in Ihrer Anwendung eine Schaltfläche zum Fortsetzen des Downloads anzeigen. Da der Kino-Demoserver für PWA HTTP-Bereichsanfragen unterstützt, ist das Fortsetzen eines Downloads relativ 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 Schreib-Zwischenspeicher 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. Wir müssen also nur 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 als Ihr Download sind. Das liegt nicht daran, dass IndexedDB-Schreibvorgänge langsam sind, sondern daran, dass wir viel Transaktions-Overhead hinzufügen, indem wir für jeden Datenblock, den wir aus einem Netzwerk empfangen, eine neue Transaktion erstellen.
Die heruntergeladenen Chunks können recht klein sein und in schneller Folge vom Stream ausgegeben werden. Sie müssen die Rate der IndexedDB-Schreibvorgänge begrenzen. In der Kino-Demo-PWA wird dies durch die Implementierung eines Zwischenspeichers erreicht.
Wenn Datenblöcke aus dem Netzwerk eintreffen, hängen wir sie zuerst an unseren Puffer an. Wenn die eingehenden Daten nicht passen, leeren wir den gesamten Puffer in die Datenbank und löschen ihn, bevor wir die restlichen Daten anhängen. Dadurch werden weniger IndexedDB-Schreibvorgänge ausgeführt, was die Schreibleistung deutlich verbessert.
Mediendatei aus dem Offlinespeicher bereitstellen
Nachdem Sie eine Mediendatei heruntergeladen haben, soll Ihr Service Worker sie wahrscheinlich aus IndexedDB bereitstellen, 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 einResponse-Objekt als Parameter.Der Response()-Konstruktor zeigt, dass es verschiedene Arten von Objekten gibt, die wir zum Instanziieren eines
Response-Objekts verwenden können: einBlob,BufferSource,ReadableStreamund mehr.Wir benötigen ein Objekt, das nicht alle seine Daten im Arbeitsspeicher speichert. Daher sollten wir wahrscheinlich
ReadableStreamauswählen.
Da wir es mit großen Dateien zu tun haben und Browser nur den Teil der Datei anfordern sollen, 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 Kino-Demo-PWA können Sie sehen, wie wir in einer echten Anwendung Dateidaten aus IndexedDB lesen und einen Stream erstellen.
Weitere Hinweise
Nachdem Sie die größten Hürden überwunden haben, können Sie Ihrer Videoanwendung nun einige zusätzliche Funktionen hinzufügen. Hier einige Beispiele für Funktionen, die Sie in der Demo-PWA Kino finden:
- Media Session API-Integration, mit der Nutzer die Medienwiedergabe über spezielle Hardware-Medientasten oder über Pop-ups mit Medienbenachrichtigungen steuern können.
- Das Caching anderer Assets, die mit den Media-Dateien verknüpft sind, z. B. Untertitel und Posterbilder, erfolgt über die Cache API.
- Unterstützung für das Herunterladen von Videostreams (DASH, HLS) in der App. Da in Streammanifesten in der Regel mehrere Quellen mit unterschiedlichen Bitraten deklariert werden, müssen Sie die Manifestdatei transformieren und nur eine Mediaversion herunterladen, bevor Sie sie für die Offline-Wiedergabe speichern.
Als Nächstes erfährst du mehr über die schnelle Wiedergabe mit Audio- und Video-Preload.