Creare un player video con React

Creare un player video con React

L'elemento <video> di HTML5 offre già dei controlli nativi, ma il loro aspetto cambia da browser a browser e le possibilità di personalizzazione sono molto limitate. Quando serve un'interfaccia coerente con il resto dell'applicazione, con scorciatoie da tastiera, controllo della velocità e una barra di avanzamento su misura, la soluzione è costruire un player personalizzato. In questo articolo vedremo come realizzarlo con React, separando la logica in un custom hook e l'interfaccia in componenti riutilizzabili.

Struttura del progetto

Partiamo da un progetto creato con Vite e il template React. La struttura che adotteremo è la seguente:

src/
├── components/
│   └── VideoPlayer/
│       ├── VideoPlayer.jsx
│       ├── ProgressBar.jsx
│       ├── VolumeControl.jsx
│       ├── PlaybackRateSelect.jsx
│       └── VideoPlayer.css
├── hooks/
│   └── useVideoPlayer.js
├── utils/
│   └── formatTime.js
└── App.jsx
npm create vite@latest react-video-player -- --template react
cd react-video-player
npm install
npm run dev

L'idea di fondo è semplice: l'elemento <video> resta la fonte di verità per lo stato della riproduzione, mentre React si limita a rispecchiarlo nello stato del componente ascoltando gli eventi del media. Non proveremo quindi a “pilotare” il video tramite lo stato, ma useremo un ref per invocare i metodi dell'API HTMLMediaElement e aggiorneremo lo stato in risposta agli eventi.

Formattare il tempo

Le proprietà currentTime e duration sono espresse in secondi con parte decimale. Per mostrarle all'utente serve una piccola funzione di utilità che produca il formato mm:ss oppure hh:mm:ss per i video più lunghi di un'ora.

// src/utils/formatTime.js

export function formatTime(totalSeconds) {
  // Gestisce valori non validi (es. durata non ancora disponibile)
  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 paddedSeconds = String(seconds).padStart(2, '0');

  if (hours > 0) {
    const paddedMinutes = String(minutes).padStart(2, '0');
    return `${hours}:${paddedMinutes}:${paddedSeconds}`;
  }

  return `${minutes}:${paddedSeconds}`;
}

Il controllo con Number.isFinite() è importante: prima che i metadati siano caricati duration vale NaN, e per gli stream live può valere Infinity.

Il custom hook useVideoPlayer

Tutta la logica del player viene concentrata in un hook. Il componente riceverà dall'hook lo stato corrente e un insieme di azioni, senza doversi occupare di come vengono gestiti gli eventi del media.

Lo stato e gli eventi del media

Gli eventi che ci interessano sono pochi ma fondamentali:

  • loadedmetadata: la durata e le dimensioni del video sono disponibili;
  • timeupdate: la posizione corrente è cambiata (viene emesso circa quattro volte al secondo);
  • play e pause: lo stato di riproduzione è cambiato;
  • progress: il browser ha scaricato nuovi dati, utile per mostrare il buffer;
  • waiting e canplay: la riproduzione si è fermata per mancanza di dati oppure può ripartire;
  • volumechange e ratechange: volume, stato muto o velocità sono cambiati;
  • ended: il video è terminato.

Ascoltare play, pause e volumechange invece di aggiornare lo stato direttamente nei gestori dei pulsanti ha un vantaggio concreto: lo stato resta corretto anche quando la riproduzione viene modificata dall'esterno, ad esempio dai tasti multimediali della tastiera o dai controlli del sistema operativo.

// src/hooks/useVideoPlayer.js
import { useCallback, useEffect, useRef, useState } from 'react';

const INITIAL_STATE = {
  isPlaying: false,
  isMuted: false,
  isBuffering: false,
  isEnded: false,
  isFullscreen: false,
  currentTime: 0,
  duration: 0,
  bufferedEnd: 0,
  volume: 1,
  playbackRate: 1,
};

