UNPKG

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