PWA z obsługą strumieniowania offline

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

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 obiektu Response jako parametru.

  • 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.