export function useVideoPlayer() {
  const videoRef = useRef(null);
  const containerRef = useRef(null);
  const [state, setState] = useState(INITIAL_STATE);

  // Aggiorna solo le proprietà indicate, mantenendo le altre
  const patchState = useCallback((partial) => {
    setState((prev) => ({ ...prev, ...partial }));
  }, []);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    // Restituisce la fine dell'intervallo bufferizzato che contiene la posizione corrente
    const getBufferedEnd = () => {
      const { buffered, currentTime } = video;
      for (let i = 0; i < buffered.length; i++) {
        if (buffered.start(i) <= currentTime && currentTime <= buffered.end(i)) {
          return buffered.end(i);
        }
      }
      return 0;
    };

    const handlers = {
      loadedmetadata: () => patchState({ duration: video.duration }),
      durationchange: () => patchState({ duration: video.duration }),
      timeupdate: () =>
        patchState({ currentTime: video.currentTime, bufferedEnd: getBufferedEnd() }),
      progress: () => patchState({ bufferedEnd: getBufferedEnd() }),
      play: () => patchState({ isPlaying: true, isEnded: false }),
      pause: () => patchState({ isPlaying: false }),
      waiting: () => patchState({ isBuffering: true }),
      canplay: () => patchState({ isBuffering: false }),
      playing: () => patchState({ isBuffering: false }),
      ended: () => patchState({ isPlaying: false, isEnded: true }),
      volumechange: () => patchState({ volume: video.volume, isMuted: video.muted }),
      ratechange: () => patchState({ playbackRate: video.playbackRate }),
    };

    // Registra tutti i listener sull'elemento video
    Object.entries(handlers).forEach(([event, handler]) => {
      video.addEventListener(event, handler);
    });

    // Sincronizza lo stato nel caso i metadati siano già stati caricati
    if (video.readyState >= 1) {
      handlers.loadedmetadata();
    }

    // Rimuove i listener allo smontaggio del componente
    return () => {
      Object.entries(handlers).forEach(([event, handler]) => {
        video.removeEventListener(event, handler);
      });
    };
  }, [patchState]);

  // Il resto dell'hook viene mostrato nelle sezioni successive
}

Il controllo su readyState dopo la registrazione dei listener evita un problema frequente: se il video è in cache, l'evento loadedmetadata può essere già stato emesso prima che l'effetto venga eseguito, e la durata resterebbe a zero.

Le azioni

Le azioni sono funzioni stabili, create con useCallback, che agiscono direttamente sull'elemento video. Una particolarità di play() è che restituisce una Promise: il browser può rifiutarla se la riproduzione automatica è bloccata oppure se viene chiamato pause() prima che la riproduzione sia effettivamente partita. È buona norma gestire il rifiuto per evitare errori non catturati in console.

  // Avvia o mette in pausa la riproduzione
  const togglePlay = useCallback(async () => {
    const video = videoRef.current;
    if (!video) return;

    if (video.paused || video.ended) {
      try {
        await video.play();
      } catch (error) {
        // AbortError si verifica se la riproduzione viene interrotta da pause()
        if (error.name !== 'AbortError') {
          console.error('Impossibile avviare la riproduzione:', error);
        }
      }
    } else {
      video.pause();
    }
  }, []);

  // Sposta la riproduzione a un istante preciso, entro i limiti del video
  const seek = useCallback((time) => {
    const video = videoRef.current;
    if (!video || !Number.isFinite(video.duration)) return;
    video.currentTime = Math.min(Math.max(time, 0), video.duration);
  }, []);

  // Avanza o arretra di un certo numero di secondi
  const skip = useCallback((seconds) => {
    const video = videoRef.current;
    if (!video) return;
    seek(video.currentTime + seconds);
  }, [seek]);

  // Imposta il volume tra 0 e 1 e rimuove il muto se il volume è positivo
  const changeVolume = useCallback((value) => {
    const video = videoRef.current;
    if (!video) return;
    const volume = Math.min(Math.max(value, 0), 1);
    video.volume = volume;
    video.muted = volume === 0;
  }, []);

  const toggleMute = useCallback(() => {
    const video = videoRef.current;
    if (!video) return;
    // Se il volume era a zero, riattivare l'audio non avrebbe effetto: lo riportiamo a un valore udibile
    if (video.muted && video.volume === 0) {
      video.volume = 0.5;
    }
    video.muted = !video.muted;
  }, []);

  const changePlaybackRate = useCallback((rate) => {
    const video = videoRef.current;
    if (!video) return;
    video.playbackRate = rate;
  }, []);

