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

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

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

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