diff --git a/src/gap-skipper.js b/src/gap-skipper.js index 0f9820904..d570c0032 100644 --- a/src/gap-skipper.js +++ b/src/gap-skipper.js @@ -144,6 +144,49 @@ export default class GapSkipper { this.tech_.setCurrentTime(nextRange.start(0) + Ranges.TIME_FUDGE_FACTOR); } + gapFromVideoUnderflow_(buffered, currentTime) { + // At least in Chrome, if there is a gap in the video buffer, the audio will continue + // playing for ~3 seconds after the video gap starts. This is done to account for + // video buffer underflow/underrun (note that this is not done when there is audio + // buffer underflow/underrun -- in that case the video will stop as soon as it + // encounters the gap, as audio stalls are more noticeable/jarring to a user than + // video stalls). The player's time will reflect the playthrough of audio, so the + // time will appear as if we are in a buffered region, even if we are stuck in a + // "gap." + // + // Example: + // video buffer: 0 => 10.1, 10.2 => 20 + // audio buffer: 0 => 20 + // overall buffer: 0 => 10.1, 10.2 => 20 + // current time: 13 + // + // Chrome's video froze at 10 seconds, where the video buffer encountered the gap, + // however, the audio continued playing until it reached ~3 seconds past the gap + // (13 seconds), at which point it stops as well. Since current time is past the + // gap, findNextRange will return no ranges. + // + // To check for this issue, we see if there is a small gap that is somewhere within + // a 3 second range (3 seconds +/- 1 second) back from our current time. + let gaps = Ranges.findGaps(buffered); + + for (let i = 0; i < gaps.length; i++) { + let start = gaps.start(i); + let end = gaps.end(i); + + // gap is small + if (end - start < 1 && + // gap is 3 seconds back +/- 1 second + currentTime - start < 4 && currentTime - end > 2) { + return { + start, + end + }; + } + } + + return null; + } + /** * Set a timer to skip the unbuffered region. * @@ -154,8 +197,27 @@ export default class GapSkipper { let currentTime = this.tech_.currentTime(); let nextRange = Ranges.findNextRange(buffered, currentTime); - if (nextRange.length === 0 || - this.timer_ !== null) { + if (this.timer_ !== null) { + return; + } + + if (nextRange.length === 0) { + // Even if there is no available next range, there is still a possibility we are + // stuck in a gap due to video underflow. + let gap = this.gapFromVideoUnderflow_(buffered, currentTime); + + if (gap) { + this.logger_('setTimer_:', + 'Encountered a gap in video', + 'from: ', gap.start, + 'to: ', gap.end, + 'seeking to current time: ', currentTime); + // 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 + // (only suffering ~3 seconds of frozen video and a pause in audio playback). + this.tech_.setCurrentTime(currentTime); + } return; } diff --git a/src/ranges.js b/src/ranges.js index 926cb44a4..3df37b11c 100644 --- a/src/ranges.js +++ b/src/ranges.js @@ -51,8 +51,7 @@ const findRange = function(buffered, time) { }; /** - * Returns the TimeRanges that begin at or later than the specified - * time. + * Returns the TimeRanges that begin later than the specified time. * @param {TimeRanges} timeRanges - the TimeRanges object to query * @param {number} time - the time to filter on. * @returns {TimeRanges} a new TimeRanges object. @@ -63,6 +62,28 @@ const findNextRange = function(timeRanges, time) { }); }; +/** + * Returns gaps within a list of TimeRanges + * @param {TimeRanges} buffered - the TimeRanges object + * @return {TimeRanges} a TimeRanges object of gaps + */ +const findGaps = function(buffered) { + if (buffered.length < 2) { + return videojs.createTimeRanges(); + } + + let ranges = []; + + for (let i = 1; i < buffered.length; i++) { + let start = buffered.end(i - 1); + let end = buffered.start(i); + + ranges.push([start, end]); + } + + return videojs.createTimeRanges(ranges); +}; + /** * Search for a likely end time for the segment that was just appened * based on the state of the `buffered` property before and after the @@ -300,6 +321,7 @@ const getSegmentBufferedPercent = function(startOfSegment, export default { findRange, findNextRange, + findGaps, findSoleUncommonTimeRangesEnd, getSegmentBufferedPercent, TIME_FUDGE_FACTOR diff --git a/test/gap-skipper.test.js b/test/gap-skipper.test.js index 896947b75..73716d4d0 100644 --- a/test/gap-skipper.test.js +++ b/test/gap-skipper.test.js @@ -7,6 +7,7 @@ import { openMediaSource, standardXHRResponse } from './test-helpers.js'; +import GapSkipper from '../src/gap-skipper'; QUnit.module('GapSkipper', { beforeEach() { @@ -102,3 +103,111 @@ QUnit.test('skips over gap in chrome without waiting event', function() { 20, 'Player seeked over gap after timer'); }); + +QUnit.test('skips over gap in Chrome due to video underflow', function() { + this.player.autoplay(true); + + this.player.tech_.buffered = () => { + return videojs.createTimeRanges([[0, 10], [10.1, 20]]); + }; + + // 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); + + this.player.currentTime(13); + + let seeks = []; + + this.player.tech_.setCurrentTime = (time) => { + seeks.push(time); + }; + + for (let i = 0; i < 7; i++) { + this.player.tech_.trigger('timeupdate'); + } + + QUnit.equal(seeks.length, 1, 'one seek'); + QUnit.equal(seeks[0], 13, 'player seeked to current time'); +}); + +QUnit.module('GapSkipper isolated functions', { + beforeEach() { + this.gapSkipper = new GapSkipper({ + tech: { + on: () => {}, + off: () => {} + } + }); + } +}); + +QUnit.test('skips gap from video underflow', function() { + QUnit.equal( + this.gapSkipper.gapFromVideoUnderflow_(videojs.createTimeRanges(), 0), + null, + 'returns null when buffer is empty'); + QUnit.equal( + this.gapSkipper.gapFromVideoUnderflow_(videojs.createTimeRanges([[0, 10]]), 13), + null, + 'returns null when there is only a previous buffer'); + QUnit.equal( + this.gapSkipper.gapFromVideoUnderflow_( + videojs.createTimeRanges([[0, 10], [10.1, 20]]), 15), + null, + 'returns null when gap is too far from current time'); + QUnit.equal( + this.gapSkipper.gapFromVideoUnderflow_( + videojs.createTimeRanges([[0, 10], [10.1, 20]]), 9.9), + null, + 'returns null when gap is after current time'); + QUnit.equal( + this.gapSkipper.gapFromVideoUnderflow_( + videojs.createTimeRanges([[0, 10], [11.1, 20]]), 13), + null, + 'returns null when gap is too large'); + QUnit.equal( + this.gapSkipper.gapFromVideoUnderflow_( + videojs.createTimeRanges([[0, 10], [10.1, 20]]), 12.1), + null, + 'returns null when time is less than or euqal to 2 seconds ahead'); + QUnit.equal( + this.gapSkipper.gapFromVideoUnderflow_( + videojs.createTimeRanges([[0, 10], [10.1, 20]]), 14.1), + null, + 'returns null when time is greater than or equal to 4 seconds ahead'); + + QUnit.deepEqual( + this.gapSkipper.gapFromVideoUnderflow_( + videojs.createTimeRanges([[0, 10], [10.1, 20]]), 12.2), + {start: 10, end: 10.1}, + 'returns gap when gap is small and time is greater than 2 seconds ahead in a buffer'); + QUnit.deepEqual( + this.gapSkipper.gapFromVideoUnderflow_( + videojs.createTimeRanges([[0, 10], [10.1, 20]]), 13), + {start: 10, end: 10.1}, + 'returns gap when gap is small and time is 3 seconds ahead in a buffer'); + QUnit.deepEqual( + this.gapSkipper.gapFromVideoUnderflow_( + videojs.createTimeRanges([[0, 10], [10.1, 20]]), 13.9), + {start: 10, end: 10.1}, + 'returns gap when gap is small and time is less than 4 seconds ahead in a buffer'); + // In a case where current time is outside of the buffered range, something odd must've + // happened, but we should still allow the player to try to continue from that spot. + QUnit.deepEqual( + this.gapSkipper.gapFromVideoUnderflow_( + videojs.createTimeRanges([[0, 10], [10.1, 12.9]]), 13), + {start: 10, end: 10.1}, + 'returns gap even when current time is not in buffered range'); +}); diff --git a/test/ranges.test.js b/test/ranges.test.js index 0e1bb3365..11051350b 100644 --- a/test/ranges.test.js +++ b/test/ranges.test.js @@ -2,6 +2,25 @@ import Ranges from '../src/ranges'; import {createTimeRanges} from 'video.js'; import QUnit from 'qunit'; +let rangesEqual = (rangeOne, rangeTwo) => { + if (!rangeOne || !rangeTwo) { + return false; + } + + if (rangeOne.length !== rangeTwo.length) { + return false; + } + + for (let i = 0; i < rangeOne.length; i++) { + if (rangeOne.start(i) !== rangeTwo.start(i) || + rangeOne.end(i) !== rangeTwo.end(i)) { + return false; + } + } + + return true; +}; + QUnit.module('TimeRanges Utilities'); QUnit.test('finds the overlapping time range', function() { @@ -211,3 +230,53 @@ QUnit.test('calculates the percent buffered for segments ' + QUnit.equal(percentBuffered, 95, 'calculated the buffered amount correctly'); }); + +QUnit.test('finds next range', function() { + QUnit.equal(Ranges.findNextRange(createTimeRanges(), 10).length, + 0, + 'does not find next range in empty buffer'); + QUnit.equal(Ranges.findNextRange(createTimeRanges([[0, 20]]), 10).length, + 0, + 'does not find next range when no next ranges'); + QUnit.equal(Ranges.findNextRange(createTimeRanges([[0, 20]]), 30).length, + 0, + 'does not find next range when current time later than buffer'); + QUnit.equal(Ranges.findNextRange(createTimeRanges([[10, 20]]), 10).length, + 0, + 'does not find next range when current time is at beginning of buffer'); + QUnit.equal(Ranges.findNextRange(createTimeRanges([[10, 20]]), 11).length, + 0, + 'does not find next range when current time in middle of buffer'); + QUnit.equal(Ranges.findNextRange(createTimeRanges([[10, 20]]), 20).length, + 0, + 'does not find next range when current time is at end of buffer'); + + QUnit.ok(rangesEqual(Ranges.findNextRange(createTimeRanges([[10, 20]]), 0), + createTimeRanges([[10, 20]])), + 'finds next range when buffer comes after time'); + QUnit.ok(rangesEqual(Ranges.findNextRange(createTimeRanges([[10, 20], [25, 35]]), 22), + createTimeRanges([[25, 35]])), + 'finds next range when time between buffers'); + QUnit.ok(rangesEqual(Ranges.findNextRange(createTimeRanges([[10, 20], [25, 35]]), 15), + createTimeRanges([[25, 35]])), + 'finds next range when time in previous buffer'); +}); + +QUnit.test('finds gaps within ranges', function() { + QUnit.equal(Ranges.findGaps(createTimeRanges()).length, + 0, + 'does not find gap in empty buffer'); + QUnit.equal(Ranges.findGaps(createTimeRanges([[0, 10]])).length, + 0, + 'does not find gap in single buffer'); + QUnit.equal(Ranges.findGaps(createTimeRanges([[1, 10]])).length, + 0, + 'does not find gap at start of buffer'); + + QUnit.ok(rangesEqual(Ranges.findGaps(createTimeRanges([[0, 10], [11, 20]])), + createTimeRanges([[10, 11]])), + 'finds a single gap'); + QUnit.ok(rangesEqual(Ranges.findGaps(createTimeRanges([[0, 10], [11, 20], [22, 30]])), + createTimeRanges([[10, 11], [20, 22]])), + 'finds multiple gaps'); +});