Merge pull request #92 from videojs/hotfix/bw-scenarios
Adaptive switching enhancements
Showing
5 changed files
with
167 additions
and
42 deletions
... | @@ -11,9 +11,6 @@ | ... | @@ -11,9 +11,6 @@ |
11 | 11 | ||
12 | var | 12 | var |
13 | 13 | ||
14 | // the desired length of video to maintain in the buffer, in seconds | ||
15 | goalBufferLength = 5, | ||
16 | |||
17 | // a fudge factor to apply to advertised playlist bitrates to account for | 14 | // a fudge factor to apply to advertised playlist bitrates to account for |
18 | // temporary flucations in client bandwidth | 15 | // temporary flucations in client bandwidth |
19 | bandwidthVariance = 1.1, | 16 | bandwidthVariance = 1.1, |
... | @@ -333,6 +330,20 @@ var | ... | @@ -333,6 +330,20 @@ var |
333 | }; | 330 | }; |
334 | 331 | ||
335 | /** | 332 | /** |
333 | * Abort all outstanding work and cleanup. | ||
334 | */ | ||
335 | player.hls.dispose = function() { | ||
336 | if (segmentXhr) { | ||
337 | segmentXhr.onreadystatechange = null; | ||
338 | segmentXhr.abort(); | ||
339 | } | ||
340 | if (this.playlists) { | ||
341 | this.playlists.dispose(); | ||
342 | } | ||
343 | videojs.Flash.prototype.dispose.call(this); | ||
344 | }; | ||
345 | |||
346 | /** | ||
336 | * Determines whether there is enough video data currently in the buffer | 347 | * Determines whether there is enough video data currently in the buffer |
337 | * and downloads a new segment if the buffered time is less than the goal. | 348 | * and downloads a new segment if the buffered time is less than the goal. |
338 | * @param offset (optional) {number} the offset into the downloaded segment | 349 | * @param offset (optional) {number} the offset into the downloaded segment |
... | @@ -370,7 +381,8 @@ var | ... | @@ -370,7 +381,8 @@ var |
370 | 381 | ||
371 | // if there is plenty of content in the buffer and we're not | 382 | // if there is plenty of content in the buffer and we're not |
372 | // seeking, relax for awhile | 383 | // seeking, relax for awhile |
373 | if (typeof offset !== 'number' && bufferedTime >= goalBufferLength) { | 384 | if (typeof offset !== 'number' && |
385 | bufferedTime >= videojs.Hls.GOAL_BUFFER_LENGTH) { | ||
374 | return; | 386 | return; |
375 | } | 387 | } |
376 | 388 | ||
... | @@ -396,6 +408,12 @@ var | ... | @@ -396,6 +408,12 @@ var |
396 | segmentXhr = null; | 408 | segmentXhr = null; |
397 | 409 | ||
398 | if (error) { | 410 | if (error) { |
411 | // if a segment request times out, we may have better luck with another playlist | ||
412 | if (error === 'timeout') { | ||
413 | player.hls.bandwidth = 1; | ||
414 | return player.hls.playlists.media(player.hls.selectPlaylist()); | ||
415 | } | ||
416 | // otherwise, try jumping ahead to the next segment | ||
399 | player.hls.error = { | 417 | player.hls.error = { |
400 | status: this.status, | 418 | status: this.status, |
401 | message: 'HLS segment request error at URL: ' + url, | 419 | message: 'HLS segment request error at URL: ' + url, |
... | @@ -577,6 +595,9 @@ videojs.Hls = videojs.Flash.extend({ | ... | @@ -577,6 +595,9 @@ videojs.Hls = videojs.Flash.extend({ |
577 | } | 595 | } |
578 | }); | 596 | }); |
579 | 597 | ||
598 | // the desired length of video to maintain in the buffer, in seconds | ||
599 | videojs.Hls.GOAL_BUFFER_LENGTH = 30; | ||
600 | |||
580 | videojs.Hls.prototype.src = function(src) { | 601 | videojs.Hls.prototype.src = function(src) { |
581 | var | 602 | var |
582 | player = this.player(), | 603 | player = this.player(), |
... | @@ -606,13 +627,6 @@ videojs.Hls.prototype.duration = function() { | ... | @@ -606,13 +627,6 @@ videojs.Hls.prototype.duration = function() { |
606 | return 0; | 627 | return 0; |
607 | }; | 628 | }; |
608 | 629 | ||
609 | videojs.Hls.prototype.dispose = function() { | ||
610 | if (this.playlists) { | ||
611 | this.playlists.dispose(); | ||
612 | } | ||
613 | videojs.Flash.prototype.dispose.call(this); | ||
614 | }; | ||
615 | |||
616 | videojs.Hls.isSupported = function() { | 630 | videojs.Hls.isSupported = function() { |
617 | return videojs.Flash.isSupported() && videojs.MediaSource; | 631 | return videojs.Flash.isSupported() && videojs.MediaSource; |
618 | }; | 632 | }; |
... | @@ -664,10 +678,14 @@ xhr = videojs.Hls.xhr = function(url, callback) { | ... | @@ -664,10 +678,14 @@ xhr = videojs.Hls.xhr = function(url, callback) { |
664 | if (options.timeout) { | 678 | if (options.timeout) { |
665 | if (request.timeout === 0) { | 679 | if (request.timeout === 0) { |
666 | request.timeout = options.timeout; | 680 | request.timeout = options.timeout; |
681 | request.ontimeout = function() { | ||
682 | request.timedout = true; | ||
683 | }; | ||
667 | } else { | 684 | } else { |
668 | // polyfill XHR2 by aborting after the timeout | 685 | // polyfill XHR2 by aborting after the timeout |
669 | abortTimeout = window.setTimeout(function() { | 686 | abortTimeout = window.setTimeout(function() { |
670 | if (request.readystate !== 4) { | 687 | if (request.readystate !== 4) { |
688 | request.timedout = true; | ||
671 | request.abort(); | 689 | request.abort(); |
672 | } | 690 | } |
673 | }, options.timeout); | 691 | }, options.timeout); |
... | @@ -683,7 +701,12 @@ xhr = videojs.Hls.xhr = function(url, callback) { | ... | @@ -683,7 +701,12 @@ xhr = videojs.Hls.xhr = function(url, callback) { |
683 | // clear outstanding timeouts | 701 | // clear outstanding timeouts |
684 | window.clearTimeout(abortTimeout); | 702 | window.clearTimeout(abortTimeout); |
685 | 703 | ||
686 | // request error | 704 | // request timeout |
705 | if (request.timedout) { | ||
706 | return callback.call(this, 'timeout', url); | ||
707 | } | ||
708 | |||
709 | // request aborted or errored | ||
687 | if (this.status >= 400 || this.status === 0) { | 710 | if (this.status >= 400 || this.status === 0) { |
688 | return callback.call(this, true, url); | 711 | return callback.call(this, true, url); |
689 | } | 712 | } | ... | ... |
... | @@ -432,6 +432,11 @@ form label { | ... | @@ -432,6 +432,11 @@ form label { |
432 | stroke-dasharray: 5, 5; | 432 | stroke-dasharray: 5, 5; |
433 | } | 433 | } |
434 | 434 | ||
435 | .buffer-empty { | ||
436 | fill: #e44d26; | ||
437 | opacity: 0.4; | ||
438 | } | ||
439 | |||
435 | .timeline { | 440 | .timeline { |
436 | color: #888; | 441 | color: #888; |
437 | height: 500px; | 442 | height: 500px; | ... | ... |
... | @@ -85,6 +85,13 @@ | ... | @@ -85,6 +85,13 @@ |
85 | capacity. | 85 | capacity. |
86 | <button type=button class=add-time-period>Add time period</button> | 86 | <button type=button class=add-time-period>Add time period</button> |
87 | </p> | 87 | </p> |
88 | <p> | ||
89 | If you've created a complex scenario you'd like to retry | ||
90 | later, run the simulation and then save the URL of this | ||
91 | page. All of the network conditions you specify are | ||
92 | saved into the URL fragment after the results of a | ||
93 | simulation run are displayed. | ||
94 | </p> | ||
88 | The video is available at | 95 | The video is available at |
89 | <ul> | 96 | <ul> |
90 | <li><input class=bitrate type=number min=1 value=65536> bits per second</li> | 97 | <li><input class=bitrate type=number min=1 value=65536> bits per second</li> | ... | ... |
... | @@ -10,9 +10,6 @@ | ... | @@ -10,9 +10,6 @@ |
10 | player, | 10 | player, |
11 | runButton, | 11 | runButton, |
12 | parameters, | 12 | parameters, |
13 | addTimePeriod, | ||
14 | networkTimeline, | ||
15 | timePeriod, | ||
16 | timeline, | 13 | timeline, |
17 | 14 | ||
18 | displayTimeline; | 15 | displayTimeline; |
... | @@ -31,23 +28,46 @@ | ... | @@ -31,23 +28,46 @@ |
31 | }; | 28 | }; |
32 | 29 | ||
33 | // a dynamic number of time-bandwidth pairs may be defined to drive the simulation | 30 | // a dynamic number of time-bandwidth pairs may be defined to drive the simulation |
34 | addTimePeriod = document.querySelector('.add-time-period'); | 31 | (function() { |
35 | networkTimeline = document.querySelector('.network-timeline'); | 32 | var params, |
36 | timePeriod = networkTimeline.cloneNode(true); | 33 | networkTimeline = document.querySelector('.network-timeline'), |
37 | addTimePeriod.addEventListener('click', function() { | 34 | timePeriod = networkTimeline.querySelector('li:last-child').cloneNode(true), |
38 | var clone = timePeriod.cloneNode(true), | 35 | appendTimePeriod = function() { |
39 | fragment = document.createDocumentFragment(), | 36 | var clone = timePeriod.cloneNode(true), |
40 | count = networkTimeline.querySelectorAll('input.bandwidth').length, | 37 | count = networkTimeline.querySelectorAll('input.bandwidth').length, |
41 | time = clone.querySelector('.time'), | 38 | time = clone.querySelector('.time'), |
42 | bandwidth = clone.querySelector('input.bandwidth'); | 39 | bandwidth = clone.querySelector('input.bandwidth'); |
43 | 40 | ||
44 | time.name = 'time' + count; | 41 | time.name = 'time' + count; |
45 | bandwidth.name = 'bandwidth' + count; | 42 | bandwidth.name = 'bandwidth' + count; |
46 | while (clone.childNodes.length) { | 43 | networkTimeline.appendChild(clone); |
47 | fragment.appendChild(clone.childNodes[0]); | 44 | }; |
45 | document.querySelector('.add-time-period') | ||
46 | .addEventListener('click', appendTimePeriod); | ||
47 | |||
48 | // apply any simulation parameters that were set in the fragment identifier | ||
49 | if (!window.location.hash) { | ||
50 | return; | ||
48 | } | 51 | } |
49 | networkTimeline.appendChild(fragment); | 52 | |
50 | }); | 53 | // time periods are specified as t<seconds>=<bitrate> |
54 | // e.g. #t15=450560&t150=65530 | ||
55 | params = window.location.hash.substring(1) | ||
56 | .split('&') | ||
57 | .map(function(param) { | ||
58 | return ((/t(\d+)=(\d+)/i).exec(param) || []) | ||
59 | .map(window.parseFloat).slice(1); | ||
60 | }).filter(function(pair) { | ||
61 | return pair.length === 2; | ||
62 | }); | ||
63 | |||
64 | networkTimeline.innerHTML = ''; | ||
65 | params.forEach(function(param) { | ||
66 | appendTimePeriod(); | ||
67 | networkTimeline.querySelector('li:last-child .time').value = param[0]; | ||
68 | networkTimeline.querySelector('li:last-child input.bandwidth').value = param[1]; | ||
69 | }); | ||
70 | })(); | ||
51 | 71 | ||
52 | // collect the simulation parameters | 72 | // collect the simulation parameters |
53 | parameters = function() { | 73 | parameters = function() { |
... | @@ -143,7 +163,7 @@ | ... | @@ -143,7 +163,7 @@ |
143 | buffered = 0, | 163 | buffered = 0, |
144 | currentTime = 0; | 164 | currentTime = 0; |
145 | 165 | ||
146 | // mock out buffered and currentTime | 166 | // simulate buffered and currentTime during playback |
147 | player.buffered = function() { | 167 | player.buffered = function() { |
148 | return videojs.createTimeRange(0, currentTime + buffered); | 168 | return videojs.createTimeRange(0, currentTime + buffered); |
149 | }; | 169 | }; |
... | @@ -191,10 +211,6 @@ | ... | @@ -191,10 +211,6 @@ |
191 | // segment response headers arrive after the propogation delay | 211 | // segment response headers arrive after the propogation delay |
192 | setTimeout(function() { | 212 | setTimeout(function() { |
193 | var arrival = Math.ceil(+new Date() * 0.001); | 213 | var arrival = Math.ceil(+new Date() * 0.001); |
194 | results.playlists.push({ | ||
195 | time: arrival, | ||
196 | bitrate: +request.url.match(/(\d+)-\d+$/)[1] | ||
197 | }); | ||
198 | request.setResponseHeaders({ | 214 | request.setResponseHeaders({ |
199 | 'Content-Type': 'video/mp2t' | 215 | 'Content-Type': 'video/mp2t' |
200 | }); | 216 | }); |
... | @@ -204,12 +220,23 @@ | ... | @@ -204,12 +220,23 @@ |
204 | if (remaining - value.bandwidth <= 0) { | 220 | if (remaining - value.bandwidth <= 0) { |
205 | // send the response body once all bytes have been delivered | 221 | // send the response body once all bytes have been delivered |
206 | setTimeout(function() { | 222 | setTimeout(function() { |
207 | buffered += segmentDuration; | 223 | var time = Math.ceil(+new Date() * 0.001); |
224 | if (request.aborted) { | ||
225 | return; | ||
226 | } | ||
208 | request.status = 200; | 227 | request.status = 200; |
209 | request.response = new Uint8Array(segmentSize * 0.125); | 228 | request.response = new Uint8Array(segmentSize * 0.125); |
210 | request.setResponseBody(''); | 229 | request.setResponseBody(''); |
230 | |||
231 | results.playlists.push({ | ||
232 | time: time, | ||
233 | bitrate: +request.url.match(/(\d+)-\d+$/)[1] | ||
234 | }); | ||
235 | // update the buffered value | ||
236 | buffered += segmentDuration; | ||
237 | results.buffered[results.buffered.length - 1].buffered = buffered; | ||
211 | results.effectiveBandwidth.push({ | 238 | results.effectiveBandwidth.push({ |
212 | time: Math.ceil(+new Date() * 0.001), | 239 | time: time, |
213 | bandwidth: player.hls.bandwidth | 240 | bandwidth: player.hls.bandwidth |
214 | }); | 241 | }); |
215 | }, ((remaining / value.bandwidth) + i) * 1000); | 242 | }, ((remaining / value.bandwidth) + i) * 1000); |
... | @@ -240,6 +267,11 @@ | ... | @@ -240,6 +267,11 @@ |
240 | clock.restore(); | 267 | clock.restore(); |
241 | fakeXhr.restore(); | 268 | fakeXhr.restore(); |
242 | 269 | ||
270 | // update the fragment identifier so this scenario can be re-run easily | ||
271 | window.location.hash = '#' + options.bandwidths.map(function(interval) { | ||
272 | return 't' + interval.time + '=' + interval.bandwidth; | ||
273 | }).join('&'); | ||
274 | |||
243 | done(null, results); | 275 | done(null, results); |
244 | }, 0); | 276 | }, 0); |
245 | }); | 277 | }); |
... | @@ -303,7 +335,9 @@ | ... | @@ -303,7 +335,9 @@ |
303 | })); | 335 | })); |
304 | y.domain([0, Math.max(d3.max(data.bandwidth, function(data) { | 336 | y.domain([0, Math.max(d3.max(data.bandwidth, function(data) { |
305 | return data.bandwidth; | 337 | return data.bandwidth; |
306 | }), d3.max(data.options.playlists))]); | 338 | }), d3.max(data.options.playlists), d3.max(data.playlists, function(data) { |
339 | return data.bitrate; | ||
340 | }))]); | ||
307 | 341 | ||
308 | // time axis | 342 | // time axis |
309 | svg.selectAll('.axis').remove(); | 343 | svg.selectAll('.axis').remove(); |
... | @@ -324,6 +358,7 @@ | ... | @@ -324,6 +358,7 @@ |
324 | .text('Bitrate (kb/s)'); | 358 | .text('Bitrate (kb/s)'); |
325 | 359 | ||
326 | // playlist bitrate lines | 360 | // playlist bitrate lines |
361 | svg.selectAll('.line.bitrate').remove(); | ||
327 | svg.selectAll('.line.bitrate') | 362 | svg.selectAll('.line.bitrate') |
328 | .data(data.options.playlists) | 363 | .data(data.options.playlists) |
329 | .enter().append('path') | 364 | .enter().append('path') |
... | @@ -368,6 +403,40 @@ | ... | @@ -368,6 +403,40 @@ |
368 | .attr('cy', function(playlist) { | 403 | .attr('cy', function(playlist) { |
369 | return y(playlist.bitrate); | 404 | return y(playlist.bitrate); |
370 | }); | 405 | }); |
406 | |||
407 | // highlight intervals when the buffer is empty | ||
408 | svg.selectAll('.buffer-empty').remove(); | ||
409 | svg.selectAll('.buffer-empty') | ||
410 | .data(data.buffered.reduce(function(result, sample) { | ||
411 | var last = result[result.length - 1]; | ||
412 | if (sample.buffered === 0) { | ||
413 | if (last && sample.time === last.end + 1) { | ||
414 | // add this sample to the interval we're accumulating | ||
415 | return result.slice(0, result.length - 1).concat({ | ||
416 | start: last.start, | ||
417 | end: sample.time | ||
418 | }); | ||
419 | } else { | ||
420 | // this sample starts a new interval | ||
421 | return result.concat({ | ||
422 | start: sample.time, | ||
423 | end: sample.time | ||
424 | }); | ||
425 | } | ||
426 | } | ||
427 | // filter out time periods where the buffer isn't empty | ||
428 | return result; | ||
429 | }, [])) | ||
430 | .enter().append('rect') | ||
431 | .attr('class', 'buffer-empty') | ||
432 | .attr('x', function(data) { | ||
433 | return x(data.start); | ||
434 | }) | ||
435 | .attr('width', function(data) { | ||
436 | return x(1 + data.end - data.start); | ||
437 | }) | ||
438 | .attr('y', 0) | ||
439 | .attr('height', y(height)); | ||
371 | }; | 440 | }; |
372 | })(); | 441 | })(); |
373 | 442 | ... | ... |
... | @@ -420,7 +420,6 @@ test('selects a playlist after segment downloads', function() { | ... | @@ -420,7 +420,6 @@ test('selects a playlist after segment downloads', function() { |
420 | player.trigger('timeupdate'); | 420 | player.trigger('timeupdate'); |
421 | 421 | ||
422 | standardXHRResponse(requests[3]); | 422 | standardXHRResponse(requests[3]); |
423 | console.log(requests.map(function(i) { return i.url; })); | ||
424 | strictEqual(calls, 2, 'selects after additional segments'); | 423 | strictEqual(calls, 2, 'selects after additional segments'); |
425 | }); | 424 | }); |
426 | 425 | ||
... | @@ -631,15 +630,16 @@ test('selects the correct rendition by player dimensions', function() { | ... | @@ -631,15 +630,16 @@ test('selects the correct rendition by player dimensions', function() { |
631 | 630 | ||
632 | 631 | ||
633 | test('does not download the next segment if the buffer is full', function() { | 632 | test('does not download the next segment if the buffer is full', function() { |
633 | var currentTime = 15; | ||
634 | player.src({ | 634 | player.src({ |
635 | src: 'manifest/media.m3u8', | 635 | src: 'manifest/media.m3u8', |
636 | type: 'application/vnd.apple.mpegurl' | 636 | type: 'application/vnd.apple.mpegurl' |
637 | }); | 637 | }); |
638 | player.currentTime = function() { | 638 | player.currentTime = function() { |
639 | return 15; | 639 | return currentTime; |
640 | }; | 640 | }; |
641 | player.buffered = function() { | 641 | player.buffered = function() { |
642 | return videojs.createTimeRange(0, 20); | 642 | return videojs.createTimeRange(0, currentTime + videojs.Hls.GOAL_BUFFER_LENGTH); |
643 | }; | 643 | }; |
644 | player.hls.mediaSource.trigger({ | 644 | player.hls.mediaSource.trigger({ |
645 | type: 'sourceopen' | 645 | type: 'sourceopen' |
... | @@ -1150,6 +1150,27 @@ test('clears the segment buffer on seek', function() { | ... | @@ -1150,6 +1150,27 @@ test('clears the segment buffer on seek', function() { |
1150 | strictEqual(aborts, 1, 'cleared the segment buffer on a seek'); | 1150 | strictEqual(aborts, 1, 'cleared the segment buffer on a seek'); |
1151 | }); | 1151 | }); |
1152 | 1152 | ||
1153 | test('resets the switching algorithm if a request times out', function() { | ||
1154 | player.src({ | ||
1155 | src: 'master.m3u8', | ||
1156 | type: 'application/vnd.apple.mpegurl' | ||
1157 | }); | ||
1158 | player.hls.mediaSource.trigger({ | ||
1159 | type: 'sourceopen' | ||
1160 | }); | ||
1161 | standardXHRResponse(requests.shift()); // master | ||
1162 | standardXHRResponse(requests.shift()); // media.m3u8 | ||
1163 | // simulate a segment timeout | ||
1164 | requests[0].timedout = true; | ||
1165 | requests.shift().abort(); | ||
1166 | |||
1167 | standardXHRResponse(requests.shift()); | ||
1168 | |||
1169 | strictEqual(player.hls.playlists.media(), | ||
1170 | player.hls.playlists.master.playlists[1], | ||
1171 | 'reset to the lowest bitrate playlist'); | ||
1172 | }); | ||
1173 | |||
1153 | test('disposes the playlist loader', function() { | 1174 | test('disposes the playlist loader', function() { |
1154 | var disposes = 0, player, loaderDispose; | 1175 | var disposes = 0, player, loaderDispose; |
1155 | player = createPlayer(); | 1176 | player = createPlayer(); | ... | ... |
-
Please register or sign in to post a comment