d828f47e by David LaPalomento

Merge pull request #50 from videojs/feature/playlist-loader

Refactor M3U8 loading
2 parents 600ff0f1 423037c5
......@@ -30,7 +30,8 @@ module.exports = function(grunt) {
'src/aac-stream.js',
'src/segment-parser.js',
'src/stream.js',
'src/m3u8/m3u8-parser.js'
'src/m3u8/m3u8-parser.js',
'src/playlist-loader.js'
],
dest: 'dist/videojs.hls.js'
}
......
......@@ -68,19 +68,25 @@ See html5rocks's [article](http://www.html5rocks.com/en/tutorials/cors/)
for more info.
### Runtime Properties
#### player.hls.master
#### player.hls.playlists.master
Type: `object`
An object representing the parsed master playlist. If a media playlist
is loaded directly, a master playlist with only one entry will be
created.
#### player.hls.media
Type: `object`
#### player.hls.playlists.media
Type: `function`
An object representing the currently selected media playlist. This is
the playlist that is being referred to when a additional video data
needs to be downloaded.
A function that can be used to retrieve or modify the currently active
media playlist. The active media playlist is referred to when
additional video data needs to be downloaded. Calling this function
with no arguments returns the parsed playlist object for the active
media playlist. Calling this function with a playlist object from the
master playlist or a URI string as specified in the master playlist
will kick off an asynchronous load of the specified media
playlist. Once it has been retreived, it will become the active media
playlist.
#### player.hls.mediaIndex
Type: `number`
......
No preview for this file type
......@@ -25,6 +25,7 @@
<!-- m3u8 handling -->
<script src="src/stream.js"></script>
<script src="src/m3u8/m3u8-parser.js"></script>
<script src="src/playlist-loader.js"></script>
<!-- example MPEG2-TS segments -->
<!-- bipbop -->
......
/**
* A state machine that manages the loading, caching, and updating of
* M3U8 playlists.
*/
(function(window, videojs) {
'use strict';
var
resolveUrl = videojs.hls.resolveUrl,
xhr = videojs.hls.xhr,
/**
* 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 = videojs.util.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] = videojs.util.mergeOptions(playlist, media);
result.playlists[media.uri] = result.playlists[i];
changed = true;
}
}
return changed ? result : null;
},
PlaylistLoader = function(srcUrl, withCredentials) {
var
loader = this,
media,
request,
haveMetadata = function(error, xhr, url) {
var parser, refreshDelay, update;
// any in-flight request is now finished
request = null;
if (error) {
loader.error = {
status: xhr.status,
message: 'HLS playlist request error at URL: ' + url,
code: (xhr.status >= 500) ? 4 : 2
};
return loader.trigger('error');
}
loader.state = 'HAVE_METADATA';
parser = new videojs.m3u8.Parser();
parser.push(xhr.responseText);
parser.manifest.uri = url;
// merge this playlist into the master
update = updateMaster(loader.master, parser.manifest);
refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
if (update) {
loader.master = update;
media = loader.master.playlists[url];
} else {
// if the playlist is unchanged since the last reload,
// try again after half the target duration
refreshDelay /= 2;
}
// refresh live playlists after a target duration passes
if (!loader.media().endList) {
window.setTimeout(function() {
loader.trigger('mediaupdatetimeout');
}, refreshDelay);
}
loader.trigger('loadedplaylist');
};
PlaylistLoader.prototype.init.call(this);
if (!srcUrl) {
throw new Error('A non-empty playlist URL is required');
}
loader.state = 'HAVE_NOTHING';
/**
* 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 or HAVE_MASTER states 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) {
// getter
if (!playlist) {
return media;
}
// setter
if (loader.state === 'HAVE_NOTHING' || loader.state === 'HAVE_MASTER') {
throw new Error('Cannot switch media playlist from ' + loader.state);
}
// 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];
}
if (playlist.uri === media.uri) {
// switching to the currently active playlist is a no-op
return;
}
loader.state = 'SWITCHING_MEDIA';
// abort any outstanding playlist refreshes
if (request) {
request.abort();
request = null;
}
// request the new playlist
request = xhr({
url: resolveUrl(loader.master.uri, playlist.uri),
withCredentials: withCredentials
}, function(error) {
haveMetadata(error, this, playlist.uri);
});
};
// 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;
}
loader.state = 'HAVE_CURRENT_METADATA';
request = xhr({
url: resolveUrl(loader.master.uri, loader.media().uri),
withCredentials: withCredentials
}, function(error) {
haveMetadata(error, this, loader.media().uri);
});
});
// request the specified URL
xhr({
url: srcUrl,
withCredentials: withCredentials
}, function(error) {
var parser, i;
if (error) {
loader.error = {
status: this.status,
message: 'HLS playlist request error at URL: ' + srcUrl,
code: 2 // MEDIA_ERR_NETWORK
};
return loader.trigger('error');
}
parser = new videojs.m3u8.Parser();
parser.push(this.responseText);
loader.state = 'HAVE_MASTER';
parser.manifest.uri = srcUrl;
// loaded a master playlist
if (parser.manifest.playlists) {
loader.master = parser.manifest;
// setup by-URI lookups
i = loader.master.playlists.length;
while (i--) {
loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i];
}
request = xhr({
url: resolveUrl(srcUrl, parser.manifest.playlists[0].uri),
withCredentials: withCredentials
}, function(error) {
// pass along the URL specified in the master playlist
haveMetadata(error,
this,
parser.manifest.playlists[0].uri);
loader.trigger('loadedmetadata');
});
return loader.trigger('loadedplaylist');
}
// 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(null, this, srcUrl);
return loader.trigger('loadedmetadata');
});
};
PlaylistLoader.prototype = new videojs.hls.Stream();
videojs.hls.PlaylistLoader = PlaylistLoader;
})(window, window.videojs);
......@@ -32,8 +32,6 @@ videojs.hls = {
var
settings,
// the desired length of video to maintain in the buffer, in seconds
goalBufferLength = 5,
......@@ -104,9 +102,12 @@ var
* inititated. If it is an object, it should contain a `url`
* property that indicates the URL to request and optionally a
* `method` which is the type of HTTP request to send.
* @param callback (optional) {function} a function to call when the
* request completes. If the request was not successful, the first
* argument will be falsey.
* @return {object} the XMLHttpRequest that was initiated.
*/
xhr = function(url, callback) {
xhr = videojs.hls.xhr = function(url, callback) {
var
options = {
method: 'GET'
......@@ -128,7 +129,7 @@ var
if (options.responseType) {
request.responseType = options.responseType;
}
if (settings.withCredentials) {
if (options.withCredentials) {
request.withCredentials = true;
}
......@@ -193,15 +194,20 @@ var
*/
translateMediaIndex = function(mediaIndex, original, update) {
var
i = update.segments.length,
i,
originalSegment;
// no segments have been loaded from the original playlist
if (mediaIndex === 0) {
return 0;
}
if (!(update && update.segments)) {
// let the media index be zero when there are no segments defined
return 0;
}
// try to sync based on URI
i = update.segments.length;
originalSegment = original.segments[mediaIndex - 1];
while (i--) {
if (originalSegment.uri === update.segments[i].uri) {
......@@ -250,7 +256,7 @@ var
* with `path`
* @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue
*/
resolveUrl = function(basePath, path) {
resolveUrl = videojs.hls.resolveUrl = function(basePath, path) {
// use the base element to get the browser to handle URI resolution
var
oldBase = document.querySelector('base'),
......@@ -291,11 +297,9 @@ var
player = this,
srcUrl,
playlistXhr,
segmentXhr,
loadedPlaylist,
settings,
fillBuffer,
updateCurrentPlaylist,
updateDuration;
// if the video element supports HLS natively, do nothing
......@@ -374,7 +378,8 @@ var
player.on('seeking', function() {
var currentTime = player.currentTime();
player.hls.mediaIndex = getMediaIndexByTime(player.hls.media, currentTime);
player.hls.mediaIndex = getMediaIndexByTime(player.hls.playlists.media(),
currentTime);
// abort any segments still being decoded
player.hls.sourceBuffer.abort();
......@@ -405,36 +410,6 @@ var
};
/**
* Determine whether the current media playlist should be changed
* and trigger a switch if necessary. If a sufficiently fresh
* version of the target playlist is available, the switch will take
* effect immediately. Otherwise, the target playlist will be
* refreshed.
*/
updateCurrentPlaylist = function() {
var playlist, mediaSequence;
playlist = player.hls.selectPlaylist();
mediaSequence = player.hls.mediaIndex + (player.hls.media.mediaSequence || 0);
if (!playlist.segments ||
mediaSequence < (playlist.mediaSequence || 0) ||
mediaSequence > (playlist.mediaSequence || 0) + playlist.segments.length) {
if (playlistXhr) {
playlistXhr.abort();
}
playlistXhr = xhr(resolveUrl(srcUrl, playlist.uri), loadedPlaylist);
} else {
player.hls.mediaIndex =
translateMediaIndex(player.hls.mediaIndex,
player.hls.media,
playlist);
player.hls.media = playlist;
updateDuration(player.hls.media);
}
};
/**
* Chooses the appropriate media playlist based on the current
* bandwidth estimate and the player size.
* @return the highest bitrate playlist less than the currently detected
......@@ -443,7 +418,7 @@ var
player.hls.selectPlaylist = function () {
var
effectiveBitrate,
sortedPlaylists = player.hls.master.playlists.slice(),
sortedPlaylists = player.hls.playlists.master.playlists.slice(),
bandwidthPlaylists = [],
i = sortedPlaylists.length,
variant,
......@@ -508,97 +483,6 @@ var
};
/**
* Callback that is invoked when a media playlist finishes
* downloading. Triggers `loadedmanifest` once for each playlist
* that is downloaded and `loadedmetadata` after at least one
* media playlist has been parsed.
*
* @param error {*} truthy if the request was not successful
* @param url {string} a URL to the M3U8 file to process
*/
loadedPlaylist = function(error, url) {
var i, parser, playlist, playlistUri, refreshDelay;
// clear the current playlist XHR
playlistXhr = null;
if (error) {
player.hls.error = {
status: this.status,
message: 'HLS playlist request error at URL: ' + url,
code: (this.status >= 500) ? 4 : 2
};
return player.trigger('error');
}
parser = new videojs.m3u8.Parser();
parser.push(this.responseText);
// merge this playlist into the master
i = player.hls.master.playlists.length;
refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
while (i--) {
playlist = player.hls.master.playlists[i];
playlistUri = resolveUrl(srcUrl, playlist.uri);
if (playlistUri === url) {
// if the playlist is unchanged since the last reload,
// try again after half the target duration
// http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4
if (playlist.segments &&
playlist.segments.length === parser.manifest.segments.length) {
refreshDelay /= 2;
}
player.hls.master.playlists[i] =
videojs.util.mergeOptions(playlist, parser.manifest);
if (playlist !== player.hls.media) {
continue;
}
// determine the new mediaIndex if we're updating the
// current media playlist
player.hls.mediaIndex =
translateMediaIndex(player.hls.mediaIndex,
playlist,
parser.manifest);
player.hls.media = parser.manifest;
}
}
// check the playlist for updates if EXT-X-ENDLIST isn't present
if (!parser.manifest.endList) {
window.setTimeout(function() {
if (!playlistXhr &&
resolveUrl(srcUrl, player.hls.media.uri) === url) {
playlistXhr = xhr(url, loadedPlaylist);
}
}, refreshDelay);
}
// always start playback with the default rendition
if (!player.hls.media) {
player.hls.media = player.hls.master.playlists[0];
// update the duration
updateDuration(parser.manifest);
// periodicaly check if the buffer needs to be refilled
player.on('timeupdate', fillBuffer);
player.trigger('loadedmanifest');
player.trigger('loadedmetadata');
fillBuffer();
return;
}
// select a playlist and download its metadata if necessary
updateCurrentPlaylist();
player.trigger('loadedmanifest');
};
/**
* Determines whether there is enough video data currently in the buffer
* and downloads a new segment if the buffered time is less than the goal.
* @param offset (optional) {number} the offset into the downloaded segment
......@@ -618,12 +502,12 @@ var
}
// if no segments are available, do nothing
if (!player.hls.media.segments) {
if (!player.hls.playlists.media().segments) {
return;
}
// if the video has finished downloading, stop trying to buffer
segment = player.hls.media.segments[player.hls.mediaIndex];
segment = player.hls.playlists.media().segments[player.hls.mediaIndex];
if (!segment) {
return;
}
......@@ -639,10 +523,10 @@ var
}
// resolve the segment URL relative to the playlist
if (player.hls.media.uri === srcUrl) {
if (player.hls.playlists.media().uri === srcUrl) {
segmentUri = resolveUrl(srcUrl, segment.uri);
} else {
segmentUri = resolveUrl(resolveUrl(srcUrl, player.hls.media.uri || ''),
segmentUri = resolveUrl(resolveUrl(srcUrl, player.hls.playlists.media().uri || ''),
segment.uri);
}
......@@ -651,7 +535,8 @@ var
// request the next segment
segmentXhr = xhr({
url: segmentUri,
responseType: 'arraybuffer'
responseType: 'arraybuffer',
withCredentials: settings.withCredentials
}, function(error, url) {
// the segment request is no longer outstanding
segmentXhr = null;
......@@ -702,43 +587,55 @@ var
player.hls.mediaIndex++;
if (player.hls.mediaIndex === player.hls.media.segments.length) {
if (player.hls.mediaIndex === player.hls.playlists.media().segments.length) {
mediaSource.endOfStream();
}
// figure out what stream the next segment should be downloaded from
// with the updated bandwidth information
updateCurrentPlaylist();
player.hls.playlists.media(player.hls.selectPlaylist());
});
};
// load the MediaSource into the player
mediaSource.addEventListener('sourceopen', function() {
// construct the video data buffer and set the appropriate MIME type
var sourceBuffer = mediaSource.addSourceBuffer('video/flv; codecs="vp6,aac"');
var
sourceBuffer = mediaSource.addSourceBuffer('video/flv; codecs="vp6,aac"'),
oldMediaPlaylist;
player.hls.sourceBuffer = sourceBuffer;
sourceBuffer.appendBuffer(segmentParser.getFlvHeader());
player.hls.mediaIndex = 0;
xhr(srcUrl, function(error, url) {
var uri, parser = new videojs.m3u8.Parser();
parser.push(this.responseText);
// master playlists
if (parser.manifest.playlists) {
player.hls.master = parser.manifest;
playlistXhr = xhr(resolveUrl(url, parser.manifest.playlists[0].uri), loadedPlaylist);
return player.trigger('loadedmanifest');
} else {
// infer a master playlist if a media playlist is loaded directly
uri = resolveUrl(window.location.href, url);
player.hls.master = {
playlists: [{
uri: uri
}]
};
loadedPlaylist.call(this, error, uri);
player.hls.playlists =
new videojs.hls.PlaylistLoader(srcUrl, settings.withCredentials);
player.hls.playlists.on('loadedmetadata', function() {
oldMediaPlaylist = player.hls.playlists.media();
// periodicaly check if the buffer needs to be refilled
fillBuffer();
player.on('timeupdate', fillBuffer);
player.trigger('loadedmetadata');
});
player.hls.playlists.on('error', function() {
player.hls.error = player.hls.playlists.error;
player.trigger('error');
});
player.hls.playlists.on('loadedplaylist', function() {
var updatedPlaylist = player.hls.playlists.media();
if (!updatedPlaylist) {
// do nothing before an initial media playlist has been activated
return;
}
updateDuration(player.hls.playlists.media());
player.hls.mediaIndex = translateMediaIndex(player.hls.mediaIndex,
oldMediaPlaylist,
updatedPlaylist);
oldMediaPlaylist = updatedPlaylist;
});
});
player.src([{
......
......@@ -74,6 +74,7 @@ module.exports = function(config) {
'../node_modules/sinon/lib/sinon/util/event.js',
'../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js',
'../node_modules/sinon/lib/sinon/util/xhr_ie.js',
'../node_modules/sinon/lib/sinon/util/fake_timers.js',
'../node_modules/video.js/dist/video-js/video.js',
'../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
'../test/karma-qunit-shim.js',
......@@ -85,6 +86,7 @@ module.exports = function(config) {
'../src/segment-parser.js',
'../src/stream.js',
'../src/m3u8/m3u8-parser.js',
'../src/playlist-loader.js',
'../tmp/manifests.js',
'../tmp/expected.js',
'tsSegment-bc.js',
......
......@@ -38,6 +38,7 @@ module.exports = function(config) {
'../node_modules/sinon/lib/sinon/util/event.js',
'../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js',
'../node_modules/sinon/lib/sinon/util/xhr_ie.js',
'../node_modules/sinon/lib/sinon/util/fake_timers.js',
'../node_modules/video.js/dist/video-js/video.js',
'../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
'../test/karma-qunit-shim.js',
......@@ -49,6 +50,7 @@ module.exports = function(config) {
'../src/segment-parser.js',
'../src/stream.js',
'../src/m3u8/m3u8-parser.js',
'../src/playlist-loader.js',
'../tmp/manifests.js',
'../tmp/expected.js',
'tsSegment-bc.js',
......
(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('/');
};
module('Playlist Loader', {
setup: function() {
// fake XHRs
sinonXhr = sinon.useFakeXMLHttpRequest();
requests = [];
sinonXhr.onCreate = function(xhr) {
requests.push(xhr);
};
// fake timers
clock = sinon.useFakeTimers();
},
teardown: function() {
sinonXhr.restore();
clock.restore();
}
});
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('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');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n');
ok(loader.master, 'the master playlist is available');
strictEqual(loader.state, 'HAVE_MASTER', 'the state is correct');
});
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('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('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,
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);
strictEqual(errors, 1, 'emitted an error');
strictEqual(loader.error.status, 500, 'captured the status code');
});
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');
});
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');
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');
});
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');
strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
});
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 is sent');
});
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');
throws(function() {
loader.media('high.m3u8');
}, 'throws an error from HAVE_MASTER');
});
test('throws an error if a switch to an unrecognized playlist is requested', function() {
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');
});
})(window);
......@@ -8,6 +8,7 @@
<script src="../node_modules/sinon/lib/sinon/util/event.js"></script>
<script src="../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js"></script>
<script src="../node_modules/sinon/lib/sinon/util/xhr_ie.js"></script>
<script src="../node_modules/sinon/lib/sinon/util/fake_timers.js"></script>
<!-- Load local QUnit. -->
<link rel="stylesheet" href="../libs/qunit/qunit.css" media="screen">
......@@ -28,6 +29,7 @@
<!-- M3U8 -->
<script src="../src/stream.js"></script>
<script src="../src/m3u8/m3u8-parser.js"></script>
<script src="../src/playlist-loader.js"></script>
<!-- M3U8 TEST DATA -->
<script src="../tmp/manifests.js"></script>
<script src="../tmp/expected.js"></script>
......@@ -51,6 +53,7 @@
<script src="exp-golomb_test.js"></script>
<script src="flv-tag_test.js"></script>
<script src="m3u8_test.js"></script>
<script src="playlist-loader_test.js"></script>
</head>
<body>
<div id="qunit"></div>
......
......@@ -115,7 +115,7 @@ module('HLS', {
oldSegmentParser = videojs.hls.SegmentParser;
oldSetTimeout = window.setTimeout;
// make XHRs synchronous
// fake XHRs
xhr = sinon.useFakeXMLHttpRequest();
requests = [];
xhr.onCreate = function(xhr) {
......@@ -149,30 +149,25 @@ test('starts playing if autoplay is specified', function() {
strictEqual(1, plays, 'play was called');
});
test('loads the specified manifest URL on init', function() {
var loadedmanifest = false, loadedmetadata = false;
player.on('loadedmanifest', function() {
loadedmanifest = true;
});
test('creates a PlaylistLoader on init', function() {
var loadedmetadata = false;
player.on('loadedmetadata', function() {
loadedmetadata = true;
});
player.hls('manifest/playlist.m3u8');
strictEqual(player.hls.readyState(), 0, 'the readyState is HAVE_NOTHING');
ok(!player.hls.playlists, 'waits for sourceopen to create the loader');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
ok(loadedmanifest, 'loadedmanifest fires');
ok(loadedmetadata, 'loadedmetadata fires');
ok(player.hls.master, 'a master is inferred');
ok(player.hls.media, 'the manifest is available');
ok(player.hls.media.segments, 'the segment entries are parsed');
strictEqual(player.hls.master.playlists[0],
player.hls.media,
ok(player.hls.playlists.master, 'set the master playlist');
ok(player.hls.playlists.media(), 'set the media playlist');
ok(player.hls.playlists.media().segments, 'the segment entries are parsed');
strictEqual(player.hls.playlists.master.playlists[0],
player.hls.playlists.media(),
'the playlist is selected');
strictEqual(player.hls.readyState(), 1, 'the readyState is HAVE_METADATA');
});
test('sets the duration if one is available on the playlist', function() {
......@@ -189,8 +184,9 @@ test('sets the duration if one is available on the playlist', function() {
});
standardXHRResponse(requests[0]);
strictEqual(calls, 1, 'duration is set');
standardXHRResponse(requests[1]);
strictEqual(calls, 2, 'duration is set');
strictEqual(calls, 1, 'duration is set');
});
test('calculates the duration if needed', function() {
......@@ -207,10 +203,9 @@ test('calculates the duration if needed', function() {
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
strictEqual(durations.length, 2, 'duration is set');
strictEqual(durations.length, 1, 'duration is set');
strictEqual(durations[0],
player.hls.media.segments.length * 10,
player.hls.playlists.media().segments.length * 10,
'duration is calculated');
});
......@@ -269,18 +264,7 @@ test('re-initializes the plugin for each source', function() {
});
test('triggers an error when a master playlist request errors', function() {
var
status = 0,
error;
window.XMLHttpRequest = function() {
this.open = function() {};
this.send = function() {
this.readyState = 4;
this.status = status;
this.onreadystatechange();
};
};
var error;
player.on('error', function() {
error = player.hls.error;
});
......@@ -288,6 +272,7 @@ test('triggers an error when a master playlist request errors', function() {
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
requests.pop().respond(500);
ok(error, 'an error is triggered');
strictEqual(2, error.code, 'a network error is triggered');
......@@ -355,7 +340,7 @@ test('selects a playlist after segment downloads', function() {
player.hls('manifest/master.m3u8');
player.hls.selectPlaylist = function() {
calls++;
return player.hls.master.playlists[0];
return player.hls.playlists.master.playlists[0];
};
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
......@@ -375,6 +360,7 @@ test('selects a playlist after segment downloads', function() {
player.trigger('timeupdate');
standardXHRResponse(requests[3]);
console.log(requests.map(function(i) { return i.url; }));
strictEqual(calls, 2, 'selects after additional segments');
});
......@@ -403,14 +389,14 @@ test('updates the duration after switching playlists', function() {
player.hls('manifest/master.m3u8');
player.hls.selectPlaylist = function() {
selectedPlaylist = true;
return player.hls.master.playlists[1];
return player.hls.playlists.master.playlists[1];
};
player.duration = function(duration) {
if (duration === undefined) {
return 0;
}
// only track calls that occur after the playlist has been switched
if (player.hls.media === player.hls.master.playlists[1]) {
if (player.hls.playlists.media() === player.hls.playlists.master.playlists[1]) {
calls++;
}
};
......@@ -462,8 +448,10 @@ test('downloads additional playlists if required', function() {
'/manifest/' +
playlist.uri,
'made playlist request');
strictEqual(playlist, player.hls.media, 'a new playlists was selected');
ok(player.hls.media.segments, 'segments are now available');
strictEqual(playlist.uri,
player.hls.playlists.media().uri,
'a new playlists was selected');
ok(player.hls.playlists.media().segments, 'segments are now available');
});
test('selects a playlist below the current bandwidth', function() {
......@@ -476,15 +464,15 @@ test('selects a playlist below the current bandwidth', function() {
standardXHRResponse(requests[0]);
// the default playlist has a really high bitrate
player.hls.master.playlists[0].attributes.BANDWIDTH = 9e10;
player.hls.playlists.master.playlists[0].attributes.BANDWIDTH = 9e10;
// playlist 1 has a very low bitrate
player.hls.master.playlists[1].attributes.BANDWIDTH = 1;
player.hls.playlists.master.playlists[1].attributes.BANDWIDTH = 1;
// but the detected client bandwidth is really low
player.hls.bandwidth = 10;
playlist = player.hls.selectPlaylist();
strictEqual(playlist,
player.hls.master.playlists[1],
player.hls.playlists.master.playlists[1],
'the low bitrate stream is selected');
});
......@@ -498,15 +486,15 @@ test('raises the minimum bitrate for a stream proportionially', function() {
standardXHRResponse(requests[0]);
// the default playlist's bandwidth + 10% is equal to the current bandwidth
player.hls.master.playlists[0].attributes.BANDWIDTH = 10;
player.hls.playlists.master.playlists[0].attributes.BANDWIDTH = 10;
player.hls.bandwidth = 11;
// 9.9 * 1.1 < 11
player.hls.master.playlists[1].attributes.BANDWIDTH = 9.9;
player.hls.playlists.master.playlists[1].attributes.BANDWIDTH = 9.9;
playlist = player.hls.selectPlaylist();
strictEqual(playlist,
player.hls.master.playlists[1],
player.hls.playlists.master.playlists[1],
'a lower bitrate stream is selected');
});
......@@ -525,7 +513,7 @@ test('uses the lowest bitrate if no other is suitable', function() {
// playlist 1 has the lowest advertised bitrate
strictEqual(playlist,
player.hls.master.playlists[1],
player.hls.playlists.master.playlists[1],
'the lowest bitrate stream is selected');
});
......@@ -855,30 +843,21 @@ test('clears pending buffer updates when seeking', function() {
test('playlist 404 should trigger MEDIA_ERR_NETWORK', function() {
var errorTriggered = false;
window.XMLHttpRequest = function() {
this.open = function(method, url) {
xhrUrls.push(url);
};
this.send = function() {
this.readyState = 4;
this.status = 404;
this.onreadystatechange();
};
};
player.hls('manifest/media.m3u8');
player.on('error', function() {
errorTriggered = true;
});
player.hls('manifest/media.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
equal(true, errorTriggered, 'Missing Playlist error event should trigger');
equal(2, player.hls.error.code, 'Player error code should be set to MediaError.MEDIA_ERR_NETWORK');
requests.pop().respond(404);
equal(errorTriggered,
true,
'Missing Playlist error event should trigger');
equal(player.hls.error.code,
2,
'Player error code should be set to MediaError.MEDIA_ERR_NETWORK');
ok(player.hls.error.message, 'Player error type should inform user correctly');
});
......@@ -916,24 +895,6 @@ test('has no effect if native HLS is available', function() {
'no media source was opened');
});
test('reloads live playlists', function() {
var callbacks = [];
// capture timeouts
window.setTimeout = function(callback, timeout) {
callbacks.push({ callback: callback, timeout: timeout });
};
player.hls('http://example.com/manifest/missingEndlist.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
strictEqual(1, callbacks.length, 'refresh was scheduled');
strictEqual(player.hls.media.targetDuration * 1000,
callbacks[0].timeout,
'waited one target duration');
});
test('duration is Infinity for live playlists', function() {
player.hls('http://example.com/manifest/missingEndlist.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
......@@ -959,88 +920,37 @@ test('does not reload playlists with an endlist tag', function() {
strictEqual(0, callbacks.length, 'no refresh was scheduled');
});
test('reloads a live playlist after half a target duration if it has not ' +
'changed since the last request', function() {
var callbacks = [];
// capture timeouts
window.setTimeout = function(callback, timeout) {
callbacks.push({ callback: callback, timeout: timeout });
};
player.hls('http://example.com/manifest/missingEndlist.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
strictEqual(callbacks.length, 1, 'full-length refresh scheduled');
callbacks.pop().callback();
standardXHRResponse(requests[2]);
strictEqual(callbacks.length, 1, 'half-length refresh was scheduled');
strictEqual(callbacks[0].timeout,
player.hls.media.targetDuration / 2 * 1000,
'waited half a target duration');
});
test('merges playlist reloads', function() {
var oldPlaylist,
callback;
// capture timeouts
window.setTimeout = function(cb) {
callback = cb;
};
player.hls('http://example.com/manifest/missingEndlist.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
oldPlaylist = player.hls.media;
callback();
standardXHRResponse(requests[2]);
ok(oldPlaylist !== player.hls.media, 'player.hls.media was updated');
});
test('updates the media index when a playlist reloads', function() {
var callback;
window.setTimeout = function(cb) {
callback = cb;
};
// the initial playlist
window.manifests['live-updating'] =
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n';
player.hls('http://example.com/live-updating.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
requests[0].respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n');
standardXHRResponse(requests[1]);
// play the stream until 2.ts is playing
player.hls.mediaIndex = 3;
// reload the updated playlist
window.manifests['live-updating'] =
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n';
callback();
standardXHRResponse(requests[2]);
player.hls.playlists.media = function() {
return {
segments: [{
uri: '1.ts'
}, {
uri: '2.ts'
}, {
uri: '3.ts'
}]
};
};
player.hls.playlists.trigger('loadedplaylist');
strictEqual(player.hls.mediaIndex, 2, 'mediaIndex is updated after the reload');
});
......@@ -1112,64 +1022,6 @@ test('does not reload master playlists', function() {
strictEqual(callbacks.length, 0, 'no reload scheduled');
});
test('only reloads the active media playlist', function() {
var callbacks = [],
i = 0,
filteredRequests = [],
customResponse;
customResponse = function(request) {
request.response = new Uint8Array([1]).buffer;
request.respond(200,
{'Content-Type': 'application/vnd.apple.mpegurl'},
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'1.ts\n');
};
window.setTimeout = function(callback) {
callbacks.push(callback);
};
player.hls('http://example.com/missingEndlist.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
videojs.mediaSources[player.currentSrc()].endOfStream = function() {};
player.hls.selectPlaylist = function() {
return player.hls.master.playlists[1];
};
player.hls.master.playlists.push({
uri: 'http://example.com/switched.m3u8'
});
player.trigger('timeupdate');
strictEqual(callbacks.length, 1, 'a refresh is scheduled');
standardXHRResponse(requests[2]); // segment response
customResponse(requests[3]); // loaded witched.m3u8
callbacks.shift()(); // out-of-date refresh of missingEndlist.m3u8
callbacks.shift()(); // refresh switched.m3u8
for (; i < requests.length; i++) {
if (/switched/.test(requests[i].url)) {
filteredRequests.push(requests[i]);
}
}
strictEqual(filteredRequests.length, 2, 'one refresh was made');
strictEqual(filteredRequests[1].url,
'http://example.com/switched.m3u8',
'refreshed the active playlist');
});
test('if withCredentials option is used, withCredentials is set on the XHR object', function() {
player.hls({
url: 'http://example.com/media.m3u8',
......@@ -1182,20 +1034,15 @@ test('if withCredentials option is used, withCredentials is set on the XHR objec
});
test('does not break if the playlist has no segments', function() {
var customResponse = function(request) {
request.response = new Uint8Array([1]).buffer;
request.respond(200,
{'Content-Type': 'application/vnd.apple.mpegurl'},
'#EXTM3U\n' +
'#EXT-X-PLAYLIST-TYPE:VOD\n' +
'#EXT-X-TARGETDURATION:10\n');
};
player.hls('manifest/master.m3u8');
try {
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
customResponse(requests[0]);
requests[0].respond(200, null,
'#EXTM3U\n' +
'#EXT-X-PLAYLIST-TYPE:VOD\n' +
'#EXT-X-TARGETDURATION:10\n');
} catch(e) {
ok(false, 'an error was thrown');
throw e;
......