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;
}
}
)
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")
};
}
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);
}
And here's open
:
async open(){
this.#handle = await window.showDirectoryPicker();
await this.#storage.set("handle", this.#handle);
this.getFiles();
}
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);
});
}
}
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();
}
}
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);
}
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;
}
Once we have our list we create a document fragment and add li
s 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);
}
It's a weak map because if we were to remove one of the li
s 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;
}
}
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);
}
}
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){};
}
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);
}
}
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();
}
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/
Top comments (0)