requestVideoFrameCallback 的妙用

Heero.Luo发表于12小时前,已被查看12次

基于 HTML Video 元素的 Web 播放器,通常需要在播放卡顿时呈现加载中的交互。它的代码实现可能是这样的:

video.addEventListener('waiting', function() {
  console.info('show loading');
}, false);

video.addEventListener('playing', function() {
  console.info('hide loading');
}, false);

然而,这个方案是不可靠的,在移动设备播放 HLS(M3U8)直播流的场景下会有诸多问题:

  • 部分机型或浏览器在缓冲视频时不会触发 waiting,恢复播放后不会触发 playing。
  • 部分机型或浏览器在播放尚未恢复时就触发了 playing。

于是,就有了基于 timeupdate 事件的改良方案:

let timer;
function onTimeUpdate() {
  if (timer) { clearTimeout(timer); }
  console.info('hide loading');
  timer = setTimeout(function() {
    if (!video.paused) {
      console.info('show loading');            
    }
  }, 1000);
}

video.addEventListener('timeupdate', onTimeUpdate, false);

只要视频在播放,timeupdate 事件就会不断触发,从而清理上一次回调时创建的定时器,通过定时器设定的函数就不会执行。反之,只要 1 秒内没有触发 timeupdate 事件,通过定时器设定的函数就会执行,从而显示加载中的交互。

这个方案在大部分情况下是适用的。然而,在后来的一次通过代理进行的限速测试中发现:在某些 iOS 版本中,即使直播卡顿,timeupdate 仍在继续触发,从而导致加载中的交互没有显示。苦恼之际,我发现了 requestVideoFrameCallback。

requestVideoFrameCallback

requestVideoFrameCallback 是 HTML Video 元素的方法,它可以注册一个回调函数。该回调函数在一个新的视频帧发送到合成器时执行。

function callback() {
  console.info('requestVideoFrameCallback');
}
video.requestVideoFrameCallback(callback);

视频播放过程中,会不断产生新的视频帧。不过,通过 requestVideoFrameCallback 注册的回调函数只会触发一次。如果希望回调函数不断执行,就要不断注册。

function callbackAndRegisterNext(isFirst) {
  console.info('requestVideoFrameCallback');
  video.requestVideoFrameCallback(callbackAndRegisterNext);
}
video.requestVideoFrameCallback(callbackAndRegisterNext);

讲到这里,我们可以发现,与 timeupdate 方案的原理类似,通过在 requestVideoFrameCallback 注册的回调函数中设定定时执行的函数,也可以判断视频是否正在播放,从而显示或隐藏加载中的交互。

function checkPlayingByRVFC() {
  onTimeUpdate();
  video.requestVideoFrameCallback(checkPlaying);   
}
checkPlayingByRVFC();

然而,requestVideoFrameCallback 的兼容性相对较差,比如 iOS 最低支持版本是 15.4。甚至有些浏览器虽然表面上支持这个接口,但通过它注册的回调根本不会执行。因此,使用 requestVideoFrameCallback 方案前要先检查兼容性:

function canUseRVFC(video, cb) {
  if (video.requestVideoFrameCallback) {
    video.requestVideoFrameCallback(function() {
      // 触发过一次即为支持
      cb(true);
    });
  } else {
    cb(false);
  }
}

video.addEventListener('timeupdate', onTimeUpdate, false);

canUseRVFC(video, function(result) {
  if (result) {
    // 支持 requesVideoFrameCallback 就不需要用 timeupdate 方案了
    video.removeEventListener('timeupdate', onTimeUpdate, false);
    checkPlayingByRVFC();
  }
});

直播结束的检测

在直播场景下,HTML Video 元素的 ended 事件是不会触发的,这就需要开发者以其他方式去判断直播是否结束。

常用的方法是在后端维护直播状态,开播端开播时将其设为直播中,开播端下播后将其设为直播结束。前端通过轮询、SSE 或 WebSocket 获取该状态。然而,考虑到兼容性,移动 Web 端通常会采用 HLS 作为直播流协议,延迟通常会达到十几甚至几十秒。也就是说,开播端下播后,观众端也需要这么长的时间,才能播完剩下的内容。由于后端维护的直播状态是实时的,如果前端收到直播状态为结束时就掐断直播,剩下的这部分内容就无法播完。

根据主流云服务厂商的表现,直播结束后的短时间内,HLS 拉流地址就会不存在,返回 404 状态码。不过终端已加载的 ts 片仍然可以继续播放,直到播放完毕,画面就会卡住,然后黑屏。因此,关键点还是在于视频是否在播放。只有后端返回的直播状态是结束,视频也没有在播放,且拉流地址不存在,才可以判定为直播结束

let isStatusEnd;
let isPlaying;

// 检查拉流地址是否存在
// 由于拉流地址可能不会马上不存在,所以也要轮询
let videoSrcTimer;
async function checkVideoSrc() {
  if (videoSrcTimer) { clearTimeout(videoSrcTimer); }
  if (isStatusEnd && !isPlaying) {
    // 仅需获取状态码,用 HEAD 方法请求足矣
    const response = await fetch(video.src, { method: 'HEAD' });
    if (response.status === 404) {
      console.info('直播结束');
    } else {
      videoSrcTimer = setTimeout(checkStatus, 5 * 1000);
    }
  }
}

let rvfcTimer;
function checkEndedByRVFC() {
  if (rvfcTimer) { clearTimeout(rvfcTimer); }
  isPlaying = true;
  rvfcTimer = setTimeout(function() {
    isPlaying = false;
    checkVideoSrc(); // 直播不在播放时才去检查拉流地址
  }, 1000);
  video.requestVideoFrameCallback(checkEndedByRVFC);   
}

// 每 10s 查询一次后端的直播状态
async function checkStatus() {
  const response = await fetch('后端获取直播状态的接口');
  const result = await response.json();
  if (result.status === 'end') {
    isStatusEnd = true;
    checkEndedByRVFC(); // 接口返回结束时才检查视频是否在播放
  } else {
    setTimeout(checkStatus, 10 * 1000);
  }
}

checkStatus();

评论 (0条)

发表评论

(必填)

(选填,不公开)

(选填,不公开)

(必填)