DEV Community

loading...

Building a PWA Music Player Part 2: Offline

ndesmic profile image ndesmic ・12 min read

Last time we built a player that could use the File System API to read tracks from the user's harddrive and put them into the player interface. This time we'll add a simple service worker so we can use the player offline.

Cleanup

After coming back I realize some things I'd like to do a little better. One is the play button which in pretty much all music players is also the pause button so let's combine those.

To avoid walls of text as everything changes I'm going to describe parts of the update so you might have a hard time if you are just copy-pasting.

Unshown updates:

  • remove stop button
  • rename play button toggle-play
//components/wc-music-player.js
#isPlaying = false;
togglePlayClick(e){
    this.togglePlay();
}
togglePlay(value){
    const shouldPlay = value ?? !this.#isPlaying;
    if(shouldPlay){
        this.dom.audio.play();
        this.#isPlaying = true;
        this.dom.togglePlay.textContent = "Stop";
    } else {
        this.dom.audio.pause();
        this.#isPlaying = false;
        this.dom.togglePlay.textContent = "Play";
    }
}
Enter fullscreen mode Exit fullscreen mode

We have method to programmatically toggle the play state. I'm using this both for the toggle-play button as well as for clicking on a track. When you click on a track it will call this.togglePlay(true) to force it into the play state. I also now use a new method for the event (remember to bind all the methods up top!) because if I passed togglePlay in directly to the listener the value would be event args and it would only play, never stop.

I also want to have an actual state for inactive/not ready rather than a visual class. The visual updates should be driven from the state change.

//components/wc-music-player.js
#isReady = false;
set isReady(value){
    if(value){
        this.#isReady = true;
        this.setAttribute("ready", "");
    } else {
        this.#isReady = false;
        this.removeAttribute("ready");
    }
}
get isReady(){
    return this.#isReady;
}
Enter fullscreen mode Exit fullscreen mode

Where we used to remove the inactive class we just set isReady = true.

The updated markup looks like this:

