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.
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.
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.
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.
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.