Custom Audio Player Component
I wanted a custom audio / music player that has some modern features such as a
- custom slider
- custom controls
- more visual feedback
- analyzer / spectrogram
Following this video tutorial I was able to create something from vanilla Javascript with a ReactJS like structure.
plans to port this over to a real ReactJS are to follow --> Custom Audio Player Component - ReactJS Port
index.html
<head>
<script src='./scripts/MusicPlayer.js'></script>
</head>
<main className={styles.main}>
<h1>audio file visualizer</h1>
<music-player
src="/audio/sinsweeps.mp3"
title="Sick Track"
artist="WilliaMusic"
controls
// muted
// loop
preload
crossorigin='anonymous'
></music-player>
</main>
MusicPlayer.js
{
class MusicPlayer extends HTMLElement {
playing = false
currentTime = 0
duration = 0
volume = 0.4
prevVolume = 0.4
initialized = false
title = 'untitled'
artist = 'uknown'
constructor(){
super()
this.attachShadow( {mode: 'open'} )
this.render()
this.initializeAudio()
this.attachEvents()
}
static get observedAttributes(){
return ['src', 'title', 'artist', 'muted', 'crossorigin', 'loop', 'preload']
}
async attributeChangedCallback(name, oldValue, newValue){
if(name === 'src'){
if(this.playing){
await this.togglePLay()
}
this.initialized = false
this.render()
}
else if(name === 'title'){
this.title = newValue
if(this.titleElement){
this.titleElement.textContent = this.title
}
}
else if(name === 'artist'){
this.artist = newValue
if(this.artistElement){
this.artistElement.textContent = this.artist
}
}
else if(name === 'muted'){
this.volumeBar.value = 0
this.volume = 0
if(this.artistElement){
this.artistElement.textContent = this.artist
}
}
for (let i = 0; i < this.attributes.length; i++) {
const attr = this.attributes[i]
if(attr.specified && attr.name !== 'title'){
this.audio.setAttribute(attr.name, attr.value)
}
if(attr.specified && attr.name !== 'artist'){
this.audio.setAttribute(attr.name, attr.value)
}
}
if(!this.initialized){
this.initializeAudio()
}
console.log('--- ', name, oldValue, newValue);
}
initializeAudio(){
if(this.initialized) return
console.log('-- initializeAudio')
this.initialized = true
this.audioCtx = new AudioContext()
this.track = this.audioCtx.createMediaElementSource(this.audio)
this.gainNode = this.audioCtx.createGain()
this.analyzerNode = this.audioCtx.createAnalyser()
this.analyzerNode.fftSize = 2048
this.bufferLength = this.analyzerNode.frequencyBinCount
this.dataArray = new Uint8Array(this.bufferLength)
this.analyzerNode.getByteFrequencyData(this.dataArray)
this.track
.connect(this.gainNode)
.connect(this.analyzerNode)
.connect(this.audioCtx.destination) //TODO input different sources
this.attachEvents()
}
updateFrequency(){
if(!this.playing) return
this.analyzerNode.getByteFrequencyData(this.dataArray)
this.canvasCtx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.canvasCtx.fillStyle = 'rgba(255,255,255, 0.1)'
this.canvasCtx.fillRect(0, 0, this.canvas.width, this.canvas.height)
const barWidth = 3
const gap = 2
const barCount = this.bufferLength / ((barWidth + gap) - gap)
let x = 0
for(let i = 0; i < barCount; i++){
const perc = (this.dataArray[i] * 100) / 255
const h = (perc * this.canvas.height) / 100
this.canvasCtx.fillStyle = `rgba(${this.dataArray[i]}, 230, 200, 1)`
this.canvasCtx.fillRect(x, this.canvas.height - h, barWidth, h)
x += barWidth + gap
}
requestAnimationFrame(this.updateFrequency.bind(this))
}
attachEvents(){
this.playPauseBtn.addEventListener('click', this.togglePLay.bind(this), false)
this.muteButtonEl.addEventListener( 'click', e => {
this.toggleMute()
})
this.volumeBar.addEventListener( 'input', this.changeVolume.bind(this), false)
this.progressBar.addEventListener('input', () => {
this.seekTo(this.progressBar.value)
}, false )
this.audio.addEventListener('loadedmetadata', () => {
this.duration = this.audio.duration
this.progressBar.max = this.duration
// this.durationEl.textContent = `${mins}:${secs}`
// console.log('duration', this.audio.duration );
// console.log('currentTime', this.audio.currentTime );
})
this.audio.addEventListener('timeupdate', () => {
this.updateAudioTime(this.audio.currentTime)
})
this.audio.addEventListener('ended', () => {
this.playing = false
this.playPauseBtn.textContent = '🔄'
})
}
async togglePLay(){
if(this.audioCtx.state === 'suspended') await this.audioCtx.resume()
if(this.playing){
await this.audio.pause()
this.playing = false
this.playPauseBtn.textContent = '▶️'
} else {
await this.audio.play()
this.playing = true
this.playPauseBtn.textContent = '⏸️'
this.updateFrequency()
}
}
seekTo(value){
this.audio.currentTime = value
}
updateAudioTime(time){
this.currentTime = time
this.progressBar.value = this.currentTime
const secs = `${parseInt( `${time % 60}`, 10)}`.padStart(2, '0')
const mins = parseInt(`${(time / 60) % 60}`, 10)
this.currentTimeEl.textContent = `${mins}:${secs}`
}
changeVolume(){
this.prevVolume = this.volume
this.volume = Number(this.volumeBar.value)
this.gainNode.gain.value = this.volume
if(Number(this.volume) > 1.5){
this.volumeBar.parentNode.className = 'volume-bar high'
this.muteButtonEl.textContent = '🔊'
}
else if (Number(this.volume) > 1){
this.volumeBar.parentNode.className = 'volume-bar over'
this.muteButtonEl.textContent = '🔉'
}
else if (Number(this.volume) > 0){
this.volumeBar.parentNode.className = 'volume-bar half'
this.muteButtonEl.textContent = '🔈'
}
else{
this.volumeBar.parentNode.className = 'volume-bar'
this.muteButtonEl.textContent = '🔇'
}
}
toggleMute(){
console.log('mute toggled')
this.volumeBar.value = this.volume === 0
? this.prevVolume
: 0
this.changeVolume()
}
render() {
this.shadowRoot.innerHTML = `
<figure class="audio-player">
<figcaption class="audio-title"></figcaption>
<figcaption class="audio-artist"></figcaption>
<audio style="display: none"></audio>
<canvas class="visualizer" style="width: 100%; height: 60px"></canvas>
<div class='audio-controls'>
<div class='audio-transport'>
<button class="prev-btn" type="button"> ⏮️ </button>
<button class="play-btn" type="button"> ▶️ </button>
<button class="next-btn" type="button"> ⏭️ </button>
</div>
<div class="volume-bar">
<button class="mute-btn" type="button"> 🔈 </button>
<input type="range" min="0" max="2" step="0.01" value="${this.volume}" class="volume-field">
</div>
</div>
<div class="progress-indicator">
<span class="audio-time current-time">0:0</span>
<input type="range" max="100" value="0" class="progress-bar">
<span class="audio-time duration">0:00</span>
</div>
</figure>
`
this.canvas = this.shadowRoot.querySelector('canvas')
this.canvasCtx = this.canvas.getContext('2d')
this.audio = this.shadowRoot.querySelector('audio')
this.playPauseBtn = this.shadowRoot.querySelector('.play-btn')
this.titleElement = this.shadowRoot.querySelector('.audio-title');
this.artistElement = this.shadowRoot.querySelector('.audio-artist');
this.volumeBar = this.shadowRoot.querySelector('.volume-field');
this.muteButtonEl = this.shadowRoot.querySelector('.mute-btn');
this.progressIndicator = this.shadowRoot.querySelector('.progress-indicator');
this.currentTimeEl = this.progressIndicator.children[0];
this.progressBar = this.progressIndicator.children[1];
this.durationEl = this.progressIndicator.children[2];
}
}
customElements.define( 'music-player', MusicPlayer)
}
style.css
:host{
width: 100%;
max-width: 400px;
font-family: sans-serif;
}
:host * {
box-sizing: border-box
}
.audio-player{
background: #163532;
color: #fff;
border-radius: 5px;
padding: 1em;
align-items: center;
// display: flex;
position: relative;
margin: 0;
}
.audio-title{
font-weight: bold;
}
.audio-artist{
opacity: .7;
font-weight: 100;
font-size: .9rem;
margin-bottom: 1em;
}
.progress-indicator{
width: 100%;
padding: 1em .5em 1em .5em;
display: flex;
justify-content: center;
align-items: center;
}
input[type="range"], .progress-bar{
width: 70%;
height: 100%;
border-radius: 500px;
appearance: none;
background: none;
overflow: hidden;
cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track{
background: grey;
height: 10px;
border-radius: 500px;
appearance: none;
}
input[type="range"]::-webkit-slider-thumb{
width: 0;
// border-radius: 50%;
box-shadow: -300px 0 0 300px dimgrey;
appearance: none;
background: dimgrey;
width: 6px;
height: 20px;
margin: -7px 0 0 0;
border-radius: 12px;
}
div:has(input[type="range"]):hover input[type="range"]::-webkit-slider-thumb{
background: white;
}
.audio-time{
opacity: .7;
font-size: .7rem;
font-weight: 100;
margin: 0 .4em;
}
.audio-controls{
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1em;
}
.volume-bar {
position: relative;
}
.volume-bar.half button{
color: blue;
}
.volume-bar.over button{
color: red;
}