//components/wc-music-player.js
<style>
    :host { height: 320px; width: 480px; display: grid; grid-template-columns: 1fr; grid-template-rows: 2rem 1fr 2rem; background: 
#efefef; grid-template-areas: "title" "track-list" "controls"; }
    :host(:not([ready])) #track-list { filter: blur(2px); }
    :host(:not([ready])) #toggle-play { display: none; }
    #title { grid-area: title; margin: 0; whitespace: nowrap; text-overflow: ellipsis; }
    #track-list-container { grid-area: track-list; }
    #controls { grid-area: controls; }
    .overflow { overflow-y: auto; }
</style>
<h1 id="title"></h1>
<div class="overflow" id="track-list-container">
    <ul id="track-list"></ul>
</div>
<div id="controls">
    <button id="open">Open</button>
    <button id="toggle-play" class="hidden">Play</button>
    <audio></audio>
</div>
Enter fullscreen mode Exit fullscreen mode

I've used a grid layout to push the controls to the bottom, wrapped the track list in an overflowable container so only that part scrolls, capped and adjusted the size of the track title, and hidden controls when the ready attribute is not present.

Registering the Service Worker

Now that the UI is just a smoldering bin fire opposed to a raging dumpster inferno let's move on to something a little more interesting: service workers! Service workers are the magic that lets us use our player offline making it much more like an actual app. I have to admit though, they are difficult to work with and have very complex life-cycles and even after using them quite a bit I'm still really confused by them. If you peaked into the codebase before, you might have noticed some of the boilerplate I typically use for it. This is actually old boilerplate so you can ignore it, we'll be revamping it. First lets start with a web component that is used for registration:

//components/wc-service-worker.js
class WcServiceWorker extends HTMLElement {
    #scope = "./";
    #url = "service-worker.js";
    static observedAttributes = ["url", "scope"];
    connectedCallback(){
        this.installServiceWorker();
    }
    async installServiceWorker(){
        if("serviceWorker" in navigator){
            try {
                const serviceWorker = await navigator.serviceWorker.register(this.url, {scope: this.scope});
                this.serviceWorkerInstalled(serviceWorker);
            }catch(ex){
                this.serviceWorkerInstallFailed(ex);
            }
        }
    }

    serviceWorkerInstalled(registration){
        console.log("App Service registration successful with scope:", registration.scope);
    }

    serviceWorkerInstallFailed(error){
        console.error("App Service failed to install", error);
    }
    attributeChangedCallback(name, oldValue, newValue) {
        this[name] = newValue;
    }
    get url(){
        return this.#url;
    }
    set url(value){
        this.#url = value;
    }
    get scope(){
        return this.#scope;
    }
    set scope(value){
        this.#scope = value;
    }
}
customElements.define("wc-service-worker", WcServiceWorker);
Enter fullscreen mode Exit fullscreen mode

This is pretty simple, you pass 2 optional attributes url and scope. url points to where the service worker script is located. scope is the service worker's scope. In most cases you want it to be the same as the page so leave it at the default. It looks to see if service workers are supported and if so calls navigator.serviceWorker.register which creates it. There's also some messaging because working with service workers can be hard but you can remove that for a prod implementation.

You might wonder why I use a custom element for it, and it's because it's very easy to reuse like this.

The Service Worker Implementation

The service worker code is in the service-worker.js file which itself is located adjacent to index.html. The url location matters, you cannot register a service worker on a deeper path than your own. So while you can rename the file (update the url property on wc-service-worker too) don't move it or it won't work.

In the service worker script we need to first setup some events. there are two important ones that you'll almost always need: install and fetch. All code is at the root.

//service-worker.js
function attachEvents() {
    self.addEventListener("install", onInstall);
    self.addEventListener("fetch", onFetch);
}
attachEvents();
Enter fullscreen mode Exit fullscreen mode

self is how the service worker references itself (ServiceWorkerGlobalScope) and events work like any other events.

Installing

Let's first look at installing.

//service-worker.js
const cacheName = "app-shell";
const precacheUrls = ["/index.html"];
function onInstall(e) {
    e.waitUntil(async () => {
        const cache = await caches.open(cacheName);
        await cache.addAll(precacheUrls);
    });
}
Enter fullscreen mode Exit fullscreen mode

e.waitUntil is very important. If you don't use it sometimes things will work and sometimes they won't. Service workers are allowed to be abruptly killed when the browser feels like it, the code just stops doing what it's doing only to be revived later in a completely fresh state in response to events with no visibility. waitUntil(promise) signals to the browser that important work is happening and please don't kill me until the promise finishes.

The termination behavior is very different than nearly any other context you might have used before so it bears an extra warning. Anything like local variables will be destroyed randomly as it dies and revives so it's important to never try to hold state in variables or objects while in a service worker. You can use IndexedDB, Caches and other persisted storage to hold onto things but try to do as little of that as possible.

The promise that we pass in to waitUtil is opening a cache and then adding an array of urls using addAll. These will be fetched and their responses will be put in the cache automatically. Note that while it's fetching you could get requests for things that aren't in the cache yet so don't depend on the cache to be filled.

The list of precacheUrls is up to you. There are numerous strategies to take and precaching is but one. Maybe you want to cache every script in the project, but be sure you understand that it makes updating harder. There's often a lot of build processes that comes into play when using service-workers and precaching because you'll often want to dynamically grab file names for precache and make sure they are hashed so that you don't hold on to stale references and don't make extra requests for ones you already have up-to-date versions of. If you go this route I suggest taking a look at workbox: https://developers.google.com/web/tools/workbox/ which has a lot of utilities to make it easier.

I'm also using a single cache but you can use more than one if you have need of it, they are referenced by name. The name app-shell suggests it's part of the app's main boilerplate and not a cache for other resources like media files.

Fetch Handling

In my simple, buildless implementation I'm just caching the page index.html and nothing else up front because I don't know how my files will change and I don't want to keep manually updating a cache manifest. Instead I want to cache every request as it comes in. In this way I'm caching everything. This is okay because the app is static but if I had a REST API or something dynamic I'd have to worry about whether those can be cached and for how long.

//service-worker.js
function onFetch(e) {
    e.respondWith(
        caches.match(e.request)
            .then(response => respondFromCache(e.request, response)
    ));
}
Enter fullscreen mode Exit fullscreen mode

As a request comes in, I check if it's in the cache and then respond using e.respondWith this has the same function as waitUntil but the promise should produce a response that the page-side fetch will use.

//service-worker.js
function respondFromCache(request, response) {
    if (response) {
        return response;
    }
    var fetchRequest = request.clone();
    return fetch(fetchRequest)
        .then(newResponse => cacheResponse(request, newResponse));
}
Enter fullscreen mode Exit fullscreen mode

If I had a match then it was already there and I can just pass it through. If not, I need internally run the fetch and then cache the response while also returning it back to the requester. This introduces the clone method. Responses and requests can be used only once because they are essentially streams so once you consumed it, you can't use it anymore. To get around this we clone so that while one might be consumed the other can still be pristine for usage in other parts of the code.

So how do we cache a response?

//service-worker.js
function cacheResponse(request, response) {
    if (!response || response.status !== 200 || response.type !== "basic") {
        return response;
    }
    const responseToCache = response.clone();
    caches.open(cacheName)
        .then(cache => cache.put(request, responseToCache));
    return response;
}
Enter fullscreen mode Exit fullscreen mode

Caches are made up of request to response mappings. But we need to be careful, if there was an error we don't want to cache that. So first we guard against this by only caching 200 responses. The type is an advanced thing dealing with the difference between normal and navigation requests, we're not going to worry about those. If it fails the check then we just pass the response back without caching. If it's good then we open the cache stick a fresh cloned response in, and pass back the original.

With this we have a very rudimentary cache strategy that will enable offline once the app has loaded at least once. There are some very big caveats though, lazy loaded assets that the user has not yet encountered at least once will not be cached and will fail if we try to access them offline. Perhaps the bigger issue is we also have no way to update the app. Once the user has downloaded it once, any bug fixes or updates in the JS won't be downloaded anymore.

Message passing and the bail-out button

Creating a strategy and all the code to seamless update, deal with multiple open tabs, precaching just enough to be usable but not bloating the cache, optimal refresh timing, refreshing just the cache deltas, update UI, offline UI etc are all way too much to cover in a single blog post focused on a music player so this is going to be hacky. However, we also don't want users to need to know how to use dev tools to get updates and even for ourselves clearing service workers manually is a lot of clicks, so I propose the "bail-out button." This will be a button in the UI that clears the cache so you can get fresh updates. When in doubt press the button.

This will exist outside the music player as it's not related to the component but rather the page.

//index.html
<body>
    <wc-music-player></wc-music-player>
    <p>If something isn't working or if you're not sure if the application updated correctly press the button (this will prevent offline 
access until next refresh):</p>
    <button id="clear-cache">Clear Network Cache</button>
    <wc-service-worker url="service-worker.js" scope="./"></wc-service-worker>
    <script src="js/components/wc-music-player.js" type="module"></script>
    <script src="js/components/wc-service-worker.js" type="module"></script>
    <script src="js/app.js" type="module"></script>
</body>
Enter fullscreen mode Exit fullscreen mode

While it's technically possible to clear the cache directly from the page, I think architecturally it makes sense to handle that in one place and this is a good case to show how to build a simple message-passing system for service workers.

//app.js
const broadcastChannel = new BroadcastChannel("sw");
const clearCache = document.getElementById("clear-cache");

clearCache.addEventListener("click", () => {
    navigator.serviceWorker.controller?.postMessage({
        type: "clear-cache"
    });
});

broadcastChannel.addEventListener("message", e => {
    switch(e.data.type){
        case "clear-cache-done":
            console.log("Cache cleared!");
    }
});
Enter fullscreen mode Exit fullscreen mode

When we click the button we send a postMessage to the service worker (note the optional chaining, controller can be null if it hasn't registered yet). We also setup a BroadcastChannel with the name sw to suggest it's connected to the service worker. BroadcastChannels are easy ways to setup communication with other same-origin contexts like workers, frames etc. In each context just create a BroadcastChannel with the same name and you can listen and send messages to it. This is also nice because if we had multiple tabs open to the player it would send messages to those too. So why even bother posting a message to the controller? That's because the service worker might not be awake to accept the broadcast but the post message directly to it will wake it up. We can also post message back to the window, it's just more cumbersome because we have to ask the service worker for all open windows and post to them but if the browser didn't support BroadcastChannel that's how you'd do it.

Back in the service worker we add the new event for message:

//service-worker.js
self.addEventListener("message", onMessage);

function onMessage(e){
    async function handleMessage(){
        switch (e.data.type) {
            case "clear-cache":
                await caches.delete(cacheName);
                broadcastChannel.postMessage({ type: "clear-cache-done" });
        }
    }
    e.waitUntil(handleMessage());
}
Enter fullscreen mode Exit fullscreen mode

It's a tad annoying that waitUntil uses a promise because there's a little extra you need to do to call the async function (this is why I didn't use them in fetch and install). The message switch is straightforward, each message object is given a type and I can use the switch statement to respond appropriately. There's other types of messages we my wish deal with that might have other payload properties so it's a good pattern to put in place.

With that we have a bi-directional message system we can use for all sorts of things and we can now clear the cache from the UI itself even if it's a bit jank.

A better strategy

One thing that I realized after writing the above section is that I could have picked a better strategy without doing much work. While I started with the "cache first" strategy, it probably made more sense to go "network first." In this case we always try to go to the network and only use the cache if something failed. There's a trade-off though. "Cache first" gives us ultimate performance. The user will always try to get things locally which means subsequent loads are instant. It's also better for the server because fewer requests will be made. However, it left us with the problem of having stale data and having to figure out when and how to update. "Network first" ensures that we're always using fresh data but offers no performance benefit. In fact, we might need to wait for the request to time out before we get enough of a signal to fall back leaving the page half drawn. Still, I'd say that this might be a better general strategy if you don't know too much about you app behavior. It's closer to how the web usually works and simply adds offline as an extra feature.

Anyway, to change it's as simple as flipping two lines:

//service-worker.js
function onFetch(e) {
    e.respondWith(
        fetch(e.request)
            .then(response => cacheResponse(e.request, response))
            .catch(() =>
                caches.match(e.request))
    );
}
Enter fullscreen mode Exit fullscreen mode

Now we respond with a fetch and only use the cache match when it fails.

Some Debugging Tips

Debugging service workers can be difficult. The first thing to remember is that hard reload (long press reload or shift+F5) is your friend. You can easily wind up with some stale files and get into weird states and this help prevent that.

Also keep in mind that the cache and the service worker are different things. Just because you cleared the cache does not mean that your service worker updated. Service workers can take 2 refreshes to become "active" as the first loads the new service worker for staging and the second allows the active worker to be removed and replaced. There are update strategies like "skip waiting" that can prevent this at the risk of causing a discrepancy between the page code and the service worker code. Also take care when using multiple tabs as this can prevent updating or cause out-of-sync issues as well.

In devtools Application -> Service Worker you can can test a lot of service worker functionality. You can also unregister and skip the service worker in case you think you need to purge it in order to update correctly (the UI never fully shows the correct state though). You can also manually view and empty the cache from there.

Screenshot 2021-01-30 151343

Screenshot 2021-01-30 151327

On the network panel resources you can see which resources came from a service worker in the "size" column:

Screenshot 2021-01-30 151130

And when testing from localhost if you have other projects that use service workers make sure you aren't accidentally sharing the cache between them. The browser doesn't know that you're hosting an entirely different website and if there are name collisions you can get some really weird results.

For full code: https://github.com/ndesmic/music-player/tree/v0.3
For the in-progress player: https://gh.ndesmic.com/music-player/

Discussion (0)

pic
Editor guide