Riproduzione veloce con precaricamento audio e video

Come accelerare la riproduzione dei contenuti multimediali precaricando attivamente le risorse.

François Beaufort
François Beaufort

Un inizio di riproduzione più rapido significa un maggior numero di persone che guardano il tuo video o ascoltano il tuo audio. È un fatto noto. In questo articolo esamino le tecniche che puoi utilizzare per accelerare la riproduzione audio e video precaricando attivamente le risorse in base al tuo caso d'uso.

Riconoscimenti: copyright Blender Foundation | www.blender.org .

Descriverò tre metodi di precaricamento dei file multimediali, iniziando con i relativi pro e contro.

È fantastico… Ma…
Attributo di precaricamento dei video È facile da usare per un file univoco ospitato su un server web. I browser potrebbero ignorare completamente l'attributo.
Il recupero delle risorse inizia quando il documento HTML è stato completamente caricato e analizza.
Le estensioni di origine media (MSE) ignorano l'attributo preload negli elementi multimediali perché è compito dell'app fornire i contenuti multimediali a MSE.
Precaricamento dei link Forza il browser a effettuare una richiesta di una risorsa video senza bloccare l'evento onload del documento. Le richieste HTTP Range non sono compatibili.
Compatibile con MSE e segmenti di file. Deve essere utilizzato solo per file multimediali di piccole dimensioni (<5 MB) quando si recuperano risorse complete.
Buffering manuale Controllo completo La gestione degli errori complessi è responsabilità del sito web.

Attributo di precaricamento video

Se la sorgente video è un file univoco ospitato su un server web, ti consigliamo di utilizzare l'attributo video preload per fornire un suggerimento al browser su quanto caricare in anteprima di informazioni o contenuti. Ciò significa che le Estensioni di origine media (MSE) non sono compatibili con preload.

