From 50efd48c93e06a10f3dfacc85a0ea14594fa42c9 Mon Sep 17 00:00:00 2001 From: David LaPalomento Date: Sun, 5 Jan 2014 09:43:48 -0500 Subject: [PATCH] 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. --- src/m3u8/m3u8-parser.js | 2 +- src/videojs-hls.js | 295 ++++++++++++++++++++-------------- test/fixtures/bipbop.m3u8 | 8 +- test/manifest/brightcove.json | 40 +++++ test/manifest/brightcove.m3u8 | 9 ++ test/manifest/master.json | 8 +- test/manifest/master.m3u8 | 9 +- test/videojs-hls_test.js | 45 +++++- 8 files changed, 279 insertions(+), 137 deletions(-) create mode 100644 test/manifest/brightcove.json create mode 100644 test/manifest/brightcove.m3u8 diff --git a/src/m3u8/m3u8-parser.js b/src/m3u8/m3u8-parser.js index 49bebe6a..627218f0 100644 --- a/src/m3u8/m3u8-parser.js +++ b/src/m3u8/m3u8-parser.js @@ -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); diff --git a/src/videojs-hls.js b/src/videojs-hls.js index a9f5497b..4f1de735 100644 --- a/src/videojs-hls.js +++ b/src/videojs-hls.js @@ -6,7 +6,7 @@ * All rights reserved. */ -(function(window, videojs, undefined) { +(function(window, videojs, document, undefined) { videojs.hls = {}; @@ -14,6 +14,45 @@ var // the desired length of video to maintain in the buffer, in seconds 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,137 +86,155 @@ 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) { - player.duration(value); - }; + onDurationUpdate = function(value) { + player.duration(value); + }; - /** - * 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. - */ - fillBuffer = function() { - var - buffered = player.buffered(), - bufferedTime = 0, - segment = player.hls.currentPlaylist.segments[player.hls.currentMediaIndex], - segmentUri, - startTime; - - // if there is a request already in flight, do nothing - if (segmentXhr) { - return; - } + /** + * 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; + } - // if the video has finished downloading, stop trying to buffer - if (!segment) { - 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] + }; + } - if (buffered) { - // assuming a single, contiguous buffer region - bufferedTime = player.buffered().end(0) - player.currentTime(); + player.hls.selectPlaylist(); + player.trigger('loadedmanifest'); + player.trigger('loadedmetadata'); } + }; + xhr.send(null); + }; - // if there is plenty of content in the buffer, relax for awhile - if (bufferedTime >= goalBufferLength) { - return; - } + /** + * 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. + */ + fillBuffer = function() { + var + buffered = player.buffered(), + bufferedTime = 0, + segment = player.hls.media.segments[player.hls.mediaIndex], + segmentUri, + startTime; + + // if there is a request already in flight, do nothing + if (segmentXhr) { + 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 = ''; - - 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('/'); - } + // if the video has finished downloading, stop trying to buffer + if (!segment) { + return; + } - // request the next segment - segmentXhr = new window.XMLHttpRequest(); - segmentXhr.open('GET', segmentUri); - segmentXhr.responseType = 'arraybuffer'; - segmentXhr.onreadystatechange = function() { - if (segmentXhr.readyState === 4) { - // calculate the download bandwidth - player.hls.segmentXhrTime = (+new Date()) - startTime; - player.hls.bandwidth = segmentXhr.response.byteLength / player.hls.segmentXhrTime; - - // transmux the segment data from MP2T to FLV - segmentParser.parseSegmentBinaryData(new Uint8Array(segmentXhr.response)); - while (segmentParser.tagsAvailable()) { - player.hls.sourceBuffer.appendBuffer(segmentParser.getNextTag().bytes, - player); - } + if (buffered) { + // assuming a single, contiguous buffer region + bufferedTime = player.buffered().end(0) - player.currentTime(); + } + + // if there is plenty of content in the buffer, relax for awhile + if (bufferedTime >= goalBufferLength) { + return; + } - segmentXhr = null; - player.hls.currentMediaIndex++; + segmentUri = resolveUrl(resolveUrl(srcUrl, player.hls.media.uri || ''), + segment.uri); + + // request the next segment + segmentXhr = new window.XMLHttpRequest(); + segmentXhr.open('GET', segmentUri); + segmentXhr.responseType = 'arraybuffer'; + segmentXhr.onreadystatechange = function() { + if (segmentXhr.readyState === 4) { + // calculate the download bandwidth + player.hls.segmentXhrTime = (+new Date()) - startTime; + player.hls.bandwidth = segmentXhr.response.byteLength / player.hls.segmentXhrTime; + + // transmux the segment data from MP2T to FLV + segmentParser.parseSegmentBinaryData(new Uint8Array(segmentXhr.response)); + while (segmentParser.tagsAvailable()) { + player.hls.sourceBuffer.appendBuffer(segmentParser.getNextTag().bytes, + player); } - }; - startTime = +new Date(); - segmentXhr.send(null); + + segmentXhr = null; + player.hls.mediaIndex++; + } }; + startTime = +new Date(); + segmentXhr.send(null); + }; + + // 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.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); - } - - player.trigger('loadedmanifest'); - 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); diff --git a/test/fixtures/bipbop.m3u8 b/test/fixtures/bipbop.m3u8 index 8838fbb1..d9a6f21e 100644 --- a/test/fixtures/bipbop.m3u8 +++ b/test/fixtures/bipbop.m3u8 @@ -1,3 +1,9 @@ #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 diff --git a/test/manifest/brightcove.json b/test/manifest/brightcove.json new file mode 100644 index 00000000..6951619f --- /dev/null +++ b/test/manifest/brightcove.json @@ -0,0 +1,40 @@ +{ + "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" + }] +} diff --git a/test/manifest/brightcove.m3u8 b/test/manifest/brightcove.m3u8 new file mode 100644 index 00000000..cdd12263 --- /dev/null +++ b/test/manifest/brightcove.m3u8 @@ -0,0 +1,9 @@ +#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 diff --git a/test/manifest/master.json b/test/manifest/master.json index a3c16606..c554b14c 100644 --- a/test/manifest/master.json +++ b/test/manifest/master.json @@ -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" }] } diff --git a/test/manifest/master.m3u8 b/test/manifest/master.m3u8 index cdd12263..a9c6d522 100644 --- a/test/manifest/master.m3u8 +++ b/test/manifest/master.m3u8 @@ -1,9 +1,10 @@ +# 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 diff --git a/test/videojs-hls_test.js b/test/videojs-hls_test.js index 41a37772..ffe6d6db 100644 --- a/test/videojs-hls_test.js +++ b/test/videojs-hls_test.js @@ -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');