AWP con transmisión sin conexión

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Las apps web progresivas ofrecen muchas funciones que antes solo estaban disponibles para las aplicaciones nativas. Una de las funciones más destacadas asociadas con las AWP es la experiencia sin conexión.

Aún mejor sería una experiencia de transmisión de contenido multimedia 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. 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 lo entrego al usuario?

En este artículo, analizaremos las respuestas a estas preguntas y haremos referencia a la PWA de demostración Kino que compilamos y que te brinda 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 con fines educativos, ya que, en la mayoría de los casos, es probable que debas usar uno de los frameworks multimedia existentes para proporcionar estas funciones.

A menos que tengas un buen caso de negocio para desarrollar la tuya, 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 proporcionar a los usuarios una experiencia de contenido multimedia sin conexión de alta calidad.

Descarga y almacena un archivo multimedia grande

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

Este es un ejemplo básico de cómo usar la API de caché 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, usar la API de Cache tiene varias limitaciones que hacen que su uso con archivos grandes no sea práctico.

Por ejemplo, la API de Cache no hace lo siguiente:

  • Te permite 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 serias para cualquier aplicación de video. Revisemos otras opciones que podrían ser más apropiadas.

Actualmente, la API de Fetch es una forma multinavegador de acceder de forma asíncrona a archivos remotos. 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 con tu archivo multimedia, como el nombre, la descripción, la duración, la categoría, etcétera.

No solo almacenas 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 excelente solución para almacenar los datos y los metadatos 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

Creamos algunas 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 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 repaso 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);
}

¿Observas que await reader.read() está en un bucle? De esta manera, recibirás fragmentos de datos de un flujo 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 de la red.

Cómo reanudar 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 de IndexedDB. Luego, puedes mostrar un botón para reanudar una descarga en tu aplicación. Debido a que el servidor de la PWA de demostración de Kino admite 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 dataChunk en una base de datos de IndexedDB es simple. Esos valores ya son instancias de ArrayBuffer, que se pueden almacenar directamente en IndexedDB, por lo que solo podemos crear un objeto de 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 tus 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 mucha sobrecarga de transacciones cuando creamos una transacción nueva para cada fragmento de datos que recibimos de una red.

Los fragmentos descargados pueden ser bastante pequeños y la transmisión puede emitirlos en rápida sucesión. Debes limitar la velocidad de las operaciones de escritura de IndexedDB. En la PWA de demostración de Kino, lo hacemos implementando un buffer de escritura intermedio.

A medida que llegan los fragmentos de datos de la red, primero los adjuntamos a nuestro búfer. Si los datos entrantes no se ajustan, borramos 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 en IndexedDB son menos frecuentes, lo que mejora significativamente el rendimiento de la escritura.

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

Una vez que hayas descargado un archivo multimedia, es probable que desees que tu trabajador de servicio 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 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: un Blob, un BufferSource, un ReadableStream y mucho más.

  • Necesitamos un objeto que no contenga todos sus datos en la memoria, por lo que probablemente querremos elegir ReadableStream.

Además, como estamos trabajando con archivos grandes y queríamos permitir que los navegadores solo soliciten la parte del archivo que necesitan en ese momento, tuvimos que implementar 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 trabajador de servicio de la PWA de demostración de Kino para descubrir cómo leemos los datos de archivos de IndexedDB y cómo construimos un flujo en una aplicación real.

Otras consideraciones

Ahora que ya no tienes los obstáculos principales, puedes comenzar a agregar algunas funciones útiles a tu aplicación de video. Estos son algunos ejemplos de las funciones que encontrarías 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 los subtítulos y las imágenes de póster, con la API de Cache.
  • Compatibilidad con la descarga de transmisiones de video (DASH, HLS) dentro de la app. Como 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 multimedia antes de almacenarla para verla sin conexión.

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