Skip to content
This repository has been archived by the owner on Jan 12, 2019. It is now read-only.

Improved seek behaviour #1374

Closed
149 changes: 139 additions & 10 deletions src/master-playlist-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @file master-playlist-controller.js
*/
import PlaylistLoader from './playlist-loader';
import { isEnabled, isLowestEnabledRendition } from './playlist.js';
import { isEnabled, isLowestEnabledRendition, getMediaInfoForTime } from './playlist.js';
import SegmentLoader from './segment-loader';
import VTTSegmentLoader from './vtt-segment-loader';
import Ranges from './ranges';
Expand All @@ -15,6 +15,7 @@ import Decrypter from './decrypter-worker';
import Config from './config';
import { parseCodecs } from './util/codecs.js';
import { createMediaTypes, setupMediaGroups } from './media-groups';
import { maxBandwidthForDeadlineSelector } from './playlist-selectors';

const ABORT_EARLY_BLACKLIST_SECONDS = 60 * 2;

Expand Down Expand Up @@ -245,7 +246,8 @@ export class MasterPlaylistController extends videojs.EventTarget {
externHls,
useCueTags,
blacklistDuration,
enableLowInitialPlaylist
enableLowInitialPlaylist,
seekDeadline
} = options;

if (!url) {
Expand All @@ -260,12 +262,14 @@ export class MasterPlaylistController extends videojs.EventTarget {
this.useCueTags_ = useCueTags;
this.blacklistDuration = blacklistDuration;
this.enableLowInitialPlaylist = enableLowInitialPlaylist;
this.seekDeadline_ = seekDeadline;

if (this.useCueTags_) {
this.cueTagsTrack_ = this.tech_.addTextTrack('metadata',
'ad-cues');
this.cueTagsTrack_.inBandMetadataTrackDispatchType = '';
}
this.lastPlaybackRate_ = null;

this.requestOptions_ = {
withCredentials,
Expand All @@ -281,6 +285,7 @@ export class MasterPlaylistController extends videojs.EventTarget {
this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen_.bind(this));

this.seekable_ = videojs.createTimeRanges();
this.seeking_ = false;
this.hasPlayed_ = () => false;

this.syncController_ = new SyncController(options);
Expand All @@ -296,7 +301,8 @@ export class MasterPlaylistController extends videojs.EventTarget {
mediaSource: this.mediaSource,
currentTime: this.tech_.currentTime.bind(this.tech_),
seekable: () => this.seekable(),
seeking: () => this.tech_.seeking(),
seeking: () => this.seeking(),
seekStartedAt: () => this.seekStartedAt_,
duration: () => this.mediaSource.duration,
hasPlayed: () => this.hasPlayed_(),
goalBufferLength: () => this.goalBufferLength(),
Expand Down Expand Up @@ -643,6 +649,70 @@ export class MasterPlaylistController extends videojs.EventTarget {
this.audioSegmentLoader_.on('ended', () => {
this.onEndOfStream();
});

this.mainSegmentLoader_.on('buffered', this.seeked_.bind(this));
this.tech_.on('seeked', () => {
if (this.seeking_ && !this.tech_.seeking()) {
// we've got an old version of videojs that doesn't support deferring
// seeking to the source handler, so resume immediately
this.seeked_();

// if we seeked close to the end of a segment, we'll run out of buffer
// before we can download the next segment. when we're rebuffering,
// there should be a spinner over the video, but this requires a bit
// of finagling for some browsers.
if (videojs.browser.IS_EDGE || videojs.browser.IE_VERSION) {
// on IE/edge, no `waiting` is fired when you're rebuffering. also,
// `timeupdate`s constantly fire. so we simply check to see if our
// current time advances at all and fire waiting events for a period
// of time after seeking
let lastTime = this.tech_.currentTime();
let waitingChecker = () => {
if (lastTime === this.tech_.currentTime() && !this.tech_.paused()) {
this.tech_.trigger('waiting');
}
lastTime = this.tech_.currentTime();
};

this.tech_.on('timeupdate', waitingChecker);
setTimeout(() => {
this.tech_.off('timeupdate', waitingChecker);
}, this.masterPlaylistLoader_.targetDuration * 1000);

} else if (videojs.browser.IS_CHROME) {
// on chrome, a `waiting` event will fire, but will also have a
// `timeupdate` fire right after (while still rebuffering). this
// causes the spinner to clear because of the code in video.js. to
// work around this, we re-trigger a `waiting` if the current time
// at the first `waiting` and the subsequent `timeupdate` are the
// same.
this.tech_.one('waiting', () => {
let currentTime = this.tech_.currentTime();

this.tech_.one('timeupdate', () => {
if (this.tech_.currentTime() === currentTime) {
this.tech_.trigger('waiting');
}
});
});
}
}
});
}

/**
* Restore playback rate and set seeking to false
*
* @private
*/
seeked_() {
if (!this.seeking_) {
return;
}
this.seekStartedAt_ = null;
this.seeking_ = false;
this.tech_.setPlaybackRate(this.lastPlaybackRate_);
this.lastPlaybackRate_ = null;
}

mediaSecondsLoaded_() {
Expand All @@ -664,15 +734,17 @@ export class MasterPlaylistController extends videojs.EventTarget {
}

/**
* Re-tune playback quality level for the current player
* conditions. This method may perform destructive actions, like
* removing already buffered content, to readjust the currently
* active playlist quickly.
* Re-tune playback quality level for either the current player
* conditions or the explicitly passed playlist. This method may
* perform destructive actions, like removing already buffered
* content, to readjust the currently active playlist quickly.
*
* @param {Playlist=} nextPlaylist Force quality switch to this playlist
*
* @private
*/
fastQualityChange_() {
let media = this.selectPlaylist();
fastQualityChange_(nextPlaylist) {
let media = nextPlaylist || this.selectPlaylist();

if (media !== this.masterPlaylistLoader_.media()) {
this.masterPlaylistLoader_.media(media);
Expand Down Expand Up @@ -934,6 +1006,7 @@ export class MasterPlaylistController extends videojs.EventTarget {
* @return {TimeRange} the current time
*/
setCurrentTime(currentTime) {

let buffered = Ranges.findRange(this.tech_.buffered(), currentTime);

if (!(this.masterPlaylistLoader_ && this.masterPlaylistLoader_.media())) {
Expand All @@ -943,7 +1016,7 @@ export class MasterPlaylistController extends videojs.EventTarget {

// it's clearly an edge-case but don't thrown an error if asked to
// seek within an empty playlist
if (!this.masterPlaylistLoader_.media().segments) {
if (!this.masterPlaylistLoader_.media().segments.length) {
return 0;
}

Expand All @@ -954,6 +1027,48 @@ export class MasterPlaylistController extends videojs.EventTarget {
return currentTime;
}

// If we get here, we're taking control of the seeking process
this.seeking_ = true;
// If a seek happens while a previous seek is still ongoing, we need to
// make sure we don't accidentally overwrite the lastPlaybackRate_ with 0
this.lastPlaybackRate_ = this.tech_.playbackRate() || this.lastPlaybackRate_;
this.tech_.setPlaybackRate(0);

let media = this.masterPlaylistLoader_.media();

let syncPoint = this.syncController_.getSyncPoint(
media,
this.duration(),
undefined,
currentTime);

let mediaSourceInfo = getMediaInfoForTime(
media,
currentTime,
syncPoint.segmentIndex,
syncPoint.time
);

let segmentDuration = media.segments[mediaSourceInfo.mediaIndex].duration;
let bufferedAfterSeek = (mediaSourceInfo.startTime + segmentDuration) - currentTime;
let extraRequests = 0;

if (bufferedAfterSeek < (this.mainSegmentLoader_.roundTrip / 1000)) {
extraRequests++;
}

let nextPlaylist = maxBandwidthForDeadlineSelector({
master: this.master(),
currentTime,
bandwidth: this.mainSegmentLoader_.bandwidth,
duration: this.duration(),
segmentDuration,
deadline: this.seekDeadline_,
currentTimeline: this.mainSegmentLoader_.currentTimeline_,
syncController: this.syncController_,
extraRequests
});

// cancel outstanding requests so we begin buffering at the new
// location
this.mainSegmentLoader_.resetEverything();
Expand All @@ -967,6 +1082,16 @@ export class MasterPlaylistController extends videojs.EventTarget {
this.subtitleSegmentLoader_.abort();
}

this.seekStartedAt_ = new Date();

if (nextPlaylist && media.uri !== nextPlaylist.playlist.uri) {
this.fastQualityChange_(nextPlaylist.playlist);
// If we need to load a different playlist to meet the seek deadline,
// don't start the segment loaders immediately. The playlist switch
// process will do it automatically.
return;
}

// start segment loader loading in case they are paused
this.load();
}
Expand Down Expand Up @@ -997,6 +1122,10 @@ export class MasterPlaylistController extends videojs.EventTarget {
return this.seekable_;
}

seeking() {
return this.tech_.el_.seeking || this.seeking_;
}

onSyncInfoUpdate_() {
let mainSeekable;
let audioSeekable;
Expand Down
4 changes: 2 additions & 2 deletions src/playback-watcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,8 @@ export default class PlaybackWatcher {
return true;
}

if (this.tech_.seeking() || this.timer_ !== null) {
// Tech is seeking or already waiting on another action, no action needed
if (this.tech_.seeking() || this.tech_.playbackRate() === 0 || this.timer_ !== null) {
// Tech is seeking, filling buffer post-seek, or already waiting on another action; no action needed
return true;
}

Expand Down
32 changes: 17 additions & 15 deletions src/playlist-selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -312,29 +312,32 @@ export const movingAverageBandwidthSelector = function(decay) {
* Duration of the media
* @param {Number} settings.segmentDuration
* Segment duration to be used in round trip time calculations
* @param {Number} settings.timeUntilRebuffer
* Time left in seconds until the player has to rebuffer
* @param {Number} settings.deadline
* Deadline time in seconds
* @param {Number} settings.currentTimeline
* The current timeline segments are being loaded from
* @param {SyncController} settings.syncController
* SyncController for determining if we have a sync point for a given playlist
* @param {Number=} settings.extraRequests
* The number of extra requests that must be taken into account
* @return {Object|null}
* {Object} return.playlist
* The highest bandwidth playlist with the least amount of rebuffering
* {Number} return.rebufferingImpact
* The amount of time in seconds switching to this playlist will rebuffer. A
* negative value means that switching will cause zero rebuffering.
*/
export const minRebufferMaxBandwidthSelector = function(settings) {
export const maxBandwidthForDeadlineSelector = function(settings) {
const {
master,
currentTime,
bandwidth,
duration,
segmentDuration,
timeUntilRebuffer,
deadline,
currentTimeline,
syncController
syncController,
extraRequests
} = settings;

// filter out any playlists that have been excluded due to
Expand Down Expand Up @@ -364,30 +367,29 @@ export const minRebufferMaxBandwidthSelector = function(settings) {
currentTime);
// If there is no sync point for this playlist, switching to it will require a
// sync request first. This will double the request time
const numRequests = syncPoint ? 1 : 2;
const numRequests = (syncPoint ? 1 : 2) + (extraRequests || 0);
const requestTimeEstimate = Playlist.estimateSegmentRequestTime(segmentDuration,
bandwidth,
playlist);
const rebufferingImpact = (requestTimeEstimate * numRequests) - timeUntilRebuffer;
playlist) * numRequests;

return {
playlist,
rebufferingImpact
requestTimeEstimate
};
});

const noRebufferingPlaylists = rebufferingEstimates.filter(
(estimate) => estimate.rebufferingImpact <= 0);
const safePlaylists = rebufferingEstimates.filter(
(estimate) => estimate.requestTimeEstimate <= deadline);

// Sort by bandwidth DESC
stableSort(noRebufferingPlaylists,
stableSort(safePlaylists,
(a, b) => comparePlaylistBandwidth(b.playlist, a.playlist));

if (noRebufferingPlaylists.length) {
return noRebufferingPlaylists[0];
if (safePlaylists.length) {
return safePlaylists[0];
}

stableSort(rebufferingEstimates, (a, b) => a.rebufferingImpact - b.rebufferingImpact);
stableSort(rebufferingEstimates, (a, b) => a.requestTimeEstimate - b.requestTimeEstimate);

return rebufferingEstimates[0] || null;
};
Expand Down
Loading