Опубликовано: 5 июля 2021 г.
Прогрессивные веб-приложения переносят в веб множество функций, ранее доступных только для нативных приложений. Одна из самых важных особенностей PWA — возможность работы в автономном режиме.
Ещё лучше было бы офлайн-трансляцию потокового медиа, которую вы могли бы предложить своим пользователям несколькими способами. Однако это создаёт действительно уникальную проблему: медиафайлы могут быть очень большими. Поэтому вы можете задаться вопросом:
- Как загрузить и сохранить большой видеофайл?
- И как мне предоставить его пользователю?
В этой статье мы обсудим ответы на эти вопросы, ссылаясь на созданный нами демонстрационный PWA-приложение Kino , которое предоставляет практические примеры реализации офлайн-трансляций без использования каких-либо функциональных или презентационных фреймворков. Следующие примеры приведены в основном в образовательных целях, поскольку в большинстве случаев для реализации этих функций, вероятно, потребуется использовать один из существующих медиафреймворков .
Если у вас нет веского бизнес-кейса для разработки собственного приложения, создание PWA с офлайн-трансляцией может столкнуться с трудностями. В этой статье вы узнаете об API и методах, используемых для предоставления пользователям высококачественного офлайн-медиа.
Загрузка и сохранение большого медиафайла
Прогрессивные веб-приложения обычно используют удобный 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',
]);
})
);
});
Хотя приведенный выше пример технически работает, использование API кэширования имеет ряд ограничений, которые делают его применение с большими файлами непрактичным.
Например, API кэша не делает следующее:
- Позволяет легко приостанавливать и возобновлять загрузки.
- Позволяет отслеживать ход загрузки
- Предложить способ правильного ответа на HTTP-запросы диапазона
Все эти проблемы создают довольно серьёзные ограничения для любого видеоприложения. Давайте рассмотрим другие варианты, которые могут быть более подходящими.
В настоящее время Fetch API — это кроссбраузерный способ асинхронного доступа к удалённым файлам. В нашем случае он позволяет получать доступ к большим видеофайлам как к потоку и сохранять их инкрементно в виде фрагментов с помощью HTTP-запроса на диапазон.
Теперь, когда вы можете читать фрагменты данных с помощью Fetch API, вам также необходимо их сохранить. Скорее всего, с вашим медиафайлом связан целый ряд метаданных, таких как имя, описание, длительность, категория и т. д.
Вы не храните просто один медиа-файл, вы храните структурированный объект, и медиа-файл — это всего лишь одно из его свойств.
В этом случае API IndexedDB предоставляет отличное решение для хранения как медиаданных, так и метаданных. Он легко хранит огромные объёмы двоичных данных, а также предлагает индексы, позволяющие выполнять очень быстрый поиск данных.
Загрузка медиафайлов с помощью Fetch API
Мы создали несколько интересных функций на основе Fetch API в нашем демонстрационном PWA, который мы назвали Kino . Исходный код находится в открытом доступе, поэтому вы можете свободно с ним ознакомиться.
- Возможность приостанавливать и возобновлять незавершенные загрузки.
- Пользовательский буфер для хранения фрагментов данных в базе данных.
Прежде чем показать, как реализованы эти функции, мы сначала сделаем краткий обзор того, как можно использовать API Fetch для загрузки файлов.
/**
* 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 Range, возобновление загрузки довольно просто:
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. В демо-приложении Kino 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 и создаем поток в реальном приложении.
Другие соображения
Устранив основные препятствия, вы можете начать добавлять полезные функции в своё видеоприложение. Вот несколько примеров функций, которые вы найдёте в демо-приложении Kino PWA:
- Интеграция API Media Session , которая позволяет вашим пользователям управлять воспроизведением мультимедиа с помощью специальных аппаратных клавиш управления мультимедиа или с помощью всплывающих уведомлений о мультимедиа.
- Кэширование других ресурсов, связанных с медиафайлами, таких как субтитры и постеры, с использованием старого доброго API кэширования .
- Поддержка загрузки видеопотоков (DASH, HLS) в приложении. Поскольку в манифестах потоков обычно указано несколько источников с разным битрейтом, необходимо преобразовать файл манифеста и загрузить только одну версию медиаконтента перед сохранением для просмотра офлайн.
Далее вы узнаете о быстром воспроизведении с предварительной загрузкой аудио и видео .