DEV Community

ndesmic
ndesmic

Posted on

Building a PWA Music Player Part 3: Branding and OS File Handling

Cleanup

I did some small updates to the UI to look more like an app with a full-screen player. Also got rid of the cache bust button since we don't really need it right now.

Manifest.json

Most of the branding is done via the manifest.json as well a few other PWA features including file handling. You can add one you your page by specifying the path to it in a link tag:

<link rel="manifest" href="manifest.json">
Enter fullscreen mode Exit fullscreen mode

The file itself can located anywhere and is just some simple JSON data.

Icons

Let's start with perhaps the most important branding part of the PWA, icons. PWA icons can on paper be SVGs in most modern browsers however when I went to upgrade some of my boilerplate I found this was not working quite as expected. I was really hoping for it to work because I don't want to have a bunch of different images for different sizes. Unfortunately as of this writing, Chrome does not properly support SVG images as icons. If you add them you will get warnings in the application panel about them not being the correct size. Given this it's also not surprising that space delimited sizes and the any keyword also don't work.
Instead we'll need to use PNGs and we'll specifically need two sizes: 192x192 and 512x512. These are the two sizes that Chrome can use to scale the others it needs to display. You can add your own if you'd like more manual control.

Also we need at least one other icon, the favicon! Most modern browsers support SVGs so I suggest using that but otherwise a 32x32 ico is most compatible.

<link rel="icon" href="img/icon512.svg" type="image/xml+svg">
Enter fullscreen mode Exit fullscreen mode

I really wish I could stop using this tag and have it default to the manifest.json.

Speaking of things that need to use the manifest.json, optionally you can also add Apple specific meta tags for apple-touch-icon etc as well to improve the iOS experience.

<link rel="apple-touch-icon" href="img/icon192.png">
Enter fullscreen mode Exit fullscreen mode

Other manifest values

Let's look at the manifest:

