Progresywne aplikacje internetowe oferują wiele funkcji wcześniej zarezerwowanych dla natywnych aplikacji internetowych. Jedną z najważniejszych funkcji aplikacji PWAs jest możliwość korzystania z nich offline.
Jeszcze lepiej byłoby przesyłać strumieniowo multimedia w trybie offline, ponieważ możesz je zaoferować użytkownikom na kilka różnych sposobów. Stwarza to jednak bardzo specyficzny problem – pliki multimedialne mogą być bardzo duże. Możesz się zastanawiać:
- Jak pobrać i przechowywać duży plik wideo?
- I jak mogę go wyświetlić użytkownikowi?
W tym artykule omówimy odpowiedzi na te pytania, odwołując się do naszej demonstracyjnej aplikacji PWA Kino, która zawiera praktyczne przykłady implementowania strumieniowego przesyłania multimediów offline bez użycia żadnych ram funkcjonalnych ani prezentacyjnych. Poniższe przykłady służą głównie celom edukacyjnym, ponieważ w większości przypadków do udostępniania tych funkcji należy użyć jednej z dostępnych ramek mediów.
Jeśli nie masz dobrego uzasadnienia dla stworzenia własnej aplikacji, tworzenie aplikacji PWA z funkcją strumieniowania offline wiąże się z pewnymi wyzwaniami. Z tego artykułu dowiesz się, jakie interfejsy API i techniki są używane do zapewniania 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 korzystania z aplikacji w trybie offline: dokumenty, czcionki, obrazy itp.
Oto podstawowy przykład użycia interfejsu Cache API w ramach usługi 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',
]);
})
);
});
Choć ten przykład działa technicznie, korzystanie z interfejsu Cache API ma kilka ograniczeń, które sprawiają, że jest on niepraktyczny z dużymi plikami.
Na przykład interfejs Cache API:
- umożliwiają łatwe wstrzymywanie i wznawianie pobierania;
- umożliwiają śledzenie postępu pobierania;
- Udostępnianie sposobu prawidłowego reagowania na żądania zakresu HTTP
Wszystkie wymienione wyżej problemy stanowią dość poważne ograniczenia dla każdej aplikacji wideo. Przyjrzyjmy się innym opcjom, które mogą być bardziej odpowiednie.
Obecnie interfejs Fetch API umożliwia asynchroniczny dostęp do plików zdalnych w różnych przeglądarkach. W naszym przypadku umożliwia on dostęp do dużych plików wideo jako strumienia i ich stopniowe przechowywanie w kawałkach za pomocą żądania zakresu HTTP.
Teraz, gdy możesz odczytywać fragmenty danych za pomocą interfejsu Fetch API, musisz je też przechowywać. Z pliku multimedialnego prawdopodobnie powiązane są liczne metadane, takie jak nazwa, opis, czas trwania, kategoria itp.
Nie przechowujesz tylko jednego pliku multimedialnego, ale uporządkowany obiekt, w którym plik multimedialny jest tylko jedną z właściwości.
W takim przypadku interfejs IndexedDB API stanowi doskonałe rozwiązanie do przechowywania zarówno danych multimedialnych, jak i metadanych. Może łatwo przechowywać ogromne ilości danych binarnych. Udostępnia też indeksy umożliwiające bardzo szybkie wyszukiwanie danych.
Pobieranie plików multimedialnych za pomocą interfejsu Fetch API
Opracowaliśmy kilka ciekawych funkcji związanych z interfejsem Fetch API w naszej demonstracyjnej aplikacji PWA o nazwie Kino. Kod źródłowy jest publiczny, więc możesz się z nim zapoznać.
- możliwość wstrzymywania i wznawiania nieukończonych pobierania;
- Niestandardowy bufor do przechowywania fragmentów danych w bazie danych.
Zanim pokażemy, jak te funkcje są wdrażane, krótko omówimy, 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);
}
Czy widzisz, że await reader.read()
jest w pętli? W ten sposób będziesz otrzymywać fragmenty danych z czytelnego strumienia, gdy będą one docierać z sieci. Zastanówmy się, jak przydatne jest to rozwiązanie: możesz zacząć przetwarzać dane jeszcze przed ich otrzymaniem z sieci.
Wznawiam pobieranie
Gdy pobieranie zostanie wstrzymane lub przerwane, otrzymane fragmenty danych zostaną bezpiecznie zapisane w bazie danych IndexedDB. Następnie możesz wyświetlić w aplikacji przycisk wznowienia pobierania. Ponieważ serwer PWA w demo 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
Na papierze proces zapisywania wartości dataChunk
do bazy danych IndexedDB jest prosty. Te wartości są już instancjami ArrayBuffer
, które można przechowywać bezpośrednio w IndexedDB, więc możemy po prostu utworzyć obiekt o odpowiedniej formie 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 danych w IndexedDB jest znacznie wolniejsze niż pobieranie. Nie jest to spowodowane tym, że zapisywanie w IndexedDB jest powolne, ale tym, że tworzymy wiele obciążeń transakcyjnych, tworząc nową transakcję dla każdego fragmentu danych, który otrzymujemy z sieci.
Pobrane fragmenty mogą być raczej małe i mogą być emitowane przez strumień w szybkiej kolejności. Musisz ograniczyć szybkość zapisywania w IndexedDB. W demo PWA aplikacji Kino robimy to, wdrażając bufor zapisu pośredniczący.
Wycinki danych z sieci zostają umieszczone w naszym buforze w pierwszej kolejności. Jeśli napływające dane nie mieszczą się w buforze, przepłukujemy pełny bufor do bazy danych i czyszczymy go przed dodaniem reszty danych. W rezultacie operacje zapisu w IndexedDB są rzadsze, co prowadzi do znacznej poprawy wydajności zapisu.
Udostępnianie pliku multimedialnego z pamięci offline
Po pobraniu pliku multimedialnego prawdopodobnie chcesz, aby skrypt service worker obsługiwał go z IndexedDB, zamiast pobierać go 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 musisz zrobić w getVideoResponse()
?
Metoda
event.respondWith()
oczekuje jako parametru obiektuResponse
.Konstruktor Response() wskazuje, że do utworzenia obiektu
Response
można użyć obiektuBlob
,BufferSource
,ReadableStream
i innych.Potrzebujemy obiektu, który nie przechowuje wszystkich danych w pamięci, więc prawdopodobnie wybierzemy
ReadableStream
.
Ponieważ mamy do czynienia z dużymi plikami i chcieliśmy umożliwić przeglądarkom żądanie tylko 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 demo Kino, czyli źródeł kodu serwisu workera w PWA, aby dowiedzieć się, jak odczytujemy dane o pliku z IndexedDB i jak tworzymy strumień w rzeczywistej aplikacji.
Inne uwagi
Po usunięciu głównych przeszkód możesz zacząć dodawać do aplikacji do tworzenia filmów przydatne funkcje. Oto kilka przykładów funkcji, które znajdziesz w demo PWA aplikacji Kino:
- Integracja interfejsu Media Session API, która umożliwia użytkownikom sterowanie odtwarzaniem multimediów za pomocą dedykowanych sprzętowych klawiszy multimedialnych lub za pomocą wyskakujących okienek z powiadomieniami o multimediach.
- Zapisywanie w pamięci podręcznej innych zasobów powiązanych z plikami multimedialnymi, takich jak napisy i zdjęcia plakatowe, za pomocą starego interfejsu Cache API.
- Obsługa pobierania strumieni wideo (DASH, HLS) w aplikacji. Ponieważ pliki manifestu strumienia zazwyczaj deklarują wiele źródeł o różnych szybkościach transmisji bitów, przed zapisaniem go do oglądania offline musisz przekształcić plik manifestu i pobrać tylko jedną wersję multimediów.
W następnym kroku dowiesz się więcej o szybkim odtwarzaniu z wstępnym wczytaniem dźwięku i obrazu.