Creare un player audio con Vue.js
Il browser mette a disposizione l'elemento <audio> con i suoi controlli nativi, ma il loro aspetto varia da un browser all'altro e non si integra facilmente con il design di un'applicazione. In questo articolo costruiremo un player audio completo con Vue.js 3 e la Composition API: riproduzione e pausa, barra di avanzamento con seek, controllo del volume, formattazione del tempo e gestione di una playlist. Separeremo la logica dalla presentazione con un composable riutilizzabile.
Struttura del progetto
Partiamo da un progetto creato con Vite:
npm create vue@latest vue-audio-player
cd vue-audio-player
npm install
npm run dev
I file su cui lavoreremo sono i seguenti:
src/composables/useAudioPlayer.js: la logica del player, indipendente dall'interfaccia;src/utils/formatTime.js: una funzione di utilità per formattare i secondi;src/components/AudioPlayer.vue: il componente che espone i controlli;src/components/PlayList.vue: l'elenco delle tracce;src/App.vue: il componente radice che mette tutto insieme.
I file audio possono essere collocati nella cartella public/audio, in modo da essere serviti così come sono senza passare dalla pipeline di build.
L'oggetto HTMLAudioElement
Prima di scrivere codice Vue conviene ricordare gli elementi dell'API nativa che useremo. Un HTMLAudioElement espone alcune proprietà fondamentali:
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;paused: indica se la riproduzione è ferma.
Gli eventi che ci interessano sono loadedmetadata, timeupdate, play, pause, ended, volumechange, waiting, canplay ed error. Il principio che seguiremo è semplice: l'elemento audio è l'unica fonte di verità, e lo stato reattivo di Vue viene aggiornato esclusivamente in risposta ai suoi eventi. In questo modo l'interfaccia rimane coerente anche quando la riproduzione viene controllata dall'esterno, ad esempio dai tasti multimediali del sistema operativo.
Formattare il tempo
Iniziamo con una piccola funzione che converte un numero di secondi nel formato mm:ss o h:mm:ss:
// src/utils/formatTime.js
export function formatTime(seconds) {
// Gestiamo i valori non validi, ad esempio la durata prima del caricamento
if (!Number.isFinite(seconds) || seconds < 0) {
return '0:00';
}
const total = Math.floor(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const paddedSeconds = String(secs).padStart(2, '0');
if (hours > 0) {
const paddedMinutes = String(minutes).padStart(2, '0');
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
}
return `${minutes}:${paddedSeconds}`;
}
Il controllo con Number.isFinite() è importante perché duration può valere NaN prima del caricamento e Infinity nel caso di uno stream in diretta.
Il composable useAudioPlayer
Il cuore del player è un composable che crea un'istanza di Audio, registra i listener sugli eventi e restituisce uno stato reattivo insieme ai metodi di controllo. Creare l'elemento via JavaScript invece di inserirlo nel template ci permette di mantenere il player attivo anche se il componente che lo visualizza cambia.
// src/composables/useAudioPlayer.js
import { ref, computed, watch, onBeforeUnmount } from 'vue';
export function useAudioPlayer(tracks, options = {}) {
const { initialVolume = 0.8, autoAdvance = true } = options;
// Istanza dell'elemento audio, fonte unica di verità
const audio = new Audio();
audio.preload = 'metadata';
audio.volume = initialVolume;
// Stato reattivo sincronizzato con gli eventi dell'elemento
const currentIndex = ref(0);
const isPlaying = ref(false);
const isLoading = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const volume = ref(initialVolume);
const isMuted = ref(false);
const error = ref(null);
const currentTrack = computed(() => tracks.value[currentIndex.value] ?? null);
const progress = computed(() => {
if (!duration.value) {
return 0;
}
return (currentTime.value / duration.value) * 100;
});
const hasNext = computed(() => currentIndex.value < tracks.value.length - 1);
const hasPrevious = computed(() => currentIndex.value > 0);
// Gestori degli eventi dell'elemento audio
const handlers = {
loadedmetadata: () => {
duration.value = audio.duration;
},
timeupdate: () => {
currentTime.value = audio.currentTime;
},
play: () => {
isPlaying.value = true;
},
pause: () => {
isPlaying.value = false;
},
waiting: () => {
isLoading.value = true;
},
canplay: () => {
isLoading.value = false;
},
volumechange: () => {
volume.value = audio.volume;
isMuted.value = audio.muted;
},
ended: () => {
if (autoAdvance && hasNext.value) {
next();
} else {
isPlaying.value = false;
currentTime.value = 0;
}
},
error: () => {
isLoading.value = false;
isPlaying.value = false;
error.value = 'Impossibile caricare la traccia audio.';
},
};
// Registriamo tutti i listener in un colpo solo
Object.entries(handlers).forEach(([event, handler]) => {
audio.addEventListener(event, handler);
});
// Carica la traccia corrente senza avviarla
function load() {
const track = currentTrack.value;
if (!track) {
return;
}
error.value = null;
currentTime.value = 0;
duration.value = 0;
audio.src = track.src;
audio.load();
}
async function play() {
if (!audio.src) {
load();
}
try {
// play() restituisce una Promise che può essere rifiutata
// se il browser blocca la riproduzione automatica
await audio.play();
} catch (err) {
if (err.name !== 'AbortError') {
error.value = 'La riproduzione è stata bloccata dal browser.';
}
}
}
function pause() {
audio.pause();
}
function togglePlay() {
return audio.paused ? play() : pause();
}
// Sposta la riproduzione a una posizione espressa in secondi
function seek(seconds) {
if (!Number.isFinite(audio.duration)) {
return;
}
const clamped = Math.min(Math.max(seconds, 0), audio.duration);
audio.currentTime = clamped;
currentTime.value = clamped;
}
// Sposta la riproduzione a una percentuale della durata
function seekToPercent(percent) {
seek((percent / 100) * audio.duration);
}
function skip(delta) {
seek(audio.currentTime + delta);
}
function setVolume(value) {
audio.volume = Math.min(Math.max(value, 0), 1);
// Alzare il volume annulla automaticamente il mute
if (audio.volume > 0 && audio.muted) {
audio.muted = false;
}
}
function toggleMute() {
audio.muted = !audio.muted;
}
// Seleziona una traccia e, se richiesto, la avvia
async function selectTrack(index, autoplay = true) {
if (index < 0 || index >= tracks.value.length) {
return;
}
currentIndex.value = index;
load();
if (autoplay) {
await play();
}
}
function next() {
if (hasNext.value) {
return selectTrack(currentIndex.value + 1);
}
}
function previous() {
// Come nei player più diffusi: se la traccia è avanzata
// di qualche secondo, il primo clic la riporta all'inizio
if (audio.currentTime > 3 || !hasPrevious.value) {
seek(0);
return;
}
return selectTrack(currentIndex.value - 1);
}
// Se la playlist cambia e l'indice non è più valido, lo riportiamo a zero
watch(
() => tracks.value.length,
(length) => {
if (currentIndex.value >= length) {
currentIndex.value = 0;
pause();
load();
}
}
);
// Pulizia: fermiamo l'audio e rimuoviamo i listener
onBeforeUnmount(() => {
audio.pause();
Object.entries(handlers).forEach(([event, handler]) => {
audio.removeEventListener(event, handler);
});
audio.removeAttribute('src');
audio.load();
});
// Carichiamo i metadati della prima traccia
load();
return {
currentIndex,
currentTrack,
isPlaying,
isLoading,
currentTime,
duration,
progress,
volume,
isMuted,
error,
hasNext,
hasPrevious,
play,
pause,
togglePlay,
seek,
seekToPercent,
skip,
setVolume,
toggleMute,
selectTrack,
next,
previous,
};
}
Alcuni dettagli meritano attenzione. Il metodo play() dell'elemento audio restituisce una Promise che viene rifiutata quando il browser impedisce la riproduzione senza un'interazione dell'utente, oppure con un AbortError quando una chiamata a load() interrompe una riproduzione in corso: il secondo caso è del tutto normale durante il cambio di traccia e non va mostrato come errore. Inoltre, nel metodo seek() aggiorniamo subito currentTime senza attendere il successivo evento timeupdate, che il browser emette a intervalli di circa 250 millisecondi: così la barra di avanzamento risponde immediatamente al clic.
Il composable riceve le tracce come ref, in modo da reagire alle modifiche della playlist. Ogni traccia è un semplice oggetto con le proprietà id, title, artist e src.
Il componente AudioPlayer
Il componente riceve dal genitore lo stato e i metodi del player e si limita a presentarli. La barra di avanzamento e il volume sono realizzati con elementi <input type="range">, che offrono gratuitamente il supporto a tastiera e agli screen reader.
<!-- src/components/AudioPlayer.vue -->
<script setup>
import { ref, computed } from 'vue';
import { formatTime } from '../utils/formatTime';
const props = defineProps({
player: {
type: Object,
required: true,
},
});
// Durante il trascinamento mostriamo la posizione scelta
// senza spostare davvero la riproduzione
const isDragging = ref(false);
const dragValue = ref(0);
const displayedProgress = computed(() =>
isDragging.value ? dragValue.value : props.player.progress.value
);
const displayedTime = computed(() => {
if (isDragging.value) {
return (dragValue.value / 100) * props.player.duration.value;
}
return props.player.currentTime.value;
});
const volumePercent = computed(() =>
props.player.isMuted.value ? 0 : Math.round(props.player.volume.value * 100)
);
function onProgressInput(event) {
isDragging.value = true;
dragValue.value = Number(event.target.value);
}
function onProgressChange(event) {
// Il seek avviene solo al rilascio del cursore
props.player.seekToPercent(Number(event.target.value));
isDragging.value = false;
}
function onVolumeInput(event) {
props.player.setVolume(Number(event.target.value) / 100);
}
</script>
<template>
<section class="audio-player" aria-label="Player audio">
<header class="audio-player__info">
<p class="audio-player__title">
{{ player.currentTrack.value?.title ?? 'Nessuna traccia' }}
</p>
<p class="audio-player__artist">
{{ player.currentTrack.value?.artist }}
</p>
</header>
<div class="audio-player__progress">
<span class="audio-player__time">{{ formatTime(displayedTime) }}</span>
<input
type="range"
min="0"
max="100"
step="0.1"
:value="displayedProgress"
:disabled="!player.duration.value"
aria-label="Posizione nella traccia"
:aria-valuetext="`${formatTime(displayedTime)} di ${formatTime(player.duration.value)}`"
@input="onProgressInput"
@change="onProgressChange"
/>
<span class="audio-player__time">{{ formatTime(player.duration.value) }}</span>
</div>
<div class="audio-player__controls">
<button
type="button"
aria-label="Traccia precedente"
@click="player.previous()"
>
⏮
</button>
<button
type="button"
aria-label="Indietro di 10 secondi"
@click="player.skip(-10)"
>
−10
</button>
<button
type="button"
class="audio-player__play"
:aria-label="player.isPlaying.value ? 'Pausa' : 'Riproduci'"
:disabled="!player.currentTrack.value"
@click="player.togglePlay()"
>
<span v-if="player.isLoading.value">…</span>
<span v-else-if="player.isPlaying.value">⏸</span>
<span v-else>▶</span>
</button>
<button
type="button"
aria-label="Avanti di 10 secondi"
@click="player.skip(10)"
>
+10
</button>
<button
type="button"
aria-label="Traccia successiva"
:disabled="!player.hasNext.value"
@click="player.next()"
>
⏭
</button>
</div>
<div class="audio-player__volume">
<button
type="button"
:aria-label="player.isMuted.value ? 'Riattiva audio' : 'Disattiva audio'"
@click="player.toggleMute()"
>
{{ player.isMuted.value || volumePercent === 0 ? '🔇' : '🔊' }}
</button>
<input
type="range"
min="0"
max="100"
:value="volumePercent"
aria-label="Volume"
@input="onVolumeInput"
/>
</div>
<p v-if="player.error.value" class="audio-player__error" role="alert">
{{ player.error.value }}
</p>
</section>
</template>
Sulla barra di avanzamento distinguiamo due eventi. L'evento input viene emesso continuamente durante il trascinamento: lo usiamo solo per aggiornare la posizione mostrata. L'evento change viene emesso al rilascio: solo in quel momento eseguiamo il seek. Se spostassimo la riproduzione a ogni evento input, il browser dovrebbe richiedere nuovi frammenti del file decine di volte al secondo, con un audio frammentato e un inutile consumo di banda. La variabile isDragging evita inoltre che gli eventi timeupdate facciano “saltare” il cursore sotto il dito dell'utente.
Poiché il componente riceve un oggetto che contiene dei ref, nel template accediamo ai valori con .value: lo unwrapping automatico di Vue si applica solo ai ref di primo livello, non a quelli annidati dentro un oggetto passato come prop.
Il componente PlayList
La playlist mostra l'elenco delle tracce ed emette un evento quando l'utente ne seleziona una:
<!-- src/components/PlayList.vue -->
<script setup>
defineProps({
tracks: {
type: Array,
required: true,
},
currentIndex: {
type: Number,
default: 0,
},
isPlaying: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
</script>
<template>
<ol class="playlist">
<li
v-for="(track, index) in tracks"
:key="track.id"
class="playlist__item"
:class="{ 'playlist__item--active': index === currentIndex }"
>
<button
type="button"
:aria-current="index === currentIndex ? 'true' : undefined"
@click="emit('select', index)"
>
<span class="playlist__indicator">
{{ index === currentIndex && isPlaying ? '♪' : index + 1 }}
</span>
<span class="playlist__title">{{ track.title }}</span>
<span class="playlist__artist">{{ track.artist }}</span>
</button>
</li>
</ol>
</template>
Il componente radice
In App.vue definiamo le tracce, creiamo il player con il composable e lo passiamo ai due componenti figli:
<!-- src/App.vue -->
<script setup>
import { ref } from 'vue';
import { useAudioPlayer } from './composables/useAudioPlayer';
import AudioPlayer from './components/AudioPlayer.vue';
import PlayList from './components/PlayList.vue';
// Le tracce potrebbero arrivare anche da una API
const tracks = ref([
{ id: 1, title: 'Morning Light', artist: 'Studio Session', src: '/audio/morning-light.mp3' },
{ id: 2, title: 'City Walk', artist: 'Studio Session', src: '/audio/city-walk.mp3' },
{ id: 3, title: 'Night Drive', artist: 'Studio Session', src: '/audio/night-drive.mp3' },
]);
const player = useAudioPlayer(tracks, {
initialVolume: 0.7,
autoAdvance: true,
});
</script>
<template>
<main class="app">
<AudioPlayer :player="player" />
<PlayList
:tracks="tracks"
:current-index="player.currentIndex.value"
:is-playing="player.isPlaying.value"
@select="player.selectTrack"
/>
</main>
</template>
Lo stile è volutamente lasciato fuori dall'articolo: le classi seguono la convenzione BEM e possono essere definite in un blocco <style scoped> di ciascun componente o in un foglio di stile globale.
Scorciatoie da tastiera
Un player completo dovrebbe rispondere anche alla tastiera. Aggiungiamo un secondo composable che associa la barra spaziatrice alla riproduzione, le frecce orizzontali al seek e le frecce verticali al volume:
// src/composables/usePlayerShortcuts.js
import { onMounted, onBeforeUnmount } from 'vue';
export function usePlayerShortcuts(player) {
function onKeydown(event) {
// Non intercettiamo 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.code) {
case 'Space':
event.preventDefault();
player.togglePlay();
break;
case 'ArrowRight':
player.skip(5);
break;
case 'ArrowLeft':
player.skip(-5);
break;
case 'ArrowUp':
event.preventDefault();
player.setVolume(player.volume.value + 0.1);
break;
case 'ArrowDown':
event.preventDefault();
player.setVolume(player.volume.value - 0.1);
break;
case 'KeyM':
player.toggleMute();
break;
default:
break;
}
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
}
Il controllo sul tagName evita conflitti con i cursori <input type="range">, che gestiscono già le frecce in modo nativo quando hanno il focus. Per attivare le scorciatoie basta una riga in App.vue, subito dopo la creazione del player:
import { usePlayerShortcuts } from './composables/usePlayerShortcuts';
// Colleghiamo le scorciatoie all'istanza del player
usePlayerShortcuts(player);
Integrazione con la Media Session API
La Media Session API permette di mostrare titolo e artista nei controlli multimediali del sistema operativo, nella schermata di blocco dei dispositivi mobili e nelle notifiche, e di ricevere i comandi dai tasti multimediali della tastiera. Possiamo integrarla con un watch sulla traccia corrente:
// src/composables/useMediaSession.js
import { watch } from 'vue';
export function useMediaSession(player) {
// L'API non è disponibile in tutti i browser
if (!('mediaSession' in navigator)) {
return;
}
// Aggiorniamo i metadati a ogni cambio di traccia
watch(
player.currentTrack,
(track) => {
if (!track) {
return;
}
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist,
artwork: track.cover ? [{ src: track.cover, sizes: '512x512', type: 'image/jpeg' }] : [],
});
},
{ immediate: true }
);
// Sincronizziamo lo stato di riproduzione mostrato dal sistema
watch(player.isPlaying, (playing) => {
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
});
// Associamo i comandi di sistema ai metodi del player
const actions = {
play: () => player.play(),
pause: () => player.pause(),
previoustrack: () => player.previous(),
nexttrack: () => player.next(),
seekbackward: (details) => player.skip(-(details.seekOffset ?? 10)),
seekforward: (details) => player.skip(details.seekOffset ?? 10),
seekto: (details) => player.seek(details.seekTime),
};
Object.entries(actions).forEach(([action, handler]) => {
try {
navigator.mediaSession.setActionHandler(action, handler);
} catch {
// Alcune azioni potrebbero non essere supportate dal browser
}
});
}
Grazie alla scelta iniziale di considerare l'elemento audio come fonte di verità, i comandi ricevuti dal sistema operativo aggiornano automaticamente anche l'interfaccia Vue: il pulsante di pausa premuto sulla tastiera emette l'evento pause, che a sua volta aggiorna isPlaying.
Condividere il player tra più pagine
In un'applicazione con Vue Router il player dovrebbe continuare a suonare durante la navigazione. Poiché il composable registra un onBeforeUnmount, va invocato in un componente che non viene mai smontato, come App.vue. Per rendere il player accessibile ai componenti più profondi senza passarlo di prop in prop si può usare provide e inject:
// In App.vue: rendiamo disponibile il player a tutto l'albero dei componenti
import { provide } from 'vue';
provide('player', player);
// In un qualsiasi componente discendente
import { inject } from 'vue';
const player = inject('player');
In alternativa, se il progetto utilizza già Pinia, lo stato e i metodi del composable possono essere spostati in uno store, mantenendo invariata la logica basata sugli eventi.
Considerazioni finali
Abbiamo realizzato un player audio completo separando nettamente la logica, contenuta nel composable useAudioPlayer, dalla presentazione, affidata a componenti che si limitano a leggere lo stato e a invocare metodi. I punti chiave da ricordare sono tre: trattare l'elemento audio come unica fonte di verità e aggiornare lo stato reattivo solo in risposta ai suoi eventi; gestire correttamente la Promise restituita da play(), distinguendo le interruzioni legittime dai blocchi del browser; eseguire il seek al rilascio del cursore e non durante il trascinamento. Su queste basi è semplice aggiungere funzionalità come la riproduzione casuale, la ripetizione della playlist, la velocità di riproduzione tramite playbackRate o la visualizzazione della forma d'onda con la Web Audio API.