d2c53be3 by David LaPalomento

Merge pull request #411 from dmlap/next-segment-calculation

Determine the segment to load by looking at buffered
2 parents e3c7a1bc 0de3d793
(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;
}
};
......
......@@ -2,13 +2,7 @@
* 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 looks like this:
*
* |-- expired --|-- segments --|
* M3U8 playlists.
*
*/
(function(window, videojs) {
......@@ -16,7 +10,6 @@
var
resolveUrl = videojs.Hls.resolveUrl,
xhr = videojs.Hls.xhr,
Playlist = videojs.Hls.Playlist,
mergeOptions = videojs.mergeOptions,
/**
......@@ -158,14 +151,6 @@
// initialize the loader state
loader.state = 'HAVE_NOTHING';
// 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.expired_ = 0;
// capture the prototype dispose function
dispose = this.dispose;
......@@ -187,20 +172,20 @@
* active media playlist. When called with a single argument,
* triggers the playlist loader to asynchronously switch to the
* specified media playlist. Calling this method while the
* loader is in the HAVE_NOTHING or HAVE_MASTER states causes an
* error to be emitted but otherwise has no effect.
* loader is in the HAVE_NOTHING causes an error to be emitted
* but otherwise has no effect.
* @param playlist (optional) {object} the parsed media playlist
* object to switch to
*/
loader.media = function(playlist) {
var mediaChange = false;
var startingState = loader.state, mediaChange;
// getter
if (!playlist) {
return loader.media_;
}
// setter
if (loader.state === 'HAVE_NOTHING' || loader.state === 'HAVE_MASTER') {
if (loader.state === 'HAVE_NOTHING') {
throw new Error('Cannot switch media playlist from ' + loader.state);
}
......@@ -213,7 +198,7 @@
playlist = loader.master.playlists[playlist];
}
mediaChange = playlist.uri !== loader.media_.uri;
mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri;
// switch to fully loaded playlists immediately
if (loader.master.playlists[playlist.uri].endList) {
......@@ -258,7 +243,17 @@
withCredentials: withCredentials
}, function(error, request) {
haveMetadata(error, request, playlist.uri);
loader.trigger('mediachange');
if (error) {
return;
}
// fire loadedmetadata the first time a media playlist is loaded
if (startingState === 'HAVE_MASTER') {
loader.trigger('loadedmetadata');
} else {
loader.trigger('mediachange');
}
});
};
......@@ -320,19 +315,13 @@
loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i];
}
request = xhr({
uri: resolveUrl(srcUrl, parser.manifest.playlists[0].uri),
withCredentials: withCredentials
}, function(error, request) {
// pass along the URL specified in the master playlist
haveMetadata(error,
request,
parser.manifest.playlists[0].uri);
if (!error) {
loader.trigger('loadedmetadata');
}
});
return loader.trigger('loadedplaylist');
loader.trigger('loadedplaylist');
if (!request) {
// no media playlist was specifically selected so start
// from the first listed one
loader.media(parser.manifest.playlists[0]);
}
return;
}
// loaded a media playlist
......@@ -356,43 +345,10 @@
* @param update {object} the updated media playlist object
*/
PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) {
var expiredCount;
if (this.media_) {
expiredCount = update.mediaSequence - this.media_.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
......@@ -411,7 +367,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;
......@@ -419,28 +375,61 @@
// when the requested position is earlier than the current set of
// segments, return the earliest segment index
time -= this.expired_;
if (time < 0) {
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;
if (time < 0) {
// the segment with start information is also our best guess
// for the momment
return i;
}
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);
}
}
// the playback position is outside the range of available
// segments so return the last one
return this.media_.segments.length - 1;
// segments so return the length
return this.media_.segments.length;
};
videojs.Hls.PlaylistLoader = PlaylistLoader;
......
......@@ -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,170 +23,49 @@
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
* may be calculated by specifying a start and end index.
* may be calculated by specifying an end index.
*
* @param playlist {object} a media playlist object
* @param startSequence {number} (optional) an inclusive lower
* 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, endSequence) {
var result = 0, segment, targetDuration, i;
if (startSequence === undefined) {
startSequence = playlist.mediaSequence || 0;
}
if (endSequence === undefined) {
endSequence = startSequence + (playlist.segments || []).length;
endSequence = playlist.mediaSequence + (playlist.segments || []).length;
}
if (endSequence < 0) {
return 0;
}
targetDuration = playlist.targetDuration || DEFAULT_TARGET_DURATION;
// estimate expired segment duration using the target duration
expiredSegmentCount = optionalMax(playlist.mediaSequence - startSequence, 0);
result += expiredSegmentCount * targetDuration;
i = endSequence - playlist.mediaSequence;
// if a start time is available for segment immediately following
// the interval, use it
segment = playlist.segments[i];
// Walk backward until we find the latest segment with timeline
// information that is earlier than endSequence
if (segment && segment.start !== undefined) {
return segment.start;
}
while (i--) {
segment = playlist.segments[i];
if (segment.end !== undefined) {
return result + segment.end;
}
// accumulate the segment durations into the result
result += accumulateDuration(playlist,
startSequence + expiredSegmentCount - playlist.mediaSequence,
endSequence - playlist.mediaSequence,
includeTrailingTime);
result += (segment.duration || targetDuration);
if (segment.start !== undefined) {
return result + segment.start;
}
}
return result;
};
......@@ -196,17 +75,16 @@
* timeline between those two indices. The total duration for live
* playlists is always Infinity.
* @param playlist {object} a media playlist object
* @param startSequence {number} (optional) an inclusive lower
* 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} (optional) if false, the interval between
* the final segment and the subsequent segment will not be included
* in the result
* @param endSequence {number} (optional) an exclusive upper
* boundary for the playlist. Defaults to the playlist media
* sequence number plus its length.
* @param includeTrailingTime {boolean} (optional) 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.
*/
duration = function(playlist, startSequence, endSequence, includeTrailingTime) {
duration = function(playlist, endSequence, includeTrailingTime) {
if (!playlist) {
return 0;
}
......@@ -217,7 +95,7 @@
// if a slice of the total duration is not requested, use
// playlist-level duration indicators when they're present
if (startSequence === undefined && endSequence === undefined) {
if (endSequence === undefined) {
// if present, use the duration specified in the playlist
if (playlist.totalDuration) {
return playlist.totalDuration;
......@@ -231,7 +109,6 @@
// calculate the total duration based on the segment durations
return intervalDuration(playlist,
startSequence,
endSequence,
includeTrailingTime);
};
......@@ -248,7 +125,7 @@
* for seeking
*/
seekable = function(playlist) {
var start, end, liveBuffer, targetDuration, segment, pending, i;
var start, end;
// without segments, there are no seekable ranges
if (!playlist.segments) {
......@@ -259,33 +136,14 @@
return videojs.createTimeRange(0, duration(playlist));
}
start = 0;
end = intervalDuration(playlist,
playlist.mediaSequence,
playlist.mediaSequence + playlist.segments.length);
targetDuration = playlist.targetDuration || DEFAULT_TARGET_DURATION;
// live playlists should not expose three segment durations worth
// of content from the end of the playlist
// https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-6.3.3
if (!playlist.endList) {
liveBuffer = targetDuration * 3;
// walk backward from the last available segment and track how
// much media time has elapsed until three target durations have
// been traversed. if a segment is part of the interval being
// reported, subtract the overlapping portion of its duration
// from the result.
for (i = playlist.segments.length - 1; i >= 0 && liveBuffer > 0; i--) {
segment = playlist.segments[i];
pending = optionalMin(duration(playlist,
playlist.mediaSequence + i,
playlist.mediaSequence + i + 1),
liveBuffer);
liveBuffer -= pending;
end -= pending;
}
}
start = intervalDuration(playlist, playlist.mediaSequence);
end = intervalDuration(playlist,
playlist.mediaSequence + playlist.segments.length);
end -= (playlist.targetDuration || DEFAULT_TARGET_DURATION) * 3;
end = Math.max(0, end);
return videojs.createTimeRange(start, end);
};
......
......@@ -15,7 +15,6 @@ var
// the amount of time to wait between checking the state of the buffer
bufferCheckInterval = 500,
keyXhr,
keyFailed,
resolveUrl;
......@@ -46,6 +45,8 @@ videojs.Hls = videojs.extend(Component, {
this.tech_ = tech;
this.source_ = options.source;
this.mode_ = options.mode;
// the segment info object for a segment that is in the process of
// being downloaded or processed
this.pendingSegment_ = null;
this.bytesReceived = 0;
......@@ -61,9 +62,6 @@ videojs.Hls = videojs.extend(Component, {
this.loadingState_ = 'meta';
}
// a queue of segments that need to be transmuxed and processed,
// and then fed to the source buffer
this.segmentBuffer_ = [];
// periodically check if new data needs to be downloaded or
// buffered data should be appended to the source buffer
this.startCheckingBuffer_();
......@@ -71,6 +69,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);
}
......@@ -137,21 +138,10 @@ videojs.Hls.prototype.src = function(src) {
}
this.mediaSource = new videojs.MediaSource({ mode: this.mode_ });
this.segmentBuffer_ = [];
// if the stream contains ID3 metadata, expose that as a metadata
// text track
//this.setupMetadataCueTranslation_();
// 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 +151,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 +161,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() {
......@@ -242,24 +183,20 @@ videojs.Hls.prototype.src = function(src) {
var updatedPlaylist = this.playlists.media();
if (!updatedPlaylist) {
// do nothing before an initial media playlist has been activated
// select the initial variant
this.playlists.media(this.selectPlaylist());
return;
}
this.updateDuration(this.playlists.media());
this.mediaIndex = videojs.Hls.translateMediaIndex(this.mediaIndex, oldMediaPlaylist, updatedPlaylist);
oldMediaPlaylist = updatedPlaylist;
this.fetchKeys_();
}.bind(this));
this.playlists.on('mediachange', function() {
// abort outstanding key requests and check if new keys need to be retrieved
if (keyXhr) {
this.cancelKeyXhr();
}
this.tech_.trigger({ type: 'mediachange', bubbles: true });
this.tech_.trigger({
type: 'mediachange',
bubbles: true
});
}.bind(this));
// do nothing if the tech has been disposed already
......@@ -271,26 +208,6 @@ videojs.Hls.prototype.src = function(src) {
this.tech_.src(videojs.URL.createObjectURL(this.mediaSource));
};
/* Returns the media index for the live point in the current playlist, and updates
the current time to go along with it.
*/
videojs.Hls.getMediaIndexForLive_ = function(selectedPlaylist) {
if (!selectedPlaylist.segments) {
return 0;
}
var tailIterator = selectedPlaylist.segments.length,
tailDuration = 0,
targetTail = (selectedPlaylist.targetDuration || 10) * 3;
while (tailDuration < targetTail && tailIterator > 0) {
tailDuration += selectedPlaylist.segments[tailIterator - 1].duration;
tailIterator--;
}
return tailIterator;
};
videojs.Hls.prototype.handleSourceOpen = function() {
// Only attempt to create the source buffer if none already exist.
// handleSourceOpen is also called when we are "re-opening" a source buffer
......@@ -310,6 +227,63 @@ videojs.Hls.prototype.handleSourceOpen = function() {
}
};
// Returns the array of time range edge objects that were additively
// modified between two TimeRanges.
videojs.Hls.bufferedAdditions_ = function(original, update) {
var result = [], edges = [],
i, inOriginalRanges;
// if original or update are falsey, return an empty list of
// additions
if (!original || !update) {
return result;
}
// 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;
// when two times are equal, ensure the original edge covers the
// update
if (leftTime === rightTime) {
if (left.original) {
return left.start !== undefined ? -1 : 1;
}
return right.start !== undefined ? -1 : 1;
}
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;
......@@ -330,117 +304,42 @@ 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, 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();
}
// 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
currentBuffered = this.findCurrentBuffered_();
this.playlists.updateTimelineOffset(segmentInfo.mediaIndex, this.tech_.buffered().start(i));
this.mediaIndex = this.playlists.getMediaIndexForTime_(currentBuffered.end(0) + 1);
break;
}
}
// stop here if the update errored or was aborted
if (!segmentInfo) {
return;
}
}.bind(this));
};
// register event listeners to transform in-band metadata events into
// VTTCues on a text track
videojs.Hls.prototype.setupMetadataCueTranslation_ = function() {
var
metadataStream = this.segmentParser_.metadataStream,
textTrack;
// add a metadata cue whenever a metadata event is triggered during
// segment parsing
metadataStream.on('data', function(metadata) {
var i, hexDigit;
// create the metadata track if this is the first ID3 tag we've
// seen
if (!textTrack) {
textTrack = this.tech_.addTextTrack('metadata', 'Timed Metadata');
// build the dispatch type from the stream descriptor
// https://html.spec.whatwg.org/multipage/embedded-content.html#steps-to-expose-a-media-resource-specific-text-track
textTrack.inBandMetadataTrackDispatchType = videojs.Hls.SegmentParser.STREAM_TYPES.metadata.toString(16).toUpperCase();
for (i = 0; i < metadataStream.descriptor.length; i++) {
hexDigit = ('00' + metadataStream.descriptor[i].toString(16).toUpperCase()).slice(-2);
textTrack.inBandMetadataTrackDispatchType += hexDigit;
// annotate the segment with any start and end time information
// added by the media processing
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
timelineUpdates = videojs.Hls.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;
}
}
// store this event for processing once the muxing has finished
this.tech_.segmentBuffer_[0].pendingMetadata.push({
textTrack: textTrack,
metadata: metadata
});
}.bind(this));
// when seeking, clear out all cues ahead of the earliest position
// in the new segment. keep earlier cues around so they can still be
// programmatically inspected even though they've already fired
this.on(this.tech_, 'seeking', function() {
var media, startTime, i;
if (!textTrack) {
return;
if (timelineUpdates.length) {
this.updateDuration(segmentInfo.playlist);
}
media = this.playlists.media();
startTime = this.tech_.playlists.expired_;
startTime += videojs.Hls.Playlist.duration(media,
media.mediaSequence,
media.mediaSequence + this.tech_.mediaIndex);
i = textTrack.cues.length;
while (i--) {
if (textTrack.cues[i].startTime >= startTime) {
textTrack.removeCue(textTrack.cues[i]);
}
}
});
};
videojs.Hls.prototype.addCuesForMetadata_ = function(segmentInfo) {
var i, cue, frame, metadata, minPts, segment, segmentOffset, textTrack, time;
segmentOffset = this.playlists.expired_;
segmentOffset += videojs.Hls.Playlist.duration(segmentInfo.playlist,
segmentInfo.playlist.mediaSequence,
segmentInfo.playlist.mediaSequence + segmentInfo.mediaIndex);
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
minPts = Math.min(isFinite(segment.minVideoPts) ? segment.minVideoPts : Infinity,
isFinite(segment.minAudioPts) ? segment.minAudioPts : Infinity);
while (segmentInfo.pendingMetadata.length) {
metadata = segmentInfo.pendingMetadata[0].metadata;
textTrack = segmentInfo.pendingMetadata[0].textTrack;
// create cue points for all the ID3 frames in this metadata event
for (i = 0; i < metadata.frames.length; i++) {
frame = metadata.frames[i];
time = segmentOffset + ((metadata.pts - minPts) * 0.001);
cue = new window.VTTCue(time, time, frame.value || frame.url || '');
cue.frame = frame;
cue.pts_ = metadata.pts;
textTrack.addCue(cue);
}
segmentInfo.pendingMetadata.shift();
}
// check if it's time to download the next segment
this.checkBuffer_();
}.bind(this));
};
/**
......@@ -475,14 +374,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) {
......@@ -519,23 +417,20 @@ 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();
// abort outstanding key requests, if necessary
if (keyXhr) {
keyXhr.aborted = true;
if (this.keyXhr_) {
this.keyXhr_.aborted = true;
this.cancelKeyXhr();
}
// clear out any buffered segments
this.segmentBuffer_ = [];
// clear out the segment being processed
this.pendingSegment_ = null;
// begin filling the buffer at the new position
this.fillBuffer(currentTime * 1000);
this.fillBuffer(currentTime);
};
videojs.Hls.prototype.duration = function() {
......@@ -547,7 +442,7 @@ videojs.Hls.prototype.duration = function() {
};
videojs.Hls.prototype.seekable = function() {
var currentSeekable, startOffset, media;
var media;
if (!this.playlists) {
return videojs.createTimeRanges();
......@@ -557,17 +452,7 @@ videojs.Hls.prototype.seekable = function() {
return videojs.createTimeRanges();
}
// report the seekable range relative to the earliest possible
// position when the stream was first loaded
currentSeekable = videojs.Hls.Playlist.seekable(media);
if (!currentSeekable.length) {
return currentSeekable;
}
startOffset = this.playlists.expired_;
return videojs.createTimeRanges(startOffset,
startOffset + (currentSeekable.end(0) - currentSeekable.start(0)));
return videojs.Hls.Playlist.seekable(media);
};
/**
......@@ -608,10 +493,10 @@ videojs.Hls.prototype.resetSrc_ = function() {
};
videojs.Hls.prototype.cancelKeyXhr = function() {
if (keyXhr) {
keyXhr.onreadystatechange = null;
keyXhr.abort();
keyXhr = null;
if (this.keyXhr_) {
this.keyXhr_.onreadystatechange = null;
this.keyXhr_.abort();
this.keyXhr_ = null;
}
};
......@@ -790,7 +675,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));
......@@ -810,16 +695,17 @@ 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;
segmentInfo;
// if preload is set to "none", do not download segments until playback is requested
if (this.loadingState_ !== 'segments') {
......@@ -836,6 +722,11 @@ videojs.Hls.prototype.fillBuffer = function(seekToTime) {
return;
}
// wait until the buffer is up to date
if (this.pendingSegment_) {
return;
}
// if no segments are available, do nothing
if (this.playlists.state === "HAVE_NOTHING" ||
!this.playlists.media() ||
......@@ -848,28 +739,52 @@ 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);
// package up all the work to append the segment
segmentInfo = {
// resolve the segment URL relative to the playlist
uri: this.playlistUriToUrl(segment.uri),
// the segment's mediaIndex at the time it was received
mediaIndex: mediaIndex,
// the segment's playlist
playlist: this.playlists.media(),
// optionally, a time offset to seek to within the segment
offset: seekToTime,
// unencrypted bytes of the segment
bytes: null,
// when a key is defined for this segment, the encrypted bytes
encryptedBytes: null,
// optionally, the decrypter that is unencrypting the segment
decrypter: null,
// 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
};
this.loadSegment(segmentUri, seekToTime);
this.loadSegment(segmentInfo);
};
videojs.Hls.prototype.playlistUriToUrl = function(segmentRelativeUrl) {
......@@ -900,17 +815,22 @@ videojs.Hls.prototype.setBandwidth = function(xhr) {
this.tech_.trigger('bandwidthupdate');
};
videojs.Hls.prototype.loadSegment = function(segmentUri, seekToTime) {
var self = this;
videojs.Hls.prototype.loadSegment = function(segmentInfo) {
var
self = this,
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
// if the segment is encrypted, request the key
if (segment.key) {
this.fetchKey_(segment);
}
// request the next segment
this.segmentXhr_ = videojs.Hls.xhr({
uri: segmentUri,
uri: segmentInfo.uri,
responseType: 'arraybuffer',
withCredentials: this.source_.withCredentials
}, function(error, request) {
var segmentInfo;
// the segment request is no longer outstanding
self.segmentXhr_ = null;
......@@ -920,17 +840,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,
message: 'HLS segment request error at URL: ' + segmentInfo.uri,
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
......@@ -940,35 +858,15 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, seekToTime) {
self.setBandwidth(request);
// package up all the work to append the segment
segmentInfo = {
// the segment's mediaIndex at the time it was received
mediaIndex: self.mediaIndex,
// the segment's playlist
playlist: self.playlists.media(),
// optionally, a time offset to seek to within the segment
offset: seekToTime,
// unencrypted bytes of the segment
bytes: null,
// when a key is defined for this segment, the encrypted bytes
encryptedBytes: null,
// optionally, the decrypter that is unencrypting the segment
decrypter: null,
// metadata events discovered during muxing that need to be
// translated into cue points
pendingMetadata: []
};
if (segmentInfo.playlist.segments[segmentInfo.mediaIndex].key) {
if (segment.key) {
segmentInfo.encryptedBytes = new Uint8Array(request.response);
} else {
segmentInfo.bytes = new Uint8Array(request.response);
}
self.segmentBuffer_.push(segmentInfo);
self.pendingSegment_ = segmentInfo;
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());
......@@ -988,13 +886,11 @@ videojs.Hls.prototype.drainBuffer = function(event) {
segmentTimestampOffset = 0,
hasBufferedContent = (this.tech_.buffered().length !== 0),
currentBuffered = this.findCurrentBuffered_(),
outsideBufferedRanges = !(currentBuffered && currentBuffered.length),
// ptsTime,
segmentBuffer = this.segmentBuffer_;
outsideBufferedRanges = !(currentBuffered && currentBuffered.length);
// if the buffer is empty or the source buffer hasn't been created
// yet, do nothing
if (!segmentBuffer.length || !this.sourceBuffer) {
if (!this.pendingSegment_ || !this.sourceBuffer) {
return;
}
......@@ -1004,7 +900,7 @@ videojs.Hls.prototype.drainBuffer = function(event) {
return;
}
segmentInfo = segmentBuffer[0];
segmentInfo = this.pendingSegment_;
mediaIndex = segmentInfo.mediaIndex;
playlist = segmentInfo.playlist;
offset = segmentInfo.offset;
......@@ -1017,18 +913,19 @@ videojs.Hls.prototype.drainBuffer = function(event) {
// if the key download failed, we want to skip this segment
// but if the key hasn't downloaded yet, we want to try again later
if (keyFailed(segment.key)) {
return segmentBuffer.shift();
videojs.log.warn('Network error retrieving key from "' +
segment.key.uri + '"');
return this.mediaSource.endOfStream('network');
} else if (!segment.key.bytes) {
// trigger a key request if one is not already in-flight
return this.fetchKeys_();
// waiting for the key bytes, try again later
return;
} else if (segmentInfo.decrypter) {
// decryption is in progress, try again later
return;
} else {
// if the media sequence is greater than 2^32, the IV will be incorrect
// assuming 10s segments, that would be about 1300 years
segIv = segment.key.iv || new Uint32Array([0, 0, 0, mediaIndex + playlist.mediaSequence]);
......@@ -1047,32 +944,6 @@ videojs.Hls.prototype.drainBuffer = function(event) {
event = event || {};
// if (this.segmentParser_.tagsAvailable()) {
// // record PTS information for the segment so we can calculate
// // accurate durations and seek reliably
// if (this.segmentParser_.stats.h264Tags()) {
// segment.minVideoPts = this.segmentParser_.stats.minVideoPts();
// segment.maxVideoPts = this.segmentParser_.stats.maxVideoPts();
// }
// if (this.segmentParser_.stats.aacTags()) {
// segment.minAudioPts = this.segmentParser_.stats.minAudioPts();
// segment.maxAudioPts = this.segmentParser_.stats.maxAudioPts();
// }
// }
// while (this.segmentParser_.tagsAvailable()) {
// tags.push(this.segmentParser_.getNextTag());
// }
this.addCuesForMetadata_(segmentInfo);
//this.updateDuration(this.playlists.media());
// // when we're crossing a discontinuity, inject metadata to indicate
// // that the decoder should be reset appropriately
// if (segment.discontinuity && tags.length) {
// this.tech_.el().vjs_discontinuity();
// }
// If we have seeked into a non-buffered time-range, remove all buffered
// time-ranges because they could have been incorrectly placed originally
if (this.tech_.seeking() && outsideBufferedRanges) {
......@@ -1088,7 +959,7 @@ videojs.Hls.prototype.drainBuffer = function(event) {
// anew on every seek
if (segmentInfo.playlist.discontinuityStarts.length) {
if (segmentInfo.mediaIndex > 0) {
segmentTimestampOffset = videojs.Hls.Playlist.duration(segmentInfo.playlist, 0, segmentInfo.mediaIndex);
segmentTimestampOffset = videojs.Hls.Playlist.duration(segmentInfo.playlist, segmentInfo.mediaIndex);
}
// Now that the forward buffer is clear, we have to set timestamp offset to
......@@ -1102,44 +973,46 @@ videojs.Hls.prototype.drainBuffer = function(event) {
this.sourceBuffer.timestampOffset = currentBuffered.end(0);
}
if (currentBuffered.length) {
// Chrome 45 stalls if appends overlap the playhead
this.sourceBuffer.appendWindowStart = Math.min(this.tech_.currentTime(), currentBuffered.end(0));
} else {
this.sourceBuffer.appendWindowStart = 0;
}
this.pendingSegment_.buffered = this.tech_.buffered();
// the segment is asynchronously added to the current buffered data
this.sourceBuffer.appendBuffer(bytes);
this.pendingSegment_ = segmentBuffer.shift();
};
/**
* Attempt to retrieve keys starting at a particular media
* segment. This method has no effect if segments are not yet
* available or a key request is already in progress.
*
* @param playlist {object} the media playlist to fetch keys for
* @param index {number} the media segment index to start from
* Attempt to retrieve the key for a particular media segment.
*/
videojs.Hls.prototype.fetchKeys_ = function() {
var i, key, tech, player, settings, segment, view, receiveKey;
videojs.Hls.prototype.fetchKey_ = function(segment) {
var key, self, settings, receiveKey;
// if there is a pending XHR or no segments, don't do anything
if (keyXhr || !this.segmentBuffer_.length) {
if (this.keyXhr_) {
return;
}
tech = this;
player = this.player();
self = this;
settings = this.options_;
/**
* Handle a key XHR response. This function needs to lookup the
* Handle a key XHR response.
*/
receiveKey = function(key) {
return function(error, request) {
keyXhr = null;
var view;
self.keyXhr_ = null;
if (error || !request.response || request.response.byteLength !== 16) {
key.retries = key.retries || 0;
key.retries++;
if (!request.aborted) {
// try fetching again
tech.fetchKeys_();
self.fetchKey_(segment);
}
return;
}
......@@ -1153,28 +1026,25 @@ videojs.Hls.prototype.fetchKeys_ = function() {
]);
// check to see if this allows us to make progress buffering now
tech.checkBuffer_();
self.checkBuffer_();
};
};
for (i = 0; i < tech.segmentBuffer_.length; i++) {
segment = tech.segmentBuffer_[i].playlist.segments[tech.segmentBuffer_[i].mediaIndex];
key = segment.key;
key = segment.key;
// continue looking if this segment is unencrypted
if (!key) {
continue;
}
// nothing to do if this segment is unencrypted
if (!key) {
return;
}
// request the key if the retry limit hasn't been reached
if (!key.bytes && !keyFailed(key)) {
keyXhr = videojs.Hls.xhr({
uri: this.playlistUriToUrl(key.uri),
responseType: 'arraybuffer',
withCredentials: settings.withCredentials
}, receiveKey(key));
break;
}
// request the key if the retry limit hasn't been reached
if (!key.bytes && !keyFailed(key)) {
this.keyXhr_ = videojs.Hls.xhr({
uri: this.playlistUriToUrl(key.uri),
responseType: 'arraybuffer',
withCredentials: settings.withCredentials
}, receiveKey(key));
return;
}
};
......@@ -1206,83 +1076,6 @@ videojs.Hls.isSupported = function() {
};
/**
* Calculate the duration of a playlist from a given start index to a given
* end index.
* @param playlist {object} a media playlist object
* @param startIndex {number} an inclusive lower boundary for the playlist.
* Defaults to 0.
* @param endIndex {number} an exclusive upper boundary for the playlist.
* Defaults to playlist length.
* @return {number} the duration between the start index and end index.
*/
videojs.Hls.getPlaylistDuration = function(playlist, startIndex, endIndex) {
videojs.log.warn('videojs.Hls.getPlaylistDuration is deprecated. ' +
'Use videojs.Hls.Playlist.duration instead');
return videojs.Hls.Playlist.duration(playlist, startIndex, endIndex);
};
/**
* Calculate the total duration for a playlist based on segment metadata.
* @param playlist {object} a media playlist object
* @return {number} the currently known duration, in seconds
*/
videojs.Hls.getPlaylistTotalDuration = function(playlist) {
videojs.log.warn('videojs.Hls.getPlaylistTotalDuration is deprecated. ' +
'Use videojs.Hls.Playlist.duration instead');
return videojs.Hls.Playlist.duration(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
*/
videojs.Hls.getMediaIndexByTime = function() {
videojs.log.warn('getMediaIndexByTime is deprecated. ' +
'Use PlaylistLoader.getMediaIndexForTime_ instead.');
return 0;
};
/**
* A comparator function to sort two playlist object by bandwidth.
* @param left {object} a media playlist object
* @param right {object} a media playlist object
......
......@@ -53,15 +53,6 @@
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');
......@@ -69,13 +60,16 @@
});
test('moves to HAVE_MASTER after loading a master playlist', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
var loader = new videojs.Hls.PlaylistLoader('master.m3u8'), state;
loader.on('loadedplaylist', function() {
state = loader.state;
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n');
ok(loader.master, 'the master playlist is available');
strictEqual(loader.state, 'HAVE_MASTER', 'the state is correct');
strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct');
});
test('jumps to HAVE_METADATA when initialized with a media playlist', function() {
......@@ -172,101 +166,6 @@
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_, 13 + 4, 'tracked expired prior to 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 both expired discontinuities');
});
test('emits an error when an initial playlist request fails', function() {
var
errors = [],
......@@ -453,6 +352,20 @@
'updated the active media');
});
test('can switch playlists immediately after the master is downloaded', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
loader.on('loadedplaylist', function() {
loader.media('high.m3u8');
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
equal(requests[0].url, urlTo('high.m3u8'), 'switched variants immediately');
});
test('can switch media playlists based on URI', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
requests.pop().respond(200, null,
......@@ -624,9 +537,6 @@
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
throws(function() {
loader.media('high.m3u8');
}, 'throws an error from HAVE_MASTER');
});
test('throws an error if a switch to an unrecognized playlist is requested', function() {
......@@ -743,8 +653,8 @@
equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero');
equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2');
equal(loader.getMediaIndexForTime_(22),
2,
'the index is never greater than the length');
3,
'time greater than the length is index 3');
});
test('returns the lower index when calculating for a segment boundary', function() {
......@@ -757,10 +667,8 @@
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXT-X-ENDLIST\n');
equal(loader.getMediaIndexForTime_(4), 0, 'rounds down exact matches');
equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches');
equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down');
// FIXME: the test below should pass for HLSv3
//equal(loader.getMediaIndexForTime_(4.2), 0, 'rounds down');
equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5');
});
......@@ -773,7 +681,7 @@
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
loader.expired_ = 150;
loader.media().segments[0].start = 150;
equal(loader.getMediaIndexForTime_(0), 0, 'the lowest returned value is zero');
equal(loader.getMediaIndexForTime_(45), 0, 'expired content returns zero');
......@@ -785,30 +693,6 @@
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,
......
......@@ -18,27 +18,6 @@
module('Playlist Interval Duration');
test('accounts expired duration for live playlists', function() {
var duration = Playlist.duration({
mediaSequence: 10,
segments: [{
duration: 10,
uri: '10.ts'
}, {
duration: 10,
uri: '11.ts'
}, {
duration: 10,
uri: '12.ts'
}, {
duration: 10,
uri: '13.ts'
}]
}, 0, 14);
equal(duration, 14 * 10, 'duration includes dropped segments');
});
test('accounts for non-zero starting VOD media sequences', function() {
var duration = Playlist.duration({
mediaSequence: 10,
......@@ -61,47 +40,37 @@
equal(duration, 4 * 10, 'includes only listed segments');
});
test('uses PTS values when available', function() {
test('uses timeline values when available', function() {
var duration = Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
minVideoPts: 1,
minAudioPts: 2,
start: 0,
uri: '0.ts'
}, {
duration: 10,
maxVideoPts: 2 * 10 * 1000 + 1,
maxAudioPts: 2 * 10 * 1000 + 2,
end: 2 * 10 + 2,
uri: '1.ts'
}, {
duration: 10,
maxVideoPts: 3 * 10 * 1000 + 1,
maxAudioPts: 3 * 10 * 1000 + 2,
end: 3 * 10 + 2,
uri: '2.ts'
}, {
duration: 10,
maxVideoPts: 4 * 10 * 1000 + 1,
maxAudioPts: 4 * 10 * 1000 + 2,
end: 4 * 10 + 2,
uri: '3.ts'
}]
}, 0, 4);
}, 4);
equal(duration, ((4 * 10 * 1000 + 2) - 1) * 0.001, 'used PTS values');
equal(duration, 4 * 10 + 2, 'used timeline values');
});
test('works when partial PTS information is available', function() {
test('works when partial timeline information is available', function() {
var duration = Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
minVideoPts: 1,
minAudioPts: 2,
maxVideoPts: 10 * 1000 + 1,
// intentionally less duration than video
// the max stream duration should be used
maxAudioPts: 10 * 1000 + 1,
start: 0,
uri: '0.ts'
}, {
duration: 9,
......@@ -111,67 +80,17 @@
uri: '2.ts'
}, {
duration: 10,
minVideoPts: 30 * 1000 + 7,
minAudioPts: 30 * 1000 + 10,
maxVideoPts: 40 * 1000 + 1,
maxAudioPts: 40 * 1000 + 2,
start: 30.007,
end: 40.002,
uri: '3.ts'
}, {
duration: 10,
maxVideoPts: 50 * 1000 + 1,
maxAudioPts: 50 * 1000 + 2,
end: 50.0002,
uri: '4.ts'
}]
}, 0, 5);
}, 5);
equal(duration,
((50 * 1000 + 2) - 1) * 0.001,
'calculated with mixed intervals');
});
test('ignores segments before the start', function() {
var duration = Playlist.duration({
mediaSequence: 0,
segments: [{
duration: 10,
uri: '0.ts'
}, {
duration: 10,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}]
}, 1, 3);
equal(duration, 10 + 10, 'ignored the first segment');
});
test('ignores discontinuity sequences earlier than the start', function() {
var duration = Playlist.duration({
mediaSequence: 0,
discontinuityStarts: [1, 3],
segments: [{
minVideoPts: 0,
minAudioPts: 0,
maxVideoPts: 10 * 1000,
maxAudioPts: 10 * 1000,
uri: '0.ts'
}, {
discontinuity: true,
duration: 9,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
discontinuity: true,
duration: 10,
uri: '3.ts'
}]
}, 2, 4);
equal(duration, 10 + 10, 'excluded the earlier segments');
equal(duration, 50.0002, 'calculated with mixed intervals');
});
test('ignores discontinuity sequences later than the end', function() {
......@@ -196,20 +115,19 @@
duration: 10,
uri: '3.ts'
}]
}, 0, 2);
}, 2);
equal(duration, 19, 'excluded the later segments');
});
test('handles trailing segments without PTS information', function() {
var duration = Playlist.duration({
test('handles trailing segments without timeline information', function() {
var playlist, duration;
playlist = {
mediaSequence: 0,
endList: true,
segments: [{
minVideoPts: 0,
minAudioPts: 0,
maxVideoPts: 10 * 1000,
maxAudioPts: 10 * 1000,
start: 0,
end: 10.5,
uri: '0.ts'
}, {
duration: 9,
......@@ -218,107 +136,43 @@
duration: 10,
uri: '2.ts'
}, {
minVideoPts: 29.5 * 1000,
minAudioPts: 29.5 * 1000,
maxVideoPts: 39.5 * 1000,
maxAudioPts: 39.5 * 1000,
start: 29.45,
end: 39.5,
uri: '3.ts'
}]
}, 0, 3);
};
duration = Playlist.duration(playlist, 3);
equal(duration, 29.45, 'calculated duration');
equal(duration, 29.5, 'calculated duration');
duration = Playlist.duration(playlist, 2);
equal(duration, 19.5, 'calculated duration');
});
test('uses PTS intervals when the start and end segment have them', function() {
test('uses timeline intervals when segments have them', function() {
var playlist, duration;
playlist = {
mediaSequence: 0,
segments: [{
minVideoPts: 0,
minAudioPts: 0,
maxVideoPts: 10 * 1000,
maxAudioPts: 10 * 1000,
start: 0,
end: 10,
uri: '0.ts'
}, {
duration: 9,
uri: '1.ts'
},{
minVideoPts: 20 * 1000 + 100,
minAudioPts: 20 * 1000 + 100,
maxVideoPts: 30 * 1000 + 100,
maxAudioPts: 30 * 1000 + 100,
start: 20.1,
end: 30.1,
duration: 10,
uri: '2.ts'
}]
};
duration = Playlist.duration(playlist, 0, 2);
duration = Playlist.duration(playlist, 2);
equal(duration, 20.1, 'used the PTS-based interval');
equal(duration, 20.1, 'used the timeline-based interval');
duration = Playlist.duration(playlist, 0, 3);
equal(duration, 30.1, 'used the PTS-based interval');
});
test('works for media without audio', function() {
equal(Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
minVideoPts: 0,
maxVideoPts: 9 * 1000,
uri: 'no-audio.ts'
}]
}), 9, 'used video PTS values');
});
test('works for media without video', function() {
equal(Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
minAudioPts: 0,
maxAudioPts: 9 * 1000,
uri: 'no-video.ts'
}]
}), 9, 'used video PTS values');
});
test('uses the largest continuous available PTS ranges', function() {
var playlist = {
mediaSequence: 0,
segments: [{
minVideoPts: 0,
minAudioPts: 0,
maxVideoPts: 10 * 1000,
maxAudioPts: 10 * 1000,
uri: '0.ts'
}, {
duration: 10,
uri: '1.ts'
}, {
// starts 0.5s earlier than the previous segment indicates
minVideoPts: 19.5 * 1000,
minAudioPts: 19.5 * 1000,
maxVideoPts: 29.5 * 1000,
maxAudioPts: 29.5 * 1000,
uri: '2.ts'
}, {
duration: 10,
uri: '3.ts'
}, {
// ... but by the last segment, there is actual 0.5s more
// content than duration indicates
minVideoPts: 40.5 * 1000,
minAudioPts: 40.5 * 1000,
maxVideoPts: 50.5 * 1000,
maxAudioPts: 50.5 * 1000,
uri: '4.ts'
}]
};
equal(Playlist.duration(playlist, 0, 5),
50.5,
'calculated across the larger PTS interval');
duration = Playlist.duration(playlist, 3);
equal(duration, 30.1, 'used the timeline-based interval');
});
test('counts the time between segments as part of the earlier segment\'s duration', function() {
......@@ -326,22 +180,18 @@
mediaSequence: 0,
endList: true,
segments: [{
minVideoPts: 0,
minAudioPts: 0,
maxVideoPts: 1 * 10 * 1000,
maxAudioPts: 1 * 10 * 1000,
start: 0,
end: 10,
uri: '0.ts'
}, {
minVideoPts: 1 * 10 * 1000 + 100,
minAudioPts: 1 * 10 * 1000 + 100,
maxVideoPts: 2 * 10 * 1000 + 100,
maxAudioPts: 2 * 10 * 1000 + 100,
start: 10.1,
end: 20.1,
duration: 10,
uri: '1.ts'
}]
}, 0, 1);
}, 1);
equal(duration, (1 * 10 * 1000 + 100) * 0.001, 'included the segment gap');
equal(duration, 10.1, 'included the segment gap');
});
test('accounts for discontinuities', function() {
......@@ -364,7 +214,7 @@
duration: 10,
uri: '1.ts'
}]
}, 0, 2);
}, 2);
equal(duration, 10 + 10, 'handles discontinuities');
});
......@@ -389,7 +239,7 @@
duration: 10,
uri: '1.ts'
}]
}, 0, 1);
}, 1);
equal(duration, (1 * 10 * 1000) * 0.001, 'did not include the segment gap');
});
......@@ -412,7 +262,7 @@
duration: 10,
uri: '1.ts'
}]
}, 0, 1, false);
}, 1, false);
equal(duration, (1 * 10 * 1000) * 0.001, 'did not include the segment gap');
});
......@@ -431,10 +281,9 @@
}]
};
equal(Playlist.duration(playlist, 0, 0), 0, 'zero-length duration is zero');
equal(Playlist.duration(playlist, 0, 0, false), 0, 'zero-length duration is zero');
equal(Playlist.duration(playlist, 0, -1), 0, 'negative length duration is zero');
equal(Playlist.duration(playlist, 2, 1, false), 0, 'negative length duration is zero');
equal(Playlist.duration(playlist, 0), 0, 'zero-length duration is zero');
equal(Playlist.duration(playlist, 0, false), 0, 'zero-length duration is zero');
equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero');
});
module('Playlist Seekable');
......
......@@ -167,60 +167,6 @@ var
window.manifests[manifestName]);
},
mockSegmentParser = function(tags) {
var MockSegmentParser;
if (tags === undefined) {
tags = [{ pts: 0, bytes: new Uint8Array(1) }];
}
MockSegmentParser = function() {
this.getFlvHeader = function() {
return 'flv';
};
this.parseSegmentBinaryData = function() {};
this.flushTags = function() {};
this.tagsAvailable = function() {
return tags.length;
};
this.getTags = function() {
return tags;
};
this.getNextTag = function() {
return tags.shift();
};
this.metadataStream = new videojs.Hls.Stream();
this.metadataStream.init();
this.metadataStream.descriptor = new Uint8Array([
1, 2, 3, 0xbb
]);
this.stats = {
h264Tags: function() {
return tags.length;
},
minVideoPts: function() {
return tags[0].pts;
},
maxVideoPts: function() {
return tags[tags.length - 1].pts;
},
aacTags: function() {
return tags.length;
},
minAudioPts: function() {
return tags[0].pts;
},
maxAudioPts: function() {
return tags[tags.length - 1].pts;
},
};
};
MockSegmentParser.STREAM_TYPES = videojs.Hls.SegmentParser.STREAM_TYPES;
return MockSegmentParser;
},
// a no-op MediaSource implementation to allow synchronous testing
MockMediaSource = videojs.extend(videojs.EventTarget, {
constructor: function() {},
......@@ -524,7 +470,7 @@ test('sets the duration if one is available on the playlist', function() {
equal(events, 1, 'durationchange is fired');
});
QUnit.skip('calculates the duration if needed', function() {
test('estimates individual segment durations if needed', function() {
var changes = 0;
player.src({
src: 'http://example.com/manifest/missingExtinf.m3u8',
......@@ -532,7 +478,7 @@ QUnit.skip('calculates the duration if needed', function() {
});
openMediaSource(player);
player.tech_.hls.mediaSource.duration = NaN;
player.on('durationchange', function() {
player.tech_.on('durationchange', function() {
changes++;
});
......@@ -686,73 +632,59 @@ test('downloads media playlists after loading the master', function() {
});
openMediaSource(player);
// set bandwidth to an appropriate number so we don't switch
player.tech_.hls.bandwidth = 200000;
player.tech_.hls.bandwidth = 20e10;
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
standardXHRResponse(requests[2]);
strictEqual(requests[0].url, 'manifest/master.m3u8', 'master playlist requested');
strictEqual(requests[1].url,
absoluteUrl('manifest/media.m3u8'),
absoluteUrl('manifest/media3.m3u8'),
'media playlist requested');
strictEqual(requests[2].url,
absoluteUrl('manifest/media-00001.ts'),
absoluteUrl('manifest/media3-00001.ts'),
'first segment requested');
});
test('upshift if initial bandwidth is high', function() {
test('upshifts if the initial bandwidth hint is high', function() {
player.src({
src: 'manifest/master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.hls.bandwidth = 10e20;
standardXHRResponse(requests[0]);
player.tech_.hls.playlists.setBandwidth = function() {
player.tech_.hls.playlists.bandwidth = 1000000000;
};
standardXHRResponse(requests[1]);
standardXHRResponse(requests[2]);
standardXHRResponse(requests[3]);
strictEqual(requests[0].url, 'manifest/master.m3u8', 'master playlist requested');
strictEqual(requests[1].url,
absoluteUrl('manifest/media.m3u8'),
'media playlist requested');
strictEqual(requests[2].url,
absoluteUrl('manifest/media3.m3u8'),
'media playlist requested');
strictEqual(requests[3].url,
strictEqual(requests[2].url,
absoluteUrl('manifest/media3-00001.ts'),
'first segment requested');
});
test('dont downshift if bandwidth is low', function() {
test('downshifts if the initial bandwidth hint is low', function() {
player.src({
src: 'manifest/master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.hls.bandwidth = 100;
standardXHRResponse(requests[0]);
player.tech_.hls.playlists.setBandwidth = function() {
player.tech_.hls.playlists.bandwidth = 100;
};
standardXHRResponse(requests[1]);
standardXHRResponse(requests[2]);
strictEqual(requests[0].url, 'manifest/master.m3u8', 'master playlist requested');
strictEqual(requests[1].url,
absoluteUrl('manifest/media.m3u8'),
absoluteUrl('manifest/media1.m3u8'),
'media playlist requested');
strictEqual(requests[2].url,
absoluteUrl('manifest/media-00001.ts'),
absoluteUrl('manifest/media1-00001.ts'),
'first segment requested');
});
......@@ -822,7 +754,7 @@ test('buffer checks are noops when only the master is ready', function() {
strictEqual(1, requests.length, 'one request was made');
strictEqual(requests[0].url,
absoluteUrl('manifest/media.m3u8'),
absoluteUrl('manifest/media1.m3u8'),
'media playlist requested');
});
......@@ -876,11 +808,9 @@ test('selects a playlist after segment downloads', function() {
return player.tech_.hls.playlists.master.playlists[0];
};
standardXHRResponse(requests[0]);
player.tech_.hls.bandwidth = 3000000;
standardXHRResponse(requests[1]);
standardXHRResponse(requests[2]);
standardXHRResponse(requests[0]); // master
standardXHRResponse(requests[1]); // media
standardXHRResponse(requests[2]); // segment
strictEqual(calls, 2, 'selects after the initial segment');
player.currentTime = function() {
......@@ -889,6 +819,7 @@ test('selects a playlist after segment downloads', function() {
player.buffered = function() {
return videojs.createTimeRange(0, 2);
};
player.tech_.hls.sourceBuffer.trigger('updateend');
player.tech_.hls.checkBuffer_();
standardXHRResponse(requests[3]);
......@@ -896,9 +827,7 @@ test('selects a playlist after segment downloads', function() {
strictEqual(calls, 3, 'selects after additional segments');
});
test('moves to the next segment if there is a network error', function() {
var mediaIndex;
test('reports an error if a segment is unreachable', function() {
player.src({
src: 'manifest/master.m3u8',
type: 'application/vnd.apple.mpegurl'
......@@ -906,65 +835,11 @@ test('moves to the next segment if there is a network error', function() {
openMediaSource(player);
player.tech_.hls.bandwidth = 20000;
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
mediaIndex = player.tech_.hls.mediaIndex;
player.trigger('timeupdate');
requests[2].respond(400);
strictEqual(mediaIndex + 1, player.tech_.hls.mediaIndex, 'media index is incremented');
});
test('updates playlist timeline offsets if it detects a desynchronization', function() {
var buffered = [], currentTime = 0;
player.src({
src: 'manifest/master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
standardXHRResponse(requests.shift()); // master
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:2\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n'); // media
player.tech_.buffered = function() { return videojs.createTimeRange(buffered); };
player.tech_.currentTime = function() { return currentTime; };
player.tech_.paused = function() { return false; };
player.tech_.trigger('play');
clock.tick(1);
standardXHRResponse(requests.shift()); // segment 0
equal(player.tech_.hls.mediaIndex, 1, 'incremented mediaIndex');
standardXHRResponse(requests[0]); // master
standardXHRResponse(requests[1]); // media
player.tech_.hls.sourceBuffer.trigger('updateend');
buffered.push([0, 10]);
// force a playlist switch
player.tech_.hls.playlists.media('media1.m3u8');
requests = requests.filter(function(request) {
return !request.aborted;
});
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:9999\n' +
'#EXTINF:10,\n' +
'3.ts\n' +
'#EXTINF:10,\n' +
'4.ts\n' +
'#EXTINF:10,\n' +
'5.ts\n'); // media1
player.tech_.hls.checkBuffer_();
standardXHRResponse(requests.shift());
buffered.push([20, 30]);
currentTime = 8;
player.tech_.hls.sourceBuffer.trigger('updateend');
equal(player.tech_.hls.mediaIndex, 0, 'prepared to request the missing segment');
requests[2].respond(400); // segment
strictEqual(player.tech_.hls.mediaSource.error_, 'network', 'network error is triggered');
});
test('updates the duration after switching playlists', function() {
......@@ -974,19 +849,22 @@ test('updates the duration after switching playlists', function() {
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.hls.bandwidth = 1e20;
standardXHRResponse(requests[0]); // master
standardXHRResponse(requests[1]); // media3
player.tech_.hls.selectPlaylist = function() {
selectedPlaylist = true;
// this duraiton should be overwritten by the playlist change
// this duration should be overwritten by the playlist change
player.tech_.hls.mediaSource.duration = -Infinity;
return player.tech_.hls.playlists.master.playlists[1];
};
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
standardXHRResponse(requests[2]);
standardXHRResponse(requests[3]);
standardXHRResponse(requests[2]); // segment 0
standardXHRResponse(requests[3]); // media1
ok(selectedPlaylist, 'selected playlist');
ok(player.tech_.hls.mediaSource.duration !== -Infinity, 'updates the duration');
});
......@@ -1058,21 +936,6 @@ test('selects a playlist below the current bandwidth', function() {
'the low bitrate stream is selected');
});
test('scales the bandwidth estimate for the first segment', function() {
player.src({
src: 'manifest/master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
requests[0].bandwidth = 500;
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-PLAYLIST-TYPE:VOD\n' +
'#EXT-X-TARGETDURATION:10\n');
equal(player.tech_.hls.bandwidth, 500 * 5, 'scaled the bandwidth estimate by 5');
});
test('allows initial bandwidth to be provided', function() {
player.src({
src: 'manifest/master.m3u8',
......@@ -1242,6 +1105,7 @@ test('downloads the next segment if the buffer is getting low', function() {
player.tech_.buffered = function() {
return videojs.createTimeRange(0, 19.999);
};
player.tech_.hls.sourceBuffer.trigger('updateend');
player.tech_.hls.checkBuffer_();
standardXHRResponse(requests[2]);
......@@ -1253,46 +1117,41 @@ test('downloads the next segment if the buffer is getting low', function() {
});
test('buffers based on the correct TimeRange if multiple ranges exist', function() {
player.tech_.currentTime = function() {
return 8;
};
var currentTime, buffered;
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.currentTime = function() {
return currentTime;
};
player.tech_.buffered = function() {
return videojs.createTimeRange([[0, 10], [50, 160]]);
return videojs.createTimeRange(buffered);
};
currentTime = 8;
buffered = [[0, 10], [20, 40]];
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
strictEqual(requests.length, 2, 'made two requests');
strictEqual(requests[1].url,
absoluteUrl('manifest/media-00001.ts'),
absoluteUrl('manifest/media-00002.ts'),
'made segment request');
player.tech_.currentTime = function() {
return 55;
};
currentTime = 22;
player.tech_.hls.sourceBuffer.trigger('updateend');
player.tech_.hls.checkBuffer_();
strictEqual(requests.length, 2, 'made no additional requests');
player.tech_.currentTime = function() {
return 134;
};
buffered = [[0, 10], [20, 30]];
player.tech_.hls.checkBuffer_();
standardXHRResponse(requests[2]);
strictEqual(requests.length, 3, 'made three requests');
strictEqual(requests[2].url,
absoluteUrl('manifest/media-00002.ts'),
absoluteUrl('manifest/media-00004.ts'),
'made segment request');
});
......@@ -1325,7 +1184,6 @@ test('only makes one segment request at a time', function() {
});
test('only appends one segment at a time', function() {
var appends = 0;
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
......@@ -1334,97 +1192,11 @@ test('only appends one segment at a time', function() {
standardXHRResponse(requests.pop()); // media.m3u8
standardXHRResponse(requests.pop()); // segment 0
player.tech_.hls.sourceBuffer.updating = true;
player.tech_.hls.sourceBuffer.appendBuffer = function() {
appends++;
};
player.tech_.hls.checkBuffer_();
standardXHRResponse(requests.pop()); // segment 1
player.tech_.hls.checkBuffer_(); // should be a no-op
equal(appends, 0, 'did not append while updating');
});
QUnit.skip('records the min and max PTS values for a segment', function() {
var tags = [];
videojs.Hls.SegmentParser = mockSegmentParser(tags);
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
standardXHRResponse(requests.pop()); // media.m3u8
tags.push({ pts: 0, bytes: new Uint8Array(1) });
tags.push({ pts: 10, bytes: new Uint8Array(1) });
standardXHRResponse(requests.pop()); // segment 0
equal(player.tech_.hls.playlists.media().segments[0].minVideoPts, 0, 'recorded min video pts');
equal(player.tech_.hls.playlists.media().segments[0].maxVideoPts, 10, 'recorded max video pts');
equal(player.tech_.hls.playlists.media().segments[0].minAudioPts, 0, 'recorded min audio pts');
equal(player.tech_.hls.playlists.media().segments[0].maxAudioPts, 10, 'recorded max audio pts');
});
QUnit.skip('records PTS values for video-only segments', function() {
var tags = [];
videojs.Hls.SegmentParser = mockSegmentParser(tags);
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
standardXHRResponse(requests.pop()); // media.m3u8
player.tech_.hls.segmentParser_.stats.aacTags = function() {
return 0;
};
player.tech_.hls.segmentParser_.stats.minAudioPts = function() {
throw new Error('No audio tags');
};
player.tech_.hls.segmentParser_.stats.maxAudioPts = function() {
throw new Error('No audio tags');
};
tags.push({ pts: 0, bytes: new Uint8Array(1) });
tags.push({ pts: 10, bytes: new Uint8Array(1) });
standardXHRResponse(requests.pop()); // segment 0
equal(player.tech_.hls.playlists.media().segments[0].minVideoPts, 0, 'recorded min video pts');
equal(player.tech_.hls.playlists.media().segments[0].maxVideoPts, 10, 'recorded max video pts');
strictEqual(player.tech_.hls.playlists.media().segments[0].minAudioPts, undefined, 'min audio pts is undefined');
strictEqual(player.tech_.hls.playlists.media().segments[0].maxAudioPts, undefined, 'max audio pts is undefined');
});
QUnit.skip('records PTS values for audio-only segments', function() {
var tags = [];
videojs.Hls.SegmentParser = mockSegmentParser(tags);
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
standardXHRResponse(requests.pop()); // media.m3u8
player.tech_.hls.segmentParser_.stats.h264Tags = function() {
return 0;
};
player.tech_.hls.segmentParser_.stats.minVideoPts = function() {
throw new Error('No video tags');
};
player.tech_.hls.segmentParser_.stats.maxVideoPts = function() {
throw new Error('No video tags');
};
tags.push({ pts: 0, bytes: new Uint8Array(1) });
tags.push({ pts: 10, bytes: new Uint8Array(1) });
standardXHRResponse(requests.pop()); // segment 0
equal(player.tech_.hls.playlists.media().segments[0].minAudioPts, 0, 'recorded min audio pts');
equal(player.tech_.hls.playlists.media().segments[0].maxAudioPts, 10, 'recorded max audio pts');
strictEqual(player.tech_.hls.playlists.media().segments[0].minVideoPts, undefined, 'min video pts is undefined');
strictEqual(player.tech_.hls.playlists.media().segments[0].maxVideoPts, undefined, 'max video pts is undefined');
equal(requests.length, 0, 'did not request while updating');
});
test('waits to download new segments until the media playlist is stable', function() {
var media;
player.src({
src: 'manifest/master.m3u8',
type: 'application/vnd.apple.mpegurl'
......@@ -1432,22 +1204,19 @@ test('waits to download new segments until the media playlist is stable', functi
openMediaSource(player);
standardXHRResponse(requests.shift()); // master
player.tech_.hls.bandwidth = 1; // make sure we stay on the lowest variant
standardXHRResponse(requests.shift()); // media
standardXHRResponse(requests.shift()); // media1
// mock a playlist switch
media = player.tech_.hls.playlists.media();
player.tech_.hls.playlists.media = function() {
return media;
};
player.tech_.hls.playlists.state = 'SWITCHING_MEDIA';
// force a playlist switch
player.tech_.hls.playlists.media('media3.m3u8');
standardXHRResponse(requests.shift()); // segment 0
player.tech_.hls.sourceBuffer.trigger('updateend');
equal(requests.length, 0, 'no requests outstanding');
equal(requests.length, 1, 'only the playlist request outstanding');
player.tech_.hls.checkBuffer_();
equal(requests.length, 0, 'delays segment fetching');
equal(requests.length, 1, 'delays segment fetching');
player.tech_.hls.playlists.state = 'LOADED_METADATA';
standardXHRResponse(requests.shift()); // media3
player.tech_.hls.checkBuffer_();
equal(requests.length, 1, 'resumes segment fetching');
});
......@@ -1536,359 +1305,6 @@ test('segmentXhr is properly nulled out when dispose is called', function() {
Flash.prototype.dispose = oldDispose;
});
QUnit.skip('exposes in-band metadata events as cues', function() {
var track;
videojs.Hls.SegmentParser = mockSegmentParser();
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.hls.segmentParser_.parseSegmentBinaryData = function() {
// trigger a metadata event
player.tech_.hls.segmentParser_.metadataStream.trigger('data', {
pts: 2000,
data: new Uint8Array([]),
frames: [{
id: 'TXXX',
value: 'cue text'
}, {
id: 'WXXX',
url: 'http://example.com'
}, {
id: 'PRIV',
owner: 'owner@example.com',
privateData: new Uint8Array([1, 2, 3])
}]
});
};
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
equal(player.textTracks().length, 1, 'created a text track');
track = player.textTracks()[0];
equal(track.kind, 'metadata', 'kind is metadata');
equal(track.inBandMetadataTrackDispatchType, '15010203BB', 'set the dispatch type');
equal(track.cues.length, 3, 'created three cues');
equal(track.cues[0].startTime, 2, 'cue starts at 2 seconds');
equal(track.cues[0].endTime, 2, 'cue ends at 2 seconds');
equal(track.cues[0].pauseOnExit, false, 'cue does not pause on exit');
equal(track.cues[0].text, 'cue text', 'set cue text');
equal(track.cues[1].startTime, 2, 'cue starts at 2 seconds');
equal(track.cues[1].endTime, 2, 'cue ends at 2 seconds');
equal(track.cues[1].pauseOnExit, false, 'cue does not pause on exit');
equal(track.cues[1].text, 'http://example.com', 'set cue text');
equal(track.cues[2].startTime, 2, 'cue starts at 2 seconds');
equal(track.cues[2].endTime, 2, 'cue ends at 2 seconds');
equal(track.cues[2].pauseOnExit, false, 'cue does not pause on exit');
equal(track.cues[2].text, '', 'did not set cue text');
equal(track.cues[2].frame.owner, 'owner@example.com', 'set the owner');
deepEqual(track.cues[2].frame.privateData,
new Uint8Array([1, 2, 3]),
'set the private data');
});
QUnit.skip('only adds in-band cues the first time they are encountered', function() {
var tags = [{ pts: 0, bytes: new Uint8Array(1) }], track;
videojs.Hls.SegmentParser = mockSegmentParser(tags);
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.hls.segmentParser_.parseSegmentBinaryData = function() {
// trigger a metadata event
player.tech_.hls.segmentParser_.metadataStream.trigger('data', {
pts: 2000,
data: new Uint8Array([]),
frames: [{
id: 'TXXX',
value: 'cue text'
}]
});
};
standardXHRResponse(requests.shift());
standardXHRResponse(requests.shift());
// seek back to the first segment
player.currentTime(0);
player.tech_.hls.trigger('seeking');
tags.push({ pts: 0, bytes: new Uint8Array(1) });
standardXHRResponse(requests.shift());
track = player.textTracks()[0];
equal(track.cues.length, 1, 'only added the cue once');
});
QUnit.skip('clears in-band cues ahead of current time on seek', function() {
var
tags = [],
events = [],
track;
videojs.Hls.SegmentParser = mockSegmentParser(tags);
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.hls.segmentParser_.parseSegmentBinaryData = function() {
// trigger a metadata event
while (events.length) {
player.tech_.hls.segmentParser_.metadataStream.trigger('data', events.shift());
}
};
standardXHRResponse(requests.shift()); // media
tags.push({ pts: 0, bytes: new Uint8Array(1) },
{ pts: 10 * 1000, bytes: new Uint8Array(1) });
events.push({
pts: 9.9 * 1000,
data: new Uint8Array([]),
frames: [{
id: 'TXXX',
value: 'cue 1'
}]
});
events.push({
pts: 20 * 1000,
data: new Uint8Array([]),
frames: [{
id: 'TXXX',
value: 'cue 3'
}]
});
standardXHRResponse(requests.shift()); // segment 0
tags.push({ pts: 10 * 1000 + 1, bytes: new Uint8Array(1) },
{ pts: 20 * 1000, bytes: new Uint8Array(1) });
events.push({
pts: 19.9 * 1000,
data: new Uint8Array([]),
frames: [{
id: 'TXXX',
value: 'cue 2'
}]
});
player.tech_.hls.checkBuffer_();
standardXHRResponse(requests.shift()); // segment 1
track = player.textTracks()[0];
equal(track.cues.length, 3, 'added the cues');
// seek into segment 1
player.currentTime(11);
player.trigger('seeking');
equal(track.cues.length, 1, 'removed later cues');
equal(track.cues[0].startTime, 9.9, 'retained the earlier cue');
});
QUnit.skip('translates ID3 PTS values to cue media timeline positions', function() {
var tags = [{ pts: 4 * 1000, bytes: new Uint8Array(1) }], track;
videojs.Hls.SegmentParser = mockSegmentParser(tags);
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.hls.segmentParser_.parseSegmentBinaryData = function() {
// trigger a metadata event
player.tech_.hls.segmentParser_.metadataStream.trigger('data', {
pts: 5 * 1000,
data: new Uint8Array([]),
frames: [{
id: 'TXXX',
value: 'cue text'
}]
});
};
standardXHRResponse(requests.shift()); // media
standardXHRResponse(requests.shift()); // segment 0
track = player.textTracks()[0];
equal(track.cues[0].startTime, 1, 'translated startTime');
equal(track.cues[0].endTime, 1, 'translated startTime');
});
QUnit.skip('translates ID3 PTS values with expired segments', function() {
var tags = [{ pts: 4 * 1000, bytes: new Uint8Array(1) }], track;
videojs.Hls.SegmentParser = mockSegmentParser(tags);
player.src({
src: 'live.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.play();
// 20.9 seconds of content have expired
player.hls.playlists.expiredPostDiscontinuity_ = 20.9;
player.hls.segmentParser_.parseSegmentBinaryData = function() {
// trigger a metadata event
player.hls.segmentParser_.metadataStream.trigger('data', {
pts: 5 * 1000,
data: new Uint8Array([]),
frames: [{
id: 'TXXX',
value: 'cue text'
}]
});
};
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:2\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n'); // media
standardXHRResponse(requests.shift()); // segment 0
track = player.textTracks()[0];
equal(track.cues[0].startTime, 20.9 + 1, 'translated startTime');
equal(track.cues[0].endTime, 20.9 + 1, 'translated startTime');
});
QUnit.skip('translates id3 PTS values for audio-only media', function() {
var tags = [{ pts: 4 * 1000, bytes: new Uint8Array(1) }], track;
videojs.Hls.SegmentParser = mockSegmentParser(tags);
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.hls.segmentParser_.parseSegmentBinaryData = function() {
// trigger a metadata event
player.hls.segmentParser_.metadataStream.trigger('data', {
pts: 5 * 1000,
data: new Uint8Array([]),
frames: [{
id: 'TXXX',
value: 'cue text'
}]
});
};
player.hls.segmentParser_.stats.h264Tags = function() { return 0; };
player.hls.segmentParser_.stats.minVideoPts = null;
standardXHRResponse(requests.shift()); // media
standardXHRResponse(requests.shift()); // segment 0
track = player.textTracks()[0];
equal(track.cues[0].startTime, 1, 'translated startTime');
});
QUnit.skip('translates ID3 PTS values across discontinuities', function() {
var tags = [], events = [], track;
videojs.Hls.SegmentParser = mockSegmentParser(tags);
player.src({
src: 'cues-and-discontinuities.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.hls.segmentParser_.parseSegmentBinaryData = function() {
// trigger a metadata event
if (events.length) {
player.tech_.hls.segmentParser_.metadataStream.trigger('data', events.shift());
}
};
// media playlist
player.trigger('play');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:10,\n' +
'1.ts\n');
// segment 0 starts at PTS 14000 and has a cue point at 15000
tags.push({ pts: 14 * 1000, bytes: new Uint8Array(1) },
{ pts: 24 * 1000, bytes: new Uint8Array(1) });
events.push({
pts: 15 * 1000,
data: new Uint8Array([]),
frames: [{
id: 'TXXX',
value: 'cue 0'
}]
});
standardXHRResponse(requests.shift()); // segment 0
// segment 1 is after a discontinuity, starts at PTS 22000
// and has a cue point at 23000
tags.push({ pts: 22 * 1000, bytes: new Uint8Array(1) });
events.push({
pts: 23 * 1000,
data: new Uint8Array([]),
frames: [{
id: 'TXXX',
value: 'cue 1'
}]
});
player.tech_.hls.checkBuffer_();
standardXHRResponse(requests.shift());
track = player.textTracks()[0];
equal(track.cues.length, 2, 'created cues');
equal(track.cues[0].startTime, 1, 'first cue started at the correct time');
equal(track.cues[0].endTime, 1, 'first cue ended at the correct time');
equal(track.cues[1].startTime, 11, 'second cue started at the correct time');
equal(track.cues[1].endTime, 11, 'second cue ended at the correct time');
});
test('adjusts the segment offsets for out-of-buffer seeking', function() {
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
standardXHRResponse(requests.shift()); // media
player.tech_.hls.sourceBuffer.buffered = function() {
return videojs.createTimeRange(0, 20);
};
equal(player.tech_.hls.mediaIndex, 0, 'starts at zero');
player.tech_.setCurrentTime(35);
clock.tick(1);
// drop the aborted segment
requests.shift();
equal(player.tech_.hls.mediaIndex, 3, 'moved the mediaIndex');
standardXHRResponse(requests.shift());
});
test('seeks between buffered time ranges', function() {
player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
standardXHRResponse(requests.shift()); // media
player.tech_.buffered = function() {
return {
length: 2,
ranges_: [[0, 10], [20, 30]],
start: function(i) {
return this.ranges_[i][0];
},
end: function(i) {
return this.ranges_[i][1];
}
};
};
player.tech_.setCurrentTime(15);
clock.tick(1);
// drop the aborted segment
requests.shift();
equal(player.tech_.hls.mediaIndex, 1, 'updated the mediaIndex');
standardXHRResponse(requests.shift());
});
test('does not modify the media index for in-buffer seeking', function() {
var mediaIndex;
player.src({
......@@ -1979,40 +1395,6 @@ test('duration is Infinity for live playlists', function() {
'duration is infinity');
});
test('updates the media index when a playlist reloads', function() {
player.src({
src: 'http://example.com/live-updating.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.trigger('play');
requests[0].respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n');
standardXHRResponse(requests[1]);
// play the stream until 2.ts is playing
player.tech_.hls.mediaIndex = 3;
// trigger a playlist refresh
player.tech_.hls.playlists.trigger('mediaupdatetimeout');
requests[2].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');
strictEqual(player.tech_.hls.mediaIndex, 2, 'mediaIndex is updated after the reload');
});
test('live playlist starts three target durations before live', function() {
var mediaPlaylist;
player.src({
......@@ -2040,30 +1422,11 @@ test('live playlist starts three target durations before live', function() {
player.tech_.trigger('play');
clock.tick(1);
mediaPlaylist = player.tech_.hls.playlists.media();
equal(player.tech_.hls.mediaIndex, 1, 'mediaIndex is updated at play');
equal(player.currentTime(), player.tech_.hls.seekable().end(0), 'seeked to the seekable end');
equal(requests.length, 1, 'begins buffering');
});
test('does not reset live currentTime if mediaIndex is one beyond the last available segment', function() {
var playlist = {
mediaSequence: 20,
targetDuration: 9,
segments: [{
duration: 3
}, {
duration: 3
}, {
duration: 3
}]
};
equal(playlist.segments.length,
videojs.Hls.translateMediaIndex(playlist.segments.length, playlist, playlist),
'did not change mediaIndex');
});
test('live playlist starts with correct currentTime value', function() {
player.src({
src: 'http://example.com/manifest/liveStart30sBefore.m3u8',
......@@ -2117,39 +1480,6 @@ test('resets the time to a seekable position when resuming a live stream ' +
player.tech_.trigger('seeked');
});
test('mediaIndex is zero before the first segment loads', function() {
window.manifests['first-seg-load'] =
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n';
player.src({
src: 'http://example.com/first-seg-load.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
strictEqual(player.tech_.hls.mediaIndex, 0, 'mediaIndex is zero');
});
test('mediaIndex returns correctly at playlist boundaries', function() {
player.src({
src: 'http://example.com/master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
standardXHRResponse(requests.shift()); // master
standardXHRResponse(requests.shift()); // media
strictEqual(player.tech_.hls.mediaIndex, 0, 'mediaIndex is zero at first segment');
// seek to end
player.tech_.setCurrentTime(40);
clock.tick(1);
strictEqual(player.tech_.hls.mediaIndex, 3, 'mediaIndex is 3 at last segment');
});
test('reloads out-of-date live playlists when switching variants', function() {
player.src({
src: 'http://example.com/master.m3u8',
......@@ -2248,26 +1578,21 @@ test('does not break if the playlist has no segments', function() {
strictEqual(requests.length, 1, 'no requests for non-existent segments were queued');
});
test('clears the segment buffer on seek', function() {
var currentTime, oldCurrentTime;
test('aborts segment processing on seek', function() {
var currentTime = 0;
player.src({
src: 'discontinuity.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
oldCurrentTime = player.currentTime;
player.currentTime = function(time) {
if (time !== undefined) {
return oldCurrentTime.call(player, time);
}
player.tech_.currentTime = function() {
return currentTime;
};
player.tech_.buffered = function() {
return videojs.createTimeRange();
};
requests.pop().respond(200, null,
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php"\n' +
'#EXTINF:10,0\n' +
......@@ -2275,23 +1600,19 @@ test('clears the segment buffer on seek', function() {
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:10,0\n' +
'2.ts\n' +
'#EXT-X-ENDLIST\n');
standardXHRResponse(requests.pop()); // 1.ts
// play to 6s to trigger the next segment request
currentTime = 6;
clock.tick(6000);
standardXHRResponse(requests.pop()); // 2.ts
equal(player.tech_.hls.segmentBuffer_.length, 2, 'started fetching segments');
'#EXT-X-ENDLIST\n'); // media
standardXHRResponse(requests.shift()); // 1.ts
standardXHRResponse(requests.shift()); // key.php
ok(player.tech_.hls.pendingSegment_, 'decrypting the segment');
// seek back to the beginning
player.currentTime(0);
clock.tick(1);
equal(player.tech_.hls.segmentBuffer_.length, 0, 'cleared the segment buffer');
ok(!player.tech_.hls.pendingSegment_, 'aborted processing');
});
test('calls mediaSource\'s timestampOffset on discontinuity', function() {
var buffered = [[]];
player.src({
src: 'discontinuity.m3u8',
type: 'application/vnd.apple.mpegurl'
......@@ -2299,10 +1620,10 @@ test('calls mediaSource\'s timestampOffset on discontinuity', function() {
openMediaSource(player);
player.play();
player.tech_.buffered = function() {
return videojs.createTimeRange(0, 10);
return videojs.createTimeRange(buffered);
};
requests.pop().respond(200, null,
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,0\n' +
'1.ts\n' +
......@@ -2311,14 +1632,14 @@ test('calls mediaSource\'s timestampOffset on discontinuity', function() {
'2.ts\n' +
'#EXT-X-ENDLIST\n');
player.tech_.hls.sourceBuffer.timestampOffset = 0;
standardXHRResponse(requests.pop()); // 1.ts
equal(player.tech_.hls.sourceBuffer.timestampOffset, 0, 'timestampOffset starts at zero');
// play to 6s to trigger the next segment request
clock.tick(6000);
standardXHRResponse(requests.shift()); // 1.ts
equal(player.tech_.hls.sourceBuffer.timestampOffset,
0,
'timestampOffset starts at zero');
standardXHRResponse(requests.pop()); // 2.ts
buffered = [[0, 10]];
player.tech_.hls.sourceBuffer.trigger('updateend');
standardXHRResponse(requests.shift()); // 2.ts
equal(player.tech_.hls.sourceBuffer.timestampOffset, 10, 'timestampOffset set after discontinuity');
});
......@@ -2410,7 +1731,7 @@ QUnit.skip('sets the timestampOffset after seeking to discontinuity', function()
'set the timestamp offset');
});
QUnit.skip('tracks segment end times as they are buffered', function() {
test('tracks segment end times as they are buffered', function() {
var bufferEnd = 0;
player.src({
src: 'media.m3u8',
......@@ -2437,8 +1758,7 @@ QUnit.skip('tracks segment end times as they are buffered', function() {
bufferEnd = 9.5;
player.tech_.hls.sourceBuffer.trigger('update');
player.tech_.hls.sourceBuffer.trigger('updateend');
equal(player.tech_.duration(), 10 + 9.5, 'updated duration');
equal(player.tech_.hls.appendingSegmentInfo_, null, 'cleared the appending segment');
equal(player.tech_.hls.mediaSource.duration, 10 + 9.5, 'updated duration');
});
QUnit.skip('seeking does not fail when targeted between segments', function() {
......@@ -2486,7 +1806,7 @@ test('resets the switching algorithm if a request times out', function() {
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.hls.bandwidth = 20000;
player.tech_.hls.bandwidth = 1e20;
standardXHRResponse(requests.shift()); // master
standardXHRResponse(requests.shift()); // media.m3u8
......@@ -2667,6 +1987,7 @@ test('tracks the bytes downloaded', function() {
// transmit some segment bytes
requests[0].response = new ArrayBuffer(17);
requests.shift().respond(200, null, '');
player.tech_.hls.sourceBuffer.trigger('updateend');
strictEqual(player.tech_.hls.bytesReceived, 17, 'tracked bytes received');
......@@ -2721,12 +2042,15 @@ test('can be disposed before finishing initialization', function() {
});
test('calls ended() on the media source at the end of a playlist', function() {
var endOfStreams = 0;
var endOfStreams = 0, buffered = [[]];
player.src({
src: 'http://example.com/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.buffered = function() {
return videojs.createTimeRanges(buffered);
};
player.tech_.hls.mediaSource.endOfStream = function() {
endOfStreams++;
};
......@@ -2741,70 +2065,61 @@ test('calls ended() on the media source at the end of a playlist', function() {
requests.shift().respond(200, null, '');
strictEqual(endOfStreams, 0, 'waits for the buffer update to finish');
buffered =[[0, 10]];
player.tech_.hls.sourceBuffer.trigger('updateend');
strictEqual(endOfStreams, 1, 'ended media source');
});
test('calling play() at the end of a video resets the media index', function() {
test('calling play() at the end of a video replays', function() {
var seekTime = -1;
player.src({
src: 'http://example.com/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
player.tech_.setCurrentTime = function(time) {
if (time !== undefined) {
seekTime = time;
}
return 0;
};
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXT-X-ENDLIST\n');
standardXHRResponse(requests.shift());
strictEqual(player.tech_.hls.mediaIndex, 1, 'index is 1 after the first segment');
player.tech_.ended = function() {
return true;
};
player.tech_.trigger('play');
strictEqual(player.tech_.hls.mediaIndex, 0, 'index is 0 after the first segment');
equal(seekTime, 0, 'seeked to the beginning');
});
test('drainBuffer will not proceed with empty source buffer', function() {
var oldMedia, newMedia, compareBuffer;
test('segments remain pending without a source buffer', function() {
player.src({
src: 'https://example.com/encrypted-media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(player);
oldMedia = player.tech_.hls.playlists.media;
newMedia = {segments: [{
key: {
'retries': 5
},
uri: 'http://media.example.com/fileSequence52-A.ts'
}, {
key: {
'method': 'AES-128',
'uri': 'https://priv.example.com/key.php?r=53'
},
uri: 'http://media.example.com/fileSequence53-B.ts'
}]};
player.tech_.hls.playlists.media = function() {
return newMedia;
};
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php?r=52"\n' +
'#EXTINF:10,\n' +
'http://media.example.com/fileSequence52-A.ts' +
'#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php?r=53"\n' +
'#EXTINF:10,\n' +
'http://media.example.com/fileSequence53-B.ts\n' +
'#EXT-X-ENDLIST\n');
player.tech_.hls.sourceBuffer = undefined;
compareBuffer = [{mediaIndex: 0, playlist: newMedia, offset: 0, bytes: new Uint8Array(3)}];
player.tech_.hls.segmentBuffer_ = [{mediaIndex: 0, playlist: newMedia, offset: 0, bytes: new Uint8Array(3)}];
player.tech_.hls.drainBuffer();
/* Normally, drainBuffer() calls segmentBuffer.shift(), removing a segment from the stack.
* Comparing two buffers to ensure no segment was popped verifies that we returned early
* from drainBuffer() because sourceBuffer was empty.
*/
deepEqual(player.tech_.hls.segmentBuffer_, compareBuffer, 'playlist remains unchanged');
player.tech_.hls.playlists.media = oldMedia;
standardXHRResponse(requests.shift()); // key
standardXHRResponse(requests.shift()); // segment
player.tech_.hls.checkBuffer_();
ok(player.tech_.hls.pendingSegment_, 'waiting for the source buffer');
});
test('keys are requested when an encrypted segment is loaded', function() {
......@@ -2815,12 +2130,14 @@ test('keys are requested when an encrypted segment is loaded', function() {
openMediaSource(player);
player.tech_.trigger('play');
standardXHRResponse(requests.shift()); // playlist
standardXHRResponse(requests.shift()); // first segment
strictEqual(requests.length, 1, 'a key XHR is created');
strictEqual(requests.length, 2, 'a key XHR is created');
strictEqual(requests[0].url,
player.tech_.hls.playlists.media().segments[0].key.uri,
'a key XHR is created with correct uri');
'key XHR is created with correct uri');
strictEqual(requests[1].url,
player.tech_.hls.playlists.media().segments[0].uri,
'segment XHR is created with correct uri');
});
test('keys are resolved relative to the master playlist', function() {
......@@ -2841,11 +2158,10 @@ test('keys are resolved relative to the master playlist', function() {
'#EXTINF:2.833,\n' +
'http://media.example.com/fileSequence1.ts\n' +
'#EXT-X-ENDLIST\n');
standardXHRResponse(requests.shift());
equal(requests.length, 1, 'requested the key');
ok((/video\/playlist\/keys\/key\.php$/).test(requests[0].url),
'resolves multiple relative paths');
equal(requests.length, 2, 'requested the key');
equal(requests[0].url,
absoluteUrl('video/playlist/keys/key.php'),
'resolves multiple relative paths');
});
test('keys are resolved relative to their containing playlist', function() {
......@@ -2861,13 +2177,13 @@ test('keys are resolved relative to their containing playlist', function() {
'#EXTINF:2.833,\n' +
'http://media.example.com/fileSequence1.ts\n' +
'#EXT-X-ENDLIST\n');
standardXHRResponse(requests.shift());
equal(requests.length, 1, 'requested a key');
ok((/video\/keys\/key\.php$/).test(requests[0].url),
'resolves multiple relative paths');
equal(requests.length, 2, 'requested a key');
equal(requests[0].url,
absoluteUrl('video/keys/key.php'),
'resolves multiple relative paths');
});
test('a new key XHR is created when a the segment is received', function() {
test('a new key XHR is created when a the segment is requested', function() {
player.src({
src: 'https://example.com/encrypted-media.m3u8',
type: 'application/vnd.apple.mpegurl'
......@@ -2884,15 +2200,17 @@ test('a new key XHR is created when a the segment is received', function() {
'#EXTINF:2.833,\n' +
'http://media.example.com/fileSequence2.ts\n' +
'#EXT-X-ENDLIST\n');
standardXHRResponse(requests.shift()); // segment 1
standardXHRResponse(requests.shift()); // key 1
standardXHRResponse(requests.shift()); // segment 1
// "finish" decrypting segment 1
player.tech_.hls.segmentBuffer_[0].bytes = new Uint8Array(16);
player.tech_.hls.pendingSegment_.bytes = new Uint8Array(16);
player.tech_.hls.checkBuffer_();
player.tech_.buffered = function() {
return videojs.createTimeRange(0, 2.833);
};
player.tech_.hls.sourceBuffer.trigger('updateend');
standardXHRResponse(requests.shift()); // segment 2
strictEqual(requests.length, 1, 'a key XHR is created');
strictEqual(requests.length, 2, 'a key XHR is created');
strictEqual(requests[0].url,
'https://example.com/' +
player.tech_.hls.playlists.media().segments[1].key.uri,
......@@ -2916,16 +2234,14 @@ test('seeking should abort an outstanding key request and create a new one', fun
'#EXTINF:9,\n' +
'http://media.example.com/fileSequence2.ts\n' +
'#EXT-X-ENDLIST\n');
standardXHRResponse(requests.shift()); // segment 1
standardXHRResponse(requests.pop()); // segment 1
player.currentTime(11);
clock.tick(1);
ok(requests[0].aborted, 'the key XHR should be aborted');
requests.shift(); // aborted key 1
equal(requests.length, 1, 'requested the new segment');
standardXHRResponse(requests.shift()); // segment 2
equal(requests.length, 1, 'requested the new key');
equal(requests.length, 2, 'requested the new key');
equal(requests[0].url,
'https://example.com/' +
player.tech_.hls.playlists.media().segments[1].key.uri,
......@@ -2948,7 +2264,7 @@ test('retries key requests once upon failure', function() {
'#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=53"\n' +
'#EXTINF:15.0,\n' +
'http://media.example.com/fileSequence53-A.ts\n');
standardXHRResponse(requests.shift()); // segment
standardXHRResponse(requests.pop()); // segment
requests[0].respond(404);
equal(requests.length, 2, 'create a new XHR for the same key');
equal(requests[1].url, requests[0].url, 'should be the same key');
......@@ -2957,7 +2273,7 @@ test('retries key requests once upon failure', function() {
equal(requests.length, 2, 'gives up after one retry');
});
test('skip segments if key requests fail more than once', function() {
test('errors if key requests fail more than once', function() {
var bytes = [];
player.src({
......@@ -2978,23 +2294,14 @@ test('skip segments if key requests fail more than once', function() {
player.tech_.hls.sourceBuffer.appendBuffer = function(chunk) {
bytes.push(chunk);
};
standardXHRResponse(requests.shift()); // segment 1
standardXHRResponse(requests.pop()); // segment 1
requests.shift().respond(404); // fail key
requests.shift().respond(404); // fail key, again
player.tech_.hls.checkBuffer_();
standardXHRResponse(requests.shift()); // segment 2
equal(bytes.length, 0, 'did not append encrypted bytes');
// key for second segment
requests[0].response = new Uint32Array([0,0,0,0]).buffer;
requests.shift().respond(200, null, '');
// "finish" decryption
player.tech_.hls.segmentBuffer_[0].bytes = new Uint8Array(16);
player.tech_.hls.checkBuffer_();
equal(bytes.length, 1, 'appended cleartext bytes from the second segment');
deepEqual(bytes[0], new Uint8Array(16), 'appended bytes from the second segment, not the first');
equal(player.tech_.hls.mediaSource.error_,
'network',
'triggered a network error');
});
test('the key is supplied to the decrypter in the correct format', function() {
......@@ -3016,12 +2323,11 @@ test('the key is supplied to the decrypter in the correct format', function() {
'#EXTINF:15.0,\n' +
'http://media.example.com/fileSequence52-B.ts\n');
videojs.Hls.Decrypter = function(encrypted, key) {
keys.push(key);
};
standardXHRResponse(requests.shift()); // segment
standardXHRResponse(requests.pop()); // segment
requests[0].response = new Uint32Array([0,1,2,3]).buffer;
requests[0].respond(200, null, '');
requests.shift(); // key
......@@ -3068,6 +2374,7 @@ test('supplies the media sequence of current segment as the IV by default, if no
});
test('switching playlists with an outstanding key request does not stall playback', function() {
var buffered = [];
var media = '#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:5\n' +
'#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n' +
......@@ -3082,31 +2389,37 @@ test('switching playlists with an outstanding key request does not stall playbac
openMediaSource(player);
player.tech_.trigger('play');
player.tech_.hls.bandwidth = 1;
player.tech_.buffered = function() {
return videojs.createTimeRange(buffered);
};
// master playlist
standardXHRResponse(requests.shift());
// media playlist
requests.shift().respond(200, null, media);
// mock out media switching from this point on
player.tech_.hls.playlists.media = function() {
return player.tech_.hls.playlists.master.playlists[0];
return player.tech_.hls.playlists.master.playlists[1];
};
// first segment of the original media playlist
standardXHRResponse(requests.shift());
// don't respond to the initial key request
requests.shift();
standardXHRResponse(requests.pop());
// "switch" media
player.tech_.hls.playlists.trigger('mediachange');
ok(!requests[0].aborted, 'did not abort the key request');
// "finish" decrypting segment 1
standardXHRResponse(requests.shift()); // key
player.tech_.hls.pendingSegment_.bytes = new Uint8Array(16);
player.tech_.hls.checkBuffer_();
buffered = [[0, 2.833]];
player.tech_.hls.sourceBuffer.trigger('updateend');
player.tech_.hls.checkBuffer_();
ok(requests.length, 'made a request');
equal(requests.length, 1, 'made a request');
equal(requests[0].url,
'http://media.example.com/fileSequence52-B.ts',
'requested the segment');
equal(requests[1].url,
'https://priv.example.com/key.php?r=52',
'requested the key');
});
test('resolves relative key URLs against the playlist', function() {
......@@ -3123,8 +2436,6 @@ test('resolves relative key URLs against the playlist', function() {
'#EXTINF:2.833,\n' +
'http://media.example.com/fileSequence52-A.ts\n' +
'#EXT-X-ENDLIST\n');
standardXHRResponse(requests.shift()); // segment
equal(requests[0].url, 'https://example.com/key.php?r=52', 'resolves the key URL');
});
......@@ -3149,7 +2460,7 @@ test('treats invalid keys as a key request failure', function() {
bytes.push(chunk);
};
// segment request
standardXHRResponse(requests.shift());
standardXHRResponse(requests.pop());
// keys should be 16 bytes long
requests[0].response = new Uint8Array(1).buffer;
requests.shift().respond(200, null, '');
......@@ -3159,17 +2470,12 @@ test('treats invalid keys as a key request failure', function() {
// the retried response is invalid, too
requests[0].response = new Uint8Array(1);
requests.shift().respond(200, null, '');
// the first segment should be dropped and playback moves on
player.tech_.hls.checkBuffer_();
equal(bytes.length, 0, 'did not append bytes');
// second segment request
requests[0].response = new Uint8Array([1, 2]);
requests.shift().respond(200, null, '');
equal(bytes.length, 1, 'appended bytes');
deepEqual(bytes[0], new Uint8Array([1, 2]), 'skipped to the second segment');
// two failed attempts is a network error
equal(player.tech_.hls.mediaSource.error_,
'network',
'triggered a network error');
});
test('live stream should not call endOfStream', function(){
......@@ -3209,4 +2515,49 @@ test('does not download segments if preload option set to none', function() {
equal(requests.length, 0, 'did not download any segments');
});
module('Buffer Inspection');
test('detects time range edges added by updates', function() {
var edges;
edges = videojs.Hls.bufferedAdditions_(videojs.createTimeRange([[0, 10]]),
videojs.createTimeRange([[0, 11]]));
deepEqual(edges, [{ end: 11 }], 'detected a forward addition');
edges = videojs.Hls.bufferedAdditions_(videojs.createTimeRange([[5, 10]]),
videojs.createTimeRange([[0, 10]]));
deepEqual(edges, [{ start: 0 }], 'detected a backward addition');
edges = videojs.Hls.bufferedAdditions_(videojs.createTimeRange([[5, 10]]),
videojs.createTimeRange([[0, 11]]));
deepEqual(edges, [
{ start: 0 }, { end: 11 }
], 'detected forward and backward additions');
edges = videojs.Hls.bufferedAdditions_(videojs.createTimeRange([[0, 10]]),
videojs.createTimeRange([[0, 10]]));
deepEqual(edges, [], 'detected no addition');
edges = videojs.Hls.bufferedAdditions_(videojs.createTimeRange([]),
videojs.createTimeRange([[0, 10]]));
deepEqual(edges, [
{ start: 0 },
{ end: 10 }
], 'detected an initial addition');
edges = videojs.Hls.bufferedAdditions_(videojs.createTimeRange([[0, 10]]),
videojs.createTimeRange([[0, 10], [20, 30]]));
deepEqual(edges, [
{ start: 20 },
{ end: 30}
], 'detected a non-contiguous addition');
});
test('treats null buffered ranges as no addition', function() {
var edges = videojs.Hls.bufferedAdditions_(null,
videojs.createTimeRange([[0, 11]]));
equal(edges.length, 0, 'no additions');
});
})(window, window.videojs);
......