UNPKG

videojs-contrib-hls

Version:

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

242 lines (211 loc) 7.26 kB
/** * @file gap-skipper.js */ import Ranges from './ranges'; import videojs from 'video.js'; // Set of events that reset the gap-skipper logic and clear the timeout const 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 */ export default class GapSkipper { /** * Represents a GapSKipper object. * @constructor * @param {object} options an object that includes the tech and settings */ constructor(options) { this.tech_ = options.tech; this.consecutiveUpdates = 0; this.lastRecordedTime = null; this.timer_ = null; if (options.debug) { this.logger_ = videojs.log.bind(videojs, 'gap-skipper ->'); } this.logger_('initialize'); let waitingHandler = ()=> this.waiting_(); let timeupdateHandler = ()=> this.timeupdate_(); let cancelTimerHandler = ()=> 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 = () => { 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 */ 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 */ timeupdate_() { if (this.tech_.paused() || this.tech_.seeking()) { return; } let 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 */ 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 */ skipTheGap_(scheduledCurrentTime) { let buffered = this.tech_.buffered(); let currentTime = this.tech_.currentTime(); let nextRange = Ranges.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) + Ranges.TIME_FUDGE_FACTOR); } 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. let gaps = Ranges.findGaps(buffered); for (let i = 0; i < gaps.length; i++) { let start = gaps.start(i); let end = gaps.end(i); // gap is starts no more than 4 seconds back if (currentTime - start < 4 && currentTime - start > 2) { return { start, end }; } } return null; } /** * Set a timer to skip the unbuffered region. * * @private */ setTimer_() { let buffered = this.tech_.buffered(); let currentTime = this.tech_.currentTime(); let nextRange = Ranges.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. let 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; } let 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 */ logger_() {} }