Building with Stencil: Audio Player Component

In this tutorial, we are going to build an audio player component using Stencil. You’ll be introduced to many fundamental concepts in Stencil and by the end of the tutorial you’ll have a foundational audio player component that you can customize for your own apps.
Stencil is a great tool for building reusable web components and is especially suitable for building out design systems at scale. Stencil components can be incorporated into many front-end frameworks like React, Angular, and Vue—or no framework at all. By building our audio player with Stencil, we’ll have an incredibly versatile component that we can use anywhere.
Here’s the final audio player component we’ll be building:
Find all of the code for this tutorial at the Stencil audio player component GitHub repository here.
Set Up Your Stencil Project
To create a new Stencil project, we’ll want to open up the terminal and run the following command:
npm init stencil
After running this command, you may be prompted to give permission to install the create-stencil
package. If so, answer “yes” to the prompt. Next, we’ll be prompted for a starter. Select “component” as that’s what we’ll be building in this tutorial.
? Pick a starter › - Use arrow-keys. Return to submit.
app Minimal starter for building a Stencil app or website
❯ component Collection of web components that can be used anywhere
Next you’ll be prompted for a project name. Feel free to name your project whatever you like. I named mine audio-player-project
. Once you’ve named your project, you’re all set up and ready to create a component.
Generate a New Component
Once we’ve created the Stencil project and we’ve navigated inside the project directory, we can generate all of the boilerplate code for our component with the following command:
npm run generate
When prompted for the component tag name, feel free to name it whatever you like. Just make sure that the name uses dash-case (includes a hyphen). We’ll be naming the component audio-player
. After you name the component, press enter again to generate all of the additional files. These files are used for testing and styling. Finally, navigate to src/components/audio-player/audio-player.tsx
(this will be different if you’ve named your component something else). This audio-player.tsx
file is the core of our component and we’ll also be using audio-player.css
for styling.
Props and State
In order to build this component, we need to think of all the required elements. We want to be able to provide any title and audio source for the player. Therefore, we need to use the @Prop
decorator to expose these properties.
We also need a few pieces of state in our audio player. We want to know the total duration of the audio clip, the current time of the clip, and whether or not the audio is playing or paused. Because each of these aspects can change depending on how the user interacts with the component, we’ll add them as pieces of state with the @State
decorator.
Finally, we need one last piece of state that we’ll use as a reference to our audio
element in order to access certain properties of the audio clip. With all of that in mind, we can initialize our props and state.
Export class AudioPlayer {
@Prop() title: string;
@Prop() src: string;
@State() isPlaying: boolean = false;
@State() duration: number = 0;
@State() currentTime: number = 0;
@State() audioPlayer: HTMLAudioElement;
Rendering the Audio Player
Now that we have all of our properties and states, we can begin to incorporate them into our render function to display them in the component.
render() {
return (
<div class="container">
<div class="title">{this.title}</div>
<div class="player">
<button class="play-button">
{this.isPlaying ? 'Pause' : 'Play' }
</button>
{this.currentTime + "/" + this.duration}
<audio
src={this.src}
preload="metadata"
ref={(el) => this.audioPlayer = el as HTMLAudioElement}
>
Your browser does not support the HTML5 audio element
</audio>
</div>
</div>
);
}
Most of the component is simply displaying the values of our props and state. The play button uses conditional rendering to display “play” or “pause” depending on whether or not the audio is playing. Most importantly, we have the audio
element at the bottom. Naturally, we set the source to the value of the prop passed into the component. By setting the preload
attribute to metadata
, we are specifying that the browser should load the audio metadata on page load. Finally, we use the ref
attribute to create a reference to the audio
element.
Of course, we’ll need to pass in attribute values for our audio-player
in our index.html
file.
<audio-player title="My Audio Clip" src="https://ionic.io/blog/wp-content/uploads/2021/09/Theme-Song-Ferdinand-Souvenir.mp3" />
Feel free to use whatever audio source you want here.
Playing Audio
Next, we want to add some functionality so we can actually play our audio. Luckily for us, our audio
element has methods for doing just that. Using our reference to the audio
element, and making sure to manage our isPlaying
state, we can create a function to play and pause.
togglePlay = () => {
if (this.isPlaying) {
this.audioPlayer.pause();
this.isPlaying = false;
} else {
this.audioPlayer.play();
this.isPlaying = true;
}
}
Then call that function when our button is pressed.
<button class="play-button" onClick={this.togglePlay}>
{this.isPlaying ? 'Pause' : 'Play' }
</button>
Audio Duration
Both our duration
and currentTime
states will be numbers representing time in seconds. We want to display these values to the user, but we want the time in minutes and seconds. Let’s create a function that takes time in seconds and formats the time in a more human readable way.
formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60).toLocaleString('en-US', { minimumIntegerDigits: 2 });
return minutes + ":" + seconds;
}
toLocaleString
is a convenient little method that allows us to prepend a 0 when the seconds value is a single digit. This way, one minute and three seconds is “1:03” instead of “1:3”.
Now let’s use this function to format our times.
{this.formatTime(this.currentTime) + "/" + this.formatTime(this.duration)}
Now that duration and current time are initialized and formatted, let’s actually set their values. First, we’ll set the duration. This is a property that we can access on our audioPlayer
element. However, we can only access it after our audioPlayer
reference has been attached to our audio
element and the audio metadata has loaded. We can wait for both of those events like so:
@Watch('audioPlayer')
watchAudioPlayerHandler() {
this.audioPlayer.onloadedmetadata = () => {
this.duration = this.audioPlayer.duration;
}
}
Here we use the @Watch
decorator which executes the attached function whenever the specified state changes. In our case, the audioPlayer
state changes when it becomes a reference to the audio
element. That change is our cue to grab the audio duration. To do that, we have to ensure the metadata has been loaded and is available, so we use the onloadedmetadata
method.
Current Time
The currentTime
is a little more complex because it is constantly changing. For such a situation we can leverage requestAnimationFrame()
which allows us to make a request to the browser to execute a specific function before the next repaint. The function is passed to requestAnimationFrame()
as a callback. Most importantly, if that function contains another call to requestAnimationFrame()
, it will create an animation loop. We’ll do this to continually increment our current time.
incrementTime = () => {
this.currentTime = this.audioPlayer.currentTime;
this.stopId = requestAnimationFrame(this.incrementTime);
}
You may be curious about this.stopId
. This is a number that is returned by requestAnimationFrame()
to identify the animation request. We can declare it towards the top of our component.
private stopId: number;
And we can use it in our togglePlay
function to stop the animation:
togglePlay = () => {
if (this.isPlaying) {
this.audioPlayer.pause();
this.isPlaying = false;
cancelAnimationFrame(this.stopId);
} else {
this.audioPlayer.play();
this.isPlaying = true;
this.incrementTime();
}
}
When we want to play the audio, we call our incrementTime
function to start the animation loop, and when we pause, we call cancelAnimationFrame
with our stopId
to end the animation loop.
Make it Pretty
Our play/pause button works well, but it doesn’t look great. To use icons instead of text, we must first create a new folder under src
called assets
. We then have to reference our assets
directory in our component.
@Component({
tag: 'audio-player',
styleUrl: 'audio-player.css',
assetsDirs: ['assets'],
shadow: true,
})
Next, we’ll add our play and pause icons to our assets
folder. Finally, we can use getAssetPath()
in an img
tag to display our icons.
<div class="play-button" onClick={this.togglePlay}>
{
this.isPlaying
? <img src={getAssetPath("../../assets/pause.svg")} />
: <img src={getAssetPath("../../assets/play.svg")} />
}
</div>
To top it all off, we’ll add some CSS in audio-player.css
:
:host {
display: block;
font-family: sans-serif;
}
.container {
width: fit-content;
padding: 12px;
border-radius: 8px;
box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
}
.title {
font-weight: bold;
margin-bottom: 8px;
}
.player {
display: flex;
justify-content: space-between;
align-items: center;
}
.play-button {
width: 16px;
}
And there you have it! We’ve built the foundation of an audio player component that you can now add all kinds of exciting features and styles to. Our audio player is one example, but you can build any component you want with Stencil, like a calendar or a clock. You can then use the components you build to create an entire design system that can be used across different frameworks. Needless to say, we’re excited to see what you decide to create!