205d32e2 by David LaPalomento

Track expired content duration

When we switch playlists in a live video, we have to find the right place in the new playlist to continue buffering. This is complicated because we can't guarantee the two variants are segmented at the same time positions or that the windows of time they represent are exactly in sync. Most of the time, they're pretty close to one another and we can use that fact to make better guesses at which segment to download when switching.

This PR adds back tracking of expired content in the playlist loader, which can then be used to estimate the seekable window for live playlists even before we've buffered any segments from them. This also allows seekable to be accurate even when the player has paused for a long time and all the segment timing information we gathered has gone out of date. To make rejoining or seeking in a live stream even more robust, we detect when a seek "misses" the live window and seek again to a safe position.
1 parent e02911f3
......@@ -152,6 +152,11 @@
// initialize the loader state
loader.state = 'HAVE_NOTHING';
// track the time that has expired from the live window
// this allows the seekable start range to be calculated even if
// all segments with timing information have expired
this.expired_ = 0;
// capture the prototype dispose function
dispose = this.dispose;
......@@ -346,7 +351,52 @@
* @param update {object} the updated media playlist object
*/
PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) {
var outdated, i, segment;
outdated = this.media_;
this.media_ = this.master.playlists[update.uri];
if (!outdated) {
return;
}
// try using precise timing from first segment of the updated
// playlist
if (update.segments.length) {
if (update.segments[0].start !== undefined) {
this.expired_ = update.segments[0].start;
return;
} else if (update.segments[0].end !== undefined) {
this.expired_ = update.segments[0].end - update.segments[0].duration;
return;
}
}
// calculate expired by walking the outdated playlist
i = update.mediaSequence - outdated.mediaSequence - 1;
for (; i >= 0; i--) {
segment = outdated.segments[i];
if (!segment) {
// we missed information on this segment completely between
// playlist updates so we'll have to take an educated guess
// once we begin buffering again, any error we introduce can
// be corrected
this.expired_ += outdated.targetDuration || 10;
continue;
}
if (segment.end !== undefined) {
this.expired_ = segment.end;
return;
}
if (segment.start !== undefined) {
this.expired_ = segment.start + segment.duration;
return;
}
this.expired_ += segment.duration;
}
};
/**
......@@ -457,8 +507,8 @@
if (i === endIndex) {
// We haven't found a segment but we did hit a known end point
// so fallback to "Algorithm Jon" - try to interpolate the segment
// index based on the known span of the timeline we are dealing with
// so fallback to interpolating between the segment index
// based on the known span of the timeline we are dealing with
// and the number of segments inside that span
return startIndex + Math.floor(
((originalTime - knownStart) / (knownEnd - knownStart)) *
......@@ -481,9 +531,13 @@
// We haven't found a segment so load the first one
return 0;
} else {
// We known nothing so use "Algorithm A" - walk from the front
// of the playlist naively subtracking durations until we find
// a segment that contains time and return it
// We known nothing so walk from the front of the playlist,
// subtracting durations until we find a segment that contains
// time and return it
time = time - this.expired_;
if (time < 0) {
return -1;
}
for (i = 0; i < numSegments; i++) {
segment = this.media_.segments[i];
time -= segment.duration || targetDuration;
......
......@@ -187,7 +187,7 @@ videojs.HlsHandler.prototype.src = function(src) {
}.bind(this));
this.playlists.on('loadedplaylist', function() {
var updatedPlaylist = this.playlists.media();
var updatedPlaylist = this.playlists.media(), seekable;
if (!updatedPlaylist) {
// select the initial variant
......@@ -196,6 +196,14 @@ videojs.HlsHandler.prototype.src = function(src) {
}
this.updateDuration(this.playlists.media());
// update seekable
seekable = this.seekable();
if (this.duration() === Infinity &&
seekable.length !== 0) {
this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0));
}
oldMediaPlaylist = updatedPlaylist;
}.bind(this));
......@@ -291,7 +299,6 @@ videojs.Hls.bufferedAdditions_ = function(original, update) {
return result;
};
var parseCodecs = function(codecs) {
var result = {
codecCount: 0,
......@@ -312,6 +319,7 @@ var parseCodecs = function(codecs) {
return result;
};
/**
* Blacklist playlists that are known to be codec or
* stream-incompatible with the SourceBuffer configuration. For
......@@ -445,15 +453,15 @@ videojs.HlsHandler.prototype.play = function() {
// if the viewer has paused and we fell out of the live window,
// seek forward to the earliest available position
if (this.duration() === Infinity) {
if (this.tech_.currentTime() < this.tech_.seekable().start(0)) {
this.tech_.setCurrentTime(this.tech_.seekable().start(0));
if (this.tech_.currentTime() < this.seekable().start(0)) {
this.tech_.setCurrentTime(this.seekable().start(0));
}
}
};
videojs.HlsHandler.prototype.setCurrentTime = function(currentTime) {
var
buffered = this.findCurrentBuffered_();
buffered = this.findBufferedRange_();
if (!(this.playlists && this.playlists.media())) {
// return immediately if the metadata is not ready yet
......@@ -501,7 +509,7 @@ videojs.HlsHandler.prototype.duration = function() {
};
videojs.HlsHandler.prototype.seekable = function() {
var media;
var media, seekable;
if (!this.playlists) {
return videojs.createTimeRanges();
......@@ -511,7 +519,25 @@ videojs.HlsHandler.prototype.seekable = function() {
return videojs.createTimeRanges();
}
return videojs.Hls.Playlist.seekable(media);
seekable = videojs.Hls.Playlist.seekable(media);
if (seekable.length === 0) {
return seekable;
}
// if the seekable start is zero, it may be because the player has
// been paused for a long time and stopped buffering. in that case,
// fall back to the playlist loader's running estimate of expired
// time
if (seekable.start(0) === 0) {
return videojs.createTimeRanges([[
this.playlists.expired_,
this.playlists.expired_ + seekable.end(0)
]]);
}
// seekable has been calculated based on buffering video data so it
// can be returned directly
return seekable;
};
/**
......@@ -522,15 +548,10 @@ videojs.HlsHandler.prototype.updateDuration = function(playlist) {
newDuration = videojs.Hls.Playlist.duration(playlist),
setDuration = function() {
this.mediaSource.duration = newDuration;
// update seekable
if (seekable.length !== 0 && newDuration === Infinity) {
this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0));
}
this.tech_.trigger('durationchange');
this.mediaSource.removeEventListener('sourceopen', setDuration);
}.bind(this),
seekable = this.seekable();
}.bind(this);
// if the duration has changed, invalidate the cached value
if (oldDuration !== newDuration) {
......@@ -539,10 +560,6 @@ videojs.HlsHandler.prototype.updateDuration = function(playlist) {
this.mediaSource.addEventListener('sourceopen', setDuration);
} else if (!this.sourceBuffer || !this.sourceBuffer.updating) {
this.mediaSource.duration = newDuration;
// update seekable
if (seekable.length !== 0 && newDuration === Infinity) {
this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0));
}
this.tech_.trigger('durationchange');
}
}
......@@ -745,43 +762,63 @@ videojs.HlsHandler.prototype.stopCheckingBuffer_ = function() {
this.tech_.off('waiting', this.drainBuffer);
};
/**
* Attempts to find the buffered TimeRange where playback is currently
* happening. Returns a new TimeRange with one or zero ranges.
*/
videojs.HlsHandler.prototype.findCurrentBuffered_ = function() {
var
ranges,
i,
tech = this.tech_,
// !!The order of the next two lines is important!!
// `currentTime` must be equal-to or greater-than the start of the
// buffered range. Flash executes out-of-process so, every value can
// change behind the scenes from line-to-line. By reading `currentTime`
// after `buffered`, we ensure that it is always a current or later
// value during playback.
buffered = tech.buffered(),
currentTime = tech.currentTime();
var filterBufferedRanges = function(predicate) {
return function(time) {
var
i,
ranges = [],
tech = this.tech_,
// !!The order of the next two assignments is important!!
// `currentTime` must be equal-to or greater-than the start of the
// buffered range. Flash executes out-of-process so, every value can
// change behind the scenes from line-to-line. By reading `currentTime`
// after `buffered`, we ensure that it is always a current or later
// value during playback.
buffered = tech.buffered();
if (time === undefined) {
time = tech.currentTime();
}
if (buffered && buffered.length) {
// Search for a range containing the play-head
for (i = 0; i < buffered.length; i++) {
if (buffered.start(i) - TIME_FUDGE_FACTOR <= currentTime &&
buffered.end(i) + TIME_FUDGE_FACTOR >= currentTime) {
ranges = videojs.createTimeRanges(buffered.start(i), buffered.end(i));
ranges.indexOf = i;
return ranges;
if (buffered && buffered.length) {
// Search for a range containing the play-head
for (i = 0; i < buffered.length; i++) {
if (predicate(buffered.start(i), buffered.end(i), time)) {
ranges.push([buffered.start(i), buffered.end(i)]);
}
}
}
}
// Return an empty range if no ranges exist
ranges = videojs.createTimeRanges();
ranges.indexOf = -1;
return ranges;
return videojs.createTimeRanges(ranges);
};
};
/**
* Attempts to find the buffered TimeRange that contains the specified
* time, or where playback is currently happening if no specific time
* is specified.
* @param time (optional) {number} the time to filter on. Defaults to
* currentTime.
* @return a new TimeRanges object.
*/
videojs.HlsHandler.prototype.findBufferedRange_ = filterBufferedRanges(function(start, end, time) {
return start - TIME_FUDGE_FACTOR <= time &&
end + TIME_FUDGE_FACTOR >= time;
});
/**
* Returns the TimeRanges that begin at or later than the specified
* time.
* @param time (optional) {number} the time to filter on. Defaults to
* currentTime.
* @return a new TimeRanges object.
*/
videojs.HlsHandler.prototype.findNextBufferedRange_ = filterBufferedRanges(function(start, end, time) {
return start - TIME_FUDGE_FACTOR >= time;
});
/**
* Determines whether there is enough video data currently in the buffer
* and downloads a new segment if the buffered time is less than the goal.
* @param seekToTime (optional) {number} the offset into the downloaded segment
......@@ -791,7 +828,7 @@ videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) {
var
tech = this.tech_,
currentTime = tech.currentTime(),
currentBuffered = this.findCurrentBuffered_(),
currentBuffered = this.findBufferedRange_(),
currentBufferedEnd = 0,
bufferedTime = 0,
segment,
......@@ -1025,7 +1062,7 @@ videojs.HlsHandler.prototype.drainBuffer = function(event) {
segIv,
segmentTimestampOffset = 0,
hasBufferedContent = (this.tech_.buffered().length !== 0),
currentBuffered = this.findCurrentBuffered_(),
currentBuffered = this.findBufferedRange_(),
outsideBufferedRanges = !(currentBuffered && currentBuffered.length);
// if the buffer is empty or the source buffer hasn't been created
......@@ -1132,6 +1169,7 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () {
playlist,
currentMediaIndex,
currentBuffered,
seekable,
timelineUpdates;
this.pendingSegment_ = null;
......@@ -1144,7 +1182,7 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () {
playlist = this.playlists.media();
segments = playlist.segments;
currentMediaIndex = segmentInfo.mediaIndex + (segmentInfo.mediaSequence - playlist.mediaSequence);
currentBuffered = this.findCurrentBuffered_();
currentBuffered = this.findBufferedRange_();
// if we switched renditions don't try to add segment timeline
// information to the playlist
......@@ -1156,9 +1194,25 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () {
// added by the media processing
segment = playlist.segments[currentMediaIndex];
// when seeking to the beginning of the seekable range, it's
// possible that imprecise timing information may cause the seek to
// end up earlier than the start of the range
// in that case, seek again
seekable = this.seekable();
if (this.tech_.seeking() &&
currentBuffered.length === 0) {
if (seekable.length &&
this.tech_.currentTime() < seekable.start(0)) {
var next = this.findNextBufferedRange_();
if (next.length) {
videojs.log('tried seeking to', this.tech_.currentTime(), 'but that was too early, retrying at', next.start(0));
this.tech_.setCurrentTime(next.start(0));
}
}
}
timelineUpdates = videojs.Hls.bufferedAdditions_(segmentInfo.buffered,
this.tech_.buffered());
timelineUpdates.forEach(function (update) {
if (segment) {
if (update.end !== undefined) {
......
......@@ -53,6 +53,17 @@
strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet');
});
test('starts with no expired time', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
equal(loader.expired_,
0,
'zero seconds expired');
});
test('requests the initial playlist immediately', function() {
new videojs.Hls.PlaylistLoader('master.m3u8');
strictEqual(requests.length, 1, 'made a request');
......@@ -166,6 +177,125 @@
strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
test('increments expired seconds after a segment is removed', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n' +
'#EXTINF:10,\n' +
'4.ts\n');
equal(loader.expired_, 10, 'expired one segment');
});
test('increments expired seconds after a discontinuity', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:3,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:3,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
equal(loader.expired_, 10, 'expired one segment');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:2\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
equal(loader.expired_, 13, 'no expirations after the discontinuity yet');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:3\n' +
'#EXT-X-DISCONTINUITY-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'3.ts\n');
equal(loader.expired_, 17, 'tracked expiration across the discontinuity');
});
test('tracks expired seconds properly when two discontinuities expire at once', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:6,\n' +
'2.ts\n' +
'#EXTINF:7,\n' +
'3.ts\n');
clock.tick(10 * 1000);
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:3\n' +
'#EXT-X-DISCONTINUITY-SEQUENCE:2\n' +
'#EXTINF:7,\n' +
'3.ts\n');
equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities');
});
test('estimates expired if an entire window elapses between live playlist updates', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n');
clock.tick(10 * 1000);
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:4\n' +
'#EXTINF:6,\n' +
'4.ts\n' +
'#EXTINF:7,\n' +
'5.ts\n');
equal(loader.expired_,
4 + 5 + (2 * 10),
'made a very rough estimate of expired time');
});
test('emits an error when an initial playlist request fails', function() {
var
errors = [],
......@@ -672,7 +802,7 @@
equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5');
});
test('accounts for expired time when calculating media index', function() {
test('accounts for non-zero starting segment time when calculating media index', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.shift().respond(200, null,
'#EXTM3U\n' +
......@@ -693,6 +823,53 @@
equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment');
});
test('prefers precise segment timing when tracking expired time', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
// setup the loader with an "imprecise" value as if it had been
// accumulating segment durations as they expire
loader.expired_ = 160;
// annotate the first segment with a start time
// this number would be coming from the Source Buffer in practice
loader.media().segments[0].start = 150;
equal(loader.getMediaIndexForTime_(151), 0, 'prefers the value on the first segment');
clock.tick(10 * 1000); // trigger a playlist refresh
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1002\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
equal(loader.getMediaIndexForTime_(150 + 4 + 1), 0, 'tracks precise expired times');
});
test('accounts for expired time when calculating media index', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
loader.expired_ = 150;
equal(loader.getMediaIndexForTime_(0), -1, 'expired content returns a negative index');
equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns a negative index');
equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position');
equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment');
});
test('does not misintrepret playlists missing newlines at the end', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.shift().respond(200, null,
......
......@@ -549,11 +549,11 @@ test('finds the correct buffered region based on currentTime', function() {
standardXHRResponse(requests[1]);
player.currentTime(3);
clock.tick(1);
equal(player.tech_.hls.findCurrentBuffered_().end(0),
equal(player.tech_.hls.findBufferedRange_().end(0),
5, 'inside the first buffered region');
player.currentTime(6);
clock.tick(1);
equal(player.tech_.hls.findCurrentBuffered_().end(0),
equal(player.tech_.hls.findBufferedRange_().end(0),
12, 'inside the second buffered region');
});
......@@ -1636,6 +1636,41 @@ test('live playlist starts with correct currentTime value', function() {
'currentTime is updated at playback');
});
test('adjusts the seekable start based on the amount of expired live content', function() {
player.src({
src: 'http://example.com/manifest/liveStart30sBefore.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
standardXHRResponse(requests.shift());
// add timeline info to the playlist
player.tech_.hls.playlists.media().segments[1].end = 29.5;
// expired_ should be ignored if there is timeline information on
// the playlist
player.tech_.hls.playlists.expired_ = 172;
equal(player.seekable().start(0),
29.5 - 29,
'offset the seekable start');
});
test('estimates seekable ranges for live streams that have been paused for a long time', function() {
player.src({
src: 'http://example.com/manifest/liveStart30sBefore.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
standardXHRResponse(requests.shift());
player.tech_.hls.playlists.expired_ = 172;
equal(player.seekable().start(0),
player.tech_.hls.playlists.expired_,
'offset the seekable start');
});
test('resets the time to a seekable position when resuming a live stream ' +
'after a long break', function() {
var seekTarget;
......