PWA с автономной потоковой передачей

Дерек Херман
Derek Herman
Ярослав Полакович
Jaroslav Polakovič

Прогрессивные веб-приложения привносят в Интернет множество функций, ранее зарезервированных для собственных приложений. Одной из наиболее выдающихся особенностей PWA является возможность работы в автономном режиме.

Еще лучше было бы использовать потоковую передачу мультимедиа в автономном режиме, которую вы можете предложить своим пользователям несколькими различными способами. Однако это создает поистине уникальную проблему: медиафайлы могут быть очень большими. Итак, вы можете спросить:

  • Как загрузить и сохранить большой видеофайл?
  • И как мне передать его пользователю?

В этой статье мы обсудим ответы на эти вопросы, ссылаясь на созданную нами демонстрационную версию PWA Kino , которая предоставляет вам практические примеры того, как можно реализовать потоковую передачу мультимедиа в автономном режиме без использования каких-либо функциональных или презентационных инфраструктур. Следующие примеры предназначены в основном для образовательных целей, поскольку в большинстве случаев вам, вероятно, придется использовать одну из существующих медиа-платформ для предоставления этих функций.

Если у вас нет хорошего экономического обоснования для собственной разработки, создание PWA с потоковой передачей в автономном режиме будет сопряжено с трудностями. В этой статье вы узнаете об API и методах, используемых для предоставления пользователям высококачественного автономного мультимедиа.

Загрузка и сохранение большого медиафайла

Прогрессивные веб-приложения обычно используют удобный Cache API для загрузки и хранения ресурсов, необходимых для работы в автономном режиме: документов, таблиц стилей, изображений и т. д.

Вот базовый пример использования Cache API в 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',
     
]);
   
})
 
);
});

Хотя приведенный выше пример технически работает, использование Cache API имеет несколько ограничений, которые делают его использование с большими файлами непрактичным.

Например, Cache API не делает:

  • Позволяет легко приостанавливать и возобновлять загрузку
  • Позволяет отслеживать ход загрузок
  • Предложите способ правильного ответа на запросы диапазона HTTP.

Все эти проблемы являются довольно серьезными ограничениями для любого видеоприложения. Давайте рассмотрим некоторые другие варианты, которые могут быть более подходящими.

В настоящее время Fetch API — это кроссбраузерный способ асинхронного доступа к удаленным файлам. В нашем случае это позволяет вам получать доступ к большим видеофайлам в виде потока и постепенно сохранять их в виде фрагментов, используя запрос диапазона HTTP.

Теперь, когда вы можете читать фрагменты данных с помощью Fetch API, вам также необходимо их сохранить. Скорее всего, с вашим медиа-файлом связано множество метаданных, таких как: имя, описание, продолжительность выполнения, категория и т. д.

Вы не храните один медиафайл, вы храните структурированный объект, а медиафайл — это всего лишь одно из его свойств.

В этом случае API IndexedDB предоставляет отличное решение для хранения как медиаданных, так и метаданных. Он может легко хранить огромные объемы двоичных данных, а также предлагает индексы, которые позволяют выполнять очень быстрый поиск данных.

Загрузка медиафайлов с помощью Fetch API

Мы создали несколько интересных функций вокруг Fetch API в нашей демонстрационной версии PWA, которую мы назвали Kinoисходный код является общедоступным, поэтому не стесняйтесь его просмотреть.

  • Возможность приостанавливать и возобновлять незавершенные загрузки.
  • Пользовательский буфер для хранения фрагментов данных в базе данных.

Прежде чем показать, как реализованы эти функции, мы сначала кратко рассмотрим, как вы можете использовать Fetch API для загрузки файлов.

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

Обратите внимание, что await reader.read() находится в цикле? Таким образом вы будете получать фрагменты данных из читаемого потока по мере их поступления из сети. Подумайте, насколько это полезно: вы можете начать обработку своих данных еще до того, как они поступят из сети.

Возобновление загрузок

Когда загрузка приостанавливается или прерывается, поступившие фрагменты данных будут безопасно сохранены в базе данных IndexedDB. Затем вы можете отобразить кнопку для возобновления загрузки в своем приложении. Поскольку демо-сервер PWA Kino поддерживает запросы диапазона HTTP, возобновить загрузку довольно просто:

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

Пользовательский буфер записи для IndexedDB

На бумаге процесс записи значений dataChunk в базу данных IndexedDB прост. Эти значения уже являются экземплярами ArrayBuffer , которые можно хранить непосредственно в IndexedDB, поэтому мы можем просто создать объект соответствующей формы и сохранить его.

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

Хотя этот подход работает, вы, вероятно, обнаружите, что запись в IndexedDB происходит значительно медленнее, чем загрузка. Это происходит не потому, что запись в IndexedDB медленная, а потому, что мы добавляем много транзакционных издержек, создавая новую транзакцию для каждого фрагмента данных, получаемого из сети.

Загруженные фрагменты могут быть довольно небольшими и могут передаваться потоком в быстрой последовательности. Вам необходимо ограничить скорость записи IndexedDB. В демонстрационном PWA Кино мы делаем это, реализуя промежуточный буфер записи .

Когда фрагменты данных поступают из сети, мы сначала добавляем их в наш буфер. Если входящие данные не подходят, мы сбрасываем весь буфер в базу данных и очищаем его перед добавлением остальных данных. В результате записи в IndexedDB происходят реже, что приводит к значительному повышению производительности записи.

Обслуживание медиафайла из автономного хранилища

После загрузки медиафайла вы, вероятно, захотите, чтобы ваш сервис-воркер обслуживал его из IndexedDB, а не загружал файл из сети.

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

Итак, что вам нужно сделать в getVideoResponse() ?

  • Метод event.respondWith() ожидает объект Response в качестве параметра.

  • Конструктор Response() сообщает нам, что существует несколько типов объектов, которые мы могли бы использовать для создания экземпляра объекта Response : Blob , BufferSource , ReadableStream и другие.

  • Нам нужен объект, который не хранит все свои данные в памяти, поэтому мы, вероятно, захотим выбрать ReadableStream .

Кроме того, поскольку мы имеем дело с большими файлами и хотели разрешить браузерам запрашивать только ту часть файла, которая им нужна в данный момент, нам нужно было реализовать некоторую базовую поддержку запросов диапазона 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;

Не стесняйтесь ознакомиться с исходным кодом демонстрационного PWA-сервиса Kino, чтобы узнать, как мы читаем данные файла из IndexedDB и создаем поток в реальном приложении.

Другие соображения

Теперь, когда основные препятствия устранены, вы можете начать добавлять некоторые полезные функции в свое видеоприложение. Вот несколько примеров функций, которые вы найдете в демо-версии PWA Kino :

  • Интеграция API сеанса мультимедиа , которая позволяет вашим пользователям управлять воспроизведением мультимедиа с помощью специальных аппаратных клавиш мультимедиа или из всплывающих окон с медиа-уведомлениями.
  • Кэширование других ресурсов, связанных с медиафайлами, таких как субтитры и изображения постеров, с использованием старого доброго Cache API .
  • Поддержка загрузки видеопотоков (DASH, HLS) внутри приложения. Поскольку в манифестах потоков обычно объявляется несколько источников с разным битрейтом, вам необходимо преобразовать файл манифеста и загрузить только одну медиа-версию, прежде чем сохранять ее для просмотра в автономном режиме.

Далее вы узнаете об ускоренном воспроизведении с предварительной загрузкой аудио и видео .