Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Wait for loadstart when changing sources, instead of just ready. #4743

Merged
merged 6 commits into from
Nov 16, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 3 additions & 4 deletions src/js/big-play-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import Button from './button.js';
import Component from './component.js';
import {isPromise} from './utils/promise';

/**
* The initial play button that shows before the video has played. The hiding of the
Expand Down Expand Up @@ -58,10 +59,8 @@ class BigPlayButton extends Button {

const playFocus = () => playToggle.focus();

if (playPromise && playPromise.then) {
const ignoreRejectedPlayPromise = () => {};

playPromise.then(playFocus, ignoreRejectedPlayPromise);
if (isPromise(playPromise)) {
playPromise.then(playFocus, () => {});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not import silencePromise and use that?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy/paste from my other PR, good call.

Copy link
Member Author

@misteroneill misteroneill Nov 13, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, actually, we can't because it's not silencing it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't? What the difference between ignoreRejectedPlayPromise and silencePromise?

Oh, I see. We are silencing the playPromise but we're also adding a playFocus handler.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could potentially do something like:

if (isPromise(playPromise)) {
  silencePromise(playPromise);
  playPromise.then(playFocus);
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like overkill...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, either way. using silencePromise is probably extra explicit/clear but either is fine.

} else {
this.setTimeout(playFocus, 1);
}
Expand Down
63 changes: 42 additions & 21 deletions src/js/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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 textTrackConverter from './tracks/text-track-list-converter.js';
import ModalDialog from './modal-dialog';
import Tech from './tech/tech.js';
Expand Down Expand Up @@ -461,6 +462,8 @@ class Player extends Component {
this.on('stageclick', this.handleStageClick_);

this.changingSrc_ = false;
this.playWaitingForReady_ = false;
this.playOnLoadstart_ = null;
}

/**
Expand Down Expand Up @@ -1623,37 +1626,55 @@ class Player extends Component {
}

/**
* start media playback
* Attempt to begin playback at the first opportunity.
*
* @return {Promise|undefined}
* Returns a `Promise` if the browser returns one, for most browsers this will
* return undefined.
* Returns a `Promise` only if the browser returns one and the player
* is ready to begin playback. For some browsers and all non-ready
* situations, this will return `undefined`.
*/
play() {
if (this.changingSrc_) {
this.ready(function() {
const retval = this.techGet_('play');

// silence errors (unhandled promise from play)
if (retval !== undefined && typeof retval.then === 'function') {
retval.then(null, (e) => {});
}
// 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_);
}

// 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we set this in the constructor to a default value as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

silencePromise(this.play());
});

// Only calls the tech's play if we already have a src loaded
} else if (this.isReady_ && (this.src() || this.currentSrc())) {
// If the player/tech is ready and we have a source, we can attempt playback.
} else if (!this.changingSrc_ && (this.src() || this.currentSrc())) {
return this.techGet_('play');

// 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.ready(function() {
this.tech_.one('loadstart', function() {
const retval = this.play();

// silence errors (unhandled promise from play)
if (retval !== undefined && typeof retval.then === 'function') {
retval.then(null, (e) => {});
}
});
});
this.playOnLoadstart_ = () => {
this.playOnLoadstart_ = null;
silencePromise(this.play());
};

this.one('loadstart', this.playOnLoadstart_);
}
}

Expand Down
28 changes: 28 additions & 0 deletions src/js/utils/promise.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@

/**
* Returns whether an object is `Promise`-like (i.e. has a `then` method).
*
* @param {Object} value
* An object that may or may not be `Promise`-like.
*
* @return {Boolean}
* Whether or not the object is `Promise`-like.
*/
export function isPromise(value) {
return value !== undefined && typeof value.then === 'function';
}

/**
* Silence a Promise-like object.
*
* This is useful for avoiding non-harmful, but potentially confusing "uncaught
* play promise" rejection error messages.
*
* @param {Object} value
* An object that may or may not be `Promise`-like.
*/
export function silencePromise(value) {
if (isPromise(value)) {
value.then(null, (e) => {});
}
}
100 changes: 100 additions & 0 deletions test/unit/player-play.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/* eslint-env qunit */
import sinon from 'sinon';
import {silencePromise} from '../../src/js/utils/promise';
import TestHelpers from './test-helpers';

