UNPKG

16.4 kBJavaScriptView Raw
1/**
2 * @file playback-watcher.js
3 *
4 * Playback starts, and now my watch begins. It shall not end until my death. I shall
5 * take no wait, hold no uncleared timeouts, father no bad seeks. I shall wear no crowns
6 * and win no glory. I shall live and die at my post. I am the corrector of the underflow.
7 * I am the watcher of gaps. I am the shield that guards the realms of seekable. I pledge
8 * my life and honor to the Playback Watch, for this Player and all the Players to come.
9 */
10
11'use strict';
12
13Object.defineProperty(exports, '__esModule', {
14 value: true
15});
16
17var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })();
18
19function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
20
21function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } }
22
23var _globalWindow = require('global/window');
24
25var _globalWindow2 = _interopRequireDefault(_globalWindow);
26
27var _ranges = require('./ranges');
28
29var _ranges2 = _interopRequireDefault(_ranges);
30
31var _videoJs = require('video.js');
32
33var _videoJs2 = _interopRequireDefault(_videoJs);
34
35// Set of events that reset the playback-watcher time check logic and clear the timeout
36var timerCancelEvents = ['seeking', 'seeked', 'pause', 'playing', 'error'];
37
38/**
39 * @class PlaybackWatcher
40 */
41
42var PlaybackWatcher = (function () {
43 /**
44 * Represents an PlaybackWatcher object.
45 * @constructor
46 * @param {object} options an object that includes the tech and settings
47 */
48
49 function PlaybackWatcher(options) {
50 var _this = this;
51
52 _classCallCheck(this, PlaybackWatcher);
53
54 this.tech_ = options.tech;
55 this.seekable = options.seekable;
56
57 this.consecutiveUpdates = 0;
58 this.lastRecordedTime = null;
59 this.timer_ = null;
60 this.checkCurrentTimeTimeout_ = null;
61
62 if (options.debug) {
63 this.logger_ = _videoJs2['default'].log.bind(_videoJs2['default'], 'playback-watcher ->');
64 }
65 this.logger_('initialize');
66
67 var canPlayHandler = function canPlayHandler() {
68 return _this.monitorCurrentTime_();
69 };
70 var waitingHandler = function waitingHandler() {
71 return _this.techWaiting_();
72 };
73 var cancelTimerHandler = function cancelTimerHandler() {
74 return _this.cancelTimer_();
75 };
76 var fixesBadSeeksHandler = function fixesBadSeeksHandler() {
77 return _this.fixesBadSeeks_();
78 };
79
80 this.tech_.on('seekablechanged', fixesBadSeeksHandler);
81 this.tech_.on('waiting', waitingHandler);
82 this.tech_.on(timerCancelEvents, cancelTimerHandler);
83 this.tech_.on('canplay', canPlayHandler);
84
85 // Define the dispose function to clean up our events
86 this.dispose = function () {
87 _this.logger_('dispose');
88 _this.tech_.off('seekablechanged', fixesBadSeeksHandler);
89 _this.tech_.off('waiting', waitingHandler);
90 _this.tech_.off(timerCancelEvents, cancelTimerHandler);
91 _this.tech_.off('canplay', canPlayHandler);
92 if (_this.checkCurrentTimeTimeout_) {
93 _globalWindow2['default'].clearTimeout(_this.checkCurrentTimeTimeout_);
94 }
95 _this.cancelTimer_();
96 };
97 }
98
99 /**
100 * Periodically check current time to see if playback stopped
101 *
102 * @private
103 */
104
105 _createClass(PlaybackWatcher, [{
106 key: 'monitorCurrentTime_',
107 value: function monitorCurrentTime_() {
108 this.checkCurrentTime_();
109
110 if (this.checkCurrentTimeTimeout_) {
111 _globalWindow2['default'].clearTimeout(this.checkCurrentTimeTimeout_);
112 }
113
114 // 42 = 24 fps // 250 is what Webkit uses // FF uses 15
115 this.checkCurrentTimeTimeout_ = _globalWindow2['default'].setTimeout(this.monitorCurrentTime_.bind(this), 250);
116 }
117
118 /**
119 * The purpose of this function is to emulate the "waiting" event on
120 * browsers that do not emit it when they are waiting for more
121 * data to continue playback
122 *
123 * @private
124 */
125 }, {
126 key: 'checkCurrentTime_',
127 value: function checkCurrentTime_() {
128 if (this.tech_.seeking() && this.fixesBadSeeks_()) {
129 this.consecutiveUpdates = 0;
130 this.lastRecordedTime = this.tech_.currentTime();
131 return;
132 }
133
134 if (this.tech_.paused() || this.tech_.seeking()) {
135 return;
136 }
137
138 var currentTime = this.tech_.currentTime();
139 var buffered = this.tech_.buffered();
140
141 if (this.lastRecordedTime === currentTime && (!buffered.length || currentTime + _ranges2['default'].SAFE_TIME_DELTA >= buffered.end(buffered.length - 1))) {
142 // If current time is at the end of the final buffered region, then any playback
143 // stall is most likely caused by buffering in a low bandwidth environment. The tech
144 // should fire a `waiting` event in this scenario, but due to browser and tech
145 // inconsistencies (e.g. The Flash tech does not fire a `waiting` event when the end
146 // of the buffer is reached and has fallen off the live window). Calling
147 // `techWaiting_` here allows us to simulate responding to a native `waiting` event
148 // when the tech fails to emit one.
149 return this.techWaiting_();
150 }
151
152 if (this.consecutiveUpdates >= 5 && currentTime === this.lastRecordedTime) {
153 this.consecutiveUpdates++;
154 this.waiting_();
155 } else if (currentTime === this.lastRecordedTime) {
156 this.consecutiveUpdates++;
157 } else {
158 this.consecutiveUpdates = 0;
159 this.lastRecordedTime = currentTime;
160 }
161 }
162
163 /**
164 * Cancels any pending timers and resets the 'timeupdate' mechanism
165 * designed to detect that we are stalled
166 *
167 * @private
168 */
169 }, {
170 key: 'cancelTimer_',
171 value: function cancelTimer_() {
172 this.consecutiveUpdates = 0;
173
174 if (this.timer_) {
175 this.logger_('cancelTimer_');
176 clearTimeout(this.timer_);
177 }
178
179 this.timer_ = null;
180 }
181
182 /**
183 * Fixes situations where there's a bad seek
184 *
185 * @return {Boolean} whether an action was taken to fix the seek
186 * @private
187 */
188 }, {
189 key: 'fixesBadSeeks_',
190 value: function fixesBadSeeks_() {
191 var seeking = this.tech_.seeking();
192 var seekable = this.seekable();
193 var currentTime = this.tech_.currentTime();
194 var seekTo = undefined;
195
196 if (seeking && this.afterSeekableWindow_(seekable, currentTime)) {
197 var seekableEnd = seekable.end(seekable.length - 1);
198
199 // sync to live point (if VOD, our seekable was updated and we're simply adjusting)
200 seekTo = seekableEnd;
201 }
202
203 if (seeking && this.beforeSeekableWindow_(seekable, currentTime)) {
204 var seekableStart = seekable.start(0);
205
206 // sync to the beginning of the live window
207 // provide a buffer of .1 seconds to handle rounding/imprecise numbers
208 seekTo = seekableStart + _ranges2['default'].SAFE_TIME_DELTA;
209 }
210
211 if (typeof seekTo !== 'undefined') {
212 this.logger_('Trying to seek outside of seekable at time ' + currentTime + ' with ' + ('seekable range ' + _ranges2['default'].printableRange(seekable) + '. Seeking to ') + (seekTo + '.'));
213
214 this.tech_.setCurrentTime(seekTo);
215 return true;
216 }
217
218 return false;
219 }
220
221 /**
222 * Handler for situations when we determine the player is waiting.
223 *
224 * @private
225 */
226 }, {
227 key: 'waiting_',
228 value: function waiting_() {
229 if (this.techWaiting_()) {
230 return;
231 }
232
233 // All tech waiting checks failed. Use last resort correction
234 var currentTime = this.tech_.currentTime();
235 var buffered = this.tech_.buffered();
236 var currentRange = _ranges2['default'].findRange(buffered, currentTime);
237
238 // Sometimes the player can stall for unknown reasons within a contiguous buffered
239 // region with no indication that anything is amiss (seen in Firefox). Seeking to
240 // currentTime is usually enough to kickstart the player. This checks that the player
241 // is currently within a buffered region before attempting a corrective seek.
242 // Chrome does not appear to continue `timeupdate` events after a `waiting` event
243 // until there is ~ 3 seconds of forward buffer available. PlaybackWatcher should also
244 // make sure there is ~3 seconds of forward buffer before taking any corrective action
245 // to avoid triggering an `unknownwaiting` event when the network is slow.
246 if (currentRange.length && currentTime + 3 <= currentRange.end(0)) {
247 this.cancelTimer_();
248 this.tech_.setCurrentTime(currentTime);
249
250 this.logger_('Stopped at ' + currentTime + ' while inside a buffered region ' + ('[' + currentRange.start(0) + ' -> ' + currentRange.end(0) + ']. Attempting to resume ') + 'playback by seeking to the current time.');
251
252 // unknown waiting corrections may be useful for monitoring QoS
253 this.tech_.trigger({ type: 'usage', name: 'hls-unknown-waiting' });
254 return;
255 }
256 }
257
258 /**
259 * Handler for situations when the tech fires a `waiting` event
260 *
261 * @return {Boolean}
262 * True if an action (or none) was needed to correct the waiting. False if no
263 * checks passed
264 * @private
265 */
266 }, {
267 key: 'techWaiting_',
268 value: function techWaiting_() {
269 var seekable = this.seekable();
270 var currentTime = this.tech_.currentTime();
271
272 if (this.tech_.seeking() && this.fixesBadSeeks_()) {
273 // Tech is seeking or bad seek fixed, no action needed
274 return true;
275 }
276
277 if (this.tech_.seeking() || this.timer_ !== null) {
278 // Tech is seeking or already waiting on another action, no action needed
279 return true;
280 }
281
282 if (this.beforeSeekableWindow_(seekable, currentTime)) {
283 var livePoint = seekable.end(seekable.length - 1);
284
285 this.logger_('Fell out of live window at time ' + currentTime + '. Seeking to ' + ('live point (seekable end) ' + livePoint));
286 this.cancelTimer_();
287 this.tech_.setCurrentTime(livePoint);
288
289 // live window resyncs may be useful for monitoring QoS
290 this.tech_.trigger({ type: 'usage', name: 'hls-live-resync' });
291 return true;
292 }
293
294 var buffered = this.tech_.buffered();
295 var nextRange = _ranges2['default'].findNextRange(buffered, currentTime);
296
297 if (this.videoUnderflow_(nextRange, buffered, currentTime)) {
298 // Even though the video underflowed and was stuck in a gap, the audio overplayed
299 // the gap, leading currentTime into a buffered range. Seeking to currentTime
300 // allows the video to catch up to the audio position without losing any audio
301 // (only suffering ~3 seconds of frozen video and a pause in audio playback).
302 this.cancelTimer_();
303 this.tech_.setCurrentTime(currentTime);
304
305 // video underflow may be useful for monitoring QoS
306 this.tech_.trigger({ type: 'usage', name: 'hls-video-underflow' });
307 return true;
308 }
309
310 // check for gap
311 if (nextRange.length > 0) {
312 var difference = nextRange.start(0) - currentTime;
313
314 this.logger_('Stopped at ' + currentTime + ', setting timer for ' + difference + ', seeking ' + ('to ' + nextRange.start(0)));
315
316 this.timer_ = setTimeout(this.skipTheGap_.bind(this), difference * 1000, currentTime);
317 return true;
318 }
319
320 // All checks failed. Returning false to indicate failure to correct waiting
321 return false;
322 }
323 }, {
324 key: 'afterSeekableWindow_',
325 value: function afterSeekableWindow_(seekable, currentTime) {
326 if (!seekable.length) {
327 // we can't make a solid case if there's no seekable, default to false
328 return false;
329 }
330
331 if (currentTime > seekable.end(seekable.length - 1) + _ranges2['default'].SAFE_TIME_DELTA) {
332 return true;
333 }
334
335 return false;
336 }
337 }, {
338 key: 'beforeSeekableWindow_',
339 value: function beforeSeekableWindow_(seekable, currentTime) {
340 if (seekable.length &&
341 // can't fall before 0 and 0 seekable start identifies VOD stream
342 seekable.start(0) > 0 && currentTime < seekable.start(0) - _ranges2['default'].SAFE_TIME_DELTA) {
343 return true;
344 }
345
346 return false;
347 }
348 }, {
349 key: 'videoUnderflow_',
350 value: function videoUnderflow_(nextRange, buffered, currentTime) {
351 if (nextRange.length === 0) {
352 // Even if there is no available next range, there is still a possibility we are
353 // stuck in a gap due to video underflow.
354 var gap = this.gapFromVideoUnderflow_(buffered, currentTime);
355
356 if (gap) {
357 this.logger_('Encountered a gap in video from ' + gap.start + ' to ' + gap.end + '. ' + ('Seeking to current time ' + currentTime));
358
359 return true;
360 }
361 }
362
363 return false;
364 }
365
366 /**
367 * Timer callback. If playback still has not proceeded, then we seek
368 * to the start of the next buffered region.
369 *
370 * @private
371 */
372 }, {
373 key: 'skipTheGap_',
374 value: function skipTheGap_(scheduledCurrentTime) {
375 var buffered = this.tech_.buffered();
376 var currentTime = this.tech_.currentTime();
377 var nextRange = _ranges2['default'].findNextRange(buffered, currentTime);
378
379 this.cancelTimer_();
380
381 if (nextRange.length === 0 || currentTime !== scheduledCurrentTime) {
382 return;
383 }
384
385 this.logger_('skipTheGap_:', 'currentTime:', currentTime, 'scheduled currentTime:', scheduledCurrentTime, 'nextRange start:', nextRange.start(0));
386
387 // only seek if we still have not played
388 this.tech_.setCurrentTime(nextRange.start(0) + _ranges2['default'].TIME_FUDGE_FACTOR);
389
390 this.tech_.trigger({ type: 'usage', name: 'hls-gap-skip' });
391 }
392 }, {
393 key: 'gapFromVideoUnderflow_',
394 value: function gapFromVideoUnderflow_(buffered, currentTime) {
395 // At least in Chrome, if there is a gap in the video buffer, the audio will continue
396 // playing for ~3 seconds after the video gap starts. This is done to account for
397 // video buffer underflow/underrun (note that this is not done when there is audio
398 // buffer underflow/underrun -- in that case the video will stop as soon as it
399 // encounters the gap, as audio stalls are more noticeable/jarring to a user than
400 // video stalls). The player's time will reflect the playthrough of audio, so the
401 // time will appear as if we are in a buffered region, even if we are stuck in a
402 // "gap."
403 //
404 // Example:
405 // video buffer: 0 => 10.1, 10.2 => 20
406 // audio buffer: 0 => 20
407 // overall buffer: 0 => 10.1, 10.2 => 20
408 // current time: 13
409 //
410 // Chrome's video froze at 10 seconds, where the video buffer encountered the gap,
411 // however, the audio continued playing until it reached ~3 seconds past the gap
412 // (13 seconds), at which point it stops as well. Since current time is past the
413 // gap, findNextRange will return no ranges.
414 //
415 // To check for this issue, we see if there is a gap that starts somewhere within
416 // a 3 second range (3 seconds +/- 1 second) back from our current time.
417 var gaps = _ranges2['default'].findGaps(buffered);
418
419 for (var i = 0; i < gaps.length; i++) {
420 var start = gaps.start(i);
421 var end = gaps.end(i);
422
423 // gap is starts no more than 4 seconds back
424 if (currentTime - start < 4 && currentTime - start > 2) {
425 return {
426 start: start,
427 end: end
428 };
429 }
430 }
431
432 return null;
433 }
434
435 /**
436 * A debugging logger noop that is set to console.log only if debugging
437 * is enabled globally
438 *
439 * @private
440 */
441 }, {
442 key: 'logger_',
443 value: function logger_() {}
444 }]);
445
446 return PlaybackWatcher;
447})();
448
449exports['default'] = PlaybackWatcher;
450module.exports = exports['default'];
\No newline at end of file