Offline Videos
At
Fugo
, we needed to ensure uninterrupted playback. In this article, you will learn how
to do this utilizing Service Worker, Cache API, as well as a couple of gotchas about
evicting policies and video with audio.
Progressive Web Apps
We support a wide range of devices so we needed a way to create a
cross-platform player that can be implemented and supported by a
small team. Also, Digital Signage players often have a poor
internet connection or no connection at all. PWA is a perfect fit
for the task. Basically, PWA is an approach to web development
that results in a web app that has a user experience of a native
application. This means, that the app can be installed on the home
screen and used even in the absence of the internet.
Indexed Database API
A big part of any complex Web App is persistent state storage.
IndexedDB is the best candidate for this: it’s performant and
doesn’t block the main thread. Its API might be a bit too
elaborate and that’s why we have chosen idb-keyval – a wrapper
library with simple interfaces for writing and reading from the
DB. One of the states in our case could be a list of the video
files:
1import { set, Store } from "idb-keyval";
2
3async function storeFileList(URLs) {
4 const db = new Store("my-files");
5 await new Promise.all(URLs.map((url) => set(url, url, db)));
6}
It looks a bit weird to store a list in a key-val fashion but I
think it gives us simplicity.
Cache API + Service Worker
In order to ensure offline playback and uninterrupted video
without buffering over a poor internet connection, we need to
store files in the Cache and intercept the network request using
Service Worker. First, we need to write a file to the Cache:
1function cacheFile(url) {
2 const cache = await caches.open("my-files");
3 const response = await fetch(url);
4 await cache.put(url, response);
5}
Second, we need to intercept a network request and serve it from
the Cache:
1self.addEventListener("fetch", (event) =>
2 event.respondWith(getResponse(event))
3);
4
5async function getResponse(event) {
6 const response = await caches.match(event.request);
7 if (response) return response;
8
9 const onlineResponse = fetch(event.request);
10 return onlineResponse;
11}
Finally, the Service Worker needs to be registered:
1navigator.serviceWorker.register("service-worker.js");
Gotcha number one: cache eviction policies
Files in the Cache can be removed by the browser if the OS needs
more space on the disk. Even though modern Chrome gives 60% of
free space to a website, older browsers can have their own rules
for eviction. That’s why we need to make sure that the file we
need to play soon is in the cache. First, try to tell the browser
to persist the data. It most likely will not obey, but it doesn’t
hurt to ask:
1const isPersisted = await navigator.storage.persisted();
2console.log(`Persisted storage granted: ${isPersisted}`);
Here is a simple code for checking and re-downloading missing
files:
1async function reCheckCache() {
2 const db = new Store("my-files");
3 const files = (await keys(db)).map((key) => key.toString());
4 const cachedFiles = (await cache.keys()).map((key) => key.url);
5 await Promise.all(
6 files.map(async (file) => {
7 if (!cachedFiles.file.includes(file)) {
8 cacheFile(file);
9 }
10 })
11 );
12}
Gotcha number two: videos with an audio track
Websites with autoplaying videos have ruined the browser
experience for all of us. To combat that, Google decided to forbid
playing videos with sound without the user’s input. Our app needs
to survive an atomic explosion so this should be handled with
ease. Here is a code that will allow you to play the video no
matter what
1function play(videoEl) {
2 try {
3 videoEl.play();
4 } catch (e) {
5 videoEl.muted = true;
6 videoEl.play();
7 }
8}
Conclusion
Service Worker and Cache API are quite old technologies that have
been available since 2015 in Chrome. It means that it’s safe to
use them and it’s perfect if you need to keep playing the music
when the internet speed is close to zero.