La modalità a schermo intero

Se chiedessimo lo schermo intero direttamente sull'elemento <video>, il browser mostrerebbe i suoi controlli nativi e perderemmo la nostra interfaccia. Per questo la richiesta va fatta sul contenitore del player, che include sia il video sia i controlli personalizzati. L'evento fullscreenchange sul documento ci permette di tenere lo stato allineato anche quando l'utente esce premendo il tasto Esc.

  const toggleFullscreen = useCallback(async () => {
    const container = containerRef.current;
    if (!container) return;

    try {
      if (document.fullscreenElement) {
        await document.exitFullscreen();
      } else {
        await container.requestFullscreen();
      }
    } catch (error) {
      console.error('Errore nella gestione dello schermo intero:', error);
    }
  }, []);

  // Mantiene lo stato allineato anche quando si esce con il tasto Esc
  useEffect(() => {
    const handleFullscreenChange = () => {
      patchState({ isFullscreen: document.fullscreenElement === containerRef.current });
    };

    document.addEventListener('fullscreenchange', handleFullscreenChange);
    return () => {
      document.removeEventListener('fullscreenchange', handleFullscreenChange);
    };
  }, [patchState]);

  return {
    videoRef,
    containerRef,
    ...state,
    togglePlay,
    seek,
    skip,
    changeVolume,
    toggleMute,
    changePlaybackRate,
    toggleFullscreen,
  };

Su Safari per iOS l'API Fullscreen non è disponibile per elementi diversi dal video: in quel contesto si può ricorrere al metodo non standard webkitEnterFullscreen() dell'elemento video, accettando in quel caso i controlli nativi del sistema.

La barra di avanzamento

La barra di avanzamento è il componente più delicato. Deve mostrare la posizione corrente, la porzione già scaricata e permettere il trascinamento. Usiamo un input di tipo range, che ci offre gratuitamente il supporto a tastiera e l'accessibilità, e ci sovrapponiamo due livelli grafici per il buffer e l'avanzamento.

Durante il trascinamento non vogliamo che gli eventi timeupdate facciano “saltare” il cursore sotto il dito dell'utente. Per questo manteniamo un valore locale di trascinamento che, se presente, ha la precedenza sulla posizione reale del video.

// src/components/VideoPlayer/ProgressBar.jsx
import { useState } from 'react';
import { formatTime } from '../../utils/formatTime';

export default function ProgressBar({ currentTime, duration, bufferedEnd, onSeek }) {
  // Valore temporaneo usato durante il trascinamento
  const [scrubTime, setScrubTime] = useState(null);

  const displayedTime = scrubTime ?? currentTime;
  const safeDuration = duration > 0 ? duration : 0;
  const progressPercent = safeDuration ? (displayedTime / safeDuration) * 100 : 0;
  const bufferedPercent = safeDuration ? (bufferedEnd / safeDuration) * 100 : 0;

  const handleChange = (event) => {
    setScrubTime(Number(event.target.value));
  };

  // Al rilascio applica la posizione scelta e torna a seguire il video
  const commitSeek = () => {
    if (scrubTime !== null) {
      onSeek(scrubTime);
      setScrubTime(null);
    }
  };

  return (
    <div className="vp-progress">
      <div className="vp-progress__track">
        <div className="vp-progress__buffered" style={{ width: `${bufferedPercent}%` }} />
        <div className="vp-progress__played" style={{ width: `${progressPercent}%` }} />
      </div>
      <input
        type="range"
        className="vp-progress__input"
        min={0}
        max={safeDuration}
        step={0.1}
        value={displayedTime}
        onChange={handleChange}
        onPointerUp={commitSeek}
        onKeyUp={commitSeek}
        onBlur={commitSeek}
        aria-label="Posizione nel video"
        aria-valuetext={`${formatTime(displayedTime)} di ${formatTime(safeDuration)}`}
      />
    </div>
  );
}

L'attributo aria-valuetext fa sì che gli screen reader leggano un tempo comprensibile, come “1:32 di 4:05”, invece di un numero di secondi con decimali.

Il controllo del volume

Il controllo del volume combina un pulsante per il muto e un secondo slider. Quando l'audio è disattivato lo slider mostra zero, così l'interfaccia riflette ciò che l'utente sente effettivamente.