Il recupero delle risorse inizierà solo quando il documento HTML iniziale sarà stato caricato e analizzato completamente (ad es. quando è stato attivato l'evento DOMContentLoaded), mentre l'evento load molto diverso verrà attivato quando la risorsa sarà stata effettivamente recuperata.

L'impostazione dell'attributo preload su metadata indica che non è previsto che l'utente abbia bisogno del video, ma che è auspicabile recuperare i relativi metadati (dimensioni, elenco tracce, durata e così via). Tieni presente che, a partire da Chrome 64, il valore predefinito per preload è metadata. (in precedenza era auto).

<video id="video" preload="metadata" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

L'impostazione dell'attributo preload su auto indica che il browser può memorizzare nella cache una quantità di dati sufficiente per consentire la riproduzione completa senza richiedere l'interruzione per un ulteriore buffering.

<video id="video" preload="auto" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

Tuttavia, ci sono alcune avvertenze. Poiché si tratta solo di un suggerimento, il browser potrebbe ignorare completamente l'attributo preload. Al momento della stesura di questo articolo, ecco alcune regole applicate in Chrome:

  • Quando la funzionalità Risparmio dati è attiva, Chrome forza il valore preload su none.
  • In Android 4.3, Chrome forza il valore preload su none a causa di un bug di Android.
  • Su una connessione di rete mobile (2G, 3G e 4G), Chrome forza il valore preload su metadata.

Suggerimenti

Se il tuo sito web contiene molte risorse video nello stesso dominio, ti consigliamo di impostare il valore preload su metadata o di definire l'attributo poster e impostare preload su none. In questo modo, eviterai di raggiungere il numero massimo di connessioni HTTP allo stesso dominio (6 in base alla specifica HTTP 1.1), che può bloccare il caricamento delle risorse. Tieni presente che questa operazione potrebbe anche migliorare la velocità della pagina se i video non fanno parte dell'esperienza utente principale.

Come descritto in altri articoli, il precaricamento dei link è un recupero dichiarativo che consente di forzare il browser a effettuare una richiesta per una risorsa senza bloccare l'evento load e durante il download della pagina. Le risorse caricate tramite <link rel="preload"> vengono archiviate localmente nel browser e sono effettivamente inattive finché non viene fatto riferimento esplicito a DOM, JavaScript o CSS.

Il precaricamento è diverso dal pre-caricamento in quanto si concentra sulla navigazione corrente e recupera le risorse in base alla priorità in base al loro tipo (script, stile, carattere, video, audio e così via). Deve essere utilizzata per attivare la cache del browser per le sessioni correnti.

Precaricare il video completo

Ecco come precaricare un video completo sul tuo sito web in modo che, quando il codice JavaScript chiede di recuperare i contenuti del video, questi vengano letti dalla cache, poiché la risorsa potrebbe essere già stata memorizzata nella cache dal browser. Se la richiesta di precaricamento non è ancora stata completata, verrà eseguito un recupero regolare dalla rete.

<link rel="preload" as="video" href="https://cdn.com/small-file.mp4">

<video id="video" controls></video>

<script>
  // Later on, after some condition has been met, set video source to the
  // preloaded video URL.
  video.src = 'https://cdn.com/small-file.mp4';
  video.play().then(() => {
    // If preloaded video URL was already cached, playback started immediately.
  });
</script>

Poiché la risorsa precaricata verrà utilizzata da un elemento video nell'esempio, il valore del link di precaricamento as è video. Se fosse un elemento audio, sarebbe as="audio".

Precarica il primo segmento

L'esempio riportato di seguito mostra come precaricare il primo segmento di un video con <link rel="preload"> e utilizzarlo con le estensioni Media Source. Se non hai dimestichezza con l'API JavaScript MSE, consulta le nozioni di base su MSE.

Per semplicità, supponiamo che l'intero video sia stato suddiviso in file più piccoli come file_1.webm, file_2.webm, file_3.webm e così via.

<link rel="preload" as="fetch" href="https://cdn.com/file_1.webm">

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // If video is preloaded already, fetch will return immediately a response
    // from the browser cache (memory cache). Otherwise, it will perform a
    // regular network fetch.
    fetch('https://cdn.com/file_1.webm')
    .then(response => response.arrayBuffer())
    .then(data => {
      // Append the data into the new sourceBuffer.
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch file_2.webm when user starts playing video.
    })
    .catch(error => {
      // TODO: Show "Video is not available" message to user.
    });
  }
</script>

Assistenza

Puoi rilevare il supporto di vari tipi di as per <link rel=preload> con gli snippet riportati di seguito:

function preloadFullVideoSupported() {
  const link = document.createElement('link');
  link.as = 'video';
  return (link.as === 'video');
}

function preloadFirstSegmentSupported() {
  const link = document.createElement('link');
  link.as = 'fetch';
  return (link.as === 'fetch');
}

Buffering manuale

