fd8b3a9c by David LaPalomento

Adjust timeline offsets if we discover they are inaccurate

When switching renditions or dealing with a live stream with unaligned variant playlists, we may discover that the segment we buffered isn't associated with the time range we expected it to be. In that case, adjust our information about timeline positioning and try buffering again.
1 parent e79f548b
1 /** 1 /**
2 * playlist-loader
3 *
2 * A state machine that manages the loading, caching, and updating of 4 * A state machine that manages the loading, caching, and updating of
3 * M3U8 playlists. When tracking a live playlist, loaders will keep 5 * M3U8 playlists. When tracking a live playlist, loaders will keep
4 * track of the duration of content that expired since the loader was 6 * track of the duration of content that expired since the loader was
5 * initialized and when the current discontinuity sequence was 7 * initialized and when the current discontinuity sequence was
6 * encountered. A complete media timeline for a live playlist with 8 * encountered. A complete media timeline for a live playlist with
7 * expiring segments and discontinuities looks like this: 9 * expiring segments looks like this:
8 * 10 *
9 * |-- expiredPreDiscontinuity --|-- expiredPostDiscontinuity --|-- segments --| 11 * |-- expired --|-- segments --|
10 * 12 *
11 * You can use these values to calculate how much time has elapsed
12 * since the stream began loading or how long it has been since the
13 * most recent discontinuity was encountered, for instance.
14 */ 13 */
15 (function(window, videojs) { 14 (function(window, videojs) {
16 'use strict'; 15 'use strict';
...@@ -159,20 +158,13 @@ ...@@ -159,20 +158,13 @@
159 // initialize the loader state 158 // initialize the loader state
160 loader.state = 'HAVE_NOTHING'; 159 loader.state = 'HAVE_NOTHING';
161 160
162 // the total duration of all segments that expired and have been 161 // The total duration of all segments that expired and have been
163 // removed from the current playlist after the last 162 // removed from the current playlist, in seconds. This property
164 // #EXT-X-DISCONTINUITY. In a live playlist without 163 // should always be zero for non-live playlists. In a live
165 // discontinuities, this is the total amount of time that has 164 // playlist, this is the total amount of time that has been
166 // been removed from the stream since the playlist loader began 165 // removed from the stream since the playlist loader began
167 // tracking it. 166 // tracking it.
168 loader.expiredPostDiscontinuity_ = 0; 167 loader.expired_ = 0;
169
170 // the total duration of all segments that expired and have been
171 // removed from the current playlist before the last
172 // #EXT-X-DISCONTINUITY. The total amount of time that has
173 // expired is always the sum of expiredPreDiscontinuity_ and
174 // expiredPostDiscontinuity_.
175 loader.expiredPreDiscontinuity_ = 0;
176 168
177 // capture the prototype dispose function 169 // capture the prototype dispose function
178 dispose = this.dispose; 170 dispose = this.dispose;
...@@ -364,35 +356,14 @@ ...@@ -364,35 +356,14 @@
364 * @param update {object} the updated media playlist object 356 * @param update {object} the updated media playlist object
365 */ 357 */
366 PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) { 358 PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) {
367 var lastDiscontinuity, expiredCount, i; 359 var expiredCount;
368 360
369 if (this.media_) { 361 if (this.media_) {
370 expiredCount = update.mediaSequence - this.media_.mediaSequence; 362 expiredCount = update.mediaSequence - this.media_.mediaSequence;
371 363
372 // setup the index for duration calculations so that the newly 364 // update the expired time count
373 // expired time will be accumulated after the last 365 this.expired_ += Playlist.duration(this.media_,
374 // discontinuity, unless we discover otherwise
375 lastDiscontinuity = this.media_.mediaSequence;
376
377 if (this.media_.discontinuitySequence !== update.discontinuitySequence) {
378 i = expiredCount;
379 while (i--) {
380 if (this.media_.segments[i].discontinuity) {
381 // a segment that begins a new discontinuity sequence has expired
382 lastDiscontinuity = i + this.media_.mediaSequence;
383 this.expiredPreDiscontinuity_ += this.expiredPostDiscontinuity_;
384 this.expiredPostDiscontinuity_ = 0;
385 break;
386 }
387 }
388 }
389
390 // update the expirated durations
391 this.expiredPreDiscontinuity_ += Playlist.duration(this.media_,
392 this.media_.mediaSequence, 366 this.media_.mediaSequence,
393 lastDiscontinuity);
394 this.expiredPostDiscontinuity_ += Playlist.duration(this.media_,
395 lastDiscontinuity,
396 update.mediaSequence); 367 update.mediaSequence);
397 } 368 }
398 369
...@@ -400,6 +371,28 @@ ...@@ -400,6 +371,28 @@
400 }; 371 };
401 372
402 /** 373 /**
374 * When switching variant playlists in a live stream, the player may
375 * discover that the new set of available segments is shifted in
376 * time relative to the old playlist. If that is the case, you can
377 * call this method to synchronize the playlist loader so that
378 * subsequent calls to getMediaIndexForTime_() return values
379 * appropriate for the new playlist.
380 *
381 * @param mediaIndex {integer} the index of the segment that will be
382 * the used to base timeline calculations on
383 * @param startTime {number} the media timeline position of the
384 * first moment of video data for the specified segment. That is,
385 * data from the specified segment will first be displayed when
386 * `currentTime` is equal to `startTime`.
387 */
388 PlaylistLoader.prototype.updateTimelineOffset = function(mediaIndex, startingTime) {
389 var segmentOffset = Playlist.duration(this.media_,
390 this.media_.mediaSequence,
391 this.media_.mediaSequence + mediaIndex);
392 this.expired_ = startingTime - segmentOffset;
393 };
394
395 /**
403 * Determine the index of the segment that contains a specified 396 * Determine the index of the segment that contains a specified
404 * playback position in the current media playlist. Early versions 397 * playback position in the current media playlist. Early versions
405 * of the HLS specification require segment durations to be rounded 398 * of the HLS specification require segment durations to be rounded
...@@ -426,7 +419,7 @@ ...@@ -426,7 +419,7 @@
426 419
427 // when the requested position is earlier than the current set of 420 // when the requested position is earlier than the current set of
428 // segments, return the earliest segment index 421 // segments, return the earliest segment index
429 time -= this.expiredPreDiscontinuity_ + this.expiredPostDiscontinuity_; 422 time -= this.expired_;
430 if (time < 0) { 423 if (time < 0) {
431 return 0; 424 return 0;
432 } 425 }
......
...@@ -46,6 +46,8 @@ videojs.Hls = videojs.extend(Component, { ...@@ -46,6 +46,8 @@ videojs.Hls = videojs.extend(Component, {
46 this.tech_ = tech; 46 this.tech_ = tech;
47 this.source_ = options.source; 47 this.source_ = options.source;
48 this.mode_ = options.mode; 48 this.mode_ = options.mode;
49 this.pendingSegment_ = null;
50
49 this.bytesReceived = 0; 51 this.bytesReceived = 0;
50 52
51 // loadingState_ tracks how far along the buffering process we 53 // loadingState_ tracks how far along the buffering process we
...@@ -322,19 +324,34 @@ videojs.Hls.prototype.setupSourceBuffer_ = function() { ...@@ -322,19 +324,34 @@ videojs.Hls.prototype.setupSourceBuffer_ = function() {
322 // transition the sourcebuffer to the ended state if we've hit the end of 324 // transition the sourcebuffer to the ended state if we've hit the end of
323 // the playlist 325 // the playlist
324 this.sourceBuffer.addEventListener('updateend', function() { 326 this.sourceBuffer.addEventListener('updateend', function() {
327 var segmentInfo = this.pendingSegment_, i, currentBuffered;
328
329 this.pendingSegment_ = null;
330
325 if (this.duration() !== Infinity && 331 if (this.duration() !== Infinity &&
326 this.mediaIndex === this.playlists.media().segments.length) { 332 this.mediaIndex === this.playlists.media().segments.length) {
327 this.mediaSource.endOfStream(); 333 this.mediaSource.endOfStream();
328 } 334 }
329 335
330 // when switching renditions or seeking, we may misjudge the media 336 // When switching renditions or seeking, we may misjudge the media
331 // index to request to continue playback. check after each append 337 // index to request to continue playback. Check after each append
332 // that our buffering is productive and seek if necessary to 338 // that a gap hasn't appeared in the buffered region and adjust
333 // continue playback 339 // the media index to fill it if necessary
334 if (this.tech_.buffered().length && 340 if (this.tech_.buffered().length === 2 &&
335 this.tech_.currentTime() < this.tech_.buffered().start(this.tech_.buffered().length - 1)) { 341 segmentInfo.playlist === this.playlists.media()) {
336 videojs.log('Variants out of sync. Seeking to continue.'); 342 i = this.tech_.buffered().length;
337 this.tech_.setCurrentTime(this.tech_.buffered().start(this.tech_.buffered().length - 1)); 343 while (i--) {
344 if (this.tech_.currentTime() < this.tech_.buffered().start(i)) {
345 // found the misidentified segment's buffered time range
346 // adjust the media index to fill the gap
347 var mi = this.mediaIndex;
348 currentBuffered = this.findCurrentBuffered_();
349 this.playlists.updateTimelineOffset(segmentInfo.mediaIndex, this.tech_.buffered().start(i));
350 this.mediaIndex = this.playlists.getMediaIndexForTime_(currentBuffered.end(0) + 1);
351 console.log(mi, '->', this.mediaIndex, 'expired:', this.tech_.buffered().start(i));
352 break;
353 }
354 }
338 } 355 }
339 }.bind(this)); 356 }.bind(this));
340 }; 357 };
...@@ -381,8 +398,10 @@ videojs.Hls.prototype.setupMetadataCueTranslation_ = function() { ...@@ -381,8 +398,10 @@ videojs.Hls.prototype.setupMetadataCueTranslation_ = function() {
381 return; 398 return;
382 } 399 }
383 media = this.playlists.media(); 400 media = this.playlists.media();
384 startTime = this.tech_.playlists.expiredPreDiscontinuity_ + this.tech_.playlists.expiredPostDiscontinuity_; 401 startTime = this.tech_.playlists.expired_;
385 startTime += videojs.Hls.Playlist.duration(media, media.mediaSequence, media.mediaSequence + this.tech_.mediaIndex); 402 startTime += videojs.Hls.Playlist.duration(media,
403 media.mediaSequence,
404 media.mediaSequence + this.tech_.mediaIndex);
386 405
387 i = textTrack.cues.length; 406 i = textTrack.cues.length;
388 while (i--) { 407 while (i--) {
...@@ -395,8 +414,7 @@ videojs.Hls.prototype.setupMetadataCueTranslation_ = function() { ...@@ -395,8 +414,7 @@ videojs.Hls.prototype.setupMetadataCueTranslation_ = function() {
395 414
396 videojs.Hls.prototype.addCuesForMetadata_ = function(segmentInfo) { 415 videojs.Hls.prototype.addCuesForMetadata_ = function(segmentInfo) {
397 var i, cue, frame, metadata, minPts, segment, segmentOffset, textTrack, time; 416 var i, cue, frame, metadata, minPts, segment, segmentOffset, textTrack, time;
398 segmentOffset = this.playlists.expiredPreDiscontinuity_; 417 segmentOffset = this.playlists.expired_;
399 segmentOffset += this.playlists.expiredPostDiscontinuity_;
400 segmentOffset += videojs.Hls.Playlist.duration(segmentInfo.playlist, 418 segmentOffset += videojs.Hls.Playlist.duration(segmentInfo.playlist,
401 segmentInfo.playlist.mediaSequence, 419 segmentInfo.playlist.mediaSequence,
402 segmentInfo.playlist.mediaSequence + segmentInfo.mediaIndex); 420 segmentInfo.playlist.mediaSequence + segmentInfo.mediaIndex);
...@@ -543,7 +561,7 @@ videojs.Hls.prototype.seekable = function() { ...@@ -543,7 +561,7 @@ videojs.Hls.prototype.seekable = function() {
543 return currentSeekable; 561 return currentSeekable;
544 } 562 }
545 563
546 startOffset = this.playlists.expiredPostDiscontinuity_ - this.playlists.expiredPreDiscontinuity_; 564 startOffset = this.playlists.expired_;
547 return videojs.createTimeRanges(startOffset, 565 return videojs.createTimeRanges(startOffset,
548 startOffset + (currentSeekable.end(0) - currentSeekable.start(0))); 566 startOffset + (currentSeekable.end(0) - currentSeekable.start(0)));
549 }; 567 };
...@@ -1080,10 +1098,9 @@ videojs.Hls.prototype.drainBuffer = function(event) { ...@@ -1080,10 +1098,9 @@ videojs.Hls.prototype.drainBuffer = function(event) {
1080 this.sourceBuffer.timestampOffset = currentBuffered.end(0); 1098 this.sourceBuffer.timestampOffset = currentBuffered.end(0);
1081 } 1099 }
1082 1100
1101 // the segment is asynchronously added to the current buffered data
1083 this.sourceBuffer.appendBuffer(bytes); 1102 this.sourceBuffer.appendBuffer(bytes);
1084 1103 this.pendingSegment_ = segmentBuffer.shift();
1085 // we're done processing this segment
1086 segmentBuffer.shift();
1087 }; 1104 };
1088 1105
1089 /** 1106 /**
......
...@@ -59,12 +59,7 @@ ...@@ -59,12 +59,7 @@
59 '#EXTM3U\n' + 59 '#EXTM3U\n' +
60 '#EXTINF:10,\n' + 60 '#EXTINF:10,\n' +
61 '0.ts\n'); 61 '0.ts\n');
62 equal(loader.expiredPreDiscontinuity_, 62 equal(loader.expired_, 0, 'zero seconds expired');
63 0,
64 'zero seconds expired pre-discontinuity');
65 equal(loader.expiredPostDiscontinuity_,
66 0,
67 'zero seconds expired post-discontinuity');
68 }); 63 });
69 64
70 test('requests the initial playlist immediately', function() { 65 test('requests the initial playlist immediately', function() {
...@@ -202,7 +197,7 @@ ...@@ -202,7 +197,7 @@
202 '3.ts\n' + 197 '3.ts\n' +
203 '#EXTINF:10,\n' + 198 '#EXTINF:10,\n' +
204 '4.ts\n'); 199 '4.ts\n');
205 equal(loader.expiredPostDiscontinuity_, 10, 'expired one segment'); 200 equal(loader.expired_, 10, 'expired one segment');
206 }); 201 });
207 202
208 test('increments expired seconds after a discontinuity', function() { 203 test('increments expired seconds after a discontinuity', function() {
...@@ -226,8 +221,7 @@ ...@@ -226,8 +221,7 @@
226 '#EXT-X-DISCONTINUITY\n' + 221 '#EXT-X-DISCONTINUITY\n' +
227 '#EXTINF:4,\n' + 222 '#EXTINF:4,\n' +
228 '2.ts\n'); 223 '2.ts\n');
229 equal(loader.expiredPreDiscontinuity_, 0, 'identifies pre-discontinuity time'); 224 equal(loader.expired_, 10, 'expired one segment');
230 equal(loader.expiredPostDiscontinuity_, 10, 'expired one segment');
231 225
232 clock.tick(10 * 1000); // 10s, one target duration 226 clock.tick(10 * 1000); // 10s, one target duration
233 requests.pop().respond(200, null, 227 requests.pop().respond(200, null,
...@@ -236,8 +230,7 @@ ...@@ -236,8 +230,7 @@
236 '#EXT-X-DISCONTINUITY\n' + 230 '#EXT-X-DISCONTINUITY\n' +
237 '#EXTINF:4,\n' + 231 '#EXTINF:4,\n' +
238 '2.ts\n'); 232 '2.ts\n');
239 equal(loader.expiredPreDiscontinuity_, 0, 'tracked time across the discontinuity'); 233 equal(loader.expired_, 13, 'no expirations after the discontinuity yet');
240 equal(loader.expiredPostDiscontinuity_, 13, 'no expirations after the discontinuity yet');
241 234
242 clock.tick(10 * 1000); // 10s, one target duration 235 clock.tick(10 * 1000); // 10s, one target duration
243 requests.pop().respond(200, null, 236 requests.pop().respond(200, null,
...@@ -246,8 +239,7 @@ ...@@ -246,8 +239,7 @@
246 '#EXT-X-DISCONTINUITY-SEQUENCE:1\n' + 239 '#EXT-X-DISCONTINUITY-SEQUENCE:1\n' +
247 '#EXTINF:10,\n' + 240 '#EXTINF:10,\n' +
248 '3.ts\n'); 241 '3.ts\n');
249 equal(loader.expiredPreDiscontinuity_, 13, 'did not increment pre-discontinuity'); 242 equal(loader.expired_, 13 + 4, 'tracked expired prior to the discontinuity');
250 equal(loader.expiredPostDiscontinuity_, 4, 'expired post-discontinuity');
251 }); 243 });
252 244
253 test('tracks expired seconds properly when two discontinuities expire at once', function() { 245 test('tracks expired seconds properly when two discontinuities expire at once', function() {
...@@ -272,8 +264,7 @@ ...@@ -272,8 +264,7 @@
272 '#EXT-X-DISCONTINUITY-SEQUENCE:2\n' + 264 '#EXT-X-DISCONTINUITY-SEQUENCE:2\n' +
273 '#EXTINF:7,\n' + 265 '#EXTINF:7,\n' +
274 '3.ts\n'); 266 '3.ts\n');
275 equal(loader.expiredPreDiscontinuity_, 4 + 5, 'tracked pre-discontinuity time'); 267 equal(loader.expired_, 4 + 5 + 6, 'tracked both expired discontinuities');
276 equal(loader.expiredPostDiscontinuity_, 6, 'tracked post-discontinuity time');
277 }); 268 });
278 269
279 test('emits an error when an initial playlist request fails', function() { 270 test('emits an error when an initial playlist request fails', function() {
...@@ -782,8 +773,7 @@ ...@@ -782,8 +773,7 @@
782 '1001.ts\n' + 773 '1001.ts\n' +
783 '#EXTINF:5,\n' + 774 '#EXTINF:5,\n' +
784 '1002.ts\n'); 775 '1002.ts\n');
785 loader.expiredPreDiscontinuity_ = 50; 776 loader.expired_ = 150;
786 loader.expiredPostDiscontinuity_ = 100;
787 777
788 equal(loader.getMediaIndexForTime_(0), 0, 'the lowest returned value is zero'); 778 equal(loader.getMediaIndexForTime_(0), 0, 'the lowest returned value is zero');
789 equal(loader.getMediaIndexForTime_(45), 0, 'expired content returns zero'); 779 equal(loader.getMediaIndexForTime_(45), 0, 'expired content returns zero');
...@@ -795,6 +785,30 @@ ...@@ -795,6 +785,30 @@
795 equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment'); 785 equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment');
796 }); 786 });
797 787
788 test('updating the timeline offset adjusts results from getMediaIndexForTime_', function() {
789 var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
790 requests.pop().respond(200, null,
791 '#EXTM3U\n' +
792 '#EXT-X-MEDIA-SEQUENCE:23\n' +
793 '#EXTINF:4,\n' +
794 '23.ts\n' +
795 '#EXTINF:5,\n' +
796 '24.ts\n' +
797 '#EXTINF:6,\n' +
798 '25.ts\n' +
799 '#EXTINF:7,\n' +
800 '26.ts\n');
801 loader.updateTimelineOffset(0, 150);
802 equal(loader.getMediaIndexForTime_(150), 0, 'translated the first segment');
803 equal(loader.getMediaIndexForTime_(130), 0, 'clamps the index to zero');
804 equal(loader.getMediaIndexForTime_(155), 1, 'translated the second segment');
805
806 loader.updateTimelineOffset(2, 30);
807 equal(loader.getMediaIndexForTime_(30 - 5 - 1), 0, 'translated the first segment');
808 equal(loader.getMediaIndexForTime_(30 + 7), 3, 'translated the last segment');
809 equal(loader.getMediaIndexForTime_(30 - 3), 1, 'translated an earlier segment');
810 });
811
798 test('does not misintrepret playlists missing newlines at the end', function() { 812 test('does not misintrepret playlists missing newlines at the end', function() {
799 var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); 813 var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
800 requests.shift().respond(200, null, 814 requests.shift().respond(200, null,
......