5a33fc57 by brandonocasey

browserify-p4: playlist*, xhr, and resolve-url

updated stub.js to keep unit tests working
updated build/watch scripts
ripped resolve-url out of videojs-contrib-hls for now
1 parent e3c93f60
......@@ -48,10 +48,7 @@
<script src="/node_modules/video.js/dist/video.js"></script>
<script src="/node_modules/videojs-contrib-media-sources/dist/videojs-media-sources.js"></script>
<script src="/src/videojs-contrib-hls.js"></script>
<script src="/src/xhr.js"></script>
<script src="/dist/videojs-contrib-hls.js"></script>
<script src="/src/playlist.js"></script>
<script src="/src/playlist-loader.js"></script>
<script src="/src/bin-utils.js"></script>
<script>
(function(window, videojs) {
......
......@@ -2,7 +2,7 @@ var browserify = require('browserify');
var fs = require('fs');
var glob = require('glob');
glob('test/{decryper,m3u8,stub}.test.js', function(err, files) {
glob('test/{playlist*,decryper,m3u8,stub}.test.js', function(err, files) {
browserify(files)
.transform('babelify')
.bundle()
......
......@@ -3,7 +3,7 @@ var fs = require('fs');
var glob = require('glob');
var watchify = require('watchify');
glob('test/{decrypter,m3u8,stub}.test.js', function(err, files) {
glob('test/{playlist*,decrypter,m3u8,stub}.test.js', function(err, files) {
var b = browserify(files, {
cache: {},
packageCache: {},
......
......@@ -5,376 +5,385 @@
* M3U8 playlists.
*
*/
(function(window, videojs) {
'use strict';
var
resolveUrl = videojs.Hls.resolveUrl,
xhr = videojs.Hls.xhr,
mergeOptions = videojs.mergeOptions,
/**
* Returns a new master playlist that is the result of merging an
* updated media playlist into the original version. If the
* updated media playlist does not match any of the playlist
* entries in the original master playlist, null is returned.
* @param master {object} a parsed master M3U8 object
* @param media {object} a parsed media M3U8 object
* @return {object} a new object that represents the original
* master playlist with the updated media playlist merged in, or
* null if the merge produced no change.
*/
updateMaster = function(master, media) {
var
changed = false,
result = mergeOptions(master, {}),
i,
playlist;
i = master.playlists.length;
while (i--) {
playlist = result.playlists[i];
if (playlist.uri === media.uri) {
// consider the playlist unchanged if the number of segments
// are equal and the media sequence number is unchanged
if (playlist.segments &&
media.segments &&
playlist.segments.length === media.segments.length &&
playlist.mediaSequence === media.mediaSequence) {
continue;
}
result.playlists[i] = mergeOptions(playlist, media);
result.playlists[media.uri] = result.playlists[i];
// if the update could overlap existing segment information,
// merge the two lists
if (playlist.segments) {
result.playlists[i].segments = updateSegments(playlist.segments,
media.segments,
media.mediaSequence - playlist.mediaSequence);
}
changed = true;
}
}
return changed ? result : null;
},
/**
* Returns a new array of segments that is the result of merging
* properties from an older list of segments onto an updated
* list. No properties on the updated playlist will be overridden.
* @param original {array} the outdated list of segments
* @param update {array} the updated list of segments
* @param offset {number} (optional) the index of the first update
* segment in the original segment list. For non-live playlists,
* this should always be zero and does not need to be
* specified. For live playlists, it should be the difference
* between the media sequence numbers in the original and updated
* playlists.
* @return a list of merged segment objects
*/
updateSegments = function(original, update, offset) {
var result = update.slice(), length, i;
offset = offset || 0;
length = Math.min(original.length, update.length + offset);
for (i = offset; i < length; i++) {
result[i - offset] = mergeOptions(original[i], result[i - offset]);
import resolveUrl from './resolve-url';
import XhrModule from './xhr';
import {mergeOptions} from 'video.js';
import Stream from './stream';
import m3u8 from './m3u8';
/**
* Returns a new array of segments that is the result of merging
* properties from an older list of segments onto an updated
* list. No properties on the updated playlist will be overridden.
* @param original {array} the outdated list of segments
* @param update {array} the updated list of segments
* @param offset {number} (optional) the index of the first update
* segment in the original segment list. For non-live playlists,
* this should always be zero and does not need to be
* specified. For live playlists, it should be the difference
* between the media sequence numbers in the original and updated
* playlists.
* @return a list of merged segment objects
*/
const updateSegments = function(original, update, offset) {
let result = update.slice();
let length;
let i;
offset = offset || 0;
length = Math.min(original.length, update.length + offset);
for (i = offset; i < length; i++) {
result[i - offset] = mergeOptions(original[i], result[i - offset]);
}
return result;
};
/**
* Returns a new master playlist that is the result of merging an
* updated media playlist into the original version. If the
* updated media playlist does not match any of the playlist
* entries in the original master playlist, null is returned.
* @param master {object} a parsed master M3U8 object
* @param media {object} a parsed media M3U8 object
* @return {object} a new object that represents the original
* master playlist with the updated media playlist merged in, or
* null if the merge produced no change.
*/
const updateMaster = function(master, media) {
let changed = false;
let result = mergeOptions(master, {});
let i = master.playlists.length;
let playlist;
while (i--) {
playlist = result.playlists[i];
if (playlist.uri === media.uri) {
// consider the playlist unchanged if the number of segments
// are equal and the media sequence number is unchanged
if (playlist.segments &&
media.segments &&
playlist.segments.length === media.segments.length &&
playlist.mediaSequence === media.mediaSequence) {
continue;
}
return result;
},
PlaylistLoader = function(srcUrl, withCredentials) {
var
loader = this,
dispose,
mediaUpdateTimeout,
request,
playlistRequestError,
haveMetadata;
PlaylistLoader.prototype.init.call(this);
// a flag that disables "expired time"-tracking this setting has
// no effect when not playing a live stream
this.trackExpiredTime_ = false;
if (!srcUrl) {
throw new Error('A non-empty playlist URL is required');
result.playlists[i] = mergeOptions(playlist, media);
result.playlists[media.uri] = result.playlists[i];
// if the update could overlap existing segment information,
// merge the two lists
if (playlist.segments) {
result.playlists[i].segments = updateSegments(
playlist.segments,
media.segments,
media.mediaSequence - playlist.mediaSequence
);
}
changed = true;
}
}
return changed ? result : null;
};
playlistRequestError = function(xhr, url, startingState) {
loader.setBandwidth(request || xhr);
export default class PlaylistLoader extends Stream {
constructor(srcUrl, withCredentials) {
super();
this.srcUrl = srcUrl;
this.withCredentials = withCredentials;
// any in-flight request is now finished
request = null;
if (startingState) {
loader.state = startingState;
}
this.mediaUpdateTimeout = null;
loader.error = {
playlist: loader.master.playlists[url],
status: xhr.status,
message: 'HLS playlist request error at URL: ' + url,
responseText: xhr.responseText,
code: (xhr.status >= 500) ? 4 : 2
};
loader.trigger('error');
};
// initialize the loader state
this.state = 'HAVE_NOTHING';
// update the playlist loader's state in response to a new or
// updated playlist.
// track the time that has expired from the live window
// this allows the seekable start range to be calculated even if
// all segments with timing information have expired
this.expired_ = 0;
haveMetadata = function(xhr, url) {
var parser, refreshDelay, update;
// a flag that disables "expired time"-tracking this setting has
// no effect when not playing a live stream
this.trackExpiredTime_ = false;
loader.setBandwidth(request || xhr);
if (!this.srcUrl) {
throw new Error('A non-empty playlist URL is required');
}
// any in-flight request is now finished
request = null;
loader.state = 'HAVE_METADATA';
// In a live list, don't keep track of the expired time until
// HLS tells us that "first play" has commenced
this.on('firstplay', function() {
this.trackExpiredTime_ = true;
});
parser = new videojs.m3u8.Parser();
parser.push(xhr.responseText);
parser.end();
parser.manifest.uri = url;
// live playlist staleness timeout
this.on('mediaupdatetimeout', () => {
if (this.state !== 'HAVE_METADATA') {
// only refresh the media playlist if no other activity is going on
return;
}
// merge this playlist into the master
update = updateMaster(loader.master, parser.manifest);
refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
if (update) {
loader.master = update;
loader.updateMediaPlaylist_(parser.manifest);
} else {
// if the playlist is unchanged since the last reload,
// try again after half the target duration
refreshDelay /= 2;
this.state = 'HAVE_CURRENT_METADATA';
this.request = XhrModule({
uri: resolveUrl(this.master.uri, this.media().uri),
withCredentials: this.withCredentials
}, (error, request) => {
if (error) {
return this.playlistRequestError(request, this.media().uri);
}
this.haveMetadata(request, this.media().uri);
});
});
// request the specified URL
this.request = XhrModule({
uri: this.srcUrl,
withCredentials: this.withCredentials
}, (error, request) => {
let parser = new m3u8.Parser();
let i;
// clear the loader's request reference
this.request = null;
if (error) {
this.error = {
status: request.status,
message: 'HLS playlist request error at URL: ' + this.srcUrl,
responseText: request.responseText,
// MEDIA_ERR_NETWORK
code: 2
};
return this.trigger('error');
}
// refresh live playlists after a target duration passes
if (!loader.media().endList) {
window.clearTimeout(mediaUpdateTimeout);
mediaUpdateTimeout = window.setTimeout(function() {
loader.trigger('mediaupdatetimeout');
}, refreshDelay);
}
parser.push(request.responseText);
parser.end();
loader.trigger('loadedplaylist');
};
this.state = 'HAVE_MASTER';
// initialize the loader state
loader.state = 'HAVE_NOTHING';
// track the time that has expired from the live window
// this allows the seekable start range to be calculated even if
// all segments with timing information have expired
this.expired_ = 0;
// capture the prototype dispose function
dispose = this.dispose;
/**
* Abort any outstanding work and clean up.
*/
loader.dispose = function() {
if (request) {
request.onreadystatechange = null;
request.abort();
request = null;
}
window.clearTimeout(mediaUpdateTimeout);
dispose.call(this);
};
parser.manifest.uri = this.srcUrl;
/**
* When called without any arguments, returns the currently
* 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 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 startingState = loader.state, mediaChange;
// getter
if (!playlist) {
return loader.media_;
}
// loaded a master playlist
if (parser.manifest.playlists) {
this.master = parser.manifest;
// setter
if (loader.state === 'HAVE_NOTHING') {
throw new Error('Cannot switch media playlist from ' + loader.state);
// setup by-URI lookups
i = this.master.playlists.length;
while (i--) {
this.master.playlists[this.master.playlists[i].uri] =
this.master.playlists[i];
}
// find the playlist object if the target playlist has been
// specified by URI
if (typeof playlist === 'string') {
if (!loader.master.playlists[playlist]) {
throw new Error('Unknown playlist URI: ' + playlist);
}
playlist = loader.master.playlists[playlist];
this.trigger('loadedplaylist');
if (!this.request) {
// no media playlist was specifically selected so start
// from the first listed one
this.media(parser.manifest.playlists[0]);
}
return;
}
mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri;
// switch to fully loaded playlists immediately
if (loader.master.playlists[playlist.uri].endList) {
// abort outstanding playlist requests
if (request) {
request.onreadystatechange = null;
request.abort();
request = null;
}
loader.state = 'HAVE_METADATA';
loader.media_ = playlist;
// trigger media change if the active media has been updated
if (mediaChange) {
loader.trigger('mediachange');
}
return;
}
// loaded a media playlist
// infer a master playlist if none was previously requested
this.master = {
uri: window.location.href,
playlists: [{
uri: this.srcUrl
}]
};
this.master.playlists[this.srcUrl] = this.master.playlists[0];
this.haveMetadata(request, this.srcUrl);
return this.trigger('loadedmetadata');
});
}
// switching to the active playlist is a no-op
if (!mediaChange) {
return;
}
playlistRequestError(xhr, url, startingState) {
this.setBandwidth(this.request || xhr);
loader.state = 'SWITCHING_MEDIA';
// there is already an outstanding playlist request
if (request) {
if (resolveUrl(loader.master.uri, playlist.uri) === request.url) {
// requesting to switch to the same playlist multiple times
// has no effect after the first
return;
}
request.onreadystatechange = null;
request.abort();
request = null;
}
// any in-flight request is now finished
this.request = null;
// request the new playlist
request = xhr({
uri: resolveUrl(loader.master.uri, playlist.uri),
withCredentials: withCredentials
}, function(error, request) {
if (error) {
return playlistRequestError(request, playlist.uri, startingState);
}
haveMetadata(request, playlist.uri);
// fire loadedmetadata the first time a media playlist is loaded
if (startingState === 'HAVE_MASTER') {
loader.trigger('loadedmetadata');
} else {
loader.trigger('mediachange');
}
});
};
if (startingState) {
this.state = startingState;
}
loader.setBandwidth = function(xhr) {
loader.bandwidth = xhr.bandwidth;
};
this.error = {
playlist: this.master.playlists[url],
status: xhr.status,
message: 'HLS playlist request error at URL: ' + url,
responseText: xhr.responseText,
code: (xhr.status >= 500) ? 4 : 2
};
this.trigger('error');
}
// update the playlist loader's state in response to a new or
// updated playlist.
haveMetadata(xhr, url) {
let parser;
let refreshDelay;
let update;
this.setBandwidth(this.request || xhr);
// any in-flight request is now finished
this.request = null;
this.state = 'HAVE_METADATA';
parser = new m3u8.Parser();
parser.push(xhr.responseText);
parser.end();
parser.manifest.uri = url;
// merge this playlist into the master
update = updateMaster(this.master, parser.manifest);
refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
if (update) {
this.master = update;
this.updateMediaPlaylist_(parser.manifest);
} else {
// if the playlist is unchanged since the last reload,
// try again after half the target duration
refreshDelay /= 2;
}
// In a live list, don't keep track of the expired time until
// HLS tells us that "first play" has commenced
loader.on('firstplay', function() {
this.trackExpiredTime_ = true;
});
// refresh live playlists after a target duration passes
if (!this.media().endList) {
this.clearMediaUpdateTimeout_();
this.mediaUpdateTimeout = window.setTimeout(() => {
this.trigger('mediaupdatetimeout');
}, refreshDelay);
}
// live playlist staleness timeout
loader.on('mediaupdatetimeout', function() {
if (loader.state !== 'HAVE_METADATA') {
// only refresh the media playlist if no other activity is going on
return;
}
this.trigger('loadedplaylist');
}
loader.state = 'HAVE_CURRENT_METADATA';
request = xhr({
uri: resolveUrl(loader.master.uri, loader.media().uri),
withCredentials: withCredentials
}, function(error, request) {
if (error) {
return playlistRequestError(request, loader.media().uri);
}
haveMetadata(request, loader.media().uri);
});
});
clearMediaUpdateTimeout_() {
if (this.mediaUpdateTimeout) {
window.clearTimeout(this.mediaUpdateTimeout);
}
}
// request the specified URL
request = xhr({
uri: srcUrl,
withCredentials: withCredentials
}, function(error, req) {
var parser, i;
requestDispose_() {
if (this.request) {
this.request.onreadystatechange = null;
this.request.abort();
this.request = null;
}
}
// clear the loader's request reference
request = null;
/**
* Abort any outstanding work and clean up.
*/
dispose() {
this.requestDispose_();
this.clearMediaUpdateTimeout_();
super.dispose();
}
if (error) {
loader.error = {
status: req.status,
message: 'HLS playlist request error at URL: ' + srcUrl,
responseText: req.responseText,
code: 2 // MEDIA_ERR_NETWORK
};
return loader.trigger('error');
}
/**
* When called without any arguments, returns the currently
* 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 causes an error to be emitted
* but otherwise has no effect.
* @param playlist (optional) {object} the parsed media playlist
* object to switch to
*/
media(playlist) {
let startingState = this.state;
let mediaChange;
parser = new videojs.m3u8.Parser();
parser.push(req.responseText);
parser.end();
// getter
if (!playlist) {
return this.media_;
}
loader.state = 'HAVE_MASTER';
// setter
if (this.state === 'HAVE_NOTHING') {
throw new Error('Cannot switch media playlist from ' + this.state);
}
parser.manifest.uri = srcUrl;
// find the playlist object if the target playlist has been
// specified by URI
if (typeof playlist === 'string') {
if (!this.master.playlists[playlist]) {
throw new Error('Unknown playlist URI: ' + playlist);
}
playlist = this.master.playlists[playlist];
}
// loaded a master playlist
if (parser.manifest.playlists) {
loader.master = parser.manifest;
mediaChange = !this.media_ || playlist.uri !== this.media_.uri;
// setup by-URI lookups
i = loader.master.playlists.length;
while (i--) {
loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i];
}
// switch to fully loaded playlists immediately
if (this.master.playlists[playlist.uri].endList) {
// abort outstanding playlist requests
this.requestDispose_();
this.state = 'HAVE_METADATA';
this.media_ = playlist;
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;
}
// trigger media change if the active media has been updated
if (mediaChange) {
this.trigger('mediachange');
}
return;
}
// loaded a media playlist
// infer a master playlist if none was previously requested
loader.master = {
uri: window.location.href,
playlists: [{
uri: srcUrl
}]
};
loader.master.playlists[srcUrl] = loader.master.playlists[0];
haveMetadata(req, srcUrl);
return loader.trigger('loadedmetadata');
});
};
PlaylistLoader.prototype = new videojs.Hls.Stream();
// switching to the active playlist is a no-op
if (!mediaChange) {
return;
}
this.state = 'SWITCHING_MEDIA';
// there is already an outstanding playlist request
if (this.request) {
if (resolveUrl(this.master.uri, playlist.uri) === this.request.url) {
// requesting to switch to the same playlist multiple times
// has no effect after the first
return;
}
this.requestDispose_();
}
// request the new playlist
this.request = XhrModule({
uri: resolveUrl(this.master.uri, playlist.uri),
withCredentials: this.withCredentials
}, (error, request) => {
if (error) {
return this.playlistRequestError(request, playlist.uri, startingState);
}
this.haveMetadata(request, playlist.uri);
if (error) {
return;
}
// fire loadedmetadata the first time a media playlist is loaded
if (startingState === 'HAVE_MASTER') {
this.trigger('loadedmetadata');
} else {
this.trigger('mediachange');
}
});
}
setBandwidth(xhr) {
this.bandwidth = xhr.bandwidth;
}
/**
* Update the PlaylistLoader state to reflect the changes in an
* update to the current media playlist.
* @param update {object} the updated media playlist object
*/
PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) {
var outdated, i, segment;
updateMediaPlaylist_(update) {
let outdated;
let i;
let segment;
outdated = this.media_;
this.media_ = this.master.playlists[update.uri];
......@@ -398,10 +407,10 @@
// try using precise timing from first segment of the updated
// playlist
if (update.segments.length) {
if (update.segments[0].start !== undefined) {
if (typeof update.segments[0].start !== 'undefined') {
this.expired_ = update.segments[0].start;
return;
} else if (update.segments[0].end !== undefined) {
} else if (typeof update.segments[0].end !== 'undefined') {
this.expired_ = update.segments[0].end - update.segments[0].duration;
return;
}
......@@ -422,17 +431,17 @@
continue;
}
if (segment.end !== undefined) {
if (typeof segment.end !== 'undefined') {
this.expired_ = segment.end;
return;
}
if (segment.start !== undefined) {
if (typeof segment.start !== 'undefined') {
this.expired_ = segment.start + segment.duration;
return;
}
this.expired_ += segment.duration;
}
};
}
/**
* Determine the index of the segment that contains a specified
......@@ -452,17 +461,16 @@
* value will be clamped to the index of the segment containing the
* closest playback position that is currently available.
*/
PlaylistLoader.prototype.getMediaIndexForTime_ = function(time) {
var
i,
segment,
originalTime = time,
numSegments = this.media_.segments.length,
lastSegment = numSegments - 1,
startIndex,
endIndex,
knownStart,
knownEnd;
getMediaIndexForTime_(time) {
let i;
let segment;
let originalTime = time;
let numSegments = this.media_.segments.length;
let lastSegment = numSegments - 1;
let startIndex;
let endIndex;
let knownStart;
let knownEnd;
if (!this.media_) {
return 0;
......@@ -492,7 +500,7 @@
// use the bounds we just found and playlist information to
// estimate the segment that contains the time we are looking for
if (startIndex !== undefined) {
if (typeof startIndex !== 'undefined') {
// We have a known-start point that is before our desired time so
// walk from that point forwards
time = time - knownStart;
......@@ -517,7 +525,7 @@
// We _still_ haven't found a segment so load the last one
return lastSegment;
} else if (endIndex !== undefined) {
} else if (typeof endIndex !== 'undefined') {
// We _only_ have a known-end point that is after our desired time so
// walk from that point backwards
time = knownEnd - time;
......@@ -533,32 +541,28 @@
// We haven't found a segment so load the first one if time is zero
if (time === 0) {
return 0;
} else {
return -1;
}
} else {
// We known nothing so walk from the front of the playlist,
// subtracting durations until we find a segment that contains
// time and return it
time = time - this.expired_;
return -1;
}
// We known nothing so walk from the front of the playlist,
// subtracting durations until we find a segment that contains
// time and return it
time = time - this.expired_;
if (time < 0) {
return -1;
}
if (time < 0) {
return -1;
}
for (i = 0; i < numSegments; i++) {
segment = this.media_.segments[i];
time -= segment.duration;
if (time < 0) {
return i;
}
for (i = 0; i < numSegments; i++) {
segment = this.media_.segments[i];
time -= segment.duration;
if (time < 0) {
return i;
}
// We are out of possible candidates so load the last one...
// The last one is the least likely to overlap a buffer and therefore
// the one most likely to tell us something about the timeline
return lastSegment;
}
};
videojs.Hls.PlaylistLoader = PlaylistLoader;
})(window, window.videojs);
// We are out of possible candidates so load the last one...
// The last one is the least likely to overlap a buffer and therefore
// the one most likely to tell us something about the timeline
return lastSegment;
}
}
......
/**
* Playlist related utilities.
*/
(function(window, videojs) {
'use strict';
var duration, intervalDuration, backwardDuration, forwardDuration, seekable;
backwardDuration = function(playlist, endSequence) {
var result = 0, segment, i;
i = endSequence - playlist.mediaSequence;
// if a start time is available for segment immediately following
// the interval, use it
import {createTimeRange} from 'video.js';
const backwardDuration = function(playlist, endSequence) {
let result = 0;
let i = endSequence - playlist.mediaSequence;
// if a start time is available for segment immediately following
// the interval, use it
let segment = playlist.segments[i];
// Walk backward until we find the latest segment with timeline
// information that is earlier than endSequence
if (segment) {
if (typeof segment.start !== 'undefined') {
return { result: segment.start, precise: true };
}
if (typeof segment.end !== 'undefined') {
return {
result: segment.end - segment.duration,
precise: true
};
}
}
while (i--) {
segment = playlist.segments[i];
// Walk backward until we find the latest segment with timeline
// information that is earlier than endSequence
if (segment) {
if (segment.start !== undefined) {
return { result: segment.start, precise: true };
}
if (segment.end !== undefined) {
return {
result: segment.end - segment.duration,
precise: true
};
}
if (typeof segment.end !== 'undefined') {
return { result: result + segment.end, precise: true };
}
while (i--) {
segment = playlist.segments[i];
if (segment.end !== undefined) {
return { result: result + segment.end, precise: true };
}
result += segment.duration;
result += segment.duration;
if (segment.start !== undefined) {
return { result: result + segment.start, precise: true };
}
if (typeof segment.start !== 'undefined') {
return { result: result + segment.start, precise: true };
}
return { result: result, precise: false };
};
forwardDuration = function(playlist, endSequence) {
var result = 0, segment, i;
i = endSequence - playlist.mediaSequence;
// Walk forward until we find the earliest segment with timeline
// information
for (; i < playlist.segments.length; i++) {
segment = playlist.segments[i];
if (segment.start !== undefined) {
return {
result: segment.start - result,
precise: true
};
}
result += segment.duration;
if (segment.end !== undefined) {
return {
result: segment.end - result,
precise: true
};
}
}
// indicate we didn't find a useful duration estimate
return { result: -1, precise: false };
};
/**
* 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 an end index.
*
* @param playlist {object} a media playlist object
* @param endSequence {number} (optional) an exclusive upper boundary
* for the playlist. Defaults to playlist length.
* @return {number} the duration between the first available segment
* and end index.
*/
intervalDuration = function(playlist, endSequence) {
var backward, forward;
if (endSequence === undefined) {
endSequence = playlist.mediaSequence + playlist.segments.length;
}
return { result, precise: false };
};
const forwardDuration = function(playlist, endSequence) {
let result = 0;
let segment;
let i = endSequence - playlist.mediaSequence;
// Walk forward until we find the earliest segment with timeline
// information
for (; i < playlist.segments.length; i++) {
segment = playlist.segments[i];
if (typeof segment.start !== 'undefined') {
return {
result: segment.start - result,
precise: true
};
}
if (endSequence < playlist.mediaSequence) {
return 0;
}
result += segment.duration;
// do a backward walk to estimate the duration
backward = backwardDuration(playlist, endSequence);
if (backward.precise) {
// if we were able to base our duration estimate on timing
// information provided directly from the Media Source, return
// it
return backward.result;
if (typeof segment.end !== 'undefined') {
return {
result: segment.end - result,
precise: true
};
}
// walk forward to see if a precise duration estimate can be made
// that way
forward = forwardDuration(playlist, endSequence);
if (forward.precise) {
// we found a segment that has been buffered and so it's
// position is known precisely
return forward.result;
}
}
// indicate we didn't find a useful duration estimate
return { result: -1, precise: false };
};
// return the less-precise, playlist-based duration estimate
/**
* 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 an end index.
*
* @param playlist {object} a media playlist object
* @param endSequence {number} (optional) an exclusive upper boundary
* for the playlist. Defaults to playlist length.
* @return {number} the duration between the first available segment
* and end index.
*/
const intervalDuration = function(playlist, endSequence) {
let backward;
let forward;
if (typeof endSequence === 'undefined') {
endSequence = playlist.mediaSequence + playlist.segments.length;
}
if (endSequence < playlist.mediaSequence) {
return 0;
}
// do a backward walk to estimate the duration
backward = backwardDuration(playlist, endSequence);
if (backward.precise) {
// if we were able to base our duration estimate on timing
// information provided directly from the Media Source, return
// it
return backward.result;
};
/**
* Calculates the duration of a playlist. If a start and end index
* are specified, the duration will be for the subset of the media
* timeline between those two indices. The total duration for live
* playlists is always Infinity.
* @param playlist {object} a media playlist object
* @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, endSequence, includeTrailingTime) {
if (!playlist) {
return 0;
}
}
if (includeTrailingTime === undefined) {
includeTrailingTime = true;
}
// walk forward to see if a precise duration estimate can be made
// that way
forward = forwardDuration(playlist, endSequence);
if (forward.precise) {
// we found a segment that has been buffered and so it's
// position is known precisely
return forward.result;
}
// if a slice of the total duration is not requested, use
// playlist-level duration indicators when they're present
if (endSequence === undefined) {
// if present, use the duration specified in the playlist
if (playlist.totalDuration) {
return playlist.totalDuration;
}
// duration should be Infinity for live playlists
if (!playlist.endList) {
return window.Infinity;
}
}
// return the less-precise, playlist-based duration estimate
return backward.result;
};
// calculate the total duration based on the segment durations
return intervalDuration(playlist,
endSequence,
includeTrailingTime);
};
/**
* Calculates the interval of time that is currently seekable in a
* playlist. The returned time ranges are relative to the earliest
* moment in the specified playlist that is still available. A full
* seekable implementation for live streams would need to offset
* these values by the duration of content that has expired from the
* stream.
* @param playlist {object} a media playlist object
* @return {TimeRanges} the periods of time that are valid targets
* for seeking
*/
seekable = function(playlist) {
var start, end;
// without segments, there are no seekable ranges
if (!playlist.segments) {
return videojs.createTimeRange();
/**
* Calculates the duration of a playlist. If a start and end index
* are specified, the duration will be for the subset of the media
* timeline between those two indices. The total duration for live
* playlists is always Infinity.
* @param playlist {object} a media playlist object
* @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.
*/
export const duration = function(playlist, endSequence, includeTrailingTime) {
if (!playlist) {
return 0;
}
if (typeof includeTrailingTime === 'undefined') {
includeTrailingTime = true;
}
// if a slice of the total duration is not requested, use
// playlist-level duration indicators when they're present
if (typeof endSequence === 'undefined') {
// if present, use the duration specified in the playlist
if (playlist.totalDuration) {
return playlist.totalDuration;
}
// when the playlist is complete, the entire duration is seekable
if (playlist.endList) {
return videojs.createTimeRange(0, duration(playlist));
// duration should be Infinity for live playlists
if (!playlist.endList) {
return window.Infinity;
}
}
// 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
start = intervalDuration(playlist, playlist.mediaSequence);
end = intervalDuration(playlist,
playlist.mediaSequence + Math.max(0, playlist.segments.length - 3));
return videojs.createTimeRange(start, end);
};
// exports
videojs.Hls.Playlist = {
duration: duration,
seekable: seekable
};
})(window, window.videojs);
// calculate the total duration based on the segment durations
return intervalDuration(
playlist,
endSequence,
includeTrailingTime
);
};
/**
* Calculates the interval of time that is currently seekable in a
* playlist. The returned time ranges are relative to the earliest
* moment in the specified playlist that is still available. A full
* seekable implementation for live streams would need to offset
* these values by the duration of content that has expired from the
* stream.
* @param playlist {object} a media playlist object
* @return {TimeRanges} the periods of time that are valid targets
* for seeking
*/
export const seekable = function(playlist) {
let start;
let end;
// without segments, there are no seekable ranges
if (!playlist.segments) {
return createTimeRange();
}
// when the playlist is complete, the entire duration is seekable
if (playlist.endList) {
return createTimeRange(0, duration(playlist));
}
// 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
start = intervalDuration(playlist, playlist.mediaSequence);
end = intervalDuration(
playlist,
playlist.mediaSequence + Math.max(0, playlist.segments.length - 3)
);
return createTimeRange(start, end);
};
// exports
export default {
duration,
seekable
};
......
import document from 'global/document';
/* eslint-disable max-len */
/**
* Constructs a new URI by interpreting a path relative to another
* URI.
* @param basePath {string} a relative or absolute URI
* @param path {string} a path part to combine with the base
* @return {string} a URI that is equivalent to composing `base`
* with `path`
* @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue
*/
/* eslint-enable max-len */
const resolveUrl = function(basePath, path) {
// use the base element to get the browser to handle URI resolution
let oldBase = document.querySelector('base');
let docHead = document.querySelector('head');
let a = document.createElement('a');
let base = oldBase;
let oldHref;
let result;
// prep the document
if (oldBase) {
oldHref = oldBase.href;
} else {
base = docHead.appendChild(document.createElement('base'));
}
base.href = basePath;
a.href = path;
result = a.href;
// clean up
if (oldBase) {
oldBase.href = oldHref;
} else {
docHead.removeChild(base);
}
return result;
};
export default resolveUrl;
......@@ -2,6 +2,10 @@ import m3u8 from './m3u8';
import Stream from './stream';
import videojs from 'video.js';
import {Decrypter, decrypt, AsyncStream} from './decrypter';
import Playlist from './playlist';
import PlaylistLoader from './playlist-loader';
import xhr from './xhr';
if(typeof window.videojs.Hls === 'undefined') {
videojs.Hls = {};
......@@ -11,3 +15,6 @@ videojs.m3u8 = m3u8;
videojs.Hls.decrypt = decrypt;
videojs.Hls.Decrypter = Decrypter;
videojs.Hls.AsyncStream = AsyncStream;
videojs.Hls.xhr = xhr;
videojs.Hls.Playlist = Playlist;
videojs.Hls.PlaylistLoader = PlaylistLoader;
......
(function(videojs) {
'use strict';
/**
* A wrapper for videojs.xhr that tracks bandwidth.
*/
import {xhr as videojsXHR, mergeOptions} from 'video.js';
const xhr = function(options, callback) {
// Add a default timeout for all hls requests
options = mergeOptions({
timeout: 45e3
}, options);
/**
* A wrapper for videojs.xhr that tracks bandwidth.
*/
videojs.Hls.xhr = function(options, callback) {
// Add a default timeout for all hls requests
options = videojs.mergeOptions({
timeout: 45e3
}, options);
var request = videojs.xhr(options, function(error, response) {
if (!error && request.response) {
request.responseTime = (new Date()).getTime();
request.roundTripTime = request.responseTime - request.requestTime;
request.bytesReceived = request.response.byteLength || request.response.length;
if (!request.bandwidth) {
request.bandwidth = Math.floor((request.bytesReceived / request.roundTripTime) * 8 * 1000);
}
let request = videojsXHR(options, function(error, response) {
if (!error && request.response) {
request.responseTime = (new Date()).getTime();
request.roundTripTime = request.responseTime - request.requestTime;
request.bytesReceived = request.response.byteLength || request.response.length;
if (!request.bandwidth) {
request.bandwidth =
Math.floor((request.bytesReceived / request.roundTripTime) * 8 * 1000);
}
}
// videojs.xhr now uses a specific code on the error object to signal that a request has
// timed out errors of setting a boolean on the request object
if (error || request.timedout) {
request.timedout = request.timedout || (error.code === 'ETIMEDOUT');
} else {
request.timedout = false;
}
// videojs.xhr now uses a specific code
// on the error object to signal that a request has
// timed out errors of setting a boolean on the request object
if (error || request.timedout) {
request.timedout = request.timedout || (error.code === 'ETIMEDOUT');
} else {
request.timedout = false;
}
// videojs.xhr no longer considers status codes outside of 200 and 0
// (for file uris) to be errors, but the old XHR did, so emulate that
// behavior. Status 206 may be used in response to byterange requests.
if (!error &&
response.statusCode !== 200 &&
response.statusCode !== 206 &&
response.statusCode !== 0) {
error = new Error('XHR Failed with a response of: ' +
(request && (request.response || request.responseText)));
}
// videojs.xhr no longer considers status codes outside of 200 and 0
// (for file uris) to be errors, but the old XHR did, so emulate that
// behavior. Status 206 may be used in response to byterange requests.
if (!error &&
response.statusCode !== 200 &&
response.statusCode !== 206 &&
response.statusCode !== 0) {
error = new Error(
'XHR Failed with a response of: ' +
(request && (request.response || request.responseText))
);
}
callback(error, request);
});
callback(error, request);
});
request.requestTime = (new Date()).getTime();
return request;
};
request.requestTime = (new Date()).getTime();
return request;
};
})(window.videojs);
export default xhr;
......
......@@ -16,16 +16,11 @@
<script src="/node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script>
<script src="/src/videojs-contrib-hls.js"></script>
<script src="/src/xhr.js"></script>
<script src="/dist/videojs-contrib-hls.js"></script>
<script src="/src/playlist.js"></script>
<script src="/src/playlist-loader.js"></script>
<script src="/src/bin-utils.js"></script>
<script src="/test/videojs-contrib-hls.test.js"></script>
<script src="/dist-test/videojs-contrib-hls.js"></script>
<script src="/test/playlist.test.js"></script>
<script src="/test/playlist-loader.test.js"></script>
</body>
</html>
......
......@@ -16,11 +16,8 @@ var DEFAULTS = {
// these two stub old functionality
'src/videojs-contrib-hls.js',
'src/xhr.js',
'dist/videojs-contrib-hls.js',
'src/playlist.js',
'src/playlist-loader.js',
'src/bin-utils.js',
'test/stub.test.js',
......@@ -45,7 +42,7 @@ var DEFAULTS = {
],
preprocessors: {
'test/{decrypter,stub,m3u8}.test.js': ['browserify']
'test/{playlist*,decrypter,stub,m3u8}.test.js': ['browserify']
},
reporters: ['dots'],
......
(function(window) {
'use strict';
var
sinonXhr,
clock,
requests,
videojs = window.videojs,
// Attempts to produce an absolute URL to a given relative path
// based on window.location.href
urlTo = function(path) {
return window.location.href
.split('/')
.slice(0, -1)
.concat([path])
.join('/');
import sinon from 'sinon';
import QUnit from 'qunit';
import PlaylistLoader from '../src/playlist-loader';
import videojs from 'video.js';
// Attempts to produce an absolute URL to a given relative path
// based on window.location.href
const urlTo = function(path) {
return window.location.href
.split('/')
.slice(0, -1)
.concat([path])
.join('/');
};
const respond = function(request, string) {
return request.respond(200, null, string);
};
QUnit.module('Playlist Loader', {
beforeEach() {
// fake XHRs
this.oldXHR = videojs.xhr.XMLHttpRequest;
this.sinonXhr = sinon.useFakeXMLHttpRequest();
this.requests = [];
this.sinonXhr.onCreate = (xhr) => {
// force the XHR2 timeout polyfill
xhr.timeout = null;
this.requests.push(xhr);
};
QUnit.module('Playlist Loader', {
setup: function() {
// fake XHRs
sinonXhr = sinon.useFakeXMLHttpRequest();
videojs.xhr.XMLHttpRequest = sinonXhr;
requests = [];
sinonXhr.onCreate = function(xhr) {
// force the XHR2 timeout polyfill
xhr.timeout = undefined;
requests.push(xhr);
};
// fake timers
clock = sinon.useFakeTimers();
},
teardown: function() {
sinonXhr.restore();
videojs.xhr.XMLHttpRequest = window.XMLHttpRequest;
clock.restore();
}
// fake timers
this.clock = sinon.useFakeTimers();
videojs.xhr.XMLHttpRequest = this.sinonXhr;
},
afterEach() {
this.sinonXhr.restore();
this.clock.restore();
videojs.xhr.XMLHttpRequest = this.oldXHR;
}
});
QUnit.test('throws if the playlist url is empty or undefined', function() {
QUnit.throws(function() {
PlaylistLoader();
}, 'requires an argument');
QUnit.throws(function() {
PlaylistLoader('');
}, 'does not accept the empty string');
});
QUnit.test('starts without any metadata', function() {
let loader = new PlaylistLoader('master.m3u8');
QUnit.strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet');
});
QUnit.test('starts with no expired time', function() {
let loader = new PlaylistLoader('media.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
QUnit.equal(loader.expired_,
0,
'zero seconds expired');
});
QUnit.test('this.requests the initial playlist immediately', function() {
/* eslint-disable no-unused-vars */
let loader = new PlaylistLoader('master.m3u8');
/* eslint-enable no-unused-vars */
QUnit.strictEqual(this.requests.length, 1, 'made a request');
QUnit.strictEqual(this.requests[0].url,
'master.m3u8',
'requested the initial playlist');
});
QUnit.test('moves to HAVE_MASTER after loading a master playlist', function() {
let loader = new PlaylistLoader('master.m3u8');
let state;
loader.on('loadedplaylist', function() {
state = loader.state;
});
test('throws if the playlist url is empty or undefined', function() {
throws(function() {
videojs.Hls.PlaylistLoader();
}, 'requires an argument');
throws(function() {
videojs.Hls.PlaylistLoader('');
}, 'does not accept the empty string');
});
test('starts without any metadata', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
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');
strictEqual(requests[0].url, 'master.m3u8', 'requested the initial playlist');
});
test('moves to HAVE_MASTER after loading a master playlist', function() {
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(state, 'HAVE_MASTER', 'the state at loadedplaylist correct');
});
test('jumps to HAVE_METADATA when initialized with a media playlist', function() {
var
loadedmetadatas = 0,
loader = new videojs.Hls.PlaylistLoader('media.m3u8');
loader.on('loadedmetadata', function() {
loadedmetadatas++;
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXT-X-ENDLIST\n');
ok(loader.master, 'infers a master playlist');
ok(loader.media(), 'sets the media playlist');
ok(loader.media().uri, 'sets the media playlist URI');
strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
strictEqual(requests.length, 0, 'no more requests are made');
strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata');
});
test('jumps to HAVE_METADATA when initialized with a live media playlist', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
ok(loader.master, 'infers a master playlist');
ok(loader.media(), 'sets the media playlist');
strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
test('moves to HAVE_METADATA after loading a media playlist', function() {
var
loadedPlaylist = 0,
loadedMetadata = 0,
loader = new videojs.Hls.PlaylistLoader('master.m3u8');
loader.on('loadedplaylist', function() {
loadedPlaylist++;
});
loader.on('loadedmetadata', function() {
loadedMetadata++;
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n' +
'alt.m3u8\n');
strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once');
strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata');
strictEqual(requests.length, 1, 'requests the media playlist');
strictEqual(requests[0].method, 'GET', 'GETs the media playlist');
strictEqual(requests[0].url,
urlTo('media.m3u8'),
'requests the first playlist');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
ok(loader.master, 'sets the master playlist');
ok(loader.media(), 'sets the media playlist');
strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice');
strictEqual(loadedMetadata, 1, 'fired loadedmetadata once');
strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(10 * 1000); // 10s, one target duration
strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct');
strictEqual(requests.length, 1, 'requested playlist');
strictEqual(requests[0].url,
urlTo('live.m3u8'),
'refreshes the media playlist');
});
test('returns to HAVE_METADATA after refreshing the playlist', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'1.ts\n');
strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
test('does not increment expired seconds before firstplay is triggered', 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_, 0, 'expired one segment');
});
test('increments expired seconds after a segment is removed', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
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');
loader.trigger('firstplay');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:3,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:3,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
equal(loader.expired_, 10, 'expired one segment');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:2\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
equal(loader.expired_, 13, 'no expirations after the discontinuity yet');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:3\n' +
'#EXT-X-DISCONTINUITY-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'3.ts\n');
equal(loader.expired_, 17, 'tracked expiration across the discontinuity');
});
test('tracks expired seconds properly when two discontinuities expire at once', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:6,\n' +
'2.ts\n' +
'#EXTINF:7,\n' +
'3.ts\n');
clock.tick(10 * 1000);
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:3\n' +
'#EXT-X-DISCONTINUITY-SEQUENCE:2\n' +
'#EXTINF:7,\n' +
'3.ts\n');
equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities');
});
test('estimates expired if an entire window elapses between live playlist updates', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n');
clock.tick(10 * 1000);
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:4\n' +
'#EXTINF:6,\n' +
'4.ts\n' +
'#EXTINF:7,\n' +
'5.ts\n');
equal(loader.expired_,
4 + 5 + (2 * 10),
'made a very rough estimate of expired time');
});
test('emits an error when an initial playlist request fails', function() {
var
errors = [],
loader = new videojs.Hls.PlaylistLoader('master.m3u8');
loader.on('error', function() {
errors.push(loader.error);
});
requests.pop().respond(500);
strictEqual(errors.length, 1, 'emitted one error');
strictEqual(errors[0].status, 500, 'http status is captured');
});
test('errors when an initial media playlist request fails', function() {
var
errors = [],
loader = new videojs.Hls.PlaylistLoader('master.m3u8');
loader.on('error', function() {
errors.push(loader.error);
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n');
strictEqual(errors.length, 0, 'emitted no errors');
requests.pop().respond(500);
strictEqual(errors.length, 1, 'emitted one error');
strictEqual(errors[0].status, 500, 'http status is captured');
});
// http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4
test('halves the refresh timeout if a playlist is unchanged' +
'since the last reload', function() {
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');
clock.tick(10 * 1000); // trigger a refresh
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(5 * 1000); // half the default target-duration
strictEqual(requests.length, 1, 'sent a request');
strictEqual(requests[0].url,
urlTo('live.m3u8'),
'requested the media playlist');
});
test('preserves segment metadata across playlist refreshes', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8'), segment;
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');
// add PTS info to 1.ts
segment = loader.media().segments[1];
segment.minVideoPts = 14;
segment.maxAudioPts = 27;
segment.preciseDuration = 10.045;
clock.tick(10 * 1000); // trigger a refresh
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');
deepEqual(loader.media().segments[0], segment, 'preserved segment attributes');
});
test('clears the update timeout when switching quality', function() {
var loader = new videojs.Hls.PlaylistLoader('live-master.m3u8'), refreshes = 0;
// track the number of playlist refreshes triggered
loader.on('mediaupdatetimeout', function() {
refreshes++;
});
// deliver the master
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'live-low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'live-high.m3u8\n');
// deliver the low quality playlist
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
// change to a higher quality playlist
loader.media('live-high.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
clock.tick(10 * 1000); // trigger a refresh
equal(1, refreshes, 'only one refresh was triggered');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n');
QUnit.ok(loader.master, 'the master playlist is available');
QUnit.strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct');
});
QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', function() {
let loadedmetadatas = 0;
let loader = new PlaylistLoader('media.m3u8');
loader.on('loadedmetadata', function() {
loadedmetadatas++;
});
test('media-sequence updates are considered a playlist change', function() {
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');
clock.tick(10 * 1000); // trigger a refresh
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(5 * 1000); // half the default target-duration
strictEqual(requests.length, 0, 'no request is sent');
});
test('emits an error if a media refresh fails', function() {
var
errors = 0,
errorResponseText = 'custom error message',
loader = new videojs.Hls.PlaylistLoader('live.m3u8');
loader.on('error', function() {
errors++;
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(10 * 1000); // trigger a refresh
requests.pop().respond(500, null, errorResponseText);
strictEqual(errors, 1, 'emitted an error');
strictEqual(loader.error.status, 500, 'captured the status code');
strictEqual(loader.error.responseText, errorResponseText, 'captured the responseText');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.ok(loader.master, 'infers a master playlist');
QUnit.ok(loader.media(), 'sets the media playlist');
QUnit.ok(loader.media().uri, 'sets the media playlist URI');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
QUnit.strictEqual(this.requests.length, 0, 'no more this.requests are made');
QUnit.strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata');
});
QUnit.test(
'jumps to HAVE_METADATA when initialized with a live media playlist',
function() {
let loader = new PlaylistLoader('media.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
QUnit.ok(loader.master, 'infers a master playlist');
QUnit.ok(loader.media(), 'sets the media playlist');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() {
let loadedPlaylist = 0;
let loadedMetadata = 0;
let loader = new PlaylistLoader('master.m3u8');
loader.on('loadedplaylist', function() {
loadedPlaylist++;
});
test('switches media playlists when requested', function() {
var loader = new videojs.Hls.PlaylistLoader('master.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');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
loader.media(loader.master.playlists[1]);
strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
strictEqual(loader.media(),
loader.master.playlists[1],
'updated the active media');
loader.on('loadedmetadata', function() {
loadedMetadata++;
});
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');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n' +
'alt.m3u8\n');
QUnit.strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once');
QUnit.strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata');
QUnit.strictEqual(this.requests.length, 1, 'this.requests the media playlist');
QUnit.strictEqual(this.requests[0].method, 'GET', 'GETs the media playlist');
QUnit.strictEqual(this.requests[0].url,
urlTo('media.m3u8'),
'this.requests the first playlist');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
QUnit.ok(loader.master, 'sets the master playlist');
QUnit.ok(loader.media(), 'sets the media playlist');
QUnit.strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice');
QUnit.strictEqual(loadedMetadata, 1, 'fired loadedmetadata once');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
let loader = new PlaylistLoader('live.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// 10s, one target duration
this.clock.tick(10 * 1000);
QUnit.strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct');
QUnit.strictEqual(this.requests.length, 1, 'requested playlist');
QUnit.strictEqual(this.requests[0].url,
urlTo('live.m3u8'),
'refreshes the media playlist');
});
QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function() {
let loader = new PlaylistLoader('live.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// 10s, one target duration
this.clock.tick(10 * 1000);
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'1.ts\n');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
QUnit.test(
'does not increment expired seconds before firstplay is triggered',
function() {
let loader = new PlaylistLoader('live.m3u8');
respond(this.requests.pop(),
'#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');
// 10s, one target duration
this.clock.tick(10 * 1000);
respond(this.requests.pop(),
'#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');
QUnit.equal(loader.expired_, 0, 'expired one segment');
});
QUnit.test('increments expired seconds after a segment is removed', function() {
let loader = new PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
respond(this.requests.pop(),
'#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');
// 10s, one target duration
this.clock.tick(10 * 1000);
respond(this.requests.pop(),
'#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');
QUnit.equal(loader.expired_, 10, 'expired one segment');
});
QUnit.test('increments expired seconds after a discontinuity', function() {
let loader = new PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
respond(this.requests.pop(),
'#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');
// 10s, one target duration
this.clock.tick(10 * 1000);
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:3,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
QUnit.equal(loader.expired_, 10, 'expired one segment');
// 10s, one target duration
this.clock.tick(10 * 1000);
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:2\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
QUnit.equal(loader.expired_, 13, 'no expirations after the discontinuity yet');
// 10s, one target duration
this.clock.tick(10 * 1000);
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:3\n' +
'#EXT-X-DISCONTINUITY-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'3.ts\n');
QUnit.equal(loader.expired_, 17, 'tracked expiration across the discontinuity');
});
QUnit.test(
'tracks expired seconds properly when two discontinuities expire at once',
function() {
let loader = new PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
respond(this.requests.pop(),
'#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');
this.clock.tick(10 * 1000);
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:3\n' +
'#EXT-X-DISCONTINUITY-SEQUENCE:2\n' +
'#EXTINF:7,\n' +
'3.ts\n');
QUnit.equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities');
});
QUnit.test(
'estimates expired if an entire window elapses between live playlist updates',
function() {
let loader = new PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n');
this.clock.tick(10 * 1000);
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:4\n' +
'#EXTINF:6,\n' +
'4.ts\n' +
'#EXTINF:7,\n' +
'5.ts\n');
QUnit.equal(loader.expired_,
4 + 5 + (2 * 10),
'made a very rough estimate of expired time');
});
QUnit.test('emits an error when an initial playlist request fails', function() {
let errors = [];
let loader = new PlaylistLoader('master.m3u8');
loader.on('error', function() {
errors.push(loader.error);
});
this.requests.pop().respond(500);
test('can switch media playlists based on URI', function() {
var loader = new videojs.Hls.PlaylistLoader('master.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');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
QUnit.strictEqual(errors.length, 1, 'emitted one error');
QUnit.strictEqual(errors[0].status, 500, 'http status is captured');
});
loader.media('high.m3u8');
strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
strictEqual(loader.media(),
loader.master.playlists[1],
'updated the active media');
});
QUnit.test('errors when an initial media playlist request fails', function() {
let errors = [];
let loader = new PlaylistLoader('master.m3u8');
test('aborts in-flight playlist refreshes when switching', function() {
var loader = new videojs.Hls.PlaylistLoader('master.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');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
clock.tick(10 * 1000);
loader.media('high.m3u8');
strictEqual(requests[0].aborted, true, 'aborted refresh request');
ok(!requests[0].onreadystatechange, 'onreadystatechange handlers should be removed on abort');
strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
loader.on('error', function() {
errors.push(loader.error);
});
test('switching to the active playlist is a no-op', function() {
var loader = new videojs.Hls.PlaylistLoader('master.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');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('low.m3u8');
strictEqual(requests.length, 0, 'no requests are sent');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n');
QUnit.strictEqual(errors.length, 0, 'emitted no errors');
this.requests.pop().respond(500);
QUnit.strictEqual(errors.length, 1, 'emitted one error');
QUnit.strictEqual(errors[0].status, 500, 'http status is captured');
});
// http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4
QUnit.test(
'halves the refresh timeout if a playlist is unchanged since the last reload',
function() {
/* eslint-disable no-unused-vars */
let loader = new PlaylistLoader('live.m3u8');
/* eslint-enable no-unused-vars */
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// trigger a refresh
this.clock.tick(10 * 1000);
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// half the default target-duration
this.clock.tick(5 * 1000);
QUnit.strictEqual(this.requests.length, 1, 'sent a request');
QUnit.strictEqual(this.requests[0].url,
urlTo('live.m3u8'),
'requested the media playlist');
});
QUnit.test('preserves segment metadata across playlist refreshes', function() {
let loader = new PlaylistLoader('live.m3u8');
let segment;
respond(this.requests.pop(),
'#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');
// add PTS info to 1.ts
segment = loader.media().segments[1];
segment.minVideoPts = 14;
segment.maxAudioPts = 27;
segment.preciseDuration = 10.045;
// trigger a refresh
this.clock.tick(10 * 1000);
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n');
QUnit.deepEqual(loader.media().segments[0], segment, 'preserved segment attributes');
});
QUnit.test('clears the update timeout when switching quality', function() {
let loader = new PlaylistLoader('live-master.m3u8');
let refreshes = 0;
// track the number of playlist refreshes triggered
loader.on('mediaupdatetimeout', function() {
refreshes++;
});
test('switching to the active live playlist is a no-op', function() {
var loader = new videojs.Hls.PlaylistLoader('master.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');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
loader.media('low.m3u8');
strictEqual(requests.length, 0, 'no requests are sent');
// deliver the master
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'live-low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'live-high.m3u8\n');
// deliver the low quality playlist
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
// change to a higher quality playlist
loader.media('live-high.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
// trigger a refresh
this.clock.tick(10 * 1000);
QUnit.equal(1, refreshes, 'only one refresh was triggered');
});
QUnit.test('media-sequence updates are considered a playlist change', function() {
/* eslint-disable no-unused-vars */
let loader = new PlaylistLoader('live.m3u8');
/* eslint-enable no-unused-vars */
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// trigger a refresh
this.clock.tick(10 * 1000);
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// half the default target-duration
this.clock.tick(5 * 1000);
QUnit.strictEqual(this.requests.length, 0, 'no request is sent');
});
QUnit.test('emits an error if a media refresh fails', function() {
let errors = 0;
let errorResponseText = 'custom error message';
let loader = new PlaylistLoader('live.m3u8');
loader.on('error', function() {
errors++;
});
test('switches back to loaded playlists without re-requesting them', function() {
var loader = new videojs.Hls.PlaylistLoader('master.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');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// trigger a refresh
this.clock.tick(10 * 1000);
this.requests.pop().respond(500, null, errorResponseText);
QUnit.strictEqual(errors, 1, 'emitted an error');
QUnit.strictEqual(loader.error.status, 500, 'captured the status code');
QUnit.strictEqual(loader.error.responseText,
errorResponseText,
'captured the responseText');
});
QUnit.test('switches media playlists when requested', function() {
let loader = new PlaylistLoader('master.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
loader.media(loader.master.playlists[1]);
QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
QUnit.strictEqual(loader.media(),
loader.master.playlists[1],
'updated the active media');
});
QUnit.test('can switch playlists immediately after the master is downloaded', function() {
let loader = new PlaylistLoader('master.m3u8');
loader.on('loadedplaylist', function() {
loader.media('high.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('low.m3u8');
strictEqual(requests.length, 0, 'no outstanding requests');
strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist');
});
test('aborts outstanding requests if switching back to an already loaded playlist', function() {
var loader = new videojs.Hls.PlaylistLoader('master.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');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
QUnit.equal(this.requests[0].url, urlTo('high.m3u8'), 'switched variants immediately');
});
QUnit.test('can switch media playlists based on URI', function() {
let loader = new PlaylistLoader('master.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
loader.media('high.m3u8');
QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
QUnit.strictEqual(loader.media(),
loader.master.playlists[1],
'updated the active media');
});
QUnit.test('aborts in-flight playlist refreshes when switching', function() {
let loader = new PlaylistLoader('master.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
this.clock.tick(10 * 1000);
loader.media('high.m3u8');
QUnit.strictEqual(this.requests[0].aborted, true, 'aborted refresh request');
QUnit.ok(!this.requests[0].onreadystatechange,
'onreadystatechange handlers should be removed on abort');
QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
});
QUnit.test('switching to the active playlist is a no-op', function() {
let loader = new PlaylistLoader('master.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('low.m3u8');
QUnit.strictEqual(this.requests.length, 0, 'no this.requests are sent');
});
QUnit.test('switching to the active live playlist is a no-op', function() {
let loader = new PlaylistLoader('master.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
loader.media('low.m3u8');
QUnit.strictEqual(this.requests.length, 0, 'no this.requests are sent');
});
QUnit.test('switches back to loaded playlists without re-requesting them', function() {
let loader = new PlaylistLoader('master.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('high.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('low.m3u8');
QUnit.strictEqual(this.requests.length, 0, 'no outstanding this.requests');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist');
});
QUnit.test(
'aborts outstanding this.requests if switching back to an already loaded playlist',
function() {
let loader = new PlaylistLoader('master.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('high.m3u8');
loader.media('low.m3u8');
QUnit.strictEqual(this.requests.length,
1,
'requested high playlist');
QUnit.ok(this.requests[0].aborted,
'aborted playlist request');
QUnit.ok(!this.requests[0].onreadystatechange,
'onreadystatechange handlers should be removed on abort');
QUnit.strictEqual(loader.state,
'HAVE_METADATA',
'returned to loaded playlist');
QUnit.strictEqual(loader.media(),
loader.master.playlists[0],
'switched to loaded playlist');
});
QUnit.test(
'does not abort this.requests when the same playlist is re-requested',
function() {
let loader = new PlaylistLoader('master.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('high.m3u8');
loader.media('high.m3u8');
QUnit.strictEqual(this.requests.length, 1, 'made only one request');
QUnit.ok(!this.requests[0].aborted, 'request not aborted');
});
QUnit.test('throws an error if a media switch is initiated too early', function() {
let loader = new PlaylistLoader('master.m3u8');
QUnit.throws(function() {
loader.media('high.m3u8');
loader.media('low.m3u8');
strictEqual(requests.length, 1, 'requested high playlist');
ok(requests[0].aborted, 'aborted playlist request');
ok(!requests[0].onreadystatechange, 'onreadystatechange handlers should be removed on abort');
strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist');
strictEqual(loader.media(), loader.master.playlists[0], 'switched to loaded playlist');
}, 'threw an error from HAVE_NOTHING');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
});
QUnit.test(
'throws an error if a switch to an unrecognized playlist is requested',
function() {
let loader = new PlaylistLoader('master.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'media.m3u8\n');
QUnit.throws(function() {
loader.media('unrecognized.m3u8');
}, 'throws an error');
});
QUnit.test('dispose cancels the refresh timeout', function() {
let loader = new PlaylistLoader('live.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
loader.dispose();
// a lot of time passes...
this.clock.tick(15 * 1000);
QUnit.strictEqual(this.requests.length, 0, 'no refresh request was made');
});
QUnit.test('dispose aborts pending refresh this.requests', function() {
let loader = new PlaylistLoader('live.m3u8');
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
this.clock.tick(10 * 1000);
loader.dispose();
QUnit.ok(this.requests[0].aborted, 'refresh request aborted');
QUnit.ok(
!this.requests[0].onreadystatechange,
'onreadystatechange handler should not exist after dispose called'
);
});
QUnit.test('errors if this.requests take longer than 45s', function() {
let loader = new PlaylistLoader('media.m3u8');
let errors = 0;
loader.on('error', function() {
errors++;
});
this.clock.tick(45 * 1000);
QUnit.strictEqual(errors, 1, 'fired one error');
QUnit.strictEqual(loader.error.code, 2, 'fired a network error');
});
test('does not abort requests when the same playlist is re-requested', function() {
var loader = new videojs.Hls.PlaylistLoader('master.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');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('high.m3u8');
loader.media('high.m3u8');
QUnit.test('triggers an event when the active media changes', function() {
let loader = new PlaylistLoader('master.m3u8');
let mediaChanges = 0;
strictEqual(requests.length, 1, 'made only one request');
ok(!requests[0].aborted, 'request not aborted');
loader.on('mediachange', function() {
mediaChanges++;
});
test('throws an error if a media switch is initiated too early', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
throws(function() {
loader.media('high.m3u8');
}, 'threw an error from HAVE_NOTHING');
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');
});
test('throws an error if a switch to an unrecognized playlist is requested', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'media.m3u8\n');
throws(function() {
loader.media('unrecognized.m3u8');
}, 'throws an error');
});
test('dispose cancels the refresh timeout', 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');
loader.dispose();
// a lot of time passes...
clock.tick(15 * 1000);
strictEqual(requests.length, 0, 'no refresh request was made');
});
test('dispose aborts pending refresh requests', 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');
clock.tick(10 * 1000);
loader.dispose();
ok(requests[0].aborted, 'refresh request aborted');
ok(!requests[0].onreadystatechange, 'onreadystatechange handler should not exist after dispose called');
});
test('errors if requests take longer than 45s', function() {
var
loader = new videojs.Hls.PlaylistLoader('media.m3u8'),
errors = 0;
loader.on('error', function() {
errors++;
});
clock.tick(45 * 1000);
strictEqual(errors, 1, 'fired one error');
strictEqual(loader.error.code, 2, 'fired a network error');
});
test('triggers an event when the active media changes', function() {
var
loader = new videojs.Hls.PlaylistLoader('master.m3u8'),
mediaChanges = 0;
loader.on('mediachange', function() {
mediaChanges++;
});
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');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
strictEqual(mediaChanges, 0, 'initial selection is not a media change');
loader.media('high.m3u8');
strictEqual(mediaChanges, 0, 'mediachange does not fire immediately');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n' +
'#EXT-X-ENDLIST\n');
strictEqual(mediaChanges, 1, 'fired a mediachange');
// switch back to an already loaded playlist
loader.media('low.m3u8');
strictEqual(mediaChanges, 2, 'fired a mediachange');
// trigger a no-op switch
loader.media('low.m3u8');
strictEqual(mediaChanges, 2, 'ignored a no-op media change');
});
test('can get media index by playback position for non-live videos', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXTINF:6,\n' +
'2.ts\n' +
'#EXT-X-ENDLIST\n');
equal(loader.getMediaIndexForTime_(-1),
0,
'the index is never less than zero');
equal(loader.getMediaIndexForTime_(0), 0, 'time zero is index zero');
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,
'time greater than the length is index 2');
});
test('returns the lower index when calculating for a segment boundary', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXT-X-ENDLIST\n');
equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches');
equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down');
equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5');
});
test('accounts for non-zero starting segment time when calculating media index', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
loader.media().segments[0].end = 154;
equal(loader.getMediaIndexForTime_(0), -1, 'the lowest returned value is negative one');
equal(loader.getMediaIndexForTime_(45), -1, 'expired content returns negative one');
equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns negative one');
equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position');
equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 4), 1, 'calculates within the second segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment');
});
test('prefers precise segment timing when tracking expired time', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
loader.trigger('firstplay');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
// setup the loader with an "imprecise" value as if it had been
// accumulating segment durations as they expire
loader.expired_ = 160;
// annotate the first segment with a start time
// this number would be coming from the Source Buffer in practice
loader.media().segments[0].end = 150;
equal(loader.getMediaIndexForTime_(149), 0, 'prefers the value on the first segment');
clock.tick(10 * 1000); // trigger a playlist refresh
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1002\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
equal(loader.getMediaIndexForTime_(150 + 4 + 1), 0, 'tracks precise expired times');
});
test('accounts for expired time when calculating media index', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
loader.expired_ = 150;
equal(loader.getMediaIndexForTime_(0), -1, 'expired content returns a negative index');
equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns a negative index');
equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position');
equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment');
});
test('does not misintrepret playlists missing newlines at the end', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST'); // no newline
ok(loader.media().endList, 'flushed the final line of input');
});
})(window);
respond(this.requests.pop(),
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
respond(this.requests.shift(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.strictEqual(mediaChanges, 0, 'initial selection is not a media change');
loader.media('high.m3u8');
QUnit.strictEqual(mediaChanges, 0, 'mediachange does not fire immediately');
respond(this.requests.shift(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.strictEqual(mediaChanges, 1, 'fired a mediachange');
// switch back to an already loaded playlist
loader.media('low.m3u8');
QUnit.strictEqual(mediaChanges, 2, 'fired a mediachange');
// trigger a no-op switch
loader.media('low.m3u8');
QUnit.strictEqual(mediaChanges, 2, 'ignored a no-op media change');
});
QUnit.test('can get media index by playback position for non-live videos', function() {
let loader = new PlaylistLoader('media.m3u8');
respond(this.requests.shift(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXTINF:6,\n' +
'2.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.equal(loader.getMediaIndexForTime_(-1),
0,
'the index is never less than zero');
QUnit.equal(loader.getMediaIndexForTime_(0), 0, 'time zero is index zero');
QUnit.equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero');
QUnit.equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2');
QUnit.equal(loader.getMediaIndexForTime_(22),
2,
'time greater than the length is index 2');
});
QUnit.test('returns the lower index when calculating for a segment boundary', function() {
let loader = new PlaylistLoader('media.m3u8');
respond(this.requests.shift(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches');
QUnit.equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down');
QUnit.equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5');
});
QUnit.test(
'accounts for non-zero starting segment time when calculating media index',
function() {
let loader = new PlaylistLoader('media.m3u8');
respond(this.requests.shift(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
loader.media().segments[0].end = 154;
QUnit.equal(loader.getMediaIndexForTime_(0),
-1,
'the lowest returned value is negative one');
QUnit.equal(loader.getMediaIndexForTime_(45),
-1,
'expired content returns negative one');
QUnit.equal(loader.getMediaIndexForTime_(75),
-1,
'expired content returns negative one');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100),
0,
'calculates the earliest available position');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2),
0,
'calculates within the first segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2),
0,
'calculates within the first segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4),
1,
'calculates within the second segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5),
1,
'calculates within the second segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6),
1,
'calculates within the second segment');
});
QUnit.test('prefers precise segment timing when tracking expired time', function() {
let loader = new PlaylistLoader('media.m3u8');
loader.trigger('firstplay');
respond(this.requests.shift(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
// setup the loader with an "imprecise" value as if it had been
// accumulating segment durations as they expire
loader.expired_ = 160;
// annotate the first segment with a start time
// this number would be coming from the Source Buffer in practice
loader.media().segments[0].end = 150;
QUnit.equal(loader.getMediaIndexForTime_(149),
0,
'prefers the value on the first segment');
// trigger a playlist refresh
this.clock.tick(10 * 1000);
respond(this.requests.shift(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1002\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
QUnit.equal(loader.getMediaIndexForTime_(150 + 4 + 1),
0,
'tracks precise expired times');
});
QUnit.test('accounts for expired time when calculating media index', function() {
let loader = new PlaylistLoader('media.m3u8');
respond(this.requests.shift(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
loader.expired_ = 150;
QUnit.equal(loader.getMediaIndexForTime_(0),
-1,
'expired content returns a negative index');
QUnit.equal(loader.getMediaIndexForTime_(75),
-1,
'expired content returns a negative index');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100),
0,
'calculates the earliest available position');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2),
0,
'calculates within the first segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2),
0,
'calculates within the first segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5),
1,
'calculates within the second segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6),
1,
'calculates within the second segment');
});
QUnit.test('does not misintrepret playlists missing newlines at the end', function() {
let loader = new PlaylistLoader('media.m3u8');
// no newline
respond(this.requests.shift(),
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST');
QUnit.ok(loader.media().endList, 'flushed the final line of input');
});
......
/* Tests for the playlist utilities */
(function(window, videojs) {
'use strict';
var Playlist = videojs.Hls.Playlist;
QUnit.module('Playlist Duration');
test('total duration for live playlists is Infinity', function() {
var duration = Playlist.duration({
segments: [{
duration: 4,
uri: '0.ts'
}]
});
equal(duration, Infinity, 'duration is infinity');
import Playlist from '../src/playlist';
import QUnit from 'qunit';
QUnit.module('Playlist Duration');
QUnit.test('total duration for live playlists is Infinity', function() {
let duration = Playlist.duration({
segments: [{
duration: 4,
uri: '0.ts'
}]
});
QUnit.module('Playlist Interval Duration');
test('accounts for non-zero starting VOD media sequences', function() {
var duration = Playlist.duration({
mediaSequence: 10,
endList: true,
segments: [{
duration: 10,
uri: '0.ts'
}, {
duration: 10,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
duration: 10,
uri: '3.ts'
}]
});
equal(duration, 4 * 10, 'includes only listed segments');
QUnit.equal(duration, Infinity, 'duration is infinity');
});
QUnit.module('Playlist Interval Duration');
QUnit.test('accounts for non-zero starting VOD media sequences', function() {
let duration = Playlist.duration({
mediaSequence: 10,
endList: true,
segments: [{
duration: 10,
uri: '0.ts'
}, {
duration: 10,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
duration: 10,
uri: '3.ts'
}]
});
test('uses timeline values when available', function() {
var duration = Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
start: 0,
uri: '0.ts'
}, {
duration: 10,
end: 2 * 10 + 2,
uri: '1.ts'
}, {
duration: 10,
end: 3 * 10 + 2,
uri: '2.ts'
}, {
duration: 10,
end: 4 * 10 + 2,
uri: '3.ts'
}]
}, 4);
equal(duration, 4 * 10 + 2, 'used timeline values');
QUnit.equal(duration, 4 * 10, 'includes only listed segments');
});
QUnit.test('uses timeline values when available', function() {
let duration = Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
start: 0,
uri: '0.ts'
}, {
duration: 10,
end: 2 * 10 + 2,
uri: '1.ts'
}, {
duration: 10,
end: 3 * 10 + 2,
uri: '2.ts'
}, {
duration: 10,
end: 4 * 10 + 2,
uri: '3.ts'
}]
}, 4);
QUnit.equal(duration, 4 * 10 + 2, 'used timeline values');
});
QUnit.test('works when partial timeline information is available', function() {
let duration = Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
start: 0,
uri: '0.ts'
}, {
duration: 9,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
duration: 10,
start: 30.007,
end: 40.002,
uri: '3.ts'
}, {
duration: 10,
end: 50.0002,
uri: '4.ts'
}]
}, 5);
QUnit.equal(duration, 50.0002, 'calculated with mixed intervals');
});
QUnit.test('uses timeline values for the expired duration of live playlists', function() {
let playlist = {
mediaSequence: 12,
segments: [{
duration: 10,
end: 120.5,
uri: '0.ts'
}, {
duration: 9,
uri: '1.ts'
}]
};
let duration;
duration = Playlist.duration(playlist, playlist.mediaSequence);
QUnit.equal(duration, 110.5, 'used segment end time');
duration = Playlist.duration(playlist, playlist.mediaSequence + 1);
QUnit.equal(duration, 120.5, 'used segment end time');
duration = Playlist.duration(playlist, playlist.mediaSequence + 2);
QUnit.equal(duration, 120.5 + 9, 'used segment end time');
});
QUnit.test(
'looks outside the queried interval for live playlist timeline values',
function() {
let playlist = {
mediaSequence: 12,
segments: [{
duration: 10,
uri: '0.ts'
}, {
duration: 9,
end: 120.5,
uri: '1.ts'
}]
};
let duration;
duration = Playlist.duration(playlist, playlist.mediaSequence);
QUnit.equal(duration, 120.5 - 9 - 10, 'used segment end time');
});
QUnit.test('ignores discontinuity sequences later than the end', function() {
let duration = Playlist.duration({
mediaSequence: 0,
discontinuityStarts: [1, 3],
segments: [{
duration: 10,
uri: '0.ts'
}, {
discontinuity: true,
duration: 9,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
discontinuity: true,
duration: 10,
uri: '3.ts'
}]
}, 2);
QUnit.equal(duration, 19, 'excluded the later segments');
});
QUnit.test('handles trailing segments without timeline information', function() {
let duration;
let playlist = {
mediaSequence: 0,
endList: true,
segments: [{
start: 0,
end: 10.5,
uri: '0.ts'
}, {
duration: 9,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
start: 29.45,
end: 39.5,
uri: '3.ts'
}]
};
duration = Playlist.duration(playlist, 3);
QUnit.equal(duration, 29.45, 'calculated duration');
duration = Playlist.duration(playlist, 2);
QUnit.equal(duration, 19.5, 'calculated duration');
});
QUnit.test('uses timeline intervals when segments have them', function() {
let duration;
let playlist = {
mediaSequence: 0,
segments: [{
start: 0,
end: 10,
uri: '0.ts'
}, {
duration: 9,
uri: '1.ts'
}, {
start: 20.1,
end: 30.1,
duration: 10,
uri: '2.ts'
}]
};
duration = Playlist.duration(playlist, 2);
QUnit.equal(duration, 20.1, 'used the timeline-based interval');
duration = Playlist.duration(playlist, 3);
QUnit.equal(duration, 30.1, 'used the timeline-based interval');
});
QUnit.test(
'counts the time between segments as part of the earlier segment\'s duration',
function() {
let duration = Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
start: 0,
end: 10,
uri: '0.ts'
}, {
start: 10.1,
end: 20.1,
duration: 10,
uri: '1.ts'
}]
}, 1);
QUnit.equal(duration, 10.1, 'included the segment gap');
});
QUnit.test('accounts for discontinuities', function() {
let duration = Playlist.duration({
mediaSequence: 0,
endList: true,
discontinuityStarts: [1],
segments: [{
duration: 10,
uri: '0.ts'
}, {
discontinuity: true,
duration: 10,
uri: '1.ts'
}]
}, 2);
QUnit.equal(duration, 10 + 10, 'handles discontinuities');
});
QUnit.test('a non-positive length interval has zero duration', function() {
let playlist = {
mediaSequence: 0,
discontinuityStarts: [1],
segments: [{
duration: 10,
uri: '0.ts'
}, {
discontinuity: true,
duration: 10,
uri: '1.ts'
}]
};
QUnit.equal(Playlist.duration(playlist, 0), 0, 'zero-length duration is zero');
QUnit.equal(Playlist.duration(playlist, 0, false), 0, 'zero-length duration is zero');
QUnit.equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero');
});
QUnit.module('Playlist Seekable');
QUnit.test('calculates seekable time ranges from the available segments', function() {
let playlist = {
mediaSequence: 0,
segments: [{
duration: 10,
uri: '0.ts'
}, {
duration: 10,
uri: '1.ts'
}],
endList: true
};
let seekable = Playlist.seekable(playlist);
QUnit.equal(seekable.length, 1, 'there are seekable ranges');
QUnit.equal(seekable.start(0), 0, 'starts at zero');
QUnit.equal(seekable.end(0), Playlist.duration(playlist), 'ends at the duration');
});
QUnit.test('master playlists have empty seekable ranges', function() {
let seekable = Playlist.seekable({
playlists: [{
uri: 'low.m3u8'
}, {
uri: 'high.m3u8'
}]
});
test('works when partial timeline information is available', function() {
var duration = Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
start: 0,
uri: '0.ts'
}, {
duration: 9,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
duration: 10,
start: 30.007,
end: 40.002,
uri: '3.ts'
}, {
duration: 10,
end: 50.0002,
uri: '4.ts'
}]
}, 5);
equal(duration, 50.0002, 'calculated with mixed intervals');
QUnit.equal(seekable.length, 0, 'no seekable ranges from a master playlist');
});
QUnit.test(
'seekable end is three target durations from the actual end of live playlists',
function() {
let seekable = Playlist.seekable({
mediaSequence: 0,
segments: [{
duration: 7,
uri: '0.ts'
}, {
duration: 10,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
duration: 10,
uri: '3.ts'
}]
});
test('uses timeline values for the expired duration of live playlists', function() {
var playlist = {
mediaSequence: 12,
segments: [{
duration: 10,
end: 120.5,
uri: '0.ts'
}, {
duration: 9,
uri: '1.ts'
}]
}, duration;
duration = Playlist.duration(playlist, playlist.mediaSequence);
equal(duration, 110.5, 'used segment end time');
duration = Playlist.duration(playlist, playlist.mediaSequence + 1);
equal(duration, 120.5, 'used segment end time');
duration = Playlist.duration(playlist, playlist.mediaSequence + 2);
equal(duration, 120.5 + 9, 'used segment end time');
QUnit.equal(seekable.length, 1, 'there are seekable ranges');
QUnit.equal(seekable.start(0), 0, 'starts at zero');
QUnit.equal(seekable.end(0), 7, 'ends three target durations from the last segment');
});
QUnit.test('only considers available segments', function() {
let seekable = Playlist.seekable({
mediaSequence: 7,
segments: [{
uri: '8.ts',
duration: 10
}, {
uri: '9.ts',
duration: 10
}, {
uri: '10.ts',
duration: 10
}, {
uri: '11.ts',
duration: 10
}]
});
test('looks outside the queried interval for live playlist timeline values', function() {
var playlist = {
mediaSequence: 12,
segments: [{
duration: 10,
uri: '0.ts'
}, {
duration: 9,
end: 120.5,
uri: '1.ts'
}]
}, duration;
duration = Playlist.duration(playlist, playlist.mediaSequence);
equal(duration, 120.5 - 9 - 10, 'used segment end time');
QUnit.equal(seekable.length, 1, 'there are seekable ranges');
QUnit.equal(seekable.start(0), 0, 'starts at the earliest available segment');
QUnit.equal(
seekable.end(0),
10,
'ends three target durations from the last available segment'
);
});
QUnit.test('seekable end accounts for non-standard target durations', function() {
let seekable = Playlist.seekable({
targetDuration: 2,
mediaSequence: 0,
segments: [{
duration: 2,
uri: '0.ts'
}, {
duration: 2,
uri: '1.ts'
}, {
duration: 1,
uri: '2.ts'
}, {
duration: 2,
uri: '3.ts'
}, {
duration: 2,
uri: '4.ts'
}]
});
test('ignores discontinuity sequences later than the end', function() {
var duration = Playlist.duration({
mediaSequence: 0,
discontinuityStarts: [1, 3],
segments: [{
duration: 10,
uri: '0.ts'
}, {
discontinuity: true,
duration: 9,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
discontinuity: true,
duration: 10,
uri: '3.ts'
}]
}, 2);
equal(duration, 19, 'excluded the later segments');
});
test('handles trailing segments without timeline information', function() {
var playlist, duration;
playlist = {
mediaSequence: 0,
endList: true,
segments: [{
start: 0,
end: 10.5,
uri: '0.ts'
}, {
duration: 9,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
start: 29.45,
end: 39.5,
uri: '3.ts'
}]
};
duration = Playlist.duration(playlist, 3);
equal(duration, 29.45, 'calculated duration');
duration = Playlist.duration(playlist, 2);
equal(duration, 19.5, 'calculated duration');
});
test('uses timeline intervals when segments have them', function() {
var playlist, duration;
playlist = {
mediaSequence: 0,
segments: [{
start: 0,
end: 10,
uri: '0.ts'
}, {
duration: 9,
uri: '1.ts'
},{
start: 20.1,
end: 30.1,
duration: 10,
uri: '2.ts'
}]
};
duration = Playlist.duration(playlist, 2);
equal(duration, 20.1, 'used the timeline-based 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() {
var duration = Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
start: 0,
end: 10,
uri: '0.ts'
}, {
start: 10.1,
end: 20.1,
duration: 10,
uri: '1.ts'
}]
}, 1);
equal(duration, 10.1, 'included the segment gap');
});
test('accounts for discontinuities', function() {
var duration = Playlist.duration({
mediaSequence: 0,
endList: true,
discontinuityStarts: [1],
segments: [{
duration: 10,
uri: '0.ts'
}, {
discontinuity: true,
duration: 10,
uri: '1.ts'
}]
}, 2);
equal(duration, 10 + 10, 'handles discontinuities');
});
test('a non-positive length interval has zero duration', function() {
var playlist = {
mediaSequence: 0,
discontinuityStarts: [1],
segments: [{
duration: 10,
uri: '0.ts'
}, {
discontinuity: true,
duration: 10,
uri: '1.ts'
}]
};
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');
});
QUnit.module('Playlist Seekable');
test('calculates seekable time ranges from the available segments', function() {
var playlist = {
mediaSequence: 0,
segments: [{
duration: 10,
uri: '0.ts'
}, {
duration: 10,
uri: '1.ts'
}],
endList: true
}, seekable = Playlist.seekable(playlist);
equal(seekable.length, 1, 'there are seekable ranges');
equal(seekable.start(0), 0, 'starts at zero');
equal(seekable.end(0), Playlist.duration(playlist), 'ends at the duration');
});
test('master playlists have empty seekable ranges', function() {
var seekable = Playlist.seekable({
playlists: [{
uri: 'low.m3u8'
}, {
uri: 'high.m3u8'
}]
});
equal(seekable.length, 0, 'no seekable ranges from a master playlist');
});
test('seekable end is three target durations from the actual end of live playlists', function() {
var seekable = Playlist.seekable({
mediaSequence: 0,
segments: [{
duration: 7,
uri: '0.ts'
}, {
duration: 10,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
duration: 10,
uri: '3.ts'
}]
});
equal(seekable.length, 1, 'there are seekable ranges');
equal(seekable.start(0), 0, 'starts at zero');
equal(seekable.end(0), 7, 'ends three target durations from the last segment');
});
test('only considers available segments', function() {
var seekable = Playlist.seekable({
mediaSequence: 7,
segments: [{
uri: '8.ts',
duration: 10
}, {
uri: '9.ts',
duration: 10
}, {
uri: '10.ts',
duration: 10
}, {
uri: '11.ts',
duration: 10
}]
});
equal(seekable.length, 1, 'there are seekable ranges');
equal(seekable.start(0), 0, 'starts at the earliest available segment');
equal(seekable.end(0), 10, 'ends three target durations from the last available segment');
});
test('seekable end accounts for non-standard target durations', function() {
var seekable = Playlist.seekable({
targetDuration: 2,
mediaSequence: 0,
segments: [{
duration: 2,
uri: '0.ts'
}, {
duration: 2,
uri: '1.ts'
}, {
duration: 1,
uri: '2.ts'
}, {
duration: 2,
uri: '3.ts'
}, {
duration: 2,
uri: '4.ts'
}]
});
equal(seekable.start(0), 0, 'starts at the earliest available segment');
equal(seekable.end(0),
9 - (2 + 2 + 1),
'allows seeking no further than three segments from the end');
});
})(window, window.videojs);
QUnit.equal(seekable.start(0), 0, 'starts at the earliest available segment');
QUnit.equal(
seekable.end(0),
9 - (2 + 2 + 1),
'allows seeking no further than three segments from the end'
);
});
......