5ee4363a by David LaPalomento

Determine the segment to load by looking at buffered

When playlists are not segment-aligned or began at different times, we could make bad decisions about which segment to load by just incrementing the media index. Instead, annotate segments in the playlist with timeline information as they are downloaded. When a decision about what segment to fetch is required, simply try to fetch the segment that lines up with the latest edge of the buffered time range that contains the current time. Add a utility to stringify TextRanges for debugging. This is a checkpoint commit; 35 tests are currently failing in Chrome.
1 parent ad82ecc0
(function(window) {
var textRange = function(range, i) {
return range.start(i) + '-' + range.end(i);
};
var module = {
hexDump: function(data) {
var
......@@ -26,6 +29,13 @@
},
tagDump: function(tag) {
return module.hexDump(tag.bytes);
},
textRanges: function(ranges) {
var result = '', i;
for (i = 0; i < ranges.length; i++) {
result += textRange(ranges, i) + ' ';
}
return result;
}
};
......
......@@ -411,7 +411,7 @@
* closest playback position that is currently available.
*/
PlaylistLoader.prototype.getMediaIndexForTime_ = function(time) {
var i;
var i, j, segment, targetDuration;
if (!this.media_) {
return 0;
......@@ -424,17 +424,46 @@
return 0;
}
for (i = 0; i < this.media_.segments.length; i++) {
time -= Playlist.duration(this.media_,
this.media_.mediaSequence + i,
this.media_.mediaSequence + i + 1,
false);
// HLS version 3 and lower round segment durations to the
// nearest decimal integer. When the correct media index is
// ambiguous, prefer the higher one.
if (time <= 0) {
return i;
// 1) Walk backward until we find the latest segment with timeline
// information that is earlier than `time`
targetDuration = this.media_.targetDuration || 10;
i = this.media_.segments.length;
while (i--) {
segment = this.media_.segments[i];
if (segment.end !== undefined && segment.end <= time) {
time -= segment.end;
break;
}
if (segment.start !== undefined && segment.start < time) {
if (segment.end !== undefined && segment.end > time) {
// we've found the target segment exactly
return i;
}
time -= segment.start;
time -= segment.duration || targetDuration;
break;
}
}
i++;
// 2) Walk forward, testing each segment to see if `time` falls within it
for (j = i; j < this.media_.segments.length; j++) {
segment = this.media_.segments[j];
time -= segment.duration || targetDuration;
if (time < 0) {
return j;
}
// 2a) If we discover a segment that has timeline information
// before finding the result segment, the playlist information
// must have been inaccurate. Start a binary search for the
// segment which contains `time`. If the guess turns out to be
// incorrect, we'll have more info to work with next time.
if (segment.start !== undefined || segment.end !== undefined) {
return Math.floor((j - i) * 0.5);
}
}
......
......@@ -5,7 +5,7 @@
'use strict';
var DEFAULT_TARGET_DURATION = 10;
var accumulateDuration, ascendingNumeric, duration, intervalDuration, optionalMin, optionalMax, rangeDuration, seekable;
var duration, intervalDuration, optionalMin, optionalMax, seekable;
// Math.min that will return the alternative input if one of its
// parameters in undefined
......@@ -23,133 +23,6 @@
return Math.max(left, right);
};
// Array.sort comparator to sort numbers in ascending order
ascendingNumeric = function(left, right) {
return left - right;
};
/**
* Returns the media duration for the segments between a start and
* exclusive end index. The start and end parameters are interpreted
* as indices into the currently available segments. This method
* does not calculate durations for segments that have expired.
* @param playlist {object} a media playlist object
* @param start {number} an inclusive lower boundary for the
* segments to examine.
* @param end {number} an exclusive upper boundary for the segments
* to examine.
* @param includeTrailingTime {boolean} if false, the interval between
* the final segment and the subsequent segment will not be included
* in the result
* @return {number} the duration between the start index and end
* index in seconds.
*/
accumulateDuration = function(playlist, start, end, includeTrailingTime) {
var
ranges = [],
rangeEnds = (playlist.discontinuityStarts || []).concat(end),
result = 0,
i;
// short circuit if start and end don't specify a non-empty range
// of segments
if (start >= end) {
return 0;
}
// create a range object for each discontinuity sequence
rangeEnds.sort(ascendingNumeric);
for (i = 0; i < rangeEnds.length; i++) {
if (rangeEnds[i] > start) {
ranges.push({ start: start, end: rangeEnds[i] });
i++;
break;
}
}
for (; i < rangeEnds.length; i++) {
// ignore times ranges later than end
if (rangeEnds[i] >= end) {
ranges.push({ start: rangeEnds[i - 1], end: end });
break;
}
ranges.push({ start: ranges[ranges.length - 1].end, end: rangeEnds[i] });
}
// add up the durations for each of the ranges
for (i = 0; i < ranges.length; i++) {
result += rangeDuration(playlist,
ranges[i],
i === ranges.length - 1 && includeTrailingTime);
}
return result;
};
/**
* Returns the duration of the specified range of segments. The
* range *must not* cross a discontinuity.
* @param playlist {object} a media playlist object
* @param range {object} an object that specifies a starting and
* ending index into the available segments.
* @param includeTrailingTime {boolean} if false, the interval between
* the final segment and the subsequent segment will not be included
* in the result
* @return {number} the duration of the range in seconds.
*/
rangeDuration = function(playlist, range, includeTrailingTime) {
var
result = 0,
targetDuration = playlist.targetDuration || DEFAULT_TARGET_DURATION,
segment,
left, right;
// accumulate while searching for the earliest segment with
// available PTS information
for (left = range.start; left < range.end; left++) {
segment = playlist.segments[left];
if (segment.minVideoPts !== undefined ||
segment.minAudioPts !== undefined) {
break;
}
result += segment.duration || targetDuration;
}
// see if there's enough information to include the trailing time
if (includeTrailingTime) {
segment = playlist.segments[range.end];
if (segment &&
(segment.minVideoPts !== undefined ||
segment.minAudioPts !== undefined)) {
result += 0.001 *
(optionalMin(segment.minVideoPts, segment.minAudioPts) -
optionalMin(playlist.segments[left].minVideoPts,
playlist.segments[left].minAudioPts));
return result;
}
}
// do the same thing while finding the latest segment
for (right = range.end - 1; right >= left; right--) {
segment = playlist.segments[right];
if (segment.maxVideoPts !== undefined ||
segment.maxAudioPts !== undefined) {
break;
}
result += segment.duration || targetDuration;
}
// add in the PTS interval in seconds between them
if (right >= left) {
result += 0.001 *
(optionalMax(playlist.segments[right].maxVideoPts,
playlist.segments[right].maxAudioPts) -
optionalMin(playlist.segments[left].minVideoPts,
playlist.segments[left].minAudioPts));
}
return result;
};
/**
* Calculate the media duration from the segments associated with a
* playlist. The duration of a subinterval of the available segments
......@@ -160,14 +33,11 @@
* boundary for the playlist. Defaults to 0.
* @param endSequence {number} (optional) an exclusive upper boundary
* for the playlist. Defaults to playlist length.
* @param includeTrailingTime {boolean} if false, the interval between
* the final segment and the subsequent segment will not be included
* in the result
* @return {number} the duration between the start index and end
* index.
*/
intervalDuration = function(playlist, startSequence, endSequence, includeTrailingTime) {
var result = 0, targetDuration, expiredSegmentCount;
intervalDuration = function(playlist, startSequence, endSequence) {
var result = 0, targetDuration, i, start, end, expiredSegmentCount;
if (startSequence === undefined) {
startSequence = playlist.mediaSequence || 0;
......@@ -177,16 +47,26 @@
}
targetDuration = playlist.targetDuration || DEFAULT_TARGET_DURATION;
// estimate expired segment duration using the target duration
// accumulate while looking for the latest known segment-timeline mapping
expiredSegmentCount = optionalMax(playlist.mediaSequence - startSequence, 0);
result += expiredSegmentCount * targetDuration;
start = startSequence + expiredSegmentCount - playlist.mediaSequence;
end = endSequence - playlist.mediaSequence;
for (i = end - 1; i >= start; i--) {
if (playlist.segments[i].end !== undefined) {
result += playlist.segments[i].end;
return result;
}
// accumulate the segment durations into the result
result += accumulateDuration(playlist,
startSequence + expiredSegmentCount - playlist.mediaSequence,
endSequence - playlist.mediaSequence,
includeTrailingTime);
result += playlist.segments[i].duration || targetDuration;
if (playlist.segments[i].start !== undefined) {
result += playlist.segments[i].start;
return result;
}
}
// neither a start or end time was found in the interval so we
// have to estimate the expired duration
result += expiredSegmentCount * targetDuration;
return result;
};
......
......@@ -71,6 +71,9 @@ videojs.Hls = videojs.extend(Component, {
this.on(this.tech_, 'seeking', function() {
this.setCurrentTime(this.tech_.currentTime());
});
this.on(this.tech_, 'error', function() {
this.stopCheckingBuffer_();
});
this.on(this.tech_, 'play', this.play);
}
......@@ -146,12 +149,6 @@ videojs.Hls.prototype.src = function(src) {
// load the MediaSource into the player
this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen.bind(this));
// The index of the next segment to be downloaded in the current
// media playlist. When the current media playlist is live with
// expiring segments, it may be a different value from the media
// sequence number for a segment.
this.mediaIndex = 0;
this.options_ = {};
if (this.source_.withCredentials !== undefined) {
this.options_.withCredentials = this.source_.withCredentials;
......@@ -161,9 +158,6 @@ videojs.Hls.prototype.src = function(src) {
this.playlists = new videojs.Hls.PlaylistLoader(this.source_.src, this.options_.withCredentials);
this.playlists.on('loadedmetadata', function() {
var selectedPlaylist, loaderHandler, oldBitrate, newBitrate, segmentDuration,
segmentDlTime, threshold;
oldMediaPlaylist = this.playlists.media();
// if this isn't a live video and preload permits, start
......@@ -174,56 +168,10 @@ videojs.Hls.prototype.src = function(src) {
this.loadingState_ = 'segments';
}
// the bandwidth estimate for the first segment is based on round
// trip time for the master playlist. the master playlist is
// almost always tiny so the round-trip time is dominated by
// latency and the computed bandwidth is much lower than
// steady-state. if the the downstream developer has a better way
// of detecting bandwidth and provided a number, use that instead.
if (this.bandwidth === undefined) {
// we're going to have to estimate initial bandwidth
// ourselves. scale the bandwidth estimate to account for the
// relatively high round-trip time from the master playlist.
this.setBandwidth({
bandwidth: this.playlists.bandwidth * 5
});
}
this.setupSourceBuffer_();
selectedPlaylist = this.selectPlaylist();
oldBitrate = oldMediaPlaylist.attributes &&
oldMediaPlaylist.attributes.BANDWIDTH || 0;
newBitrate = selectedPlaylist.attributes &&
selectedPlaylist.attributes.BANDWIDTH || 0;
segmentDuration = oldMediaPlaylist.segments &&
oldMediaPlaylist.segments[this.mediaIndex].duration ||
oldMediaPlaylist.targetDuration;
segmentDlTime = (segmentDuration * newBitrate) / this.bandwidth;
if (!segmentDlTime) {
segmentDlTime = Infinity;
}
// this threshold is to account for having a high latency on the manifest
// request which is a somewhat small file.
threshold = 10;
if (newBitrate > oldBitrate && segmentDlTime <= threshold) {
this.playlists.media(selectedPlaylist);
loaderHandler = function() {
this.setupFirstPlay();
this.fillBuffer();
this.tech_.trigger('loadedmetadata');
this.playlists.off('loadedplaylist', loaderHandler);
}.bind(this);
this.playlists.on('loadedplaylist', loaderHandler);
} else {
this.setupFirstPlay();
this.fillBuffer();
this.tech_.trigger('loadedmetadata');
}
this.setupFirstPlay();
this.fillBuffer();
this.tech_.trigger('loadedmetadata');
}.bind(this));
this.playlists.on('error', function() {
......@@ -247,7 +195,6 @@ videojs.Hls.prototype.src = function(src) {
}
this.updateDuration(this.playlists.media());
this.mediaIndex = videojs.Hls.translateMediaIndex(this.mediaIndex, oldMediaPlaylist, updatedPlaylist);
oldMediaPlaylist = updatedPlaylist;
this.fetchKeys_();
......@@ -305,6 +252,48 @@ videojs.Hls.prototype.handleSourceOpen = function() {
}
};
// Returns the array of time range edge objects that were additively
// modified between two TimeRanges.
var bufferedAdditions = function(original, update) {
var result = [], edges = [],
i, inOriginalRanges;
// create a sorted array of time range start and end times
for (i = 0; i < original.length; i++) {
edges.push({ original: true, start: original.start(i) });
edges.push({ original: true, end: original.end(i) });
}
for (i = 0; i < update.length; i++) {
edges.push({ start: update.start(i) });
edges.push({ end: update.end(i) });
}
edges.sort(function(left, right) {
var leftTime, rightTime;
leftTime = left.start !== undefined ? left.start : left.end;
rightTime = right.start !== undefined ? right.start : right.end;
return leftTime - rightTime;
});
// filter out all time range edges that occur during a period that
// was already covered by `original`
inOriginalRanges = false;
for (i = 0; i < edges.length; i++) {
// if this is a transition point for `original`, track whether
// subsequent edges are additions
if (edges[i].original) {
inOriginalRanges = edges[i].start !== undefined;
continue;
}
// if we're in a time range that was in `original`, ignore this edge
if (inOriginalRanges) {
continue;
}
// this edge occurred outside the range of `original`
result.push(edges[i]);
}
return result;
};
videojs.Hls.prototype.setupSourceBuffer_ = function() {
var media = this.playlists.media(), mimeType;
......@@ -325,12 +314,13 @@ 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;
var segmentInfo = this.pendingSegment_, segment, i, currentBuffered, timelineUpdates;
this.pendingSegment_ = null;
if (this.duration() !== Infinity &&
this.mediaIndex === this.playlists.media().segments.length) {
// if we've buffered to the end of the video, let the MediaSource know
currentBuffered = this.findCurrentBuffered_();
if (currentBuffered.length && this.duration() === currentBuffered.end(0)) {
this.mediaSource.endOfStream();
}
......@@ -345,13 +335,31 @@ videojs.Hls.prototype.setupSourceBuffer_ = function() {
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
currentBuffered = this.findCurrentBuffered_();
this.playlists.updateTimelineOffset(segmentInfo.mediaIndex, this.tech_.buffered().start(i));
this.mediaIndex = this.playlists.getMediaIndexForTime_(currentBuffered.end(0) + 1);
this.playlists.updateTimelineOffset(segmentInfo.mediaIndex,
this.tech_.buffered().start(i));
break;
}
}
}
if (!segmentInfo) {
return;
}
// annotate the segment with any start and end time information
// added by the media processing
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
timelineUpdates = bufferedAdditions(segmentInfo.buffered,
this.tech_.buffered());
timelineUpdates.forEach(function(update) {
if (update.start !== undefined) {
segment.start = update.start;
}
if (update.end !== undefined) {
segment.end = update.end;
}
});
}.bind(this));
};
......@@ -470,14 +478,13 @@ videojs.Hls.prototype.setupFirstPlay = function() {
};
/**
* Reset the mediaIndex if play() is called after the video has
* ended.
* Begin playing the video.
*/
videojs.Hls.prototype.play = function() {
this.loadingState_ = 'segments';
if (this.tech_.ended()) {
this.mediaIndex = 0;
this.tech_.setCurrentTime(0);
}
if (this.tech_.played().length === 0) {
......@@ -514,9 +521,6 @@ videojs.Hls.prototype.setCurrentTime = function(currentTime) {
return currentTime;
}
// determine the requested segment
this.mediaIndex = this.playlists.getMediaIndexForTime_(currentTime);
// cancel outstanding requests and buffer appends
this.cancelSegmentXhr();
......@@ -530,7 +534,7 @@ videojs.Hls.prototype.setCurrentTime = function(currentTime) {
this.segmentBuffer_ = [];
// begin filling the buffer at the new position
this.fillBuffer(currentTime * 1000);
this.fillBuffer(currentTime);
};
videojs.Hls.prototype.duration = function() {
......@@ -785,7 +789,7 @@ videojs.Hls.prototype.findCurrentBuffered_ = function() {
if (buffered && buffered.length) {
// Search for a range containing the play-head
for (i = 0;i < buffered.length; i++) {
for (i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= currentTime &&
buffered.end(i) >= currentTime) {
ranges = videojs.createTimeRanges(buffered.start(i), buffered.end(i));
......@@ -805,14 +809,15 @@ videojs.Hls.prototype.findCurrentBuffered_ = function() {
* 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
* to seek to, in milliseconds
* to seek to, in seconds
*/
videojs.Hls.prototype.fillBuffer = function(seekToTime) {
var
tech = this.tech_,
currentTime = tech.currentTime(),
buffered = this.findCurrentBuffered_(),
currentBuffered = this.findCurrentBuffered_(),
bufferedTime = 0,
mediaIndex = 0,
segment,
segmentUri;
......@@ -831,6 +836,11 @@ videojs.Hls.prototype.fillBuffer = function(seekToTime) {
return;
}
// wait until the buffer is up to date
if (this.segmentBuffer_.length || this.pendingSegment_) {
return;
}
// if no segments are available, do nothing
if (this.playlists.state === "HAVE_NOTHING" ||
!this.playlists.media() ||
......@@ -843,28 +853,33 @@ videojs.Hls.prototype.fillBuffer = function(seekToTime) {
return;
}
// find the next segment to download
if (typeof seekToTime === 'number') {
mediaIndex = this.playlists.getMediaIndexForTime_(seekToTime);
} else if (currentBuffered && currentBuffered.length) {
mediaIndex = this.playlists.getMediaIndexForTime_(currentBuffered.end(0));
bufferedTime = Math.max(0, currentBuffered.end(0) - currentTime);
} else {
mediaIndex = this.playlists.getMediaIndexForTime_(this.tech_.currentTime());
}
segment = this.playlists.media().segments[mediaIndex];
// if the video has finished downloading, stop trying to buffer
segment = this.playlists.media().segments[this.mediaIndex];
if (!segment) {
return;
}
// To determine how much is buffered, we need to find the buffered region we
// are currently playing in and measure it's length
if (buffered && buffered.length) {
bufferedTime = Math.max(0, buffered.end(0) - currentTime);
}
// if there is plenty of content in the buffer and we're not
// seeking, relax for awhile
if (typeof seekToTime !== 'number' && bufferedTime >= videojs.Hls.GOAL_BUFFER_LENGTH) {
if (typeof seekToTime !== 'number' &&
bufferedTime >= videojs.Hls.GOAL_BUFFER_LENGTH) {
return;
}
// resolve the segment URL relative to the playlist
segmentUri = this.playlistUriToUrl(segment.uri);
this.loadSegment(segmentUri, seekToTime);
this.loadSegment(segmentUri, mediaIndex, seekToTime);
};
videojs.Hls.prototype.playlistUriToUrl = function(segmentRelativeUrl) {
......@@ -895,7 +910,7 @@ videojs.Hls.prototype.setBandwidth = function(xhr) {
this.tech_.trigger('bandwidthupdate');
};
videojs.Hls.prototype.loadSegment = function(segmentUri, seekToTime) {
videojs.Hls.prototype.loadSegment = function(segmentUri, mediaIndex, seekToTime) {
var self = this;
// request the next segment
......@@ -915,17 +930,15 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, seekToTime) {
return self.playlists.media(self.selectPlaylist());
}
// otherwise, trigger a network error
if (!request.aborted && error) {
// otherwise, try jumping ahead to the next segment
self.error = {
status: request.status,
message: 'HLS segment request error at URL: ' + segmentUri,
code: (request.status >= 500) ? 4 : 2
};
// try moving on to the next segment
self.mediaIndex++;
return;
return self.mediaSource.endOfStream('network');
}
// stop processing if the request was aborted
......@@ -938,7 +951,7 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, seekToTime) {
// package up all the work to append the segment
segmentInfo = {
// the segment's mediaIndex at the time it was received
mediaIndex: self.mediaIndex,
mediaIndex: mediaIndex,
// the segment's playlist
playlist: self.playlists.media(),
// optionally, a time offset to seek to within the segment
......@@ -951,9 +964,13 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, seekToTime) {
decrypter: null,
// metadata events discovered during muxing that need to be
// translated into cue points
pendingMetadata: []
pendingMetadata: [],
// the state of the buffer before a segment is appended will be
// stored here so that the actual segment duration can be
// determined after it has been appended
buffered: null
};
if (segmentInfo.playlist.segments[segmentInfo.mediaIndex].key) {
if (segmentInfo.playlist.segments[mediaIndex].key) {
segmentInfo.encryptedBytes = new Uint8Array(request.response);
} else {
segmentInfo.bytes = new Uint8Array(request.response);
......@@ -962,8 +979,6 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, seekToTime) {
self.tech_.trigger('progress');
self.drainBuffer();
self.mediaIndex++;
// figure out what stream the next segment should be downloaded from
// with the updated bandwidth information
self.playlists.media(self.selectPlaylist());
......@@ -1098,8 +1113,15 @@ videojs.Hls.prototype.drainBuffer = function(event) {
}
// the segment is asynchronously added to the current buffered data
this.sourceBuffer.appendBuffer(bytes);
if (currentBuffered.length) {
this.sourceBuffer.videoBuffer_.appendWindowStart = Math.min(this.tech_.currentTime(), currentBuffered.end(0));
} else if (this.sourceBuffer.videoBuffer_) {
this.sourceBuffer.videoBuffer_.appendWindowStart = 0;
}
this.pendingSegment_ = segmentBuffer.shift();
this.pendingSegment_.buffered = this.tech_.buffered();
this.sourceBuffer.appendBuffer(bytes);
};
/**
......@@ -1228,45 +1250,6 @@ videojs.Hls.getPlaylistTotalDuration = function(playlist) {
};
/**
* Determine the media index in one playlist that corresponds to a
* specified media index in another. This function can be used to
* calculate a new segment position when a playlist is reloaded or a
* variant playlist is becoming active.
* @param mediaIndex {number} the index into the original playlist
* to translate
* @param original {object} the playlist to translate the media
* index from
* @param update {object} the playlist to translate the media index
* to
* @param {number} the corresponding media index in the updated
* playlist
*/
videojs.Hls.translateMediaIndex = function(mediaIndex, original, update) {
var translatedMediaIndex;
// no segments have been loaded from the original playlist
if (mediaIndex === 0) {
return 0;
}
if (!(update && update.segments)) {
// let the media index be zero when there are no segments defined
return 0;
}
// translate based on media sequence numbers. syncing up across
// bitrate switches should be happening here.
translatedMediaIndex = (mediaIndex + (original.mediaSequence - update.mediaSequence));
if (translatedMediaIndex > update.segments.length || translatedMediaIndex < 0) {
// recalculate the live point if the streams are too far out of sync
return videojs.Hls.getMediaIndexForLive_(update) + 1;
}
return translatedMediaIndex;
};
/**
* Deprecated.
*
* @deprecated use player.hls.playlists.getMediaIndexForTime_() instead
......