Building a minimal audio player

04 September, 201910 min to read

Last week I needed to build an audio player for the company I work for. I bet there are already a ton of NPM packages out there that are accomplishing the same thing, but since the UI of the audio player needed to match our existing UI, and the effort to create a custom one would be relatively small, I decided to write this component myself. So here we are... coding time!

What are we going to build?

Oh, by the way, the audio you're hearing is from a podcast called The Undefined Podcast. Well worth a listen 👍🏼.

What do we want to build?

First, let's take a look at what we want to build. We want...

  • our player to play audio based on a url we provide via props
  • to toggle between playback states (pause / play)
  • to see the current position in the audio-file (min:sec)
  • to see the total duration of the audio-file (min:sec)
  • a bar which gives some sense of progress
  • a circle on that bar that allows us to go to any position
  • our player to show a loading state
  • our player to show a buffering state

Now that we've formulated the player's requirements, we kind of know how the component will look from the outside:

<AudioPlayer url="link-to-audio.mp3" />

Right, so basically our AudioPlayer is responsible for all the behavior and interaction we're about to implement. Of course, we can extend it's flexibility by optionally passing control to the parent for instance, but for now, we got enough work on our plates as it is.

Let's go!

Structure

The UI of the player will consist of two main components:

  • PlaybackButton - for toggling between play and pause
  • TimeBar - for showing time related info / interaction

Ideally, I want these UI components to be as dumb as possible. They just have to do their jobs. As far as I'm concerned they don't even have to know they're dealing with audio. This way, when we're building a video player in a few weeks, it's very likely we can re-use the same components we've built today.

But obviously the logic needs to go somewhere, preferably a hook, so that we can hopefully use it somewhere else in the future too.

A quick draft:

function AudioPlayer({ url }) {
  const audioLogicStuff = useAudio(url);

  return (
    <>
      <PlaybackButton />
      <TimeBar />
    </>
  );
}

PlaybackButton

This is the least interesting part I guess. Basically it's just another button we can click on, indicating a playbackState by means of an icon. Sorry Button, nothing personal!

TimeBar (part 1)

Controls vs. props

Alright, this will be a bit harder though ;)

Let's start with the props to get some sense:

<TimeBar
  currentTime={0} // how much seconds have we played since the start
  duration={1000} // total duration in seconds
  progress={50} // current position within audio-file (percentage 0-100)
  isSeeking={true} // did we just jump in time and is audio buffering
  setTime={() => {}} // function to set time location within audio
/>

Next we need to layout the structure of our elements:

function TimeBar() {
  return (
    <div className="timebar">
      <div className="timebar-bar" />
      <div className="timebar-circle" />
      <div className="timebar-time-info">
        <div>{isSeeking ? "buffering..." : "00:00"}</div>
        <div>03:11</div>
      </div>
    </div>
  );
}

We need to think about how the time related data we'll get from props, will affect the styling of our elements.

Let's begin with timebar-bar. We want a grey background in general, and a colored area up to the point where the current location is (progress in audio file). We could use two div's to achieve this, or we could use a linear-gradient background. Let's choose the latter, because... well I just like gradients 🙂. Also let's write a small utility-function that generates a style based on a certain progress:

function getBarStyle(progress) {
  const GREY = "#737373";
  const FILL = "#3d858c";

  return {
    background: `linear-gradient(to right, ${FILL} 0%, ${FILL} ${progress}%, ${GREY} ${progress}%, ${GREY} 100%)`,
  };
}

We might as well repeat this process for the timebar-circle:

function getCircleStyle(progress) {
  return {
    left: `${progress}%`,
  };
}

What's left are the time displays; one for the current time, and one for the total duration. The problem is that we will receive this data in seconds, though we want to display them in a 'mm:ss' format. So, we need some kind of function to transform those values:

// 90 => "01:30"
function formatTime(seconds) {
  return (
    [
      Math.floor(seconds / 60), // minutes
      Math.floor(seconds % 60), // remaining seconds
    ]
      .map(x => x.toString())

      // we want double digits, prepend a "0"
      // if necessary
      .map(x => (x.length === 1 ? `0${x}` : x))

      // join the result with a colon
      .join(":")
  );
}

If we apply these functions to our existing code:

