Publicado: 5 de julio de 2021
Las apps web progresivas incorporan a la Web muchas funciones que antes estaban reservadas para las aplicaciones nativas. Una de las funciones más destacadas asociadas a las PWA es la experiencia sin conexión.
Aún mejor sería una experiencia de transmisión de contenido multimedia sin conexión, una mejora que podrías ofrecer a tus usuarios de varias maneras. Sin embargo, esto crea un problema verdaderamente único: los archivos multimedia pueden ser muy grandes. Por lo tanto, es posible que te preguntes lo siguiente:
- ¿Cómo puedo descargar y almacenar un archivo de video grande?
- ¿Y cómo se la presento al usuario?
En este artículo, analizaremos las respuestas a estas preguntas y haremos referencia a la PWA de demostración Kino que creamos y que te proporciona ejemplos prácticos de cómo puedes implementar una experiencia de transmisión de contenido multimedia sin conexión sin usar ningún framework funcional o de presentación. Los siguientes ejemplos son principalmente para fines educativos, ya que, en la mayoría de los casos, probablemente deberías usar uno de los Frameworks de medios existentes para proporcionar estas funciones.
A menos que tengas un buen caso de negocio para desarrollar tu propia PWA, crear una con transmisión sin conexión tiene sus desafíos. En este artículo, aprenderás sobre las APIs y las técnicas que se usan para brindar a los usuarios una experiencia multimedia sin conexión de alta calidad.
Descarga y almacenamiento de un archivo multimedia grande
Por lo general, las aplicaciones web progresivas usan la conveniente API de Cache para descargar y almacenar los recursos necesarios para brindar la experiencia sin conexión: documentos, hojas de estilo, imágenes y otros.
A continuación, se muestra un ejemplo básico del uso de la API de Cache en 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',
]);
})
);
});
Si bien el ejemplo anterior funciona técnicamente, el uso de la API de Cache tiene varias limitaciones que hacen que su uso con archivos grandes sea poco práctico.
Por ejemplo, la API de Cache no hace lo siguiente:
- Permiten pausar y reanudar las descargas fácilmente
- Te permite hacer un seguimiento del progreso de las descargas
- Ofrecer una forma de responder correctamente a las solicitudes de rango HTTP
Todos estos problemas son limitaciones bastante graves para cualquier aplicación de video. Revisemos otras opciones que podrían ser más adecuadas.
Actualmente, la API de Fetch es una forma de acceder de forma asíncrona a archivos remotos en todos los navegadores. En nuestro caso de uso, te permite acceder a archivos de video grandes como una transmisión y almacenarlos de forma incremental como fragmentos con una solicitud de rango HTTP.
Ahora que puedes leer los fragmentos de datos con la API de Fetch, también debes almacenarlos. Es probable que haya muchos metadatos asociados a tu archivo multimedia, como el nombre, la descripción, la duración, la categoría, etcétera.
No solo almacenas un archivo multimedia, sino que almacenas un objeto estructurado, y el archivo multimedia es solo una de sus propiedades.
En este caso, la API de IndexedDB proporciona una excelente solución para almacenar tanto los datos multimedia como los metadatos. Puede contener grandes cantidades de datos binarios con facilidad y también ofrece índices que te permiten realizar búsquedas de datos muy rápidas.
Cómo descargar archivos multimedia con la API de Fetch
Creamos algunas funciones interesantes en torno a la API de Fetch en nuestra AWP de demostración, a la que llamamos Kino. El código fuente es público, así que no dudes en revisarlo.
- La capacidad de detener y reanudar descargas incompletas
- Es un búfer personalizado para almacenar fragmentos de datos en la base de datos.
Antes de mostrar cómo se implementan esas funciones, primero haremos un breve resumen de cómo puedes usar la API de Fetch para descargar archivos.
/**
* 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);
}
¿Observas que await reader.read()
está en un bucle? Así es como recibirás fragmentos de datos de una transmisión legible a medida que lleguen de la red. Considera lo útil que es esto: puedes comenzar a procesar tus datos incluso antes de que lleguen todos desde la red.
Cómo reanudar las descargas
Cuando se pausa o interrumpe una descarga, los fragmentos de datos que llegaron se almacenan de forma segura en una base de datos de IndexedDB. Luego, puedes mostrar un botón para reanudar una descarga en tu aplicación. Dado que el servidor de la PWA de demostración de Kino admite la reanudación de solicitudes de rango HTTP, reanudar una descarga es bastante sencillo:
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);
}
Búfer de escritura personalizado para IndexedDB
En teoría, el proceso de escribir valores de dataChunk
en una base de datos de IndexedDB es simple. Esos valores ya son instancias de ArrayBuffer
, que se pueden almacenar en IndexedDB directamente, por lo que solo tenemos que crear un objeto con la forma adecuada y almacenarlo.
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 = () => { ... }
Si bien este enfoque funciona, es probable que descubras que tus escrituras de IndexedDB son mucho más lentas que tus descargas. Esto no se debe a que las escrituras de IndexedDB sean lentas, sino a que agregamos una gran cantidad de sobrecarga transaccional creando una nueva transacción para cada fragmento de datos que recibimos de una red.
Los fragmentos descargados pueden ser bastante pequeños y pueden emitirse por la transmisión en rápida sucesión. Debes limitar la tasa de escrituras de IndexedDB. En la PWA de demostración de Kino, implementamos un búfer de escritura intermedio para lograrlo.
A medida que llegan fragmentos de datos de la red, primero los agregamos a nuestro búfer. Si los datos entrantes no caben, volcamos el búfer completo en la base de datos y lo borramos antes de agregar el resto de los datos. Como resultado, nuestras escrituras en IndexedDB son menos frecuentes, lo que mejora significativamente el rendimiento de escritura.
Cómo entregar un archivo multimedia desde el almacenamiento sin conexión
Una vez que hayas descargado un archivo multimedia, es probable que quieras que tu service worker lo entregue desde IndexedDB en lugar de recuperarlo de la red.
/**
* 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);
Entonces, ¿qué debes hacer en getVideoResponse()
?
El método
event.respondWith()
espera un objetoResponse
como parámetro.El constructor Response() nos indica que hay varios tipos de objetos que podríamos usar para crear una instancia de un objeto
Response
: unBlob
,BufferSource
,ReadableStream
y muchos más.Necesitamos un objeto que no contenga todos sus datos en la memoria, por lo que probablemente querremos elegir
ReadableStream
.
Además, como trabajamos con archivos grandes y queríamos permitir que los navegadores solo soliciten la parte del archivo que necesitan en ese momento, debimos implementar cierta compatibilidad básica con las solicitudes de rango 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;
No dudes en consultar el Kino para descubrir cómo leemos los datos de archivos de IndexedDB y construimos un flujo en una aplicación real.
Otras consideraciones
Ahora que ya superaste los principales obstáculos, puedes comenzar a agregar algunas funciones deseables a tu aplicación de video. Estos son algunos ejemplos de las funciones que encontrarás en la PWA de demostración de Kino:
- Integración de la API de Media Session que permite a los usuarios controlar la reproducción de contenido multimedia con teclas multimedia de hardware dedicadas o desde ventanas emergentes de notificaciones multimedia.
- Almacenamiento en caché de otros recursos asociados con los archivos multimedia, como subtítulos y las imágenes de póster, con la buena y antigua API de Cache.
- Compatibilidad con la descarga de transmisiones de video (DASH, HLS) dentro de la app. Dado que los manifiestos de transmisión suelen declarar varias fuentes de diferentes tasas de bits, debes transformar el archivo de manifiesto y descargar solo una versión de los medios antes de almacenarla para verla sin conexión.
A continuación, obtendrás información sobre la reproducción rápida con precarga de audio y video.