PWA avec streaming hors connexion

Derek Herman
Derek Herman
Jaroslav Polakovič
Jarooslav Polakovič

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

Mieux encore, vous pourriez proposer une expérience multimédia en streaming hors connexion. Il s'agit d'un perfectionnement que vous pourriez offrir à vos utilisateurs de différentes manières. Cependant, cela crée un problème vraiment unique : les fichiers multimédias peuvent être très volumineux. Alors, vous vous demandez peut-être:

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

Dans cet article, nous abordons 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 multimédia en streaming hors connexion sans utiliser de framework fonctionnel ou de présentation. Les exemples suivants sont principalement fournis à des fins pédagogiques, 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 rentabilité pour développer la vôtre, créer une PWA avec streaming hors connexion peut s'avérer difficile. Dans cet article, vous découvrirez les API et les techniques utilisées pour 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 pour télécharger et stocker les éléments nécessaires à l'expérience hors connexion: documents, feuilles de style, images, etc.

Voici un exemple de base 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 difficilement pratique.

Par exemple, l'API Cache:

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

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

Aujourd'hui, l'API Fetch permet d'accéder aux fichiers distants de manière asynchrone entre plusieurs navigateurs. Dans notre cas d'utilisation, elle vous permet d'accéder à des fichiers vidéo volumineux en tant que 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 que de nombreuses métadonnées soient associées à votre fichier multimédia, telles que le nom, la description, la durée d'exécution, la catégorie, etc.

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

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

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

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

  • Possibilité d'interrompre et de reprendre les téléchargements incomplets
  • Tampon personnalisé permettant de stocker des fragments de données dans la base de données.

Avant de vous montrer comment ces fonctionnalités sont mises en œuvre, nous allons d'abord récapituler rapidement comment vous pouvez 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 avez remarqué que await reader.read() est dans une boucle ? C'est ainsi que vous recevrez des morceaux de données depuis un flux lisible à mesure qu'ils arrivent du réseau. Réfléchissez à l'intérêt de cette méthode: vous pouvez commencer à traiter vos données avant même qu'elles n'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. Étant donné que le serveur de PWA de démonstration Kino prend en charge 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 la 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 constaterez probablement que vos écritures IndexedDB sont beaucoup plus lentes que votre téléchargement. Cela n'est pas dû au fait que les écritures IndexedDB sont lentes, mais plutôt parce que nous augmentons considérablement la charge transactionnelle 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 peuvent être émis par le flux rapidement. 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 blocs de données arrivent du réseau, nous les ajoutons d'abord à notre tampon. Si les données entrantes ne rentrent 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 dans une base de données indexée 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 depuis 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 en getVideoResponse() ?

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

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

  • Nous avons besoin d'un objet qui ne contient pas toutes ses données en mémoire. Nous choisirons donc probablement le ReadableStream.

De plus, comme nous traitons des fichiers volumineux et que nous voulions autoriser les navigateurs à ne demander que la partie du fichier dont ils ont actuellement besoin, nous avons dû mettre en œuvre une compatibilité 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 démonstration de Kino pour découvrir comment nous lisons les données des fichiers IndexedDB et créons un flux dans une application réelle.

Autres points à prendre en compte

Une fois les principaux obstacles résolus, vous pouvez commencer à ajouter des fonctionnalités utiles à votre application vidéo. Voici quelques exemples de fonctionnalités disponibles dans la PWA de démonstration Kino:

  • L'intégration de l'API Media Session qui permet à vos utilisateurs de contrôler la lecture de contenus multimédias à l'aide de clés multimédias matérielles dédiées ou à partir de fenêtres pop-up de notification multimédia.
  • Mise en cache d'autres éléments associés aux fichiers multimédias tels que les sous-titres et les 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 ne télécharger qu'une seule version multimédia avant de le stocker pour un visionnage hors connexion.

Dans la suite, vous allez découvrir la lecture rapide avec préchargement audio et vidéo.