// src/components/VideoPlayer/VolumeControl.jsx

export default function VolumeControl({ volume, isMuted, onVolumeChange, onToggleMute }) {
  const effectiveVolume = isMuted ? 0 : volume;

  // Sceglie l'icona in base al livello del volume
  let icon = '🔊';
  if (effectiveVolume === 0) icon = '🔇';
  else if (effectiveVolume < 0.5) icon = '🔉';

  return (
    <div className="vp-volume">
      <button
        type="button"
        className="vp-button"
        onClick={onToggleMute}
        aria-label={isMuted ? 'Riattiva audio' : 'Disattiva audio'}
      >
        {icon}
      </button>
      <input
        type="range"
        className="vp-volume__input"
        min={0}
        max={1}
        step={0.05}
        value={effectiveVolume}
        onChange={(event) => onVolumeChange(Number(event.target.value))}
        aria-label="Volume"
        aria-valuetext={`${Math.round(effectiveVolume * 100)}%`}
      />
    </div>
  );
}

La velocità di riproduzione

Un semplice select è sufficiente per scegliere la velocità. I valori tra 0.25 e 2 sono supportati da tutti i browser principali senza distorsioni evidenti dell'audio.

// src/components/VideoPlayer/PlaybackRateSelect.jsx

const RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];

export default function PlaybackRateSelect({ playbackRate, onChange }) {
  return (
    <select
      className="vp-rate"
      value={playbackRate}
      onChange={(event) => onChange(Number(event.target.value))}
      aria-label="Velocità di riproduzione"
    >
      {RATES.map((rate) => (
        <option key={rate} value={rate}>
          {rate === 1 ? 'Normale' : `${rate}×`}
        </option>
      ))}
    </select>
  );
}

Il componente VideoPlayer

Il componente principale mette insieme l'hook e i sottocomponenti. Oltre ai controlli, gestisce due comportamenti tipici dei player moderni: il clic sul video per avviare o mettere in pausa e la scomparsa automatica dei controlli dopo qualche secondo di inattività durante la riproduzione.

// src/components/VideoPlayer/VideoPlayer.jsx
import { useCallback, useEffect, useRef, useState } from 'react';
import { useVideoPlayer } from '../../hooks/useVideoPlayer';
import { formatTime } from '../../utils/formatTime';
import ProgressBar from './ProgressBar';
import VolumeControl from './VolumeControl';
import PlaybackRateSelect from './PlaybackRateSelect';
import './VideoPlayer.css';

const HIDE_CONTROLS_DELAY = 2500;

