PWA con streaming offline

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

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

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

  • Come faccio a scaricare e archiviare un file video di grandi dimensioni?
  • E come faccio a 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. 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 caso aziendale per sviluppare una tua applicazione, creare una PWA con i flussi offline ha le sue 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 in genere utilizzano la comoda API Cache per scaricare e archiviare gli asset necessari per offrire l'esperienza offline: documenti, fogli di stile, immagini e altro.

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 riportato sopra funzioni tecnicamente, l'utilizzo dell'API Cache presenta diverse limitazioni che ne rendono poco pratico l'utilizzo con file di grandi dimensioni.

Ad esempio, l'API Cache non:

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

Tutti questi problemi rappresentano un grave limite per qualsiasi applicazione video. Esaminiamo alcune altre opzioni che potrebbero essere più appropriate.

Oggi, l'API Fetch è una modalità 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 flusso 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 ci sia un gruppo di metadati associati al file multimediale, come nome, descrizione, durata del runtime, 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 rappresenta una soluzione eccellente per archiviare sia i dati multimediali che i metadati. Può contenere facilmente enormi quantità di dati binari e offre anche indici che consentono di eseguire ricerche molto rapide nei dati.

Download di file multimediali utilizzando l'API Fetch

Abbiamo creato un paio di funzionalità interessanti relative all'API Fetch nella nostra PWA demo, che abbiamo chiamato Kino. Il codice sorgente è pubblico, quindi non esitare 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 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);
}

Noti che await reader.read() è in un loop? È così che riceverai blocchi di dati da un flusso leggibile quando arrivano dalla rete. Valuta quanto utili questi dati: puoi iniziare a elaborare i tuoi dati anche prima che arrivino tutti dalla rete.

Ripresa dei download in corso...

Quando un download viene messo in pausa o interrotto, i blocchi di dati arrivati verranno archiviati in modo sicuro 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 di intervallo HTTP, la ripresa di un download è piuttosto 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

Il processo per scrivere i valori dataChunk in un database IndexedDB è semplice. Questi valori sono già istanze ArrayBuffer, memorizzabili 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 = () => { ... }

Sebbene questo approccio funzioni, probabilmente scoprirai che le scritture IndexedDB sono molto più lente del download. Questo non è perché le scritture IndexedDB sono lente, ma stiamo aggiungendo molto overhead 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 dallo stream in rapida successione. Devi limitare la frequenza delle scritture IndexedDB. Nella PWA demo di Kino, lo facciamo implementando un buffer di scrittura intermediario.

Quando arrivano blocchi di dati dalla rete, li aggiungiamo prima al buffer. Se i dati in entrata non sono compatibili, svuota il database e cancelliamo l'intero buffer 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 service worker lo fornisca da IndexedDB invece di recuperare il file 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() prevede un oggetto Response come parametro.

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

  • Abbiamo bisogno di un oggetto che non conserva 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 attualmente bisogno, dovevamo 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;

Dai un'occhiata al codice sorgente dei service worker della PWA Kino per scoprire come stiamo leggendo i dati dei file da IndexedDB e costruendo un flusso in un'applicazione reale.

Altre considerazioni

Grazie agli ostacoli principali, ora puoi iniziare ad aggiungere alcune funzionalità interessanti per la 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 multimediale utilizzando tasti multimediali hardware dedicati o da popup di notifica multimediali.
  • Memorizzazione nella cache di altre risorse associate ai file multimediali, come i sottotitoli e le immagini poster, utilizzando la vecchia API Cache.
  • Supporto per il download di stream video (DASH, HLS) all'interno dell'app. Poiché i manifest di streaming dichiarano generalmente più origini con velocità in bit diverse, devi trasformare il file manifest e scaricare solo una versione multimediale prima di memorizzarla per la visualizzazione offline.

Nella sezione successiva scoprirai la riproduzione rapida con precaricamento di audio e video.