fd8b3a9c by David LaPalomento

Adjust timeline offsets if we discover they are inaccurate

When switching renditions or dealing with a live stream with unaligned variant playlists, we may discover that the segment we buffered isn't associated with the time range we expected it to be. In that case, adjust our information about timeline positioning and try buffering again.
1 parent e79f548b
/**
* playlist-loader
*
* A state machine that manages the loading, caching, and updating of
* M3U8 playlists. When tracking a live playlist, loaders will keep
* track of the duration of content that expired since the loader was
* initialized and when the current discontinuity sequence was
* encountered. A complete media timeline for a live playlist with
* expiring segments and discontinuities looks like this:
* expiring segments looks like this:
*
* |-- expiredPreDiscontinuity --|-- expiredPostDiscontinuity --|-- segments --|
* |-- expired --|-- segments --|
*
* You can use these values to calculate how much time has elapsed
* since the stream began loading or how long it has been since the
* most recent discontinuity was encountered, for instance.
*/
(function(window, videojs) {
'use strict';
......@@ -159,20 +158,13 @@
// initialize the loader state
loader.state = 'HAVE_NOTHING';
// the total duration of all segments that expired and have been
// removed from the current playlist after the last
// #EXT-X-DISCONTINUITY. In a live playlist without
// discontinuities, this is the total amount of time that has
// been removed from the stream since the playlist loader began
// The total duration of all segments that expired and have been
// removed from the current playlist, in seconds. This property
// should always be zero for non-live playlists. In a live
// playlist, this is the total amount of time that has been
// removed from the stream since the playlist loader began
// tracking it.
loader.expiredPostDiscontinuity_ = 0;
// the total duration of all segments that expired and have been
// removed from the current playlist before the last
// #EXT-X-DISCONTINUITY. The total amount of time that has
// expired is always the sum of expiredPreDiscontinuity_ and
// expiredPostDiscontinuity_.
loader.expiredPreDiscontinuity_ = 0;
loader.expired_ = 0;
// capture the prototype dispose function
dispose = this.dispose;
......@@ -364,42 +356,43 @@
* @param update {object} the updated media playlist object
*/
PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) {
var lastDiscontinuity, expiredCount, i;
var expiredCount;
if (this.media_) {
expiredCount = update.mediaSequence - this.media_.mediaSequence;
// setup the index for duration calculations so that the newly
// expired time will be accumulated after the last
// discontinuity, unless we discover otherwise
lastDiscontinuity = this.media_.mediaSequence;
if (this.media_.discontinuitySequence !== update.discontinuitySequence) {
i = expiredCount;
while (i--) {
if (this.media_.segments[i].discontinuity) {
// a segment that begins a new discontinuity sequence has expired
lastDiscontinuity = i + this.media_.mediaSequence;
this.expiredPreDiscontinuity_ += this.expiredPostDiscontinuity_;
this.expiredPostDiscontinuity_ = 0;
break;
}
}
}
// update the expirated durations
this.expiredPreDiscontinuity_ += Playlist.duration(this.media_,
this.media_.mediaSequence,
lastDiscontinuity);
this.expiredPostDiscontinuity_ += Playlist.duration(this.media_,
lastDiscontinuity,
update.mediaSequence);
// update the expired time count
this.expired_ += Playlist.duration(this.media_,
this.media_.mediaSequence,
update.mediaSequence);
}
this.media_ = this.master.playlists[update.uri];
};
/**
* When switching variant playlists in a live stream, the player may
* discover that the new set of available segments is shifted in
* time relative to the old playlist. If that is the case, you can
* call this method to synchronize the playlist loader so that
* subsequent calls to getMediaIndexForTime_() return values
* appropriate for the new playlist.
*
* @param mediaIndex {integer} the index of the segment that will be
* the used to base timeline calculations on
* @param startTime {number} the media timeline position of the
* first moment of video data for the specified segment. That is,
* data from the specified segment will first be displayed when
* `currentTime` is equal to `startTime`.
*/
PlaylistLoader.prototype.updateTimelineOffset = function(mediaIndex, startingTime) {
var segmentOffset = Playlist.duration(this.media_,
this.media_.mediaSequence,
this.media_.mediaSequence + mediaIndex);
this.expired_ = startingTime - segmentOffset;
};
/**
* Determine the index of the segment that contains a specified
* playback position in the current media playlist. Early versions
* of the HLS specification require segment durations to be rounded
......@@ -426,7 +419,7 @@
// when the requested position is earlier than the current set of
// segments, return the earliest segment index
time -= this.expiredPreDiscontinuity_ + this.expiredPostDiscontinuity_;
time -= this.expired_;
if (time < 0) {
return 0;
}
......
......@@ -46,6 +46,8 @@ videojs.Hls = videojs.extend(Component, {
this.tech_ = tech;
this.source_ = options.source;
this.mode_ = options.mode;
this.pendingSegment_ = null;
this.bytesReceived = 0;
// loadingState_ tracks how far along the buffering process we
......@@ -322,19 +324,34 @@ videojs.Hls.prototype.setupSourceBuffer_ = function() {
// transition the sourcebuffer to the ended state if we've hit the end of
// the playlist
this.sourceBuffer.addEventListener('updateend', function() {
var segmentInfo = this.pendingSegment_, i, currentBuffered;
this.pendingSegment_ = null;
if (this.duration() !== Infinity &&
this.mediaIndex === this.playlists.media().segments.length) {
this.mediaSource.endOfStream();
}
// when switching renditions or seeking, we may misjudge the media
// index to request to continue playback. check after each append
// that our buffering is productive and seek if necessary to
// continue playback
if (this.tech_.buffered().length &&
this.tech_.currentTime() < this.tech_.buffered().start(this.tech_.buffered().length - 1)) {
videojs.log('Variants out of sync. Seeking to continue.');
this.tech_.setCurrentTime(this.tech_.buffered().start(this.tech_.buffered().length - 1));
// When switching renditions or seeking, we may misjudge the media
// index to request to continue playback. Check after each append
// that a gap hasn't appeared in the buffered region and adjust
// the media index to fill it if necessary
if (this.tech_.buffered().length === 2 &&
segmentInfo.playlist === this.playlists.media()) {
i = this.tech_.buffered().length;
while (i--) {
if (this.tech_.currentTime() < this.tech_.buffered().start(i)) {
// found the misidentified segment's buffered time range
// adjust the media index to fill the gap
var mi = this.mediaIndex;
currentBuffered = this.findCurrentBuffered_();
this.playlists.updateTimelineOffset(segmentInfo.mediaIndex, this.tech_.buffered().start(i));
this.mediaIndex = this.playlists.getMediaIndexForTime_(currentBuffered.end(0) + 1);
console.log(mi, '->', this.mediaIndex, 'expired:', this.tech_.buffered().start(i));
break;
}
}
}
}.bind(this));
};
......@@ -381,8 +398,10 @@ videojs.Hls.prototype.setupMetadataCueTranslation_ = function() {
return;
}
media = this.playlists.media();
startTime = this.tech_.playlists.expiredPreDiscontinuity_ + this.tech_.playlists.expiredPostDiscontinuity_;
startTime += videojs.Hls.Playlist.duration(media, media.mediaSequence, media.mediaSequence + this.tech_.mediaIndex);
startTime = this.tech_.playlists.expired_;
startTime += videojs.Hls.Playlist.duration(media,
media.mediaSequence,
media.mediaSequence + this.tech_.mediaIndex);
i = textTrack.cues.length;
while (i--) {
......@@ -395,8 +414,7 @@ videojs.Hls.prototype.setupMetadataCueTranslation_ = function() {
videojs.Hls.prototype.addCuesForMetadata_ = function(segmentInfo) {
var i, cue, frame, metadata, minPts, segment, segmentOffset, textTrack, time;
segmentOffset = this.playlists.expiredPreDiscontinuity_;
segmentOffset += this.playlists.expiredPostDiscontinuity_;
segmentOffset = this.playlists.expired_;
segmentOffset += videojs.Hls.Playlist.duration(segmentInfo.playlist,
segmentInfo.playlist.mediaSequence,
segmentInfo.playlist.mediaSequence + segmentInfo.mediaIndex);
......@@ -543,7 +561,7 @@ videojs.Hls.prototype.seekable = function() {
return currentSeekable;
}
startOffset = this.playlists.expiredPostDiscontinuity_ - this.playlists.expiredPreDiscontinuity_;
startOffset = this.playlists.expired_;
return videojs.createTimeRanges(startOffset,
startOffset + (currentSeekable.end(0) - currentSeekable.start(0)));
};
......@@ -1080,10 +1098,9 @@ videojs.Hls.prototype.drainBuffer = function(event) {
this.sourceBuffer.timestampOffset = currentBuffered.end(0);
}
// the segment is asynchronously added to the current buffered data
this.sourceBuffer.appendBuffer(bytes);
// we're done processing this segment
segmentBuffer.shift();
this.pendingSegment_ = segmentBuffer.shift();
};
/**
......
......@@ -59,12 +59,7 @@
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
equal(loader.expiredPreDiscontinuity_,
0,
'zero seconds expired pre-discontinuity');
equal(loader.expiredPostDiscontinuity_,
0,
'zero seconds expired post-discontinuity');
equal(loader.expired_, 0, 'zero seconds expired');
});
test('requests the initial playlist immediately', function() {
......@@ -202,7 +197,7 @@
'3.ts\n' +
'#EXTINF:10,\n' +
'4.ts\n');
equal(loader.expiredPostDiscontinuity_, 10, 'expired one segment');
equal(loader.expired_, 10, 'expired one segment');
});
test('increments expired seconds after a discontinuity', function() {
......@@ -226,8 +221,7 @@
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
equal(loader.expiredPreDiscontinuity_, 0, 'identifies pre-discontinuity time');
equal(loader.expiredPostDiscontinuity_, 10, 'expired one segment');
equal(loader.expired_, 10, 'expired one segment');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
......@@ -236,8 +230,7 @@
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
equal(loader.expiredPreDiscontinuity_, 0, 'tracked time across the discontinuity');
equal(loader.expiredPostDiscontinuity_, 13, 'no expirations after the discontinuity yet');
equal(loader.expired_, 13, 'no expirations after the discontinuity yet');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
......@@ -246,8 +239,7 @@
'#EXT-X-DISCONTINUITY-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'3.ts\n');
equal(loader.expiredPreDiscontinuity_, 13, 'did not increment pre-discontinuity');
equal(loader.expiredPostDiscontinuity_, 4, 'expired post-discontinuity');
equal(loader.expired_, 13 + 4, 'tracked expired prior to the discontinuity');
});
test('tracks expired seconds properly when two discontinuities expire at once', function() {
......@@ -272,8 +264,7 @@
'#EXT-X-DISCONTINUITY-SEQUENCE:2\n' +
'#EXTINF:7,\n' +
'3.ts\n');
equal(loader.expiredPreDiscontinuity_, 4 + 5, 'tracked pre-discontinuity time');
equal(loader.expiredPostDiscontinuity_, 6, 'tracked post-discontinuity time');
equal(loader.expired_, 4 + 5 + 6, 'tracked both expired discontinuities');
});
test('emits an error when an initial playlist request fails', function() {
......@@ -782,8 +773,7 @@
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
loader.expiredPreDiscontinuity_ = 50;
loader.expiredPostDiscontinuity_ = 100;
loader.expired_ = 150;
equal(loader.getMediaIndexForTime_(0), 0, 'the lowest returned value is zero');
equal(loader.getMediaIndexForTime_(45), 0, 'expired content returns zero');
......@@ -795,6 +785,30 @@
equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment');
});
test('updating the timeline offset adjusts results from getMediaIndexForTime_', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:23\n' +
'#EXTINF:4,\n' +
'23.ts\n' +
'#EXTINF:5,\n' +
'24.ts\n' +
'#EXTINF:6,\n' +
'25.ts\n' +
'#EXTINF:7,\n' +
'26.ts\n');
loader.updateTimelineOffset(0, 150);
equal(loader.getMediaIndexForTime_(150), 0, 'translated the first segment');
equal(loader.getMediaIndexForTime_(130), 0, 'clamps the index to zero');
equal(loader.getMediaIndexForTime_(155), 1, 'translated the second segment');
loader.updateTimelineOffset(2, 30);
equal(loader.getMediaIndexForTime_(30 - 5 - 1), 0, 'translated the first segment');
equal(loader.getMediaIndexForTime_(30 + 7), 3, 'translated the last segment');
equal(loader.getMediaIndexForTime_(30 - 3), 1, 'translated an earlier 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,
......