PWA avec streaming hors connexion

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Les progressive web apps intègrent sur le Web de nombreuses fonctionnalités auparavant réservées aux applications natives. L'une des fonctionnalités les plus importantes associées aux PWA est l'expérience hors connexion.

Mieux encore, une expérience multimédia en streaming hors connexion serait une amélioration que vous pourriez offrir à vos utilisateurs de différentes manières. Cependant, cela entraîne un problème vraiment unique : les fichiers multimédias peuvent être très volumineux. Vous vous demandez peut-être:

  • Comment télécharger et stocker un fichier vidéo volumineux ?
  • Et comment la présenter à l'utilisateur ?

Dans cet article, nous aborderons les réponses à ces questions, tout en faisant référence à la PWA de démonstration Kino que nous avons créée. Elle fournit des exemples pratiques de mise en œuvre d'une expérience de streaming multimédia hors connexion sans utiliser de framework fonctionnel ou de présentation. Les exemples suivants sont principalement fournis à titre indicatif, car dans la plupart des cas, il est préférable d'utiliser l'un des frameworks multimédias existants pour fournir ces fonctionnalités.

À moins que vous n'ayez une bonne analyse de rentabilisation pour développer votre propre PWA, la création d'une PWA avec flux hors connexion présente des difficultés. Cet article présente les API et les techniques permettant d'offrir aux utilisateurs une expérience multimédia hors connexion de haute qualité.

Télécharger et stocker un fichier multimédia volumineux

Les progressive web apps utilisent généralement l'API Cache, pratique qui permet de télécharger et de stocker les éléments nécessaires pour fournir une expérience hors connexion: documents, feuilles de style, images, etc.

Voici un exemple basique d'utilisation de l'API Cache dans 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',
      ]);
    })
  );
});

Bien que l'exemple ci-dessus fonctionne techniquement, l'utilisation de l'API Cache présente plusieurs limitations qui rendent son utilisation avec des fichiers volumineux peu pratique.

Par exemple, l'API Cache ne:

  • Suspendre et reprendre facilement les téléchargements
  • suivre la progression des téléchargements ;
  • permettre de répondre correctement aux requêtes de plage HTTP ;

Tous ces problèmes constituent des limitations assez sérieuses pour toute application vidéo. Examinons d'autres options qui pourraient être plus appropriées.

De nos jours, l'API Fetch permet d'accéder aux fichiers distants de manière asynchrone sur plusieurs navigateurs. Dans notre cas d'utilisation, elle vous permet d'accéder à des fichiers vidéo volumineux sous forme de flux et de les stocker de manière incrémentielle sous forme de fragments à l'aide d'une requête de plage HTTP.

Maintenant que vous pouvez lire les fragments de données avec l'API Fetch, vous devez également les stocker. Il y a de fortes chances qu'il y ait de nombreuses métadonnées associées à votre fichier multimédia, telles que: nom, description, durée de l'exécution, catégorie, etc.

Vous ne stockez pas uniquement un fichier multimédia, vous stockez un objet structuré, et le fichier multimédia n'est que l'une de ses propriétés.

Dans ce cas, l'API IndexedDB fournit une excellente solution pour stocker à la fois les données multimédias et les métadonnées. Il peut facilement contenir d'énormes quantités de données binaires et propose également des index qui vous permettent d'effectuer des recherches de données très rapides.

Télécharger des fichiers multimédias à l'aide de l'API Fetch

Nous avons créé quelques fonctionnalités intéressantes autour de l'API Fetch dans notre PWA de démonstration, que nous avons nommée Kino. Le code source est public, n'hésitez donc pas à le consulter.

  • Possibilité de suspendre et de reprendre les téléchargements incomplets
  • Tampon personnalisé pour stocker des fragments de données dans la base de données.

Avant de vous montrer comment ces fonctionnalités sont implémentées, nous allons d'abord récapituler comment utiliser l'API Fetch pour télécharger des fichiers.

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

Vous remarquez que await reader.read() est dans une boucle ? C'est ainsi que vous recevrez des fragments de données depuis un flux lisible à mesure qu'ils arrivent du réseau. Considérez à quel point cela est utile: vous pouvez commencer à traiter vos données avant même qu'elles ne arrivent du réseau.

