450 lines
16 KiB
JavaScript
450 lines
16 KiB
JavaScript
/**
|
|
* @file playback-watcher.js
|
|
*
|
|
* Playback starts, and now my watch begins. It shall not end until my death. I shall
|
|
* take no wait, hold no uncleared timeouts, father no bad seeks. I shall wear no crowns
|
|
* and win no glory. I shall live and die at my post. I am the corrector of the underflow.
|
|
* I am the watcher of gaps. I am the shield that guards the realms of seekable. I pledge
|
|
* my life and honor to the Playback Watch, for this Player and all the Players to come.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
Object.defineProperty(exports, '__esModule', {
|
|
value: true
|
|
});
|
|
|
|
var _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; }; })();
|
|
|
|
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
|
|
|
|
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } }
|
|
|
|
var _globalWindow = require('global/window');
|
|
|
|
var _globalWindow2 = _interopRequireDefault(_globalWindow);
|
|
|
|
var _ranges = require('./ranges');
|
|
|
|
var _ranges2 = _interopRequireDefault(_ranges);
|
|
|
|
var _videoJs = require('video.js');
|
|
|
|
var _videoJs2 = _interopRequireDefault(_videoJs);
|
|
|
|
// Set of events that reset the playback-watcher time check logic and clear the timeout
|
|
var timerCancelEvents = ['seeking', 'seeked', 'pause', 'playing', 'error'];
|
|
|
|
/**
|
|
* @class PlaybackWatcher
|
|
*/
|
|
|
|
var PlaybackWatcher = (function () {
|
|
/**
|
|
* Represents an PlaybackWatcher object.
|
|
* @constructor
|
|
* @param {object} options an object that includes the tech and settings
|
|
*/
|
|
|
|
function PlaybackWatcher(options) {
|
|
var _this = this;
|
|
|
|
_classCallCheck(this, PlaybackWatcher);
|
|
|
|
this.tech_ = options.tech;
|
|
this.seekable = options.seekable;
|
|
|
|
this.consecutiveUpdates = 0;
|
|
this.lastRecordedTime = null;
|
|
this.timer_ = null;
|
|
this.checkCurrentTimeTimeout_ = null;
|
|
|
|
if (options.debug) {
|
|
this.logger_ = _videoJs2['default'].log.bind(_videoJs2['default'], 'playback-watcher ->');
|
|
}
|
|
this.logger_('initialize');
|
|
|
|
var canPlayHandler = function canPlayHandler() {
|
|
return _this.monitorCurrentTime_();
|
|
};
|
|
var waitingHandler = function waitingHandler() {
|
|
return _this.techWaiting_();
|
|
};
|
|
var cancelTimerHandler = function cancelTimerHandler() {
|
|
return _this.cancelTimer_();
|
|
};
|
|
var fixesBadSeeksHandler = function fixesBadSeeksHandler() {
|
|
return _this.fixesBadSeeks_();
|
|
};
|
|
|
|
this.tech_.on('seekablechanged', fixesBadSeeksHandler);
|
|
this.tech_.on('waiting', waitingHandler);
|
|
this.tech_.on(timerCancelEvents, cancelTimerHandler);
|
|
this.tech_.on('canplay', canPlayHandler);
|
|
|
|
// Define the dispose function to clean up our events
|
|
this.dispose = function () {
|
|
_this.logger_('dispose');
|
|
_this.tech_.off('seekablechanged', fixesBadSeeksHandler);
|
|
_this.tech_.off('waiting', waitingHandler);
|
|
_this.tech_.off(timerCancelEvents, cancelTimerHandler);
|
|
_this.tech_.off('canplay', canPlayHandler);
|
|
if (_this.checkCurrentTimeTimeout_) {
|
|
_globalWindow2['default'].clearTimeout(_this.checkCurrentTimeTimeout_);
|
|
}
|
|
_this.cancelTimer_();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Periodically check current time to see if playback stopped
|
|
*
|
|
* @private
|
|
*/
|
|
|
|
_createClass(PlaybackWatcher, [{
|
|
key: 'monitorCurrentTime_',
|
|
value: function monitorCurrentTime_() {
|
|
this.checkCurrentTime_();
|
|
|
|
if (this.checkCurrentTimeTimeout_) {
|
|
_globalWindow2['default'].clearTimeout(this.checkCurrentTimeTimeout_);
|
|
}
|
|
|
|
// 42 = 24 fps // 250 is what Webkit uses // FF uses 15
|
|
this.checkCurrentTimeTimeout_ = _globalWindow2['default'].setTimeout(this.monitorCurrentTime_.bind(this), 250);
|
|
}
|
|
|
|
/**
|
|
* The purpose of this function is to emulate the "waiting" event on
|
|
* browsers that do not emit it when they are waiting for more
|
|
* data to continue playback
|
|
*
|
|
* @private
|
|
*/
|
|
}, {
|
|
key: 'checkCurrentTime_',
|
|
value: function checkCurrentTime_() {
|
|
if (this.tech_.seeking() && this.fixesBadSeeks_()) {
|
|
this.consecutiveUpdates = 0;
|
|
this.lastRecordedTime = this.tech_.currentTime();
|
|
return;
|
|
}
|
|
|
|
if (this.tech_.paused() || this.tech_.seeking()) {
|
|
return;
|
|
}
|
|
|
|
var currentTime = this.tech_.currentTime();
|
|
var buffered = this.tech_.buffered();
|
|
|
|
if (this.lastRecordedTime === currentTime && (!buffered.length || currentTime + _ranges2['default'].SAFE_TIME_DELTA >= buffered.end(buffered.length - 1))) {
|
|
// If current time is at the end of the final buffered region, then any playback
|
|
// stall is most likely caused by buffering in a low bandwidth environment. The tech
|
|
// should fire a `waiting` event in this scenario, but due to browser and tech
|
|
// inconsistencies (e.g. The Flash tech does not fire a `waiting` event when the end
|
|
// of the buffer is reached and has fallen off the live window). Calling
|
|
// `techWaiting_` here allows us to simulate responding to a native `waiting` event
|
|
// when the tech fails to emit one.
|
|
return this.techWaiting_();
|
|
}
|
|
|
|
if (this.consecutiveUpdates >= 5 && currentTime === this.lastRecordedTime) {
|
|
this.consecutiveUpdates++;
|
|
this.waiting_();
|
|
} else if (currentTime === this.lastRecordedTime) {
|
|
this.consecutiveUpdates++;
|
|
} else {
|
|
this.consecutiveUpdates = 0;
|
|
this.lastRecordedTime = currentTime;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancels any pending timers and resets the 'timeupdate' mechanism
|
|
* designed to detect that we are stalled
|
|
*
|
|
* @private
|
|
*/
|
|
}, {
|
|
key: 'cancelTimer_',
|
|
value: function cancelTimer_() {
|
|
this.consecutiveUpdates = 0;
|
|
|
|
if (this.timer_) {
|
|
this.logger_('cancelTimer_');
|
|
clearTimeout(this.timer_);
|
|
}
|
|
|
|
this.timer_ = null;
|
|
}
|
|
|
|
/**
|
|
* Fixes situations where there's a bad seek
|
|
*
|
|
* @return {Boolean} whether an action was taken to fix the seek
|
|
* @private
|
|
*/
|
|
}, {
|
|
key: 'fixesBadSeeks_',
|
|
value: function fixesBadSeeks_() {
|
|
var seeking = this.tech_.seeking();
|
|
var seekable = this.seekable();
|
|
var currentTime = this.tech_.currentTime();
|
|
var seekTo = undefined;
|
|
|
|
if (seeking && this.afterSeekableWindow_(seekable, currentTime)) {
|
|
var seekableEnd = seekable.end(seekable.length - 1);
|
|
|
|
// sync to live point (if VOD, our seekable was updated and we're simply adjusting)
|
|
seekTo = seekableEnd;
|
|
}
|
|
|
|
if (seeking && this.beforeSeekableWindow_(seekable, currentTime)) {
|
|
var seekableStart = seekable.start(0);
|
|
|
|
// sync to the beginning of the live window
|
|
// provide a buffer of .1 seconds to handle rounding/imprecise numbers
|
|
seekTo = seekableStart + _ranges2['default'].SAFE_TIME_DELTA;
|
|
}
|
|
|
|
if (typeof seekTo !== 'undefined') {
|
|
this.logger_('Trying to seek outside of seekable at time ' + currentTime + ' with ' + ('seekable range ' + _ranges2['default'].printableRange(seekable) + '. Seeking to ') + (seekTo + '.'));
|
|
|
|
this.tech_.setCurrentTime(seekTo);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Handler for situations when we determine the player is waiting.
|
|
*
|
|
* @private
|
|
*/
|
|
}, {
|
|
key: 'waiting_',
|
|
value: function waiting_() {
|
|
if (this.techWaiting_()) {
|
|
return;
|
|
}
|
|
|
|
// All tech waiting checks failed. Use last resort correction
|
|
var currentTime = this.tech_.currentTime();
|
|
var buffered = this.tech_.buffered();
|
|
var currentRange = _ranges2['default'].findRange(buffered, currentTime);
|
|
|
|
// Sometimes the player can stall for unknown reasons within a contiguous buffered
|
|
// region with no indication that anything is amiss (seen in Firefox). Seeking to
|
|
// currentTime is usually enough to kickstart the player. This checks that the player
|
|
// is currently within a buffered region before attempting a corrective seek.
|
|
// Chrome does not appear to continue `timeupdate` events after a `waiting` event
|
|
// until there is ~ 3 seconds of forward buffer available. PlaybackWatcher should also
|
|
// make sure there is ~3 seconds of forward buffer before taking any corrective action
|
|
// to avoid triggering an `unknownwaiting` event when the network is slow.
|
|
if (currentRange.length && currentTime + 3 <= currentRange.end(0)) {
|
|
this.cancelTimer_();
|
|
this.tech_.setCurrentTime(currentTime);
|
|
|
|
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.');
|
|
|
|
// unknown waiting corrections may be useful for monitoring QoS
|
|
this.tech_.trigger({ type: 'usage', name: 'hls-unknown-waiting' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for situations when the tech fires a `waiting` event
|
|
*
|
|
* @return {Boolean}
|
|
* True if an action (or none) was needed to correct the waiting. False if no
|
|
* checks passed
|
|
* @private
|
|
*/
|
|
}, {
|
|
key: 'techWaiting_',
|
|
value: function techWaiting_() {
|
|
var seekable = this.seekable();
|
|
var currentTime = this.tech_.currentTime();
|
|
|
|
if (this.tech_.seeking() && this.fixesBadSeeks_()) {
|
|
// Tech is seeking or bad seek fixed, no action needed
|
|
return true;
|
|
}
|
|
|
|
if (this.tech_.seeking() || this.timer_ !== null) {
|
|
// Tech is seeking or already waiting on another action, no action needed
|
|
return true;
|
|
}
|
|
|
|
if (this.beforeSeekableWindow_(seekable, currentTime)) {
|
|
var livePoint = seekable.end(seekable.length - 1);
|
|
|
|
this.logger_('Fell out of live window at time ' + currentTime + '. Seeking to ' + ('live point (seekable end) ' + livePoint));
|
|
this.cancelTimer_();
|
|
this.tech_.setCurrentTime(livePoint);
|
|
|
|
// live window resyncs may be useful for monitoring QoS
|
|
this.tech_.trigger({ type: 'usage', name: 'hls-live-resync' });
|
|
return true;
|
|
}
|
|
|
|
var buffered = this.tech_.buffered();
|
|
var nextRange = _ranges2['default'].findNextRange(buffered, currentTime);
|
|
|
|
if (this.videoUnderflow_(nextRange, buffered, currentTime)) {
|
|
// Even though the video underflowed and was stuck in a gap, the audio overplayed
|
|
// the gap, leading currentTime into a buffered range. Seeking to currentTime
|
|
// allows the video to catch up to the audio position without losing any audio
|
|
// (only suffering ~3 seconds of frozen video and a pause in audio playback).
|
|
this.cancelTimer_();
|
|
this.tech_.setCurrentTime(currentTime);
|
|
|
|
// video underflow may be useful for monitoring QoS
|
|
this.tech_.trigger({ type: 'usage', name: 'hls-video-underflow' });
|
|
return true;
|
|
}
|
|
|
|
// check for gap
|
|
if (nextRange.length > 0) {
|
|
var difference = nextRange.start(0) - currentTime;
|
|
|
|
this.logger_('Stopped at ' + currentTime + ', setting timer for ' + difference + ', seeking ' + ('to ' + nextRange.start(0)));
|
|
|
|
this.timer_ = setTimeout(this.skipTheGap_.bind(this), difference * 1000, currentTime);
|
|
return true;
|
|
}
|
|
|
|
// All checks failed. Returning false to indicate failure to correct waiting
|
|
return false;
|
|
}
|
|
}, {
|
|
key: 'afterSeekableWindow_',
|
|
value: function afterSeekableWindow_(seekable, currentTime) {
|
|
if (!seekable.length) {
|
|
// we can't make a solid case if there's no seekable, default to false
|
|
return false;
|
|
}
|
|
|
|
if (currentTime > seekable.end(seekable.length - 1) + _ranges2['default'].SAFE_TIME_DELTA) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
key: 'beforeSeekableWindow_',
|
|
value: function beforeSeekableWindow_(seekable, currentTime) {
|
|
if (seekable.length &&
|
|
// can't fall before 0 and 0 seekable start identifies VOD stream
|
|
seekable.start(0) > 0 && currentTime < seekable.start(0) - _ranges2['default'].SAFE_TIME_DELTA) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, {
|
|
key: 'videoUnderflow_',
|
|
value: function videoUnderflow_(nextRange, buffered, currentTime) {
|
|
if (nextRange.length === 0) {
|
|
// Even if there is no available next range, there is still a possibility we are
|
|
// stuck in a gap due to video underflow.
|
|
var gap = this.gapFromVideoUnderflow_(buffered, currentTime);
|
|
|
|
if (gap) {
|
|
this.logger_('Encountered a gap in video from ' + gap.start + ' to ' + gap.end + '. ' + ('Seeking to current time ' + currentTime));
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Timer callback. If playback still has not proceeded, then we seek
|
|
* to the start of the next buffered region.
|
|
*
|
|
* @private
|
|
*/
|
|
}, {
|
|
key: 'skipTheGap_',
|
|
value: function skipTheGap_(scheduledCurrentTime) {
|
|
var buffered = this.tech_.buffered();
|
|
var currentTime = this.tech_.currentTime();
|
|
var nextRange = _ranges2['default'].findNextRange(buffered, currentTime);
|
|
|
|
this.cancelTimer_();
|
|
|
|
if (nextRange.length === 0 || currentTime !== scheduledCurrentTime) {
|
|
return;
|
|
}
|
|
|
|
this.logger_('skipTheGap_:', 'currentTime:', currentTime, 'scheduled currentTime:', scheduledCurrentTime, 'nextRange start:', nextRange.start(0));
|
|
|
|
// only seek if we still have not played
|
|
this.tech_.setCurrentTime(nextRange.start(0) + _ranges2['default'].TIME_FUDGE_FACTOR);
|
|
|
|
this.tech_.trigger({ type: 'usage', name: 'hls-gap-skip' });
|
|
}
|
|
}, {
|
|
key: 'gapFromVideoUnderflow_',
|
|
value: function gapFromVideoUnderflow_(buffered, currentTime) {
|
|
// At least in Chrome, if there is a gap in the video buffer, the audio will continue
|
|
// playing for ~3 seconds after the video gap starts. This is done to account for
|
|
// video buffer underflow/underrun (note that this is not done when there is audio
|
|
// buffer underflow/underrun -- in that case the video will stop as soon as it
|
|
// encounters the gap, as audio stalls are more noticeable/jarring to a user than
|
|
// video stalls). The player's time will reflect the playthrough of audio, so the
|
|
// time will appear as if we are in a buffered region, even if we are stuck in a
|
|
// "gap."
|
|
//
|
|
// Example:
|
|
// video buffer: 0 => 10.1, 10.2 => 20
|
|
// audio buffer: 0 => 20
|
|
// overall buffer: 0 => 10.1, 10.2 => 20
|
|
// current time: 13
|
|
//
|
|
// Chrome's video froze at 10 seconds, where the video buffer encountered the gap,
|
|
// however, the audio continued playing until it reached ~3 seconds past the gap
|
|
// (13 seconds), at which point it stops as well. Since current time is past the
|
|
// gap, findNextRange will return no ranges.
|
|
//
|
|
// To check for this issue, we see if there is a gap that starts somewhere within
|
|
// a 3 second range (3 seconds +/- 1 second) back from our current time.
|
|
var gaps = _ranges2['default'].findGaps(buffered);
|
|
|
|
for (var i = 0; i < gaps.length; i++) {
|
|
var start = gaps.start(i);
|
|
var end = gaps.end(i);
|
|
|
|
// gap is starts no more than 4 seconds back
|
|
if (currentTime - start < 4 && currentTime - start > 2) {
|
|
return {
|
|
start: start,
|
|
end: end
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* A debugging logger noop that is set to console.log only if debugging
|
|
* is enabled globally
|
|
*
|
|
* @private
|
|
*/
|
|
}, {
|
|
key: 'logger_',
|
|
value: function logger_() {}
|
|
}]);
|
|
|
|
return PlaybackWatcher;
|
|
})();
|
|
|
|
exports['default'] = PlaybackWatcher;
|
|
module.exports = exports['default']; |