Sounds like Vue

Building a custom audio player for the web.

Tagged with: javascript, programming, web

Published on

Recently I built a custom audio player for one of my websites. I quickly created the first version using Alpine.js. But after looking at the kilobytes shipped to the user, I decided to try to build a more lightweight version with Vue.js.

Last, I used Vue in a customer project three or four years ago. Since then, Vue version 3 was released which introduced a new API called the composition API. This was the first time I was using the new API. There were some initial learnings, but I quickly got used to the new way of building Vue components. Honestly, Vue is a lot of fun to work with.

Although Vue can be used without any build process, the real fun starts with single-file components. A single-file component is a file with the .vue extension. Such a file contains the logic, template, and styles of a single component. No special syntax has to be learned as it is mostly just JavaScript in a script tag, HTML in a template tag, and CSS in a style tag. The following figure shows what a simple “Hello, World!” component looks like.

<script setup>
  const name = "Tobias";
</script>

<template>
  <div>Hello, {{ name }}!</div>
</template>

<style scoped>
  div {
    font-size: 2rem;
    font-weight: bold;
  }
</style>

Source and rendered output of the “Hello, World!” Vue component.

Although this component is pretty simple many things are going on. First, you might spot the setup attribute on the script tag. This tells Vue that this component uses the composition API and that some boilerplate for this API can be prepared by Vue. Inside the template tag, the variables and functions defined in the script can be used. In this component, simple text interpolation using the double curly braces is used to print the value of the name variable. At the end of the single-file component comes the style tag which has a special scoped attribute. This attribute instructs Vue to only apply the given styles to the current component. Therefore, I can just write a CSS rule targeting div without having to worry about breaking the styles of everything else on the page.

This is already pretty nice but to implement an audio player some interactivity is necessary. So let’s take a look at how Vue deals with that.

<script setup>
  import { reactive } from "vue";

  const state = reactive({
    stepSize: 1,
    gauge: 0,
    countDecrease: 0,
    countIncrease: 0,
  });

  function onDecrease() {
    state.gauge -= state.stepSize;
    state.countDecrease += 1;
  }

  function onIncrease() {
    state.gauge += state.stepSize;
    state.countIncrease += 1;
  }
</script>

<template>
  <div>
    <div>Gauge: {{ state.gauge }}</div>
    <div>Increase Count: {{ state.countIncrease }}</div>
    <div>Decrease Count: {{ state.countDecrease }}</div>
    <div>
      <label for="interactive-step-size">Step size</label>
      <input
        id="interactive-step-size"
        type="number"
        step="1"
        min="1"
        max="99"
        :value="state.stepSize"
        @input="state.stepSize = parseInt($event.target.value, 10)"
      />
    </div>
    <button @click="onDecrease">Decrease</button>
    <button @click="onIncrease">Increase</button>
    <button
      @click="
        state.stepSize = 1;
        state.gauge = 0;
        state.countDecrease = 0;
        state.countIncrease = 0;
      "
    >
      Reset
    </button>
  </div>
</template>

Source and rendered output of an interactive Vue component.

The component above consists of an input, three buttons, and three values. The decrease and the increase button decrease or increase the gauge by the number of steps configured via the step-size input. The third button can be used to reset the state of the component. The values that are displayed by the component are the current value of the gauge, the number of times the increase button was clicked, and the number of times the decrease button was clicked.

First, let’s take a look at the template tag. There you can see that text interpolation is used to display the three values. More interesting are the input and the three buttons. Some attributes start with an @ sign. These are event handlers. If you compare them you can see that it is possible to either pass a function that should be called each time the event occurs, for example onDecrease, or to handle the event inline and use the $event variable to access the event. Double curly braces cannot be used to dynamically bind the values of attributes. Instead, to dynamically bind a value to an attribute the attribute has to be prefixed with :. This is what happens to the value attribute of the input. It is bound to the state.stepSize variable.

If you take a look at the script tag you can see that the reactive function is imported from Vue. This function can be used to create reactive objects in Vue. By using this function Vue can track what happens to the object and update the UI if it needs to. The rest of the script only contains the two event handlers to increase or decrease the gauge. With this example, all basics of Vue should be clear and we can get into how I built the audio player, if you want to know more about Vue just head over to its great documentation.

At the core of my custom audio player lies an HTML audio element. Without any customization, this element looks different in each browser. Just like you can see and try below.

HTML audio element playing:
“Heroic Age” Kevin MacLeod (incompetech.com)
Licensed under Creative Commons: By Attribution 4.0 License

I wanted my audio player to have the following features:

  • play/pause
  • go back 10 seconds
  • go forward 30 seconds
  • show progress
  • allow position seeking
  • display the current position
  • display the total duration
  • provide accessible labels

To implement the first three features only the basics of Vue and some knowledge of the HTML audio element are required. There needs to be some kind of state that holds information about the current playing state, position, and total duration. Then some buttons with event handlers are needed to toggle play and pause, go back 10 seconds, and go forward 30 seconds. These can be implemented via the reactive function and some @ event handlers as shown earlier. But somehow the audio element needs to be controlled and the total duration of the audio has to be known to correctly calculate the position when going forward. To do this two Vue functions are used.

First, we need the ref function which can either be used similarly to the reactive function or to hold a reference to a real DOM element, which has to be marked with the ref attribute, in a variable. With a ref variable that holds an audio element its JavaScript functions and properties can be accessed, for example, play(), pause(), duration, or currentTime. So now the element can be controlled by accessing the value of the ref variable that contains it. Additionally, I added event handlers to the play and pause events of the audio element to update the state of the component in case an external input starts or stops audio, for example media controls of the operating system.

