UNPKG

videojs-contrib-hls

Version:

Play back HLS with video.js, even where it's not natively supported

270 lines (226 loc) 9.04 kB
/** * @file gap-skipper.js */ '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 _ranges = require('./ranges'); var _ranges2 = _interopRequireDefault(_ranges); var _videoJs = require('video.js'); var _videoJs2 = _interopRequireDefault(_videoJs); // Set of events that reset the gap-skipper logic and clear the timeout var timerCancelEvents = ['seeking', 'seeked', 'pause', 'playing', 'error']; /** * The gap skipper object handles all scenarios * where the player runs into the end of a buffered * region and there is a buffered region ahead. * * It then handles the skipping behavior by setting a * timer to the size (in time) of the gap. This gives * the hls segment fetcher time to close the gap and * resume playing before the timer is triggered and * the gap skipper simply seeks over the gap as a * last resort to resume playback. * * @class GapSkipper */ var GapSkipper = (function () { /** * Represents a GapSKipper object. * @constructor * @param {object} options an object that includes the tech and settings */ function GapSkipper(options) { var _this = this; _classCallCheck(this, GapSkipper); this.tech_ = options.tech; this.consecutiveUpdates = 0; this.lastRecordedTime = null; this.timer_ = null; if (options.debug) { this.logger_ = _videoJs2['default'].log.bind(_videoJs2['default'], 'gap-skipper ->'); } this.logger_('initialize'); var waitingHandler = function waitingHandler() { return _this.waiting_(); }; var timeupdateHandler = function timeupdateHandler() { return _this.timeupdate_(); }; var cancelTimerHandler = function cancelTimerHandler() { return _this.cancelTimer_(); }; this.tech_.on('waiting', waitingHandler); this.tech_.on('timeupdate', timeupdateHandler); this.tech_.on(timerCancelEvents, cancelTimerHandler); // Define the dispose function to clean up our events this.dispose = function () { _this.logger_('dispose'); _this.tech_.off('waiting', waitingHandler); _this.tech_.off('timeupdate', timeupdateHandler); _this.tech_.off(timerCancelEvents, cancelTimerHandler); _this.cancelTimer_(); }; } /** * Handler for `waiting` events from the player * * @private */ _createClass(GapSkipper, [{ key: 'waiting_', value: function waiting_() { if (!this.tech_.seeking()) { this.setTimer_(); } } /** * 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: 'timeupdate_', value: function timeupdate_() { if (this.tech_.paused() || this.tech_.seeking()) { return; } var currentTime = this.tech_.currentTime(); 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; } /** * 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.consecutiveUpdates = 0; this.timer_ = null; 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); } }, { 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; } /** * Set a timer to skip the unbuffered region. * * @private */ }, { key: 'setTimer_', value: function setTimer_() { var buffered = this.tech_.buffered(); var currentTime = this.tech_.currentTime(); var nextRange = _ranges2['default'].findNextRange(buffered, currentTime); if (this.timer_ !== null) { return; } 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_('setTimer_:', 'Encountered a gap in video', 'from: ', gap.start, 'to: ', gap.end, 'seeking to current time: ', 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.tech_.setCurrentTime(currentTime); } return; } var difference = nextRange.start(0) - currentTime; this.logger_('setTimer_:', 'stopped at:', currentTime, 'setting timer for:', difference, 'seeking to:', nextRange.start(0)); this.timer_ = setTimeout(this.skipTheGap_.bind(this), difference * 1000, currentTime); } /** * A debugging logger noop that is set to console.log only if debugging * is enabled globally * * @private */ }, { key: 'logger_', value: function logger_() {} }]); return GapSkipper; })(); exports['default'] = GapSkipper; module.exports = exports['default'];