UNPKG

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