作為保利威前端團隊的開發者,持續優化 Web 端的直播體驗是我們的核心目標。為此,我們深入研究了各種播放場景下的細節實現。本文將以兩個常見狀態的檢測為例,分享我們發現問題以及優化的過程。
基於 HTML Video 元素的直播播放器,通常需要在播放卡頓時呈現加載中的交互。
它的代碼實現可能是這樣的:
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();
總結
requestVideoFrameCallback 在支持的平台上確實能提供更準確的播放狀態反饋,是優化播放器體驗的重要方式。但其較差的兼容性要求我們必須有健壯的降級機制。
上述方案已在保利威直播播放器中穩定運行,有效處理了多種移動設備和複雜網絡環境下的交互問題。希望對遇到類似問題的朋友有所啓發。如果你有更好的思路或遇到其他“坑點”,歡迎在評論中交流。