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

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

François Beaufort
François Beaufort

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

קרדיטים: זכויות יוצרים של 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.

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

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

כך אפשר לטעון מראש סרטון מלא באתר, כדי שכאשר ה-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 ולשירותי העבודה, נראה איך מאחסנים בסטרימר סרטון באופן ידני באמצעות 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 Range במטמון המקדים של הסרטון.

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

יצירת תגובות Range באמצעות שירות עובד (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() מעבירה את כל הקובץ לזיכרון של ה-renderer.

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

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