Creare un player audio con React
L'elemento <audio> nativo del browser offre già tutto il necessario per riprodurre file audio, ma i suoi controlli predefiniti hanno un aspetto diverso in ogni browser e non si integrano con l'interfaccia di un'applicazione. In questo articolo vedremo come costruire con React un player audio personalizzato, con pulsanti play/pausa, barra di avanzamento, controllo del volume e una piccola playlist, isolando tutta la logica in un custom hook riutilizzabile.
Come funziona HTMLAudioElement
Alla base del player c'è l'interfaccia HTMLAudioElement, che eredita da HTMLMediaElement. Le proprietà e i metodi che ci interessano sono pochi:
play()epause(): avviano e interrompono la riproduzione.play()restituisce unaPromiseche può essere rifiutata, ad esempio se il browser blocca l'autoplay.currentTime: la posizione corrente in secondi, leggibile e scrivibile.duration: la durata totale in secondi, disponibile solo dopo il caricamento dei metadati (prima valeNaN).volume: un valore compreso tra0e1.muted: un booleano che silenzia l'audio senza modificare il volume.
L'elemento notifica i cambiamenti di stato tramite eventi: loadedmetadata, timeupdate, play, pause, ended, volumechange ed error. Il principio da seguire in React è semplice: l'elemento audio è la fonte di verità, e lo stato del componente si limita a rispecchiarlo ascoltando questi eventi.
Struttura del progetto
Creiamo un nuovo progetto con Vite:
npm create vite@latest react-audio-player -- --template react
cd react-audio-player
npm install
npm run dev
I file che scriveremo sono i seguenti:
src/
hooks/
useAudioPlayer.js
components/
AudioPlayer.jsx
ProgressBar.jsx
VolumeControl.jsx
Playlist.jsx
utils/
formatTime.js
App.jsx
I file audio di esempio vanno inseriti nella cartella public/audio/, in modo che siano serviti direttamente da Vite.
Formattare il tempo
Partiamo da una funzione di utilità che converte i secondi nel formato mm:ss (oppure h:mm:ss per tracce più lunghe di un'ora):
// src/utils/formatTime.js
export function formatTime(seconds) {
// Gestisce i valori non ancora disponibili (NaN, Infinity)
if (!Number.isFinite(seconds) || seconds < 0) {
return '0:00';
}
const totalSeconds = Math.floor(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
const paddedSecs = String(secs).padStart(2, '0');
if (hours > 0) {
const paddedMinutes = String(minutes).padStart(2, '0');
return `${hours}:${paddedMinutes}:${paddedSecs}`;
}
return `${minutes}:${paddedSecs}`;
}
Il custom hook useAudioPlayer
Tutta la logica di riproduzione viene concentrata in un hook. Il riferimento all'elemento audio è gestito con useRef, mentre lo stato esposto al componente (riproduzione in corso, tempo corrente, durata, volume) viene aggiornato dai listener registrati in un useEffect.
// src/hooks/useAudioPlayer.js
import { useCallback, useEffect, useRef, useState } from 'react';
export function useAudioPlayer(src) {
const audioRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolumeState] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
// Registra i listener sugli eventi dell'elemento audio
useEffect(() => {
const audio = audioRef.current;
if (!audio) {
return;
}
const handleLoadedMetadata = () => {
setDuration(audio.duration);
setIsLoading(false);
};
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleWaiting = () => setIsLoading(true);
const handleCanPlay = () => setIsLoading(false);
const handleVolumeChange = () => {
setVolumeState(audio.volume);
setIsMuted(audio.muted);
};
const handleError = () => {
setError('Impossibile caricare il file audio.');
setIsLoading(false);
setIsPlaying(false);
};
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('play', handlePlay);
audio.addEventListener('pause', handlePause);
audio.addEventListener('waiting', handleWaiting);
audio.addEventListener('canplay', handleCanPlay);
audio.addEventListener('volumechange', handleVolumeChange);
audio.addEventListener('error', handleError);
// Rimuove i listener quando il componente viene smontato
return () => {
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('play', handlePlay);
audio.removeEventListener('pause', handlePause);
audio.removeEventListener('waiting', handleWaiting);
audio.removeEventListener('canplay', handleCanPlay);
audio.removeEventListener('volumechange', handleVolumeChange);
audio.removeEventListener('error', handleError);
};
}, []);
// Reimposta lo stato a ogni cambio di sorgente
useEffect(() => {
setCurrentTime(0);
setDuration(0);
setError(null);
setIsLoading(true);
}, [src]);
const play = useCallback(async () => {
const audio = audioRef.current;
if (!audio) {
return;
}
try {
await audio.play();
} catch (err) {
// Il browser può bloccare la riproduzione senza interazione dell'utente
if (err.name !== 'AbortError') {
setError('Riproduzione non consentita dal browser.');
}
}
}, []);
const pause = useCallback(() => {
audioRef.current?.pause();
}, []);
const togglePlay = useCallback(() => {
const audio = audioRef.current;
if (!audio) {
return;
}
if (audio.paused) {
play();
} else {
pause();
}
}, [play, pause]);
const seek = useCallback((time) => {
const audio = audioRef.current;
if (!audio || !Number.isFinite(audio.duration)) {
return;
}
// Limita il valore all'intervallo valido
const clamped = Math.min(Math.max(time, 0), audio.duration);
audio.currentTime = clamped;
setCurrentTime(clamped);
}, []);
const skip = useCallback((offset) => {
const audio = audioRef.current;
if (audio) {
seek(audio.currentTime + offset);
}
}, [seek]);
const setVolume = useCallback((value) => {
const audio = audioRef.current;
if (!audio) {
return;
}
audio.volume = Math.min(Math.max(value, 0), 1);
// Riattiva l'audio se l'utente alza il volume
if (audio.muted && value > 0) {
audio.muted = false;
}
}, []);
const toggleMute = useCallback(() => {
const audio = audioRef.current;
if (audio) {
audio.muted = !audio.muted;
}
}, []);
return {
audioRef,
isPlaying,
currentTime,
duration,
volume,
isMuted,
isLoading,
error,
play,
pause,
togglePlay,
seek,
skip,
setVolume,
toggleMute,
};
}
Alcune osservazioni su questa implementazione:
- Lo stato
isPlayingnon viene impostato direttamente inplay()opause(), ma dagli eventiplayepause. In questo modo resta coerente anche quando la riproduzione viene controllata dall'esterno, ad esempio dai tasti multimediali della tastiera o dal sistema operativo. - L'errore
AbortErrorviene ignorato perché si verifica normalmente quando una chiamata aplay()viene interrotta da unpause()o da un cambio di sorgente. - L'evento
timeupdateviene emesso circa quattro volte al secondo: una frequenza sufficiente per la barra di avanzamento senza appesantire il rendering.
La barra di avanzamento
Per la barra di avanzamento usiamo un <input type="range">, che è accessibile da tastiera e dagli screen reader senza lavoro aggiuntivo. Durante il trascinamento conserviamo un valore locale, così la barra non "salta" per effetto degli aggiornamenti di timeupdate; la posizione reale viene impostata solo al rilascio.
// src/components/ProgressBar.jsx
import { useState } from 'react';
import { formatTime } from '../utils/formatTime';
export default function ProgressBar({ currentTime, duration, onSeek }) {
const [isDragging, setIsDragging] = useState(false);
const [dragValue, setDragValue] = useState(0);
// Durante il trascinamento mostra il valore locale
const displayedTime = isDragging ? dragValue : currentTime;
const hasDuration = Number.isFinite(duration) && duration > 0;
const handleChange = (event) => {
setIsDragging(true);
setDragValue(Number(event.target.value));
};
const commitSeek = () => {
if (isDragging) {
onSeek(dragValue);
setIsDragging(false);
}
};
return (
<div className="progress">
<span className="progress__time">{formatTime(displayedTime)}</span>
<input
type="range"
className="progress__slider"
min={0}
max={hasDuration ? duration : 0}
step={0.1}
value={displayedTime}
disabled={!hasDuration}
onChange={handleChange}
onPointerUp={commitSeek}
onKeyUp={commitSeek}
onBlur={commitSeek}
aria-label="Posizione della traccia"
aria-valuetext={`${formatTime(displayedTime)} di ${formatTime(duration)}`}
/>
<span className="progress__time">{formatTime(duration)}</span>
</div>
);
}
L'attributo aria-valuetext fa sì che gli screen reader leggano un tempo comprensibile (ad esempio "1:24 di 3:50") invece del valore numerico grezzo in secondi.
Il controllo del volume
Il controllo del volume combina un pulsante per silenziare l'audio e un secondo slider:
// src/components/VolumeControl.jsx
export default function VolumeControl({ volume, isMuted, onVolumeChange, onToggleMute }) {
// Se l'audio è silenziato lo slider mostra zero
const displayedVolume = isMuted ? 0 : volume;
return (
<div className="volume">
<button
type="button"
className="volume__mute"
onClick={onToggleMute}
aria-label={isMuted ? 'Riattiva audio' : 'Disattiva audio'}
aria-pressed={isMuted}
>
{isMuted || volume === 0 ? '🔇' : '🔊'}
</button>
<input
type="range"
className="volume__slider"
min={0}
max={1}
step={0.01}
value={displayedVolume}
onChange={(event) => onVolumeChange(Number(event.target.value))}
aria-label="Volume"
aria-valuetext={`${Math.round(displayedVolume * 100)}%`}
/>
</div>
);
}
Va tenuto presente che su iOS la proprietà volume è di sola lettura: il volume è sempre controllato dai tasti fisici del dispositivo. Il pulsante di mute continua invece a funzionare.
La playlist
La playlist è un semplice elenco di tracce in cui quella in riproduzione viene evidenziata:
// src/components/Playlist.jsx
export default function Playlist({ tracks, currentIndex, isPlaying, onSelect }) {
return (
<ol className="playlist">
{tracks.map((track, index) => {
const isCurrent = index === currentIndex;
return (
<li key={track.src}>
<button
type="button"
className={isCurrent ? 'playlist__item playlist__item--active' : 'playlist__item'}
onClick={() => onSelect(index)}
aria-current={isCurrent ? 'true' : undefined}
>
<span className="playlist__title">{track.title}</span>
<span className="playlist__artist">{track.artist}</span>
{isCurrent && isPlaying && (
<span className="playlist__status">In riproduzione</span>
)}
</button>
</li>
);
})}
</ol>
);
}
Il componente AudioPlayer
Il componente principale unisce l'hook e i sottocomponenti. Gestisce l'indice della traccia corrente, il passaggio automatico alla traccia successiva alla fine della riproduzione e le scorciatoie da tastiera.
// src/components/AudioPlayer.jsx
import { useEffect, useRef, useState } from 'react';
import { useAudioPlayer } from '../hooks/useAudioPlayer';
import ProgressBar from './ProgressBar';
import VolumeControl from './VolumeControl';
import Playlist from './Playlist';
export default function AudioPlayer({ tracks }) {
const [currentIndex, setCurrentIndex] = useState(0);
const shouldAutoPlay = useRef(false);
const currentTrack = tracks[currentIndex];
const {
audioRef,
isPlaying,
currentTime,
duration,
volume,
isMuted,
isLoading,
error,
play,
pause,
togglePlay,
seek,
skip,
setVolume,
toggleMute,
} = useAudioPlayer(currentTrack.src);
const goToTrack = (index, autoPlay = true) => {
// Scorre la playlist in modo circolare
const nextIndex = (index + tracks.length) % tracks.length;
shouldAutoPlay.current = autoPlay;
setCurrentIndex(nextIndex);
};
const handlePrevious = () => {
// Come nei player comuni: dopo 3 secondi torna all'inizio della traccia
if (currentTime > 3) {
seek(0);
} else {
goToTrack(currentIndex - 1, isPlaying);
}
};
const handleNext = () => goToTrack(currentIndex + 1, isPlaying);
const handleEnded = () => {
// Alla fine dell'ultima traccia la riproduzione si ferma
if (currentIndex < tracks.length - 1) {
goToTrack(currentIndex + 1, true);
}
};
// Avvia la nuova traccia quando la sorgente cambia
useEffect(() => {
if (shouldAutoPlay.current) {
shouldAutoPlay.current = false;
play();
}
}, [currentIndex, play]);
// Scorciatoie da tastiera
useEffect(() => {
const handleKeyDown = (event) => {
// Non intercetta i tasti mentre l'utente scrive in un campo
const tag = event.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || event.target.isContentEditable) {
return;
}
switch (event.key) {
case ' ':
case 'k':
event.preventDefault();
togglePlay();
break;
case 'ArrowLeft':
skip(-5);
break;
case 'ArrowRight':
skip(5);
break;
case 'm':
toggleMute();
break;
default:
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [togglePlay, skip, toggleMute]);
return (
<section className="player" aria-label="Player audio">
<audio
ref={audioRef}
src={currentTrack.src}
preload="metadata"
onEnded={handleEnded}
/>
<header className="player__info">
<h2 className="player__title">{currentTrack.title}</h2>
<p className="player__artist">{currentTrack.artist}</p>
</header>
{error && <p className="player__error" role="alert">{error}</p>}
<ProgressBar currentTime={currentTime} duration={duration} onSeek={seek} />
<div className="player__controls">
<button type="button" onClick={handlePrevious} aria-label="Traccia precedente">
⏮
</button>
<button type="button" onClick={() => skip(-10)} aria-label="Indietro di 10 secondi">
−10
</button>
<button
type="button"
className="player__play"
onClick={togglePlay}
disabled={Boolean(error)}
aria-label={isPlaying ? 'Pausa' : 'Riproduci'}
>
{isLoading && isPlaying ? '…' : isPlaying ? '⏸' : '▶'}
</button>
<button type="button" onClick={() => skip(10)} aria-label="Avanti di 10 secondi">
+10
</button>
<button type="button" onClick={handleNext} aria-label="Traccia successiva">
⏭
</button>
</div>
<VolumeControl
volume={volume}
isMuted={isMuted}
onVolumeChange={setVolume}
onToggleMute={toggleMute}
/>
<Playlist
tracks={tracks}
currentIndex={currentIndex}
isPlaying={isPlaying}
onSelect={(index) => goToTrack(index, true)}
/>
</section>
);
}
L'elemento <audio> non ha l'attributo controls, quindi resta invisibile: l'interfaccia è interamente costruita dai nostri componenti. L'attributo preload="metadata" chiede al browser di scaricare solo le informazioni necessarie a conoscere la durata, senza caricare l'intero file finché l'utente non avvia la riproduzione.
Il ref shouldAutoPlay serve a distinguere un cambio di traccia che deve avviare subito la riproduzione (clic su un brano della playlist, fine della traccia precedente) da uno che non deve farlo (pulsante "successiva" premuto mentre il player è in pausa). Usare un ref al posto dello stato evita un rendering superfluo.
Utilizzo nell'applicazione
Infine, definiamo la playlist e montiamo il player:
// src/App.jsx
import AudioPlayer from './components/AudioPlayer';
const tracks = [
{ title: 'Alba sul porto', artist: 'Ensemble Adriatico', src: '/audio/alba-sul-porto.mp3' },
{ title: 'Vento di maestrale', artist: 'Ensemble Adriatico', src: '/audio/vento-di-maestrale.mp3' },
{ title: 'Notturno', artist: 'Trio Costa', src: '/audio/notturno.mp3' },
];
export default function App() {
return (
<main>
<AudioPlayer tracks={tracks} />
</main>
);
}
Integrazione con la Media Session API
Un player completo dovrebbe mostrare titolo e artista anche nei controlli multimediali del sistema operativo (schermata di blocco su mobile, overlay dei tasti multimediali su desktop). La Media Session API permette di farlo con poche righe, da aggiungere in AudioPlayer:
// Aggiorna i metadati e le azioni della sessione multimediale
useEffect(() => {
if (!('mediaSession' in navigator)) {
return;
}
navigator.mediaSession.metadata = new MediaMetadata({
title: currentTrack.title,
artist: currentTrack.artist,
});
navigator.mediaSession.setActionHandler('play', play);
navigator.mediaSession.setActionHandler('pause', pause);
navigator.mediaSession.setActionHandler('previoustrack', handlePrevious);
navigator.mediaSession.setActionHandler('nexttrack', handleNext);
navigator.mediaSession.setActionHandler('seekbackward', () => skip(-10));
navigator.mediaSession.setActionHandler('seekforward', () => skip(10));
// Rimuove gli handler allo smontaggio
return () => {
const actions = ['play', 'pause', 'previoustrack', 'nexttrack', 'seekbackward', 'seekforward'];
actions.forEach((action) => navigator.mediaSession.setActionHandler(action, null));
};
});
L'effetto non ha un array di dipendenze perché gli handler handlePrevious e handleNext vengono ricreati a ogni rendering e devono sempre leggere i valori aggiornati di currentIndex e currentTime. In alternativa si possono memorizzare con useCallback ed elencarli esplicitamente come dipendenze.
Considerazioni finali
Alcuni aspetti da tenere presenti quando si porta il player in produzione:
- Autoplay: i browser bloccano la riproduzione con audio se non è preceduta da un'interazione dell'utente. Per questo la prima chiamata a
play()deve partire da un gestore di clic o di tastiera, e laPromiserestituita va sempre gestita. - Formati: MP3 e AAC sono supportati ovunque; per offrire alternative si può usare più elementi
<source>dentro<audio>, oppure verificare il supporto conaudio.canPlayType('audio/ogg'). - Server: per consentire lo spostamento nella traccia su file lunghi, il server deve rispondere alle richieste
Rangecon lo stato206 Partial Content. Nginx e i principali server statici lo fanno per impostazione predefinita. - CORS: se i file audio sono ospitati su un dominio diverso e si vuole elaborarli con la Web Audio API (ad esempio per disegnare una forma d'onda), sono necessari l'attributo
crossOrigin="anonymous"e le intestazioni CORS appropriate lato server.
Separare la logica nel custom hook useAudioPlayer rende il codice facile da testare e da riutilizzare: la stessa logica può alimentare un mini-player fisso in fondo alla pagina, un player per podcast con velocità di riproduzione variabile (basta esporre la proprietà playbackRate) o un'interfaccia completamente diversa, senza toccare la gestione degli eventi dell'elemento audio.