//manifest.json
{
  "name": "Music Player",
  "short_name" :"Music Player",
  "icons": [
    {
      "src": "img/icon512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "img/icon192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ],
  "theme_color": "#ff6400",
  "background_color": "#9E9E9E",
  "start_url": "index.html",
  "display": "standalone",
  "orientation": "portrait"
}
Enter fullscreen mode Exit fullscreen mode

icon192 and icon512 are the respective icon sizes. You can optionally specify the purpose key per image. Values be any or maskable. These let you specify if it's maskable (ie "can I cut the corners off?"). I'm not going to worry about that as my icon should be okay, but if you need a special icon for that then you can add it along with the rest.

The other stuff here should be relatively straightforward. name is the name of the app while short_name might be used for the icon where there is less space. theme_color is the color the title bar takes in Android. Typically this will be primary color of your app. background_color is the color of the splash screen but browsers and OSs can use these values for whatever they want. start_url is the url that your app starts on when opened. This is important, it has to be a real url that the service worker can respond to, if not then you can't get an install prompt. orientation sets which type of display you want it to have portrait or landscape the latter might be useful if you are making a game. display is an interesting one that basically let's you set the browser chrome. standalone is the most typical if you are making a native looking app without default forward/back url bar, or fullscreen if you want fullscreen. The other values like browser will just make it a normal tab or minimal-ui will give you some browser ui.

File handling

Now that we have the manifest we can move on to file handling. This will allow us to be used like a real app when you double click a file! We just need to add this key to manifest.json:

//manifest.json
{
//...
  "file_handlers": [
    {
      "action": "index.html",
      "accept": {
        "audio/mp3": ".mp3"
      }
    },
    {
      "action": "index.html",
      "accept": {
        "audio/mp4": ".m4a"
      }
    }
  ],
//...
}
Enter fullscreen mode Exit fullscreen mode

We can register multiple handlers. Each has an action and a collection of MIME types that it should accept. The MIME types also include extensions (and you should include the "."). Since we're a music player audio/mp3 is a good one to support. We could probably do mp4a but it really depends on what the browser supports. Since I'm not maintaining my own codecs right now I'm going to be a bit conservative and not include exotic types like FLAC or OGG. The "action" is the page that will launch when you open that file type. You probably want to make sure that your service-worker can serve that page offline.

Launch Queue

The launch queue is a property on window that exists for browsers that can support file handling. It's pretty new (at least at the time of writing) so let's do feature detection:

if ("launchQueue" in window) {
  launchQueue.setConsumer(async launchParams => {
  }
}
Enter fullscreen mode Exit fullscreen mode

The launchParams will have the list of files that were sent.

In our app we'll just add a little bit of code to pass the file to the music player on load:

//app.js
const musicPlayer = document.querySelector("wc-music-player");
if ("launchQueue" in window) {
    launchQueue.setConsumer(async launchParams => {
        if (launchParams.files.length) {
            musicPlayer.loaded.then(() => musicPlayer.addFiles(launchParams.files, true));
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

In order for this to work we need to build some extra features into WcMusicPlayer. The first is to have a way to tell when the element is loaded. This state is different from ready which we used earlier to determine if it has file system access to play file (in retrospect the component itself dealing with the file picker was a bad idea). loaded will be to see if the element has been inserted and has a complete shadow DOM.

We'll add the loaded property in the constructor.

//wc-music-player
#setLoaded;
constructor() {
    super();
    this.#fileLinks = new WeakMap();
    this.bind(this);
    this.loaded = new Promise((res,rej) => this.#setLoaded = res)
}
Enter fullscreen mode Exit fullscreen mode

I then use a little trick to externalize the resolve callback. Normally you don't have access to it from the outside but if we assign it to #setLoaded then we can resolve this promise somewhere else. That somewhere else is right after we add the event handlers:

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

This makes sure that we don't try to add elements to the track list before it exists.

Next I've extracted the file adding from getFiles into it's own method addFiles:

addFiles(files, shouldPlay = false){
    this.isReady = true;
    const docFrag = document.createDocumentFragment();
    files.forEach(f => {
        this.#files.push(f);
        const li = document.createElement("li");
        li.textContent = f.name;
        docFrag.appendChild(li);
        this.#fileLinks.set(li, f);
    });
    this.dom.list.appendChild(docFrag);
    if(shouldPlay){
        files[0].getFile().then(f => this.playFile(f));
    }
}
Enter fullscreen mode Exit fullscreen mode

It's the same thing though I've made a few changes. Now adding files either internally or externally will set the player state to ready. It also take a flag shouldPlay. This is a UI choice. When we add thing we might want to play them right away, such as when we use the file handler, but we might also want to just add them to the playlist silently, especially if the user already has something playing. This gives us that choice.

getFiles was rewritten to call addFiles:

async getFiles(){
    this.addFiles(await collectAsyncIterableAsArray(filterAsyncIterable(this.#handle.values(), f => 
        f.kind === "file" && (f.name.endsWith(".mp3") || f.name.endsWith(".m4a")
        ))));
}
Enter fullscreen mode Exit fullscreen mode

Also playFile makes playing files simple:

//this is the actual file, not a file handle!
playFile(file){
    const url = URL.createObjectURL(file);
    this.dom.audio.src = url;
    this.dom.title.textContent = file.name;
    this.togglePlay(true);
}
Enter fullscreen mode Exit fullscreen mode

and selectTrack will use that now:

async selectTrack(e){
    const fileHandle = this.#fileLinks.get(e.target);
    if(fileHandle){
        const file = await fileHandle.getFile();
        this.playFile(file);
    }
}
Enter fullscreen mode Exit fullscreen mode

So now, if we have files in the launchParams they are inserted into the playlist and the first one starts playing!

You can now right-click files to play them in the music player:

Screenshot 2021-02-13 223415

You can see the Music Player option with the terrible icon. You could even make this the default player!

Caveats

If you decide to add new types later you need to re-install the app for them to appear. Also, because other means of adding files are not related to the file picker API we cannot persist them into the list as we will lose access to them next time.

Code is available here: https://github.com/ndesmic/music-player/tree/v0.4

Top comments (0)