c694b4b7 by David LaPalomento

Merge pull request #544 from BrandonOCasey/browserify-p4

browserify-p4: playlist*, xhr, and resolve-url
2 parents e3c93f60 4c27b9d1
...@@ -48,10 +48,7 @@ ...@@ -48,10 +48,7 @@
48 <script src="/node_modules/video.js/dist/video.js"></script> 48 <script src="/node_modules/video.js/dist/video.js"></script>
49 <script src="/node_modules/videojs-contrib-media-sources/dist/videojs-media-sources.js"></script> 49 <script src="/node_modules/videojs-contrib-media-sources/dist/videojs-media-sources.js"></script>
50 <script src="/src/videojs-contrib-hls.js"></script> 50 <script src="/src/videojs-contrib-hls.js"></script>
51 <script src="/src/xhr.js"></script>
52 <script src="/dist/videojs-contrib-hls.js"></script> 51 <script src="/dist/videojs-contrib-hls.js"></script>
53 <script src="/src/playlist.js"></script>
54 <script src="/src/playlist-loader.js"></script>
55 <script src="/src/bin-utils.js"></script> 52 <script src="/src/bin-utils.js"></script>
56 <script> 53 <script>
57 (function(window, videojs) { 54 (function(window, videojs) {
......
...@@ -2,7 +2,7 @@ var browserify = require('browserify'); ...@@ -2,7 +2,7 @@ var browserify = require('browserify');
2 var fs = require('fs'); 2 var fs = require('fs');
3 var glob = require('glob'); 3 var glob = require('glob');
4 4
5 glob('test/{decryper,m3u8,stub}.test.js', function(err, files) { 5 glob('test/{playlist*,decryper,m3u8,stub}.test.js', function(err, files) {
6 browserify(files) 6 browserify(files)
7 .transform('babelify') 7 .transform('babelify')
8 .bundle() 8 .bundle()
......
...@@ -3,7 +3,7 @@ var fs = require('fs'); ...@@ -3,7 +3,7 @@ var fs = require('fs');
3 var glob = require('glob'); 3 var glob = require('glob');
4 var watchify = require('watchify'); 4 var watchify = require('watchify');
5 5
6 glob('test/{decrypter,m3u8,stub}.test.js', function(err, files) { 6 glob('test/{playlist*,decrypter,m3u8,stub}.test.js', function(err, files) {
7 var b = browserify(files, { 7 var b = browserify(files, {
8 cache: {}, 8 cache: {},
9 packageCache: {}, 9 packageCache: {},
......
...@@ -5,560 +5,562 @@ ...@@ -5,560 +5,562 @@
5 * M3U8 playlists. 5 * M3U8 playlists.
6 * 6 *
7 */ 7 */
8 (function(window, videojs) { 8 import resolveUrl from './resolve-url';
9 'use strict'; 9 import XhrModule from './xhr';
10 var 10 import {mergeOptions} from 'video.js';
11 resolveUrl = videojs.Hls.resolveUrl, 11 import Stream from './stream';
12 xhr = videojs.Hls.xhr, 12 import m3u8 from './m3u8';
13 mergeOptions = videojs.mergeOptions,
14
15 /**
16 * Returns a new master playlist that is the result of merging an
17 * updated media playlist into the original version. If the
18 * updated media playlist does not match any of the playlist
19 * entries in the original master playlist, null is returned.
20 * @param master {object} a parsed master M3U8 object
21 * @param media {object} a parsed media M3U8 object
22 * @return {object} a new object that represents the original
23 * master playlist with the updated media playlist merged in, or
24 * null if the merge produced no change.
25 */
26 updateMaster = function(master, media) {
27 var
28 changed = false,
29 result = mergeOptions(master, {}),
30 i,
31 playlist;
32
33 i = master.playlists.length;
34 while (i--) {
35 playlist = result.playlists[i];
36 if (playlist.uri === media.uri) {
37 // consider the playlist unchanged if the number of segments
38 // are equal and the media sequence number is unchanged
39 if (playlist.segments &&
40 media.segments &&
41 playlist.segments.length === media.segments.length &&
42 playlist.mediaSequence === media.mediaSequence) {
43 continue;
44 }
45
46 result.playlists[i] = mergeOptions(playlist, media);
47 result.playlists[media.uri] = result.playlists[i];
48
49 // if the update could overlap existing segment information,
50 // merge the two lists
51 if (playlist.segments) {
52 result.playlists[i].segments = updateSegments(playlist.segments,
53 media.segments,
54 media.mediaSequence - playlist.mediaSequence);
55 }
56 changed = true;
57 }
58 }
59 return changed ? result : null;
60 },
61
62 /**
63 * Returns a new array of segments that is the result of merging
64 * properties from an older list of segments onto an updated
65 * list. No properties on the updated playlist will be overridden.
66 * @param original {array} the outdated list of segments
67 * @param update {array} the updated list of segments
68 * @param offset {number} (optional) the index of the first update
69 * segment in the original segment list. For non-live playlists,
70 * this should always be zero and does not need to be
71 * specified. For live playlists, it should be the difference
72 * between the media sequence numbers in the original and updated
73 * playlists.
74 * @return a list of merged segment objects
75 */
76 updateSegments = function(original, update, offset) {
77 var result = update.slice(), length, i;
78 offset = offset || 0;
79 length = Math.min(original.length, update.length + offset);
80
81 for (i = offset; i < length; i++) {
82 result[i - offset] = mergeOptions(original[i], result[i - offset]);
83 }
84 return result;
85 },
86
87 PlaylistLoader = function(srcUrl, withCredentials) {
88 var
89 loader = this,
90 dispose,
91 mediaUpdateTimeout,
92 request,
93 playlistRequestError,
94 haveMetadata;
95
96 PlaylistLoader.prototype.init.call(this);
97
98 // a flag that disables "expired time"-tracking this setting has
99 // no effect when not playing a live stream
100 this.trackExpiredTime_ = false;
101
102 if (!srcUrl) {
103 throw new Error('A non-empty playlist URL is required');
104 }
105
106 playlistRequestError = function(xhr, url, startingState) {
107 loader.setBandwidth(request || xhr);
108
109 // any in-flight request is now finished
110 request = null;
111
112 if (startingState) {
113 loader.state = startingState;
114 }
115
116 loader.error = {
117 playlist: loader.master.playlists[url],
118 status: xhr.status,
119 message: 'HLS playlist request error at URL: ' + url,
120 responseText: xhr.responseText,
121 code: (xhr.status >= 500) ? 4 : 2
122 };
123 loader.trigger('error');
124 };
125
126 // update the playlist loader's state in response to a new or
127 // updated playlist.
128
129 haveMetadata = function(xhr, url) {
130 var parser, refreshDelay, update;
131
132 loader.setBandwidth(request || xhr);
133
134 // any in-flight request is now finished
135 request = null;
136 loader.state = 'HAVE_METADATA';
137
138 parser = new videojs.m3u8.Parser();
139 parser.push(xhr.responseText);
140 parser.end();
141 parser.manifest.uri = url;
142
143 // merge this playlist into the master
144 update = updateMaster(loader.master, parser.manifest);
145 refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
146 if (update) {
147 loader.master = update;
148 loader.updateMediaPlaylist_(parser.manifest);
149 } else {
150 // if the playlist is unchanged since the last reload,
151 // try again after half the target duration
152 refreshDelay /= 2;
153 }
154
155 // refresh live playlists after a target duration passes
156 if (!loader.media().endList) {
157 window.clearTimeout(mediaUpdateTimeout);
158 mediaUpdateTimeout = window.setTimeout(function() {
159 loader.trigger('mediaupdatetimeout');
160 }, refreshDelay);
161 }
162
163 loader.trigger('loadedplaylist');
164 };
165
166 // initialize the loader state
167 loader.state = 'HAVE_NOTHING';
168
169 // track the time that has expired from the live window
170 // this allows the seekable start range to be calculated even if
171 // all segments with timing information have expired
172 this.expired_ = 0;
173
174 // capture the prototype dispose function
175 dispose = this.dispose;
176
177 /**
178 * Abort any outstanding work and clean up.
179 */
180 loader.dispose = function() {
181 if (request) {
182 request.onreadystatechange = null;
183 request.abort();
184 request = null;
185 }
186 window.clearTimeout(mediaUpdateTimeout);
187 dispose.call(this);
188 };
189
190 /**
191 * When called without any arguments, returns the currently
192 * active media playlist. When called with a single argument,
193 * triggers the playlist loader to asynchronously switch to the
194 * specified media playlist. Calling this method while the
195 * loader is in the HAVE_NOTHING causes an error to be emitted
196 * but otherwise has no effect.
197 * @param playlist (optional) {object} the parsed media playlist
198 * object to switch to
199 */
200 loader.media = function(playlist) {
201 var startingState = loader.state, mediaChange;
202 // getter
203 if (!playlist) {
204 return loader.media_;
205 }
206
207 // setter
208 if (loader.state === 'HAVE_NOTHING') {
209 throw new Error('Cannot switch media playlist from ' + loader.state);
210 }
211
212 // find the playlist object if the target playlist has been
213 // specified by URI
214 if (typeof playlist === 'string') {
215 if (!loader.master.playlists[playlist]) {
216 throw new Error('Unknown playlist URI: ' + playlist);
217 }
218 playlist = loader.master.playlists[playlist];
219 }
220
221 mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri;
222
223 // switch to fully loaded playlists immediately
224 if (loader.master.playlists[playlist.uri].endList) {
225 // abort outstanding playlist requests
226 if (request) {
227 request.onreadystatechange = null;
228 request.abort();
229 request = null;
230 }
231 loader.state = 'HAVE_METADATA';
232 loader.media_ = playlist;
233
234 // trigger media change if the active media has been updated
235 if (mediaChange) {
236 loader.trigger('mediachange');
237 }
238 return;
239 }
240
241 // switching to the active playlist is a no-op
242 if (!mediaChange) {
243 return;
244 }
245
246 loader.state = 'SWITCHING_MEDIA';
247
248 // there is already an outstanding playlist request
249 if (request) {
250 if (resolveUrl(loader.master.uri, playlist.uri) === request.url) {
251 // requesting to switch to the same playlist multiple times
252 // has no effect after the first
253 return;
254 }
255 request.onreadystatechange = null;
256 request.abort();
257 request = null;
258 }
259
260 // request the new playlist
261 request = xhr({
262 uri: resolveUrl(loader.master.uri, playlist.uri),
263 withCredentials: withCredentials
264 }, function(error, request) {
265 if (error) {
266 return playlistRequestError(request, playlist.uri, startingState);
267 }
268
269 haveMetadata(request, playlist.uri);
270
271 // fire loadedmetadata the first time a media playlist is loaded
272 if (startingState === 'HAVE_MASTER') {
273 loader.trigger('loadedmetadata');
274 } else {
275 loader.trigger('mediachange');
276 }
277 });
278 };
279
280 loader.setBandwidth = function(xhr) {
281 loader.bandwidth = xhr.bandwidth;
282 };
283
284 // In a live list, don't keep track of the expired time until
285 // HLS tells us that "first play" has commenced
286 loader.on('firstplay', function() {
287 this.trackExpiredTime_ = true;
288 });
289
290 // live playlist staleness timeout
291 loader.on('mediaupdatetimeout', function() {
292 if (loader.state !== 'HAVE_METADATA') {
293 // only refresh the media playlist if no other activity is going on
294 return;
295 }
296
297 loader.state = 'HAVE_CURRENT_METADATA';
298 request = xhr({
299 uri: resolveUrl(loader.master.uri, loader.media().uri),
300 withCredentials: withCredentials
301 }, function(error, request) {
302 if (error) {
303 return playlistRequestError(request, loader.media().uri);
304 }
305 haveMetadata(request, loader.media().uri);
306 });
307 });
308
309 // request the specified URL
310 request = xhr({
311 uri: srcUrl,
312 withCredentials: withCredentials
313 }, function(error, req) {
314 var parser, i;
315
316 // clear the loader's request reference
317 request = null;
318
319 if (error) {
320 loader.error = {
321 status: req.status,
322 message: 'HLS playlist request error at URL: ' + srcUrl,
323 responseText: req.responseText,
324 code: 2 // MEDIA_ERR_NETWORK
325 };
326 return loader.trigger('error');
327 }
328
329 parser = new videojs.m3u8.Parser();
330 parser.push(req.responseText);
331 parser.end();
332
333 loader.state = 'HAVE_MASTER';
334
335 parser.manifest.uri = srcUrl;
336
337 // loaded a master playlist
338 if (parser.manifest.playlists) {
339 loader.master = parser.manifest;
340
341 // setup by-URI lookups
342 i = loader.master.playlists.length;
343 while (i--) {
344 loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i];
345 }
346
347 loader.trigger('loadedplaylist');
348 if (!request) {
349 // no media playlist was specifically selected so start
350 // from the first listed one
351 loader.media(parser.manifest.playlists[0]);
352 }
353 return;
354 }
355
356 // loaded a media playlist
357 // infer a master playlist if none was previously requested
358 loader.master = {
359 uri: window.location.href,
360 playlists: [{
361 uri: srcUrl
362 }]
363 };
364 loader.master.playlists[srcUrl] = loader.master.playlists[0];
365 haveMetadata(req, srcUrl);
366 return loader.trigger('loadedmetadata');
367 });
368 };
369 PlaylistLoader.prototype = new videojs.Hls.Stream();
370
371 /**
372 * Update the PlaylistLoader state to reflect the changes in an
373 * update to the current media playlist.
374 * @param update {object} the updated media playlist object
375 */
376 PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) {
377 var outdated, i, segment;
378
379 outdated = this.media_;
380 this.media_ = this.master.playlists[update.uri];
381
382 if (!outdated) {
383 return;
384 }
385
386 // don't track expired time until this flag is truthy
387 if (!this.trackExpiredTime_) {
388 return;
389 }
390
391 // if the update was the result of a rendition switch do not
392 // attempt to calculate expired_ since media-sequences need not
393 // correlate between renditions/variants
394 if (update.uri !== outdated.uri) {
395 return;
396 }
397 13
398 // try using precise timing from first segment of the updated 14 /**
399 // playlist 15 * Returns a new master playlist that is the result of merging an
400 if (update.segments.length) { 16 * updated media playlist into the original version. If the
401 if (update.segments[0].start !== undefined) { 17 * updated media playlist does not match any of the playlist
402 this.expired_ = update.segments[0].start; 18 * entries in the original master playlist, null is returned.
403 return; 19 * @param master {object} a parsed master M3U8 object
404 } else if (update.segments[0].end !== undefined) { 20 * @param media {object} a parsed media M3U8 object
405 this.expired_ = update.segments[0].end - update.segments[0].duration; 21 * @return {object} a new object that represents the original
406 return; 22 * master playlist with the updated media playlist merged in, or
407 } 23 * null if the merge produced no change.
408 } 24 */
409 25 const updateMaster = function(master, media) {
410 // calculate expired by walking the outdated playlist 26 let changed = false;
411 i = update.mediaSequence - outdated.mediaSequence - 1; 27 let result = mergeOptions(master, {});
412 28 let i = master.playlists.length;
413 for (; i >= 0; i--) { 29 let playlist;
414 segment = outdated.segments[i]; 30
415 31 while (i--) {
416 if (!segment) { 32 playlist = result.playlists[i];
417 // we missed information on this segment completely between 33 if (playlist.uri === media.uri) {
418 // playlist updates so we'll have to take an educated guess 34 // consider the playlist unchanged if the number of segments
419 // once we begin buffering again, any error we introduce can 35 // are equal and the media sequence number is unchanged
420 // be corrected 36 if (playlist.segments &&
421 this.expired_ += outdated.targetDuration || 10; 37 media.segments &&
38 playlist.segments.length === media.segments.length &&
39 playlist.mediaSequence === media.mediaSequence) {
422 continue; 40 continue;
423 } 41 }
424 42
425 if (segment.end !== undefined) { 43 result.playlists[i] = mergeOptions(playlist, media);
426 this.expired_ = segment.end; 44 result.playlists[media.uri] = result.playlists[i];
427 return;
428 }
429 if (segment.start !== undefined) {
430 this.expired_ = segment.start + segment.duration;
431 return;
432 }
433 this.expired_ += segment.duration;
434 }
435 };
436 45
437 /** 46 // if the update could overlap existing segment information,
438 * Determine the index of the segment that contains a specified 47 // merge the two lists
439 * playback position in the current media playlist. Early versions 48 if (playlist.segments) {
440 * of the HLS specification require segment durations to be rounded 49 result.playlists[i].segments = updateSegments(playlist.segments,
441 * to the nearest integer which means it may not be possible to 50 media.segments,
442 * determine the correct segment for a playback position if that 51 media.mediaSequence -
443 * position is within .5 seconds of the segment duration. This 52 playlist.mediaSequence);
444 * function will always return the lower of the two possible indices
445 * in those cases.
446 *
447 * @param time {number} The number of seconds since the earliest
448 * possible position to determine the containing segment for
449 * @returns {number} The number of the media segment that contains
450 * that time position. If the specified playback position is outside
451 * the time range of the current set of media segments, the return
452 * value will be clamped to the index of the segment containing the
453 * closest playback position that is currently available.
454 */
455 PlaylistLoader.prototype.getMediaIndexForTime_ = function(time) {
456 var
457 i,
458 segment,
459 originalTime = time,
460 numSegments = this.media_.segments.length,
461 lastSegment = numSegments - 1,
462 startIndex,
463 endIndex,
464 knownStart,
465 knownEnd;
466
467 if (!this.media_) {
468 return 0;
469 }
470
471 // when the requested position is earlier than the current set of
472 // segments, return the earliest segment index
473 if (time < 0) {
474 return 0;
475 }
476
477 // find segments with known timing information that bound the
478 // target time
479 for (i = 0; i < numSegments; i++) {
480 segment = this.media_.segments[i];
481 if (segment.end) {
482 if (segment.end > time) {
483 knownEnd = segment.end;
484 endIndex = i;
485 break;
486 } else {
487 knownStart = segment.end;
488 startIndex = i + 1;
489 }
490 }
491 }
492
493 // use the bounds we just found and playlist information to
494 // estimate the segment that contains the time we are looking for
495 if (startIndex !== undefined) {
496 // We have a known-start point that is before our desired time so
497 // walk from that point forwards
498 time = time - knownStart;
499 for (i = startIndex; i < (endIndex || numSegments); i++) {
500 segment = this.media_.segments[i];
501 time -= segment.duration;
502
503 if (time < 0) {
504 return i;
505 }
506 }
507
508 if (i >= endIndex) {
509 // We haven't found a segment but we did hit a known end point
510 // so fallback to interpolating between the segment index
511 // based on the known span of the timeline we are dealing with
512 // and the number of segments inside that span
513 return startIndex + Math.floor(
514 ((originalTime - knownStart) / (knownEnd - knownStart)) *
515 (endIndex - startIndex));
516 } 53 }
517 54 changed = true;
518 // We _still_ haven't found a segment so load the last one
519 return lastSegment;
520 } else if (endIndex !== undefined) {
521 // We _only_ have a known-end point that is after our desired time so
522 // walk from that point backwards
523 time = knownEnd - time;
524 for (i = endIndex; i >= 0; i--) {
525 segment = this.media_.segments[i];
526 time -= segment.duration;
527
528 if (time < 0) {
529 return i;
530 }
531 }
532
533 // We haven't found a segment so load the first one if time is zero
534 if (time === 0) {
535 return 0;
536 } else {
537 return -1;
538 }
539 } else {
540 // We known nothing so walk from the front of the playlist,
541 // subtracting durations until we find a segment that contains
542 // time and return it
543 time = time - this.expired_;
544
545 if (time < 0) {
546 return -1;
547 }
548
549 for (i = 0; i < numSegments; i++) {
550 segment = this.media_.segments[i];
551 time -= segment.duration;
552 if (time < 0) {
553 return i;
554 }
555 }
556 // We are out of possible candidates so load the last one...
557 // The last one is the least likely to overlap a buffer and therefore
558 // the one most likely to tell us something about the timeline
559 return lastSegment;
560 } 55 }
561 }; 56 }
57 return changed ? result : null;
58 };
562 59
563 videojs.Hls.PlaylistLoader = PlaylistLoader; 60 /**
564 })(window, window.videojs); 61 * Returns a new array of segments that is the result of merging
62 * properties from an older list of segments onto an updated
63 * list. No properties on the updated playlist will be overridden.
64 * @param original {array} the outdated list of segments
65 * @param update {array} the updated list of segments
66 * @param offset {number} (optional) the index of the first update
67 * segment in the original segment list. For non-live playlists,
68 * this should always be zero and does not need to be
69 * specified. For live playlists, it should be the difference
70 * between the media sequence numbers in the original and updated
71 * playlists.
72 * @return a list of merged segment objects
73 */
74 const updateSegments = function(original, update, offset) {
75 let result = update.slice();
76 let length;
77 let i;
78
79 offset = offset || 0;
80 length = Math.min(original.length, update.length + offset);
81
82 for (i = offset; i < length; i++) {
83 result[i - offset] = mergeOptions(original[i], result[i - offset]);
84 }
85 return result;
86 };
87
88 export default class PlaylistLoader extends Stream {
89 constructor(srcUrl, withCredentials) {
90 super();
91 let loader = this;
92 let dispose;
93 let mediaUpdateTimeout;
94 let request;
95 let playlistRequestError;
96 let haveMetadata;
97
98 // a flag that disables "expired time"-tracking this setting has
99 // no effect when not playing a live stream
100 this.trackExpiredTime_ = false;
101
102 if (!srcUrl) {
103 throw new Error('A non-empty playlist URL is required');
104 }
105
106 playlistRequestError = function(xhr, url, startingState) {
107 loader.setBandwidth(request || xhr);
108
109 // any in-flight request is now finished
110 request = null;
111
112 if (startingState) {
113 loader.state = startingState;
114 }
115
116 loader.error = {
117 playlist: loader.master.playlists[url],
118 status: xhr.status,
119 message: 'HLS playlist request error at URL: ' + url,
120 responseText: xhr.responseText,
121 code: (xhr.status >= 500) ? 4 : 2
122 };
123 loader.trigger('error');
124 };
125
126 // update the playlist loader's state in response to a new or
127 // updated playlist.
128
129 haveMetadata = function(xhr, url) {
130 let parser;
131 let refreshDelay;
132 let update;
133
134 loader.setBandwidth(request || xhr);
135
136 // any in-flight request is now finished
137 request = null;
138 loader.state = 'HAVE_METADATA';
139
140 parser = new m3u8.Parser();
141 parser.push(xhr.responseText);
142 parser.end();
143 parser.manifest.uri = url;
144
145 // merge this playlist into the master
146 update = updateMaster(loader.master, parser.manifest);
147 refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
148 if (update) {
149 loader.master = update;
150 loader.updateMediaPlaylist_(parser.manifest);
151 } else {
152 // if the playlist is unchanged since the last reload,
153 // try again after half the target duration
154 refreshDelay /= 2;
155 }
156
157 // refresh live playlists after a target duration passes
158 if (!loader.media().endList) {
159 window.clearTimeout(mediaUpdateTimeout);
160 mediaUpdateTimeout = window.setTimeout(function() {
161 loader.trigger('mediaupdatetimeout');
162 }, refreshDelay);
163 }
164
165 loader.trigger('loadedplaylist');
166 };
167
168 // initialize the loader state
169 loader.state = 'HAVE_NOTHING';
170
171 // track the time that has expired from the live window
172 // this allows the seekable start range to be calculated even if
173 // all segments with timing information have expired
174 this.expired_ = 0;
175
176 // capture the prototype dispose function
177 dispose = this.dispose;
178
179 /**
180 * Abort any outstanding work and clean up.
181 */
182 loader.dispose = function() {
183 if (request) {
184 request.onreadystatechange = null;
185 request.abort();
186 request = null;
187 }
188 window.clearTimeout(mediaUpdateTimeout);
189 dispose.call(this);
190 };
191
192 /**
193 * When called without any arguments, returns the currently
194 * active media playlist. When called with a single argument,
195 * triggers the playlist loader to asynchronously switch to the
196 * specified media playlist. Calling this method while the
197 * loader is in the HAVE_NOTHING causes an error to be emitted
198 * but otherwise has no effect.
199 * @param playlist (optional) {object} the parsed media playlist
200 * object to switch to
201 */
202 loader.media = function(playlist) {
203 let startingState = loader.state;
204 let mediaChange;
205 // getter
206 if (!playlist) {
207 return loader.media_;
208 }
209
210 // setter
211 if (loader.state === 'HAVE_NOTHING') {
212 throw new Error('Cannot switch media playlist from ' + loader.state);
213 }
214
215 // find the playlist object if the target playlist has been
216 // specified by URI
217 if (typeof playlist === 'string') {
218 if (!loader.master.playlists[playlist]) {
219 throw new Error('Unknown playlist URI: ' + playlist);
220 }
221 playlist = loader.master.playlists[playlist];
222 }
223
224 mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri;
225
226 // switch to fully loaded playlists immediately
227 if (loader.master.playlists[playlist.uri].endList) {
228 // abort outstanding playlist requests
229 if (request) {
230 request.onreadystatechange = null;
231 request.abort();
232 request = null;
233 }
234 loader.state = 'HAVE_METADATA';
235 loader.media_ = playlist;
236
237 // trigger media change if the active media has been updated
238 if (mediaChange) {
239 loader.trigger('mediachange');
240 }
241 return;
242 }
243
244 // switching to the active playlist is a no-op
245 if (!mediaChange) {
246 return;
247 }
248
249 loader.state = 'SWITCHING_MEDIA';
250
251 // there is already an outstanding playlist request
252 if (request) {
253 if (resolveUrl(loader.master.uri, playlist.uri) === request.url) {
254 // requesting to switch to the same playlist multiple times
255 // has no effect after the first
256 return;
257 }
258 request.onreadystatechange = null;
259 request.abort();
260 request = null;
261 }
262
263 // request the new playlist
264 request = XhrModule({
265 uri: resolveUrl(loader.master.uri, playlist.uri),
266 withCredentials
267 }, function(error, request) {
268 if (error) {
269 return playlistRequestError(request, playlist.uri, startingState);
270 }
271
272 haveMetadata(request, playlist.uri);
273
274 // fire loadedmetadata the first time a media playlist is loaded
275 if (startingState === 'HAVE_MASTER') {
276 loader.trigger('loadedmetadata');
277 } else {
278 loader.trigger('mediachange');
279 }
280 });
281 };
282
283 loader.setBandwidth = function(xhr) {
284 loader.bandwidth = xhr.bandwidth;
285 };
286
287 // In a live list, don't keep track of the expired time until
288 // HLS tells us that "first play" has commenced
289 loader.on('firstplay', function() {
290 this.trackExpiredTime_ = true;
291 });
292
293 // live playlist staleness timeout
294 loader.on('mediaupdatetimeout', function() {
295 if (loader.state !== 'HAVE_METADATA') {
296 // only refresh the media playlist if no other activity is going on
297 return;
298 }
299
300 loader.state = 'HAVE_CURRENT_METADATA';
301 request = XhrModule({
302 uri: resolveUrl(loader.master.uri, loader.media().uri),
303 withCredentials
304 }, function(error, request) {
305 if (error) {
306 return playlistRequestError(request, loader.media().uri);
307 }
308 haveMetadata(request, loader.media().uri);
309 });
310 });
311
312 // request the specified URL
313 request = XhrModule({
314 uri: srcUrl,
315 withCredentials
316 }, function(error, req) {
317 let parser;
318 let i;
319
320 // clear the loader's request reference
321 request = null;
322
323 if (error) {
324 loader.error = {
325 status: req.status,
326 message: 'HLS playlist request error at URL: ' + srcUrl,
327 responseText: req.responseText,
328 // MEDIA_ERR_NETWORK
329 code: 2
330 };
331 return loader.trigger('error');
332 }
333
334 parser = new m3u8.Parser();
335 parser.push(req.responseText);
336 parser.end();
337
338 loader.state = 'HAVE_MASTER';
339
340 parser.manifest.uri = srcUrl;
341
342 // loaded a master playlist
343 if (parser.manifest.playlists) {
344 loader.master = parser.manifest;
345
346 // setup by-URI lookups
347 i = loader.master.playlists.length;
348 while (i--) {
349 loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i];
350 }
351
352 loader.trigger('loadedplaylist');
353 if (!request) {
354 // no media playlist was specifically selected so start
355 // from the first listed one
356 loader.media(parser.manifest.playlists[0]);
357 }
358 return;
359 }
360
361 // loaded a media playlist
362 // infer a master playlist if none was previously requested
363 loader.master = {
364 uri: window.location.href,
365 playlists: [{
366 uri: srcUrl
367 }]
368 };
369 loader.master.playlists[srcUrl] = loader.master.playlists[0];
370 haveMetadata(req, srcUrl);
371 return loader.trigger('loadedmetadata');
372 });
373 }
374 /**
375 * Update the PlaylistLoader state to reflect the changes in an
376 * update to the current media playlist.
377 * @param update {object} the updated media playlist object
378 */
379 updateMediaPlaylist_(update) {
380 let outdated;
381 let i;
382 let segment;
383
384 outdated = this.media_;
385 this.media_ = this.master.playlists[update.uri];
386
387 if (!outdated) {
388 return;
389 }
390
391 // don't track expired time until this flag is truthy
392 if (!this.trackExpiredTime_) {
393 return;
394 }
395
396 // if the update was the result of a rendition switch do not
397 // attempt to calculate expired_ since media-sequences need not
398 // correlate between renditions/variants
399 if (update.uri !== outdated.uri) {
400 return;
401 }
402
403 // try using precise timing from first segment of the updated
404 // playlist
405 if (update.segments.length) {
406 if (update.segments[0].start !== undefined) {
407 this.expired_ = update.segments[0].start;
408 return;
409 } else if (update.segments[0].end !== undefined) {
410 this.expired_ = update.segments[0].end - update.segments[0].duration;
411 return;
412 }
413 }
414
415 // calculate expired by walking the outdated playlist
416 i = update.mediaSequence - outdated.mediaSequence - 1;
417
418 for (; i >= 0; i--) {
419 segment = outdated.segments[i];
420
421 if (!segment) {
422 // we missed information on this segment completely between
423 // playlist updates so we'll have to take an educated guess
424 // once we begin buffering again, any error we introduce can
425 // be corrected
426 this.expired_ += outdated.targetDuration || 10;
427 continue;
428 }
429
430 if (segment.end !== undefined) {
431 this.expired_ = segment.end;
432 return;
433 }
434 if (segment.start !== undefined) {
435 this.expired_ = segment.start + segment.duration;
436 return;
437 }
438 this.expired_ += segment.duration;
439 }
440 }
441
442 /**
443 * Determine the index of the segment that contains a specified
444 * playback position in the current media playlist. Early versions
445 * of the HLS specification require segment durations to be rounded
446 * to the nearest integer which means it may not be possible to
447 * determine the correct segment for a playback position if that
448 * position is within .5 seconds of the segment duration. This
449 * function will always return the lower of the two possible indices
450 * in those cases.
451 *
452 * @param time {number} The number of seconds since the earliest
453 * possible position to determine the containing segment for
454 * @returns {number} The number of the media segment that contains
455 * that time position. If the specified playback position is outside
456 * the time range of the current set of media segments, the return
457 * value will be clamped to the index of the segment containing the
458 * closest playback position that is currently available.
459 */
460 getMediaIndexForTime_(time) {
461 let i;
462 let segment;
463 let originalTime = time;
464 let numSegments = this.media_.segments.length;
465 let lastSegment = numSegments - 1;
466 let startIndex;
467 let endIndex;
468 let knownStart;
469 let knownEnd;
470
471 if (!this.media_) {
472 return 0;
473 }
474
475 // when the requested position is earlier than the current set of
476 // segments, return the earliest segment index
477 if (time < 0) {
478 return 0;
479 }
480
481 // find segments with known timing information that bound the
482 // target time
483 for (i = 0; i < numSegments; i++) {
484 segment = this.media_.segments[i];
485 if (segment.end) {
486 if (segment.end > time) {
487 knownEnd = segment.end;
488 endIndex = i;
489 break;
490 } else {
491 knownStart = segment.end;
492 startIndex = i + 1;
493 }
494 }
495 }
496
497 // use the bounds we just found and playlist information to
498 // estimate the segment that contains the time we are looking for
499 if (startIndex !== undefined) {
500 // We have a known-start point that is before our desired time so
501 // walk from that point forwards
502 time = time - knownStart;
503 for (i = startIndex; i < (endIndex || numSegments); i++) {
504 segment = this.media_.segments[i];
505 time -= segment.duration;
506
507 if (time < 0) {
508 return i;
509 }
510 }
511
512 if (i >= endIndex) {
513 // We haven't found a segment but we did hit a known end point
514 // so fallback to interpolating between the segment index
515 // based on the known span of the timeline we are dealing with
516 // and the number of segments inside that span
517 return startIndex + Math.floor(
518 ((originalTime - knownStart) / (knownEnd - knownStart)) *
519 (endIndex - startIndex));
520 }
521
522 // We _still_ haven't found a segment so load the last one
523 return lastSegment;
524 } else if (endIndex !== undefined) {
525 // We _only_ have a known-end point that is after our desired time so
526 // walk from that point backwards
527 time = knownEnd - time;
528 for (i = endIndex; i >= 0; i--) {
529 segment = this.media_.segments[i];
530 time -= segment.duration;
531
532 if (time < 0) {
533 return i;
534 }
535 }
536
537 // We haven't found a segment so load the first one if time is zero
538 if (time === 0) {
539 return 0;
540 } else {
541 return -1;
542 }
543 } else {
544 // We known nothing so walk from the front of the playlist,
545 // subtracting durations until we find a segment that contains
546 // time and return it
547 time = time - this.expired_;
548
549 if (time < 0) {
550 return -1;
551 }
552
553 for (i = 0; i < numSegments; i++) {
554 segment = this.media_.segments[i];
555 time -= segment.duration;
556 if (time < 0) {
557 return i;
558 }
559 }
560 // We are out of possible candidates so load the last one...
561 // The last one is the least likely to overlap a buffer and therefore
562 // the one most likely to tell us something about the timeline
563 return lastSegment;
564 }
565 }
566 }
......
1 /** 1 /**
2 * Playlist related utilities. 2 * Playlist related utilities.
3 */ 3 */
4 (function(window, videojs) { 4 import {createTimeRange} from 'video.js';
5 'use strict'; 5
6 6 const backwardDuration = function(playlist, endSequence) {
7 var duration, intervalDuration, backwardDuration, forwardDuration, seekable; 7 let result = 0;
8 8 let i = endSequence - playlist.mediaSequence;
9 backwardDuration = function(playlist, endSequence) { 9 // if a start time is available for segment immediately following
10 var result = 0, segment, i; 10 // the interval, use it
11 11 let segment = playlist.segments[i];
12 i = endSequence - playlist.mediaSequence; 12
13 // if a start time is available for segment immediately following 13 // Walk backward until we find the latest segment with timeline
14 // the interval, use it 14 // information that is earlier than endSequence
15 if (segment) {
16 if (typeof segment.start !== 'undefined') {
17 return { result: segment.start, precise: true };
18 }
19 if (typeof segment.end !== 'undefined') {
20 return {
21 result: segment.end - segment.duration,
22 precise: true
23 };
24 }
25 }
26 while (i--) {
15 segment = playlist.segments[i]; 27 segment = playlist.segments[i];
16 // Walk backward until we find the latest segment with timeline 28 if (typeof segment.end !== 'undefined') {
17 // information that is earlier than endSequence 29 return { result: result + segment.end, precise: true };
18 if (segment) {
19 if (segment.start !== undefined) {
20 return { result: segment.start, precise: true };
21 }
22 if (segment.end !== undefined) {
23 return {
24 result: segment.end - segment.duration,
25 precise: true
26 };
27 }
28 } 30 }
29 while (i--) {
30 segment = playlist.segments[i];
31 if (segment.end !== undefined) {
32 return { result: result + segment.end, precise: true };
33 }
34 31
35 result += segment.duration; 32 result += segment.duration;
36 33
37 if (segment.start !== undefined) { 34 if (typeof segment.start !== 'undefined') {
38 return { result: result + segment.start, precise: true }; 35 return { result: result + segment.start, precise: true };
39 }
40 } 36 }
41 return { result: result, precise: false }; 37 }
42 }; 38 return { result, precise: false };
43 39 };
44 forwardDuration = function(playlist, endSequence) { 40
45 var result = 0, segment, i; 41 const forwardDuration = function(playlist, endSequence) {
46 42 let result = 0;
47 i = endSequence - playlist.mediaSequence; 43 let segment;
48 // Walk forward until we find the earliest segment with timeline 44 let i = endSequence - playlist.mediaSequence;
49 // information 45 // Walk forward until we find the earliest segment with timeline
50 for (; i < playlist.segments.length; i++) { 46 // information
51 segment = playlist.segments[i]; 47
52 if (segment.start !== undefined) { 48 for (; i < playlist.segments.length; i++) {
53 return { 49 segment = playlist.segments[i];
54 result: segment.start - result, 50 if (typeof segment.start !== 'undefined') {
55 precise: true 51 return {
56 }; 52 result: segment.start - result,
57 } 53 precise: true
58 54 };
59 result += segment.duration;
60
61 if (segment.end !== undefined) {
62 return {
63 result: segment.end - result,
64 precise: true
65 };
66 }
67
68 }
69 // indicate we didn't find a useful duration estimate
70 return { result: -1, precise: false };
71 };
72
73 /**
74 * Calculate the media duration from the segments associated with a
75 * playlist. The duration of a subinterval of the available segments
76 * may be calculated by specifying an end index.
77 *
78 * @param playlist {object} a media playlist object
79 * @param endSequence {number} (optional) an exclusive upper boundary
80 * for the playlist. Defaults to playlist length.
81 * @return {number} the duration between the first available segment
82 * and end index.
83 */
84 intervalDuration = function(playlist, endSequence) {
85 var backward, forward;
86
87 if (endSequence === undefined) {
88 endSequence = playlist.mediaSequence + playlist.segments.length;
89 } 55 }
90 56
91 if (endSequence < playlist.mediaSequence) { 57 result += segment.duration;
92 return 0;
93 }
94 58
95 // do a backward walk to estimate the duration 59 if (typeof segment.end !== 'undefined') {
96 backward = backwardDuration(playlist, endSequence); 60 return {
97 if (backward.precise) { 61 result: segment.end - result,
98 // if we were able to base our duration estimate on timing 62 precise: true
99 // information provided directly from the Media Source, return 63 };
100 // it
101 return backward.result;
102 } 64 }
103 65
104 // walk forward to see if a precise duration estimate can be made 66 }
105 // that way 67 // indicate we didn't find a useful duration estimate
106 forward = forwardDuration(playlist, endSequence); 68 return { result: -1, precise: false };
107 if (forward.precise) { 69 };
108 // we found a segment that has been buffered and so it's
109 // position is known precisely
110 return forward.result;
111 }
112 70
113 // return the less-precise, playlist-based duration estimate 71 /**
72 * Calculate the media duration from the segments associated with a
73 * playlist. The duration of a subinterval of the available segments
74 * may be calculated by specifying an end index.
75 *
76 * @param playlist {object} a media playlist object
77 * @param endSequence {number} (optional) an exclusive upper boundary
78 * for the playlist. Defaults to playlist length.
79 * @return {number} the duration between the first available segment
80 * and end index.
81 */
82 const intervalDuration = function(playlist, endSequence) {
83 let backward;
84 let forward;
85
86 if (typeof endSequence === 'undefined') {
87 endSequence = playlist.mediaSequence + playlist.segments.length;
88 }
89
90 if (endSequence < playlist.mediaSequence) {
91 return 0;
92 }
93
94 // do a backward walk to estimate the duration
95 backward = backwardDuration(playlist, endSequence);
96 if (backward.precise) {
97 // if we were able to base our duration estimate on timing
98 // information provided directly from the Media Source, return
99 // it
114 return backward.result; 100 return backward.result;
115 }; 101 }
116
117 /**
118 * Calculates the duration of a playlist. If a start and end index
119 * are specified, the duration will be for the subset of the media
120 * timeline between those two indices. The total duration for live
121 * playlists is always Infinity.
122 * @param playlist {object} a media playlist object
123 * @param endSequence {number} (optional) an exclusive upper
124 * boundary for the playlist. Defaults to the playlist media
125 * sequence number plus its length.
126 * @param includeTrailingTime {boolean} (optional) if false, the
127 * interval between the final segment and the subsequent segment
128 * will not be included in the result
129 * @return {number} the duration between the start index and end
130 * index.
131 */
132 duration = function(playlist, endSequence, includeTrailingTime) {
133 if (!playlist) {
134 return 0;
135 }
136 102
137 if (includeTrailingTime === undefined) { 103 // walk forward to see if a precise duration estimate can be made
138 includeTrailingTime = true; 104 // that way
139 } 105 forward = forwardDuration(playlist, endSequence);
106 if (forward.precise) {
107 // we found a segment that has been buffered and so it's
108 // position is known precisely
109 return forward.result;
110 }
140 111
141 // if a slice of the total duration is not requested, use 112 // return the less-precise, playlist-based duration estimate
142 // playlist-level duration indicators when they're present 113 return backward.result;
143 if (endSequence === undefined) { 114 };
144 // if present, use the duration specified in the playlist
145 if (playlist.totalDuration) {
146 return playlist.totalDuration;
147 }
148
149 // duration should be Infinity for live playlists
150 if (!playlist.endList) {
151 return window.Infinity;
152 }
153 }
154 115
155 // calculate the total duration based on the segment durations 116 /**
156 return intervalDuration(playlist, 117 * Calculates the duration of a playlist. If a start and end index
157 endSequence, 118 * are specified, the duration will be for the subset of the media
158 includeTrailingTime); 119 * timeline between those two indices. The total duration for live
159 }; 120 * playlists is always Infinity.
160 121 * @param playlist {object} a media playlist object
161 /** 122 * @param endSequence {number} (optional) an exclusive upper
162 * Calculates the interval of time that is currently seekable in a 123 * boundary for the playlist. Defaults to the playlist media
163 * playlist. The returned time ranges are relative to the earliest 124 * sequence number plus its length.
164 * moment in the specified playlist that is still available. A full 125 * @param includeTrailingTime {boolean} (optional) if false, the
165 * seekable implementation for live streams would need to offset 126 * interval between the final segment and the subsequent segment
166 * these values by the duration of content that has expired from the 127 * will not be included in the result
167 * stream. 128 * @return {number} the duration between the start index and end
168 * @param playlist {object} a media playlist object 129 * index.
169 * @return {TimeRanges} the periods of time that are valid targets 130 */
170 * for seeking 131 export const duration = function(playlist, endSequence, includeTrailingTime) {
171 */ 132 if (!playlist) {
172 seekable = function(playlist) { 133 return 0;
173 var start, end; 134 }
174 135
175 // without segments, there are no seekable ranges 136 if (typeof includeTrailingTime === 'undefined') {
176 if (!playlist.segments) { 137 includeTrailingTime = true;
177 return videojs.createTimeRange(); 138 }
139
140 // if a slice of the total duration is not requested, use
141 // playlist-level duration indicators when they're present
142 if (typeof endSequence === 'undefined') {
143 // if present, use the duration specified in the playlist
144 if (playlist.totalDuration) {
145 return playlist.totalDuration;
178 } 146 }
179 // when the playlist is complete, the entire duration is seekable 147
180 if (playlist.endList) { 148 // duration should be Infinity for live playlists
181 return videojs.createTimeRange(0, duration(playlist)); 149 if (!playlist.endList) {
150 return window.Infinity;
182 } 151 }
152 }
183 153
184 // live playlists should not expose three segment durations worth 154 // calculate the total duration based on the segment durations
185 // of content from the end of the playlist 155 return intervalDuration(playlist,
186 // https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-6.3.3 156 endSequence,
187 start = intervalDuration(playlist, playlist.mediaSequence); 157 includeTrailingTime);
188 end = intervalDuration(playlist, 158 };
189 playlist.mediaSequence + Math.max(0, playlist.segments.length - 3)); 159
190 return videojs.createTimeRange(start, end); 160 /**
191 }; 161 * Calculates the interval of time that is currently seekable in a
192 162 * playlist. The returned time ranges are relative to the earliest
193 // exports 163 * moment in the specified playlist that is still available. A full
194 videojs.Hls.Playlist = { 164 * seekable implementation for live streams would need to offset
195 duration: duration, 165 * these values by the duration of content that has expired from the
196 seekable: seekable 166 * stream.
197 }; 167 * @param playlist {object} a media playlist object
198 })(window, window.videojs); 168 * @return {TimeRanges} the periods of time that are valid targets
169 * for seeking
170 */
171 export const seekable = function(playlist) {
172 let start;
173 let end;
174
175 // without segments, there are no seekable ranges
176 if (!playlist.segments) {
177 return createTimeRange();
178 }
179 // when the playlist is complete, the entire duration is seekable
180 if (playlist.endList) {
181 return createTimeRange(0, duration(playlist));
182 }
183
184 // live playlists should not expose three segment durations worth
185 // of content from the end of the playlist
186 // https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-6.3.3
187 start = intervalDuration(playlist, playlist.mediaSequence);
188 end = intervalDuration(playlist,
189 playlist.mediaSequence +
190 Math.max(0, playlist.segments.length - 3));
191 return createTimeRange(start, end);
192 };
193
194 // exports
195 export default {
196 duration,
197 seekable
198 };
......
1 import document from 'global/document';
2 /* eslint-disable max-len */
3 /**
4 * Constructs a new URI by interpreting a path relative to another
5 * URI.
6 * @param basePath {string} a relative or absolute URI
7 * @param path {string} a path part to combine with the base
8 * @return {string} a URI that is equivalent to composing `base`
9 * with `path`
10 * @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue
11 */
12 /* eslint-enable max-len */
13 const resolveUrl = function(basePath, path) {
14 // use the base element to get the browser to handle URI resolution
15 let oldBase = document.querySelector('base');
16 let docHead = document.querySelector('head');
17 let a = document.createElement('a');
18 let base = oldBase;
19 let oldHref;
20 let result;
21
22 // prep the document
23 if (oldBase) {
24 oldHref = oldBase.href;
25 } else {
26 base = docHead.appendChild(document.createElement('base'));
27 }
28
29 base.href = basePath;
30 a.href = path;
31 result = a.href;
32
33 // clean up
34 if (oldBase) {
35 oldBase.href = oldHref;
36 } else {
37 docHead.removeChild(base);
38 }
39 return result;
40 };
41
42 export default resolveUrl;
...@@ -2,6 +2,10 @@ import m3u8 from './m3u8'; ...@@ -2,6 +2,10 @@ import m3u8 from './m3u8';
2 import Stream from './stream'; 2 import Stream from './stream';
3 import videojs from 'video.js'; 3 import videojs from 'video.js';
4 import {Decrypter, decrypt, AsyncStream} from './decrypter'; 4 import {Decrypter, decrypt, AsyncStream} from './decrypter';
5 import Playlist from './playlist';
6 import PlaylistLoader from './playlist-loader';
7 import xhr from './xhr';
8
5 9
6 if(typeof window.videojs.Hls === 'undefined') { 10 if(typeof window.videojs.Hls === 'undefined') {
7 videojs.Hls = {}; 11 videojs.Hls = {};
...@@ -11,3 +15,6 @@ videojs.m3u8 = m3u8; ...@@ -11,3 +15,6 @@ videojs.m3u8 = m3u8;
11 videojs.Hls.decrypt = decrypt; 15 videojs.Hls.decrypt = decrypt;
12 videojs.Hls.Decrypter = Decrypter; 16 videojs.Hls.Decrypter = Decrypter;
13 videojs.Hls.AsyncStream = AsyncStream; 17 videojs.Hls.AsyncStream = AsyncStream;
18 videojs.Hls.xhr = xhr;
19 videojs.Hls.Playlist = Playlist;
20 videojs.Hls.PlaylistLoader = PlaylistLoader;
......
1 (function(videojs) { 1 /**
2 'use strict'; 2 * A wrapper for videojs.xhr that tracks bandwidth.
3 */
4 import {xhr as videojsXHR, mergeOptions} from 'video.js';
5 const xhr = function(options, callback) {
6 // Add a default timeout for all hls requests
7 options = mergeOptions({
8 timeout: 45e3
9 }, options);
3 10
4 /** 11 let request = videojsXHR(options, function(error, response) {
5 * A wrapper for videojs.xhr that tracks bandwidth. 12 if (!error && request.response) {
6 */ 13 request.responseTime = (new Date()).getTime();
7 videojs.Hls.xhr = function(options, callback) { 14 request.roundTripTime = request.responseTime - request.requestTime;
8 // Add a default timeout for all hls requests 15 request.bytesReceived = request.response.byteLength || request.response.length;
9 options = videojs.mergeOptions({ 16 if (!request.bandwidth) {
10 timeout: 45e3 17 request.bandwidth =
11 }, options); 18 Math.floor((request.bytesReceived / request.roundTripTime) * 8 * 1000);
12
13 var request = videojs.xhr(options, function(error, response) {
14 if (!error && request.response) {
15 request.responseTime = (new Date()).getTime();
16 request.roundTripTime = request.responseTime - request.requestTime;
17 request.bytesReceived = request.response.byteLength || request.response.length;
18 if (!request.bandwidth) {
19 request.bandwidth = Math.floor((request.bytesReceived / request.roundTripTime) * 8 * 1000);
20 }
21 } 19 }
20 }
22 21
23 // videojs.xhr now uses a specific code on the error object to signal that a request has 22 // videojs.xhr now uses a specific code
24 // timed out errors of setting a boolean on the request object 23 // on the error object to signal that a request has
25 if (error || request.timedout) { 24 // timed out errors of setting a boolean on the request object
26 request.timedout = request.timedout || (error.code === 'ETIMEDOUT'); 25 if (error || request.timedout) {
27 } else { 26 request.timedout = request.timedout || (error.code === 'ETIMEDOUT');
28 request.timedout = false; 27 } else {
29 } 28 request.timedout = false;
29 }
30 30
31 // videojs.xhr no longer considers status codes outside of 200 and 0 31 // videojs.xhr no longer considers status codes outside of 200 and 0
32 // (for file uris) to be errors, but the old XHR did, so emulate that 32 // (for file uris) to be errors, but the old XHR did, so emulate that
33 // behavior. Status 206 may be used in response to byterange requests. 33 // behavior. Status 206 may be used in response to byterange requests.
34 if (!error && 34 if (!error &&
35 response.statusCode !== 200 && 35 response.statusCode !== 200 &&
36 response.statusCode !== 206 && 36 response.statusCode !== 206 &&
37 response.statusCode !== 0) { 37 response.statusCode !== 0) {
38 error = new Error('XHR Failed with a response of: ' + 38 error = new Error('XHR Failed with a response of: ' +
39 (request && (request.response || request.responseText))); 39 (request && (request.response || request.responseText)));
40 } 40 }
41
42 callback(error, request);
43 });
41 44
42 callback(error, request); 45 request.requestTime = (new Date()).getTime();
43 }); 46 return request;
47 };
44 48
45 request.requestTime = (new Date()).getTime(); 49 export default xhr;
46 return request;
47 };
48 })(window.videojs);
......
...@@ -16,16 +16,11 @@ ...@@ -16,16 +16,11 @@
16 <script src="/node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script> 16 <script src="/node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script>
17 17
18 <script src="/src/videojs-contrib-hls.js"></script> 18 <script src="/src/videojs-contrib-hls.js"></script>
19 <script src="/src/xhr.js"></script>
20 <script src="/dist/videojs-contrib-hls.js"></script> 19 <script src="/dist/videojs-contrib-hls.js"></script>
21 <script src="/src/playlist.js"></script>
22 <script src="/src/playlist-loader.js"></script>
23 <script src="/src/bin-utils.js"></script> 20 <script src="/src/bin-utils.js"></script>
24 21
25 <script src="/test/videojs-contrib-hls.test.js"></script> 22 <script src="/test/videojs-contrib-hls.test.js"></script>
26 <script src="/dist-test/videojs-contrib-hls.js"></script> 23 <script src="/dist-test/videojs-contrib-hls.js"></script>
27 <script src="/test/playlist.test.js"></script>
28 <script src="/test/playlist-loader.test.js"></script>
29 24
30 </body> 25 </body>
31 </html> 26 </html>
......
...@@ -16,11 +16,8 @@ var DEFAULTS = { ...@@ -16,11 +16,8 @@ var DEFAULTS = {
16 16
17 // these two stub old functionality 17 // these two stub old functionality
18 'src/videojs-contrib-hls.js', 18 'src/videojs-contrib-hls.js',
19 'src/xhr.js',
20 'dist/videojs-contrib-hls.js', 19 'dist/videojs-contrib-hls.js',
21 20
22 'src/playlist.js',
23 'src/playlist-loader.js',
24 'src/bin-utils.js', 21 'src/bin-utils.js',
25 22
26 'test/stub.test.js', 23 'test/stub.test.js',
...@@ -45,7 +42,7 @@ var DEFAULTS = { ...@@ -45,7 +42,7 @@ var DEFAULTS = {
45 ], 42 ],
46 43
47 preprocessors: { 44 preprocessors: {
48 'test/{decrypter,stub,m3u8}.test.js': ['browserify'] 45 'test/{playlist*,decrypter,stub,m3u8}.test.js': ['browserify']
49 }, 46 },
50 47
51 reporters: ['dots'], 48 reporters: ['dots'],
......
1 (function(window) { 1 import sinon from 'sinon';
2 'use strict'; 2 import QUnit from 'qunit';
3 var 3 import PlaylistLoader from '../src/playlist-loader';
4 sinonXhr, 4 import videojs from 'video.js';
5 clock, 5 // Attempts to produce an absolute URL to a given relative path
6 requests, 6 // based on window.location.href
7 videojs = window.videojs, 7 const urlTo = function(path) {
8 8 return window.location.href
9 // Attempts to produce an absolute URL to a given relative path 9 .split('/')
10 // based on window.location.href 10 .slice(0, -1)
11 urlTo = function(path) { 11 .concat([path])
12 return window.location.href 12 .join('/');
13 .split('/') 13 };
14 .slice(0, -1) 14
15 .concat([path]) 15 QUnit.module('Playlist Loader', {
16 .join('/'); 16 beforeEach() {
17 // fake XHRs
18 this.oldXHR = videojs.xhr.XMLHttpRequest;
19 this.sinonXhr = sinon.useFakeXMLHttpRequest();
20 this.requests = [];
21 this.sinonXhr.onCreate = (xhr) => {
22 // force the XHR2 timeout polyfill
23 xhr.timeout = null;
24 this.requests.push(xhr);
17 }; 25 };
18 26
19 QUnit.module('Playlist Loader', { 27 // fake timers
20 setup: function() { 28 this.clock = sinon.useFakeTimers();
21 // fake XHRs 29 videojs.xhr.XMLHttpRequest = this.sinonXhr;
22 sinonXhr = sinon.useFakeXMLHttpRequest(); 30 },
23 videojs.xhr.XMLHttpRequest = sinonXhr; 31 afterEach() {
24 32 this.sinonXhr.restore();
25 requests = []; 33 this.clock.restore();
26 sinonXhr.onCreate = function(xhr) { 34 videojs.xhr.XMLHttpRequest = this.oldXHR;
27 // force the XHR2 timeout polyfill 35 }
28 xhr.timeout = undefined; 36 });
29 requests.push(xhr); 37
30 }; 38 QUnit.test('throws if the playlist url is empty or undefined', function() {
31 39 QUnit.throws(function() {
32 // fake timers 40 PlaylistLoader();
33 clock = sinon.useFakeTimers(); 41 }, 'requires an argument');
34 }, 42 QUnit.throws(function() {
35 teardown: function() { 43 PlaylistLoader('');
36 sinonXhr.restore(); 44 }, 'does not accept the empty string');
37 videojs.xhr.XMLHttpRequest = window.XMLHttpRequest; 45 });
38 clock.restore(); 46
39 } 47 QUnit.test('starts without any metadata', function() {
40 }); 48 let loader = new PlaylistLoader('master.m3u8');
41 49
42 test('throws if the playlist url is empty or undefined', function() { 50 QUnit.strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet');
43 throws(function() { 51 });
44 videojs.Hls.PlaylistLoader(); 52
45 }, 'requires an argument'); 53 QUnit.test('starts with no expired time', function() {
46 throws(function() { 54 let loader = new PlaylistLoader('media.m3u8');
47 videojs.Hls.PlaylistLoader(''); 55
48 }, 'does not accept the empty string'); 56 this.requests.pop().respond(200, null,
49 }); 57 '#EXTM3U\n' +
50 58 '#EXTINF:10,\n' +
51 test('starts without any metadata', function() { 59 '0.ts\n');
52 var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); 60 QUnit.equal(loader.expired_,
53 strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); 61 0,
54 }); 62 'zero seconds expired');
55 63 });
56 test('starts with no expired time', function() { 64
57 var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); 65 QUnit.test('requests the initial playlist immediately', function() {
58 requests.pop().respond(200, null, 66 /* eslint-disable no-unused-vars */
59 '#EXTM3U\n' + 67 let loader = new PlaylistLoader('master.m3u8');
60 '#EXTINF:10,\n' + 68 /* eslint-enable no-unused-vars */
61 '0.ts\n'); 69
62 equal(loader.expired_, 70 QUnit.strictEqual(this.requests.length, 1, 'made a request');
63 0, 71 QUnit.strictEqual(this.requests[0].url,
64 'zero seconds expired'); 72 'master.m3u8',
65 }); 73 'requested the initial playlist');
66 74 });
67 test('requests the initial playlist immediately', function() { 75
68 new videojs.Hls.PlaylistLoader('master.m3u8'); 76 QUnit.test('moves to HAVE_MASTER after loading a master playlist', function() {
69 strictEqual(requests.length, 1, 'made a request'); 77 let loader = new PlaylistLoader('master.m3u8');
70 strictEqual(requests[0].url, 'master.m3u8', 'requested the initial playlist'); 78 let state;
71 }); 79
72 80 loader.on('loadedplaylist', function() {
73 test('moves to HAVE_MASTER after loading a master playlist', function() { 81 state = loader.state;
74 var loader = new videojs.Hls.PlaylistLoader('master.m3u8'), state; 82 });
75 loader.on('loadedplaylist', function() { 83 this.requests.pop().respond(200, null,
76 state = loader.state; 84 '#EXTM3U\n' +
77 }); 85 '#EXT-X-STREAM-INF:\n' +
78 requests.pop().respond(200, null, 86 'media.m3u8\n');
79 '#EXTM3U\n' + 87 QUnit.ok(loader.master, 'the master playlist is available');
80 '#EXT-X-STREAM-INF:\n' + 88 QUnit.strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct');
81 'media.m3u8\n'); 89 });
82 ok(loader.master, 'the master playlist is available'); 90
83 strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct'); 91 QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', function() {
84 }); 92 let loadedmetadatas = 0;
85 93 let loader = new PlaylistLoader('media.m3u8');
86 test('jumps to HAVE_METADATA when initialized with a media playlist', function() { 94
87 var 95 loader.on('loadedmetadata', function() {
88 loadedmetadatas = 0, 96 loadedmetadatas++;
89 loader = new videojs.Hls.PlaylistLoader('media.m3u8'); 97 });
90 loader.on('loadedmetadata', function() { 98 this.requests.pop().respond(200, null,
91 loadedmetadatas++;
92 });
93 requests.pop().respond(200, null,
94 '#EXTM3U\n' +
95 '#EXTINF:10,\n' +
96 '0.ts\n' +
97 '#EXT-X-ENDLIST\n');
98 ok(loader.master, 'infers a master playlist');
99 ok(loader.media(), 'sets the media playlist');
100 ok(loader.media().uri, 'sets the media playlist URI');
101 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
102 strictEqual(requests.length, 0, 'no more requests are made');
103 strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata');
104 });
105
106 test('jumps to HAVE_METADATA when initialized with a live media playlist', function() {
107 var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
108 requests.pop().respond(200, null,
109 '#EXTM3U\n' +
110 '#EXTINF:10,\n' +
111 '0.ts\n');
112 ok(loader.master, 'infers a master playlist');
113 ok(loader.media(), 'sets the media playlist');
114 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
115 });
116
117 test('moves to HAVE_METADATA after loading a media playlist', function() {
118 var
119 loadedPlaylist = 0,
120 loadedMetadata = 0,
121 loader = new videojs.Hls.PlaylistLoader('master.m3u8');
122 loader.on('loadedplaylist', function() {
123 loadedPlaylist++;
124 });
125 loader.on('loadedmetadata', function() {
126 loadedMetadata++;
127 });
128 requests.pop().respond(200, null,
129 '#EXTM3U\n' +
130 '#EXT-X-STREAM-INF:\n' +
131 'media.m3u8\n' +
132 'alt.m3u8\n');
133 strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once');
134 strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata');
135 strictEqual(requests.length, 1, 'requests the media playlist');
136 strictEqual(requests[0].method, 'GET', 'GETs the media playlist');
137 strictEqual(requests[0].url,
138 urlTo('media.m3u8'),
139 'requests the first playlist');
140
141 requests.pop().respond(200, null,
142 '#EXTM3U\n' +
143 '#EXTINF:10,\n' +
144 '0.ts\n');
145 ok(loader.master, 'sets the master playlist');
146 ok(loader.media(), 'sets the media playlist');
147 strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice');
148 strictEqual(loadedMetadata, 1, 'fired loadedmetadata once');
149 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
150 });
151
152 test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
153 var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
154 requests.pop().respond(200, null,
155 '#EXTM3U\n' +
156 '#EXTINF:10,\n' +
157 '0.ts\n');
158 clock.tick(10 * 1000); // 10s, one target duration
159 strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct');
160 strictEqual(requests.length, 1, 'requested playlist');
161 strictEqual(requests[0].url,
162 urlTo('live.m3u8'),
163 'refreshes the media playlist');
164 });
165
166 test('returns to HAVE_METADATA after refreshing the playlist', function() {
167 var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
168 requests.pop().respond(200, null,
169 '#EXTM3U\n' +
170 '#EXTINF:10,\n' +
171 '0.ts\n');
172 clock.tick(10 * 1000); // 10s, one target duration
173 requests.pop().respond(200, null,
174 '#EXTM3U\n' +
175 '#EXTINF:10,\n' +
176 '1.ts\n');
177 strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
178 });
179
180 test('does not increment expired seconds before firstplay is triggered', function() {
181 var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
182 requests.pop().respond(200, null,
183 '#EXTM3U\n' +
184 '#EXT-X-MEDIA-SEQUENCE:0\n' +
185 '#EXTINF:10,\n' +
186 '0.ts\n' +
187 '#EXTINF:10,\n' +
188 '1.ts\n' +
189 '#EXTINF:10,\n' +
190 '2.ts\n' +
191 '#EXTINF:10,\n' +
192 '3.ts\n');
193 clock.tick(10 * 1000); // 10s, one target duration
194 requests.pop().respond(200, null,
195 '#EXTM3U\n' +
196 '#EXT-X-MEDIA-SEQUENCE:1\n' +
197 '#EXTINF:10,\n' +
198 '1.ts\n' +
199 '#EXTINF:10,\n' +
200 '2.ts\n' +
201 '#EXTINF:10,\n' +
202 '3.ts\n' +
203 '#EXTINF:10,\n' +
204 '4.ts\n');
205 equal(loader.expired_, 0, 'expired one segment');
206 });
207
208 test('increments expired seconds after a segment is removed', function() {
209 var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
210 loader.trigger('firstplay');
211 requests.pop().respond(200, null,
212 '#EXTM3U\n' +
213 '#EXT-X-MEDIA-SEQUENCE:0\n' +
214 '#EXTINF:10,\n' +
215 '0.ts\n' +
216 '#EXTINF:10,\n' +
217 '1.ts\n' +
218 '#EXTINF:10,\n' +
219 '2.ts\n' +
220 '#EXTINF:10,\n' +
221 '3.ts\n');
222 clock.tick(10 * 1000); // 10s, one target duration
223 requests.pop().respond(200, null,
224 '#EXTM3U\n' +
225 '#EXT-X-MEDIA-SEQUENCE:1\n' +
226 '#EXTINF:10,\n' +
227 '1.ts\n' +
228 '#EXTINF:10,\n' +
229 '2.ts\n' +
230 '#EXTINF:10,\n' +
231 '3.ts\n' +
232 '#EXTINF:10,\n' +
233 '4.ts\n');
234 equal(loader.expired_, 10, 'expired one segment');
235 });
236
237 test('increments expired seconds after a discontinuity', function() {
238 var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
239 loader.trigger('firstplay');
240 requests.pop().respond(200, null,
241 '#EXTM3U\n' +
242 '#EXT-X-MEDIA-SEQUENCE:0\n' +
243 '#EXTINF:10,\n' +
244 '0.ts\n' +
245 '#EXTINF:3,\n' +
246 '1.ts\n' +
247 '#EXT-X-DISCONTINUITY\n' +
248 '#EXTINF:4,\n' +
249 '2.ts\n');
250 clock.tick(10 * 1000); // 10s, one target duration
251 requests.pop().respond(200, null,
252 '#EXTM3U\n' +
253 '#EXT-X-MEDIA-SEQUENCE:1\n' +
254 '#EXTINF:3,\n' +
255 '1.ts\n' +
256 '#EXT-X-DISCONTINUITY\n' +
257 '#EXTINF:4,\n' +
258 '2.ts\n');
259 equal(loader.expired_, 10, 'expired one segment');
260
261 clock.tick(10 * 1000); // 10s, one target duration
262 requests.pop().respond(200, null,
263 '#EXTM3U\n' +
264 '#EXT-X-MEDIA-SEQUENCE:2\n' +
265 '#EXT-X-DISCONTINUITY\n' +
266 '#EXTINF:4,\n' +
267 '2.ts\n');
268 equal(loader.expired_, 13, 'no expirations after the discontinuity yet');
269
270 clock.tick(10 * 1000); // 10s, one target duration
271 requests.pop().respond(200, null,
272 '#EXTM3U\n' +
273 '#EXT-X-MEDIA-SEQUENCE:3\n' +
274 '#EXT-X-DISCONTINUITY-SEQUENCE:1\n' +
275 '#EXTINF:10,\n' +
276 '3.ts\n');
277 equal(loader.expired_, 17, 'tracked expiration across the discontinuity');
278 });
279
280 test('tracks expired seconds properly when two discontinuities expire at once', function() {
281 var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
282 loader.trigger('firstplay');
283 requests.pop().respond(200, null,
284 '#EXTM3U\n' +
285 '#EXT-X-MEDIA-SEQUENCE:0\n' +
286 '#EXTINF:4,\n' +
287 '0.ts\n' +
288 '#EXT-X-DISCONTINUITY\n' +
289 '#EXTINF:5,\n' +
290 '1.ts\n' +
291 '#EXT-X-DISCONTINUITY\n' +
292 '#EXTINF:6,\n' +
293 '2.ts\n' +
294 '#EXTINF:7,\n' +
295 '3.ts\n');
296 clock.tick(10 * 1000);
297 requests.pop().respond(200, null,
298 '#EXTM3U\n' +
299 '#EXT-X-MEDIA-SEQUENCE:3\n' +
300 '#EXT-X-DISCONTINUITY-SEQUENCE:2\n' +
301 '#EXTINF:7,\n' +
302 '3.ts\n');
303 equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities');
304 });
305
306 test('estimates expired if an entire window elapses between live playlist updates', function() {
307 var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
308 loader.trigger('firstplay');
309 requests.pop().respond(200, null,
310 '#EXTM3U\n' +
311 '#EXT-X-MEDIA-SEQUENCE:0\n' +
312 '#EXTINF:4,\n' +
313 '0.ts\n' +
314 '#EXTINF:5,\n' +
315 '1.ts\n');
316
317 clock.tick(10 * 1000);
318 requests.pop().respond(200, null,
319 '#EXTM3U\n' +
320 '#EXT-X-MEDIA-SEQUENCE:4\n' +
321 '#EXTINF:6,\n' +
322 '4.ts\n' +
323 '#EXTINF:7,\n' +
324 '5.ts\n');
325
326 equal(loader.expired_,
327 4 + 5 + (2 * 10),
328 'made a very rough estimate of expired time');
329 });
330
331 test('emits an error when an initial playlist request fails', function() {
332 var
333 errors = [],
334 loader = new videojs.Hls.PlaylistLoader('master.m3u8');
335
336 loader.on('error', function() {
337 errors.push(loader.error);
338 });
339 requests.pop().respond(500);
340
341 strictEqual(errors.length, 1, 'emitted one error');
342 strictEqual(errors[0].status, 500, 'http status is captured');
343 });
344
345 test('errors when an initial media playlist request fails', function() {
346 var
347 errors = [],
348 loader = new videojs.Hls.PlaylistLoader('master.m3u8');
349
350 loader.on('error', function() {
351 errors.push(loader.error);
352 });
353 requests.pop().respond(200, null,
354 '#EXTM3U\n' +
355 '#EXT-X-STREAM-INF:\n' +
356 'media.m3u8\n');
357
358 strictEqual(errors.length, 0, 'emitted no errors');
359
360 requests.pop().respond(500);
361
362 strictEqual(errors.length, 1, 'emitted one error');
363 strictEqual(errors[0].status, 500, 'http status is captured');
364 });
365
366 // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4
367 test('halves the refresh timeout if a playlist is unchanged' +
368 'since the last reload', function() {
369 new videojs.Hls.PlaylistLoader('live.m3u8');
370 requests.pop().respond(200, null,
371 '#EXTM3U\n' +
372 '#EXT-X-MEDIA-SEQUENCE:0\n' +
373 '#EXTINF:10,\n' +
374 '0.ts\n');
375 clock.tick(10 * 1000); // trigger a refresh
376 requests.pop().respond(200, null,
377 '#EXTM3U\n' +
378 '#EXT-X-MEDIA-SEQUENCE:0\n' +
379 '#EXTINF:10,\n' +
380 '0.ts\n');
381 clock.tick(5 * 1000); // half the default target-duration
382
383 strictEqual(requests.length, 1, 'sent a request');
384 strictEqual(requests[0].url,
385 urlTo('live.m3u8'),
386 'requested the media playlist');
387 });
388
389 test('preserves segment metadata across playlist refreshes', function() {
390 var loader = new videojs.Hls.PlaylistLoader('live.m3u8'), segment;
391 requests.pop().respond(200, null,
392 '#EXTM3U\n' +
393 '#EXT-X-MEDIA-SEQUENCE:0\n' +
394 '#EXTINF:10,\n' +
395 '0.ts\n' +
396 '#EXTINF:10,\n' +
397 '1.ts\n' +
398 '#EXTINF:10,\n' +
399 '2.ts\n');
400 // add PTS info to 1.ts
401 segment = loader.media().segments[1];
402 segment.minVideoPts = 14;
403 segment.maxAudioPts = 27;
404 segment.preciseDuration = 10.045;
405
406 clock.tick(10 * 1000); // trigger a refresh
407 requests.pop().respond(200, null,
408 '#EXTM3U\n' +
409 '#EXT-X-MEDIA-SEQUENCE:1\n' +
410 '#EXTINF:10,\n' +
411 '1.ts\n' +
412 '#EXTINF:10,\n' +
413 '2.ts\n');
414
415 deepEqual(loader.media().segments[0], segment, 'preserved segment attributes');
416 });
417
418 test('clears the update timeout when switching quality', function() {
419 var loader = new videojs.Hls.PlaylistLoader('live-master.m3u8'), refreshes = 0;
420 // track the number of playlist refreshes triggered
421 loader.on('mediaupdatetimeout', function() {
422 refreshes++;
423 });
424 // deliver the master
425 requests.pop().respond(200, null,
426 '#EXTM3U\n' +
427 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
428 'live-low.m3u8\n' +
429 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
430 'live-high.m3u8\n');
431 // deliver the low quality playlist
432 requests.pop().respond(200, null,
433 '#EXTM3U\n' +
434 '#EXT-X-MEDIA-SEQUENCE:0\n' +
435 '#EXTINF:10,\n' +
436 'low-0.ts\n');
437 // change to a higher quality playlist
438 loader.media('live-high.m3u8');
439 requests.pop().respond(200, null,
440 '#EXTM3U\n' +
441 '#EXT-X-MEDIA-SEQUENCE:0\n' +
442 '#EXTINF:10,\n' +
443 'high-0.ts\n');
444 clock.tick(10 * 1000); // trigger a refresh
445
446 equal(1, refreshes, 'only one refresh was triggered');
447 });
448
449 test('media-sequence updates are considered a playlist change', function() {
450 new videojs.Hls.PlaylistLoader('live.m3u8');
451 requests.pop().respond(200, null,
452 '#EXTM3U\n' +
453 '#EXT-X-MEDIA-SEQUENCE:0\n' +
454 '#EXTINF:10,\n' +
455 '0.ts\n');
456 clock.tick(10 * 1000); // trigger a refresh
457 requests.pop().respond(200, null,
458 '#EXTM3U\n' +
459 '#EXT-X-MEDIA-SEQUENCE:1\n' +
460 '#EXTINF:10,\n' +
461 '0.ts\n');
462 clock.tick(5 * 1000); // half the default target-duration
463
464 strictEqual(requests.length, 0, 'no request is sent');
465 });
466
467 test('emits an error if a media refresh fails', function() {
468 var
469 errors = 0,
470 errorResponseText = 'custom error message',
471 loader = new videojs.Hls.PlaylistLoader('live.m3u8');
472
473 loader.on('error', function() {
474 errors++;
475 });
476 requests.pop().respond(200, null,
477 '#EXTM3U\n' +
478 '#EXT-X-MEDIA-SEQUENCE:0\n' +
479 '#EXTINF:10,\n' +
480 '0.ts\n');
481 clock.tick(10 * 1000); // trigger a refresh
482 requests.pop().respond(500, null, errorResponseText);
483
484 strictEqual(errors, 1, 'emitted an error');
485 strictEqual(loader.error.status, 500, 'captured the status code');
486 strictEqual(loader.error.responseText, errorResponseText, 'captured the responseText');
487 });
488
489 test('switches media playlists when requested', function() {
490 var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
491 requests.pop().respond(200, null,
492 '#EXTM3U\n' +
493 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
494 'low.m3u8\n' +
495 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
496 'high.m3u8\n');
497 requests.pop().respond(200, null,
498 '#EXTM3U\n' +
499 '#EXT-X-MEDIA-SEQUENCE:0\n' +
500 '#EXTINF:10,\n' +
501 'low-0.ts\n');
502
503 loader.media(loader.master.playlists[1]);
504 strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
505
506 requests.pop().respond(200, null,
507 '#EXTM3U\n' +
508 '#EXT-X-MEDIA-SEQUENCE:0\n' +
509 '#EXTINF:10,\n' +
510 'high-0.ts\n');
511 strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
512 strictEqual(loader.media(),
513 loader.master.playlists[1],
514 'updated the active media');
515 });
516
517 test('can switch playlists immediately after the master is downloaded', function() {
518 var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
519 loader.on('loadedplaylist', function() {
520 loader.media('high.m3u8');
521 });
522 requests.pop().respond(200, null,
523 '#EXTM3U\n' +
524 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
525 'low.m3u8\n' +
526 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
527 'high.m3u8\n');
528 equal(requests[0].url, urlTo('high.m3u8'), 'switched variants immediately');
529 });
530
531 test('can switch media playlists based on URI', function() {
532 var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
533 requests.pop().respond(200, null,
534 '#EXTM3U\n' +
535 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
536 'low.m3u8\n' +
537 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
538 'high.m3u8\n');
539 requests.pop().respond(200, null,
540 '#EXTM3U\n' +
541 '#EXT-X-MEDIA-SEQUENCE:0\n' +
542 '#EXTINF:10,\n' +
543 'low-0.ts\n');
544
545 loader.media('high.m3u8');
546 strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
547
548 requests.pop().respond(200, null,
549 '#EXTM3U\n' +
550 '#EXT-X-MEDIA-SEQUENCE:0\n' +
551 '#EXTINF:10,\n' +
552 'high-0.ts\n');
553 strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
554 strictEqual(loader.media(),
555 loader.master.playlists[1],
556 'updated the active media');
557 });
558
559 test('aborts in-flight playlist refreshes when switching', function() {
560 var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
561 requests.pop().respond(200, null,
562 '#EXTM3U\n' +
563 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
564 'low.m3u8\n' +
565 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
566 'high.m3u8\n');
567 requests.pop().respond(200, null,
568 '#EXTM3U\n' +
569 '#EXT-X-MEDIA-SEQUENCE:0\n' +
570 '#EXTINF:10,\n' +
571 'low-0.ts\n');
572 clock.tick(10 * 1000);
573 loader.media('high.m3u8');
574 strictEqual(requests[0].aborted, true, 'aborted refresh request');
575 ok(!requests[0].onreadystatechange, 'onreadystatechange handlers should be removed on abort');
576 strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
577 });
578
579 test('switching to the active playlist is a no-op', function() {
580 var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
581 requests.pop().respond(200, null,
582 '#EXTM3U\n' +
583 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
584 'low.m3u8\n' +
585 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
586 'high.m3u8\n');
587 requests.pop().respond(200, null,
588 '#EXTM3U\n' +
589 '#EXT-X-MEDIA-SEQUENCE:0\n' +
590 '#EXTINF:10,\n' +
591 'low-0.ts\n' +
592 '#EXT-X-ENDLIST\n');
593 loader.media('low.m3u8');
594
595 strictEqual(requests.length, 0, 'no requests are sent');
596 });
597
598 test('switching to the active live playlist is a no-op', function() {
599 var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
600 requests.pop().respond(200, null,
601 '#EXTM3U\n' +
602 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
603 'low.m3u8\n' +
604 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
605 'high.m3u8\n');
606 requests.pop().respond(200, null,
607 '#EXTM3U\n' +
608 '#EXT-X-MEDIA-SEQUENCE:0\n' +
609 '#EXTINF:10,\n' +
610 'low-0.ts\n');
611 loader.media('low.m3u8');
612
613 strictEqual(requests.length, 0, 'no requests are sent');
614 });
615
616 test('switches back to loaded playlists without re-requesting them', function() {
617 var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
618 requests.pop().respond(200, null,
619 '#EXTM3U\n' +
620 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
621 'low.m3u8\n' +
622 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
623 'high.m3u8\n');
624 requests.pop().respond(200, null,
625 '#EXTM3U\n' +
626 '#EXT-X-MEDIA-SEQUENCE:0\n' +
627 '#EXTINF:10,\n' +
628 'low-0.ts\n' +
629 '#EXT-X-ENDLIST\n');
630 loader.media('high.m3u8');
631 requests.pop().respond(200, null,
632 '#EXTM3U\n' +
633 '#EXT-X-MEDIA-SEQUENCE:0\n' +
634 '#EXTINF:10,\n' +
635 'high-0.ts\n' +
636 '#EXT-X-ENDLIST\n');
637 loader.media('low.m3u8');
638
639 strictEqual(requests.length, 0, 'no outstanding requests');
640 strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist');
641 });
642
643 test('aborts outstanding requests if switching back to an already loaded playlist', function() {
644 var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
645 requests.pop().respond(200, null,
646 '#EXTM3U\n' +
647 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
648 'low.m3u8\n' +
649 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
650 'high.m3u8\n');
651 requests.pop().respond(200, null,
652 '#EXTM3U\n' +
653 '#EXT-X-MEDIA-SEQUENCE:0\n' +
654 '#EXTINF:10,\n' +
655 'low-0.ts\n' +
656 '#EXT-X-ENDLIST\n');
657 loader.media('high.m3u8');
658 loader.media('low.m3u8');
659
660 strictEqual(requests.length, 1, 'requested high playlist');
661 ok(requests[0].aborted, 'aborted playlist request');
662 ok(!requests[0].onreadystatechange, 'onreadystatechange handlers should be removed on abort');
663 strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist');
664 strictEqual(loader.media(), loader.master.playlists[0], 'switched to loaded playlist');
665 });
666
667
668 test('does not abort requests when the same playlist is re-requested', function() {
669 var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
670 requests.pop().respond(200, null,
671 '#EXTM3U\n' +
672 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
673 'low.m3u8\n' +
674 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
675 'high.m3u8\n');
676 requests.pop().respond(200, null,
677 '#EXTM3U\n' +
678 '#EXT-X-MEDIA-SEQUENCE:0\n' +
679 '#EXTINF:10,\n' +
680 'low-0.ts\n' +
681 '#EXT-X-ENDLIST\n');
682 loader.media('high.m3u8');
683 loader.media('high.m3u8');
684
685 strictEqual(requests.length, 1, 'made only one request');
686 ok(!requests[0].aborted, 'request not aborted');
687 });
688
689 test('throws an error if a media switch is initiated too early', function() {
690 var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
691
692 throws(function() {
693 loader.media('high.m3u8');
694 }, 'threw an error from HAVE_NOTHING');
695
696 requests.pop().respond(200, null,
697 '#EXTM3U\n' +
698 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
699 'low.m3u8\n' +
700 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
701 'high.m3u8\n');
702 });
703
704 test('throws an error if a switch to an unrecognized playlist is requested', function() {
705 var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
706 requests.pop().respond(200, null,
707 '#EXTM3U\n' +
708 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
709 'media.m3u8\n');
710
711 throws(function() {
712 loader.media('unrecognized.m3u8');
713 }, 'throws an error');
714 });
715
716 test('dispose cancels the refresh timeout', function() {
717 var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
718 requests.pop().respond(200, null,
719 '#EXTM3U\n' +
720 '#EXT-X-MEDIA-SEQUENCE:0\n' +
721 '#EXTINF:10,\n' +
722 '0.ts\n');
723 loader.dispose();
724 // a lot of time passes...
725 clock.tick(15 * 1000);
726
727 strictEqual(requests.length, 0, 'no refresh request was made');
728 });
729
730 test('dispose aborts pending refresh requests', function() {
731 var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
732 requests.pop().respond(200, null,
733 '#EXTM3U\n' +
734 '#EXT-X-MEDIA-SEQUENCE:0\n' +
735 '#EXTINF:10,\n' +
736 '0.ts\n');
737 clock.tick(10 * 1000);
738
739 loader.dispose();
740 ok(requests[0].aborted, 'refresh request aborted');
741 ok(!requests[0].onreadystatechange, 'onreadystatechange handler should not exist after dispose called');
742 });
743
744 test('errors if requests take longer than 45s', function() {
745 var
746 loader = new videojs.Hls.PlaylistLoader('media.m3u8'),
747 errors = 0;
748 loader.on('error', function() {
749 errors++;
750 });
751 clock.tick(45 * 1000);
752
753 strictEqual(errors, 1, 'fired one error');
754 strictEqual(loader.error.code, 2, 'fired a network error');
755 });
756
757 test('triggers an event when the active media changes', function() {
758 var
759 loader = new videojs.Hls.PlaylistLoader('master.m3u8'),
760 mediaChanges = 0;
761 loader.on('mediachange', function() {
762 mediaChanges++;
763 });
764 requests.pop().respond(200, null,
765 '#EXTM3U\n' +
766 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
767 'low.m3u8\n' +
768 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
769 'high.m3u8\n');
770 requests.shift().respond(200, null,
771 '#EXTM3U\n' +
772 '#EXT-X-MEDIA-SEQUENCE:0\n' +
773 '#EXTINF:10,\n' +
774 'low-0.ts\n' +
775 '#EXT-X-ENDLIST\n');
776 strictEqual(mediaChanges, 0, 'initial selection is not a media change');
777
778 loader.media('high.m3u8');
779 strictEqual(mediaChanges, 0, 'mediachange does not fire immediately');
780
781 requests.shift().respond(200, null,
782 '#EXTM3U\n' + 99 '#EXTM3U\n' +
783 '#EXT-X-MEDIA-SEQUENCE:0\n' +
784 '#EXTINF:10,\n' + 100 '#EXTINF:10,\n' +
785 'high-0.ts\n' +
786 '#EXT-X-ENDLIST\n');
787 strictEqual(mediaChanges, 1, 'fired a mediachange');
788
789 // switch back to an already loaded playlist
790 loader.media('low.m3u8');
791 strictEqual(mediaChanges, 2, 'fired a mediachange');
792
793 // trigger a no-op switch
794 loader.media('low.m3u8');
795 strictEqual(mediaChanges, 2, 'ignored a no-op media change');
796 });
797
798 test('can get media index by playback position for non-live videos', function() {
799 var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
800 requests.shift().respond(200, null,
801 '#EXTM3U\n' +
802 '#EXT-X-MEDIA-SEQUENCE:0\n' +
803 '#EXTINF:4,\n' +
804 '0.ts\n' +
805 '#EXTINF:5,\n' +
806 '1.ts\n' +
807 '#EXTINF:6,\n' +
808 '2.ts\n' +
809 '#EXT-X-ENDLIST\n');
810
811 equal(loader.getMediaIndexForTime_(-1),
812 0,
813 'the index is never less than zero');
814 equal(loader.getMediaIndexForTime_(0), 0, 'time zero is index zero');
815 equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero');
816 equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2');
817 equal(loader.getMediaIndexForTime_(22),
818 2,
819 'time greater than the length is index 2');
820 });
821
822 test('returns the lower index when calculating for a segment boundary', function() {
823 var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
824 requests.shift().respond(200, null,
825 '#EXTM3U\n' +
826 '#EXT-X-MEDIA-SEQUENCE:0\n' +
827 '#EXTINF:4,\n' +
828 '0.ts\n' + 101 '0.ts\n' +
829 '#EXTINF:5,\n' +
830 '1.ts\n' +
831 '#EXT-X-ENDLIST\n'); 102 '#EXT-X-ENDLIST\n');
832 equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches'); 103 QUnit.ok(loader.master, 'infers a master playlist');
833 equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down'); 104 QUnit.ok(loader.media(), 'sets the media playlist');
834 equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5'); 105 QUnit.ok(loader.media().uri, 'sets the media playlist URI');
835 }); 106 QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
836 107 QUnit.strictEqual(this.requests.length, 0, 'no more requests are made');
837 test('accounts for non-zero starting segment time when calculating media index', function() { 108 QUnit.strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata');
838 var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); 109 });
839 requests.shift().respond(200, null, 110
840 '#EXTM3U\n' + 111 QUnit.test('jumps to HAVE_METADATA when initialized with a live media playlist',
841 '#EXT-X-MEDIA-SEQUENCE:1001\n' + 112 function() {
842 '#EXTINF:4,\n' + 113 let loader = new PlaylistLoader('media.m3u8');
843 '1001.ts\n' + 114
844 '#EXTINF:5,\n' + 115 this.requests.pop().respond(200, null,
845 '1002.ts\n'); 116 '#EXTM3U\n' +
846 loader.media().segments[0].end = 154; 117 '#EXTINF:10,\n' +
847 118 '0.ts\n');
848 equal(loader.getMediaIndexForTime_(0), -1, 'the lowest returned value is negative one'); 119 QUnit.ok(loader.master, 'infers a master playlist');
849 equal(loader.getMediaIndexForTime_(45), -1, 'expired content returns negative one'); 120 QUnit.ok(loader.media(), 'sets the media playlist');
850 equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns negative one'); 121 QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
851 equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position'); 122 });
852 equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); 123
853 equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); 124 QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() {
854 equal(loader.getMediaIndexForTime_(50 + 100 + 4), 1, 'calculates within the second segment'); 125 let loadedPlaylist = 0;
855 equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment'); 126 let loadedMetadata = 0;
856 equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment'); 127 let loader = new PlaylistLoader('master.m3u8');
857 }); 128
858 129 loader.on('loadedplaylist', function() {
859 test('prefers precise segment timing when tracking expired time', function() { 130 loadedPlaylist++;
860 var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); 131 });
861 loader.trigger('firstplay'); 132 loader.on('loadedmetadata', function() {
862 requests.shift().respond(200, null, 133 loadedMetadata++;
863 '#EXTM3U\n' + 134 });
864 '#EXT-X-MEDIA-SEQUENCE:1001\n' + 135 this.requests.pop().respond(200, null,
865 '#EXTINF:4,\n' + 136 '#EXTM3U\n' +
866 '1001.ts\n' + 137 '#EXT-X-STREAM-INF:\n' +
867 '#EXTINF:5,\n' + 138 'media.m3u8\n' +
868 '1002.ts\n'); 139 'alt.m3u8\n');
869 // setup the loader with an "imprecise" value as if it had been 140 QUnit.strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once');
870 // accumulating segment durations as they expire 141 QUnit.strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata');
871 loader.expired_ = 160; 142 QUnit.strictEqual(this.requests.length, 1, 'requests the media playlist');
872 // annotate the first segment with a start time 143 QUnit.strictEqual(this.requests[0].method, 'GET', 'GETs the media playlist');
873 // this number would be coming from the Source Buffer in practice 144 QUnit.strictEqual(this.requests[0].url,
874 loader.media().segments[0].end = 150; 145 urlTo('media.m3u8'),
875 146 'requests the first playlist');
876 equal(loader.getMediaIndexForTime_(149), 0, 'prefers the value on the first segment'); 147
877 148 this.requests.pop().respond(200, null,
878 clock.tick(10 * 1000); // trigger a playlist refresh 149 '#EXTM3U\n' +
879 requests.shift().respond(200, null, 150 '#EXTINF:10,\n' +
880 '#EXTM3U\n' + 151 '0.ts\n');
881 '#EXT-X-MEDIA-SEQUENCE:1002\n' + 152 QUnit.ok(loader.master, 'sets the master playlist');
882 '#EXTINF:5,\n' + 153 QUnit.ok(loader.media(), 'sets the media playlist');
883 '1002.ts\n'); 154 QUnit.strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice');
884 equal(loader.getMediaIndexForTime_(150 + 4 + 1), 0, 'tracks precise expired times'); 155 QUnit.strictEqual(loadedMetadata, 1, 'fired loadedmetadata once');
885 }); 156 QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
886 157 });
887 test('accounts for expired time when calculating media index', function() { 158
888 var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); 159 QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
889 requests.shift().respond(200, null, 160 let loader = new PlaylistLoader('live.m3u8');
890 '#EXTM3U\n' + 161
891 '#EXT-X-MEDIA-SEQUENCE:1001\n' + 162 this.requests.pop().respond(200, null,
892 '#EXTINF:4,\n' + 163 '#EXTM3U\n' +
893 '1001.ts\n' + 164 '#EXTINF:10,\n' +
894 '#EXTINF:5,\n' + 165 '0.ts\n');
895 '1002.ts\n'); 166 // 10s, one target duration
896 loader.expired_ = 150; 167 this.clock.tick(10 * 1000);
897 168 QUnit.strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct');
898 equal(loader.getMediaIndexForTime_(0), -1, 'expired content returns a negative index'); 169 QUnit.strictEqual(this.requests.length, 1, 'requested playlist');
899 equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns a negative index'); 170 QUnit.strictEqual(this.requests[0].url,
900 equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position'); 171 urlTo('live.m3u8'),
901 equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); 172 'refreshes the media playlist');
902 equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); 173 });
903 equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment'); 174
904 equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment'); 175 QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function() {
905 }); 176 let loader = new PlaylistLoader('live.m3u8');
906 177
907 test('does not misintrepret playlists missing newlines at the end', function() { 178 this.requests.pop().respond(200, null,
908 var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); 179 '#EXTM3U\n' +
909 requests.shift().respond(200, null, 180 '#EXTINF:10,\n' +
910 '#EXTM3U\n' + 181 '0.ts\n');
911 '#EXT-X-MEDIA-SEQUENCE:0\n' + 182 // 10s, one target duration
912 '#EXTINF:10,\n' + 183 this.clock.tick(10 * 1000);
913 'low-0.ts\n' + 184 this.requests.pop().respond(200, null,
914 '#EXT-X-ENDLIST'); // no newline 185 '#EXTM3U\n' +
915 ok(loader.media().endList, 'flushed the final line of input'); 186 '#EXTINF:10,\n' +
187 '1.ts\n');
188 QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
189 });
190
191 QUnit.test('does not increment expired seconds before firstplay is triggered',
192 function() {
193 let loader = new PlaylistLoader('live.m3u8');
194
195 this.requests.pop().respond(200, null,
196 '#EXTM3U\n' +
197 '#EXT-X-MEDIA-SEQUENCE:0\n' +
198 '#EXTINF:10,\n' +
199 '0.ts\n' +
200 '#EXTINF:10,\n' +
201 '1.ts\n' +
202 '#EXTINF:10,\n' +
203 '2.ts\n' +
204 '#EXTINF:10,\n' +
205 '3.ts\n');
206 // 10s, one target duration
207 this.clock.tick(10 * 1000);
208 this.requests.pop().respond(200, null,
209 '#EXTM3U\n' +
210 '#EXT-X-MEDIA-SEQUENCE:1\n' +
211 '#EXTINF:10,\n' +
212 '1.ts\n' +
213 '#EXTINF:10,\n' +
214 '2.ts\n' +
215 '#EXTINF:10,\n' +
216 '3.ts\n' +
217 '#EXTINF:10,\n' +
218 '4.ts\n');
219 QUnit.equal(loader.expired_, 0, 'expired one segment');
220 });
221
222 QUnit.test('increments expired seconds after a segment is removed', function() {
223 let loader = new PlaylistLoader('live.m3u8');
224
225 loader.trigger('firstplay');
226 this.requests.pop().respond(200, null,
227 '#EXTM3U\n' +
228 '#EXT-X-MEDIA-SEQUENCE:0\n' +
229 '#EXTINF:10,\n' +
230 '0.ts\n' +
231 '#EXTINF:10,\n' +
232 '1.ts\n' +
233 '#EXTINF:10,\n' +
234 '2.ts\n' +
235 '#EXTINF:10,\n' +
236 '3.ts\n');
237 // 10s, one target duration
238 this.clock.tick(10 * 1000);
239 this.requests.pop().respond(200, null,
240 '#EXTM3U\n' +
241 '#EXT-X-MEDIA-SEQUENCE:1\n' +
242 '#EXTINF:10,\n' +
243 '1.ts\n' +
244 '#EXTINF:10,\n' +
245 '2.ts\n' +
246 '#EXTINF:10,\n' +
247 '3.ts\n' +
248 '#EXTINF:10,\n' +
249 '4.ts\n');
250 QUnit.equal(loader.expired_, 10, 'expired one segment');
251 });
252
253 QUnit.test('increments expired seconds after a discontinuity', function() {
254 let loader = new PlaylistLoader('live.m3u8');
255
256 loader.trigger('firstplay');
257 this.requests.pop().respond(200, null,
258 '#EXTM3U\n' +
259 '#EXT-X-MEDIA-SEQUENCE:0\n' +
260 '#EXTINF:10,\n' +
261 '0.ts\n' +
262 '#EXTINF:3,\n' +
263 '1.ts\n' +
264 '#EXT-X-DISCONTINUITY\n' +
265 '#EXTINF:4,\n' +
266 '2.ts\n');
267 // 10s, one target duration
268 this.clock.tick(10 * 1000);
269 this.requests.pop().respond(200, null,
270 '#EXTM3U\n' +
271 '#EXT-X-MEDIA-SEQUENCE:1\n' +
272 '#EXTINF:3,\n' +
273 '1.ts\n' +
274 '#EXT-X-DISCONTINUITY\n' +
275 '#EXTINF:4,\n' +
276 '2.ts\n');
277 QUnit.equal(loader.expired_, 10, 'expired one segment');
278
279 // 10s, one target duration
280 this.clock.tick(10 * 1000);
281 this.requests.pop().respond(200, null,
282 '#EXTM3U\n' +
283 '#EXT-X-MEDIA-SEQUENCE:2\n' +
284 '#EXT-X-DISCONTINUITY\n' +
285 '#EXTINF:4,\n' +
286 '2.ts\n');
287 QUnit.equal(loader.expired_, 13, 'no expirations after the discontinuity yet');
288
289 // 10s, one target duration
290 this.clock.tick(10 * 1000);
291 this.requests.pop().respond(200, null,
292 '#EXTM3U\n' +
293 '#EXT-X-MEDIA-SEQUENCE:3\n' +
294 '#EXT-X-DISCONTINUITY-SEQUENCE:1\n' +
295 '#EXTINF:10,\n' +
296 '3.ts\n');
297 QUnit.equal(loader.expired_, 17, 'tracked expiration across the discontinuity');
298 });
299
300 QUnit.test('tracks expired seconds properly when two discontinuities expire at once',
301 function() {
302 let loader = new PlaylistLoader('live.m3u8');
303
304 loader.trigger('firstplay');
305 this.requests.pop().respond(200, null,
306 '#EXTM3U\n' +
307 '#EXT-X-MEDIA-SEQUENCE:0\n' +
308 '#EXTINF:4,\n' +
309 '0.ts\n' +
310 '#EXT-X-DISCONTINUITY\n' +
311 '#EXTINF:5,\n' +
312 '1.ts\n' +
313 '#EXT-X-DISCONTINUITY\n' +
314 '#EXTINF:6,\n' +
315 '2.ts\n' +
316 '#EXTINF:7,\n' +
317 '3.ts\n');
318 this.clock.tick(10 * 1000);
319 this.requests.pop().respond(200, null,
320 '#EXTM3U\n' +
321 '#EXT-X-MEDIA-SEQUENCE:3\n' +
322 '#EXT-X-DISCONTINUITY-SEQUENCE:2\n' +
323 '#EXTINF:7,\n' +
324 '3.ts\n');
325 QUnit.equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities');
326 });
327
328 QUnit.test('estimates expired if an entire window elapses between live playlist updates',
329 function() {
330 let loader = new PlaylistLoader('live.m3u8');
331
332 loader.trigger('firstplay');
333 this.requests.pop().respond(200, null,
334 '#EXTM3U\n' +
335 '#EXT-X-MEDIA-SEQUENCE:0\n' +
336 '#EXTINF:4,\n' +
337 '0.ts\n' +
338 '#EXTINF:5,\n' +
339 '1.ts\n');
340
341 this.clock.tick(10 * 1000);
342 this.requests.pop().respond(200, null,
343 '#EXTM3U\n' +
344 '#EXT-X-MEDIA-SEQUENCE:4\n' +
345 '#EXTINF:6,\n' +
346 '4.ts\n' +
347 '#EXTINF:7,\n' +
348 '5.ts\n');
349
350 QUnit.equal(loader.expired_,
351 4 + 5 + (2 * 10),
352 'made a very rough estimate of expired time');
353 });
354
355 QUnit.test('emits an error when an initial playlist request fails', function() {
356 let errors = [];
357 let loader = new PlaylistLoader('master.m3u8');
358
359 loader.on('error', function() {
360 errors.push(loader.error);
361 });
362 this.requests.pop().respond(500);
363
364 QUnit.strictEqual(errors.length, 1, 'emitted one error');
365 QUnit.strictEqual(errors[0].status, 500, 'http status is captured');
366 });
367
368 QUnit.test('errors when an initial media playlist request fails', function() {
369 let errors = [];
370 let loader = new PlaylistLoader('master.m3u8');
371
372 loader.on('error', function() {
373 errors.push(loader.error);
374 });
375 this.requests.pop().respond(200, null,
376 '#EXTM3U\n' +
377 '#EXT-X-STREAM-INF:\n' +
378 'media.m3u8\n');
379
380 QUnit.strictEqual(errors.length, 0, 'emitted no errors');
381
382 this.requests.pop().respond(500);
383
384 QUnit.strictEqual(errors.length, 1, 'emitted one error');
385 QUnit.strictEqual(errors[0].status, 500, 'http status is captured');
386 });
387
388 // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4
389 QUnit.test('halves the refresh timeout if a playlist is unchanged since the last reload',
390 function() {
391 /* eslint-disable no-unused-vars */
392 let loader = new PlaylistLoader('live.m3u8');
393 /* eslint-enable no-unused-vars */
394
395 this.requests.pop().respond(200, null,
396 '#EXTM3U\n' +
397 '#EXT-X-MEDIA-SEQUENCE:0\n' +
398 '#EXTINF:10,\n' +
399 '0.ts\n');
400 // trigger a refresh
401 this.clock.tick(10 * 1000);
402 this.requests.pop().respond(200, null,
403 '#EXTM3U\n' +
404 '#EXT-X-MEDIA-SEQUENCE:0\n' +
405 '#EXTINF:10,\n' +
406 '0.ts\n');
407 // half the default target-duration
408 this.clock.tick(5 * 1000);
409
410 QUnit.strictEqual(this.requests.length, 1, 'sent a request');
411 QUnit.strictEqual(this.requests[0].url,
412 urlTo('live.m3u8'),
413 'requested the media playlist');
414 });
415
416 QUnit.test('preserves segment metadata across playlist refreshes', function() {
417 let loader = new PlaylistLoader('live.m3u8');
418 let segment;
419
420 this.requests.pop().respond(200, null,
421 '#EXTM3U\n' +
422 '#EXT-X-MEDIA-SEQUENCE:0\n' +
423 '#EXTINF:10,\n' +
424 '0.ts\n' +
425 '#EXTINF:10,\n' +
426 '1.ts\n' +
427 '#EXTINF:10,\n' +
428 '2.ts\n');
429 // add PTS info to 1.ts
430 segment = loader.media().segments[1];
431 segment.minVideoPts = 14;
432 segment.maxAudioPts = 27;
433 segment.preciseDuration = 10.045;
434
435 // trigger a refresh
436 this.clock.tick(10 * 1000);
437 this.requests.pop().respond(200, null,
438 '#EXTM3U\n' +
439 '#EXT-X-MEDIA-SEQUENCE:1\n' +
440 '#EXTINF:10,\n' +
441 '1.ts\n' +
442 '#EXTINF:10,\n' +
443 '2.ts\n');
444
445 QUnit.deepEqual(loader.media().segments[0], segment, 'preserved segment attributes');
446 });
447
448 QUnit.test('clears the update timeout when switching quality', function() {
449 let loader = new PlaylistLoader('live-master.m3u8');
450 let refreshes = 0;
451
452 // track the number of playlist refreshes triggered
453 loader.on('mediaupdatetimeout', function() {
454 refreshes++;
455 });
456 // deliver the master
457 this.requests.pop().respond(200, null,
458 '#EXTM3U\n' +
459 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
460 'live-low.m3u8\n' +
461 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
462 'live-high.m3u8\n');
463 // deliver the low quality playlist
464 this.requests.pop().respond(200, null,
465 '#EXTM3U\n' +
466 '#EXT-X-MEDIA-SEQUENCE:0\n' +
467 '#EXTINF:10,\n' +
468 'low-0.ts\n');
469 // change to a higher quality playlist
470 loader.media('live-high.m3u8');
471 this.requests.pop().respond(200, null,
472 '#EXTM3U\n' +
473 '#EXT-X-MEDIA-SEQUENCE:0\n' +
474 '#EXTINF:10,\n' +
475 'high-0.ts\n');
476 // trigger a refresh
477 this.clock.tick(10 * 1000);
478
479 QUnit.equal(1, refreshes, 'only one refresh was triggered');
480 });
481
482 QUnit.test('media-sequence updates are considered a playlist change', function() {
483 /* eslint-disable no-unused-vars */
484 let loader = new PlaylistLoader('live.m3u8');
485 /* eslint-enable no-unused-vars */
486
487 this.requests.pop().respond(200, null,
488 '#EXTM3U\n' +
489 '#EXT-X-MEDIA-SEQUENCE:0\n' +
490 '#EXTINF:10,\n' +
491 '0.ts\n');
492 // trigger a refresh
493 this.clock.tick(10 * 1000);
494 this.requests.pop().respond(200, null,
495 '#EXTM3U\n' +
496 '#EXT-X-MEDIA-SEQUENCE:1\n' +
497 '#EXTINF:10,\n' +
498 '0.ts\n');
499 // half the default target-duration
500 this.clock.tick(5 * 1000);
501
502 QUnit.strictEqual(this.requests.length, 0, 'no request is sent');
503 });
504
505 QUnit.test('emits an error if a media refresh fails', function() {
506 let errors = 0;
507 let errorResponseText = 'custom error message';
508 let loader = new PlaylistLoader('live.m3u8');
509
510 loader.on('error', function() {
511 errors++;
512 });
513 this.requests.pop().respond(200, null,
514 '#EXTM3U\n' +
515 '#EXT-X-MEDIA-SEQUENCE:0\n' +
516 '#EXTINF:10,\n' +
517 '0.ts\n');
518 // trigger a refresh
519 this.clock.tick(10 * 1000);
520 this.requests.pop().respond(500, null, errorResponseText);
521
522 QUnit.strictEqual(errors, 1, 'emitted an error');
523 QUnit.strictEqual(loader.error.status, 500, 'captured the status code');
524 QUnit.strictEqual(loader.error.responseText,
525 errorResponseText,
526 'captured the responseText');
527 });
528
529 QUnit.test('switches media playlists when requested', function() {
530 let loader = new PlaylistLoader('master.m3u8');
531
532 this.requests.pop().respond(200, null,
533 '#EXTM3U\n' +
534 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
535 'low.m3u8\n' +
536 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
537 'high.m3u8\n');
538 this.requests.pop().respond(200, null,
539 '#EXTM3U\n' +
540 '#EXT-X-MEDIA-SEQUENCE:0\n' +
541 '#EXTINF:10,\n' +
542 'low-0.ts\n');
543
544 loader.media(loader.master.playlists[1]);
545 QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
546
547 this.requests.pop().respond(200, null,
548 '#EXTM3U\n' +
549 '#EXT-X-MEDIA-SEQUENCE:0\n' +
550 '#EXTINF:10,\n' +
551 'high-0.ts\n');
552 QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
553 QUnit.strictEqual(loader.media(),
554 loader.master.playlists[1],
555 'updated the active media');
556 });
557
558 QUnit.test('can switch playlists immediately after the master is downloaded', function() {
559 let loader = new PlaylistLoader('master.m3u8');
560
561 loader.on('loadedplaylist', function() {
562 loader.media('high.m3u8');
916 }); 563 });
917 564 this.requests.pop().respond(200, null,
918 })(window); 565 '#EXTM3U\n' +
566 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
567 'low.m3u8\n' +
568 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
569 'high.m3u8\n');
570 QUnit.equal(this.requests[0].url, urlTo('high.m3u8'), 'switched variants immediately');
571 });
572
573 QUnit.test('can switch media playlists based on URI', function() {
574 let loader = new PlaylistLoader('master.m3u8');
575
576 this.requests.pop().respond(200, null,
577 '#EXTM3U\n' +
578 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
579 'low.m3u8\n' +
580 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
581 'high.m3u8\n');
582 this.requests.pop().respond(200, null,
583 '#EXTM3U\n' +
584 '#EXT-X-MEDIA-SEQUENCE:0\n' +
585 '#EXTINF:10,\n' +
586 'low-0.ts\n');
587
588 loader.media('high.m3u8');
589 QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
590
591 this.requests.pop().respond(200, null,
592 '#EXTM3U\n' +
593 '#EXT-X-MEDIA-SEQUENCE:0\n' +
594 '#EXTINF:10,\n' +
595 'high-0.ts\n');
596 QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
597 QUnit.strictEqual(loader.media(),
598 loader.master.playlists[1],
599 'updated the active media');
600 });
601
602 QUnit.test('aborts in-flight playlist refreshes when switching', function() {
603 let loader = new PlaylistLoader('master.m3u8');
604
605 this.requests.pop().respond(200, null,
606 '#EXTM3U\n' +
607 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
608 'low.m3u8\n' +
609 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
610 'high.m3u8\n');
611 this.requests.pop().respond(200, null,
612 '#EXTM3U\n' +
613 '#EXT-X-MEDIA-SEQUENCE:0\n' +
614 '#EXTINF:10,\n' +
615 'low-0.ts\n');
616 this.clock.tick(10 * 1000);
617 loader.media('high.m3u8');
618 QUnit.strictEqual(this.requests[0].aborted, true, 'aborted refresh request');
619 QUnit.ok(!this.requests[0].onreadystatechange,
620 'onreadystatechange handlers should be removed on abort');
621 QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
622 });
623
624 QUnit.test('switching to the active playlist is a no-op', function() {
625 let loader = new PlaylistLoader('master.m3u8');
626
627 this.requests.pop().respond(200, null,
628 '#EXTM3U\n' +
629 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
630 'low.m3u8\n' +
631 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
632 'high.m3u8\n');
633 this.requests.pop().respond(200, null,
634 '#EXTM3U\n' +
635 '#EXT-X-MEDIA-SEQUENCE:0\n' +
636 '#EXTINF:10,\n' +
637 'low-0.ts\n' +
638 '#EXT-X-ENDLIST\n');
639 loader.media('low.m3u8');
640
641 QUnit.strictEqual(this.requests.length, 0, 'no requests are sent');
642 });
643
644 QUnit.test('switching to the active live playlist is a no-op', function() {
645 let loader = new PlaylistLoader('master.m3u8');
646
647 this.requests.pop().respond(200, null,
648 '#EXTM3U\n' +
649 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
650 'low.m3u8\n' +
651 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
652 'high.m3u8\n');
653 this.requests.pop().respond(200, null,
654 '#EXTM3U\n' +
655 '#EXT-X-MEDIA-SEQUENCE:0\n' +
656 '#EXTINF:10,\n' +
657 'low-0.ts\n');
658 loader.media('low.m3u8');
659
660 QUnit.strictEqual(this.requests.length, 0, 'no requests are sent');
661 });
662
663 QUnit.test('switches back to loaded playlists without re-requesting them', function() {
664 let loader = new PlaylistLoader('master.m3u8');
665
666 this.requests.pop().respond(200, null,
667 '#EXTM3U\n' +
668 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
669 'low.m3u8\n' +
670 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
671 'high.m3u8\n');
672 this.requests.pop().respond(200, null,
673 '#EXTM3U\n' +
674 '#EXT-X-MEDIA-SEQUENCE:0\n' +
675 '#EXTINF:10,\n' +
676 'low-0.ts\n' +
677 '#EXT-X-ENDLIST\n');
678 loader.media('high.m3u8');
679 this.requests.pop().respond(200, null,
680 '#EXTM3U\n' +
681 '#EXT-X-MEDIA-SEQUENCE:0\n' +
682 '#EXTINF:10,\n' +
683 'high-0.ts\n' +
684 '#EXT-X-ENDLIST\n');
685 loader.media('low.m3u8');
686
687 QUnit.strictEqual(this.requests.length, 0, 'no outstanding requests');
688 QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist');
689 });
690
691 QUnit.test('aborts outstanding requests if switching back to an already loaded playlist',
692 function() {
693 let loader = new PlaylistLoader('master.m3u8');
694
695 this.requests.pop().respond(200, null,
696 '#EXTM3U\n' +
697 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
698 'low.m3u8\n' +
699 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
700 'high.m3u8\n');
701 this.requests.pop().respond(200, null,
702 '#EXTM3U\n' +
703 '#EXT-X-MEDIA-SEQUENCE:0\n' +
704 '#EXTINF:10,\n' +
705 'low-0.ts\n' +
706 '#EXT-X-ENDLIST\n');
707 loader.media('high.m3u8');
708 loader.media('low.m3u8');
709
710 QUnit.strictEqual(this.requests.length,
711 1,
712 'requested high playlist');
713 QUnit.ok(this.requests[0].aborted,
714 'aborted playlist request');
715 QUnit.ok(!this.requests[0].onreadystatechange,
716 'onreadystatechange handlers should be removed on abort');
717 QUnit.strictEqual(loader.state,
718 'HAVE_METADATA',
719 'returned to loaded playlist');
720 QUnit.strictEqual(loader.media(),
721 loader.master.playlists[0],
722 'switched to loaded playlist');
723 });
724
725 QUnit.test('does not abort requests when the same playlist is re-requested',
726 function() {
727 let loader = new PlaylistLoader('master.m3u8');
728
729 this.requests.pop().respond(200, null,
730 '#EXTM3U\n' +
731 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
732 'low.m3u8\n' +
733 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
734 'high.m3u8\n');
735 this.requests.pop().respond(200, null,
736 '#EXTM3U\n' +
737 '#EXT-X-MEDIA-SEQUENCE:0\n' +
738 '#EXTINF:10,\n' +
739 'low-0.ts\n' +
740 '#EXT-X-ENDLIST\n');
741 loader.media('high.m3u8');
742 loader.media('high.m3u8');
743
744 QUnit.strictEqual(this.requests.length, 1, 'made only one request');
745 QUnit.ok(!this.requests[0].aborted, 'request not aborted');
746 });
747
748 QUnit.test('throws an error if a media switch is initiated too early', function() {
749 let loader = new PlaylistLoader('master.m3u8');
750
751 QUnit.throws(function() {
752 loader.media('high.m3u8');
753 }, 'threw an error from HAVE_NOTHING');
754
755 this.requests.pop().respond(200, null,
756 '#EXTM3U\n' +
757 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
758 'low.m3u8\n' +
759 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
760 'high.m3u8\n');
761 });
762
763 QUnit.test('throws an error if a switch to an unrecognized playlist is requested',
764 function() {
765 let loader = new PlaylistLoader('master.m3u8');
766
767 this.requests.pop().respond(200, null,
768 '#EXTM3U\n' +
769 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
770 'media.m3u8\n');
771
772 QUnit.throws(function() {
773 loader.media('unrecognized.m3u8');
774 }, 'throws an error');
775 });
776
777 QUnit.test('dispose cancels the refresh timeout', function() {
778 let loader = new PlaylistLoader('live.m3u8');
779
780 this.requests.pop().respond(200, null,
781 '#EXTM3U\n' +
782 '#EXT-X-MEDIA-SEQUENCE:0\n' +
783 '#EXTINF:10,\n' +
784 '0.ts\n');
785 loader.dispose();
786 // a lot of time passes...
787 this.clock.tick(15 * 1000);
788
789 QUnit.strictEqual(this.requests.length, 0, 'no refresh request was made');
790 });
791
792 QUnit.test('dispose aborts pending refresh requests', function() {
793 let loader = new PlaylistLoader('live.m3u8');
794
795 this.requests.pop().respond(200, null,
796 '#EXTM3U\n' +
797 '#EXT-X-MEDIA-SEQUENCE:0\n' +
798 '#EXTINF:10,\n' +
799 '0.ts\n');
800 this.clock.tick(10 * 1000);
801
802 loader.dispose();
803 QUnit.ok(this.requests[0].aborted, 'refresh request aborted');
804 QUnit.ok(!this.requests[0].onreadystatechange,
805 'onreadystatechange handler should not exist after dispose called'
806 );
807 });
808
809 QUnit.test('errors if requests take longer than 45s', function() {
810 let loader = new PlaylistLoader('media.m3u8');
811 let errors = 0;
812
813 loader.on('error', function() {
814 errors++;
815 });
816 this.clock.tick(45 * 1000);
817
818 QUnit.strictEqual(errors, 1, 'fired one error');
819 QUnit.strictEqual(loader.error.code, 2, 'fired a network error');
820 });
821
822 QUnit.test('triggers an event when the active media changes', function() {
823 let loader = new PlaylistLoader('master.m3u8');
824 let mediaChanges = 0;
825
826 loader.on('mediachange', function() {
827 mediaChanges++;
828 });
829 this.requests.pop().respond(200, null,
830 '#EXTM3U\n' +
831 '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
832 'low.m3u8\n' +
833 '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
834 'high.m3u8\n');
835 this.requests.shift().respond(200, null,
836 '#EXTM3U\n' +
837 '#EXT-X-MEDIA-SEQUENCE:0\n' +
838 '#EXTINF:10,\n' +
839 'low-0.ts\n' +
840 '#EXT-X-ENDLIST\n');
841 QUnit.strictEqual(mediaChanges, 0, 'initial selection is not a media change');
842
843 loader.media('high.m3u8');
844 QUnit.strictEqual(mediaChanges, 0, 'mediachange does not fire immediately');
845
846 this.requests.shift().respond(200, null,
847 '#EXTM3U\n' +
848 '#EXT-X-MEDIA-SEQUENCE:0\n' +
849 '#EXTINF:10,\n' +
850 'high-0.ts\n' +
851 '#EXT-X-ENDLIST\n');
852 QUnit.strictEqual(mediaChanges, 1, 'fired a mediachange');
853
854 // switch back to an already loaded playlist
855 loader.media('low.m3u8');
856 QUnit.strictEqual(mediaChanges, 2, 'fired a mediachange');
857
858 // trigger a no-op switch
859 loader.media('low.m3u8');
860 QUnit.strictEqual(mediaChanges, 2, 'ignored a no-op media change');
861 });
862
863 QUnit.test('can get media index by playback position for non-live videos', function() {
864 let loader = new PlaylistLoader('media.m3u8');
865
866 this.requests.shift().respond(200, null,
867 '#EXTM3U\n' +
868 '#EXT-X-MEDIA-SEQUENCE:0\n' +
869 '#EXTINF:4,\n' +
870 '0.ts\n' +
871 '#EXTINF:5,\n' +
872 '1.ts\n' +
873 '#EXTINF:6,\n' +
874 '2.ts\n' +
875 '#EXT-X-ENDLIST\n');
876
877 QUnit.equal(loader.getMediaIndexForTime_(-1),
878 0,
879 'the index is never less than zero');
880 QUnit.equal(loader.getMediaIndexForTime_(0), 0, 'time zero is index zero');
881 QUnit.equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero');
882 QUnit.equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2');
883 QUnit.equal(loader.getMediaIndexForTime_(22),
884 2,
885 'time greater than the length is index 2');
886 });
887
888 QUnit.test('returns the lower index when calculating for a segment boundary', function() {
889 let loader = new PlaylistLoader('media.m3u8');
890
891 this.requests.shift().respond(200, null,
892 '#EXTM3U\n' +
893 '#EXT-X-MEDIA-SEQUENCE:0\n' +
894 '#EXTINF:4,\n' +
895 '0.ts\n' +
896 '#EXTINF:5,\n' +
897 '1.ts\n' +
898 '#EXT-X-ENDLIST\n');
899 QUnit.equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches');
900 QUnit.equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down');
901 QUnit.equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5');
902 });
903
904 QUnit.test('accounts for non-zero starting segment time when calculating media index',
905 function() {
906 let loader = new PlaylistLoader('media.m3u8');
907
908 this.requests.shift().respond(200, null,
909 '#EXTM3U\n' +
910 '#EXT-X-MEDIA-SEQUENCE:1001\n' +
911 '#EXTINF:4,\n' +
912 '1001.ts\n' +
913 '#EXTINF:5,\n' +
914 '1002.ts\n');
915 loader.media().segments[0].end = 154;
916
917 QUnit.equal(loader.getMediaIndexForTime_(0),
918 -1,
919 'the lowest returned value is negative one');
920 QUnit.equal(loader.getMediaIndexForTime_(45),
921 -1,
922 'expired content returns negative one');
923 QUnit.equal(loader.getMediaIndexForTime_(75),
924 -1,
925 'expired content returns negative one');
926 QUnit.equal(loader.getMediaIndexForTime_(50 + 100),
927 0,
928 'calculates the earliest available position');
929 QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2),
930 0,
931 'calculates within the first segment');
932 QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4),
933 1,
934 'calculates within the second segment');
935 QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5),
936 1,
937 'calculates within the second segment');
938 QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6),
939 1,
940 'calculates within the second segment');
941 });
942
943 QUnit.test('prefers precise segment timing when tracking expired time', function() {
944 let loader = new PlaylistLoader('media.m3u8');
945
946 loader.trigger('firstplay');
947 this.requests.shift().respond(200, null,
948 '#EXTM3U\n' +
949 '#EXT-X-MEDIA-SEQUENCE:1001\n' +
950 '#EXTINF:4,\n' +
951 '1001.ts\n' +
952 '#EXTINF:5,\n' +
953 '1002.ts\n');
954 // setup the loader with an "imprecise" value as if it had been
955 // accumulating segment durations as they expire
956 loader.expired_ = 160;
957 // annotate the first segment with a start time
958 // this number would be coming from the Source Buffer in practice
959 loader.media().segments[0].end = 150;
960
961 QUnit.equal(loader.getMediaIndexForTime_(149),
962 0,
963 'prefers the value on the first segment');
964
965 // trigger a playlist refresh
966 this.clock.tick(10 * 1000);
967 this.requests.shift().respond(200, null,
968 '#EXTM3U\n' +
969 '#EXT-X-MEDIA-SEQUENCE:1002\n' +
970 '#EXTINF:5,\n' +
971 '1002.ts\n');
972 QUnit.equal(loader.getMediaIndexForTime_(150 + 4 + 1),
973 0,
974 'tracks precise expired times');
975 });
976
977 QUnit.test('accounts for expired time when calculating media index', function() {
978 let loader = new PlaylistLoader('media.m3u8');
979
980 this.requests.shift().respond(200, null,
981 '#EXTM3U\n' +
982 '#EXT-X-MEDIA-SEQUENCE:1001\n' +
983 '#EXTINF:4,\n' +
984 '1001.ts\n' +
985 '#EXTINF:5,\n' +
986 '1002.ts\n');
987 loader.expired_ = 150;
988
989 QUnit.equal(loader.getMediaIndexForTime_(0),
990 -1,
991 'expired content returns a negative index');
992 QUnit.equal(loader.getMediaIndexForTime_(75),
993 -1,
994 'expired content returns a negative index');
995 QUnit.equal(loader.getMediaIndexForTime_(50 + 100),
996 0,
997 'calculates the earliest available position');
998 QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2),
999 0,
1000 'calculates within the first segment');
1001 QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5),
1002 1,
1003 'calculates within the second segment');
1004 QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6),
1005 1,
1006 'calculates within the second segment');
1007 });
1008
1009 QUnit.test('does not misintrepret playlists missing newlines at the end', function() {
1010 let loader = new PlaylistLoader('media.m3u8');
1011
1012 // no newline
1013 this.requests.shift().respond(200, null,
1014 '#EXTM3U\n' +
1015 '#EXT-X-MEDIA-SEQUENCE:0\n' +
1016 '#EXTINF:10,\n' +
1017 'low-0.ts\n' +
1018 '#EXT-X-ENDLIST');
1019 QUnit.ok(loader.media().endList, 'flushed the final line of input');
1020 });
......
1 /* Tests for the playlist utilities */ 1 import Playlist from '../src/playlist';
2 (function(window, videojs) { 2 import QUnit from 'qunit';
3 'use strict'; 3 QUnit.module('Playlist Duration');
4 var Playlist = videojs.Hls.Playlist; 4
5 5 QUnit.test('total duration for live playlists is Infinity', function() {
6 QUnit.module('Playlist Duration'); 6 let duration = Playlist.duration({
7 7 segments: [{
8 test('total duration for live playlists is Infinity', function() { 8 duration: 4,
9 var duration = Playlist.duration({ 9 uri: '0.ts'
10 segments: [{ 10 }]
11 duration: 4,
12 uri: '0.ts'
13 }]
14 });
15
16 equal(duration, Infinity, 'duration is infinity');
17 }); 11 });
18 12
19 QUnit.module('Playlist Interval Duration'); 13 QUnit.equal(duration, Infinity, 'duration is infinity');
20 14 });
21 test('accounts for non-zero starting VOD media sequences', function() { 15
22 var duration = Playlist.duration({ 16 QUnit.module('Playlist Interval Duration');
23 mediaSequence: 10, 17
24 endList: true, 18 QUnit.test('accounts for non-zero starting VOD media sequences', function() {
25 segments: [{ 19 let duration = Playlist.duration({
26 duration: 10, 20 mediaSequence: 10,
27 uri: '0.ts' 21 endList: true,
28 }, { 22 segments: [{
29 duration: 10, 23 duration: 10,
30 uri: '1.ts' 24 uri: '0.ts'
31 }, { 25 }, {
32 duration: 10, 26 duration: 10,
33 uri: '2.ts' 27 uri: '1.ts'
34 }, { 28 }, {
35 duration: 10, 29 duration: 10,
36 uri: '3.ts' 30 uri: '2.ts'
37 }] 31 }, {
38 }); 32 duration: 10,
39 33 uri: '3.ts'
40 equal(duration, 4 * 10, 'includes only listed segments'); 34 }]
41 }); 35 });
42 36
43 test('uses timeline values when available', function() { 37 QUnit.equal(duration, 4 * 10, 'includes only listed segments');
44 var duration = Playlist.duration({ 38 });
45 mediaSequence: 0, 39
46 endList: true, 40 QUnit.test('uses timeline values when available', function() {
47 segments: [{ 41 let duration = Playlist.duration({
48 start: 0, 42 mediaSequence: 0,
49 uri: '0.ts' 43 endList: true,
50 }, { 44 segments: [{
51 duration: 10, 45 start: 0,
52 end: 2 * 10 + 2, 46 uri: '0.ts'
53 uri: '1.ts' 47 }, {
54 }, { 48 duration: 10,
55 duration: 10, 49 end: 2 * 10 + 2,
56 end: 3 * 10 + 2, 50 uri: '1.ts'
57 uri: '2.ts' 51 }, {
58 }, { 52 duration: 10,
59 duration: 10, 53 end: 3 * 10 + 2,
60 end: 4 * 10 + 2, 54 uri: '2.ts'
61 uri: '3.ts' 55 }, {
62 }] 56 duration: 10,
63 }, 4); 57 end: 4 * 10 + 2,
64 58 uri: '3.ts'
65 equal(duration, 4 * 10 + 2, 'used timeline values'); 59 }]
60 }, 4);
61
62 QUnit.equal(duration, 4 * 10 + 2, 'used timeline values');
63 });
64
65 QUnit.test('works when partial timeline information is available', function() {
66 let duration = Playlist.duration({
67 mediaSequence: 0,
68 endList: true,
69 segments: [{
70 start: 0,
71 uri: '0.ts'
72 }, {
73 duration: 9,
74 uri: '1.ts'
75 }, {
76 duration: 10,
77 uri: '2.ts'
78 }, {
79 duration: 10,
80 start: 30.007,
81 end: 40.002,
82 uri: '3.ts'
83 }, {
84 duration: 10,
85 end: 50.0002,
86 uri: '4.ts'
87 }]
88 }, 5);
89
90 QUnit.equal(duration, 50.0002, 'calculated with mixed intervals');
91 });
92
93 QUnit.test('uses timeline values for the expired duration of live playlists', function() {
94 let playlist = {
95 mediaSequence: 12,
96 segments: [{
97 duration: 10,
98 end: 120.5,
99 uri: '0.ts'
100 }, {
101 duration: 9,
102 uri: '1.ts'
103 }]
104 };
105 let duration;
106
107 duration = Playlist.duration(playlist, playlist.mediaSequence);
108 QUnit.equal(duration, 110.5, 'used segment end time');
109 duration = Playlist.duration(playlist, playlist.mediaSequence + 1);
110 QUnit.equal(duration, 120.5, 'used segment end time');
111 duration = Playlist.duration(playlist, playlist.mediaSequence + 2);
112 QUnit.equal(duration, 120.5 + 9, 'used segment end time');
113 });
114
115 QUnit.test('looks outside the queried interval for live playlist timeline values',
116 function() {
117 let playlist = {
118 mediaSequence: 12,
119 segments: [{
120 duration: 10,
121 uri: '0.ts'
122 }, {
123 duration: 9,
124 end: 120.5,
125 uri: '1.ts'
126 }]
127 };
128 let duration;
129
130 duration = Playlist.duration(playlist, playlist.mediaSequence);
131 QUnit.equal(duration, 120.5 - 9 - 10, 'used segment end time');
132 });
133
134 QUnit.test('ignores discontinuity sequences later than the end', function() {
135 let duration = Playlist.duration({
136 mediaSequence: 0,
137 discontinuityStarts: [1, 3],
138 segments: [{
139 duration: 10,
140 uri: '0.ts'
141 }, {
142 discontinuity: true,
143 duration: 9,
144 uri: '1.ts'
145 }, {
146 duration: 10,
147 uri: '2.ts'
148 }, {
149 discontinuity: true,
150 duration: 10,
151 uri: '3.ts'
152 }]
153 }, 2);
154
155 QUnit.equal(duration, 19, 'excluded the later segments');
156 });
157
158 QUnit.test('handles trailing segments without timeline information', function() {
159 let duration;
160 let playlist = {
161 mediaSequence: 0,
162 endList: true,
163 segments: [{
164 start: 0,
165 end: 10.5,
166 uri: '0.ts'
167 }, {
168 duration: 9,
169 uri: '1.ts'
170 }, {
171 duration: 10,
172 uri: '2.ts'
173 }, {
174 start: 29.45,
175 end: 39.5,
176 uri: '3.ts'
177 }]
178 };
179
180 duration = Playlist.duration(playlist, 3);
181 QUnit.equal(duration, 29.45, 'calculated duration');
182
183 duration = Playlist.duration(playlist, 2);
184 QUnit.equal(duration, 19.5, 'calculated duration');
185 });
186
187 QUnit.test('uses timeline intervals when segments have them', function() {
188 let duration;
189 let playlist = {
190 mediaSequence: 0,
191 segments: [{
192 start: 0,
193 end: 10,
194 uri: '0.ts'
195 }, {
196 duration: 9,
197 uri: '1.ts'
198 }, {
199 start: 20.1,
200 end: 30.1,
201 duration: 10,
202 uri: '2.ts'
203 }]
204 };
205
206 duration = Playlist.duration(playlist, 2);
207 QUnit.equal(duration, 20.1, 'used the timeline-based interval');
208
209 duration = Playlist.duration(playlist, 3);
210 QUnit.equal(duration, 30.1, 'used the timeline-based interval');
211 });
212
213 QUnit.test('counts the time between segments as part of the earlier segment\'s duration',
214 function() {
215 let duration = Playlist.duration({
216 mediaSequence: 0,
217 endList: true,
218 segments: [{
219 start: 0,
220 end: 10,
221 uri: '0.ts'
222 }, {
223 start: 10.1,
224 end: 20.1,
225 duration: 10,
226 uri: '1.ts'
227 }]
228 }, 1);
229
230 QUnit.equal(duration, 10.1, 'included the segment gap');
231 });
232
233 QUnit.test('accounts for discontinuities', function() {
234 let duration = Playlist.duration({
235 mediaSequence: 0,
236 endList: true,
237 discontinuityStarts: [1],
238 segments: [{
239 duration: 10,
240 uri: '0.ts'
241 }, {
242 discontinuity: true,
243 duration: 10,
244 uri: '1.ts'
245 }]
246 }, 2);
247
248 QUnit.equal(duration, 10 + 10, 'handles discontinuities');
249 });
250
251 QUnit.test('a non-positive length interval has zero duration', function() {
252 let playlist = {
253 mediaSequence: 0,
254 discontinuityStarts: [1],
255 segments: [{
256 duration: 10,
257 uri: '0.ts'
258 }, {
259 discontinuity: true,
260 duration: 10,
261 uri: '1.ts'
262 }]
263 };
264
265 QUnit.equal(Playlist.duration(playlist, 0), 0, 'zero-length duration is zero');
266 QUnit.equal(Playlist.duration(playlist, 0, false), 0, 'zero-length duration is zero');
267 QUnit.equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero');
268 });
269
270 QUnit.module('Playlist Seekable');
271
272 QUnit.test('calculates seekable time ranges from the available segments', function() {
273 let playlist = {
274 mediaSequence: 0,
275 segments: [{
276 duration: 10,
277 uri: '0.ts'
278 }, {
279 duration: 10,
280 uri: '1.ts'
281 }],
282 endList: true
283 };
284 let seekable = Playlist.seekable(playlist);
285
286 QUnit.equal(seekable.length, 1, 'there are seekable ranges');
287 QUnit.equal(seekable.start(0), 0, 'starts at zero');
288 QUnit.equal(seekable.end(0), Playlist.duration(playlist), 'ends at the duration');
289 });
290
291 QUnit.test('master playlists have empty seekable ranges', function() {
292 let seekable = Playlist.seekable({
293 playlists: [{
294 uri: 'low.m3u8'
295 }, {
296 uri: 'high.m3u8'
297 }]
66 }); 298 });
67 299
68 test('works when partial timeline information is available', function() { 300 QUnit.equal(seekable.length, 0, 'no seekable ranges from a master playlist');
69 var duration = Playlist.duration({ 301 });
70 mediaSequence: 0, 302
71 endList: true, 303 QUnit.test('seekable end is three target durations from the actual end of live playlists',
72 segments: [{ 304 function() {
73 start: 0, 305 let seekable = Playlist.seekable({
74 uri: '0.ts' 306 mediaSequence: 0,
75 }, { 307 segments: [{
76 duration: 9, 308 duration: 7,
77 uri: '1.ts' 309 uri: '0.ts'
78 }, { 310 }, {
79 duration: 10, 311 duration: 10,
80 uri: '2.ts' 312 uri: '1.ts'
81 }, { 313 }, {
82 duration: 10, 314 duration: 10,
83 start: 30.007, 315 uri: '2.ts'
84 end: 40.002, 316 }, {
85 uri: '3.ts' 317 duration: 10,
86 }, { 318 uri: '3.ts'
87 duration: 10, 319 }]
88 end: 50.0002,
89 uri: '4.ts'
90 }]
91 }, 5);
92
93 equal(duration, 50.0002, 'calculated with mixed intervals');
94 }); 320 });
95 321
96 test('uses timeline values for the expired duration of live playlists', function() { 322 QUnit.equal(seekable.length, 1, 'there are seekable ranges');
97 var playlist = { 323 QUnit.equal(seekable.start(0), 0, 'starts at zero');
98 mediaSequence: 12, 324 QUnit.equal(seekable.end(0), 7, 'ends three target durations from the last segment');
99 segments: [{ 325 });
100 duration: 10, 326
101 end: 120.5, 327 QUnit.test('only considers available segments', function() {
102 uri: '0.ts' 328 let seekable = Playlist.seekable({
103 }, { 329 mediaSequence: 7,
104 duration: 9, 330 segments: [{
105 uri: '1.ts' 331 uri: '8.ts',
106 }] 332 duration: 10
107 }, duration; 333 }, {
108 334 uri: '9.ts',
109 duration = Playlist.duration(playlist, playlist.mediaSequence); 335 duration: 10
110 equal(duration, 110.5, 'used segment end time'); 336 }, {
111 duration = Playlist.duration(playlist, playlist.mediaSequence + 1); 337 uri: '10.ts',
112 equal(duration, 120.5, 'used segment end time'); 338 duration: 10
113 duration = Playlist.duration(playlist, playlist.mediaSequence + 2); 339 }, {
114 equal(duration, 120.5 + 9, 'used segment end time'); 340 uri: '11.ts',
341 duration: 10
342 }]
115 }); 343 });
116 344
117 test('looks outside the queried interval for live playlist timeline values', function() { 345 QUnit.equal(seekable.length, 1, 'there are seekable ranges');
118 var playlist = { 346 QUnit.equal(seekable.start(0), 0, 'starts at the earliest available segment');
119 mediaSequence: 12, 347 QUnit.equal(seekable.end(0),
120 segments: [{ 348 10,
121 duration: 10, 349 'ends three target durations from the last available segment');
122 uri: '0.ts' 350 });
123 }, { 351
124 duration: 9, 352 QUnit.test('seekable end accounts for non-standard target durations', function() {
125 end: 120.5, 353 let seekable = Playlist.seekable({
126 uri: '1.ts' 354 targetDuration: 2,
127 }] 355 mediaSequence: 0,
128 }, duration; 356 segments: [{
129 357 duration: 2,
130 duration = Playlist.duration(playlist, playlist.mediaSequence); 358 uri: '0.ts'
131 equal(duration, 120.5 - 9 - 10, 'used segment end time'); 359 }, {
360 duration: 2,
361 uri: '1.ts'
362 }, {
363 duration: 1,
364 uri: '2.ts'
365 }, {
366 duration: 2,
367 uri: '3.ts'
368 }, {
369 duration: 2,
370 uri: '4.ts'
371 }]
132 }); 372 });
133 373
134 test('ignores discontinuity sequences later than the end', function() { 374 QUnit.equal(seekable.start(0), 0, 'starts at the earliest available segment');
135 var duration = Playlist.duration({ 375 QUnit.equal(seekable.end(0),
136 mediaSequence: 0, 376 9 - (2 + 2 + 1),
137 discontinuityStarts: [1, 3], 377 'allows seeking no further than three segments from the end');
138 segments: [{ 378 });
139 duration: 10,
140 uri: '0.ts'
141 }, {
142 discontinuity: true,
143 duration: 9,
144 uri: '1.ts'
145 }, {
146 duration: 10,
147 uri: '2.ts'
148 }, {
149 discontinuity: true,
150 duration: 10,
151 uri: '3.ts'
152 }]
153 }, 2);
154
155 equal(duration, 19, 'excluded the later segments');
156 });
157
158 test('handles trailing segments without timeline information', function() {
159 var playlist, duration;
160 playlist = {
161 mediaSequence: 0,
162 endList: true,
163 segments: [{
164 start: 0,
165 end: 10.5,
166 uri: '0.ts'
167 }, {
168 duration: 9,
169 uri: '1.ts'
170 }, {
171 duration: 10,
172 uri: '2.ts'
173 }, {
174 start: 29.45,
175 end: 39.5,
176 uri: '3.ts'
177 }]
178 };
179
180 duration = Playlist.duration(playlist, 3);
181 equal(duration, 29.45, 'calculated duration');
182
183 duration = Playlist.duration(playlist, 2);
184 equal(duration, 19.5, 'calculated duration');
185 });
186
187 test('uses timeline intervals when segments have them', function() {
188 var playlist, duration;
189 playlist = {
190 mediaSequence: 0,
191 segments: [{
192 start: 0,
193 end: 10,
194 uri: '0.ts'
195 }, {
196 duration: 9,
197 uri: '1.ts'
198 },{
199 start: 20.1,
200 end: 30.1,
201 duration: 10,
202 uri: '2.ts'
203 }]
204 };
205 duration = Playlist.duration(playlist, 2);
206
207 equal(duration, 20.1, 'used the timeline-based interval');
208
209 duration = Playlist.duration(playlist, 3);
210 equal(duration, 30.1, 'used the timeline-based interval');
211 });
212
213 test('counts the time between segments as part of the earlier segment\'s duration', function() {
214 var duration = Playlist.duration({
215 mediaSequence: 0,
216 endList: true,
217 segments: [{
218 start: 0,
219 end: 10,
220 uri: '0.ts'
221 }, {
222 start: 10.1,
223 end: 20.1,
224 duration: 10,
225 uri: '1.ts'
226 }]
227 }, 1);
228
229 equal(duration, 10.1, 'included the segment gap');
230 });
231
232 test('accounts for discontinuities', function() {
233 var duration = Playlist.duration({
234 mediaSequence: 0,
235 endList: true,
236 discontinuityStarts: [1],
237 segments: [{
238 duration: 10,
239 uri: '0.ts'
240 }, {
241 discontinuity: true,
242 duration: 10,
243 uri: '1.ts'
244 }]
245 }, 2);
246
247 equal(duration, 10 + 10, 'handles discontinuities');
248 });
249
250 test('a non-positive length interval has zero duration', function() {
251 var playlist = {
252 mediaSequence: 0,
253 discontinuityStarts: [1],
254 segments: [{
255 duration: 10,
256 uri: '0.ts'
257 }, {
258 discontinuity: true,
259 duration: 10,
260 uri: '1.ts'
261 }]
262 };
263
264 equal(Playlist.duration(playlist, 0), 0, 'zero-length duration is zero');
265 equal(Playlist.duration(playlist, 0, false), 0, 'zero-length duration is zero');
266 equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero');
267 });
268
269 QUnit.module('Playlist Seekable');
270
271 test('calculates seekable time ranges from the available segments', function() {
272 var playlist = {
273 mediaSequence: 0,
274 segments: [{
275 duration: 10,
276 uri: '0.ts'
277 }, {
278 duration: 10,
279 uri: '1.ts'
280 }],
281 endList: true
282 }, seekable = Playlist.seekable(playlist);
283
284 equal(seekable.length, 1, 'there are seekable ranges');
285 equal(seekable.start(0), 0, 'starts at zero');
286 equal(seekable.end(0), Playlist.duration(playlist), 'ends at the duration');
287 });
288
289 test('master playlists have empty seekable ranges', function() {
290 var seekable = Playlist.seekable({
291 playlists: [{
292 uri: 'low.m3u8'
293 }, {
294 uri: 'high.m3u8'
295 }]
296 });
297 equal(seekable.length, 0, 'no seekable ranges from a master playlist');
298 });
299
300 test('seekable end is three target durations from the actual end of live playlists', function() {
301 var seekable = Playlist.seekable({
302 mediaSequence: 0,
303 segments: [{
304 duration: 7,
305 uri: '0.ts'
306 }, {
307 duration: 10,
308 uri: '1.ts'
309 }, {
310 duration: 10,
311 uri: '2.ts'
312 }, {
313 duration: 10,
314 uri: '3.ts'
315 }]
316 });
317 equal(seekable.length, 1, 'there are seekable ranges');
318 equal(seekable.start(0), 0, 'starts at zero');
319 equal(seekable.end(0), 7, 'ends three target durations from the last segment');
320 });
321
322 test('only considers available segments', function() {
323 var seekable = Playlist.seekable({
324 mediaSequence: 7,
325 segments: [{
326 uri: '8.ts',
327 duration: 10
328 }, {
329 uri: '9.ts',
330 duration: 10
331 }, {
332 uri: '10.ts',
333 duration: 10
334 }, {
335 uri: '11.ts',
336 duration: 10
337 }]
338 });
339 equal(seekable.length, 1, 'there are seekable ranges');
340 equal(seekable.start(0), 0, 'starts at the earliest available segment');
341 equal(seekable.end(0), 10, 'ends three target durations from the last available segment');
342 });
343
344 test('seekable end accounts for non-standard target durations', function() {
345 var seekable = Playlist.seekable({
346 targetDuration: 2,
347 mediaSequence: 0,
348 segments: [{
349 duration: 2,
350 uri: '0.ts'
351 }, {
352 duration: 2,
353 uri: '1.ts'
354 }, {
355 duration: 1,
356 uri: '2.ts'
357 }, {
358 duration: 2,
359 uri: '3.ts'
360 }, {
361 duration: 2,
362 uri: '4.ts'
363 }]
364 });
365 equal(seekable.start(0), 0, 'starts at the earliest available segment');
366 equal(seekable.end(0),
367 9 - (2 + 2 + 1),
368 'allows seeking no further than three segments from the end');
369 });
370
371 })(window, window.videojs);
......