PWA con streaming offline

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Le app web progressive offrono al web molte funzionalità precedentemente riservate alle applicazioni native. Una delle funzionalità più importanti associate alle PWA è l'esperienza offline.

Ancora meglio sarebbe un'esperienza multimediale in streaming offline, un miglioramento che potresti offrire ai tuoi utenti in diversi modi. Tuttavia, questo crea un problema davvero unico: i file multimediali possono essere molto grandi. Quindi, potresti chiederti:

  • Come faccio a scaricare e archiviare un file video di grandi dimensioni?
  • E come faccio a servirlo all'utente?

In questo articolo illustreremo le risposte a queste domande, facendo riferimento alla PWA demo Kino che abbiamo creato per fornirti esempi pratici di come implementare un'esperienza di streaming di contenuti multimediali offline senza utilizzare framework funzionali o di presentazione. Gli esempi riportati di seguito sono principalmente a scopo didattico, perché nella maggior parte dei casi dovresti probabilmente utilizzare uno dei Media Framework esistenti per fornire queste funzionalità.

A meno che tu non abbia un buon business case per svilupparne una, la creazione di una PWA con streaming offline presenta delle sfide. In questo articolo scoprirai le API e le tecniche utilizzate per offrire agli utenti un'esperienza multimediale offline di alta qualità.

Download e archiviazione di un file multimediale di grandi dimensioni

Le app web progressive in genere utilizzano la comoda API Cache per scaricare e archiviare gli asset necessari per fornire l'esperienza offline: documenti, stylesheet, immagini e altro ancora.

Ecco un esempio di base di utilizzo dell'API Cache in un 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',
      ]);
    })
  );
});

Sebbene l'esempio riportato sopra funzioni tecnicamente, l'utilizzo dell'API Cache presenta diversi limiti che ne rendono impraticabile l'uso con file di grandi dimensioni.

Ad esempio, l'API Cache non:

  • Ti consentono di mettere in pausa e riprendere facilmente i download
  • Consentono di monitorare l'avanzamento dei download
  • Offrire un modo per rispondere correttamente alle richieste di intervallo HTTP

Tutti questi problemi rappresentano limitazioni piuttosto gravi per qualsiasi applicazione video. Vediamo alcune altre opzioni che potrebbero essere più appropriate.

Oggi, l'API Fetch è un modo cross-browser per accedere in modo asincrono ai file remote. Nel nostro caso d'uso, ti consente di accedere a file video di grandi dimensioni come stream e di memorizzarli in modo incrementale come chunk utilizzando una richiesta di intervallo HTTP.

Ora che puoi leggere i blocchi di dati con l'API Fetch, devi anche memorizzarli. È probabile che al file multimediale siano associati molti metadati, ad esempio nome, descrizione, durata, categoria e così via.

Non archivi solo un file multimediale, bensì un oggetto strutturato. Il file multimediale è solo una delle sue proprietà.

In questo caso, l'API IndexedDB offre una soluzione eccellente per archiviare sia i dati multimediali sia i metadati. Può contenere facilmente enormi quantità di dati binari e offre anche indici che consentono di eseguire ricerche di dati molto rapide.

Scaricare file multimediali utilizzando l'API Fetch

Abbiamo creato un paio di funzionalità interessanti per l'API Fetch nella nostra PWA di demo, che abbiamo chiamato Kino. Il codice sorgente è pubblico, quindi non esitare a esaminarlo.

  • Possibilità di mettere in pausa e riprendere i download incompleti.
  • Un buffer personalizzato per l'archiviazione di blocchi di dati nel database.

Prima di mostrare l'implementazione di queste funzionalità, faremo un rapido riepilogo di come è possibile utilizzare l'API Fetch per scaricare i file.

