DEV Community

ndesmic
ndesmic

Posted on • Updated on

Building a PWA Music Player Part 1: File System API

In this post I'm going to be making a barebones music player using the File System API to persist access to files. It'll be built in vanilla js using web compoenents.

Boilerplate

We'll start with some web component boilerplate as I want this control to be portable.

//wc-music-player.js
customElements.define("wc-music-player",
    class extends HTMLElement {
        static get observedAttributes(){
            return [];
        }
        constructor(){
            super();
            this.bind(this);
        }
        bind(element){
            element.attachEvents = element.attachEvents.bind(element);
            element.cacheDom = element.cacheDom.bind(element);
        }
        connectedCallback(){
            this.
            this.cacheDom();
            this.attachEvents();
        }
        render(){
            this.shadow = this.attachShadow({ mode: "open" });
            this.shadow.innerHTML = ``;
        }
        cacheDom(){
            this.dom = {};
        }
        attachEvents(){

        }
        attributeChangedCallback(name, oldValue, newValue){
            this[name] = newValue;
        }
    }
)

Enter fullscreen mode Exit fullscreen mode

Getting a directory

In the most simple cases we can simply use an input with the multiple attribute or drag-and-drop to access files. However, I'm going to opt to use something a little more advanced and less supported by browsers called showDirectoryPicker. For brevity, I'm not going to add the fallbacks to file and drag-drop as there's a lot to be comprehensive but know that if you want to make this work to some degree in other browsers you absolutely can.

The trick to getting showDirectoryPicker to work is that it needs to be fired on a user gesture so you can't just blindside the user with a permission prompt to sensitive files. So we'll add a button they can press. This is also handy incase they want to retarget the file handle to a different directory later, they'll need a button to do that.

render() {
    this.shadow = this.attachShadow({ mode: "open" });
    this.shadow.innerHTML = `
        <style>
            :host { height: 320px; width: 480px; display: block; background: #efefef; overflow: scroll; }
        </style>
        <button id="open">Open</button>
        <h1></h1>
        <ul></ul>
        <audio />
    `;
}
cacheDom() {
    this.dom = {
        title: this.shadowRoot.querySelector("h1"),
        audio: this.shadowRoot.querySelector("audio"),
        list: this.shadowRoot.querySelector("ul"),
        open: this.shadowRoot.querySelector("#open")
    };
}
Enter fullscreen mode Exit fullscreen mode

I'm setting some basic heights and widths but you can modify that to your liking. The scroll is necessary to scroll the list of tracks. The h1, ul and audio are placeholder elements for now.

With the basic DOM out of the way we need to add the open button click.

attachEvents() {
    this.dom.open.addEventListener("click", this.open);
}
Enter fullscreen mode Exit fullscreen mode

And here's open:

async open(){
    this.#handle = await window.showDirectoryPicker();
    await this.#storage.set("handle", this.#handle);
    this.getFiles();
}
Enter fullscreen mode Exit fullscreen mode

We'll get the handle from showDirectoryPicker, then we'll save it, and then call getFiles which will list out the contents in the DOM.

The handle is a very special object as it has references to the file system but you are allowed to persist it. This solves a limitation of the input and drag-drop techniques, if the user refreshes the page you still have a reference to the files and can display them without another user gesture. Since this is a pretty big permission the user needs to ok it in a prompt.

So how do we save a handle? We can do that in indexedDB.

Saving file handles

I'm not going to get too into IndexedDB as it's complicated but I built a wrapper around it that does a few basic operations in a key-value store type of way. You can copy-paste it.

//idb-storage.js
const defaults = {
    name: "idb-storage",
    siloName: "db-cache"
};

export class IdbStorage {
    constructor(options) {
        this.options = { ...defaults, ...options };
        this.bind(this);
        this.idbPromise = this.openIndexDb();
    }

    bind(idbStorage) {
        this.get = this.get.bind(idbStorage);
        this.get = this.get.bind(idbStorage);
        this.getAll = this.getAll.bind(idbStorage);
        this.set = this.set.bind(idbStorage);
        this.openIndexDb = this.openIndexDb.bind(idbStorage);
    }

    get(key) {
        return new Promise((resolve, reject) => {
            this.idbPromise
                .then(idb => {
                    const transaction = idb.transaction(this.options.siloName, "readonly");
                    const store = transaction.objectStore(this.options.siloName);
                    const request = store.get(key);
                    request.onerror = () => reject(request.error);
                    request.onsuccess = e => resolve(e.target.result);
                });
        });
    }

