From f597301b743b1e9aade8afd4b4d50d48c570d064 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 17 Oct 2016 16:57:54 -0400 Subject: [PATCH 01/48] First run at enhanced plugins --- src/js/player.js | 4 + src/js/plugin.js | 194 ++++++++++++++++++++++++++++++++++++++ src/js/plugins.js | 93 +++++++++++++++++- src/js/video.js | 4 +- test/unit/plugin.test.js | 13 +++ test/unit/plugins.test.js | 107 ++++++++------------- test/unit/video.test.js | 9 +- 7 files changed, 343 insertions(+), 81 deletions(-) create mode 100644 src/js/plugin.js create mode 100644 test/unit/plugin.test.js diff --git a/src/js/player.js b/src/js/player.js index 1f6ad43ec4..5b0b81dca6 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -347,6 +347,8 @@ class Player extends Component { */ this.scrubbing_ = false; + this.plugins_ = {}; + this.el_ = this.createEl(); // We also want to pass the original player options to each component and plugin @@ -3317,6 +3319,8 @@ TECH_EVENTS_RETRIGGER.forEach(function(event) { }; }); +/* document methods */ + /** * Fired when the player has initial duration and dimension information * diff --git a/src/js/plugin.js b/src/js/plugin.js new file mode 100644 index 0000000000..b11890abab --- /dev/null +++ b/src/js/plugin.js @@ -0,0 +1,194 @@ +import EventTarget from './event-target'; +import * as Fn from './utils/fn'; + +/** + * A Plugin object represents an interface between a Player and a plugin as + * registered with videojs. + * + * A Plugin is returned by the Player#plugin() method. + */ +class Plugin extends EventTarget { + + /** + * Creates a plugin object for a specific plugin on a specific player. + * + * @param {Player} player + * @param {String} name + * @param {Object} plugin + * Contains properties like `init` and `dispose` that were + * created when registering a plugin with videojs. + */ + constructor(player, name, plugin) { + super(); + + this.active_ = false; + this.name_ = name; + this.player_ = player; + this.plugin_ = plugin; + this.state_ = {}; + + ['init', 'deinit', 'dispose', 'state', 'version'].forEach(k => { + this[k] = Fn.bind(this, this[k]); + }); + + this.player_.on('dispose', this.dispose); + } + + /** + * Initializes a plugin on a player. + * + * @fires Plugin#beforeinit + * @fires Plugin#init + * @param {...Mixed} args + * If the plugin author defined a `dispose()` method, we pass any + * arguments along to it. + */ + init(...args) { + if (this.active_) { + this.deinit(); + } + + /** + * This event fires before the plugin has been initialized on this player. + * + * @event Plugin#beforeinit + */ + this.trigger('beforeinit'); + this.active_ = true; + this.plugin_.init.apply(this.player_, args); + + /** + * This event fires after the plugin has been initialized on this player. + * + * @event Plugin#init + */ + this.trigger('init'); + } + + /** + * De-inits a plugin on a player. This is similar to disposing, but it + * preserves references. This should be used when a plugin may be re- + * initialized at a later time. + * + * @fires Plugin#beforedeinit + * @fires Plugin#deinit + * @param {...Mixed} args + * If the plugin author defined a `deinit()` method, we pass + * any arguments along to it. + */ + deinit(...args) { + if (!this.active_) { + return; + } + + /** + * This event fires before the plugin has been initialized on this player. + * + * @event Plugin#beforedeinit + */ + this.trigger('beforedeinit'); + + // Plugins can be initialized more than once; so, this allows us to track + // the number of times this has happened - potentially for debug purposes. + this.active_ = false; + this.plugin_.deinit.apply(this.player_, args); + + /** + * This event fires after the plugin has been initialized on this player. + * + * @event Plugin#deinit + */ + this.trigger('deinit'); + } + + /** + * Disposes a plugin that was previously initialized on a player. + * + * @fires Plugin#beforedispose + * @fires Plugin#dispose + * @param {...Mixed} args + * If the plugin author defined a `dispose()` method, we pass any + * arguments along to it. + */ + dispose(...args) { + if (this.active_) { + this.deinit(); + } + + /** + * This event fires before the plugin has been disposed from this player. + * + * @event Plugin#beforedispose + */ + this.trigger('beforedispose'); + this.plugin_.dispose.apply(this.player_, args); + + // Eliminate the references between the Player object and Plugin object. + this.player_.plugins_[this.name_] = null; + this.player_ = null; + this.state_ = null; + + /** + * This event fires after the plugin has been disposed from this player. + * + * @event Plugin#dispose + */ + this.trigger('dispose'); + } + + /** + * Whether or not this plugin is active on this player. + * + * @return {Boolean} + */ + active() { + return this.active_; + } + + /** + * Get or set any stored state for a plugin. + * + * @fires Plugin#beforestatechange + * @fires Plugin#statechange + * @param {Object} [props] + * If given, will update the plugin's state object. + * @return {Object} + */ + state(props) { + if (props && typeof props === 'object') { + + /** + * If properties were passed to `state()`, this event fires before the + * internal state has been modified. + * + * @event Plugin#beforestatechange + * @type {Object} + */ + this.trigger('beforestatechange', props); + Object.keys(props).forEach(k => { + this.state_[k] = props[k]; + }); + + /** + * If properties were passed to `state()`, this event fires after the + * internal state has been modified. + * + * @event Plugin#statechange + */ + this.trigger('statechange'); + } + return this.state_; + } + + /** + * Get the version of the plugin, if available. + * + * @return {String} + * If the version is unknown, will be an empty string. + */ + version() { + return this.plugin_.VERSION || ''; + } +} + +export default Plugin; diff --git a/src/js/plugins.js b/src/js/plugins.js index 0d70d0bb51..44a59f7187 100644 --- a/src/js/plugins.js +++ b/src/js/plugins.js @@ -2,19 +2,102 @@ * @file plugins.js * @module plugins */ -import Player from './player.js'; +import log from './utils/log'; +import Player from './player'; +import Plugin from './plugin'; /** + * Store all available plugins. + * + * @type {Object} + */ +const plugins = {}; + +/** + * Normalize the second argument to `registerPlugin` into an object. + * + * @param {Function|Object} plugin + * @return {Object} + */ +const normalizePlugin = (plugin) => { + if (plugin !== null && typeof plugin === 'object') { + return plugin; + } + return { + deinit() {}, + dispose() {}, + init: plugin + }; +}; + +/** + * * The method for registering a video.js plugin. {@link videojs:videojs.registerPlugin]. * * @param {string} name * The name of the plugin that is being registered * * @param {plugins:PluginFn} init - * The function that gets run when a `Player` initializes. + * A plugin object with an `init` method and optional `dispose` + * method. As a short-cut, a function can be provided, which will + * be used as the init for your plugin. + */ +const registerPlugin = function(name, plugin) { + const normalized = normalizePlugin(plugin); + + if (typeof name !== 'string' || !name.trim()) { + throw new Error('illegal plugin name; must be non-empty string'); + } + + if (plugins[name]) { + throw new Error('illegal plugin name; already exists'); + } + + if (typeof normalized.init !== 'function') { + throw new Error('illegal plugin init method; must be a function'); + } + + if (normalized.dispose && typeof normalized.dispose !== 'function') { + throw new Error('illegal plugin dipose method; must be a function'); + } + + // Create a private plugin object, which is used to create Plugin objects + // when a plugin is accessed on a player. + plugins[name] = normalized; + + // Support old-style plugin initialization. + Player.prototype[name] = function(...args) { + log.warn(`initializing plugins via custom player method names is deprecated as of video.js 6.0. instead of calling this.${name}(), use this.plugin('${name}').init()`); + this.plugin(name).init(...args); + }; + + // If using the old style of passing just an init function, copy own + // properties from the init (such as `VERSION`) onto the plugin object, + // excluding "reserved" keys that are already found on the created plugin + // object. This prevents weird scenarios like overriding `plugin.init`. + if (typeof plugin === 'function') { + const reserved = Object.keys(plugins[name]); + + Object.keys(plugin) + .filter(k => reserved.indexOf(k) === -1) + .forEach(k => { + plugins[name][k] = plugin[k]; + }); + } +}; + +/** + * This is documented in the Player component, but defined here because it + * needs to be to avoid circular dependencies. + * + * @ignore */ -const plugin = function(name, init) { - Player.prototype[name] = init; +Player.prototype.plugin = function(name) { + if (!this.plugins_[name]) { + this.plugins_[name] = new Plugin(this, name, plugins[name]); + } + + return this.plugins_[name]; }; -export default plugin; +export default registerPlugin; diff --git a/src/js/video.js b/src/js/video.js index 21187ec92c..a1b3822cc3 100644 --- a/src/js/video.js +++ b/src/js/video.js @@ -13,7 +13,7 @@ import Component from './component'; import EventTarget from './event-target'; import * as Events from './utils/events.js'; import Player from './player'; -import plugin from './plugins.js'; +import registerPlugin from './plugins.js'; import mergeOptions from './utils/merge-options.js'; import * as Fn from './utils/fn.js'; import TextTrack from './tracks/text-track.js'; @@ -354,7 +354,7 @@ videojs.bind = Fn.bind; * * @borrows plugin:plugin as videojs.plugin */ -videojs.plugin = plugin; +videojs.registerPlugin = registerPlugin; /** * Adding languages so that they're available to all players. diff --git a/test/unit/plugin.test.js b/test/unit/plugin.test.js new file mode 100644 index 0000000000..e538d722b0 --- /dev/null +++ b/test/unit/plugin.test.js @@ -0,0 +1,13 @@ +/* eslint-env qunit */ +// import {IE_VERSION} from '../../src/js/utils/browser'; +// import registerPlugin from '../../src/js/plugins'; +// import Player from '../../src/js/player'; +// import TestHelpers from './test-helpers'; +// import window from 'global/window'; +// import sinon from 'sinon'; + +QUnit.module('Plugins'); + +QUnit.test('Plugin should get initialized and receive options', function(assert) { + assert.expect(0); +}); diff --git a/test/unit/plugins.test.js b/test/unit/plugins.test.js index 7de733f5d6..ae0584df4b 100644 --- a/test/unit/plugins.test.js +++ b/test/unit/plugins.test.js @@ -1,8 +1,8 @@ /* eslint-env qunit */ import {IE_VERSION} from '../../src/js/utils/browser'; -import registerPlugin from '../../src/js/plugins.js'; -import Player from '../../src/js/player.js'; -import TestHelpers from './test-helpers.js'; +import registerPlugin from '../../src/js/plugins'; +import Player from '../../src/js/player'; +import TestHelpers from './test-helpers'; import window from 'global/window'; import sinon from 'sinon'; @@ -71,73 +71,44 @@ QUnit.test('Plugin should be able to add a UI component', function(assert) { player.dispose(); }); -QUnit.test('Plugin should overwrite plugin of same name', function(assert) { - let v1Called = 0; - let v2Called = 0; - let v3Called = 0; - - // Create initial plugin - registerPlugin('myPlugin5', function(options) { - v1Called++; - }); - const player = TestHelpers.makePlayer({}); - - player.myPlugin5({}); - - // Overwrite and create new player - registerPlugin('myPlugin5', function(options) { - v2Called++; - }); - const player2 = TestHelpers.makePlayer({}); - - player2.myPlugin5({}); - - // Overwrite and init new version on existing player - registerPlugin('myPlugin5', function(options) { - v3Called++; - }); - player2.myPlugin5({}); - - assert.ok(v1Called === 1, 'First version of plugin called once'); - assert.ok(v2Called === 1, 'Plugin overwritten for new player'); - assert.ok(v3Called === 1, 'Plugin overwritten for existing player'); - - player.dispose(); - player2.dispose(); +QUnit.test('Plugin should throw if plugin of same name exists', function(assert) { + assert.throws(function() { + registerPlugin('myPlugin4', function() {}); + }, 'threw because this plugin was created in a previous test'); }); -QUnit.test('Plugins should get events in registration order', function(assert) { - const order = []; - const expectedOrder = []; - const pluginName = 'orderPlugin'; - const player = TestHelpers.makePlayer({}); - const plugin = function(name) { - registerPlugin(name, function(opts) { - this.on('test', function(event) { - order.push(name); - }); - }); - player[name]({}); - }; - - for (let i = 0; i < 3; i++) { - const name = pluginName + i; - - expectedOrder.push(name); - plugin(name); - } - - registerPlugin('testerPlugin', function(opts) { - this.trigger('test'); - }); - - player.testerPlugin({}); - - assert.deepEqual(order, - expectedOrder, - 'plugins should receive events in order of initialization'); - player.dispose(); -}); +// QUnit.test('Plugins should get events in registration order', function(assert) { +// const order = []; +// const expectedOrder = []; +// const pluginName = 'orderPlugin'; +// const player = TestHelpers.makePlayer({}); +// const plugin = function(name) { +// registerPlugin(name, function(opts) { +// this.on('test', function(event) { +// order.push(name); +// }); +// }); +// player[name]({}); +// }; + +// for (let i = 0; i < 3; i++) { +// const name = pluginName + i; + +// expectedOrder.push(name); +// plugin(name); +// } + +// registerPlugin('testerPlugin', function(opts) { +// this.trigger('test'); +// }); + +// player.testerPlugin({}); + +// assert.deepEqual(order, +// expectedOrder, +// 'plugins should receive events in order of initialization'); +// player.dispose(); +// }); QUnit.test('Plugins should not get events after stopImmediatePropagation is called', function(assert) { const order = []; diff --git a/test/unit/video.test.js b/test/unit/video.test.js index 2472d06600..9c2ac9d07f 100644 --- a/test/unit/video.test.js +++ b/test/unit/video.test.js @@ -166,17 +166,14 @@ QUnit.test('should add the value to the languages object with lower case lang co }); QUnit.test('should expose plugin registry function', function(assert) { - const pluginName = 'foo'; - const pluginFunction = function(options) {}; + assert.ok(videojs.registerPlugin, 'should exist'); - assert.ok(videojs.plugin, 'should exist'); - - videojs.plugin(pluginName, pluginFunction); + videojs.registerPlugin('foo', function() {}); const player = TestHelpers.makePlayer(); assert.ok(player.foo, 'should exist'); - assert.equal(player.foo, pluginFunction, 'should be equal'); + assert.ok(player.plugin('foo'), 'should return a Plugin object'); player.dispose(); }); From e7f0087ed46a27b04f18d21d7072a02f9345cf3d Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 18 Oct 2016 14:21:04 -0400 Subject: [PATCH 02/48] Restore videojs.plugin as deprecated --- src/js/video.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/js/video.js b/src/js/video.js index a1b3822cc3..fed2a00630 100644 --- a/src/js/video.js +++ b/src/js/video.js @@ -356,6 +356,11 @@ videojs.bind = Fn.bind; */ videojs.registerPlugin = registerPlugin; +videojs.plugin = (...args) => { + log.warn('videojs.plugin() is deprecated; use videojs.registerPlugin() instead'); + return videojs.registerPlugin(...args); +}; + /** * Adding languages so that they're available to all players. * Example: `videojs.addLanguage('es', { 'Hello': 'Hola' });` From c1eb33c4d635407e750a8ae46a81c902119305a2 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 18 Oct 2016 16:33:19 -0400 Subject: [PATCH 03/48] Get closer to the previous API --- src/js/plugin.js | 194 -------------------------------------- src/js/plugins.js | 225 ++++++++++++++++++++++++++++++++------------ src/js/utils/obj.js | 15 +++ 3 files changed, 179 insertions(+), 255 deletions(-) delete mode 100644 src/js/plugin.js diff --git a/src/js/plugin.js b/src/js/plugin.js deleted file mode 100644 index b11890abab..0000000000 --- a/src/js/plugin.js +++ /dev/null @@ -1,194 +0,0 @@ -import EventTarget from './event-target'; -import * as Fn from './utils/fn'; - -/** - * A Plugin object represents an interface between a Player and a plugin as - * registered with videojs. - * - * A Plugin is returned by the Player#plugin() method. - */ -class Plugin extends EventTarget { - - /** - * Creates a plugin object for a specific plugin on a specific player. - * - * @param {Player} player - * @param {String} name - * @param {Object} plugin - * Contains properties like `init` and `dispose` that were - * created when registering a plugin with videojs. - */ - constructor(player, name, plugin) { - super(); - - this.active_ = false; - this.name_ = name; - this.player_ = player; - this.plugin_ = plugin; - this.state_ = {}; - - ['init', 'deinit', 'dispose', 'state', 'version'].forEach(k => { - this[k] = Fn.bind(this, this[k]); - }); - - this.player_.on('dispose', this.dispose); - } - - /** - * Initializes a plugin on a player. - * - * @fires Plugin#beforeinit - * @fires Plugin#init - * @param {...Mixed} args - * If the plugin author defined a `dispose()` method, we pass any - * arguments along to it. - */ - init(...args) { - if (this.active_) { - this.deinit(); - } - - /** - * This event fires before the plugin has been initialized on this player. - * - * @event Plugin#beforeinit - */ - this.trigger('beforeinit'); - this.active_ = true; - this.plugin_.init.apply(this.player_, args); - - /** - * This event fires after the plugin has been initialized on this player. - * - * @event Plugin#init - */ - this.trigger('init'); - } - - /** - * De-inits a plugin on a player. This is similar to disposing, but it - * preserves references. This should be used when a plugin may be re- - * initialized at a later time. - * - * @fires Plugin#beforedeinit - * @fires Plugin#deinit - * @param {...Mixed} args - * If the plugin author defined a `deinit()` method, we pass - * any arguments along to it. - */ - deinit(...args) { - if (!this.active_) { - return; - } - - /** - * This event fires before the plugin has been initialized on this player. - * - * @event Plugin#beforedeinit - */ - this.trigger('beforedeinit'); - - // Plugins can be initialized more than once; so, this allows us to track - // the number of times this has happened - potentially for debug purposes. - this.active_ = false; - this.plugin_.deinit.apply(this.player_, args); - - /** - * This event fires after the plugin has been initialized on this player. - * - * @event Plugin#deinit - */ - this.trigger('deinit'); - } - - /** - * Disposes a plugin that was previously initialized on a player. - * - * @fires Plugin#beforedispose - * @fires Plugin#dispose - * @param {...Mixed} args - * If the plugin author defined a `dispose()` method, we pass any - * arguments along to it. - */ - dispose(...args) { - if (this.active_) { - this.deinit(); - } - - /** - * This event fires before the plugin has been disposed from this player. - * - * @event Plugin#beforedispose - */ - this.trigger('beforedispose'); - this.plugin_.dispose.apply(this.player_, args); - - // Eliminate the references between the Player object and Plugin object. - this.player_.plugins_[this.name_] = null; - this.player_ = null; - this.state_ = null; - - /** - * This event fires after the plugin has been disposed from this player. - * - * @event Plugin#dispose - */ - this.trigger('dispose'); - } - - /** - * Whether or not this plugin is active on this player. - * - * @return {Boolean} - */ - active() { - return this.active_; - } - - /** - * Get or set any stored state for a plugin. - * - * @fires Plugin#beforestatechange - * @fires Plugin#statechange - * @param {Object} [props] - * If given, will update the plugin's state object. - * @return {Object} - */ - state(props) { - if (props && typeof props === 'object') { - - /** - * If properties were passed to `state()`, this event fires before the - * internal state has been modified. - * - * @event Plugin#beforestatechange - * @type {Object} - */ - this.trigger('beforestatechange', props); - Object.keys(props).forEach(k => { - this.state_[k] = props[k]; - }); - - /** - * If properties were passed to `state()`, this event fires after the - * internal state has been modified. - * - * @event Plugin#statechange - */ - this.trigger('statechange'); - } - return this.state_; - } - - /** - * Get the version of the plugin, if available. - * - * @return {String} - * If the version is unknown, will be an empty string. - */ - version() { - return this.plugin_.VERSION || ''; - } -} - -export default Plugin; diff --git a/src/js/plugins.js b/src/js/plugins.js index 44a59f7187..c64ec8a201 100644 --- a/src/js/plugins.js +++ b/src/js/plugins.js @@ -2,31 +2,170 @@ * @file plugins.js * @module plugins */ -import log from './utils/log'; +import * as Fn from './utils/fn'; +import * as Obj from './utils/obj'; +import EventTarget from './event-target'; import Player from './player'; -import Plugin from './plugin'; /** - * Store all available plugins. + * Cache of plugins that have been registered. * * @type {Object} */ const plugins = {}; /** - * Normalize the second argument to `registerPlugin` into an object. + * Plugin wrapper methods. * - * @param {Function|Object} plugin + * @private + * @type {Object} + */ +const wrapperMethods = Obj.assign({ + + /** + * Provides boilerplate for a plugin teardown process. + * + * @param {...Mixed} args + */ + teardown(...args) { + if (!this.active_) { + return; + } + + this.trigger('beforeteardown'); + + // Plugins can be initialized more than once; so, this allows us to track + // the number of times this has happened - potentially for debug purposes. + this.active_ = false; + this.teardown(...args); + this.trigger('teardown'); + }, + + /** + * Provides boilerplate for a plugin disposal process. + * + * @param {...Mixed} args + */ + dispose(...args) { + if (this.active_) { + this.teardown(); + } + + this.trigger('beforedispose'); + this.dispose(...args); + this.off(); + this.trigger('dispose'); + + // Eliminate possible sources of leaking memory after disposal. + delete this.player_[this.name_]; + this.player_ = this.state_ = null; + }, + + /** + * Getter/setter for a state management on a per-player/per-plugin basis. + * + * @param {Object} [props] + * If provided, acts as a setter. + * @return {Object} + */ + state(props) { + if (props && typeof props === 'object') { + this.trigger('beforestatechange', props); + Obj.assign(this.state_, props); + this.trigger('statechange'); + } + + return this.state_; + }, + + /** + * Whether or not this plugin is active on this player. + * + * @return {Boolean} + */ + active() { + return this.active_; + }, + + /** + * The version number of this plugin, if available. + * + * @return {String} + */ + version() { + return this.VERSION || ''; + } +}, EventTarget.prototype); + +/** + * Normalize an object or function into an object proper for validating and + * creating a plugin. + * + * @param {Object|Function} obj * @return {Object} */ -const normalizePlugin = (plugin) => { - if (plugin !== null && typeof plugin === 'object') { - return plugin; +const normalize = (obj) => { + if (typeof obj === 'function') { + return Obj.assign({setup: obj}, obj); } - return { - deinit() {}, - dispose() {}, - init: plugin + return obj; +}; + +/** + * Takes an object with at least a setup method on it and returns a wrapped + * function that will initialize the plugin and handle per-player state setup. + * + * @private + * @param {String} name + * @param {Object} plugin + * @return {Function} + */ +const implement = (name, plugin) => { + + // Create a Player.prototype-level plugin wrapper that only gets called + // once per player. + plugins[name] = Player.prototype[name] = function(...firstArgs) { + + // Replace this function with a new player-specific plugin wrapper. + const wrapper = function(...args) { + if (this.active_) { + this.teardown(); + } + + this.trigger('beforesetup'); + plugin.setup(...args); + this.trigger('setup'); + }; + + // Wrapper is bound to itself to preserve expectations. + this[name] = Fn.bind(wrapper, wrapper); + + // Add EventTarget methods and custom, per-player properties to the wrapper object. + Obj.assign(wrapper, wrapperMethods, { + active_: false, + name_: name, + player_: this, + state_: {} + }); + + // Wrap/mirror all own properties of the source `plugin` object that are + // NOT present on the wrapper object onto the wrapper object. + Obj.each(plugin, (value, key) => { + if (wrapper.hasOwnProperty(key)) { + return; + } + wrapper[key] = plugin[key]; + }); + + // Bind all methods of the wrapper to itself. + Obj.each(wrapper, (value, key) => { + if (typeof value === 'function') { + wrapper[key] = Fn.bind(this, value); + } + }); + + // Finally, call the player-specific plugin wrapper. + wrapper(...firstArgs); }; }; @@ -38,66 +177,30 @@ const normalizePlugin = (plugin) => { * The name of the plugin that is being registered * * @param {plugins:PluginFn} init - * A plugin object with an `init` method and optional `dispose` - * method. As a short-cut, a function can be provided, which will - * be used as the init for your plugin. */ -const registerPlugin = function(name, plugin) { - const normalized = normalizePlugin(plugin); - +const registerPlugin = function(name, obj) { if (typeof name !== 'string' || !name.trim()) { throw new Error('illegal plugin name; must be non-empty string'); } - if (plugins[name]) { - throw new Error('illegal plugin name; already exists'); - } - - if (typeof normalized.init !== 'function') { - throw new Error('illegal plugin init method; must be a function'); - } - - if (normalized.dispose && typeof normalized.dispose !== 'function') { - throw new Error('illegal plugin dipose method; must be a function'); + if (Player.prototype[name]) { + throw new Error(`illegal plugin name; "${name}" already exists`); } - // Create a private plugin object, which is used to create Plugin objects - // when a plugin is accessed on a player. - plugins[name] = normalized; - - // Support old-style plugin initialization. - Player.prototype[name] = function(...args) { - log.warn(`initializing plugins via custom player method names is deprecated as of video.js 6.0. instead of calling this.${name}(), use this.plugin('${name}').init()`); - this.plugin(name).init(...args); - }; + const plugin = normalize(obj); - // If using the old style of passing just an init function, copy own - // properties from the init (such as `VERSION`) onto the plugin object, - // excluding "reserved" keys that are already found on the created plugin - // object. This prevents weird scenarios like overriding `plugin.init`. - if (typeof plugin === 'function') { - const reserved = Object.keys(plugins[name]); - - Object.keys(plugin) - .filter(k => reserved.indexOf(k) === -1) - .forEach(k => { - plugins[name][k] = plugin[k]; - }); + if (typeof plugin.setup !== 'function') { + throw new Error('illegal setup() method; should be a function'); } -}; -/** - * This is documented in the Player component, but defined here because it - * needs to be to avoid circular dependencies. - * - * @ignore - */ -Player.prototype.plugin = function(name) { - if (!this.plugins_[name]) { - this.plugins_[name] = new Plugin(this, name, plugins[name]); - } + // If optional methods exist, they should be functions. + ['dispose', 'teardown'].forEach(method => { + if (plugin[method] && typeof plugin[method] !== 'function') { + throw new Error(`illegal ${method}() method; should be a function`); + } + }); - return this.plugins_[name]; + implement(plugin); }; export default registerPlugin; diff --git a/src/js/utils/obj.js b/src/js/utils/obj.js index a6896e2712..12e7ebbf6d 100644 --- a/src/js/utils/obj.js +++ b/src/js/utils/obj.js @@ -2,6 +2,7 @@ * @file obj.js * @module obj */ +import objectAssign from 'object.assign'; /** * @callback obj:EachCallback @@ -116,3 +117,17 @@ export function isPlain(value) { toString.call(value) === '[object Object]' && value.constructor === Object; } + +/** + * Object.assign polyfill + * + * @param {Object} target + * @param {Object} ...sources + * @return {Object} + */ +export function assign(...args) { + + // This exists primarily to isolate our dependence on this third-party + // polyfill. If we decide to move away from it, we can do so in one place. + return objectAssign(...args); +} From 5adbfc852f3417a12ebfb7992b6ba6d7d8aefa42 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 24 Oct 2016 16:24:46 -0400 Subject: [PATCH 04/48] Move toward new class-based plugin architecture --- src/js/player.js | 16 ++- src/js/plugin.js | 216 ++++++++++++++++++++++++++++++++++++++ src/js/plugins.js | 206 ------------------------------------ src/js/utils/obj.js | 12 +++ src/js/video.js | 21 +++- test/unit/plugin.test.js | 12 +-- test/unit/plugins.test.js | 186 -------------------------------- 7 files changed, 261 insertions(+), 408 deletions(-) create mode 100644 src/js/plugin.js delete mode 100644 src/js/plugins.js delete mode 100644 test/unit/plugins.test.js diff --git a/src/js/player.js b/src/js/player.js index 5b0b81dca6..486fe07cd9 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -347,8 +347,7 @@ class Player extends Component { */ this.scrubbing_ = false; - this.plugins_ = {}; - + this.activePlugins_ = {}; this.el_ = this.createEl(); // We also want to pass the original player options to each component and plugin @@ -3104,6 +3103,19 @@ class Player extends Component { return modal.open(); } + /** + * Reports whether or not a player is using a plugin by name. + * + * For basic plugins, this only reports whether the plugin has _ever_ been + * initialized on this player. + * + * @param {String} name + * @return {Boolean} + */ + usingPlugin(name) { + return !!(this.activePlugins_ && this.activePlugins_[name]); + } + /** * Gets tag settings * diff --git a/src/js/plugin.js b/src/js/plugin.js new file mode 100644 index 0000000000..1a9661c798 --- /dev/null +++ b/src/js/plugin.js @@ -0,0 +1,216 @@ +/** + * @file plugin.js + */ +import * as Fn from './utils/fn'; +import * as Obj from './utils/obj'; +import EventTarget from './event-target'; +import Player from './player'; + +/** + * Stores registered plugins in a private space. + * + * @private + * @type {Object} + */ +const pluginCache = {}; + +/** + * Takes a basic plugin function and returns a wrapper function which marks + * on the player that the plugin has been activated. + * + * @private + * @return {Function} + */ +const createBasicPlugin = (name, plugin) => function() { + const result = plugin.apply(this, arguments); + + this.activePlugins_[name] = true; + this.trigger('pluginsetup', name, result); + return result; +}; + +/** + * Takes a plugin sub-class and returns a factory function for generating + * instances of it. + * + * This factory function will replace itself with an instance of the requested + * sub-class of Plugin. + * + * @private + * @return {Function} + */ +const createPluginFactory = (name, PluginSubClass) => { + + // Add a `name` property to the plugin prototype so that each plugin can + // refer to itself. + PluginSubClass.prototype.name = name; + + return function(...args) { + this[name] = new PluginSubClass(...[this, ...args]); + return this[name]; + }; +}; + +class Plugin extends EventTarget { + + /** + * Plugin constructor. + * + * Subclasses should make sure they call `super()` in order to make sure their + * plugins are properly initialized. + * + * @param {Player} player + */ + constructor(player) { + this.player = player; + this.state = Obj.assign({}, this.constructor.defaultState); + player.on('dispose', Fn.bind(this, this.dispose)); + player.activePlugins_[this.name] = true; + player.trigger('pluginsetup', this.name, this); + } + + /** + * Disposes a plugin. + * + * Subclasses can override this if they want, but for the sake of safety, + * it's probably best to subscribe to one of the disposal events. + */ + dispose() { + const {name, player, state} = this; + const props = {name, player, state}; + + this.off(); + player.activePlugins_[name] = false; + + // Eliminate possible sources of leaking memory. + this.player[name] = createPluginFactory(name, pluginCache[name]); + this.player = this.state = null; + this.trigger('dispose', this, props); + } + + /** + * Set the state of a plugin by mutating the plugin instance's `state` + * object in place. + * + * @param {Object|Function} next + * A new set of properties to shallow-merge into the plugin state. Can + * be a plain object or a function returning a plain object. + */ + setState(next) { + if (typeof next === 'function') { + next = next(); + } + + if (!Obj.isObject(next)) { + return; + } + + const {state} = this; + const changes = {}; + + Obj.each(next, (value, key) => { + + // Record the change if the value is different from what's in the + // current state. + if (state[key] !== value) { + changes[key] = { + from: state[key], + to: value + }; + } + + state[key] = value; + }); + + this.trigger('statechange', next, changes); + } + + /** + * Gets the version of the plugin, if known. + * + * This will look for a `VERSION` property on the plugin subclass. + * + * @return {String} [description] + */ + static version() { + return this.VERSION || ''; + } + + /** + * Determines if a plugin is a "basic" plugin (i.e. not a sub-class of `Plugin`). + * + * @param {String|Function} plugin + * If a string, matches the name of a plugin. If a function, will be + * tested directly. + * @return {Boolean} + */ + static isBasic(plugin) { + plugin = (typeof plugin === 'string') ? Plugin.getPlugin(plugin) : plugin; + + return typeof plugin === 'function' && + !Plugin.prototype.isPrototypeOf(plugin.prototype); + } + + /** + * Register a video.js plugin + * + * @param {String} name + * @param {Function} plugin + * A sub-class of `Plugin` or an anonymous function for simple plugins. + * @return {Function} + */ + static registerPlugin(name, plugin) { + if (pluginCache[name] || Player.prototype[name]) { + throw new Error(`illegal plugin name, "${name}"`); + } + + if (typeof plugin !== 'function') { + throw new Error(`illegal plugin for "${name}", must be a function, was ${typeof plugin}`); + } + + pluginCache[name] = plugin; + + if (Plugin.isBasic(plugin)) { + Player.prototype[name] = createBasicPlugin(name, plugin); + } else { + Player.prototype[name] = createPluginFactory(name, plugin); + } + + return plugin; + } + + /** + * Gets an object containing all plugins. + * + * @return {Object} + */ + static getPlugins() { + return Obj.assign({}, pluginCache); + } + + /** + * Gets a plugin by name + * + * @param {[type]} name [description] + * @return {[type]} [description] + */ + static getPlugin(name) { + return pluginCache[name] || Player.prototype[name]; + } + + /** + * Gets a plugin's version, if available + * + * @param {String} name + * @return {String} + */ + static getPluginVersion(name) { + const plugin = Plugin.getPlugin(name); + + return plugin && plugin.version() || ''; + } +} + +Plugin.registerPlugin('plugin', Plugin); + +export default Plugin; diff --git a/src/js/plugins.js b/src/js/plugins.js deleted file mode 100644 index c64ec8a201..0000000000 --- a/src/js/plugins.js +++ /dev/null @@ -1,206 +0,0 @@ -/** - * @file plugins.js - * @module plugins - */ -import * as Fn from './utils/fn'; -import * as Obj from './utils/obj'; -import EventTarget from './event-target'; -import Player from './player'; - -/** - * Cache of plugins that have been registered. - * - * @type {Object} - */ -const plugins = {}; - -/** - * Plugin wrapper methods. - * - * @private - * @type {Object} - */ -const wrapperMethods = Obj.assign({ - - /** - * Provides boilerplate for a plugin teardown process. - * - * @param {...Mixed} args - */ - teardown(...args) { - if (!this.active_) { - return; - } - - this.trigger('beforeteardown'); - - // Plugins can be initialized more than once; so, this allows us to track - // the number of times this has happened - potentially for debug purposes. - this.active_ = false; - this.teardown(...args); - this.trigger('teardown'); - }, - - /** - * Provides boilerplate for a plugin disposal process. - * - * @param {...Mixed} args - */ - dispose(...args) { - if (this.active_) { - this.teardown(); - } - - this.trigger('beforedispose'); - this.dispose(...args); - this.off(); - this.trigger('dispose'); - - // Eliminate possible sources of leaking memory after disposal. - delete this.player_[this.name_]; - this.player_ = this.state_ = null; - }, - - /** - * Getter/setter for a state management on a per-player/per-plugin basis. - * - * @param {Object} [props] - * If provided, acts as a setter. - * @return {Object} - */ - state(props) { - if (props && typeof props === 'object') { - this.trigger('beforestatechange', props); - Obj.assign(this.state_, props); - this.trigger('statechange'); - } - - return this.state_; - }, - - /** - * Whether or not this plugin is active on this player. - * - * @return {Boolean} - */ - active() { - return this.active_; - }, - - /** - * The version number of this plugin, if available. - * - * @return {String} - */ - version() { - return this.VERSION || ''; - } -}, EventTarget.prototype); - -/** - * Normalize an object or function into an object proper for validating and - * creating a plugin. - * - * @param {Object|Function} obj - * @return {Object} - */ -const normalize = (obj) => { - if (typeof obj === 'function') { - return Obj.assign({setup: obj}, obj); - } - return obj; -}; - -/** - * Takes an object with at least a setup method on it and returns a wrapped - * function that will initialize the plugin and handle per-player state setup. - * - * @private - * @param {String} name - * @param {Object} plugin - * @return {Function} - */ -const implement = (name, plugin) => { - - // Create a Player.prototype-level plugin wrapper that only gets called - // once per player. - plugins[name] = Player.prototype[name] = function(...firstArgs) { - - // Replace this function with a new player-specific plugin wrapper. - const wrapper = function(...args) { - if (this.active_) { - this.teardown(); - } - - this.trigger('beforesetup'); - plugin.setup(...args); - this.trigger('setup'); - }; - - // Wrapper is bound to itself to preserve expectations. - this[name] = Fn.bind(wrapper, wrapper); - - // Add EventTarget methods and custom, per-player properties to the wrapper object. - Obj.assign(wrapper, wrapperMethods, { - active_: false, - name_: name, - player_: this, - state_: {} - }); - - // Wrap/mirror all own properties of the source `plugin` object that are - // NOT present on the wrapper object onto the wrapper object. - Obj.each(plugin, (value, key) => { - if (wrapper.hasOwnProperty(key)) { - return; - } - wrapper[key] = plugin[key]; - }); - - // Bind all methods of the wrapper to itself. - Obj.each(wrapper, (value, key) => { - if (typeof value === 'function') { - wrapper[key] = Fn.bind(this, value); - } - }); - - // Finally, call the player-specific plugin wrapper. - wrapper(...firstArgs); - }; -}; - -/** - * - * The method for registering a video.js plugin. {@link videojs:videojs.registerPlugin]. - * - * @param {string} name - * The name of the plugin that is being registered - * - * @param {plugins:PluginFn} init - */ -const registerPlugin = function(name, obj) { - if (typeof name !== 'string' || !name.trim()) { - throw new Error('illegal plugin name; must be non-empty string'); - } - - if (Player.prototype[name]) { - throw new Error(`illegal plugin name; "${name}" already exists`); - } - - const plugin = normalize(obj); - - if (typeof plugin.setup !== 'function') { - throw new Error('illegal setup() method; should be a function'); - } - - // If optional methods exist, they should be functions. - ['dispose', 'teardown'].forEach(method => { - if (plugin[method] && typeof plugin[method] !== 'function') { - throw new Error(`illegal ${method}() method; should be a function`); - } - }); - - implement(plugin); -}; - -export default registerPlugin; diff --git a/src/js/utils/obj.js b/src/js/utils/obj.js index 12e7ebbf6d..05648db20e 100644 --- a/src/js/utils/obj.js +++ b/src/js/utils/obj.js @@ -131,3 +131,15 @@ export function assign(...args) { // polyfill. If we decide to move away from it, we can do so in one place. return objectAssign(...args); } + +/** + * Returns whether an object appears to be a non-function/non-array object. + * + * This avoids gotchas like `typeof null === 'object'`. + * + * @param {Object} object + * @return {Boolean} + */ +export function isObject(object) { + return Object.prototype.toString.call(object) === '[object Object]'; +} diff --git a/src/js/video.js b/src/js/video.js index fed2a00630..9daa776d8d 100644 --- a/src/js/video.js +++ b/src/js/video.js @@ -13,7 +13,7 @@ import Component from './component'; import EventTarget from './event-target'; import * as Events from './utils/events.js'; import Player from './player'; -import registerPlugin from './plugins.js'; +import Plugin from './plugin'; import mergeOptions from './utils/merge-options.js'; import * as Fn from './utils/fn.js'; import TextTrack from './tracks/text-track.js'; @@ -352,15 +352,30 @@ videojs.bind = Fn.bind; * in the player options, or the plugin function on the player instance is * called. * - * @borrows plugin:plugin as videojs.plugin + * @borrows plugin:registerPlugin as videojs.registerPlugin + * @param {String} name The plugin name + * @param {Function} fn The plugin function that will be called with options + * @mixes videojs + * @method registerPlugin */ -videojs.registerPlugin = registerPlugin; +videojs.registerPlugin = Fn.bind(Plugin, Plugin.registerPlugin); +/** + * @deprecated videojs.plugin() is deprecated; use videojs.registerPlugin() instead + * @param {String} name The plugin name + * @param {Function} fn The plugin function that will be called with options + * @mixes videojs + * @method plugin + */ videojs.plugin = (...args) => { log.warn('videojs.plugin() is deprecated; use videojs.registerPlugin() instead'); return videojs.registerPlugin(...args); }; +videojs.getPlugins = Fn.bind(Plugin, Plugin.getPlugins); +videojs.getPlugin = Fn.bind(Plugin, Plugin.getPlugin); +videojs.getPluginVersion = Fn.bind(Plugin, Plugin.getPluginVersion); + /** * Adding languages so that they're available to all players. * Example: `videojs.addLanguage('es', { 'Hello': 'Hola' });` diff --git a/test/unit/plugin.test.js b/test/unit/plugin.test.js index e538d722b0..c2ab63c7ad 100644 --- a/test/unit/plugin.test.js +++ b/test/unit/plugin.test.js @@ -1,13 +1,3 @@ /* eslint-env qunit */ -// import {IE_VERSION} from '../../src/js/utils/browser'; -// import registerPlugin from '../../src/js/plugins'; -// import Player from '../../src/js/player'; -// import TestHelpers from './test-helpers'; -// import window from 'global/window'; -// import sinon from 'sinon'; -QUnit.module('Plugins'); - -QUnit.test('Plugin should get initialized and receive options', function(assert) { - assert.expect(0); -}); +QUnit.module('Plugin'); diff --git a/test/unit/plugins.test.js b/test/unit/plugins.test.js deleted file mode 100644 index ae0584df4b..0000000000 --- a/test/unit/plugins.test.js +++ /dev/null @@ -1,186 +0,0 @@ -/* eslint-env qunit */ -import {IE_VERSION} from '../../src/js/utils/browser'; -import registerPlugin from '../../src/js/plugins'; -import Player from '../../src/js/player'; -import TestHelpers from './test-helpers'; -import window from 'global/window'; -import sinon from 'sinon'; - -QUnit.module('Plugins'); - -QUnit.test('Plugin should get initialized and receive options', function(assert) { - assert.expect(2); - - registerPlugin('myPlugin1', function(options) { - assert.ok(true, 'Plugin initialized'); - assert.ok(options.test, 'Option passed through'); - }); - - registerPlugin('myPlugin2', function(options) { - assert.ok(false, 'Plugin initialized and should not have been'); - }); - - const player = TestHelpers.makePlayer({ - plugins: { - myPlugin1: { - test: true - } - } - }); - - player.dispose(); -}); - -QUnit.test('Plugin should have the option of being initilized outside of player init', function(assert) { - assert.expect(3); - - registerPlugin('myPlugin3', function(options) { - assert.ok(true, 'Plugin initialized after player init'); - assert.ok(options.test, 'Option passed through'); - }); - - const player = TestHelpers.makePlayer({}); - - assert.ok(player.myPlugin3, 'Plugin has direct access on player instance'); - - player.myPlugin3({ - test: true - }); - - player.dispose(); -}); - -QUnit.test('Plugin should be able to add a UI component', function(assert) { - assert.expect(2); - - registerPlugin('myPlugin4', function(options) { - assert.ok((this instanceof Player), 'Plugin executed in player scope by default'); - this.addChild('component'); - }); - - const player = TestHelpers.makePlayer({}); - - player.myPlugin4({ - test: true - }); - - const comp = player.getChild('component'); - - assert.ok(comp, 'Plugin added a component to the player'); - - player.dispose(); -}); - -QUnit.test('Plugin should throw if plugin of same name exists', function(assert) { - assert.throws(function() { - registerPlugin('myPlugin4', function() {}); - }, 'threw because this plugin was created in a previous test'); -}); - -// QUnit.test('Plugins should get events in registration order', function(assert) { -// const order = []; -// const expectedOrder = []; -// const pluginName = 'orderPlugin'; -// const player = TestHelpers.makePlayer({}); -// const plugin = function(name) { -// registerPlugin(name, function(opts) { -// this.on('test', function(event) { -// order.push(name); -// }); -// }); -// player[name]({}); -// }; - -// for (let i = 0; i < 3; i++) { -// const name = pluginName + i; - -// expectedOrder.push(name); -// plugin(name); -// } - -// registerPlugin('testerPlugin', function(opts) { -// this.trigger('test'); -// }); - -// player.testerPlugin({}); - -// assert.deepEqual(order, -// expectedOrder, -// 'plugins should receive events in order of initialization'); -// player.dispose(); -// }); - -QUnit.test('Plugins should not get events after stopImmediatePropagation is called', function(assert) { - const order = []; - const expectedOrder = []; - const pluginName = 'orderPlugin'; - const player = TestHelpers.makePlayer({}); - const plugin = function(name) { - registerPlugin(name, function(opts) { - this.on('test', function(event) { - order.push(name); - event.stopImmediatePropagation(); - }); - }); - player[name]({}); - }; - - for (let i = 0; i < 3; i++) { - const name = pluginName + i; - - expectedOrder.push(name); - plugin(name); - } - - registerPlugin('testerPlugin', function(opts) { - this.trigger('test'); - }); - - player.testerPlugin({}); - - assert.deepEqual(order, - expectedOrder.slice(0, order.length), - 'plugins should receive events in order of ' + - 'initialization, until stopImmediatePropagation'); - - assert.equal(order.length, 1, 'only one event listener should have triggered'); - player.dispose(); -}); - -QUnit.test('Plugin that does not exist logs an error', function(assert) { - - const origConsole = window.console; - - // stub the global log functions - const console = window.console = { - log() {}, - warn() {}, - error() {} - }; - const log = sinon.stub(console, 'log'); - const error = sinon.stub(console, 'error'); - - // enable a non-existing plugin - TestHelpers.makePlayer({ - plugins: { - nonExistingPlugin: { - foo: 'bar' - } - } - }); - - assert.ok(error.called, 'error was called'); - - if (IE_VERSION && IE_VERSION < 11) { - assert.equal(error.firstCall.args[0], - 'VIDEOJS: ERROR: Unable to find plugin: nonExistingPlugin'); - } else { - assert.equal(error.firstCall.args[2], 'Unable to find plugin:'); - assert.equal(error.firstCall.args[3], 'nonExistingPlugin'); - } - - // tear down logging stubs - log.restore(); - error.restore(); - window.console = origConsole; -}); From 77dc7f9c74450303973e157d1256e4b2bdf77c7b Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 25 Oct 2016 12:28:19 -0400 Subject: [PATCH 05/48] Move state to a mixin --- src/js/mixins/stateful.js | 75 +++++++++++++++++++++++++++++++ src/js/plugin.js | 43 ++---------------- src/js/utils/obj.js | 11 +++++ test/unit/mixins/stateful.test.js | 65 +++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 39 deletions(-) create mode 100644 src/js/mixins/stateful.js create mode 100644 test/unit/mixins/stateful.test.js diff --git a/src/js/mixins/stateful.js b/src/js/mixins/stateful.js new file mode 100644 index 0000000000..4a98e9ff6e --- /dev/null +++ b/src/js/mixins/stateful.js @@ -0,0 +1,75 @@ +/** + * @file mixins/stateful.js + */ +import * as Fn from '../utils/fn'; +import log from '../utils/log'; +import * as Obj from '../utils/obj'; + +/** + * Set the state of an object by mutating its `state` object in place. + * + * @param {Object|Function} next + * A new set of properties to shallow-merge into the plugin state. Can + * be a plain object or a function returning a plain object. + * + * @return {Object} + * An object containing changes that occured. If no changes occurred, + * returns `undefined`. + * + */ +const setState = function(next) { + if (typeof next === 'function') { + next = next(); + } + + if (!Obj.isPlain(next)) { + log.warn('non-plain object passed to `setState`', next); + return; + } + + let changes; + + Obj.each(next, (value, key) => { + + // Record the change if the value is different from what's in the + // current state. + if (this.state[key] !== value) { + changes = changes || {}; + changes[key] = { + from: this.state[key], + to: value + }; + } + + this.state[key] = value; + }); + + // Only trigger "statechange" if there were changes AND we have a trigger + // function. This allows us to not require that the target object be an + // evented object. + if (changes && typeof this.trigger === 'function') { + this.trigger({ + changes, + type: 'statechanged' + }); + } + + return changes; +}; + +/** + * Makes an object "stateful" - granting it a `state` property containing + * arbitrary keys/values and a `setState` method which will trigger state + * changes if the object has a `trigger` method. + * + * @param {Object} object + * @return {Object} + * Returns the `object`. + */ +function stateful(target, defaultState) { + target.state = Obj.assign({}, defaultState); + target.setState = Fn.bind(target, setState); + return target; +} + +export default stateful; diff --git a/src/js/plugin.js b/src/js/plugin.js index 1a9661c798..2245f19059 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -1,6 +1,7 @@ /** * @file plugin.js */ +import stateful from './mixins/stateful'; import * as Fn from './utils/fn'; import * as Obj from './utils/obj'; import EventTarget from './event-target'; @@ -56,14 +57,15 @@ class Plugin extends EventTarget { /** * Plugin constructor. * - * Subclasses should make sure they call `super()` in order to make sure their + * Subclasses should make sure they call `super` in order to make sure their * plugins are properly initialized. * * @param {Player} player */ constructor(player) { + super(); this.player = player; - this.state = Obj.assign({}, this.constructor.defaultState); + stateful(this, this.constructor.defaultState); player.on('dispose', Fn.bind(this, this.dispose)); player.activePlugins_[this.name] = true; player.trigger('pluginsetup', this.name, this); @@ -88,43 +90,6 @@ class Plugin extends EventTarget { this.trigger('dispose', this, props); } - /** - * Set the state of a plugin by mutating the plugin instance's `state` - * object in place. - * - * @param {Object|Function} next - * A new set of properties to shallow-merge into the plugin state. Can - * be a plain object or a function returning a plain object. - */ - setState(next) { - if (typeof next === 'function') { - next = next(); - } - - if (!Obj.isObject(next)) { - return; - } - - const {state} = this; - const changes = {}; - - Obj.each(next, (value, key) => { - - // Record the change if the value is different from what's in the - // current state. - if (state[key] !== value) { - changes[key] = { - from: state[key], - to: value - }; - } - - state[key] = value; - }); - - this.trigger('statechange', next, changes); - } - /** * Gets the version of the plugin, if known. * diff --git a/src/js/utils/obj.js b/src/js/utils/obj.js index 05648db20e..893143131e 100644 --- a/src/js/utils/obj.js +++ b/src/js/utils/obj.js @@ -143,3 +143,14 @@ export function assign(...args) { export function isObject(object) { return Object.prototype.toString.call(object) === '[object Object]'; } + +/** + * Returns whether an object appears to be a "plain" object - that is, a + * direct instance of `Object`. + * + * @param {Object} object + * @return {Boolean} + */ +export function isPlain(object) { + return isObject(object) && object.constructor === Object; +} diff --git a/test/unit/mixins/stateful.test.js b/test/unit/mixins/stateful.test.js new file mode 100644 index 0000000000..2ec7f8f7fe --- /dev/null +++ b/test/unit/mixins/stateful.test.js @@ -0,0 +1,65 @@ +/* eslint-env qunit */ +import sinon from 'sinon'; +import EventTarget from '../../../src/js/event-target'; +import stateful from '../../../src/js/mixins/stateful'; +import * as Obj from '../../../src/js/utils/obj'; + +QUnit.module('Mixins: Stateful'); + +QUnit.test('stateful() mutations', function(assert) { + const target = {}; + + assert.strictEqual(typeof stateful, 'function', 'the mixin is a function'); + assert.strictEqual(stateful(target), target, 'returns the target object'); + + assert.ok(Obj.isObject(target), 'the target is still an object'); + assert.ok(Obj.isPlain(target.state), 'the target has a state'); + assert.strictEqual(Object.keys(target.state).length, 0, 'the target state is empty by default'); + assert.strictEqual(typeof target.setState, 'function', 'the target has a setState method'); +}); + +QUnit.test('stateful() with defaults', function(assert) { + const target = stateful({}, {foo: 'bar'}); + + assert.strictEqual(target.state.foo, 'bar', 'the default properties are added to the state'); +}); + +QUnit.test('setState()', function(assert) { + const target = stateful(new EventTarget(), {foo: 'bar', abc: 'xyz'}); + const spy = sinon.spy(); + + target.on('statechanged', spy); + + const next = {foo: null, boo: 123}; + const changes = target.setState(next); + + assert.deepEqual(changes, { + foo: {from: 'bar', to: null}, + boo: {from: undefined, to: 123} + }, 'setState returns changes, a plain object'); + + assert.deepEqual(target.state, { + abc: 'xyz', + foo: null, + boo: 123 + }, 'the state was updated as expected'); + + assert.ok(spy.called, 'the "statechanged" event occurred'); + + const event = spy.firstCall.args[0]; + + assert.strictEqual(event.type, 'statechanged'); + assert.strictEqual(event.changes, changes, 'the changes object is sent along with the event'); +}); + +QUnit.test('setState() without changes', function(assert) { + const target = stateful(new EventTarget(), {foo: 'bar'}); + const spy = sinon.spy(); + + target.on('statechanged', spy); + + const changes = target.setState({foo: 'bar'}); + + assert.strictEqual(changes, undefined, 'no changes were returned'); + assert.strictEqual(spy.callCount, 0, 'no event was triggered'); +}); From 4161b9aa23f1dff5cdadf2a593e6c32f9d659930 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 25 Oct 2016 16:06:00 -0400 Subject: [PATCH 06/48] Coordinate and test Plugin static methods --- src/js/plugin.js | 79 ++++++++++++++++++++------ src/js/video.js | 65 +++++++++++++++------ test/unit/plugin.test.js | 119 ++++++++++++++++++++++++++++++++++++++- test/unit/video.test.js | 18 +++--- 4 files changed, 235 insertions(+), 46 deletions(-) diff --git a/src/js/plugin.js b/src/js/plugin.js index 2245f19059..a77d147666 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -90,17 +90,6 @@ class Plugin extends EventTarget { this.trigger('dispose', this, props); } - /** - * Gets the version of the plugin, if known. - * - * This will look for a `VERSION` property on the plugin subclass. - * - * @return {String} [description] - */ - static version() { - return this.VERSION || ''; - } - /** * Determines if a plugin is a "basic" plugin (i.e. not a sub-class of `Plugin`). * @@ -117,15 +106,15 @@ class Plugin extends EventTarget { } /** - * Register a video.js plugin + * Register a Video.js plugin * * @param {String} name * @param {Function} plugin - * A sub-class of `Plugin` or an anonymous function for simple plugins. + * A sub-class of `Plugin` or an anonymous function for basic plugins. * @return {Function} */ static registerPlugin(name, plugin) { - if (pluginCache[name] || Player.prototype[name]) { + if (typeof name !== 'string' || pluginCache[name] || Player.prototype[name]) { throw new Error(`illegal plugin name, "${name}"`); } @@ -145,12 +134,66 @@ class Plugin extends EventTarget { } /** - * Gets an object containing all plugins. + * Register multiple plugins via an object where the keys are plugin names + * and the values are sub-classes of `Plugin` or anonymous functions for + * basic plugins. + * + * @param {Object} plugins + * @return {Object} + * An object containing plugins that were added. + */ + static registerPlugins(plugins) { + Obj.each(plugins, (value, key) => this.registerPlugin(key, value)); + return plugins; + } + + /** + * De-register a Video.js plugin. + * + * This is mostly used for testing, but may potentially be useful in advanced + * player workflows. + * + * @param {String} name + */ + static deregisterPlugin(name) { + if (pluginCache.hasOwnProperty(name)) { + delete pluginCache[name]; + delete Player.prototype[name]; + } + } + + /** + * De-register multiple Video.js plugins. + * + * @param {Array} [names] + * If provided, should be an array of plugin names. Defaults to _all_ + * plugin names. + */ + static deregisterPlugins(names = Object.keys(pluginCache)) { + names.forEach(name => this.deregisterPlugin(name)); + } + + /** + * Gets an object containing multiple Video.js plugins. * + * @param {Array} [names] + * If provided, should be an array of plugin names. Defaults to _all_ + * plugin names. * @return {Object} */ - static getPlugins() { - return Obj.assign({}, pluginCache); + static getPlugins(names = Object.keys(pluginCache)) { + let result; + + names.forEach(name => { + const plugin = this.getPlugin(name); + + if (plugin) { + result = result || {}; + result[name] = plugin; + } + }); + + return result; } /** @@ -172,7 +215,7 @@ class Plugin extends EventTarget { static getPluginVersion(name) { const plugin = Plugin.getPlugin(name); - return plugin && plugin.version() || ''; + return plugin && plugin.VERSION || ''; } } diff --git a/src/js/video.js b/src/js/video.js index 9daa776d8d..78b177f4df 100644 --- a/src/js/video.js +++ b/src/js/video.js @@ -347,34 +347,67 @@ videojs.mergeOptions = mergeOptions; videojs.bind = Fn.bind; /** - * Create a Video.js player plugin. - * Plugins are only initialized when options for the plugin are included - * in the player options, or the plugin function on the player instance is - * called. + * Register a Video.js plugin * * @borrows plugin:registerPlugin as videojs.registerPlugin * @param {String} name The plugin name - * @param {Function} fn The plugin function that will be called with options + * @param {Function} plugin A sub-class of `Plugin` or an anonymous function for basic plugins. * @mixes videojs * @method registerPlugin */ -videojs.registerPlugin = Fn.bind(Plugin, Plugin.registerPlugin); +videojs.registerPlugin = (name, plugin) => Plugin.registerPlugin(name, plugin); /** - * @deprecated videojs.plugin() is deprecated; use videojs.registerPlugin() instead - * @param {String} name The plugin name - * @param {Function} fn The plugin function that will be called with options - * @mixes videojs - * @method plugin + * Register multiple Video.js plugins via an object where the keys are + * plugin names and the values are sub-classes of `Plugin` or anonymous + * functions for basic plugins. + * + * @param {Object} plugins + * @return {Object} + * An object containing plugins that were added. */ -videojs.plugin = (...args) => { +videojs.registerPlugins = (plugins) => Plugin.registerPlugins(plugins); + +/** + * Deprecated method to register a plugin with Video.js + * + * @deprecated + * videojs.plugin() is deprecated; use videojs.registerPlugin() instead + * + * @param {String} name + * The plugin name + * + * @param {Plugin|Function} plugin + * The plugin sub-class or function + */ +videojs.plugin = (name, plugin) => { log.warn('videojs.plugin() is deprecated; use videojs.registerPlugin() instead'); - return videojs.registerPlugin(...args); + return Plugin.registerPlugin(name, plugin); }; -videojs.getPlugins = Fn.bind(Plugin, Plugin.getPlugins); -videojs.getPlugin = Fn.bind(Plugin, Plugin.getPlugin); -videojs.getPluginVersion = Fn.bind(Plugin, Plugin.getPluginVersion); +/** + * Get an object containing all available plugins. + * + * @return {Object} + */ +videojs.getPlugins = () => Plugin.getPlugins(); + +/** + * Get a single plugin by name. + * + * @param {String} name + * @return {Plugin|Function} + */ +videojs.getPlugin = (name) => Plugin.getPlugin(name); + +/** + * Get the version - if known - of a plugin by name. + * + * @param {String} name + * @return {String} + * If the version is not known, returns an empty string. + */ +videojs.getPluginVersion = (name) => Plugin.getPluginVersion(name); /** * Adding languages so that they're available to all players. diff --git a/test/unit/plugin.test.js b/test/unit/plugin.test.js index c2ab63c7ad..0d87c6ca1e 100644 --- a/test/unit/plugin.test.js +++ b/test/unit/plugin.test.js @@ -1,3 +1,120 @@ /* eslint-env qunit */ +import Player from '../../src/js/player'; +import Plugin from '../../src/js/plugin'; -QUnit.module('Plugin'); +class MockPlugin extends Plugin {} + +MockPlugin.VERSION = 'v1.2.3'; + +const basicPlugin = () => {}; + +QUnit.module('Plugin', { + + beforeEach() { + Plugin.registerPlugins({ + basicPlugin, + mockPlugin: MockPlugin + }); + }, + + afterEach() { + Plugin.deregisterPlugins(); + } +}); + +QUnit.test('registerPlugin()', function(assert) { + const foo = () => {}; + + assert.strictEqual(Plugin.registerPlugin('foo', foo), foo); + assert.strictEqual(Plugin.getPlugin('foo'), foo); + assert.strictEqual(typeof Player.prototype.foo, 'function'); + assert.notStrictEqual(Player.prototype.foo, foo, 'the function on the player prototype is a wrapper'); +}); + +QUnit.test('registerPlugin() illegal arguments', function(assert) { + assert.throws( + () => Plugin.registerPlugin(), + new Error('illegal plugin name, "undefined"'), + 'plugins must have a name' + ); + + assert.throws( + () => Plugin.registerPlugin('play'), + new Error('illegal plugin name, "play"'), + 'plugins cannot share a name with an existing player method' + ); + + assert.throws( + () => Plugin.registerPlugin('foo'), + new Error('illegal plugin for "foo", must be a function, was undefined'), + 'plugins require both arguments' + ); + + assert.throws( + () => Plugin.registerPlugin('foo', {}), + new Error('illegal plugin for "foo", must be a function, was object'), + 'plugins must be functions' + ); +}); + +QUnit.test('registerPlugins()', function(assert) { + const foo = () => {}; + const bar = () => {}; + + Plugin.registerPlugins({bar, foo}); + + assert.strictEqual(Plugin.getPlugin('foo'), foo); + assert.strictEqual(Plugin.getPlugin('bar'), bar); +}); + +QUnit.test('getPlugin()', function(assert) { + assert.ok(Plugin.getPlugin('basicPlugin')); + assert.ok(Plugin.getPlugin('mockPlugin')); + assert.strictEqual(Plugin.getPlugin(), undefined); + assert.strictEqual(Plugin.getPlugin('nonExistent'), undefined); + assert.strictEqual(Plugin.getPlugin(123), undefined); +}); + +QUnit.test('getPluginVersion()', function(assert) { + assert.strictEqual(Plugin.getPluginVersion('basicPlugin'), '', 'the basic plugin has no version'); + assert.strictEqual(Plugin.getPluginVersion('mockPlugin'), 'v1.2.3'); +}); + +QUnit.test('getPlugins()', function(assert) { + assert.strictEqual(Object.keys(Plugin.getPlugins()).length, 2); + assert.strictEqual(Plugin.getPlugins().basicPlugin, basicPlugin); + assert.strictEqual(Plugin.getPlugins().mockPlugin, MockPlugin); + assert.strictEqual(Object.keys(Plugin.getPlugins(['basicPlugin'])).length, 1); + assert.strictEqual(Plugin.getPlugins(['basicPlugin']).basicPlugin, basicPlugin); +}); + +QUnit.test('deregisterPlugin()', function(assert) { + const foo = () => {}; + + Plugin.registerPlugin('foo', foo); + Plugin.deregisterPlugin('foo'); + + assert.strictEqual(Player.prototype.foo, undefined); + assert.strictEqual(Plugin.getPlugin('foo'), undefined); +}); + +QUnit.test('deregisterPlugins()', function(assert) { + const foo = () => {}; + const bar = () => {}; + + Plugin.registerPlugins({bar, foo}); + Plugin.deregisterPlugins(['bar']); + + assert.strictEqual(Plugin.getPlugin('foo'), foo); + assert.strictEqual(Plugin.getPlugin('bar'), undefined); + + Plugin.deregisterPlugins(); + assert.strictEqual(Plugin.getPlugin('foo'), undefined); +}); + +QUnit.test('isBasic()', function(assert) { + assert.ok(Plugin.isBasic(basicPlugin)); + assert.ok(Plugin.isBasic('basicPlugin')); + assert.ok(!Plugin.isBasic(MockPlugin)); + assert.ok(!Plugin.isBasic('mockPlugin')); +}); diff --git a/test/unit/video.test.js b/test/unit/video.test.js index 9c2ac9d07f..899a9baefa 100644 --- a/test/unit/video.test.js +++ b/test/unit/video.test.js @@ -1,6 +1,5 @@ /* eslint-env qunit */ import videojs from '../../src/js/video.js'; -import TestHelpers from './test-helpers.js'; import * as Dom from '../../src/js/utils/dom.js'; import log from '../../src/js/utils/log.js'; import document from 'global/document'; @@ -165,16 +164,13 @@ QUnit.test('should add the value to the languages object with lower case lang co 'should also match'); }); -QUnit.test('should expose plugin registry function', function(assert) { - assert.ok(videojs.registerPlugin, 'should exist'); - - videojs.registerPlugin('foo', function() {}); - - const player = TestHelpers.makePlayer(); - - assert.ok(player.foo, 'should exist'); - assert.ok(player.plugin('foo'), 'should return a Plugin object'); - player.dispose(); +QUnit.test('should expose plugin functions', function(assert) { + assert.strictEqual(typeof videojs.registerPlugin, 'function'); + assert.strictEqual(typeof videojs.registerPlugins, 'function'); + assert.strictEqual(typeof videojs.plugin, 'function'); + assert.strictEqual(typeof videojs.getPlugins, 'function'); + assert.strictEqual(typeof videojs.getPlugin, 'function'); + assert.strictEqual(typeof videojs.getPluginVersion, 'function'); }); QUnit.test('should expose options and players properties for backward-compatibility', function(assert) { From 2df27324f63f6d4b9d965462e4d2e734b072d01f Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 25 Oct 2016 17:09:49 -0400 Subject: [PATCH 07/48] Add basic plugin test --- src/js/plugin.js | 20 ++++++-- test/unit/plugin.test.js | 102 +++++++++++++++++++++++++++++++++------ 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/src/js/plugin.js b/src/js/plugin.js index a77d147666..c5bb0cc3c4 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -23,11 +23,16 @@ const pluginCache = {}; * @return {Function} */ const createBasicPlugin = (name, plugin) => function() { - const result = plugin.apply(this, arguments); + const instance = plugin.apply(this, arguments); this.activePlugins_[name] = true; - this.trigger('pluginsetup', name, result); - return result; + + this.trigger({ + type: 'pluginsetup', + pluginSetupMeta: {name, plugin, instance} + }); + + return instance; }; /** @@ -68,7 +73,14 @@ class Plugin extends EventTarget { stateful(this, this.constructor.defaultState); player.on('dispose', Fn.bind(this, this.dispose)); player.activePlugins_[this.name] = true; - player.trigger('pluginsetup', this.name, this); + player.trigger({ + type: 'pluginsetup', + pluginSetupMeta: { + name: this.name, + plugin: this.constructor, + instance: this + } + }); } /** diff --git a/test/unit/plugin.test.js b/test/unit/plugin.test.js index 0d87c6ca1e..b95cb372c7 100644 --- a/test/unit/plugin.test.js +++ b/test/unit/plugin.test.js @@ -1,19 +1,21 @@ /* eslint-env qunit */ +import sinon from 'sinon'; import Player from '../../src/js/player'; import Plugin from '../../src/js/plugin'; +import TestHelpers from './test-helpers'; class MockPlugin extends Plugin {} MockPlugin.VERSION = 'v1.2.3'; -const basicPlugin = () => {}; - -QUnit.module('Plugin', { +QUnit.module('Plugin: static methods', { beforeEach() { + this.basic = () => {}; + Plugin.registerPlugins({ - basicPlugin, - mockPlugin: MockPlugin + basic: this.basic, + mock: MockPlugin }); }, @@ -68,24 +70,24 @@ QUnit.test('registerPlugins()', function(assert) { }); QUnit.test('getPlugin()', function(assert) { - assert.ok(Plugin.getPlugin('basicPlugin')); - assert.ok(Plugin.getPlugin('mockPlugin')); + assert.ok(Plugin.getPlugin('basic')); + assert.ok(Plugin.getPlugin('mock')); assert.strictEqual(Plugin.getPlugin(), undefined); assert.strictEqual(Plugin.getPlugin('nonExistent'), undefined); assert.strictEqual(Plugin.getPlugin(123), undefined); }); QUnit.test('getPluginVersion()', function(assert) { - assert.strictEqual(Plugin.getPluginVersion('basicPlugin'), '', 'the basic plugin has no version'); - assert.strictEqual(Plugin.getPluginVersion('mockPlugin'), 'v1.2.3'); + assert.strictEqual(Plugin.getPluginVersion('basic'), '', 'the basic plugin has no version'); + assert.strictEqual(Plugin.getPluginVersion('mock'), 'v1.2.3'); }); QUnit.test('getPlugins()', function(assert) { assert.strictEqual(Object.keys(Plugin.getPlugins()).length, 2); - assert.strictEqual(Plugin.getPlugins().basicPlugin, basicPlugin); - assert.strictEqual(Plugin.getPlugins().mockPlugin, MockPlugin); - assert.strictEqual(Object.keys(Plugin.getPlugins(['basicPlugin'])).length, 1); - assert.strictEqual(Plugin.getPlugins(['basicPlugin']).basicPlugin, basicPlugin); + assert.strictEqual(Plugin.getPlugins().basic, this.basic); + assert.strictEqual(Plugin.getPlugins().mock, MockPlugin); + assert.strictEqual(Object.keys(Plugin.getPlugins(['basic'])).length, 1); + assert.strictEqual(Plugin.getPlugins(['basic']).basic, this.basic); }); QUnit.test('deregisterPlugin()', function(assert) { @@ -113,8 +115,76 @@ QUnit.test('deregisterPlugins()', function(assert) { }); QUnit.test('isBasic()', function(assert) { - assert.ok(Plugin.isBasic(basicPlugin)); - assert.ok(Plugin.isBasic('basicPlugin')); + assert.ok(Plugin.isBasic(this.basic)); + assert.ok(Plugin.isBasic('basic')); assert.ok(!Plugin.isBasic(MockPlugin)); - assert.ok(!Plugin.isBasic('mockPlugin')); + assert.ok(!Plugin.isBasic('mock')); +}); + +QUnit.module('Plugin: basic', { + + beforeEach() { + this.basic = sinon.spy(); + this.player = TestHelpers.makePlayer(); + + Plugin.registerPlugin('basic', this.basic); + }, + + afterEach() { + this.player.dispose(); + Plugin.deregisterPlugins(); + } +}); + +QUnit.test('pre-setup interface', function(assert) { + assert.strictEqual(typeof this.player.basic, 'function', 'basic plugins are a function on a player'); + assert.notStrictEqual(this.player.basic, this.basic, 'basic plugins are wrapped'); + assert.strictEqual(this.player.basic.dispose, undefined, 'unlike class-based plugins, basic plugins do not have a dispose method'); + assert.ok(!this.player.usingPlugin('basic')); +}); + +QUnit.test('setup', function(assert) { + this.player.basic({foo: 'bar'}, 123); + + assert.strictEqual(this.basic.callCount, 1, 'the plugin was called once'); + assert.strictEqual(this.basic.firstCall.thisValue, this.player, 'the plugin `this` value was the player'); + assert.deepEqual(this.basic.firstCall.args, [{foo: 'bar'}, 123], 'the plugin had the correct arguments'); + assert.ok(this.player.usingPlugin('basic'), 'the player now recognizes that the plugin was set up'); +}); + +QUnit.test('"pluginsetup" event', function(assert) { + const setupSpy = sinon.spy(); + + this.player.on('pluginsetup', setupSpy); + const instance = this.player.basic(); + + assert.strictEqual(setupSpy.callCount, 1, 'the "pluginsetup" event was triggered'); + + const event = setupSpy.firstCall.args[0]; + + assert.strictEqual(event.type, 'pluginsetup', 'the event has the correct type'); + assert.deepEqual(event.pluginSetupMeta, { + name: 'basic', + instance, + plugin: this.basic + }, 'the event `pluginSetupMeta` object is correct'); +}); + +QUnit.module('Plugin: class-based', { + + beforeEach() { + this.player = TestHelpers.makePlayer(); + Plugin.registerPlugin('mock', MockPlugin); + }, + + afterEach() { + this.player.dispose(); + Plugin.deregisterPlugins(); + } +}); + +QUnit.test('pre-activation interface', function(assert) { + assert.strictEqual(typeof this.player.mock, 'function', 'plugins are a factory function on a player'); + assert.strictEqual(this.player.mock.dispose, undefined, 'class-based plugins are not populated on a player until the factory method creates them'); + assert.ok(!this.player.usingPlugin('mock')); }); From dbdcf6938edf145db2fdafcf9c3ca817e355bfb1 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Wed, 26 Oct 2016 12:41:34 -0400 Subject: [PATCH 08/48] Fixes for events on plugins --- src/js/decorators/eventful.js | 31 ++++++++ src/js/{mixins => decorators}/stateful.js | 4 +- src/js/plugin.js | 70 +++++++++++++------ test/unit/decorators/eventful.test.js | 46 ++++++++++++ .../{mixins => decorators}/stateful.test.js | 6 +- test/unit/plugin.test.js | 5 +- 6 files changed, 135 insertions(+), 27 deletions(-) create mode 100644 src/js/decorators/eventful.js rename src/js/{mixins => decorators}/stateful.js (96%) create mode 100644 test/unit/decorators/eventful.test.js rename test/unit/{mixins => decorators}/stateful.test.js (91%) diff --git a/src/js/decorators/eventful.js b/src/js/decorators/eventful.js new file mode 100644 index 0000000000..7c6464ae02 --- /dev/null +++ b/src/js/decorators/eventful.js @@ -0,0 +1,31 @@ +/** + * @file decorators/eventful.js + */ +import * as Events from '../utils/events'; + +/** + * Makes an object "eventful" - granting it methods from the `Events` utility. + * + * By default, this adds the `off`, `on`, `one`, and `trigger` methods, but + * exclusions can optionally be made. + * + * @param {Object} target + * The object to which to add event methods. + * + * @param {Array} [exclusions=[]] + * An array of methods to exclude from addition to the object. + * + * @return {Object} + * The target object. + */ +function eventful(target, exclusions = []) { + ['off', 'on', 'one', 'trigger'] + .filter(name => exclusions.indexOf(name) === -1) + .forEach(name => { + target[name] = Events[name].bind(target, target); + }); + + return target; +} + +export default eventful; diff --git a/src/js/mixins/stateful.js b/src/js/decorators/stateful.js similarity index 96% rename from src/js/mixins/stateful.js rename to src/js/decorators/stateful.js index 4a98e9ff6e..fee0d00180 100644 --- a/src/js/mixins/stateful.js +++ b/src/js/decorators/stateful.js @@ -1,5 +1,5 @@ /** - * @file mixins/stateful.js + * @file decorators/stateful.js */ import * as Fn from '../utils/fn'; import log from '../utils/log'; @@ -62,7 +62,7 @@ const setState = function(next) { * arbitrary keys/values and a `setState` method which will trigger state * changes if the object has a `trigger` method. * - * @param {Object} object + * @param {Object} target * @return {Object} * Returns the `object`. */ diff --git a/src/js/plugin.js b/src/js/plugin.js index c5bb0cc3c4..6bbf27a71e 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -1,7 +1,9 @@ /** * @file plugin.js */ -import stateful from './mixins/stateful'; +import eventful from './decorators/eventful'; +import stateful from './decorators/stateful'; +import * as Events from './utils/events'; import * as Fn from './utils/fn'; import * as Obj from './utils/obj'; import EventTarget from './event-target'; @@ -27,11 +29,11 @@ const createBasicPlugin = (name, plugin) => function() { this.activePlugins_[name] = true; - this.trigger({ - type: 'pluginsetup', - pluginSetupMeta: {name, plugin, instance} - }); - + // We trigger the "pluginsetup" event regardless, but we want the hash to + // be consistent. The only odd thing here is the `instance` is the value + // returned by the `plugin` function (instead of, necessarily, an instance + // of it). + this.trigger('pluginsetup', {name, plugin, instance}); return instance; }; @@ -70,17 +72,48 @@ class Plugin extends EventTarget { constructor(player) { super(); this.player = player; + + eventful(this, ['trigger']); stateful(this, this.constructor.defaultState); + player.on('dispose', Fn.bind(this, this.dispose)); player.activePlugins_[this.name] = true; - player.trigger({ - type: 'pluginsetup', - pluginSetupMeta: { - name: this.name, - plugin: this.constructor, - instance: this - } - }); + player.trigger('pluginsetup', this.getEventHash_()); + } + + /** + * Each event triggered by plugins includes a hash of additional data with + * conventional properties. + * + * This returns that object or mutates an existing hash. + * + * @param {Object} [hash={}] + * @return {Object} + * - `instance`: The plugin instance on which the event is fired. + * - `name`: The name of the plugin. + * - `plugin`: The plugin class/constructor. + */ + getEventHash_(hash = {}) { + hash.name = this.name; + hash.plugin = this.constructor; + hash.instance = this; + return hash; + } + + /** + * Triggers an event on the plugin object. + * + * @param {Event|Object|String} event + * A string (the type) or an event object with a type attribute. + * + * @param {Object} [hash={}] + * Additional data hash to pass along with the event. + * + * @return {Boolean} + * Whether or not default was prevented. + */ + trigger(event, hash = {}) { + return Events.trigger(this, event, this.getEventHash_(hash)); } /** @@ -90,16 +123,13 @@ class Plugin extends EventTarget { * it's probably best to subscribe to one of the disposal events. */ dispose() { - const {name, player, state} = this; - const props = {name, player, state}; - this.off(); - player.activePlugins_[name] = false; + this.player.activePlugins_[this.name] = false; + this.trigger('dispose'); // Eliminate possible sources of leaking memory. - this.player[name] = createPluginFactory(name, pluginCache[name]); + this.player[this.name] = createPluginFactory(this.name, pluginCache[this.name]); this.player = this.state = null; - this.trigger('dispose', this, props); } /** diff --git a/test/unit/decorators/eventful.test.js b/test/unit/decorators/eventful.test.js new file mode 100644 index 0000000000..3b819e0798 --- /dev/null +++ b/test/unit/decorators/eventful.test.js @@ -0,0 +1,46 @@ +/* eslint-env qunit */ +import sinon from 'sinon'; +import eventful from '../../../src/js/decorators/eventful'; +import * as Obj from '../../../src/js/utils/obj'; + +QUnit.module('Decorators: Eventful'); + +QUnit.test('eventful() mutations', function(assert) { + const target = {}; + + assert.strictEqual(typeof eventful, 'function', 'the decorator is a function'); + assert.strictEqual(eventful(target), target, 'returns the target object'); + + assert.ok(Obj.isObject(target), 'the target is still an object'); + assert.strictEqual(typeof target.off, 'function', 'the target has an off method'); + assert.strictEqual(typeof target.on, 'function', 'the target has an on method'); + assert.strictEqual(typeof target.one, 'function', 'the target has a one method'); + assert.strictEqual(typeof target.trigger, 'function', 'the target has a trigger method'); +}); + +QUnit.test('eventful() with exclusions', function(assert) { + const target = eventful({}, ['one']); + + assert.strictEqual(typeof target.off, 'function', 'the target has an off method'); + assert.strictEqual(typeof target.on, 'function', 'the target has an on method'); + assert.notStrictEqual(typeof target.one, 'function', 'the target DOES NOT have a one method'); + assert.strictEqual(typeof target.trigger, 'function', 'the target has a trigger method'); +}); + +QUnit.test('supports basic event handling (not complete functionality tests)', function(assert) { + const spy = sinon.spy(); + const target = eventful({}); + + target.on('foo', spy); + target.trigger('foo', {x: 1}); + target.off('foo'); + target.trigger('foo'); + + assert.strictEqual(spy.callCount, 1, 'the spy was called once'); + + const event = spy.firstCall.args[0]; + const hash = spy.firstCall.args[1]; + + assert.strictEqual(event.type, 'foo', 'the spy saw a "foo" event'); + assert.strictEqual(hash.x, 1, 'the "foo" event included an extra hash'); +}); diff --git a/test/unit/mixins/stateful.test.js b/test/unit/decorators/stateful.test.js similarity index 91% rename from test/unit/mixins/stateful.test.js rename to test/unit/decorators/stateful.test.js index 2ec7f8f7fe..4d453fec52 100644 --- a/test/unit/mixins/stateful.test.js +++ b/test/unit/decorators/stateful.test.js @@ -1,15 +1,15 @@ /* eslint-env qunit */ import sinon from 'sinon'; import EventTarget from '../../../src/js/event-target'; -import stateful from '../../../src/js/mixins/stateful'; +import stateful from '../../../src/js/decorators/stateful'; import * as Obj from '../../../src/js/utils/obj'; -QUnit.module('Mixins: Stateful'); +QUnit.module('Decorators: Stateful'); QUnit.test('stateful() mutations', function(assert) { const target = {}; - assert.strictEqual(typeof stateful, 'function', 'the mixin is a function'); + assert.strictEqual(typeof stateful, 'function', 'the decorator is a function'); assert.strictEqual(stateful(target), target, 'returns the target object'); assert.ok(Obj.isObject(target), 'the target is still an object'); diff --git a/test/unit/plugin.test.js b/test/unit/plugin.test.js index b95cb372c7..56323f1617 100644 --- a/test/unit/plugin.test.js +++ b/test/unit/plugin.test.js @@ -161,13 +161,14 @@ QUnit.test('"pluginsetup" event', function(assert) { assert.strictEqual(setupSpy.callCount, 1, 'the "pluginsetup" event was triggered'); const event = setupSpy.firstCall.args[0]; + const hash = setupSpy.firstCall.args[1]; assert.strictEqual(event.type, 'pluginsetup', 'the event has the correct type'); - assert.deepEqual(event.pluginSetupMeta, { + assert.deepEqual(hash, { name: 'basic', instance, plugin: this.basic - }, 'the event `pluginSetupMeta` object is correct'); + }, 'the event hash object is correct'); }); QUnit.module('Plugin: class-based', { From 7763bbdf3510327437a30284811e312fe8a6a44e Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Wed, 26 Oct 2016 13:52:20 -0400 Subject: [PATCH 09/48] Decorators -> Mixins --- src/js/{decorators => mixins}/eventful.js | 2 +- src/js/{decorators => mixins}/stateful.js | 2 +- src/js/plugin.js | 4 ++-- test/unit/{decorators => mixins}/eventful.test.js | 6 +++--- test/unit/{decorators => mixins}/stateful.test.js | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) rename src/js/{decorators => mixins}/eventful.js (95%) rename src/js/{decorators => mixins}/stateful.js (98%) rename test/unit/{decorators => mixins}/eventful.test.js (90%) rename test/unit/{decorators => mixins}/stateful.test.js (91%) diff --git a/src/js/decorators/eventful.js b/src/js/mixins/eventful.js similarity index 95% rename from src/js/decorators/eventful.js rename to src/js/mixins/eventful.js index 7c6464ae02..c936a53835 100644 --- a/src/js/decorators/eventful.js +++ b/src/js/mixins/eventful.js @@ -1,5 +1,5 @@ /** - * @file decorators/eventful.js + * @file mixins/eventful.js */ import * as Events from '../utils/events'; diff --git a/src/js/decorators/stateful.js b/src/js/mixins/stateful.js similarity index 98% rename from src/js/decorators/stateful.js rename to src/js/mixins/stateful.js index fee0d00180..89f74bf53b 100644 --- a/src/js/decorators/stateful.js +++ b/src/js/mixins/stateful.js @@ -1,5 +1,5 @@ /** - * @file decorators/stateful.js + * @file mixins/stateful.js */ import * as Fn from '../utils/fn'; import log from '../utils/log'; diff --git a/src/js/plugin.js b/src/js/plugin.js index 6bbf27a71e..a1228c39ba 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -1,8 +1,8 @@ /** * @file plugin.js */ -import eventful from './decorators/eventful'; -import stateful from './decorators/stateful'; +import eventful from './mixins/eventful'; +import stateful from './mixins/stateful'; import * as Events from './utils/events'; import * as Fn from './utils/fn'; import * as Obj from './utils/obj'; diff --git a/test/unit/decorators/eventful.test.js b/test/unit/mixins/eventful.test.js similarity index 90% rename from test/unit/decorators/eventful.test.js rename to test/unit/mixins/eventful.test.js index 3b819e0798..e900ab0b9c 100644 --- a/test/unit/decorators/eventful.test.js +++ b/test/unit/mixins/eventful.test.js @@ -1,14 +1,14 @@ /* eslint-env qunit */ import sinon from 'sinon'; -import eventful from '../../../src/js/decorators/eventful'; +import eventful from '../../../src/js/mixins/eventful'; import * as Obj from '../../../src/js/utils/obj'; -QUnit.module('Decorators: Eventful'); +QUnit.module('mixins: Eventful'); QUnit.test('eventful() mutations', function(assert) { const target = {}; - assert.strictEqual(typeof eventful, 'function', 'the decorator is a function'); + assert.strictEqual(typeof eventful, 'function', 'the mixin is a function'); assert.strictEqual(eventful(target), target, 'returns the target object'); assert.ok(Obj.isObject(target), 'the target is still an object'); diff --git a/test/unit/decorators/stateful.test.js b/test/unit/mixins/stateful.test.js similarity index 91% rename from test/unit/decorators/stateful.test.js rename to test/unit/mixins/stateful.test.js index 4d453fec52..99e6092c47 100644 --- a/test/unit/decorators/stateful.test.js +++ b/test/unit/mixins/stateful.test.js @@ -1,15 +1,15 @@ /* eslint-env qunit */ import sinon from 'sinon'; import EventTarget from '../../../src/js/event-target'; -import stateful from '../../../src/js/decorators/stateful'; +import stateful from '../../../src/js/mixins/stateful'; import * as Obj from '../../../src/js/utils/obj'; -QUnit.module('Decorators: Stateful'); +QUnit.module('mixins: Stateful'); QUnit.test('stateful() mutations', function(assert) { const target = {}; - assert.strictEqual(typeof stateful, 'function', 'the decorator is a function'); + assert.strictEqual(typeof stateful, 'function', 'the mixin is a function'); assert.strictEqual(stateful(target), target, 'returns the target object'); assert.ok(Obj.isObject(target), 'the target is still an object'); From a386a6db9db7cac87e25734883575f2ba1b193f7 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Wed, 26 Oct 2016 14:03:13 -0400 Subject: [PATCH 10/48] Split plugin tests into multiple files --- test/unit/plugin-basic.test.js | 84 ++++++++++++++++++ test/unit/plugin-class.test.js | 38 +++++++++ .../{plugin.test.js => plugin-static.test.js} | 85 +++---------------- 3 files changed, 134 insertions(+), 73 deletions(-) create mode 100644 test/unit/plugin-basic.test.js create mode 100644 test/unit/plugin-class.test.js rename test/unit/{plugin.test.js => plugin-static.test.js} (56%) diff --git a/test/unit/plugin-basic.test.js b/test/unit/plugin-basic.test.js new file mode 100644 index 0000000000..10d925190f --- /dev/null +++ b/test/unit/plugin-basic.test.js @@ -0,0 +1,84 @@ +/* eslint-env qunit */ +import sinon from 'sinon'; +import Plugin from '../../src/js/plugin'; +import TestHelpers from './test-helpers'; + +QUnit.module('Plugin: basic', { + + beforeEach() { + this.basic = sinon.spy(); + this.player = TestHelpers.makePlayer(); + + Plugin.registerPlugin('basic', this.basic); + }, + + afterEach() { + this.player.dispose(); + Plugin.deregisterPlugins(); + } +}); + +QUnit.test('pre-setup interface', function(assert) { + assert.strictEqual( + typeof this.player.basic, + 'function', + 'basic plugins are a function on a player' + ); + + assert.notStrictEqual( + this.player.basic, + this.basic, + 'basic plugins are wrapped' + ); + + assert.strictEqual( + this.player.basic.dispose, + undefined, + 'unlike class-based plugins, basic plugins do not have a dispose method' + ); + + assert.ok(!this.player.usingPlugin('basic')); +}); + +QUnit.test('setup', function(assert) { + this.player.basic({foo: 'bar'}, 123); + + assert.strictEqual(this.basic.callCount, 1, 'the plugin was called once'); + + assert.strictEqual( + this.basic.firstCall.thisValue, + this.player, + 'the plugin `this` value was the player' + ); + + assert.deepEqual( + this.basic.firstCall.args, + [{foo: 'bar'}, 123], + 'the plugin had the correct arguments' + ); + + assert.ok( + this.player.usingPlugin('basic'), + 'the player now recognizes that the plugin was set up' + ); + +}); + +QUnit.test('"pluginsetup" event', function(assert) { + const setupSpy = sinon.spy(); + + this.player.on('pluginsetup', setupSpy); + const instance = this.player.basic(); + + assert.strictEqual(setupSpy.callCount, 1, 'the "pluginsetup" event was triggered'); + + const event = setupSpy.firstCall.args[0]; + const hash = setupSpy.firstCall.args[1]; + + assert.strictEqual(event.type, 'pluginsetup', 'the event has the correct type'); + assert.deepEqual(hash, { + name: 'basic', + instance, + plugin: this.basic + }, 'the event hash object is correct'); +}); diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js new file mode 100644 index 0000000000..0f81620dec --- /dev/null +++ b/test/unit/plugin-class.test.js @@ -0,0 +1,38 @@ +/* eslint-env qunit */ +import sinon from 'sinon'; +import Plugin from '../../src/js/plugin'; +import TestHelpers from './test-helpers'; + +class MockPlugin extends Plugin {} + +MockPlugin.VERSION = 'v1.2.3'; + +QUnit.module('Plugin: class-based', { + + beforeEach() { + this.derp = sinon.spy(); + this.player = TestHelpers.makePlayer(); + Plugin.registerPlugin('mock', MockPlugin); + }, + + afterEach() { + this.player.dispose(); + Plugin.deregisterPlugins(); + } +}); + +QUnit.test('pre-activation interface', function(assert) { + assert.strictEqual( + typeof this.player.mock, + 'function', + 'plugins are a factory function on a player' + ); + + assert.strictEqual( + this.player.mock.dispose, + undefined, + 'class-based plugins are not populated on a player until the factory method creates them' + ); + + assert.ok(!this.player.usingPlugin('mock')); +}); diff --git a/test/unit/plugin.test.js b/test/unit/plugin-static.test.js similarity index 56% rename from test/unit/plugin.test.js rename to test/unit/plugin-static.test.js index 56323f1617..56111e9560 100644 --- a/test/unit/plugin.test.js +++ b/test/unit/plugin-static.test.js @@ -1,8 +1,6 @@ /* eslint-env qunit */ -import sinon from 'sinon'; import Player from '../../src/js/player'; import Plugin from '../../src/js/plugin'; -import TestHelpers from './test-helpers'; class MockPlugin extends Plugin {} @@ -30,7 +28,12 @@ QUnit.test('registerPlugin()', function(assert) { assert.strictEqual(Plugin.registerPlugin('foo', foo), foo); assert.strictEqual(Plugin.getPlugin('foo'), foo); assert.strictEqual(typeof Player.prototype.foo, 'function'); - assert.notStrictEqual(Player.prototype.foo, foo, 'the function on the player prototype is a wrapper'); + + assert.notStrictEqual( + Player.prototype.foo, + foo, + 'the function on the player prototype is a wrapper' + ); }); QUnit.test('registerPlugin() illegal arguments', function(assert) { @@ -78,7 +81,12 @@ QUnit.test('getPlugin()', function(assert) { }); QUnit.test('getPluginVersion()', function(assert) { - assert.strictEqual(Plugin.getPluginVersion('basic'), '', 'the basic plugin has no version'); + assert.strictEqual( + Plugin.getPluginVersion('basic'), + '', + 'the basic plugin has no version' + ); + assert.strictEqual(Plugin.getPluginVersion('mock'), 'v1.2.3'); }); @@ -120,72 +128,3 @@ QUnit.test('isBasic()', function(assert) { assert.ok(!Plugin.isBasic(MockPlugin)); assert.ok(!Plugin.isBasic('mock')); }); - -QUnit.module('Plugin: basic', { - - beforeEach() { - this.basic = sinon.spy(); - this.player = TestHelpers.makePlayer(); - - Plugin.registerPlugin('basic', this.basic); - }, - - afterEach() { - this.player.dispose(); - Plugin.deregisterPlugins(); - } -}); - -QUnit.test('pre-setup interface', function(assert) { - assert.strictEqual(typeof this.player.basic, 'function', 'basic plugins are a function on a player'); - assert.notStrictEqual(this.player.basic, this.basic, 'basic plugins are wrapped'); - assert.strictEqual(this.player.basic.dispose, undefined, 'unlike class-based plugins, basic plugins do not have a dispose method'); - assert.ok(!this.player.usingPlugin('basic')); -}); - -QUnit.test('setup', function(assert) { - this.player.basic({foo: 'bar'}, 123); - - assert.strictEqual(this.basic.callCount, 1, 'the plugin was called once'); - assert.strictEqual(this.basic.firstCall.thisValue, this.player, 'the plugin `this` value was the player'); - assert.deepEqual(this.basic.firstCall.args, [{foo: 'bar'}, 123], 'the plugin had the correct arguments'); - assert.ok(this.player.usingPlugin('basic'), 'the player now recognizes that the plugin was set up'); -}); - -QUnit.test('"pluginsetup" event', function(assert) { - const setupSpy = sinon.spy(); - - this.player.on('pluginsetup', setupSpy); - const instance = this.player.basic(); - - assert.strictEqual(setupSpy.callCount, 1, 'the "pluginsetup" event was triggered'); - - const event = setupSpy.firstCall.args[0]; - const hash = setupSpy.firstCall.args[1]; - - assert.strictEqual(event.type, 'pluginsetup', 'the event has the correct type'); - assert.deepEqual(hash, { - name: 'basic', - instance, - plugin: this.basic - }, 'the event hash object is correct'); -}); - -QUnit.module('Plugin: class-based', { - - beforeEach() { - this.player = TestHelpers.makePlayer(); - Plugin.registerPlugin('mock', MockPlugin); - }, - - afterEach() { - this.player.dispose(); - Plugin.deregisterPlugins(); - } -}); - -QUnit.test('pre-activation interface', function(assert) { - assert.strictEqual(typeof this.player.mock, 'function', 'plugins are a factory function on a player'); - assert.strictEqual(this.player.mock.dispose, undefined, 'class-based plugins are not populated on a player until the factory method creates them'); - assert.ok(!this.player.usingPlugin('mock')); -}); From 7285d1e5074602a02ef1f390dfbac1fd7fe4486d Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Wed, 26 Oct 2016 17:14:24 -0400 Subject: [PATCH 11/48] Resolve issues w/ events and add a bunch of class-based plugin tests --- src/js/mixins/eventful.js | 4 +- src/js/player.js | 4 +- src/js/plugin.js | 69 ++++++----- test/unit/mixins/eventful.test.js | 4 +- test/unit/mixins/stateful.test.js | 2 +- test/unit/plugin-class.test.js | 190 +++++++++++++++++++++++++++++- 6 files changed, 235 insertions(+), 38 deletions(-) diff --git a/src/js/mixins/eventful.js b/src/js/mixins/eventful.js index c936a53835..428cd47f51 100644 --- a/src/js/mixins/eventful.js +++ b/src/js/mixins/eventful.js @@ -1,6 +1,7 @@ /** * @file mixins/eventful.js */ +import * as Dom from '../utils/dom'; import * as Events from '../utils/events'; /** @@ -22,7 +23,8 @@ function eventful(target, exclusions = []) { ['off', 'on', 'one', 'trigger'] .filter(name => exclusions.indexOf(name) === -1) .forEach(name => { - target[name] = Events[name].bind(target, target); + target.eventBusEl_ = target.el_ || Dom.createEl('span', {className: 'vjs-event-bus'}); + target[name] = (...args) => Events[name](...[target.eventBusEl_, ...args]); }); return target; diff --git a/src/js/player.js b/src/js/player.js index 486fe07cd9..88215f1425 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -347,7 +347,7 @@ class Player extends Component { */ this.scrubbing_ = false; - this.activePlugins_ = {}; + this.plugins_ = {}; this.el_ = this.createEl(); // We also want to pass the original player options to each component and plugin @@ -3113,7 +3113,7 @@ class Player extends Component { * @return {Boolean} */ usingPlugin(name) { - return !!(this.activePlugins_ && this.activePlugins_[name]); + return !!(this.plugins_ && this.plugins_[name]); } /** diff --git a/src/js/plugin.js b/src/js/plugin.js index a1228c39ba..99695b5a27 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -6,7 +6,6 @@ import stateful from './mixins/stateful'; import * as Events from './utils/events'; import * as Fn from './utils/fn'; import * as Obj from './utils/obj'; -import EventTarget from './event-target'; import Player from './player'; /** @@ -15,7 +14,7 @@ import Player from './player'; * @private * @type {Object} */ -const pluginCache = {}; +const pluginStorage = {}; /** * Takes a basic plugin function and returns a wrapper function which marks @@ -27,12 +26,12 @@ const pluginCache = {}; const createBasicPlugin = (name, plugin) => function() { const instance = plugin.apply(this, arguments); - this.activePlugins_[name] = true; + this.plugins_[name] = true; - // We trigger the "pluginsetup" event regardless, but we want the hash to - // be consistent. The only odd thing here is the `instance` is the value - // returned by the `plugin` function (instead of, necessarily, an instance - // of it). + // We trigger the "pluginsetup" event on the player regardless, but we want + // the hash to be consistent with the hash provided for class-based plugins. + // The only potentially counter-intuitive thing here is the `instance` is the + // value returned by the `plugin` function. this.trigger('pluginsetup', {name, plugin, instance}); return instance; }; @@ -50,7 +49,7 @@ const createBasicPlugin = (name, plugin) => function() { const createPluginFactory = (name, PluginSubClass) => { // Add a `name` property to the plugin prototype so that each plugin can - // refer to itself. + // refer to itself by name. PluginSubClass.prototype.name = name; return function(...args) { @@ -59,7 +58,7 @@ const createPluginFactory = (name, PluginSubClass) => { }; }; -class Plugin extends EventTarget { +class Plugin { /** * Plugin constructor. @@ -70,14 +69,11 @@ class Plugin extends EventTarget { * @param {Player} player */ constructor(player) { - super(); this.player = player; - eventful(this, ['trigger']); stateful(this, this.constructor.defaultState); - - player.on('dispose', Fn.bind(this, this.dispose)); - player.activePlugins_[this.name] = true; + player.plugins_[this.name] = true; + player.one('dispose', Fn.bind(this, this.dispose)); player.trigger('pluginsetup', this.getEventHash_()); } @@ -87,6 +83,7 @@ class Plugin extends EventTarget { * * This returns that object or mutates an existing hash. * + * @private * @param {Object} [hash={}] * @return {Object} * - `instance`: The plugin instance on which the event is fired. @@ -107,13 +104,18 @@ class Plugin extends EventTarget { * A string (the type) or an event object with a type attribute. * * @param {Object} [hash={}] - * Additional data hash to pass along with the event. + * Additional data hash to pass along with the event. In this case, + * several properties are added to the hash: + * + * - `instance`: The plugin instance on which the event is fired. + * - `name`: The name of the plugin. + * - `plugin`: The plugin class/constructor. * * @return {Boolean} * Whether or not default was prevented. */ trigger(event, hash = {}) { - return Events.trigger(this, event, this.getEventHash_(hash)); + return Events.trigger(this.eventBusEl_, event, this.getEventHash_(hash)); } /** @@ -123,13 +125,26 @@ class Plugin extends EventTarget { * it's probably best to subscribe to one of the disposal events. */ dispose() { - this.off(); - this.player.activePlugins_[this.name] = false; + const {name, player} = this; + this.trigger('dispose'); + this.off(); - // Eliminate possible sources of leaking memory. - this.player[this.name] = createPluginFactory(this.name, pluginCache[this.name]); + // Eliminate any possible sources of leaking memory by clearing up references + // between the player and the plugin instance and nulling out the plugin's + // state and replacing methods with a function that throws. + player.plugins_[name] = false; this.player = this.state = null; + + this.dispose = () => { + throw new Error('cannot call methods on a disposed object'); + }; + + this.setState = this.off = this.on = this.one = this.trigger = this.dispose; + + // Finally, replace the plugin name on the player with a new factory + // function, so that the plugin is ready to be set up again. + player[name] = createPluginFactory(name, pluginStorage[name]); } /** @@ -156,7 +171,7 @@ class Plugin extends EventTarget { * @return {Function} */ static registerPlugin(name, plugin) { - if (typeof name !== 'string' || pluginCache[name] || Player.prototype[name]) { + if (typeof name !== 'string' || pluginStorage[name] || Player.prototype[name]) { throw new Error(`illegal plugin name, "${name}"`); } @@ -164,7 +179,7 @@ class Plugin extends EventTarget { throw new Error(`illegal plugin for "${name}", must be a function, was ${typeof plugin}`); } - pluginCache[name] = plugin; + pluginStorage[name] = plugin; if (Plugin.isBasic(plugin)) { Player.prototype[name] = createBasicPlugin(name, plugin); @@ -198,8 +213,8 @@ class Plugin extends EventTarget { * @param {String} name */ static deregisterPlugin(name) { - if (pluginCache.hasOwnProperty(name)) { - delete pluginCache[name]; + if (pluginStorage.hasOwnProperty(name)) { + delete pluginStorage[name]; delete Player.prototype[name]; } } @@ -211,7 +226,7 @@ class Plugin extends EventTarget { * If provided, should be an array of plugin names. Defaults to _all_ * plugin names. */ - static deregisterPlugins(names = Object.keys(pluginCache)) { + static deregisterPlugins(names = Object.keys(pluginStorage)) { names.forEach(name => this.deregisterPlugin(name)); } @@ -223,7 +238,7 @@ class Plugin extends EventTarget { * plugin names. * @return {Object} */ - static getPlugins(names = Object.keys(pluginCache)) { + static getPlugins(names = Object.keys(pluginStorage)) { let result; names.forEach(name => { @@ -245,7 +260,7 @@ class Plugin extends EventTarget { * @return {[type]} [description] */ static getPlugin(name) { - return pluginCache[name] || Player.prototype[name]; + return pluginStorage[name] || Player.prototype[name]; } /** diff --git a/test/unit/mixins/eventful.test.js b/test/unit/mixins/eventful.test.js index e900ab0b9c..0af5550d57 100644 --- a/test/unit/mixins/eventful.test.js +++ b/test/unit/mixins/eventful.test.js @@ -1,9 +1,10 @@ /* eslint-env qunit */ import sinon from 'sinon'; import eventful from '../../../src/js/mixins/eventful'; +import * as Dom from '../../../src/js/utils/dom'; import * as Obj from '../../../src/js/utils/obj'; -QUnit.module('mixins: Eventful'); +QUnit.module('mixins: eventful'); QUnit.test('eventful() mutations', function(assert) { const target = {}; @@ -12,6 +13,7 @@ QUnit.test('eventful() mutations', function(assert) { assert.strictEqual(eventful(target), target, 'returns the target object'); assert.ok(Obj.isObject(target), 'the target is still an object'); + assert.ok(Dom.isEl(target.eventBusEl_), 'the target has an event bus element'); assert.strictEqual(typeof target.off, 'function', 'the target has an off method'); assert.strictEqual(typeof target.on, 'function', 'the target has an on method'); assert.strictEqual(typeof target.one, 'function', 'the target has a one method'); diff --git a/test/unit/mixins/stateful.test.js b/test/unit/mixins/stateful.test.js index 99e6092c47..ca4373ee7a 100644 --- a/test/unit/mixins/stateful.test.js +++ b/test/unit/mixins/stateful.test.js @@ -4,7 +4,7 @@ import EventTarget from '../../../src/js/event-target'; import stateful from '../../../src/js/mixins/stateful'; import * as Obj from '../../../src/js/utils/obj'; -QUnit.module('mixins: Stateful'); +QUnit.module('mixins: stateful'); QUnit.test('stateful() mutations', function(assert) { const target = {}; diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js index 0f81620dec..bf4bb4d063 100644 --- a/test/unit/plugin-class.test.js +++ b/test/unit/plugin-class.test.js @@ -3,15 +3,21 @@ import sinon from 'sinon'; import Plugin from '../../src/js/plugin'; import TestHelpers from './test-helpers'; -class MockPlugin extends Plugin {} - -MockPlugin.VERSION = 'v1.2.3'; - QUnit.module('Plugin: class-based', { beforeEach() { - this.derp = sinon.spy(); this.player = TestHelpers.makePlayer(); + const spy = this.spy = sinon.spy(); + + class MockPlugin extends Plugin { + + constructor(...args) { + super(...args); + spy.apply(this, args); + } + } + + this.MockPlugin = MockPlugin; Plugin.registerPlugin('mock', MockPlugin); }, @@ -21,7 +27,7 @@ QUnit.module('Plugin: class-based', { } }); -QUnit.test('pre-activation interface', function(assert) { +QUnit.test('pre-setup interface', function(assert) { assert.strictEqual( typeof this.player.mock, 'function', @@ -36,3 +42,175 @@ QUnit.test('pre-activation interface', function(assert) { assert.ok(!this.player.usingPlugin('mock')); }); + +QUnit.test('setup', function(assert) { + const instance = this.player.mock({foo: 'bar'}, 123); + + assert.strictEqual(this.spy.callCount, 1, 'plugin was set up once'); + + assert.strictEqual( + this.spy.firstCall.thisValue, + instance, + 'plugin constructor `this` value was the instance' + ); + + assert.deepEqual( + this.spy.firstCall.args, + [this.player, {foo: 'bar'}, 123], + 'plugin had the correct arguments' + ); + + assert.ok( + this.player.usingPlugin('mock'), + 'player now recognizes that the plugin was set up' + ); + + assert.ok( + instance instanceof this.MockPlugin, + 'plugin instance has the correct constructor' + ); + + assert.strictEqual(instance, this.player.mock, 'instance replaces the factory'); + + assert.strictEqual( + instance.player, + this.player, + 'instance has a reference to the player' + ); + + assert.strictEqual(instance.name, 'mock', 'instance knows its name'); + assert.strictEqual(typeof instance.state, 'object', 'instance is stateful'); + assert.strictEqual(typeof instance.setState, 'function', 'instance is stateful'); + assert.strictEqual(typeof instance.off, 'function', 'instance is eventful'); + assert.strictEqual(typeof instance.on, 'function', 'instance is eventful'); + assert.strictEqual(typeof instance.one, 'function', 'instance is eventful'); + assert.strictEqual(typeof instance.trigger, 'function', 'instance is eventful'); + assert.strictEqual(typeof instance.dispose, 'function', 'instance has dispose method'); +}); + +QUnit.test('"pluginsetup" event', function(assert) { + const setupSpy = sinon.spy(); + + this.player.on('pluginsetup', setupSpy); + const instance = this.player.mock(); + + assert.strictEqual(setupSpy.callCount, 1, 'the "pluginsetup" event was triggered'); + + const event = setupSpy.firstCall.args[0]; + const hash = setupSpy.firstCall.args[1]; + + assert.strictEqual(event.type, 'pluginsetup', 'the event has the correct type'); + + assert.strictEqual( + event.target, + this.player.el_, + 'the event has the correct target' + ); + + assert.deepEqual(hash, { + name: 'mock', + instance, + plugin: this.MockPlugin + }, 'the event hash object is correct'); +}); + +QUnit.test('defaultState static property is used to populate state', function(assert) { + class DefaultStateMock extends Plugin {} + DefaultStateMock.defaultState = {foo: 1, bar: 2}; + Plugin.registerPlugin('dsm', DefaultStateMock); + + const instance = this.player.dsm(); + + assert.deepEqual(instance.state, {foo: 1, bar: 2}); +}); + +QUnit.test('dispose', function(assert) { + const instance = this.player.mock(); + + instance.dispose(); + + assert.ok( + !this.player.usingPlugin('mock'), + 'player recognizes that the plugin is NOT set up' + ); + + assert.strictEqual( + typeof this.player.mock, + 'function', + 'instance is replaced by factory' + ); + + assert.notStrictEqual(instance, this.player.mock, 'instance is replaced by factory'); + + assert.strictEqual( + instance.player, + null, + 'instance no longer has a reference to the player' + ); + + assert.strictEqual(instance.state, null, 'state is now null'); + + ['dispose', 'setState', 'off', 'on', 'one', 'trigger'].forEach(n => { + assert.throws( + function() { + instance[n](); + }, + new Error('cannot call methods on a disposed object'), + `the "${n}" method now throws` + ); + }); +}); + +QUnit.test('"dispose" event', function(assert) { + const disposeSpy = sinon.spy(); + const instance = this.player.mock(); + + instance.on('dispose', disposeSpy); + instance.dispose(); + + assert.strictEqual(disposeSpy.callCount, 1, 'the "dispose" event was triggered'); + + const event = disposeSpy.firstCall.args[0]; + const hash = disposeSpy.firstCall.args[1]; + + assert.strictEqual(event.type, 'dispose', 'the event has the correct type'); + + assert.strictEqual( + event.target, + instance.eventBusEl_, + 'the event has the correct target' + ); + + assert.deepEqual(hash, { + name: 'mock', + instance, + plugin: this.MockPlugin + }, 'the event hash object is correct'); +}); + +QUnit.test('arbitrary events', function(assert) { + const fooSpy = sinon.spy(); + const instance = this.player.mock(); + + instance.on('foo', fooSpy); + instance.trigger('foo'); + + assert.strictEqual(fooSpy.callCount, 1, 'the "foo" event was triggered'); + + const event = fooSpy.firstCall.args[0]; + const hash = fooSpy.firstCall.args[1]; + + assert.strictEqual(event.type, 'foo', 'the event has the correct type'); + + assert.strictEqual( + event.target, + instance.eventBusEl_, + 'the event has the correct target' + ); + + assert.deepEqual(hash, { + name: 'mock', + instance, + plugin: this.MockPlugin + }, 'the event hash object is correct'); +}); From c13fd7b714511d4242b795c101fb53c0b61d0305 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Thu, 27 Oct 2016 15:31:24 -0400 Subject: [PATCH 12/48] Rewrite plugins guide for 6.0 --- docs/guides/plugins.md | 336 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 311 insertions(+), 25 deletions(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index a45cf2abf3..e5770156f6 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -1,58 +1,344 @@ -# Plugins +# Video.js Plugins -If you've built something cool with Video.js, you can easily share it with the rest of the world by creating a plugin. Although, you can roll your own, you can also use [generator-videojs-plugin](https://github.com/dmlap/generator-videojs-plugin), a [Yeoman](http://yeoman.io) generator that provides scaffolding for video.js plugins including: + + -* [Grunt](http://gruntjs.com) for build management -* [npm](https://www.npmjs.org) for dependency management -* [QUnit](http://qunitjs.com) for testing -## Step 1: Write Some Javascript +- [Writing a Basic Plugin](#writing-a-basic-plugin) + - [Write a JavaScript Function](#write-a-javascript-function) + - [Register a Basic Plugin](#register-a-basic-plugin) + - [Setting up a Basic Plugin](#setting-up-a-basic-plugin) +- [Writing a Class-Based Plugin](#writing-a-class-based-plugin) + - [Write a JavaScript Class/Constructor](#write-a-javascript-classconstructor) + - [Register a Class-Based Plugin](#register-a-class-based-plugin) + - [Setting up a Class-Based Plugin](#setting-up-a-class-based-plugin) + - [Key Differences from Basic Plugins](#key-differences-from-basic-plugins) + - [The Value of `this`](#the-value-of-this) + - [The Player Plugin Name Property](#the-player-plugin-name-property) + - [Advanced Features of Class-based Plugins](#advanced-features-of-class-based-plugins) + - [Events](#events) + - [Statefulness](#statefulness) + - [Lifecycle](#lifecycle) + - [Advanced Example Class-based Plugin](#advanced-example-class-based-plugin) +- [References](#references) -You may have already done this step. Code up something interesting and then wrap it in a function. At the most basic level, that's all a video.js plugin is. By convention, plugins take a hash of options as their first argument: + + +One of the great strengths of Video.js is its ecosystem of plugins that allow authors from all over the world to share their video player customizations. This includes everything from the simplest UI tweaks to new [playback technologies and source handlers](tech.md)! + +Because we view plugins as such an important part of Video.js, the organization is committed to maintaining a robust set of tools for plugin authorship: + +- [generator-videojs-plugin][generator] + + A [Yeoman][yeoman] generator for scaffolding a Video.js plugin project. Additionally, it offers a set of [conventions for plugin authorship][standards] that, if followed, make authorship, contribution, and usage consistent and predictable. + + In short, the generator sets up plugin authors to focus on writing their plugin - not messing with tools. + +- [videojs-spellbook][spellbook] + + As of version 3, the plugin generator includes a new dependency: [videojs-spellbook][spellbook]. Spellbook is a kitchen sink plugin development tool: it builds plugins, creates tags, runs a development server, and more. + + The benefit of Spellbook is that you can run the generator _once_ and receive updates and bugfixes in Spellbook without having to run the generator again and deal with Yeoman conflicts and other headaches. + + As long as your plugin project follows the [conventions][standards], Spellbook should work on it! + +## Writing a Basic Plugin + +If you've written a Video.js plugin before, the basic plugin concept should be familiar. It's similar to a jQuery plugin in that the core idea is that you're adding a method to the player. + +### Write a JavaScript Function + +A basic plugin is a plain JavaScript function: ```js function examplePlugin(options) { - this.on('play', function(e) { - console.log('playback has started!'); + this.addClass(options.customClass); + this.on('playing', function() { + videojs.log('playback began!'); }); -}; +} ``` -When it's activated, `this` will be the Video.js player your plugin is attached to. You can use anything you'd like in the [Video.js API](./api.md) when you're writing a plugin: change the `src`, mess up the DOM, or listen for and emit your own events. +By convention, plugins are passed an `options` object; however, you can realistically accept whatever arguments you want. This example plugin will add a custom class (whatever is passed in as `options.customClass`) and, whenever playback begins, it will log a message to the browser console. + +> **Note:** The value of `this` in the plugin function is the player instance; so, you have access to [its complete API][api-player]. -## Step 2: Registering A Plugin +### Register a Basic Plugin -It's time to give the rest of the world the opportunity to be awed by your genius. When your plugin is loaded, it needs to let Video.js know this amazing new functionality is now available: +Now that we have a function that does something with a player, all that's left is to register the plugin with Video.js: ```js -videojs.plugin('examplePlugin', examplePlugin); +videojs.registerPlugin('examplePlugin', examplePlugin); ``` -From this point on, your plugin will be added to the Video.js prototype and will show up as a property on every instance created. Make sure you choose a unique name that doesn't clash with any of the properties already in Video.js. Which leads us to... +The only stipulation with the name of the plugin is that it cannot conflict with any existing player method. After that, any player will automatically have an `examplePlugin` method on its prototype! -## Step 3: Using A Plugin +### Setting up a Basic Plugin -There are two ways to initialize a plugin. If you're creating your video tag dynamically, you can specify the plugins you'd like to initialize with it and any options you want to pass to them: +Finally, we can use our plugin on a player. There are two ways to do this. The first way is during creation of a Video.js player. Using the `plugins` option, a plugin can be automatically set up on a player: ```js -videojs('vidId', { +videojs('example-player', { plugins: { examplePlugin: { - exampleOption: true + customClass: 'example-class' } } }); ``` -If you've already initialized your video tag, you can activate a plugin at any time by calling its setup function directly: +Otherwise, a plugin can be manually set up: ```js -var video = videojs('cool-vid'); -video.examplePlugin({ exampleOption: true }); +var player = videojs('example-player'); +player.examplePlugin({customClass: 'example-class'}); ``` -That's it. Head on over to the [Video.js wiki](https://github.com/videojs/video.js/wiki/Plugins) and add your plugin to the list so everyone else can check it out. +These two methods are functionally identical - use whichever you prefer! That's all there is to it for basic plugins. + +## Writing a Class-Based Plugin + +As of Video.js 6, there is an additional type of plugin supported: class-based plugins. + +At any time, you may want to refer to the [Plugin API docs][api-plugin] for more detail. + +### Write a JavaScript Class/Constructor + +If you're familiar with creating [components](components.md), this process is similar. A class-based plugin starts with a JavaScript class (a.k.a. a constructor function). + +This can be achieved with ES6 classes: + +```js +const Plugin = videojs.getPlugin('Plugin'); + +class ExamplePlugin extends Plugin { + + constructor(player, options) { + super(player); + + player.addClass(options.customClass); + player.on('playing', function() { + videojs.log('playback began!'); + }); + } +} +``` + +Or with ES5: + +```js +var Plugin = videojs.getPlugin('Plugin'); + +var ExamplePlugin = videojs.extend(Plugin, { + + constructor: function(player, options) { + Plugin.prototype.call(this, player, options); + + player.addClass(options.customClass); + player.on('playing', function() { + videojs.log('playback began!'); + }); + } +}); +``` + +For now, this example class-based plugin does the exact same thing as the basic plugin described above - not to worry, we will make it more interesting as we continue! + +### Register a Class-Based Plugin + +The registration process for class-based plugins is identical to [the process for basic plugins](#register-a-basic-plugin). + +```js +videojs.registerPlugin('examplePlugin', ExamplePlugin); +``` + +### Setting up a Class-Based Plugin + +Again, just like registration, the setup process for class-based plugins is identical to [the process for basic plugins](#setting-up-a-basic-plugin). + +```js +videojs('example-player', { + plugins: { + examplePlugin: { + customClass: 'example-class' + } + } +}); +``` + +Otherwise, a plugin can be manually set up: + +```js +var player = videojs('example-player'); +player.examplePlugin({customClass: 'example-class'}); +``` + +### Key Differences from Basic Plugins + +Class-based plugins have two key differences from basic plugins that are important to understand before describing their advanced features. + +#### The Value of `this` + +With basic plugins, the value of `this` in the plugin function will be the _player_. + +With class-based plugins, the value of `this` is the _instance of the plugin class_. The player is passed to the plugin constructor as its first argument (and is automatically applied to the plugin instance as the `player` property) and any further arguments are passed after that. + +#### The Player Plugin Name Property + +Both basic plugins and class-based plugins are set up by calling a method on a player with a name matching the plugin (e.g., `player.examplePlugin()`). + +However, with class-based plugins, this method acts like a factory function and it is _replaced_ for the current player by the plugin class instance: + +```js +// `examplePlugin` has not been called, so it is a factory function. +player.examplePlugin(); + +// `examplePlugin` is now an instance of `ExamplePlugin`. +player.examplePlugin.someMethodName(); +``` + +With basic plugins, the method does not change - it is always the same function. + +### Advanced Features of Class-based Plugins + +Up to this point, our example class-based plugin is functionally identical to our example basic plugin. However, class-based plugins bring with them a great deal of benefit that is not built into basic plugins. + +#### Events + +Like components, class-based plugins offer an implementation of events. This includes: + +- The ability to listen for events on the plugin instance using `on` or `one` and stop listening for events using `off`: + + ```js + player.examplePlugin.on('example-event', function() { + videojs.log('example plugin received an example-event'); + }); + ``` + +- The ability to `trigger` custom events on a plugin instance: + + ```js + player.examplePlugin.trigger('example-event'); + ``` + +By offering a built-in events system, class-based plugins offer a wider range of options for code structure with a pattern familiar to most web developers. + +#### Statefulness + +A new concept introduced in Video.js 6 for class-based plugins is _statefulness_. This is similar to React components' `state` property and `setState` method. + +Class-based plugin instances each have a `state` property, which is a plain JavaScript object - it can contain any keys and values the plugin author wants. + +A default `state` can be provided by adding a static property to a plugin constructor: + +```js +ExamplePlugin.defaultState = { + customClass: 'default-custom-class' +}; +``` + +When the `state` is updated via the `setState` method, the plugin instance fires a `"statechanged"` event, but _only if something changed!_ This event can be used as a signal to update the DOM or perform some other action. Listeners to this event will receive, as a second argument, a hash of changes which occurred on the `state` property: + +```js +player.examplePlugin.on('statechanged', function(changes) { + if (changes.customClass) { + this + .removeClass(changes.customClass.from) + .addClass(changes.customClass.to); + } +}); + +player.examplePlugin.setState({customClass: 'another-custom-class'}); +``` + +#### Lifecycle + +Like components, class-based plugins have a lifecycle. They can be created with their factory function and they can be destroyed using their `dispose` method: + +```js +// set up a example plugin instance +player.examplePlugin(); + +// dispose of it anytime thereafter +player.examplePlugin.dispose(); +``` + +The `dispose` method has several effects: + +- Triggers a `"dispose"` event on the plugin instance. +- Cleans up all event listeners on the plugin instance. +- Removes statefulness, event methods, and references to the player to avoid memory leaks. +- Reverts the player property (e.g. `player.examplePlugin`) _back_ to a factory function, so the plugin can be set up again. + +In addition, if the player is disposed, the disposal of all its class-based plugin instances will be triggered as well. + +### Advanced Example Class-based Plugin + +What follows is a complete ES6 class-based plugin that logs a custom message when the player's state changes between playing and paused. It uses all the described advanced features: + +```js +import videojs from 'video.js'; + +const Plugin = videojs.getPlugin('Plugin'); + +class Advanced extends Plugin { + + constructor(player, options) { + super(player, options); + + // Whenever the player emits a playing or paused event, we update the + // state if necessary. + this.on(player, ['playing', 'paused'], this.updateState); + this.on('statechanged', this.logState); + } + + dispose() { + super(); + videojs.log('the advanced plugin is being disposed'); + } + + updateState() { + this.setState({playing: !player.paused()}); + } + + logState(changed) { + videojs.log(`the player is now ${this.state.playing ? 'playing' : 'paused'}`); + } +} + +videojs.registerPlugin('advanced', Advanced); + +const player = videojs('example-player'); + +player.advanced(); + +// This will begin playback, which will trigger a "playing" event, which will +// update the state of the plugin, which will cause a message to be logged. +player.play(); + +// This will pause playback, which will trigger a "paused" event, which will +// update the state of the plugin, which will cause a message to be logged. +player.pause(); + +player.advanced.dispose(); + +// This will begin playback, but the plugin has been disposed, so it will not +// log any messages. +player.play(); +``` + +This example may be a bit pointless in reality, but it demonstrates the sort of flexibility offered by class-based plugins over basic plugins. + +## References -## How should I use the Video.js icons in my plugin? +- [Player API][api-player] +- [Plugin API][api-plugin] +- [Plugin Generator][generator] +- [Plugin Conventions][standards] -If you'd like to use any of the icons available in the [Video.js icon set](http://videojs.github.io/font/), please target them via the CSS class names instead of codepoints. The codepoints _may_ change between versions of the font, so using the class names ensures that your plugin will stay up to date with any font changes. +[api-player]: http://docs.videojs.com/docs/api/player.html +[api-plugin]: http://docs.videojs.com/docs/api/plugin.html +[generator]: https://github.com/videojs/generator-videojs-plugin +[spellbook]: https://github.com/videojs/spellbook +[standards]: https://github.com/videojs/generator-videojs-plugin/blob/master/docs/standards.md +[yeoman]: http://yeoman.io From 9ee98baccd8b0d791961302ed2e435efd74a27e6 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Thu, 27 Oct 2016 15:31:37 -0400 Subject: [PATCH 13/48] Rename eventful to evented --- src/js/mixins/{eventful.js => evented.js} | 8 ++++---- src/js/plugin.js | 4 ++-- .../mixins/{eventful.test.js => evented.test.js} | 16 ++++++++-------- test/unit/plugin-class.test.js | 8 ++++---- 4 files changed, 18 insertions(+), 18 deletions(-) rename src/js/mixins/{eventful.js => evented.js} (81%) rename test/unit/mixins/{eventful.test.js => evented.test.js} (78%) diff --git a/src/js/mixins/eventful.js b/src/js/mixins/evented.js similarity index 81% rename from src/js/mixins/eventful.js rename to src/js/mixins/evented.js index 428cd47f51..6b6a27f405 100644 --- a/src/js/mixins/eventful.js +++ b/src/js/mixins/evented.js @@ -1,11 +1,11 @@ /** - * @file mixins/eventful.js + * @file mixins/evented.js */ import * as Dom from '../utils/dom'; import * as Events from '../utils/events'; /** - * Makes an object "eventful" - granting it methods from the `Events` utility. + * Makes an object "evented" - granting it methods from the `Events` utility. * * By default, this adds the `off`, `on`, `one`, and `trigger` methods, but * exclusions can optionally be made. @@ -19,7 +19,7 @@ import * as Events from '../utils/events'; * @return {Object} * The target object. */ -function eventful(target, exclusions = []) { +function evented(target, exclusions = []) { ['off', 'on', 'one', 'trigger'] .filter(name => exclusions.indexOf(name) === -1) .forEach(name => { @@ -30,4 +30,4 @@ function eventful(target, exclusions = []) { return target; } -export default eventful; +export default evented; diff --git a/src/js/plugin.js b/src/js/plugin.js index 99695b5a27..58afa35a18 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -1,7 +1,7 @@ /** * @file plugin.js */ -import eventful from './mixins/eventful'; +import evented from './mixins/evented'; import stateful from './mixins/stateful'; import * as Events from './utils/events'; import * as Fn from './utils/fn'; @@ -70,7 +70,7 @@ class Plugin { */ constructor(player) { this.player = player; - eventful(this, ['trigger']); + evented(this, ['trigger']); stateful(this, this.constructor.defaultState); player.plugins_[this.name] = true; player.one('dispose', Fn.bind(this, this.dispose)); diff --git a/test/unit/mixins/eventful.test.js b/test/unit/mixins/evented.test.js similarity index 78% rename from test/unit/mixins/eventful.test.js rename to test/unit/mixins/evented.test.js index 0af5550d57..548b2b9046 100644 --- a/test/unit/mixins/eventful.test.js +++ b/test/unit/mixins/evented.test.js @@ -1,16 +1,16 @@ /* eslint-env qunit */ import sinon from 'sinon'; -import eventful from '../../../src/js/mixins/eventful'; +import evented from '../../../src/js/mixins/evented'; import * as Dom from '../../../src/js/utils/dom'; import * as Obj from '../../../src/js/utils/obj'; -QUnit.module('mixins: eventful'); +QUnit.module('mixins: evented'); -QUnit.test('eventful() mutations', function(assert) { +QUnit.test('evented() mutations', function(assert) { const target = {}; - assert.strictEqual(typeof eventful, 'function', 'the mixin is a function'); - assert.strictEqual(eventful(target), target, 'returns the target object'); + assert.strictEqual(typeof evented, 'function', 'the mixin is a function'); + assert.strictEqual(evented(target), target, 'returns the target object'); assert.ok(Obj.isObject(target), 'the target is still an object'); assert.ok(Dom.isEl(target.eventBusEl_), 'the target has an event bus element'); @@ -20,8 +20,8 @@ QUnit.test('eventful() mutations', function(assert) { assert.strictEqual(typeof target.trigger, 'function', 'the target has a trigger method'); }); -QUnit.test('eventful() with exclusions', function(assert) { - const target = eventful({}, ['one']); +QUnit.test('evented() with exclusions', function(assert) { + const target = evented({}, ['one']); assert.strictEqual(typeof target.off, 'function', 'the target has an off method'); assert.strictEqual(typeof target.on, 'function', 'the target has an on method'); @@ -31,7 +31,7 @@ QUnit.test('eventful() with exclusions', function(assert) { QUnit.test('supports basic event handling (not complete functionality tests)', function(assert) { const spy = sinon.spy(); - const target = eventful({}); + const target = evented({}); target.on('foo', spy); target.trigger('foo', {x: 1}); diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js index bf4bb4d063..5ce3bfb405 100644 --- a/test/unit/plugin-class.test.js +++ b/test/unit/plugin-class.test.js @@ -81,10 +81,10 @@ QUnit.test('setup', function(assert) { assert.strictEqual(instance.name, 'mock', 'instance knows its name'); assert.strictEqual(typeof instance.state, 'object', 'instance is stateful'); assert.strictEqual(typeof instance.setState, 'function', 'instance is stateful'); - assert.strictEqual(typeof instance.off, 'function', 'instance is eventful'); - assert.strictEqual(typeof instance.on, 'function', 'instance is eventful'); - assert.strictEqual(typeof instance.one, 'function', 'instance is eventful'); - assert.strictEqual(typeof instance.trigger, 'function', 'instance is eventful'); + assert.strictEqual(typeof instance.off, 'function', 'instance is evented'); + assert.strictEqual(typeof instance.on, 'function', 'instance is evented'); + assert.strictEqual(typeof instance.one, 'function', 'instance is evented'); + assert.strictEqual(typeof instance.trigger, 'function', 'instance is evented'); assert.strictEqual(typeof instance.dispose, 'function', 'instance has dispose method'); }); From 7ddb681c360d28c7aa3c0a30431ba1899c69486d Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Thu, 27 Oct 2016 16:11:58 -0400 Subject: [PATCH 14/48] Support custom event bus elements --- src/js/mixins/evented.js | 31 ++++++++++++++++++++++++++----- src/js/plugin.js | 2 +- test/unit/mixins/evented.test.js | 15 ++++++++++++++- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/js/mixins/evented.js b/src/js/mixins/evented.js index 6b6a27f405..b63e432469 100644 --- a/src/js/mixins/evented.js +++ b/src/js/mixins/evented.js @@ -13,18 +13,39 @@ import * as Events from '../utils/events'; * @param {Object} target * The object to which to add event methods. * - * @param {Array} [exclusions=[]] + * @param {Object} [options] + * Options for customizing the mixin behavior. + * + * @param {Array} [options.exclude=[]] * An array of methods to exclude from addition to the object. * + * @param {String} [options.eventBusKey] + * By default, adds a `eventBusEl_` DOM element to the target object, + * which is used as an event bus. If the target object already has a + * DOM element that should be used, pass its key here. + * * @return {Object} * The target object. */ -function evented(target, exclusions = []) { +function evented(target, options = {}) { + const {exclude, eventBusKey} = options; + + // Set or create the eventBusEl_. + if (eventBusKey) { + if (!Dom.isEl(target[eventBusKey])) { + throw new Error(`eventBusKey "${eventBusKey}" does not refer to an element`); + } + target.eventBusEl_ = target[eventBusKey]; + } else { + target.eventBusEl_ = Dom.createEl('span', {className: 'vjs-event-bus'}); + } + ['off', 'on', 'one', 'trigger'] - .filter(name => exclusions.indexOf(name) === -1) + .filter(name => !exclude || exclude.indexOf(name) === -1) .forEach(name => { - target.eventBusEl_ = target.el_ || Dom.createEl('span', {className: 'vjs-event-bus'}); - target[name] = (...args) => Events[name](...[target.eventBusEl_, ...args]); + target[name] = (...args) => { + return Events[name](...[target.eventBusEl_, ...args]); + }; }); return target; diff --git a/src/js/plugin.js b/src/js/plugin.js index 58afa35a18..e0d4eef40c 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -70,7 +70,7 @@ class Plugin { */ constructor(player) { this.player = player; - evented(this, ['trigger']); + evented(this, {exclude: ['trigger']}); stateful(this, this.constructor.defaultState); player.plugins_[this.name] = true; player.one('dispose', Fn.bind(this, this.dispose)); diff --git a/test/unit/mixins/evented.test.js b/test/unit/mixins/evented.test.js index 548b2b9046..e056c68d36 100644 --- a/test/unit/mixins/evented.test.js +++ b/test/unit/mixins/evented.test.js @@ -21,7 +21,7 @@ QUnit.test('evented() mutations', function(assert) { }); QUnit.test('evented() with exclusions', function(assert) { - const target = evented({}, ['one']); + const target = evented({}, {exclude: ['one']}); assert.strictEqual(typeof target.off, 'function', 'the target has an off method'); assert.strictEqual(typeof target.on, 'function', 'the target has an on method'); @@ -29,6 +29,19 @@ QUnit.test('evented() with exclusions', function(assert) { assert.strictEqual(typeof target.trigger, 'function', 'the target has a trigger method'); }); +QUnit.test('evented() with custom element', function(assert) { + const target = evented({foo: Dom.createEl('span')}, {eventBusKey: 'foo'}); + + assert.strictEqual(target.eventBusEl_, target.foo, 'the custom DOM element is re-used'); + + assert.throws( + function() { + evented({foo: {}}, {eventBusKey: 'foo'}); + }, + new Error('eventBusKey "foo" does not refer to an element'), + 'throws if the target does not have an element at the supplied key'); +}); + QUnit.test('supports basic event handling (not complete functionality tests)', function(assert) { const spy = sinon.spy(); const target = evented({}); From 2aceef08eb1f5d9043357c18642cad8a3099e35c Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Thu, 27 Oct 2016 19:58:34 -0400 Subject: [PATCH 15/48] Re-implement component as an evented object --- src/js/component.js | 202 ++-------------------------- src/js/mixins/evented.js | 219 ++++++++++++++++++++++++++++++- test/unit/mixins/evented.test.js | 25 ++++ 3 files changed, 254 insertions(+), 192 deletions(-) diff --git a/src/js/component.js b/src/js/component.js index a9db8596f5..113cad0926 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -4,13 +4,13 @@ * @file component.js */ import window from 'global/window'; -import * as Dom from './utils/dom.js'; -import * as Fn from './utils/fn.js'; -import * as Guid from './utils/guid.js'; -import * as Events from './utils/events.js'; -import log from './utils/log.js'; -import toTitleCase from './utils/to-title-case.js'; -import mergeOptions from './utils/merge-options.js'; +import evented from './mixins/evented'; +import * as Dom from './utils/dom'; +import * as Fn from './utils/fn'; +import * as Guid from './utils/guid'; +import log from './utils/log'; +import toTitleCase from './utils/to-title-case'; +import mergeOptions from './utils/merge-options'; /** * Base class for all UI Components. @@ -82,6 +82,9 @@ class Component { this.el_ = this.createEl(); } + // Make this an evented object and use `el_` as its event bus. + evented(this, {eventBusKey: 'el_'}); + this.children_ = []; this.childIndex_ = {}; this.childNameIndex_ = {}; @@ -564,188 +567,9 @@ class Component { } /** - * Add an `event listener` to this `Component`s element. - * - * The benefit of using this over the following: - * - `VjsEvents.on(otherElement, 'eventName', myFunc)` - * - `otherComponent.on('eventName', myFunc)` - * - * 1. Is that the listeners will get cleaned up when either component gets disposed. - * 1. It will also bind `myComponent` as the context of `myFunc`. - * > NOTE: If you remove the element from the DOM that has used `on` you need to - * clean up references using: `myComponent.trigger(el, 'dispose')` - * This will also allow the browser to garbage collect it. In special - * cases such as with `window` and `document`, which are both permanent, - * this is not necessary. - * - * @param {string|Component|string[]} [first] - * The event name, and array of event names, or another `Component`. - * - * @param {EventTarget~EventListener|string|string[]} [second] - * The listener function, an event name, or an Array of events names. - * - * @param {EventTarget~EventListener} [third] - * The event handler if `first` is a `Component` and `second` is an event name - * or an Array of event names. - * - * @return {Component} - * Returns itself; method can be chained. - * - * @listens Component#dispose - */ - on(first, second, third) { - if (typeof first === 'string' || Array.isArray(first)) { - Events.on(this.el_, first, Fn.bind(this, second)); - - // Targeting another component or element - } else { - const target = first; - const type = second; - const fn = Fn.bind(this, third); - - // When this component is disposed, remove the listener from the other component - const removeOnDispose = () => this.off(target, type, fn); - - // Use the same function ID so we can remove it later it using the ID - // of the original listener - removeOnDispose.guid = fn.guid; - this.on('dispose', removeOnDispose); - - // If the other component is disposed first we need to clean the reference - // to the other component in this component's removeOnDispose listener - // Otherwise we create a memory leak. - const cleanRemover = () => this.off('dispose', removeOnDispose); - - // Add the same function ID so we can easily remove it later - cleanRemover.guid = fn.guid; - - // Check if this is a DOM node - if (first.nodeName) { - // Add the listener to the other element - Events.on(target, type, fn); - Events.on(target, 'dispose', cleanRemover); - - // Should be a component - // Not using `instanceof Component` because it makes mock players difficult - } else if (typeof first.on === 'function') { - // Add the listener to the other component - target.on(type, fn); - target.on('dispose', cleanRemover); - } - } - - return this; - } - - /** - * Remove an event listener from this `Component`s element. If the second argument is - * exluded all listeners for the type passed in as the first argument will be removed. - * - * @param {string|Component|string[]} [first] - * The event name, and array of event names, or another `Component`. - * - * @param {EventTarget~EventListener|string|string[]} [second] - * The listener function, an event name, or an Array of events names. - * - * @param {EventTarget~EventListener} [third] - * The event handler if `first` is a `Component` and `second` is an event name - * or an Array of event names. - * - * @return {Component} - * Returns itself; method can be chained. - */ - off(first, second, third) { - if (!first || typeof first === 'string' || Array.isArray(first)) { - Events.off(this.el_, first, second); - } else { - const target = first; - const type = second; - // Ensure there's at least a guid, even if the function hasn't been used - const fn = Fn.bind(this, third); - - // Remove the dispose listener on this component, - // which was given the same guid as the event listener - this.off('dispose', fn); - - if (first.nodeName) { - // Remove the listener - Events.off(target, type, fn); - // Remove the listener for cleaning the dispose listener - Events.off(target, 'dispose', fn); - } else { - target.off(type, fn); - target.off('dispose', fn); - } - } - - return this; - } - - /** - * Add an event listener that gets triggered only once and then gets removed. - * - * @param {string|Component|string[]} [first] - * The event name, and array of event names, or another `Component`. - * - * @param {EventTarget~EventListener|string|string[]} [second] - * The listener function, an event name, or an Array of events names. - * - * @param {EventTarget~EventListener} [third] - * The event handler if `first` is a `Component` and `second` is an event name - * or an Array of event names. - * - * @return {Component} - * Returns itself; method can be chained. - */ - one(first, second, third) { - if (typeof first === 'string' || Array.isArray(first)) { - Events.one(this.el_, first, Fn.bind(this, second)); - } else { - const target = first; - const type = second; - const fn = Fn.bind(this, third); - - const newFunc = () => { - this.off(target, type, newFunc); - fn.apply(null, arguments); - }; - - // Keep the same function ID so we can remove it later - newFunc.guid = fn.guid; - - this.on(target, type, newFunc); - } - - return this; - } - - /** - * Trigger an event on an element. - * - * @param {EventTarget~Event|Object|string} event - * The event name, and Event, or an event-like object with a type attribute - * set to the event name. - * - * @param {Object} [hash] - * Data hash to pass along with the event - * - * @return {Component} - * Returns itself; method can be chained. - */ - trigger(event, hash) { - Events.trigger(this.el_, event, hash); - return this; - } - - /** - * Bind a listener to the component's ready state. If the ready event has already - * happened it will trigger the function immediately. - * - * @param {Component~ReadyCallback} fn - * A function to call when ready is triggered. - * - * @param {boolean} [sync=false] - * Execute the listener synchronously if `Component` is ready. + * Bind a listener to the component's ready state. + * Different from event listeners in that if the ready event has already happened + * it will trigger the function immediately. * * @return {Component} * Returns itself; method can be chained. diff --git a/src/js/mixins/evented.js b/src/js/mixins/evented.js index b63e432469..f4d4733a0d 100644 --- a/src/js/mixins/evented.js +++ b/src/js/mixins/evented.js @@ -2,8 +2,222 @@ * @file mixins/evented.js */ import * as Dom from '../utils/dom'; +import * as Fn from '../utils/fn'; import * as Events from '../utils/events'; +/** + * Methods that can be mixed-in with any object to provide event capabilities. + * + * @name mixins/evented + * @type {Object} + */ +const mixin = { + + /** + * Add a listener to an event (or events) on this object or another evented + * object. + * + * @param {String|Array|Element|Object} first + * If this is a string or array, it represents an event type(s) and + * the listener will be bound to this object. + * + * Another evented object can be passed here instead, which will + * bind a listener to the given event(s) being triggered on THAT + * object. + * + * In either case, the listener's `this` value will be bound to + * this object. + * + * @param {String|Array|Function} second + * If the first argument was a string or array, this should be the + * listener function. Otherwise, this is a string or array of event + * type(s). + * + * @param {Function} [third] + * If the first argument was another evented object, this will be + * the listener function. + * + * @return {Object} + * Returns the object itself. + */ + on(first, second, third) { + // Targeting this evented object. + if (typeof first === 'string' || Array.isArray(first)) { + Events.on(this.eventBusEl_, first, Fn.bind(this, second)); + + // Targeting another evented object. + } else { + const target = first; + const type = second; + const listener = Fn.bind(this, third); + + // When this object is disposed, remove the listener from the target. + const removeOnDispose = () => this.off(target, type, listener); + + // Use the same function ID as the listener so we can remove it later it + // using the ID of the original listener. + removeOnDispose.guid = listener.guid; + this.on('dispose', removeOnDispose); + + // If the target is disposed first, we need to remove the reference to + // the target in this evented object's `removeOnDispose` listener. + // Otherwise, we create a memory leak. + const cleanRemover = () => this.off('dispose', removeOnDispose); + + // Add the same function ID so we can easily remove it later + cleanRemover.guid = listener.guid; + + // Handle cases where the target is a DOM node. + if (target.nodeName) { + Events.on(target, type, listener); + Events.on(target, 'dispose', cleanRemover); + + // Should be another evented object, which we detect via duck typing. + } else if (typeof target.on === 'function') { + target.on(type, listener); + target.on('dispose', cleanRemover); + + // If the target is not a valid object, throw. + } else { + throw new Error('target was not a DOM node or an evented object'); + } + } + + return this; + }, + + /** + * Add a listener to an event (or events) on this object or another evented + * object. The listener will be removed after the first time it is called. + * + * @param {String|Array|Element|Object} first + * If this is a string or array, it represents an event type(s) and + * the listener will be bound to this object. + * + * Another evented object can be passed here instead, which will + * bind a listener to the given event(s) being triggered on THAT + * object. + * + * In either case, the listener's `this` value will be bound to + * this object. + * + * @param {String|Array|Function} second + * If the first argument was a string or array, this should be the + * listener function. Otherwise, this is a string or array of event + * type(s). + * + * @param {Function} [third] + * If the first argument was another evented object, this will be + * the listener function. + * + * @return {Object} + * Returns the object itself. + */ + one(first, second, third) { + + // Targeting this evented object. + if (typeof first === 'string' || Array.isArray(first)) { + Events.one(this.eventBusEl_, first, Fn.bind(this, second)); + + // Targeting another evented object. + } else { + const target = first; + const type = second; + const listener = Fn.bind(this, third); + + const wrapper = (...args) => { + this.off(target, type, wrapper); + listener.apply(null, args); + }; + + // Keep the same function ID so we can remove it later + wrapper.guid = listener.guid; + this.on(target, type, wrapper); + } + + return this; + }, + + /** + * Removes listeners from events on an evented object. + * + * @param {String|Array|Element|Object} [first] + * If this is a string or array, it represents an event type(s) + * from which to remove the listener. + * + * Another evented object can be passed here instead, which will + * remove the listener from THAT object. + * + * If no arguments are passed at all, ALL listeners will be removed + * from this evented object. + * + * @param {String|Array|Function} [second] + * If the first argument was a string or array, this should be the + * listener function. Otherwise, this is a string or array of event + * type(s). + * + * @param {Function} [third] + * If the first argument was another evented object, this will be + * the listener function. + * + * @return {Object} + * Returns the object itself. + */ + off(first, second, third) { + + // Targeting this evented object. + if (!first || typeof first === 'string' || Array.isArray(first)) { + Events.off(this.eventBusEl_, first, second); + + // Targeting another evented object. + } else { + const target = first; + const type = second; + + // Ensure there's at least a guid, even if the function hasn't been used + const listener = Fn.bind(this, third); + + // Remove the dispose listener on this evented object, which was given + // the same guid as the event listener in on(). + this.off('dispose', listener); + + // Handle cases where the target is a DOM node. + if (first.nodeName) { + Events.off(target, type, listener); + Events.off(target, 'dispose', listener); + + // Should be another evented object, which we detect via duck typing. + } else if (typeof target.off === 'function') { + target.off(type, listener); + target.off('dispose', listener); + + // If the target is not a valid object, throw. + } else { + throw new Error('target was not a DOM node or an evented object'); + } + } + + return this; + }, + + /** + * Fire an event on this evented object. + * + * @param {String|Object} event + * An event type or an object with a type property. + * + * @param {Object} [hash] + * An additional object to pass along to listeners. + * + * @return {Object} + * Returns the object itself. + */ + trigger(event, hash) { + Events.trigger(this.el_, event, hash); + return this; + } +}; + /** * Makes an object "evented" - granting it methods from the `Events` utility. * @@ -40,12 +254,11 @@ function evented(target, options = {}) { target.eventBusEl_ = Dom.createEl('span', {className: 'vjs-event-bus'}); } + // Add the mixin methods with whichever exclusions were requested. ['off', 'on', 'one', 'trigger'] .filter(name => !exclude || exclude.indexOf(name) === -1) .forEach(name => { - target[name] = (...args) => { - return Events[name](...[target.eventBusEl_, ...args]); - }; + target[name] = Fn.bind(target, mixin[name]); }); return target; diff --git a/test/unit/mixins/evented.test.js b/test/unit/mixins/evented.test.js index e056c68d36..51b4c4b16f 100644 --- a/test/unit/mixins/evented.test.js +++ b/test/unit/mixins/evented.test.js @@ -59,3 +59,28 @@ QUnit.test('supports basic event handling (not complete functionality tests)', f assert.strictEqual(event.type, 'foo', 'the spy saw a "foo" event'); assert.strictEqual(hash.x, 1, 'the "foo" event included an extra hash'); }); + +QUnit.test('target objects add listeners for events on themselves', function(assert) { + +}); + +QUnit.test('target objects add listeners that only fire once for events on themselves', function(assert) { + +}); + +QUnit.test('target objects add listeners for events on themselves - and remove them when disposed', function(assert) { + +}); + +QUnit.test('target objects add listeners for events on other evented objects', function(assert) { + +}); + +QUnit.test('target objects add listeners that only fire once for events on other evented objects', function(assert) { + +}); + +QUnit.test('target objects add listeners for events on other evented objects - and remove them when disposed', function(assert) { + +}); + From 8668f3e52bad03a13416af773358bed79c36d528 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Fri, 28 Oct 2016 15:55:30 -0400 Subject: [PATCH 16/48] Fix tests --- src/js/component.js | 4 +++- src/js/mixins/evented.js | 4 ++-- src/js/player.js | 4 ++++ test/unit/mixins/evented.test.js | 12 ++++++------ test/unit/tracks/audio-track.test.js | 2 +- test/unit/tracks/track.test.js | 2 +- test/unit/tracks/video-track.test.js | 2 +- 7 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/js/component.js b/src/js/component.js index 113cad0926..f3d84b78f4 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -83,7 +83,9 @@ class Component { } // Make this an evented object and use `el_` as its event bus. - evented(this, {eventBusKey: 'el_'}); + if (this.el_) { + evented(this, {eventBusKey: 'el_'}); + } this.children_ = []; this.childIndex_ = {}; diff --git a/src/js/mixins/evented.js b/src/js/mixins/evented.js index f4d4733a0d..9cefd812a1 100644 --- a/src/js/mixins/evented.js +++ b/src/js/mixins/evented.js @@ -213,7 +213,7 @@ const mixin = { * Returns the object itself. */ trigger(event, hash) { - Events.trigger(this.el_, event, hash); + Events.trigger(this.eventBusEl_, event, hash); return this; } }; @@ -246,7 +246,7 @@ function evented(target, options = {}) { // Set or create the eventBusEl_. if (eventBusKey) { - if (!Dom.isEl(target[eventBusKey])) { + if (!target[eventBusKey].nodeName) { throw new Error(`eventBusKey "${eventBusKey}" does not refer to an element`); } target.eventBusEl_ = target[eventBusKey]; diff --git a/src/js/player.js b/src/js/player.js index 88215f1425..13dbf6fc9e 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -6,6 +6,7 @@ import Component from './component.js'; import document from 'global/document'; import window from 'global/window'; +import evented from './mixins/evented'; import * as Events from './utils/events.js'; import * as Dom from './utils/dom.js'; import * as Fn from './utils/fn.js'; @@ -350,6 +351,9 @@ class Player extends Component { this.plugins_ = {}; this.el_ = this.createEl(); + // Make this an evented object and use `el_` as its event bus. + evented(this, {eventBusKey: 'el_'}); + // We also want to pass the original player options to each component and plugin // as well so they don't need to reach back into the player for options later. // We also need to do another copy of this.options_ so we don't end up with diff --git a/test/unit/mixins/evented.test.js b/test/unit/mixins/evented.test.js index 51b4c4b16f..23fa76253f 100644 --- a/test/unit/mixins/evented.test.js +++ b/test/unit/mixins/evented.test.js @@ -61,26 +61,26 @@ QUnit.test('supports basic event handling (not complete functionality tests)', f }); QUnit.test('target objects add listeners for events on themselves', function(assert) { - + assert.expect(0); }); QUnit.test('target objects add listeners that only fire once for events on themselves', function(assert) { - + assert.expect(0); }); QUnit.test('target objects add listeners for events on themselves - and remove them when disposed', function(assert) { - + assert.expect(0); }); QUnit.test('target objects add listeners for events on other evented objects', function(assert) { - + assert.expect(0); }); QUnit.test('target objects add listeners that only fire once for events on other evented objects', function(assert) { - + assert.expect(0); }); QUnit.test('target objects add listeners for events on other evented objects - and remove them when disposed', function(assert) { - + assert.expect(0); }); diff --git a/test/unit/tracks/audio-track.test.js b/test/unit/tracks/audio-track.test.js index 55b20886df..bd33bf80f8 100644 --- a/test/unit/tracks/audio-track.test.js +++ b/test/unit/tracks/audio-track.test.js @@ -29,7 +29,7 @@ QUnit.test('defaults when items not provided', function(assert) { assert.equal(track.enabled, false, 'enabled defaulted to true since there is one track'); assert.equal(track.label, '', 'label defaults to empty string'); assert.equal(track.language, '', 'language defaults to empty string'); - assert.ok(track.id.match(/vjs_track_\d{5}/), 'id defaults to vjs_track_GUID'); + assert.ok(track.id.match(/vjs_track_\d+/), 'id defaults to vjs_track_GUID'); }); QUnit.test('kind can only be one of several options, defaults to empty string', function(assert) { diff --git a/test/unit/tracks/track.test.js b/test/unit/tracks/track.test.js index 5d0b91b155..dd5bf0c38c 100644 --- a/test/unit/tracks/track.test.js +++ b/test/unit/tracks/track.test.js @@ -30,5 +30,5 @@ QUnit.test('defaults when items not provided', function(assert) { assert.equal(track.kind, '', 'kind defaulted to empty string'); assert.equal(track.label, '', 'label defaults to empty string'); assert.equal(track.language, '', 'language defaults to empty string'); - assert.ok(track.id.match(/vjs_track_\d{5}/), 'id defaults to vjs_track_GUID'); + assert.ok(track.id.match(/vjs_track_\d+/), 'id defaults to vjs_track_GUID'); }); diff --git a/test/unit/tracks/video-track.test.js b/test/unit/tracks/video-track.test.js index 09b8650ff8..552310c619 100644 --- a/test/unit/tracks/video-track.test.js +++ b/test/unit/tracks/video-track.test.js @@ -31,7 +31,7 @@ QUnit.test('defaults when items not provided', function(assert) { 'selected defaulted to true since there is one track'); assert.equal(track.label, '', 'label defaults to empty string'); assert.equal(track.language, '', 'language defaults to empty string'); - assert.ok(track.id.match(/vjs_track_\d{5}/), 'id defaults to vjs_track_GUID'); + assert.ok(track.id.match(/vjs_track_\d+/), 'id defaults to vjs_track_GUID'); }); QUnit.test('kind can only be one of several options, defaults to empty string', function(assert) { From 6a5f8cc149354c6e6f5444dd5dd04b2e512dc043 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Sat, 29 Oct 2016 14:31:03 -0400 Subject: [PATCH 17/48] Evented progress --- src/js/component.js | 3 - src/js/mixins/evented.js | 261 ++++++++++++++++++++++--------- src/js/slider/slider.js | 5 +- test/index.html | 2 - test/karma.conf.js | 1 - test/unit/controls.test.js | 65 ++------ test/unit/mixins/evented.test.js | 54 ++++--- 7 files changed, 242 insertions(+), 149 deletions(-) diff --git a/src/js/component.js b/src/js/component.js index f3d84b78f4..989013aa03 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -138,9 +138,6 @@ class Component { this.childIndex_ = null; this.childNameIndex_ = null; - // Remove all event listeners. - this.off(); - // Remove element from DOM if (this.el_.parentNode) { this.el_.parentNode.removeChild(this.el_); diff --git a/src/js/mixins/evented.js b/src/js/mixins/evented.js index 9cefd812a1..5d12aa0699 100644 --- a/src/js/mixins/evented.js +++ b/src/js/mixins/evented.js @@ -5,6 +5,134 @@ import * as Dom from '../utils/dom'; import * as Fn from '../utils/fn'; import * as Events from '../utils/events'; +/** + * Returns whether or not an object has had the evented mixin applied. + * + * @private + * @param {Object} object + * @return {Boolean} + */ +const isEvented = (object) => + !!object.eventBusEl_ && + ['on', 'one', 'off', 'trigger'].every(k => typeof object[k] === 'function'); + +/** + * Whether a value is a valid event type - string or array. + * + * @param {String|Array} type + * @return {Boolean} + */ +const isValidEventType = (type) => typeof type === 'string' || Array.isArray(type); + +/** + * Validates a value to determine if it is a valid event target. Throws if not. + * + * @throws {Error} + * @param {Object} target + */ +const validateTarget = (target) => { + if (!target.nodeName && !isEvented(target)) { + throw new Error('invalid target; must be a DOM node or evented object'); + } +}; + +/** + * Validates a value to determine if it is a valid event target. Throws if not. + * + * @throws {Error} + * @param {String|Array} type + */ +const validateEventType = (type) => { + if (!isValidEventType(type)) { + throw new Error('invalid event type; must be a string or array'); + } +}; + +/** + * Validates a value to determine if it is a valid listener. Throws if not. + * + * @throws Error + * @param {Function} listener + */ +const validateListener = (listener) => { + if (typeof listener !== 'function') { + throw new Error('invalid listener; must be a function'); + } +}; + +/** + * Takes an array of arguments given to on() or one(), validates them, and + * normalizes them into an object. + * + * @throws Error + * @param {Object} self + * The evented object on which on() or one() was called. + * + * @param {Array} args + * An array of arguments passed to on() or one(). + * + * @return {Object} + */ +const normalizeListenArgs = (self, args) => { + + // If the number of arguments is less than 3, the target is always the + // evented object itself. + const isTargetingSelf = args.length < 3 || args[0] === self || args[0] === self.eventBusEl_; + let target; + let type; + let listener; + + if (isTargetingSelf) { + target = self.eventBusEl_; + + // Deal with cases where we got 3 arguments, but we are still listening to + // the evented object itself. + if (args.length >= 3) { + args.shift(); + } + + [type, listener] = args; + } else { + [target, type, listener] = args; + } + + validateTarget(target); + validateEventType(type); + validateListener(listener); + + listener = Fn.bind(self, listener); + + return {isTargetingSelf, target, type, listener}; +}; + +/** + * Adds the listener to the event type(s) on the target, normalizing for + * the type of target. + * + * @private + * @throws {Error} If unable to add the listener + * @param {Element|Object} target + * A DOM node or evented object. + * + * @param {String|Array} type + * One or more event type(s). + * + * @param {Function} listener + * A listener function. + * + * @param {String} [method="on"] + * The event binding method to use. + */ +const listen = (target, type, listener, method = 'on') => { + validateTarget(target); + + if (target.nodeName) { + Events[method](target, type, listener); + } else { + target[method](type, listener); + } +}; + /** * Methods that can be mixed-in with any object to provide event capabilities. * @@ -17,7 +145,7 @@ const mixin = { * Add a listener to an event (or events) on this object or another evented * object. * - * @param {String|Array|Element|Object} first + * @param {String|Array|Element|Object} targetOrType * If this is a string or array, it represents an event type(s) and * the listener will be bound to this object. * @@ -28,59 +156,44 @@ const mixin = { * In either case, the listener's `this` value will be bound to * this object. * - * @param {String|Array|Function} second + * @param {String|Array|Function} typeOrListener * If the first argument was a string or array, this should be the * listener function. Otherwise, this is a string or array of event * type(s). * - * @param {Function} [third] + * @param {Function} [listener] * If the first argument was another evented object, this will be * the listener function. * * @return {Object} * Returns the object itself. */ - on(first, second, third) { - // Targeting this evented object. - if (typeof first === 'string' || Array.isArray(first)) { - Events.on(this.eventBusEl_, first, Fn.bind(this, second)); + on(...args) { + const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args); - // Targeting another evented object. - } else { - const target = first; - const type = second; - const listener = Fn.bind(this, third); + listen(target, type, listener); + + // If this object is listening to another evented object. + if (!isTargetingSelf) { - // When this object is disposed, remove the listener from the target. - const removeOnDispose = () => this.off(target, type, listener); + // If this object is disposed, remove the listener. + const removeListenerOnDispose = () => this.off(target, type, listener); // Use the same function ID as the listener so we can remove it later it // using the ID of the original listener. - removeOnDispose.guid = listener.guid; - this.on('dispose', removeOnDispose); - - // If the target is disposed first, we need to remove the reference to - // the target in this evented object's `removeOnDispose` listener. - // Otherwise, we create a memory leak. - const cleanRemover = () => this.off('dispose', removeOnDispose); + removeListenerOnDispose.guid = listener.guid; - // Add the same function ID so we can easily remove it later - cleanRemover.guid = listener.guid; - - // Handle cases where the target is a DOM node. - if (target.nodeName) { - Events.on(target, type, listener); - Events.on(target, 'dispose', cleanRemover); + // Add a listener to the target's dispose event as well. This ensures + // that if the target is disposed BEFORE this object, we remove the + // removal listener that was just added. Otherwise, we create a memory leak. + const removeRemoverOnTargetDispose = () => this.off('dispose', removeListenerOnDispose); - // Should be another evented object, which we detect via duck typing. - } else if (typeof target.on === 'function') { - target.on(type, listener); - target.on('dispose', cleanRemover); + // Use the same function ID as the listener so we can remove it later + // it using the ID of the original listener. + removeRemoverOnTargetDispose.guid = listener.guid; - // If the target is not a valid object, throw. - } else { - throw new Error('target was not a DOM node or an evented object'); - } + listen(this, 'dispose', removeListenerOnDispose); + listen(target, 'dispose', removeRemoverOnTargetDispose); } return this; @@ -88,9 +201,9 @@ const mixin = { /** * Add a listener to an event (or events) on this object or another evented - * object. The listener will be removed after the first time it is called. + * object. The listener will only be called once and then removed. * - * @param {String|Array|Element|Object} first + * @param {String|Array|Element|Object} targetOrType * If this is a string or array, it represents an event type(s) and * the listener will be bound to this object. * @@ -101,38 +214,36 @@ const mixin = { * In either case, the listener's `this` value will be bound to * this object. * - * @param {String|Array|Function} second + * @param {String|Array|Function} typeOrListener * If the first argument was a string or array, this should be the * listener function. Otherwise, this is a string or array of event * type(s). * - * @param {Function} [third] + * @param {Function} [listener] * If the first argument was another evented object, this will be * the listener function. * * @return {Object} * Returns the object itself. */ - one(first, second, third) { + one(...args) { + const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args); // Targeting this evented object. - if (typeof first === 'string' || Array.isArray(first)) { - Events.one(this.eventBusEl_, first, Fn.bind(this, second)); + if (isTargetingSelf) { + listen(target, type, listener, 'one'); // Targeting another evented object. } else { - const target = first; - const type = second; - const listener = Fn.bind(this, third); - - const wrapper = (...args) => { + const wrapper = (...largs) => { this.off(target, type, wrapper); - listener.apply(null, args); + listener.apply(null, largs); }; - // Keep the same function ID so we can remove it later + // Use the same function ID as the listener so we can remove it later + // it using the ID of the original listener. wrapper.guid = listener.guid; - this.on(target, type, wrapper); + listen(target, type, wrapper, 'one'); } return this; @@ -141,59 +252,58 @@ const mixin = { /** * Removes listeners from events on an evented object. * - * @param {String|Array|Element|Object} [first] - * If this is a string or array, it represents an event type(s) - * from which to remove the listener. + * @param {String|Array|Element|Object} [targetOrType] + * If this is a string or array, it represents an event type(s) and + * the listener will be bound to this object. * * Another evented object can be passed here instead, which will - * remove the listener from THAT object. + * bind a listener to the given event(s) being triggered on THAT + * object. * - * If no arguments are passed at all, ALL listeners will be removed - * from this evented object. + * In either case, the listener's `this` value will be bound to + * this object. * - * @param {String|Array|Function} [second] + * @param {String|Array|Function} [typeOrListener] * If the first argument was a string or array, this should be the * listener function. Otherwise, this is a string or array of event * type(s). * - * @param {Function} [third] + * @param {Function} [listener] * If the first argument was another evented object, this will be * the listener function. * * @return {Object} * Returns the object itself. */ - off(first, second, third) { + off(targetOrType, typeOrListener, listener) { // Targeting this evented object. - if (!first || typeof first === 'string' || Array.isArray(first)) { - Events.off(this.eventBusEl_, first, second); + if (!targetOrType || isValidEventType(targetOrType)) { + Events.off(this.eventBusEl_, targetOrType, typeOrListener); // Targeting another evented object. } else { - const target = first; - const type = second; + const target = targetOrType; + const type = typeOrListener; + + // Fail fast and in a meaningful way! + validateTarget(target); + validateEventType(type); + validateListener(listener); // Ensure there's at least a guid, even if the function hasn't been used - const listener = Fn.bind(this, third); + listener = Fn.bind(this, listener); // Remove the dispose listener on this evented object, which was given // the same guid as the event listener in on(). this.off('dispose', listener); - // Handle cases where the target is a DOM node. - if (first.nodeName) { + if (target.nodeName) { Events.off(target, type, listener); Events.off(target, 'dispose', listener); - - // Should be another evented object, which we detect via duck typing. - } else if (typeof target.off === 'function') { + } else if (isEvented(target)) { target.off(type, listener); target.off('dispose', listener); - - // If the target is not a valid object, throw. - } else { - throw new Error('target was not a DOM node or an evented object'); } } @@ -261,6 +371,9 @@ function evented(target, options = {}) { target[name] = Fn.bind(target, mixin[name]); }); + // When any evented object is disposed, it removes all its listeners. + target.on('dispose', () => target.off()); + return target; } diff --git a/src/js/slider/slider.js b/src/js/slider/slider.js index 11f7b4eaf6..db3cf2442a 100644 --- a/src/js/slider/slider.js +++ b/src/js/slider/slider.js @@ -38,7 +38,10 @@ class Slider extends Component { this.on('click', this.handleClick); this.on(player, 'controlsvisible', this.update); - this.on(player, this.playerEvent, this.update); + + if (this.playerEvent) { + this.on(player, this.playerEvent, this.update); + } } /** diff --git a/test/index.html b/test/index.html index 595b238bd9..211fd404a2 100644 --- a/test/index.html +++ b/test/index.html @@ -10,8 +10,6 @@
- - diff --git a/test/karma.conf.js b/test/karma.conf.js index 1be85889c8..22c189fa1a 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -10,7 +10,6 @@ module.exports = function(config) { // Compling tests here files: [ '../build/temp/video-js.css', - '../build/temp/ie8/videojs-ie8.min.js', '../test/globals-shim.js', '../test/unit/**/*.js', '../build/temp/browserify.js', diff --git a/test/unit/controls.test.js b/test/unit/controls.test.js index 079aa7f2fd..6bcc2be318 100644 --- a/test/unit/controls.test.js +++ b/test/unit/controls.test.js @@ -11,49 +11,23 @@ QUnit.module('Controls'); QUnit.test('should hide volume control if it\'s not supported', function(assert) { assert.expect(2); - const noop = function() {}; - const player = { - id: noop, - on: noop, - ready: noop, - tech_: { - featuresVolumeControl: false - }, - volume() {}, - muted() {}, - reportUserActivity() {} - }; + + const player = TestHelpers.makePlayer(); + + player.tech_.featuresVolumeControl = false; const volumeControl = new VolumeControl(player); const muteToggle = new MuteToggle(player); - assert.ok(volumeControl.el().className.indexOf('vjs-hidden') >= 0, 'volumeControl is not hidden'); - assert.ok(muteToggle.el().className.indexOf('vjs-hidden') >= 0, 'muteToggle is not hidden'); + assert.ok(volumeControl.hasClass('vjs-hidden'), 'volumeControl is not hidden'); + assert.ok(muteToggle.hasClass('vjs-hidden'), 'muteToggle is not hidden'); + player.dispose(); }); QUnit.test('should test and toggle volume control on `loadstart`', function(assert) { - const noop = function() {}; - const listeners = []; - const player = { - id: noop, - on(event, callback) { - // don't fire dispose listeners - if (event !== 'dispose') { - listeners.push(callback); - } - }, - ready: noop, - volume() { - return 1; - }, - muted() { - return false; - }, - tech_: { - featuresVolumeControl: true - }, - reportUserActivity() {} - }; + const player = TestHelpers.makePlayer(); + + player.tech_.featuresVolumeControl = true; const volumeControl = new VolumeControl(player); const muteToggle = new MuteToggle(player); @@ -62,30 +36,23 @@ QUnit.test('should test and toggle volume control on `loadstart`', function(asse assert.equal(muteToggle.hasClass('vjs-hidden'), false, 'muteToggle is hidden initially'); player.tech_.featuresVolumeControl = false; - for (let i = 0; i < listeners.length; i++) { - listeners[i](); - } + player.trigger('loadstart'); assert.equal(volumeControl.hasClass('vjs-hidden'), true, 'volumeControl does not hide itself'); assert.equal(muteToggle.hasClass('vjs-hidden'), true, 'muteToggle does not hide itself'); player.tech_.featuresVolumeControl = true; - for (let i = 0; i < listeners.length; i++) { - listeners[i](); - } + player.trigger('loadstart'); assert.equal(volumeControl.hasClass('vjs-hidden'), false, 'volumeControl does not show itself'); assert.equal(muteToggle.hasClass('vjs-hidden'), false, 'muteToggle does not show itself'); }); QUnit.test('calculateDistance should use changedTouches, if available', function(assert) { - const noop = function() {}; - const player = { - id: noop, - on: noop, - ready: noop, - reportUserActivity: noop - }; + const player = TestHelpers.makePlayer(); + + player.tech_.featuresVolumeControl = true; + const slider = new Slider(player); document.body.appendChild(slider.el_); diff --git a/test/unit/mixins/evented.test.js b/test/unit/mixins/evented.test.js index 23fa76253f..3f8e68a459 100644 --- a/test/unit/mixins/evented.test.js +++ b/test/unit/mixins/evented.test.js @@ -1,5 +1,4 @@ /* eslint-env qunit */ -import sinon from 'sinon'; import evented from '../../../src/js/mixins/evented'; import * as Dom from '../../../src/js/utils/dom'; import * as Obj from '../../../src/js/utils/obj'; @@ -42,45 +41,62 @@ QUnit.test('evented() with custom element', function(assert) { 'throws if the target does not have an element at the supplied key'); }); -QUnit.test('supports basic event handling (not complete functionality tests)', function(assert) { - const spy = sinon.spy(); - const target = evented({}); +QUnit.test('on() errors', function(assert) { + assert.expect(0); +}); + +QUnit.test('one() errors', function(assert) { + assert.expect(0); +}); + +QUnit.test('off() errors', function(assert) { + assert.expect(0); +}); + +QUnit.test('a.on("x", fn)', function(assert) { + assert.expect(0); +}); - target.on('foo', spy); - target.trigger('foo', {x: 1}); - target.off('foo'); - target.trigger('foo'); +QUnit.test('a.on(["x", "y"], fn)', function(assert) { + assert.expect(0); +}); - assert.strictEqual(spy.callCount, 1, 'the spy was called once'); +QUnit.test('a.one("x", fn)', function(assert) { + assert.expect(0); +}); - const event = spy.firstCall.args[0]; - const hash = spy.firstCall.args[1]; +QUnit.test('a.one(["x", "y"], fn)', function(assert) { + assert.expect(0); +}); - assert.strictEqual(event.type, 'foo', 'the spy saw a "foo" event'); - assert.strictEqual(hash.x, 1, 'the "foo" event included an extra hash'); +QUnit.test('a.on(b, "x", fn)', function(assert) { + assert.expect(0); }); -QUnit.test('target objects add listeners for events on themselves', function(assert) { +QUnit.test('a.on(b, ["x", "y"], fn)', function(assert) { assert.expect(0); }); -QUnit.test('target objects add listeners that only fire once for events on themselves', function(assert) { +QUnit.test('a.one(b, "x", fn)', function(assert) { assert.expect(0); }); -QUnit.test('target objects add listeners for events on themselves - and remove them when disposed', function(assert) { +QUnit.test('a.one(b, ["x", "y"], fn)', function(assert) { assert.expect(0); }); -QUnit.test('target objects add listeners for events on other evented objects', function(assert) { +QUnit.test('a.off()', function(assert) { assert.expect(0); }); -QUnit.test('target objects add listeners that only fire once for events on other evented objects', function(assert) { +QUnit.test('a.off("x")', function(assert) { assert.expect(0); }); -QUnit.test('target objects add listeners for events on other evented objects - and remove them when disposed', function(assert) { +QUnit.test('a.off("x", fn)', function(assert) { assert.expect(0); }); +QUnit.test('a.off(b, "x", fn)', function(assert) { + assert.expect(0); +}); From a6876cae51e4af52cd11bc95ab313d7fbcb79d7b Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Sun, 30 Oct 2016 11:02:47 -0400 Subject: [PATCH 18/48] Evented tests --- src/js/mixins/evented.js | 19 +- test/unit/mixins/evented.test.js | 391 +++++++++++++++++++++++++++++-- 2 files changed, 380 insertions(+), 30 deletions(-) diff --git a/src/js/mixins/evented.js b/src/js/mixins/evented.js index 5d12aa0699..4c4982b1ba 100644 --- a/src/js/mixins/evented.js +++ b/src/js/mixins/evented.js @@ -17,12 +17,14 @@ const isEvented = (object) => ['on', 'one', 'off', 'trigger'].every(k => typeof object[k] === 'function'); /** - * Whether a value is a valid event type - string or array. + * Whether a value is a valid event type - non-empty string or array. * * @param {String|Array} type * @return {Boolean} */ -const isValidEventType = (type) => typeof type === 'string' || Array.isArray(type); +const isValidEventType = (type) => + (typeof type === 'string' && (/\S/).test(type)) || + (Array.isArray(type) && !!type.length); /** * Validates a value to determine if it is a valid event target. Throws if not. @@ -44,7 +46,7 @@ const validateTarget = (target) => { */ const validateEventType = (type) => { if (!isValidEventType(type)) { - throw new Error('invalid event type; must be a string or array'); + throw new Error('invalid event type; must be a non-empty string or array'); } }; @@ -253,15 +255,10 @@ const mixin = { * Removes listeners from events on an evented object. * * @param {String|Array|Element|Object} [targetOrType] - * If this is a string or array, it represents an event type(s) and - * the listener will be bound to this object. - * - * Another evented object can be passed here instead, which will - * bind a listener to the given event(s) being triggered on THAT - * object. + * If this is a string or array, it represents an event type(s). * - * In either case, the listener's `this` value will be bound to - * this object. + * Another evented object can be passed here instead, in which case + * ALL 3 arguments are REQUIRED. * * @param {String|Array|Function} [typeOrListener] * If the first argument was a string or array, this should be the diff --git a/test/unit/mixins/evented.test.js b/test/unit/mixins/evented.test.js index 3f8e68a459..d365678152 100644 --- a/test/unit/mixins/evented.test.js +++ b/test/unit/mixins/evented.test.js @@ -1,12 +1,22 @@ /* eslint-env qunit */ +import sinon from 'sinon'; import evented from '../../../src/js/mixins/evented'; import * as Dom from '../../../src/js/utils/dom'; import * as Obj from '../../../src/js/utils/obj'; -QUnit.module('mixins: evented'); +QUnit.module('mixins: evented', { + + beforeEach() { + this.targets = {}; + }, + + afterEach() { + Object.keys(this.targets).forEach(k => this.targets[k].trigger('dispose')); + } +}); QUnit.test('evented() mutations', function(assert) { - const target = {}; + const target = this.targets.a = {}; assert.strictEqual(typeof evented, 'function', 'the mixin is a function'); assert.strictEqual(evented(target), target, 'returns the target object'); @@ -29,7 +39,7 @@ QUnit.test('evented() with exclusions', function(assert) { }); QUnit.test('evented() with custom element', function(assert) { - const target = evented({foo: Dom.createEl('span')}, {eventBusKey: 'foo'}); + const target = this.targets.a = evented({foo: Dom.createEl('span')}, {eventBusKey: 'foo'}); assert.strictEqual(target.eventBusEl_, target.foo, 'the custom DOM element is re-used'); @@ -38,65 +48,408 @@ QUnit.test('evented() with custom element', function(assert) { evented({foo: {}}, {eventBusKey: 'foo'}); }, new Error('eventBusKey "foo" does not refer to an element'), - 'throws if the target does not have an element at the supplied key'); + 'throws if the target does not have an element at the supplied key' + ); }); QUnit.test('on() errors', function(assert) { - assert.expect(0); + const target = this.targets.a = evented({}); + + assert.throws( + function() { + target.on(); + }, + new Error('invalid event type; must be a non-empty string or array') + ); + + assert.throws( + function() { + target.on(' '); + }, + new Error('invalid event type; must be a non-empty string or array') + ); + + assert.throws( + function() { + target.on([]); + }, + new Error('invalid event type; must be a non-empty string or array'), + '' + ); + + assert.throws( + function() { + target.on('x'); + }, + new Error('invalid listener; must be a function') + ); + + assert.throws( + function() { + target.on({}, 'x', () => {}); + }, + new Error('invalid target; must be a DOM node or evented object') + ); + + assert.throws( + function() { + target.on(evented({}), 'x', null); + }, + new Error('invalid listener; must be a function') + ); }); QUnit.test('one() errors', function(assert) { - assert.expect(0); + const target = this.targets.a = evented({}); + + assert.throws( + function() { + target.one(); + }, + new Error('invalid event type; must be a non-empty string or array') + ); + + assert.throws( + function() { + target.one(' '); + }, + new Error('invalid event type; must be a non-empty string or array') + ); + + assert.throws( + function() { + target.one([]); + }, + new Error('invalid event type; must be a non-empty string or array'), + '' + ); + + assert.throws( + function() { + target.one('x'); + }, + new Error('invalid listener; must be a function') + ); + + assert.throws( + function() { + target.one({}, 'x', () => {}); + }, + new Error('invalid target; must be a DOM node or evented object') + ); + + assert.throws( + function() { + target.one(evented({}), 'x', null); + }, + new Error('invalid listener; must be a function') + ); }); QUnit.test('off() errors', function(assert) { - assert.expect(0); + const target = this.targets.a = evented({}); + + // An invalid event actually causes an invalid target error because it + // gets passed into code that assumes the first argument is the target. + assert.throws( + function() { + target.off([]); + }, + new Error('invalid target; must be a DOM node or evented object') + ); + + assert.throws( + function() { + target.off({}, 'x', () => {}); + }, + new Error('invalid target; must be a DOM node or evented object') + ); + + assert.throws( + function() { + target.off(evented({}), '', () => {}); + }, + new Error('invalid event type; must be a non-empty string or array') + ); + + assert.throws( + function() { + target.off(evented({}), [], () => {}); + }, + new Error('invalid event type; must be a non-empty string or array') + ); + + assert.throws( + function() { + target.off(evented({}), 'x', null); + }, + new Error('invalid listener; must be a function') + ); }); QUnit.test('a.on("x", fn)', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const spy = sinon.spy(); + + a.on('x', spy); + a.trigger('x'); + + assert.strictEqual(spy.callCount, 1); + assert.strictEqual(spy.getCall(0).thisValue, a); + assert.strictEqual(spy.getCall(0).args[0].type, 'x'); + assert.strictEqual(spy.getCall(0).args[0].target, a.eventBusEl_); }); QUnit.test('a.on(["x", "y"], fn)', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const spy = sinon.spy(); + + a.on(['x', 'y'], spy); + a.trigger('x'); + a.trigger('y'); + + assert.strictEqual(spy.callCount, 2); + assert.strictEqual(spy.getCall(0).thisValue, a); + assert.strictEqual(spy.getCall(0).args[0].type, 'x'); + assert.strictEqual(spy.getCall(0).args[0].target, a.eventBusEl_); + assert.strictEqual(spy.getCall(1).thisValue, a); + assert.strictEqual(spy.getCall(1).args[0].type, 'y'); + assert.strictEqual(spy.getCall(1).args[0].target, a.eventBusEl_); }); QUnit.test('a.one("x", fn)', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const spy = sinon.spy(); + + a.one('x', spy); + a.trigger('x'); + a.trigger('x'); + + assert.strictEqual(spy.callCount, 1); + assert.strictEqual(spy.getCall(0).thisValue, a); + assert.strictEqual(spy.getCall(0).args[0].type, 'x'); + assert.strictEqual(spy.getCall(0).args[0].target, a.eventBusEl_); }); QUnit.test('a.one(["x", "y"], fn)', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const spy = sinon.spy(); + + a.one(['x', 'y'], spy); + a.trigger('x'); + a.trigger('y'); + a.trigger('x'); + a.trigger('y'); + + assert.strictEqual(spy.callCount, 2); + assert.strictEqual(spy.getCall(0).thisValue, a); + assert.strictEqual(spy.getCall(0).args[0].type, 'x'); + assert.strictEqual(spy.getCall(0).args[0].target, a.eventBusEl_); + assert.strictEqual(spy.getCall(1).thisValue, a); + assert.strictEqual(spy.getCall(1).args[0].type, 'y'); + assert.strictEqual(spy.getCall(1).args[0].target, a.eventBusEl_); }); QUnit.test('a.on(b, "x", fn)', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const b = this.targets.b = evented({}); + const spy = sinon.spy(); + + a.on(b, 'x', spy); + b.trigger('x'); + + // Make sure we aren't magically binding a listener to "a". + a.trigger('x'); + + assert.strictEqual(spy.callCount, 1); + assert.strictEqual(spy.getCall(0).thisValue, a); + assert.strictEqual(spy.getCall(0).args[0].type, 'x'); + assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); }); QUnit.test('a.on(b, ["x", "y"], fn)', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const b = this.targets.b = evented({}); + const spy = sinon.spy(); + + a.on(b, ['x', 'y'], spy); + b.trigger('x'); + b.trigger('y'); + + // Make sure we aren't magically binding a listener to "a". + a.trigger('x'); + a.trigger('y'); + + assert.strictEqual(spy.callCount, 2); + assert.strictEqual(spy.getCall(0).thisValue, a); + assert.strictEqual(spy.getCall(0).args[0].type, 'x'); + assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); + assert.strictEqual(spy.getCall(1).thisValue, a); + assert.strictEqual(spy.getCall(1).args[0].type, 'y'); + assert.strictEqual(spy.getCall(1).args[0].target, b.eventBusEl_); }); QUnit.test('a.one(b, "x", fn)', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const b = this.targets.b = evented({}); + const spy = sinon.spy(); + + a.one(b, 'x', spy); + b.trigger('x'); + + // Make sure we aren't magically binding a listener to "a". + a.trigger('x'); + + assert.strictEqual(spy.callCount, 1); + assert.strictEqual(spy.getCall(0).thisValue, a); + assert.strictEqual(spy.getCall(0).args[0].type, 'x'); + assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); }); +// The behavior here unfortunately differs from the identical case where "a" +// listens to itself. This is something that should be resolved... QUnit.test('a.one(b, ["x", "y"], fn)', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const b = this.targets.b = evented({}); + const spy = sinon.spy(); + + a.one(b, ['x', 'y'], spy); + b.trigger('x'); + b.trigger('y'); + b.trigger('x'); + b.trigger('y'); + + // Make sure we aren't magically binding a listener to "a". + a.trigger('x'); + a.trigger('y'); + + assert.strictEqual(spy.callCount, 1); + assert.strictEqual(spy.getCall(0).thisValue, a); + assert.strictEqual(spy.getCall(0).args[0].type, 'x'); + assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); }); QUnit.test('a.off()', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const spyX = sinon.spy(); + const spyY = sinon.spy(); + + a.on('x', spyX); + a.on('y', spyY); + a.trigger('x'); + a.trigger('y'); + a.off(); + a.trigger('x'); + a.trigger('y'); + + assert.strictEqual(spyX.callCount, 1); + assert.strictEqual(spyX.getCall(0).thisValue, a); + assert.strictEqual(spyX.getCall(0).args[0].type, 'x'); + assert.strictEqual(spyX.getCall(0).args[0].target, a.eventBusEl_); + + assert.strictEqual(spyY.callCount, 1); + assert.strictEqual(spyY.getCall(0).thisValue, a); + assert.strictEqual(spyY.getCall(0).args[0].type, 'y'); + assert.strictEqual(spyY.getCall(0).args[0].target, a.eventBusEl_); }); QUnit.test('a.off("x")', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const spyX = sinon.spy(); + const spyY = sinon.spy(); + + a.on('x', spyX); + a.on('y', spyY); + a.trigger('x'); + a.trigger('y'); + a.off('x'); + a.trigger('x'); + a.trigger('y'); + + assert.strictEqual(spyX.callCount, 1); + assert.strictEqual(spyX.getCall(0).thisValue, a); + assert.strictEqual(spyX.getCall(0).args[0].type, 'x'); + assert.strictEqual(spyX.getCall(0).args[0].target, a.eventBusEl_); + + assert.strictEqual(spyY.callCount, 2); + assert.strictEqual(spyY.getCall(0).thisValue, a); + assert.strictEqual(spyY.getCall(0).args[0].type, 'y'); + assert.strictEqual(spyY.getCall(0).args[0].target, a.eventBusEl_); + assert.strictEqual(spyY.getCall(1).thisValue, a); + assert.strictEqual(spyY.getCall(1).args[0].type, 'y'); + assert.strictEqual(spyY.getCall(1).args[0].target, a.eventBusEl_); }); QUnit.test('a.off("x", fn)', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const spyX1 = sinon.spy(); + const spyX2 = sinon.spy(); + const spyY = sinon.spy(); + + a.on('x', spyX1); + a.on('x', spyX2); + a.on('y', spyY); + a.trigger('x'); + a.trigger('y'); + a.off('x', spyX1); + a.trigger('x'); + a.trigger('y'); + + assert.strictEqual(spyX1.callCount, 1); + assert.strictEqual(spyX1.getCall(0).thisValue, a); + assert.strictEqual(spyX1.getCall(0).args[0].type, 'x'); + assert.strictEqual(spyX1.getCall(0).args[0].target, a.eventBusEl_); + + assert.strictEqual(spyX2.callCount, 2); + assert.strictEqual(spyX2.getCall(0).thisValue, a); + assert.strictEqual(spyX2.getCall(0).args[0].type, 'x'); + assert.strictEqual(spyX2.getCall(0).args[0].target, a.eventBusEl_); + assert.strictEqual(spyX2.getCall(1).thisValue, a); + assert.strictEqual(spyX2.getCall(1).args[0].type, 'x'); + assert.strictEqual(spyX2.getCall(1).args[0].target, a.eventBusEl_); + + assert.strictEqual(spyY.callCount, 2); + assert.strictEqual(spyY.getCall(0).thisValue, a); + assert.strictEqual(spyY.getCall(0).args[0].type, 'y'); + assert.strictEqual(spyY.getCall(0).args[0].target, a.eventBusEl_); + assert.strictEqual(spyY.getCall(1).thisValue, a); + assert.strictEqual(spyY.getCall(1).args[0].type, 'y'); + assert.strictEqual(spyY.getCall(1).args[0].target, a.eventBusEl_); }); QUnit.test('a.off(b, "x", fn)', function(assert) { - assert.expect(0); + const a = this.targets.a = evented({}); + const b = this.targets.b = evented({}); + const spy = sinon.spy(); + + a.on(b, 'x', spy); + b.trigger('x'); + a.off(b, 'x', spy); + b.trigger('x'); + + assert.strictEqual(spy.callCount, 1); + assert.strictEqual(spy.getCall(0).thisValue, a); + assert.strictEqual(spy.getCall(0).args[0].type, 'x'); + assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); +}); + +QUnit.test('a.off(b, ["x", "y"], fn)', function(assert) { + const a = this.targets.a = evented({}); + const b = this.targets.b = evented({}); + const spy = sinon.spy(); + + a.on(b, ['x', 'y'], spy); + b.trigger('x'); + b.trigger('y'); + a.off(b, ['x', 'y'], spy); + b.trigger('x'); + b.trigger('y'); + + assert.strictEqual(spy.callCount, 2); + assert.strictEqual(spy.getCall(0).thisValue, a); + assert.strictEqual(spy.getCall(0).args[0].type, 'x'); + assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); + assert.strictEqual(spy.getCall(1).thisValue, a); + assert.strictEqual(spy.getCall(1).args[0].type, 'y'); + assert.strictEqual(spy.getCall(1).args[0].target, b.eventBusEl_); }); From 8e8fd9a6da14429f5929fdbb4d58aced98e070e2 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Sun, 30 Oct 2016 15:41:40 -0400 Subject: [PATCH 19/48] Roll back implementation of evented in components --- src/js/component.js | 204 ++++++++++++++++++++++++++++++++++--- src/js/player.js | 4 - src/js/slider/slider.js | 5 +- test/unit/controls.test.js | 65 +++++++++--- 4 files changed, 242 insertions(+), 36 deletions(-) diff --git a/src/js/component.js b/src/js/component.js index 989013aa03..7bf74b8d2e 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -4,13 +4,13 @@ * @file component.js */ import window from 'global/window'; -import evented from './mixins/evented'; -import * as Dom from './utils/dom'; -import * as Fn from './utils/fn'; -import * as Guid from './utils/guid'; -import log from './utils/log'; -import toTitleCase from './utils/to-title-case'; -import mergeOptions from './utils/merge-options'; +import * as Dom from './utils/dom.js'; +import * as Fn from './utils/fn.js'; +import * as Guid from './utils/guid.js'; +import * as Events from './utils/events.js'; +import log from './utils/log.js'; +import toTitleCase from './utils/to-title-case.js'; +import mergeOptions from './utils/merge-options.js'; /** * Base class for all UI Components. @@ -82,11 +82,6 @@ class Component { this.el_ = this.createEl(); } - // Make this an evented object and use `el_` as its event bus. - if (this.el_) { - evented(this, {eventBusKey: 'el_'}); - } - this.children_ = []; this.childIndex_ = {}; this.childNameIndex_ = {}; @@ -138,6 +133,9 @@ class Component { this.childIndex_ = null; this.childNameIndex_ = null; + // Remove all event listeners. + this.off(); + // Remove element from DOM if (this.el_.parentNode) { this.el_.parentNode.removeChild(this.el_); @@ -565,6 +563,188 @@ class Component { return ''; } + /** + * Add an event listener to this component's element + * ```js + * var myFunc = function() { + * var myComponent = this; + * // Do something when the event is fired + * }; + * + * myComponent.on('eventType', myFunc); + * ``` + * The context of myFunc will be myComponent unless previously bound. + * Alternatively, you can add a listener to another element or component. + * ```js + * myComponent.on(otherElement, 'eventName', myFunc); + * myComponent.on(otherComponent, 'eventName', myFunc); + * ``` + * The benefit of using this over `VjsEvents.on(otherElement, 'eventName', myFunc)` + * and `otherComponent.on('eventName', myFunc)` is that this way the listeners + * will be automatically cleaned up when either component is disposed. + * It will also bind myComponent as the context of myFunc. + * **NOTE**: When using this on elements in the page other than window + * and document (both permanent), if you remove the element from the DOM + * you need to call `myComponent.trigger(el, 'dispose')` on it to clean up + * references to it and allow the browser to garbage collect it. + * + * @param {String|Component} first The event type or other component + * @param {Function|String} second The event handler or event type + * @param {Function} third The event handler + * @return {Component} + * @method on + */ + on(first, second, third) { + if (typeof first === 'string' || Array.isArray(first)) { + Events.on(this.el_, first, Fn.bind(this, second)); + + // Targeting another component or element + } else { + const target = first; + const type = second; + const fn = Fn.bind(this, third); + + // When this component is disposed, remove the listener from the other component + const removeOnDispose = () => this.off(target, type, fn); + + // Use the same function ID so we can remove it later it using the ID + // of the original listener + removeOnDispose.guid = fn.guid; + this.on('dispose', removeOnDispose); + + // If the other component is disposed first we need to clean the reference + // to the other component in this component's removeOnDispose listener + // Otherwise we create a memory leak. + const cleanRemover = () => this.off('dispose', removeOnDispose); + + // Add the same function ID so we can easily remove it later + cleanRemover.guid = fn.guid; + + // Check if this is a DOM node + if (first.nodeName) { + // Add the listener to the other element + Events.on(target, type, fn); + Events.on(target, 'dispose', cleanRemover); + + // Should be a component + // Not using `instanceof Component` because it makes mock players difficult + } else if (typeof first.on === 'function') { + // Add the listener to the other component + target.on(type, fn); + target.on('dispose', cleanRemover); + } + } + + return this; + } + + /** + * Remove an event listener from this component's element + * ```js + * myComponent.off('eventType', myFunc); + * ``` + * If myFunc is excluded, ALL listeners for the event type will be removed. + * If eventType is excluded, ALL listeners will be removed from the component. + * Alternatively you can use `off` to remove listeners that were added to other + * elements or components using `myComponent.on(otherComponent...`. + * In this case both the event type and listener function are REQUIRED. + * ```js + * myComponent.off(otherElement, 'eventType', myFunc); + * myComponent.off(otherComponent, 'eventType', myFunc); + * ``` + * + * @param {String=|Component} first The event type or other component + * @param {Function=|String} second The listener function or event type + * @param {Function=} third The listener for other component + * @return {Component} + * @method off + */ + off(first, second, third) { + if (!first || typeof first === 'string' || Array.isArray(first)) { + Events.off(this.el_, first, second); + } else { + const target = first; + const type = second; + // Ensure there's at least a guid, even if the function hasn't been used + const fn = Fn.bind(this, third); + + // Remove the dispose listener on this component, + // which was given the same guid as the event listener + this.off('dispose', fn); + + if (first.nodeName) { + // Remove the listener + Events.off(target, type, fn); + // Remove the listener for cleaning the dispose listener + Events.off(target, 'dispose', fn); + } else { + target.off(type, fn); + target.off('dispose', fn); + } + } + + return this; + } + + /** + * Add an event listener to be triggered only once and then removed + * ```js + * myComponent.one('eventName', myFunc); + * ``` + * Alternatively you can add a listener to another element or component + * that will be triggered only once. + * ```js + * myComponent.one(otherElement, 'eventName', myFunc); + * myComponent.one(otherComponent, 'eventName', myFunc); + * ``` + * + * @param {String|Component} first The event type or other component + * @param {Function|String} second The listener function or event type + * @param {Function=} third The listener function for other component + * @return {Component} + * @method one + */ + one(first, second, third) { + if (typeof first === 'string' || Array.isArray(first)) { + Events.one(this.el_, first, Fn.bind(this, second)); + } else { + const target = first; + const type = second; + const fn = Fn.bind(this, third); + + const newFunc = () => { + this.off(target, type, newFunc); + fn.apply(null, arguments); + }; + + // Keep the same function ID so we can remove it later + newFunc.guid = fn.guid; + + this.on(target, type, newFunc); + } + + return this; + } + + /** + * Trigger an event on an element + * ```js + * myComponent.trigger('eventName'); + * myComponent.trigger({'type':'eventName'}); + * myComponent.trigger('eventName', {data: 'some data'}); + * myComponent.trigger({'type':'eventName'}, {data: 'some data'}); + * ``` + * + * @param {Event|Object|String} event A string (the type) or an event object with a type attribute + * @param {Object} [hash] data hash to pass along with the event + * @return {Component} self + * @method trigger + */ + trigger(event, hash) { + Events.trigger(this.el_, event, hash); + return this; + } + /** * Bind a listener to the component's ready state. * Different from event listeners in that if the ready event has already happened diff --git a/src/js/player.js b/src/js/player.js index 13dbf6fc9e..88215f1425 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -6,7 +6,6 @@ import Component from './component.js'; import document from 'global/document'; import window from 'global/window'; -import evented from './mixins/evented'; import * as Events from './utils/events.js'; import * as Dom from './utils/dom.js'; import * as Fn from './utils/fn.js'; @@ -351,9 +350,6 @@ class Player extends Component { this.plugins_ = {}; this.el_ = this.createEl(); - // Make this an evented object and use `el_` as its event bus. - evented(this, {eventBusKey: 'el_'}); - // We also want to pass the original player options to each component and plugin // as well so they don't need to reach back into the player for options later. // We also need to do another copy of this.options_ so we don't end up with diff --git a/src/js/slider/slider.js b/src/js/slider/slider.js index db3cf2442a..11f7b4eaf6 100644 --- a/src/js/slider/slider.js +++ b/src/js/slider/slider.js @@ -38,10 +38,7 @@ class Slider extends Component { this.on('click', this.handleClick); this.on(player, 'controlsvisible', this.update); - - if (this.playerEvent) { - this.on(player, this.playerEvent, this.update); - } + this.on(player, this.playerEvent, this.update); } /** diff --git a/test/unit/controls.test.js b/test/unit/controls.test.js index 6bcc2be318..079aa7f2fd 100644 --- a/test/unit/controls.test.js +++ b/test/unit/controls.test.js @@ -11,23 +11,49 @@ QUnit.module('Controls'); QUnit.test('should hide volume control if it\'s not supported', function(assert) { assert.expect(2); - - const player = TestHelpers.makePlayer(); - - player.tech_.featuresVolumeControl = false; + const noop = function() {}; + const player = { + id: noop, + on: noop, + ready: noop, + tech_: { + featuresVolumeControl: false + }, + volume() {}, + muted() {}, + reportUserActivity() {} + }; const volumeControl = new VolumeControl(player); const muteToggle = new MuteToggle(player); - assert.ok(volumeControl.hasClass('vjs-hidden'), 'volumeControl is not hidden'); - assert.ok(muteToggle.hasClass('vjs-hidden'), 'muteToggle is not hidden'); - player.dispose(); + assert.ok(volumeControl.el().className.indexOf('vjs-hidden') >= 0, 'volumeControl is not hidden'); + assert.ok(muteToggle.el().className.indexOf('vjs-hidden') >= 0, 'muteToggle is not hidden'); }); QUnit.test('should test and toggle volume control on `loadstart`', function(assert) { - const player = TestHelpers.makePlayer(); - - player.tech_.featuresVolumeControl = true; + const noop = function() {}; + const listeners = []; + const player = { + id: noop, + on(event, callback) { + // don't fire dispose listeners + if (event !== 'dispose') { + listeners.push(callback); + } + }, + ready: noop, + volume() { + return 1; + }, + muted() { + return false; + }, + tech_: { + featuresVolumeControl: true + }, + reportUserActivity() {} + }; const volumeControl = new VolumeControl(player); const muteToggle = new MuteToggle(player); @@ -36,23 +62,30 @@ QUnit.test('should test and toggle volume control on `loadstart`', function(asse assert.equal(muteToggle.hasClass('vjs-hidden'), false, 'muteToggle is hidden initially'); player.tech_.featuresVolumeControl = false; - player.trigger('loadstart'); + for (let i = 0; i < listeners.length; i++) { + listeners[i](); + } assert.equal(volumeControl.hasClass('vjs-hidden'), true, 'volumeControl does not hide itself'); assert.equal(muteToggle.hasClass('vjs-hidden'), true, 'muteToggle does not hide itself'); player.tech_.featuresVolumeControl = true; - player.trigger('loadstart'); + for (let i = 0; i < listeners.length; i++) { + listeners[i](); + } assert.equal(volumeControl.hasClass('vjs-hidden'), false, 'volumeControl does not show itself'); assert.equal(muteToggle.hasClass('vjs-hidden'), false, 'muteToggle does not show itself'); }); QUnit.test('calculateDistance should use changedTouches, if available', function(assert) { - const player = TestHelpers.makePlayer(); - - player.tech_.featuresVolumeControl = true; - + const noop = function() {}; + const player = { + id: noop, + on: noop, + ready: noop, + reportUserActivity: noop + }; const slider = new Slider(player); document.body.appendChild(slider.el_); From 43086f9896ca266fe203412c874e616a732c7100 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Thu, 10 Nov 2016 11:03:23 -0500 Subject: [PATCH 20/48] Remove plural static methods --- docs/guides/plugins.md | 6 ++--- src/js/plugin.js | 47 +++++++++++++-------------------- test/unit/plugin-basic.test.js | 2 +- test/unit/plugin-class.test.js | 3 ++- test/unit/plugin-static.test.js | 45 ++++++++++--------------------- 5 files changed, 38 insertions(+), 65 deletions(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index e5770156f6..72adfd25c5 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -109,7 +109,7 @@ If you're familiar with creating [components](components.md), this process is si This can be achieved with ES6 classes: ```js -const Plugin = videojs.getPlugin('Plugin'); +const Plugin = videojs.getPlugin('plugin'); class ExamplePlugin extends Plugin { @@ -127,7 +127,7 @@ class ExamplePlugin extends Plugin { Or with ES5: ```js -var Plugin = videojs.getPlugin('Plugin'); +var Plugin = videojs.getPlugin('plugin'); var ExamplePlugin = videojs.extend(Plugin, { @@ -279,7 +279,7 @@ What follows is a complete ES6 class-based plugin that logs a custom message whe ```js import videojs from 'video.js'; -const Plugin = videojs.getPlugin('Plugin'); +const Plugin = videojs.getPlugin('plugin'); class Advanced extends Plugin { diff --git a/src/js/plugin.js b/src/js/plugin.js index e0d4eef40c..6753ec686d 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -5,9 +5,16 @@ import evented from './mixins/evented'; import stateful from './mixins/stateful'; import * as Events from './utils/events'; import * as Fn from './utils/fn'; -import * as Obj from './utils/obj'; import Player from './player'; +/** + * The base plugin name. + * + * @private + * @type {String} + */ +const BASE_PLUGIN_NAME = 'plugin'; + /** * Stores registered plugins in a private space. * @@ -171,8 +178,12 @@ class Plugin { * @return {Function} */ static registerPlugin(name, plugin) { - if (typeof name !== 'string' || pluginStorage[name] || Player.prototype[name]) { - throw new Error(`illegal plugin name, "${name}"`); + if (typeof name !== 'string') { + throw new Error(`illegal plugin name, "${name}", must be a string, was ${typeof name}`); + } + + if (pluginStorage[name] || Player.prototype[name]) { + throw new Error(`illegal plugin name, "${name}", already exists`); } if (typeof plugin !== 'function') { @@ -190,20 +201,6 @@ class Plugin { return plugin; } - /** - * Register multiple plugins via an object where the keys are plugin names - * and the values are sub-classes of `Plugin` or anonymous functions for - * basic plugins. - * - * @param {Object} plugins - * @return {Object} - * An object containing plugins that were added. - */ - static registerPlugins(plugins) { - Obj.each(plugins, (value, key) => this.registerPlugin(key, value)); - return plugins; - } - /** * De-register a Video.js plugin. * @@ -213,23 +210,15 @@ class Plugin { * @param {String} name */ static deregisterPlugin(name) { + if (name === BASE_PLUGIN_NAME) { + throw new Error('cannot de-register base plugin'); + } if (pluginStorage.hasOwnProperty(name)) { delete pluginStorage[name]; delete Player.prototype[name]; } } - /** - * De-register multiple Video.js plugins. - * - * @param {Array} [names] - * If provided, should be an array of plugin names. Defaults to _all_ - * plugin names. - */ - static deregisterPlugins(names = Object.keys(pluginStorage)) { - names.forEach(name => this.deregisterPlugin(name)); - } - /** * Gets an object containing multiple Video.js plugins. * @@ -276,6 +265,6 @@ class Plugin { } } -Plugin.registerPlugin('plugin', Plugin); +Plugin.registerPlugin(BASE_PLUGIN_NAME, Plugin); export default Plugin; diff --git a/test/unit/plugin-basic.test.js b/test/unit/plugin-basic.test.js index 10d925190f..1a8b478b88 100644 --- a/test/unit/plugin-basic.test.js +++ b/test/unit/plugin-basic.test.js @@ -14,7 +14,7 @@ QUnit.module('Plugin: basic', { afterEach() { this.player.dispose(); - Plugin.deregisterPlugins(); + Plugin.deregisterPlugin('basic'); } }); diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js index 5ce3bfb405..f763e2e1cc 100644 --- a/test/unit/plugin-class.test.js +++ b/test/unit/plugin-class.test.js @@ -23,7 +23,7 @@ QUnit.module('Plugin: class-based', { afterEach() { this.player.dispose(); - Plugin.deregisterPlugins(); + Plugin.deregisterPlugin('mock'); } }); @@ -122,6 +122,7 @@ QUnit.test('defaultState static property is used to populate state', function(as const instance = this.player.dsm(); assert.deepEqual(instance.state, {foo: 1, bar: 2}); + Plugin.deregisterPlugin('dsm'); }); QUnit.test('dispose', function(assert) { diff --git a/test/unit/plugin-static.test.js b/test/unit/plugin-static.test.js index 56111e9560..c85b3607ab 100644 --- a/test/unit/plugin-static.test.js +++ b/test/unit/plugin-static.test.js @@ -11,14 +11,13 @@ QUnit.module('Plugin: static methods', { beforeEach() { this.basic = () => {}; - Plugin.registerPlugins({ - basic: this.basic, - mock: MockPlugin - }); + Plugin.registerPlugin('basic', this.basic); + Plugin.registerPlugin('mock', MockPlugin); }, afterEach() { - Plugin.deregisterPlugins(); + Plugin.deregisterPlugin('basic'); + Plugin.deregisterPlugin('mock'); } }); @@ -34,18 +33,20 @@ QUnit.test('registerPlugin()', function(assert) { foo, 'the function on the player prototype is a wrapper' ); + + Plugin.deregisterPlugin('foo'); }); QUnit.test('registerPlugin() illegal arguments', function(assert) { assert.throws( () => Plugin.registerPlugin(), - new Error('illegal plugin name, "undefined"'), + new Error('illegal plugin name, "undefined", must be a string, was undefined'), 'plugins must have a name' ); assert.throws( () => Plugin.registerPlugin('play'), - new Error('illegal plugin name, "play"'), + new Error('illegal plugin name, "play", already exists'), 'plugins cannot share a name with an existing player method' ); @@ -62,16 +63,6 @@ QUnit.test('registerPlugin() illegal arguments', function(assert) { ); }); -QUnit.test('registerPlugins()', function(assert) { - const foo = () => {}; - const bar = () => {}; - - Plugin.registerPlugins({bar, foo}); - - assert.strictEqual(Plugin.getPlugin('foo'), foo); - assert.strictEqual(Plugin.getPlugin('bar'), bar); -}); - QUnit.test('getPlugin()', function(assert) { assert.ok(Plugin.getPlugin('basic')); assert.ok(Plugin.getPlugin('mock')); @@ -91,9 +82,10 @@ QUnit.test('getPluginVersion()', function(assert) { }); QUnit.test('getPlugins()', function(assert) { - assert.strictEqual(Object.keys(Plugin.getPlugins()).length, 2); + assert.strictEqual(Object.keys(Plugin.getPlugins()).length, 3); assert.strictEqual(Plugin.getPlugins().basic, this.basic); assert.strictEqual(Plugin.getPlugins().mock, MockPlugin); + assert.strictEqual(Plugin.getPlugins().plugin, Plugin); assert.strictEqual(Object.keys(Plugin.getPlugins(['basic'])).length, 1); assert.strictEqual(Plugin.getPlugins(['basic']).basic, this.basic); }); @@ -106,20 +98,11 @@ QUnit.test('deregisterPlugin()', function(assert) { assert.strictEqual(Player.prototype.foo, undefined); assert.strictEqual(Plugin.getPlugin('foo'), undefined); -}); -QUnit.test('deregisterPlugins()', function(assert) { - const foo = () => {}; - const bar = () => {}; - - Plugin.registerPlugins({bar, foo}); - Plugin.deregisterPlugins(['bar']); - - assert.strictEqual(Plugin.getPlugin('foo'), foo); - assert.strictEqual(Plugin.getPlugin('bar'), undefined); - - Plugin.deregisterPlugins(); - assert.strictEqual(Plugin.getPlugin('foo'), undefined); + assert.throws( + () => Plugin.deregisterPlugin('plugin'), + new Error('cannot de-register base plugin'), + ); }); QUnit.test('isBasic()', function(assert) { From 2593edc360afd94f3db27dfd9cfeb2e6fc59a987 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Thu, 10 Nov 2016 11:15:08 -0500 Subject: [PATCH 21/48] Plugin guide tweak --- docs/guides/plugins.md | 72 +++++++++++++++--------------------------- 1 file changed, 26 insertions(+), 46 deletions(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 72adfd25c5..0794a79ad0 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -7,11 +7,9 @@ - [Writing a Basic Plugin](#writing-a-basic-plugin) - [Write a JavaScript Function](#write-a-javascript-function) - [Register a Basic Plugin](#register-a-basic-plugin) - - [Setting up a Basic Plugin](#setting-up-a-basic-plugin) - [Writing a Class-Based Plugin](#writing-a-class-based-plugin) - [Write a JavaScript Class/Constructor](#write-a-javascript-classconstructor) - [Register a Class-Based Plugin](#register-a-class-based-plugin) - - [Setting up a Class-Based Plugin](#setting-up-a-class-based-plugin) - [Key Differences from Basic Plugins](#key-differences-from-basic-plugins) - [The Value of `this`](#the-value-of-this) - [The Player Plugin Name Property](#the-player-plugin-name-property) @@ -20,6 +18,7 @@ - [Statefulness](#statefulness) - [Lifecycle](#lifecycle) - [Advanced Example Class-based Plugin](#advanced-example-class-based-plugin) +- [Setting up a Plugin](#setting-up-a-plugin) - [References](#references) @@ -73,29 +72,6 @@ videojs.registerPlugin('examplePlugin', examplePlugin); The only stipulation with the name of the plugin is that it cannot conflict with any existing player method. After that, any player will automatically have an `examplePlugin` method on its prototype! -### Setting up a Basic Plugin - -Finally, we can use our plugin on a player. There are two ways to do this. The first way is during creation of a Video.js player. Using the `plugins` option, a plugin can be automatically set up on a player: - -```js -videojs('example-player', { - plugins: { - examplePlugin: { - customClass: 'example-class' - } - } -}); -``` - -Otherwise, a plugin can be manually set up: - -```js -var player = videojs('example-player'); -player.examplePlugin({customClass: 'example-class'}); -``` - -These two methods are functionally identical - use whichever you prefer! That's all there is to it for basic plugins. - ## Writing a Class-Based Plugin As of Video.js 6, there is an additional type of plugin supported: class-based plugins. @@ -152,27 +128,6 @@ The registration process for class-based plugins is identical to [the process fo videojs.registerPlugin('examplePlugin', ExamplePlugin); ``` -### Setting up a Class-Based Plugin - -Again, just like registration, the setup process for class-based plugins is identical to [the process for basic plugins](#setting-up-a-basic-plugin). - -```js -videojs('example-player', { - plugins: { - examplePlugin: { - customClass: 'example-class' - } - } -}); -``` - -Otherwise, a plugin can be manually set up: - -```js -var player = videojs('example-player'); -player.examplePlugin({customClass: 'example-class'}); -``` - ### Key Differences from Basic Plugins Class-based plugins have two key differences from basic plugins that are important to understand before describing their advanced features. @@ -329,6 +284,31 @@ player.play(); This example may be a bit pointless in reality, but it demonstrates the sort of flexibility offered by class-based plugins over basic plugins. +## Setting up a Plugin + +There are two ways to set up (or initialize) a plugin on a player. Both ways work identically for both basic and class-based plugins. + +The first way is during creation of the player. Using the `plugins` option, a plugin can be automatically set up on a player: + +```js +videojs('example-player', { + plugins: { + examplePlugin: { + customClass: 'example-class' + } + } +}); +``` + +Otherwise, a plugin can be manually set up: + +```js +var player = videojs('example-player'); +player.examplePlugin({customClass: 'example-class'}); +``` + +These two methods are functionally identical - use whichever you prefer! + ## References - [Player API][api-player] From 13c9c64871171e10bedcca3848110414f134eb92 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Thu, 10 Nov 2016 12:16:10 -0500 Subject: [PATCH 22/48] Make sure options.plugins is tested This also makes a few changes, including defining player methods that relate to plugins in the plugin.js file to avoid circular dependencies. And adds the `hasPlugin` method. --- src/js/player.js | 30 ++++++-- src/js/plugin.js | 118 +++++++++++++++++++++++++------- test/unit/player.test.js | 35 ++++++++++ test/unit/plugin-basic.test.js | 5 +- test/unit/plugin-class.test.js | 12 +++- test/unit/plugin-static.test.js | 4 +- 6 files changed, 169 insertions(+), 35 deletions(-) diff --git a/src/js/player.js b/src/js/player.js index 88215f1425..5e456609cd 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -347,7 +347,6 @@ class Player extends Component { */ this.scrubbing_ = false; - this.plugins_ = {}; this.el_ = this.createEl(); // We also want to pass the original player options to each component and plugin @@ -360,11 +359,11 @@ class Player extends Component { if (options.plugins) { const plugins = options.plugins; - Object.getOwnPropertyNames(plugins).forEach(function(name) { + Object.keys(plugins).forEach(function(name) { if (typeof this[name] === 'function') { this[name](plugins[name]); } else { - log.error('Unable to find plugin:', name); + throw new Error(`plugin "${name}" does not exist`); } }, this); } @@ -3103,17 +3102,36 @@ class Player extends Component { return modal.open(); } + /** + * Reports whether or not a player has a plugin available. + * + * This does not report whether or not the plugin has ever been initialized + * on this player. For that, [usingPlugin]{@link Player#usingPlugin}. + * + * @param {string} name + * The name of a plugin. + * + * @return {boolean} + */ + hasPlugin(name) { + // While a no-op by default, this method is created in plugin.js to avoid + // circular dependencies. + } + /** * Reports whether or not a player is using a plugin by name. * * For basic plugins, this only reports whether the plugin has _ever_ been * initialized on this player. * - * @param {String} name - * @return {Boolean} + * @param {string} name + * The name of a plugin. + * + * @return {boolean} */ usingPlugin(name) { - return !!(this.plugins_ && this.plugins_[name]); + // While a no-op by default, this method is created in plugin.js to avoid + // circular dependencies. } /** diff --git a/src/js/plugin.js b/src/js/plugin.js index 6753ec686d..a3c6384f7a 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -11,10 +11,18 @@ import Player from './player'; * The base plugin name. * * @private - * @type {String} + * @type {string} */ const BASE_PLUGIN_NAME = 'plugin'; +/** + * The key on which a player's active plugins cache is stored. + * + * @private + * @type {string} + */ +const PLUGIN_CACHE_KEY = 'activePlugins_'; + /** * Stores registered plugins in a private space. * @@ -23,6 +31,46 @@ const BASE_PLUGIN_NAME = 'plugin'; */ const pluginStorage = {}; +/** + * Reports whether or not a plugin exists in storage. + * + * @private + * @param {string} name + * The name of a plugin. + * + * @return {boolean} + */ +const pluginExists = (name) => pluginStorage.hasOwnProperty(name); + +/** + * Get a plugin from storage. + * + * @private + * @param {string} name + * The name of a plugin. + * + * @return {Function|undefined} + * The plugin (or undefined). + */ +const getPlugin = (name) => pluginExists(name) ? pluginStorage[name] : undefined; + +/** + * Marks a plugin as "active" on a player. + * + * Also ensures that the player has an object for tracking active plugins. + * + * @private + * @param {Player} player + * A Video.js player. + * + * @param {string} name + * The name of a plugin. + */ +const markPluginAsActive = (player, name) => { + player[PLUGIN_CACHE_KEY] = player[PLUGIN_CACHE_KEY] || {}; + player[PLUGIN_CACHE_KEY][name] = true; +}; + /** * Takes a basic plugin function and returns a wrapper function which marks * on the player that the plugin has been activated. @@ -33,7 +81,7 @@ const pluginStorage = {}; const createBasicPlugin = (name, plugin) => function() { const instance = plugin.apply(this, arguments); - this.plugins_[name] = true; + markPluginAsActive(this, name); // We trigger the "pluginsetup" event on the player regardless, but we want // the hash to be consistent with the hash provided for class-based plugins. @@ -79,7 +127,7 @@ class Plugin { this.player = player; evented(this, {exclude: ['trigger']}); stateful(this, this.constructor.defaultState); - player.plugins_[this.name] = true; + markPluginAsActive(player, this.name); player.one('dispose', Fn.bind(this, this.dispose)); player.trigger('pluginsetup', this.getEventHash_()); } @@ -107,7 +155,7 @@ class Plugin { /** * Triggers an event on the plugin object. * - * @param {Event|Object|String} event + * @param {Event|Object|string} event * A string (the type) or an event object with a type attribute. * * @param {Object} [hash={}] @@ -118,7 +166,7 @@ class Plugin { * - `name`: The name of the plugin. * - `plugin`: The plugin class/constructor. * - * @return {Boolean} + * @return {boolean} * Whether or not default was prevented. */ trigger(event, hash = {}) { @@ -140,7 +188,7 @@ class Plugin { // Eliminate any possible sources of leaking memory by clearing up references // between the player and the plugin instance and nulling out the plugin's // state and replacing methods with a function that throws. - player.plugins_[name] = false; + player[PLUGIN_CACHE_KEY][name] = false; this.player = this.state = null; this.dispose = () => { @@ -157,22 +205,22 @@ class Plugin { /** * Determines if a plugin is a "basic" plugin (i.e. not a sub-class of `Plugin`). * - * @param {String|Function} plugin + * @param {string|Function} plugin * If a string, matches the name of a plugin. If a function, will be * tested directly. - * @return {Boolean} + * + * @return {boolean} */ static isBasic(plugin) { - plugin = (typeof plugin === 'string') ? Plugin.getPlugin(plugin) : plugin; + const p = (typeof plugin === 'string') ? getPlugin(plugin) : plugin; - return typeof plugin === 'function' && - !Plugin.prototype.isPrototypeOf(plugin.prototype); + return typeof p === 'function' && !Plugin.prototype.isPrototypeOf(p.prototype); } /** * Register a Video.js plugin * - * @param {String} name + * @param {string} name * @param {Function} plugin * A sub-class of `Plugin` or an anonymous function for basic plugins. * @return {Function} @@ -182,7 +230,7 @@ class Plugin { throw new Error(`illegal plugin name, "${name}", must be a string, was ${typeof name}`); } - if (pluginStorage[name] || Player.prototype[name]) { + if (pluginExists(name) || Player.prototype.hasOwnProperty(name)) { throw new Error(`illegal plugin name, "${name}", already exists`); } @@ -207,13 +255,13 @@ class Plugin { * This is mostly used for testing, but may potentially be useful in advanced * player workflows. * - * @param {String} name + * @param {string} name */ static deregisterPlugin(name) { if (name === BASE_PLUGIN_NAME) { throw new Error('cannot de-register base plugin'); } - if (pluginStorage.hasOwnProperty(name)) { + if (pluginExists(name)) { delete pluginStorage[name]; delete Player.prototype[name]; } @@ -231,7 +279,7 @@ class Plugin { let result; names.forEach(name => { - const plugin = this.getPlugin(name); + const plugin = getPlugin(name); if (plugin) { result = result || {}; @@ -243,23 +291,29 @@ class Plugin { } /** - * Gets a plugin by name + * Gets a plugin by name if it exists. + * + * @param {string} name + * The name of a plugin. * - * @param {[type]} name [description] - * @return {[type]} [description] + * @return {Function|undefined} + * The plugin (or undefined). */ static getPlugin(name) { - return pluginStorage[name] || Player.prototype[name]; + return getPlugin(name); } /** * Gets a plugin's version, if available * - * @param {String} name - * @return {String} + * @param {string} name + * The name of a plugin. + * + * @return {string} + * The plugin's version or an empty string. */ static getPluginVersion(name) { - const plugin = Plugin.getPlugin(name); + const plugin = getPlugin(name); return plugin && plugin.VERSION || ''; } @@ -267,4 +321,22 @@ class Plugin { Plugin.registerPlugin(BASE_PLUGIN_NAME, Plugin); +/** + * Documented in player.js + * + * @ignore + */ +Player.prototype.usingPlugin = function(name) { + return !!this[PLUGIN_CACHE_KEY] && this[PLUGIN_CACHE_KEY][name] === true; +}; + +/** + * Documented in player.js + * + * @ignore + */ +Player.prototype.hasPlugin = function(name) { + return !!pluginExists(name); +}; + export default Plugin; diff --git a/test/unit/player.test.js b/test/unit/player.test.js index c374776219..8c0cdecd1b 100644 --- a/test/unit/player.test.js +++ b/test/unit/player.test.js @@ -1,4 +1,5 @@ /* eslint-env qunit */ +import Plugin from '../../src/js/plugin'; import Player from '../../src/js/player.js'; import videojs from '../../src/js/video.js'; import * as Dom from '../../src/js/utils/dom.js'; @@ -1387,3 +1388,37 @@ QUnit.test('should not allow to register custom player when any player has been // reset the Player to the original value; videojs.registerComponent('Player', Player); }); + +QUnit.test('options: plugins', function(assert) { + const optionsSpy = sinon.spy(); + + Plugin.registerPlugin('foo', (options) => { + optionsSpy(options); + }); + + const player = TestHelpers.makePlayer({ + plugins: { + foo: { + bar: 1 + } + } + }); + + assert.strictEqual(optionsSpy.callCount, 1, 'the plugin was set up'); + assert.deepEqual(optionsSpy.getCall(0).args[0], {bar: 1}, 'the plugin got the expected options'); + + assert.throws( + () => { + TestHelpers.makePlayer({ + plugins: { + nope: {} + } + }); + }, + new Error('plugin "nope" does not exist'), + 'plugins that do not exist cause the player to throw' + ); + + player.dispose(); + Plugin.deregisterPlugin('foo'); +}); diff --git a/test/unit/plugin-basic.test.js b/test/unit/plugin-basic.test.js index 1a8b478b88..0eaa35ec86 100644 --- a/test/unit/plugin-basic.test.js +++ b/test/unit/plugin-basic.test.js @@ -25,6 +25,8 @@ QUnit.test('pre-setup interface', function(assert) { 'basic plugins are a function on a player' ); + assert.ok(this.player.hasPlugin('basic'), 'player has the plugin available'); + assert.notStrictEqual( this.player.basic, this.basic, @@ -37,7 +39,7 @@ QUnit.test('pre-setup interface', function(assert) { 'unlike class-based plugins, basic plugins do not have a dispose method' ); - assert.ok(!this.player.usingPlugin('basic')); + assert.notOk(this.player.usingPlugin('basic')); }); QUnit.test('setup', function(assert) { @@ -62,6 +64,7 @@ QUnit.test('setup', function(assert) { 'the player now recognizes that the plugin was set up' ); + assert.ok(this.player.hasPlugin('basic'), 'player has the plugin available'); }); QUnit.test('"pluginsetup" event', function(assert) { diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js index f763e2e1cc..4915613901 100644 --- a/test/unit/plugin-class.test.js +++ b/test/unit/plugin-class.test.js @@ -34,13 +34,15 @@ QUnit.test('pre-setup interface', function(assert) { 'plugins are a factory function on a player' ); + assert.ok(this.player.hasPlugin('mock'), 'player has the plugin available'); + assert.strictEqual( this.player.mock.dispose, undefined, 'class-based plugins are not populated on a player until the factory method creates them' ); - assert.ok(!this.player.usingPlugin('mock')); + assert.notOk(this.player.usingPlugin('mock')); }); QUnit.test('setup', function(assert) { @@ -65,6 +67,8 @@ QUnit.test('setup', function(assert) { 'player now recognizes that the plugin was set up' ); + assert.ok(this.player.hasPlugin('mock'), 'player has the plugin available'); + assert.ok( instance instanceof this.MockPlugin, 'plugin instance has the correct constructor' @@ -130,11 +134,13 @@ QUnit.test('dispose', function(assert) { instance.dispose(); - assert.ok( - !this.player.usingPlugin('mock'), + assert.notOk( + this.player.usingPlugin('mock'), 'player recognizes that the plugin is NOT set up' ); + assert.ok(this.player.hasPlugin('mock'), 'player still has the plugin available'); + assert.strictEqual( typeof this.player.mock, 'function', diff --git a/test/unit/plugin-static.test.js b/test/unit/plugin-static.test.js index c85b3607ab..54f8752c0b 100644 --- a/test/unit/plugin-static.test.js +++ b/test/unit/plugin-static.test.js @@ -108,6 +108,6 @@ QUnit.test('deregisterPlugin()', function(assert) { QUnit.test('isBasic()', function(assert) { assert.ok(Plugin.isBasic(this.basic)); assert.ok(Plugin.isBasic('basic')); - assert.ok(!Plugin.isBasic(MockPlugin)); - assert.ok(!Plugin.isBasic('mock')); + assert.notOk(Plugin.isBasic(MockPlugin)); + assert.notOk(Plugin.isBasic('mock')); }); From 1c4901f068ab4c16f3cb6f92e8d4df3bdd83dc4e Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Fri, 11 Nov 2016 12:51:08 -0500 Subject: [PATCH 23/48] Fix bad code in guide --- docs/guides/plugins.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 0794a79ad0..953f9ad8ea 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -108,7 +108,7 @@ var Plugin = videojs.getPlugin('plugin'); var ExamplePlugin = videojs.extend(Plugin, { constructor: function(player, options) { - Plugin.prototype.call(this, player, options); + Plugin.call(this, player, options); player.addClass(options.customClass); player.on('playing', function() { From ce0e3324d99dd9cebd2e66ff9fe4e3832b115d5d Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 15 Nov 2016 13:54:32 -0500 Subject: [PATCH 24/48] Documentation fixes --- docs/guides/plugins.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 953f9ad8ea..1626f48874 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -51,7 +51,11 @@ A basic plugin is a plain JavaScript function: ```js function examplePlugin(options) { - this.addClass(options.customClass); + + if (options.customClass) { + this.addClass(options.customClass); + } + this.on('playing', function() { videojs.log('playback began!'); }); @@ -92,7 +96,10 @@ class ExamplePlugin extends Plugin { constructor(player, options) { super(player); - player.addClass(options.customClass); + if (options.customClass) { + player.addClass(options.customClass); + } + player.on('playing', function() { videojs.log('playback began!'); }); @@ -110,7 +117,10 @@ var ExamplePlugin = videojs.extend(Plugin, { constructor: function(player, options) { Plugin.call(this, player, options); - player.addClass(options.customClass); + if (options.customClass) { + player.addClass(options.customClass); + } + player.on('playing', function() { videojs.log('playback began!'); }); @@ -192,14 +202,14 @@ ExamplePlugin.defaultState = { }; ``` -When the `state` is updated via the `setState` method, the plugin instance fires a `"statechanged"` event, but _only if something changed!_ This event can be used as a signal to update the DOM or perform some other action. Listeners to this event will receive, as a second argument, a hash of changes which occurred on the `state` property: +When the `state` is updated via the `setState` method, the plugin instance fires a `"statechanged"` event, but _only if something changed!_ This event can be used as a signal to update the DOM or perform some other action. The event object passed to listeners for this event includes, an object describing the changes that occurred on the `state` property: ```js -player.examplePlugin.on('statechanged', function(changes) { - if (changes.customClass) { - this - .removeClass(changes.customClass.from) - .addClass(changes.customClass.to); +player.examplePlugin.on('statechanged', function(e) { + if (e.changes && e.changes.customClass) { + this.player + .removeClass(e.changes.customClass.from) + .addClass(e.changes.customClass.to); } }); From eaff7f6b5b27f58246a3129915cf6745295412ae Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 21 Nov 2016 09:41:40 -0500 Subject: [PATCH 25/48] Add handleStateChanged method --- src/js/plugin.js | 26 ++++++++++++++++++++++---- test/unit/plugin-class.test.js | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/js/plugin.js b/src/js/plugin.js index a3c6384f7a..3ec2c546f0 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -125,10 +125,20 @@ class Plugin { */ constructor(player) { this.player = player; + evented(this, {exclude: ['trigger']}); stateful(this, this.constructor.defaultState); markPluginAsActive(player, this.name); - player.one('dispose', Fn.bind(this, this.dispose)); + + // Bind all plugin prototype methods to this object. + Object.keys(Plugin.prototype).forEach(k => { + if (typeof this[k] === 'function') { + this[k] = Fn.bind(this, this[k]); + } + }); + + this.on('statechanged', this.handleStateChanged); + player.one('dispose', this.dispose); player.trigger('pluginsetup', this.getEventHash_()); } @@ -173,6 +183,14 @@ class Plugin { return Events.trigger(this.eventBusEl_, event, this.getEventHash_(hash)); } + /** + * Handles "statechange" events on the plugin. Override by subclassing. + * + * @param {Event} e + * @param {Object} e.changes + */ + handleStateChanged(e) {} + /** * Disposes a plugin. * @@ -185,9 +203,9 @@ class Plugin { this.trigger('dispose'); this.off(); - // Eliminate any possible sources of leaking memory by clearing up references - // between the player and the plugin instance and nulling out the plugin's - // state and replacing methods with a function that throws. + // Eliminate any possible sources of leaking memory by clearing up + // references between the player and the plugin instance and nulling out + // the plugin's state and replacing methods with a function that throws. player[PLUGIN_CACHE_KEY][name] = false; this.player = this.state = null; diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js index 4915613901..d131a3bc57 100644 --- a/test/unit/plugin-class.test.js +++ b/test/unit/plugin-class.test.js @@ -221,3 +221,21 @@ QUnit.test('arbitrary events', function(assert) { plugin: this.MockPlugin }, 'the event hash object is correct'); }); + +QUnit.test('handleStateChanged() method is automatically bound to the "statechanged" event', function(assert) { + const spy = sinon.spy(); + + class TestHandler extends Plugin {} + TestHandler.prototype.handleStateChanged = spy; + + Plugin.registerPlugin('testHandler', TestHandler); + + const instance = this.player.testHandler(); + + instance.setState({foo: 1}); + assert.strictEqual(spy.callCount, 1, 'the handleStateChanged listener was called'); + assert.strictEqual(spy.firstCall.args[0].type, 'statechanged', 'the event was "statechanged"'); + assert.strictEqual(typeof spy.firstCall.args[0].changes, 'object', 'the event included a changes object'); + + Plugin.deregisterPlugin('testHandler'); +}); From d0cce8c3f759e643cccfc5359d5ce56f699995e3 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 21 Nov 2016 10:13:35 -0500 Subject: [PATCH 26/48] Prevent Plugin from being instantiated directly --- src/js/plugin.js | 19 ++++++++++++++----- test/unit/plugin-class.test.js | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/js/plugin.js b/src/js/plugin.js index 3ec2c546f0..d22fbbdb29 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -126,6 +126,10 @@ class Plugin { constructor(player) { this.player = player; + if (this.constructor === Plugin) { + throw new Error('Plugin must be sub-classed; not directly instantiated'); + } + evented(this, {exclude: ['trigger']}); stateful(this, this.constructor.defaultState); markPluginAsActive(player, this.name); @@ -184,7 +188,8 @@ class Plugin { } /** - * Handles "statechange" events on the plugin. Override by subclassing. + * Handles "statechange" events on the plugin. No-op by default, override by + * subclassing. * * @param {Event} e * @param {Object} e.changes @@ -258,10 +263,14 @@ class Plugin { pluginStorage[name] = plugin; - if (Plugin.isBasic(plugin)) { - Player.prototype[name] = createBasicPlugin(name, plugin); - } else { - Player.prototype[name] = createPluginFactory(name, plugin); + // Add a player prototype method for all sub-classed plugins (but not for + // the base Plugin class). + if (name !== 'plugin') { + if (Plugin.isBasic(plugin)) { + Player.prototype[name] = createBasicPlugin(name, plugin); + } else { + Player.prototype[name] = createPluginFactory(name, plugin); + } } return plugin; diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js index d131a3bc57..18d316e569 100644 --- a/test/unit/plugin-class.test.js +++ b/test/unit/plugin-class.test.js @@ -28,6 +28,12 @@ QUnit.module('Plugin: class-based', { }); QUnit.test('pre-setup interface', function(assert) { + assert.strictEqual( + typeof this.player.plugin, + 'undefined', + 'the base Plugin does not add a method to the player' + ); + assert.strictEqual( typeof this.player.mock, 'function', @@ -90,6 +96,16 @@ QUnit.test('setup', function(assert) { assert.strictEqual(typeof instance.one, 'function', 'instance is evented'); assert.strictEqual(typeof instance.trigger, 'function', 'instance is evented'); assert.strictEqual(typeof instance.dispose, 'function', 'instance has dispose method'); + + assert.throws( + function() { + + // This needs to return so that the linter doesn't complain. + return new Plugin(this.player); + }, + new Error('Plugin must be sub-classed; not directly instantiated'), + 'the Plugin class cannot be directly instantiated' + ); }); QUnit.test('"pluginsetup" event', function(assert) { From 96a85fc2c930a45da01c2831fc50d9dbc41f6144 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 28 Nov 2016 14:27:23 -0500 Subject: [PATCH 27/48] Documentation updates based on feedback --- src/js/mixins/evented.js | 76 +++++++++++++++++++-------------- src/js/mixins/stateful.js | 13 ++++-- src/js/player.js | 2 + src/js/plugin.js | 90 +++++++++++++++++++++++++++++++-------- 4 files changed, 130 insertions(+), 51 deletions(-) diff --git a/src/js/mixins/evented.js b/src/js/mixins/evented.js index 4c4982b1ba..5ce999a470 100644 --- a/src/js/mixins/evented.js +++ b/src/js/mixins/evented.js @@ -10,7 +10,10 @@ import * as Events from '../utils/events'; * * @private * @param {Object} object - * @return {Boolean} + * An object to test. + * + * @return {boolean} + * Whether or not the object appears to be evented. */ const isEvented = (object) => !!object.eventBusEl_ && @@ -19,8 +22,12 @@ const isEvented = (object) => /** * Whether a value is a valid event type - non-empty string or array. * - * @param {String|Array} type - * @return {Boolean} + * @private + * @param {string|Array} type + * The type value to test. + * + * @return {boolean} + * Whether or not the type is a valid event type. */ const isValidEventType = (type) => (typeof type === 'string' && (/\S/).test(type)) || @@ -30,7 +37,10 @@ const isValidEventType = (type) => * Validates a value to determine if it is a valid event target. Throws if not. * * @throws {Error} + * If the target does not appear to be a valid event target. + * * @param {Object} target + * The object to test. */ const validateTarget = (target) => { if (!target.nodeName && !isEvented(target)) { @@ -42,7 +52,10 @@ const validateTarget = (target) => { * Validates a value to determine if it is a valid event target. Throws if not. * * @throws {Error} - * @param {String|Array} type + * If the type does not appear to be a valid event type. + * + * @param {string|Array} type + * The type to test. */ const validateEventType = (type) => { if (!isValidEventType(type)) { @@ -53,8 +66,11 @@ const validateEventType = (type) => { /** * Validates a value to determine if it is a valid listener. Throws if not. * - * @throws Error + * @throws {Error} + * If the listener is not a function. + * * @param {Function} listener + * The listener to test. */ const validateListener = (listener) => { if (typeof listener !== 'function') { @@ -63,17 +79,18 @@ const validateListener = (listener) => { }; /** - * Takes an array of arguments given to on() or one(), validates them, and + * Takes an array of arguments given to `on()` or `one()`, validates them, and * normalizes them into an object. * - * @throws Error + * @private * @param {Object} self - * The evented object on which on() or one() was called. + * The evented object on which `on()` or `one()` was called. * * @param {Array} args - * An array of arguments passed to on() or one(). + * An array of arguments passed to `on()` or `one()`. * * @return {Object} + * An object containing useful values for `on()` or `one()` calls. */ const normalizeListenArgs = (self, args) => { @@ -112,17 +129,16 @@ const normalizeListenArgs = (self, args) => { * the type of target. * * @private - * @throws {Error} If unable to add the listener * @param {Element|Object} target * A DOM node or evented object. * - * @param {String|Array} type + * @param {string|Array} type * One or more event type(s). * * @param {Function} listener * A listener function. * - * @param {String} [method="on"] + * @param {string} [method="on"] * The event binding method to use. */ const listen = (target, type, listener, method = 'on') => { @@ -147,18 +163,17 @@ const mixin = { * Add a listener to an event (or events) on this object or another evented * object. * - * @param {String|Array|Element|Object} targetOrType + * @param {string|Array|Element|Object} targetOrType * If this is a string or array, it represents an event type(s) and - * the listener will be bound to this object. + * the listener will listen for events on this object. * * Another evented object can be passed here instead, which will - * bind a listener to the given event(s) being triggered on THAT - * object. + * cause the listener to listen for events on THAT object. * * In either case, the listener's `this` value will be bound to * this object. * - * @param {String|Array|Function} typeOrListener + * @param {string|Array|Function} typeOrListener * If the first argument was a string or array, this should be the * listener function. Otherwise, this is a string or array of event * type(s). @@ -168,7 +183,7 @@ const mixin = { * the listener function. * * @return {Object} - * Returns the object itself. + * Returns this object. */ on(...args) { const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args); @@ -205,18 +220,17 @@ const mixin = { * Add a listener to an event (or events) on this object or another evented * object. The listener will only be called once and then removed. * - * @param {String|Array|Element|Object} targetOrType + * @param {string|Array|Element|Object} targetOrType * If this is a string or array, it represents an event type(s) and - * the listener will be bound to this object. + * the listener will listen for events on this object. * * Another evented object can be passed here instead, which will - * bind a listener to the given event(s) being triggered on THAT - * object. + * cause the listener to listen for events on THAT object. * * In either case, the listener's `this` value will be bound to * this object. * - * @param {String|Array|Function} typeOrListener + * @param {string|Array|Function} typeOrListener * If the first argument was a string or array, this should be the * listener function. Otherwise, this is a string or array of event * type(s). @@ -226,7 +240,7 @@ const mixin = { * the listener function. * * @return {Object} - * Returns the object itself. + * Returns this object. */ one(...args) { const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args); @@ -252,16 +266,16 @@ const mixin = { }, /** - * Removes listeners from events on an evented object. + * Removes listener(s) from event(s) on an evented object. * - * @param {String|Array|Element|Object} [targetOrType] + * @param {string|Array|Element|Object} [targetOrType] * If this is a string or array, it represents an event type(s). * * Another evented object can be passed here instead, in which case * ALL 3 arguments are REQUIRED. * - * @param {String|Array|Function} [typeOrListener] - * If the first argument was a string or array, this should be the + * @param {string|Array|Function} [typeOrListener] + * If the first argument was a string or array, this may be the * listener function. Otherwise, this is a string or array of event * type(s). * @@ -308,16 +322,16 @@ const mixin = { }, /** - * Fire an event on this evented object. + * Fire an event on this evented object, causing its listeners to be called. * - * @param {String|Object} event + * @param {string|Object} event * An event type or an object with a type property. * * @param {Object} [hash] * An additional object to pass along to listeners. * * @return {Object} - * Returns the object itself. + * Returns this object. */ trigger(event, hash) { Events.trigger(this.eventBusEl_, event, hash); diff --git a/src/js/mixins/stateful.js b/src/js/mixins/stateful.js index 89f74bf53b..ff0c5f572d 100644 --- a/src/js/mixins/stateful.js +++ b/src/js/mixins/stateful.js @@ -13,11 +13,12 @@ import * as Obj from '../utils/obj'; * be a plain object or a function returning a plain object. * * @return {Object} - * An object containing changes that occured. If no changes occurred, + * An object containing changes that occurred. If no changes occurred, * returns `undefined`. - * */ const setState = function(next) { + + // Support providing the `next` state as a function. if (typeof next === 'function') { next = next(); } @@ -63,8 +64,14 @@ const setState = function(next) { * changes if the object has a `trigger` method. * * @param {Object} target + * The object to be made stateful. + * + * @param {Object} [defaultState] + * A default set of properties to populate the newly-stateful object's + * `state` property. + * * @return {Object} - * Returns the `object`. + * Returns the `target`. */ function stateful(target, defaultState) { target.state = Obj.assign({}, defaultState); diff --git a/src/js/player.js b/src/js/player.js index 5e456609cd..677d2edfd0 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -3112,6 +3112,7 @@ class Player extends Component { * The name of a plugin. * * @return {boolean} + * Whether or not this player has the requested plugin available. */ hasPlugin(name) { // While a no-op by default, this method is created in plugin.js to avoid @@ -3128,6 +3129,7 @@ class Player extends Component { * The name of a plugin. * * @return {boolean} + * Whether or not this player is using the requested plugin. */ usingPlugin(name) { // While a no-op by default, this method is created in plugin.js to avoid diff --git a/src/js/plugin.js b/src/js/plugin.js index d22fbbdb29..51de3669f0 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -32,13 +32,14 @@ const PLUGIN_CACHE_KEY = 'activePlugins_'; const pluginStorage = {}; /** - * Reports whether or not a plugin exists in storage. + * Reports whether or not a plugin has been registered. * * @private * @param {string} name * The name of a plugin. * * @return {boolean} + * Whether or not the plugin has been registered. */ const pluginExists = (name) => pluginStorage.hasOwnProperty(name); @@ -57,11 +58,11 @@ const getPlugin = (name) => pluginExists(name) ? pluginStorage[name] : undefined /** * Marks a plugin as "active" on a player. * - * Also ensures that the player has an object for tracking active plugins. + * Also, ensures that the player has an object for tracking active plugins. * * @private * @param {Player} player - * A Video.js player. + * A Video.js player instance. * * @param {string} name * The name of a plugin. @@ -76,7 +77,14 @@ const markPluginAsActive = (player, name) => { * on the player that the plugin has been activated. * * @private + * @param {string} name + * The name of the plugin. + * + * @param {Function} plugin + * The basic plugin. + * * @return {Function} + * A wrapper function for the given plugin. */ const createBasicPlugin = (name, plugin) => function() { const instance = plugin.apply(this, arguments); @@ -99,7 +107,14 @@ const createBasicPlugin = (name, plugin) => function() { * sub-class of Plugin. * * @private + * @param {string} name + * The name of the plugin. + * + * @param {Plugin} PluginSubClass + * The class-based plugin. + * * @return {Function} + * A factory function for the plugin sub-class. */ const createPluginFactory = (name, PluginSubClass) => { @@ -118,10 +133,10 @@ class Plugin { /** * Plugin constructor. * - * Subclasses should make sure they call `super` in order to make sure their - * plugins are properly initialized. + * Subclasses should call `super` to ensure plugins are properly initialized. * * @param {Player} player + * A Video.js player instance. */ constructor(player) { this.player = player; @@ -143,6 +158,23 @@ class Plugin { this.on('statechanged', this.handleStateChanged); player.one('dispose', this.dispose); + + /** + * Signals that a plugin (both basic and class-based) has just been set up + * on a player. + * + * In all cases, an object containing the following properties is passed as a + * second argument to event listeners: + * + * - `name`: The name of the plugin that was set up. + * - `plugin`: The raw plugin function. + * - `instance`: For class-based plugins, the instance of the plugin sub-class, + * but, for basic plugins, the return value of the plugin invocation. + * + * @event pluginsetup + * @memberof Player + * @instance + */ player.trigger('pluginsetup', this.getEventHash_()); } @@ -154,7 +186,11 @@ class Plugin { * * @private * @param {Object} [hash={}] + * An object to be used as event an event hash. + * * @return {Object} + * The event hash object with, at least, the following properties: + * * - `instance`: The plugin instance on which the event is fired. * - `name`: The name of the plugin. * - `plugin`: The plugin class/constructor. @@ -173,7 +209,7 @@ class Plugin { * A string (the type) or an event object with a type attribute. * * @param {Object} [hash={}] - * Additional data hash to pass along with the event. In this case, + * Additional data hash to pass along with the event. For plugins, * several properties are added to the hash: * * - `instance`: The plugin instance on which the event is fired. @@ -188,11 +224,15 @@ class Plugin { } /** - * Handles "statechange" events on the plugin. No-op by default, override by + * Handles "statechanged" events on the plugin. No-op by default, override by * subclassing. * * @param {Event} e + * An event object provided by a "statechanged" event. + * * @param {Object} e.changes + * An object describing changes that occurred with the "statechanged" + * event. */ handleStateChanged(e) {} @@ -200,11 +240,18 @@ class Plugin { * Disposes a plugin. * * Subclasses can override this if they want, but for the sake of safety, - * it's probably best to subscribe to one of the disposal events. + * it's probably best to subscribe the "dispose" event. */ dispose() { const {name, player} = this; + /** + * Signals that a class-based plugin is about to be disposed. + * + * @event dispose + * @memberof Plugin + * @instance + */ this.trigger('dispose'); this.off(); @@ -226,13 +273,14 @@ class Plugin { } /** - * Determines if a plugin is a "basic" plugin (i.e. not a sub-class of `Plugin`). + * Determines if a plugin is a basic plugin (i.e. not a sub-class of `Plugin`). * * @param {string|Function} plugin * If a string, matches the name of a plugin. If a function, will be * tested directly. * * @return {boolean} + * Whether or not a plugin is a basic plugin. */ static isBasic(plugin) { const p = (typeof plugin === 'string') ? getPlugin(plugin) : plugin; @@ -241,12 +289,19 @@ class Plugin { } /** - * Register a Video.js plugin + * Register a Video.js plugin. * * @param {string} name + * The name of the plugin to be registered. Must be a string and + * must not match an existing plugin or a method on the `Player` + * prototype. + * * @param {Function} plugin - * A sub-class of `Plugin` or an anonymous function for basic plugins. + * A sub-class of `Plugin` or a function for basic plugins. + * * @return {Function} + * For class-based plugins, a factory function for that plugin. For + * basic plugins, a wrapper function that initializes the plugin. */ static registerPlugin(name, plugin) { if (typeof name !== 'string') { @@ -265,7 +320,7 @@ class Plugin { // Add a player prototype method for all sub-classed plugins (but not for // the base Plugin class). - if (name !== 'plugin') { + if (name !== BASE_PLUGIN_NAME) { if (Plugin.isBasic(plugin)) { Player.prototype[name] = createBasicPlugin(name, plugin); } else { @@ -279,10 +334,8 @@ class Plugin { /** * De-register a Video.js plugin. * - * This is mostly used for testing, but may potentially be useful in advanced - * player workflows. - * * @param {string} name + * The name of the plugin to be deregistered. */ static deregisterPlugin(name) { if (name === BASE_PLUGIN_NAME) { @@ -300,7 +353,10 @@ class Plugin { * @param {Array} [names] * If provided, should be an array of plugin names. Defaults to _all_ * plugin names. - * @return {Object} + * + * @return {Object|undefined} + * An object containing plugin(s) associated with their name(s) or + * `undefined` if no matching plugins exist). */ static getPlugins(names = Object.keys(pluginStorage)) { let result; @@ -324,7 +380,7 @@ class Plugin { * The name of a plugin. * * @return {Function|undefined} - * The plugin (or undefined). + * The plugin (or `undefined`). */ static getPlugin(name) { return getPlugin(name); From 6a0cdab196a4f5e7e67a33b173dbc25fad713a03 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 28 Nov 2016 14:56:40 -0500 Subject: [PATCH 28/48] Restore IE8 script --- test/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/index.html b/test/index.html index 211fd404a2..dee7975002 100644 --- a/test/index.html +++ b/test/index.html @@ -9,11 +9,11 @@
+ - From 929affce5c420fe6496e7ea57201dd8c34bb4b65 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 28 Nov 2016 15:01:59 -0500 Subject: [PATCH 29/48] Don't wrap public methods --- src/js/plugin.js | 2 +- src/js/video.js | 64 ++++++++++++++++++++++++++++-------------------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/src/js/plugin.js b/src/js/plugin.js index 51de3669f0..53ef093ea0 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -44,7 +44,7 @@ const pluginStorage = {}; const pluginExists = (name) => pluginStorage.hasOwnProperty(name); /** - * Get a plugin from storage. + * Get a single registered plugin by name. * * @private * @param {string} name diff --git a/src/js/video.js b/src/js/video.js index 78b177f4df..722d345f47 100644 --- a/src/js/video.js +++ b/src/js/video.js @@ -347,26 +347,25 @@ videojs.mergeOptions = mergeOptions; videojs.bind = Fn.bind; /** - * Register a Video.js plugin + * Register a Video.js plugin. * * @borrows plugin:registerPlugin as videojs.registerPlugin - * @param {String} name The plugin name - * @param {Function} plugin A sub-class of `Plugin` or an anonymous function for basic plugins. * @mixes videojs * @method registerPlugin - */ -videojs.registerPlugin = (name, plugin) => Plugin.registerPlugin(name, plugin); - -/** - * Register multiple Video.js plugins via an object where the keys are - * plugin names and the values are sub-classes of `Plugin` or anonymous - * functions for basic plugins. * - * @param {Object} plugins - * @return {Object} - * An object containing plugins that were added. + * @param {string} name + * The name of the plugin to be registered. Must be a string and + * must not match an existing plugin or a method on the `Player` + * prototype. + * + * @param {Function} plugin + * A sub-class of `Plugin` or a function for basic plugins. + * + * @return {Function} + * For class-based plugins, a factory function for that plugin. For + * basic plugins, a wrapper function that initializes the plugin. */ -videojs.registerPlugins = (plugins) => Plugin.registerPlugins(plugins); +videojs.registerPlugin = Plugin.registerPlugin; /** * Deprecated method to register a plugin with Video.js @@ -374,7 +373,7 @@ videojs.registerPlugins = (plugins) => Plugin.registerPlugins(plugins); * @deprecated * videojs.plugin() is deprecated; use videojs.registerPlugin() instead * - * @param {String} name + * @param {string} name * The plugin name * * @param {Plugin|Function} plugin @@ -386,28 +385,39 @@ videojs.plugin = (name, plugin) => { }; /** - * Get an object containing all available plugins. + * Gets an object containing multiple Video.js plugins. * - * @return {Object} + * @param {Array} [names] + * If provided, should be an array of plugin names. Defaults to _all_ + * plugin names. + * + * @return {Object|undefined} + * An object containing plugin(s) associated with their name(s) or + * `undefined` if no matching plugins exist). */ -videojs.getPlugins = () => Plugin.getPlugins(); +videojs.getPlugins = Plugin.getPlugins; /** - * Get a single plugin by name. + * Gets a plugin by name if it exists. * - * @param {String} name - * @return {Plugin|Function} + * @param {string} name + * The name of a plugin. + * + * @return {Function|undefined} + * The plugin (or `undefined`). */ -videojs.getPlugin = (name) => Plugin.getPlugin(name); +videojs.getPlugin = Plugin.getPlugin; /** - * Get the version - if known - of a plugin by name. + * Gets a plugin's version, if available + * + * @param {string} name + * The name of a plugin. * - * @param {String} name - * @return {String} - * If the version is not known, returns an empty string. + * @return {string} + * The plugin's version or an empty string. */ -videojs.getPluginVersion = (name) => Plugin.getPluginVersion(name); +videojs.getPluginVersion = Plugin.getPluginVersion; /** * Adding languages so that they're available to all players. From 21d484a4bb0c0c00b0fb60417d0cb7a0231869f5 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 28 Nov 2016 16:30:24 -0500 Subject: [PATCH 30/48] Remove 'exclude' option --- package.json | 1 + src/js/mixins/evented.js | 14 ++++---------- src/js/plugin.js | 6 +++++- test/unit/mixins/evented.test.js | 11 +---------- test/unit/video.test.js | 1 - 5 files changed, 11 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index d2bf00efeb..bcdc4f99f4 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "lodash": "^4.16.6", "markdown-table": "^1.0.0", "npm-run": "^4.1.0", + "permute": "^1.0.0", "proxyquireify": "^3.0.0", "qunitjs": "1.23.1", "remark-cli": "^2.1.0", diff --git a/src/js/mixins/evented.js b/src/js/mixins/evented.js index 5ce999a470..7711c268b2 100644 --- a/src/js/mixins/evented.js +++ b/src/js/mixins/evented.js @@ -351,9 +351,6 @@ const mixin = { * @param {Object} [options] * Options for customizing the mixin behavior. * - * @param {Array} [options.exclude=[]] - * An array of methods to exclude from addition to the object. - * * @param {String} [options.eventBusKey] * By default, adds a `eventBusEl_` DOM element to the target object, * which is used as an event bus. If the target object already has a @@ -363,7 +360,7 @@ const mixin = { * The target object. */ function evented(target, options = {}) { - const {exclude, eventBusKey} = options; + const {eventBusKey} = options; // Set or create the eventBusEl_. if (eventBusKey) { @@ -375,12 +372,9 @@ function evented(target, options = {}) { target.eventBusEl_ = Dom.createEl('span', {className: 'vjs-event-bus'}); } - // Add the mixin methods with whichever exclusions were requested. - ['off', 'on', 'one', 'trigger'] - .filter(name => !exclude || exclude.indexOf(name) === -1) - .forEach(name => { - target[name] = Fn.bind(target, mixin[name]); - }); + ['off', 'on', 'one', 'trigger'].forEach(name => { + target[name] = Fn.bind(target, mixin[name]); + }); // When any evented object is disposed, it removes all its listeners. target.on('dispose', () => target.off()); diff --git a/src/js/plugin.js b/src/js/plugin.js index 53ef093ea0..250a2162ad 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -145,7 +145,11 @@ class Plugin { throw new Error('Plugin must be sub-classed; not directly instantiated'); } - evented(this, {exclude: ['trigger']}); + // Make this object evented, but remove the added `trigger` method so we + // use the prototype version instead. + evented(this); + delete this.trigger; + stateful(this, this.constructor.defaultState); markPluginAsActive(player, this.name); diff --git a/test/unit/mixins/evented.test.js b/test/unit/mixins/evented.test.js index d365678152..b0302fc462 100644 --- a/test/unit/mixins/evented.test.js +++ b/test/unit/mixins/evented.test.js @@ -15,7 +15,7 @@ QUnit.module('mixins: evented', { } }); -QUnit.test('evented() mutations', function(assert) { +QUnit.test('evented() mutates an object as expected', function(assert) { const target = this.targets.a = {}; assert.strictEqual(typeof evented, 'function', 'the mixin is a function'); @@ -29,15 +29,6 @@ QUnit.test('evented() mutations', function(assert) { assert.strictEqual(typeof target.trigger, 'function', 'the target has a trigger method'); }); -QUnit.test('evented() with exclusions', function(assert) { - const target = evented({}, {exclude: ['one']}); - - assert.strictEqual(typeof target.off, 'function', 'the target has an off method'); - assert.strictEqual(typeof target.on, 'function', 'the target has an on method'); - assert.notStrictEqual(typeof target.one, 'function', 'the target DOES NOT have a one method'); - assert.strictEqual(typeof target.trigger, 'function', 'the target has a trigger method'); -}); - QUnit.test('evented() with custom element', function(assert) { const target = this.targets.a = evented({foo: Dom.createEl('span')}, {eventBusKey: 'foo'}); diff --git a/test/unit/video.test.js b/test/unit/video.test.js index 899a9baefa..cb06bb0218 100644 --- a/test/unit/video.test.js +++ b/test/unit/video.test.js @@ -166,7 +166,6 @@ QUnit.test('should add the value to the languages object with lower case lang co QUnit.test('should expose plugin functions', function(assert) { assert.strictEqual(typeof videojs.registerPlugin, 'function'); - assert.strictEqual(typeof videojs.registerPlugins, 'function'); assert.strictEqual(typeof videojs.plugin, 'function'); assert.strictEqual(typeof videojs.getPlugins, 'function'); assert.strictEqual(typeof videojs.getPlugin, 'function'); From de217dfa46db0fab41ccd9c270a5fe5e813a2596 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 28 Nov 2016 16:38:39 -0500 Subject: [PATCH 31/48] DRY-er error testing --- test/unit/mixins/evented.test.js | 149 +++++-------------------------- 1 file changed, 22 insertions(+), 127 deletions(-) diff --git a/test/unit/mixins/evented.test.js b/test/unit/mixins/evented.test.js index b0302fc462..0d8b4c1d60 100644 --- a/test/unit/mixins/evented.test.js +++ b/test/unit/mixins/evented.test.js @@ -4,6 +4,13 @@ import evented from '../../../src/js/mixins/evented'; import * as Dom from '../../../src/js/utils/dom'; import * as Obj from '../../../src/js/utils/obj'; +// Common errors thrown by evented objects. +const errors = { + type: new Error('invalid event type; must be a non-empty string or array'), + listener: new Error('invalid listener; must be a function'), + target: new Error('invalid target; must be a DOM node or evented object') +}; + QUnit.module('mixins: evented', { beforeEach() { @@ -35,106 +42,23 @@ QUnit.test('evented() with custom element', function(assert) { assert.strictEqual(target.eventBusEl_, target.foo, 'the custom DOM element is re-used'); assert.throws( - function() { - evented({foo: {}}, {eventBusKey: 'foo'}); - }, + () => evented({foo: {}}, {eventBusKey: 'foo'}), new Error('eventBusKey "foo" does not refer to an element'), 'throws if the target does not have an element at the supplied key' ); }); -QUnit.test('on() errors', function(assert) { +QUnit.test('on() and one() errors', function(assert) { const target = this.targets.a = evented({}); - assert.throws( - function() { - target.on(); - }, - new Error('invalid event type; must be a non-empty string or array') - ); - - assert.throws( - function() { - target.on(' '); - }, - new Error('invalid event type; must be a non-empty string or array') - ); - - assert.throws( - function() { - target.on([]); - }, - new Error('invalid event type; must be a non-empty string or array'), - '' - ); - - assert.throws( - function() { - target.on('x'); - }, - new Error('invalid listener; must be a function') - ); - - assert.throws( - function() { - target.on({}, 'x', () => {}); - }, - new Error('invalid target; must be a DOM node or evented object') - ); - - assert.throws( - function() { - target.on(evented({}), 'x', null); - }, - new Error('invalid listener; must be a function') - ); -}); - -QUnit.test('one() errors', function(assert) { - const target = this.targets.a = evented({}); - - assert.throws( - function() { - target.one(); - }, - new Error('invalid event type; must be a non-empty string or array') - ); - - assert.throws( - function() { - target.one(' '); - }, - new Error('invalid event type; must be a non-empty string or array') - ); - - assert.throws( - function() { - target.one([]); - }, - new Error('invalid event type; must be a non-empty string or array'), - '' - ); - - assert.throws( - function() { - target.one('x'); - }, - new Error('invalid listener; must be a function') - ); - - assert.throws( - function() { - target.one({}, 'x', () => {}); - }, - new Error('invalid target; must be a DOM node or evented object') - ); - - assert.throws( - function() { - target.one(evented({}), 'x', null); - }, - new Error('invalid listener; must be a function') - ); + ['on', 'one'].forEach(method => { + assert.throws(() => target[method](), errors.type); + assert.throws(() => target[method](' '), errors.type); + assert.throws(() => target[method]([]), errors.type); + assert.throws(() => target[method]('x'), errors.listener); + assert.throws(() => target[method]({}, 'x', () => {}), errors.target); + assert.throws(() => target[method](evented({}), 'x', null), errors.listener); + }); }); QUnit.test('off() errors', function(assert) { @@ -142,40 +66,11 @@ QUnit.test('off() errors', function(assert) { // An invalid event actually causes an invalid target error because it // gets passed into code that assumes the first argument is the target. - assert.throws( - function() { - target.off([]); - }, - new Error('invalid target; must be a DOM node or evented object') - ); - - assert.throws( - function() { - target.off({}, 'x', () => {}); - }, - new Error('invalid target; must be a DOM node or evented object') - ); - - assert.throws( - function() { - target.off(evented({}), '', () => {}); - }, - new Error('invalid event type; must be a non-empty string or array') - ); - - assert.throws( - function() { - target.off(evented({}), [], () => {}); - }, - new Error('invalid event type; must be a non-empty string or array') - ); - - assert.throws( - function() { - target.off(evented({}), 'x', null); - }, - new Error('invalid listener; must be a function') - ); + assert.throws(() => target.off([]), errors.target); + assert.throws(() => target.off({}, 'x', () => {}), errors.target); + assert.throws(() => target.off(evented({}), '', () => {}), errors.type); + assert.throws(() => target.off(evented({}), [], () => {}), errors.type); + assert.throws(() => target.off(evented({}), 'x', null), errors.listener); }); QUnit.test('a.on("x", fn)', function(assert) { From 4a82b5c0c614b53a6fe6725809b15a4a32a75da4 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 28 Nov 2016 16:49:52 -0500 Subject: [PATCH 32/48] English test names --- test/unit/mixins/evented.test.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/unit/mixins/evented.test.js b/test/unit/mixins/evented.test.js index 0d8b4c1d60..390a0b677f 100644 --- a/test/unit/mixins/evented.test.js +++ b/test/unit/mixins/evented.test.js @@ -73,7 +73,7 @@ QUnit.test('off() errors', function(assert) { assert.throws(() => target.off(evented({}), 'x', null), errors.listener); }); -QUnit.test('a.on("x", fn)', function(assert) { +QUnit.test('on() can add a listener to one event type on this object', function(assert) { const a = this.targets.a = evented({}); const spy = sinon.spy(); @@ -86,7 +86,7 @@ QUnit.test('a.on("x", fn)', function(assert) { assert.strictEqual(spy.getCall(0).args[0].target, a.eventBusEl_); }); -QUnit.test('a.on(["x", "y"], fn)', function(assert) { +QUnit.test('on() can add a listener to an array of event types on this object', function(assert) { const a = this.targets.a = evented({}); const spy = sinon.spy(); @@ -103,7 +103,7 @@ QUnit.test('a.on(["x", "y"], fn)', function(assert) { assert.strictEqual(spy.getCall(1).args[0].target, a.eventBusEl_); }); -QUnit.test('a.one("x", fn)', function(assert) { +QUnit.test('one() can add a listener to one event type on this object', function(assert) { const a = this.targets.a = evented({}); const spy = sinon.spy(); @@ -117,7 +117,7 @@ QUnit.test('a.one("x", fn)', function(assert) { assert.strictEqual(spy.getCall(0).args[0].target, a.eventBusEl_); }); -QUnit.test('a.one(["x", "y"], fn)', function(assert) { +QUnit.test('one() can add a listener to an array of event types on this object', function(assert) { const a = this.targets.a = evented({}); const spy = sinon.spy(); @@ -136,7 +136,7 @@ QUnit.test('a.one(["x", "y"], fn)', function(assert) { assert.strictEqual(spy.getCall(1).args[0].target, a.eventBusEl_); }); -QUnit.test('a.on(b, "x", fn)', function(assert) { +QUnit.test('on() can add a listener to one event type on a different target object', function(assert) { const a = this.targets.a = evented({}); const b = this.targets.b = evented({}); const spy = sinon.spy(); @@ -153,7 +153,7 @@ QUnit.test('a.on(b, "x", fn)', function(assert) { assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); }); -QUnit.test('a.on(b, ["x", "y"], fn)', function(assert) { +QUnit.test('on() can add a listener to an array of event types on a different target object', function(assert) { const a = this.targets.a = evented({}); const b = this.targets.b = evented({}); const spy = sinon.spy(); @@ -175,7 +175,7 @@ QUnit.test('a.on(b, ["x", "y"], fn)', function(assert) { assert.strictEqual(spy.getCall(1).args[0].target, b.eventBusEl_); }); -QUnit.test('a.one(b, "x", fn)', function(assert) { +QUnit.test('one() can add a listener to one event type on a different target object', function(assert) { const a = this.targets.a = evented({}); const b = this.targets.b = evented({}); const spy = sinon.spy(); @@ -194,7 +194,7 @@ QUnit.test('a.one(b, "x", fn)', function(assert) { // The behavior here unfortunately differs from the identical case where "a" // listens to itself. This is something that should be resolved... -QUnit.test('a.one(b, ["x", "y"], fn)', function(assert) { +QUnit.test('one() can add a listener to an array of event types on a different target object', function(assert) { const a = this.targets.a = evented({}); const b = this.targets.b = evented({}); const spy = sinon.spy(); @@ -215,7 +215,7 @@ QUnit.test('a.one(b, ["x", "y"], fn)', function(assert) { assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); }); -QUnit.test('a.off()', function(assert) { +QUnit.test('off() with no arguments will remove all listeners from all events on this object', function(assert) { const a = this.targets.a = evented({}); const spyX = sinon.spy(); const spyY = sinon.spy(); @@ -239,7 +239,7 @@ QUnit.test('a.off()', function(assert) { assert.strictEqual(spyY.getCall(0).args[0].target, a.eventBusEl_); }); -QUnit.test('a.off("x")', function(assert) { +QUnit.test('off() can remove all listeners from a single event on this object', function(assert) { const a = this.targets.a = evented({}); const spyX = sinon.spy(); const spyY = sinon.spy(); @@ -266,7 +266,7 @@ QUnit.test('a.off("x")', function(assert) { assert.strictEqual(spyY.getCall(1).args[0].target, a.eventBusEl_); }); -QUnit.test('a.off("x", fn)', function(assert) { +QUnit.test('off() can remove a listener from a single event on this object', function(assert) { const a = this.targets.a = evented({}); const spyX1 = sinon.spy(); const spyX2 = sinon.spy(); @@ -303,7 +303,7 @@ QUnit.test('a.off("x", fn)', function(assert) { assert.strictEqual(spyY.getCall(1).args[0].target, a.eventBusEl_); }); -QUnit.test('a.off(b, "x", fn)', function(assert) { +QUnit.test('off() can remove a listener from a single event on a different target object', function(assert) { const a = this.targets.a = evented({}); const b = this.targets.b = evented({}); const spy = sinon.spy(); @@ -319,7 +319,7 @@ QUnit.test('a.off(b, "x", fn)', function(assert) { assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); }); -QUnit.test('a.off(b, ["x", "y"], fn)', function(assert) { +QUnit.test('off() can remove a listener from an array of events on a different target object', function(assert) { const a = this.targets.a = evented({}); const b = this.targets.b = evented({}); const spy = sinon.spy(); From 1c7514c429093e75c8a342294ddb2c154149bb46 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 28 Nov 2016 16:53:49 -0500 Subject: [PATCH 33/48] Address stateful test feedback --- test/unit/mixins/stateful.test.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/unit/mixins/stateful.test.js b/test/unit/mixins/stateful.test.js index ca4373ee7a..c410f5004d 100644 --- a/test/unit/mixins/stateful.test.js +++ b/test/unit/mixins/stateful.test.js @@ -6,7 +6,7 @@ import * as Obj from '../../../src/js/utils/obj'; QUnit.module('mixins: stateful'); -QUnit.test('stateful() mutations', function(assert) { +QUnit.test('stateful() mutates an object as expected', function(assert) { const target = {}; assert.strictEqual(typeof stateful, 'function', 'the mixin is a function'); @@ -18,13 +18,19 @@ QUnit.test('stateful() mutations', function(assert) { assert.strictEqual(typeof target.setState, 'function', 'the target has a setState method'); }); -QUnit.test('stateful() with defaults', function(assert) { +QUnit.test('stateful() with default state passed in', function(assert) { const target = stateful({}, {foo: 'bar'}); assert.strictEqual(target.state.foo, 'bar', 'the default properties are added to the state'); }); -QUnit.test('setState()', function(assert) { +QUnit.test('stateful() without default state passed in', function(assert) { + const target = stateful({}); + + assert.strictEqual(Object.keys(target.state).length, 0, 'no default properties are added to the state'); +}); + +QUnit.test('setState() works as expected', function(assert) { const target = stateful(new EventTarget(), {foo: 'bar', abc: 'xyz'}); const spy = sinon.spy(); @@ -52,7 +58,7 @@ QUnit.test('setState()', function(assert) { assert.strictEqual(event.changes, changes, 'the changes object is sent along with the event'); }); -QUnit.test('setState() without changes', function(assert) { +QUnit.test('setState() without changes does not trigger the "statechanged" event', function(assert) { const target = stateful(new EventTarget(), {foo: 'bar'}); const spy = sinon.spy(); From 19844be122fd17570a39f38c33c2dde2dcd72d67 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 29 Nov 2016 10:46:19 -0500 Subject: [PATCH 34/48] Testing updates based on feedback --- src/js/plugin.js | 7 + test/unit/mixins/evented.test.js | 267 ++++++++++++++++++------------ test/unit/mixins/stateful.test.js | 2 +- test/unit/plugin-basic.test.js | 59 ++----- test/unit/plugin-class.test.js | 134 +++------------ test/unit/plugin-static.test.js | 75 +++++---- test/unit/video.test.js | 14 +- 7 files changed, 264 insertions(+), 294 deletions(-) diff --git a/src/js/plugin.js b/src/js/plugin.js index 250a2162ad..cd4ec76fa7 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -408,6 +408,13 @@ class Plugin { Plugin.registerPlugin(BASE_PLUGIN_NAME, Plugin); +/** + * The name of the base plugin class as it is registered. + * + * @type {string} + */ +Plugin.BASE_PLUGIN_NAME = BASE_PLUGIN_NAME; + /** * Documented in player.js * diff --git a/test/unit/mixins/evented.test.js b/test/unit/mixins/evented.test.js index 390a0b677f..d64085fb18 100644 --- a/test/unit/mixins/evented.test.js +++ b/test/unit/mixins/evented.test.js @@ -11,6 +11,19 @@ const errors = { target: new Error('invalid target; must be a DOM node or evented object') }; +const validateListenerCall = (call, thisValue, eventExpectation) => { + const eventActual = call.args[0]; + + QUnit.assert.strictEqual(call.thisValue, thisValue, 'the listener had the expected "this" value'); + QUnit.assert.strictEqual(typeof eventActual, 'object', 'the listener was passed an event object'); + + // We don't use `deepEqual` here because we only want to test a subset of + // properties (designated by the `eventExpectation`). + Object.keys(eventExpectation).forEach(key => { + QUnit.assert.strictEqual(eventActual[key], eventExpectation[key], `the event had the expected "${key}"`); + }); +}; + QUnit.module('mixins: evented', { beforeEach() { @@ -52,12 +65,12 @@ QUnit.test('on() and one() errors', function(assert) { const target = this.targets.a = evented({}); ['on', 'one'].forEach(method => { - assert.throws(() => target[method](), errors.type); - assert.throws(() => target[method](' '), errors.type); - assert.throws(() => target[method]([]), errors.type); - assert.throws(() => target[method]('x'), errors.listener); - assert.throws(() => target[method]({}, 'x', () => {}), errors.target); - assert.throws(() => target[method](evented({}), 'x', null), errors.listener); + assert.throws(() => target[method](), errors.type, 'the expected error is thrown'); + assert.throws(() => target[method](' '), errors.type, 'the expected error is thrown'); + assert.throws(() => target[method]([]), errors.type, 'the expected error is thrown'); + assert.throws(() => target[method]('x'), errors.listener, 'the expected error is thrown'); + assert.throws(() => target[method]({}, 'x', () => {}), errors.target, 'the expected error is thrown'); + assert.throws(() => target[method](evented({}), 'x', null), errors.listener, 'the expected error is thrown'); }); }); @@ -66,11 +79,11 @@ QUnit.test('off() errors', function(assert) { // An invalid event actually causes an invalid target error because it // gets passed into code that assumes the first argument is the target. - assert.throws(() => target.off([]), errors.target); - assert.throws(() => target.off({}, 'x', () => {}), errors.target); - assert.throws(() => target.off(evented({}), '', () => {}), errors.type); - assert.throws(() => target.off(evented({}), [], () => {}), errors.type); - assert.throws(() => target.off(evented({}), 'x', null), errors.listener); + assert.throws(() => target.off([]), errors.target, 'the expected error is thrown'); + assert.throws(() => target.off({}, 'x', () => {}), errors.target, 'the expected error is thrown'); + assert.throws(() => target.off(evented({}), '', () => {}), errors.type, 'the expected error is thrown'); + assert.throws(() => target.off(evented({}), [], () => {}), errors.type, 'the expected error is thrown'); + assert.throws(() => target.off(evented({}), 'x', null), errors.listener, 'the expected error is thrown'); }); QUnit.test('on() can add a listener to one event type on this object', function(assert) { @@ -80,10 +93,12 @@ QUnit.test('on() can add a listener to one event type on this object', function( a.on('x', spy); a.trigger('x'); - assert.strictEqual(spy.callCount, 1); - assert.strictEqual(spy.getCall(0).thisValue, a); - assert.strictEqual(spy.getCall(0).args[0].type, 'x'); - assert.strictEqual(spy.getCall(0).args[0].target, a.eventBusEl_); + assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times'); + + validateListenerCall(spy.getCall(0), a, { + type: 'x', + target: a.eventBusEl_ + }); }); QUnit.test('on() can add a listener to an array of event types on this object', function(assert) { @@ -94,13 +109,17 @@ QUnit.test('on() can add a listener to an array of event types on this object', a.trigger('x'); a.trigger('y'); - assert.strictEqual(spy.callCount, 2); - assert.strictEqual(spy.getCall(0).thisValue, a); - assert.strictEqual(spy.getCall(0).args[0].type, 'x'); - assert.strictEqual(spy.getCall(0).args[0].target, a.eventBusEl_); - assert.strictEqual(spy.getCall(1).thisValue, a); - assert.strictEqual(spy.getCall(1).args[0].type, 'y'); - assert.strictEqual(spy.getCall(1).args[0].target, a.eventBusEl_); + assert.strictEqual(spy.callCount, 2, 'the listener was called the expected number of times'); + + validateListenerCall(spy.getCall(0), a, { + type: 'x', + target: a.eventBusEl_ + }); + + validateListenerCall(spy.getCall(1), a, { + type: 'y', + target: a.eventBusEl_ + }); }); QUnit.test('one() can add a listener to one event type on this object', function(assert) { @@ -111,10 +130,12 @@ QUnit.test('one() can add a listener to one event type on this object', function a.trigger('x'); a.trigger('x'); - assert.strictEqual(spy.callCount, 1); - assert.strictEqual(spy.getCall(0).thisValue, a); - assert.strictEqual(spy.getCall(0).args[0].type, 'x'); - assert.strictEqual(spy.getCall(0).args[0].target, a.eventBusEl_); + assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times'); + + validateListenerCall(spy.getCall(0), a, { + type: 'x', + target: a.eventBusEl_ + }); }); QUnit.test('one() can add a listener to an array of event types on this object', function(assert) { @@ -127,13 +148,17 @@ QUnit.test('one() can add a listener to an array of event types on this object', a.trigger('x'); a.trigger('y'); - assert.strictEqual(spy.callCount, 2); - assert.strictEqual(spy.getCall(0).thisValue, a); - assert.strictEqual(spy.getCall(0).args[0].type, 'x'); - assert.strictEqual(spy.getCall(0).args[0].target, a.eventBusEl_); - assert.strictEqual(spy.getCall(1).thisValue, a); - assert.strictEqual(spy.getCall(1).args[0].type, 'y'); - assert.strictEqual(spy.getCall(1).args[0].target, a.eventBusEl_); + assert.strictEqual(spy.callCount, 2, 'the listener was called the expected number of times'); + + validateListenerCall(spy.getCall(0), a, { + type: 'x', + target: a.eventBusEl_ + }); + + validateListenerCall(spy.getCall(1), a, { + type: 'y', + target: a.eventBusEl_ + }); }); QUnit.test('on() can add a listener to one event type on a different target object', function(assert) { @@ -147,10 +172,12 @@ QUnit.test('on() can add a listener to one event type on a different target obje // Make sure we aren't magically binding a listener to "a". a.trigger('x'); - assert.strictEqual(spy.callCount, 1); - assert.strictEqual(spy.getCall(0).thisValue, a); - assert.strictEqual(spy.getCall(0).args[0].type, 'x'); - assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); + assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times'); + + validateListenerCall(spy.getCall(0), a, { + type: 'x', + target: b.eventBusEl_ + }); }); QUnit.test('on() can add a listener to an array of event types on a different target object', function(assert) { @@ -166,13 +193,17 @@ QUnit.test('on() can add a listener to an array of event types on a different ta a.trigger('x'); a.trigger('y'); - assert.strictEqual(spy.callCount, 2); - assert.strictEqual(spy.getCall(0).thisValue, a); - assert.strictEqual(spy.getCall(0).args[0].type, 'x'); - assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); - assert.strictEqual(spy.getCall(1).thisValue, a); - assert.strictEqual(spy.getCall(1).args[0].type, 'y'); - assert.strictEqual(spy.getCall(1).args[0].target, b.eventBusEl_); + assert.strictEqual(spy.callCount, 2, 'the listener was called the expected number of times'); + + validateListenerCall(spy.getCall(0), a, { + type: 'x', + target: b.eventBusEl_ + }); + + validateListenerCall(spy.getCall(1), a, { + type: 'y', + target: b.eventBusEl_ + }); }); QUnit.test('one() can add a listener to one event type on a different target object', function(assert) { @@ -186,10 +217,12 @@ QUnit.test('one() can add a listener to one event type on a different target obj // Make sure we aren't magically binding a listener to "a". a.trigger('x'); - assert.strictEqual(spy.callCount, 1); - assert.strictEqual(spy.getCall(0).thisValue, a); - assert.strictEqual(spy.getCall(0).args[0].type, 'x'); - assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); + assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times'); + + validateListenerCall(spy.getCall(0), a, { + type: 'x', + target: b.eventBusEl_ + }); }); // The behavior here unfortunately differs from the identical case where "a" @@ -209,10 +242,12 @@ QUnit.test('one() can add a listener to an array of event types on a different t a.trigger('x'); a.trigger('y'); - assert.strictEqual(spy.callCount, 1); - assert.strictEqual(spy.getCall(0).thisValue, a); - assert.strictEqual(spy.getCall(0).args[0].type, 'x'); - assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); + assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times'); + + validateListenerCall(spy.getCall(0), a, { + type: 'x', + target: b.eventBusEl_ + }); }); QUnit.test('off() with no arguments will remove all listeners from all events on this object', function(assert) { @@ -228,15 +263,19 @@ QUnit.test('off() with no arguments will remove all listeners from all events on a.trigger('x'); a.trigger('y'); - assert.strictEqual(spyX.callCount, 1); - assert.strictEqual(spyX.getCall(0).thisValue, a); - assert.strictEqual(spyX.getCall(0).args[0].type, 'x'); - assert.strictEqual(spyX.getCall(0).args[0].target, a.eventBusEl_); + assert.strictEqual(spyX.callCount, 1, 'the listener was called the expected number of times'); + + validateListenerCall(spyX.getCall(0), a, { + type: 'x', + target: a.eventBusEl_ + }); + + assert.strictEqual(spyY.callCount, 1, 'the listener was called the expected number of times'); - assert.strictEqual(spyY.callCount, 1); - assert.strictEqual(spyY.getCall(0).thisValue, a); - assert.strictEqual(spyY.getCall(0).args[0].type, 'y'); - assert.strictEqual(spyY.getCall(0).args[0].target, a.eventBusEl_); + validateListenerCall(spyY.getCall(0), a, { + type: 'y', + target: a.eventBusEl_ + }); }); QUnit.test('off() can remove all listeners from a single event on this object', function(assert) { @@ -252,18 +291,24 @@ QUnit.test('off() can remove all listeners from a single event on this object', a.trigger('x'); a.trigger('y'); - assert.strictEqual(spyX.callCount, 1); - assert.strictEqual(spyX.getCall(0).thisValue, a); - assert.strictEqual(spyX.getCall(0).args[0].type, 'x'); - assert.strictEqual(spyX.getCall(0).args[0].target, a.eventBusEl_); - - assert.strictEqual(spyY.callCount, 2); - assert.strictEqual(spyY.getCall(0).thisValue, a); - assert.strictEqual(spyY.getCall(0).args[0].type, 'y'); - assert.strictEqual(spyY.getCall(0).args[0].target, a.eventBusEl_); - assert.strictEqual(spyY.getCall(1).thisValue, a); - assert.strictEqual(spyY.getCall(1).args[0].type, 'y'); - assert.strictEqual(spyY.getCall(1).args[0].target, a.eventBusEl_); + assert.strictEqual(spyX.callCount, 1, 'the listener was called the expected number of times'); + + validateListenerCall(spyX.getCall(0), a, { + type: 'x', + target: a.eventBusEl_ + }); + + assert.strictEqual(spyY.callCount, 2, 'the listener was called the expected number of times'); + + validateListenerCall(spyY.getCall(0), a, { + type: 'y', + target: a.eventBusEl_ + }); + + validateListenerCall(spyY.getCall(1), a, { + type: 'y', + target: a.eventBusEl_ + }); }); QUnit.test('off() can remove a listener from a single event on this object', function(assert) { @@ -281,26 +326,36 @@ QUnit.test('off() can remove a listener from a single event on this object', fun a.trigger('x'); a.trigger('y'); - assert.strictEqual(spyX1.callCount, 1); - assert.strictEqual(spyX1.getCall(0).thisValue, a); - assert.strictEqual(spyX1.getCall(0).args[0].type, 'x'); - assert.strictEqual(spyX1.getCall(0).args[0].target, a.eventBusEl_); - - assert.strictEqual(spyX2.callCount, 2); - assert.strictEqual(spyX2.getCall(0).thisValue, a); - assert.strictEqual(spyX2.getCall(0).args[0].type, 'x'); - assert.strictEqual(spyX2.getCall(0).args[0].target, a.eventBusEl_); - assert.strictEqual(spyX2.getCall(1).thisValue, a); - assert.strictEqual(spyX2.getCall(1).args[0].type, 'x'); - assert.strictEqual(spyX2.getCall(1).args[0].target, a.eventBusEl_); - - assert.strictEqual(spyY.callCount, 2); - assert.strictEqual(spyY.getCall(0).thisValue, a); - assert.strictEqual(spyY.getCall(0).args[0].type, 'y'); - assert.strictEqual(spyY.getCall(0).args[0].target, a.eventBusEl_); - assert.strictEqual(spyY.getCall(1).thisValue, a); - assert.strictEqual(spyY.getCall(1).args[0].type, 'y'); - assert.strictEqual(spyY.getCall(1).args[0].target, a.eventBusEl_); + assert.strictEqual(spyX1.callCount, 1, 'the listener was called the expected number of times'); + + validateListenerCall(spyX1.getCall(0), a, { + type: 'x', + target: a.eventBusEl_ + }); + + assert.strictEqual(spyX2.callCount, 2, 'the listener was called the expected number of times'); + + validateListenerCall(spyX2.getCall(0), a, { + type: 'x', + target: a.eventBusEl_ + }); + + validateListenerCall(spyX2.getCall(1), a, { + type: 'x', + target: a.eventBusEl_ + }); + + assert.strictEqual(spyY.callCount, 2, 'the listener was called the expected number of times'); + + validateListenerCall(spyY.getCall(0), a, { + type: 'y', + target: a.eventBusEl_ + }); + + validateListenerCall(spyY.getCall(1), a, { + type: 'y', + target: a.eventBusEl_ + }); }); QUnit.test('off() can remove a listener from a single event on a different target object', function(assert) { @@ -313,10 +368,12 @@ QUnit.test('off() can remove a listener from a single event on a different targe a.off(b, 'x', spy); b.trigger('x'); - assert.strictEqual(spy.callCount, 1); - assert.strictEqual(spy.getCall(0).thisValue, a); - assert.strictEqual(spy.getCall(0).args[0].type, 'x'); - assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); + assert.strictEqual(spy.callCount, 1, 'the listener was called the expected number of times'); + + validateListenerCall(spy.getCall(0), a, { + type: 'x', + target: b.eventBusEl_ + }); }); QUnit.test('off() can remove a listener from an array of events on a different target object', function(assert) { @@ -331,11 +388,15 @@ QUnit.test('off() can remove a listener from an array of events on a different t b.trigger('x'); b.trigger('y'); - assert.strictEqual(spy.callCount, 2); - assert.strictEqual(spy.getCall(0).thisValue, a); - assert.strictEqual(spy.getCall(0).args[0].type, 'x'); - assert.strictEqual(spy.getCall(0).args[0].target, b.eventBusEl_); - assert.strictEqual(spy.getCall(1).thisValue, a); - assert.strictEqual(spy.getCall(1).args[0].type, 'y'); - assert.strictEqual(spy.getCall(1).args[0].target, b.eventBusEl_); + assert.strictEqual(spy.callCount, 2, 'the listener was called the expected number of times'); + + validateListenerCall(spy.getCall(0), a, { + type: 'x', + target: b.eventBusEl_ + }); + + validateListenerCall(spy.getCall(1), a, { + type: 'y', + target: b.eventBusEl_ + }); }); diff --git a/test/unit/mixins/stateful.test.js b/test/unit/mixins/stateful.test.js index c410f5004d..377fb9e557 100644 --- a/test/unit/mixins/stateful.test.js +++ b/test/unit/mixins/stateful.test.js @@ -54,7 +54,7 @@ QUnit.test('setState() works as expected', function(assert) { const event = spy.firstCall.args[0]; - assert.strictEqual(event.type, 'statechanged'); + assert.strictEqual(event.type, 'statechanged', 'the event had the expected type'); assert.strictEqual(event.changes, changes, 'the changes object is sent along with the event'); }); diff --git a/test/unit/plugin-basic.test.js b/test/unit/plugin-basic.test.js index 0eaa35ec86..8fff898f70 100644 --- a/test/unit/plugin-basic.test.js +++ b/test/unit/plugin-basic.test.js @@ -14,56 +14,29 @@ QUnit.module('Plugin: basic', { afterEach() { this.player.dispose(); - Plugin.deregisterPlugin('basic'); + + Object.keys(Plugin.getPlugins()).forEach(key => { + if (key !== Plugin.BASE_PLUGIN_NAME) { + Plugin.deregisterPlugin(key); + } + }); } }); QUnit.test('pre-setup interface', function(assert) { - assert.strictEqual( - typeof this.player.basic, - 'function', - 'basic plugins are a function on a player' - ); - + assert.strictEqual(typeof this.player.basic, 'function', 'basic plugins are a function on a player'); assert.ok(this.player.hasPlugin('basic'), 'player has the plugin available'); - - assert.notStrictEqual( - this.player.basic, - this.basic, - 'basic plugins are wrapped' - ); - - assert.strictEqual( - this.player.basic.dispose, - undefined, - 'unlike class-based plugins, basic plugins do not have a dispose method' - ); - - assert.notOk(this.player.usingPlugin('basic')); + assert.notStrictEqual(this.player.basic, this.basic, 'basic plugins are wrapped'); + assert.strictEqual(this.player.basic.dispose, undefined, 'unlike class-based plugins, basic plugins do not have a dispose method'); + assert.notOk(this.player.usingPlugin('basic'), 'the player is not using the plugin'); }); QUnit.test('setup', function(assert) { this.player.basic({foo: 'bar'}, 123); - assert.strictEqual(this.basic.callCount, 1, 'the plugin was called once'); - - assert.strictEqual( - this.basic.firstCall.thisValue, - this.player, - 'the plugin `this` value was the player' - ); - - assert.deepEqual( - this.basic.firstCall.args, - [{foo: 'bar'}, 123], - 'the plugin had the correct arguments' - ); - - assert.ok( - this.player.usingPlugin('basic'), - 'the player now recognizes that the plugin was set up' - ); - + assert.strictEqual(this.basic.firstCall.thisValue, this.player, 'the plugin `this` value was the player'); + assert.deepEqual(this.basic.firstCall.args, [{foo: 'bar'}, 123], 'the plugin had the correct arguments'); + assert.ok(this.player.usingPlugin('basic'), 'the player now recognizes that the plugin was set up'); assert.ok(this.player.hasPlugin('basic'), 'player has the plugin available'); }); @@ -71,14 +44,14 @@ QUnit.test('"pluginsetup" event', function(assert) { const setupSpy = sinon.spy(); this.player.on('pluginsetup', setupSpy); - const instance = this.player.basic(); - - assert.strictEqual(setupSpy.callCount, 1, 'the "pluginsetup" event was triggered'); + const instance = this.player.basic(); const event = setupSpy.firstCall.args[0]; const hash = setupSpy.firstCall.args[1]; + assert.strictEqual(setupSpy.callCount, 1, 'the "pluginsetup" event was triggered'); assert.strictEqual(event.type, 'pluginsetup', 'the event has the correct type'); + assert.deepEqual(hash, { name: 'basic', instance, diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js index 18d316e569..b191cf0d05 100644 --- a/test/unit/plugin-class.test.js +++ b/test/unit/plugin-class.test.js @@ -23,71 +23,34 @@ QUnit.module('Plugin: class-based', { afterEach() { this.player.dispose(); - Plugin.deregisterPlugin('mock'); + + Object.keys(Plugin.getPlugins()).forEach(key => { + if (key !== Plugin.BASE_PLUGIN_NAME) { + Plugin.deregisterPlugin(key); + } + }); } }); QUnit.test('pre-setup interface', function(assert) { - assert.strictEqual( - typeof this.player.plugin, - 'undefined', - 'the base Plugin does not add a method to the player' - ); - - assert.strictEqual( - typeof this.player.mock, - 'function', - 'plugins are a factory function on a player' - ); - + assert.strictEqual(typeof this.player.plugin, 'undefined', 'the base Plugin does not add a method to the player'); + assert.strictEqual(typeof this.player.mock, 'function', 'plugins are a factory function on a player'); assert.ok(this.player.hasPlugin('mock'), 'player has the plugin available'); - - assert.strictEqual( - this.player.mock.dispose, - undefined, - 'class-based plugins are not populated on a player until the factory method creates them' - ); - - assert.notOk(this.player.usingPlugin('mock')); + assert.strictEqual(this.player.mock.dispose, undefined, 'class-based plugins are not populated on a player until the factory method creates them'); + assert.notOk(this.player.usingPlugin('mock'), 'the player is not using the plugin'); }); QUnit.test('setup', function(assert) { const instance = this.player.mock({foo: 'bar'}, 123); assert.strictEqual(this.spy.callCount, 1, 'plugin was set up once'); - - assert.strictEqual( - this.spy.firstCall.thisValue, - instance, - 'plugin constructor `this` value was the instance' - ); - - assert.deepEqual( - this.spy.firstCall.args, - [this.player, {foo: 'bar'}, 123], - 'plugin had the correct arguments' - ); - - assert.ok( - this.player.usingPlugin('mock'), - 'player now recognizes that the plugin was set up' - ); - + assert.strictEqual(this.spy.firstCall.thisValue, instance, 'plugin constructor `this` value was the instance'); + assert.deepEqual(this.spy.firstCall.args, [this.player, {foo: 'bar'}, 123], 'plugin had the correct arguments'); + assert.ok(this.player.usingPlugin('mock'), 'player now recognizes that the plugin was set up'); assert.ok(this.player.hasPlugin('mock'), 'player has the plugin available'); - - assert.ok( - instance instanceof this.MockPlugin, - 'plugin instance has the correct constructor' - ); - + assert.ok(instance instanceof this.MockPlugin, 'plugin instance has the correct constructor'); assert.strictEqual(instance, this.player.mock, 'instance replaces the factory'); - - assert.strictEqual( - instance.player, - this.player, - 'instance has a reference to the player' - ); - + assert.strictEqual(instance.player, this.player, 'instance has a reference to the player'); assert.strictEqual(instance.name, 'mock', 'instance knows its name'); assert.strictEqual(typeof instance.state, 'object', 'instance is stateful'); assert.strictEqual(typeof instance.setState, 'function', 'instance is stateful'); @@ -98,11 +61,7 @@ QUnit.test('setup', function(assert) { assert.strictEqual(typeof instance.dispose, 'function', 'instance has dispose method'); assert.throws( - function() { - - // This needs to return so that the linter doesn't complain. - return new Plugin(this.player); - }, + () => new Plugin(this.player), new Error('Plugin must be sub-classed; not directly instantiated'), 'the Plugin class cannot be directly instantiated' ); @@ -112,20 +71,14 @@ QUnit.test('"pluginsetup" event', function(assert) { const setupSpy = sinon.spy(); this.player.on('pluginsetup', setupSpy); - const instance = this.player.mock(); - - assert.strictEqual(setupSpy.callCount, 1, 'the "pluginsetup" event was triggered'); + const instance = this.player.mock(); const event = setupSpy.firstCall.args[0]; const hash = setupSpy.firstCall.args[1]; + assert.strictEqual(setupSpy.callCount, 1, 'the "pluginsetup" event was triggered'); assert.strictEqual(event.type, 'pluginsetup', 'the event has the correct type'); - - assert.strictEqual( - event.target, - this.player.el_, - 'the event has the correct target' - ); + assert.strictEqual(event.target, this.player.el_, 'the event has the correct target'); assert.deepEqual(hash, { name: 'mock', @@ -141,8 +94,7 @@ QUnit.test('defaultState static property is used to populate state', function(as const instance = this.player.dsm(); - assert.deepEqual(instance.state, {foo: 1, bar: 2}); - Plugin.deregisterPlugin('dsm'); + assert.deepEqual(instance.state, {foo: 1, bar: 2}, 'the plugin state has default properties'); }); QUnit.test('dispose', function(assert) { @@ -150,34 +102,16 @@ QUnit.test('dispose', function(assert) { instance.dispose(); - assert.notOk( - this.player.usingPlugin('mock'), - 'player recognizes that the plugin is NOT set up' - ); - + assert.notOk(this.player.usingPlugin('mock'), 'player recognizes that the plugin is NOT set up'); assert.ok(this.player.hasPlugin('mock'), 'player still has the plugin available'); - - assert.strictEqual( - typeof this.player.mock, - 'function', - 'instance is replaced by factory' - ); - + assert.strictEqual(typeof this.player.mock, 'function', 'instance is replaced by factory'); assert.notStrictEqual(instance, this.player.mock, 'instance is replaced by factory'); - - assert.strictEqual( - instance.player, - null, - 'instance no longer has a reference to the player' - ); - + assert.strictEqual(instance.player, null, 'instance no longer has a reference to the player'); assert.strictEqual(instance.state, null, 'state is now null'); ['dispose', 'setState', 'off', 'on', 'one', 'trigger'].forEach(n => { assert.throws( - function() { - instance[n](); - }, + () => instance[n](), new Error('cannot call methods on a disposed object'), `the "${n}" method now throws` ); @@ -197,12 +131,7 @@ QUnit.test('"dispose" event', function(assert) { const hash = disposeSpy.firstCall.args[1]; assert.strictEqual(event.type, 'dispose', 'the event has the correct type'); - - assert.strictEqual( - event.target, - instance.eventBusEl_, - 'the event has the correct target' - ); + assert.strictEqual(event.target, instance.eventBusEl_, 'the event has the correct target'); assert.deepEqual(hash, { name: 'mock', @@ -218,18 +147,12 @@ QUnit.test('arbitrary events', function(assert) { instance.on('foo', fooSpy); instance.trigger('foo'); - assert.strictEqual(fooSpy.callCount, 1, 'the "foo" event was triggered'); - const event = fooSpy.firstCall.args[0]; const hash = fooSpy.firstCall.args[1]; + assert.strictEqual(fooSpy.callCount, 1, 'the "foo" event was triggered'); assert.strictEqual(event.type, 'foo', 'the event has the correct type'); - - assert.strictEqual( - event.target, - instance.eventBusEl_, - 'the event has the correct target' - ); + assert.strictEqual(event.target, instance.eventBusEl_, 'the event has the correct target'); assert.deepEqual(hash, { name: 'mock', @@ -243,7 +166,6 @@ QUnit.test('handleStateChanged() method is automatically bound to the "statechan class TestHandler extends Plugin {} TestHandler.prototype.handleStateChanged = spy; - Plugin.registerPlugin('testHandler', TestHandler); const instance = this.player.testHandler(); @@ -252,6 +174,4 @@ QUnit.test('handleStateChanged() method is automatically bound to the "statechan assert.strictEqual(spy.callCount, 1, 'the handleStateChanged listener was called'); assert.strictEqual(spy.firstCall.args[0].type, 'statechanged', 'the event was "statechanged"'); assert.strictEqual(typeof spy.firstCall.args[0].changes, 'object', 'the event included a changes object'); - - Plugin.deregisterPlugin('testHandler'); }); diff --git a/test/unit/plugin-static.test.js b/test/unit/plugin-static.test.js index 54f8752c0b..cde6668e7f 100644 --- a/test/unit/plugin-static.test.js +++ b/test/unit/plugin-static.test.js @@ -16,23 +16,32 @@ QUnit.module('Plugin: static methods', { }, afterEach() { - Plugin.deregisterPlugin('basic'); - Plugin.deregisterPlugin('mock'); + Object.keys(Plugin.getPlugins()).forEach(key => { + if (key !== Plugin.BASE_PLUGIN_NAME) { + Plugin.deregisterPlugin(key); + } + }); } }); -QUnit.test('registerPlugin()', function(assert) { +QUnit.test('registerPlugin() works with basic plugins', function(assert) { const foo = () => {}; - assert.strictEqual(Plugin.registerPlugin('foo', foo), foo); - assert.strictEqual(Plugin.getPlugin('foo'), foo); - assert.strictEqual(typeof Player.prototype.foo, 'function'); + assert.strictEqual(Plugin.registerPlugin('foo', foo), foo, 'the plugin is returned'); + assert.strictEqual(Plugin.getPlugin('foo'), foo, 'the plugin can be retrieved'); + assert.strictEqual(typeof Player.prototype.foo, 'function', 'the plugin has a wrapper function'); + assert.notStrictEqual(Player.prototype.foo, foo, 'the function on the player prototype is a wrapper'); - assert.notStrictEqual( - Player.prototype.foo, - foo, - 'the function on the player prototype is a wrapper' - ); + Plugin.deregisterPlugin('foo'); +}); + +QUnit.test('registerPlugin() works with class-based plugins', function(assert) { + class Foo extends Plugin {} + + assert.strictEqual(Plugin.registerPlugin('foo', Foo), Foo, 'the plugin is returned'); + assert.strictEqual(Plugin.getPlugin('foo'), Foo, 'the plugin can be retrieved'); + assert.strictEqual(typeof Player.prototype.foo, 'function', 'the plugin has a factory function'); + assert.notStrictEqual(Player.prototype.foo, Foo, 'the function on the player prototype is a factory'); Plugin.deregisterPlugin('foo'); }); @@ -64,30 +73,25 @@ QUnit.test('registerPlugin() illegal arguments', function(assert) { }); QUnit.test('getPlugin()', function(assert) { - assert.ok(Plugin.getPlugin('basic')); - assert.ok(Plugin.getPlugin('mock')); - assert.strictEqual(Plugin.getPlugin(), undefined); - assert.strictEqual(Plugin.getPlugin('nonExistent'), undefined); - assert.strictEqual(Plugin.getPlugin(123), undefined); + assert.ok(Plugin.getPlugin('basic'), 'the "basic" plugin exists'); + assert.ok(Plugin.getPlugin('mock'), 'the "mock" plugin exists'); + assert.strictEqual(Plugin.getPlugin(), undefined, 'returns undefined with no arguments'); + assert.strictEqual(Plugin.getPlugin('nonExistent'), undefined, 'returns undefined with non-existent plugin'); + assert.strictEqual(Plugin.getPlugin(123), undefined, 'returns undefined with an invalid type'); }); QUnit.test('getPluginVersion()', function(assert) { - assert.strictEqual( - Plugin.getPluginVersion('basic'), - '', - 'the basic plugin has no version' - ); - - assert.strictEqual(Plugin.getPluginVersion('mock'), 'v1.2.3'); + assert.strictEqual(Plugin.getPluginVersion('basic'), '', 'the basic plugin has no version'); + assert.strictEqual(Plugin.getPluginVersion('mock'), 'v1.2.3', 'a plugin with a version returns its version'); }); QUnit.test('getPlugins()', function(assert) { - assert.strictEqual(Object.keys(Plugin.getPlugins()).length, 3); - assert.strictEqual(Plugin.getPlugins().basic, this.basic); - assert.strictEqual(Plugin.getPlugins().mock, MockPlugin); - assert.strictEqual(Plugin.getPlugins().plugin, Plugin); - assert.strictEqual(Object.keys(Plugin.getPlugins(['basic'])).length, 1); - assert.strictEqual(Plugin.getPlugins(['basic']).basic, this.basic); + assert.strictEqual(Object.keys(Plugin.getPlugins()).length, 3, 'all plugins are returned by default'); + assert.strictEqual(Plugin.getPlugins().basic, this.basic, 'the "basic" plugin is included'); + assert.strictEqual(Plugin.getPlugins().mock, MockPlugin, 'the "mock" plugin is included'); + assert.strictEqual(Plugin.getPlugins().plugin, Plugin, 'the "plugin" plugin is included'); + assert.strictEqual(Object.keys(Plugin.getPlugins(['basic'])).length, 1, 'a subset of plugins can be requested'); + assert.strictEqual(Plugin.getPlugins(['basic']).basic, this.basic, 'the correct subset of plugins is returned'); }); QUnit.test('deregisterPlugin()', function(assert) { @@ -96,18 +100,19 @@ QUnit.test('deregisterPlugin()', function(assert) { Plugin.registerPlugin('foo', foo); Plugin.deregisterPlugin('foo'); - assert.strictEqual(Player.prototype.foo, undefined); - assert.strictEqual(Plugin.getPlugin('foo'), undefined); + assert.strictEqual(Player.prototype.foo, undefined, 'the player prototype method is removed'); + assert.strictEqual(Plugin.getPlugin('foo'), undefined, 'the plugin can no longer be retrieved'); assert.throws( () => Plugin.deregisterPlugin('plugin'), new Error('cannot de-register base plugin'), + 'the base plugin cannot be de-registered' ); }); QUnit.test('isBasic()', function(assert) { - assert.ok(Plugin.isBasic(this.basic)); - assert.ok(Plugin.isBasic('basic')); - assert.notOk(Plugin.isBasic(MockPlugin)); - assert.notOk(Plugin.isBasic('mock')); + assert.ok(Plugin.isBasic(this.basic), 'the "basic" plugin is a basic plugin (by reference)'); + assert.ok(Plugin.isBasic('basic'), 'the "basic" plugin is a basic plugin (by name)'); + assert.notOk(Plugin.isBasic(MockPlugin), 'the "mock" plugin is NOT a basic plugin (by reference)'); + assert.notOk(Plugin.isBasic('mock'), 'the "mock" plugin is NOT a basic plugin (by name)'); }); diff --git a/test/unit/video.test.js b/test/unit/video.test.js index cb06bb0218..db8b74d039 100644 --- a/test/unit/video.test.js +++ b/test/unit/video.test.js @@ -165,11 +165,15 @@ QUnit.test('should add the value to the languages object with lower case lang co }); QUnit.test('should expose plugin functions', function(assert) { - assert.strictEqual(typeof videojs.registerPlugin, 'function'); - assert.strictEqual(typeof videojs.plugin, 'function'); - assert.strictEqual(typeof videojs.getPlugins, 'function'); - assert.strictEqual(typeof videojs.getPlugin, 'function'); - assert.strictEqual(typeof videojs.getPluginVersion, 'function'); + [ + 'registerPlugin', + 'plugin', + 'getPlugins', + 'getPlugin', + 'getPluginVersion' + ].forEach(name => { + assert.strictEqual(typeof videojs[name], 'function', `videojs.${name} is a function`); + }); }); QUnit.test('should expose options and players properties for backward-compatibility', function(assert) { From 90b3907e59ea2e64230e6532b3a52d33f95e0d67 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 29 Nov 2016 10:54:41 -0500 Subject: [PATCH 35/48] Updates based on guide feedback --- docs/guides/plugins.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 1626f48874..672f86e141 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -138,6 +138,8 @@ The registration process for class-based plugins is identical to [the process fo videojs.registerPlugin('examplePlugin', ExamplePlugin); ``` +> **Note:** Because ES6 classes are syntactic sugar on top of existing constructor function and prototype architecture in JavaScript, in all cases `registerPlugin`'s second argument is a function. + ### Key Differences from Basic Plugins Class-based plugins have two key differences from basic plugins that are important to understand before describing their advanced features. @@ -258,7 +260,7 @@ class Advanced extends Plugin { } dispose() { - super(); + super.dispose(); videojs.log('the advanced plugin is being disposed'); } From 99bcef5143ebbfb5ae271ee78845be62cf5fa0e2 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 29 Nov 2016 12:47:17 -0500 Subject: [PATCH 36/48] Use on instead of one for dispose listener --- src/js/plugin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/plugin.js b/src/js/plugin.js index cd4ec76fa7..1ed8c64723 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -161,7 +161,7 @@ class Plugin { }); this.on('statechanged', this.handleStateChanged); - player.one('dispose', this.dispose); + player.on('dispose', this.dispose); /** * Signals that a plugin (both basic and class-based) has just been set up From 6a194fec6407f2e98489955b1db69104d2b65b51 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Fri, 2 Dec 2016 15:07:47 -0500 Subject: [PATCH 37/48] Proper capitalization for thrown errors --- src/js/mixins/evented.js | 8 ++++---- src/js/plugin.js | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/js/mixins/evented.js b/src/js/mixins/evented.js index 7711c268b2..3a8d502c10 100644 --- a/src/js/mixins/evented.js +++ b/src/js/mixins/evented.js @@ -44,7 +44,7 @@ const isValidEventType = (type) => */ const validateTarget = (target) => { if (!target.nodeName && !isEvented(target)) { - throw new Error('invalid target; must be a DOM node or evented object'); + throw new Error('Invalid target; must be a DOM node or evented object.'); } }; @@ -59,7 +59,7 @@ const validateTarget = (target) => { */ const validateEventType = (type) => { if (!isValidEventType(type)) { - throw new Error('invalid event type; must be a non-empty string or array'); + throw new Error('Invalid event type; must be a non-empty string or array.'); } }; @@ -74,7 +74,7 @@ const validateEventType = (type) => { */ const validateListener = (listener) => { if (typeof listener !== 'function') { - throw new Error('invalid listener; must be a function'); + throw new Error('Invalid listener; must be a function.'); } }; @@ -365,7 +365,7 @@ function evented(target, options = {}) { // Set or create the eventBusEl_. if (eventBusKey) { if (!target[eventBusKey].nodeName) { - throw new Error(`eventBusKey "${eventBusKey}" does not refer to an element`); + throw new Error(`The eventBusKey "${eventBusKey}" does not refer to an element.`); } target.eventBusEl_ = target[eventBusKey]; } else { diff --git a/src/js/plugin.js b/src/js/plugin.js index 1ed8c64723..8b7b9a8f49 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -142,7 +142,7 @@ class Plugin { this.player = player; if (this.constructor === Plugin) { - throw new Error('Plugin must be sub-classed; not directly instantiated'); + throw new Error('Plugin must be sub-classed; not directly instantiated.'); } // Make this object evented, but remove the added `trigger` method so we @@ -266,7 +266,7 @@ class Plugin { this.player = this.state = null; this.dispose = () => { - throw new Error('cannot call methods on a disposed object'); + throw new Error('Cannot call methods on a disposed object.'); }; this.setState = this.off = this.on = this.one = this.trigger = this.dispose; @@ -309,15 +309,15 @@ class Plugin { */ static registerPlugin(name, plugin) { if (typeof name !== 'string') { - throw new Error(`illegal plugin name, "${name}", must be a string, was ${typeof name}`); + throw new Error(`Illegal plugin name, "${name}", must be a string, was ${typeof name}.`); } if (pluginExists(name) || Player.prototype.hasOwnProperty(name)) { - throw new Error(`illegal plugin name, "${name}", already exists`); + throw new Error(`Illegal plugin name, "${name}", already exists.`); } if (typeof plugin !== 'function') { - throw new Error(`illegal plugin for "${name}", must be a function, was ${typeof plugin}`); + throw new Error(`Illegal plugin for "${name}", must be a function, was ${typeof plugin}.`); } pluginStorage[name] = plugin; @@ -343,7 +343,7 @@ class Plugin { */ static deregisterPlugin(name) { if (name === BASE_PLUGIN_NAME) { - throw new Error('cannot de-register base plugin'); + throw new Error('Cannot de-register base plugin.'); } if (pluginExists(name)) { delete pluginStorage[name]; From e1ac672229e4578938ccdf1cfa6e5a309bd4bcb9 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Fri, 2 Dec 2016 16:37:21 -0500 Subject: [PATCH 38/48] Post-rebase cleanup --- src/js/component.js | 238 +++++++++++++++++++++++-------- src/js/player.js | 2 - src/js/utils/obj.js | 38 ----- test/unit/mixins/evented.test.js | 8 +- test/unit/plugin-class.test.js | 4 +- test/unit/plugin-static.test.js | 10 +- 6 files changed, 190 insertions(+), 110 deletions(-) diff --git a/src/js/component.js b/src/js/component.js index 7bf74b8d2e..7406e49762 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -564,35 +564,63 @@ class Component { } /** - * Add an event listener to this component's element - * ```js - * var myFunc = function() { - * var myComponent = this; - * // Do something when the event is fired - * }; + * Add an `event listener` to this `Component`s element. * - * myComponent.on('eventType', myFunc); + * ```js + * var player = videojs('some-player-id'); + * var Component = videojs.getComponent('Component'); + * var myComponent = new Component(player); + * var myFunc = function() { + * var myComponent = this; + * console.log('myFunc called'); + * }; + * + * myComponent.on('eventType', myFunc); + * myComponent.trigger('eventType'); + * // logs 'myFunc called' * ``` - * The context of myFunc will be myComponent unless previously bound. - * Alternatively, you can add a listener to another element or component. + * + * The context of `myFunc` will be `myComponent` unless it is bound. You can add + * a listener to another element or component. * ```js - * myComponent.on(otherElement, 'eventName', myFunc); - * myComponent.on(otherComponent, 'eventName', myFunc); + * var otherComponent = new Component(player); + * + * // myComponent/myFunc is from the above example + * myComponent.on(otherComponent.el(), 'eventName', myFunc); + * myComponent.on(otherComponent, 'eventName', myFunc); + * + * otherComponent.trigger('eventName'); + * // logs 'myFunc called' twice * ``` - * The benefit of using this over `VjsEvents.on(otherElement, 'eventName', myFunc)` - * and `otherComponent.on('eventName', myFunc)` is that this way the listeners - * will be automatically cleaned up when either component is disposed. - * It will also bind myComponent as the context of myFunc. - * **NOTE**: When using this on elements in the page other than window - * and document (both permanent), if you remove the element from the DOM - * you need to call `myComponent.trigger(el, 'dispose')` on it to clean up - * references to it and allow the browser to garbage collect it. - * - * @param {String|Component} first The event type or other component - * @param {Function|String} second The event handler or event type - * @param {Function} third The event handler + * + * The benefit of using this over the following: + * - `VjsEvents.on(otherElement, 'eventName', myFunc)` + * - `otherComponent.on('eventName', myFunc)` + * Is that the listeners will get cleaned up when either component gets disposed. + * It will also bind `myComponent` as the context of `myFunc`. + * > NOTE: If you remove the element from the DOM that has used `on` you need to + * clean up references using: + * + * `myComponent.trigger(el, 'dispose')` + * + * This will also allow the browser to garbage collect it. In special + * cases such as with `window` and `document`, which are both permanent, + * this is not necessary. + * + * @param {string|Component|string[]} [first] + * The event name, and array of event names, or another `Component`. + * + * @param {EventTarget~EventListener|string|string[]} [second] + * The listener function, an event name, or an Array of events names. + * + * @param {EventTarget~EventListener} [third] + * The event handler if `first` is a `Component` and `second` is an event name + * or an Array of event names. + * * @return {Component} - * @method on + * Returns itself; method can be chained. + * + * @listens Component#dispose */ on(first, second, third) { if (typeof first === 'string' || Array.isArray(first)) { @@ -639,25 +667,60 @@ class Component { } /** - * Remove an event listener from this component's element + * Remove an event listener from this `Component`s element. * ```js - * myComponent.off('eventType', myFunc); + * var player = videojs('some-player-id'); + * var Component = videojs.getComponent('Component'); + * var myComponent = new Component(player); + * var myFunc = function() { + * var myComponent = this; + * console.log('myFunc called'); + * }; + * myComponent.on('eventType', myFunc); + * myComponent.trigger('eventType'); + * // logs 'myFunc called' + * + * myComponent.off('eventType', myFunc); + * myComponent.trigger('eventType'); + * // does nothing * ``` - * If myFunc is excluded, ALL listeners for the event type will be removed. - * If eventType is excluded, ALL listeners will be removed from the component. - * Alternatively you can use `off` to remove listeners that were added to other - * elements or components using `myComponent.on(otherComponent...`. - * In this case both the event type and listener function are REQUIRED. + * + * If myFunc gets excluded, ALL listeners for the event type will get removed. If + * eventType gets excluded, ALL listeners will get removed from the component. + * You can use `off` to remove listeners that get added to other elements or + * components using: + * + * `myComponent.on(otherComponent...` + * + * In this case both the event type and listener function are **REQUIRED**. + * * ```js - * myComponent.off(otherElement, 'eventType', myFunc); - * myComponent.off(otherComponent, 'eventType', myFunc); + * var otherComponent = new Component(player); + * + * // myComponent/myFunc is from the above example + * myComponent.on(otherComponent.el(), 'eventName', myFunc); + * myComponent.on(otherComponent, 'eventName', myFunc); + * + * otherComponent.trigger('eventName'); + * // logs 'myFunc called' twice + * myComponent.off(ootherComponent.el(), 'eventName', myFunc); + * myComponent.off(otherComponent, 'eventName', myFunc); + * otherComponent.trigger('eventName'); + * // does nothing * ``` * - * @param {String=|Component} first The event type or other component - * @param {Function=|String} second The listener function or event type - * @param {Function=} third The listener for other component + * @param {string|Component|string[]} [first] + * The event name, and array of event names, or another `Component`. + * + * @param {EventTarget~EventListener|string|string[]} [second] + * The listener function, an event name, or an Array of events names. + * + * @param {EventTarget~EventListener} [third] + * The event handler if `first` is a `Component` and `second` is an event name + * or an Array of event names. + * * @return {Component} - * @method off + * Returns itself; method can be chained. */ off(first, second, third) { if (!first || typeof first === 'string' || Array.isArray(first)) { @@ -687,22 +750,52 @@ class Component { } /** - * Add an event listener to be triggered only once and then removed + * Add an event listener that gets triggered only once and then gets removed. * ```js - * myComponent.one('eventName', myFunc); + * var player = videojs('some-player-id'); + * var Component = videojs.getComponent('Component'); + * var myComponent = new Component(player); + * var myFunc = function() { + * var myComponent = this; + * console.log('myFunc called'); + * }; + * myComponent.one('eventName', myFunc); + * myComponent.trigger('eventName'); + * // logs 'myFunc called' + * + * myComponent.trigger('eventName'); + * // does nothing + * * ``` - * Alternatively you can add a listener to another element or component - * that will be triggered only once. + * + * You can also add a listener to another element or component that will get + * triggered only once. * ```js - * myComponent.one(otherElement, 'eventName', myFunc); - * myComponent.one(otherComponent, 'eventName', myFunc); + * var otherComponent = new Component(player); + * + * // myComponent/myFunc is from the above example + * myComponent.one(otherComponent.el(), 'eventName', myFunc); + * myComponent.one(otherComponent, 'eventName', myFunc); + * + * otherComponent.trigger('eventName'); + * // logs 'myFunc called' twice + * + * otherComponent.trigger('eventName'); + * // does nothing * ``` * - * @param {String|Component} first The event type or other component - * @param {Function|String} second The listener function or event type - * @param {Function=} third The listener function for other component + * @param {string|Component|string[]} [first] + * The event name, and array of event names, or another `Component`. + * + * @param {EventTarget~EventListener|string|string[]} [second] + * The listener function, an event name, or an Array of events names. + * + * @param {EventTarget~EventListener} [third] + * The event handler if `first` is a `Component` and `second` is an event name + * or an Array of event names. + * * @return {Component} - * @method one + * Returns itself; method can be chained. */ one(first, second, third) { if (typeof first === 'string' || Array.isArray(first)) { @@ -727,18 +820,40 @@ class Component { } /** - * Trigger an event on an element + * Trigger an event on an element. + * * ```js - * myComponent.trigger('eventName'); - * myComponent.trigger({'type':'eventName'}); - * myComponent.trigger('eventName', {data: 'some data'}); - * myComponent.trigger({'type':'eventName'}, {data: 'some data'}); + * var player = videojs('some-player-id'); + * var Component = videojs.getComponent('Component'); + * var myComponent = new Component(player); + * var myFunc = function(data) { + * var myComponent = this; + * console.log('myFunc called'); + * console.log(data); + * }; + * myComponent.one('eventName', myFunc); + * myComponent.trigger('eventName'); + * // logs 'myFunc called' and 'undefined' + * + * myComponent.trigger({'type':'eventName'}); + * // logs 'myFunc called' and 'undefined' + * + * myComponent.trigger('eventName', {data: 'some data'}); + * // logs 'myFunc called' and "{data: 'some data'}" + * + * myComponent.trigger({'type':'eventName'}, {data: 'some data'}); + * // logs 'myFunc called' and "{data: 'some data'}" * ``` * - * @param {Event|Object|String} event A string (the type) or an event object with a type attribute - * @param {Object} [hash] data hash to pass along with the event - * @return {Component} self - * @method trigger + * @param {EventTarget~Event|Object|string} event + * The event name, and Event, or an event-like object with a type attribute + * set to the event name. + * + * @param {Object} [hash] + * Data hash to pass along with the event + * + * @return {Component} + * Returns itself; method can be chained. */ trigger(event, hash) { Events.trigger(this.el_, event, hash); @@ -746,9 +861,14 @@ class Component { } /** - * Bind a listener to the component's ready state. - * Different from event listeners in that if the ready event has already happened - * it will trigger the function immediately. + * Bind a listener to the component's ready state. If the ready event has already + * happened it will trigger the function immediately. + * + * @param {Component~ReadyCallback} fn + * A function to call when ready is triggered. + * + * @param {boolean} [sync=false] + * Execute the listener synchronously if `Component` is ready. * * @return {Component} * Returns itself; method can be chained. diff --git a/src/js/player.js b/src/js/player.js index 677d2edfd0..abcf7ba73a 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -3351,8 +3351,6 @@ TECH_EVENTS_RETRIGGER.forEach(function(event) { }; }); -/* document methods */ - /** * Fired when the player has initial duration and dimension information * diff --git a/src/js/utils/obj.js b/src/js/utils/obj.js index 893143131e..a6896e2712 100644 --- a/src/js/utils/obj.js +++ b/src/js/utils/obj.js @@ -2,7 +2,6 @@ * @file obj.js * @module obj */ -import objectAssign from 'object.assign'; /** * @callback obj:EachCallback @@ -117,40 +116,3 @@ export function isPlain(value) { toString.call(value) === '[object Object]' && value.constructor === Object; } - -/** - * Object.assign polyfill - * - * @param {Object} target - * @param {Object} ...sources - * @return {Object} - */ -export function assign(...args) { - - // This exists primarily to isolate our dependence on this third-party - // polyfill. If we decide to move away from it, we can do so in one place. - return objectAssign(...args); -} - -/** - * Returns whether an object appears to be a non-function/non-array object. - * - * This avoids gotchas like `typeof null === 'object'`. - * - * @param {Object} object - * @return {Boolean} - */ -export function isObject(object) { - return Object.prototype.toString.call(object) === '[object Object]'; -} - -/** - * Returns whether an object appears to be a "plain" object - that is, a - * direct instance of `Object`. - * - * @param {Object} object - * @return {Boolean} - */ -export function isPlain(object) { - return isObject(object) && object.constructor === Object; -} diff --git a/test/unit/mixins/evented.test.js b/test/unit/mixins/evented.test.js index d64085fb18..493e48ae9e 100644 --- a/test/unit/mixins/evented.test.js +++ b/test/unit/mixins/evented.test.js @@ -6,9 +6,9 @@ import * as Obj from '../../../src/js/utils/obj'; // Common errors thrown by evented objects. const errors = { - type: new Error('invalid event type; must be a non-empty string or array'), - listener: new Error('invalid listener; must be a function'), - target: new Error('invalid target; must be a DOM node or evented object') + type: new Error('Invalid event type; must be a non-empty string or array.'), + listener: new Error('Invalid listener; must be a function.'), + target: new Error('Invalid target; must be a DOM node or evented object.') }; const validateListenerCall = (call, thisValue, eventExpectation) => { @@ -56,7 +56,7 @@ QUnit.test('evented() with custom element', function(assert) { assert.throws( () => evented({foo: {}}, {eventBusKey: 'foo'}), - new Error('eventBusKey "foo" does not refer to an element'), + new Error('The eventBusKey "foo" does not refer to an element.'), 'throws if the target does not have an element at the supplied key' ); }); diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js index b191cf0d05..8aac42e477 100644 --- a/test/unit/plugin-class.test.js +++ b/test/unit/plugin-class.test.js @@ -62,7 +62,7 @@ QUnit.test('setup', function(assert) { assert.throws( () => new Plugin(this.player), - new Error('Plugin must be sub-classed; not directly instantiated'), + new Error('Plugin must be sub-classed; not directly instantiated.'), 'the Plugin class cannot be directly instantiated' ); }); @@ -112,7 +112,7 @@ QUnit.test('dispose', function(assert) { ['dispose', 'setState', 'off', 'on', 'one', 'trigger'].forEach(n => { assert.throws( () => instance[n](), - new Error('cannot call methods on a disposed object'), + new Error('Cannot call methods on a disposed object.'), `the "${n}" method now throws` ); }); diff --git a/test/unit/plugin-static.test.js b/test/unit/plugin-static.test.js index cde6668e7f..9465ca1ae3 100644 --- a/test/unit/plugin-static.test.js +++ b/test/unit/plugin-static.test.js @@ -49,25 +49,25 @@ QUnit.test('registerPlugin() works with class-based plugins', function(assert) { QUnit.test('registerPlugin() illegal arguments', function(assert) { assert.throws( () => Plugin.registerPlugin(), - new Error('illegal plugin name, "undefined", must be a string, was undefined'), + new Error('Illegal plugin name, "undefined", must be a string, was undefined.'), 'plugins must have a name' ); assert.throws( () => Plugin.registerPlugin('play'), - new Error('illegal plugin name, "play", already exists'), + new Error('Illegal plugin name, "play", already exists.'), 'plugins cannot share a name with an existing player method' ); assert.throws( () => Plugin.registerPlugin('foo'), - new Error('illegal plugin for "foo", must be a function, was undefined'), + new Error('Illegal plugin for "foo", must be a function, was undefined.'), 'plugins require both arguments' ); assert.throws( () => Plugin.registerPlugin('foo', {}), - new Error('illegal plugin for "foo", must be a function, was object'), + new Error('Illegal plugin for "foo", must be a function, was object.'), 'plugins must be functions' ); }); @@ -105,7 +105,7 @@ QUnit.test('deregisterPlugin()', function(assert) { assert.throws( () => Plugin.deregisterPlugin('plugin'), - new Error('cannot de-register base plugin'), + new Error('Cannot de-register base plugin.'), 'the base plugin cannot be de-registered' ); }); From f1694e221e5de2051d3488680e78a52401344b8f Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 5 Dec 2016 11:00:26 -0500 Subject: [PATCH 39/48] Move handleStateChanged support to mixin --- src/js/component.js | 13 +++++++++++++ src/js/mixins/stateful.js | 9 +++++++++ src/js/plugin.js | 2 +- test/unit/mixins/stateful.test.js | 16 ++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/js/component.js b/src/js/component.js index 7406e49762..e72526cfec 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -1645,6 +1645,19 @@ class Component { return intervalId; } + /** + * Handles "statechanged" events on the component. No-op by default, override + * by subclassing. + * + * @param {Event} e + * An event object provided by a "statechanged" event. + * + * @param {Object} e.changes + * An object describing changes that occurred with the "statechanged" + * event. + */ + handleStateChanged(e) {} + /** * Register a `Component` with `videojs` given the name and the component. * diff --git a/src/js/mixins/stateful.js b/src/js/mixins/stateful.js index ff0c5f572d..dbf086ca1d 100644 --- a/src/js/mixins/stateful.js +++ b/src/js/mixins/stateful.js @@ -63,6 +63,9 @@ const setState = function(next) { * arbitrary keys/values and a `setState` method which will trigger state * changes if the object has a `trigger` method. * + * If the target object has a `handleStateChanged` method, it will be + * automatically bound to the `statechanged` event on itself. + * * @param {Object} target * The object to be made stateful. * @@ -76,6 +79,12 @@ const setState = function(next) { function stateful(target, defaultState) { target.state = Obj.assign({}, defaultState); target.setState = Fn.bind(target, setState); + + // Auto-bind the `handleStateChanged` method of the target object if it exists. + if (typeof target.handleStateChanged === 'function' && typeof target.on === 'function') { + target.on('statechanged', target.handleStateChanged); + } + return target; } diff --git a/src/js/plugin.js b/src/js/plugin.js index 8b7b9a8f49..ff7be3431c 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -160,7 +160,7 @@ class Plugin { } }); - this.on('statechanged', this.handleStateChanged); + // If the player is disposed, dispose the plugin. player.on('dispose', this.dispose); /** diff --git a/test/unit/mixins/stateful.test.js b/test/unit/mixins/stateful.test.js index 377fb9e557..62f165319e 100644 --- a/test/unit/mixins/stateful.test.js +++ b/test/unit/mixins/stateful.test.js @@ -69,3 +69,19 @@ QUnit.test('setState() without changes does not trigger the "statechanged" event assert.strictEqual(changes, undefined, 'no changes were returned'); assert.strictEqual(spy.callCount, 0, 'no event was triggered'); }); + +QUnit.test('handleStateChanged() is automatically bound to "statechanged" event', function(assert) { + const target = new EventTarget(); + + target.handleStateChanged = sinon.spy(); + stateful(target, {foo: 'bar'}); + + const changes = target.setState({foo: true}); + + assert.ok(target.handleStateChanged.called, 'the "statechanged" event occurred'); + + const event = target.handleStateChanged.firstCall.args[0]; + + assert.strictEqual(event.type, 'statechanged', 'the event had the expected type'); + assert.strictEqual(event.changes, changes, 'the handleStateChanged() method was called'); +}); From 09a9830260c7b2725dfa13f9ee72452c81218e4e Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Mon, 5 Dec 2016 14:58:41 -0500 Subject: [PATCH 40/48] Remove mistakenly-added permute module --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index bcdc4f99f4..d2bf00efeb 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,6 @@ "lodash": "^4.16.6", "markdown-table": "^1.0.0", "npm-run": "^4.1.0", - "permute": "^1.0.0", "proxyquireify": "^3.0.0", "qunitjs": "1.23.1", "remark-cli": "^2.1.0", From 414edb286782318c046ec770fa62333d8d9cb5b8 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 13 Dec 2016 20:29:11 -0500 Subject: [PATCH 41/48] Replace plugin factory with plugin getter --- docs/guides/plugins.md | 25 +++++++++++++------------ src/js/plugin.js | 8 ++++++-- test/unit/plugin-class.test.js | 2 +- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 672f86e141..287152990c 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -154,17 +154,18 @@ With class-based plugins, the value of `this` is the _instance of the plugin cla Both basic plugins and class-based plugins are set up by calling a method on a player with a name matching the plugin (e.g., `player.examplePlugin()`). -However, with class-based plugins, this method acts like a factory function and it is _replaced_ for the current player by the plugin class instance: +However, with class-based plugins, this method acts like a factory function and it is _replaced_ for the current player by a new function which returns the plugin instance: ```js // `examplePlugin` has not been called, so it is a factory function. player.examplePlugin(); -// `examplePlugin` is now an instance of `ExamplePlugin`. -player.examplePlugin.someMethodName(); +// `examplePlugin` is now a function that returns the same instance of +// `ExamplePlugin` that was generated by the previous call. +player.examplePlugin().someMethodName(); ``` -With basic plugins, the method does not change - it is always the same function. +With basic plugins, the method does not change - it is always the same function. It is up to the authors of basic plugins to deal with multiple calls to their plugin function. ### Advanced Features of Class-based Plugins @@ -177,7 +178,7 @@ Like components, class-based plugins offer an implementation of events. This inc - The ability to listen for events on the plugin instance using `on` or `one` and stop listening for events using `off`: ```js - player.examplePlugin.on('example-event', function() { + player.examplePlugin().on('example-event', function() { videojs.log('example plugin received an example-event'); }); ``` @@ -185,7 +186,7 @@ Like components, class-based plugins offer an implementation of events. This inc - The ability to `trigger` custom events on a plugin instance: ```js - player.examplePlugin.trigger('example-event'); + player.examplePlugin().trigger('example-event'); ``` By offering a built-in events system, class-based plugins offer a wider range of options for code structure with a pattern familiar to most web developers. @@ -207,7 +208,7 @@ ExamplePlugin.defaultState = { When the `state` is updated via the `setState` method, the plugin instance fires a `"statechanged"` event, but _only if something changed!_ This event can be used as a signal to update the DOM or perform some other action. The event object passed to listeners for this event includes, an object describing the changes that occurred on the `state` property: ```js -player.examplePlugin.on('statechanged', function(e) { +player.examplePlugin().on('statechanged', function(e) { if (e.changes && e.changes.customClass) { this.player .removeClass(e.changes.customClass.from) @@ -215,7 +216,7 @@ player.examplePlugin.on('statechanged', function(e) { } }); -player.examplePlugin.setState({customClass: 'another-custom-class'}); +player.examplePlugin().setState({customClass: 'another-custom-class'}); ``` #### Lifecycle @@ -227,15 +228,15 @@ Like components, class-based plugins have a lifecycle. They can be created with player.examplePlugin(); // dispose of it anytime thereafter -player.examplePlugin.dispose(); +player.examplePlugin().dispose(); ``` The `dispose` method has several effects: - Triggers a `"dispose"` event on the plugin instance. - Cleans up all event listeners on the plugin instance. -- Removes statefulness, event methods, and references to the player to avoid memory leaks. -- Reverts the player property (e.g. `player.examplePlugin`) _back_ to a factory function, so the plugin can be set up again. +- Removes plugin state and references to the player to avoid memory leaks. +- Reverts the player's named property (e.g. `player.examplePlugin`) _back_ to the original factory function, so the plugin can be set up again. In addition, if the player is disposed, the disposal of all its class-based plugin instances will be triggered as well. @@ -287,7 +288,7 @@ player.play(); // update the state of the plugin, which will cause a message to be logged. player.pause(); -player.advanced.dispose(); +player.advanced().dispose(); // This will begin playback, but the plugin has been disposed, so it will not // log any messages. diff --git a/src/js/plugin.js b/src/js/plugin.js index ff7be3431c..219ac4aea6 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -123,8 +123,12 @@ const createPluginFactory = (name, PluginSubClass) => { PluginSubClass.prototype.name = name; return function(...args) { - this[name] = new PluginSubClass(...[this, ...args]); - return this[name]; + const instance = new PluginSubClass(...[this, ...args]); + + // The plugin is replaced by a function that returns the current instance. + this[name] = () => instance; + + return instance; }; }; diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js index 8aac42e477..916debb076 100644 --- a/test/unit/plugin-class.test.js +++ b/test/unit/plugin-class.test.js @@ -49,7 +49,7 @@ QUnit.test('setup', function(assert) { assert.ok(this.player.usingPlugin('mock'), 'player now recognizes that the plugin was set up'); assert.ok(this.player.hasPlugin('mock'), 'player has the plugin available'); assert.ok(instance instanceof this.MockPlugin, 'plugin instance has the correct constructor'); - assert.strictEqual(instance, this.player.mock, 'instance replaces the factory'); + assert.strictEqual(instance, this.player.mock(), 'factory is replaced by method returning the instance'); assert.strictEqual(instance.player, this.player, 'instance has a reference to the player'); assert.strictEqual(instance.name, 'mock', 'instance knows its name'); assert.strictEqual(typeof instance.state, 'object', 'instance is stateful'); From a245f917a03b43f6adcecb8652032f73ab9df60e Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 13 Dec 2016 20:33:02 -0500 Subject: [PATCH 42/48] Undo method removal. Too much. --- src/js/plugin.js | 6 ------ test/unit/plugin-class.test.js | 8 -------- 2 files changed, 14 deletions(-) diff --git a/src/js/plugin.js b/src/js/plugin.js index 219ac4aea6..eba0c697ed 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -269,12 +269,6 @@ class Plugin { player[PLUGIN_CACHE_KEY][name] = false; this.player = this.state = null; - this.dispose = () => { - throw new Error('Cannot call methods on a disposed object.'); - }; - - this.setState = this.off = this.on = this.one = this.trigger = this.dispose; - // Finally, replace the plugin name on the player with a new factory // function, so that the plugin is ready to be set up again. player[name] = createPluginFactory(name, pluginStorage[name]); diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js index 916debb076..ab51fee131 100644 --- a/test/unit/plugin-class.test.js +++ b/test/unit/plugin-class.test.js @@ -108,14 +108,6 @@ QUnit.test('dispose', function(assert) { assert.notStrictEqual(instance, this.player.mock, 'instance is replaced by factory'); assert.strictEqual(instance.player, null, 'instance no longer has a reference to the player'); assert.strictEqual(instance.state, null, 'state is now null'); - - ['dispose', 'setState', 'off', 'on', 'one', 'trigger'].forEach(n => { - assert.throws( - () => instance[n](), - new Error('Cannot call methods on a disposed object.'), - `the "${n}" method now throws` - ); - }); }); QUnit.test('"dispose" event', function(assert) { From f411b33bf6e7fd1602707d7812fc17259f469b28 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 13 Dec 2016 20:37:25 -0500 Subject: [PATCH 43/48] 'Class-based' to 'Advanced' --- docs/guides/plugins.md | 56 ++++++++++++++++++---------------- src/js/plugin.js | 12 ++++---- src/js/video.js | 2 +- test/unit/plugin-basic.test.js | 2 +- test/unit/plugin-class.test.js | 2 +- 5 files changed, 38 insertions(+), 36 deletions(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 287152990c..a261c955ef 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -7,17 +7,17 @@ - [Writing a Basic Plugin](#writing-a-basic-plugin) - [Write a JavaScript Function](#write-a-javascript-function) - [Register a Basic Plugin](#register-a-basic-plugin) -- [Writing a Class-Based Plugin](#writing-a-class-based-plugin) +- [Writing a Advanced Plugin](#writing-a-advanced-plugin) - [Write a JavaScript Class/Constructor](#write-a-javascript-classconstructor) - - [Register a Class-Based Plugin](#register-a-class-based-plugin) + - [Register a Advanced Plugin](#register-a-advanced-plugin) - [Key Differences from Basic Plugins](#key-differences-from-basic-plugins) - [The Value of `this`](#the-value-of-this) - [The Player Plugin Name Property](#the-player-plugin-name-property) - - [Advanced Features of Class-based Plugins](#advanced-features-of-class-based-plugins) + - [Advanced Features of Advanced Plugins](#advanced-features-of-advanced-plugins) - [Events](#events) - [Statefulness](#statefulness) - [Lifecycle](#lifecycle) - - [Advanced Example Class-based Plugin](#advanced-example-class-based-plugin) + - [Advanced Example Advanced Plugin](#advanced-example-advanced-plugin) - [Setting up a Plugin](#setting-up-a-plugin) - [References](#references) @@ -74,17 +74,19 @@ Now that we have a function that does something with a player, all that's left i videojs.registerPlugin('examplePlugin', examplePlugin); ``` -The only stipulation with the name of the plugin is that it cannot conflict with any existing player method. After that, any player will automatically have an `examplePlugin` method on its prototype! +After that, any player will automatically have an `examplePlugin` method on its prototype! -## Writing a Class-Based Plugin +> **Note:** The only stipulation with the name of the plugin is that it cannot conflict with any existing plugin or player method. -As of Video.js 6, there is an additional type of plugin supported: class-based plugins. +## Writing a Advanced Plugin + +As of Video.js 6, there is an additional type of plugin supported: advanced plugins. At any time, you may want to refer to the [Plugin API docs][api-plugin] for more detail. ### Write a JavaScript Class/Constructor -If you're familiar with creating [components](components.md), this process is similar. A class-based plugin starts with a JavaScript class (a.k.a. a constructor function). +If you're familiar with creating [components](components.md), this process is similar. A advanced plugin starts with a JavaScript class (a.k.a. a constructor function). This can be achieved with ES6 classes: @@ -128,11 +130,11 @@ var ExamplePlugin = videojs.extend(Plugin, { }); ``` -For now, this example class-based plugin does the exact same thing as the basic plugin described above - not to worry, we will make it more interesting as we continue! +For now, this example advanced plugin does the exact same thing as the basic plugin described above - not to worry, we will make it more interesting as we continue! -### Register a Class-Based Plugin +### Register a Advanced Plugin -The registration process for class-based plugins is identical to [the process for basic plugins](#register-a-basic-plugin). +The registration process for advanced plugins is identical to [the process for basic plugins](#register-a-basic-plugin). ```js videojs.registerPlugin('examplePlugin', ExamplePlugin); @@ -142,19 +144,19 @@ videojs.registerPlugin('examplePlugin', ExamplePlugin); ### Key Differences from Basic Plugins -Class-based plugins have two key differences from basic plugins that are important to understand before describing their advanced features. +Advanced plugins have two key differences from basic plugins that are important to understand before describing their advanced features. #### The Value of `this` With basic plugins, the value of `this` in the plugin function will be the _player_. -With class-based plugins, the value of `this` is the _instance of the plugin class_. The player is passed to the plugin constructor as its first argument (and is automatically applied to the plugin instance as the `player` property) and any further arguments are passed after that. +With advanced plugins, the value of `this` is the _instance of the plugin class_. The player is passed to the plugin constructor as its first argument (and is automatically applied to the plugin instance as the `player` property) and any further arguments are passed after that. #### The Player Plugin Name Property -Both basic plugins and class-based plugins are set up by calling a method on a player with a name matching the plugin (e.g., `player.examplePlugin()`). +Both basic plugins and advanced plugins are set up by calling a method on a player with a name matching the plugin (e.g., `player.examplePlugin()`). -However, with class-based plugins, this method acts like a factory function and it is _replaced_ for the current player by a new function which returns the plugin instance: +However, with advanced plugins, this method acts like a factory function and it is _replaced_ for the current player by a new function which returns the plugin instance: ```js // `examplePlugin` has not been called, so it is a factory function. @@ -167,13 +169,13 @@ player.examplePlugin().someMethodName(); With basic plugins, the method does not change - it is always the same function. It is up to the authors of basic plugins to deal with multiple calls to their plugin function. -### Advanced Features of Class-based Plugins +### Features of Advanced Plugins -Up to this point, our example class-based plugin is functionally identical to our example basic plugin. However, class-based plugins bring with them a great deal of benefit that is not built into basic plugins. +Up to this point, our example advanced plugin is functionally identical to our example basic plugin. However, advanced plugins bring with them a great deal of benefit that is not built into basic plugins. #### Events -Like components, class-based plugins offer an implementation of events. This includes: +Like components, advanced plugins offer an implementation of events. This includes: - The ability to listen for events on the plugin instance using `on` or `one` and stop listening for events using `off`: @@ -189,13 +191,13 @@ Like components, class-based plugins offer an implementation of events. This inc player.examplePlugin().trigger('example-event'); ``` -By offering a built-in events system, class-based plugins offer a wider range of options for code structure with a pattern familiar to most web developers. +By offering a built-in events system, advanced plugins offer a wider range of options for code structure with a pattern familiar to most web developers. #### Statefulness -A new concept introduced in Video.js 6 for class-based plugins is _statefulness_. This is similar to React components' `state` property and `setState` method. +A new concept introduced in Video.js 6 for advanced plugins is _statefulness_. This is similar to React components' `state` property and `setState` method. -Class-based plugin instances each have a `state` property, which is a plain JavaScript object - it can contain any keys and values the plugin author wants. +Advanced plugin instances each have a `state` property, which is a plain JavaScript object - it can contain any keys and values the plugin author wants. A default `state` can be provided by adding a static property to a plugin constructor: @@ -221,7 +223,7 @@ player.examplePlugin().setState({customClass: 'another-custom-class'}); #### Lifecycle -Like components, class-based plugins have a lifecycle. They can be created with their factory function and they can be destroyed using their `dispose` method: +Like components, advanced plugins have a lifecycle. They can be created with their factory function and they can be destroyed using their `dispose` method: ```js // set up a example plugin instance @@ -238,11 +240,11 @@ The `dispose` method has several effects: - Removes plugin state and references to the player to avoid memory leaks. - Reverts the player's named property (e.g. `player.examplePlugin`) _back_ to the original factory function, so the plugin can be set up again. -In addition, if the player is disposed, the disposal of all its class-based plugin instances will be triggered as well. +In addition, if the player is disposed, the disposal of all its advanced plugin instances will be triggered as well. -### Advanced Example Class-based Plugin +### Advanced Example Advanced Plugin -What follows is a complete ES6 class-based plugin that logs a custom message when the player's state changes between playing and paused. It uses all the described advanced features: +What follows is a complete ES6 advanced plugin that logs a custom message when the player's state changes between playing and paused. It uses all the described advanced features: ```js import videojs from 'video.js'; @@ -295,11 +297,11 @@ player.advanced().dispose(); player.play(); ``` -This example may be a bit pointless in reality, but it demonstrates the sort of flexibility offered by class-based plugins over basic plugins. +This example may be a bit pointless in reality, but it demonstrates the sort of flexibility offered by advanced plugins over basic plugins. ## Setting up a Plugin -There are two ways to set up (or initialize) a plugin on a player. Both ways work identically for both basic and class-based plugins. +There are two ways to set up (or initialize) a plugin on a player. Both ways work identically for both basic and advanced plugins. The first way is during creation of the player. Using the `plugins` option, a plugin can be automatically set up on a player: diff --git a/src/js/plugin.js b/src/js/plugin.js index eba0c697ed..08683ee166 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -92,7 +92,7 @@ const createBasicPlugin = (name, plugin) => function() { markPluginAsActive(this, name); // We trigger the "pluginsetup" event on the player regardless, but we want - // the hash to be consistent with the hash provided for class-based plugins. + // the hash to be consistent with the hash provided for advanced plugins. // The only potentially counter-intuitive thing here is the `instance` is the // value returned by the `plugin` function. this.trigger('pluginsetup', {name, plugin, instance}); @@ -111,7 +111,7 @@ const createBasicPlugin = (name, plugin) => function() { * The name of the plugin. * * @param {Plugin} PluginSubClass - * The class-based plugin. + * The advanced plugin. * * @return {Function} * A factory function for the plugin sub-class. @@ -168,7 +168,7 @@ class Plugin { player.on('dispose', this.dispose); /** - * Signals that a plugin (both basic and class-based) has just been set up + * Signals that a plugin (both basic and advanced) has just been set up * on a player. * * In all cases, an object containing the following properties is passed as a @@ -176,7 +176,7 @@ class Plugin { * * - `name`: The name of the plugin that was set up. * - `plugin`: The raw plugin function. - * - `instance`: For class-based plugins, the instance of the plugin sub-class, + * - `instance`: For advanced plugins, the instance of the plugin sub-class, * but, for basic plugins, the return value of the plugin invocation. * * @event pluginsetup @@ -254,7 +254,7 @@ class Plugin { const {name, player} = this; /** - * Signals that a class-based plugin is about to be disposed. + * Signals that a advanced plugin is about to be disposed. * * @event dispose * @memberof Plugin @@ -302,7 +302,7 @@ class Plugin { * A sub-class of `Plugin` or a function for basic plugins. * * @return {Function} - * For class-based plugins, a factory function for that plugin. For + * For advanced plugins, a factory function for that plugin. For * basic plugins, a wrapper function that initializes the plugin. */ static registerPlugin(name, plugin) { diff --git a/src/js/video.js b/src/js/video.js index 722d345f47..4fdd8d7957 100644 --- a/src/js/video.js +++ b/src/js/video.js @@ -362,7 +362,7 @@ videojs.bind = Fn.bind; * A sub-class of `Plugin` or a function for basic plugins. * * @return {Function} - * For class-based plugins, a factory function for that plugin. For + * For advanced plugins, a factory function for that plugin. For * basic plugins, a wrapper function that initializes the plugin. */ videojs.registerPlugin = Plugin.registerPlugin; diff --git a/test/unit/plugin-basic.test.js b/test/unit/plugin-basic.test.js index 8fff898f70..fededbc16a 100644 --- a/test/unit/plugin-basic.test.js +++ b/test/unit/plugin-basic.test.js @@ -27,7 +27,7 @@ QUnit.test('pre-setup interface', function(assert) { assert.strictEqual(typeof this.player.basic, 'function', 'basic plugins are a function on a player'); assert.ok(this.player.hasPlugin('basic'), 'player has the plugin available'); assert.notStrictEqual(this.player.basic, this.basic, 'basic plugins are wrapped'); - assert.strictEqual(this.player.basic.dispose, undefined, 'unlike class-based plugins, basic plugins do not have a dispose method'); + assert.strictEqual(this.player.basic.dispose, undefined, 'unlike advanced plugins, basic plugins do not have a dispose method'); assert.notOk(this.player.usingPlugin('basic'), 'the player is not using the plugin'); }); diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-class.test.js index ab51fee131..23c4d3dccf 100644 --- a/test/unit/plugin-class.test.js +++ b/test/unit/plugin-class.test.js @@ -3,7 +3,7 @@ import sinon from 'sinon'; import Plugin from '../../src/js/plugin'; import TestHelpers from './test-helpers'; -QUnit.module('Plugin: class-based', { +QUnit.module('Plugin: advanced', { beforeEach() { this.player = TestHelpers.makePlayer(); From 7342033e269ad0c040e712561a0d359e30df46ca Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Tue, 13 Dec 2016 21:16:46 -0500 Subject: [PATCH 44/48] Lots of feedback changes, mostly docs --- docs/guides/plugins.md | 18 +++++++---- src/js/component.js | 1 + src/js/mixins/evented.js | 54 ++++++++++++++++---------------- src/js/mixins/stateful.js | 21 +++++++------ src/js/plugin.js | 65 +++++++++++++++++++++++---------------- 5 files changed, 89 insertions(+), 70 deletions(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index a261c955ef..789402acbc 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -80,15 +80,15 @@ After that, any player will automatically have an `examplePlugin` method on its ## Writing a Advanced Plugin -As of Video.js 6, there is an additional type of plugin supported: advanced plugins. +Video.js 6 introduces advanced plugins: these are plugins that share a similar API with basic plugins, but are class-based and offer a range of extra features out of the box. -At any time, you may want to refer to the [Plugin API docs][api-plugin] for more detail. +While reading the following sections, you may want to refer to the [Plugin API docs][api-plugin] for more detail. ### Write a JavaScript Class/Constructor If you're familiar with creating [components](components.md), this process is similar. A advanced plugin starts with a JavaScript class (a.k.a. a constructor function). -This can be achieved with ES6 classes: +If you're using ES6 already, you can use that syntax with your transpiler/language of choice (Babel, TypeScript, etc): ```js const Plugin = videojs.getPlugin('plugin'); @@ -177,7 +177,7 @@ Up to this point, our example advanced plugin is functionally identical to our e Like components, advanced plugins offer an implementation of events. This includes: -- The ability to listen for events on the plugin instance using `on` or `one` and stop listening for events using `off`: +- The ability to listen for events on the plugin instance using `on` or `one`: ```js player.examplePlugin().on('example-event', function() { @@ -191,11 +191,17 @@ Like components, advanced plugins offer an implementation of events. This includ player.examplePlugin().trigger('example-event'); ``` +- The ability to stop listening to custom events on a plugin instance using `off`: + + ```js + player.examplePlugin().off('example-event'); + ``` + By offering a built-in events system, advanced plugins offer a wider range of options for code structure with a pattern familiar to most web developers. #### Statefulness -A new concept introduced in Video.js 6 for advanced plugins is _statefulness_. This is similar to React components' `state` property and `setState` method. +A new concept introduced for advanced plugins is _statefulness_. This is similar to React components' `state` property and `setState` method. Advanced plugin instances each have a `state` property, which is a plain JavaScript object - it can contain any keys and values the plugin author wants. @@ -236,7 +242,7 @@ player.examplePlugin().dispose(); The `dispose` method has several effects: - Triggers a `"dispose"` event on the plugin instance. -- Cleans up all event listeners on the plugin instance. +- Cleans up all event listeners on the plugin instance, which helps avoid errors caused by events being triggered after an object is cleaned up. - Removes plugin state and references to the player to avoid memory leaks. - Reverts the player's named property (e.g. `player.examplePlugin`) _back_ to the original factory function, so the plugin can be set up again. diff --git a/src/js/component.js b/src/js/component.js index e72526cfec..bc93a05f83 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -1649,6 +1649,7 @@ class Component { * Handles "statechanged" events on the component. No-op by default, override * by subclassing. * + * @abstract * @param {Event} e * An event object provided by a "statechanged" event. * diff --git a/src/js/mixins/evented.js b/src/js/mixins/evented.js index 3a8d502c10..32c917a4c2 100644 --- a/src/js/mixins/evented.js +++ b/src/js/mixins/evented.js @@ -30,12 +30,15 @@ const isEvented = (object) => * Whether or not the type is a valid event type. */ const isValidEventType = (type) => + // The regex here verifies that the `type` contains at least one non- + // whitespace character. (typeof type === 'string' && (/\S/).test(type)) || (Array.isArray(type) && !!type.length); /** * Validates a value to determine if it is a valid event target. Throws if not. * + * @private * @throws {Error} * If the target does not appear to be a valid event target. * @@ -51,6 +54,7 @@ const validateTarget = (target) => { /** * Validates a value to determine if it is a valid event target. Throws if not. * + * @private * @throws {Error} * If the type does not appear to be a valid event type. * @@ -66,6 +70,7 @@ const validateEventType = (type) => { /** * Validates a value to determine if it is a valid listener. Throws if not. * + * @private * @throws {Error} * If the listener is not a function. * @@ -84,7 +89,8 @@ const validateListener = (listener) => { * * @private * @param {Object} self - * The evented object on which `on()` or `one()` was called. + * The evented object on which `on()` or `one()` was called. This + * object will be bound as the `this` value for the listener. * * @param {Array} args * An array of arguments passed to `on()` or `one()`. @@ -132,16 +138,16 @@ const normalizeListenArgs = (self, args) => { * @param {Element|Object} target * A DOM node or evented object. * + * @param {string} method + * The event binding method to use ("on" or "one"). + * * @param {string|Array} type * One or more event type(s). * * @param {Function} listener * A listener function. - * - * @param {string} [method="on"] - * The event binding method to use. */ -const listen = (target, type, listener, method = 'on') => { +const listen = (target, method, type, listener) => { validateTarget(target); if (target.nodeName) { @@ -164,11 +170,11 @@ const mixin = { * object. * * @param {string|Array|Element|Object} targetOrType - * If this is a string or array, it represents an event type(s) and - * the listener will listen for events on this object. + * If this is a string or array, it represents the event type(s) + * that will trigger the listener. * * Another evented object can be passed here instead, which will - * cause the listener to listen for events on THAT object. + * cause the listener to listen for events on _that_ object. * * In either case, the listener's `this` value will be bound to * this object. @@ -188,7 +194,7 @@ const mixin = { on(...args) { const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args); - listen(target, type, listener); + listen(target, 'on', type, listener); // If this object is listening to another evented object. if (!isTargetingSelf) { @@ -209,11 +215,9 @@ const mixin = { // it using the ID of the original listener. removeRemoverOnTargetDispose.guid = listener.guid; - listen(this, 'dispose', removeListenerOnDispose); - listen(target, 'dispose', removeRemoverOnTargetDispose); + listen(this, 'on', 'dispose', removeListenerOnDispose); + listen(target, 'on', 'dispose', removeRemoverOnTargetDispose); } - - return this; }, /** @@ -221,11 +225,11 @@ const mixin = { * object. The listener will only be called once and then removed. * * @param {string|Array|Element|Object} targetOrType - * If this is a string or array, it represents an event type(s) and - * the listener will listen for events on this object. + * If this is a string or array, it represents the event type(s) + * that will trigger the listener. * * Another evented object can be passed here instead, which will - * cause the listener to listen for events on THAT object. + * cause the listener to listen for events on _that_ object. * * In either case, the listener's `this` value will be bound to * this object. @@ -247,7 +251,7 @@ const mixin = { // Targeting this evented object. if (isTargetingSelf) { - listen(target, type, listener, 'one'); + listen(target, 'one', type, listener); // Targeting another evented object. } else { @@ -259,20 +263,18 @@ const mixin = { // Use the same function ID as the listener so we can remove it later // it using the ID of the original listener. wrapper.guid = listener.guid; - listen(target, type, wrapper, 'one'); + listen(target, 'one', type, wrapper); } - - return this; }, /** * Removes listener(s) from event(s) on an evented object. * * @param {string|Array|Element|Object} [targetOrType] - * If this is a string or array, it represents an event type(s). + * If this is a string or array, it represents the event type(s). * * Another evented object can be passed here instead, in which case - * ALL 3 arguments are REQUIRED. + * ALL 3 arguments are _required_. * * @param {string|Array|Function} [typeOrListener] * If the first argument was a string or array, this may be the @@ -281,7 +283,8 @@ const mixin = { * * @param {Function} [listener] * If the first argument was another evented object, this will be - * the listener function. + * the listener function; otherwise, _all_ listeners bound to the + * event type(s) will be removed. * * @return {Object} * Returns the object itself. @@ -317,8 +320,6 @@ const mixin = { target.off('dispose', listener); } } - - return this; }, /** @@ -335,7 +336,6 @@ const mixin = { */ trigger(event, hash) { Events.trigger(this.eventBusEl_, event, hash); - return this; } }; @@ -348,7 +348,7 @@ const mixin = { * @param {Object} target * The object to which to add event methods. * - * @param {Object} [options] + * @param {Object} [options={}] * Options for customizing the mixin behavior. * * @param {String} [options.eventBusKey] diff --git a/src/js/mixins/stateful.js b/src/js/mixins/stateful.js index dbf086ca1d..c16f59e4cd 100644 --- a/src/js/mixins/stateful.js +++ b/src/js/mixins/stateful.js @@ -8,7 +8,7 @@ import * as Obj from '../utils/obj'; /** * Set the state of an object by mutating its `state` object in place. * - * @param {Object|Function} next + * @param {Object|Function} stateUpdates * A new set of properties to shallow-merge into the plugin state. Can * be a plain object or a function returning a plain object. * @@ -16,21 +16,21 @@ import * as Obj from '../utils/obj'; * An object containing changes that occurred. If no changes occurred, * returns `undefined`. */ -const setState = function(next) { +const setState = function(stateUpdates) { - // Support providing the `next` state as a function. - if (typeof next === 'function') { - next = next(); + // Support providing the `stateUpdates` state as a function. + if (typeof stateUpdates === 'function') { + stateUpdates = stateUpdates(); } - if (!Obj.isPlain(next)) { - log.warn('non-plain object passed to `setState`', next); + if (!Obj.isPlain(stateUpdates)) { + log.warn('non-plain object passed to `setState`', stateUpdates); return; } let changes; - Obj.each(next, (value, key) => { + Obj.each(stateUpdates, (value, key) => { // Record the change if the value is different from what's in the // current state. @@ -63,8 +63,9 @@ const setState = function(next) { * arbitrary keys/values and a `setState` method which will trigger state * changes if the object has a `trigger` method. * - * If the target object has a `handleStateChanged` method, it will be - * automatically bound to the `statechanged` event on itself. + * If the target object is evented (that is, uses the evented mixin) and has a + * `handleStateChanged` method, it will be automatically bound to the + * `statechanged` event on itself. * * @param {Object} target * The object to be made stateful. diff --git a/src/js/plugin.js b/src/js/plugin.js index 08683ee166..12adf2613e 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -7,6 +7,32 @@ import * as Events from './utils/events'; import * as Fn from './utils/fn'; import Player from './player'; +/** + * @typedef {Object} plugin:AdvancedEventHash + * + * @property {string} instance + * The plugin instance on which the event is fired. + * + * @property {string} name + * The name of the plugin. + * + * @property {string} plugin + * The plugin class/constructor. + */ + +/** + * @typedef {Object} plugin:BasicEventHash + * + * @property {string} instance + * The return value of the plugin function. + * + * @property {string} name + * The name of the plugin. + * + * @property {string} plugin + * The plugin function. + */ + /** * The base plugin name. * @@ -139,6 +165,7 @@ class Plugin { * * Subclasses should call `super` to ensure plugins are properly initialized. * + * @fires Plugin#pluginsetup * @param {Player} player * A Video.js player instance. */ @@ -171,17 +198,8 @@ class Plugin { * Signals that a plugin (both basic and advanced) has just been set up * on a player. * - * In all cases, an object containing the following properties is passed as a - * second argument to event listeners: - * - * - `name`: The name of the plugin that was set up. - * - `plugin`: The raw plugin function. - * - `instance`: For advanced plugins, the instance of the plugin sub-class, - * but, for basic plugins, the return value of the plugin invocation. - * - * @event pluginsetup - * @memberof Player - * @instance + * @event Plugin#pluginsetup + * @type {EventTarget~Event} */ player.trigger('pluginsetup', this.getEventHash_()); } @@ -196,12 +214,8 @@ class Plugin { * @param {Object} [hash={}] * An object to be used as event an event hash. * - * @return {Object} - * The event hash object with, at least, the following properties: - * - * - `instance`: The plugin instance on which the event is fired. - * - `name`: The name of the plugin. - * - `plugin`: The plugin class/constructor. + * @return {plugin:AdvancedEventHash} + * An event hash object with provided properties mixed-in. */ getEventHash_(hash = {}) { hash.name = this.name; @@ -216,13 +230,8 @@ class Plugin { * @param {Event|Object|string} event * A string (the type) or an event object with a type attribute. * - * @param {Object} [hash={}] - * Additional data hash to pass along with the event. For plugins, - * several properties are added to the hash: - * - * - `instance`: The plugin instance on which the event is fired. - * - `name`: The name of the plugin. - * - `plugin`: The plugin class/constructor. + * @param {plugin:AdvancedEventHash} [hash={}] + * Additional data hash to pass along with the event. * * @return {boolean} * Whether or not default was prevented. @@ -235,6 +244,7 @@ class Plugin { * Handles "statechanged" events on the plugin. No-op by default, override by * subclassing. * + * @abstract * @param {Event} e * An event object provided by a "statechanged" event. * @@ -249,6 +259,8 @@ class Plugin { * * Subclasses can override this if they want, but for the sake of safety, * it's probably best to subscribe the "dispose" event. + * + * @fires Plugin#dispose */ dispose() { const {name, player} = this; @@ -256,9 +268,8 @@ class Plugin { /** * Signals that a advanced plugin is about to be disposed. * - * @event dispose - * @memberof Plugin - * @instance + * @event Plugin#dispose + * @type {EventTarget~Event} */ this.trigger('dispose'); this.off(); From 2f1f15c000d250253cf4c187116f89a3ba73d268 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Wed, 4 Jan 2017 13:54:46 -0500 Subject: [PATCH 45/48] Updates based on feedback --- docs/guides/plugins.md | 6 ++-- src/js/mixins/evented.js | 2 +- src/js/mixins/stateful.js | 9 +----- src/js/player.js | 62 ++++++++++++++++++--------------------- src/js/plugin.js | 34 ++++++++------------- 5 files changed, 45 insertions(+), 68 deletions(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 789402acbc..77bc8c83bb 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -78,7 +78,7 @@ After that, any player will automatically have an `examplePlugin` method on its > **Note:** The only stipulation with the name of the plugin is that it cannot conflict with any existing plugin or player method. -## Writing a Advanced Plugin +## Writing an Advanced Plugin Video.js 6 introduces advanced plugins: these are plugins that share a similar API with basic plugins, but are class-based and offer a range of extra features out of the box. @@ -86,7 +86,7 @@ While reading the following sections, you may want to refer to the [Plugin API d ### Write a JavaScript Class/Constructor -If you're familiar with creating [components](components.md), this process is similar. A advanced plugin starts with a JavaScript class (a.k.a. a constructor function). +If you're familiar with creating [components](components.md), this process is similar. An advanced plugin starts with a JavaScript class (a.k.a. a constructor function). If you're using ES6 already, you can use that syntax with your transpiler/language of choice (Babel, TypeScript, etc): @@ -96,7 +96,7 @@ const Plugin = videojs.getPlugin('plugin'); class ExamplePlugin extends Plugin { constructor(player, options) { - super(player); + super(player, options); if (options.customClass) { player.addClass(options.customClass); diff --git a/src/js/mixins/evented.js b/src/js/mixins/evented.js index 32c917a4c2..8376f57313 100644 --- a/src/js/mixins/evented.js +++ b/src/js/mixins/evented.js @@ -372,7 +372,7 @@ function evented(target, options = {}) { target.eventBusEl_ = Dom.createEl('span', {className: 'vjs-event-bus'}); } - ['off', 'on', 'one', 'trigger'].forEach(name => { + Object.keys(mixin).forEach(name => { target[name] = Fn.bind(target, mixin[name]); }); diff --git a/src/js/mixins/stateful.js b/src/js/mixins/stateful.js index c16f59e4cd..246694ee15 100644 --- a/src/js/mixins/stateful.js +++ b/src/js/mixins/stateful.js @@ -1,8 +1,6 @@ /** * @file mixins/stateful.js */ -import * as Fn from '../utils/fn'; -import log from '../utils/log'; import * as Obj from '../utils/obj'; /** @@ -23,11 +21,6 @@ const setState = function(stateUpdates) { stateUpdates = stateUpdates(); } - if (!Obj.isPlain(stateUpdates)) { - log.warn('non-plain object passed to `setState`', stateUpdates); - return; - } - let changes; Obj.each(stateUpdates, (value, key) => { @@ -79,7 +72,7 @@ const setState = function(stateUpdates) { */ function stateful(target, defaultState) { target.state = Obj.assign({}, defaultState); - target.setState = Fn.bind(target, setState); + target.setState = setState; // Auto-bind the `handleStateChanged` method of the target object if it exists. if (typeof target.handleStateChanged === 'function' && typeof target.on === 'function') { diff --git a/src/js/player.js b/src/js/player.js index abcf7ba73a..b31a23e0f4 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -3102,40 +3102,6 @@ class Player extends Component { return modal.open(); } - /** - * Reports whether or not a player has a plugin available. - * - * This does not report whether or not the plugin has ever been initialized - * on this player. For that, [usingPlugin]{@link Player#usingPlugin}. - * - * @param {string} name - * The name of a plugin. - * - * @return {boolean} - * Whether or not this player has the requested plugin available. - */ - hasPlugin(name) { - // While a no-op by default, this method is created in plugin.js to avoid - // circular dependencies. - } - - /** - * Reports whether or not a player is using a plugin by name. - * - * For basic plugins, this only reports whether the plugin has _ever_ been - * initialized on this player. - * - * @param {string} name - * The name of a plugin. - * - * @return {boolean} - * Whether or not this player is using the requested plugin. - */ - usingPlugin(name) { - // While a no-op by default, this method is created in plugin.js to avoid - // circular dependencies. - } - /** * Gets tag settings * @@ -3381,5 +3347,33 @@ TECH_EVENTS_RETRIGGER.forEach(function(event) { * @type {EventTarget~Event} */ +/** + * Reports whether or not a player has a plugin available. + * + * This does not report whether or not the plugin has ever been initialized + * on this player. For that, [usingPlugin]{@link Player#usingPlugin}. + * + * @method hasPlugin + * @param {string} name + * The name of a plugin. + * + * @return {boolean} + * Whether or not this player has the requested plugin available. + */ + +/** + * Reports whether or not a player is using a plugin by name. + * + * For basic plugins, this only reports whether the plugin has _ever_ been + * initialized on this player. + * + * @method usingPlugin + * @param {string} name + * The name of a plugin. + * + * @return {boolean} + * Whether or not this player is using the requested plugin. + */ + Component.registerComponent('Player', Player); export default Player; diff --git a/src/js/plugin.js b/src/js/plugin.js index 12adf2613e..6f7911e27c 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -4,7 +4,6 @@ import evented from './mixins/evented'; import stateful from './mixins/stateful'; import * as Events from './utils/events'; -import * as Fn from './utils/fn'; import Player from './player'; /** @@ -184,13 +183,6 @@ class Plugin { stateful(this, this.constructor.defaultState); markPluginAsActive(player, this.name); - // Bind all plugin prototype methods to this object. - Object.keys(Plugin.prototype).forEach(k => { - if (typeof this[k] === 'function') { - this[k] = Fn.bind(this, this[k]); - } - }); - // If the player is disposed, dispose the plugin. player.on('dispose', this.dispose); @@ -386,19 +378,6 @@ class Plugin { return result; } - /** - * Gets a plugin by name if it exists. - * - * @param {string} name - * The name of a plugin. - * - * @return {Function|undefined} - * The plugin (or `undefined`). - */ - static getPlugin(name) { - return getPlugin(name); - } - /** * Gets a plugin's version, if available * @@ -415,7 +394,16 @@ class Plugin { } } -Plugin.registerPlugin(BASE_PLUGIN_NAME, Plugin); +/** + * Gets a plugin by name if it exists. + * + * @param {string} name + * The name of a plugin. + * + * @return {Function|undefined} + * The plugin (or `undefined`). + */ +Plugin.getPlugin = getPlugin; /** * The name of the base plugin class as it is registered. @@ -424,6 +412,8 @@ Plugin.registerPlugin(BASE_PLUGIN_NAME, Plugin); */ Plugin.BASE_PLUGIN_NAME = BASE_PLUGIN_NAME; +Plugin.registerPlugin(BASE_PLUGIN_NAME, Plugin); + /** * Documented in player.js * From 8eb90bfe6ef14cde8af8cd819f767b4c8a74240d Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Thu, 5 Jan 2017 13:45:54 -0500 Subject: [PATCH 46/48] Updates based on feedback --- docs/guides/plugins.md | 23 ------ src/js/component.js | 155 +++-------------------------------------- 2 files changed, 9 insertions(+), 169 deletions(-) diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 77bc8c83bb..bee8f5739e 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -1,28 +1,5 @@ # Video.js Plugins - - - - -- [Writing a Basic Plugin](#writing-a-basic-plugin) - - [Write a JavaScript Function](#write-a-javascript-function) - - [Register a Basic Plugin](#register-a-basic-plugin) -- [Writing a Advanced Plugin](#writing-a-advanced-plugin) - - [Write a JavaScript Class/Constructor](#write-a-javascript-classconstructor) - - [Register a Advanced Plugin](#register-a-advanced-plugin) - - [Key Differences from Basic Plugins](#key-differences-from-basic-plugins) - - [The Value of `this`](#the-value-of-this) - - [The Player Plugin Name Property](#the-player-plugin-name-property) - - [Advanced Features of Advanced Plugins](#advanced-features-of-advanced-plugins) - - [Events](#events) - - [Statefulness](#statefulness) - - [Lifecycle](#lifecycle) - - [Advanced Example Advanced Plugin](#advanced-example-advanced-plugin) -- [Setting up a Plugin](#setting-up-a-plugin) -- [References](#references) - - - One of the great strengths of Video.js is its ecosystem of plugins that allow authors from all over the world to share their video player customizations. This includes everything from the simplest UI tweaks to new [playback technologies and source handlers](tech.md)! Because we view plugins as such an important part of Video.js, the organization is committed to maintaining a robust set of tools for plugin authorship: diff --git a/src/js/component.js b/src/js/component.js index bc93a05f83..a9db8596f5 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -566,46 +566,17 @@ class Component { /** * Add an `event listener` to this `Component`s element. * - * ```js - * var player = videojs('some-player-id'); - * var Component = videojs.getComponent('Component'); - * var myComponent = new Component(player); - * var myFunc = function() { - * var myComponent = this; - * console.log('myFunc called'); - * }; - * - * myComponent.on('eventType', myFunc); - * myComponent.trigger('eventType'); - * // logs 'myFunc called' - * ``` - * - * The context of `myFunc` will be `myComponent` unless it is bound. You can add - * a listener to another element or component. - * ```js - * var otherComponent = new Component(player); - * - * // myComponent/myFunc is from the above example - * myComponent.on(otherComponent.el(), 'eventName', myFunc); - * myComponent.on(otherComponent, 'eventName', myFunc); - * - * otherComponent.trigger('eventName'); - * // logs 'myFunc called' twice - * ``` - * * The benefit of using this over the following: * - `VjsEvents.on(otherElement, 'eventName', myFunc)` * - `otherComponent.on('eventName', myFunc)` - * Is that the listeners will get cleaned up when either component gets disposed. - * It will also bind `myComponent` as the context of `myFunc`. - * > NOTE: If you remove the element from the DOM that has used `on` you need to - * clean up references using: - * - * `myComponent.trigger(el, 'dispose')` * - * This will also allow the browser to garbage collect it. In special - * cases such as with `window` and `document`, which are both permanent, - * this is not necessary. + * 1. Is that the listeners will get cleaned up when either component gets disposed. + * 1. It will also bind `myComponent` as the context of `myFunc`. + * > NOTE: If you remove the element from the DOM that has used `on` you need to + * clean up references using: `myComponent.trigger(el, 'dispose')` + * This will also allow the browser to garbage collect it. In special + * cases such as with `window` and `document`, which are both permanent, + * this is not necessary. * * @param {string|Component|string[]} [first] * The event name, and array of event names, or another `Component`. @@ -667,47 +638,8 @@ class Component { } /** - * Remove an event listener from this `Component`s element. - * ```js - * var player = videojs('some-player-id'); - * var Component = videojs.getComponent('Component'); - * var myComponent = new Component(player); - * var myFunc = function() { - * var myComponent = this; - * console.log('myFunc called'); - * }; - * myComponent.on('eventType', myFunc); - * myComponent.trigger('eventType'); - * // logs 'myFunc called' - * - * myComponent.off('eventType', myFunc); - * myComponent.trigger('eventType'); - * // does nothing - * ``` - * - * If myFunc gets excluded, ALL listeners for the event type will get removed. If - * eventType gets excluded, ALL listeners will get removed from the component. - * You can use `off` to remove listeners that get added to other elements or - * components using: - * - * `myComponent.on(otherComponent...` - * - * In this case both the event type and listener function are **REQUIRED**. - * - * ```js - * var otherComponent = new Component(player); - * - * // myComponent/myFunc is from the above example - * myComponent.on(otherComponent.el(), 'eventName', myFunc); - * myComponent.on(otherComponent, 'eventName', myFunc); - * - * otherComponent.trigger('eventName'); - * // logs 'myFunc called' twice - * myComponent.off(ootherComponent.el(), 'eventName', myFunc); - * myComponent.off(otherComponent, 'eventName', myFunc); - * otherComponent.trigger('eventName'); - * // does nothing - * ``` + * Remove an event listener from this `Component`s element. If the second argument is + * exluded all listeners for the type passed in as the first argument will be removed. * * @param {string|Component|string[]} [first] * The event name, and array of event names, or another `Component`. @@ -751,38 +683,6 @@ class Component { /** * Add an event listener that gets triggered only once and then gets removed. - * ```js - * var player = videojs('some-player-id'); - * var Component = videojs.getComponent('Component'); - * var myComponent = new Component(player); - * var myFunc = function() { - * var myComponent = this; - * console.log('myFunc called'); - * }; - * myComponent.one('eventName', myFunc); - * myComponent.trigger('eventName'); - * // logs 'myFunc called' - * - * myComponent.trigger('eventName'); - * // does nothing - * - * ``` - * - * You can also add a listener to another element or component that will get - * triggered only once. - * ```js - * var otherComponent = new Component(player); - * - * // myComponent/myFunc is from the above example - * myComponent.one(otherComponent.el(), 'eventName', myFunc); - * myComponent.one(otherComponent, 'eventName', myFunc); - * - * otherComponent.trigger('eventName'); - * // logs 'myFunc called' twice - * - * otherComponent.trigger('eventName'); - * // does nothing - * ``` * * @param {string|Component|string[]} [first] * The event name, and array of event names, or another `Component`. @@ -822,29 +722,6 @@ class Component { /** * Trigger an event on an element. * - * ```js - * var player = videojs('some-player-id'); - * var Component = videojs.getComponent('Component'); - * var myComponent = new Component(player); - * var myFunc = function(data) { - * var myComponent = this; - * console.log('myFunc called'); - * console.log(data); - * }; - * myComponent.one('eventName', myFunc); - * myComponent.trigger('eventName'); - * // logs 'myFunc called' and 'undefined' - * - * myComponent.trigger({'type':'eventName'}); - * // logs 'myFunc called' and 'undefined' - * - * myComponent.trigger('eventName', {data: 'some data'}); - * // logs 'myFunc called' and "{data: 'some data'}" - * - * myComponent.trigger({'type':'eventName'}, {data: 'some data'}); - * // logs 'myFunc called' and "{data: 'some data'}" - * ``` - * * @param {EventTarget~Event|Object|string} event * The event name, and Event, or an event-like object with a type attribute * set to the event name. @@ -1645,20 +1522,6 @@ class Component { return intervalId; } - /** - * Handles "statechanged" events on the component. No-op by default, override - * by subclassing. - * - * @abstract - * @param {Event} e - * An event object provided by a "statechanged" event. - * - * @param {Object} e.changes - * An object describing changes that occurred with the "statechanged" - * event. - */ - handleStateChanged(e) {} - /** * Register a `Component` with `videojs` given the name and the component. * From 56de0b5196366176e0123468807744baad4c56c2 Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Fri, 6 Jan 2017 14:32:31 -0500 Subject: [PATCH 47/48] Documentation cleanup/improvement, fix dispose state issue --- docs/guides/components.md | 4 +- docs/guides/plugins.md | 26 ++-- src/js/mixins/evented.js | 46 +++--- src/js/mixins/stateful.js | 135 ++++++++++------- src/js/plugin.js | 237 ++++++++++++++++-------------- test/globals-shim.js | 4 +- test/index.html | 2 +- test/unit/mixins/stateful.test.js | 8 +- 8 files changed, 247 insertions(+), 215 deletions(-) diff --git a/docs/guides/components.md b/docs/guides/components.md index 356d8caf3b..b888ac3b67 100644 --- a/docs/guides/components.md +++ b/docs/guides/components.md @@ -195,8 +195,8 @@ myComponent.trigger('eventType'); // does nothing ``` -If myFunc gets excluded, *all* listeners for the event type will get removed. If -eventType gets excluded, *all* listeners will get removed from the component. +If myFunc gets excluded, _all_ listeners for the event type will get removed. If +eventType gets excluded, _all_ listeners will get removed from the component. You can use `off` to remove listeners that get added to other elements or components using: diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index bee8f5739e..1e0d0d7795 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -4,13 +4,13 @@ One of the great strengths of Video.js is its ecosystem of plugins that allow au Because we view plugins as such an important part of Video.js, the organization is committed to maintaining a robust set of tools for plugin authorship: -- [generator-videojs-plugin][generator] +* [generator-videojs-plugin][generator] A [Yeoman][yeoman] generator for scaffolding a Video.js plugin project. Additionally, it offers a set of [conventions for plugin authorship][standards] that, if followed, make authorship, contribution, and usage consistent and predictable. In short, the generator sets up plugin authors to focus on writing their plugin - not messing with tools. -- [videojs-spellbook][spellbook] +* [videojs-spellbook][spellbook] As of version 3, the plugin generator includes a new dependency: [videojs-spellbook][spellbook]. Spellbook is a kitchen sink plugin development tool: it builds plugins, creates tags, runs a development server, and more. @@ -154,7 +154,7 @@ Up to this point, our example advanced plugin is functionally identical to our e Like components, advanced plugins offer an implementation of events. This includes: -- The ability to listen for events on the plugin instance using `on` or `one`: +* The ability to listen for events on the plugin instance using `on` or `one`: ```js player.examplePlugin().on('example-event', function() { @@ -162,13 +162,13 @@ Like components, advanced plugins offer an implementation of events. This includ }); ``` -- The ability to `trigger` custom events on a plugin instance: +* The ability to `trigger` custom events on a plugin instance: ```js player.examplePlugin().trigger('example-event'); ``` -- The ability to stop listening to custom events on a plugin instance using `off`: +* The ability to stop listening to custom events on a plugin instance using `off`: ```js player.examplePlugin().off('example-event'); @@ -218,10 +218,10 @@ player.examplePlugin().dispose(); The `dispose` method has several effects: -- Triggers a `"dispose"` event on the plugin instance. -- Cleans up all event listeners on the plugin instance, which helps avoid errors caused by events being triggered after an object is cleaned up. -- Removes plugin state and references to the player to avoid memory leaks. -- Reverts the player's named property (e.g. `player.examplePlugin`) _back_ to the original factory function, so the plugin can be set up again. +* Triggers a `"dispose"` event on the plugin instance. +* Cleans up all event listeners on the plugin instance, which helps avoid errors caused by events being triggered after an object is cleaned up. +* Removes plugin state and references to the player to avoid memory leaks. +* Reverts the player's named property (e.g. `player.examplePlugin`) _back_ to the original factory function, so the plugin can be set up again. In addition, if the player is disposed, the disposal of all its advanced plugin instances will be triggered as well. @@ -309,10 +309,10 @@ These two methods are functionally identical - use whichever you prefer! ## References -- [Player API][api-player] -- [Plugin API][api-plugin] -- [Plugin Generator][generator] -- [Plugin Conventions][standards] +* [Player API][api-player] +* [Plugin API][api-plugin] +* [Plugin Generator][generator] +* [Plugin Conventions][standards] [api-player]: http://docs.videojs.com/docs/api/player.html [api-plugin]: http://docs.videojs.com/docs/api/plugin.html diff --git a/src/js/mixins/evented.js b/src/js/mixins/evented.js index 8376f57313..94dea9ef22 100644 --- a/src/js/mixins/evented.js +++ b/src/js/mixins/evented.js @@ -1,14 +1,15 @@ /** * @file mixins/evented.js + * @module evented */ import * as Dom from '../utils/dom'; -import * as Fn from '../utils/fn'; import * as Events from '../utils/events'; +import * as Fn from '../utils/fn'; +import * as Obj from '../utils/obj'; /** * Returns whether or not an object has had the evented mixin applied. * - * @private * @param {Object} object * An object to test. * @@ -158,12 +159,12 @@ const listen = (target, method, type, listener) => { }; /** - * Methods that can be mixed-in with any object to provide event capabilities. + * Contains methods that provide event capabilites to an object which is passed + * to {@link module:evented|evented}. * - * @name mixins/evented - * @type {Object} + * @mixin EventedMixin */ -const mixin = { +const EventedMixin = { /** * Add a listener to an event (or events) on this object or another evented @@ -187,9 +188,6 @@ const mixin = { * @param {Function} [listener] * If the first argument was another evented object, this will be * the listener function. - * - * @return {Object} - * Returns this object. */ on(...args) { const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args); @@ -242,9 +240,6 @@ const mixin = { * @param {Function} [listener] * If the first argument was another evented object, this will be * the listener function. - * - * @return {Object} - * Returns this object. */ one(...args) { const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args); @@ -285,9 +280,6 @@ const mixin = { * If the first argument was another evented object, this will be * the listener function; otherwise, _all_ listeners bound to the * event type(s) will be removed. - * - * @return {Object} - * Returns the object itself. */ off(targetOrType, typeOrListener, listener) { @@ -325,25 +317,22 @@ const mixin = { /** * Fire an event on this evented object, causing its listeners to be called. * - * @param {string|Object} event - * An event type or an object with a type property. + * @param {string|Object} event + * An event type or an object with a type property. * - * @param {Object} [hash] - * An additional object to pass along to listeners. + * @param {Object} [hash] + * An additional object to pass along to listeners. * - * @return {Object} - * Returns this object. + * @returns {boolean} + * Whether or not the default behavior was prevented. */ trigger(event, hash) { - Events.trigger(this.eventBusEl_, event, hash); + return Events.trigger(this.eventBusEl_, event, hash); } }; /** - * Makes an object "evented" - granting it methods from the `Events` utility. - * - * By default, this adds the `off`, `on`, `one`, and `trigger` methods, but - * exclusions can optionally be made. + * Applies {@link module:evented~EventedMixin|EventedMixin} to a target object. * * @param {Object} target * The object to which to add event methods. @@ -372,9 +361,7 @@ function evented(target, options = {}) { target.eventBusEl_ = Dom.createEl('span', {className: 'vjs-event-bus'}); } - Object.keys(mixin).forEach(name => { - target[name] = Fn.bind(target, mixin[name]); - }); + Obj.assign(target, EventedMixin); // When any evented object is disposed, it removes all its listeners. target.on('dispose', () => target.off()); @@ -383,3 +370,4 @@ function evented(target, options = {}) { } export default evented; +export {isEvented}; diff --git a/src/js/mixins/stateful.js b/src/js/mixins/stateful.js index 246694ee15..66431dc60b 100644 --- a/src/js/mixins/stateful.js +++ b/src/js/mixins/stateful.js @@ -1,81 +1,116 @@ /** * @file mixins/stateful.js + * @module stateful */ +import {isEvented} from './evented'; import * as Obj from '../utils/obj'; /** - * Set the state of an object by mutating its `state` object in place. + * Contains methods that provide statefulness to an object which is passed + * to {@link module:stateful}. * - * @param {Object|Function} stateUpdates - * A new set of properties to shallow-merge into the plugin state. Can - * be a plain object or a function returning a plain object. - * - * @return {Object} - * An object containing changes that occurred. If no changes occurred, - * returns `undefined`. + * @mixin StatefulMixin */ -const setState = function(stateUpdates) { - - // Support providing the `stateUpdates` state as a function. - if (typeof stateUpdates === 'function') { - stateUpdates = stateUpdates(); - } +const StatefulMixin = { - let changes; + /** + * A hash containing arbitrary keys and values representing the state of + * the object. + * + * @type {Object} + */ + state: {}, - Obj.each(stateUpdates, (value, key) => { + /** + * Set the state of an object by mutating its + * {@link module:stateful~StatefulMixin.state|state} object in place. + * + * @fires module:stateful~StatefulMixin#statechanged + * @param {Object|Function} stateUpdates + * A new set of properties to shallow-merge into the plugin state. + * Can be a plain object or a function returning a plain object. + * + * @returns {Object|undefined} + * An object containing changes that occurred. If no changes + * occurred, returns `undefined`. + */ + setState(stateUpdates) { - // Record the change if the value is different from what's in the - // current state. - if (this.state[key] !== value) { - changes = changes || {}; - changes[key] = { - from: this.state[key], - to: value - }; + // Support providing the `stateUpdates` state as a function. + if (typeof stateUpdates === 'function') { + stateUpdates = stateUpdates(); } - this.state[key] = value; - }); + let changes; - // Only trigger "statechange" if there were changes AND we have a trigger - // function. This allows us to not require that the target object be an - // evented object. - if (changes && typeof this.trigger === 'function') { - this.trigger({ - changes, - type: 'statechanged' + Obj.each(stateUpdates, (value, key) => { + + // Record the change if the value is different from what's in the + // current state. + if (this.state[key] !== value) { + changes = changes || {}; + changes[key] = { + from: this.state[key], + to: value + }; + } + + this.state[key] = value; }); - } - return changes; + // Only trigger "statechange" if there were changes AND we have a trigger + // function. This allows us to not require that the target object be an + // evented object. + if (changes && isEvented(this)) { + + /** + * An event triggered on an object that is both + * {@link module:stateful|stateful} and {@link module:evented|evented} + * indicating that its state has changed. + * + * @event module:stateful~StatefulMixin#statechanged + * @type {Object} + * @property {Object} changes + * A hash containing the properties that were changed and + * the values they were changed `from` and `to`. + */ + this.trigger({ + changes, + type: 'statechanged' + }); + } + + return changes; + } }; /** - * Makes an object "stateful" - granting it a `state` property containing - * arbitrary keys/values and a `setState` method which will trigger state - * changes if the object has a `trigger` method. + * Applies {@link module:stateful~StatefulMixin|StatefulMixin} to a target + * object. * - * If the target object is evented (that is, uses the evented mixin) and has a - * `handleStateChanged` method, it will be automatically bound to the + * If the target object is {@link module:evented|evented} and has a + * `handleStateChanged` method, that method will be automatically bound to the * `statechanged` event on itself. * - * @param {Object} target - * The object to be made stateful. + * @param {Object} target + * The object to be made stateful. * - * @param {Object} [defaultState] - * A default set of properties to populate the newly-stateful object's - * `state` property. + * @param {Object} [defaultState] + * A default set of properties to populate the newly-stateful object's + * `state` property. * - * @return {Object} - * Returns the `target`. + * @returns {Object} + * Returns the `target`. */ function stateful(target, defaultState) { - target.state = Obj.assign({}, defaultState); - target.setState = setState; + Obj.assign(target, StatefulMixin); + + // This happens after the mixing-in because we need to replace the `state` + // added in that step. + target.state = Obj.assign({}, target.state, defaultState); // Auto-bind the `handleStateChanged` method of the target object if it exists. - if (typeof target.handleStateChanged === 'function' && typeof target.on === 'function') { + if (typeof target.handleStateChanged === 'function' && isEvented(target)) { target.on('statechanged', target.handleStateChanged); } diff --git a/src/js/plugin.js b/src/js/plugin.js index 6f7911e27c..f042b81dd9 100644 --- a/src/js/plugin.js +++ b/src/js/plugin.js @@ -4,38 +4,14 @@ import evented from './mixins/evented'; import stateful from './mixins/stateful'; import * as Events from './utils/events'; +import * as Fn from './utils/fn'; import Player from './player'; -/** - * @typedef {Object} plugin:AdvancedEventHash - * - * @property {string} instance - * The plugin instance on which the event is fired. - * - * @property {string} name - * The name of the plugin. - * - * @property {string} plugin - * The plugin class/constructor. - */ - -/** - * @typedef {Object} plugin:BasicEventHash - * - * @property {string} instance - * The return value of the plugin function. - * - * @property {string} name - * The name of the plugin. - * - * @property {string} plugin - * The plugin function. - */ - /** * The base plugin name. * * @private + * @constant * @type {string} */ const BASE_PLUGIN_NAME = 'plugin'; @@ -44,7 +20,8 @@ const BASE_PLUGIN_NAME = 'plugin'; * The key on which a player's active plugins cache is stored. * * @private - * @type {string} + * @constant + * @type {string} */ const PLUGIN_CACHE_KEY = 'activePlugins_'; @@ -52,7 +29,7 @@ const PLUGIN_CACHE_KEY = 'activePlugins_'; * Stores registered plugins in a private space. * * @private - * @type {Object} + * @type {Object} */ const pluginStorage = {}; @@ -60,11 +37,11 @@ const pluginStorage = {}; * Reports whether or not a plugin has been registered. * * @private - * @param {string} name - * The name of a plugin. + * @param {string} name + * The name of a plugin. * - * @return {boolean} - * Whether or not the plugin has been registered. + * @returns {boolean} + * Whether or not the plugin has been registered. */ const pluginExists = (name) => pluginStorage.hasOwnProperty(name); @@ -72,11 +49,11 @@ const pluginExists = (name) => pluginStorage.hasOwnProperty(name); * Get a single registered plugin by name. * * @private - * @param {string} name - * The name of a plugin. + * @param {string} name + * The name of a plugin. * - * @return {Function|undefined} - * The plugin (or undefined). + * @returns {Function|undefined} + * The plugin (or undefined). */ const getPlugin = (name) => pluginExists(name) ? pluginStorage[name] : undefined; @@ -86,11 +63,11 @@ const getPlugin = (name) => pluginExists(name) ? pluginStorage[name] : undefined * Also, ensures that the player has an object for tracking active plugins. * * @private - * @param {Player} player - * A Video.js player instance. + * @param {Player} player + * A Video.js player instance. * - * @param {string} name - * The name of a plugin. + * @param {string} name + * The name of a plugin. */ const markPluginAsActive = (player, name) => { player[PLUGIN_CACHE_KEY] = player[PLUGIN_CACHE_KEY] || {}; @@ -102,14 +79,14 @@ const markPluginAsActive = (player, name) => { * on the player that the plugin has been activated. * * @private - * @param {string} name - * The name of the plugin. + * @param {string} name + * The name of the plugin. * - * @param {Function} plugin - * The basic plugin. + * @param {Function} plugin + * The basic plugin. * - * @return {Function} - * A wrapper function for the given plugin. + * @returns {Function} + * A wrapper function for the given plugin. */ const createBasicPlugin = (name, plugin) => function() { const instance = plugin.apply(this, arguments); @@ -132,14 +109,13 @@ const createBasicPlugin = (name, plugin) => function() { * sub-class of Plugin. * * @private - * @param {string} name - * The name of the plugin. + * @param {string} name + * The name of the plugin. * - * @param {Plugin} PluginSubClass - * The advanced plugin. + * @param {Plugin} PluginSubClass + * The advanced plugin. * - * @return {Function} - * A factory function for the plugin sub-class. + * @returns {Function} */ const createPluginFactory = (name, PluginSubClass) => { @@ -157,14 +133,24 @@ const createPluginFactory = (name, PluginSubClass) => { }; }; +/** + * Parent class for all advanced plugins. + * + * @mixes module:evented~EventedMixin + * @mixes module:stateful~StatefulMixin + * @fires Player#pluginsetup + * @listens Player#dispose + * @throws {Error} + * If attempting to instantiate the base {@link Plugin} class + * directly instead of via a sub-class. + */ class Plugin { /** - * Plugin constructor. + * Creates an instance of this class. * - * Subclasses should call `super` to ensure plugins are properly initialized. + * Sub-classes should call `super` to ensure plugins are properly initialized. * - * @fires Plugin#pluginsetup * @param {Player} player * A Video.js player instance. */ @@ -183,17 +169,13 @@ class Plugin { stateful(this, this.constructor.defaultState); markPluginAsActive(player, this.name); + // Auto-bind the dispose method so we can use it as a listener and unbind + // it later easily. + this.dispose = Fn.bind(this, this.dispose); + // If the player is disposed, dispose the plugin. player.on('dispose', this.dispose); - - /** - * Signals that a plugin (both basic and advanced) has just been set up - * on a player. - * - * @event Plugin#pluginsetup - * @type {EventTarget~Event} - */ - player.trigger('pluginsetup', this.getEventHash_()); + player.trigger('pluginsetup', this.getEventHash()); } /** @@ -202,14 +184,13 @@ class Plugin { * * This returns that object or mutates an existing hash. * - * @private - * @param {Object} [hash={}] - * An object to be used as event an event hash. + * @param {Object} [hash={}] + * An object to be used as event an event hash. * - * @return {plugin:AdvancedEventHash} - * An event hash object with provided properties mixed-in. + * @returns {Plugin~PluginEventHash} + * An event hash object with provided properties mixed-in. */ - getEventHash_(hash = {}) { + getEventHash(hash = {}) { hash.name = this.name; hash.plugin = this.constructor; hash.instance = this; @@ -217,19 +198,21 @@ class Plugin { } /** - * Triggers an event on the plugin object. + * Triggers an event on the plugin object and overrides + * {@link module:evented~EventedMixin.trigger|EventedMixin.trigger}. * - * @param {Event|Object|string} event - * A string (the type) or an event object with a type attribute. + * @param {string|Object} event + * An event type or an object with a type property. * - * @param {plugin:AdvancedEventHash} [hash={}] - * Additional data hash to pass along with the event. + * @param {Object} [hash={}] + * Additional data hash to merge with a + * {@link Plugin~PluginEventHash|PluginEventHash}. * - * @return {boolean} - * Whether or not default was prevented. + * @returns {boolean} + * Whether or not default was prevented. */ trigger(event, hash = {}) { - return Events.trigger(this.eventBusEl_, event, this.getEventHash_(hash)); + return Events.trigger(this.eventBusEl_, event, this.getEventHash(hash)); } /** @@ -237,12 +220,12 @@ class Plugin { * subclassing. * * @abstract - * @param {Event} e - * An event object provided by a "statechanged" event. + * @param {Event} e + * An event object provided by a "statechanged" event. * - * @param {Object} e.changes - * An object describing changes that occurred with the "statechanged" - * event. + * @param {Object} e.changes + * An object describing changes that occurred with the "statechanged" + * event. */ handleStateChanged(e) {} @@ -261,10 +244,11 @@ class Plugin { * Signals that a advanced plugin is about to be disposed. * * @event Plugin#dispose - * @type {EventTarget~Event} + * @type {EventTarget~Event} */ this.trigger('dispose'); this.off(); + player.off('dispose', this.dispose); // Eliminate any possible sources of leaking memory by clearing up // references between the player and the plugin instance and nulling out @@ -280,12 +264,12 @@ class Plugin { /** * Determines if a plugin is a basic plugin (i.e. not a sub-class of `Plugin`). * - * @param {string|Function} plugin - * If a string, matches the name of a plugin. If a function, will be - * tested directly. + * @param {string|Function} plugin + * If a string, matches the name of a plugin. If a function, will be + * tested directly. * - * @return {boolean} - * Whether or not a plugin is a basic plugin. + * @returns {boolean} + * Whether or not a plugin is a basic plugin. */ static isBasic(plugin) { const p = (typeof plugin === 'string') ? getPlugin(plugin) : plugin; @@ -296,17 +280,17 @@ class Plugin { /** * Register a Video.js plugin. * - * @param {string} name - * The name of the plugin to be registered. Must be a string and - * must not match an existing plugin or a method on the `Player` - * prototype. + * @param {string} name + * The name of the plugin to be registered. Must be a string and + * must not match an existing plugin or a method on the `Player` + * prototype. * - * @param {Function} plugin - * A sub-class of `Plugin` or a function for basic plugins. + * @param {Function} plugin + * A sub-class of `Plugin` or a function for basic plugins. * - * @return {Function} - * For advanced plugins, a factory function for that plugin. For - * basic plugins, a wrapper function that initializes the plugin. + * @returns {Function} + * For advanced plugins, a factory function for that plugin. For + * basic plugins, a wrapper function that initializes the plugin. */ static registerPlugin(name, plugin) { if (typeof name !== 'string') { @@ -339,8 +323,8 @@ class Plugin { /** * De-register a Video.js plugin. * - * @param {string} name - * The name of the plugin to be deregistered. + * @param {string} name + * The name of the plugin to be deregistered. */ static deregisterPlugin(name) { if (name === BASE_PLUGIN_NAME) { @@ -355,13 +339,13 @@ class Plugin { /** * Gets an object containing multiple Video.js plugins. * - * @param {Array} [names] - * If provided, should be an array of plugin names. Defaults to _all_ - * plugin names. + * @param {Array} [names] + * If provided, should be an array of plugin names. Defaults to _all_ + * plugin names. * - * @return {Object|undefined} - * An object containing plugin(s) associated with their name(s) or - * `undefined` if no matching plugins exist). + * @returns {Object|undefined} + * An object containing plugin(s) associated with their name(s) or + * `undefined` if no matching plugins exist). */ static getPlugins(names = Object.keys(pluginStorage)) { let result; @@ -381,11 +365,11 @@ class Plugin { /** * Gets a plugin's version, if available * - * @param {string} name - * The name of a plugin. + * @param {string} name + * The name of a plugin. * - * @return {string} - * The plugin's version or an empty string. + * @returns {string} + * The plugin's version or an empty string. */ static getPluginVersion(name) { const plugin = getPlugin(name); @@ -397,11 +381,14 @@ class Plugin { /** * Gets a plugin by name if it exists. * - * @param {string} name - * The name of a plugin. + * @static + * @method getPlugin + * @memberOf Plugin + * @param {string} name + * The name of a plugin. * - * @return {Function|undefined} - * The plugin (or `undefined`). + * @returns {Function|undefined} + * The plugin (or `undefined`). */ Plugin.getPlugin = getPlugin; @@ -433,3 +420,25 @@ Player.prototype.hasPlugin = function(name) { }; export default Plugin; + +/** + * Signals that a plugin has just been set up on a player. + * + * @event Player#pluginsetup + * @type {Plugin~PluginEventHash} + */ + +/** + * @typedef {Object} Plugin~PluginEventHash + * + * @property {string} instance + * For basic plugins, the return value of the plugin function. For + * advanced plugins, the plugin instance on which the event is fired. + * + * @property {string} name + * The name of the plugin. + * + * @property {string} plugin + * For basic plugins, the plugin function. For advanced plugins, the + * plugin class/constructor. + */ diff --git a/test/globals-shim.js b/test/globals-shim.js index 989df9e37a..170e5b14db 100644 --- a/test/globals-shim.js +++ b/test/globals-shim.js @@ -1,6 +1,6 @@ /* eslint-env qunit */ -import 'es5-shim'; -import 'es6-shim'; +// import 'es5-shim'; +// import 'es6-shim'; import document from 'global/document'; import window from 'global/window'; import sinon from 'sinon'; diff --git a/test/index.html b/test/index.html index dee7975002..977d322efe 100644 --- a/test/index.html +++ b/test/index.html @@ -9,7 +9,7 @@
- + diff --git a/test/unit/mixins/stateful.test.js b/test/unit/mixins/stateful.test.js index 62f165319e..2f3843241d 100644 --- a/test/unit/mixins/stateful.test.js +++ b/test/unit/mixins/stateful.test.js @@ -1,6 +1,6 @@ /* eslint-env qunit */ import sinon from 'sinon'; -import EventTarget from '../../../src/js/event-target'; +import evented from '../../../src/js/mixins/evented'; import stateful from '../../../src/js/mixins/stateful'; import * as Obj from '../../../src/js/utils/obj'; @@ -31,7 +31,7 @@ QUnit.test('stateful() without default state passed in', function(assert) { }); QUnit.test('setState() works as expected', function(assert) { - const target = stateful(new EventTarget(), {foo: 'bar', abc: 'xyz'}); + const target = stateful(evented({}), {foo: 'bar', abc: 'xyz'}); const spy = sinon.spy(); target.on('statechanged', spy); @@ -59,7 +59,7 @@ QUnit.test('setState() works as expected', function(assert) { }); QUnit.test('setState() without changes does not trigger the "statechanged" event', function(assert) { - const target = stateful(new EventTarget(), {foo: 'bar'}); + const target = stateful(evented({}), {foo: 'bar'}); const spy = sinon.spy(); target.on('statechanged', spy); @@ -71,7 +71,7 @@ QUnit.test('setState() without changes does not trigger the "statechanged" event }); QUnit.test('handleStateChanged() is automatically bound to "statechanged" event', function(assert) { - const target = new EventTarget(); + const target = evented({}); target.handleStateChanged = sinon.spy(); stateful(target, {foo: 'bar'}); From 1c95599915d27db686028fa7b3f7fd2b8b67599a Mon Sep 17 00:00:00 2001 From: Pat O'Neill Date: Fri, 6 Jan 2017 14:45:04 -0500 Subject: [PATCH 48/48] Rename a test --- test/unit/{plugin-class.test.js => plugin-advanced.test.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/unit/{plugin-class.test.js => plugin-advanced.test.js} (100%) diff --git a/test/unit/plugin-class.test.js b/test/unit/plugin-advanced.test.js similarity index 100% rename from test/unit/plugin-class.test.js rename to test/unit/plugin-advanced.test.js