/**
 * 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);
}

Hai notato che await reader.read() è in un loop? È così che riceverai blocchi di dati da un flusso leggibile quando arrivano dalla rete. Pensa a quanto sia utile: puoi iniziare a elaborare i dati ancor prima che arrivino dalla rete.

Riprendere i download

Quando un download viene messo in pausa o interrotto, i chunk di dati ricevuti vengono memorizzati in modo sicuro in un database IndexedDB. Puoi quindi visualizzare un pulsante per riprendere un download nella tua applicazione. Poiché il server PWA demo di Kino supporta le richieste di intervallo HTTP, riprendere un download è abbastanza semplice:

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);
}

Buffer di scrittura personalizzato per IndexedDB

Sulla carta, la procedura di scrittura dei valori dataChunk in un database IndexedDB è semplice. Questi valori sono già istanze di ArrayBuffer, che possono essere memorizzate direttamente in IndexedDB, quindi possiamo semplicemente creare un oggetto di forma appropriata e memorizzarlo.

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 = () => { ... }

Sebbene questo approccio funzioni, probabilmente scoprirai che le scritture IndexedDB sono molto più lente del download. Questo non perché le scritture di IndexedDB sono lente, ma perché stiamo aggiungendo un sacco di overhead transazionale creando una nuova transazione per ogni blocco di dati che riceviamo da una rete.

I chunk scaricati possono essere piuttosto piccoli e possono essere emessi dallo stream in rapida successione. Devi limitare la frequenza delle scritture IndexedDB. Nella PWA demo Kino lo facciamo implementando un buffer di scrittura intermedio.

Quando arrivano blocchi di dati dalla rete, li aggiungiamo prima al buffer. Se i dati in entrata non si adattano, svuotiamo il buffer completo nel database e lo svuotiamo prima di aggiungere il resto dei dati. Di conseguenza, le nostre operazioni di scrittura IndexedDB sono meno frequenti, il che porta a prestazioni di scrittura notevolmente migliorate.

Pubblicazione di un file multimediale da archiviazione offline

Una volta scaricato un file multimediale, probabilmente vorrai che il tuo worker di servizio lo pubblichi da IndexedDB anziché recuperarlo dalla rete.

/**
 * 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);

Cosa devi fare in getVideoResponse()?

  • Il metodo event.respondWith() si aspetta un oggetto Response come parametro.

  • Il costruttore Response() ci dice che esistono diversi tipi di oggetti che potremmo utilizzare per creare un oggetto Response: Blob, BufferSource, ReadableStream e altri ancora.

  • Abbiamo bisogno di un oggetto che non contenga tutti i dati in memoria, quindi probabilmente sceglieremo ReadableStream.

Inoltre, poiché abbiamo a che fare con file di grandi dimensioni e volevamo consentire ai browser di richiedere solo la parte del file di cui hanno bisogno al momento, abbiamo dovuto implementare un supporto di base per le richieste di intervallo 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;

Non esitare a dare un'occhiata al codice sorgente del servizio worker della PWA demo Kino per scoprire come leggiamo i dati dei file da IndexedDB e costruiamo uno stream in un'applicazione reale.

Altre considerazioni

Una volta rimossi gli ostacoli principali, ora puoi iniziare ad aggiungere alcune funzionalità utili alla tua applicazione video. Ecco alcuni esempi di funzionalità che puoi trovare nella PWA demo di Kino:

  • L'integrazione dell'API Media Session consente agli utenti di controllare la riproduzione dei contenuti multimediali utilizzando i tasti multimediali hardware dedicati o i popup di notifica dei contenuti multimediali.
  • Memorizzazione nella cache di altri asset associati ai file multimediali, come sottotitoli e immagini di poster, utilizzando la buona vecchia API Cache.
  • Supporto per il download di stream video (DASH, HLS) all'interno dell'app. Poiché i manifest dello stream in genere dichiarano più origini con bitrate diverse, devi trasformare il file manifest e scaricare una sola versione multimediale prima di archiviarla per la visualizzazione offline.

A seguire, scoprirai di più sulla riproduzione rapida con il precaricamento audio e video.