Reprise des téléchargements

Lorsqu'un téléchargement est suspendu ou interrompu, les fragments de données qui sont arrivés sont stockés de manière sécurisée dans une base de données IndexedDB. Vous pouvez ensuite afficher un bouton pour reprendre un téléchargement dans votre application. Comme le serveur PWA de démonstration Kino accepte les requêtes de plage HTTP, la reprise d'un téléchargement est relativement simple:

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

Tampon d'écriture personnalisé pour IndexedDB

Sur le papier, le processus d'écriture des valeurs dataChunk dans une base de données IndexedDB est simple. Ces valeurs sont déjà des instances ArrayBuffer, qui peuvent être stockées directement dans IndexedDB. Nous pouvons donc simplement créer un objet de forme appropriée et le stocker.

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

Bien que cette approche fonctionne, vous découvrirez probablement que vos écritures IndexedDB sont beaucoup plus lentes que votre téléchargement. Ce n'est pas parce que les écritures IndexedDB sont lentes, mais parce que nous ajoutons beaucoup de frais généraux de transaction en créant une nouvelle transaction pour chaque fragment de données que nous recevons d'un réseau.

Les fragments téléchargés peuvent être assez petits et être émis rapidement par le flux. Vous devez limiter le taux d'écritures IndexedDB. Dans la PWA de démonstration Kino, nous effectuons cette opération en implémentant un tampon d'écriture intermédiaire.

Lorsque les fragments de données arrivent du réseau, nous les ajoutons d'abord à notre tampon. Si les données entrantes ne correspondent pas, nous vidons l'intégralité du tampon dans la base de données et l'effaçons avant d'ajouter le reste des données. Par conséquent, nos écritures IndexedDB sont moins fréquentes, ce qui améliore considérablement les performances d'écriture.

Diffuser un fichier multimédia à partir d'un espace de stockage hors connexion

Une fois que vous avez téléchargé un fichier multimédia, vous souhaitez probablement que votre service worker le diffuse à partir d'IndexedDB au lieu de le récupérer sur le réseau.

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

Que devez-vous faire dans getVideoResponse() ?

  • La méthode event.respondWith() attend un objet Response comme paramètre.

  • Le constructeur Response() nous indique que vous pouvez utiliser plusieurs types d'objets pour instancier un objet Response: Blob, BufferSource, ReadableStream, etc.

  • Nous avons besoin d'un objet qui ne conserve pas toutes ses données en mémoire. Nous allons donc probablement choisir ReadableStream.

En outre, comme nous traitons des fichiers volumineux et que nous voulons autoriser les navigateurs à ne demander que la partie du fichier dont ils ont besoin actuellement, nous avons dû mettre en œuvre une prise en charge de base pour les requêtes de plage 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;

N'hésitez pas à consulter le code source du service worker de la PWA de démonstration Kino pour savoir comment lire les données de fichiers d'IndexedDB et créer un flux dans une application réelle.

Autres points à prendre en compte

Maintenant que vous avez identifié les principaux obstacles, vous pouvez commencer à ajouter des fonctionnalités pratiques à votre application vidéo. Voici quelques exemples de fonctionnalités disponibles dans la PWA de démonstration Kino:

  • Intégration de l'API Media Session qui permet aux utilisateurs de contrôler la lecture de contenus multimédias à l'aide de touches multimédias matérielles dédiées ou à partir de fenêtres pop-up de notifications multimédias.
  • Mise en cache des autres éléments associés aux fichiers multimédias tels que des sous-titres et des images poster à l'aide de l'ancienne API Cache.
  • Prise en charge du téléchargement de flux vidéo (DASH, HLS) dans l'application. Étant donné que les fichiers manifestes de flux déclarent généralement plusieurs sources de débits différents, vous devez transformer le fichier manifeste et télécharger une seule version multimédia avant de le stocker pour un visionnage hors connexion.

Nous allons maintenant nous intéresser à la lecture rapide avec préchargement audio et vidéo.