Creare un player audio con JavaScript e CSS

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">&#9198;</button>
    <button type="button" class="btn btn-play" id="btn-play" aria-label="Riproduci">&#9654;</button>
    <button type="button" class="btn" id="btn-next" aria-label="Brano successivo">&#9197;</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">&#128266;</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 ? '&#9654;' : '&#10074;&#10074;';
    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 ? '&#128263;' : '&#128266;';
    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.