يمكنك تشغيل الفيديو بسرعة من خلال تحميل الصوت والفيديو مسبقًا.

كيفية تسريع تشغيل الوسائط من خلال التحميل المُسبق للموارد

فادي مجدي
فرانسوا بوفورت

يؤدي بدء التشغيل بشكل أسرع إلى زيادة عدد الأشخاص الذين يشاهدون الفيديو أو يستمعون إلى الصوت. هذه حقيقة معروفة. في هذه المقالة، سأستكشف التقنيات التي يمكنك استخدامها لتسريع تشغيل الصوت والفيديو من خلال التحميل المسبق الفعال للموارد استنادًا إلى حالة الاستخدام.

المساهمين: حقوق الطبع والنشر Blender Foundation | www.blender.org .

سأصف ثلاث طرق لتحميل ملفات الوسائط مسبقًا، بدءًا من إيجابياتها وسلبياتها.

إنه رائع... ولكن...
سمة التحميل المُسبق للفيديو يمكن استخدام هذا الملف بسهولة مع ملف فريد تتم استضافته على خادم ويب. وقد تتجاهل المتصفّحات السمة تمامًا.
يبدأ جلب الموارد عندما يتم تحميل مستند HTML وتحليله بالكامل.
تتجاهل إضافات مصدر الوسائط (MSE) السمة preload في عناصر الوسائط، لأنّ التطبيق يكون مسؤولاً عن تقديم الوسائط إلى الخطأ التربيعي المتوسط.
التحميل المُسبق للرابط يفرض على المتصفِّح إنشاء طلب لمورد فيديو بدون حظر حدث onload في المستند. طلبات نطاق HTTP غير متوافقة.
متوافق مع الخطأ التربيعي المتوسط وأجزاء الملفات. يجب استخدامها فقط لملفات الوسائط الصغيرة (أقل من 5 ميغابايت) عند جلب الموارد الكاملة.
التخزين المؤقت اليدوي تحكُّم كامل تقع مسؤولية معالجة الأخطاء المعقّدة على عاتق الموقع الإلكتروني.

سمة التحميل المُسبق للفيديو

إذا كان مصدر الفيديو عبارة عن ملف فريد تتم استضافته على خادم ويب، يمكنك استخدام سمة preload للفيديو لتقديم تلميح للمتصفح عن مقدار المعلومات أو المحتوى المطلوب التحميل مسبقًا. يعني ذلك أنّ إضافات مصادر الوسائط (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.
  • عند استخدام اتصال شبكة الجوّال (الجيل الثاني والجيل الثالث والجيل الرابع)، يفرض 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"> واستخدامه مع إضافات مصدر الوسائط. إذا لم تكن على دراية بواجهة برمجة تطبيقات JavaScript للخطأ التربيعي المتوسط، يمكنك الاطّلاع على أساسيات الخطأ التربيعي المتوسط.

ولتبسيط الأمر، لنفترض أنّه تم تقسيم الفيديو بالكامل إلى ملفات أصغر حجمًا مثل 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');
}

التخزين المؤقت اليدوي

قبل أن نتحدث بالتفصيل عن واجهة برمجة تطبيقات ذاكرة التخزين المؤقت ومشغّلي الخدمات، لنتعرّف على طريقة التخزين المؤقت للفيديو يدويًا باستخدام الخطأ التربيعي المتوسط. يفترض المثال أدناه أن خادم الويب يتيح طلبات HTTP Range، ولكن هذا سيكون مشابهًا إلى حد كبير لشرائح الملفات. تجدر الإشارة إلى أنّه تم تصميم بعض مكتبات البرمجيات الوسيطة مثل Shاكا 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 ونبدأ في جمع تلك البيانات.

يوضح لك المثال المكوّن من جزأين أدناه كيفية التخزين المؤقت مسبقًا لعدّة مقاطع من الفيديو باستخدام ذاكرة التخزين المؤقت API الفعّالة والسهلة الاستخدام. لاحظ أنه يمكن أيضًا تحقيق شيء مشابه باستخدام قاعدة البيانات المفهرسة. لم نستخدم مشغِّلي الخدمات في الوقت الحالي، إذ يمكن الوصول إلى واجهة برمجة التطبيقات 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() يؤدي إلى جلب محتوى الاستجابة بالكامل في الوقت نفسه إلى ذاكرة العارض، ولهذا السبب قد تحتاج إلى استخدام نطاقات صغيرة.

كمرجع لك، عدّلتُ جزءًا من المثال أعلاه لحفظ طلبات نطاق 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 بحيث يبدأ التشغيل فورًا إذا كان متاحًا. وإلا، فسنجلبه من الشبكة. ضع في اعتبارك أن المتصفحات والمستخدمين قد يقررون محو ذاكرة التخزين المؤقت.

كما سبق وذكرنا، نستخدم الخطأ التربيعي المتوسط لإضافة المقطع الأول من الفيديو إلى عنصر الفيديو.

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

إنشاء استجابات نطاق باستخدام مشغّل خدمات

ماذا لو جلبت ملف فيديو كاملاً وحفظته في Cache API؟ عندما يرسل المتصفّح طلب HTTP Range، لن تحتاج بالتأكيد إلى وضع الفيديو بالكامل في ذاكرة العارض لأنّ واجهة برمجة التطبيقات Cache API لا تتوافق مع استجابات Range بعد.

لذلك، سأوضّح كيفية اعتراض هذه الطلبات وعرض استجابة Range مخصّصة من مشغّل الخدمات.

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 لتجاهل وقت الاستجابة كمؤشر على سرعة الشبكة.

يمكنك إلقاء نظرة على نموذج تطبيق الوسائط الرسمي وعلى وجه الخصوص ملف ranged-response.js للاطّلاع على حل كامل حول كيفية التعامل مع طلبات Range.