    getAll() {
        return new Promise((resolve, reject) => {
            this.idbPromise
                .then(idb => {
                    const transaction = idb.transaction(this.options.siloName, "readonly");
                    const store = transaction.objectStore(this.options.siloName);
                    const request = store.getAll();
                    request.onerror = () => reject(request.error);
                    request.onsuccess = e => resolve(e.target.result);
                });
        });
    }

    set(key, value) {
        return new Promise((resolve, reject) => {
            this.idbPromise
                .then(idb => {
                    const transaction = idb.transaction(this.options.siloName, "readwrite");
                    const store = transaction.objectStore(this.options.siloName);
                    const request = store.put(value, key);
                    request.onerror = () => reject(request.error);
                    request.onsuccess = e => resolve(e.target.result);
                });
        });
    }

    openIndexDb() {
        return new Promise((resolve, reject) => {
            let openRequest = indexedDB.open(this.options.name, 1);
            openRequest.onerror = () => reject(openRequest.error);
            openRequest.onupgradeneeded = e => {
                if (!e.target.result.objectStoreNames.contains(this.options.siloName)) {
                    e.target.result.createObjectStore(this.options.siloName);
                }
            };
            openRequest.onsuccess = () => resolve(openRequest.result);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

You pass in some options, the name of the database and the name of the silo. Then you can use get and set to add values or get values asynchronously. If you mess up the names or want to rename it can get tricky, for users who already have the app you need to increment the 1 on indexedDB.open as that's the version number and signals to the browser you want a schema change and this triggers the onupgradeneeded event. For development, in the devtools application tab, delete the database and re-run the code. It's pretty hard to make this ergonomic.

Once this is added we can look at the music player's connectedCallback:

async connectedCallback() {
    this.#storage = new IdbStorage({ siloName: "file-handles" });
    this.#handle = await this.#storage.get("handle");
    this.render();
    this.cacheDom();
    this.attachEvents();
    if(this.#handle){
        this.getFiles();
    }
}
Enter fullscreen mode Exit fullscreen mode

We first try to get a handle from the database. If the handle existed then at the end we can jump straight to getFiles otherwise they need to have clicked "open" first.

Displaying the tracks

All we're going to do is dump the track names in to a <ul>.

async getFiles(){
    this.#files = await collectAsyncIterableAsArray(filterAsyncIterable(this.#handle.values(), f => f.kind === "file" && (f.name.endsWith(".mp3") || f.name.endsWith(".m4a"))));
    const docFrag = document.createDocumentFragment();
    this.#files.forEach(f => {
        const li = document.createElement("li");
        li.textContent = f.name;
        docFrag.appendChild(li);
        this.#fileLinks.set(li, f);
    });
    this.dom.list.appendChild(docFrag);
    this.shadowRoot.addEventListener("click", this.selectTrack, false);
}
Enter fullscreen mode Exit fullscreen mode

That first line uses some helpers. The values of the handle are not an array but an iterator, so if we want a list of all of them we need to "collect" the values into an array. The inner part filters entries that look like what we want. The includes the kinds of "file" (as opposed to directories) and they must end in .mp3 or .mp4a. You could add any other supported audio type too and you could also dig into the nested folders to make it more rubust. All the little details that add up to a real project and not just a simple afternoon proof-of-concept.

Here's the iterator functions, it's basically filter and join but for async iterables.

//iterator-tools.js
export async function* filterAsyncIterable(asyncIterable, filterFunc) {
    for await(const value of asyncIterable){
        if(filterFunc(value)) yield value;
    }
}
export async function collectAsyncIterableAsArray(asyncIterable) {
    const result = [];
    for await(const value of asyncIterable){
        result.push(value);
    }
    return result;
}
Enter fullscreen mode Exit fullscreen mode

Once we have our list we create a document fragment and add lis to it. If you're unfamiliar with document fragments, they allow us to create a virtual element so we aren't continually appending to the DOM and creating lots of repaints and re-layouts. Each li is equipped with a click event that will play it. The #fileLinks is a way in which we will associate an li element to a file:

constructor() {
    super();
    this.#fileLinks = new WeakMap();
    this.bind(this);
}
Enter fullscreen mode Exit fullscreen mode

It's a weak map because if we were to remove one of the lis we'd expect the associated file reference to be garbage collected provided no other reference exists.

Playing a track

async selectTrack(e){
    const fileHandle = this.#fileLinks.get(e.target);
    if(fileHandle){
        const file = await fileHandle.getFile();
        const url = URL.createObjectURL(file);
        this.dom.audio.src = url;
        this.dom.audio.play();
                this.dom.title.textContent = file.name;
    }
}
Enter fullscreen mode Exit fullscreen mode

We lookup the handle from the li, get the file it points to, create an object url and then feed that url to the audio element and call play. I also add the track name to the h1 so the user can see what is playing. You can do a lot here that I haven't to make a real UI by showing a play symbol, highlighting the track etc.

Permissions

One confusing oddity of the file system API is that while you save references to file handles, you do not keep the permissions across reloads. This definitely creates friction in the UI but you can mostly overcome it. If you were to reload the app now you'd see a list of tracks but if you clicked them you'd get a permission error in the console DOMException: The request is not allowed by the user agent or the platform in the current context.

To overcome this we need to renew our permissions every page load. I made a simple UI that just blurs the track names until the user clicks anywhere on the control. Once they do it reprompts:

attachEvents() {
    this.dom.open.addEventListener("click", this.open);
    if(this.#handle){ //new code
        this.addEventListener("click", this.requestPermission);
    }
}
Enter fullscreen mode Exit fullscreen mode

If we have a handle then we register the requestPermission handler. We need to do it this way because if we were to reuse a handler like the track click event, it would fail because we aren't allowed to wrap the requestPermission call in if statements due to how user gesture heuristic rules work. So we conditionally add the handler instead.

Request permission is simple:

async requestPermission(){
    try{
        await this.#handle.requestPermission({ mode: "read" });
        this.classList.remove("inactive");
                this.removeEventListener("click", this.requestPermission);
    } catch(e){};
}
Enter fullscreen mode Exit fullscreen mode

If the await succeeds then we have permission and can remove the class showing the blurred track list and the handler. If the user aborts (exception catch) then we do nothing and let them try again.

Play controls

Here's what the current code looks like, I've added some extra buttons:

render() {
    this.shadow = this.attachShadow({ mode: "open" });
    this.shadow.innerHTML = `
        <style>
            :host { height: 320px; width: 480px; display: block; background: #ef
            :host(.inactive) ul { filter: blur(2px); }
        </style>
        <button id="open">Open</button>
        <button id="stop">Stop</button>
        <button id="play">Play</button>
        <h1></h1>
        <ul></ul>
        <audio></audio>
    `;
    this.classList.add("inactive");
}
cacheDom() {
    this.dom = {
        title: this.shadowRoot.querySelector("h1"),
        audio: this.shadowRoot.querySelector("audio"),
        list: this.shadowRoot.querySelector("ul"),
        open: this.shadowRoot.querySelector("#open"),
        stop: this.shadowRoot.querySelector("#stop"),
        play: this.shadowRoot.querySelector("#play")
    };
}
attachEvents() {
    this.dom.open.addEventListener("click", this.open);
    this.dom.stop.addEventListener("click", this.stop);
    this.dom.play.addEventListener("click", this.play);
    if(this.#handle){
        this.addEventListener("click", this.requestPermission);
    }
}
Enter fullscreen mode Exit fullscreen mode

I added two buttons, "play" and "stop" to control playback. You could get fancy and reuse the same button if you want. They do exactly what you'd expect:

stop(){
    this.dom.audio.pause();
}
play(){
    this.dom.audio.play();
}
Enter fullscreen mode Exit fullscreen mode

Pause and play.

Conclusion

What I did here was very basic and no consideration was given to the UI. However, it shows you can make a music player with just web technologies and it can work decently enough. There's many ways I'd like to consider expanding this example in the future including media session API (to add controls to the lock screen on mobile), better file system fallbacks, better player UI, and the ability to launch the player when you click on an audio file on your OS. Hopefully it's enough to get you started if you want to try and make your own.

You can find the player here: https://gh.ndesmic.com/music-player/
And the code here: https://github.com/ndesmic/music-player/tree/v0.1

As of the time of writing this is the only real decent documentation on the File System API: https://web.dev/file-system-access/

Discussion (0)