הפעלה מהירה באמצעות טעינה מראש של אודיו ווידאו

איך להאיץ את הפעלת המדיה על ידי טעינה מראש של משאבים באופן פעיל.

François Beaufort
François Beaufort

התחלת הפעלה מהירה יותר תגרום לכך שיותר אנשים צופים בסרטון או מאזינים לאודיו. זה עובדה ידועה. במאמר הזה מפורטות שיטות שבהן אפשר להשתמש כדי להאיץ את ההפעלה של אודיו ווידאו, על ידי טעינה מראש של משאבים בהתאם לתרחיש לדוגמה שלכם.

קרדיטים: Copyright Blender Foundation | www.blender.org .

אציג שלוש שיטות לטעינת קבצי מדיה מראש, ואתחיל בתיאור היתרונות והחסרונות שלהן.

מעולה... אבל...
מאפיין הטעינה מראש של הסרטון פשוט לשימוש בקובץ ייחודי המתארח בשרת אינטרנט. הדפדפנים עשויים להתעלם מהמאפיין לחלוטין.
אחזור המשאבים מתחיל כשמסמך ה-HTML נטען ונותח במלואו.
תוספי מקור מדיה (MSE) מתעלמים מהמאפיין preload ברכיבי מדיה כי האפליקציה אחראית לספק מדיה ל-MSE.
טעינה מראש של קישור אילוץ הדפדפן לשלוח בקשה למשאב וידאו בלי לחסום את אירוע onload של המסמך. בקשות HTTP Range לא תואמות.
תאימות ל-MSE ולפלחים של קבצים. מיועד לשימוש רק לקובצי מדיה קטנים (פחות מ-5MB) לאחזור משאבים מלאים.
אגירת נתונים ידנית שליטה מלאה טיפול בשגיאות מורכבות נמצא באחריות האתר.

מאפיין טעינה מראש של סרטון

אם מקור הסרטון הוא קובץ ייחודי שמתארח בשרת אינטרנט, כדאי להשתמש במאפיין הווידאו preload כדי לרמוז לדפדפן כמה מידע או תוכן כדאי לטעון מראש. המשמעות היא שMedia Source Extensions‏ (MSE) לא תואם ל-preload.

אחזור המשאבים יתחיל רק אחרי שמסמך ה-HTML הראשוני יהיה נטען ומנותח במלואו (למשל, האירוע DOMContentLoaded יופעל), בעוד שהאירוע load, ששונה מאוד, יופעל אחרי שהמשאב יוחזר בפועל.

