Audio Player in Js - Self-Review #1

This is the first post in an ongoing series of Self-Code-Review. Where I pick subjectively unique and interesting modules, in different projects, architectures, and languages.

It’s not always the code that matters, it’s the critiques and observations that we will learn most from.

I know you guys have a tendency to skip through the whole thing only looking for the text in a monospace typeface (Don’t deny it) so I’ve something just for you: A collapsible text that you only have to read if you think it’s starting to get interesting.

What is this? What am I doing here?

We all have this moment when we look back at older code, take some time to remember that it was us who wrote it, and then feel happy that we can now tell how horrible it is, and how we could improve it now, just to loath it again 6 months in the future.

This is basically it: In every post in this series I’ll provide a guide on how to build a certain feature/component. And then iterate on it pinpointing what could be better, what could be added, and why some things need to remain the way they are.

Audio-Player Library

We finally made it here! We want to make a reusable, typical audio player API that we can later use in many contexts be it user interaction or a response to an app event…etc.

We need to define some core functionality that should exist in a standard audio player:

  • play audio (Oh my goodness, gracious me!)
  • pause audio
  • resume audio
  • stop audio
  • play multiple audio(s?)
  • repeat/ loop

And to be more realistic we need some way to know what’s actually going on, they call that event listeners:

  • onPlay
  • onTimeUpdate
  • onPause
  • onStop

Making some noise

Like a good API, ours shouldn’t make assumptions about who is using it. It shouldn’t matter if it’s a react component or the man sitting inside your TV.

Just hook into an audio tag mate! (you think?)

We can’t rely on UI (or in this case the DOM) to make our native audio player (even though we can totally do it this way). It’d be much better if our code is self-sufficient and reusable in many scenarios.

let player = new Audio();

Luckily for us, we have a native Audio API in Js that we can abuse. It gives us primitives that we can build upon much more sophisticated functionalities.

Let’s start simple and play

export const play = (url) => {
  stop();
  player.src = url;
  player.play();
};
We made some bold decisions here!

First we accept the audio to play in the form of a url string. One would think that's not the most future proof choice to make; A wrapper object of sorts that encapsulates the format of audio file to play would be better and can be easier to tweak in the future.

A counter argument would be that's not very likely (in the context of the project this was built in) that this api will be going to do more. Future proofing is always good, but realism and time constrains should always govern all decisions.

Personally? I think it's not that complex or lengthy to implement a wrapper for our audio uri. I was just being lazy.

We also added a call to stop()! Why? It's the cheap way of running things in order. What is stop()? Glad you asked!

export const stop = () => {
  player.pause();
  player.currentTime = 0;
};

Yes, it’s like manually adjusting your tape player.

How can we improve this? I think a sharp STOP isn’t exactly polite and not what you want to hear, it’d be sweet to add some fading effect. But yeah, total perfectionism.

Two core functionalities remain:

export const resume = () => {
  player.play();
};

export const pause = () => {
  player.pause();
};
Now we're ready to discuss a bigger design choice: Why is it all just functions?

It's not a paradigm war, if you put this code inside a class, you'll probably instantiate one object to use in your app. For some folk this is known as a static class. And this is just another fancy word for redundancy.


Events

This is my onPlay

export const onPlay = (action) => {
  const cb = () => {
    action(player.src);
  };
  player.addEventListener("play", cb);
  return () => player.removeEventListener("play", cb);
};

Simple as it is, it gives the user a way out by returning a function that unsubscribes from the event.

The same applies to other events

export const onTimeUpdate = (action) => {
  const cb = () => {
    action(player.src, player.currentTime);
  };
  player.addEventListener("timeupdate", cb);
  return () => player.removeEventListener("timeupdate", cb);
};
export const onPause = (action) => {
  const cb = () => {
    action(player.src);
  };
  player.addEventListener("pause", cb);
  return () => player.removeEventListener("pause", cb);
};
export const onStop = (action) => {
  const cb = () => {
    action(player.src);
  };
  player.addEventListener("ended", cb);
  return () => player.removeEventListener("ended", cb);
};

A playlist

Our lib now is good at playing one audio at a time, this is good. It’s also useless TBH. How can we make that (a playlist) happen using the primitives we originally had and the ones we created?

We know we don’t have a way to add multiple sources to our player, so we have to make our own way of queuing these audio sources

