PWA z obsługą strumieniowania offline

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Progresywne aplikacje internetowe zapewniają wiele funkcji, które wcześniej były zarezerwowane dla aplikacji natywnych. Jedną z najważniejszych funkcji PWA są aplikacje offline.

Jeszcze lepszym rozwiązaniem byłoby strumieniowanie multimediów offline, które jest udogodnieniem, które możesz zaoferować użytkownikom na kilka różnych 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 ją udostępnić użytkownikowi?

W tym artykule omówimy odpowiedzi na te pytania, odwołując się do stworzonej przez nas wersji demonstracyjnej aplikacji PWA Kino, która zawiera praktyczne przykłady tego, jak wdrożyć strumieniowe przesyłanie multimediów offline bez korzystania z żadnej funkcjonalnej ani prezentacji. Poniższe przykłady mają charakter przede wszystkim edukacyjny, ponieważ w większości przypadków trzeba użyć jednego z dotychczasowych platform mediów.

O ile nie masz dobrego argumentu za opracowaniem własnej aplikacji PWA ze strumieniowym przesyłaniem danych offline, W tym artykule poznasz interfejsy API i techniki służące do zapewnienia użytkownikom wysokiej jakości medió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 w trybie offline: dokumentów, arkuszy stylów, obrazów i innych.

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',
      ]);
    })
  );
});

Technicznie rzecz biorąc, jak widać powyżej, korzystanie z interfejsu Cache API ma kilka ograniczeń, które sprawiają, że korzystanie z niego w przypadku dużych plików jest niepraktyczne.

Na przykład interfejs Cache API nie:

  • umożliwiają łatwe wstrzymywanie i wznawianie pobierania;
  • Pozwalają śledzić postęp pobierania
  • oferować sposób poprawnego odpowiadania na żądania zakresów HTTP,

Wszystkie te problemy stanowią dość poważne ograniczenia w przypadku każdej aplikacji wideo. Przyjrzyjmy się innym opcje, które mogą być dla Ciebie odpowiednie.

Obecnie interfejs Fetch API umożliwia asynchroniczny dostęp do plików zdalnych za pomocą wielu przeglądarek. W naszym przypadku pozwala uzyskać dostęp do dużych plików wideo jako strumień i przechowywać je stopniowo jako fragmenty przy użyciu żądania zakresu HTTP.

Gdy możesz już odczytywać fragmenty danych za pomocą interfejsu Fetch API, musisz je też zapisać. Z plikiem multimedialnym może być powiązanych wiele metadanych, np. nazwa, opis, długość działania, kategoria itp.

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

W tym przypadku interfejs IndexedDB API stanowi doskonałe rozwiązanie do przechowywania zarówno danych multimedialnych, jak i metadanych. Łatwo przechowywać ogromne ilości danych binarnych i udostępnia indeksy, które umożliwiają bardzo szybkie wyszukiwanie danych.

Pobieranie plików multimedialnych przy użyciu interfejsu Fetch API

W wersji demonstracyjnej aplikacji PWA stworzyliśmy kilka interesujących funkcji związanych z Fetch API. Nazwaliśmy je Kino. Kod źródłowy jest dostępny publicznie, więc możesz go przejrzeć.

  • możliwość wstrzymywania i wznawiania niedokończonych pobierania,
  • Niestandardowy bufor do przechowywania fragmentów danych w bazie danych.

Zanim pokażemy Ci, jak te funkcje są zaimplementowane, przypomnimy o tym, 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);
}

Widzisz, że await reader.read() jest w pętli? W ten sposób będziesz otrzymywać fragmenty danych z czytelnego strumienia pochodzące z sieci. Warto zastanowić się, na ile to przydatne – można zacząć przetwarzać dane, jeszcze zanim wszystkie dostarczą je z sieci.

Wznawiam pobieranie

Po wstrzymaniu lub przerwaniu pobierania porcje danych są bezpiecznie przechowywane w bazie danych IndexedDB. Możesz wyświetlić przycisk wznawiania pobierania w aplikacji. Ponieważ demonstracyjny serwer PWA Kino obsługuje żądania zakresu HTTP, aby wznowić pobieranie, jest to 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 na potrzeby IndexedDB

Na papierze proces zapisywania wartości dataChunk w bazie danych IndexedDB jest prosty. Te wartości są już instancjami ArrayBuffer, które można przechowywać bezpośrednio w IndexedDB. Możemy więc 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 = () => { ... }

Mimo że to podejście działa, prawdopodobnie odkryjesz, że zapisy w IndexedDB są znacznie wolniejsze niż pobieranie. Dzieje się tak nie dlatego, że zapisy w IndexedDB są powolne, tylko dlatego, że zwiększamy obciążenie transakcyjne przez tworzenie nowej transakcji dla każdego fragmentu danych odbieranego z sieci.

Pobrane fragmenty mogą być raczej małe i mogą być wysyłane szybko przez strumień. Musisz ograniczyć liczbę zapisów IndexedDB. W demonstracyjnej PWA Kino robimy to, implementując pośredni bufor zapisu.

Gdy porcje danych pochodzą z sieci, najpierw dołączamy je do bufora. Jeśli przychodzące dane nie pasują, opróżniamy pełny bufor w bazie danych i czyścimy go przed dołączeniem pozostałych danych. W rezultacie nasze zapisy w IndexedDB są rzadsze, co prowadzi do znacznie większej wydajności zapisu.

Wyświetlanie pliku multimedialnego z pamięci offline

Po pobraniu pliku multimedialnego prawdopodobnie chcesz, aby mechanizm Service Worker wyświetlał go z IndexedDB zamiast pobierania 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 aplikacji getVideoResponse()?

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

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

  • Potrzebujemy obiektu, który nie przechowuje wszystkich swoich danych w pamięci, więc lepiej będzie wybrać ReadableStream.

Ponieważ zajmujemy się dużymi plikami i chcieliśmy umożliwić przeglądarkom przesyłanie żądań tylko do tych części pliku, których obecnie potrzebują, musieliśmy więc 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;

Zapoznaj się z demonstracyjnym kodem PWA Kino kodem źródłowym skryptu service worker, aby dowiedzieć się, jak odczytuje dane plików z IndexedDB i tworzymy strumień w prawdziwej aplikacji.

Inne uwagi

Po pokonaniu głównych przeszkód możesz zacząć dodawać do swojej aplikacji wideo kilka przydatnych funkcji. Oto kilka przykładów funkcji, które zawiera demonstracyjna progresywna aplikacja internetowa Kino:

  • Integracja interfejsu Media Session API, która umożliwia użytkownikom sterowanie odtwarzaniem multimediów za pomocą dedykowanych sprzętowych klawiszy multimedialnych lub wyskakujących okienek z powiadomieniami.
  • Buforowanie innych zasobów powiązanych z plikami multimedialnymi, takich jak napisy i obrazy plakatów, przy użyciu starego interfejsu Cache API.
  • Obsługa strumieni wideo (DASH, HLS) pobieranych z poziomu aplikacji. Pliki manifestu strumienia deklarują zazwyczaj wiele źródeł o różnych szybkościach transmisji bitów, dlatego musisz go przekształcić i pobrać tylko jedną wersję multimediów przed zapisaniem go do przeglądania offline.

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