QUnit.module('Player#play', {

beforeEach() {
this.clock = sinon.useFakeTimers();
this.player = TestHelpers.makePlayer({});
this.techPlayCallCount = 0;
this.player.tech_.play = () => {
this.techPlayCallCount++;
};
},

afterEach() {
this.player.dispose();
this.clock.restore();
}
});

QUnit.test('tech not ready + no source = wait for ready, then loadstart', function(assert) {

// Mock the player/tech not being ready.
this.player.isReady_ = false;

// Attempt to play.
this.player.play();
this.clock.tick(100);
assert.strictEqual(this.techPlayCallCount, 0, 'tech_.play was not called because the tech was not ready');

// Ready the player.
this.player.triggerReady();
this.clock.tick(100);
assert.strictEqual(this.techPlayCallCount, 0, 'tech_.play was not called because there was no source');

// Add a source and trigger loadstart.
this.player.src('xyz.mp4');
this.clock.tick(100);
this.player.trigger('loadstart');
assert.strictEqual(this.techPlayCallCount, 1, 'tech_.play was called');
});

QUnit.test('tech not ready + has source = wait for ready', function(assert) {

// Mock the player/tech not being ready, but having a source.
this.player.isReady_ = false;
this.player.src('xyz.mp4');
this.clock.tick(100);

// Attempt to play.
this.player.play();
this.clock.tick(100);
assert.strictEqual(this.techPlayCallCount, 0, 'tech_.play was not called because the tech was not ready');

// Ready the player.
this.player.triggerReady();
this.clock.tick(100);
assert.strictEqual(this.techPlayCallCount, 1, 'tech_.play was called');
});

QUnit.test('tech ready + no source = wait for loadstart', function(assert) {

// Attempt to play.
this.player.play();
this.clock.tick(100);
assert.strictEqual(this.techPlayCallCount, 0, 'tech_.play was not called because the tech was not ready');

// Add a source and trigger loadstart.
this.player.src('xyz.mp4');
this.clock.tick(100);
this.player.trigger('loadstart');
assert.strictEqual(this.techPlayCallCount, 1, 'tech_.play was called');
});

QUnit.test('tech ready + has source = play immediately!', function(assert) {

// Mock the player having a source.
this.player.src('xyz.mp4');
this.clock.tick(100);

// Attempt to play, but silence the promise that might be returned.
silencePromise(this.player.play());
assert.strictEqual(this.techPlayCallCount, 1, 'tech_.play was called');
});

QUnit.test('tech ready + has source + changing source = wait for loadstart', function(assert) {

// Mock the player having a source and in the process of changing its source.
this.player.src('xyz.mp4');
this.clock.tick(100);
this.player.src('abc.mp4');
this.player.play();
this.clock.tick(100);
assert.strictEqual(this.techPlayCallCount, 0, 'tech_.play was not called because the source was changing');

this.player.trigger('loadstart');
assert.strictEqual(this.techPlayCallCount, 1, 'tech_.play was called');
});
20 changes: 20 additions & 0 deletions test/unit/utils/promise.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/* eslint-env qunit */
import window from 'global/window';
import * as promise from '../../../src/js/utils/promise';

QUnit.module('utils/promise');

QUnit.test('can correctly identify a native Promise (if supported)', function(assert) {

// If Promises aren't supported, skip this.
if (!window.Promise) {
return assert.expect(0);
}

assert.ok(promise.isPromise(new window.Promise((resolve) => resolve())), 'a native Promise was recognized');
});

QUnit.test('can identify a Promise-like object', function(assert) {
assert.notOk(promise.isPromise({}), 'an object without a `then` method is not Promise-like');
assert.ok(promise.isPromise({then: () => {}}), 'an object with a `then` method is Promise-like');
});