From b116e44286968647687b662b6a54474937cd6068 Mon Sep 17 00:00:00 2001 From: Alex Barstow Date: Tue, 23 Mar 2021 17:50:12 -0400 Subject: [PATCH] feat: retry on error (#7038) Add a `retryOnError` option. When set, during source selection, if a source fails to load, we will retry the next item in the sources list. In the future, we may enable this by default. A source that fails during playback will *not* trigger this behavior. Fixes #1805. --- src/js/player.js | 66 +++++++++++++-- test/unit/player.test.js | 170 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 5 deletions(-) diff --git a/src/js/player.js b/src/js/player.js index e8ecc2c186..aa6acea2bf 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -3308,7 +3308,7 @@ class Player extends Component { } /** - * Get or set the video source. + * Executes source setting and getting logic * * @param {Tech~SourceObject|Tech~SourceObject[]|string} [source] * A SourceObject, an array of SourceObjects, or a string referencing @@ -3317,16 +3317,24 @@ class Player extends Component { * algorithms can take the `type` into account. * * If not provided, this method acts as a getter. + * @param {boolean} isRetry + * Indicates whether this is being called internally as a result of a retry * * @return {string|undefined} * If the `source` argument is missing, returns the current source * URL. Otherwise, returns nothing/undefined. */ - src(source) { + handleSrc_(source, isRetry) { // getter usage if (typeof source === 'undefined') { return this.cache_.src || ''; } + + // Reset retry behavior for new source + if (this.resetRetryOnError_) { + this.resetRetryOnError_(); + } + // filter out invalid sources and turn our source into // an array of source objects const sources = filterSource(source); @@ -3344,7 +3352,12 @@ class Player extends Component { // initial sources this.changingSrc_ = true; - this.cache_.sources = sources; + // Only update the cached source list if we are not retrying a new source after error, + // since in that case we want to include the failed source(s) in the cache + if (!isRetry) { + this.cache_.sources = sources; + } + this.updateSourceCaches_(sources[0]); // middlewareSource is the source after it has been changed by middleware @@ -3353,14 +3366,17 @@ class Player extends Component { // since sourceSet is async we have to update the cache again after we select a source since // the source that is selected could be out of order from the cache update above this callback. - this.cache_.sources = sources; + if (!isRetry) { + this.cache_.sources = sources; + } + this.updateSourceCaches_(middlewareSource); const err = this.src_(middlewareSource); if (err) { if (sources.length > 1) { - return this.src(sources.slice(1)); + return this.handleSrc_(sources.slice(1)); } this.changingSrc_ = false; @@ -3379,6 +3395,46 @@ class Player extends Component { middleware.setTech(mws, this.tech_); }); + + // Try another available source if this one fails before playback. + if (this.options_.retryOnError && sources.length > 1) { + const retry = () => { + // Remove the error modal + this.error(null); + this.handleSrc_(sources.slice(1), true); + }; + + const stopListeningForErrors = () => { + this.off('error', retry); + }; + + this.one('error', retry); + this.one('playing', stopListeningForErrors); + + this.resetRetryOnError_ = () => { + this.off('error', retry); + this.off('playing', stopListeningForErrors); + }; + } + } + + /** + * Get or set the video source. + * + * @param {Tech~SourceObject|Tech~SourceObject[]|string} [source] + * A SourceObject, an array of SourceObjects, or a string referencing + * a URL to a media source. It is _highly recommended_ that an object + * or array of objects is used here, so that source selection + * algorithms can take the `type` into account. + * + * If not provided, this method acts as a getter. + * + * @return {string|undefined} + * If the `source` argument is missing, returns the current source + * URL. Otherwise, returns nothing/undefined. + */ + src(source) { + return this.handleSrc_(source, false); } /** diff --git a/test/unit/player.test.js b/test/unit/player.test.js index 7c8535087a..e9121bf1dc 100644 --- a/test/unit/player.test.js +++ b/test/unit/player.test.js @@ -349,6 +349,176 @@ QUnit.test('should asynchronously fire error events during source selection', fu log.error.restore(); }); +QUnit.test('should retry setting source if error occurs and retryOnError: true', function(assert) { + const player = TestHelpers.makePlayer({ + techOrder: ['html5'], + retryOnError: true, + sources: [ + { src: 'http://vjs.zencdn.net/v/oceans.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/oceans2.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/oceans3.mp4', type: 'video/mp4' } + ] + }); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/oceans.mp4', type: 'video/mp4' }, + 'first source set' + ); + + player.trigger('error'); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/oceans2.mp4', type: 'video/mp4' }, + 'second source set' + ); + + player.trigger('error'); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/oceans3.mp4', type: 'video/mp4' }, + 'last source set' + ); + + // No more sources to try so the previous source should remain + player.trigger('error'); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/oceans3.mp4', type: 'video/mp4' }, + 'last source remains' + ); + + assert.deepEqual( + player.currentSources(), + [ + { src: 'http://vjs.zencdn.net/v/oceans.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/oceans2.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/oceans3.mp4', type: 'video/mp4' } + ], + 'currentSources() correctly returns the full source list' + ); + + player.dispose(); +}); + +QUnit.test('should not retry setting source if retryOnError: true and error occurs during playback', function(assert) { + const player = TestHelpers.makePlayer({ + techOrder: ['html5'], + retryOnError: true, + sources: [ + { src: 'http://vjs.zencdn.net/v/oceans.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/oceans2.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/oceans3.mp4', type: 'video/mp4' } + ] + }); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/oceans.mp4', type: 'video/mp4' }, + 'first source set' + ); + + player.trigger('error'); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/oceans2.mp4', type: 'video/mp4' }, + 'second source set' + ); + + // Playback starts then error occurs + player.trigger('playing'); + player.trigger('error'); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/oceans2.mp4', type: 'video/mp4' }, + 'second source remains' + ); + + assert.deepEqual( + player.currentSources(), + [ + { src: 'http://vjs.zencdn.net/v/oceans.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/oceans2.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/oceans3.mp4', type: 'video/mp4' } + ], + 'currentSources() correctly returns the full source list' + ); + + player.dispose(); +}); + +QUnit.test('aborts and resets retryOnError behavior if new src() call made during a retry', function(assert) { + const player = TestHelpers.makePlayer({ + techOrder: ['html5'], + retryOnError: true, + sources: [ + { src: 'http://vjs.zencdn.net/v/oceans.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/oceans2.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/oceans3.mp4', type: 'video/mp4' } + ] + }); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/oceans.mp4', type: 'video/mp4' }, + 'first source set' + ); + + player.trigger('error'); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/oceans2.mp4', type: 'video/mp4' }, + 'second source set' + ); + + // Setting a new source list should reset retry behavior and enable it for the new sources + player.src([ + { src: 'http://vjs.zencdn.net/v/newSource.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/newSource2.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/newSource3.mp4', type: 'video/mp4' } + ]); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/newSource.mp4', type: 'video/mp4' }, + 'first new source set' + ); + + player.trigger('error'); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/newSource2.mp4', type: 'video/mp4' }, + 'second new source set' + ); + + player.trigger('error'); + + assert.deepEqual( + player.currentSource(), + { src: 'http://vjs.zencdn.net/v/newSource3.mp4', type: 'video/mp4' }, + 'third new source set' + ); + + assert.deepEqual( + player.currentSources(), + [ + { src: 'http://vjs.zencdn.net/v/newSource.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/newSource2.mp4', type: 'video/mp4' }, + { src: 'http://vjs.zencdn.net/v/newSource3.mp4', type: 'video/mp4' } + ], + 'currentSources() correctly returns the full new source list' + ); + + player.dispose(); +}); + QUnit.test('should suppress source error messages', function(assert) { sinon.stub(log, 'error'); const clock = sinon.useFakeTimers();