เล่นอย่างรวดเร็วด้วยเสียงและวิดีโอที่โหลดไว้ล่วงหน้า

วิธีเร่งการเล่นสื่อด้วยการโหลดทรัพยากรล่วงหน้าอย่างสม่ำเสมอ

François Beaufort
François Beaufort

การเริ่มเล่นที่เร็วขึ้นหมายความว่ามีผู้ชมวิดีโอหรือฟังเสียงของคุณมากขึ้น นั่นเป็นข้อเท็จจริงที่ทราบกันดี ในบทความนี้ เราจะกล่าวถึงเทคนิคที่คุณสามารถใช้เพื่อเร่งการเล่นเสียงและวิดีโอด้วยการโหลดทรัพยากรล่วงหน้าอย่างสม่ำเสมอ ทั้งนี้ขึ้นอยู่กับกรณีการใช้งาน

เครดิต: สงวนลิขสิทธิ์โดย Blender Foundation | www.blender.org

เราจะอธิบายวิธี 3 วิธีในการโหลดไฟล์สื่อล่วงหน้า โดยเริ่มจากข้อดีและข้อเสีย

เยี่ยมเลย แต่...
แอตทริบิวต์การโหลดวิดีโอล่วงหน้า ใช้งานง่ายสำหรับไฟล์ที่ไม่ซ้ำกันซึ่งโฮสต์ในเว็บเซิร์ฟเวอร์ เบราว์เซอร์อาจละเว้นแอตทริบิวต์นี้โดยสิ้นเชิง
การดึงข้อมูลทรัพยากรจะเริ่มขึ้นเมื่อเอกสาร HTML โหลดและแยกวิเคราะห์เสร็จสมบูรณ์
ส่วนขยายแหล่งที่มาของสื่อ (MSE) จะไม่สนใจแอตทริบิวต์ preload ในองค์ประกอบสื่อ เนื่องจากแอปมีหน้าที่รับผิดชอบในการส่งสื่อไปยัง MSE
การโหลดลิงก์ล่วงหน้า บังคับให้เบราว์เซอร์ส่งคำขอทรัพยากรวิดีโอโดยไม่บล็อกเหตุการณ์ onload ของเอกสาร คำขอช่วง HTTP ใช้ร่วมกันไม่ได้
ใช้งานร่วมกับ MSE และกลุ่มไฟล์ได้ ควรใช้กับไฟล์สื่อขนาดเล็ก (<5 MB) เมื่อดึงข้อมูลทรัพยากรแบบเต็มเท่านั้น
การบัฟเฟอร์ด้วยตนเอง ควบคุมได้เต็มรูปแบบ การจัดการข้อผิดพลาดที่ซับซ้อนเป็นความรับผิดชอบของเว็บไซต์

แอตทริบิวต์การโหลดวิดีโอล่วงหน้า

หากแหล่งที่มาของวิดีโอเป็นไฟล์ที่ไม่ซ้ำกันซึ่งโฮสต์ในเว็บเซิร์ฟเวอร์ คุณอาจต้องใช้แอตทริบิวต์ 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 และ Service Worker เรามาดูวิธีบัฟเฟอร์วิดีโอด้วย 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 รายการ เราอาจมีหน่วยความจำเพียงพอที่จะดึงข้อมูลไฟล์กลุ่มจากแต่ละรายการได้ แต่ไม่ควรสร้างองค์ประกอบ <video> ที่ซ่อนเร้นและออบเจ็กต์ MediaSource 10 รายการ แล้วเริ่มป้อนข้อมูลนั้น

ตัวอย่างแบบ 2 ส่วนด้านล่างแสดงวิธีแคชช่วงแรกของวิดีโอหลายช่วงไว้ล่วงหน้าโดยใช้ Cache API ที่มีประสิทธิภาพและใช้งานง่าย โปรดทราบว่าคุณทำสิ่งคล้ายๆ นี้ได้โดยใช้ IndexedDB เช่นกัน เรายังไม่ใช้ Service Worker เนื่องจากเข้าถึง 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;
    });
  });
}

โปรดทราบว่าหากฉันใช้คําขอ Range ของ HTTP ฉันจะต้องสร้างออบเจ็กต์ Response อีกครั้งด้วยตนเอง เนื่องจาก Cache API ยังไม่รองรับการตอบกลับ Range โปรดทราบว่าการเรียกใช้ networkResponse.arrayBuffer() จะดึงข้อมูลเนื้อหาทั้งหมดของคำตอบลงในหน่วยความจำของโปรแกรมแสดงผลพร้อมกัน คุณจึงอาจต้องใช้ช่วงเล็กๆ

เราได้แก้ไขตัวอย่างข้างต้นบางส่วนเพื่อบันทึกคำขอ 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.
      });
    }
  });
}

สร้างการตอบกลับแบบช่วงด้วย Service Worker

จะเกิดอะไรขึ้นหากคุณดึงข้อมูลไฟล์วิดีโอทั้งไฟล์และบันทึกไว้ใน Cache API เมื่อเบราว์เซอร์ส่งคำขอ Range HTTP คุณคงไม่ต้องการนำวิดีโอทั้งเรื่องไปไว้ในหน่วยความจำของโปรแกรมแสดงผล เนื่องจาก 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 สามารถใช้เพื่อละเว้นเวลาในการตอบสนองในฐานะตัวบ่งชี้ความเร็วของเครือข่าย

โปรดดูตัวอย่างแอปสื่ออย่างเป็นทางการและไฟล์ ranged-response.js โดยเฉพาะเพื่อดูวิธีจัดการRangeคำขออย่างครบวงจร