Publié le 5 juillet 2021
Les progressive web apps apportent au 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.
Une expérience de streaming multimédia hors connexion serait encore mieux. Vous pouvez l'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. Vous vous demandez peut-être :
- Comment télécharger et stocker un fichier vidéo volumineux ?
- Et comment le proposer à l'utilisateur ?
Dans cet article, nous répondrons à ces questions en nous référant à la PWA de démonstration Kino que nous avons créée. Elle vous fournit des exemples pratiques de la façon dont vous pouvez implémenter une expérience de streaming multimédia hors connexion sans utiliser de frameworks fonctionnels ni de présentation. Les exemples suivants sont principalement destinés à des fins pédagogiques, car dans la plupart des cas, vous devriez probablement utiliser l'un des frameworks multimédias existants pour fournir ces fonctionnalités.
À moins que vous n'ayez une bonne raison de développer votre propre PWA, la création d'une PWA avec streaming hors connexion présente des difficultés. 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 la pratique API Cache 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 limites qui rendent son utilisation avec des fichiers volumineux peu pratique.
Par exemple, l'API Cache ne :
- vous permettre de suspendre et de reprendre facilement les téléchargements ;
- vous permet de suivre la progression des téléchargements ;
- Offrir un moyen de répondre correctement aux requêtes HTTP de plage
Tous ces problèmes constituent des limites assez sérieuses pour toute application vidéo. Examinons d'autres options qui pourraient être plus appropriées.
Aujourd'hui, l'API Fetch est un moyen multi-navigateur d'accéder de manière asynchrone à des fichiers distants. Dans notre cas d'utilisation, il 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 blocs à l'aide d'une requête de plage HTTP.
Maintenant que vous pouvez lire les blocs de données avec l'API Fetch, vous devez également les stocker. Il y a probablement un grand nombre de métadonnées associées à votre fichier multimédia, telles que le nom, la description, la durée, la catégorie, etc.
Vous ne stockez pas un seul fichier multimédia, mais un objet structuré dont le fichier multimédia n'est qu'une des 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 appelée Kino. Le code source est public, alors n'hésitez pas à l'examiner.
- la possibilité de suspendre et de reprendre les téléchargements incomplets ;
- Un tampon personnalisé pour 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 rappeler rapidement 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);
}
Notez que await reader.read()
est dans une boucle. C'est ainsi que vous recevrez des blocs de données d'un flux lisible à mesure qu'ils arrivent du réseau. Imaginez l'utilité de cette fonctionnalité : vous pouvez commencer à traiter vos données avant même qu'elles n'arrivent toutes 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 permettant de reprendre un téléchargement dans votre application. Étant donné que le serveur de 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);
}
Mémoire tampon d'écriture personnalisée 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 constaterez 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 surcharge transactionnelle en créant une nouvelle transaction pour chaque bloc de données que nous recevons d'un réseau.
Les blocs téléchargés peuvent être assez petits et peuvent être émis par le flux en succession rapide. Vous devez limiter la fréquence des écritures IndexedDB. Dans la PWA de démonstration Kino, nous le faisons en implémentant un tampon d'écriture intermédiaire.
Lorsque des blocs de données arrivent du réseau, nous les ajoutons d'abord à notre tampon. Si les données entrantes ne tiennent pas, nous vidons 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 depuis un espace de stockage hors connexion
Une fois qu'un fichier multimédia est 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 objetResponse
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 stocke pas toutes ses données en mémoire. Nous allons donc probablement choisir
ReadableStream
.
De plus, comme nous traitons des fichiers volumineux et que nous voulions permettre aux navigateurs de ne demander que la partie du fichier dont ils ont besoin, nous avons dû implémenter une prise en charge de base des requêtes HTTP par plage.
/**
* 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 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 surmonté les principaux obstacles, vous pouvez commencer à ajouter des fonctionnalités intéressantes à 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 de contenus multimédias à l'aide de touches multimédias matérielles dédiées ou à partir de pop-ups de notifications multimédias.
- Mise en cache d'autres éléments associés aux fichiers multimédias, tels que les sous-titres et les affiches, à l'aide de la bonne vieille 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 binaires 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 visionner hors connexion.
Ensuite, vous découvrirez la lecture rapide avec préchargement audio et vidéo.