export default function VideoPlayer({ src, poster, tracks = [], title }) {
  const player = useVideoPlayer();
  const [areControlsVisible, setAreControlsVisible] = useState(true);
  const hideTimeoutRef = useRef(null);

  // Mostra i controlli e pianifica la loro scomparsa se il video è in riproduzione
  const showControls = useCallback(() => {
    setAreControlsVisible(true);
    clearTimeout(hideTimeoutRef.current);

    if (player.isPlaying) {
      hideTimeoutRef.current = setTimeout(() => {
        setAreControlsVisible(false);
      }, HIDE_CONTROLS_DELAY);
    }
  }, [player.isPlaying]);

  // Rivaluta la visibilità dei controlli a ogni cambio di stato della riproduzione
  useEffect(() => {
    showControls();
    return () => clearTimeout(hideTimeoutRef.current);
  }, [showControls]);

  const containerClassName = [
    'vp',
    player.isFullscreen && 'vp--fullscreen',
    !areControlsVisible && 'vp--controls-hidden',
  ]
    .filter(Boolean)
    .join(' ');

  return (
    <div
      ref={player.containerRef}
      className={containerClassName}
      onMouseMove={showControls}
      onFocus={showControls}
      tabIndex={-1}
    >
      <video
        ref={player.videoRef}
        className="vp__video"
        src={src}
        poster={poster}
        preload="metadata"
        playsInline
        onClick={player.togglePlay}
        onDoubleClick={player.toggleFullscreen}
      >
        {tracks.map((track) => (
          <track key={track.srcLang} kind="subtitles" {...track} />
        ))}
        Il tuo browser non supporta la riproduzione di video HTML5.
      </video>

      {player.isBuffering && <div className="vp__spinner" aria-hidden="true" />}

      {!player.isPlaying && !player.isBuffering && (
        <button
          type="button"
          className="vp__big-play"
          onClick={player.togglePlay}
          aria-label={player.isEnded ? 'Riproduci di nuovo' : 'Riproduci'}
        >
          {player.isEnded ? '↻' : '▶'}
        </button>
      )}

      <div className="vp__controls" role="group" aria-label={`Controlli del video ${title ?? ''}`}>
        <ProgressBar
          currentTime={player.currentTime}
          duration={player.duration}
          bufferedEnd={player.bufferedEnd}
          onSeek={player.seek}
        />

        <div className="vp__bar">
          <button
            type="button"
            className="vp-button"
            onClick={player.togglePlay}
            aria-label={player.isPlaying ? 'Pausa' : 'Riproduci'}
          >
            {player.isPlaying ? '❚❚' : '▶'}
          </button>

          <button type="button" className="vp-button" onClick={() => player.skip(-10)} aria-label="Indietro di 10 secondi">
            « 10
          </button>
          <button type="button" className="vp-button" onClick={() => player.skip(10)} aria-label="Avanti di 10 secondi">
            10 »
          </button>

          <VolumeControl
            volume={player.volume}
            isMuted={player.isMuted}
            onVolumeChange={player.changeVolume}
            onToggleMute={player.toggleMute}
          />

          <span className="vp__time" aria-live="off">
            {formatTime(player.currentTime)} / {formatTime(player.duration)}
          </span>

          <div className="vp__spacer" />

          <PlaybackRateSelect playbackRate={player.playbackRate} onChange={player.changePlaybackRate} />

          <button
            type="button"
            className="vp-button"
            onClick={player.toggleFullscreen}
            aria-label={player.isFullscreen ? 'Esci da schermo intero' : 'Schermo intero'}
          >
            {player.isFullscreen ? '⤡' : '⤢'}
          </button>
        </div>
      </div>
    </div>
  );
}

L'attributo playsInline impedisce a iOS di aprire automaticamente il video a schermo intero all'avvio, mentre preload="metadata" limita il download iniziale ai soli metadati, sufficienti per conoscere la durata e mostrare la barra di avanzamento.

Le scorciatoie da tastiera

Chi usa abitualmente i player dei grandi servizi di streaming si aspetta alcune scorciatoie standard: spazio o K per play e pausa, frecce per spostarsi e regolare il volume, M per il muto e F per lo schermo intero. Le aggiungiamo con un effetto nel componente VideoPlayer, ascoltando gli eventi sul contenitore invece che sull'intero documento: in questo modo più player nella stessa pagina non interferiscono tra loro.

  // Scorciatoie da tastiera attive quando il focus è all'interno del player
  useEffect(() => {
    const container = player.containerRef.current;
    if (!container) return;

    const handleKeyDown = (event) => {
      // Non intercetta i tasti quando l'utente sta usando un select
      if (event.target.tagName === 'SELECT') return;

      const isRangeInput = event.target.type === 'range';

      switch (event.key) {
        case ' ':
        case 'k':
        case 'K':
          // La barra spaziatrice su un pulsante lo attiverebbe già: evitiamo il doppio comando
          if (event.target.tagName === 'BUTTON') return;
          event.preventDefault();
          player.togglePlay();
          break;
        case 'ArrowLeft':
          if (isRangeInput) return;
          event.preventDefault();
          player.skip(-5);
          break;
        case 'ArrowRight':
          if (isRangeInput) return;
          event.preventDefault();
          player.skip(5);
          break;
        case 'ArrowUp':
          if (isRangeInput) return;
          event.preventDefault();
          player.changeVolume(player.volume + 0.1);
          break;
        case 'ArrowDown':
          if (isRangeInput) return;
          event.preventDefault();
          player.changeVolume(player.volume - 0.1);
          break;
        case 'm':
        case 'M':
          player.toggleMute();
          break;
        case 'f':
        case 'F':
          player.toggleFullscreen();
          break;
        default:
          // I tasti numerici da 0 a 9 spostano la riproduzione alla percentuale corrispondente
          if (/^[0-9]$/.test(event.key)) {
            player.seek((Number(event.key) / 10) * player.duration);
          }
      }

      showControls();
    };

    container.addEventListener('keydown', handleKeyDown);
    return () => container.removeEventListener('keydown', handleKeyDown);
  }, [player, showControls]);

