Merge pull request #544 from BrandonOCasey/browserify-p4
browserify-p4: playlist*, xhr, and resolve-url
Showing
12 changed files
with
2202 additions
and
2052 deletions
... | @@ -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 | }; | ... | ... |
src/resolve-url.js
0 → 100644
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); | ... | ... |
-
Please register or sign in to post a comment