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) { ...@@ -30,7 +30,8 @@ module.exports = function(grunt) {
30 'src/aac-stream.js', 30 'src/aac-stream.js',
31 'src/segment-parser.js', 31 'src/segment-parser.js',
32 'src/stream.js', 32 'src/stream.js',
33 'src/m3u8/m3u8-parser.js' 33 'src/m3u8/m3u8-parser.js',
34 'src/playlist-loader.js'
34 ], 35 ],
35 dest: 'dist/videojs.hls.js' 36 dest: 'dist/videojs.hls.js'
36 } 37 }
......
...@@ -68,19 +68,25 @@ See html5rocks's [article](http://www.html5rocks.com/en/tutorials/cors/) ...@@ -68,19 +68,25 @@ See html5rocks's [article](http://www.html5rocks.com/en/tutorials/cors/)
68 for more info. 68 for more info.
69 69
70 ### Runtime Properties 70 ### Runtime Properties
71 #### player.hls.master 71 #### player.hls.playlists.master
72 Type: `object` 72 Type: `object`
73 73
74 An object representing the parsed master playlist. If a media playlist 74 An object representing the parsed master playlist. If a media playlist
75 is loaded directly, a master playlist with only one entry will be 75 is loaded directly, a master playlist with only one entry will be
76 created. 76 created.
77 77
78 #### player.hls.media 78 #### player.hls.playlists.media
79 Type: `object` 79 Type: `function`
80 80
81 An object representing the currently selected media playlist. This is 81 A function that can be used to retrieve or modify the currently active
82 the playlist that is being referred to when a additional video data 82 media playlist. The active media playlist is referred to when
83 needs to be downloaded. 83 additional video data needs to be downloaded. Calling this function
84 with no arguments returns the parsed playlist object for the active
85 media playlist. Calling this function with a playlist object from the
86 master playlist or a URI string as specified in the master playlist
87 will kick off an asynchronous load of the specified media
88 playlist. Once it has been retreived, it will become the active media
89 playlist.
84 90
85 #### player.hls.mediaIndex 91 #### player.hls.mediaIndex
86 Type: `number` 92 Type: `number`
......
No preview for this file type
...@@ -25,6 +25,7 @@ ...@@ -25,6 +25,7 @@
25 <!-- m3u8 handling --> 25 <!-- m3u8 handling -->
26 <script src="src/stream.js"></script> 26 <script src="src/stream.js"></script>
27 <script src="src/m3u8/m3u8-parser.js"></script> 27 <script src="src/m3u8/m3u8-parser.js"></script>
28 <script src="src/playlist-loader.js"></script>
28 29
29 <!-- example MPEG2-TS segments --> 30 <!-- example MPEG2-TS segments -->
30 <!-- bipbop --> 31 <!-- bipbop -->
......
1 /**
2 * A state machine that manages the loading, caching, and updating of
3 * M3U8 playlists.
4 */
5 (function(window, videojs) {
6 'use strict';
7 var
8 resolveUrl = videojs.hls.resolveUrl,
9 xhr = videojs.hls.xhr,
10
11 /**
12 * Returns a new master playlist that is the result of merging an
13 * updated media playlist into the original version. If the
14 * updated media playlist does not match any of the playlist
15 * entries in the original master playlist, null is returned.
16 * @param master {object} a parsed master M3U8 object
17 * @param media {object} a parsed media M3U8 object
18 * @return {object} a new object that represents the original
19 * master playlist with the updated media playlist merged in, or
20 * null if the merge produced no change.
21 */
22 updateMaster = function(master, media) {
23 var
24 changed = false,
25 result = videojs.util.mergeOptions(master, {}),
26 i,
27 playlist;
28
29 i = master.playlists.length;
30 while (i--) {
31 playlist = result.playlists[i];
32 if (playlist.uri === media.uri) {
33 // consider the playlist unchanged if the number of segments
34 // are equal and the media sequence number is unchanged
35 if (playlist.segments &&
36 media.segments &&
37 playlist.segments.length === media.segments.length &&
38 playlist.mediaSequence === media.mediaSequence) {
39 continue;
40 }
41
42 result.playlists[i] = videojs.util.mergeOptions(playlist, media);
43 result.playlists[media.uri] = result.playlists[i];
44 changed = true;
45 }
46 }
47 return changed ? result : null;
48 },
49
50 PlaylistLoader = function(srcUrl, withCredentials) {
51 var
52 loader = this,
53 media,
54 request,
55
56 haveMetadata = function(error, xhr, url) {
57 var parser, refreshDelay, update;
58
59 // any in-flight request is now finished
60 request = null;
61
62 if (error) {
63 loader.error = {
64 status: xhr.status,
65 message: 'HLS playlist request error at URL: ' + url,
66 code: (xhr.status >= 500) ? 4 : 2
67 };
68 return loader.trigger('error');
69 }
70
71 loader.state = 'HAVE_METADATA';
72
73 parser = new videojs.m3u8.Parser();
74 parser.push(xhr.responseText);
75 parser.manifest.uri = url;
76
77 // merge this playlist into the master
78 update = updateMaster(loader.master, parser.manifest);
79 refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
80 if (update) {
81 loader.master = update;
82 media = loader.master.playlists[url];
83 } else {
84 // if the playlist is unchanged since the last reload,
85 // try again after half the target duration
86 refreshDelay /= 2;
87 }
88
89 // refresh live playlists after a target duration passes
90 if (!loader.media().endList) {
91 window.setTimeout(function() {
92 loader.trigger('mediaupdatetimeout');
93 }, refreshDelay);
94 }
95
96 loader.trigger('loadedplaylist');
97 };
98
99 PlaylistLoader.prototype.init.call(this);
100
101 if (!srcUrl) {
102 throw new Error('A non-empty playlist URL is required');
103 }
104
105 loader.state = 'HAVE_NOTHING';
106
107 /**
108 * When called without any arguments, returns the currently
109 * active media playlist. When called with a single argument,
110 * triggers the playlist loader to asynchronously switch to the
111 * specified media playlist. Calling this method while the
112 * loader is in the HAVE_NOTHING or HAVE_MASTER states causes an
113 * error to be emitted but otherwise has no effect.
114 * @param playlist (optional) {object} the parsed media playlist
115 * object to switch to
116 */
117 loader.media = function(playlist) {
118 // getter
119 if (!playlist) {
120 return media;
121 }
122
123 // setter
124 if (loader.state === 'HAVE_NOTHING' || loader.state === 'HAVE_MASTER') {
125 throw new Error('Cannot switch media playlist from ' + loader.state);
126 }
127
128 // find the playlist object if the target playlist has been
129 // specified by URI
130 if (typeof playlist === 'string') {
131 if (!loader.master.playlists[playlist]) {
132 throw new Error('Unknown playlist URI: ' + playlist);
133 }
134 playlist = loader.master.playlists[playlist];
135 }
136
137 if (playlist.uri === media.uri) {
138 // switching to the currently active playlist is a no-op
139 return;
140 }
141
142 loader.state = 'SWITCHING_MEDIA';
143
144 // abort any outstanding playlist refreshes
145 if (request) {
146 request.abort();
147 request = null;
148 }
149
150 // request the new playlist
151 request = xhr({
152 url: resolveUrl(loader.master.uri, playlist.uri),
153 withCredentials: withCredentials
154 }, function(error) {
155 haveMetadata(error, this, playlist.uri);
156 });
157 };
158
159 // live playlist staleness timeout
160 loader.on('mediaupdatetimeout', function() {
161 if (loader.state !== 'HAVE_METADATA') {
162 // only refresh the media playlist if no other activity is going on
163 return;
164 }
165
166 loader.state = 'HAVE_CURRENT_METADATA';
167 request = xhr({
168 url: resolveUrl(loader.master.uri, loader.media().uri),
169 withCredentials: withCredentials
170 }, function(error) {
171 haveMetadata(error, this, loader.media().uri);
172 });
173 });
174
175 // request the specified URL
176 xhr({
177 url: srcUrl,
178 withCredentials: withCredentials
179 }, function(error) {
180 var parser, i;
181
182 if (error) {
183 loader.error = {
184 status: this.status,
185 message: 'HLS playlist request error at URL: ' + srcUrl,
186 code: 2 // MEDIA_ERR_NETWORK
187 };
188 return loader.trigger('error');
189 }
190
191 parser = new videojs.m3u8.Parser();
192 parser.push(this.responseText);
193
194 loader.state = 'HAVE_MASTER';
195
196 parser.manifest.uri = srcUrl;
197
198 // loaded a master playlist
199 if (parser.manifest.playlists) {
200 loader.master = parser.manifest;
201
202 // setup by-URI lookups
203 i = loader.master.playlists.length;
204 while (i--) {
205 loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i];
206 }
207
208 request = xhr({
209 url: resolveUrl(srcUrl, parser.manifest.playlists[0].uri),
210 withCredentials: withCredentials
211 }, function(error) {
212 // pass along the URL specified in the master playlist
213 haveMetadata(error,
214 this,
215 parser.manifest.playlists[0].uri);
216 loader.trigger('loadedmetadata');
217 });
218 return loader.trigger('loadedplaylist');
219 }
220
221 // loaded a media playlist
222 // infer a master playlist if none was previously requested
223 loader.master = {
224 uri: window.location.href,
225 playlists: [{
226 uri: srcUrl
227 }]
228 };
229 loader.master.playlists[srcUrl] = loader.master.playlists[0];
230 haveMetadata(null, this, srcUrl);
231 return loader.trigger('loadedmetadata');
232 });
233 };
234 PlaylistLoader.prototype = new videojs.hls.Stream();
235
236 videojs.hls.PlaylistLoader = PlaylistLoader;
237 })(window, window.videojs);
...@@ -74,6 +74,7 @@ module.exports = function(config) { ...@@ -74,6 +74,7 @@ module.exports = function(config) {
74 '../node_modules/sinon/lib/sinon/util/event.js', 74 '../node_modules/sinon/lib/sinon/util/event.js',
75 '../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js', 75 '../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js',
76 '../node_modules/sinon/lib/sinon/util/xhr_ie.js', 76 '../node_modules/sinon/lib/sinon/util/xhr_ie.js',
77 '../node_modules/sinon/lib/sinon/util/fake_timers.js',
77 '../node_modules/video.js/dist/video-js/video.js', 78 '../node_modules/video.js/dist/video-js/video.js',
78 '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', 79 '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
79 '../test/karma-qunit-shim.js', 80 '../test/karma-qunit-shim.js',
...@@ -85,6 +86,7 @@ module.exports = function(config) { ...@@ -85,6 +86,7 @@ module.exports = function(config) {
85 '../src/segment-parser.js', 86 '../src/segment-parser.js',
86 '../src/stream.js', 87 '../src/stream.js',
87 '../src/m3u8/m3u8-parser.js', 88 '../src/m3u8/m3u8-parser.js',
89 '../src/playlist-loader.js',
88 '../tmp/manifests.js', 90 '../tmp/manifests.js',
89 '../tmp/expected.js', 91 '../tmp/expected.js',
90 'tsSegment-bc.js', 92 'tsSegment-bc.js',
......
...@@ -38,6 +38,7 @@ module.exports = function(config) { ...@@ -38,6 +38,7 @@ module.exports = function(config) {
38 '../node_modules/sinon/lib/sinon/util/event.js', 38 '../node_modules/sinon/lib/sinon/util/event.js',
39 '../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js', 39 '../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js',
40 '../node_modules/sinon/lib/sinon/util/xhr_ie.js', 40 '../node_modules/sinon/lib/sinon/util/xhr_ie.js',
41 '../node_modules/sinon/lib/sinon/util/fake_timers.js',
41 '../node_modules/video.js/dist/video-js/video.js', 42 '../node_modules/video.js/dist/video-js/video.js',
42 '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', 43 '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
43 '../test/karma-qunit-shim.js', 44 '../test/karma-qunit-shim.js',
...@@ -49,6 +50,7 @@ module.exports = function(config) { ...@@ -49,6 +50,7 @@ module.exports = function(config) {
49 '../src/segment-parser.js', 50 '../src/segment-parser.js',
50 '../src/stream.js', 51 '../src/stream.js',
51 '../src/m3u8/m3u8-parser.js', 52 '../src/m3u8/m3u8-parser.js',
53 '../src/playlist-loader.js',
52 '../tmp/manifests.js', 54 '../tmp/manifests.js',
53 '../tmp/expected.js', 55 '../tmp/expected.js',
54 'tsSegment-bc.js', 56 'tsSegment-bc.js',
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
8 <script src="../node_modules/sinon/lib/sinon/util/event.js"></script> 8 <script src="../node_modules/sinon/lib/sinon/util/event.js"></script>
9 <script src="../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js"></script> 9 <script src="../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js"></script>
10 <script src="../node_modules/sinon/lib/sinon/util/xhr_ie.js"></script> 10 <script src="../node_modules/sinon/lib/sinon/util/xhr_ie.js"></script>
11 <script src="../node_modules/sinon/lib/sinon/util/fake_timers.js"></script>
11 12
12 <!-- Load local QUnit. --> 13 <!-- Load local QUnit. -->
13 <link rel="stylesheet" href="../libs/qunit/qunit.css" media="screen"> 14 <link rel="stylesheet" href="../libs/qunit/qunit.css" media="screen">
...@@ -28,6 +29,7 @@ ...@@ -28,6 +29,7 @@
28 <!-- M3U8 --> 29 <!-- M3U8 -->
29 <script src="../src/stream.js"></script> 30 <script src="../src/stream.js"></script>
30 <script src="../src/m3u8/m3u8-parser.js"></script> 31 <script src="../src/m3u8/m3u8-parser.js"></script>
32 <script src="../src/playlist-loader.js"></script>
31 <!-- M3U8 TEST DATA --> 33 <!-- M3U8 TEST DATA -->
32 <script src="../tmp/manifests.js"></script> 34 <script src="../tmp/manifests.js"></script>
33 <script src="../tmp/expected.js"></script> 35 <script src="../tmp/expected.js"></script>
...@@ -51,6 +53,7 @@ ...@@ -51,6 +53,7 @@
51 <script src="exp-golomb_test.js"></script> 53 <script src="exp-golomb_test.js"></script>
52 <script src="flv-tag_test.js"></script> 54 <script src="flv-tag_test.js"></script>
53 <script src="m3u8_test.js"></script> 55 <script src="m3u8_test.js"></script>
56 <script src="playlist-loader_test.js"></script>
54 </head> 57 </head>
55 <body> 58 <body>
56 <div id="qunit"></div> 59 <div id="qunit"></div>
......