Quando il focus si trova su uno degli slider lasciamo che le frecce mantengano il loro comportamento nativo, così l'utente può regolare con precisione la posizione o il volume. Il tabIndex={-1} sul contenitore permette di dargli il focus con un clic senza inserirlo nella sequenza di tabulazione.

Poiché l'oggetto player restituito dall'hook viene ricreato a ogni render, l'effetto verrà registrato nuovamente a ogni aggiornamento di timeupdate. Il costo è trascurabile, ma se si preferisce evitarlo si può memorizzare l'ultimo valore di player in un ref e leggere da quello all'interno del gestore, registrando il listener una sola volta.

Lo stile

Il foglio di stile posiziona i controlli sopra il video con un gradiente che ne garantisce la leggibilità su qualsiasi fotogramma. Gli input range vengono resi trasparenti e sovrapposti alle tracce grafiche, così da mantenere l'interazione nativa con un aspetto personalizzato.

/* src/components/VideoPlayer/VideoPlayer.css */

.vp {
  position: relative;
  max-width: 960px;
  aspect-ratio: 16 / 9;
  background: #000;
  border-radius: 8px;
  overflow: hidden;
  color: #fff;
  font-family: system-ui, sans-serif;
  outline: none;
}

.vp--fullscreen {
  max-width: none;
  border-radius: 0;
}

.vp__video {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}

/* Barra dei controlli sovrapposta al video */
.vp__controls {
  position: absolute;
  inset: auto 0 0 0;
  padding: 12px 16px 10px;
  background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
  transition: opacity 0.25s ease;
}

.vp--controls-hidden {
  cursor: none;
}

.vp--controls-hidden .vp__controls {
  opacity: 0;
  pointer-events: none;
}

.vp__bar {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 8px;
}

.vp__spacer {
  flex: 1;
}

.vp__time {
  font-variant-numeric: tabular-nums;
  font-size: 0.875rem;
}

.vp-button {
  background: none;
  border: 0;
  color: inherit;
  font-size: 1rem;
  padding: 6px 8px;
  border-radius: 4px;
  cursor: pointer;
}

.vp-button:hover,
.vp-button:focus-visible {
  background: rgba(255, 255, 255, 0.15);
}

/* Barra di avanzamento: tracce grafiche con input trasparente sovrapposto */
.vp-progress {
  position: relative;
  height: 14px;
}

.vp-progress__track {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 4px;
  transform: translateY(-50%);
  background: rgba(255, 255, 255, 0.25);
  border-radius: 2px;
  overflow: hidden;
}

.vp-progress:hover .vp-progress__track {
  height: 6px;
}

.vp-progress__buffered,
.vp-progress__played {
  position: absolute;
  inset: 0 auto 0 0;
}

.vp-progress__buffered {
  background: rgba(255, 255, 255, 0.4);
}

.vp-progress__played {
  background: #e53935;
}

