PWA avec streaming hors connexion

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Les progressive web apps offrent de nombreuses fonctionnalités, jusque-là réservées aux applications natives des applications sur le Web. L'une des fonctionnalités les plus importantes Les PWA sont une expérience hors connexion.

Mieux encore, une expérience multimédia en streaming hors connexion d'amélioration que vous pourriez proposer à vos utilisateurs de différentes manières. Toutefois, Cela crée un problème véritablement unique : les fichiers multimédias peuvent être très volumineux. Donc vous pourriez vous demander:

  • 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, en faisant référence à la PWA de démonstration Kino que nous avons conçue pour vous fournir Exemples de la façon dont vous pouvez implémenter une expérience de streaming multimédia hors connexion sans en utilisant des cadres fonctionnels ou de présentation. Les exemples suivants sont principalement à des fins pédagogiques, car dans la plupart des cas, vous devriez probablement utiliser l'un des Media Frameworks existants pour fournir ces fonctionnalités.

À moins que vous n'ayez une bonne analyse de rentabilisation pour développer votre propre PWA, du streaming hors connexion présente des difficultés. Dans cet article, vous découvrirez Les API et les techniques permettant de fournir aux utilisateurs un support hors connexion de haute qualité expérience.

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

Les progressive web apps utilisent généralement l'API Cache, pratique qui permet à la fois de télécharger et stocker les éléments nécessaires pour offrir l'expérience hors connexion: documents, des feuilles de style, des 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 comporte plusieurs des 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 de manière asynchrone à des objets distants . Dans notre cas d'utilisation, il vous permet d'accéder à des fichiers vidéo volumineux sous forme de flux et les stocker progressivement 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 contenu multimédia par exemple un nom, une description, une durée d'exécution, une catégorie, etc.

Vous ne stockez pas uniquement le 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 constitue une excellente solution pour stocker à la fois les des données et des métadonnées des contenus multimédias. Il peut facilement contenir d’énormes quantités de données binaires et il 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é Kino, le code source étant public, n'hésitez 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 un bref récapitulatif de la façon dont 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 remarquez que await reader.read() est dans une boucle ? C'est ainsi que vous recevrez des fragments de données provenant d'un flux lisible à mesure qu'elles arrivent du réseau. Réfléchissez à la façon dont 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 arrivés être stockées en toute sécurité dans une base de données IndexedDB. Vous pouvez ensuite afficher un bouton pour de 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 très simple. Ces valeurs sont déjà des instances ArrayBuffer, qui peuvent être stockées directement dans IndexedDB. Nous pouvons donc créer un objet dont la forme et les 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 données IndexedDB écrivent sont beaucoup plus lents que votre téléchargement. Ce n'est pas parce que IndexedDB écrit sont lents, car nous ajoutons beaucoup de coûts 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 plutôt petits et émis par le flux une succession rapide. Vous devez limiter le taux d'écritures IndexedDB. Dans Pour ce faire, nous utilisons un tampon d'écriture intermédiaire avec la PWA de démonstration Kino.

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 vidons l'intégralité du tampon dans la base de données et l’effacer avant d’ajouter le reste des données. C'est pourquoi l'index IndexedDB les écritures sont moins fréquentes, ce qui améliore considérablement le processus d'écriture des performances.

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

Une fois le fichier multimédia téléchargé, vous souhaiterez probablement que votre service worker le diffuser à partir de IndexedDB au lieu de récupérer le fichier à partir du 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 qu'il existe plusieurs types d'objets pour instancier un objet Response: Blob, BufferSource, ReadableStream et plus encore.

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

De plus, comme nous traitons des fichiers volumineux et que nous voulons permettre aux navigateurs ne demander que la partie du fichier dont ils ont actuellement besoin, nous avons dû implémenter des fonctionnalités 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 trouver la façon dont nous lisons les données d'un fichier à partir de IndexedDB et que nous construisons un flux dans une vraie application.

Autres points à prendre en compte

Maintenant que vous avez les principales difficultés rencontrées, vous pouvez commencer à en ajouter et ajouter des fonctionnalités intéressantes à votre application vidéo. Voici quelques exemples disponibles dans la PWA de démonstration Kino:

  • Intégration de l'API Media Session pour permettre aux utilisateurs de contrôler les contenus multimédias lecture via des touches multimédias matérielles dédiées ou depuis une notification multimédia des fenêtres pop-up.
  • La mise en cache des autres éléments associés aux fichiers multimédias tels que les sous-titres 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. Parce que le flux les fichiers manifestes déclarent généralement plusieurs sources de débits différents, transformer le fichier manifeste et télécharger une seule version multimédia avant de la stocker pour un visionnage hors connexion.

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