Szybkie odtwarzanie z wstępnym wczytywaniem dźwięku i obrazu

Jak przyspieszyć odtwarzanie multimediów przez aktywne wstępne wczytywanie zasobów.

François Beaufort
François Beaufort

Szybsze rozpoczynanie odtwarzania oznacza, że więcej osób ogląda Twój film lub słucha Twojego dźwięku. To znany fakt. W tym artykule omawiam techniki, które możesz stosować do przyspieszania odtwarzania dźwięku i obrazu przez aktywne wstępne wczytywanie zasobów w zależności od przypadku użycia.

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

Opiszę 3 metody wstępnego wczytywania plików multimedialnych, zaczynając od ich zalet i wad.

Wspaniale... Ale...
Atrybut wstępnego wczytania filmu Prosty w użyciu w przypadku unikalnego pliku hostowanego na serwerze WWW. Przeglądarki mogą całkowicie zignorować ten atrybut.
Pobieranie zasobów rozpoczyna się po całkowitym wczytaniu i przeanalizowaniu dokumentu HTML.
Rozszerzenia Media Source Extensions (MSE) ignorują atrybut preload w elementach multimedialnych, ponieważ to aplikacja odpowiada za przesyłanie multimediów do MSE.
Wstępne wczytywanie linków Wymusza wysłanie przez przeglądarkę żądania zasobu wideo bez blokowania zdarzenia onload dokumentu. Żądania zakresu HTTP są niezgodne.
Zgodność z MSE i segmentami plików. Powinna być używana tylko w przypadku małych plików multimedialnych (poniżej 5 MB) podczas pobierania pełnych zasobów.
Buforowanie ręczne Pełna kontrola Za obsługę złożonych błędów odpowiada właściciel witryny.

Atrybut wstępnego wczytywania filmu

Jeśli źródło filmu to unikalny plik hostowany na serwerze internetowym, warto użyć atrybutu video preload, aby przekazać przeglądarce wskazówkę dotyczącą ile informacji lub treści ma zostać wstępnie załadowane. Oznacza to, że rozszerzenia źródła multimediów (MSE) nie są zgodne z tagiem preload.

Pobieranie zasobów rozpocznie się dopiero po pełnym wczytaniu i przeanalizowaniu początkowego dokumentu HTML (np. po wywołaniu zdarzenia DOMContentLoaded), a zupełnie inne zdarzenie load zostanie wywołane po rzeczywistym pobraniu zasobu.