Prima di addentrarci nell'API Cache e nei service worker, vediamo come eseguire il buffering manuale di un video con MSE. L'esempio riportato di seguito presuppone che il tuo server web supporti le richieste HTTP Range, ma la procedura è molto simile anche per i segmenti di file. Tieni presente che alcune librerie middleware come Shaka Player di Google, JW Player e Video.js sono progettate per gestire questo problema per te.

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // Fetch beginning of the video by setting the Range HTTP request header.
    fetch('file.webm', { headers: { range: 'bytes=0-567139' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      sourceBuffer.appendBuffer(data);
      sourceBuffer.addEventListener('updateend', updateEnd, { once: true });
    });
  }

  function updateEnd() {
    // Video is now ready to play!
    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);

    // Fetch the next segment of video when user starts playing the video.
    video.addEventListener('playing', fetchNextSegment, { once: true });
  }

  function fetchNextSegment() {
    fetch('file.webm', { headers: { range: 'bytes=567140-1196488' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      const sourceBuffer = mediaSource.sourceBuffers[0];
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch further segment and append it.
    });
  }
</script>

Considerazioni

Ora che hai il controllo dell'intera esperienza di buffering dei contenuti multimediali, ti consiglio di prendere in considerazione il livello della batteria del dispositivo, la preferenza utente "Modalità Risparmio dati" e le informazioni sulla rete quando pensi al precaricamento.

Rilevamento batteria

Tieni conto del livello della batteria dei dispositivi degli utenti prima di pensare di precaricare un video. In questo modo, la durata della batteria viene preservata quando il livello di carica è basso.

Disattiva il precaricamento o, almeno, precarica un video a risoluzione inferiore quando la batteria del dispositivo sta per scaricarsi.

if ('getBattery' in navigator) {
  navigator.getBattery()
  .then(battery => {
    // If battery is charging or battery level is high enough
    if (battery.charging || battery.level > 0.15) {
      // TODO: Preload the first segment of a video.
    }
  });
}

Rileva "Risparmio dati"

Utilizza l'intestazione della richiesta di suggerimento client Save-Data per fornire applicazioni rapide e leggere agli utenti che hanno attivato la modalità "risparmio dati" nel browser. Identificando questa intestazione della richiesta, la tua applicazione può personalizzare e offrire un'esperienza utente ottimizzata agli utenti con limitazioni in termini di costi e prestazioni.

Per saperne di più, consulta Pubblicare applicazioni veloci e leggere con Risparmia dati.

Caricamento intelligente in base alle informazioni di rete

Ti consigliamo di controllare navigator.connection.type prima del precaricamento. Se è impostato su cellular, puoi impedire il precaricamento e avvisare gli utenti che il loro operatore di rete mobile potrebbe addebitare la larghezza di banda e avviare solo la riproduzione automatica dei contenuti memorizzati nella cache in precedenza.

if ('connection' in navigator) {
  if (navigator.connection.type == 'cellular') {
    // TODO: Prompt user before preloading video
  } else {
    // TODO: Preload the first segment of a video.
  }
}

Consulta l'esempio di informazioni sulla rete per scoprire come reagire anche alle modifiche della rete.

Eseguire la precache di più segmenti iniziali

Ora, che cosa succede se voglio precaricare in modo speculativo alcuni contenuti multimediali senza sapere quale contenuto multimediale l'utente sceglierà? Se l'utente è su una pagina web che contiene 10 video, probabilmente abbiamo memoria sufficiente per recuperare un file di segmento da ciascuno, ma non dobbiamo assolutamente creare 10 elementi <video> nascosti e 10 oggetti MediaSource e iniziare a inviare questi dati.

L'esempio in due parti riportato di seguito mostra come eseguire la pre-cache di più primi segmenti di video utilizzando l'API Cache, potente e facile da usare. Tieni presente che è possibile ottenere risultati simili anche con IndexedDB. Non utilizziamo ancora i worker di servizio perché l'API Cache è accessibile anche dall'oggetto window.

Recupera e memorizza nella cache

const videoFileUrls = [
  'bat_video_file_1.webm',
  'cow_video_file_1.webm',
  'dog_video_file_1.webm',
  'fox_video_file_1.webm',
];

// Let's create a video pre-cache and store all first segments of videos inside.
window.caches.open('video-pre-cache')
.then(cache => Promise.all(videoFileUrls.map(videoFileUrl => fetchAndCache(videoFileUrl, cache))));

function fetchAndCache(videoFileUrl, cache) {
  // Check first if video is in the cache.
  return cache.match(videoFileUrl)
  .then(cacheResponse => {
    // Let's return cached response if video is already in the cache.
    if (cacheResponse) {
      return cacheResponse;
    }
    // Otherwise, fetch the video from the network.
    return fetch(videoFileUrl)
    .then(networkResponse => {
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, networkResponse.clone());
      return networkResponse;
    });
  });
}