Second, to set the total duration of the audio an event listener that listens to the loadedmetadata event can be registered. This event listener can store the duration in the reactive state. This will work sometimes but more often this event is not dispatched because the metadata of the audio element was already fetched before Vue registers its handlers. To still get the duration from the element a Vue lifecycle hook called onMounted can be used which is called after Vue created and inserted the components’ DOM tree. In this hook, the audio element can be accessed and its duration can be stored.

Without further ado here is the very simple audio player in all its glory.

<script setup>
  import { ref, reactive, onMounted } from "vue";

  const state = reactive({
    playing: false,
    position: 0,
    duration: 0,
  });

  const audioElement = ref(null);

  function togglePlayPause() {
    if (state.playing) {
      audioElement.value.pause();
    } else {
      audioElement.value.play();
    }
  }

  function goBack() {
    setPosition(Math.max(0, state.position - 10));
  }

  function goForward() {
    setPosition(Math.min(state.duration, state.position + 30));
  }

  function setPosition(value) {
    state.position = value;
    audioElement.value.currentTime = state.position;
  }

  onMounted(() => {
    audioElement.value.readyState > 0
      ? (state.duration = audioElement.value.duration)
      : undefined;
  });
</script>

<template>
  <div>
    <audio
      ref="audioElement"
      src="/path/to/audio.mp3"
      preload="metadata"
      @loadedmetadata="state.duration = $event.target.duration"
      @pause="state.playing = false"
      @play="state.playing = true"
      @timeupdate="state.position = $event.target.currentTime"
    ></audio>
    <div>
      <button @click="goBack()">-10 Seconds</button>
      <button @click="togglePlayPause()">
        {{ state.playing ? "Pause" : "Play" }}
      </button>
      <button @click="goForward()">+30 Seconds</button>
    </div>
  </div>
</template>

Simple audio player playing:
“Heroic Age” Kevin MacLeod (incompetech.com)
Licensed under Creative Commons: By Attribution 4.0 License

After getting the basic functionality working it was time to look into how I could implement the progress and position-seeking. I used an HTML input of type range as the base for this functionality. The biggest challenge was styling this 🤬 control, which I will not discuss here. Just know that if you want to style such an input you are going to write many different styles for different browsers, which, as everybody knows, is always fun.

Back to the matter at hand. To implement seeking and keeping track of the progress I introduced a new property called seekingPosition to the reactive state of the component. This property is undefined until the range input is touched. While the range input is in use the seekingPosition holds the value of the input. When the range input is no longer used the seekingPosition is set back to undefined and the audio plays from the last value that was assigned to the seekingPosition. This means that the component can be in two different modes: playing mode or seeking mode. In playing mode the range input shows the current position. In seeking mode the range input shows the position the user is currently at with the handle. When switching to seeking mode the audio must continue to play until the handle is let go and a change event is dispatched by the input.

Most of the functionality from the simple player can be used as is. There are just some changes required to incorporate the seekingPosition. The setPosition function now resets the seekingPosition to undefined in case the position was set via the change event of the range input. Instead of an inline listener to the timeupdate event of the audio element that sets the position a function is introduced which does not update the position if the seekingPosition has a value. The seekingPosition is set via an inline event handler which reacts to the input event of the range input. Another Vue feature called a computed property is used for this part of the player. A computed property gets calculated based on other reactive variables and can be created with the computed function. In this case, the currentPosition is a computed property that is either set to the position or to the seekingPosition depending on the current mode of the component. This computed property is used to bind the value of the range input and to display the value beside the input formatted as hours, minutes, and seconds.

The following code snippet shows how the seeking and playing mode work. Some irrelevant parts have been removed from the snippet so please do not try to execute this.

<script setup>
  import { ref, reactive, computed } from "vue";

  const state = reactive({
    playing: false,
    seekingPosition: undefined,
    position: 0,
    duration: 0,
  });

  const audioElement = ref(null);

  const currentPosition = computed(() =>
    Math.floor(
      state.seekingPosition == undefined
        ? state.position
        : state.seekingPosition
    )
  );

  function setPosition(value) {
    state.position = value;
    audioElement.value.currentTime = state.position;
    state.seekingPosition = undefined;
  }

  function onTimeupdate(e) {
    if (state.seekingPosition != undefined) {
      return;
    }

    state.position = e.target.currentTime;
  }
</script>

<template>
  <audio
    ref="audioElement"
    src="/path/to/audio.mp3"
    preload="metadata"
    @loadedmetadata="state.duration = $event.target.duration"
    @pause="state.playing = false"
    @play="state.playing = true"
    @timeupdate="onTimeupdate"
  ></audio>
  <input
    type="range"
    aria-label="Position"
    :max="Math.floor(state.duration)"
    :value="currentPosition"
    :aria-valuetext="formatSecondToValuetext(currentPosition)"
    @input="state.seekingPosition = $event.target.value"
    @change="setPosition($event.target.value)"
  />
  <time :datetime="formatSecondsToDuration(currentPosition)"
    >{{ formatSecondsToDisplay(currentPosition) }}</time
  >
</template>

Last but not least I want to mention some techniques I used to try to make the audio player as accessible as possible. To make the player accessible for people that use assistive technologies like screen readers I: set labels for all controls, used the aria-valuetext attribute on the range input to provide a more meaningful textual representation of its value, declared a region around the player with the label “Audio Player”, and used HTML time elements to represent the duration and position with valid duration strings as datetime attribute values. Just remember that audio is inherently inaccessible for certain groups ant it should be accompanied by a textual representation whenever possible similar to images alt text.

Finally, the following audio player is the result of this endeavor. You can find its source in this GitHub Gist which you can use however you want. I hope you learned something from this article and will try out Vue if you haven’t already.

Audio player playing:
“Heroic Age” Kevin MacLeod (incompetech.com)
Licensed under Creative Commons: By Attribution 4.0 License