Ustawienie atrybutu preload na metadata wskazuje, że użytkownik nie potrzebuje filmu, ale pobieranie jego metadanych (wymiarów, listy utworów, czasu trwania itp.) jest pożądane. Pamiętaj, że od wersji Chrome 64 wartość domyślna dla preload to metadata. (wcześniej wynosił 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>

Ustawienie atrybutu preload na auto oznacza, że przeglądarka może zapisać w pamięci podręcznej wystarczającą ilość danych, aby umożliwić odtwarzanie bez konieczności zatrzymywania się w celu dalszego buforowania.

<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>

Jest jednak kilka zastrzeżeń. To tylko wskazówka, więc przeglądarka może całkowicie zignorować atrybut preload. W chwili tworzenia tego tekstu w Chrome obowiązują pewne reguły:

  • Gdy Oszczędzanie danych jest włączone, Chrome wymusza wartość preload na none.
  • W Androidzie 4.3 Chrome wymusza wartość preload na none ze względu na błęd Androida.
  • W przypadku połączenia komórkowego (2G, 3G i 4G) Chrome wymusza wartość preload na metadata.

Wskazówki

Jeśli Twoja witryna zawiera wiele zasobów wideo w tej samej domenie, ustaw wartość preload na metadata lub zdefiniuj atrybut poster i ustaw wartość preload na none. Dzięki temu unikniesz nawiązywania maksymalnej liczby połączeń HTTP z tą samą domeną (sześciu zgodnie ze specyfikacją HTTP 1.1), które mogą kolidować z ładowaniem zasobów. Pamiętaj, że może to też poprawić szybkość strony, jeśli filmy nie są główną częścią usługi.

Jak omówiliśmy w innych artykułach, wstępny załadowanie linku to deklaratywny sposób pobierania, który pozwala zmusić przeglądarkę do wysłania żądania zasobu bez blokowania zdarzenia load i podczas pobierania strony. Zasoby wczytywane za pomocą <link rel="preload"> są przechowywane lokalnie w przeglądarce i są nieaktywne, dopóki nie zostaną wyraźnie odwołane w DOM, JavaScript lub CSS.

Wstępne wczytywanie różni się od pobierania z wyprzedzeniem, ponieważ koncentruje się na bieżącej nawigacji i pobiera zasoby z priorytetem określonym na podstawie ich typu (skrypt, styl, czcionka, film, dźwięk itp.). Powinien służyć do rozgrzewania pamięci podręcznej przeglądarki na potrzeby bieżących sesji.

Wstępnie wczytać cały film

Oto, jak wstępnie załadować cały film w witrynie, aby po przesłaniu przez kod JavaScript żądania pobrania treści filmu, mogły one zostać odczytane z pamięci podręcznej, ponieważ zasób mógł zostać już zapisany w pamięci podręcznej przeglądarki. Jeśli żądanie wstępnego wczytywania nie zostało jeszcze zakończone, nastąpi zwykłe pobieranie sieciowe.

<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>

Ponieważ załadowany wstępnie zasób będzie używany przez element wideo w tym przykładzie, wartość linku do wstępnego wczytania as to video. Gdyby to był element audio, to byłby as="audio".

Wstępnie wczytuj pierwszy segment

Poniższy przykład pokazuje, jak wstępnie wczytać pierwszy segment filmu za pomocą parametru <link rel="preload"> i używać go z rozszerzeniami źródła multimediów. Jeśli nie znasz jeszcze interfejsu MSE JavaScript API, zapoznaj się z podstawowymi informacjami o MSE.

Dla uproszczenia załóżmy, że cały film został podzielony na mniejsze pliki, takie jak file_1.webm, file_2.webm, file_3.webm itp.

<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>

Pomoc

Możesz sprawdzić obsługę różnych typów as w witrynie <link rel=preload> dzięki poniższym fragmentom:

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');
}

Buforowanie ręczne

Zanim przejdziemy do interfejsu Cache API i usług pomocniczych, zobaczmy, jak ręcznie buforować film za pomocą MSE. Przykład poniżej zakłada, że Twój serwer WWW obsługuje żądania HTTP Range, ale w przypadku segmentów plików jest to bardzo podobne. Pamiętaj, że niektóre biblioteki pośredniczące, takie jak Shaka Player, JW Player i Video.js, zostały zaprojektowane tak, aby wykonywać tę czynność za Ciebie.

<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>

Uwagi

Ponieważ masz teraz kontrolę nad całym procesem buforowania multimediów, przed zoptymalizowaniem wczytywania wstępnego weź pod uwagę poziom naładowania baterii, ustawienie „Tryb oszczędzania danych” oraz informacje o sieci.

Znajomość baterii

Zanim zdecydujesz się na wstępne wczytanie filmu, weź pod uwagę poziom naładowania baterii w urządzeniach użytkowników. Pozwoli to wydłużyć żywotność baterii przy niskim poziomie energii.

Wyłącz wstępne wczytywanie lub przynajmniej wczytaj film w niższej rozdzielczości, gdy poziom baterii urządzenia jest niski.

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.
    }
  });
}

Wykrywanie funkcji „Oszczędzanie danych”

Użyj nagłówka żądania wskazówek dla klienta Save-Data, aby przesyłać szybkie i lekkie aplikacje użytkownikom, którzy włączyli tryb „oszczędzania danych” w przeglądarce. Dzięki określeniu tego nagłówka żądania aplikacja może dostosowywać i dostarczać użytkownikom z ograniczeniami kosztów i wydajności zoptymalizowaną obsługę.

Więcej informacji znajdziesz w artykule Tworzenie szybkich i lekkich aplikacji z Save-Data.

Inteligentne ładowanie na podstawie informacji o sieci