function TimeBar({ progress, currentTime, duration, isSeeking }) {
  return (
    <div className="timebar">
      <div className="timebar-bar" style={getBarStyle(progress)} />
      <div className="timebar-circle" style={getCircleStyle(progress)} />
      <div className="timebar-time-info">
        <div>{isSeeking ? "buffering..." : formatTime(currentTime)}</div>
        <div>{formatTime(duration)}</div>
      </div>
    </div>
  );
}

Great! We now got ourselves a component which shows us continuously relevant time info. We've forgotten one thing though. We need a way to jump back and forth in time. We'll come back at this in a second...

The logic

In order to play audio we need to use the <audio /> element. For more background I recommend to read through the docs on MDN. I'm going to give you a small overview of the bits we're going to use:

<audio
  src={"link-to-audio.mp3"}
  // calls when the current location in our audio
  // file changes
  onTimeUpdate={() => {}}
  // audio is ready to be played
  onLoadedData={() => {}}
  // we've just jumped in time and now the audio
  // is seeking it's new position (sort of buffering)
  onSeeking={() => setSeeking(true)}
  // found it's new position
  onSeeked={() => setSeeking(false)}
/>

There are a couple of methods available on a HTMLAudioElement as well. The ones we're interested in, are play() and pause().

Ok, we now know how to retrieve the desired data, and how to alter it's behavior (play/pause/change url). Next, we need to setup some state:

function useAudio(url) {
  const [currentTime, setCurrentTime] = React.useState(0);
  const [duration, setDuration] = React.useState(0);
  const [playbackStatus, setPlaybackStatus] = React.useState("pause");
  const [isLoading, setLoading] = React.useState(true);
  const [isSeeking, setSeeking] = React.useState(false);
}

Whooaah, that are a lot of useState's man! Jep, true, feel free to use useReducer instead, but for me personally the state isn't complex enough to refactor into useReducer. Anyway, let's connect it with the audio element. Yeah that's right, with hooks you can return anything you like really, so why not elements?

function useAudio(url) {
  const audioRef = React.useRef(null);

  const [currentTime, setCurrentTime] = React.useState(0);
  const [duration, setDuration] = React.useState(0);
  const [playbackStatus, setPlaybackStatus] = React.useState("pause");
  const [isLoading, setLoading] = React.useState(true);
  const [isSeeking, setSeeking] = React.useState(false);

  // when url changes -> set loading state
  React.useEffect(() => {
    setLoading(true);
  }, [url]);

  return [
    <audio
      src={url}
      // we don't want to actually display the
      // browser's default element
      hidden
      ref={audioRef}
      onLoadedData={() => {
        setPlaybackStatus("pause");
        setLoading(false);
        setDuration(audioRef.current.duration);
      }}
      onSeeking={() => setSeeking(true)}
      onSeeked={() => setSeeking(false)}
      onTimeUpdate={() => {
        // on update, retrieve currentTime from ref,
        // store it in state
        setCurrentTime(audioRef.current.currentTime);
      }}
    />,
    {
      currentTime,
      duration,
      playbackStatus,
      isSeeking,
      isLoading,
      progress: (currentTime / duration) * 100,
      setTime: seconds => {
        audioRef.current.currentTime = seconds;
      },
      togglePlaybackStatus: () => {
        // there are nicer ways to handle
        // 'play' and 'pause'. The thing is that Safari
        // browsers only allow you to change audio's
        // playback-state in direct response to an event
        // https://medium.com/@curtisrobinson/how-to-auto-play-audio-in-safari-with-javascript-21d50b0a2765
        if (playbackStatus === "play") {
          audioRef.current.pause();
          setPlaybackStatus("pause");
        }
        if (playbackStatus === "pause") {
          audioRef.current.play();
          setPlaybackStatus("play");
        }
      },
    },
  ];
}

Let's zoom out a bit to see what we got so far:

function AudioPlayer({ url }) {
  const [audioElement, audioProps] = useAudio(url);

  return (
    <div>
      {audioElement}

      {audioProps.isLoading ? (
        <div>Loading...</div>
      ) : (
        <div>
          <PlaybackButton
            onClick={audioProps.togglePlaybackStatus}
            playbackStatus={audioProps.playbackStatus}
          />
          <TimeBar
            currentTime={audioProps.currentTime}
            isSeeking={audioProps.isSeeking}
            duration={audioProps.duration}
            progress={audioProps.progress}
            setTime={audioProps.setTime}
          />
        </div>
      )}
    </div>
  );
}

On to the finale!

TimeBar (part 2)

Remember we still need to implement the time jumping thing? Let's do it!

