PWA com streaming off-line

Derek herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

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

Melhor ainda seria uma experiência de streaming de mídia off-line, que é uma melhoria que você pode oferecer aos seus usuários de algumas maneiras diferentes. No entanto, isso cria um problema único, já que os arquivos de mídia podem ser muito grandes. Talvez você se pergunte:

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

Neste artigo, discutiremos respostas a essas perguntas, ao fazer referência ao PWA de demonstração do Kino que criamos, que fornece exemplos práticos de como é possível implementar uma experiência de mídia de streaming off-line sem usar estruturas 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 Media Frameworks atuais para fornecer esses recursos.

A menos que você tenha um bom caso de negócios para desenvolver o seu, criar um PWA com streaming off-line tem desafios. Neste artigo, você vai aprender sobre as APIs e as 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 oferecer a experiência off-line: documentos, folhas de estilo, imagens e outros.

Este é 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 inviável usá-la com arquivos grandes.

Por exemplo, a API Cache não:

  • Pausar e retomar downloads facilmente
  • Acompanhe o progresso dos downloads
  • Ofereça 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 outras opções que podem ser mais apropriadas.

Hoje em dia, a API Fetch pode acessar arquivos remotos de maneira assíncrona em vários navegadores. Em nosso caso de uso, ele permite acessar grandes arquivos de vídeo 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 precisa 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 único arquivo de mídia, está armazenando um objeto estruturado, e o arquivo de mídia é apenas uma das propriedades dele.

Nesse caso, a API IndexedDB é uma solução excelente 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.

Como fazer o download de arquivos de mídia usando a API Fetch

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

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

Percebeu que await reader.read() está em loop? É assim que você recebe blocos de dados de um fluxo legível conforme eles chegam da rede. Considere como isso é útil: você pode começar a processar seus dados antes mesmo que eles cheguem pela 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 IndexedDB. Em seguida, você pode exibir um botão para retomar um download no aplicativo. Como o servidor PWA de demonstração do Kino é compatível com solicitações de intervalo HTTP, a retomada de um download é um pouco 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 gravar valores dataChunk em um banco de dados IndexedDB é simples. Esses valores já são instâncias de ArrayBuffer, que podem ser armazenadas diretamente no IndexedDB. Assim, podemos apenas criar um objeto com 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, 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, porque estamos adicionando muita sobrecarga transacional ao criar uma nova transação para cada bloco de dados recebido de uma rede.

Os blocos transferidos por download podem ser bastante 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 do 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, esvaziamos o buffer completo no banco de dados e o limpamos antes de anexar o restante dos dados. Como resultado, nossas gravações do IndexedDB são menos frequentes, o que leva a uma melhora significativa no desempenho de gravação.

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

Depois de fazer o download de um arquivo de mídia, é provável que você queira que o service worker o veicule a partir 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 no getVideoResponse()?

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

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

  • Como precisamos de um objeto que não contenha todos os dados na memória, 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 de que precisam no momento, precisávamos 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 de demonstração do Kino (em inglês) para descobrir como estamos lendo dados de arquivos do IndexedDB e criando um stream em um aplicativo real.

Outras considerações

Com os principais obstáculos no caminho, você pode começar a adicionar recursos ao seu aplicativo de vídeo. Veja 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 chaves de mídia de hardware dedicadas ou em 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 a downloads 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.