AWP con transmisión sin conexión

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Las apps web progresivas aportan muchas funciones que antes se reservaban para aplicaciones nativas a la Web. Una de las funciones más importantes asociadas con las AWP es la experiencia sin conexión.

Lo mejor sería una experiencia de transmisión de medios sin conexión, que es una mejora que podrías ofrecer a tus usuarios de diferentes maneras. Sin embargo, esto crea un problema realmente único, los archivos multimedia pueden ser muy grandes. Quizás te preguntes lo siguiente:

  • ¿Cómo descargo y almaceno un archivo de video grande?
  • ¿Y cómo se lo proporciono al usuario?

En este artículo, abordaremos las respuestas a estas preguntas y haremos referencia a la AWP de demostración de Kino que creamos y que te proporciona ejemplos prácticos sobre cómo implementar una experiencia de transmisión de medios sin conexión sin usar ningún framework funcional o de presentación. Los siguientes ejemplos se usan principalmente con fines educativos, ya que, en la mayoría de los casos, probablemente deberías usar uno de los Media Frameworks existentes para proporcionar estas funciones.

A menos que tengas un buen caso comercial para desarrollar el tuyo, compilar una AWP con transmisión sin conexión tiene sus desafíos. En este artículo, obtendrás información sobre las APIs y las técnicas que se usan para brindar a los usuarios una experiencia de medios sin conexión de alta calidad.

Cómo descargar y almacenar un archivo multimedia grande

Por lo general, las apps web progresivas usan la API de caché conveniente para descargar y almacenar los elementos necesarios para proporcionar una experiencia sin conexión: documentos, hojas de estilo, imágenes y otros.

A continuación, te mostramos un ejemplo básico de cómo usar la API de Cache dentro de 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 técnicamente funciona, el uso de la API de caché tiene varias limitaciones que hacen que su uso con archivos grandes no sea práctico.

Por ejemplo, la API de Cache no realiza las siguientes acciones:

  • Te permiten pausar y reanudar las descargas fácilmente.
  • Te permite hacer un seguimiento del progreso de las descargas.
  • Ofrece 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 entre navegadores de acceder a archivos remotos de manera asíncrona. En nuestro caso de uso, te permite acceder a archivos de video grandes como una transmisión y almacenarlos de forma incremental como fragmentos mediante una solicitud de rango HTTP.

Ahora que puedes leer los fragmentos de datos con la API de Fetch, también necesitas almacenarlos. Es probable que haya muchos metadatos asociados con tu archivo multimedia, como nombre, descripción, duración del tiempo de ejecución, categoría, etcétera.

No almacenas solo un archivo multimedia, sino un objeto estructurado, y el archivo multimedia es solo una de sus propiedades.

En este caso, la API de IndexedDB proporciona una solución excelente para almacenar los datos y los metadatos del contenido multimedia. 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

Compilamos un par de funciones interesantes en torno a la API de Fetch en nuestra AWP de demostración, que llamamos Kino. El código fuente es público, así que no dudes en revisarlo.

  • La capacidad de pausar y reanudar descargas incompletas
  • 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 resumen rápido 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);
}

¿Notas que await reader.read() está en un bucle? Así es como recibirás los fragmentos de un flujo legible a medida que lleguen desde la red. Considera lo útil que es esto: puedes comenzar a procesar tus datos incluso antes de que lleguen todos de la red.

Reanudando descargas

Cuando se pausa o interrumpe una descarga, los fragmentos de datos que llegaron se almacenarán de forma segura en una base de datos IndexedDB. Luego, podrás mostrar un botón para reanudar una descarga en tu aplicación. Debido a que el servidor de AWP de demostración de Kino admite solicitudes de rango HTTP, reanudar una descarga es un poco 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 dataChunk en una base de datos IndexedDB es simple. Esos valores ya son instancias de ArrayBuffer, que se pueden almacenar directamente en IndexedDB, por lo que podemos crear un objeto con una 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 las operaciones de escritura de IndexedDB son mucho más lentas que la descarga. Esto no se debe a que las operaciones de escritura de IndexedDB sean lentas, sino a que agregamos una gran cantidad de sobrecarga transaccional mediante la creación de una transacción nueva por cada fragmento de datos que recibimos de una red.

Los fragmentos descargados pueden ser bastante pequeños, y la transmisión puede emitirlos en sucesión rápida. Debes limitar la tasa de operaciones de escritura de IndexedDB. En la AWP de demostración de Kino, hacemos esto mediante la implementación de un búfer de escritura intermedio.

A medida que llegan los fragmentos de datos desde la red, primero los anexamos a nuestro búfer. Si los datos entrantes no caben, vacíamos el búfer completo en la base de datos y lo borramos antes de agregar el resto de los datos. Como resultado, nuestras operaciones de escritura de IndexedDB son menos frecuentes, lo que genera un rendimiento de escritura significativamente mejorado.

Cómo entregar un archivo multimedia desde el almacenamiento sin conexión

Una vez que hayas descargado un archivo multimedia, probablemente desees que tu service worker lo entregue desde IndexedDB en lugar de recuperar el archivo desde 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 objeto Response 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: Blob, BufferSource, ReadableStream, entre otros.

  • Necesitamos un objeto que no tenga todos sus datos en la memoria, por lo que es probable que queramos elegir el ReadableStream.

Además, debido a que nos ocupamos de archivos grandes y queríamos permitir que los navegadores solo soliciten la parte del archivo que necesitan actualmente, tuvimos que 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 código fuente del service worker de demostración de la AWP de Kino para saber cómo leemos los datos de archivos de IndexedDB y cómo construyemos una transmisión en una aplicación real.

Otras consideraciones

Con los principales obstáculos en tu camino, 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 AWP de demostración de Kino:

  • Integración con la API de Media Session que permite a los usuarios controlar la reproducción de contenido multimedia con teclas multimedia de hardware dedicado o desde ventanas emergentes de notificaciones multimedia
  • Almacenar en caché otros elementos asociados con los archivos multimedia, como los subtítulos y las imágenes de pósteres, usa la API de Cache anterior.
  • Compatibilidad con descargas de transmisiones de video (DASH, HLS) dentro de la app. Debido a que los manifiestos de transmisión generalmente declaran varias fuentes con distintas tasas de bits, debes transformar el archivo de manifiesto y descargar solo una versión multimedia antes de almacenarla para la visualización sin conexión.

A continuación, aprenderás sobre la Reproducción rápida con carga previa de audio y video.