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.
Showing
3 changed files
with
100 additions
and
76 deletions
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, | ... | ... |
-
Please register or sign in to post a comment