Creare un player video con Vue.js
L'elemento <video> di HTML5 offre già dei controlli nativi, ma il loro aspetto e il loro comportamento cambiano da browser a browser e lasciano poco spazio alla personalizzazione. Quando serve un'interfaccia coerente con il resto dell'applicazione, oppure funzionalità aggiuntive come scorciatoie da tastiera o la selezione della velocità di riproduzione, conviene costruire un player personalizzato. In questo articolo vedremo come farlo con Vue.js 3, la Composition API e la sintassi <script setup>, separando la logica di controllo in un composable riutilizzabile.
L'API HTMLMediaElement
Un player personalizzato non è altro che un'interfaccia costruita sopra l'API HTMLMediaElement, che l'elemento <video> implementa. Gli elementi fondamentali di questa API che useremo sono:
- i metodi
play()(che restituisce unaPromise) epause(); - le proprietà
currentTime,duration,volume,muted,playbackRateebuffered; - gli eventi
loadedmetadata,timeupdate,play,pause,ended,progress,volumechange,ratechange,waiting,playingederror.
Il principio chiave è che lo stato del player deve sempre derivare dall'elemento video e non viceversa. Quando l'utente preme il pulsante di riproduzione non impostiamo direttamente isPlaying a true: invochiamo play() e aggiorniamo lo stato reattivo solo quando il browser emette l'evento play. In questo modo l'interfaccia resta sincronizzata anche quando la riproduzione viene avviata o interrotta da fonti esterne, come i tasti multimediali della tastiera o il Picture-in-Picture.
Creazione del progetto
Partiamo da un nuovo progetto Vue basato su Vite:
npm create vue@latest vue-video-player
cd vue-video-player
npm install
npm run dev
La struttura dei file che realizzeremo è la seguente:
src/
├── App.vue
├── components/
│ └── VideoPlayer.vue
├── composables/
│ └── useVideoPlayer.js
└── utils/
└── formatTime.js
Formattazione del tempo
Iniziamo da una funzione di utilità che converte un numero di secondi in una stringa leggibile, nel formato m:ss oppure h:mm:ss per i video più lunghi di un'ora. La funzione deve gestire anche i valori non validi, perché prima del caricamento dei metadati la proprietà duration vale NaN, mentre per gli stream live vale Infinity.
// src/utils/formatTime.js
export function formatTime(totalSeconds) {
// Prima del caricamento dei metadati duration vale NaN
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) {
return '0:00';
}
const seconds = Math.floor(totalSeconds % 60);
const minutes = Math.floor((totalSeconds / 60) % 60);
const hours = Math.floor(totalSeconds / 3600);
const pad = (value) => String(value).padStart(2, '0');
return hours > 0
? `${hours}:${pad(minutes)}:${pad(seconds)}`
: `${minutes}:${pad(seconds)}`;
}
Il composable useVideoPlayer
Tutta la logica di controllo vive in un composable che riceve due template ref: quello dell'elemento <video> e quello del contenitore, che ci servirà per la modalità a schermo intero. Il composable espone uno stato reattivo in sola lettura e un insieme di metodi per agire sul video.
// src/composables/useVideoPlayer.js
import { ref, computed, readonly, onMounted, onBeforeUnmount } from 'vue';
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
export function useVideoPlayer(videoRef, containerRef) {
const isPlaying = ref(false);
const isWaiting = ref(false);
const isFullscreen = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const bufferedEnd = ref(0);
const volume = ref(1);
const isMuted = ref(false);
const playbackRate = ref(1);
const error = ref(null);
// Percentuale di avanzamento, utile per barre personalizzate
const progress = computed(() =>
duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
);
// Individua l'intervallo bufferizzato che contiene la posizione corrente
function updateBuffered(video) {
const { buffered } = video;
for (let i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= video.currentTime && buffered.end(i) >= video.currentTime) {
bufferedEnd.value = buffered.end(i);
return;
}
}
bufferedEnd.value = 0;
}
function syncDuration(video) {
duration.value = Number.isFinite(video.duration) ? video.duration : 0;
}
// Mappa evento -> gestore: lo stato deriva sempre dall'elemento video
const handlers = {
loadedmetadata: (video) => {
syncDuration(video);
error.value = null;
},
durationchange: (video) => syncDuration(video),
timeupdate: (video) => {
currentTime.value = video.currentTime;
updateBuffered(video);
},
progress: (video) => updateBuffered(video),
play: () => {
isPlaying.value = true;
},
pause: () => {
isPlaying.value = false;
},
ended: () => {
isPlaying.value = false;
},
waiting: () => {
isWaiting.value = true;
},
playing: () => {
isWaiting.value = false;
},
canplay: () => {
isWaiting.value = false;
},
volumechange: (video) => {
volume.value = video.volume;
isMuted.value = video.muted || video.volume === 0;
},
ratechange: (video) => {
playbackRate.value = video.playbackRate;
},
error: (video) => {
isWaiting.value = false;
error.value = video.error?.message || 'Impossibile riprodurre il video.';
},
};
// Conserviamo i listener effettivi per poterli rimuovere
const boundListeners = new Map();
async function play() {
const video = videoRef.value;
if (!video) return;
try {
await video.play();
} catch (err) {
// AbortError si verifica quando play() viene interrotto da pause()
if (err.name !== 'AbortError') {
error.value = err.message;
}
}
}
function pause() {
videoRef.value?.pause();
}
function togglePlay() {
const video = videoRef.value;
if (!video) return;
if (video.paused || video.ended) {
play();
} else {
pause();
}
}
function seek(time) {
const video = videoRef.value;
if (!video || !duration.value) return;
video.currentTime = clamp(time, 0, duration.value);
currentTime.value = video.currentTime;
}
function skip(seconds) {
seek(currentTime.value + seconds);
}
function setVolume(value) {
const video = videoRef.value;
if (!video) return;
video.volume = clamp(value, 0, 1);
// Alzare il volume deve anche togliere il muto
if (video.volume > 0 && video.muted) {
video.muted = false;
}
}
function toggleMute() {
const video = videoRef.value;
if (!video) return;
// Se il volume è a zero, togliere il muto ripristina un livello udibile
if (video.muted || video.volume === 0) {
video.muted = false;
if (video.volume === 0) video.volume = 0.5;
} else {
video.muted = true;
}
}
function setPlaybackRate(rate) {
const video = videoRef.value;
if (!video) return;
video.playbackRate = clamp(Number(rate), 0.25, 4);
}
async function toggleFullscreen() {
const container = containerRef.value;
const video = videoRef.value;
if (!container || !video) return;
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else if (container.requestFullscreen) {
await container.requestFullscreen();
} else if (video.webkitEnterFullscreen) {
// Fallback per Safari su iOS, che supporta solo il fullscreen del video
video.webkitEnterFullscreen();
}
} catch (err) {
error.value = err.message;
}
}
function onFullscreenChange() {
isFullscreen.value = document.fullscreenElement === containerRef.value;
}
onMounted(() => {
const video = videoRef.value;
if (!video) return;
for (const [eventName, handler] of Object.entries(handlers)) {
const listener = () => handler(video);
boundListeners.set(eventName, listener);
video.addEventListener(eventName, listener);
}
document.addEventListener('fullscreenchange', onFullscreenChange);
// I metadati potrebbero essere già disponibili (ad esempio dalla cache)
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
syncDuration(video);
}
volume.value = video.volume;
isMuted.value = video.muted;
});
onBeforeUnmount(() => {
const video = videoRef.value;
if (video) {
video.pause();
for (const [eventName, listener] of boundListeners) {
video.removeEventListener(eventName, listener);
}
}
boundListeners.clear();
document.removeEventListener('fullscreenchange', onFullscreenChange);
});
return {
isPlaying: readonly(isPlaying),
isWaiting: readonly(isWaiting),
isFullscreen: readonly(isFullscreen),
currentTime: readonly(currentTime),
duration: readonly(duration),
bufferedEnd: readonly(bufferedEnd),
volume: readonly(volume),
isMuted: readonly(isMuted),
playbackRate: readonly(playbackRate),
error: readonly(error),
progress,
play,
pause,
togglePlay,
seek,
skip,
setVolume,
toggleMute,
setPlaybackRate,
toggleFullscreen,
};
}
Alcune scelte meritano un approfondimento.
- Gestione della Promise di
play(): i browser moderni restituiscono unaPromiseche viene rifiutata quando l'autoplay è bloccato (NotAllowedError) oppure quando la riproduzione viene interrotta da una chiamata apause()(AbortError). Il secondo caso è del tutto normale e non va mostrato all'utente. - Stato in sola lettura: con
readonly()impediamo al componente di modificare direttamente, ad esempio,currentTime. L'unico modo per cambiare lo stato è passare dai metodi del composable, che agiscono sull'elemento video. - Buffer: la proprietà
bufferedè un oggettoTimeRangesche può contenere più intervalli disgiunti, ad esempio dopo un salto in avanti. Mostriamo quello che contiene la posizione corrente, che è l'informazione davvero utile per l'utente. - Pulizia dei listener: in
onBeforeUnmountrimuoviamo tutti i listener e mettiamo in pausa il video, evitando memory leak e audio che continua a suonare dopo la navigazione verso un'altra rotta.
Il componente VideoPlayer
Il componente si occupa esclusivamente della presentazione: riceve le sorgenti tramite props, collega i controlli ai metodi del composable e inoltra al genitore gli eventi principali. Le sorgenti sono un array di oggetti, così da poter offrire al browser più formati (ad esempio WebM e MP4) e lasciargli scegliere il primo supportato.
<!-- src/components/VideoPlayer.vue -->
<script setup>
import { ref } from 'vue';
import { useVideoPlayer } from '../composables/useVideoPlayer';
import { formatTime } from '../utils/formatTime';
const props = defineProps({
sources: {
type: Array,
required: true,
validator: (items) => items.every((item) => item.src && item.type),
},
poster: { type: String, default: '' },
tracks: { type: Array, default: () => [] },
title: { type: String, default: 'Video' },
skipSeconds: { type: Number, default: 10 },
});
const emit = defineEmits(['play', 'pause', 'ended', 'error']);
const containerRef = ref(null);
const videoRef = ref(null);
const rates = [0.5, 0.75, 1, 1.25, 1.5, 2];
const {
isPlaying,
isWaiting,
isFullscreen,
currentTime,
duration,
bufferedEnd,
volume,
isMuted,
playbackRate,
error,
togglePlay,
seek,
skip,
setVolume,
toggleMute,
setPlaybackRate,
toggleFullscreen,
} = useVideoPlayer(videoRef, containerRef);
function onSeekInput(event) {
seek(Number(event.target.value));
}
function onVolumeInput(event) {
setVolume(Number(event.target.value));
}
function onRateChange(event) {
setPlaybackRate(event.target.value);
}
function onKeydown(event) {
const tag = event.target.tagName;
// Gli slider e la select gestiscono già le proprie frecce
if (tag === 'INPUT' || tag === 'SELECT') return;
// Su un pulsante, spazio e invio producono già un click
if (tag === 'BUTTON' && (event.key === ' ' || event.key === 'Enter')) return;
switch (event.key) {
case ' ':
case 'k':
togglePlay();
break;
case 'ArrowRight':
case 'l':
skip(props.skipSeconds);
break;
case 'ArrowLeft':
case 'j':
skip(-props.skipSeconds);
break;
case 'ArrowUp':
setVolume(volume.value + 0.1);
break;
case 'ArrowDown':
setVolume(volume.value - 0.1);
break;
case 'm':
toggleMute();
break;
case 'f':
toggleFullscreen();
break;
case 'Home':
seek(0);
break;
case 'End':
seek(duration.value);
break;
default:
return;
}
// Impedisce lo scroll della pagina con spazio e frecce
event.preventDefault();
}
</script>
<template>
<div
ref="containerRef"
class="video-player"
:class="{ 'is-fullscreen': isFullscreen }"
role="region"
:aria-label="`Player video: ${title}`"
tabindex="0"
@keydown="onKeydown"
>
<video
ref="videoRef"
class="video-player__media"
:poster="poster"
preload="metadata"
playsinline
@click="togglePlay"
@dblclick="toggleFullscreen"
@play="emit('play')"
@pause="emit('pause')"
@ended="emit('ended')"
@error="emit('error', $event)"
>
<source
v-for="source in sources"
:key="source.src"
:src="source.src"
:type="source.type"
/>
<track
v-for="track in tracks"
:key="track.src"
kind="subtitles"
:src="track.src"
:srclang="track.srclang"
:label="track.label"
:default="track.default"
/>
Il tuo browser non supporta l'elemento video.
</video>
<p v-if="isWaiting" class="video-player__status" aria-live="polite">
Caricamento in corso…
</p>
<p v-if="error" class="video-player__error" role="alert">
{{ error }}
</p>
<div class="video-player__controls">
<button
type="button"
:aria-label="isPlaying ? 'Metti in pausa' : 'Riproduci'"
@click="togglePlay"
>
{{ isPlaying ? 'Pausa' : 'Play' }}
</button>
<button
type="button"
:aria-label="`Indietro di ${skipSeconds} secondi`"
@click="skip(-skipSeconds)"
>
-{{ skipSeconds }}s
</button>
<button
type="button"
:aria-label="`Avanti di ${skipSeconds} secondi`"
@click="skip(skipSeconds)"
>
+{{ skipSeconds }}s
</button>
<div class="video-player__timeline">
<progress
class="video-player__buffer"
:value="bufferedEnd"
:max="duration || 1"
aria-hidden="true"
></progress>
<input
type="range"
class="video-player__seek"
min="0"
:max="duration || 0"
step="0.1"
:value="currentTime"
:disabled="!duration"
aria-label="Posizione di riproduzione"
:aria-valuetext="`${formatTime(currentTime)} di ${formatTime(duration)}`"
@input="onSeekInput"
/>
</div>
<span class="video-player__time">
<time>{{ formatTime(currentTime) }}</time> /
<time>{{ formatTime(duration) }}</time>
</span>
<button
type="button"
:aria-label="isMuted ? 'Attiva audio' : 'Disattiva audio'"
:aria-pressed="isMuted"
@click="toggleMute"
>
{{ isMuted ? 'Muto' : 'Audio' }}
</button>
<input
type="range"
class="video-player__volume"
min="0"
max="1"
step="0.05"
:value="isMuted ? 0 : volume"
aria-label="Volume"
:aria-valuetext="`${Math.round((isMuted ? 0 : volume) * 100)}%`"
@input="onVolumeInput"
/>
<label>
Velocità
<select :value="playbackRate" @change="onRateChange">
<option v-for="rate in rates" :key="rate" :value="rate">
{{ rate }}×
</option>
</select>
</label>
<button
type="button"
:aria-label="isFullscreen ? 'Esci da schermo intero' : 'Schermo intero'"
@click="toggleFullscreen"
>
{{ isFullscreen ? 'Riduci' : 'Espandi' }}
</button>
</div>
</div>
</template>
Notiamo che l'elemento <video> non ha l'attributo controls: in questo modo il browser non mostra i propri controlli e l'interfaccia è interamente gestita dal componente. Le classi CSS seguono la convenzione BEM e sono pronte per essere stilizzate secondo il design system del progetto.
La barra di avanzamento
Per la timeline sovrapponiamo due elementi nativi. L'elemento <progress> mostra la porzione di video già scaricata, mentre un <input type="range"> rappresenta la posizione corrente e consente di spostarsi nel video. Usare un input di tipo range anziché un <div> cliccabile ci offre gratuitamente il supporto a tastiera, al trascinamento su dispositivi touch e alle tecnologie assistive.
L'attributo aria-valuetext è importante: senza di esso uno screen reader leggerebbe un valore come "137,4", mentre in questo modo annuncia "2:17 di 10:34". Lo stesso vale per lo slider del volume, che viene letto come percentuale.
Volume e muto
Il volume e lo stato di muto sono due proprietà indipendenti dell'elemento video. Lo slider mostra zero quando il video è muto, così che l'interfaccia rispecchi ciò che l'utente sente effettivamente. Grazie alla logica nel composable, alzare il volume dallo slider rimuove automaticamente il muto, mentre togliere il muto con il volume a zero ripristina un livello udibile: sono dettagli che evitano situazioni in cui l'utente preme "Audio" ma non sente nulla.
Velocità di riproduzione
La proprietà playbackRate accetta valori numerici, dove 1 è la velocità normale. Poiché il valore di una <select> è sempre una stringa, il composable lo converte con Number() e lo limita all'intervallo tra 0,25 e 4, entro cui la maggior parte dei browser mantiene l'audio intelligibile.
Schermo intero
Chiamiamo requestFullscreen() sul contenitore e non sull'elemento video: in questo modo i controlli personalizzati restano visibili anche in modalità a schermo intero. L'evento fullscreenchange sul documento mantiene sincronizzato lo stato isFullscreen anche quando l'utente esce premendo il tasto Esc. Safari su iOS non supporta la Fullscreen API sugli elementi generici, quindi ricorriamo a webkitEnterFullscreen(), che attiva il player nativo del sistema operativo.
Scorciatoie da tastiera
Il contenitore ha tabindex="0" e quindi può ricevere il focus. Il gestore onKeydown implementa le scorciatoie ormai diventate uno standard de facto:
SpaziooK: riproduci o metti in pausa;Freccia sinistraoJ: indietro di dieci secondi;Freccia destraoL: avanti di dieci secondi;Freccia sueFreccia giù: regola il volume;M: attiva o disattiva l'audio;F: schermo intero;HomeeEnd: vai all'inizio o alla fine.
Due controlli preliminari evitano conflitti con il comportamento nativo. Se il focus si trova su uno slider o sulla select, lasciamo che siano questi elementi a gestire le frecce. Se si trova su un pulsante, la barra spaziatrice genera già un evento click: intercettarla anche nel contenitore causerebbe una doppia azione, ad esempio una riproduzione subito seguita da una pausa. Infine, preventDefault() viene chiamato solo per i tasti effettivamente gestiti, così da non bloccare scorciatoie del browser come Tab.
Utilizzo del componente
Nel componente radice importiamo il player e gli passiamo le sorgenti, un'immagine di anteprima e una traccia di sottotitoli in formato WebVTT.
<!-- src/App.vue -->
<script setup>
import VideoPlayer from './components/VideoPlayer.vue';
const sources = [
{ src: '/media/demo.webm', type: 'video/webm' },
{ src: '/media/demo.mp4', type: 'video/mp4' },
];
const tracks = [
{ src: '/media/demo.it.vtt', srclang: 'it', label: 'Italiano', default: true },
{ src: '/media/demo.en.vtt', srclang: 'en', label: 'English' },
];
function onEnded() {
console.log('Riproduzione terminata');
}
</script>
<template>
<main>
<h1>Demo player video</h1>
<VideoPlayer
title="Video dimostrativo"
:sources="sources"
:tracks="tracks"
poster="/media/demo-poster.jpg"
:skip-seconds="5"
@ended="onEnded"
/>
</main>
</template>
Se i file vengono serviti da un dominio diverso da quello dell'applicazione, le tracce di sottotitoli richiedono l'attributo crossorigin sull'elemento video e le intestazioni CORS appropriate sul server, altrimenti il browser le ignorerà senza segnalare errori evidenti.
Testare la logica
Avendo isolato la formattazione del tempo in una funzione pura, possiamo verificarla facilmente con Vitest, che il template di create-vue propone già durante la configurazione del progetto.
// src/utils/formatTime.spec.js
import { describe, it, expect } from 'vitest';
import { formatTime } from './formatTime';
describe('formatTime', () => {
it('formatta i minuti e i secondi', () => {
expect(formatTime(0)).toBe('0:00');
expect(formatTime(65)).toBe('1:05');
expect(formatTime(599.9)).toBe('9:59');
});
it('include le ore quando necessario', () => {
expect(formatTime(3600)).toBe('1:00:00');
expect(formatTime(3725)).toBe('1:02:05');
});
it('gestisce i valori non validi', () => {
expect(formatTime(NaN)).toBe('0:00');
expect(formatTime(Infinity)).toBe('0:00');
expect(formatTime(-10)).toBe('0:00');
});
});
Il composable può essere testato allo stesso modo montando un componente di prova con @vue/test-utils in ambiente jsdom. Occorre però ricordare che jsdom non implementa la riproduzione multimediale: i metodi play() e pause() vanno sostituiti con degli stub tramite vi.spyOn(HTMLMediaElement.prototype, 'play'), e gli eventi vanno emessi manualmente con dispatchEvent().
Possibili estensioni
La struttura a composable rende semplice aggiungere nuove funzionalità senza appesantire il componente. Alcune idee:
- Picture-in-Picture tramite
video.requestPictureInPicture(), verificando prima il supporto condocument.pictureInPictureEnabled; - Media Session API, per mostrare titolo e anteprima nelle notifiche del sistema operativo e rispondere ai tasti multimediali;
- streaming adattivo HLS o DASH con librerie come hls.js o dash.js, collegate all'elemento video nel hook
onMounted; - persistenza delle preferenze, salvando volume e velocità in
localStorageper ripristinarli alla visita successiva; - ripresa della riproduzione, memorizzando la posizione raggiunta per ciascun video;
- nascondimento automatico dei controlli dopo alcuni secondi di inattività del mouse durante la riproduzione.
Conclusioni
Costruire un player video con Vue.js significa essenzialmente tradurre l'API HTMLMediaElement in uno stato reattivo. Separando la logica in un composable otteniamo un codice riutilizzabile e testabile, mentre il componente resta dedicato alla sola presentazione. Mantenere l'elemento video come unica fonte di verità, usare controlli nativi come gli input di tipo range e curare gli attributi ARIA e le scorciatoie da tastiera sono gli accorgimenti che distinguono un player dall'aspetto gradevole da uno realmente utilizzabile da tutti.