Tieni presente che, se dovessi utilizzare le richieste HTTP Range, dovrei ricreare manualmente un oggetto Response, poiché l'API Cache non supporta ancora le risposte Range. Tieni conto che l'utilizzo di networkResponse.arrayBuffer() recupera tutti i contenuti della risposta contemporaneamente nella memoria del renderer, motivo per cui ti consigliamo di utilizzare intervalli ridotti.

Come riferimento, ho modificato parte dell'esempio riportato sopra per salvare le richieste HTTP Range nella precache del video.

    ...
    return fetch(videoFileUrl, { headers: { range: 'bytes=0-567139' } })
    .then(networkResponse => networkResponse.arrayBuffer())
    .then(data => {
      const response = new Response(data);
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, response.clone());
      return response;
    });

Riproduci video

Quando un utente fa clic su un pulsante di riproduzione, recuperiamo il primo segmento di video disponibile nell'API Cache in modo che la riproduzione inizi immediatamente, se disponibile. In caso contrario, lo recupereremo dalla rete. Tieni presente che i browser e gli utenti possono decidere di svuotare la cache.

Come abbiamo visto in precedenza, utilizziamo MSE per inviare il primo segmento di video all'elemento video.

function onPlayButtonClick(videoFileUrl) {
  video.load(); // Used to be able to play video later.

  window.caches.open('video-pre-cache')
  .then(cache => fetchAndCache(videoFileUrl, cache)) // Defined above.
  .then(response => response.arrayBuffer())
  .then(data => {
    const mediaSource = new MediaSource();
    video.src = URL.createObjectURL(mediaSource);
    mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

    function sourceOpen() {
      URL.revokeObjectURL(video.src);

      const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');
      sourceBuffer.appendBuffer(data);

      video.play().then(() => {
        // TODO: Fetch the rest of the video when user starts playing video.
      });
    }
  });
}

Creare risposte di intervallo con un worker di servizio

E se hai recuperato un intero file video e lo hai salvato nell'API Cache? Quando il browser invia una richiesta HTTP Range, di certo non vorrai caricare l'intero video nella memoria del renderer, in quanto l'API Cache non supporta ancora le risposte Range.

Vediamo come intercettare queste richieste e restituire una risposta Range personalizzata da un servizio worker.

addEventListener('fetch', event => {
  event.respondWith(loadFromCacheOrFetch(event.request));
});

function loadFromCacheOrFetch(request) {
  // Search through all available caches for this request.
  return caches.match(request)
  .then(response => {

    // Fetch from network if it's not already in the cache.
    if (!response) {
      return fetch(request);
      // Note that we may want to add the response to the cache and return
      // network response in parallel as well.
    }

    // Browser sends a HTTP Range request. Let's provide one reconstructed
    // manually from the cache.
    if (request.headers.has('range')) {
      return response.blob()
      .then(data => {

        // Get start position from Range request header.
        const pos = Number(/^bytes\=(\d+)\-/g.exec(request.headers.get('range'))[1]);
        const options = {
          status: 206,
          statusText: 'Partial Content',
          headers: response.headers
        }
        const slicedResponse = new Response(data.slice(pos), options);
        slicedResponse.setHeaders('Content-Range': 'bytes ' + pos + '-' +
            (data.size - 1) + '/' + data.size);
        slicedResponse.setHeaders('X-From-Cache': 'true');

        return slicedResponse;
      });
    }

    return response;
  }
}

È importante notare che ho utilizzato response.blob() per ricreare questa risposta suddivisa in parti, in quanto mi fornisce semplicemente un handle per il file, mentre response.arrayBuffer() carica l'intero file nella memoria del renderer.

La mia intestazione HTTP X-From-Cache personalizzata può essere utilizzata per sapere se questa richiesta proviene dalla cache o dalla rete. Può essere utilizzato da un player come ShakaPlayer per ignorare il tempo di risposta come indicatore della velocità della rete.

Dai un'occhiata all'app di media di esempio ufficiale e in particolare al suo file ranged-response.js per una soluzione completa su come gestire le richieste Range.