הגדרת המאפיין preload לערך metadata מציינת שהמשתמש לא צפוי להזדקק לסרטון, אבל כדאי לאחזר את המטא-נתונים שלו (מאפיינים, רשימת טראקים, משך וכו'). הערה: החל מ-Chrome 64, ערך ברירת המחדל של preload הוא metadata. (היא הייתה 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>

הגדרת המאפיין preload לערך auto מציינת שהדפדפן עשוי לשמור במטמון מספיק נתונים כדי לאפשר השלמת ההפעלה בלי צורך להפסיק אותה לצורך אחסון נוסף במטמון.

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

עם זאת, יש כמה נקודות שכדאי לשים לב אליהן. זו רק עצה, והדפדפן עשוי להתעלם לחלוטין מהמאפיין preload. בזמן הכתיבה, הנה כמה כללים שחלים על Chrome:

  • כשחוסך הנתונים מופעל, Chrome מאלץ את הערך של preload להיות none.
  • ב-Android 4.3, Chrome אוכף את הערך preload ל-none עקב באג ב-Android.
  • בחיבור לרשת סלולרית (2G,‏ 3G ו-4G), Chrome מאלץ את הערך של preload להיות metadata.

טיפים

אם האתר שלכם מכיל משאבי וידאו רבים באותו דומיין, מומלץ להגדיר את הערך של preload כ-metadata או להגדיר את המאפיין poster ולהגדיר את preload לערך none. כך אפשר למנוע חריגה מהמספר המקסימלי של חיבורי HTTP לאותו דומיין (6 בהתאם למפרט HTTP 1.1), דבר שעלול לגרום לטעינת משאבים. שימו לב שפעולה כזו עשויה גם לשפר את מהירות הדף אם הסרטונים לא מהווים חלק מרכזי מחוויית המשתמש.

כמו שמופיע במאמרים אחרים, טעינה מראש של קישורים היא אחזור הצהרתי שמאפשר לאלץ את הדפדפן לשלוח בקשה למשאב בלי לחסום את האירוע load ובמהלך הורדת הדף. משאבים שנטענים דרך <link rel="preload"> מאוחסנים באופן מקומי בדפדפן, ולמעשה הם קיימים עד שיש הפניה אליהם במפורש ב-DOM, ב-JavaScript או ב-CSS.

טעינה מראש שונה משליפה מראש (prefetch) כי היא מתמקדת בניווט הנוכחי ומאחזרת משאבים עם עדיפות על סמך הסוג שלהם (סקריפט, סגנון, גופן, וידאו, אודיו וכו'). צריך להשתמש בה כדי לחמם את המטמון של הדפדפן לסשנים הנוכחיים.

טעינה מראש של הסרטון המלא

כך אפשר לטעון מראש סרטון מלא באתר, כדי שכאשר ה-JavaScript יבקשו לאחזר את תוכן הסרטון, הוא יקרא מהמטמון, כי יכול להיות שהדפדפן כבר שמר את המשאב במטמון. אם הבקשה לטעינת הנתונים מראש עדיין לא הסתיימה, תתבצע אחזור רגיל מהרשת.

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

מכיוון שרכיב הווידאו בדוגמה ישתמש במשאב שנטען מראש, הערך של הקישור לטעינה מראש as הוא video. אם זה היה רכיב אודיו, הוא היה as="audio".

טעינת הקטע הראשון מראש

הדוגמה הבאה מראה איך לטעון מראש את הקטע הראשון של סרטון באמצעות <link rel="preload"> ולהשתמש בו עם תוספי מקור מדיה. אם אתם לא מכירים את MSE JavaScript API, כדאי לעיין בעקרונות הבסיסיים של MSE.

כדי לפשט את העניין, נניח שהסרטון כולו מחולק לקבצים קטנים יותר, כמו file_1.webm,‏ file_2.webm,‏ file_3.webm וכו'.

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

תמיכה

אפשר לזהות תמיכה בסוגים שונים של as ב-<link rel=preload> באמצעות נכסי המידע שלמטה:

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

אגירת נתונים ידנית

לפני שנצלול ל-Cache API ו-Service Workers, נסביר איך לאחסן באופן ידני אחסון זמני של סרטונים באמצעות MSE. בדוגמה הבאה ההנחה היא ששרת האינטרנט תומך בבקשות HTTP Range, אבל היא תהיה דומה למדי עם פלחי קבצים. שימו לב שספריות מסוימות של תוכנת ביניים, כמו Shaka Player של Google,‏ JW Player ו-Video.js, מיועדות לטפל בבעיה הזו בשבילכם.

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

לתשומת ליבכם

עכשיו יש לך שליטה על כל חוויית האחסון במטמון של המדיה, ולכן מומלץ להביא בחשבון את רמת הטעינה של הסוללה, את העדפת המשתמש ב'מצב חיסכון בנתונים' ואת נתוני הרשת כשמדברים על טעינת נתונים מראש.

מעקב אחרי מצב הסוללה

חשוב להביא בחשבון את רמת הטעינה של הסוללה במכשירי המשתמשים לפני שחושבים על טעינה מראש של סרטון. כך תוכלו לחסוך בחיי הסוללה כשרמת הטעינה נמוכה.

כשהסוללה במכשיר נחלשת, כדאי להשבית את ההטענה מראש או לפחות להפעיל אותה עבור סרטון ברזולוציה נמוכה יותר.

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

זיהוי של 'חוסך הנתונים'

השתמשו בכותרת הבקשה של רמז הלקוח Save-Data כדי לספק אפליקציות מהירות וקלות למשתמשים שהביעו הסכמה למצב 'חיסכון בנתונים' בדפדפן שלהם. זיהוי כותרת הבקשה הזו מאפשר לאפליקציה להתאים אישית את חוויית המשתמש ולספק אותה למשתמשים עם מגבלות עלות וביצועים.

מידע נוסף זמין במאמר הצגת אפליקציות מהירות וקלות באמצעות חיסכון בנתונים.

טעינה חכמה על סמך פרטי הרשת

כדאי לבדוק את navigator.connection.type לפני הטעינה מראש. כשהערך מוגדר ל-cellular, אפשר למנוע טעינת תוכן מראש ולהודיע למשתמשים שיכול להיות שהספק של הרשת הסלולרית שלהם גובה על רוחב הפס, ולהתחיל רק את ההפעלה האוטומטית של תוכן שנשמר במטמון.

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

דוגמה של פרטי רשת תעזור לכם להבין איך להגיב גם לשינויים ברשת.

אחסון במטמון מראש של כמה פלחים ראשונים

מה קורה אם רוצים לטעון מראש תוכן מדיה באופן ספקולטיבי בלי לדעת באיזו קטע מדיה המשתמש יבחר בסופו של דבר? אם המשתמש נמצא בדף אינטרנט שמכיל 10 סרטונים, סביר להניח שיש לנו מספיק זיכרון כדי לאחזר קובץ מקטע אחד מכל אחד מהם, אבל בהחלט לא כדאי ליצור 10 רכיבי <video> מוסתרים ו-10 אובייקטים מסוג MediaSource ולהתחיל להזין את הנתונים האלה.

בדוגמה הבאה, שמחולקת לשני חלקים, מוסבר איך לשמור במטמון מראש כמה מהפלחים הראשונים של הסרטון באמצעות Cache API – כלי חזק וקל לשימוש. חשוב לציין שאפשר להשיג משהו דומה גם באמצעות IndexedDB. אנחנו עדיין לא משתמשים ב-service workers כי אפשר לגשת ל-Cache API גם מהאובייקט window.

אחזור ושמירה במטמון

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

שימו לב שאם משתמשים בבקשות HTTP Range, צריך ליצור מחדש באופן ידני אובייקט Response כי Cache API עדיין לא תומך בתשובות Range עדיין. חשוב לזכור שהקריאה ל-networkResponse.arrayBuffer() מאחזרת את כל התוכן של התגובה בבת אחת לזיכרון של ה-renderer, לכן מומלץ להשתמש בטווחים קטנים.

לידיעתך, שיניתי חלק מהדוגמה שלמעלה כדי לשמור בקשות טווח HTTP במטמון מראש של הסרטון.

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

הפעלת הסרטון

כשמשתמש ילחץ על לחצן הפעלה, נאחזר את קטע הווידאו הראשון שזמין ב-Cache API כדי שההפעלה תתחיל באופן מיידי, אם הוא זמין. אחרת, פשוט נאחזר אותו מהרשת. חשוב לזכור שדפדפנים ומשתמשים עשויים להחליט לנקות את המטמון.

כמו שראינו קודם, אנחנו משתמשים ב-MSE כדי להזין את הקטע הראשון של הווידאו לרכיב הווידאו.

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

יצירת תשובות בטווח בעזרת Service Worker

מה קורה אם הבאתם קובץ וידאו שלם ושמרתם אותו ב-Cache API? כשהדפדפן שולח בקשת HTTP Range, בהחלט לא כדאי להעביר את הסרטון כולו לזיכרון של המרינדר, כי עדיין Cache API לא תומך בתגובות Range.

אז בואו נראה איך ליירט את הבקשות האלה ולהחזיר תשובה של Range מותאמת אישית מ-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;
  }
}

חשוב לציין שהשתמשתי ב-response.blob() כדי ליצור מחדש את התשובה הפרוסת הזאת, כי היא פשוט נותנת לי כינוי לקובץ, בזמן ש-response.arrayBuffer() מעביר את הקובץ כולו לזיכרון של כלי הרינדור.

אפשר להשתמש בכותרת ה-HTTP בהתאמה אישית X-From-Cache, כדי לדעת אם הבקשה הזו הגיעה מהמטמון או מהרשת. שחקן כמו ShakaPlayer יכול להשתמש בו כדי להתעלם מזמן התגובה כאינדיקטור למהירות הרשת.

כדי לקבל פתרון מלא לטיפול בבקשות Range, כדאי לעיין באפליקציית Sample Media הרשמית ובקובץ ranged-response.js.