Merge pull request #397 from dmlap/live-variant-syncing
Live variant syncing
Showing
7 changed files
with
276 additions
and
126 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,42 +356,43 @@ | ... | @@ -364,42 +356,43 @@ |
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 | 366 | this.media_.mediaSequence, |
375 | lastDiscontinuity = this.media_.mediaSequence; | 367 | update.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, | ||
393 | lastDiscontinuity); | ||
394 | this.expiredPostDiscontinuity_ += Playlist.duration(this.media_, | ||
395 | lastDiscontinuity, | ||
396 | update.mediaSequence); | ||
397 | } | 368 | } |
398 | 369 | ||
399 | this.media_ = this.master.playlists[update.uri]; | 370 | this.media_ = this.master.playlists[update.uri]; |
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 |
... | @@ -311,6 +313,8 @@ videojs.Hls.prototype.setupSourceBuffer_ = function() { | ... | @@ -311,6 +313,8 @@ videojs.Hls.prototype.setupSourceBuffer_ = function() { |
311 | return; | 313 | return; |
312 | } | 314 | } |
313 | 315 | ||
316 | // if the codecs were explicitly specified, pass them along to the | ||
317 | // source buffer | ||
314 | mimeType = 'video/mp2t'; | 318 | mimeType = 'video/mp2t'; |
315 | if (media.attributes && media.attributes.CODECS) { | 319 | if (media.attributes && media.attributes.CODECS) { |
316 | mimeType += '; codecs="' + media.attributes.CODECS + '"'; | 320 | mimeType += '; codecs="' + media.attributes.CODECS + '"'; |
... | @@ -320,10 +324,33 @@ videojs.Hls.prototype.setupSourceBuffer_ = function() { | ... | @@ -320,10 +324,33 @@ videojs.Hls.prototype.setupSourceBuffer_ = function() { |
320 | // 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 |
321 | // the playlist | 325 | // the playlist |
322 | this.sourceBuffer.addEventListener('updateend', function() { | 326 | this.sourceBuffer.addEventListener('updateend', function() { |
327 | var segmentInfo = this.pendingSegment_, i, currentBuffered; | ||
328 | |||
329 | this.pendingSegment_ = null; | ||
330 | |||
323 | if (this.duration() !== Infinity && | 331 | if (this.duration() !== Infinity && |
324 | this.mediaIndex === this.playlists.media().segments.length) { | 332 | this.mediaIndex === this.playlists.media().segments.length) { |
325 | this.mediaSource.endOfStream(); | 333 | this.mediaSource.endOfStream(); |
326 | } | 334 | } |
335 | |||
336 | // When switching renditions or seeking, we may misjudge the media | ||
337 | // index to request to continue playback. Check after each append | ||
338 | // that a gap hasn't appeared in the buffered region and adjust | ||
339 | // the media index to fill it if necessary | ||
340 | if (this.tech_.buffered().length === 2 && | ||
341 | segmentInfo.playlist === this.playlists.media()) { | ||
342 | i = this.tech_.buffered().length; | ||
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 | currentBuffered = this.findCurrentBuffered_(); | ||
348 | this.playlists.updateTimelineOffset(segmentInfo.mediaIndex, this.tech_.buffered().start(i)); | ||
349 | this.mediaIndex = this.playlists.getMediaIndexForTime_(currentBuffered.end(0) + 1); | ||
350 | break; | ||
351 | } | ||
352 | } | ||
353 | } | ||
327 | }.bind(this)); | 354 | }.bind(this)); |
328 | }; | 355 | }; |
329 | 356 | ||
... | @@ -369,8 +396,10 @@ videojs.Hls.prototype.setupMetadataCueTranslation_ = function() { | ... | @@ -369,8 +396,10 @@ videojs.Hls.prototype.setupMetadataCueTranslation_ = function() { |
369 | return; | 396 | return; |
370 | } | 397 | } |
371 | media = this.playlists.media(); | 398 | media = this.playlists.media(); |
372 | startTime = this.tech_.playlists.expiredPreDiscontinuity_ + this.tech_.playlists.expiredPostDiscontinuity_; | 399 | startTime = this.tech_.playlists.expired_; |
373 | startTime += videojs.Hls.Playlist.duration(media, media.mediaSequence, media.mediaSequence + this.tech_.mediaIndex); | 400 | startTime += videojs.Hls.Playlist.duration(media, |
401 | media.mediaSequence, | ||
402 | media.mediaSequence + this.tech_.mediaIndex); | ||
374 | 403 | ||
375 | i = textTrack.cues.length; | 404 | i = textTrack.cues.length; |
376 | while (i--) { | 405 | while (i--) { |
... | @@ -383,8 +412,7 @@ videojs.Hls.prototype.setupMetadataCueTranslation_ = function() { | ... | @@ -383,8 +412,7 @@ videojs.Hls.prototype.setupMetadataCueTranslation_ = function() { |
383 | 412 | ||
384 | videojs.Hls.prototype.addCuesForMetadata_ = function(segmentInfo) { | 413 | videojs.Hls.prototype.addCuesForMetadata_ = function(segmentInfo) { |
385 | var i, cue, frame, metadata, minPts, segment, segmentOffset, textTrack, time; | 414 | var i, cue, frame, metadata, minPts, segment, segmentOffset, textTrack, time; |
386 | segmentOffset = this.playlists.expiredPreDiscontinuity_; | 415 | segmentOffset = this.playlists.expired_; |
387 | segmentOffset += this.playlists.expiredPostDiscontinuity_; | ||
388 | segmentOffset += videojs.Hls.Playlist.duration(segmentInfo.playlist, | 416 | segmentOffset += videojs.Hls.Playlist.duration(segmentInfo.playlist, |
389 | segmentInfo.playlist.mediaSequence, | 417 | segmentInfo.playlist.mediaSequence, |
390 | segmentInfo.playlist.mediaSequence + segmentInfo.mediaIndex); | 418 | segmentInfo.playlist.mediaSequence + segmentInfo.mediaIndex); |
... | @@ -531,7 +559,7 @@ videojs.Hls.prototype.seekable = function() { | ... | @@ -531,7 +559,7 @@ videojs.Hls.prototype.seekable = function() { |
531 | return currentSeekable; | 559 | return currentSeekable; |
532 | } | 560 | } |
533 | 561 | ||
534 | startOffset = this.playlists.expiredPostDiscontinuity_ - this.playlists.expiredPreDiscontinuity_; | 562 | startOffset = this.playlists.expired_; |
535 | return videojs.createTimeRanges(startOffset, | 563 | return videojs.createTimeRanges(startOffset, |
536 | startOffset + (currentSeekable.end(0) - currentSeekable.start(0))); | 564 | startOffset + (currentSeekable.end(0) - currentSeekable.start(0))); |
537 | }; | 565 | }; |
... | @@ -1068,10 +1096,9 @@ videojs.Hls.prototype.drainBuffer = function(event) { | ... | @@ -1068,10 +1096,9 @@ videojs.Hls.prototype.drainBuffer = function(event) { |
1068 | this.sourceBuffer.timestampOffset = currentBuffered.end(0); | 1096 | this.sourceBuffer.timestampOffset = currentBuffered.end(0); |
1069 | } | 1097 | } |
1070 | 1098 | ||
1099 | // the segment is asynchronously added to the current buffered data | ||
1071 | this.sourceBuffer.appendBuffer(bytes); | 1100 | this.sourceBuffer.appendBuffer(bytes); |
1072 | 1101 | this.pendingSegment_ = segmentBuffer.shift(); | |
1073 | // we're done processing this segment | ||
1074 | segmentBuffer.shift(); | ||
1075 | }; | 1102 | }; |
1076 | 1103 | ||
1077 | /** | 1104 | /** | ... | ... |
... | @@ -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, | ... | ... |
... | @@ -23,7 +23,9 @@ | ... | @@ -23,7 +23,9 @@ |
23 | 23 | ||
24 | <!-- Media Sources plugin --> | 24 | <!-- Media Sources plugin --> |
25 | <script src="../../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script> | 25 | <script src="../../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script> |
26 | 26 | <script> | |
27 | videojs.MediaSource.webWorkerURI = '../../node_modules/videojs-contrib-media-sources/src/transmuxer_worker.js'; | ||
28 | </script> | ||
27 | <!-- HLS plugin --> | 29 | <!-- HLS plugin --> |
28 | <script src="../../src/videojs-hls.js"></script> | 30 | <script src="../../src/videojs-hls.js"></script> |
29 | 31 | ||
... | @@ -77,12 +79,11 @@ | ... | @@ -77,12 +79,11 @@ |
77 | </video> | 79 | </video> |
78 | <section class="stats"> | 80 | <section class="stats"> |
79 | <h2>Player Stats</h2> | 81 | <h2>Player Stats</h2> |
80 | <div class="segment-timeline"></div> | ||
81 | <dl> | 82 | <dl> |
82 | <dt>Current Time:</dt> | 83 | <dt>Current Time:</dt> |
83 | <dd class="current-time-stat">0</dd> | 84 | <dd class="current-time-stat">0</dd> |
84 | <dt>Buffered:</dt> | 85 | <dt>Buffered:</dt> |
85 | <dd><span class="buffered-start-stat">-</span> - <span class="buffered-end-stat">-</span></dd> | 86 | <dd class="buffered-stat">-</dd> |
86 | <dt>Seekable:</dt> | 87 | <dt>Seekable:</dt> |
87 | <dd><span class="seekable-start-stat">-</span> - <span class="seekable-end-stat">-</span></dd> | 88 | <dd><span class="seekable-start-stat">-</span> - <span class="seekable-end-stat">-</span></dd> |
88 | <dt>Video Bitrate:</dt> | 89 | <dt>Video Bitrate:</dt> |
... | @@ -90,10 +91,13 @@ | ... | @@ -90,10 +91,13 @@ |
90 | <dt>Measured Bitrate:</dt> | 91 | <dt>Measured Bitrate:</dt> |
91 | <dd class="measured-bitrate-stat">0 kbps</dd> | 92 | <dd class="measured-bitrate-stat">0 kbps</dd> |
92 | </dl> | 93 | </dl> |
94 | <h3>Bitrate Switching</h3> | ||
93 | <div class="switching-stats"> | 95 | <div class="switching-stats"> |
94 | Once the player begins loading, you'll see information about the | 96 | Once the player begins loading, you'll see information about the |
95 | operation of the adaptive quality switching here. | 97 | operation of the adaptive quality switching here. |
96 | </div> | 98 | </div> |
99 | <h3>Timed Metadata</h3> | ||
100 | <div class="segment-timeline"></div> | ||
97 | </section> | 101 | </section> |
98 | 102 | ||
99 | <script src="stats.js"></script> | 103 | <script src="stats.js"></script> |
... | @@ -107,8 +111,7 @@ | ... | @@ -107,8 +111,7 @@ |
107 | // ------------ | 111 | // ------------ |
108 | 112 | ||
109 | var currentTimeStat = document.querySelector('.current-time-stat'); | 113 | var currentTimeStat = document.querySelector('.current-time-stat'); |
110 | var bufferedStartStat = document.querySelector('.buffered-start-stat'); | 114 | var bufferedStat = document.querySelector('.buffered-stat'); |
111 | var bufferedEndStat = document.querySelector('.buffered-end-stat'); | ||
112 | var seekableStartStat = document.querySelector('.seekable-start-stat'); | 115 | var seekableStartStat = document.querySelector('.seekable-start-stat'); |
113 | var seekableEndStat = document.querySelector('.seekable-end-stat'); | 116 | var seekableEndStat = document.querySelector('.seekable-end-stat'); |
114 | var videoBitrateState = document.querySelector('.video-bitrate-stat'); | 117 | var videoBitrateState = document.querySelector('.video-bitrate-stat'); |
... | @@ -119,20 +122,17 @@ | ... | @@ -119,20 +122,17 @@ |
119 | }); | 122 | }); |
120 | 123 | ||
121 | player.on('progress', function() { | 124 | player.on('progress', function() { |
122 | var oldStart, oldEnd; | 125 | var bufferedText = '', oldStart, oldEnd, i; |
126 | |||
123 | // buffered | 127 | // buffered |
124 | var buffered = player.buffered(); | 128 | var buffered = player.buffered(); |
125 | if (buffered.length) { | 129 | if (buffered.length) { |
126 | 130 | bufferedText += buffered.start(0) + ' - ' + buffered.end(0); | |
127 | oldStart = bufferedStartStat.textContent; | 131 | } |
128 | if (buffered.start(0).toFixed(1) !== oldStart) { | 132 | for (i = 1; i < buffered.length; i++) { |
129 | bufferedStartStat.textContent = buffered.start(0).toFixed(1); | 133 | bufferedText += ', ' + buffered.start(i) + ' - ' + buffered.end(i); |
130 | } | ||
131 | oldEnd = bufferedEndStat.textContent; | ||
132 | if (buffered.end(0).toFixed(1) !== oldEnd) { | ||
133 | bufferedEndStat.textContent = buffered.end(0).toFixed(1); | ||
134 | } | ||
135 | } | 134 | } |
135 | bufferedStat.textContent = bufferedText; | ||
136 | 136 | ||
137 | // seekable | 137 | // seekable |
138 | var seekable = player.seekable(); | 138 | var seekable = player.seekable(); |
... | @@ -149,14 +149,14 @@ | ... | @@ -149,14 +149,14 @@ |
149 | } | 149 | } |
150 | 150 | ||
151 | // bitrates | 151 | // bitrates |
152 | var playlist = player.tech.hls.playlists.media(); | 152 | var playlist = player.tech_.hls.playlists.media(); |
153 | if (playlist && playlist.attributes && playlist.attributes.BANDWIDTH) { | 153 | if (playlist && playlist.attributes && playlist.attributes.BANDWIDTH) { |
154 | videoBitrateState.textContent = (playlist.attributes.BANDWIDTH / 1024).toLocaleString(undefined, { | 154 | videoBitrateState.textContent = (playlist.attributes.BANDWIDTH / 1024).toLocaleString(undefined, { |
155 | maximumFractionDigits: 1 | 155 | maximumFractionDigits: 1 |
156 | }) + ' kbps'; | 156 | }) + ' kbps'; |
157 | } | 157 | } |
158 | if (player.tech.hls.bandwidth) { | 158 | if (player.tech_.hls.bandwidth) { |
159 | measuredBitrateStat.textContent = (player.tech.hls.bandwidth / 1024).toLocaleString(undefined, { | 159 | measuredBitrateStat.textContent = (player.tech_.hls.bandwidth / 1024).toLocaleString(undefined, { |
160 | maximumFractionDigits: 1 | 160 | maximumFractionDigits: 1 |
161 | }) + ' kbps'; | 161 | }) + ' kbps'; |
162 | } | 162 | } | ... | ... |
... | @@ -4,10 +4,15 @@ | ... | @@ -4,10 +4,15 @@ |
4 | } | 4 | } |
5 | 5 | ||
6 | .axis line, | 6 | .axis line, |
7 | .axis path, | 7 | .axis path { |
8 | .intersect { | 8 | fill: none; |
9 | stroke: #111; | ||
10 | } | ||
11 | |||
12 | .bitrates { | ||
9 | fill: none; | 13 | fill: none; |
10 | stroke: #000; | 14 | stroke: steelblue; |
15 | stroke-width: 3px; | ||
11 | } | 16 | } |
12 | 17 | ||
13 | .cue { | 18 | .cue { |
... | @@ -23,6 +28,6 @@ | ... | @@ -23,6 +28,6 @@ |
23 | 28 | ||
24 | .intersect { | 29 | .intersect { |
25 | fill: none; | 30 | fill: none; |
26 | stroke: #000; | 31 | stroke: #111; |
27 | stroke-dasharray: 2,2; | 32 | stroke-dasharray: 2,2; |
28 | } | 33 | } | ... | ... |
... | @@ -7,9 +7,35 @@ | ... | @@ -7,9 +7,35 @@ |
7 | 7 | ||
8 | var d3 = window.d3; | 8 | var d3 = window.d3; |
9 | 9 | ||
10 | var setupGraph = function(element) { | 10 | var bitrateTickFormatter = d3.format(',.0f'); |
11 | element.innerHTML = ''; | ||
12 | 11 | ||
12 | var updateBitrateAxes = function(svg, xScale, yScale) { | ||
13 | var xAxis = d3.svg.axis().scale(xScale).orient('bottom'); | ||
14 | svg.select('.axis.x') | ||
15 | .transition().duration(500) | ||
16 | .call(xAxis); | ||
17 | |||
18 | var yAxis = d3.svg.axis().scale(yScale) | ||
19 | .tickFormat(function(value) { | ||
20 | return bitrateTickFormatter(value / 1024); | ||
21 | }).orient('left'); | ||
22 | svg.select('.axis.y') | ||
23 | .transition().duration(500) | ||
24 | .call(yAxis); | ||
25 | }; | ||
26 | |||
27 | var updateBitrates = function(svg, x, y, measuredBitrateKbps) { | ||
28 | var bitrates, line; | ||
29 | |||
30 | bitrates = svg.selectAll('.bitrates').datum(measuredBitrateKbps); | ||
31 | line = d3.svg.line() | ||
32 | .x(function(bitrate) { return x(bitrate.time); }) | ||
33 | .y(function(bitrate) { return y(bitrate.value); }); | ||
34 | |||
35 | bitrates.transition().duration(500).attr('d', line); | ||
36 | }; | ||
37 | |||
38 | var setupGraph = function(element, player) { | ||
13 | // setup the display | 39 | // setup the display |
14 | var margin = { | 40 | var margin = { |
15 | top: 20, | 41 | top: 20, |
... | @@ -30,15 +56,14 @@ | ... | @@ -30,15 +56,14 @@ |
30 | var x = d3.time.scale().range([0, width]); // d3.scale.linear().range([0, width]); | 56 | var x = d3.time.scale().range([0, width]); // d3.scale.linear().range([0, width]); |
31 | var y = d3.scale.linear().range([height, 0]); | 57 | var y = d3.scale.linear().range([height, 0]); |
32 | 58 | ||
33 | x.domain([new Date(), new Date(Date.now() + (5 * 60 * 1000))]); | 59 | x.domain([new Date(), new Date(Date.now() + (1 * 60 * 1000))]); |
34 | y.domain([0, 5 * 1024 * 1024 * 8]); | 60 | y.domain([0, 5 * 1024 * 1024 * 8]); |
35 | 61 | ||
36 | var timeAxis = d3.svg.axis().scale(x).orient('bottom'); | 62 | var timeAxis = d3.svg.axis().scale(x).orient('bottom'); |
37 | var tickFormatter = d3.format(',.0f'); | ||
38 | var bitrateAxis = d3.svg.axis() | 63 | var bitrateAxis = d3.svg.axis() |
39 | .scale(y) | 64 | .scale(y) |
40 | .tickFormat(function(value) { | 65 | .tickFormat(function(value) { |
41 | return tickFormatter(value / 1024); | 66 | return bitrateTickFormatter(value / 1024); |
42 | }) | 67 | }) |
43 | .orient('left'); | 68 | .orient('left'); |
44 | 69 | ||
... | @@ -60,6 +85,26 @@ | ... | @@ -60,6 +85,26 @@ |
60 | .style('text-anchor', 'end') | 85 | .style('text-anchor', 'end') |
61 | .text('Bitrate (kb/s)'); | 86 | .text('Bitrate (kb/s)'); |
62 | 87 | ||
88 | svg.append('path') | ||
89 | .attr('class', 'bitrates'); | ||
90 | |||
91 | var measuredBitrateKbps = [{ | ||
92 | time: new Date(), | ||
93 | value: player.tech_.hls.bandwidth || 0 | ||
94 | }]; | ||
95 | |||
96 | player.on('progress', function() { | ||
97 | measuredBitrateKbps.push({ | ||
98 | time: new Date(), | ||
99 | value: player.tech_.hls.bandwidth || 0 | ||
100 | }); | ||
101 | x.domain([x.domain()[0], new Date()]); | ||
102 | y.domain([0, d3.max(measuredBitrateKbps, function(bitrate) { | ||
103 | return bitrate.value; | ||
104 | })]); | ||
105 | updateBitrateAxes(svg, x, y); | ||
106 | updateBitrates(svg, x, y, measuredBitrateKbps); | ||
107 | }); | ||
63 | }; | 108 | }; |
64 | 109 | ||
65 | // --------------- | 110 | // --------------- |
... | @@ -86,8 +131,8 @@ | ... | @@ -86,8 +131,8 @@ |
86 | 131 | ||
87 | var mediaDomain = function(media, player) { | 132 | var mediaDomain = function(media, player) { |
88 | var segments = media.segments; | 133 | var segments = media.segments; |
89 | var end = player.tech.hls.playlists.expiredPreDiscontinuity_; | 134 | var end = player.tech_.hls.playlists.expiredPreDiscontinuity_; |
90 | end += player.tech.hls.playlists.expiredPostDiscontinuity_; | 135 | end += player.tech_.hls.playlists.expiredPostDiscontinuity_; |
91 | end += Playlist.duration(media, | 136 | end += Playlist.duration(media, |
92 | media.mediaSequence, | 137 | media.mediaSequence, |
93 | media.mediaSequence + segments.length); | 138 | media.mediaSequence + segments.length); |
... | @@ -160,7 +205,7 @@ | ... | @@ -160,7 +205,7 @@ |
160 | .call(ptsAxis); | 205 | .call(ptsAxis); |
161 | }; | 206 | }; |
162 | var svgRenderSegmentTimeline = function(container, player) { | 207 | var svgRenderSegmentTimeline = function(container, player) { |
163 | var media = player.tech.hls.playlists.media(); | 208 | var media = player.tech_.hls.playlists.media(); |
164 | var segments = media.segments; // media.segments.slice(0, count); | 209 | var segments = media.segments; // media.segments.slice(0, count); |
165 | 210 | ||
166 | // setup the display | 211 | // setup the display |
... | @@ -196,7 +241,7 @@ | ... | @@ -196,7 +241,7 @@ |
196 | 241 | ||
197 | // update everything on progress | 242 | // update everything on progress |
198 | player.on('progress', function() { | 243 | player.on('progress', function() { |
199 | var updatedMedia = player.tech.hls.playlists.media(); | 244 | var updatedMedia = player.tech_.hls.playlists.media(); |
200 | var segments = updatedMedia.segments; // updatedMedia.segments.slice(currentIndex, currentIndex + count); | 245 | var segments = updatedMedia.segments; // updatedMedia.segments.slice(currentIndex, currentIndex + count); |
201 | 246 | ||
202 | if (updatedMedia.mediaSequence !== media.mediaSequence) { | 247 | if (updatedMedia.mediaSequence !== media.mediaSequence) { |
... | @@ -220,7 +265,7 @@ | ... | @@ -220,7 +265,7 @@ |
220 | }; | 265 | }; |
221 | 266 | ||
222 | var displayCues = function(container, player) { | 267 | var displayCues = function(container, player) { |
223 | var media = player.tech.hls.playlists.media(); | 268 | var media = player.tech_.hls.playlists.media(); |
224 | if (media && media.segments) { | 269 | if (media && media.segments) { |
225 | svgRenderSegmentTimeline(container, player); | 270 | svgRenderSegmentTimeline(container, player); |
226 | } else { | 271 | } else { | ... | ... |
... | @@ -42,7 +42,25 @@ var | ... | @@ -42,7 +42,25 @@ var |
42 | // patch over some methods of the provided tech so it can be tested | 42 | // patch over some methods of the provided tech so it can be tested |
43 | // synchronously with sinon's fake timers | 43 | // synchronously with sinon's fake timers |
44 | mockTech = function(tech) { | 44 | mockTech = function(tech) { |
45 | tech.currentTime_ = tech.currentTime; | 45 | if (tech.isMocked_) { |
46 | // make this function idempotent because HTML and Flash based | ||
47 | // playback have very different lifecycles. For HTML, the tech | ||
48 | // is available on player creation. For Flash, the tech isn't | ||
49 | // ready until the source has been loaded and one tick has | ||
50 | // expired. | ||
51 | return; | ||
52 | } | ||
53 | |||
54 | tech.isMocked_ = true; | ||
55 | |||
56 | tech.paused_ = !tech.autoplay(); | ||
57 | tech.paused = function() { | ||
58 | return tech.paused_; | ||
59 | }; | ||
60 | |||
61 | if (!tech.currentTime_) { | ||
62 | tech.currentTime_ = tech.currentTime; | ||
63 | } | ||
46 | tech.currentTime = function() { | 64 | tech.currentTime = function() { |
47 | return tech.time_ === undefined ? tech.currentTime_() : tech.time_; | 65 | return tech.time_ === undefined ? tech.currentTime_() : tech.time_; |
48 | }; | 66 | }; |
... | @@ -61,6 +79,19 @@ var | ... | @@ -61,6 +79,19 @@ var |
61 | return tech.src_ === undefined ? tech.currentSrc_() : tech.src_; | 79 | return tech.src_ === undefined ? tech.currentSrc_() : tech.src_; |
62 | }; | 80 | }; |
63 | 81 | ||
82 | tech.play_ = tech.play; | ||
83 | tech.play = function() { | ||
84 | tech.play_(); | ||
85 | tech.paused_ = false; | ||
86 | tech.trigger('play'); | ||
87 | }; | ||
88 | tech.pause_ = tech.pause_; | ||
89 | tech.pause = function() { | ||
90 | tech.pause_(); | ||
91 | tech.paused_ = true; | ||
92 | tech.trigger('pause'); | ||
93 | }; | ||
94 | |||
64 | tech.setCurrentTime = function(time) { | 95 | tech.setCurrentTime = function(time) { |
65 | tech.time_ = time; | 96 | tech.time_ = time; |
66 | 97 | ||
... | @@ -95,6 +126,7 @@ var | ... | @@ -95,6 +126,7 @@ var |
95 | // ensure the Flash tech is ready | 126 | // ensure the Flash tech is ready |
96 | player.tech_.triggerReady(); | 127 | player.tech_.triggerReady(); |
97 | clock.tick(1); | 128 | clock.tick(1); |
129 | mockTech(player.tech_); | ||
98 | 130 | ||
99 | // simulate the sourceopen event | 131 | // simulate the sourceopen event |
100 | player.tech_.hls.mediaSource.readyState = 'open'; | 132 | player.tech_.hls.mediaSource.readyState = 'open'; |
... | @@ -197,9 +229,11 @@ var | ... | @@ -197,9 +229,11 @@ var |
197 | constructor: function() {}, | 229 | constructor: function() {}, |
198 | abort: function() {}, | 230 | abort: function() {}, |
199 | buffered: videojs.createTimeRange(), | 231 | buffered: videojs.createTimeRange(), |
200 | appendBuffer: function() {} | 232 | appendBuffer: function() {}, |
233 | remove: function() {} | ||
201 | }))(); | 234 | }))(); |
202 | }, | 235 | }, |
236 | endOfStream: function() {} | ||
203 | }), | 237 | }), |
204 | 238 | ||
205 | // do a shallow copy of the properties of source onto the target object | 239 | // do a shallow copy of the properties of source onto the target object |
... | @@ -882,6 +916,57 @@ test('moves to the next segment if there is a network error', function() { | ... | @@ -882,6 +916,57 @@ test('moves to the next segment if there is a network error', function() { |
882 | strictEqual(mediaIndex + 1, player.tech_.hls.mediaIndex, 'media index is incremented'); | 916 | strictEqual(mediaIndex + 1, player.tech_.hls.mediaIndex, 'media index is incremented'); |
883 | }); | 917 | }); |
884 | 918 | ||
919 | test('updates playlist timeline offsets if it detects a desynchronization', function() { | ||
920 | var buffered = [], currentTime = 0; | ||
921 | |||
922 | player.src({ | ||
923 | src: 'manifest/master.m3u8', | ||
924 | type: 'application/vnd.apple.mpegurl' | ||
925 | }); | ||
926 | openMediaSource(player); | ||
927 | standardXHRResponse(requests.shift()); // master | ||
928 | requests.shift().respond(200, null, | ||
929 | '#EXTM3U\n' + | ||
930 | '#EXT-X-MEDIA-SEQUENCE:2\n' + | ||
931 | '#EXTINF:10,\n' + | ||
932 | '2.ts\n' + | ||
933 | '#EXTINF:10,\n' + | ||
934 | '3.ts\n'); // media | ||
935 | player.tech_.buffered = function() { return videojs.createTimeRange(buffered); }; | ||
936 | player.tech_.currentTime = function() { return currentTime; }; | ||
937 | player.tech_.paused = function() { return false; }; | ||
938 | player.tech_.trigger('play'); | ||
939 | clock.tick(1); | ||
940 | standardXHRResponse(requests.shift()); // segment 0 | ||
941 | equal(player.tech_.hls.mediaIndex, 1, 'incremented mediaIndex'); | ||
942 | |||
943 | player.tech_.hls.sourceBuffer.trigger('updateend'); | ||
944 | buffered.push([0, 10]); | ||
945 | |||
946 | // force a playlist switch | ||
947 | player.tech_.hls.playlists.media('media1.m3u8'); | ||
948 | requests = requests.filter(function(request) { | ||
949 | return !request.aborted; | ||
950 | }); | ||
951 | requests.shift().respond(200, null, | ||
952 | '#EXTM3U\n' + | ||
953 | '#EXT-X-MEDIA-SEQUENCE:9999\n' + | ||
954 | '#EXTINF:10,\n' + | ||
955 | '3.ts\n' + | ||
956 | '#EXTINF:10,\n' + | ||
957 | '4.ts\n' + | ||
958 | '#EXTINF:10,\n' + | ||
959 | '5.ts\n'); // media1 | ||
960 | player.tech_.hls.checkBuffer_(); | ||
961 | standardXHRResponse(requests.shift()); | ||
962 | |||
963 | buffered.push([20, 30]); | ||
964 | currentTime = 8; | ||
965 | |||
966 | player.tech_.hls.sourceBuffer.trigger('updateend'); | ||
967 | equal(player.tech_.hls.mediaIndex, 0, 'prepared to request the missing segment'); | ||
968 | }); | ||
969 | |||
885 | test('updates the duration after switching playlists', function() { | 970 | test('updates the duration after switching playlists', function() { |
886 | var selectedPlaylist = false; | 971 | var selectedPlaylist = false; |
887 | player.src({ | 972 | player.src({ |
... | @@ -1172,33 +1257,14 @@ test('buffers based on the correct TimeRange if multiple ranges exist', function | ... | @@ -1172,33 +1257,14 @@ test('buffers based on the correct TimeRange if multiple ranges exist', function |
1172 | return 8; | 1257 | return 8; |
1173 | }; | 1258 | }; |
1174 | 1259 | ||
1175 | player.tech_.buffered = function() { | ||
1176 | return { | ||
1177 | start: function(num) { | ||
1178 | switch (num) { | ||
1179 | case 0: | ||
1180 | return 0; | ||
1181 | case 1: | ||
1182 | return 50; | ||
1183 | } | ||
1184 | }, | ||
1185 | end: function(num) { | ||
1186 | switch (num) { | ||
1187 | case 0: | ||
1188 | return 10; | ||
1189 | case 1: | ||
1190 | return 160; | ||
1191 | } | ||
1192 | }, | ||
1193 | length: 2 | ||
1194 | }; | ||
1195 | }; | ||
1196 | |||
1197 | player.src({ | 1260 | player.src({ |
1198 | src: 'manifest/media.m3u8', | 1261 | src: 'manifest/media.m3u8', |
1199 | type: 'application/vnd.apple.mpegurl' | 1262 | type: 'application/vnd.apple.mpegurl' |
1200 | }); | 1263 | }); |
1201 | openMediaSource(player); | 1264 | openMediaSource(player); |
1265 | player.tech_.buffered = function() { | ||
1266 | return videojs.createTimeRange([[0, 10], [50, 160]]); | ||
1267 | }; | ||
1202 | 1268 | ||
1203 | standardXHRResponse(requests[0]); | 1269 | standardXHRResponse(requests[0]); |
1204 | standardXHRResponse(requests[1]); | 1270 | standardXHRResponse(requests[1]); | ... | ... |
-
Please register or sign in to post a comment