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);
...@@ -32,8 +32,6 @@ videojs.hls = { ...@@ -32,8 +32,6 @@ videojs.hls = {
32 32
33 var 33 var
34 34
35 settings,
36
37 // the desired length of video to maintain in the buffer, in seconds 35 // the desired length of video to maintain in the buffer, in seconds
38 goalBufferLength = 5, 36 goalBufferLength = 5,
39 37
...@@ -104,9 +102,12 @@ var ...@@ -104,9 +102,12 @@ var
104 * inititated. If it is an object, it should contain a `url` 102 * inititated. If it is an object, it should contain a `url`
105 * property that indicates the URL to request and optionally a 103 * property that indicates the URL to request and optionally a
106 * `method` which is the type of HTTP request to send. 104 * `method` which is the type of HTTP request to send.
105 * @param callback (optional) {function} a function to call when the
106 * request completes. If the request was not successful, the first
107 * argument will be falsey.
107 * @return {object} the XMLHttpRequest that was initiated. 108 * @return {object} the XMLHttpRequest that was initiated.
108 */ 109 */
109 xhr = function(url, callback) { 110 xhr = videojs.hls.xhr = function(url, callback) {
110 var 111 var
111 options = { 112 options = {
112 method: 'GET' 113 method: 'GET'
...@@ -128,7 +129,7 @@ var ...@@ -128,7 +129,7 @@ var
128 if (options.responseType) { 129 if (options.responseType) {
129 request.responseType = options.responseType; 130 request.responseType = options.responseType;
130 } 131 }
131 if (settings.withCredentials) { 132 if (options.withCredentials) {
132 request.withCredentials = true; 133 request.withCredentials = true;
133 } 134 }
134 135
...@@ -193,15 +194,20 @@ var ...@@ -193,15 +194,20 @@ var
193 */ 194 */
194 translateMediaIndex = function(mediaIndex, original, update) { 195 translateMediaIndex = function(mediaIndex, original, update) {
195 var 196 var
196 i = update.segments.length, 197 i,
197 originalSegment; 198 originalSegment;
198 199
199 // no segments have been loaded from the original playlist 200 // no segments have been loaded from the original playlist
200 if (mediaIndex === 0) { 201 if (mediaIndex === 0) {
201 return 0; 202 return 0;
202 } 203 }
204 if (!(update && update.segments)) {
205 // let the media index be zero when there are no segments defined
206 return 0;
207 }
203 208
204 // try to sync based on URI 209 // try to sync based on URI
210 i = update.segments.length;
205 originalSegment = original.segments[mediaIndex - 1]; 211 originalSegment = original.segments[mediaIndex - 1];
206 while (i--) { 212 while (i--) {
207 if (originalSegment.uri === update.segments[i].uri) { 213 if (originalSegment.uri === update.segments[i].uri) {
...@@ -250,7 +256,7 @@ var ...@@ -250,7 +256,7 @@ var
250 * with `path` 256 * with `path`
251 * @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue 257 * @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue
252 */ 258 */
253 resolveUrl = function(basePath, path) { 259 resolveUrl = videojs.hls.resolveUrl = function(basePath, path) {
254 // use the base element to get the browser to handle URI resolution 260 // use the base element to get the browser to handle URI resolution
255 var 261 var
256 oldBase = document.querySelector('base'), 262 oldBase = document.querySelector('base'),
...@@ -291,11 +297,9 @@ var ...@@ -291,11 +297,9 @@ var
291 player = this, 297 player = this,
292 srcUrl, 298 srcUrl,
293 299
294 playlistXhr,
295 segmentXhr, 300 segmentXhr,
296 loadedPlaylist, 301 settings,
297 fillBuffer, 302 fillBuffer,
298 updateCurrentPlaylist,
299 updateDuration; 303 updateDuration;
300 304
301 // if the video element supports HLS natively, do nothing 305 // if the video element supports HLS natively, do nothing
...@@ -374,7 +378,8 @@ var ...@@ -374,7 +378,8 @@ var
374 378
375 player.on('seeking', function() { 379 player.on('seeking', function() {
376 var currentTime = player.currentTime(); 380 var currentTime = player.currentTime();
377 player.hls.mediaIndex = getMediaIndexByTime(player.hls.media, currentTime); 381 player.hls.mediaIndex = getMediaIndexByTime(player.hls.playlists.media(),
382 currentTime);
378 383
379 // abort any segments still being decoded 384 // abort any segments still being decoded
380 player.hls.sourceBuffer.abort(); 385 player.hls.sourceBuffer.abort();
...@@ -405,36 +410,6 @@ var ...@@ -405,36 +410,6 @@ var
405 }; 410 };
406 411
407 /** 412 /**
408 * Determine whether the current media playlist should be changed
409 * and trigger a switch if necessary. If a sufficiently fresh
410 * version of the target playlist is available, the switch will take
411 * effect immediately. Otherwise, the target playlist will be
412 * refreshed.
413 */
414 updateCurrentPlaylist = function() {
415 var playlist, mediaSequence;
416 playlist = player.hls.selectPlaylist();
417 mediaSequence = player.hls.mediaIndex + (player.hls.media.mediaSequence || 0);
418 if (!playlist.segments ||
419 mediaSequence < (playlist.mediaSequence || 0) ||
420 mediaSequence > (playlist.mediaSequence || 0) + playlist.segments.length) {
421
422 if (playlistXhr) {
423 playlistXhr.abort();
424 }
425 playlistXhr = xhr(resolveUrl(srcUrl, playlist.uri), loadedPlaylist);
426 } else {
427 player.hls.mediaIndex =
428 translateMediaIndex(player.hls.mediaIndex,
429 player.hls.media,
430 playlist);
431 player.hls.media = playlist;
432
433 updateDuration(player.hls.media);
434 }
435 };
436
437 /**
438 * Chooses the appropriate media playlist based on the current 413 * Chooses the appropriate media playlist based on the current
439 * bandwidth estimate and the player size. 414 * bandwidth estimate and the player size.
440 * @return the highest bitrate playlist less than the currently detected 415 * @return the highest bitrate playlist less than the currently detected
...@@ -443,7 +418,7 @@ var ...@@ -443,7 +418,7 @@ var
443 player.hls.selectPlaylist = function () { 418 player.hls.selectPlaylist = function () {
444 var 419 var
445 effectiveBitrate, 420 effectiveBitrate,
446 sortedPlaylists = player.hls.master.playlists.slice(), 421 sortedPlaylists = player.hls.playlists.master.playlists.slice(),
447 bandwidthPlaylists = [], 422 bandwidthPlaylists = [],
448 i = sortedPlaylists.length, 423 i = sortedPlaylists.length,
449 variant, 424 variant,
...@@ -508,97 +483,6 @@ var ...@@ -508,97 +483,6 @@ var
508 }; 483 };
509 484
510 /** 485 /**
511 * Callback that is invoked when a media playlist finishes
512 * downloading. Triggers `loadedmanifest` once for each playlist
513 * that is downloaded and `loadedmetadata` after at least one
514 * media playlist has been parsed.
515 *
516 * @param error {*} truthy if the request was not successful
517 * @param url {string} a URL to the M3U8 file to process
518 */
519 loadedPlaylist = function(error, url) {
520 var i, parser, playlist, playlistUri, refreshDelay;
521
522 // clear the current playlist XHR
523 playlistXhr = null;
524
525 if (error) {
526 player.hls.error = {
527 status: this.status,
528 message: 'HLS playlist request error at URL: ' + url,
529 code: (this.status >= 500) ? 4 : 2
530 };
531 return player.trigger('error');
532 }
533
534 parser = new videojs.m3u8.Parser();
535 parser.push(this.responseText);
536
537 // merge this playlist into the master
538 i = player.hls.master.playlists.length;
539 refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
540 while (i--) {
541 playlist = player.hls.master.playlists[i];
542 playlistUri = resolveUrl(srcUrl, playlist.uri);
543 if (playlistUri === url) {
544 // if the playlist is unchanged since the last reload,
545 // try again after half the target duration
546 // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4
547 if (playlist.segments &&
548 playlist.segments.length === parser.manifest.segments.length) {
549 refreshDelay /= 2;
550 }
551
552 player.hls.master.playlists[i] =
553 videojs.util.mergeOptions(playlist, parser.manifest);
554
555 if (playlist !== player.hls.media) {
556 continue;
557 }
558
559 // determine the new mediaIndex if we're updating the
560 // current media playlist
561 player.hls.mediaIndex =
562 translateMediaIndex(player.hls.mediaIndex,
563 playlist,
564 parser.manifest);
565 player.hls.media = parser.manifest;
566 }
567 }
568
569 // check the playlist for updates if EXT-X-ENDLIST isn't present
570 if (!parser.manifest.endList) {
571 window.setTimeout(function() {
572 if (!playlistXhr &&
573 resolveUrl(srcUrl, player.hls.media.uri) === url) {
574 playlistXhr = xhr(url, loadedPlaylist);
575 }
576 }, refreshDelay);
577 }
578
579 // always start playback with the default rendition
580 if (!player.hls.media) {
581 player.hls.media = player.hls.master.playlists[0];
582
583 // update the duration
584 updateDuration(parser.manifest);
585
586 // periodicaly check if the buffer needs to be refilled
587 player.on('timeupdate', fillBuffer);
588
589 player.trigger('loadedmanifest');
590 player.trigger('loadedmetadata');
591 fillBuffer();
592 return;
593 }
594
595 // select a playlist and download its metadata if necessary
596 updateCurrentPlaylist();
597
598 player.trigger('loadedmanifest');
599 };
600
601 /**
602 * Determines whether there is enough video data currently in the buffer 486 * Determines whether there is enough video data currently in the buffer
603 * and downloads a new segment if the buffered time is less than the goal. 487 * and downloads a new segment if the buffered time is less than the goal.
604 * @param offset (optional) {number} the offset into the downloaded segment 488 * @param offset (optional) {number} the offset into the downloaded segment
...@@ -618,12 +502,12 @@ var ...@@ -618,12 +502,12 @@ var
618 } 502 }
619 503
620 // if no segments are available, do nothing 504 // if no segments are available, do nothing
621 if (!player.hls.media.segments) { 505 if (!player.hls.playlists.media().segments) {
622 return; 506 return;
623 } 507 }
624 508
625 // if the video has finished downloading, stop trying to buffer 509 // if the video has finished downloading, stop trying to buffer
626 segment = player.hls.media.segments[player.hls.mediaIndex]; 510 segment = player.hls.playlists.media().segments[player.hls.mediaIndex];
627 if (!segment) { 511 if (!segment) {
628 return; 512 return;
629 } 513 }
...@@ -639,10 +523,10 @@ var ...@@ -639,10 +523,10 @@ var
639 } 523 }
640 524
641 // resolve the segment URL relative to the playlist 525 // resolve the segment URL relative to the playlist
642 if (player.hls.media.uri === srcUrl) { 526 if (player.hls.playlists.media().uri === srcUrl) {
643 segmentUri = resolveUrl(srcUrl, segment.uri); 527 segmentUri = resolveUrl(srcUrl, segment.uri);
644 } else { 528 } else {
645 segmentUri = resolveUrl(resolveUrl(srcUrl, player.hls.media.uri || ''), 529 segmentUri = resolveUrl(resolveUrl(srcUrl, player.hls.playlists.media().uri || ''),
646 segment.uri); 530 segment.uri);
647 } 531 }
648 532
...@@ -651,7 +535,8 @@ var ...@@ -651,7 +535,8 @@ var
651 // request the next segment 535 // request the next segment
652 segmentXhr = xhr({ 536 segmentXhr = xhr({
653 url: segmentUri, 537 url: segmentUri,
654 responseType: 'arraybuffer' 538 responseType: 'arraybuffer',
539 withCredentials: settings.withCredentials
655 }, function(error, url) { 540 }, function(error, url) {
656 // the segment request is no longer outstanding 541 // the segment request is no longer outstanding
657 segmentXhr = null; 542 segmentXhr = null;
...@@ -702,43 +587,55 @@ var ...@@ -702,43 +587,55 @@ var
702 587
703 player.hls.mediaIndex++; 588 player.hls.mediaIndex++;
704 589
705 if (player.hls.mediaIndex === player.hls.media.segments.length) { 590 if (player.hls.mediaIndex === player.hls.playlists.media().segments.length) {
706 mediaSource.endOfStream(); 591 mediaSource.endOfStream();
707 } 592 }
708 593
709 // figure out what stream the next segment should be downloaded from 594 // figure out what stream the next segment should be downloaded from
710 // with the updated bandwidth information 595 // with the updated bandwidth information
711 updateCurrentPlaylist(); 596 player.hls.playlists.media(player.hls.selectPlaylist());
712 }); 597 });
713 }; 598 };
714 599
715 // load the MediaSource into the player 600 // load the MediaSource into the player
716 mediaSource.addEventListener('sourceopen', function() { 601 mediaSource.addEventListener('sourceopen', function() {
717 // construct the video data buffer and set the appropriate MIME type 602 // construct the video data buffer and set the appropriate MIME type
718 var sourceBuffer = mediaSource.addSourceBuffer('video/flv; codecs="vp6,aac"'); 603 var
604 sourceBuffer = mediaSource.addSourceBuffer('video/flv; codecs="vp6,aac"'),
605 oldMediaPlaylist;
606
719 player.hls.sourceBuffer = sourceBuffer; 607 player.hls.sourceBuffer = sourceBuffer;
720 sourceBuffer.appendBuffer(segmentParser.getFlvHeader()); 608 sourceBuffer.appendBuffer(segmentParser.getFlvHeader());
721 609
722 player.hls.mediaIndex = 0; 610 player.hls.mediaIndex = 0;
723 xhr(srcUrl, function(error, url) { 611 player.hls.playlists =
724 var uri, parser = new videojs.m3u8.Parser(); 612 new videojs.hls.PlaylistLoader(srcUrl, settings.withCredentials);
725 parser.push(this.responseText); 613 player.hls.playlists.on('loadedmetadata', function() {
726 614 oldMediaPlaylist = player.hls.playlists.media();
727 // master playlists 615
728 if (parser.manifest.playlists) { 616 // periodicaly check if the buffer needs to be refilled
729 player.hls.master = parser.manifest; 617 fillBuffer();
730 playlistXhr = xhr(resolveUrl(url, parser.manifest.playlists[0].uri), loadedPlaylist); 618 player.on('timeupdate', fillBuffer);
731 return player.trigger('loadedmanifest'); 619
732 } else { 620 player.trigger('loadedmetadata');
733 // infer a master playlist if a media playlist is loaded directly 621 });
734 uri = resolveUrl(window.location.href, url); 622 player.hls.playlists.on('error', function() {
735 player.hls.master = { 623 player.hls.error = player.hls.playlists.error;
736 playlists: [{ 624 player.trigger('error');
737 uri: uri 625 });
738 }] 626 player.hls.playlists.on('loadedplaylist', function() {
739 }; 627 var updatedPlaylist = player.hls.playlists.media();
740 loadedPlaylist.call(this, error, uri); 628
629 if (!updatedPlaylist) {
630 // do nothing before an initial media playlist has been activated
631 return;
741 } 632 }
633
634 updateDuration(player.hls.playlists.media());
635 player.hls.mediaIndex = translateMediaIndex(player.hls.mediaIndex,
636 oldMediaPlaylist,
637 updatedPlaylist);
638 oldMediaPlaylist = updatedPlaylist;
742 }); 639 });
743 }); 640 });
744 player.src([{ 641 player.src([{
......
...@@ -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',
......
1 (function(window) {
2 'use strict';
3 var
4 sinonXhr,
5 clock,
6 requests,
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 };
18
19 module('Playlist Loader', {
20 setup: function() {
21 // fake XHRs
22 sinonXhr = sinon.useFakeXMLHttpRequest();
23 requests = [];
24 sinonXhr.onCreate = function(xhr) {
25 requests.push(xhr);
26 };
27
28 // fake timers
29 clock = sinon.useFakeTimers();
30 },
31 teardown: function() {
32 sinonXhr.restore();
33 clock.restore();
34 }
35 });
36
37 test('throws if the playlist url is empty or undefined', function() {
38 throws(function() {
39 videojs.hls.PlaylistLoader();
40 }, 'requires an argument');
41 throws(function() {
42 videojs.hls.PlaylistLoader('');
43 }, 'does not accept the empty string');
44 });
45
46 test('starts without any metadata', function() {
47 var loader = new videojs.hls.PlaylistLoader('master.m3u8');
48 strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet');
49 });
50
51 test('requests the initial playlist immediately', function() {
52 new videojs.hls.PlaylistLoader('master.m3u8');
53 strictEqual(requests.length, 1, 'made a request');
54 strictEqual(requests[0].url, 'master.m3u8', 'requested the initial playlist');
55 });
56
57 test('moves to HAVE_MASTER after loading a master playlist', function() {
58 var loader = new videojs.hls.PlaylistLoader('master.m3u8');
59 requests.pop().respond(200, null,
60 '#EXTM3U\n' +
61 '#EXT-X-STREAM-INF:\n' +
62 'media.m3u8\n');
63 ok(loader.master, 'the master playlist is available');
64 strictEqual(loader.state, 'HAVE_MASTER', 'the state is correct');
65 });
66
67 test('jumps to HAVE_METADATA when initialized with a media playlist', function() {
68 var
69 loadedmetadatas = 0,
70 loader = new videojs.hls.PlaylistLoader('media.m3u8');
71 loader.on('loadedmetadata', function() {
72 loadedmetadatas++;
73 });
74 requests.pop().respond(200, null,
75 '#EXTM3U\n' +
76 '#EXTINF:10,\n' +
77 '0.ts\n' +
78 '#EXT-X-ENDLIST\n');
79 ok(loader.master, 'infers a master playlist');
80 ok(loader.media(), 'sets the media playlist');
81 ok(loader.media().uri, 'sets the media playlist URI');
82 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
83 strictEqual(requests.length, 0, 'no more requests are made');
84 strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata');
85 });
86
87 test('jumps to HAVE_METADATA when initialized with a live media playlist', function() {
88 var loader = new videojs.hls.PlaylistLoader('media.m3u8');
89 requests.pop().respond(200, null,
90 '#EXTM3U\n' +
91 '#EXTINF:10,\n' +
92 '0.ts\n');
93 ok(loader.master, 'infers a master playlist');
94 ok(loader.media(), 'sets the media playlist');
95 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
96 });
97
98 test('moves to HAVE_METADATA after loading a media playlist', function() {
99 var
100 loadedPlaylist = 0,
101 loadedMetadata = 0,
102 loader = new videojs.hls.PlaylistLoader('master.m3u8');
103 loader.on('loadedplaylist', function() {
104 loadedPlaylist++;
105 });
106 loader.on('loadedmetadata', function() {
107 loadedMetadata++;
108 });
109 requests.pop().respond(200, null,
110 '#EXTM3U\n' +
111 '#EXT-X-STREAM-INF:\n' +
112 'media.m3u8\n' +
113 'alt.m3u8\n');
114 strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once');
115 strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata');
116 strictEqual(requests.length, 1, 'requests the media playlist');
117 strictEqual(requests[0].method, 'GET', 'GETs the media playlist');
118 strictEqual(requests[0].url,
119 urlTo('media.m3u8'),
120 'requests the first playlist');
121
122 requests.pop().respond(200, null,
123 '#EXTM3U\n' +
124 '#EXTINF:10,\n' +
125 '0.ts\n');
126 ok(loader.master, 'sets the master playlist');
127 ok(loader.media(), 'sets the media playlist');
128 strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice');
129 strictEqual(loadedMetadata, 1, 'fired loadedmetadata once');
130 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
131 });
132
133 test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
134 var loader = new videojs.hls.PlaylistLoader('live.m3u8');
135 requests.pop().respond(200, null,
136 '#EXTM3U\n' +
137 '#EXTINF:10,\n' +
138 '0.ts\n');
139 clock.tick(10 * 1000); // 10s, one target duration
140 strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct');
141 strictEqual(requests.length, 1, 'requested playlist');
142 strictEqual(requests[0].url,
143 urlTo('live.m3u8'),
144 'refreshes the media playlist');
145 });
146
147 test('returns to HAVE_METADATA after refreshing the playlist', function() {
148 var loader = new videojs.hls.PlaylistLoader('live.m3u8');
149 requests.pop().respond(200, null,
150 '#EXTM3U\n' +
151 '#EXTINF:10,\n' +
152 '0.ts\n');
153 clock.tick(10 * 1000); // 10s, one target duration
154 requests.pop().respond(200, null,
155 '#EXTM3U\n' +
156 '#EXTINF:10,\n' +
157 '1.ts\n');
158 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
159 });
160
161 test('emits an error when an initial playlist request fails', function() {
162 var
163 errors = [],
164 loader = new videojs.hls.PlaylistLoader('master.m3u8');
165
166 loader.on('error', function() {
167 errors.push(loader.error);
168 });
169 requests.pop().respond(500);
170
171 strictEqual(errors.length, 1, 'emitted one error');
172 strictEqual(errors[0].status, 500, 'http status is captured');
173 });
174
175 test('errors when an initial media playlist request fails', function() {
176 var
177 errors = [],
178 loader = new videojs.hls.PlaylistLoader('master.m3u8');
179
180 loader.on('error', function() {
181 errors.push(loader.error);
182 });
183 requests.pop().respond(200, null,
184 '#EXTM3U\n' +
185 '#EXT-X-STREAM-INF:\n' +
186 'media.m3u8\n');
187
188 strictEqual(errors.length, 0, 'emitted no errors');
189
190 requests.pop().respond(500);
191
192 strictEqual(errors.length, 1, 'emitted one error');
193 strictEqual(errors[0].status, 500, 'http status is captured');
194 });
195
196
197 // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4
198 test('halves the refresh timeout if a playlist is unchanged' +
199 'since the last reload', function() {
200 new videojs.hls.PlaylistLoader('live.m3u8');
201 requests.pop().respond(200, null,
202 '#EXTM3U\n' +
203 '#EXT-X-MEDIA-SEQUENCE:0\n' +
204 '#EXTINF:10,\n' +
205 '0.ts\n');
206 clock.tick(10 * 1000); // trigger a refresh
207 requests.pop().respond(200, null,
208 '#EXTM3U\n' +
209 '#EXT-X-MEDIA-SEQUENCE:0\n' +
210 '#EXTINF:10,\n' +
211 '0.ts\n');
212 clock.tick(5 * 1000); // half the default target-duration
213
214 strictEqual(requests.length, 1, 'sent a request');
215 strictEqual(requests[0].url,
216 urlTo('live.m3u8'),
217 'requested the media playlist');
218 });
219
220 test('media-sequence updates are considered a playlist change', function() {
221 new videojs.hls.PlaylistLoader('live.m3u8');
222 requests.pop().respond(200, null,
223 '#EXTM3U\n' +
224 '#EXT-X-MEDIA-SEQUENCE:0\n' +
225 '#EXTINF:10,\n' +
226 '0.ts\n');
227 clock.tick(10 * 1000); // trigger a refresh
228 requests.pop().respond(200, null,
229 '#EXTM3U\n' +
230 '#EXT-X-MEDIA-SEQUENCE:1\n' +
231 '#EXTINF:10,\n' +
232 '0.ts\n');
233 clock.tick(5 * 1000); // half the default target-duration
234
235 strictEqual(requests.length, 0, 'no request is sent');
236 });
237
238 test('emits an error if a media refresh fails', function() {
239 var
240 errors = 0,
241 loader = new videojs.hls.PlaylistLoader('live.m3u8');
242
243 loader.on('error', function() {
244 errors++;
245 });
246 requests.pop().respond(200, null,
247 '#EXTM3U\n' +
248 '#EXT-X-MEDIA-SEQUENCE:0\n' +
249 '#EXTINF:10,\n' +
250 '0.ts\n');
251 clock.tick(10 * 1000); // trigger a refresh
252 requests.pop().respond(500);
253
254 strictEqual(errors, 1, 'emitted an error');
255 strictEqual(loader.error.status, 500, 'captured the status code');
256 });
257
258 test('switches media playlists when requested', function() {
259 var loader = new videojs.hls.PlaylistLoader('master.m3u8');
260 requests.pop().respond(200, null,
261 '#EXTM3U\n' +
262 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
263 'low.m3u8\n' +
264 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
265 'high.m3u8\n');
266 requests.pop().respond(200, null,
267 '#EXTM3U\n' +
268 '#EXT-X-MEDIA-SEQUENCE:0\n' +
269 '#EXTINF:10,\n' +
270 'low-0.ts\n');
271
272 loader.media(loader.master.playlists[1]);
273 strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
274
275 requests.pop().respond(200, null,
276 '#EXTM3U\n' +
277 '#EXT-X-MEDIA-SEQUENCE:0\n' +
278 '#EXTINF:10,\n' +
279 'high-0.ts\n');
280 strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
281 strictEqual(loader.media(),
282 loader.master.playlists[1],
283 'updated the active media');
284 });
285
286 test('can switch media playlists based on URI', function() {
287 var loader = new videojs.hls.PlaylistLoader('master.m3u8');
288 requests.pop().respond(200, null,
289 '#EXTM3U\n' +
290 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
291 'low.m3u8\n' +
292 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
293 'high.m3u8\n');
294 requests.pop().respond(200, null,
295 '#EXTM3U\n' +
296 '#EXT-X-MEDIA-SEQUENCE:0\n' +
297 '#EXTINF:10,\n' +
298 'low-0.ts\n');
299
300 loader.media('high.m3u8');
301 strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
302
303 requests.pop().respond(200, null,
304 '#EXTM3U\n' +
305 '#EXT-X-MEDIA-SEQUENCE:0\n' +
306 '#EXTINF:10,\n' +
307 'high-0.ts\n');
308 strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
309 strictEqual(loader.media(),
310 loader.master.playlists[1],
311 'updated the active media');
312 });
313
314 test('aborts in-flight playlist refreshes when switching', function() {
315 var loader = new videojs.hls.PlaylistLoader('master.m3u8');
316 requests.pop().respond(200, null,
317 '#EXTM3U\n' +
318 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
319 'low.m3u8\n' +
320 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
321 'high.m3u8\n');
322 requests.pop().respond(200, null,
323 '#EXTM3U\n' +
324 '#EXT-X-MEDIA-SEQUENCE:0\n' +
325 '#EXTINF:10,\n' +
326 'low-0.ts\n');
327 clock.tick(10 * 1000);
328 loader.media('high.m3u8');
329 strictEqual(requests[0].aborted, true, 'aborted refresh request');
330 strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
331 });
332
333 test('switching to the active playlist is a no-op', function() {
334 var loader = new videojs.hls.PlaylistLoader('master.m3u8');
335 requests.pop().respond(200, null,
336 '#EXTM3U\n' +
337 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
338 'low.m3u8\n' +
339 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
340 'high.m3u8\n');
341 requests.pop().respond(200, null,
342 '#EXTM3U\n' +
343 '#EXT-X-MEDIA-SEQUENCE:0\n' +
344 '#EXTINF:10,\n' +
345 'low-0.ts\n' +
346 '#EXT-X-ENDLIST\n');
347 loader.media('low.m3u8');
348
349 strictEqual(requests.length, 0, 'no requests is sent');
350 });
351
352 test('throws an error if a media switch is initiated too early', function() {
353 var loader = new videojs.hls.PlaylistLoader('master.m3u8');
354
355 throws(function() {
356 loader.media('high.m3u8');
357 }, 'threw an error from HAVE_NOTHING');
358
359 requests.pop().respond(200, null,
360 '#EXTM3U\n' +
361 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
362 'low.m3u8\n' +
363 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
364 'high.m3u8\n');
365 throws(function() {
366 loader.media('high.m3u8');
367 }, 'throws an error from HAVE_MASTER');
368 });
369
370 test('throws an error if a switch to an unrecognized playlist is requested', function() {
371 var loader = new videojs.hls.PlaylistLoader('master.m3u8');
372 requests.pop().respond(200, null,
373 '#EXTM3U\n' +
374 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
375 'media.m3u8\n');
376
377 throws(function() {
378 loader.media('unrecognized.m3u8');
379 }, 'throws an error');
380 });
381 })(window);
...@@ -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>
......
...@@ -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) {
...@@ -149,30 +149,25 @@ test('starts playing if autoplay is specified', function() { ...@@ -149,30 +149,25 @@ test('starts playing if autoplay is specified', function() {
149 strictEqual(1, plays, 'play was called'); 149 strictEqual(1, plays, 'play was called');
150 }); 150 });
151 151
152 test('loads the specified manifest URL on init', function() { 152 test('creates a PlaylistLoader on init', function() {
153 var loadedmanifest = false, loadedmetadata = false; 153 var loadedmetadata = false;
154 player.on('loadedmanifest', function() {
155 loadedmanifest = true;
156 });
157 player.on('loadedmetadata', function() { 154 player.on('loadedmetadata', function() {
158 loadedmetadata = true; 155 loadedmetadata = true;
159 }); 156 });
160 157
161 player.hls('manifest/playlist.m3u8'); 158 player.hls('manifest/playlist.m3u8');
162 strictEqual(player.hls.readyState(), 0, 'the readyState is HAVE_NOTHING'); 159 ok(!player.hls.playlists, 'waits for sourceopen to create the loader');
163 videojs.mediaSources[player.currentSrc()].trigger({ 160 videojs.mediaSources[player.currentSrc()].trigger({
164 type: 'sourceopen' 161 type: 'sourceopen'
165 }); 162 });
166 standardXHRResponse(requests[0]); 163 standardXHRResponse(requests[0]);
167 ok(loadedmanifest, 'loadedmanifest fires');
168 ok(loadedmetadata, 'loadedmetadata fires'); 164 ok(loadedmetadata, 'loadedmetadata fires');
169 ok(player.hls.master, 'a master is inferred'); 165 ok(player.hls.playlists.master, 'set the master playlist');
170 ok(player.hls.media, 'the manifest is available'); 166 ok(player.hls.playlists.media(), 'set the media playlist');
171 ok(player.hls.media.segments, 'the segment entries are parsed'); 167 ok(player.hls.playlists.media().segments, 'the segment entries are parsed');
172 strictEqual(player.hls.master.playlists[0], 168 strictEqual(player.hls.playlists.master.playlists[0],
173 player.hls.media, 169 player.hls.playlists.media(),
174 'the playlist is selected'); 170 'the playlist is selected');
175 strictEqual(player.hls.readyState(), 1, 'the readyState is HAVE_METADATA');
176 }); 171 });
177 172
178 test('sets the duration if one is available on the playlist', function() { 173 test('sets the duration if one is available on the playlist', function() {
...@@ -189,8 +184,9 @@ test('sets the duration if one is available on the playlist', function() { ...@@ -189,8 +184,9 @@ test('sets the duration if one is available on the playlist', function() {
189 }); 184 });
190 185
191 standardXHRResponse(requests[0]); 186 standardXHRResponse(requests[0]);
187 strictEqual(calls, 1, 'duration is set');
192 standardXHRResponse(requests[1]); 188 standardXHRResponse(requests[1]);
193 strictEqual(calls, 2, 'duration is set'); 189 strictEqual(calls, 1, 'duration is set');
194 }); 190 });
195 191
196 test('calculates the duration if needed', function() { 192 test('calculates the duration if needed', function() {
...@@ -207,10 +203,9 @@ test('calculates the duration if needed', function() { ...@@ -207,10 +203,9 @@ test('calculates the duration if needed', function() {
207 }); 203 });
208 204
209 standardXHRResponse(requests[0]); 205 standardXHRResponse(requests[0]);
210 standardXHRResponse(requests[1]); 206 strictEqual(durations.length, 1, 'duration is set');
211 strictEqual(durations.length, 2, 'duration is set');
212 strictEqual(durations[0], 207 strictEqual(durations[0],
213 player.hls.media.segments.length * 10, 208 player.hls.playlists.media().segments.length * 10,
214 'duration is calculated'); 209 'duration is calculated');
215 }); 210 });
216 211
...@@ -269,18 +264,7 @@ test('re-initializes the plugin for each source', function() { ...@@ -269,18 +264,7 @@ test('re-initializes the plugin for each source', function() {
269 }); 264 });
270 265
271 test('triggers an error when a master playlist request errors', function() { 266 test('triggers an error when a master playlist request errors', function() {
272 var 267 var error;
273 status = 0,
274 error;
275 window.XMLHttpRequest = function() {
276 this.open = function() {};
277 this.send = function() {
278 this.readyState = 4;
279 this.status = status;
280 this.onreadystatechange();
281 };
282 };
283
284 player.on('error', function() { 268 player.on('error', function() {
285 error = player.hls.error; 269 error = player.hls.error;
286 }); 270 });
...@@ -288,6 +272,7 @@ test('triggers an error when a master playlist request errors', function() { ...@@ -288,6 +272,7 @@ test('triggers an error when a master playlist request errors', function() {
288 videojs.mediaSources[player.currentSrc()].trigger({ 272 videojs.mediaSources[player.currentSrc()].trigger({
289 type: 'sourceopen' 273 type: 'sourceopen'
290 }); 274 });
275 requests.pop().respond(500);
291 276
292 ok(error, 'an error is triggered'); 277 ok(error, 'an error is triggered');
293 strictEqual(2, error.code, 'a network error is triggered'); 278 strictEqual(2, error.code, 'a network error is triggered');
...@@ -355,7 +340,7 @@ test('selects a playlist after segment downloads', function() { ...@@ -355,7 +340,7 @@ test('selects a playlist after segment downloads', function() {
355 player.hls('manifest/master.m3u8'); 340 player.hls('manifest/master.m3u8');
356 player.hls.selectPlaylist = function() { 341 player.hls.selectPlaylist = function() {
357 calls++; 342 calls++;
358 return player.hls.master.playlists[0]; 343 return player.hls.playlists.master.playlists[0];
359 }; 344 };
360 videojs.mediaSources[player.currentSrc()].trigger({ 345 videojs.mediaSources[player.currentSrc()].trigger({
361 type: 'sourceopen' 346 type: 'sourceopen'
...@@ -375,6 +360,7 @@ test('selects a playlist after segment downloads', function() { ...@@ -375,6 +360,7 @@ test('selects a playlist after segment downloads', function() {
375 player.trigger('timeupdate'); 360 player.trigger('timeupdate');
376 361
377 standardXHRResponse(requests[3]); 362 standardXHRResponse(requests[3]);
363 console.log(requests.map(function(i) { return i.url; }));
378 strictEqual(calls, 2, 'selects after additional segments'); 364 strictEqual(calls, 2, 'selects after additional segments');
379 }); 365 });
380 366
...@@ -403,14 +389,14 @@ test('updates the duration after switching playlists', function() { ...@@ -403,14 +389,14 @@ test('updates the duration after switching playlists', function() {
403 player.hls('manifest/master.m3u8'); 389 player.hls('manifest/master.m3u8');
404 player.hls.selectPlaylist = function() { 390 player.hls.selectPlaylist = function() {
405 selectedPlaylist = true; 391 selectedPlaylist = true;
406 return player.hls.master.playlists[1]; 392 return player.hls.playlists.master.playlists[1];
407 }; 393 };
408 player.duration = function(duration) { 394 player.duration = function(duration) {
409 if (duration === undefined) { 395 if (duration === undefined) {
410 return 0; 396 return 0;
411 } 397 }
412 // only track calls that occur after the playlist has been switched 398 // only track calls that occur after the playlist has been switched
413 if (player.hls.media === player.hls.master.playlists[1]) { 399 if (player.hls.playlists.media() === player.hls.playlists.master.playlists[1]) {
414 calls++; 400 calls++;
415 } 401 }
416 }; 402 };
...@@ -462,8 +448,10 @@ test('downloads additional playlists if required', function() { ...@@ -462,8 +448,10 @@ test('downloads additional playlists if required', function() {
462 '/manifest/' + 448 '/manifest/' +
463 playlist.uri, 449 playlist.uri,
464 'made playlist request'); 450 'made playlist request');
465 strictEqual(playlist, player.hls.media, 'a new playlists was selected'); 451 strictEqual(playlist.uri,
466 ok(player.hls.media.segments, 'segments are now available'); 452 player.hls.playlists.media().uri,
453 'a new playlists was selected');
454 ok(player.hls.playlists.media().segments, 'segments are now available');
467 }); 455 });
468 456
469 test('selects a playlist below the current bandwidth', function() { 457 test('selects a playlist below the current bandwidth', function() {
...@@ -476,15 +464,15 @@ test('selects a playlist below the current bandwidth', function() { ...@@ -476,15 +464,15 @@ test('selects a playlist below the current bandwidth', function() {
476 standardXHRResponse(requests[0]); 464 standardXHRResponse(requests[0]);
477 465
478 // the default playlist has a really high bitrate 466 // the default playlist has a really high bitrate
479 player.hls.master.playlists[0].attributes.BANDWIDTH = 9e10; 467 player.hls.playlists.master.playlists[0].attributes.BANDWIDTH = 9e10;
480 // playlist 1 has a very low bitrate 468 // playlist 1 has a very low bitrate
481 player.hls.master.playlists[1].attributes.BANDWIDTH = 1; 469 player.hls.playlists.master.playlists[1].attributes.BANDWIDTH = 1;
482 // but the detected client bandwidth is really low 470 // but the detected client bandwidth is really low
483 player.hls.bandwidth = 10; 471 player.hls.bandwidth = 10;
484 472
485 playlist = player.hls.selectPlaylist(); 473 playlist = player.hls.selectPlaylist();
486 strictEqual(playlist, 474 strictEqual(playlist,
487 player.hls.master.playlists[1], 475 player.hls.playlists.master.playlists[1],
488 'the low bitrate stream is selected'); 476 'the low bitrate stream is selected');
489 }); 477 });
490 478
...@@ -498,15 +486,15 @@ test('raises the minimum bitrate for a stream proportionially', function() { ...@@ -498,15 +486,15 @@ test('raises the minimum bitrate for a stream proportionially', function() {
498 standardXHRResponse(requests[0]); 486 standardXHRResponse(requests[0]);
499 487
500 // the default playlist's bandwidth + 10% is equal to the current bandwidth 488 // the default playlist's bandwidth + 10% is equal to the current bandwidth
501 player.hls.master.playlists[0].attributes.BANDWIDTH = 10; 489 player.hls.playlists.master.playlists[0].attributes.BANDWIDTH = 10;
502 player.hls.bandwidth = 11; 490 player.hls.bandwidth = 11;
503 491
504 // 9.9 * 1.1 < 11 492 // 9.9 * 1.1 < 11
505 player.hls.master.playlists[1].attributes.BANDWIDTH = 9.9; 493 player.hls.playlists.master.playlists[1].attributes.BANDWIDTH = 9.9;
506 playlist = player.hls.selectPlaylist(); 494 playlist = player.hls.selectPlaylist();
507 495
508 strictEqual(playlist, 496 strictEqual(playlist,
509 player.hls.master.playlists[1], 497 player.hls.playlists.master.playlists[1],
510 'a lower bitrate stream is selected'); 498 'a lower bitrate stream is selected');
511 }); 499 });
512 500
...@@ -525,7 +513,7 @@ test('uses the lowest bitrate if no other is suitable', function() { ...@@ -525,7 +513,7 @@ test('uses the lowest bitrate if no other is suitable', function() {
525 513
526 // playlist 1 has the lowest advertised bitrate 514 // playlist 1 has the lowest advertised bitrate
527 strictEqual(playlist, 515 strictEqual(playlist,
528 player.hls.master.playlists[1], 516 player.hls.playlists.master.playlists[1],
529 'the lowest bitrate stream is selected'); 517 'the lowest bitrate stream is selected');
530 }); 518 });
531 519
...@@ -855,30 +843,21 @@ test('clears pending buffer updates when seeking', function() { ...@@ -855,30 +843,21 @@ test('clears pending buffer updates when seeking', function() {
855 843
856 test('playlist 404 should trigger MEDIA_ERR_NETWORK', function() { 844 test('playlist 404 should trigger MEDIA_ERR_NETWORK', function() {
857 var errorTriggered = false; 845 var errorTriggered = false;
858
859 window.XMLHttpRequest = function() {
860 this.open = function(method, url) {
861 xhrUrls.push(url);
862 };
863 this.send = function() {
864 this.readyState = 4;
865 this.status = 404;
866 this.onreadystatechange();
867 };
868 };
869
870 player.hls('manifest/media.m3u8');
871
872 player.on('error', function() { 846 player.on('error', function() {
873 errorTriggered = true; 847 errorTriggered = true;
874 }); 848 });
875 849 player.hls('manifest/media.m3u8');
876 videojs.mediaSources[player.currentSrc()].trigger({ 850 videojs.mediaSources[player.currentSrc()].trigger({
877 type: 'sourceopen' 851 type: 'sourceopen'
878 }); 852 });
879 853 requests.pop().respond(404);
880 equal(true, errorTriggered, 'Missing Playlist error event should trigger'); 854
881 equal(2, player.hls.error.code, 'Player error code should be set to MediaError.MEDIA_ERR_NETWORK'); 855 equal(errorTriggered,
856 true,
857 'Missing Playlist error event should trigger');
858 equal(player.hls.error.code,
859 2,
860 'Player error code should be set to MediaError.MEDIA_ERR_NETWORK');
882 ok(player.hls.error.message, 'Player error type should inform user correctly'); 861 ok(player.hls.error.message, 'Player error type should inform user correctly');
883 }); 862 });
884 863
...@@ -916,24 +895,6 @@ test('has no effect if native HLS is available', function() { ...@@ -916,24 +895,6 @@ test('has no effect if native HLS is available', function() {
916 'no media source was opened'); 895 'no media source was opened');
917 }); 896 });
918 897
919 test('reloads live playlists', function() {
920 var callbacks = [];
921 // capture timeouts
922 window.setTimeout = function(callback, timeout) {
923 callbacks.push({ callback: callback, timeout: timeout });
924 };
925 player.hls('http://example.com/manifest/missingEndlist.m3u8');
926 videojs.mediaSources[player.currentSrc()].trigger({
927 type: 'sourceopen'
928 });
929 standardXHRResponse(requests[0]);
930
931 strictEqual(1, callbacks.length, 'refresh was scheduled');
932 strictEqual(player.hls.media.targetDuration * 1000,
933 callbacks[0].timeout,
934 'waited one target duration');
935 });
936
937 test('duration is Infinity for live playlists', function() { 898 test('duration is Infinity for live playlists', function() {
938 player.hls('http://example.com/manifest/missingEndlist.m3u8'); 899 player.hls('http://example.com/manifest/missingEndlist.m3u8');
939 videojs.mediaSources[player.currentSrc()].trigger({ 900 videojs.mediaSources[player.currentSrc()].trigger({
...@@ -959,88 +920,37 @@ test('does not reload playlists with an endlist tag', function() { ...@@ -959,88 +920,37 @@ test('does not reload playlists with an endlist tag', function() {
959 strictEqual(0, callbacks.length, 'no refresh was scheduled'); 920 strictEqual(0, callbacks.length, 'no refresh was scheduled');
960 }); 921 });
961 922
962 test('reloads a live playlist after half a target duration if it has not ' +
963 'changed since the last request', function() {
964 var callbacks = [];
965 // capture timeouts
966 window.setTimeout = function(callback, timeout) {
967 callbacks.push({ callback: callback, timeout: timeout });
968 };
969 player.hls('http://example.com/manifest/missingEndlist.m3u8');
970 videojs.mediaSources[player.currentSrc()].trigger({
971 type: 'sourceopen'
972 });
973
974 standardXHRResponse(requests[0]);
975 standardXHRResponse(requests[1]);
976 strictEqual(callbacks.length, 1, 'full-length refresh scheduled');
977 callbacks.pop().callback();
978 standardXHRResponse(requests[2]);
979
980 strictEqual(callbacks.length, 1, 'half-length refresh was scheduled');
981 strictEqual(callbacks[0].timeout,
982 player.hls.media.targetDuration / 2 * 1000,
983 'waited half a target duration');
984 });
985
986 test('merges playlist reloads', function() {
987 var oldPlaylist,
988 callback;
989
990 // capture timeouts
991 window.setTimeout = function(cb) {
992 callback = cb;
993 };
994
995 player.hls('http://example.com/manifest/missingEndlist.m3u8');
996 videojs.mediaSources[player.currentSrc()].trigger({
997 type: 'sourceopen'
998 });
999 standardXHRResponse(requests[0]);
1000 standardXHRResponse(requests[1]);
1001 oldPlaylist = player.hls.media;
1002
1003 callback();
1004 standardXHRResponse(requests[2]);
1005 ok(oldPlaylist !== player.hls.media, 'player.hls.media was updated');
1006 });
1007
1008 test('updates the media index when a playlist reloads', function() { 923 test('updates the media index when a playlist reloads', function() {
1009 var callback;
1010 window.setTimeout = function(cb) {
1011 callback = cb;
1012 };
1013 // the initial playlist
1014 window.manifests['live-updating'] =
1015 '#EXTM3U\n' +
1016 '#EXTINF:10,\n' +
1017 '0.ts\n' +
1018 '#EXTINF:10,\n' +
1019 '1.ts\n' +
1020 '#EXTINF:10,\n' +
1021 '2.ts\n';
1022
1023 player.hls('http://example.com/live-updating.m3u8'); 924 player.hls('http://example.com/live-updating.m3u8');
1024 videojs.mediaSources[player.currentSrc()].trigger({ 925 videojs.mediaSources[player.currentSrc()].trigger({
1025 type: 'sourceopen' 926 type: 'sourceopen'
1026 }); 927 });
1027 928
1028 standardXHRResponse(requests[0]); 929 requests[0].respond(200, null,
930 '#EXTM3U\n' +
931 '#EXTINF:10,\n' +
932 '0.ts\n' +
933 '#EXTINF:10,\n' +
934 '1.ts\n' +
935 '#EXTINF:10,\n' +
936 '2.ts\n');
1029 standardXHRResponse(requests[1]); 937 standardXHRResponse(requests[1]);
1030 // play the stream until 2.ts is playing 938 // play the stream until 2.ts is playing
1031 player.hls.mediaIndex = 3; 939 player.hls.mediaIndex = 3;
1032 940
1033 // reload the updated playlist 941 // reload the updated playlist
1034 window.manifests['live-updating'] = 942 player.hls.playlists.media = function() {
1035 '#EXTM3U\n' + 943 return {
1036 '#EXTINF:10,\n' + 944 segments: [{
1037 '1.ts\n' + 945 uri: '1.ts'
1038 '#EXTINF:10,\n' + 946 }, {
1039 '2.ts\n' + 947 uri: '2.ts'
1040 '#EXTINF:10,\n' + 948 }, {
1041 '3.ts\n'; 949 uri: '3.ts'
1042 callback(); 950 }]
1043 standardXHRResponse(requests[2]); 951 };
952 };
953 player.hls.playlists.trigger('loadedplaylist');
1044 954
1045 strictEqual(player.hls.mediaIndex, 2, 'mediaIndex is updated after the reload'); 955 strictEqual(player.hls.mediaIndex, 2, 'mediaIndex is updated after the reload');
1046 }); 956 });
...@@ -1112,64 +1022,6 @@ test('does not reload master playlists', function() { ...@@ -1112,64 +1022,6 @@ test('does not reload master playlists', function() {
1112 strictEqual(callbacks.length, 0, 'no reload scheduled'); 1022 strictEqual(callbacks.length, 0, 'no reload scheduled');
1113 }); 1023 });
1114 1024
1115 test('only reloads the active media playlist', function() {
1116 var callbacks = [],
1117 i = 0,
1118 filteredRequests = [],
1119 customResponse;
1120
1121 customResponse = function(request) {
1122 request.response = new Uint8Array([1]).buffer;
1123 request.respond(200,
1124 {'Content-Type': 'application/vnd.apple.mpegurl'},
1125 '#EXTM3U\n' +
1126 '#EXT-X-MEDIA-SEQUENCE:1\n' +
1127 '#EXTINF:10,\n' +
1128 '1.ts\n');
1129 };
1130
1131 window.setTimeout = function(callback) {
1132 callbacks.push(callback);
1133 };
1134
1135 player.hls('http://example.com/missingEndlist.m3u8');
1136 videojs.mediaSources[player.currentSrc()].trigger({
1137 type: 'sourceopen'
1138 });
1139
1140 standardXHRResponse(requests[0]);
1141 standardXHRResponse(requests[1]);
1142
1143 videojs.mediaSources[player.currentSrc()].endOfStream = function() {};
1144
1145 player.hls.selectPlaylist = function() {
1146 return player.hls.master.playlists[1];
1147 };
1148 player.hls.master.playlists.push({
1149 uri: 'http://example.com/switched.m3u8'
1150 });
1151
1152 player.trigger('timeupdate');
1153 strictEqual(callbacks.length, 1, 'a refresh is scheduled');
1154
1155 standardXHRResponse(requests[2]); // segment response
1156 customResponse(requests[3]); // loaded witched.m3u8
1157
1158 callbacks.shift()(); // out-of-date refresh of missingEndlist.m3u8
1159 callbacks.shift()(); // refresh switched.m3u8
1160
1161 for (; i < requests.length; i++) {
1162 if (/switched/.test(requests[i].url)) {
1163 filteredRequests.push(requests[i]);
1164 }
1165 }
1166 strictEqual(filteredRequests.length, 2, 'one refresh was made');
1167 strictEqual(filteredRequests[1].url,
1168 'http://example.com/switched.m3u8',
1169 'refreshed the active playlist');
1170
1171 });
1172
1173 test('if withCredentials option is used, withCredentials is set on the XHR object', function() { 1025 test('if withCredentials option is used, withCredentials is set on the XHR object', function() {
1174 player.hls({ 1026 player.hls({
1175 url: 'http://example.com/media.m3u8', 1027 url: 'http://example.com/media.m3u8',
...@@ -1182,20 +1034,15 @@ test('if withCredentials option is used, withCredentials is set on the XHR objec ...@@ -1182,20 +1034,15 @@ test('if withCredentials option is used, withCredentials is set on the XHR objec
1182 }); 1034 });
1183 1035
1184 test('does not break if the playlist has no segments', function() { 1036 test('does not break if the playlist has no segments', function() {
1185 var customResponse = function(request) {
1186 request.response = new Uint8Array([1]).buffer;
1187 request.respond(200,
1188 {'Content-Type': 'application/vnd.apple.mpegurl'},
1189 '#EXTM3U\n' +
1190 '#EXT-X-PLAYLIST-TYPE:VOD\n' +
1191 '#EXT-X-TARGETDURATION:10\n');
1192 };
1193 player.hls('manifest/master.m3u8'); 1037 player.hls('manifest/master.m3u8');
1194 try { 1038 try {
1195 videojs.mediaSources[player.currentSrc()].trigger({ 1039 videojs.mediaSources[player.currentSrc()].trigger({
1196 type: 'sourceopen' 1040 type: 'sourceopen'
1197 }); 1041 });
1198 customResponse(requests[0]); 1042 requests[0].respond(200, null,
1043 '#EXTM3U\n' +
1044 '#EXT-X-PLAYLIST-TYPE:VOD\n' +
1045 '#EXT-X-TARGETDURATION:10\n');
1199 } catch(e) { 1046 } catch(e) {
1200 ok(false, 'an error was thrown'); 1047 ok(false, 'an error was thrown');
1201 throw e; 1048 throw e;
......