PWA com streaming off-line

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Os Progressive Web Apps trazem para a Web vários recursos anteriormente reservados para aplicativos nativos. Um dos recursos mais importantes associados aos PWAs é a experiência off-line.

O melhor seria uma experiência de streaming de mídia off-line, que é uma melhoria que você pode oferecer aos usuários de algumas maneiras diferentes. No entanto, isso cria um problema único: os arquivos de mídia podem ser muito grandes. Então você pode estar se perguntando:

  • Como faço o download e armazeno um arquivo de vídeo grande?
  • E como faço para oferecer ao usuário?

Neste artigo, discutiremos respostas a essas perguntas e faremos referência ao PWA de demonstração Kino que criamos. Ele fornece exemplos práticos de como implementar uma experiência de mídia de streaming off-line sem usar frameworks funcionais ou de apresentação. Os exemplos a seguir são principalmente para fins educacionais, porque na maioria dos casos, você provavelmente usará um dos Media Frameworks disponíveis para fornecer esses recursos.

Criar um PWA com streaming off-line tem desafios, a menos que você tenha um bom caso de negócios para desenvolver o seu. Neste artigo, você vai aprender sobre as APIs e técnicas usadas para oferecer aos usuários uma experiência de mídia off-line de alta qualidade.

Como fazer o download e armazenar um arquivo de mídia grande

Os Progressive Web Apps geralmente usam a API Cache para fazer o download e armazenar os recursos necessários para fornecer a experiência off-line: documentos, folhas de estilo, imagens e outros.

Este é um exemplo básico de como usar a API Cache em um 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',
      ]);
    })
  );
});

Embora o exemplo acima funcione tecnicamente, o uso da API Cache tem várias limitações que tornam o uso com arquivos grandes impraticável.

Por exemplo, a API Cache não:

  • Pause e retome downloads com facilidade
  • Permite acompanhar o progresso dos downloads
  • Oferecer uma maneira de responder corretamente às solicitações de intervalo HTTP

Todos esses problemas são limitações sérias para qualquer aplicativo de vídeo. Vamos analisar outras opções que podem ser mais adequadas.

Atualmente, a API Fetch é uma maneira de acessar arquivos remotos de forma assíncrona em todos os navegadores. No nosso caso de uso, ele permite acessar arquivos de vídeo grandes como um stream e armazená-los incrementalmente como blocos usando uma solicitação de intervalo HTTP.

Agora que você pode ler os blocos de dados com a API Fetch, também é necessário armazená-los. É provável que haja muitos metadados associados ao seu arquivo de mídia, como nome, descrição, duração, categoria etc.

Você não está armazenando apenas um arquivo de mídia, mas um objeto estruturado, e o arquivo de mídia é apenas uma das propriedades dele.

Nesse caso, a API IndexedDB é uma excelente solução para armazenar os dados de mídia e os metadados. Ele pode conter grandes quantidades de dados binários com facilidade e também oferece índices que permitem realizar pesquisas de dados muito rápidas.

Download de arquivos de mídia usando a API Fetch

Criamos alguns recursos interessantes em torno da API Fetch na nossa PWA de demonstração, que chamamos de Kino. O código-fonte é público, então fique à vontade para analisá-lo.

  • A capacidade de pausar e retomar downloads incompletos.
  • Um buffer personalizado para armazenar partes de dados no banco de dados.

Antes de mostrar como esses recursos são implementados, primeiro vamos fazer um resumo rápido de como você pode usar a API Fetch para fazer o download de arquivos.

/**
 * 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);
}

Percebeu que await reader.read() está em loop? É assim que você vai receber blocos de dados de um fluxo legível conforme eles chegam da rede. Pense em como isso é útil: é possível começar a processar seus dados antes mesmo que eles cheguem da rede.

Retomar downloads

Quando um download é pausado ou interrompido, os blocos de dados que chegaram são armazenados com segurança em um banco de dados do IndexedDB. Você pode mostrar um botão para continuar um download no seu aplicativo. Como o servidor PWA de demonstração do Kino oferece suporte a solicitações de intervalos HTTP (link em inglês), retomar um download é um tanto simples:

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);
}

Buffer de gravação personalizado para IndexedDB

No papel, o processo de gravação de valores dataChunk em um banco de dados IndexedDB é simples. Esses valores já são instâncias ArrayBuffer, que podem ser armazenadas diretamente no IndexedDB. Portanto, podemos criar um objeto de uma forma adequada e armazená-lo.

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 = () => { ... }

Embora essa abordagem funcione, é provável que você descubra que as gravações do IndexedDB são muito mais lentas do que o download. Isso não acontece porque as gravações do IndexedDB são lentas, mas porque estamos adicionando muita sobrecarga transacional ao criar uma nova transação para cada bloco de dados que recebemos de uma rede.

Os blocos transferidos podem ser pequenos e podem ser emitidos pelo stream em sucessão rápida. É necessário limitar a taxa de gravações do IndexedDB. Na PWA de demonstração do Kino, fazemos isso implementando um buffer de gravação intermediário.

À medida que os blocos de dados chegam da rede, eles são anexados ao buffer primeiro. Se os dados recebidos não couberem, vamos transferir o buffer completo para o banco de dados e limpá-lo antes de anexar o restante dos dados. Como resultado, nossas gravações do IndexedDB são menos frequentes, o que leva a um desempenho de gravação significativamente melhor.

Como exibir um arquivo de mídia do armazenamento off-line

Depois de fazer o download de um arquivo de mídia, você provavelmente quer que seu service worker o exiba a partir do IndexedDB em vez de buscar o arquivo na rede.

/**
 * 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);

O que você precisa fazer em getVideoResponse()?

  • O método event.respondWith() espera um objeto Response como parâmetro.

  • O construtor Response() informa que há vários tipos de objetos que poderíamos usar para instanciar um objeto Response: Blob, BufferSource, ReadableStream e muito mais.

  • Precisamos de um objeto que não armazene todos os dados na memória. Portanto, provavelmente vamos escolher o ReadableStream.

Além disso, como estamos lidando com arquivos grandes e queríamos permitir que os navegadores solicitassem apenas a parte do arquivo necessária, precisávamos implementar suporte básico para solicitações de intervalo 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;

Confira o código-fonte do service worker do PWA de demonstração Kino (em inglês) para saber como lemos dados de arquivos do IndexedDB e construímos um stream em um aplicativo real.

Outras considerações

Com os principais obstáculos resolvidos, agora você pode começar a adicionar alguns recursos úteis ao seu aplicativo de vídeo. Confira alguns exemplos de recursos que você vai encontrar na PWA de demonstração Kino:

  • Integração da API Media Session, que permite aos usuários controlar a reprodução de mídia usando teclas de mídia de hardware dedicadas ou pop-ups de notificação de mídia.
  • Armazenamento em cache de outros recursos associados aos arquivos de mídia, como legendas e imagens de cartaz, usando a API Cache.
  • Suporte para o download de streams de vídeo (DASH, HLS) no app. Como os manifestos de stream geralmente declaram várias fontes de taxas de bits diferentes, é necessário transformar o arquivo de manifesto e fazer o download de apenas uma versão de mídia antes de armazená-la para visualização off-line.

A seguir, você vai aprender sobre a reprodução rápida com pré-carregamento de áudio e vídeo.