UNPKG

23.1 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 * as Ranges from './ranges';
13import logger from './util/logger';
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 *
31 * @class
32 * @param {Object} options an object that includes the tech and settings
33 */
34 constructor(options) {
35 this.masterPlaylistController_ = options.masterPlaylistController;
36 this.tech_ = options.tech;
37 this.seekable = options.seekable;
38 this.allowSeeksWithinUnsafeLiveWindow = options.allowSeeksWithinUnsafeLiveWindow;
39 this.liveRangeSafeTimeDelta = options.liveRangeSafeTimeDelta;
40 this.media = options.media;
41
42 this.consecutiveUpdates = 0;
43 this.lastRecordedTime = null;
44 this.timer_ = null;
45 this.checkCurrentTimeTimeout_ = null;
46 this.logger_ = logger('PlaybackWatcher');
47
48 this.logger_('initialize');
49
50 const playHandler = () => this.monitorCurrentTime_();
51 const canPlayHandler = () => this.monitorCurrentTime_();
52 const waitingHandler = () => this.techWaiting_();
53 const cancelTimerHandler = () => this.cancelTimer_();
54
55 const mpc = this.masterPlaylistController_;
56
57 const loaderTypes = ['main', 'subtitle', 'audio'];
58 const loaderChecks = {};
59
60 loaderTypes.forEach((type) => {
61 loaderChecks[type] = {
62 reset: () => this.resetSegmentDownloads_(type),
63 updateend: () => this.checkSegmentDownloads_(type)
64 };
65
66 mpc[`${type}SegmentLoader_`].on('appendsdone', loaderChecks[type].updateend);
67 // If a rendition switch happens during a playback stall where the buffer
68 // isn't changing we want to reset. We cannot assume that the new rendition
69 // will also be stalled, until after new appends.
70 mpc[`${type}SegmentLoader_`].on('playlistupdate', loaderChecks[type].reset);
71 // Playback stalls should not be detected right after seeking.
72 // This prevents one segment playlists (single vtt or single segment content)
73 // from being detected as stalling. As the buffer will not change in those cases, since
74 // the buffer is the entire video duration.
75 this.tech_.on(['seeked', 'seeking'], loaderChecks[type].reset);
76 });
77
78 /**
79 * We check if a seek was into a gap through the following steps:
80 * 1. We get a seeking event and we do not get a seeked event. This means that
81 * a seek was attempted but not completed.
82 * 2. We run `fixesBadSeeks_` on segment loader appends. This means that we already
83 * removed everything from our buffer and appended a segment, and should be ready
84 * to check for gaps.
85 */
86 const setSeekingHandlers = (fn) => {
87 ['main', 'audio'].forEach((type) => {
88 mpc[`${type}SegmentLoader_`][fn]('appended', this.seekingAppendCheck_);
89 });
90 };
91
92 this.seekingAppendCheck_ = () => {
93 if (this.fixesBadSeeks_()) {
94 this.consecutiveUpdates = 0;
95 this.lastRecordedTime = this.tech_.currentTime();
96 setSeekingHandlers('off');
97 }
98 };
99
100 this.clearSeekingAppendCheck_ = () => setSeekingHandlers('off');
101
102 this.watchForBadSeeking_ = () => {
103 this.clearSeekingAppendCheck_();
104 setSeekingHandlers('on');
105 };
106
107 this.tech_.on('seeked', this.clearSeekingAppendCheck_);
108 this.tech_.on('seeking', this.watchForBadSeeking_);
109
110 this.tech_.on('waiting', waitingHandler);
111 this.tech_.on(timerCancelEvents, cancelTimerHandler);
112 this.tech_.on('canplay', canPlayHandler);
113
114 /*
115 An edge case exists that results in gaps not being skipped when they exist at the beginning of a stream. This case
116 is surfaced in one of two ways:
117
118 1) The `waiting` event is fired before the player has buffered content, making it impossible
119 to find or skip the gap. The `waiting` event is followed by a `play` event. On first play
120 we can check if playback is stalled due to a gap, and skip the gap if necessary.
121 2) A source with a gap at the beginning of the stream is loaded programatically while the player
122 is in a playing state. To catch this case, it's important that our one-time play listener is setup
123 even if the player is in a playing state
124 */
125 this.tech_.one('play', playHandler);
126
127 // Define the dispose function to clean up our events
128 this.dispose = () => {
129 this.clearSeekingAppendCheck_();
130 this.logger_('dispose');
131 this.tech_.off('waiting', waitingHandler);
132 this.tech_.off(timerCancelEvents, cancelTimerHandler);
133 this.tech_.off('canplay', canPlayHandler);
134 this.tech_.off('play', playHandler);
135 this.tech_.off('seeking', this.watchForBadSeeking_);
136 this.tech_.off('seeked', this.clearSeekingAppendCheck_);
137
138 loaderTypes.forEach((type) => {
139 mpc[`${type}SegmentLoader_`].off('appendsdone', loaderChecks[type].updateend);
140 mpc[`${type}SegmentLoader_`].off('playlistupdate', loaderChecks[type].reset);
141 this.tech_.off(['seeked', 'seeking'], loaderChecks[type].reset);
142 });
143 if (this.checkCurrentTimeTimeout_) {
144 window.clearTimeout(this.checkCurrentTimeTimeout_);
145 }
146 this.cancelTimer_();
147 };
148 }
149
150 /**
151 * Periodically check current time to see if playback stopped
152 *
153 * @private
154 */
155 monitorCurrentTime_() {
156 this.checkCurrentTime_();
157
158 if (this.checkCurrentTimeTimeout_) {
159 window.clearTimeout(this.checkCurrentTimeTimeout_);
160 }
161
162 // 42 = 24 fps // 250 is what Webkit uses // FF uses 15
163 this.checkCurrentTimeTimeout_ =
164 window.setTimeout(this.monitorCurrentTime_.bind(this), 250);
165 }
166
167 /**
168 * Reset stalled download stats for a specific type of loader
169 *
170 * @param {string} type
171 * The segment loader type to check.
172 *
173 * @listens SegmentLoader#playlistupdate
174 * @listens Tech#seeking
175 * @listens Tech#seeked
176 */
177 resetSegmentDownloads_(type) {
178 const loader = this.masterPlaylistController_[`${type}SegmentLoader_`];
179
180 if (this[`${type}StalledDownloads_`] > 0) {
181 this.logger_(`resetting possible stalled download count for ${type} loader`);
182 }
183 this[`${type}StalledDownloads_`] = 0;
184 this[`${type}Buffered_`] = loader.buffered_();
185 }
186
187 /**
188 * Checks on every segment `appendsdone` to see
189 * if segment appends are making progress. If they are not
190 * and we are still downloading bytes. We blacklist the playlist.
191 *
192 * @param {string} type
193 * The segment loader type to check.
194 *
195 * @listens SegmentLoader#appendsdone
196 */
197 checkSegmentDownloads_(type) {
198 const mpc = this.masterPlaylistController_;
199 const loader = mpc[`${type}SegmentLoader_`];
200 const buffered = loader.buffered_();
201 const isBufferedDifferent = Ranges.isRangeDifferent(this[`${type}Buffered_`], buffered);
202
203 this[`${type}Buffered_`] = buffered;
204
205 // if another watcher is going to fix the issue or
206 // the buffered value for this loader changed
207 // appends are working
208 if (isBufferedDifferent) {
209 this.resetSegmentDownloads_(type);
210 return;
211 }
212
213 this[`${type}StalledDownloads_`]++;
214
215 this.logger_(`found #${this[`${type}StalledDownloads_`]} ${type} appends that did not increase buffer (possible stalled download)`, {
216 playlistId: loader.playlist_ && loader.playlist_.id,
217 buffered: Ranges.timeRangesToArray(buffered)
218
219 });
220
221 // after 10 possibly stalled appends with no reset, exclude
222 if (this[`${type}StalledDownloads_`] < 10) {
223 return;
224 }
225
226 this.logger_(`${type} loader stalled download exclusion`);
227 this.resetSegmentDownloads_(type);
228 this.tech_.trigger({type: 'usage', name: `vhs-${type}-download-exclusion`});
229
230 if (type === 'subtitle') {
231 return;
232 }
233
234 // TODO: should we exclude audio tracks rather than main tracks
235 // when type is audio?
236 mpc.blacklistCurrentPlaylist({
237 message: `Excessive ${type} segment downloading detected.`
238 }, Infinity);
239 }
240
241 /**
242 * The purpose of this function is to emulate the "waiting" event on
243 * browsers that do not emit it when they are waiting for more
244 * data to continue playback
245 *
246 * @private
247 */
248 checkCurrentTime_() {
249 if (this.tech_.paused() || this.tech_.seeking()) {
250 return;
251 }
252
253 const currentTime = this.tech_.currentTime();
254 const buffered = this.tech_.buffered();
255
256 if (this.lastRecordedTime === currentTime &&
257 (!buffered.length ||
258 currentTime + Ranges.SAFE_TIME_DELTA >= buffered.end(buffered.length - 1))) {
259 // If current time is at the end of the final buffered region, then any playback
260 // stall is most likely caused by buffering in a low bandwidth environment. The tech
261 // should fire a `waiting` event in this scenario, but due to browser and tech
262 // inconsistencies. Calling `techWaiting_` here allows us to simulate
263 // responding to a native `waiting` event when the tech fails to emit one.
264 return this.techWaiting_();
265 }
266
267 if (this.consecutiveUpdates >= 5 &&
268 currentTime === this.lastRecordedTime) {
269 this.consecutiveUpdates++;
270 this.waiting_();
271 } else if (currentTime === this.lastRecordedTime) {
272 this.consecutiveUpdates++;
273 } else {
274 this.consecutiveUpdates = 0;
275 this.lastRecordedTime = currentTime;
276 }
277 }
278
279 /**
280 * Cancels any pending timers and resets the 'timeupdate' mechanism
281 * designed to detect that we are stalled
282 *
283 * @private
284 */
285 cancelTimer_() {
286 this.consecutiveUpdates = 0;
287
288 if (this.timer_) {
289 this.logger_('cancelTimer_');
290 clearTimeout(this.timer_);
291 }
292
293 this.timer_ = null;
294 }
295
296 /**
297 * Fixes situations where there's a bad seek
298 *
299 * @return {boolean} whether an action was taken to fix the seek
300 * @private
301 */
302 fixesBadSeeks_() {
303 const seeking = this.tech_.seeking();
304
305 if (!seeking) {
306 return false;
307 }
308
309 // TODO: It's possible that these seekable checks should be moved out of this function
310 // and into a function that runs on seekablechange. It's also possible that we only need
311 // afterSeekableWindow as the buffered check at the bottom is good enough to handle before
312 // seekable range.
313 const seekable = this.seekable();
314 const currentTime = this.tech_.currentTime();
315 const isAfterSeekableRange = this.afterSeekableWindow_(
316 seekable,
317 currentTime,
318 this.media(),
319 this.allowSeeksWithinUnsafeLiveWindow
320 );
321 let seekTo;
322
323 if (isAfterSeekableRange) {
324 const seekableEnd = seekable.end(seekable.length - 1);
325
326 // sync to live point (if VOD, our seekable was updated and we're simply adjusting)
327 seekTo = seekableEnd;
328 }
329
330 if (this.beforeSeekableWindow_(seekable, currentTime)) {
331 const seekableStart = seekable.start(0);
332
333 // sync to the beginning of the live window
334 // provide a buffer of .1 seconds to handle rounding/imprecise numbers
335 seekTo = seekableStart +
336 // if the playlist is too short and the seekable range is an exact time (can
337 // happen in live with a 3 segment playlist), then don't use a time delta
338 (seekableStart === seekable.end(0) ? 0 : Ranges.SAFE_TIME_DELTA);
339 }
340
341 if (typeof seekTo !== 'undefined') {
342 this.logger_(`Trying to seek outside of seekable at time ${currentTime} with ` +
343 `seekable range ${Ranges.printableRange(seekable)}. Seeking to ` +
344 `${seekTo}.`);
345
346 this.tech_.setCurrentTime(seekTo);
347 return true;
348 }
349
350 const sourceUpdater = this.masterPlaylistController_.sourceUpdater_;
351 const buffered = this.tech_.buffered();
352 const audioBuffered = sourceUpdater.audioBuffer ? sourceUpdater.audioBuffered() : null;
353 const videoBuffered = sourceUpdater.videoBuffer ? sourceUpdater.videoBuffered() : null;
354 const media = this.media();
355
356 // verify that at least two segment durations or one part duration have been
357 // appended before checking for a gap.
358 const minAppendedDuration = media.partTargetDuration ? media.partTargetDuration :
359 (media.targetDuration - Ranges.TIME_FUDGE_FACTOR) * 2;
360
361 // verify that at least two segment durations have been
362 // appended before checking for a gap.
363 const bufferedToCheck = [audioBuffered, videoBuffered];
364
365 for (let i = 0; i < bufferedToCheck.length; i++) {
366 // skip null buffered
367 if (!bufferedToCheck[i]) {
368 continue;
369 }
370
371 const timeAhead = Ranges.timeAheadOf(bufferedToCheck[i], currentTime);
372
373 // if we are less than two video/audio segment durations or one part
374 // duration behind we haven't appended enough to call this a bad seek.
375 if (timeAhead < minAppendedDuration) {
376 return false;
377 }
378 }
379
380 const nextRange = Ranges.findNextRange(buffered, currentTime);
381
382 // we have appended enough content, but we don't have anything buffered
383 // to seek over the gap
384 if (nextRange.length === 0) {
385 return false;
386 }
387
388 seekTo = nextRange.start(0) + Ranges.SAFE_TIME_DELTA;
389
390 this.logger_(`Buffered region starts (${nextRange.start(0)}) ` +
391 ` just beyond seek point (${currentTime}). Seeking to ${seekTo}.`);
392
393 this.tech_.setCurrentTime(seekTo);
394
395 return true;
396 }
397
398 /**
399 * Handler for situations when we determine the player is waiting.
400 *
401 * @private
402 */
403 waiting_() {
404 if (this.techWaiting_()) {
405 return;
406 }
407
408 // All tech waiting checks failed. Use last resort correction
409 const currentTime = this.tech_.currentTime();
410 const buffered = this.tech_.buffered();
411 const currentRange = Ranges.findRange(buffered, currentTime);
412
413 // Sometimes the player can stall for unknown reasons within a contiguous buffered
414 // region with no indication that anything is amiss (seen in Firefox). Seeking to
415 // currentTime is usually enough to kickstart the player. This checks that the player
416 // is currently within a buffered region before attempting a corrective seek.
417 // Chrome does not appear to continue `timeupdate` events after a `waiting` event
418 // until there is ~ 3 seconds of forward buffer available. PlaybackWatcher should also
419 // make sure there is ~3 seconds of forward buffer before taking any corrective action
420 // to avoid triggering an `unknownwaiting` event when the network is slow.
421 if (currentRange.length && currentTime + 3 <= currentRange.end(0)) {
422 this.cancelTimer_();
423 this.tech_.setCurrentTime(currentTime);
424
425 this.logger_(`Stopped at ${currentTime} while inside a buffered region ` +
426 `[${currentRange.start(0)} -> ${currentRange.end(0)}]. Attempting to resume ` +
427 'playback by seeking to the current time.');
428
429 // unknown waiting corrections may be useful for monitoring QoS
430 this.tech_.trigger({type: 'usage', name: 'vhs-unknown-waiting'});
431 this.tech_.trigger({type: 'usage', name: 'hls-unknown-waiting'});
432 return;
433 }
434 }
435
436 /**
437 * Handler for situations when the tech fires a `waiting` event
438 *
439 * @return {boolean}
440 * True if an action (or none) was needed to correct the waiting. False if no
441 * checks passed
442 * @private
443 */
444 techWaiting_() {
445 const seekable = this.seekable();
446 const currentTime = this.tech_.currentTime();
447
448 if (this.tech_.seeking() || this.timer_ !== null) {
449 // Tech is seeking or already waiting on another action, no action needed
450 return true;
451 }
452
453 if (this.beforeSeekableWindow_(seekable, currentTime)) {
454 const livePoint = seekable.end(seekable.length - 1);
455
456 this.logger_(`Fell out of live window at time ${currentTime}. Seeking to ` +
457 `live point (seekable end) ${livePoint}`);
458 this.cancelTimer_();
459 this.tech_.setCurrentTime(livePoint);
460
461 // live window resyncs may be useful for monitoring QoS
462 this.tech_.trigger({type: 'usage', name: 'vhs-live-resync'});
463 this.tech_.trigger({type: 'usage', name: 'hls-live-resync'});
464 return true;
465 }
466
467 const sourceUpdater = this.tech_.vhs.masterPlaylistController_.sourceUpdater_;
468 const buffered = this.tech_.buffered();
469 const videoUnderflow = this.videoUnderflow_({
470 audioBuffered: sourceUpdater.audioBuffered(),
471 videoBuffered: sourceUpdater.videoBuffered(),
472 currentTime
473 });
474
475 if (videoUnderflow) {
476 // Even though the video underflowed and was stuck in a gap, the audio overplayed
477 // the gap, leading currentTime into a buffered range. Seeking to currentTime
478 // allows the video to catch up to the audio position without losing any audio
479 // (only suffering ~3 seconds of frozen video and a pause in audio playback).
480 this.cancelTimer_();
481 this.tech_.setCurrentTime(currentTime);
482
483 // video underflow may be useful for monitoring QoS
484 this.tech_.trigger({type: 'usage', name: 'vhs-video-underflow'});
485 this.tech_.trigger({type: 'usage', name: 'hls-video-underflow'});
486 return true;
487 }
488 const nextRange = Ranges.findNextRange(buffered, currentTime);
489
490 // check for gap
491 if (nextRange.length > 0) {
492 const difference = nextRange.start(0) - currentTime;
493
494 this.logger_(`Stopped at ${currentTime}, setting timer for ${difference}, seeking ` +
495 `to ${nextRange.start(0)}`);
496
497 this.cancelTimer_();
498
499 this.timer_ = setTimeout(
500 this.skipTheGap_.bind(this),
501 difference * 1000,
502 currentTime
503 );
504 return true;
505 }
506
507 // All checks failed. Returning false to indicate failure to correct waiting
508 return false;
509 }
510
511 afterSeekableWindow_(seekable, currentTime, playlist, allowSeeksWithinUnsafeLiveWindow = false) {
512 if (!seekable.length) {
513 // we can't make a solid case if there's no seekable, default to false
514 return false;
515 }
516
517 let allowedEnd = seekable.end(seekable.length - 1) + Ranges.SAFE_TIME_DELTA;
518 const isLive = !playlist.endList;
519
520 if (isLive && allowSeeksWithinUnsafeLiveWindow) {
521 allowedEnd = seekable.end(seekable.length - 1) + (playlist.targetDuration * 3);
522 }
523
524 if (currentTime > allowedEnd) {
525 return true;
526 }
527
528 return false;
529 }
530
531 beforeSeekableWindow_(seekable, currentTime) {
532 if (seekable.length &&
533 // can't fall before 0 and 0 seekable start identifies VOD stream
534 seekable.start(0) > 0 &&
535 currentTime < seekable.start(0) - this.liveRangeSafeTimeDelta) {
536 return true;
537 }
538
539 return false;
540 }
541
542 videoUnderflow_({videoBuffered, audioBuffered, currentTime}) {
543 // audio only content will not have video underflow :)
544 if (!videoBuffered) {
545 return;
546 }
547 let gap;
548
549 // find a gap in demuxed content.
550 if (videoBuffered.length && audioBuffered.length) {
551 // in Chrome audio will continue to play for ~3s when we run out of video
552 // so we have to check that the video buffer did have some buffer in the
553 // past.
554 const lastVideoRange = Ranges.findRange(videoBuffered, currentTime - 3);
555 const videoRange = Ranges.findRange(videoBuffered, currentTime);
556 const audioRange = Ranges.findRange(audioBuffered, currentTime);
557
558 if (audioRange.length && !videoRange.length && lastVideoRange.length) {
559 gap = {start: lastVideoRange.end(0), end: audioRange.end(0)};
560 }
561
562 // find a gap in muxed content.
563 } else {
564 const nextRange = Ranges.findNextRange(videoBuffered, currentTime);
565
566 // Even if there is no available next range, there is still a possibility we are
567 // stuck in a gap due to video underflow.
568 if (!nextRange.length) {
569 gap = this.gapFromVideoUnderflow_(videoBuffered, currentTime);
570 }
571 }
572
573 if (gap) {
574 this.logger_(`Encountered a gap in video from ${gap.start} to ${gap.end}. ` +
575 `Seeking to current time ${currentTime}`);
576
577 return true;
578 }
579
580 return false;
581 }
582
583 /**
584 * Timer callback. If playback still has not proceeded, then we seek
585 * to the start of the next buffered region.
586 *
587 * @private
588 */
589 skipTheGap_(scheduledCurrentTime) {
590 const buffered = this.tech_.buffered();
591 const currentTime = this.tech_.currentTime();
592 const nextRange = Ranges.findNextRange(buffered, currentTime);
593
594 this.cancelTimer_();
595
596 if (nextRange.length === 0 ||
597 currentTime !== scheduledCurrentTime) {
598 return;
599 }
600
601 this.logger_(
602 'skipTheGap_:',
603 'currentTime:', currentTime,
604 'scheduled currentTime:', scheduledCurrentTime,
605 'nextRange start:', nextRange.start(0)
606 );
607
608 // only seek if we still have not played
609 this.tech_.setCurrentTime(nextRange.start(0) + Ranges.TIME_FUDGE_FACTOR);
610
611 this.tech_.trigger({type: 'usage', name: 'vhs-gap-skip'});
612 this.tech_.trigger({type: 'usage', name: 'hls-gap-skip'});
613 }
614
615 gapFromVideoUnderflow_(buffered, currentTime) {
616 // At least in Chrome, if there is a gap in the video buffer, the audio will continue
617 // playing for ~3 seconds after the video gap starts. This is done to account for
618 // video buffer underflow/underrun (note that this is not done when there is audio
619 // buffer underflow/underrun -- in that case the video will stop as soon as it
620 // encounters the gap, as audio stalls are more noticeable/jarring to a user than
621 // video stalls). The player's time will reflect the playthrough of audio, so the
622 // time will appear as if we are in a buffered region, even if we are stuck in a
623 // "gap."
624 //
625 // Example:
626 // video buffer: 0 => 10.1, 10.2 => 20
627 // audio buffer: 0 => 20
628 // overall buffer: 0 => 10.1, 10.2 => 20
629 // current time: 13
630 //
631 // Chrome's video froze at 10 seconds, where the video buffer encountered the gap,
632 // however, the audio continued playing until it reached ~3 seconds past the gap
633 // (13 seconds), at which point it stops as well. Since current time is past the
634 // gap, findNextRange will return no ranges.
635 //
636 // To check for this issue, we see if there is a gap that starts somewhere within
637 // a 3 second range (3 seconds +/- 1 second) back from our current time.
638 const gaps = Ranges.findGaps(buffered);
639
640 for (let i = 0; i < gaps.length; i++) {
641 const start = gaps.start(i);
642 const end = gaps.end(i);
643
644 // gap is starts no more than 4 seconds back
645 if (currentTime - start < 4 && currentTime - start > 2) {
646 return {
647 start,
648 end
649 };
650 }
651 }
652
653 return null;
654 }
655}