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">
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">
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">
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"
}
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"
}
}
],
//...
}
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 => {
}
}
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));
}
});
}
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)
}
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();
}
}
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));
}
}
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")
))));
}
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);
}
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);
}
}
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:
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)