From ad53b80b8a2f58d277e08b554440f95ce6994716 Mon Sep 17 00:00:00 2001 From: Brandon Casey Date: Thu, 11 Apr 2019 14:29:27 -0400 Subject: [PATCH] fix: correctly resolve play promise when terminated via middleware (#5895) --- src/js/player.js | 142 +++++++----- src/js/tech/middleware.js | 2 + test/unit/autoplay.test.js | 189 +++++++++++++++- test/unit/play.test.js | 446 +++++++++++++++++++++++++++++++++++++ test/unit/player.test.js | 54 ----- test/unit/test-helpers.js | 6 +- 6 files changed, 719 insertions(+), 120 deletions(-) create mode 100644 test/unit/play.test.js diff --git a/src/js/player.js b/src/js/player.js index 034995bd4..1857d75a9 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -26,7 +26,7 @@ import MediaError from './media-error.js'; import safeParseTuple from 'safe-json-parse/tuple'; import {assign} from './utils/obj'; import mergeOptions from './utils/merge-options.js'; -import {silencePromise} from './utils/promise'; +import {silencePromise, isPromise} from './utils/promise'; import textTrackConverter from './tracks/text-track-list-converter.js'; import ModalDialog from './modal-dialog'; import Tech from './tech/tech.js'; @@ -421,9 +421,13 @@ class Player extends Component { tag.controls = false; tag.removeAttribute('controls'); + this.changingSrc_ = false; + this.playCallbacks_ = []; + this.playTerminatedQueue_ = []; + // the attribute overrides the option if (tag.hasAttribute('autoplay')) { - this.options_.autoplay = true; + this.autoplay(true); } else { // otherwise use the setter to validate and // set the correct value. @@ -535,9 +539,6 @@ class Player extends Component { this.breakpoints(this.options_.breakpoints); this.responsive(this.options_.responsive); - this.changingSrc_ = false; - this.playWaitingForReady_ = false; - this.playOnLoadstart_ = null; } /** @@ -1357,35 +1358,39 @@ class Player extends Component { this.muted(true); - const playPromise = this.play(); + const restoreMuted = () => { + this.muted(previouslyMuted); + }; - if (!playPromise || !playPromise.then || !playPromise.catch) { + // restore muted on play terminatation + this.playTerminatedQueue_.push(restoreMuted); + + const mutedPromise = this.play(); + + if (!isPromise(mutedPromise)) { return; } - return playPromise.catch((e) => { - // restore old value of muted on failure - this.muted(previouslyMuted); - }); + return mutedPromise.catch(restoreMuted); }; let promise; - if (type === 'any') { + // if muted defaults to true + // the only thing we can do is call play + if (type === 'any' && this.muted() !== true) { promise = this.play(); - if (promise && promise.then && promise.catch) { - promise.catch(() => { - return muted(); - }); + if (isPromise(promise)) { + promise = promise.catch(muted); } - } else if (type === 'muted') { + } else if (type === 'muted' && this.muted() !== true) { promise = muted(); } else { promise = this.play(); } - if (!promise || !promise.then || !promise.catch) { + if (!isPromise(promise)) { return; } @@ -2219,54 +2224,77 @@ class Player extends Component { * The callback that should be called when the techs play is actually called */ play_(callback = silencePromise) { - // If this is called while we have a play queued up on a loadstart, remove - // that listener to avoid getting in a potentially bad state. - if (this.playOnLoadstart_) { - this.off('loadstart', this.playOnLoadstart_); + this.playCallbacks_.push(callback); + + const isSrcReady = Boolean(!this.changingSrc_ && (this.src() || this.currentSrc())); + + // treat calls to play_ somewhat like the `one` event function + if (this.waitToPlay_) { + this.off(['ready', 'loadstart'], this.waitToPlay_); + this.waitToPlay_ = null; } - // If the player/tech is not ready, queue up another call to `play()` for - // when it is. This will loop back into this method for another attempt at - // playback when the tech is ready. - if (!this.isReady_) { - - // Bail out if we're already waiting for `ready`! - if (this.playWaitingForReady_) { - return; - } - - this.playWaitingForReady_ = true; - this.ready(() => { - this.playWaitingForReady_ = false; - callback(this.play()); - }); - - // If the player/tech is ready and we have a source, we can attempt playback. - } else if (!this.changingSrc_ && (this.src() || this.currentSrc())) { - callback(this.techGet_('play')); - return; - - // If the tech is ready, but we do not have a source, we'll need to wait - // for both the `ready` and a `loadstart` when the source is finally - // resolved by middleware and set on the player. - // - // This can happen if `play()` is called while changing sources or before - // one has been set on the player. - } else { - - this.playOnLoadstart_ = () => { - this.playOnLoadstart_ = null; - callback(this.play()); + // if the player/tech is not ready or the src itself is not ready + // queue up a call to play on `ready` or `loadstart` + if (!this.isReady_ || !isSrcReady) { + this.waitToPlay_ = (e) => { + this.play_(); }; + this.one(['ready', 'loadstart'], this.waitToPlay_); // if we are in Safari, there is a high chance that loadstart will trigger after the gesture timeperiod // in that case, we need to prime the video element by calling load so it'll be ready in time - if (browser.IS_ANY_SAFARI || browser.IS_IOS) { + if (!isSrcReady && (browser.IS_ANY_SAFARI || browser.IS_IOS)) { this.load(); } - this.one('loadstart', this.playOnLoadstart_); + return; } + // If the player/tech is ready and we have a source, we can attempt playback. + const val = this.techGet_('play'); + + // play was terminated if the returned value is null + if (val === null) { + this.runPlayTerminatedQueue_(); + } else { + this.runPlayCallbacks_(val); + } + } + + /** + * These functions will be run when if play is terminated. If play + * runPlayCallbacks_ is run these function will not be run. This allows us + * to differenciate between a terminated play and an actual call to play. + */ + runPlayTerminatedQueue_() { + const queue = this.playTerminatedQueue_.slice(0); + + this.playTerminatedQueue_ = []; + + queue.forEach(function(q) { + q(); + }); + } + + /** + * When a callback to play is delayed we have to run these + * callbacks when play is actually called on the tech. This function + * runs the callbacks that were delayed and accepts the return value + * from the tech. + * + * @param {undefined|Promise} val + * The return value from the tech. + */ + runPlayCallbacks_(val) { + const callbacks = this.playCallbacks_.slice(0); + + this.playCallbacks_ = []; + // clear play terminatedQueue since we finished a real play + this.playTerminatedQueue_ = []; + + callbacks.forEach(function(cb) { + cb(val); + }); } /** @@ -3289,7 +3317,7 @@ class Player extends Component { this.options_.autoplay = true; } - techAutoplay = techAutoplay || this.options_.autoplay; + techAutoplay = typeof techAutoplay === 'undefined' ? this.options_.autoplay : techAutoplay; // if we don't have a tech then we do not queue up // a setAutoplay call on tech ready. We do this because the diff --git a/src/js/tech/middleware.js b/src/js/tech/middleware.js index be4f3cf48..8a4a9f3ac 100644 --- a/src/js/tech/middleware.js +++ b/src/js/tech/middleware.js @@ -166,6 +166,8 @@ export function mediate(middleware, tech, method, arg = null) { const callMethod = 'call' + toTitleCase(method); const middlewareValue = middleware.reduce(middlewareIterator(callMethod), arg); const terminated = middlewareValue === TERMINATOR; + // deprecated. The `null` return value should instead return TERMINATOR to + // prevent confusion if a techs method actually returns null. const returnValue = terminated ? null : tech[method](middlewareValue); executeRight(middleware, method, returnValue, terminated); diff --git a/test/unit/autoplay.test.js b/test/unit/autoplay.test.js index 4cfd2003f..d75851e8f 100644 --- a/test/unit/autoplay.test.js +++ b/test/unit/autoplay.test.js @@ -3,6 +3,7 @@ import Player from '../../src/js/player.js'; import videojs from '../../src/js/video.js'; import TestHelpers from './test-helpers.js'; import document from 'global/document'; +import window from 'global/window'; import sinon from 'sinon'; QUnit.module('autoplay', { @@ -21,7 +22,9 @@ QUnit.module('autoplay', { this.counts = { play: 0, - muted: 0 + muted: 0, + success: 0, + failure: 0 }; fixture.appendChild(videoTag); @@ -38,6 +41,16 @@ QUnit.module('autoplay', { } }; + this.resolvePromise = { + then(fn) { + fn(); + return this; + }, + catch(fn) { + return this; + } + }; + this.createPlayer = (options = {}, attributes = {}, playRetval = null) => { Object.keys(attributes).forEach((a) => { videoTag.setAttribute(a, attributes[a]); @@ -49,20 +62,26 @@ QUnit.module('autoplay', { this.player.play = () => { this.counts.play++; - if (playRetval) { - return playRetval; + if (playRetval || this.playRetval) { + return playRetval || this.playRetval; } }; + this.mutedValue = this.player.muted(); + this.player.muted = (v) => { if (typeof v !== 'undefined') { this.counts.muted++; + this.mutedValue = v; } return oldMuted.call(this.player, v); }; + this.player.on('autoplay-success', () => this.counts.success++); + this.player.on('autoplay-failure', () => this.counts.failure++); + // we have to trigger ready so that we // are waiting for loadstart this.player.tech_.triggerReady(); @@ -84,10 +103,14 @@ QUnit.test('option = false no play/muted', function(assert) { this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); }); QUnit.test('option = true no play/muted', function(assert) { @@ -99,10 +122,14 @@ QUnit.test('option = true no play/muted', function(assert) { this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); }); QUnit.test('option = "random" no play/muted', function(assert) { @@ -114,10 +141,14 @@ QUnit.test('option = "random" no play/muted', function(assert) { this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); }); QUnit.test('option = null, should be set to false no play/muted', function(assert) { @@ -129,14 +160,18 @@ QUnit.test('option = null, should be set to false no play/muted', function(asser this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); }); -QUnit.test('options = "play" play, no muted', function(assert) { - this.createPlayer({autoplay: 'play'}); +QUnit.test('option = "play" play, no muted', function(assert) { + this.createPlayer({autoplay: 'play'}, {}, this.resolvePromise); assert.equal(this.player.autoplay(), 'play', 'player.autoplay getter'); assert.equal(this.player.tech_.autoplay(), false, 'tech.autoplay getter'); @@ -144,14 +179,18 @@ QUnit.test('options = "play" play, no muted', function(assert) { this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 1, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 1, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 2, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 2, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); }); QUnit.test('option = "any" play, no muted', function(assert) { - this.createPlayer({autoplay: 'any'}); + this.createPlayer({autoplay: 'any'}, {}, this.resolvePromise); assert.equal(this.player.autoplay(), 'any', 'player.autoplay getter'); assert.equal(this.player.tech_.autoplay(), false, 'tech.autoplay getter'); @@ -159,14 +198,18 @@ QUnit.test('option = "any" play, no muted', function(assert) { this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 1, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 1, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 2, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 2, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); }); QUnit.test('option = "muted" play and muted', function(assert) { - this.createPlayer({autoplay: 'muted'}); + this.createPlayer({autoplay: 'muted'}, {}, this.resolvePromise); assert.equal(this.player.autoplay(), 'muted', 'player.autoplay getter'); assert.equal(this.player.tech_.autoplay(), false, 'tech.autoplay getter'); @@ -174,10 +217,14 @@ QUnit.test('option = "muted" play and muted', function(assert) { this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 1, 'play count'); assert.equal(this.counts.muted, 1, 'muted count'); + assert.equal(this.counts.success, 1, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 2, 'play count'); assert.equal(this.counts.muted, 2, 'muted count'); + assert.equal(this.counts.success, 2, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); }); QUnit.test('option = "play" play, no muted, rejection ignored', function(assert) { @@ -189,10 +236,14 @@ QUnit.test('option = "play" play, no muted, rejection ignored', function(assert) this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 1, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 1, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 2, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 2, 'failure count'); }); QUnit.test('option = "any" play, no muted, rejection leads to muted then play', function(assert) { @@ -205,10 +256,14 @@ QUnit.test('option = "any" play, no muted, rejection leads to muted then play', this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 2, 'play count'); assert.equal(this.counts.muted, 2, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 1, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 4, 'play count'); assert.equal(this.counts.muted, 4, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 2, 'failure count'); }); QUnit.test('option = "muted" play and muted, rejection ignored', function(assert) { @@ -221,10 +276,14 @@ QUnit.test('option = "muted" play and muted, rejection ignored', function(assert this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 1, 'play count'); assert.equal(this.counts.muted, 2, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 1, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 2, 'play count'); assert.equal(this.counts.muted, 4, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 2, 'failure count'); }); QUnit.test('option = "muted", attr = true, play and muted', function(assert) { @@ -236,10 +295,14 @@ QUnit.test('option = "muted", attr = true, play and muted', function(assert) { this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); }); QUnit.test('option = "play", attr = true, play only', function(assert) { @@ -251,10 +314,14 @@ QUnit.test('option = "play", attr = true, play only', function(assert) { this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); }); QUnit.test('option = "any", attr = true, play only', function(assert) { @@ -266,8 +333,116 @@ QUnit.test('option = "any", attr = true, play only', function(assert) { this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); this.player.tech_.trigger('loadstart'); assert.equal(this.counts.play, 0, 'play count'); assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); +}); + +QUnit.test('option = "any", play terminated restores muted', function(assert) { + this.createPlayer({autoplay: 'any'}); + + this.playRetval = { + then(fn) { + fn(); + return this; + }, + catch: (fn) => { + assert.equal(this.counts.play, 1, 'play count'); + assert.equal(this.counts.muted, 0, 'muted count'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); + + this.playRetval = { + then(_fn) { + window.setTimeout(_fn, 1); + return this; + }, + catch(_fn) { + return this; + } + }; + const retval = fn(); + + assert.equal(this.counts.play, 2, 'play count'); + assert.equal(this.counts.muted, 1, 'muted count'); + assert.equal(this.mutedValue, true, 'is muted'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); + + return retval; + } + }; + + assert.equal(this.player.autoplay(), 'any', 'player.autoplay getter'); + assert.equal(this.player.tech_.autoplay(), false, 'tech.autoplay getter'); + assert.equal(this.mutedValue, false, 'is not muted'); + + this.player.tech_.trigger('loadstart'); + + this.player.runPlayTerminatedQueue_(); + + assert.equal(this.counts.play, 2, 'play count'); + assert.equal(this.counts.muted, 2, 'muted count'); + assert.equal(this.mutedValue, false, 'is not muted'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); + + this.player.runPlayTerminatedQueue_(); + + assert.equal(this.counts.play, 2, 'play count'); + assert.equal(this.counts.muted, 2, 'muted count'); + assert.equal(this.mutedValue, false, 'is not muted'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); + + // verify autoplay success + this.clock.tick(1); + assert.equal(this.counts.play, 2, 'play count'); + assert.equal(this.counts.muted, 2, 'muted count'); + assert.equal(this.counts.success, 1, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); +}); + +QUnit.test('option = "muted", play terminated restores muted', function(assert) { + this.createPlayer({autoplay: 'muted'}, {}, { + then(fn) { + window.setTimeout(() => { + fn(); + }, 1); + return this; + }, + catch(fn) { + return this; + } + }); + + assert.equal(this.player.autoplay(), 'muted', 'player.autoplay getter'); + assert.equal(this.player.tech_.autoplay(), false, 'tech.autoplay getter'); + + this.player.tech_.trigger('loadstart'); + + assert.equal(this.counts.play, 1, 'play count'); + assert.equal(this.counts.muted, 1, 'muted count'); + assert.equal(this.mutedValue, true, 'is muted'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); + + this.player.runPlayTerminatedQueue_(); + assert.equal(this.counts.play, 1, 'play count'); + assert.equal(this.counts.muted, 2, 'muted count'); + assert.equal(this.mutedValue, false, 'no longer muted'); + assert.equal(this.counts.success, 0, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); + + // verify autoplay success + this.clock.tick(1); + assert.equal(this.counts.play, 1, 'play count'); + assert.equal(this.counts.muted, 2, 'muted count'); + assert.equal(this.counts.success, 1, 'success count'); + assert.equal(this.counts.failure, 0, 'failure count'); }); diff --git a/test/unit/play.test.js b/test/unit/play.test.js new file mode 100644 index 000000000..fee87f3a4 --- /dev/null +++ b/test/unit/play.test.js @@ -0,0 +1,446 @@ +/* eslint-env qunit */ +import TestHelpers from './test-helpers.js'; +import sinon from 'sinon'; +import window from 'global/window'; +import * as middleware from '../../src/js/tech/middleware.js'; +import videojs from '../../src/js/video.js'; + +const middleWareTerminations = ['terminates', 'does not-terminate']; +const playReturnValues = ['non-promise', 'promise']; + +const mainModule = function(playReturnValue, middlewareTermination, subhooks) { + subhooks.beforeEach(function(assert) { + this.clock = sinon.useFakeTimers(); + this.techPlayCalls = 0; + this.playsTerminated = 0; + this.playTests = []; + this.terminate = false; + + if (middlewareTermination === 'terminates') { + this.terminate = true; + } + this.techPlay = () => { + this.techPlayCalls++; + + if (playReturnValue === 'promise') { + return window.Promise.resolve('foo'); + } + return 'foo'; + }; + + this.finish = function() { + const done = assert.async(this.playTests.length); + + const singleFinish = (playValue, assertName) => { + assert.equal(playValue, 'foo', `play call from - ${assertName} - is correct`); + done(); + }; + + this.playTests.forEach(function(test) { + const playRetval = test.playRetval; + const testName = test.assertName; + + if (typeof playRetval === 'string') { + singleFinish(playRetval, testName); + } else { + playRetval.then((v) => { + singleFinish(v, testName); + }); + } + }); + }; + + this.checkState = (assertName, options = {}) => { + const expectedState = videojs.mergeOptions({ + playCalls: 0, + techLoaded: false, + techReady: false, + playerReady: false, + changingSrc: false, + playsTerminated: 0 + }, options); + + if (typeof options.techLoaded === 'undefined' && typeof options.techReady !== 'undefined') { + expectedState.techLoaded = options.techReady; + } + + const currentState = { + playCalls: this.techPlayCalls, + techLoaded: Boolean(this.player.tech_), + techReady: Boolean((this.player.tech_ || {}).isReady_), + playerReady: Boolean(this.player.isReady_), + changingSrc: Boolean(this.player.changingSrc_), + playsTerminated: Number(this.playsTerminated) + }; + + assert.deepEqual(currentState, expectedState, assertName); + }; + + this.playTerminatedQueue = () => this.playsTerminated++; + + this.playTest = (assertName, options = {}) => { + if (this.player.playTerminatedQueue_ !== this.playTerminatedQueue) { + this.player.runPlayTerminatedQueue_ = this.playTerminatedQueue; + } + if (this.player && this.player.tech_ && this.player.tech_.play !== this.techPlay) { + this.player.tech_.play = this.techPlay; + } + this.playTests.push({assertName, playRetval: this.player.play()}); + this.checkState(assertName, options); + }; + + this.middleware = () => { + return { + // pass along source + setSource(srcObj, next) { + next(null, srcObj); + }, + callPlay: () => { + if (this.terminate) { + return middleware.TERMINATOR; + } + } + }; + }; + + middleware.use('*', this.middleware); + }); + + subhooks.afterEach(function() { + // remove added middleware + const middlewareList = middleware.getMiddleware('*'); + + for (let i = 0; i < middlewareList.length; i++) { + if (middlewareList[i] === this.middleware) { + middlewareList.splice(i, 1); + } + } + if (this.player) { + this.player.dispose(); + } + this.clock.restore(); + }); + + QUnit.test('Player#play() resolves correctly with dom sources and async tech ready', function(assert) { + // turn of mediaLoader to prevent setting a tech right away + // similar to settings sources in the DOM + // turn off autoReady to prevent syncronous ready from the tech + this.player = TestHelpers.makePlayer({mediaLoader: false, techFaker: {autoReady: false}}); + + this.playTest('before anything is ready'); + + this.player.src({ + src: 'http://example.com/video.mp4', + type: 'video/mp4' + }); + + this.playTest('only changingSrc', { + changingSrc: true + }); + + this.clock.tick(1); + + this.playTest('still changingSrc, tech loaded', { + techLoaded: true, + changingSrc: true + }); + + this.player.tech_.triggerReady(); + this.playTest('still changingSrc, tech loaded and ready', { + techReady: true, + changingSrc: true + }); + this.clock.tick(1); + + this.playTest('done changingSrc, tech/player ready', { + playerReady: true, + techReady: true, + playCalls: this.terminate ? 0 : 1, + playsTerminated: this.terminate ? 1 : 0 + }); + + this.clock.tick(1); + + this.checkState('state stays the same', { + playerReady: true, + techReady: true, + playCalls: this.terminate ? 0 : 1, + playsTerminated: this.terminate ? 1 : 0 + }); + + this.playTest('future calls hit tech#play directly, unless terminated', { + playerReady: true, + techReady: true, + playCalls: this.terminate ? 0 : 2, + playsTerminated: this.terminate ? 2 : 0 + }); + + if (this.terminate) { + this.terminate = false; + + this.playTest('play works if not terminated', { + playerReady: true, + techReady: true, + playCalls: 1, + playsTerminated: 2 + }); + + this.playTest('future calls hit tech#play directly', { + playerReady: true, + techReady: true, + playCalls: 2, + playsTerminated: 2 + }); + } + + this.finish(assert); + }); + + QUnit.test('Player#play() resolves correctly with dom sources', function(assert) { + this.player = TestHelpers.makePlayer({mediaLoader: false}); + + this.playTest('before anything is ready'); + + this.player.src({ + src: 'http://example.com/video.mp4', + type: 'video/mp4' + }); + + this.playTest('only changingSrc', { + changingSrc: true + }); + + this.clock.tick(1); + + this.playTest('still changingSrc, tech/player ready', { + techLoaded: true, + changingSrc: true, + playerReady: true, + techReady: true + }); + + this.clock.tick(1); + + this.playTest('done changingSrc, tech#play is called', { + playerReady: true, + techReady: true, + playCalls: this.terminate ? 0 : 1, + playsTerminated: this.terminate ? 1 : 0 + }); + + this.clock.tick(1); + + this.checkState('state stays the same', { + playerReady: true, + techReady: true, + playCalls: this.terminate ? 0 : 1, + playsTerminated: this.terminate ? 1 : 0 + }); + + this.playTest('future calls hit tech#play directly', { + playerReady: true, + techReady: true, + playCalls: this.terminate ? 0 : 2, + playsTerminated: this.terminate ? 2 : 0 + }); + + if (this.terminate) { + this.terminate = false; + + this.playTest('play works if not terminated', { + playerReady: true, + techReady: true, + playCalls: 1, + playsTerminated: 2 + }); + + this.playTest('future calls hit tech#play directly', { + playerReady: true, + techReady: true, + playCalls: 2, + playsTerminated: 2 + }); + } + + this.finish(assert); + }); + + QUnit.test('Player#play() resolves correctly with async tech ready', function(assert) { + this.player = TestHelpers.makePlayer({techFaker: {autoReady: false}}); + + this.playTest('before anything is ready', { + techLoaded: true + }); + + this.player.src({ + src: 'http://example.com/video.mp4', + type: 'video/mp4' + }); + + this.playTest('tech loaded changingSrc', { + techLoaded: true, + changingSrc: true + }); + + this.clock.tick(1); + + this.playTest('still changingSrc, tech loaded', { + techLoaded: true, + changingSrc: true + }); + + this.clock.tick(1); + this.playTest('still changingSrc, tech loaded again', { + techLoaded: true, + changingSrc: true + }); + + this.player.tech_.triggerReady(); + this.playTest('still changingSrc, tech loaded and ready', { + techReady: true, + changingSrc: true + }); + this.clock.tick(1); + + this.playTest('still changingSrc tech/player ready', { + changingSrc: true, + playerReady: true, + techReady: true + }); + + // player ready calls fire now + // which sets changingSrc_ to false + this.clock.tick(1); + + this.checkState('play was called on ready', { + playerReady: true, + techReady: true, + playCalls: this.terminate ? 0 : 1, + playsTerminated: this.terminate ? 1 : 0 + }); + + this.playTest('future calls hit tech#play directly', { + playerReady: true, + techReady: true, + playCalls: this.terminate ? 0 : 2, + playsTerminated: this.terminate ? 2 : 0 + }); + + if (this.terminate) { + this.terminate = false; + + this.playTest('play works if not terminated', { + playerReady: true, + techReady: true, + playCalls: 1, + playsTerminated: 2 + }); + + this.playTest('future calls hit tech#play directly', { + playerReady: true, + techReady: true, + playCalls: 2, + playsTerminated: 2 + }); + } + + this.finish(assert); + }); + + QUnit.test('Player#play() resolves correctly', function(assert) { + this.player = TestHelpers.makePlayer(); + + this.playTest('player/tech start out ready', { + techReady: true, + playerReady: true + }); + + this.player.src({ + src: 'http://example.com/video.mp4', + type: 'video/mp4' + }); + + this.playTest('now changingSrc', { + techReady: true, + playerReady: true, + changingSrc: true + }); + + this.clock.tick(1); + + this.playTest('done changingSrc, play called if not terminated', { + techReady: true, + playerReady: true, + playCalls: this.terminate ? 0 : 1, + playsTerminated: this.terminate ? 1 : 0 + }); + + this.clock.tick(2); + + this.checkState('state stays the same', { + playerReady: true, + techReady: true, + playCalls: this.terminate ? 0 : 1, + playsTerminated: this.terminate ? 1 : 0 + }); + + this.playTest('future calls hit tech#play directly', { + playerReady: true, + techReady: true, + playCalls: this.terminate ? 0 : 2, + playsTerminated: this.terminate ? 2 : 0 + }); + + if (this.terminate) { + this.terminate = false; + + this.playTest('play works if not terminated', { + playerReady: true, + techReady: true, + playCalls: 1, + playsTerminated: 2 + }); + + this.playTest('future calls hit tech#play directly', { + playerReady: true, + techReady: true, + playCalls: 2, + playsTerminated: 2 + }); + } + + this.finish(assert); + }); + + // without enableSourceset this test will fail. + QUnit.test('Player#play() resolves correctly on tech el src', function(assert) { + this.player = TestHelpers.makePlayer({techOrder: ['html5'], enableSourceset: true}, null, false); + + this.playTest('player/tech start out ready', { + techReady: true, + playerReady: true + }); + + this.player.tech_.el_.src = 'http://vjs.zencdn.net/v/oceans.mp4'; + + this.player.on('loadstart', () => { + this.checkState('play should have been called', { + techReady: true, + playerReady: true, + playCalls: 1 + }); + }); + + this.finish(assert); + }); +}; + +QUnit.module('Player#play()', (hooks) => { + playReturnValues.forEach((playReturnValue) => { + middleWareTerminations.forEach((middlewareTermination) => { + QUnit.module(`tech#play() => ${playReturnValue}, middleware ${middlewareTermination}`, (subhooks) => { + mainModule(playReturnValue, middlewareTermination, subhooks); + }); + }); + }); +}); diff --git a/test/unit/player.test.js b/test/unit/player.test.js index 6e02789df..e86d270db 100644 --- a/test/unit/player.test.js +++ b/test/unit/player.test.js @@ -1174,60 +1174,6 @@ QUnit.test('should be scrubbing while seeking', function(assert) { player.dispose(); }); -if (window.Promise) { - QUnit.test('play promise should resolve to native promise if returned', function(assert) { - const player = TestHelpers.makePlayer({}); - const done = assert.async(); - - player.src({ - src: 'http://example.com/video.mp4', - type: 'video/mp4' - }); - - this.clock.tick(1); - - player.tech_.play = () => window.Promise.resolve('foo'); - const p = player.play(); - - assert.ok(p, 'play returns something'); - assert.equal(typeof p.then, 'function', 'play returns a promise'); - p.then(function(val) { - assert.equal(val, 'foo', 'should resolve to native promise value'); - - player.dispose(); - done(); - }); - }); -} - -QUnit.test('play promise should resolve to native value if returned', function(assert) { - const done = assert.async(); - const player = TestHelpers.makePlayer({}); - - player.src({ - src: 'http://example.com/video.mp4', - type: 'video/mp4' - }); - - this.clock.tick(1); - - player.tech_.play = () => 'foo'; - const p = player.play(); - - const finish = (v) => { - assert.equal(v, 'foo', 'play returns foo'); - done(); - }; - - if (typeof p === 'string') { - finish(p); - } else { - p.then((v) => { - finish(v); - }); - } -}); - QUnit.test('should throw on startup no techs are specified', function(assert) { const techOrder = videojs.options.techOrder; const fixture = document.getElementById('qunit-fixture'); diff --git a/test/unit/test-helpers.js b/test/unit/test-helpers.js index 4aac1c88a..ac69a56af 100644 --- a/test/unit/test-helpers.js +++ b/test/unit/test-helpers.js @@ -11,7 +11,7 @@ const TestHelpers = { return videoTag; }, - makePlayer(playerOptions, videoTag) { + makePlayer(playerOptions, videoTag, addTechAsMiddleware = true) { videoTag = videoTag || TestHelpers.makeTag(); const fixture = document.getElementById('qunit-fixture'); @@ -23,7 +23,9 @@ const TestHelpers = { const player = new Player(videoTag, playerOptions); - player.middleware_ = [player.tech_]; + if (addTechAsMiddleware) { + player.middleware_ = [player.tech_]; + } return player; },