Opublikowano: 5 lipca 2021 r.
Progresywne aplikacje internetowe udostępniają w internecie wiele funkcji, które wcześniej były dostępne tylko w aplikacjach natywnych. Jedną z najważniejszych funkcji związanych z PWA jest możliwość korzystania z nich w trybie offline.
Jeszcze lepsze byłoby strumieniowe przesyłanie multimediów w trybie offline, które możesz zaoferować użytkownikom na kilka sposobów. Powoduje to jednak wyjątkowy problem – pliki multimedialne mogą być bardzo duże. Możesz więc zapytać:
- Jak pobrać i zapisać duży plik wideo?
- Jak wyświetlić go użytkownikowi?
W tym artykule znajdziesz odpowiedzi na te pytania. Będziemy się przy tym odwoływać do demonstracyjnej progresywnej aplikacji internetowej Kino, którą stworzyliśmy, aby pokazać Ci praktyczne przykłady wdrożenia strumieniowego przesyłania multimediów w trybie offline bez użycia żadnych frameworków funkcjonalnych ani prezentacyjnych. Poniższe przykłady mają głównie charakter edukacyjny, ponieważ w większości przypadków do udostępniania tych funkcji należy używać jednego z istniejących frameworków multimedialnych.
Jeśli nie masz dobrego uzasadnienia biznesowego dla opracowania własnej aplikacji PWA, jej utworzenie z funkcją strumieniowania offline może być trudne. Z tego artykułu dowiesz się więcej o interfejsach API i technikach, które umożliwiają zapewnienie użytkownikom wysokiej jakości multimediów offline.
pobieranie i przechowywanie dużego pliku multimedialnego,
Progresywne aplikacje internetowe zwykle korzystają z wygodnego interfejsu Cache API, aby pobierać i przechowywać zasoby wymagane do działania w trybie offline: dokumenty, arkusze stylów, obrazy i inne.
Oto podstawowy przykład użycia interfejsu Cache API w usłudze 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',
]);
})
);
});
Powyższy przykład działa, ale korzystanie z interfejsu Cache API ma kilka ograniczeń, które sprawiają, że używanie go w przypadku dużych plików jest niepraktyczne.
Na przykład interfejs Cache API nie:
- umożliwia łatwe wstrzymywanie i wznawianie pobierania;
- umożliwia śledzenie postępu pobierania;
- zapewniać możliwość prawidłowego odpowiadania na żądania zakresu HTTP;
Wszystkie te problemy stanowią poważne ograniczenia dla każdej aplikacji wideo. Przyjrzyjmy się innym opcjom, które mogą być bardziej odpowiednie.
Obecnie Fetch API to sposób na asynchroniczny dostęp do zdalnych plików w różnych przeglądarkach. W naszym przypadku umożliwia to dostęp do dużych plików wideo w formie strumienia i przechowywanie ich przyrostowo w postaci fragmentów za pomocą żądania zakresu HTTP.
Teraz, gdy możesz odczytywać fragmenty danych za pomocą interfejsu Fetch API, musisz je też przechowywać. Z plikiem multimedialnym jest prawdopodobnie powiązanych wiele metadanych, takich jak nazwa, opis, czas trwania, kategoria itp.
Nie przechowujesz tylko jednego pliku multimedialnego, ale obiekt strukturalny, a plik multimedialny jest tylko jedną z jego właściwości.
W takim przypadku IndexedDB API jest doskonałym rozwiązaniem do przechowywania zarówno danych multimedialnych, jak i metadanych. Może ona łatwo przechowywać ogromne ilości danych binarnych, a także oferuje indeksy, które umożliwiają bardzo szybkie wyszukiwanie danych.
Pobieranie plików multimedialnych za pomocą interfejsu Fetch API
W naszej demonstracyjnej aplikacji PWA o nazwie Kino stworzyliśmy kilka ciekawych funkcji opartych na interfejsie Fetch API. Kod źródłowy jest publiczny, więc możesz go przejrzeć.
- Możliwość wstrzymywania i wznawiania niekompletnych pobrań.
- Niestandardowy bufor do przechowywania w bazie danych fragmentów danych.
Zanim pokażemy, jak te funkcje są wdrażane, najpierw przypomnimy, jak można używać interfejsu Fetch API do pobierania plików.
/**
* 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);
}
Zwróć uwagę, że await reader.read()
jest w pętli. W ten sposób będziesz otrzymywać fragmenty danych ze strumienia do odczytu w miarę ich przesyłania przez sieć. Zastanów się, jak przydatne jest to rozwiązanie: możesz rozpocząć przetwarzanie danych, zanim wszystkie zostaną przesłane z sieci.
Wznawianie pobierania
Gdy pobieranie zostanie wstrzymane lub przerwane, przychodzące fragmenty danych będą bezpiecznie przechowywane w bazie danych IndexedDB. Następnie możesz wyświetlić w aplikacji przycisk umożliwiający wznowienie pobierania. Ponieważ serwer demonstracyjnej progresywnej aplikacji internetowej Kino obsługuje żądania zakresu HTTP, wznowienie pobierania jest dość proste:
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);
}
Niestandardowy bufor zapisu dla IndexedDB
Teoretycznie proces zapisywania wartości dataChunk
w bazie danych IndexedDB jest prosty. Te wartości są już instancjami ArrayBuffer
, które można bezpośrednio przechowywać w IndexedDB, więc wystarczy utworzyć obiekt o odpowiednim kształcie i go zapisać.
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 = () => { ... }
Chociaż to podejście działa, prawdopodobnie zauważysz, że zapisywanie w IndexedDB jest znacznie wolniejsze niż pobieranie. Nie wynika to z powolnego zapisu w IndexedDB, ale z dużego obciążenia transakcyjnego spowodowanego tworzeniem nowej transakcji dla każdego fragmentu danych otrzymywanego z sieci.
Pobrane fragmenty mogą być dość małe i mogą być emitowane przez strumień w szybkim tempie. Musisz ograniczyć szybkość zapisów w IndexedDB. W demonstracyjnej progresywnej aplikacji internetowej Kino robimy to, implementując pośredni bufor zapisu.
Gdy z sieci docierają fragmenty danych, najpierw dołączamy je do bufora. Jeśli przychodzące dane się nie mieszczą, opróżniamy cały bufor do bazy danych i czyścimy go przed dołączeniem pozostałych danych. Dzięki temu zapisy w IndexedDB są rzadsze, co znacznie poprawia wydajność zapisu.
Wyświetlanie pliku multimedialnego z pamięci offline
Po pobraniu pliku multimedialnego prawdopodobnie chcesz, aby service worker serwował go z IndexedDB zamiast pobierać z sieci.
/**
* 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);
Co trzeba zrobić w getVideoResponse()
?
Metoda
event.respondWith()
oczekuje obiektuResponse
jako parametru.Z konstruktora Response() dowiadujemy się, że do utworzenia obiektu
Response
można użyć kilku typów obiektów:Blob
,BufferSource
,ReadableStream
i innych.Potrzebujemy obiektu, który nie przechowuje wszystkich danych w pamięci, więc prawdopodobnie wybierzemy
ReadableStream
.
Poza tym, ponieważ mamy do czynienia z dużymi plikami i chcieliśmy umożliwić przeglądarkom żądanie tylko tej części pliku, której aktualnie potrzebują, musieliśmy wdrożyć podstawową obsługę żądań zakresu HTTP.
/**
* 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;
Zapoznaj się z kodem źródłowym usługi Kino, która jest demonstracyjną progresywną aplikacją internetową, aby dowiedzieć się, jak odczytujemy dane z plików w IndexedDB i tworzymy strumień w prawdziwej aplikacji.
Inne uwagi
Gdy pokonasz główne przeszkody, możesz zacząć dodawać do aplikacji wideo przydatne funkcje. Oto kilka przykładów funkcji, które znajdziesz w wersji demonstracyjnej Kino:
- Integracja interfejsu Media Session API, która umożliwia użytkownikom sterowanie odtwarzaniem multimediów za pomocą specjalnych klawiszy sprzętowych lub wyskakujących powiadomień o multimediach.
- buforowanie innych zasobów powiązanych z plikami multimedialnymi, takich jak napisy i obrazy plakatu, za pomocą sprawdzonego interfejsu Cache API;
- Obsługa pobierania strumieni wideo (DASH, HLS) w aplikacji. Pliki manifestu strumienia zwykle deklarują wiele źródeł o różnych szybkościach transmisji bitów, więc przed zapisaniem ich do oglądania offline musisz przekształcić plik manifestu i pobrać tylko jedną wersję multimediów.
Następnie dowiesz się więcej o szybkim odtwarzaniu z wstępnym wczytywaniem dźwięku i obrazu.