PWA z obsługą strumieniowania offline

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Progresywne aplikacje internetowe zapewniają dostęp do wielu funkcji, które wcześniej były zarezerwowane dla aplikacji natywnych. Jedną z najważniejszych funkcji aplikacji PWA jest obsługa 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. Powoduje to jednak naprawdę wyjątkowy problem – pliki multimedialne mogą być bardzo duże. Możesz zapytać:

  • Jak pobrać i przechowywać duży plik wideo?
  • Jak pokazać go użytkownikowi?

W tym artykule przyjrzymy się odpowiedziom na te pytania, jednocześnie nawiązując do opracowanej przez nas demonstracyjnej aplikacji PWA Kino, która zawiera praktyczne przykłady tego, jak można wdrożyć strumieniowe przesyłanie multimediów offline bez używania struktur funkcjonalnych ani prezentacji. Poniższe przykłady służą głównie do celów edukacyjnych, ponieważ w większości przypadków do udostępnienia tych funkcji należy użyć jednej z istniejących platform medialnych.

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 pozwalają zapewnić użytkownikom wysoką jakość multimediów offline.

Pobieranie i przechowywanie dużego pliku multimedialnego

Progresywne aplikacje internetowe zwykle używają wygodnego interfejsu Cache API do pobierania i przechowywania zasobów wymaganych do działania offline, takich jak dokumenty, arkusze stylów, obrazy i inne.

Oto podstawowy przykład użycia interfejsu Cache API w skrypcie 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 korzystanie z dużych plików jest niepraktyczne.

Na przykład interfejs Cache API nie:

  • pozwalają na łatwe wstrzymywanie i wznawianie pobierania.
  • Pozwalają śledzić postęp pobierania
  • Udostępnij sposób prawidłowego odpowiadania na żądania zakresów 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 Fetch API pozwala asynchronicznie uzyskiwać dostęp do zdalnych plików w różnych przeglądarkach. W naszym przypadku użycia pozwala on uzyskać dostęp do dużych plików wideo w postaci strumienia i zapisywać je stopniowo we fragmentach za pomocą żądania dotyczącego zakresu HTTP.

Już możesz odczytywać fragmenty danych za pomocą interfejsu Fetch API, ale musisz je też przechowywać. Z plikiem multimedialnym może być powiązane wiele metadanych, takich jak nazwa, opis, czas trwania, kategoria itp.

Nie przechowujesz jednego pliku multimedialnego, przechowujesz obiekt uporządkowany, a plik multimedialny jest tylko jedną z jego właściwości.

W tym przypadku interfejs IndexedDB API stanowi doskonałe rozwiązanie do przechowywania zarówno danych multimediów, 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 przy użyciu 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 niedokończonych pobierania plików.
  • Niestandardowy bufor do przechowywania fragmentów danych w bazie danych.

Zanim pokażemy, jak implementować te funkcje, powiemy krótko, jak 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);
}

Zauważasz, że aplikacja await reader.read() jest w pętli? W ten sposób będziesz otrzymywać fragmenty danych z czytelnego strumienia 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 wznawiania pobierania. Ponieważ demonstracyjny serwer PWA 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 w bazie danych IndexedDB jest prosty. Te wartości to już instancje ArrayBuffer, które można przechowywać bezpośrednio w IndexedDB, więc możemy po prostu 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ż ta metoda działa, można prawdopodobnie zauważyć, że zapisy w IndexedDB są znacznie wolniejsze niż pobieranie. Nie jest to spowodowane tym, że zapisy w IndexedDB są powolne, tylko dlatego, że zwiększamy nakłady pracy transakcyjne, tworząc nową transakcję dla każdego fragmentu danych, który otrzymujemy z sieci.

Pobrane fragmenty mogą być dość małe i mogą być wysyłane przez strumień w bardzo krótkim czasie. Musisz ograniczyć częstotliwość zapisów w IndexedDB. W wersji demonstracyjnej Kino stosujemy w tym celu pośredni bufor zapisu.

Wycinki danych z sieci zostają umieszczone w naszym buforze w pierwszej kolejności. Jeśli przychodzące dane nie mieszczą się, opróżniamy pełny bufor do bazy danych i usuwamy go przed dołączeniem reszty danych. W rezultacie zapisy w IndexedDB są rzadsze, co znacznie poprawia wydajność 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 więc musisz zrobić w aplikacji getVideoResponse()?

  • Metoda event.respondWith() oczekuje obiektu Response jako parametru.

  • Konstruktor Response() informuje, że istnieje kilka typów obiektów, których można użyć do utworzenia instancji obiektu Response: Blob, BufferSource, ReadableStream i inne.

  • Potrzebujemy obiektu, który nie przechowuje wszystkich swoich danych w pamięci, więc prawdopodobnie wybierzemy obiekt ReadableStream.

Poza tym mamy do czynienia z dużymi plikami i chcieliśmy zezwolić przeglądarkom na żądanie tylko tej części pliku, której potrzebują, więc musieliśmy wdrożyć podstawową obsługę żądań zakresów 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;

Aby dowiedzieć się, jak odczytujemy dane plików z IndexedDB i tworzymy strumień w rzeczywistej aplikacji, możesz zapoznać się z demonstracyjnym kodem źródłowym skryptu service worker Kino w PWA.

Inne uwagi

Pozbędziesz się już innych przeszkód, więc możesz zacząć dodawać do swojej aplikacji wideo kilka ciekawych funkcji. Oto kilka przykładów funkcji, które można znaleźć w demonstracyjnej aplikacji PWA 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.
  • Przechowywanie w pamięci podręcznej innych zasobów powiązanych z plikami multimedialnymi, takich jak napisy czy obrazy plakatów, przy użyciu starego interfejsu Cache API.
  • Obsługa pobierania strumieni wideo (DASH, HLS) w aplikacji. Strumień ma zwykle deklarowane kilka źródeł o różnych szybkościach transmisji bitów, dlatego przed zapisaniem pliku multimedialnego do oglądania offline musisz przekształcić plik manifestu i pobrać tylko jedną wersję multimediów.

W następnej kolejności omówimy szybkie odtwarzanie z wstępnym wczytywaniem dźwięku i obrazu.