PWA avec streaming hors connexion

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Les progressive web apps apportent au Web de nombreuses fonctionnalités auparavant réservées aux applications natives. L'une des caractéristiques les plus importantes associées aux PWA est une expérience hors connexion.

Vous pouvez même proposer une expérience multimédia en streaming hors connexion, ce qui est une amélioration que vous pouvez offrir à vos utilisateurs de plusieurs façons. Cependant, cela crée 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 allons répondre à ces questions en faisant référence à la PWA de démonstration Kino que nous avons créée. Elle vous fournit des exemples pratiques sur la façon d'implémenter une expérience multimédia de streaming 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.

Sauf si vous avez un bon argumentaire commercial pour développer la vôtre, la création d'une PWA avec streaming hors connexion présente des défis. Dans cet article, vous allez découvrir 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 applications Web progressives utilisent généralement l'API Cache pratique pour télécharger et stocker les éléments requis pour fournir 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 limites qui rendent son utilisation avec des fichiers volumineux peu pratique.

Par exemple, l'API Cache ne:

  • vous permet de suspendre et de reprendre facilement les téléchargements ;
  • vous permet de 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. Voyons d'autres options qui pourraient être plus appropriées.

De nos jours, l'API Fetch est un moyen inter-navigateur d'accéder de manière asynchrone aux fichiers distants. Dans notre cas d'utilisation, il vous permet d'accéder à de grands fichiers vidéo en tant que flux et de les stocker de manière incrémentielle en tant que segments à 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 est probable qu'un grand nombre de métadonnées soient associées à votre fichier multimédia, comme le nom, la description, la durée d'exécution, la catégorie, etc.

Vous ne stockez pas un seul fichier multimédia, mais 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 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é permettant de stocker des blocs 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 vous expliquer 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 blocs de données à partir d'un flux lisible à mesure qu'ils arrivent du réseau. Réfléchissez à l'utilité de cette fonctionnalité: vous pouvez commencer à traiter vos données avant même qu'elles ne soient toutes reçues du réseau.

Reprendre les téléchargements

Lorsqu'un téléchargement est mis en pause ou interrompu, les blocs de données reçus 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 PWA de démonstration Kino est compatible avec 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 de 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 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 au fait que nous ajoutons beaucoup de frais généraux transactionnels en créant une nouvelle transaction pour chaque bloc 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 implémentons 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 rentrent pas, nous vidageons le tampon complet 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 qu'un fichier multimédia a été téléchargé, 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 contient pas toutes ses données en mémoire. Nous allons donc probablement choisir ReadableStream.

De plus, comme nous traitons de gros fichiers et que nous souhaitions autoriser les navigateurs à ne demander que la partie du fichier dont ils ont actuellement besoin, nous avons dû implémenter une compatibilité de base avec 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 démonstration PWA Kino pour découvrir comment nous lisons les données de fichier à partir d'IndexedDB et construisons un flux dans une application réelle.

Autres points à noter

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 que vous trouverez dans la PWA de démonstration Kino:

  • Intégration de l'API Media Session qui permet à vos utilisateurs de contrôler la lecture multimédia à l'aide de touches multimédias matérielles dédiées ou à partir de pop-ups 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 d'affiche, à 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 la stocker pour la lecture hors connexion.

Vous allez maintenant découvrir la lecture rapide avec préchargement audio et vidéo.