From d0ef2983f1f9c799800eeba329446caa4d6d0336 Mon Sep 17 00:00:00 2001 From: Brandon Casey <2381475+brandonocasey@users.noreply.github.com> Date: Wed, 23 Sep 2020 13:31:30 -0400 Subject: [PATCH] fix: detect demuxed video underflow gaps (#948) --- src/playback-watcher.js | 50 ++++++++++++++++----- test/playback-watcher.test.js | 84 ++++++++++++++++++++++++++++++++--- test/test-helpers.js | 15 ++++++- 3 files changed, 130 insertions(+), 19 deletions(-) diff --git a/src/playback-watcher.js b/src/playback-watcher.js index 87a618a81..66d96f8e8 100644 --- a/src/playback-watcher.js +++ b/src/playback-watcher.js @@ -443,10 +443,15 @@ export default class PlaybackWatcher { return true; } + const sourceUpdater = this.tech_.vhs.masterPlaylistController_.sourceUpdater_; const buffered = this.tech_.buffered(); - const nextRange = Ranges.findNextRange(buffered, currentTime); + const videoUnderflow = this.videoUnderflow_({ + audioBuffered: sourceUpdater.audioBuffered(), + videoBuffered: sourceUpdater.videoBuffered(), + currentTime + }); - if (this.videoUnderflow_(nextRange, buffered, currentTime)) { + if (videoUnderflow) { // Even though the video underflowed and was stuck in a gap, the audio overplayed // the gap, leading currentTime into a buffered range. Seeking to currentTime // allows the video to catch up to the audio position without losing any audio @@ -459,6 +464,7 @@ export default class PlaybackWatcher { this.tech_.trigger({type: 'usage', name: 'hls-video-underflow'}); return true; } + const nextRange = Ranges.findNextRange(buffered, currentTime); // check for gap if (nextRange.length > 0) { @@ -512,18 +518,42 @@ export default class PlaybackWatcher { return false; } - videoUnderflow_(nextRange, buffered, currentTime) { - if (nextRange.length === 0) { + videoUnderflow_({videoBuffered, audioBuffered, currentTime}) { + // audio only content will not have video underflow :) + if (!videoBuffered) { + return; + } + let gap; + + // find a gap in demuxed content. + if (videoBuffered.length && audioBuffered.length) { + // in Chrome audio will continue to play for ~3s when we run out of video + // so we have to check that the video buffer did have some buffer in the + // past. + const lastVideoRange = Ranges.findRange(videoBuffered, currentTime - 3); + const videoRange = Ranges.findRange(videoBuffered, currentTime); + const audioRange = Ranges.findRange(audioBuffered, currentTime); + + if (audioRange.length && !videoRange.length && lastVideoRange.length) { + gap = {start: lastVideoRange.end(0), end: audioRange.end(0)}; + } + + // find a gap in muxed content. + } else { + const nextRange = Ranges.findNextRange(videoBuffered, currentTime); + // Even if there is no available next range, there is still a possibility we are // stuck in a gap due to video underflow. - const gap = this.gapFromVideoUnderflow_(buffered, currentTime); + if (!nextRange.length) { + gap = this.gapFromVideoUnderflow_(videoBuffered, currentTime); + } + } - if (gap) { - this.logger_(`Encountered a gap in video from ${gap.start} to ${gap.end}. ` + - `Seeking to current time ${currentTime}`); + if (gap) { + this.logger_(`Encountered a gap in video from ${gap.start} to ${gap.end}. ` + + `Seeking to current time ${currentTime}`); - return true; - } + return true; } return false; diff --git a/test/playback-watcher.test.js b/test/playback-watcher.test.js index 43a9a2852..64efa739c 100644 --- a/test/playback-watcher.test.js +++ b/test/playback-watcher.test.js @@ -151,7 +151,7 @@ QUnit.test('skips over gap in chrome without waiting event', function(assert) { assert.equal(hlsGapSkipEvents, 1, 'there is one skipped gap'); }); -QUnit.test('skips over gap in Chrome due to video underflow', function(assert) { +QUnit.test('skips over gap in Chrome due to muxed video underflow', function(assert) { let vhsVideoUnderflowEvents = 0; let hlsVideoUnderflowEvents = 0; @@ -166,10 +166,61 @@ QUnit.test('skips over gap in Chrome due to video underflow', function(assert) { } }); - this.player.tech_.buffered = () => { + // set an arbitrary source + this.player.src({ + src: 'master.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + // start playback normally + this.player.tech_.triggerReady(); + this.clock.tick(1); + standardXHRResponse(this.requests.shift()); + openMediaSource(this.player, this.clock); + this.player.tech_.trigger('play'); + this.player.tech_.trigger('playing'); + this.clock.tick(1); + + assert.equal(vhsVideoUnderflowEvents, 0, 'no video underflow event got triggered'); + assert.equal(hlsVideoUnderflowEvents, 0, 'no video underflow event got triggered'); + + const mpc = this.player.tech_.vhs.masterPlaylistController_; + + mpc.sourceUpdater_.videoBuffered = () => { return videojs.createTimeRanges([[0, 10], [10.1, 20]]); }; + this.player.currentTime(13); + + const seeks = []; + + this.player.tech_.setCurrentTime = (time) => { + seeks.push(time); + }; + + this.player.tech_.trigger('waiting'); + + assert.equal(seeks.length, 1, 'one seek'); + assert.equal(seeks[0], 13, 'player seeked to current time'); + assert.equal(vhsVideoUnderflowEvents, 1, 'triggered a video underflow event'); + assert.equal(hlsVideoUnderflowEvents, 1, 'triggered a video underflow event'); +}); + +QUnit.test('skips over gap in Chrome due to demuxed video underflow', function(assert) { + let vhsVideoUnderflowEvents = 0; + let hlsVideoUnderflowEvents = 0; + + this.player.autoplay(true); + + this.player.tech_.on('usage', (event) => { + if (event.name === 'vhs-video-underflow') { + vhsVideoUnderflowEvents++; + } + if (event.name === 'hls-video-underflow') { + hlsVideoUnderflowEvents++; + } + }); + // set an arbitrary source this.player.src({ src: 'master.m3u8', @@ -188,7 +239,17 @@ QUnit.test('skips over gap in Chrome due to video underflow', function(assert) { assert.equal(vhsVideoUnderflowEvents, 0, 'no video underflow event got triggered'); assert.equal(hlsVideoUnderflowEvents, 0, 'no video underflow event got triggered'); - this.player.currentTime(13); + const mpc = this.player.tech_.vhs.masterPlaylistController_; + + mpc.sourceUpdater_.videoBuffered = () => { + return videojs.createTimeRanges([[0, 15]]); + }; + + mpc.sourceUpdater_.audioBuffered = () => { + return videojs.createTimeRanges([[0, 20]]); + }; + + this.player.currentTime(18); const seeks = []; @@ -199,7 +260,7 @@ QUnit.test('skips over gap in Chrome due to video underflow', function(assert) { this.player.tech_.trigger('waiting'); assert.equal(seeks.length, 1, 'one seek'); - assert.equal(seeks[0], 13, 'player seeked to current time'); + assert.equal(seeks[0], 18, 'player seeked to current time'); assert.equal(vhsVideoUnderflowEvents, 1, 'triggered a video underflow event'); assert.equal(hlsVideoUnderflowEvents, 1, 'triggered a video underflow event'); }); @@ -429,7 +490,7 @@ QUnit.test('fires notifications when activated', function(assert) { this.player.tech_.currentTime = function() { return currentTime; }; - this.player.tech_.buffered = function() { + this.player.tech_.vhs.masterPlaylistController_.sourceUpdater_.videoBuffered = function() { return { length: buffered.length, start(i) { @@ -570,7 +631,16 @@ QUnit.test('corrects seek outside of seekable', function(assert) { currentTime: () => currentTime, // mocked out paused: () => false, - buffered: () => videojs.createTimeRanges() + buffered: () => videojs.createTimeRanges(), + trigger: () => {}, + vhs: { + masterPlaylistController_: { + sourceUpdater_: { + videoBuffered: () => {}, + audioBuffered: () => {} + } + } + } }; // waiting @@ -1078,7 +1148,7 @@ QUnit.module('PlaybackWatcher isolated functions', { } }); -QUnit.test('skips gap from video underflow', function(assert) { +QUnit.test('skips gap from muxed video underflow', function(assert) { assert.equal( this.playbackWatcher.gapFromVideoUnderflow_(videojs.createTimeRanges(), 0), null, diff --git a/test/test-helpers.js b/test/test-helpers.js index c562956b3..b352662dc 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -183,13 +183,24 @@ export const useFakeEnvironment = function(assert) { if (this.log && this.log[level] && this.log[level].restore) { if (assert) { const calls = (this.log[level].args || []).map((args) => { - return args.join(', '); + return args.reduce((acc, val) => { + if (acc) { + acc += ', '; + } + + acc += val; + + if (val.stack) { + acc += '\n' + val.stack; + } + return acc; + }, ''); }).join('\n '); assert.equal( this.log[level].callCount, 0, - 'no unexpected logs at level "' + level + '":\n ' + calls + 'no unexpected logs at level "' + level + '":\n' + calls ); } this.log[level].restore();