Creare un player video con JavaScript e CSS

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.
  • playsinline impedisce 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 attributi aria-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 metadata può associare a ogni intervallo di tempo una porzione di uno sprite di miniature, da mostrare nel tooltip.
  • Media Session API: tramite navigator.mediaSession si possono esporre titolo e copertina al sistema operativo e gestire i tasti multimediali della tastiera o della schermata di blocco.
  • Memorizzazione della posizione: salvare currentTime in localStorage a 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 EventTarget ed emettere eventi come player:ready consente 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.