PWA con streaming offline

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Le app web progressive offrono molte funzionalità precedentemente riservate agli annunci nativi le applicazioni sul web. Una delle caratteristiche più importanti associate a Le PWA sono un'esperienza offline.

Ancora meglio sarebbe un'esperienza multimediale in streaming offline, una che puoi offrire agli utenti in vari modi. Tuttavia, Ciò 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 mostrarlo all'utente?

In questo articolo illustreremo le risposte a queste domande, fare riferimento alla PWA demo di Kino che abbiamo creato, che offre pratiche di come implementare un'esperienza di streaming multimediale offline senza utilizzando quadri funzionali o di presentazione. Di seguito sono riportati alcuni esempi principalmente a scopo didattico, perché nella maggior parte dei casi dovresti usare uno dei Media Framework esistenti per offrire queste funzionalità.

A meno che tu non abbia un buon business case per svilupparne uno proprio, creando una PWA con i flussi offline ha le sue sfide. In questo articolo, scoprirai le API e le tecniche utilizzate per fornire agli utenti contenuti multimediali offline di alta qualità un'esperienza senza intervento manuale.

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

Mentre l'esempio precedente funziona tecnicamente, l'uso dell'API Cache offre diverse limitazioni che rendono impraticabile l'utilizzo di 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 è un modo cross-browser per accedere in modo asincrono . Nel nostro caso d'uso consente di accedere a file video di grandi dimensioni come stream archiviarle in modo incrementale come blocchi usando una richiesta di intervallo HTTP.

Ora che puoi leggere i blocchi di dati con l'API Fetch devi anche e archiviarle. È probabile che ci sia una serie di metadati associati ai tuoi contenuti multimediali file come nome, descrizione, durata del runtime, categoria e così via.

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

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

Download di file multimediali utilizzando l'API Fetch

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

  • 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à, rapido riepilogo di come utilizzare l'API Fetch per scaricare 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? Ecco come riceverai i chunk di dati da uno stream leggibile quando arrivano dalla rete. Valuta come è utile perché puoi iniziare a elaborare i dati ancora 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 in un database IndexedDB. Puoi quindi visualizzare un pulsante riprendi un download nell'applicazione. Poiché il server PWA demo di Kino supporta le richieste di intervallo HTTP; il ripristino 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

In base alla carta, il processo di scrittura dei 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 archiviarli.

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 il tuo IndexedDB scrive sono molto più lenti del tuo download. Questo non è perché IndexedDB scrive sono lenti, è perché stiamo aggiungendo molto overhead transazionale creando una nuova transazione per ogni blocco di dati ricevuto 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 La 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 se i dati in arrivo non sono adatti, facciamo il flush dell'intero buffer nel database cancellarli prima di aggiungere gli altri dati. Di conseguenza il nostro IndexedDB le scritture sono meno frequenti e ciò porta a un miglioramento significativo delle le prestazioni dei dispositivi.

Pubblicazione di un file multimediale da archiviazione offline

Una volta scaricato un file multimediale, è probabile che il Service worker da IndexedDB anziché 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 esistono diversi tipi di oggetti per creare un'istanza di un oggetto Response: Blob, BufferSource, ReadableStream e altri.

  • Abbiamo bisogno di un oggetto che non contenga tutti i suoi dati in memoria, quindi probabilmente vorrai scegliere il ReadableStream.

Inoltre, dato che abbiamo a che fare con file di grandi dimensioni, e volevamo consentire ai browser richiedere solo la parte del file di cui hanno attualmente bisogno, dovevamo implementare del 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 consultare il codice sorgente dei service worker della PWA Kino per trovare la demo come stiamo leggendo i dati di file da IndexedDB e costruendo un flusso in un'applicazione reale.

Altre considerazioni

Ora che i principali ostacoli non sono sulla strada giusta, puoi iniziare ad aggiungere funzionalità utili per la tua applicazione video. Ecco alcuni esempi che troverai nella PWA demo di Kino:

  • Integrazione dell'API Media Session che consente agli utenti di controllare i contenuti multimediali. la riproduzione usando tasti multimediali hardware dedicati o dalla notifica multimediale popup.
  • Memorizzazione nella cache di altre risorse associate ai file multimediali, come i sottotitoli e immagini poster utilizzando la vecchia API Cache.
  • Supporto per il download di stream video (DASH, HLS) all'interno dell'app. Poiché lo stream di solito dichiarano più sorgenti con velocità in bit diverse, è necessario trasformare il file manifest e scaricare solo una versione multimediale prima di archiviarlo per la visualizzazione offline.

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