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 { ...@@ -144,6 +144,49 @@ export default class GapSkipper {
144 this.tech_.setCurrentTime(nextRange.start(0) + Ranges.TIME_FUDGE_FACTOR); 144 this.tech_.setCurrentTime(nextRange.start(0) + Ranges.TIME_FUDGE_FACTOR);
145 } 145 }
146 146
147 gapFromVideoUnderflow_(buffered, currentTime) {
148 // At least in Chrome, if there is a gap in the video buffer, the audio will continue
149 // playing for ~3 seconds after the video gap starts. This is done to account for
150 // video buffer underflow/underrun (note that this is not done when there is audio
151 // buffer underflow/underrun -- in that case the video will stop as soon as it
152 // encounters the gap, as audio stalls are more noticeable/jarring to a user than
153 // video stalls). The player's time will reflect the playthrough of audio, so the
154 // time will appear as if we are in a buffered region, even if we are stuck in a
155 // "gap."
156 //
157 // Example:
158 // video buffer: 0 => 10.1, 10.2 => 20
159 // audio buffer: 0 => 20
160 // overall buffer: 0 => 10.1, 10.2 => 20
161 // current time: 13
162 //
163 // Chrome's video froze at 10 seconds, where the video buffer encountered the gap,
164 // however, the audio continued playing until it reached ~3 seconds past the gap
165 // (13 seconds), at which point it stops as well. Since current time is past the
166 // gap, findNextRange will return no ranges.
167 //
168 // To check for this issue, we see if there is a small gap that is somewhere within
169 // a 3 second range (3 seconds +/- 1 second) back from our current time.
170 let gaps = Ranges.findGaps(buffered);
171
172 for (let i = 0; i < gaps.length; i++) {
173 let start = gaps.start(i);
174 let end = gaps.end(i);
175
176 // gap is small
177 if (end - start < 1 &&
178 // gap is 3 seconds back +/- 1 second
179 currentTime - start < 4 && currentTime - end > 2) {
180 return {
181 start,
182 end
183 };
184 }
185 }
186
187 return null;
188 }
189
147 /** 190 /**
148 * Set a timer to skip the unbuffered region. 191 * Set a timer to skip the unbuffered region.
149 * 192 *
...@@ -154,8 +197,27 @@ export default class GapSkipper { ...@@ -154,8 +197,27 @@ export default class GapSkipper {
154 let currentTime = this.tech_.currentTime(); 197 let currentTime = this.tech_.currentTime();
155 let nextRange = Ranges.findNextRange(buffered, currentTime); 198 let nextRange = Ranges.findNextRange(buffered, currentTime);
156 199
157 if (nextRange.length === 0 || 200 if (this.timer_ !== null) {
158 this.timer_ !== null) { 201 return;
202 }
203
204 if (nextRange.length === 0) {
205 // Even if there is no available next range, there is still a possibility we are
206 // stuck in a gap due to video underflow.
207 let gap = this.gapFromVideoUnderflow_(buffered, currentTime);
208
209 if (gap) {
210 this.logger_('setTimer_:',
211 'Encountered a gap in video',
212 'from: ', gap.start,
213 'to: ', gap.end,
214 'seeking to current time: ', currentTime);
215 // Even though the video underflowed and was stuck in a gap, the audio overplayed
216 // the gap, leading currentTime into a buffered range. Seeking to currentTime
217 // allows the video to catch up to the audio position without losing any audio
218 // (only suffering ~3 seconds of frozen video and a pause in audio playback).
219 this.tech_.setCurrentTime(currentTime);
220 }
159 return; 221 return;
160 } 222 }
161 223
......
...@@ -51,8 +51,7 @@ const findRange = function(buffered, time) { ...@@ -51,8 +51,7 @@ const findRange = function(buffered, time) {
51 }; 51 };
52 52
53 /** 53 /**
54 * Returns the TimeRanges that begin at or later than the specified 54 * Returns the TimeRanges that begin later than the specified time.
55 * time.
56 * @param {TimeRanges} timeRanges - the TimeRanges object to query 55 * @param {TimeRanges} timeRanges - the TimeRanges object to query
57 * @param {number} time - the time to filter on. 56 * @param {number} time - the time to filter on.
58 * @returns {TimeRanges} a new TimeRanges object. 57 * @returns {TimeRanges} a new TimeRanges object.
...@@ -64,6 +63,28 @@ const findNextRange = function(timeRanges, time) { ...@@ -64,6 +63,28 @@ const findNextRange = function(timeRanges, time) {
64 }; 63 };
65 64
66 /** 65 /**
66 * Returns gaps within a list of TimeRanges
67 * @param {TimeRanges} buffered - the TimeRanges object
68 * @return {TimeRanges} a TimeRanges object of gaps
69 */
70 const findGaps = function(buffered) {
71 if (buffered.length < 2) {
72 return videojs.createTimeRanges();
73 }
74
75 let ranges = [];
76
77 for (let i = 1; i < buffered.length; i++) {
78 let start = buffered.end(i - 1);
79 let end = buffered.start(i);
80
81 ranges.push([start, end]);
82 }
83
84 return videojs.createTimeRanges(ranges);
85 };
86
87 /**
67 * Search for a likely end time for the segment that was just appened 88 * Search for a likely end time for the segment that was just appened
68 * based on the state of the `buffered` property before and after the 89 * based on the state of the `buffered` property before and after the
69 * append. If we fin only one such uncommon end-point return it. 90 * append. If we fin only one such uncommon end-point return it.
...@@ -300,6 +321,7 @@ const getSegmentBufferedPercent = function(startOfSegment, ...@@ -300,6 +321,7 @@ const getSegmentBufferedPercent = function(startOfSegment,
300 export default { 321 export default {
301 findRange, 322 findRange,
302 findNextRange, 323 findNextRange,
324 findGaps,
303 findSoleUncommonTimeRangesEnd, 325 findSoleUncommonTimeRangesEnd,
304 getSegmentBufferedPercent, 326 getSegmentBufferedPercent,
305 TIME_FUDGE_FACTOR 327 TIME_FUDGE_FACTOR
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
7 openMediaSource, 7 openMediaSource,
8 standardXHRResponse 8 standardXHRResponse
9 } from './test-helpers.js'; 9 } from './test-helpers.js';
10 import GapSkipper from '../src/gap-skipper';
10 11
11 QUnit.module('GapSkipper', { 12 QUnit.module('GapSkipper', {
12 beforeEach() { 13 beforeEach() {
...@@ -102,3 +103,111 @@ QUnit.test('skips over gap in chrome without waiting event', function() { ...@@ -102,3 +103,111 @@ QUnit.test('skips over gap in chrome without waiting event', function() {
102 20, 'Player seeked over gap after timer'); 103 20, 'Player seeked over gap after timer');
103 104
104 }); 105 });
106
107 QUnit.test('skips over gap in Chrome due to video underflow', function() {
108 this.player.autoplay(true);
109
110 this.player.tech_.buffered = () => {
111 return videojs.createTimeRanges([[0, 10], [10.1, 20]]);
112 };
113
114 // set an arbitrary source
115 this.player.src({
116 src: 'master.m3u8',
117 type: 'application/vnd.apple.mpegurl'
118 });
119
120 // start playback normally
121 this.player.tech_.triggerReady();
122 this.clock.tick(1);
123 standardXHRResponse(this.requests.shift());
124 openMediaSource(this.player, this.clock);
125 this.player.tech_.trigger('play');
126 this.player.tech_.trigger('playing');
127 this.clock.tick(1);
128
129 this.player.currentTime(13);
130
131 let seeks = [];
132
133 this.player.tech_.setCurrentTime = (time) => {
134 seeks.push(time);
135 };
136
137 for (let i = 0; i < 7; i++) {
138 this.player.tech_.trigger('timeupdate');
139 }
140
141 QUnit.equal(seeks.length, 1, 'one seek');
142 QUnit.equal(seeks[0], 13, 'player seeked to current time');
143 });
144
145 QUnit.module('GapSkipper isolated functions', {
146 beforeEach() {
147 this.gapSkipper = new GapSkipper({
148 tech: {
149 on: () => {},
150 off: () => {}
151 }
152 });
153 }
154 });
155
156 QUnit.test('skips gap from video underflow', function() {
157 QUnit.equal(
158 this.gapSkipper.gapFromVideoUnderflow_(videojs.createTimeRanges(), 0),
159 null,
160 'returns null when buffer is empty');
161 QUnit.equal(
162 this.gapSkipper.gapFromVideoUnderflow_(videojs.createTimeRanges([[0, 10]]), 13),
163 null,
164 'returns null when there is only a previous buffer');
165 QUnit.equal(
166 this.gapSkipper.gapFromVideoUnderflow_(
167 videojs.createTimeRanges([[0, 10], [10.1, 20]]), 15),
168 null,
169 'returns null when gap is too far from current time');
170 QUnit.equal(
171 this.gapSkipper.gapFromVideoUnderflow_(
172 videojs.createTimeRanges([[0, 10], [10.1, 20]]), 9.9),
173 null,
174 'returns null when gap is after current time');
175 QUnit.equal(
176 this.gapSkipper.gapFromVideoUnderflow_(
177 videojs.createTimeRanges([[0, 10], [11.1, 20]]), 13),
178 null,
179 'returns null when gap is too large');
180 QUnit.equal(
181 this.gapSkipper.gapFromVideoUnderflow_(
182 videojs.createTimeRanges([[0, 10], [10.1, 20]]), 12.1),
183 null,
184 'returns null when time is less than or euqal to 2 seconds ahead');
185 QUnit.equal(
186 this.gapSkipper.gapFromVideoUnderflow_(
187 videojs.createTimeRanges([[0, 10], [10.1, 20]]), 14.1),
188 null,
189 'returns null when time is greater than or equal to 4 seconds ahead');
190
191 QUnit.deepEqual(
192 this.gapSkipper.gapFromVideoUnderflow_(
193 videojs.createTimeRanges([[0, 10], [10.1, 20]]), 12.2),
194 {start: 10, end: 10.1},
195 'returns gap when gap is small and time is greater than 2 seconds ahead in a buffer');
196 QUnit.deepEqual(
197 this.gapSkipper.gapFromVideoUnderflow_(
198 videojs.createTimeRanges([[0, 10], [10.1, 20]]), 13),
199 {start: 10, end: 10.1},
200 'returns gap when gap is small and time is 3 seconds ahead in a buffer');
201 QUnit.deepEqual(
202 this.gapSkipper.gapFromVideoUnderflow_(
203 videojs.createTimeRanges([[0, 10], [10.1, 20]]), 13.9),
204 {start: 10, end: 10.1},
205 'returns gap when gap is small and time is less than 4 seconds ahead in a buffer');
206 // In a case where current time is outside of the buffered range, something odd must've
207 // happened, but we should still allow the player to try to continue from that spot.
208 QUnit.deepEqual(
209 this.gapSkipper.gapFromVideoUnderflow_(
210 videojs.createTimeRanges([[0, 10], [10.1, 12.9]]), 13),
211 {start: 10, end: 10.1},
212 'returns gap even when current time is not in buffered range');
213 });
......
...@@ -2,6 +2,25 @@ import Ranges from '../src/ranges'; ...@@ -2,6 +2,25 @@ import Ranges from '../src/ranges';
2 import {createTimeRanges} from 'video.js'; 2 import {createTimeRanges} from 'video.js';
3 import QUnit from 'qunit'; 3 import QUnit from 'qunit';
4 4
5 let rangesEqual = (rangeOne, rangeTwo) => {
6 if (!rangeOne || !rangeTwo) {
7 return false;
8 }
9
10 if (rangeOne.length !== rangeTwo.length) {
11 return false;
12 }
13
14 for (let i = 0; i < rangeOne.length; i++) {
15 if (rangeOne.start(i) !== rangeTwo.start(i) ||
16 rangeOne.end(i) !== rangeTwo.end(i)) {
17 return false;
18 }
19 }
20
21 return true;
22 };
23
5 QUnit.module('TimeRanges Utilities'); 24 QUnit.module('TimeRanges Utilities');
6 25
7 QUnit.test('finds the overlapping time range', function() { 26 QUnit.test('finds the overlapping time range', function() {
...@@ -211,3 +230,53 @@ QUnit.test('calculates the percent buffered for segments ' + ...@@ -211,3 +230,53 @@ QUnit.test('calculates the percent buffered for segments ' +
211 230
212 QUnit.equal(percentBuffered, 95, 'calculated the buffered amount correctly'); 231 QUnit.equal(percentBuffered, 95, 'calculated the buffered amount correctly');
213 }); 232 });
233
234 QUnit.test('finds next range', function() {
235 QUnit.equal(Ranges.findNextRange(createTimeRanges(), 10).length,
236 0,
237 'does not find next range in empty buffer');
238 QUnit.equal(Ranges.findNextRange(createTimeRanges([[0, 20]]), 10).length,
239 0,
240 'does not find next range when no next ranges');
241 QUnit.equal(Ranges.findNextRange(createTimeRanges([[0, 20]]), 30).length,
242 0,
243 'does not find next range when current time later than buffer');
244 QUnit.equal(Ranges.findNextRange(createTimeRanges([[10, 20]]), 10).length,
245 0,
246 'does not find next range when current time is at beginning of buffer');
247 QUnit.equal(Ranges.findNextRange(createTimeRanges([[10, 20]]), 11).length,
248 0,
249 'does not find next range when current time in middle of buffer');
250 QUnit.equal(Ranges.findNextRange(createTimeRanges([[10, 20]]), 20).length,
251 0,
252 'does not find next range when current time is at end of buffer');
253
254 QUnit.ok(rangesEqual(Ranges.findNextRange(createTimeRanges([[10, 20]]), 0),
255 createTimeRanges([[10, 20]])),
256 'finds next range when buffer comes after time');
257 QUnit.ok(rangesEqual(Ranges.findNextRange(createTimeRanges([[10, 20], [25, 35]]), 22),
258 createTimeRanges([[25, 35]])),
259 'finds next range when time between buffers');
260 QUnit.ok(rangesEqual(Ranges.findNextRange(createTimeRanges([[10, 20], [25, 35]]), 15),
261 createTimeRanges([[25, 35]])),
262 'finds next range when time in previous buffer');
263 });
264
265 QUnit.test('finds gaps within ranges', function() {
266 QUnit.equal(Ranges.findGaps(createTimeRanges()).length,
267 0,
268 'does not find gap in empty buffer');
269 QUnit.equal(Ranges.findGaps(createTimeRanges([[0, 10]])).length,
270 0,
271 'does not find gap in single buffer');
272 QUnit.equal(Ranges.findGaps(createTimeRanges([[1, 10]])).length,
273 0,
274 'does not find gap at start of buffer');
275
276 QUnit.ok(rangesEqual(Ranges.findGaps(createTimeRanges([[0, 10], [11, 20]])),
277 createTimeRanges([[10, 11]])),
278 'finds a single gap');
279 QUnit.ok(rangesEqual(Ranges.findGaps(createTimeRanges([[0, 10], [11, 20], [22, 30]])),
280 createTimeRanges([[10, 11], [20, 22]])),
281 'finds multiple gaps');
282 });
......