398cb435 by Garrett Committed by Jon-Carlos Rivera

Skip gaps caused by video underflow (#774)

* Skip gaps caused by video underflow
In the event that there is a small gap in the video buffer, Chrome will consider it video underflow, and will continue playing audio for ~3 seconds while video remains frozen. The current time of the player will update to reflect the audio playback. Due to the audio overplay, the gap skipper will think we are in a buffered region, and will not skip over the gap. This adds support for skipping over gaps caused by a browser's interpretation of video underflow.
1 parent d4da70c7
......@@ -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;
}
......
......@@ -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.
......@@ -64,6 +63,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
* append. If we fin only one such uncommon end-point return it.
......@@ -300,6 +321,7 @@ const getSegmentBufferedPercent = function(startOfSegment,
export default {
findRange,
findNextRange,
findGaps,
findSoleUncommonTimeRangesEnd,
getSegmentBufferedPercent,
TIME_FUDGE_FACTOR
......
......@@ -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');
});
......
......@@ -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');
});
......