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);
......@@ -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',
......
......@@ -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>
......