let queue = [];

This simple queue holds our audio urls. We can now play one audio after the other as soon as it’s finished playing. We have a way to know if an audio is started or stopped. We’ll definitely take advantage of that

const next = () => playlist(queue, false);
export const playlist = (
  urls,
  clear = false,
) => {
  if (clear) {
    queue = [];
    queue.push(...urls);
  }
  if (queue.length !== 0) {
    const upcoming = queue.shift();
    player.src = upcoming;
    player.removeEventListener("ended", next);
    player.addEventListener("ended", next);
    player.play();
  } else {
    player.removeEventListener("ended", next);
  }
};

Because we don’t have control over when the audio ends (we can’t always end it ourselves), we can’t iterate on the queue and play its contents one by one. So we have to use recursion (always exciting, isn’t it?)

We first declared a next() function that calls playlist() again when the ended event fires. And we stop doing that when the queue is empty.

We also added the option to start a new playlist by providing a clear flag. Neat!

How to loop?

At first, we could just add another flag and call it a day, but it becomes more complicated

export const playlist = (
  urls,
  clear = false,
  loop = false,
)

Our next() now has to support this flag as well but wait! What’s it gonna be? true? to always loop, or false? It can’t be false though otherwise we’d not have implemented it in the first place.

Here is the part of code that reminds you that nothing can be perfect. You add a hack to support a false sense of dynamic setting of the loop flag

const next = () => playlist(queue, false, false);
const nextLoop = () => playlist(queue, false, true);

We’ll have to update our logic to fit the new hacky nextLoop()

  if (queue.length !== 0) {
    const upcoming = queue.shift();
    if (loop) {
      queue.push(upcoming);
    }
    player.src = upcoming;
    player.removeEventListener("ended", loop ? nextLoop : next);
    player.addEventListener("ended", loop ? nextLoop : next);
    player.play();
  } else {
    player.removeEventListener("ended", loop ? nextLoop : next);
  }

Yep, it lost its beauty. I won’t even comment on the loop ? nextLoop : next part. You know what’s wrong with it and how to improve it.

But it’s not even done yet! We still need a way to notify the caller when each audio in the playlist is about to be played. We’ll have to accept a callback to do that

const onPlayNext = (url) => ({});
export const playlist = (
  urls,
  clear = false,
  loop = false,
  onNext = onPlayNext
)

And in the function body we modified it a little

player.play();
queue.length && onNext(upcoming); // readability: 0

What saddens me most is that Javascript acts weird when you use default arguments in your functions; Even though we added a default callback in case the user doesn’t want to use this feature, this has an effect of permanently calling the default value even if the user supplied their own! Classic JavaScript eh?

let onPlayNext = (url) => ({});
export const playlist = (
  urls,
  clear = false,
  loop = false,
  onNext = onPlayNext
) => {
  onPlayNext = onNext;
  // ...
}
This is the ugly fix in case you're wondering. And it brings problems of its own

You're now playing with globals that can very easily interleave. Yes, Js is single-threaded but it's possible to have all sorts of fun race conditions when using the event loop. We'll tap on that later.

We also have a very ugly and confusing code to handle onNext.

I’ll have to ask you to remember that we also have a play() function that plays singular audio. It was calling stop() before and that was nice. But now that we introduced playlist(), our stop() won’t be as powerful. We have to reset the playlist whenever we call stop() just in case it was playing.

We can make life easier for us by adding a clear() function that..clears

export const clear = () => {
  queue = [];
  player.removeEventListener("ended", next);
};

When we clear() the queue, it’s pretty clear (no pun intended) that we lost interest in the current queue and/or audio. We no longer need to play the next audio because there isn’t one. that’s why we removed the ended listener. Now our stop() can look like this

export const stop = () => {
  player.pause();
  player.currentTime = 0;
  clear();
};

Afterthoughts

You now reached the state I was in when I first worked on that project. But you know much better of its pitfalls and why it is the way it is. We can now briefly talk improvements:

  • This can be safer by declaring the APIs as async. Sure it’ll introduce complexity but can provide safe access to state
  • loop shouldn’t be a luggage to carry, it can be better represented as a state
  • Don’t let js stop your dreams; check if onNext is null instead of the default function arg
  • Insert yours here!

Ps: Full code can be found on GitHub