50efd48c by David LaPalomento

Basic support for master playlists

If a master playlist has been downloaded, immediately fetch the default variant playlist and start buffering it. This matches HLS network activity in Safari on OS X which also seems to lazily load the non-default variant streams. Consolidate relative URL resolution and use a solution involving the `base` element to take advantage of browser logic for URL composition. Update test cases to expect absolute URLs for XHRs after the initial manifest request.
1 parent 98a64cbe
......@@ -412,7 +412,7 @@
this.manifest.totalDuration = calculatedDuration;
this.trigger('info', {
message: 'updating total duration to use a calculated value'
})
});
}
}
})[entry.tagType] || noop).call(self);
......
......@@ -6,7 +6,7 @@
* All rights reserved.
*/
(function(window, videojs, undefined) {
(function(window, videojs, document, undefined) {
videojs.hls = {};
......@@ -15,6 +15,45 @@ var
goalBufferLength = 5,
/**
* 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
*/
resolveUrl = function(basePath, path) {
// use the base element to get the browser to handle URI resolution
var
oldBase = document.querySelector('base'),
docHead = document.querySelector('head'),
a = document.createElement('a'),
base = oldBase,
oldHref,
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;
},
/**
* Initializes the HLS plugin.
* @param options {mixed} the URL to an HLS playlist
*/
......@@ -24,21 +63,21 @@ var
segmentParser = new videojs.hls.SegmentParser(),
player = this,
extname,
url,
srcUrl,
segmentXhr,
fillBuffer,
onDurationUpdate,
selectPlaylist;
downloadPlaylist,
fillBuffer;
extname = (/[^#?]*(?:\/[^#?]*\.([^#?]*))/).exec(player.currentSrc());
if (typeof options === 'string') {
url = options;
srcUrl = options;
} else if (options) {
url = options.url;
srcUrl = options.url;
} else if (extname && extname[1] === 'm3u8') {
// if the currentSrc looks like an m3u8, attempt to use it
url = player.currentSrc();
srcUrl = player.currentSrc();
} else {
// do nothing until the plugin is initialized with a valid URL
videojs.log('hls: no valid playlist URL specified');
......@@ -47,24 +86,20 @@ var
// expose the HLS plugin state
player.hls.readyState = function() {
if (!player.hls.manifest) {
if (!player.hls.media) {
return 0; // HAVE_NOTHING
}
return 1; // HAVE_METADATA
};
// 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"');
player.hls.sourceBuffer = sourceBuffer;
sourceBuffer.appendBuffer(segmentParser.getFlvHeader());
// Chooses the appropriate media playlist based on the current bandwidth
// estimate and the player size
selectPlaylist = function() {
player.hls.currentPlaylist = player.hls.manifest;
player.hls.currentMediaIndex = 0;
/**
* Chooses the appropriate media playlist based on the current
* bandwidth estimate and the player size.
*/
player.hls.selectPlaylist = function() {
player.hls.media = player.hls.master.playlists[0];
player.hls.mediaIndex = 0;
};
onDurationUpdate = function(value) {
......@@ -72,6 +107,64 @@ var
};
/**
* Download an M3U8 and update the current manifest object. If the provided
* URL is a master playlist, the default variant will be downloaded and
* parsed as well. Triggers `loadedmanifest` once for each playlist that is
* downloaded and `loadedmetadata` after at least one media playlist has
* been parsed. Whether multiple playlists were downloaded or not, after
* `loadedmetadata` fires a parsed or inferred master playlist object will
* be available as `player.hls.master`.
*
* @param url {string} a URL to the M3U8 file to process
*/
downloadPlaylist = function(url) {
var xhr = new window.XMLHttpRequest();
xhr.open('GET', url);
xhr.onreadystatechange = function() {
var i, parser, playlist, playlistUri;
if (xhr.readyState === 4) {
// readystate DONE
parser = new videojs.m3u8.Parser();
parser.on('durationUpdate', onDurationUpdate);
parser.push(xhr.responseText);
// master playlists
if (parser.manifest.playlists) {
player.hls.master = parser.manifest;
downloadPlaylist(resolveUrl(url, parser.manifest.playlists[0].uri));
player.trigger('loadedmanifest');
return;
}
// media playlists
if (player.hls.master) {
// merge this playlist into the master
i = player.hls.master.playlists.length;
while (i--) {
playlist = player.hls.master.playlists[i];
playlistUri = resolveUrl(srcUrl, playlist.uri);
if (playlistUri === url) {
player.hls.master.playlists[i] =
videojs.util.mergeOptions(playlist, parser.manifest);
}
}
} else {
// infer a master playlist if none was previously requested
player.hls.master = {
playlists: [parser.manifest]
};
}
player.hls.selectPlaylist();
player.trigger('loadedmanifest');
player.trigger('loadedmetadata');
}
};
xhr.send(null);
};
/**
* 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.
*/
......@@ -79,7 +172,7 @@ var
var
buffered = player.buffered(),
bufferedTime = 0,
segment = player.hls.currentPlaylist.segments[player.hls.currentMediaIndex],
segment = player.hls.media.segments[player.hls.mediaIndex],
segmentUri,
startTime;
......@@ -103,25 +196,8 @@ var
return;
}
segmentUri = segment.uri;
if ((/^\/[^\/]/).test(segmentUri)) {
// the segment is specified with a network path,
// e.g. "/01.ts"
(function() {
// use an anchor to resolve the manifest URL to an absolute path
// this method should work back to IE6:
// http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue
var resolver = document.createElement('div');
resolver.innerHTML = '<a href="' + url + '"></a>';
segmentUri = (/^[A-z]*:\/\/[^\/]*/).exec(resolver.firstChild.href)[0] +
segmentUri;
})();
} else if (!(/^([A-z]*:)?\/\//).test(segmentUri)) {
// the segment is specified with a relative path,
// e.g. "../01.ts" or "path/to/01.ts"
segmentUri = url.split('/').slice(0, -1).concat(segmentUri).join('/');
}
segmentUri = resolveUrl(resolveUrl(srcUrl, player.hls.media.uri || ''),
segment.uri);
// request the next segment
segmentXhr = new window.XMLHttpRequest();
......@@ -141,43 +217,24 @@ var
}
segmentXhr = null;
player.hls.currentMediaIndex++;
player.hls.mediaIndex++;
}
};
startTime = +new Date();
segmentXhr.send(null);
};
player.on('loadedmetadata', fillBuffer);
player.on('timeupdate', fillBuffer);
// download and process the manifest
(function() {
var xhr = new window.XMLHttpRequest();
xhr.open('GET', url);
xhr.onreadystatechange = function() {
var parser;
if (xhr.readyState === 4) {
// readystate DONE
parser = new videojs.m3u8.Parser();
parser.on('durationUpdate', onDurationUpdate);
parser.push(xhr.responseText);
player.hls.manifest = parser.manifest;
if(parser.manifest.totalDuration) {
player.duration(parser.manifest.totalDuration);
}
// 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"');
player.hls.sourceBuffer = sourceBuffer;
sourceBuffer.appendBuffer(segmentParser.getFlvHeader());
player.trigger('loadedmanifest');
player.on('loadedmetadata', fillBuffer);
player.on('timeupdate', fillBuffer);
if (parser.manifest.segments) {
selectPlaylist();
player.trigger('loadedmetadata');
}
}
};
xhr.send(null);
})();
downloadPlaylist(srcUrl);
});
player.src({
src: videojs.URL.createObjectURL(mediaSource),
......@@ -195,4 +252,4 @@ videojs.plugin('hls', function() {
initialize().apply(this, arguments);
});
})(window, window.videojs);
})(window, window.videojs, document);
......
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=200000
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=240000
prog_index.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=40000
prog_index1.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=440000
prog_index2.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1928000
prog_index3.m3u8
......
{
"allowCache": true,
"playlists": [{
"attributes": {
"PROGRAM-ID": 1,
"BANDWIDTH": 240000,
"RESOLUTION": {
"width": 396,
"height": 224
}
},
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686811001&videoId=1824650741001"
}, {
"attributes": {
"PROGRAM-ID": 1,
"BANDWIDTH": 40000
},
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824683759001&videoId=1824650741001"
}, {
"attributes": {
"PROGRAM-ID": 1,
"BANDWIDTH": 440000,
"RESOLUTION": {
"width": 396,
"height": 224
}
},
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686593001&videoId=1824650741001"
}, {
"attributes": {
"PROGRAM-ID": 1,
"BANDWIDTH": 1928000,
"RESOLUTION": {
"width": 960,
"height": 540
}
},
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824687660001&videoId=1824650741001"
}]
}
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=240000,RESOLUTION=396x224
http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686811001&videoId=1824650741001
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=40000
http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824683759001&videoId=1824650741001
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=440000,RESOLUTION=396x224
http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686593001&videoId=1824650741001
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1928000,RESOLUTION=960x540
http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824687660001&videoId=1824650741001
......@@ -10,13 +10,13 @@
"height": 224
}
},
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686811001&videoId=1824650741001"
"uri": "media.m3u8"
}, {
"attributes": {
"PROGRAM-ID": 1,
"BANDWIDTH": 40000
},
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824683759001&videoId=1824650741001"
"uri": "media.m3u8"
}, {
"attributes": {
"PROGRAM-ID": 1,
......@@ -26,7 +26,7 @@
"height": 224
}
},
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686593001&videoId=1824650741001"
"uri": "media.m3u8"
}, {
"attributes": {
"PROGRAM-ID": 1,
......@@ -36,6 +36,6 @@
"height": 540
}
},
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824687660001&videoId=1824650741001"
"uri": "media.m3u8"
}]
}
......
# A simple master playlist with multiple variant streams
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=240000,RESOLUTION=396x224
http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686811001&videoId=1824650741001
media.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=40000
http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824683759001&videoId=1824650741001
media.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=440000,RESOLUTION=396x224
http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686593001&videoId=1824650741001
media.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1928000,RESOLUTION=960x540
http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824687660001&videoId=1824650741001
media.m3u8
......
......@@ -63,7 +63,7 @@ module('HLS', {
this.send = function() {
// if the request URL looks like one of the test manifests, grab the
// contents off the global object
var manifestName = (/.*\/(.*)\.m3u8/).exec(xhrUrls.slice(-1)[0]);
var manifestName = (/(?:.*\/)?(.*)\.m3u8/).exec(xhrUrls.slice(-1)[0]);
if (manifestName) {
manifestName = manifestName[1];
}
......@@ -99,10 +99,11 @@ test('loads the specified manifest URL on init', function() {
});
ok(loadedmanifest, 'loadedmanifest fires');
ok(loadedmetadata, 'loadedmetadata fires');
ok(player.hls.manifest, 'the manifest is available');
ok(player.hls.manifest.segments, 'the segment entries are parsed');
strictEqual(player.hls.manifest,
player.hls.currentPlaylist,
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,
'the playlist is selected');
strictEqual(player.hls.readyState(), 1, 'the readyState is HAVE_METADATA');
});
......@@ -116,7 +117,11 @@ test('starts downloading a segment on loadedmetadata', function() {
type: 'sourceopen'
});
strictEqual(xhrUrls[1], 'manifest/00001.ts', 'the first segment is requested');
strictEqual(xhrUrls[1],
window.location.origin +
window.location.pathname.split('/').slice(0, -1).join('/') +
'/manifest/00001.ts',
'the first segment is requested');
});
test('recognizes absolute URIs and requests them unmodified', function() {
......@@ -151,6 +156,26 @@ test('re-initializes the plugin for each source', function() {
notStrictEqual(firstInit, secondInit, 'the plugin object is replaced');
});
test('downloads media playlists after loading the master', function() {
player.hls('manifest/master.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
strictEqual(xhrUrls.length, 3, 'three requests were made');
strictEqual(xhrUrls[0], 'manifest/master.m3u8', 'master playlist requested');
strictEqual(xhrUrls[1],
window.location.origin +
window.location.pathname.split('/').slice(0, -1).join('/') +
'/manifest/media.m3u8',
'media playlist requested');
strictEqual(xhrUrls[2],
window.location.origin +
window.location.pathname.split('/').slice(0, -1).join('/') +
'/manifest/00001.ts',
'first segment requested');
});
test('calculates the bandwidth after downloading a segment', function() {
player.hls('manifest/media.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
......@@ -195,7 +220,11 @@ test('downloads the next segment if the buffer is getting low', function() {
player.trigger('timeupdate');
strictEqual(xhrUrls.length, 3, 'made a request');
strictEqual(xhrUrls[2], 'manifest/00002.ts', 'made segment request');
strictEqual(xhrUrls[2],
window.location.origin +
window.location.pathname.split('/').slice(0, -1).join('/') +
'/manifest/00002.ts',
'made segment request');
});
test('stops downloading segments at the end of the playlist', function() {
......@@ -204,7 +233,7 @@ test('stops downloading segments at the end of the playlist', function() {
type: 'sourceopen'
});
xhrUrls = [];
player.hls.currentMediaIndex = 4;
player.hls.mediaIndex = 4;
player.trigger('timeupdate');
strictEqual(xhrUrls.length, 0, 'no request is made');
......