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 @@ ...@@ -2,10 +2,10 @@
2 * A state machine that manages the loading, caching, and updating of 2 * A state machine that manages the loading, caching, and updating of
3 * M3U8 playlists. 3 * M3U8 playlists.
4 */ 4 */
5 (function(window) { 5 (function(window, videojs) {
6 'use strict'; 6 'use strict';
7 var 7 var
8 8
9 /* XXX COPIED REMOVE ME */ 9 /* XXX COPIED REMOVE ME */
10 /** 10 /**
11 * Constructs a new URI by interpreting a path relative to another 11 * Constructs a new URI by interpreting a path relative to another
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
15 * @return {string} a URI that is equivalent to composing `base` 15 * @return {string} a URI that is equivalent to composing `base`
16 * with `path` 16 * with `path`
17 * @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue 17 * @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue
18 */ 18 */
19 resolveUrl = function(basePath, path) { 19 resolveUrl = function(basePath, path) {
20 // use the base element to get the browser to handle URI resolution 20 // use the base element to get the browser to handle URI resolution
21 var 21 var
...@@ -46,33 +46,209 @@ ...@@ -46,33 +46,209 @@
46 return result; 46 return result;
47 }, 47 },
48 48
49 PlaylistLoader = function(url) { 49 /* XXX COPIED REMOVE ME */
50 var 50 /**
51 loader = this, 51 * Creates and sends an XMLHttpRequest.
52 request; 52 * @param options {string | object} if this argument is a string, it
53 if (!url) { 53 * is intrepreted as a URL and a simple GET request is
54 throw new Error('A non-empty playlist URL is required'); 54 * inititated. If it is an object, it should contain a `url`
55 } 55 * property that indicates the URL to request and optionally a
56 loader.state = 'HAVE_NOTHING'; 56 * `method` which is the type of HTTP request to send.
57 request = new window.XMLHttpRequest(); 57 * @param callback (optional) {function} a function to call when the
58 request.open('GET', url); 58 * request completes. If the request was not successful, the first
59 request.onreadystatechange = function() { 59 * argument will be falsey.
60 var parser = new videojs.m3u8.Parser(); 60 * @return {object} the XMLHttpRequest that was initiated.
61 parser.push(this.responseText); 61 */
62 62 xhr = function(url, callback) {
63 if (parser.manifest.playlists) { 63 var
64 loader.master = parser.manifest; 64 options = {
65 } else { 65 method: 'GET'
66 },
67 request;
68
69 if (typeof callback !== 'function') {
70 callback = function() {};
71 }
72
73 if (typeof url === 'object') {
74 options = videojs.util.mergeOptions(options, url);
75 url = options.url;
76 }
77
78 request = new window.XMLHttpRequest();
79 request.open(options.method, url);
80
81 if (options.responseType) {
82 request.responseType = options.responseType;
83 }
84 if (options.withCredentials) {
85 request.withCredentials = true;
86 }
87
88 request.onreadystatechange = function() {
89 // wait until the request completes
90 if (this.readyState !== 4) {
91 return;
92 }
93
94 // request error
95 if (this.status >= 400 || this.status === 0) {
96 return callback.call(this, true, url);
97 }
98
99 return callback.call(this, false, url);
100 };
101 request.send(null);
102 return request;
103 },
104
105 /**
106 * Returns a new master playlist that is the result of merging an
107 * updated media playlist into the original version. If the
108 * updated media playlist does not match any of the playlist
109 * entries in the original master playlist, null is returned.
110 * @param master {object} a parsed master M3U8 object
111 * @param media {object} a parsed media M3U8 object
112 * @return {object} a new object that represents the original
113 * master playlist with the updated media playlist merged in, or
114 * null if the merge produced no change.
115 */
116 updateMaster = function(master, media) {
117 var
118 changed = false,
119 result = videojs.util.mergeOptions(master, {}),
120 i,
121 playlist;
122
123 i = master.playlists.length;
124 while (i--) {
125 playlist = result.playlists[i];
126 if (playlist.uri === media.uri) {
127 // consider the playlist unchanged if the number of segments
128 // are equal and the media sequence number is unchanged
129 if (playlist.segments &&
130 media.segments &&
131 playlist.segments.length === media.segments.length &&
132 playlist.mediaSequence === media.mediaSequence) {
133 continue;
134 }
135
136 result.playlists[i] = videojs.util.mergeOptions(playlist, media);
137 changed = true;
138 }
139 }
140 return changed ? result : null;
141 },
142
143 PlaylistLoader = function(srcUrl) {
144 var
145 loader = this,
146 request,
147
148 haveMetadata = function(error, url) {
149 var parser, refreshDelay, update;
150 if (error) {
151 loader.error = {
152 status: this.status,
153 message: 'HLS playlist request error at URL: ' + url,
154 code: (this.status >= 500) ? 4 : 2
155 };
156 return loader.trigger('error');
157 }
158
159 loader.state = 'HAVE_METADATA';
160
161 parser = new videojs.m3u8.Parser();
162 parser.push(this.responseText);
163 parser.manifest.uri = url;
164
165 // merge this playlist into the master
166 update = updateMaster(loader.master, parser.manifest);
167 refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
168 if (update) {
169 loader.master = update;
170 loader.media = parser.manifest;
171 } else {
172 // if the playlist is unchanged since the last reload,
173 // try again after half the target duration
174 refreshDelay /= 2;
175 }
176
177 // refresh live playlists after a target duration passes
178 if (!loader.media.endList) {
179 window.setTimeout(function() {
180 loader.trigger('mediaupdatetimeout');
181 }, refreshDelay);
182 }
183 };
184
185 PlaylistLoader.prototype.init.call(this);
186
187 if (!srcUrl) {
188 throw new Error('A non-empty playlist URL is required');
189 }
190
191 loader.state = 'HAVE_NOTHING';
192
193 // live playlist staleness timeout
194 loader.on('mediaupdatetimeout', function() {
195 if (loader.state !== 'HAVE_METADATA') {
196 // only refresh the media playlist if no other activity is going on
197 return;
198 }
199
200 loader.state = 'HAVE_CURRENT_METADATA';
201 request = xhr(resolveUrl(loader.master.uri, loader.media.uri),
202 function(error) {
203 haveMetadata.call(this, error, loader.media.uri);
204 });
205 });
206
207 // request the specified URL
208 xhr(srcUrl, function(error) {
209 var parser;
210
211 if (error) {
212 loader.error = {
213 status: this.status,
214 message: 'HLS playlist request error at URL: ' + srcUrl,
215 code: (this.status >= 500) ? 4 : 2
216 };
217 return loader.trigger('error');
218 }
219
220 parser = new videojs.m3u8.Parser();
221 parser.push(this.responseText);
222
223 loader.state = 'HAVE_MASTER';
224
225 parser.manifest.uri = srcUrl;
226
227 // loaded a master playlist
228 if (parser.manifest.playlists) {
229 loader.master = parser.manifest;
230 request = xhr(resolveUrl(srcUrl, parser.manifest.playlists[0].uri),
231 function(error) {
232 // pass along the URL specified in the master playlist
233 haveMetadata.call(this,
234 error,
235 parser.manifest.playlists[0].uri);
236 });
237 return loader.trigger('loadedplaylist');
238 }
239
240 // loaded a media playlist
66 // infer a master playlist if none was previously requested 241 // infer a master playlist if none was previously requested
67 loader.master = { 242 loader.master = {
68 playlists: [parser.manifest] 243 uri: window.location.href,
244 playlists: [{
245 uri: srcUrl
246 }]
69 }; 247 };
70 } 248 return haveMetadata.call(this, null, srcUrl);
71 loader.state = 'HAVE_MASTER'; 249 });
72 return;
73 }; 250 };
74 request.send(null); 251 PlaylistLoader.prototype = new videojs.hls.Stream();
75 };
76 252
77 window.videojs.hls.PlaylistLoader = PlaylistLoader; 253 videojs.hls.PlaylistLoader = PlaylistLoader;
78 })(window); 254 })(window, window.videojs);
......
...@@ -104,6 +104,9 @@ var ...@@ -104,6 +104,9 @@ var
104 * inititated. If it is an object, it should contain a `url` 104 * inititated. If it is an object, it should contain a `url`
105 * property that indicates the URL to request and optionally a 105 * property that indicates the URL to request and optionally a
106 * `method` which is the type of HTTP request to send. 106 * `method` which is the type of HTTP request to send.
107 * @param callback (optional) {function} a function to call when the
108 * request completes. If the request was not successful, the first
109 * argument will be falsey.
107 * @return {object} the XMLHttpRequest that was initiated. 110 * @return {object} the XMLHttpRequest that was initiated.
108 */ 111 */
109 xhr = function(url, callback) { 112 xhr = function(url, callback) {
......
1 (function(window) { 1 (function(window) {
2 'use strict'; 2 'use strict';
3 var 3 var
4 oldXhr, 4 sinonXhr,
5 clock,
5 requests, 6 requests,
6 videojs = window.videojs; 7 videojs = window.videojs,
8
9 // Attempts to produce an absolute URL to a given relative path
10 // based on window.location.href
11 urlTo = function(path) {
12 return window.location.href
13 .split('/')
14 .slice(0, -1)
15 .concat([path])
16 .join('/');
17 };
7 18
8 module('Playlist Loader', { 19 module('Playlist Loader', {
9 setup: function() { 20 setup: function() {
10 oldXhr = window.XMLHttpRequest; 21 // fake XHRs
22 sinonXhr = sinon.useFakeXMLHttpRequest();
11 requests = []; 23 requests = [];
12 24 sinonXhr.onCreate = function(xhr) {
13 window.XMLHttpRequest = function() { 25 requests.push(xhr);
14 this.open = function(method, url) {
15 this.method = method;
16 this.url = url;
17 };
18 this.send = function() {
19 requests.push(this);
20 };
21 this.respond = function(response) {
22 this.responseText = response;
23 this.readyState = 4;
24 this.onreadystatechange();
25 };
26 };
27 this.send = function() {
28 }; 26 };
27
28 // fake timers
29 clock = sinon.useFakeTimers();
29 }, 30 },
30 teardown: function() { 31 teardown: function() {
31 window.XMLHttpRequest = oldXhr; 32 sinonXhr.restore();
33 clock.restore();
32 } 34 }
33 }); 35 });
34 36
...@@ -47,14 +49,15 @@ ...@@ -47,14 +49,15 @@
47 }); 49 });
48 50
49 test('requests the initial playlist immediately', function() { 51 test('requests the initial playlist immediately', function() {
50 var loader = new videojs.hls.PlaylistLoader('master.m3u8'); 52 new videojs.hls.PlaylistLoader('master.m3u8');
51 strictEqual(requests.length, 1, 'made a request'); 53 strictEqual(requests.length, 1, 'made a request');
52 strictEqual(requests[0].url, 'master.m3u8', 'requested the initial playlist'); 54 strictEqual(requests[0].url, 'master.m3u8', 'requested the initial playlist');
53 }); 55 });
54 56
55 test('moves to HAVE_MASTER after loading a master playlist', function() { 57 test('moves to HAVE_MASTER after loading a master playlist', function() {
56 var loader = new videojs.hls.PlaylistLoader('master.m3u8'); 58 var loader = new videojs.hls.PlaylistLoader('master.m3u8');
57 requests.pop().respond('#EXTM3U\n' + 59 requests.pop().respond(200, null,
60 '#EXTM3U\n' +
58 '#EXT-X-STREAM-INF:\n' + 61 '#EXT-X-STREAM-INF:\n' +
59 'media.m3u8\n'); 62 'media.m3u8\n');
60 ok(loader.master, 'the master playlist is available'); 63 ok(loader.master, 'the master playlist is available');
...@@ -63,20 +66,23 @@ ...@@ -63,20 +66,23 @@
63 66
64 test('jumps to HAVE_METADATA when initialized with a media playlist', function() { 67 test('jumps to HAVE_METADATA when initialized with a media playlist', function() {
65 var loader = new videojs.hls.PlaylistLoader('media.m3u8'); 68 var loader = new videojs.hls.PlaylistLoader('media.m3u8');
66 requests.pop().respond('#EXTM3U\n' + 69 requests.pop().respond(200, null,
67 '#EXTINF:10,\n' + 70 '#EXTM3U\n' +
71 '#EXTINF:10,\n' +
68 '0.ts\n' + 72 '0.ts\n' +
69 '#EXT-X-ENDLIST\n'); 73 '#EXT-X-ENDLIST\n');
70 ok(loader.master, 'infers a master playlist'); 74 ok(loader.master, 'infers a master playlist');
71 ok(loader.media, 'sets the media playlist'); 75 ok(loader.media, 'sets the media playlist');
76 ok(loader.media.uri, 'sets the media playlist URI');
72 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); 77 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
73 strictEqual(0, requests.length, 'no more requests are made'); 78 strictEqual(0, requests.length, 'no more requests are made');
74 }); 79 });
75 80
76 test('jumps to HAVE_METADATA when initialized with a live media playlist', function() { 81 test('jumps to HAVE_METADATA when initialized with a live media playlist', function() {
77 var loader = new videojs.hls.PlaylistLoader('media.m3u8'); 82 var loader = new videojs.hls.PlaylistLoader('media.m3u8');
78 requests.pop().respond('#EXTM3U\n' + 83 requests.pop().respond(200, null,
79 '#EXTINF:10,\n' + 84 '#EXTM3U\n' +
85 '#EXTINF:10,\n' +
80 '0.ts\n'); 86 '0.ts\n');
81 ok(loader.master, 'infers a master playlist'); 87 ok(loader.master, 'infers a master playlist');
82 ok(loader.media, 'sets the media playlist'); 88 ok(loader.media, 'sets the media playlist');
...@@ -84,18 +90,28 @@ ...@@ -84,18 +90,28 @@
84 }); 90 });
85 91
86 test('moves to HAVE_METADATA after loading a media playlist', function() { 92 test('moves to HAVE_METADATA after loading a media playlist', function() {
87 var loader = new videojs.hls.PlaylistLoader('master.m3u8'); 93 var
88 requests.pop().respond('#EXTM3U\n' + 94 loadedPlaylist = false,
95 loader = new videojs.hls.PlaylistLoader('master.m3u8');
96 loader.on('loadedplaylist', function() {
97 loadedPlaylist = true;
98 });
99 requests.pop().respond(200, null,
100 '#EXTM3U\n' +
89 '#EXT-X-STREAM-INF:\n' + 101 '#EXT-X-STREAM-INF:\n' +
90 'media.m3u8\n' + 102 'media.m3u8\n' +
91 'alt.m3u8\n'); 103 'alt.m3u8\n');
104 ok(loadedPlaylist, 'loadedplaylist fired');
92 strictEqual(requests.length, 1, 'requests the media playlist'); 105 strictEqual(requests.length, 1, 'requests the media playlist');
93 strictEqual(requests[0].method, 'GET', 'GETs the media playlist'); 106 strictEqual(requests[0].method, 'GET', 'GETs the media playlist');
94 strictEqual(requests[0].url, 'media.m3u8', 'requests the first playlist'); 107 strictEqual(requests[0].url,
108 urlTo('media.m3u8'),
109 'requests the first playlist');
95 110
96 requests.pop().response('#EXTM3U\n' + 111 requests.pop().respond(200, null,
97 '#EXTINF:10,\n' + 112 '#EXTM3U\n' +
98 '0.ts\n'); 113 '#EXTINF:10,\n' +
114 '0.ts\n');
99 ok(loader.master, 'sets the master playlist'); 115 ok(loader.master, 'sets the master playlist');
100 ok(loader.media, 'sets the media playlist'); 116 ok(loader.media, 'sets the media playlist');
101 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); 117 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
...@@ -103,24 +119,126 @@ ...@@ -103,24 +119,126 @@
103 119
104 test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() { 120 test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
105 var loader = new videojs.hls.PlaylistLoader('live.m3u8'); 121 var loader = new videojs.hls.PlaylistLoader('live.m3u8');
106 requests.pop().response('#EXTM3U\n' + 122 requests.pop().respond(200, null,
107 '#EXTINF:10,\n' + 123 '#EXTM3U\n' +
108 '0.ts\n'); 124 '#EXTINF:10,\n' +
109 loader.refreshMedia(); 125 '0.ts\n');
126 clock.tick(10 * 1000); // 10s, one target duration
110 strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct'); 127 strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct');
111 strictEqual(requests.length, 1, 'requested playlist'); 128 strictEqual(requests.length, 1, 'requested playlist');
112 strictEqual(requests[0].url, 'live.m3u8', 'refreshes the media playlist'); 129 strictEqual(requests[0].url,
130 urlTo('live.m3u8'),
131 'refreshes the media playlist');
113 }); 132 });
114 133
115 test('returns to HAVE_METADATA after refreshing the playlist', function() { 134 test('returns to HAVE_METADATA after refreshing the playlist', function() {
116 var loader = new videojs.hls.PlaylistLoader('live.m3u8'); 135 var loader = new videojs.hls.PlaylistLoader('live.m3u8');
117 requests.pop().response('#EXTM3U\n' + 136 requests.pop().respond(200, null,
118 '#EXTINF:10,\n' + 137 '#EXTM3U\n' +
119 '0.ts\n'); 138 '#EXTINF:10,\n' +
120 loader.refreshMedia(); 139 '0.ts\n');
121 requests.pop().response('#EXTM3U\n' + 140 clock.tick(10 * 1000); // 10s, one target duration
122 '#EXTINF:10,\n' + 141 requests.pop().respond(200, null,
123 '1.ts\n'); 142 '#EXTM3U\n' +
124 strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct'); 143 '#EXTINF:10,\n' +
144 '1.ts\n');
145 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
146 });
147
148 test('emits an error when an initial playlist request fails', function() {
149 var
150 errors = [],
151 loader = new videojs.hls.PlaylistLoader('master.m3u8');
152
153 loader.on('error', function() {
154 errors.push(loader.error);
155 });
156 requests.pop().respond(500);
157
158 strictEqual(errors.length, 1, 'emitted one error');
159 strictEqual(errors[0].status, 500, 'http status is captured');
160 });
161
162 test('errors when an initial media playlist request fails', function() {
163 var
164 errors = [],
165 loader = new videojs.hls.PlaylistLoader('master.m3u8');
166
167 loader.on('error', function() {
168 errors.push(loader.error);
169 });
170 requests.pop().respond(200, null,
171 '#EXTM3U\n' +
172 '#EXT-X-STREAM-INF:\n' +
173 'media.m3u8\n');
174
175 strictEqual(errors.length, 0, 'emitted no errors');
176
177 requests.pop().respond(500);
178
179 strictEqual(errors.length, 1, 'emitted one error');
180 strictEqual(errors[0].status, 500, 'http status is captured');
181 });
182
183
184 // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4
185 test('halves the refresh timeout if a playlist is unchanged' +
186 'since the last reload', function() {
187 new videojs.hls.PlaylistLoader('live.m3u8');
188 requests.pop().respond(200, null,
189 '#EXTM3U\n' +
190 '#EXT-X-MEDIA-SEQUENCE:0\n' +
191 '#EXTINF:10,\n' +
192 '0.ts\n');
193 clock.tick(10 * 1000); // trigger a refresh
194 requests.pop().respond(200, null,
195 '#EXTM3U\n' +
196 '#EXT-X-MEDIA-SEQUENCE:0\n' +
197 '#EXTINF:10,\n' +
198 '0.ts\n');
199 clock.tick(5 * 1000); // half the default target-duration
200
201 strictEqual(requests.length, 1, 'sent a request');
202 strictEqual(requests[0].url,
203 urlTo('live.m3u8'),
204 'requested the media playlist');
205 });
206
207 test('media-sequence updates are considered a playlist change', function() {
208 new videojs.hls.PlaylistLoader('live.m3u8');
209 requests.pop().respond(200, null,
210 '#EXTM3U\n' +
211 '#EXT-X-MEDIA-SEQUENCE:0\n' +
212 '#EXTINF:10,\n' +
213 '0.ts\n');
214 clock.tick(10 * 1000); // trigger a refresh
215 requests.pop().respond(200, null,
216 '#EXTM3U\n' +
217 '#EXT-X-MEDIA-SEQUENCE:1\n' +
218 '#EXTINF:10,\n' +
219 '0.ts\n');
220 clock.tick(5 * 1000); // half the default target-duration
221
222 strictEqual(requests.length, 0, 'no request is sent');
223 });
224
225 test('emits an error if a media refresh fails', function() {
226 var
227 errors = 0,
228 loader = new videojs.hls.PlaylistLoader('live.m3u8');
229
230 loader.on('error', function() {
231 errors++;
232 });
233 requests.pop().respond(200, null,
234 '#EXTM3U\n' +
235 '#EXT-X-MEDIA-SEQUENCE:0\n' +
236 '#EXTINF:10,\n' +
237 '0.ts\n');
238 clock.tick(10 * 1000); // trigger a refresh
239 requests.pop().respond(500);
240
241 strictEqual(errors, 1, 'emitted an error');
242 strictEqual(loader.error.status, 500, 'captured the status code');
125 }); 243 });
126 })(window); 244 })(window);
......
...@@ -6,8 +6,9 @@ ...@@ -6,8 +6,9 @@
6 <!-- Load sinon server for fakeXHR --> 6 <!-- Load sinon server for fakeXHR -->
7 <script src="../node_modules/sinon/lib/sinon.js"></script> 7 <script src="../node_modules/sinon/lib/sinon.js"></script>
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/xhr_ie.js"></script>
10 <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>
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">
......
...@@ -115,7 +115,7 @@ module('HLS', { ...@@ -115,7 +115,7 @@ module('HLS', {
115 oldSegmentParser = videojs.hls.SegmentParser; 115 oldSegmentParser = videojs.hls.SegmentParser;
116 oldSetTimeout = window.setTimeout; 116 oldSetTimeout = window.setTimeout;
117 117
118 // make XHRs synchronous 118 // fake XHRs
119 xhr = sinon.useFakeXMLHttpRequest(); 119 xhr = sinon.useFakeXMLHttpRequest();
120 requests = []; 120 requests = [];
121 xhr.onCreate = function(xhr) { 121 xhr.onCreate = function(xhr) {
......