PWA con streaming offline

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

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

Meglio ancora, sarebbe un'esperienza multimediale in streaming offline, un miglioramento che puoi offrire agli utenti in diversi modi. Tuttavia, ciò crea un problema davvero unico: i file multimediali possono essere molto grandi. Potresti chiederti:

  • Come si scarica e si archivia un file video di grandi dimensioni?
  • E come posso mostrarlo all'utente?

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

A meno che tu non abbia un buon business case per svilupparne uno, creare una PWA con lo streaming offline presenta delle sfide. Questo articolo fornisce informazioni sulle API e sulle 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 utilizzano in genere la pratica API Cache per scaricare e archiviare gli asset necessari per fornire l'esperienza offline: documenti, fogli di stile, immagini e altri.

Ecco un esempio 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 precedente funzioni tecnicamente, l'utilizzo dell'API Cache presenta diverse limitazioni che ne rendono inattuabile l'uso con file di grandi dimensioni.

Ad esempio, l'API Cache non:

  • Consentono di mettere in pausa e riprendere facilmente i download
  • Ti consentono di tenere traccia dell'avanzamento dei download
  • Offri un modo per rispondere correttamente alle richieste di intervalli HTTP

Tutti questi problemi rappresentano limiti piuttosto gravi per qualsiasi applicazione video. Esaminiamo altre opzioni che potrebbero essere più appropriate.

Attualmente, l'API Fetch è un metodo cross-browser per accedere in modo asincrono ai file remoti. Nel nostro caso d'uso, consente di accedere a file video di grandi dimensioni come stream e di archiviarli in modo incrementale come blocchi utilizzando una richiesta di intervallo HTTP.

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

Non stai archiviando solo un file multimediale, ma anche un oggetto strutturato e il file multimediale è solo una delle sue proprietà.

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

Download di file multimediali utilizzando l'API Fetch

Abbiamo creato un paio di funzionalità interessanti intorno all'API Fetch nella nostra PWA demo, che abbiamo chiamato Kino: il codice sorgente è pubblico, quindi non esitate a esaminarlo.

  • La 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 come vengono implementate queste funzionalità, facciamo un breve riepilogo di come puoi 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);
}

Nota che await reader.read() è in un loop? In questo modo riceverai blocchi di dati da uno stream leggibile non appena arrivano dalla rete. Pensa a quanto può essere utile questa funzionalità: puoi iniziare a elaborare i tuoi dati ancora prima che arrivino tutto dalla rete.

Ripresa dei download

Quando un download viene messo in pausa o interrotto, i blocchi di dati ricevuti verranno archiviati in sicurezza in un database IndexedDB. Puoi quindi visualizzare un pulsante per riprendere un download nell'applicazione. Poiché il server PWA demo di Kino supporta le richieste con intervallo HTTP, il ripristino di un download è in qualche modo 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, il processo di scrittura dei valori dataChunk in un database IndexedDB è semplice. Questi valori sono già istanze ArrayBuffer, archiviabili direttamente in IndexedDB, quindi possiamo semplicemente creare un oggetto di una forma appropriata e archiviarlo.

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

Anche se questo approccio funziona, probabilmente scoprirai che le scritture IndexedDB sono molto più lente rispetto al download. Non perché le scritture IndexedDB sono lente, ma stiamo aggiungendo un notevole sovraccarico transazionale creando una nuova transazione per ogni blocco di dati che riceviamo da una rete.

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

Man mano che i blocchi di dati arrivano dalla rete, li aggiungiamo prima al buffer. Se i dati in entrata non sono adatti, scarichiamo l'intero buffer nel database e lo cancelliamo prima di aggiungere il resto dei dati. Di conseguenza, le nostre scritture IndexedDB sono meno frequenti, il che porta a un notevole miglioramento delle prestazioni di scrittura.

Pubblicazione di un file multimediale dallo spazio di archiviazione offline

Dopo aver scaricato un file multimediale, probabilmente vorrai che il service worker lo fornisca da IndexedDB invece di 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 quindi in getVideoResponse()?

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

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

  • Abbiamo bisogno di un oggetto che non contenga tutti i suoi dati in memoria, quindi probabilmente dovremo scegliere 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 attualmente, abbiamo dovuto implementare un supporto di base per le richieste di intervalli 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;

Dai un'occhiata al codice sorgente dei service worker della PWA demo di Kino per scoprire come leggiamo i dati dei file da IndexedDB e creiamo un flusso in un'applicazione reale.

Altre considerazioni

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

  • Integrazione dell'API Media Session che consente agli utenti di controllare la riproduzione dei contenuti multimediali utilizzando chiavi multimediali hardware dedicate o da popup di notifica dei contenuti multimediali.
  • Memorizzazione nella cache di altri asset associati ai file multimediali, ad esempio i sottotitoli e le immagini poster, utilizzando la vecchia API Cache.
  • Supporto per il download dei video stream (DASH, HLS) all'interno dell'app. Poiché i file manifest dello stream di solito dichiarano più origini con velocità in bit diverse, devi trasformare il file manifest e scaricare una sola versione multimediale prima di memorizzarlo per la visualizzazione offline.

Nel prossimo video parleremo della Riproduzione veloce con precaricamento audio e video.