Warto sprawdzić navigator.connection.type przed rozpoczęciem wstępnego wczytywania. Jeśli opcja cellular jest ustawiona, możesz uniemożliwić wstępne wczytywanie i poinformować użytkowników, że ich operator sieci komórkowej może pobierać opłaty za przepustowość, a automatyczne odtwarzanie rozpocznie się dopiero po wcześniejszym wczytaniu treści z pamięci podręcznej.

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

Zapoznaj się również z przykładem informacji o sieci, aby dowiedzieć się, jak reagować na zmiany w sieci.

Wstępnie zapisuj wiele pierwszych segmentów w pamięci podręcznej

A co, jeśli chcę spekulować na wstępne wczytywanie treści multimedialnych, nie wiedząc, który z nich użytkownik ostatecznie wybierze? Jeśli użytkownik znajduje się na stronie internetowej zawierającej 10 filmów, prawdopodobnie mamy wystarczająco dużo pamięci, aby pobrać po jednym pliku segmentu z każdego filmu, ale nie powinniśmy tworzyć 10 ukrytych elementów <video> i 10 obiektów MediaSource, aby zacząć przesyłać te dane.

Dwuczęściowy przykład poniżej pokazuje, jak wstępnie zapisać wiele pierwszych segmentów filmu w pamięci podręcznej za pomocą zaawansowanego i łatwego w użyciu interfejsu Cache API. Pamiętaj, że podobne efekty można osiągnąć za pomocą IndexedDB. Nie używamy jeszcze mechanizmów Service Worker, ponieważ interfejs Cache API jest dostępny również z obiektu window.

Pobierz i buforuj

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;
    });
  });
}

Jeśli użyję żądań HTTP Range, muszę ręcznie odtworzyć obiekt Response, ponieważ interfejs Cache API jeszcze nie obsługuje odpowiedzi Range. Pamiętaj, że wywołanie metody networkResponse.arrayBuffer() pobiera całą treść odpowiedzi jednocześnie do pamięci mechanizmu renderowania, dlatego warto używać małych zakresów.

Część powyższego przykładu została zmodyfikowana, aby zapisać żądania zakresu HTTP w pamięci podręcznej wstępnego żądania wideo.

    ...
    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;
    });

Odtwórz film

Gdy użytkownik kliknie przycisk odtwarzania, pobieramy pierwszy segment filmu dostępny w Cache API, aby odtwarzanie rozpoczęło się natychmiast, jeśli jest możliwe. W przeciwnym razie po prostu pobieramy go z sieci. Pamiętaj, że przeglądarki i użytkownicy mogą zdecydować się na wyczyszczenie pamięci podręcznej.

Jak widzieliśmy, używamy MSE, by przekazać ten pierwszy segment wideo do elementu wideo.

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.
      });
    }
  });
}

Tworzenie odpowiedzi zakresu za pomocą skryptu service worker

Co zrobić, jeśli pobierzesz cały plik wideo i zapiszesz go w Cache API? Gdy przeglądarka wysyła żądanie HTTP Range, nie chcesz wczytywać całego filmu do pamięci renderera, ponieważ interfejs Cache API jeszcze nie obsługuje odpowiedzi Range.

Pokażę więc, jak przechwycić te żądania i zwrócić niestandardową odpowiedź Range od mechanizmu Service 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;
  }
}

Pamiętaj, że do odtworzenia tego elementu odpowiedzi w postaci sekcji użyłem funkcji response.blob(), ponieważ daje ona mi dostęp do pliku, podczas gdy funkcja response.blob() przenosi cały plik do pamięci renderera.

Mój niestandardowy nagłówek HTTP X-From-Cache może być używany do sprawdzania, czy żądanie pochodzi z pamięci podręcznej czy z sieci. Mogą go używać takie odtwarzacze jak ShakaPlayer do ignorowania czasu odpowiedzi jako wskaźnika szybkości sieci.

Przejrzyj oficjalną aplikację Sample Media App, a w szczególności jej plik ranged-response.js, aby uzyskać kompletne rozwiązanie do obsługi żądań Range.