.vp-progress__input {
  position: absolute;
  inset: 0;
  width: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

/* Indicatore di focus visibile per la navigazione da tastiera */
.vp-progress:has(.vp-progress__input:focus-visible) .vp-progress__track {
  outline: 2px solid #fff;
  outline-offset: 2px;
}

.vp-volume {
  display: flex;
  align-items: center;
}

.vp-volume__input {
  width: 80px;
  accent-color: #fff;
}

.vp-rate {
  background: rgba(0, 0, 0, 0.5);
  color: inherit;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 4px;
  padding: 4px;
}

/* Pulsante centrale di avvio */
.vp__big-play {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 72px;
  height: 72px;
  border-radius: 50%;
  border: 0;
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  font-size: 1.75rem;
  cursor: pointer;
}

/* Indicatore di caricamento */
.vp__spinner {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 48px;
  height: 48px;
  margin: -24px 0 0 -24px;
  border: 4px solid rgba(255, 255, 255, 0.3);
  border-top-color: #fff;
  border-radius: 50%;
  animation: vp-spin 0.8s linear infinite;
}

@keyframes vp-spin {
  to {
    transform: rotate(360deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .vp__spinner {
    animation-duration: 2s;
  }
}

Utilizzo del componente

Il player è ora pronto per essere usato. I sottotitoli vengono passati come array di oggetti che corrispondono agli attributi dell'elemento <track>; i file devono essere in formato WebVTT e, se ospitati su un dominio diverso, serviti con le intestazioni CORS appropriate.

// src/App.jsx
import VideoPlayer from './components/VideoPlayer/VideoPlayer';

const SUBTITLES = [
  { src: '/subtitles/it.vtt', srcLang: 'it', label: 'Italiano', default: true },
  { src: '/subtitles/en.vtt', srcLang: 'en', label: 'English' },
];

export default function App() {
  return (
    <main className="app">
      <h1>Player video con React</h1>
      <VideoPlayer
        src="/videos/demo.mp4"
        poster="/videos/demo-poster.jpg"
        tracks={SUBTITLES}
        title="Demo"
      />
    </main>
  );
}

Ricordare la posizione di riproduzione

Una funzionalità molto apprezzata è la ripresa della riproduzione dal punto in cui l'utente l'aveva interrotta. Possiamo aggiungerla con un piccolo hook che salva la posizione in localStorage, limitando la frequenza delle scritture per non appesantire il thread principale.

// src/hooks/useResumePosition.js
import { useEffect, useRef } from 'react';

const SAVE_INTERVAL_MS = 5000;

export function useResumePosition(videoRef, storageKey) {
  const lastSaveRef = useRef(0);

  useEffect(() => {
    const video = videoRef.current;
    if (!video || !storageKey) return;

    // Ripristina la posizione salvata quando i metadati sono disponibili
    const restorePosition = () => {
      const saved = Number(localStorage.getItem(storageKey));
      // Non riprende se il video era quasi finito
      if (saved > 0 && saved < video.duration - 5) {
        video.currentTime = saved;
      }
    };

    // Salva la posizione al massimo una volta ogni SAVE_INTERVAL_MS
    const savePosition = () => {
      const now = Date.now();
      if (now - lastSaveRef.current < SAVE_INTERVAL_MS) return;
      lastSaveRef.current = now;
      localStorage.setItem(storageKey, String(video.currentTime));
    };

    // A fine video la posizione salvata non serve più
    const clearPosition = () => localStorage.removeItem(storageKey);

    if (video.readyState >= 1) {
      restorePosition();
    } else {
      video.addEventListener('loadedmetadata', restorePosition, { once: true });
    }

    video.addEventListener('timeupdate', savePosition);
    video.addEventListener('ended', clearPosition);

    return () => {
      video.removeEventListener('loadedmetadata', restorePosition);
      video.removeEventListener('timeupdate', savePosition);
      video.removeEventListener('ended', clearPosition);
    };
  }, [videoRef, storageKey]);
}

All'interno di VideoPlayer basta invocarlo passando il ref del video e una chiave univoca, ad esempio basata sull'URL della sorgente:

  // Riprende la riproduzione dal punto in cui era stata interrotta
  useResumePosition(player.videoRef, `video-position:${src}`);

Considerazioni finali

Il player che abbiamo costruito copre le funzionalità essenziali di un'interfaccia moderna: riproduzione e pausa, barra di avanzamento con buffer e trascinamento, volume, velocità, schermo intero, sottotitoli, scorciatoie da tastiera e ripresa della posizione. Il punto chiave dell'architettura è aver lasciato all'elemento <video> il ruolo di fonte di verità, usando lo stato di React solo come riflesso degli eventi del media: questo rende il componente robusto rispetto ai comandi che arrivano dal sistema operativo, dai tasti multimediali o da altre parti dell'applicazione.

Da qui è possibile estendere il player in diverse direzioni: il supporto allo streaming adattivo HLS o DASH tramite librerie come hls.js o dash.js, la modalità Picture-in-Picture con requestPictureInPicture(), l'integrazione con la Media Session API per mostrare titolo e copertina nei controlli di sistema, oppure le anteprime delle miniature al passaggio del mouse sulla barra di avanzamento.