Creare un player video con JavaScript e CSS
L'elemento video di HTML5 offre già un'interfaccia di controllo nativa tramite l'attributo controls, ma il suo aspetto varia sensibilmente da un browser all'altro e non può essere personalizzato in modo coerente. Quando serve un'interfaccia uniforme, integrata con il design del sito o arricchita di funzionalità specifiche, la soluzione è costruire un player personalizzato: si nascondono i controlli nativi e si pilota l'elemento video attraverso la sua API JavaScript, la HTMLMediaElement.
In questo articolo realizzeremo un player completo con play/pausa, barra di avanzamento con buffer e ricerca, gestione del volume, velocità di riproduzione, schermo intero, Picture-in-Picture, scorciatoie da tastiera e occultamento automatico dei controlli. Il codice è scritto in JavaScript moderno senza dipendenze esterne.
La struttura HTML
Il markup si compone di un contenitore che racchiude il video e una barra di controlli. Il contenitore è fondamentale: sarà l'elemento portato a schermo intero, così che i controlli personalizzati restino visibili anche in quella modalità. Usiamo elementi button reali e attributi ARIA per garantire l'accessibilità.
<div class="player" data-player tabindex="0">
<video class="player__video" preload="metadata" playsinline poster="poster.jpg">
<source src="video.webm" type="video/webm">
<source src="video.mp4" type="video/mp4">
<track kind="captions" src="captions-it.vtt" srclang="it" label="Italiano">
</video>
<div class="player__overlay" data-overlay aria-hidden="true"></div>
<div class="player__controls" data-controls>
<div class="player__progress" data-progress
role="slider" tabindex="0"
aria-label="Avanzamento" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="player__buffered" data-buffered></div>
<div class="player__played" data-played></div>
<div class="player__tooltip" data-tooltip>0:00</div>
</div>
<div class="player__bar">
<button type="button" class="player__btn" data-action="toggle-play" aria-label="Riproduci">▶</button>
<button type="button" class="player__btn" data-action="toggle-mute" aria-label="Disattiva audio">🔊</button>
<input type="range" class="player__volume" data-volume
min="0" max="1" step="0.05" value="1" aria-label="Volume">
<span class="player__time">
<span data-current>0:00</span> / <span data-duration>0:00</span>
</span>
<span class="player__spacer"></span>
<select class="player__speed" data-speed aria-label="Velocità di riproduzione">
<option value="0.5">0.5×</option>
<option value="1" selected>1×</option>
<option value="1.25">1.25×</option>
<option value="1.5">1.5×</option>
<option value="2">2×</option>
</select>
<button type="button" class="player__btn" data-action="toggle-captions" aria-label="Sottotitoli" aria-pressed="false">CC</button>
<button type="button" class="player__btn" data-action="toggle-pip" aria-label="Picture-in-Picture">⧉</button>
<button type="button" class="player__btn" data-action="toggle-fullscreen" aria-label="Schermo intero">⛶</button>
</div>
</div>
</div>
Alcune scelte meritano una spiegazione:
preload="metadata"scarica solo le informazioni necessarie a conoscere durata e dimensioni, senza consumare banda per il contenuto.playsinlineimpedisce a Safari su iOS di aprire automaticamente il video a schermo intero.- Gli attributi
data-*separano gli agganci JavaScript dalle classi usate per lo stile: si può rinominare una classe senza rompere la logica. - La barra di avanzamento ha
role="slider"e i relativi attributiaria-value*, così che gli screen reader ne annuncino il valore.
Lo stile CSS
Il CSS definisce un contenitore con proporzioni 16:9, posiziona i controlli in sovrimpressione nella parte bassa e li fa scomparire con una transizione quando il player è in riproduzione e l'utente è inattivo. Le variabili CSS rendono il tema facilmente modificabile.
.player {
--accent: #e63946;
--controls-bg: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent);
--text: #ffffff;
position: relative;
width: 100%;
max-width: 960px;
aspect-ratio: 16 / 9;
background: #000000;
overflow: hidden;
border-radius: 8px;
font-family: system-ui, sans-serif;
color: var(--text);
outline: none;
}
.player:focus-visible {
box-shadow: 0 0 0 3px var(--accent);
}
.player__video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
/* L'overlay intercetta i clic sul video per play/pausa */
.player__overlay {
position: absolute;
inset: 0;
cursor: pointer;
}
.player__controls {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 24px 12px 8px;
background: var(--controls-bg);
transition: opacity 0.3s ease, transform 0.3s ease;
}
/* Controlli nascosti durante la riproduzione con utente inattivo */
.player.is-playing.is-idle .player__controls {
opacity: 0;
transform: translateY(8px);
pointer-events: none;
}
.player.is-playing.is-idle {
cursor: none;
}
.player__progress {
position: relative;
height: 6px;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.25);
border-radius: 3px;
cursor: pointer;
transition: height 0.15s ease;
}
.player__progress:hover,
.player__progress:focus-visible {
height: 10px;
outline: none;
}
.player__buffered,
.player__played {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 0;
border-radius: inherit;
pointer-events: none;
}
.player__buffered {
background: rgba(255, 255, 255, 0.4);
}
.player__played {
background: var(--accent);
}
.player__tooltip {
position: absolute;
bottom: 16px;
padding: 2px 6px;
font-size: 12px;
background: rgba(0, 0, 0, 0.8);
border-radius: 4px;
transform: translateX(-50%);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.player__progress:hover .player__tooltip {
opacity: 1;
}
.player__bar {
display: flex;
align-items: center;
gap: 8px;
}
.player__spacer {
flex: 1;
}
.player__btn {
min-width: 36px;
height: 36px;
padding: 0 6px;
font-size: 18px;
color: inherit;
background: transparent;
border: 0;
border-radius: 4px;
cursor: pointer;
}
.player__btn:hover,
.player__btn:focus-visible {
background: rgba(255, 255, 255, 0.15);
outline: none;
}
.player__btn[aria-pressed="true"] {
color: var(--accent);
}
.player__volume {
width: 80px;
accent-color: var(--accent);
}
.player__time {
font-size: 13px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.player__speed {
color: inherit;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 4px;
padding: 2px 4px;
}
.player__speed option {
color: #000000;
}
/* A schermo intero il contenitore occupa tutto lo spazio */
.player:fullscreen {
max-width: none;
border-radius: 0;
}
@media (max-width: 480px) {
.player__volume,
.player__speed {
display: none;
}
}
La proprietà font-variant-numeric: tabular-nums assegna a tutte le cifre la stessa larghezza, evitando che il contatore del tempo "balli" mentre cambia. La regola pointer-events: none sui controlli nascosti impedisce clic accidentali su pulsanti invisibili.
L'architettura JavaScript
Incapsuliamo tutta la logica in una classe VideoPlayer che riceve il contenitore come parametro. In questo modo la pagina può ospitare più player indipendenti. Il principio guida è che lo stato dell'interfaccia deriva sempre dagli eventi del video: il pulsante play non cambia icona quando viene cliccato, ma quando il video emette l'evento play. Così l'interfaccia resta coerente anche se la riproduzione viene avviata o fermata da altre fonti, come i controlli multimediali del sistema operativo.
class VideoPlayer {
static IDLE_TIMEOUT = 2500;
static SEEK_STEP = 5;
static VOLUME_STEP = 0.1;
constructor(root) {
this.root = root;
this.video = root.querySelector('video');
this.progress = root.querySelector('[data-progress]');
this.played = root.querySelector('[data-played]');
this.buffered = root.querySelector('[data-buffered]');
this.tooltip = root.querySelector('[data-tooltip]');
this.volume = root.querySelector('[data-volume]');
this.speed = root.querySelector('[data-speed]');
this.currentLabel = root.querySelector('[data-current]');
this.durationLabel = root.querySelector('[data-duration]');
this.overlay = root.querySelector('[data-overlay]');
this.buttons = {
play: root.querySelector('[data-action="toggle-play"]'),
mute: root.querySelector('[data-action="toggle-mute"]'),
captions: root.querySelector('[data-action="toggle-captions"]'),
pip: root.querySelector('[data-action="toggle-pip"]'),
fullscreen: root.querySelector('[data-action="toggle-fullscreen"]')
};
this.idleTimer = null;
this.isSeeking = false;
this.wasPlayingBeforeSeek = false;
// Rimuoviamo i controlli nativi nel caso fossero presenti
this.video.controls = false;
this.bindVideoEvents();
this.bindControlEvents();
this.bindKeyboard();
this.bindIdleDetection();
this.detectFeatures();
}
}
Le costanti statiche centralizzano i parametri di comportamento. Nelle sezioni che seguono aggiungeremo i metodi della classe uno alla volta.
Formattare il tempo
Un'utilità per convertire i secondi in una stringa leggibile. Per video più lunghi di un'ora aggiungiamo le ore; se la durata non è ancora nota (NaN) o è infinita, come nelle dirette, restituiamo un valore di ripiego.
static formatTime(seconds) {
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 = String(total % 60).padStart(2, '0');
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${secs}`;
}
return `${minutes}:${secs}`;
}
Play e pausa
Il metodo play() dell'elemento video restituisce una Promise che può essere rifiutata, ad esempio quando il browser blocca la riproduzione automatica con audio. Gestire il rifiuto evita errori non intercettati nella console.
async togglePlay() {
if (this.video.paused || this.video.ended) {
try {
await this.video.play();
} catch (error) {
// Il browser può bloccare la riproduzione (autoplay policy)
console.warn('Riproduzione non consentita:', error.message);
}
} else {
this.video.pause();
}
}
updatePlayState() {
const isPlaying = !this.video.paused && !this.video.ended;
this.root.classList.toggle('is-playing', isPlaying);
this.buttons.play.textContent = isPlaying ? '❚❚' : '▶';
this.buttons.play.setAttribute('aria-label', isPlaying ? 'Pausa' : 'Riproduci');
if (!isPlaying) {
this.showControls();
}
}
Gli eventi del video
Qui colleghiamo gli eventi della HTMLMediaElement all'aggiornamento dell'interfaccia. Gli eventi principali sono:
loadedmetadata: la durata è disponibile;timeupdate: il tempo corrente è cambiato (emesso circa 4 volte al secondo);progress: il browser ha scaricato nuovi dati;play,pause,ended: cambi di stato della riproduzione;volumechange: volume o stato muto sono cambiati;ratechange: la velocità di riproduzione è cambiata.
bindVideoEvents() {
const v = this.video;
v.addEventListener('loadedmetadata', () => {
this.durationLabel.textContent = VideoPlayer.formatTime(v.duration);
this.updateProgress();
});
v.addEventListener('timeupdate', () => {
// Durante il trascinamento è l'utente a governare la barra
if (!this.isSeeking) {
this.updateProgress();
}
});
v.addEventListener('progress', () => this.updateBuffered());
v.addEventListener('play', () => this.updatePlayState());
v.addEventListener('pause', () => this.updatePlayState());
v.addEventListener('ended', () => this.updatePlayState());
v.addEventListener('volumechange', () => this.updateVolumeState());
v.addEventListener('ratechange', () => {
this.speed.value = String(v.playbackRate);
});
// Se i metadati sono già stati caricati prima dell'inizializzazione
if (v.readyState >= HTMLMediaElement.HAVE_METADATA) {
this.durationLabel.textContent = VideoPlayer.formatTime(v.duration);
}
}
L'ultimo controllo su readyState copre un caso sottile: se il video era in cache, l'evento loadedmetadata potrebbe essere già stato emesso prima che il listener venisse registrato, lasciando la durata a 0:00.
La barra di avanzamento
La barra mostra due livelli: la porzione riprodotta e la porzione già scaricata. La proprietà buffered del video è un oggetto TimeRanges che può contenere più intervalli non contigui (per esempio se l'utente è saltato in avanti). Mostriamo l'intervallo che contiene la posizione corrente.
updateProgress() {
const { currentTime, duration } = this.video;
const percent = duration ? (currentTime / duration) * 100 : 0;
this.played.style.width = `${percent}%`;
this.currentLabel.textContent = VideoPlayer.formatTime(currentTime);
this.progress.setAttribute('aria-valuenow', percent.toFixed(1));
this.progress.setAttribute(
'aria-valuetext',
`${VideoPlayer.formatTime(currentTime)} di ${VideoPlayer.formatTime(duration)}`
);
}
updateBuffered() {
const { buffered, currentTime, duration } = this.video;
if (!duration || buffered.length === 0) {
return;
}
// Cerchiamo l'intervallo che contiene la posizione corrente
let end = 0;
for (let i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= currentTime && buffered.end(i) >= currentTime) {
end = buffered.end(i);
break;
}
}
this.buffered.style.width = `${(end / duration) * 100}%`;
}
Ricerca con il puntatore
Per la ricerca usiamo i Pointer Events, che unificano mouse, touch e penna in un'unica API. Il metodo setPointerCapture() garantisce che continuiamo a ricevere gli eventi pointermove anche quando il puntatore esce dalla barra durante il trascinamento. Mettiamo in pausa il video durante il trascinamento e lo riprendiamo al rilascio, se era in riproduzione.
getTimeFromPointer(event) {
const rect = this.progress.getBoundingClientRect();
const ratio = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1);
return { ratio, time: ratio * (this.video.duration || 0) };
}
bindProgressEvents() {
this.progress.addEventListener('pointerdown', (event) => {
if (!this.video.duration) {
return;
}
this.isSeeking = true;
this.wasPlayingBeforeSeek = !this.video.paused;
this.video.pause();
this.progress.setPointerCapture(event.pointerId);
this.seekPreview(event);
});
this.progress.addEventListener('pointermove', (event) => {
this.updateTooltip(event);
if (this.isSeeking) {
this.seekPreview(event);
}
});
const endSeek = (event) => {
if (!this.isSeeking) {
return;
}
this.isSeeking = false;
this.video.currentTime = this.getTimeFromPointer(event).time;
if (this.wasPlayingBeforeSeek) {
this.video.play().catch(() => {});
}
};
this.progress.addEventListener('pointerup', endSeek);
this.progress.addEventListener('pointercancel', endSeek);
}
seekPreview(event) {
const { ratio, time } = this.getTimeFromPointer(event);
// Aggiorniamo subito la grafica per un feedback immediato
this.played.style.width = `${ratio * 100}%`;
this.currentLabel.textContent = VideoPlayer.formatTime(time);
}
updateTooltip(event) {
const { ratio, time } = this.getTimeFromPointer(event);
this.tooltip.style.left = `${ratio * 100}%`;
this.tooltip.textContent = VideoPlayer.formatTime(time);
}
Impostare currentTime solo al rilascio, e non a ogni movimento, evita di sommergere il browser di richieste di ricerca, operazione costosa soprattutto con video remoti. Durante il trascinamento aggiorniamo solo l'aspetto grafico.
Volume e muto
Lo stato muto (muted) e il livello del volume (volume) sono proprietà indipendenti. Quando l'utente riattiva l'audio dopo averlo portato a zero con lo slider, conviene ripristinare un livello udibile, altrimenti il pulsante sembrerebbe non funzionare.
toggleMute() {
if (this.video.muted || this.video.volume === 0) {
this.video.muted = false;
if (this.video.volume === 0) {
this.video.volume = 0.5;
}
} else {
this.video.muted = true;
}
}
setVolume(value) {
const volume = Math.min(Math.max(value, 0), 1);
this.video.volume = volume;
this.video.muted = volume === 0;
}
updateVolumeState() {
const { muted, volume } = this.video;
const effective = muted ? 0 : volume;
this.volume.value = String(effective);
let icon = '🔊';
if (effective === 0) {
icon = '🔇';
} else if (effective < 0.5) {
icon = '🔉';
}
this.buttons.mute.textContent = icon;
this.buttons.mute.setAttribute('aria-label', effective === 0 ? 'Attiva audio' : 'Disattiva audio');
}
Nota: su iOS la proprietà volume è in sola lettura, perché il volume è governato dai tasti fisici del dispositivo. Il pulsante muto funziona comunque, mentre lo slider non avrà effetto. Per questo motivo il CSS lo nasconde sugli schermi piccoli.
Schermo intero e Picture-in-Picture
Portiamo a schermo intero il contenitore e non l'elemento video: in caso contrario il browser mostrerebbe i propri controlli nativi al posto dei nostri. Safari su iPhone non supporta la Fullscreen API sugli elementi generici, quindi ricadiamo su webkitEnterFullscreen(), specifico dell'elemento video.
async toggleFullscreen() {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else if (this.root.requestFullscreen) {
await this.root.requestFullscreen();
} else if (this.video.webkitEnterFullscreen) {
// Ripiego per Safari su iOS
this.video.webkitEnterFullscreen();
}
} catch (error) {
console.warn('Schermo intero non disponibile:', error.message);
}
}
async togglePictureInPicture() {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await this.video.requestPictureInPicture();
}
} catch (error) {
console.warn('Picture-in-Picture non disponibile:', error.message);
}
}
detectFeatures() {
// Nascondiamo i pulsanti per le funzionalità non supportate
if (!document.pictureInPictureEnabled || this.video.disablePictureInPicture) {
this.buttons.pip.hidden = true;
}
if (!document.fullscreenEnabled && !this.video.webkitEnterFullscreen) {
this.buttons.fullscreen.hidden = true;
}
if (this.video.textTracks.length === 0) {
this.buttons.captions.hidden = true;
}
}
Velocità e sottotitoli
La velocità si imposta tramite playbackRate. I sottotitoli sono gestiti dall'elemento track: ogni traccia ha una proprietà mode che può valere showing, hidden o disabled. Il browser si occupa della sincronizzazione e della resa grafica.
setSpeed(rate) {
this.video.playbackRate = Number(rate);
}
toggleCaptions() {
const track = this.video.textTracks[0];
if (!track) {
return;
}
const isShowing = track.mode === 'showing';
track.mode = isShowing ? 'hidden' : 'showing';
this.buttons.captions.setAttribute('aria-pressed', String(!isShowing));
}
Collegare i controlli
Invece di registrare un listener per ogni pulsante, usiamo la delegazione degli eventi: un unico listener sul contenitore legge l'attributo data-action del pulsante cliccato e invoca il metodo corrispondente. Una mappa rende esplicita la corrispondenza.
bindControlEvents() {
const actions = {
'toggle-play': () => this.togglePlay(),
'toggle-mute': () => this.toggleMute(),
'toggle-captions': () => this.toggleCaptions(),
'toggle-pip': () => this.togglePictureInPicture(),
'toggle-fullscreen': () => this.toggleFullscreen()
};
this.root.addEventListener('click', (event) => {
const button = event.target.closest('[data-action]');
if (button && this.root.contains(button)) {
actions[button.dataset.action]?.();
}
});
// Clic sull'area del video: play/pausa; doppio clic: schermo intero
this.overlay.addEventListener('click', () => this.togglePlay());
this.overlay.addEventListener('dblclick', () => this.toggleFullscreen());
this.volume.addEventListener('input', (event) => {
this.setVolume(Number(event.target.value));
});
this.speed.addEventListener('change', (event) => {
this.setSpeed(event.target.value);
});
document.addEventListener('fullscreenchange', () => {
const isFullscreen = document.fullscreenElement === this.root;
this.root.classList.toggle('is-fullscreen', isFullscreen);
this.buttons.fullscreen.setAttribute(
'aria-label',
isFullscreen ? 'Esci da schermo intero' : 'Schermo intero'
);
});
this.bindProgressEvents();
}
Scorciatoie da tastiera
Le scorciatoie seguono le convenzioni diffuse dalle principali piattaforme video. Il listener è registrato sul contenitore (che ha tabindex="0"), così che più player sulla stessa pagina non interferiscano tra loro. Ignoriamo i tasti quando il focus è su un campo di input o su una select, per non sovrascriverne il comportamento nativo.
bindKeyboard() {
this.root.addEventListener('keydown', (event) => {
const tag = event.target.tagName;
if (tag === 'INPUT' || tag === 'SELECT') {
return;
}
// La barra spaziatrice su un pulsante ne attiva già il clic
if (tag === 'BUTTON' && (event.key === ' ' || event.key === 'Enter')) {
return;
}
const v = this.video;
let handled = true;
switch (event.key) {
case ' ':
case 'k':
this.togglePlay();
break;
case 'ArrowLeft':
case 'j':
v.currentTime = Math.max(v.currentTime - VideoPlayer.SEEK_STEP, 0);
break;
case 'ArrowRight':
case 'l':
v.currentTime = Math.min(v.currentTime + VideoPlayer.SEEK_STEP, v.duration || 0);
break;
case 'ArrowUp':
this.setVolume(v.volume + VideoPlayer.VOLUME_STEP);
break;
case 'ArrowDown':
this.setVolume(v.volume - VideoPlayer.VOLUME_STEP);
break;
case 'm':
this.toggleMute();
break;
case 'f':
this.toggleFullscreen();
break;
case 'c':
this.toggleCaptions();
break;
case 'Home':
v.currentTime = 0;
break;
case 'End':
v.currentTime = v.duration || 0;
break;
default:
// I tasti numerici 0-9 saltano al 0%-90% della durata
if (/^[0-9]$/.test(event.key) && v.duration) {
v.currentTime = (Number(event.key) / 10) * v.duration;
} else {
handled = false;
}
}
if (handled) {
event.preventDefault();
this.showControls();
}
});
}
La chiamata a preventDefault() è necessaria: senza di essa la barra spaziatrice e le frecce farebbero scorrere la pagina oltre a controllare il video.
Occultamento automatico dei controlli
Durante la riproduzione i controlli devono sparire dopo qualche secondo di inattività, per non coprire il contenuto, e riapparire al primo movimento del puntatore. La logica si basa su un timer che viene reimpostato a ogni interazione e aggiunge la classe is-idle alla scadenza. Il CSS nasconde i controlli solo quando sono presenti sia is-playing sia is-idle.
bindIdleDetection() {
const activity = () => this.showControls();
this.root.addEventListener('pointermove', activity);
this.root.addEventListener('pointerdown', activity);
this.root.addEventListener('focusin', activity);
this.root.addEventListener('pointerleave', () => {
if (!this.video.paused) {
this.root.classList.add('is-idle');
}
});
}
showControls() {
this.root.classList.remove('is-idle');
clearTimeout(this.idleTimer);
this.idleTimer = setTimeout(() => {
// Non nascondiamo i controlli se l'utente sta interagendo con essi
const controls = this.root.querySelector('[data-controls]');
if (!this.video.paused && !this.isSeeking && !controls.matches(':hover')) {
this.root.classList.add('is-idle');
}
}, VideoPlayer.IDLE_TIMEOUT);
}
Inizializzazione
Infine istanziamo un player per ogni contenitore presente nella pagina. Lo script va caricato con l'attributo defer o come modulo, così che il DOM sia pronto al momento dell'esecuzione.
document.querySelectorAll('[data-player]').forEach((element) => {
new VideoPlayer(element);
});
<script src="video-player.js" defer></script>
Il codice completo
Per comodità riportiamo la classe completa in un unico blocco, pronta da salvare nel file video-player.js.
class VideoPlayer {
static IDLE_TIMEOUT = 2500;
static SEEK_STEP = 5;
static VOLUME_STEP = 0.1;
constructor(root) {
this.root = root;
this.video = root.querySelector('video');
this.progress = root.querySelector('[data-progress]');
this.played = root.querySelector('[data-played]');
this.buffered = root.querySelector('[data-buffered]');
this.tooltip = root.querySelector('[data-tooltip]');
this.volume = root.querySelector('[data-volume]');
this.speed = root.querySelector('[data-speed]');
this.currentLabel = root.querySelector('[data-current]');
this.durationLabel = root.querySelector('[data-duration]');
this.overlay = root.querySelector('[data-overlay]');
this.buttons = {
play: root.querySelector('[data-action="toggle-play"]'),
mute: root.querySelector('[data-action="toggle-mute"]'),
captions: root.querySelector('[data-action="toggle-captions"]'),
pip: root.querySelector('[data-action="toggle-pip"]'),
fullscreen: root.querySelector('[data-action="toggle-fullscreen"]')
};
this.idleTimer = null;
this.isSeeking = false;
this.wasPlayingBeforeSeek = false;
// Rimuoviamo i controlli nativi nel caso fossero presenti
this.video.controls = false;
this.bindVideoEvents();
this.bindControlEvents();
this.bindKeyboard();
this.bindIdleDetection();
this.detectFeatures();
}
static formatTime(seconds) {
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 = String(total % 60).padStart(2, '0');
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${secs}`;
}
return `${minutes}:${secs}`;
}
// --- Riproduzione ---
async togglePlay() {
if (this.video.paused || this.video.ended) {
try {
await this.video.play();
} catch (error) {
console.warn('Riproduzione non consentita:', error.message);
}
} else {
this.video.pause();
}
}
updatePlayState() {
const isPlaying = !this.video.paused && !this.video.ended;
this.root.classList.toggle('is-playing', isPlaying);
this.buttons.play.textContent = isPlaying ? '❚❚' : '▶';
this.buttons.play.setAttribute('aria-label', isPlaying ? 'Pausa' : 'Riproduci');
if (!isPlaying) {
this.showControls();
}
}
// --- Eventi del video ---
bindVideoEvents() {
const v = this.video;
v.addEventListener('loadedmetadata', () => {
this.durationLabel.textContent = VideoPlayer.formatTime(v.duration);
this.updateProgress();
});
v.addEventListener('timeupdate', () => {
if (!this.isSeeking) {
this.updateProgress();
}
});
v.addEventListener('progress', () => this.updateBuffered());
v.addEventListener('play', () => this.updatePlayState());
v.addEventListener('pause', () => this.updatePlayState());
v.addEventListener('ended', () => this.updatePlayState());
v.addEventListener('volumechange', () => this.updateVolumeState());
v.addEventListener('ratechange', () => {
this.speed.value = String(v.playbackRate);
});
if (v.readyState >= HTMLMediaElement.HAVE_METADATA) {
this.durationLabel.textContent = VideoPlayer.formatTime(v.duration);
}
}
// --- Barra di avanzamento ---
updateProgress() {
const { currentTime, duration } = this.video;
const percent = duration ? (currentTime / duration) * 100 : 0;
this.played.style.width = `${percent}%`;
this.currentLabel.textContent = VideoPlayer.formatTime(currentTime);
this.progress.setAttribute('aria-valuenow', percent.toFixed(1));
this.progress.setAttribute(
'aria-valuetext',
`${VideoPlayer.formatTime(currentTime)} di ${VideoPlayer.formatTime(duration)}`
);
}
updateBuffered() {
const { buffered, currentTime, duration } = this.video;
if (!duration || buffered.length === 0) {
return;
}
let end = 0;
for (let i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= currentTime && buffered.end(i) >= currentTime) {
end = buffered.end(i);
break;
}
}
this.buffered.style.width = `${(end / duration) * 100}%`;
}
getTimeFromPointer(event) {
const rect = this.progress.getBoundingClientRect();
const ratio = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1);
return { ratio, time: ratio * (this.video.duration || 0) };
}
bindProgressEvents() {
this.progress.addEventListener('pointerdown', (event) => {
if (!this.video.duration) {
return;
}
this.isSeeking = true;
this.wasPlayingBeforeSeek = !this.video.paused;
this.video.pause();
this.progress.setPointerCapture(event.pointerId);
this.seekPreview(event);
});
this.progress.addEventListener('pointermove', (event) => {
this.updateTooltip(event);
if (this.isSeeking) {
this.seekPreview(event);
}
});
const endSeek = (event) => {
if (!this.isSeeking) {
return;
}
this.isSeeking = false;
this.video.currentTime = this.getTimeFromPointer(event).time;
if (this.wasPlayingBeforeSeek) {
this.video.play().catch(() => {});
}
};
this.progress.addEventListener('pointerup', endSeek);
this.progress.addEventListener('pointercancel', endSeek);
}
seekPreview(event) {
const { ratio, time } = this.getTimeFromPointer(event);
this.played.style.width = `${ratio * 100}%`;
this.currentLabel.textContent = VideoPlayer.formatTime(time);
}
updateTooltip(event) {
const { ratio, time } = this.getTimeFromPointer(event);
this.tooltip.style.left = `${ratio * 100}%`;
this.tooltip.textContent = VideoPlayer.formatTime(time);
}
// --- Volume ---
toggleMute() {
if (this.video.muted || this.video.volume === 0) {
this.video.muted = false;
if (this.video.volume === 0) {
this.video.volume = 0.5;
}
} else {
this.video.muted = true;
}
}
setVolume(value) {
const volume = Math.min(Math.max(value, 0), 1);
this.video.volume = volume;
this.video.muted = volume === 0;
}
updateVolumeState() {
const { muted, volume } = this.video;
const effective = muted ? 0 : volume;
this.volume.value = String(effective);
let icon = '🔊';
if (effective === 0) {
icon = '🔇';
} else if (effective < 0.5) {
icon = '🔉';
}
this.buttons.mute.textContent = icon;
this.buttons.mute.setAttribute('aria-label', effective === 0 ? 'Attiva audio' : 'Disattiva audio');
}
// --- Schermo intero, PiP, velocità, sottotitoli ---
async toggleFullscreen() {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else if (this.root.requestFullscreen) {
await this.root.requestFullscreen();
} else if (this.video.webkitEnterFullscreen) {
this.video.webkitEnterFullscreen();
}
} catch (error) {
console.warn('Schermo intero non disponibile:', error.message);
}
}
async togglePictureInPicture() {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await this.video.requestPictureInPicture();
}
} catch (error) {
console.warn('Picture-in-Picture non disponibile:', error.message);
}
}
setSpeed(rate) {
this.video.playbackRate = Number(rate);
}
toggleCaptions() {
const track = this.video.textTracks[0];
if (!track) {
return;
}
const isShowing = track.mode === 'showing';
track.mode = isShowing ? 'hidden' : 'showing';
this.buttons.captions.setAttribute('aria-pressed', String(!isShowing));
}
detectFeatures() {
if (!document.pictureInPictureEnabled || this.video.disablePictureInPicture) {
this.buttons.pip.hidden = true;
}
if (!document.fullscreenEnabled && !this.video.webkitEnterFullscreen) {
this.buttons.fullscreen.hidden = true;
}
if (this.video.textTracks.length === 0) {
this.buttons.captions.hidden = true;
}
}
// --- Collegamento dei controlli ---
bindControlEvents() {
const actions = {
'toggle-play': () => this.togglePlay(),
'toggle-mute': () => this.toggleMute(),
'toggle-captions': () => this.toggleCaptions(),
'toggle-pip': () => this.togglePictureInPicture(),
'toggle-fullscreen': () => this.toggleFullscreen()
};
this.root.addEventListener('click', (event) => {
const button = event.target.closest('[data-action]');
if (button && this.root.contains(button)) {
actions[button.dataset.action]?.();
}
});
this.overlay.addEventListener('click', () => this.togglePlay());
this.overlay.addEventListener('dblclick', () => this.toggleFullscreen());
this.volume.addEventListener('input', (event) => {
this.setVolume(Number(event.target.value));
});
this.speed.addEventListener('change', (event) => {
this.setSpeed(event.target.value);
});
document.addEventListener('fullscreenchange', () => {
const isFullscreen = document.fullscreenElement === this.root;
this.root.classList.toggle('is-fullscreen', isFullscreen);
this.buttons.fullscreen.setAttribute(
'aria-label',
isFullscreen ? 'Esci da schermo intero' : 'Schermo intero'
);
});
this.bindProgressEvents();
}
// --- Tastiera ---
bindKeyboard() {
this.root.addEventListener('keydown', (event) => {
const tag = event.target.tagName;
if (tag === 'INPUT' || tag === 'SELECT') {
return;
}
if (tag === 'BUTTON' && (event.key === ' ' || event.key === 'Enter')) {
return;
}
const v = this.video;
let handled = true;
switch (event.key) {
case ' ':
case 'k':
this.togglePlay();
break;
case 'ArrowLeft':
case 'j':
v.currentTime = Math.max(v.currentTime - VideoPlayer.SEEK_STEP, 0);
break;
case 'ArrowRight':
case 'l':
v.currentTime = Math.min(v.currentTime + VideoPlayer.SEEK_STEP, v.duration || 0);
break;
case 'ArrowUp':
this.setVolume(v.volume + VideoPlayer.VOLUME_STEP);
break;
case 'ArrowDown':
this.setVolume(v.volume - VideoPlayer.VOLUME_STEP);
break;
case 'm':
this.toggleMute();
break;
case 'f':
this.toggleFullscreen();
break;
case 'c':
this.toggleCaptions();
break;
case 'Home':
v.currentTime = 0;
break;
case 'End':
v.currentTime = v.duration || 0;
break;
default:
if (/^[0-9]$/.test(event.key) && v.duration) {
v.currentTime = (Number(event.key) / 10) * v.duration;
} else {
handled = false;
}
}
if (handled) {
event.preventDefault();
this.showControls();
}
});
}
// --- Occultamento automatico ---
bindIdleDetection() {
const activity = () => this.showControls();
this.root.addEventListener('pointermove', activity);
this.root.addEventListener('pointerdown', activity);
this.root.addEventListener('focusin', activity);
this.root.addEventListener('pointerleave', () => {
if (!this.video.paused) {
this.root.classList.add('is-idle');
}
});
}
showControls() {
this.root.classList.remove('is-idle');
clearTimeout(this.idleTimer);
this.idleTimer = setTimeout(() => {
const controls = this.root.querySelector('[data-controls]');
if (!this.video.paused && !this.isSeeking && !controls.matches(':hover')) {
this.root.classList.add('is-idle');
}
}, VideoPlayer.IDLE_TIMEOUT);
}
}
document.querySelectorAll('[data-player]').forEach((element) => {
new VideoPlayer(element);
});
Possibili estensioni
La struttura a classe rende semplice aggiungere nuove funzionalità. Alcune idee:
- Anteprime sulla barra: un file WebVTT di tipo
metadatapuò associare a ogni intervallo di tempo una porzione di uno sprite di miniature, da mostrare nel tooltip. - Media Session API: tramite
navigator.mediaSessionsi possono esporre titolo e copertina al sistema operativo e gestire i tasti multimediali della tastiera o della schermata di blocco. - Memorizzazione della posizione: salvare
currentTimeinlocalStoragea intervalli regolari permette di riprendere la visione da dove era stata interrotta. - Streaming adattivo: con librerie come hls.js o dash.js si possono riprodurre flussi HLS o DASH; il player descritto continua a funzionare perché queste librerie agiscono sullo stesso elemento
video. - Eventi personalizzati: estendere
EventTargeted emettere eventi comeplayer:readyconsente al resto dell'applicazione di reagire allo stato del player.
Conclusioni
Costruire un player video personalizzato significa sostanzialmente tradurre gli eventi della HTMLMediaElement in un'interfaccia e le interazioni dell'utente in chiamate alla stessa API. Tenendo lo stato dell'interfaccia sincronizzato con gli eventi del video, anziché con le azioni dell'utente, si ottiene un componente robusto e coerente in ogni circostanza. Con qualche attenzione in più all'accessibilità (pulsanti reali, attributi ARIA, scorciatoie da tastiera) e alle differenze tra browser (fullscreen su iOS, volume in sola lettura, politiche di autoplay), il risultato è un player che non ha nulla da invidiare alle soluzioni pronte, pur restando leggero e privo di dipendenze.