Track expired content duration
When we switch playlists in a live video, we have to find the right place in the new playlist to continue buffering. This is complicated because we can't guarantee the two variants are segmented at the same time positions or that the windows of time they represent are exactly in sync. Most of the time, they're pretty close to one another and we can use that fact to make better guesses at which segment to download when switching. This PR adds back tracking of expired content in the playlist loader, which can then be used to estimate the seekable window for live playlists even before we've buffered any segments from them. This also allows seekable to be accurate even when the player has paused for a long time and all the segment timing information we gathered has gone out of date. To make rejoining or seeking in a live stream even more robust, we detect when a seek "misses" the live window and seek again to a safe position.
Showing
4 changed files
with
367 additions
and
47 deletions
... | @@ -152,6 +152,11 @@ | ... | @@ -152,6 +152,11 @@ |
152 | // initialize the loader state | 152 | // initialize the loader state |
153 | loader.state = 'HAVE_NOTHING'; | 153 | loader.state = 'HAVE_NOTHING'; |
154 | 154 | ||
155 | // track the time that has expired from the live window | ||
156 | // this allows the seekable start range to be calculated even if | ||
157 | // all segments with timing information have expired | ||
158 | this.expired_ = 0; | ||
159 | |||
155 | // capture the prototype dispose function | 160 | // capture the prototype dispose function |
156 | dispose = this.dispose; | 161 | dispose = this.dispose; |
157 | 162 | ||
... | @@ -346,7 +351,52 @@ | ... | @@ -346,7 +351,52 @@ |
346 | * @param update {object} the updated media playlist object | 351 | * @param update {object} the updated media playlist object |
347 | */ | 352 | */ |
348 | PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) { | 353 | PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) { |
354 | var outdated, i, segment; | ||
355 | |||
356 | outdated = this.media_; | ||
349 | this.media_ = this.master.playlists[update.uri]; | 357 | this.media_ = this.master.playlists[update.uri]; |
358 | |||
359 | if (!outdated) { | ||
360 | return; | ||
361 | } | ||
362 | |||
363 | // try using precise timing from first segment of the updated | ||
364 | // playlist | ||
365 | if (update.segments.length) { | ||
366 | if (update.segments[0].start !== undefined) { | ||
367 | this.expired_ = update.segments[0].start; | ||
368 | return; | ||
369 | } else if (update.segments[0].end !== undefined) { | ||
370 | this.expired_ = update.segments[0].end - update.segments[0].duration; | ||
371 | return; | ||
372 | } | ||
373 | } | ||
374 | |||
375 | // calculate expired by walking the outdated playlist | ||
376 | i = update.mediaSequence - outdated.mediaSequence - 1; | ||
377 | |||
378 | for (; i >= 0; i--) { | ||
379 | segment = outdated.segments[i]; | ||
380 | |||
381 | if (!segment) { | ||
382 | // we missed information on this segment completely between | ||
383 | // playlist updates so we'll have to take an educated guess | ||
384 | // once we begin buffering again, any error we introduce can | ||
385 | // be corrected | ||
386 | this.expired_ += outdated.targetDuration || 10; | ||
387 | continue; | ||
388 | } | ||
389 | |||
390 | if (segment.end !== undefined) { | ||
391 | this.expired_ = segment.end; | ||
392 | return; | ||
393 | } | ||
394 | if (segment.start !== undefined) { | ||
395 | this.expired_ = segment.start + segment.duration; | ||
396 | return; | ||
397 | } | ||
398 | this.expired_ += segment.duration; | ||
399 | } | ||
350 | }; | 400 | }; |
351 | 401 | ||
352 | /** | 402 | /** |
... | @@ -457,8 +507,8 @@ | ... | @@ -457,8 +507,8 @@ |
457 | 507 | ||
458 | if (i === endIndex) { | 508 | if (i === endIndex) { |
459 | // We haven't found a segment but we did hit a known end point | 509 | // We haven't found a segment but we did hit a known end point |
460 | // so fallback to "Algorithm Jon" - try to interpolate the segment | 510 | // so fallback to interpolating between the segment index |
461 | // index based on the known span of the timeline we are dealing with | 511 | // based on the known span of the timeline we are dealing with |
462 | // and the number of segments inside that span | 512 | // and the number of segments inside that span |
463 | return startIndex + Math.floor( | 513 | return startIndex + Math.floor( |
464 | ((originalTime - knownStart) / (knownEnd - knownStart)) * | 514 | ((originalTime - knownStart) / (knownEnd - knownStart)) * |
... | @@ -481,9 +531,13 @@ | ... | @@ -481,9 +531,13 @@ |
481 | // We haven't found a segment so load the first one | 531 | // We haven't found a segment so load the first one |
482 | return 0; | 532 | return 0; |
483 | } else { | 533 | } else { |
484 | // We known nothing so use "Algorithm A" - walk from the front | 534 | // We known nothing so walk from the front of the playlist, |
485 | // of the playlist naively subtracking durations until we find | 535 | // subtracting durations until we find a segment that contains |
486 | // a segment that contains time and return it | 536 | // time and return it |
537 | time = time - this.expired_; | ||
538 | if (time < 0) { | ||
539 | return -1; | ||
540 | } | ||
487 | for (i = 0; i < numSegments; i++) { | 541 | for (i = 0; i < numSegments; i++) { |
488 | segment = this.media_.segments[i]; | 542 | segment = this.media_.segments[i]; |
489 | time -= segment.duration || targetDuration; | 543 | time -= segment.duration || targetDuration; | ... | ... |
... | @@ -187,7 +187,7 @@ videojs.HlsHandler.prototype.src = function(src) { | ... | @@ -187,7 +187,7 @@ videojs.HlsHandler.prototype.src = function(src) { |
187 | }.bind(this)); | 187 | }.bind(this)); |
188 | 188 | ||
189 | this.playlists.on('loadedplaylist', function() { | 189 | this.playlists.on('loadedplaylist', function() { |
190 | var updatedPlaylist = this.playlists.media(); | 190 | var updatedPlaylist = this.playlists.media(), seekable; |
191 | 191 | ||
192 | if (!updatedPlaylist) { | 192 | if (!updatedPlaylist) { |
193 | // select the initial variant | 193 | // select the initial variant |
... | @@ -196,6 +196,14 @@ videojs.HlsHandler.prototype.src = function(src) { | ... | @@ -196,6 +196,14 @@ videojs.HlsHandler.prototype.src = function(src) { |
196 | } | 196 | } |
197 | 197 | ||
198 | this.updateDuration(this.playlists.media()); | 198 | this.updateDuration(this.playlists.media()); |
199 | |||
200 | // update seekable | ||
201 | seekable = this.seekable(); | ||
202 | if (this.duration() === Infinity && | ||
203 | seekable.length !== 0) { | ||
204 | this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0)); | ||
205 | } | ||
206 | |||
199 | oldMediaPlaylist = updatedPlaylist; | 207 | oldMediaPlaylist = updatedPlaylist; |
200 | }.bind(this)); | 208 | }.bind(this)); |
201 | 209 | ||
... | @@ -291,7 +299,6 @@ videojs.Hls.bufferedAdditions_ = function(original, update) { | ... | @@ -291,7 +299,6 @@ videojs.Hls.bufferedAdditions_ = function(original, update) { |
291 | return result; | 299 | return result; |
292 | }; | 300 | }; |
293 | 301 | ||
294 | |||
295 | var parseCodecs = function(codecs) { | 302 | var parseCodecs = function(codecs) { |
296 | var result = { | 303 | var result = { |
297 | codecCount: 0, | 304 | codecCount: 0, |
... | @@ -312,6 +319,7 @@ var parseCodecs = function(codecs) { | ... | @@ -312,6 +319,7 @@ var parseCodecs = function(codecs) { |
312 | 319 | ||
313 | return result; | 320 | return result; |
314 | }; | 321 | }; |
322 | |||
315 | /** | 323 | /** |
316 | * Blacklist playlists that are known to be codec or | 324 | * Blacklist playlists that are known to be codec or |
317 | * stream-incompatible with the SourceBuffer configuration. For | 325 | * stream-incompatible with the SourceBuffer configuration. For |
... | @@ -445,15 +453,15 @@ videojs.HlsHandler.prototype.play = function() { | ... | @@ -445,15 +453,15 @@ videojs.HlsHandler.prototype.play = function() { |
445 | // if the viewer has paused and we fell out of the live window, | 453 | // if the viewer has paused and we fell out of the live window, |
446 | // seek forward to the earliest available position | 454 | // seek forward to the earliest available position |
447 | if (this.duration() === Infinity) { | 455 | if (this.duration() === Infinity) { |
448 | if (this.tech_.currentTime() < this.tech_.seekable().start(0)) { | 456 | if (this.tech_.currentTime() < this.seekable().start(0)) { |
449 | this.tech_.setCurrentTime(this.tech_.seekable().start(0)); | 457 | this.tech_.setCurrentTime(this.seekable().start(0)); |
450 | } | 458 | } |
451 | } | 459 | } |
452 | }; | 460 | }; |
453 | 461 | ||
454 | videojs.HlsHandler.prototype.setCurrentTime = function(currentTime) { | 462 | videojs.HlsHandler.prototype.setCurrentTime = function(currentTime) { |
455 | var | 463 | var |
456 | buffered = this.findCurrentBuffered_(); | 464 | buffered = this.findBufferedRange_(); |
457 | 465 | ||
458 | if (!(this.playlists && this.playlists.media())) { | 466 | if (!(this.playlists && this.playlists.media())) { |
459 | // return immediately if the metadata is not ready yet | 467 | // return immediately if the metadata is not ready yet |
... | @@ -501,7 +509,7 @@ videojs.HlsHandler.prototype.duration = function() { | ... | @@ -501,7 +509,7 @@ videojs.HlsHandler.prototype.duration = function() { |
501 | }; | 509 | }; |
502 | 510 | ||
503 | videojs.HlsHandler.prototype.seekable = function() { | 511 | videojs.HlsHandler.prototype.seekable = function() { |
504 | var media; | 512 | var media, seekable; |
505 | 513 | ||
506 | if (!this.playlists) { | 514 | if (!this.playlists) { |
507 | return videojs.createTimeRanges(); | 515 | return videojs.createTimeRanges(); |
... | @@ -511,7 +519,25 @@ videojs.HlsHandler.prototype.seekable = function() { | ... | @@ -511,7 +519,25 @@ videojs.HlsHandler.prototype.seekable = function() { |
511 | return videojs.createTimeRanges(); | 519 | return videojs.createTimeRanges(); |
512 | } | 520 | } |
513 | 521 | ||
514 | return videojs.Hls.Playlist.seekable(media); | 522 | seekable = videojs.Hls.Playlist.seekable(media); |
523 | if (seekable.length === 0) { | ||
524 | return seekable; | ||
525 | } | ||
526 | |||
527 | // if the seekable start is zero, it may be because the player has | ||
528 | // been paused for a long time and stopped buffering. in that case, | ||
529 | // fall back to the playlist loader's running estimate of expired | ||
530 | // time | ||
531 | if (seekable.start(0) === 0) { | ||
532 | return videojs.createTimeRanges([[ | ||
533 | this.playlists.expired_, | ||
534 | this.playlists.expired_ + seekable.end(0) | ||
535 | ]]); | ||
536 | } | ||
537 | |||
538 | // seekable has been calculated based on buffering video data so it | ||
539 | // can be returned directly | ||
540 | return seekable; | ||
515 | }; | 541 | }; |
516 | 542 | ||
517 | /** | 543 | /** |
... | @@ -522,15 +548,10 @@ videojs.HlsHandler.prototype.updateDuration = function(playlist) { | ... | @@ -522,15 +548,10 @@ videojs.HlsHandler.prototype.updateDuration = function(playlist) { |
522 | newDuration = videojs.Hls.Playlist.duration(playlist), | 548 | newDuration = videojs.Hls.Playlist.duration(playlist), |
523 | setDuration = function() { | 549 | setDuration = function() { |
524 | this.mediaSource.duration = newDuration; | 550 | this.mediaSource.duration = newDuration; |
525 | // update seekable | ||
526 | if (seekable.length !== 0 && newDuration === Infinity) { | ||
527 | this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0)); | ||
528 | } | ||
529 | this.tech_.trigger('durationchange'); | 551 | this.tech_.trigger('durationchange'); |
530 | 552 | ||
531 | this.mediaSource.removeEventListener('sourceopen', setDuration); | 553 | this.mediaSource.removeEventListener('sourceopen', setDuration); |
532 | }.bind(this), | 554 | }.bind(this); |
533 | seekable = this.seekable(); | ||
534 | 555 | ||
535 | // if the duration has changed, invalidate the cached value | 556 | // if the duration has changed, invalidate the cached value |
536 | if (oldDuration !== newDuration) { | 557 | if (oldDuration !== newDuration) { |
... | @@ -539,10 +560,6 @@ videojs.HlsHandler.prototype.updateDuration = function(playlist) { | ... | @@ -539,10 +560,6 @@ videojs.HlsHandler.prototype.updateDuration = function(playlist) { |
539 | this.mediaSource.addEventListener('sourceopen', setDuration); | 560 | this.mediaSource.addEventListener('sourceopen', setDuration); |
540 | } else if (!this.sourceBuffer || !this.sourceBuffer.updating) { | 561 | } else if (!this.sourceBuffer || !this.sourceBuffer.updating) { |
541 | this.mediaSource.duration = newDuration; | 562 | this.mediaSource.duration = newDuration; |
542 | // update seekable | ||
543 | if (seekable.length !== 0 && newDuration === Infinity) { | ||
544 | this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0)); | ||
545 | } | ||
546 | this.tech_.trigger('durationchange'); | 563 | this.tech_.trigger('durationchange'); |
547 | } | 564 | } |
548 | } | 565 | } |
... | @@ -745,43 +762,63 @@ videojs.HlsHandler.prototype.stopCheckingBuffer_ = function() { | ... | @@ -745,43 +762,63 @@ videojs.HlsHandler.prototype.stopCheckingBuffer_ = function() { |
745 | this.tech_.off('waiting', this.drainBuffer); | 762 | this.tech_.off('waiting', this.drainBuffer); |
746 | }; | 763 | }; |
747 | 764 | ||
748 | /** | 765 | var filterBufferedRanges = function(predicate) { |
749 | * Attempts to find the buffered TimeRange where playback is currently | 766 | return function(time) { |
750 | * happening. Returns a new TimeRange with one or zero ranges. | ||
751 | */ | ||
752 | videojs.HlsHandler.prototype.findCurrentBuffered_ = function() { | ||
753 | var | 767 | var |
754 | ranges, | ||
755 | i, | 768 | i, |
769 | ranges = [], | ||
756 | tech = this.tech_, | 770 | tech = this.tech_, |
757 | // !!The order of the next two lines is important!! | 771 | // !!The order of the next two assignments is important!! |
758 | // `currentTime` must be equal-to or greater-than the start of the | 772 | // `currentTime` must be equal-to or greater-than the start of the |
759 | // buffered range. Flash executes out-of-process so, every value can | 773 | // buffered range. Flash executes out-of-process so, every value can |
760 | // change behind the scenes from line-to-line. By reading `currentTime` | 774 | // change behind the scenes from line-to-line. By reading `currentTime` |
761 | // after `buffered`, we ensure that it is always a current or later | 775 | // after `buffered`, we ensure that it is always a current or later |
762 | // value during playback. | 776 | // value during playback. |
763 | buffered = tech.buffered(), | 777 | buffered = tech.buffered(); |
764 | currentTime = tech.currentTime(); | 778 | |
779 | |||
780 | if (time === undefined) { | ||
781 | time = tech.currentTime(); | ||
782 | } | ||
765 | 783 | ||
766 | if (buffered && buffered.length) { | 784 | if (buffered && buffered.length) { |
767 | // Search for a range containing the play-head | 785 | // Search for a range containing the play-head |
768 | for (i = 0; i < buffered.length; i++) { | 786 | for (i = 0; i < buffered.length; i++) { |
769 | if (buffered.start(i) - TIME_FUDGE_FACTOR <= currentTime && | 787 | if (predicate(buffered.start(i), buffered.end(i), time)) { |
770 | buffered.end(i) + TIME_FUDGE_FACTOR >= currentTime) { | 788 | ranges.push([buffered.start(i), buffered.end(i)]); |
771 | ranges = videojs.createTimeRanges(buffered.start(i), buffered.end(i)); | ||
772 | ranges.indexOf = i; | ||
773 | return ranges; | ||
774 | } | 789 | } |
775 | } | 790 | } |
776 | } | 791 | } |
777 | 792 | ||
778 | // Return an empty range if no ranges exist | 793 | return videojs.createTimeRanges(ranges); |
779 | ranges = videojs.createTimeRanges(); | 794 | }; |
780 | ranges.indexOf = -1; | ||
781 | return ranges; | ||
782 | }; | 795 | }; |
783 | 796 | ||
784 | /** | 797 | /** |
798 | * Attempts to find the buffered TimeRange that contains the specified | ||
799 | * time, or where playback is currently happening if no specific time | ||
800 | * is specified. | ||
801 | * @param time (optional) {number} the time to filter on. Defaults to | ||
802 | * currentTime. | ||
803 | * @return a new TimeRanges object. | ||
804 | */ | ||
805 | videojs.HlsHandler.prototype.findBufferedRange_ = filterBufferedRanges(function(start, end, time) { | ||
806 | return start - TIME_FUDGE_FACTOR <= time && | ||
807 | end + TIME_FUDGE_FACTOR >= time; | ||
808 | }); | ||
809 | |||
810 | /** | ||
811 | * Returns the TimeRanges that begin at or later than the specified | ||
812 | * time. | ||
813 | * @param time (optional) {number} the time to filter on. Defaults to | ||
814 | * currentTime. | ||
815 | * @return a new TimeRanges object. | ||
816 | */ | ||
817 | videojs.HlsHandler.prototype.findNextBufferedRange_ = filterBufferedRanges(function(start, end, time) { | ||
818 | return start - TIME_FUDGE_FACTOR >= time; | ||
819 | }); | ||
820 | |||
821 | /** | ||
785 | * Determines whether there is enough video data currently in the buffer | 822 | * Determines whether there is enough video data currently in the buffer |
786 | * and downloads a new segment if the buffered time is less than the goal. | 823 | * and downloads a new segment if the buffered time is less than the goal. |
787 | * @param seekToTime (optional) {number} the offset into the downloaded segment | 824 | * @param seekToTime (optional) {number} the offset into the downloaded segment |
... | @@ -791,7 +828,7 @@ videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) { | ... | @@ -791,7 +828,7 @@ videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) { |
791 | var | 828 | var |
792 | tech = this.tech_, | 829 | tech = this.tech_, |
793 | currentTime = tech.currentTime(), | 830 | currentTime = tech.currentTime(), |
794 | currentBuffered = this.findCurrentBuffered_(), | 831 | currentBuffered = this.findBufferedRange_(), |
795 | currentBufferedEnd = 0, | 832 | currentBufferedEnd = 0, |
796 | bufferedTime = 0, | 833 | bufferedTime = 0, |
797 | segment, | 834 | segment, |
... | @@ -1025,7 +1062,7 @@ videojs.HlsHandler.prototype.drainBuffer = function(event) { | ... | @@ -1025,7 +1062,7 @@ videojs.HlsHandler.prototype.drainBuffer = function(event) { |
1025 | segIv, | 1062 | segIv, |
1026 | segmentTimestampOffset = 0, | 1063 | segmentTimestampOffset = 0, |
1027 | hasBufferedContent = (this.tech_.buffered().length !== 0), | 1064 | hasBufferedContent = (this.tech_.buffered().length !== 0), |
1028 | currentBuffered = this.findCurrentBuffered_(), | 1065 | currentBuffered = this.findBufferedRange_(), |
1029 | outsideBufferedRanges = !(currentBuffered && currentBuffered.length); | 1066 | outsideBufferedRanges = !(currentBuffered && currentBuffered.length); |
1030 | 1067 | ||
1031 | // if the buffer is empty or the source buffer hasn't been created | 1068 | // if the buffer is empty or the source buffer hasn't been created |
... | @@ -1132,6 +1169,7 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () { | ... | @@ -1132,6 +1169,7 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () { |
1132 | playlist, | 1169 | playlist, |
1133 | currentMediaIndex, | 1170 | currentMediaIndex, |
1134 | currentBuffered, | 1171 | currentBuffered, |
1172 | seekable, | ||
1135 | timelineUpdates; | 1173 | timelineUpdates; |
1136 | 1174 | ||
1137 | this.pendingSegment_ = null; | 1175 | this.pendingSegment_ = null; |
... | @@ -1144,7 +1182,7 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () { | ... | @@ -1144,7 +1182,7 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () { |
1144 | playlist = this.playlists.media(); | 1182 | playlist = this.playlists.media(); |
1145 | segments = playlist.segments; | 1183 | segments = playlist.segments; |
1146 | currentMediaIndex = segmentInfo.mediaIndex + (segmentInfo.mediaSequence - playlist.mediaSequence); | 1184 | currentMediaIndex = segmentInfo.mediaIndex + (segmentInfo.mediaSequence - playlist.mediaSequence); |
1147 | currentBuffered = this.findCurrentBuffered_(); | 1185 | currentBuffered = this.findBufferedRange_(); |
1148 | 1186 | ||
1149 | // if we switched renditions don't try to add segment timeline | 1187 | // if we switched renditions don't try to add segment timeline |
1150 | // information to the playlist | 1188 | // information to the playlist |
... | @@ -1156,9 +1194,25 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () { | ... | @@ -1156,9 +1194,25 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () { |
1156 | // added by the media processing | 1194 | // added by the media processing |
1157 | segment = playlist.segments[currentMediaIndex]; | 1195 | segment = playlist.segments[currentMediaIndex]; |
1158 | 1196 | ||
1197 | // when seeking to the beginning of the seekable range, it's | ||
1198 | // possible that imprecise timing information may cause the seek to | ||
1199 | // end up earlier than the start of the range | ||
1200 | // in that case, seek again | ||
1201 | seekable = this.seekable(); | ||
1202 | if (this.tech_.seeking() && | ||
1203 | currentBuffered.length === 0) { | ||
1204 | if (seekable.length && | ||
1205 | this.tech_.currentTime() < seekable.start(0)) { | ||
1206 | var next = this.findNextBufferedRange_(); | ||
1207 | if (next.length) { | ||
1208 | videojs.log('tried seeking to', this.tech_.currentTime(), 'but that was too early, retrying at', next.start(0)); | ||
1209 | this.tech_.setCurrentTime(next.start(0)); | ||
1210 | } | ||
1211 | } | ||
1212 | } | ||
1213 | |||
1159 | timelineUpdates = videojs.Hls.bufferedAdditions_(segmentInfo.buffered, | 1214 | timelineUpdates = videojs.Hls.bufferedAdditions_(segmentInfo.buffered, |
1160 | this.tech_.buffered()); | 1215 | this.tech_.buffered()); |
1161 | |||
1162 | timelineUpdates.forEach(function (update) { | 1216 | timelineUpdates.forEach(function (update) { |
1163 | if (segment) { | 1217 | if (segment) { |
1164 | if (update.end !== undefined) { | 1218 | if (update.end !== undefined) { | ... | ... |
... | @@ -53,6 +53,17 @@ | ... | @@ -53,6 +53,17 @@ |
53 | strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); | 53 | strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); |
54 | }); | 54 | }); |
55 | 55 | ||
56 | test('starts with no expired time', function() { | ||
57 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
58 | requests.pop().respond(200, null, | ||
59 | '#EXTM3U\n' + | ||
60 | '#EXTINF:10,\n' + | ||
61 | '0.ts\n'); | ||
62 | equal(loader.expired_, | ||
63 | 0, | ||
64 | 'zero seconds expired'); | ||
65 | }); | ||
66 | |||
56 | test('requests the initial playlist immediately', function() { | 67 | test('requests the initial playlist immediately', function() { |
57 | new videojs.Hls.PlaylistLoader('master.m3u8'); | 68 | new videojs.Hls.PlaylistLoader('master.m3u8'); |
58 | strictEqual(requests.length, 1, 'made a request'); | 69 | strictEqual(requests.length, 1, 'made a request'); |
... | @@ -166,6 +177,125 @@ | ... | @@ -166,6 +177,125 @@ |
166 | strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | 177 | strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); |
167 | }); | 178 | }); |
168 | 179 | ||
180 | test('increments expired seconds after a segment is removed', 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_, 10, 'expired one segment'); | ||
206 | }); | ||
207 | |||
208 | test('increments expired seconds after a discontinuity', function() { | ||
209 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
210 | requests.pop().respond(200, null, | ||
211 | '#EXTM3U\n' + | ||
212 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
213 | '#EXTINF:10,\n' + | ||
214 | '0.ts\n' + | ||
215 | '#EXTINF:3,\n' + | ||
216 | '1.ts\n' + | ||
217 | '#EXT-X-DISCONTINUITY\n' + | ||
218 | '#EXTINF:4,\n' + | ||
219 | '2.ts\n'); | ||
220 | clock.tick(10 * 1000); // 10s, one target duration | ||
221 | requests.pop().respond(200, null, | ||
222 | '#EXTM3U\n' + | ||
223 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
224 | '#EXTINF:3,\n' + | ||
225 | '1.ts\n' + | ||
226 | '#EXT-X-DISCONTINUITY\n' + | ||
227 | '#EXTINF:4,\n' + | ||
228 | '2.ts\n'); | ||
229 | equal(loader.expired_, 10, 'expired one segment'); | ||
230 | |||
231 | clock.tick(10 * 1000); // 10s, one target duration | ||
232 | requests.pop().respond(200, null, | ||
233 | '#EXTM3U\n' + | ||
234 | '#EXT-X-MEDIA-SEQUENCE:2\n' + | ||
235 | '#EXT-X-DISCONTINUITY\n' + | ||
236 | '#EXTINF:4,\n' + | ||
237 | '2.ts\n'); | ||
238 | equal(loader.expired_, 13, 'no expirations after the discontinuity yet'); | ||
239 | |||
240 | clock.tick(10 * 1000); // 10s, one target duration | ||
241 | requests.pop().respond(200, null, | ||
242 | '#EXTM3U\n' + | ||
243 | '#EXT-X-MEDIA-SEQUENCE:3\n' + | ||
244 | '#EXT-X-DISCONTINUITY-SEQUENCE:1\n' + | ||
245 | '#EXTINF:10,\n' + | ||
246 | '3.ts\n'); | ||
247 | equal(loader.expired_, 17, 'tracked expiration across the discontinuity'); | ||
248 | }); | ||
249 | |||
250 | test('tracks expired seconds properly when two discontinuities expire at once', function() { | ||
251 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
252 | requests.pop().respond(200, null, | ||
253 | '#EXTM3U\n' + | ||
254 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
255 | '#EXTINF:4,\n' + | ||
256 | '0.ts\n' + | ||
257 | '#EXT-X-DISCONTINUITY\n' + | ||
258 | '#EXTINF:5,\n' + | ||
259 | '1.ts\n' + | ||
260 | '#EXT-X-DISCONTINUITY\n' + | ||
261 | '#EXTINF:6,\n' + | ||
262 | '2.ts\n' + | ||
263 | '#EXTINF:7,\n' + | ||
264 | '3.ts\n'); | ||
265 | clock.tick(10 * 1000); | ||
266 | requests.pop().respond(200, null, | ||
267 | '#EXTM3U\n' + | ||
268 | '#EXT-X-MEDIA-SEQUENCE:3\n' + | ||
269 | '#EXT-X-DISCONTINUITY-SEQUENCE:2\n' + | ||
270 | '#EXTINF:7,\n' + | ||
271 | '3.ts\n'); | ||
272 | equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities'); | ||
273 | }); | ||
274 | |||
275 | test('estimates expired if an entire window elapses between live playlist updates', function() { | ||
276 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
277 | requests.pop().respond(200, null, | ||
278 | '#EXTM3U\n' + | ||
279 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
280 | '#EXTINF:4,\n' + | ||
281 | '0.ts\n' + | ||
282 | '#EXTINF:5,\n' + | ||
283 | '1.ts\n'); | ||
284 | |||
285 | clock.tick(10 * 1000); | ||
286 | requests.pop().respond(200, null, | ||
287 | '#EXTM3U\n' + | ||
288 | '#EXT-X-MEDIA-SEQUENCE:4\n' + | ||
289 | '#EXTINF:6,\n' + | ||
290 | '4.ts\n' + | ||
291 | '#EXTINF:7,\n' + | ||
292 | '5.ts\n'); | ||
293 | |||
294 | equal(loader.expired_, | ||
295 | 4 + 5 + (2 * 10), | ||
296 | 'made a very rough estimate of expired time'); | ||
297 | }); | ||
298 | |||
169 | test('emits an error when an initial playlist request fails', function() { | 299 | test('emits an error when an initial playlist request fails', function() { |
170 | var | 300 | var |
171 | errors = [], | 301 | errors = [], |
... | @@ -672,7 +802,7 @@ | ... | @@ -672,7 +802,7 @@ |
672 | equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5'); | 802 | equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5'); |
673 | }); | 803 | }); |
674 | 804 | ||
675 | test('accounts for expired time when calculating media index', function() { | 805 | test('accounts for non-zero starting segment time when calculating media index', function() { |
676 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | 806 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); |
677 | requests.shift().respond(200, null, | 807 | requests.shift().respond(200, null, |
678 | '#EXTM3U\n' + | 808 | '#EXTM3U\n' + |
... | @@ -693,6 +823,53 @@ | ... | @@ -693,6 +823,53 @@ |
693 | equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment'); | 823 | equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment'); |
694 | }); | 824 | }); |
695 | 825 | ||
826 | test('prefers precise segment timing when tracking expired time', function() { | ||
827 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
828 | requests.shift().respond(200, null, | ||
829 | '#EXTM3U\n' + | ||
830 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
831 | '#EXTINF:4,\n' + | ||
832 | '1001.ts\n' + | ||
833 | '#EXTINF:5,\n' + | ||
834 | '1002.ts\n'); | ||
835 | // setup the loader with an "imprecise" value as if it had been | ||
836 | // accumulating segment durations as they expire | ||
837 | loader.expired_ = 160; | ||
838 | // annotate the first segment with a start time | ||
839 | // this number would be coming from the Source Buffer in practice | ||
840 | loader.media().segments[0].start = 150; | ||
841 | |||
842 | equal(loader.getMediaIndexForTime_(151), 0, 'prefers the value on the first segment'); | ||
843 | |||
844 | clock.tick(10 * 1000); // trigger a playlist refresh | ||
845 | requests.shift().respond(200, null, | ||
846 | '#EXTM3U\n' + | ||
847 | '#EXT-X-MEDIA-SEQUENCE:1002\n' + | ||
848 | '#EXTINF:5,\n' + | ||
849 | '1002.ts\n'); | ||
850 | equal(loader.getMediaIndexForTime_(150 + 4 + 1), 0, 'tracks precise expired times'); | ||
851 | }); | ||
852 | |||
853 | test('accounts for expired time when calculating media index', function() { | ||
854 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
855 | requests.shift().respond(200, null, | ||
856 | '#EXTM3U\n' + | ||
857 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
858 | '#EXTINF:4,\n' + | ||
859 | '1001.ts\n' + | ||
860 | '#EXTINF:5,\n' + | ||
861 | '1002.ts\n'); | ||
862 | loader.expired_ = 150; | ||
863 | |||
864 | equal(loader.getMediaIndexForTime_(0), -1, 'expired content returns a negative index'); | ||
865 | equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns a negative index'); | ||
866 | equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position'); | ||
867 | equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); | ||
868 | equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); | ||
869 | equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment'); | ||
870 | equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment'); | ||
871 | }); | ||
872 | |||
696 | test('does not misintrepret playlists missing newlines at the end', function() { | 873 | test('does not misintrepret playlists missing newlines at the end', function() { |
697 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | 874 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); |
698 | requests.shift().respond(200, null, | 875 | requests.shift().respond(200, null, | ... | ... |
... | @@ -549,11 +549,11 @@ test('finds the correct buffered region based on currentTime', function() { | ... | @@ -549,11 +549,11 @@ test('finds the correct buffered region based on currentTime', function() { |
549 | standardXHRResponse(requests[1]); | 549 | standardXHRResponse(requests[1]); |
550 | player.currentTime(3); | 550 | player.currentTime(3); |
551 | clock.tick(1); | 551 | clock.tick(1); |
552 | equal(player.tech_.hls.findCurrentBuffered_().end(0), | 552 | equal(player.tech_.hls.findBufferedRange_().end(0), |
553 | 5, 'inside the first buffered region'); | 553 | 5, 'inside the first buffered region'); |
554 | player.currentTime(6); | 554 | player.currentTime(6); |
555 | clock.tick(1); | 555 | clock.tick(1); |
556 | equal(player.tech_.hls.findCurrentBuffered_().end(0), | 556 | equal(player.tech_.hls.findBufferedRange_().end(0), |
557 | 12, 'inside the second buffered region'); | 557 | 12, 'inside the second buffered region'); |
558 | }); | 558 | }); |
559 | 559 | ||
... | @@ -1636,6 +1636,41 @@ test('live playlist starts with correct currentTime value', function() { | ... | @@ -1636,6 +1636,41 @@ test('live playlist starts with correct currentTime value', function() { |
1636 | 'currentTime is updated at playback'); | 1636 | 'currentTime is updated at playback'); |
1637 | }); | 1637 | }); |
1638 | 1638 | ||
1639 | test('adjusts the seekable start based on the amount of expired live content', function() { | ||
1640 | player.src({ | ||
1641 | src: 'http://example.com/manifest/liveStart30sBefore.m3u8', | ||
1642 | type: 'application/vnd.apple.mpegurl' | ||
1643 | }); | ||
1644 | openMediaSource(player); | ||
1645 | |||
1646 | standardXHRResponse(requests.shift()); | ||
1647 | |||
1648 | // add timeline info to the playlist | ||
1649 | player.tech_.hls.playlists.media().segments[1].end = 29.5; | ||
1650 | // expired_ should be ignored if there is timeline information on | ||
1651 | // the playlist | ||
1652 | player.tech_.hls.playlists.expired_ = 172; | ||
1653 | |||
1654 | equal(player.seekable().start(0), | ||
1655 | 29.5 - 29, | ||
1656 | 'offset the seekable start'); | ||
1657 | }); | ||
1658 | |||
1659 | test('estimates seekable ranges for live streams that have been paused for a long time', function() { | ||
1660 | player.src({ | ||
1661 | src: 'http://example.com/manifest/liveStart30sBefore.m3u8', | ||
1662 | type: 'application/vnd.apple.mpegurl' | ||
1663 | }); | ||
1664 | openMediaSource(player); | ||
1665 | |||
1666 | standardXHRResponse(requests.shift()); | ||
1667 | player.tech_.hls.playlists.expired_ = 172; | ||
1668 | |||
1669 | equal(player.seekable().start(0), | ||
1670 | player.tech_.hls.playlists.expired_, | ||
1671 | 'offset the seekable start'); | ||
1672 | }); | ||
1673 | |||
1639 | test('resets the time to a seekable position when resuming a live stream ' + | 1674 | test('resets the time to a seekable position when resuming a live stream ' + |
1640 | 'after a long break', function() { | 1675 | 'after a long break', function() { |
1641 | var seekTarget; | 1676 | var seekTarget; | ... | ... |
-
Please register or sign in to post a comment