PWA com streaming off-line

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Os Progressive Web Apps trazem muitos recursos que antes eram reservados para aplicativos nativos para a Web. Um dos recursos mais importantes associados aos PWAs é a experiência off-line.

Uma experiência de mídia de streaming off-line seria ainda melhor, o que é uma melhoria que você pode oferecer aos usuários de várias maneiras. 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, vamos discutir as respostas a essas perguntas, fazendo referência à PWA de demonstração do Kino que criamos, que oferece 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.

Confira um exemplo básico de uso da 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:

  • Permitir que você pause e retome os downloads com facilidade
  • Acompanhar o progresso dos downloads
  • Oferecer uma maneira de responder corretamente às solicitações de intervalo HTTP

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

Hoje em dia, a API Fetch é uma maneira independente do navegador de acessar arquivos remotos de forma assíncrona. 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 armazenar grandes quantidades de dados binários com facilidade e também oferece índices que permitem realizar pesquisas de dados muito rápidas.

Como fazer o 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, vamos fazer um resumo rápido de como 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);
}

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

Retomar downloads

Quando um download é pausado ou interrompido, os fragmentos de dados que chegaram são armazenados com segurança em um banco de dados 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 intervalo HTTP, a retomada de um download é bastante 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 se encaixarem, vamos limpar o buffer completo no banco de dados e limpar 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, provavelmente você vai querer que o service worker o forneça do IndexedDB em vez de buscar o arquivo da 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 queremos permitir que os navegadores solicitem apenas a parte do arquivo que eles precisam no momento, precisamos implementar algum 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 do Kino para saber como estamos lendo dados de arquivos do IndexedDB e construindo um fluxo em um aplicativo real.

Outras considerações

Agora que os principais obstáculos foram removidos, 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 de 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.