Creare un player audio con JavaScript e CSS
L'elemento <audio> di HTML offre già un player funzionante tramite l'attributo controls, ma l'aspetto dei controlli nativi varia da browser a browser e non è personalizzabile in modo affidabile. La soluzione più comune consiste nel nascondere i controlli nativi e costruire un'interfaccia propria, usando l'elemento <audio> solo come motore di riproduzione e pilotandolo tramite la sua API JavaScript.
In questo articolo realizzeremo un player completo con pulsante play/pausa, barra di avanzamento cliccabile e trascinabile, visualizzazione del tempo, controllo del volume con funzione mute, una piccola playlist e scorciatoie da tastiera, prestando attenzione all'accessibilità.
La struttura HTML
Il markup è composto da un elemento <audio> privo dell'attributo controls e da un contenitore con i controlli personalizzati. Usiamo elementi semantici: pulsanti veri (<button>) per le azioni e input di tipo range per la barra di avanzamento e il volume, così da ottenere gratuitamente il supporto a tastiera e agli screen reader.
<div class="audio-player" id="player">
<audio id="audio" preload="metadata"></audio>
<div class="player-info">
<p class="track-title" id="track-title">Nessun brano</p>
<p class="track-artist" id="track-artist"></p>
</div>
<div class="player-controls">
<button type="button" class="btn" id="btn-prev" aria-label="Brano precedente">⏮</button>
<button type="button" class="btn btn-play" id="btn-play" aria-label="Riproduci">▶</button>
<button type="button" class="btn" id="btn-next" aria-label="Brano successivo">⏭</button>
</div>
<div class="player-progress">
<span class="time" id="time-current">0:00</span>
<input type="range" id="progress" class="range" min="0" max="100" step="0.1" value="0" aria-label="Avanzamento">
<span class="time" id="time-duration">0:00</span>
</div>
<div class="player-volume">
<button type="button" class="btn btn-small" id="btn-mute" aria-label="Disattiva audio">🔊</button>
<input type="range" id="volume" class="range" min="0" max="1" step="0.01" value="1" aria-label="Volume">
</div>
<ol class="playlist" id="playlist"></ol>
</div>
L'attributo preload="metadata" indica al browser di scaricare solo le informazioni essenziali del file (durata, dimensioni) e non l'intero contenuto, riducendo il consumo di banda finché l'utente non avvia la riproduzione.
Lo stile con CSS
Definiamo le variabili di colore tramite custom properties, così da poter cambiare il tema del player modificando pochi valori. La parte più delicata riguarda gli input range, il cui aspetto va reimpostato con appearance: none e poi ridisegnato tramite gli pseudo-elementi specifici di ciascun motore di rendering.
.audio-player {
--player-bg: #1e1e2e;
--player-fg: #e6e6f0;
--player-accent: #7c6cf2;
--player-track: #3a3a52;
--progress: 0%;
max-width: 420px;
padding: 1.5rem;
border-radius: 12px;
background: var(--player-bg);
color: var(--player-fg);
font-family: system-ui, sans-serif;
}
.player-info {
text-align: center;
margin-bottom: 1rem;
}
.track-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.track-artist {
margin: 0.25rem 0 0;
font-size: 0.9rem;
opacity: 0.7;
}
.player-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
font-size: 1.2rem;
cursor: pointer;
transition: background-color 0.2s;
}
.btn:hover {
background: var(--player-track);
}
.btn:focus-visible {
outline: 2px solid var(--player-accent);
outline-offset: 2px;
}
.btn-play {
width: 56px;
height: 56px;
background: var(--player-accent);
color: #fff;
}
.btn-play:hover {
background: var(--player-accent);
filter: brightness(1.1);
}
.btn-small {
width: 36px;
height: 36px;
font-size: 1rem;
}
.player-progress,
.player-volume {
display: flex;
align-items: center;
gap: 0.75rem;
}
.player-volume {
margin-top: 0.75rem;
}
.time {
min-width: 3ch;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
Per gli slider sfruttiamo un gradiente lineare il cui punto di stop è controllato dalla variabile --progress, che aggiorneremo da JavaScript. In questo modo la parte già riprodotta appare colorata anche in Chrome e Safari, che a differenza di Firefox non offrono uno pseudo-elemento dedicato alla porzione "piena" della traccia.
.range {
flex: 1;
height: 6px;
border-radius: 3px;
background: linear-gradient(
to right,
var(--player-accent) var(--progress),
var(--player-track) var(--progress)
);
appearance: none;
cursor: pointer;
}
/* Cursore per browser basati su WebKit/Blink */
.range::-webkit-slider-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
appearance: none;
transition: transform 0.15s;
}
.range:hover::-webkit-slider-thumb {
transform: scale(1.2);
}
/* Cursore per Firefox */
.range::-moz-range-thumb {
width: 14px;
height: 14px;
border: none;
border-radius: 50%;
background: #fff;
}
.range:focus-visible {
outline: 2px solid var(--player-accent);
outline-offset: 4px;
}
.playlist {
margin: 1.25rem 0 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--player-track);
}
.playlist-item {
display: flex;
justify-content: space-between;
padding: 0.6rem 0.5rem;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
}
.playlist-item:hover {
background: var(--player-track);
}
.playlist-item.is-active {
color: var(--player-accent);
font-weight: 600;
}
I dati della playlist
Rappresentiamo la playlist come un array di oggetti. In un'applicazione reale questi dati potrebbero provenire da un'API REST; qui li definiamo staticamente per semplicità.
const tracks = [
{ title: 'Morning Light', artist: 'Aurora Band', src: 'audio/morning-light.mp3' },
{ title: 'City Walk', artist: 'Neon Drift', src: 'audio/city-walk.mp3' },
{ title: 'Late Night Coding', artist: 'Lo-Fi Collective', src: 'audio/late-night-coding.mp3' }
];
La classe AudioPlayer
Organizziamo la logica in una classe che riceve l'elemento contenitore e l'elenco dei brani. Il costruttore recupera i riferimenti agli elementi del DOM, inizializza lo stato e registra i gestori degli eventi.
class AudioPlayer {
constructor(root, tracks) {
this.root = root;
this.tracks = tracks;
this.currentIndex = 0;
// Indica se l'utente sta trascinando la barra di avanzamento
this.isSeeking = false;
// Volume memorizzato prima del mute, per poterlo ripristinare
this.lastVolume = 1;
this.audio = root.querySelector('#audio');
this.btnPlay = root.querySelector('#btn-play');
this.btnPrev = root.querySelector('#btn-prev');
this.btnNext = root.querySelector('#btn-next');
this.btnMute = root.querySelector('#btn-mute');
this.progress = root.querySelector('#progress');
this.volume = root.querySelector('#volume');
this.timeCurrent = root.querySelector('#time-current');
this.timeDuration = root.querySelector('#time-duration');
this.trackTitle = root.querySelector('#track-title');
this.trackArtist = root.querySelector('#track-artist');
this.playlist = root.querySelector('#playlist');
this.renderPlaylist();
this.bindEvents();
this.loadTrack(0);
this.updateRangeFill(this.volume);
}
}
Formattare il tempo
Le proprietà currentTime e duration dell'elemento audio sono espresse in secondi con parte decimale. Ci serve un metodo di utilità che le converta nel formato m:ss, gestendo anche il caso in cui la durata non sia ancora disponibile (in quel caso vale NaN) o sia infinita, come accade per gli stream in diretta.
static formatTime(seconds) {
// Durata non ancora nota o stream senza fine
if (!Number.isFinite(seconds)) {
return '0:00';
}
const totalSeconds = Math.floor(seconds);
const minutes = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return `${minutes}:${String(secs).padStart(2, '0')}`;
}
Caricare un brano
Il metodo loadTrack() imposta la sorgente dell'elemento audio, aggiorna le informazioni visualizzate e azzera la barra di avanzamento. Il parametro autoplay permette di avviare subito la riproduzione quando si passa da un brano all'altro.
loadTrack(index, autoplay = false) {
// Gestisce lo scorrimento circolare della playlist
const total = this.tracks.length;
this.currentIndex = (index + total) % total;
const track = this.tracks[this.currentIndex];
this.audio.src = track.src;
this.trackTitle.textContent = track.title;
this.trackArtist.textContent = track.artist;
this.progress.value = 0;
this.updateRangeFill(this.progress);
this.timeCurrent.textContent = '0:00';
this.timeDuration.textContent = '0:00';
this.highlightPlaylistItem();
if (autoplay) {
this.play();
}
}
Riproduzione e pausa
Il metodo play() dell'elemento audio restituisce una Promise. Questo è importante perché i browser applicano politiche di autoplay: se la riproduzione non è stata avviata da un'interazione dell'utente, la promise viene rifiutata con un NotAllowedError. Gestire il rifiuto evita errori non catturati nella console e ci permette di mantenere l'interfaccia coerente.
async play() {
try {
await this.audio.play();
} catch (error) {
// Il browser ha bloccato la riproduzione o la sorgente non è valida
console.warn('Riproduzione non avviata:', error.message);
}
}
pause() {
this.audio.pause();
}
togglePlay() {
if (this.audio.paused) {
this.play();
} else {
this.pause();
}
}
updatePlayButton() {
const isPaused = this.audio.paused;
this.btnPlay.innerHTML = isPaused ? '▶' : '❚❚';
this.btnPlay.setAttribute('aria-label', isPaused ? 'Riproduci' : 'Pausa');
}
Notate che il pulsante non viene aggiornato direttamente dentro togglePlay(), ma in risposta agli eventi play e pause emessi dall'elemento audio. In questo modo l'interfaccia riflette sempre lo stato reale del player, anche quando la riproduzione viene controllata dall'esterno, ad esempio dai tasti multimediali della tastiera o dal sistema operativo.
Aggiornare la barra di avanzamento
L'evento timeupdate viene emesso periodicamente durante la riproduzione (in genere tra 4 e 66 volte al secondo, a seconda del browser e del carico). Ad ogni emissione calcoliamo la percentuale riprodotta e aggiorniamo lo slider, a meno che l'utente non lo stia trascinando: in quel caso sovrascriveremmo il valore che sta scegliendo, provocando un fastidioso effetto di "salto".
onTimeUpdate() {
if (this.isSeeking) {
return;
}
const { currentTime, duration } = this.audio;
const percent = Number.isFinite(duration) && duration > 0
? (currentTime / duration) * 100
: 0;
this.progress.value = percent;
this.updateRangeFill(this.progress);
this.timeCurrent.textContent = AudioPlayer.formatTime(currentTime);
}
onLoadedMetadata() {
this.timeDuration.textContent = AudioPlayer.formatTime(this.audio.duration);
}
updateRangeFill(input) {
const min = Number(input.min);
const max = Number(input.max);
const percent = ((Number(input.value) - min) / (max - min)) * 100;
// Aggiorna la variabile CSS usata dal gradiente dello slider
input.style.setProperty('--progress', `${percent}%`);
}
Spostarsi all'interno del brano
Per il seeking distinguiamo due eventi dell'input range: input, emesso continuamente durante il trascinamento, e change, emesso al rilascio. Durante il trascinamento aggiorniamo solo l'etichetta del tempo, fornendo un'anteprima della posizione; al rilascio impostiamo effettivamente currentTime. Questo approccio evita di inviare al browser decine di richieste di seek consecutive, che con file remoti potrebbero generare altrettante richieste HTTP di tipo Range.
onProgressInput() {
this.isSeeking = true;
this.updateRangeFill(this.progress);
const { duration } = this.audio;
if (Number.isFinite(duration)) {
// Mostra un'anteprima del tempo corrispondente alla posizione del cursore
const previewTime = (this.progress.value / 100) * duration;
this.timeCurrent.textContent = AudioPlayer.formatTime(previewTime);
}
}
onProgressChange() {
const { duration } = this.audio;
if (Number.isFinite(duration)) {
this.audio.currentTime = (this.progress.value / 100) * duration;
}
this.isSeeking = false;
}
Volume e mute
La proprietà volume accetta valori compresi tra 0 e 1, esattamente l'intervallo che abbiamo assegnato allo slider. Per il mute usiamo la proprietà booleana muted, che silenzia l'audio senza alterare il volume impostato. L'evento volumechange viene emesso quando cambia una qualsiasi delle due proprietà, e lo usiamo come unico punto in cui sincronizzare l'interfaccia.
onVolumeInput() {
const value = Number(this.volume.value);
this.audio.volume = value;
// Portare il volume a zero equivale a silenziare, alzarlo riattiva l'audio
this.audio.muted = value === 0;
}
toggleMute() {
if (this.audio.muted || this.audio.volume === 0) {
this.audio.muted = false;
this.audio.volume = this.lastVolume || 1;
} else {
this.lastVolume = this.audio.volume;
this.audio.muted = true;
}
}
onVolumeChange() {
const isMuted = this.audio.muted || this.audio.volume === 0;
this.volume.value = isMuted ? 0 : this.audio.volume;
this.updateRangeFill(this.volume);
this.btnMute.innerHTML = isMuted ? '🔇' : '🔊';
this.btnMute.setAttribute('aria-label', isMuted ? 'Riattiva audio' : 'Disattiva audio');
}
Tenete presente che su iOS la proprietà volume è in sola lettura: il volume è controllato esclusivamente dai tasti fisici del dispositivo e le assegnazioni vengono ignorate. Il mute continua invece a funzionare. Se il player è destinato anche a dispositivi Apple mobili, è ragionevole nascondere lo slider del volume dopo aver verificato questa limitazione.
La playlist
Generiamo gli elementi della lista a partire dall'array dei brani. Per ogni elemento usiamo textContent anziché innerHTML, così che titoli contenenti caratteri speciali non possano mai essere interpretati come markup.
renderPlaylist() {
const fragment = document.createDocumentFragment();
this.tracks.forEach((track, index) => {
const item = document.createElement('li');
item.className = 'playlist-item';
item.tabIndex = 0;
item.dataset.index = index;
item.setAttribute('role', 'button');
const title = document.createElement('span');
title.textContent = `${index + 1}. ${track.title}`;
const artist = document.createElement('span');
artist.textContent = track.artist;
item.append(title, artist);
fragment.append(item);
});
this.playlist.replaceChildren(fragment);
}
highlightPlaylistItem() {
this.playlist.querySelectorAll('.playlist-item').forEach((item) => {
const isActive = Number(item.dataset.index) === this.currentIndex;
item.classList.toggle('is-active', isActive);
item.setAttribute('aria-current', isActive ? 'true' : 'false');
});
}
onPlaylistActivate(event) {
const item = event.target.closest('.playlist-item');
if (!item) {
return;
}
this.loadTrack(Number(item.dataset.index), true);
}
Invece di registrare un gestore su ogni elemento della lista, usiamo la delegazione degli eventi: un unico listener sull'elemento <ol> intercetta i clic e risale fino all'elemento li tramite closest(). La soluzione continua a funzionare anche se la playlist viene rigenerata.
Registrare gli eventi
Il metodo bindEvents() collega tutti i gestori. Gli arrow function mantengono il corretto valore di this all'interno dei metodi della classe. Quando un brano termina (evento ended) passiamo automaticamente al successivo.
bindEvents() {
// Controlli dell'interfaccia
this.btnPlay.addEventListener('click', () => this.togglePlay());
this.btnPrev.addEventListener('click', () => this.previous());
this.btnNext.addEventListener('click', () => this.next());
this.btnMute.addEventListener('click', () => this.toggleMute());
this.progress.addEventListener('input', () => this.onProgressInput());
this.progress.addEventListener('change', () => this.onProgressChange());
this.volume.addEventListener('input', () => this.onVolumeInput());
// Playlist: clic e attivazione da tastiera
this.playlist.addEventListener('click', (event) => this.onPlaylistActivate(event));
this.playlist.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.onPlaylistActivate(event);
}
});
// Eventi dell'elemento audio
this.audio.addEventListener('play', () => this.updatePlayButton());
this.audio.addEventListener('pause', () => this.updatePlayButton());
this.audio.addEventListener('timeupdate', () => this.onTimeUpdate());
this.audio.addEventListener('loadedmetadata', () => this.onLoadedMetadata());
this.audio.addEventListener('volumechange', () => this.onVolumeChange());
this.audio.addEventListener('ended', () => this.next());
this.audio.addEventListener('error', () => this.onError());
// Scorciatoie da tastiera
this.root.addEventListener('keydown', (event) => this.onKeyDown(event));
}
previous() {
// Se il brano è avanzato oltre 3 secondi, torna all'inizio invece di cambiare brano
if (this.audio.currentTime > 3) {
this.audio.currentTime = 0;
return;
}
this.loadTrack(this.currentIndex - 1, !this.audio.paused);
}
next() {
const shouldPlay = !this.audio.paused || this.audio.ended;
this.loadTrack(this.currentIndex + 1, shouldPlay);
}
onError() {
const track = this.tracks[this.currentIndex];
this.trackArtist.textContent = `Impossibile caricare "${track.title}"`;
this.updatePlayButton();
}
Il comportamento del pulsante "precedente" riproduce quello dei player più diffusi: se il brano è già avanzato di qualche secondo, il primo clic lo riporta all'inizio e solo un secondo clic passa al brano precedente.
Scorciatoie da tastiera
Aggiungiamo alcune scorciatoie attive quando il focus si trova all'interno del player: la barra spaziatrice per play/pausa, le frecce sinistra e destra per spostarsi di 5 secondi e il tasto M per il mute. Occorre però evitare conflitti con i controlli nativi: se il focus è su uno slider, le frecce devono continuare a muovere lo slider; se è su un pulsante, la barra spaziatrice deve continuare ad attivarlo.
onKeyDown(event) {
const target = event.target;
const isRange = target.matches('input[type="range"]');
const isInteractive = target.matches('button, [role="button"]');
switch (event.key) {
case ' ':
// Lascia ai pulsanti il loro comportamento predefinito
if (isInteractive) {
return;
}
event.preventDefault();
this.togglePlay();
break;
case 'ArrowLeft':
case 'ArrowRight': {
// Sugli slider le frecce mantengono il comportamento nativo
if (isRange) {
return;
}
event.preventDefault();
const offset = event.key === 'ArrowRight' ? 5 : -5;
this.seekBy(offset);
break;
}
case 'm':
case 'M':
this.toggleMute();
break;
default:
break;
}
}
seekBy(seconds) {
const { duration, currentTime } = this.audio;
if (!Number.isFinite(duration)) {
return;
}
// Mantiene il nuovo tempo entro i limiti del brano
this.audio.currentTime = Math.min(Math.max(currentTime + seconds, 0), duration);
}
Integrazione con la Media Session API
La Media Session API permette di mostrare titolo e artista del brano nelle notifiche del sistema operativo, nella schermata di blocco dei dispositivi mobili e nei controlli multimediali del browser, e di ricevere i comandi inviati da questi controlli o dai tasti multimediali. Aggiungiamo un metodo che viene invocato ad ogni cambio di brano.
updateMediaSession() {
// Funzionalità opzionale: esce se il browser non la supporta
if (!('mediaSession' in navigator)) {
return;
}
const track = this.tracks[this.currentIndex];
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist
});
navigator.mediaSession.setActionHandler('play', () => this.play());
navigator.mediaSession.setActionHandler('pause', () => this.pause());
navigator.mediaSession.setActionHandler('previoustrack', () => this.previous());
navigator.mediaSession.setActionHandler('nexttrack', () => this.next());
navigator.mediaSession.setActionHandler('seekto', (details) => {
this.audio.currentTime = details.seekTime;
});
}
Per attivarlo è sufficiente chiamare this.updateMediaSession() alla fine del metodo loadTrack(). Se i vostri brani dispongono di una copertina, potete aggiungere all'oggetto MediaMetadata la proprietà artwork, un array di oggetti con le chiavi src, sizes e type.
Inizializzazione
Infine creiamo l'istanza del player una volta che il DOM è pronto. Caricando lo script con l'attributo defer non è necessario attendere l'evento DOMContentLoaded, perché l'esecuzione avviene comunque dopo il parsing del documento.
<script src="js/audio-player.js" defer></script>
// In fondo al file audio-player.js
const playerRoot = document.getElementById('player');
if (playerRoot) {
const player = new AudioPlayer(playerRoot, tracks);
}
Considerazioni finali
Il player che abbiamo costruito separa nettamente la riproduzione, delegata interamente all'elemento <audio>, dalla presentazione, gestita da HTML e CSS. Il principio chiave è lasciare che sia l'elemento audio la fonte di verità: l'interfaccia non modifica il proprio stato in modo autonomo ma reagisce agli eventi play, pause, timeupdate e volumechange. Questo rende il codice robusto anche quando la riproduzione viene controllata da fonti esterne come la Media Session API.
A partire da questa base è possibile aggiungere facilmente altre funzionalità, come la riproduzione casuale e la ripetizione, il controllo della velocità tramite la proprietà playbackRate, la persistenza della posizione di ascolto oppure la visualizzazione della forma d'onda in tempo reale collegando l'elemento audio a un AnalyserNode della Web Audio API.