PWA com streaming off-line

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Os Progressive Web Apps trazem para a Web muitos 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 realmente ú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 exibi-lo 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 educativos, porque, na maioria dos casos, você provavelmente precisa usar um dos frameworks de mídia existentes para fornecer esses recursos.

A menos que você tenha um bom caso de negócios para desenvolver seu próprio, criar um PWA com streaming off-line pode ser desafiador. Neste artigo, você vai aprender sobre as APIs e técnicas usadas para fornecer aos usuários uma experiência de mídia off-line de alta qualidade.

Fazer o download e armazenar um arquivo de mídia grande

Os Progressive Web Apps geralmente usam a conveniente 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
  • Acompanhar o progresso dos downloads
  • Oferecer uma maneira de responder adequadamente às solicitações de intervalo HTTP

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

Atualmente, a API Fetch é uma maneira de acessar arquivos remotos de forma assíncrona em todos os navegadores. Em nosso caso de uso, ele permite que você acesse grandes arquivos de vídeo como um stream e os armazene incrementalmente como partes 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 vários metadados associados ao arquivo de mídia, como nome, descrição, duração do tempo de execução, categoria etc.

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

Nesse caso, a API IndexedDB oferece uma excelente solução para armazenar dados de mídia e 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 com base na API Fetch no nosso PWA de demonstração, que chamamos de Kino. O código-fonte é público, então fique à vontade para revisá-lo.

  • A capacidade de pausar e retomar downloads incompletos.
  • Um buffer personalizado para armazenar blocos 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ê recebe blocos de dados de um stream legível quando eles chegam da rede. Pense em como isso é útil: é possível começar a processar seus dados antes mesmo que eles cheguem da rede.

Retomando 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. Em seguida, você pode exibir um botão para retomar um download no 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 do IndexedDB é simples. Esses valores já são instâncias de ArrayBuffer, que podem ser armazenadas diretamente no IndexedDB. Portanto, basta criar um objeto com o formato adequado 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, você provavelmente descobrirá que as gravações do IndexedDB são significativamente mais lentas do que o download. Isso não ocorre porque as gravações do IndexedDB são lentas, mas porque estamos adicionando muita sobrecarga transacional, criando uma nova transação para cada bloco de dados recebido de uma rede.

Os blocos baixados podem ser bem pequenos e emitidos pelo stream em rápida sucessão. Você precisa limitar a taxa de gravações do IndexedDB. No PWA de demonstração Kino, fazemos isso implementando um buffer de gravação intermediário.

À medida que os blocos de dados chegam da rede, nós os anexamos 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, as gravações do IndexedDB são menos frequentes, o que melhora significativamente o desempenho de gravação.

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 podemos usar para instanciar um objeto Response: Blob, BufferSource, ReadableStream e muito mais.

  • Como precisamos de um objeto que não contenha todos os dados na memória, 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 ultrapassados, agora você pode começar a adicionar alguns recursos interessantes ao seu aplicativo de vídeo. Aqui estão alguns exemplos de recursos que você encontraria no PWA de demonstração do 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 pôster, usando a antiga API Cache.
  • Suporte para 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á-lo para visualização off-line.

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