PWA com streaming off-line

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Os Progressive Web Apps oferecem muitos recursos antes reservados para o uso nativo aplicativos à Web. Um dos recursos mais importantes associados Os PWAs são uma experiência off-line.

Melhor ainda seria uma experiência de streaming mídia off-line, que é uma aprimoramento que você pode oferecer aos usuários de algumas maneiras diferentes. No entanto, isso cria um problema realmente único: 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 as respostas a essas perguntas. No entanto, referenciando o PWA de demonstração Kino que criamos, que fornece informações práticas exemplos de como é possível implementar uma experiência de streaming de mídia off-line sem usando quaisquer estruturas funcionais ou de apresentação. Os exemplos a seguir são principalmente para fins educacionais, porque na maioria dos casos você provavelmente deveria usar um dos frameworks de mídia existentes para oferecer esses recursos.

A menos que você tenha um bom caso de negócios para desenvolver seu próprio app, criar um PWA no streaming off-line tem desafios. Neste artigo, você vai conhecer as APIs e técnicas usadas para fornecer aos usuários uma mídia off-line de alta qualidade do usuário.

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 proporcionar 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 forma entre navegadores de acessar de forma assíncrona o acesso remoto . Em nosso caso de uso, ele permite que você acesse grandes arquivos de vídeo como um stream e e armazená-las incrementalmente como blocos usando uma solicitação de intervalo HTTP.

Agora que você pode ler os blocos de dados com a API Fetch, você também precisa e armazená-las. É provável que haja vários metadados associados à sua mídia como nome, descrição, duração do tempo de execução, categoria etc.

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

Nesse caso, a IndexedDB API oferece uma excelente solução para armazenar os metadados e dados de mídia. Ele pode conter grandes quantidades de dados binários com facilidade 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 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, vamos fazer recapitulação rápida 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ê receberá os blocos de dados de um fluxo legível quando eles chegam da rede. Pense em como Isso é útil: você pode começar a processar seus dados antes mesmo de todos chegarem da rede.

Retomando downloads

Quando um download é pausado ou interrompido, os blocos de dados que chegaram armazenadas com segurança em um banco de dados do IndexedDB. Você pode então exibir um botão para retomar um download em seu aplicativo. Como o servidor PWA de demonstração Kino oferece suporte a solicitações de intervalos HTTP. Retomar 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 do IndexedDB é simples. Esses valores já são instâncias de ArrayBuffer, que podem ser armazenadas diretamente no IndexedDB, então podemos criar um objeto com o formato e armazená-los.

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 seu IndexedDB grava são significativamente mais lentos do que o download. Isso não ocorre porque o IndexedDB grava 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 baixados podem ser bem pequenos e podem ser emitidos pelo stream em sucessão rápida. Você precisa 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, nós os anexamos ao buffer primeiro. Se os dados recebidos não couberem, liberamos o buffer completo no banco de dados e antes de anexar o restante dos dados. Como resultado, nosso IndexedDB gravações são menos frequentes, o que leva a uma melhoria significativa na gravação desempenho.

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 exibi-lo 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 que pode ser usado para instanciar um objeto Response: Blob, BufferSource, ReadableStream, entre outros.

  • Precisamos de um objeto que não contenha todos os dados na memória, então vamos é recomendável escolher ReadableStream.

Além disso, como estamos lidando com arquivos grandes e queríamos permitir que os navegadores só pedisse a parte do arquivo necessária, precisávamos implementar compatibilidade básica com 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 a demonstração do Kino no código-fonte do service worker do PWA como lemos dados de arquivo do IndexedDB e construímos um fluxo em um aplicativo real.

Outras considerações

Depois de enfrentar os principais obstáculos, você já pode começar a adicionar algumas recursos interessantes ao seu aplicativo de vídeo. Confira alguns exemplos recursos que você encontraria no PWA de demonstração do Kino:

  • Integração da API Media Session que permite aos usuários controlar mídias reprodução usando teclas de mídia de hardware dedicadas ou de notificações de mídia pop-ups.
  • armazenamento em cache de outros ativos associados aos arquivos de mídia, como legendas; imagens de cartaz usando a boa e velha API Cache.
  • Suporte para o download de streams de vídeo (DASH, HLS) no app. Porque o stream manifestos geralmente declaram várias fontes de taxas de bits diferentes, você precisa transformar o arquivo de manifesto e fazer o download de apenas uma versão de mídia antes de armazenar para visualização off-line.

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