56cb8748 by Jon-Carlos Rivera Committed by GitHub

Intelligent seeking over un-closeable buffer gaps - Part Deux (#735)

* Working implementation of adaptive seeking
* Use this.tech_
* Added the ability for the gap skipper to clean up after itself on dispose
1 parent dca5809b
/**
* @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);
}
/**
* 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 (nextRange.length === 0 ||
this.timer_ !== null) {
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_() {}
}
......@@ -16,6 +16,7 @@ import videojs from 'video.js';
import MasterPlaylistController from './master-playlist-controller';
import Config from './config';
import renditionSelectionMixin from './rendition-mixin';
import GapSkipper from './gap-skipper';
/**
* determine if an object a is differnt from
......@@ -374,6 +375,7 @@ class HlsHandler extends Component {
this.options_.tech = this.tech_;
this.options_.externHls = Hls;
this.masterPlaylistController_ = new MasterPlaylistController(this.options_);
this.gapSkipper_ = new GapSkipper(this.options_);
// `this` in selectPlaylist should be the HlsHandler for backwards
// compatibility with < v2
......@@ -542,6 +544,7 @@ class HlsHandler extends Component {
if (this.masterPlaylistController_) {
this.masterPlaylistController_.dispose();
}
this.gapSkipper_.dispose();
this.tech_.audioTracks().removeEventListener('change', this.audioTrackChange_);
super.dispose();
}
......
import videojs from 'video.js';
import QUnit from 'qunit';
import {
useFakeEnvironment,
useFakeMediaSource,
createPlayer,
openMediaSource,
standardXHRResponse
} from './test-helpers.js';
QUnit.module('GapSkipper', {
beforeEach() {
this.env = useFakeEnvironment();
this.requests = this.env.requests;
this.mse = useFakeMediaSource();
this.clock = this.env.clock;
this.old = {};
// setup a player
this.player = createPlayer();
},
afterEach() {
this.env.restore();
this.mse.restore();
this.player.dispose();
}
});
QUnit.test('skips over gap in firefox with waiting event', function() {
this.player.autoplay(true);
// create a buffer with a gap between 10 & 20 seconds
this.player.tech_.buffered = function() {
return videojs.createTimeRanges([[0, 10], [20, 30]]);
};
// set an arbitrary source
this.player.src({
src: 'master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
// start playback normally
this.player.tech_.triggerReady();
this.clock.tick(1);
standardXHRResponse(this.requests.shift());
openMediaSource(this.player, this.clock);
this.player.tech_.trigger('play');
this.player.tech_.trigger('playing');
this.clock.tick(1);
// seek to 10 seconds and wait 12 seconds
this.player.currentTime(10);
this.player.tech_.trigger('waiting');
this.clock.tick(12000);
// check that player jumped the gap
QUnit.equal(Math.round(this.player.currentTime()),
20, 'Player seeked over gap after timer');
});
QUnit.test('skips over gap in chrome without waiting event', function() {
this.player.autoplay(true);
// create a buffer with a gap between 10 & 20 seconds
this.player.tech_.buffered = function() {
return videojs.createTimeRanges([[0, 10], [20, 30]]);
};
// set an arbitrary source
this.player.src({
src: 'master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
// start playback normally
this.player.tech_.triggerReady();
this.clock.tick(1);
standardXHRResponse(this.requests.shift());
openMediaSource(this.player, this.clock);
this.player.tech_.trigger('play');
this.player.tech_.trigger('playing');
this.clock.tick(1);
// seek to 10 seconds & simulate chrome waiting event
this.player.currentTime(10);
for (let i = 0; i < 10; i++) {
this.player.tech_.trigger('timeupdate');
}
this.clock.tick(2000);
// checks that player doesn't seek before timer expires
QUnit.equal(this.player.currentTime(), 10, 'Player doesnt seek over gap pre-timer');
this.clock.tick(10000);
// check that player jumped the gap
QUnit.equal(Math.round(this.player.currentTime()),
20, 'Player seeked over gap after timer');
});