259d464f by David LaPalomento

Implement live playlist support

If the active media playlist does not have an ENDLIST tag, refresh it periodically.
1 parent 5c833598
......@@ -2,10 +2,10 @@
* A state machine that manages the loading, caching, and updating of
* M3U8 playlists.
*/
(function(window) {
(function(window, videojs) {
'use strict';
var
/* XXX COPIED REMOVE ME */
/**
* Constructs a new URI by interpreting a path relative to another
......@@ -15,7 +15,7 @@
* @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
*/
*/
resolveUrl = function(basePath, path) {
// use the base element to get the browser to handle URI resolution
var
......@@ -46,33 +46,209 @@
return result;
},
PlaylistLoader = function(url) {
var
loader = this,
request;
if (!url) {
throw new Error('A non-empty playlist URL is required');
}
loader.state = 'HAVE_NOTHING';
request = new window.XMLHttpRequest();
request.open('GET', url);
request.onreadystatechange = function() {
var parser = new videojs.m3u8.Parser();
parser.push(this.responseText);
if (parser.manifest.playlists) {
loader.master = parser.manifest;
} else {
/* XXX COPIED REMOVE ME */
/**
* Creates and sends an XMLHttpRequest.
* @param options {string | object} if this argument is a string, it
* is intrepreted as a URL and a simple GET request is
* 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) {
var
options = {
method: 'GET'
},
request;
if (typeof callback !== 'function') {
callback = function() {};
}
if (typeof url === 'object') {
options = videojs.util.mergeOptions(options, url);
url = options.url;
}
request = new window.XMLHttpRequest();
request.open(options.method, url);
if (options.responseType) {
request.responseType = options.responseType;
}
if (options.withCredentials) {
request.withCredentials = true;
}
request.onreadystatechange = function() {
// wait until the request completes
if (this.readyState !== 4) {
return;
}
// request error
if (this.status >= 400 || this.status === 0) {
return callback.call(this, true, url);
}
return callback.call(this, false, url);
};
request.send(null);
return request;
},
/**
* 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);
changed = true;
}
}
return changed ? result : null;
},
PlaylistLoader = function(srcUrl) {
var
loader = this,
request,
haveMetadata = function(error, url) {
var parser, refreshDelay, update;
if (error) {
loader.error = {
status: this.status,
message: 'HLS playlist request error at URL: ' + url,
code: (this.status >= 500) ? 4 : 2
};
return loader.trigger('error');
}
loader.state = 'HAVE_METADATA';
parser = new videojs.m3u8.Parser();
parser.push(this.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;
loader.media = parser.manifest;
} 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);
}
};
PlaylistLoader.prototype.init.call(this);
if (!srcUrl) {
throw new Error('A non-empty playlist URL is required');
}
loader.state = 'HAVE_NOTHING';
// 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(resolveUrl(loader.master.uri, loader.media.uri),
function(error) {
haveMetadata.call(this, error, loader.media.uri);
});
});
// request the specified URL
xhr(srcUrl, function(error) {
var parser;
if (error) {
loader.error = {
status: this.status,
message: 'HLS playlist request error at URL: ' + srcUrl,
code: (this.status >= 500) ? 4 : 2
};
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;
request = xhr(resolveUrl(srcUrl, parser.manifest.playlists[0].uri),
function(error) {
// pass along the URL specified in the master playlist
haveMetadata.call(this,
error,
parser.manifest.playlists[0].uri);
});
return loader.trigger('loadedplaylist');
}
// loaded a media playlist
// infer a master playlist if none was previously requested
loader.master = {
playlists: [parser.manifest]
uri: window.location.href,
playlists: [{
uri: srcUrl
}]
};
}
loader.state = 'HAVE_MASTER';
return;
return haveMetadata.call(this, null, srcUrl);
});
};
request.send(null);
};
PlaylistLoader.prototype = new videojs.hls.Stream();
window.videojs.hls.PlaylistLoader = PlaylistLoader;
})(window);
videojs.hls.PlaylistLoader = PlaylistLoader;
})(window, window.videojs);
......
......@@ -104,6 +104,9 @@ 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) {
......
(function(window) {
'use strict';
var
oldXhr,
sinonXhr,
clock,
requests,
videojs = window.videojs;
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() {
oldXhr = window.XMLHttpRequest;
// fake XHRs
sinonXhr = sinon.useFakeXMLHttpRequest();
requests = [];
window.XMLHttpRequest = function() {
this.open = function(method, url) {
this.method = method;
this.url = url;
};
this.send = function() {
requests.push(this);
};
this.respond = function(response) {
this.responseText = response;
this.readyState = 4;
this.onreadystatechange();
};
};
this.send = function() {
sinonXhr.onCreate = function(xhr) {
requests.push(xhr);
};
// fake timers
clock = sinon.useFakeTimers();
},
teardown: function() {
window.XMLHttpRequest = oldXhr;
sinonXhr.restore();
clock.restore();
}
});
......@@ -47,14 +49,15 @@
});
test('requests the initial playlist immediately', function() {
var loader = new videojs.hls.PlaylistLoader('master.m3u8');
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('#EXTM3U\n' +
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n');
ok(loader.master, 'the master playlist is available');
......@@ -63,20 +66,23 @@
test('jumps to HAVE_METADATA when initialized with a media playlist', function() {
var loader = new videojs.hls.PlaylistLoader('media.m3u8');
requests.pop().respond('#EXTM3U\n' +
'#EXTINF:10,\n' +
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(0, requests.length, 'no more requests are made');
});
test('jumps to HAVE_METADATA when initialized with a live media playlist', function() {
var loader = new videojs.hls.PlaylistLoader('media.m3u8');
requests.pop().respond('#EXTM3U\n' +
'#EXTINF:10,\n' +
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');
......@@ -84,18 +90,28 @@
});
test('moves to HAVE_METADATA after loading a media playlist', function() {
var loader = new videojs.hls.PlaylistLoader('master.m3u8');
requests.pop().respond('#EXTM3U\n' +
var
loadedPlaylist = false,
loader = new videojs.hls.PlaylistLoader('master.m3u8');
loader.on('loadedplaylist', function() {
loadedPlaylist = true;
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n' +
'alt.m3u8\n');
ok(loadedPlaylist, 'loadedplaylist fired');
strictEqual(requests.length, 1, 'requests the media playlist');
strictEqual(requests[0].method, 'GET', 'GETs the media playlist');
strictEqual(requests[0].url, 'media.m3u8', 'requests the first playlist');
strictEqual(requests[0].url,
urlTo('media.m3u8'),
'requests the first playlist');
requests.pop().response('#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
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(loader.state, 'HAVE_METADATA', 'the state is correct');
......@@ -103,24 +119,126 @@
test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
var loader = new videojs.hls.PlaylistLoader('live.m3u8');
requests.pop().response('#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
loader.refreshMedia();
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, 'live.m3u8', 'refreshes the media 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().response('#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
loader.refreshMedia();
requests.pop().response('#EXTM3U\n' +
'#EXTINF:10,\n' +
'1.ts\n');
strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct');
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');
});
})(window);
......
......@@ -6,8 +6,9 @@
<!-- Load sinon server for fakeXHR -->
<script src="../node_modules/sinon/lib/sinon.js"></script>
<script src="../node_modules/sinon/lib/sinon/util/event.js"></script>
<script src="../node_modules/sinon/lib/sinon/util/xhr_ie.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">
......
......@@ -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) {
......