The way for the user to jump through time is by dragging the circle above the timebar. We want it to behave as follows:

  • when a user drags the circle on the x-axis we want a visual update
  • when the users stops dragging, we want to commit and set the new time
  • while dragging we want to ignore any incoming time-updates (otherwise we get conflicting styles, which will result in the circle jumping all over the place)

I don't want to build a whole dragging mechanism myself. Fortunately we can use a library that's perfect for this scenario: react-use-gesture. You read more about it on Github.

For updating the circle's style multiple times a second, I'm gonna use the library I created myself (direct-styled) and the one we've discussed in my previous article. Let's dig in.

import * as React from "react";
import { useDrag } from "react-use-gesture";
import { directstyled } from "direct-styled";

function TimeBar({ duration, progress, currentTime, isSeeking, setTime }) {
  // keep reference to the timebar-bar element
  // we use it for measurement later on
  const barRef = React.useRef(null);

  // hooks for updating rapid changing styles
  const [barStyle, setBarStyle] = useDirectStyle();
  const [circleStyle, setCircleStyle] = useDirectStyle();

  // keep track of wether or not to ignoreTimeUpdates
  // while dragging === ignore
  const [ignoreTimeUpdates, setIgnoreTimeUpdates] = React.useState(false);

  function setStyles(progress) {
    setCircleStyle(getCircleStyle(progress));
    setBarStyle(getBarStyle(progress));
  }

  // bind contains a bundle of stuff we need to
  // spread on the draggable element (circle)
  const bind = useDrag(
    // when user is dragging...
    ({ xy, first, last, event }) => {
      // prevent default drag behavior
      event.preventDefault();

      // if user just started dragging...
      if (first) {
        setIgnoreTimeUpdates(true);
      }

      // calc new position and progress based
      // on the user's position
      const { seconds, progress } = getNewTimeProps(
        barRef.current.getBoundingClientRect(),
        xy[0],
        duration
      );

      // if user stopped dragging
      // commit time and restore regular time updates
      if (last) {
        setTime(seconds);
        setIgnoreTimeUpdates(false);
        return;
      }

      // update styles to get visual feedback
      setStyles(progress);
    },
    // some options we need in order to use `event.preventDefault()`
    { event: { passive: false, capture: true } }
  );

  // when progress prop changes...
  React.useEffect(() => {
    // do nothing if we're currently dragging
    if (ignoreTimeUpdates) {
      return;
    }

    // otherwise, update accordingly
    setStyles(progress);
  }, [progress]);

  return (
    <div className={"timebar"}>
      <directstyled.div ref={barRef} className="timebar-bar" style={barStyle} />
      <directstyled.div
        // spread useDrag's props
        {...bind()}
        className="timebar-circle"
        style={circleStyle}
      />
      <div className="timebar-time-info">
        <div>{isSeeking ? "buffering..." : formatTime(currentTime)}</div>
        <div>{formatTime(duration)}</div>
      </div>
    </div>
  );
}

Ok, that was a lot of code at once. Hopefully my comments helped in some way. There's only one piece of the puzzle missing, and that's how we calculate a new currentTime (seconds) based on the user's drag position. For completeness, it involves these couple of lines right here:

const { seconds, progress } = getNewTimeProps(
  barRef.current.getBoundingClientRect(),
  xy[0],
  duration
);

Let's take a look at this getNewTimeProps() function:

// helper function that makes sure a number does not exceed
// a lower and upper range
const minMax = (min, max, value) => {
  if (value > max) {
    return max;
  }
  if (value < min) {
    return min;
  }
  return value;
};

function getNewTimeProps(barRect, clientX, duration) {
  // Get new seconds based on the user's position, bar's width,
  // and the total duration.
  // Takes into account the bar's offset relative to the screen
  const seconds = minMax(
    0,
    duration,
    Math.floor(((clientX - barRect.left) / barRect.width) * duration)
  );

  const progress = (seconds / duration) * 100;

  return { seconds, progress };
}

Conclusion

There you have it: a minimal audio player written in ~150 lines of code! We've covered some interesting things along the way:

  • working with the audio element
  • putting logic in a hook for reusability / separation of concerns
  • how we can utilize drag interaction to jump through time
  • how we can update styles in a smooth and efficient way

Be sure to check out the complete code on CodeSandbox.

Thanks for reading! I hope you liked it, and stay tuned for more articles! And please, don't be afraid to leave your feedback below.