diff --git a/docs/guides/hooks.md b/docs/guides/hooks.md index 5aad3e1c54..1ff6fa9b46 100644 --- a/docs/guides/hooks.md +++ b/docs/guides/hooks.md @@ -9,15 +9,19 @@ Hooks exist so that users can globally hook into certain Video.js lifecycle mome * [Example](#example) * [setup](#setup) * [Example](#example-1) + * [beforeerror](#beforeerror) + * [Example](#example-2) + * [error](#error) + * [Example](#example-3) * [Usage](#usage) * [Adding](#adding) - * [Example](#example-2) + * [Example](#example-4) * [Adding Once](#adding-once) - * [Example](#example-3) + * [Example](#example-5) * [Getting](#getting) - * [Example](#example-4) + * [Example](#example-6) * [Removing](#removing) - * [Example](#example-5) + * [Example](#example-7) ## Current Hooks @@ -99,6 +103,53 @@ videojs.hook('setup', function(player) { videojs('some-id', {autoplay: true, controls: true}); ``` +### beforeerror + +`beforeerror` occurs just as we get an error on the player. This allows plugins or other custom code to intercept the error and modify it to be something else. +`error` can be [one of multiple things](https://docs.videojs.com/mediaerror#MediaError), most commonly an object with a `code` property or `null` which means that the current error should be cleared. + +`beforeerror` hook functions: + +* Take two arguments: + 1. The `player` that the error is happening on. + 1. The `error` object that was passed in. +* Return an error object that should replace the error + +#### Example + +```js +videojs.hook('beforeerror', function(player, err) { + const error = player.error(); + + // prevent current error from being cleared out + if (err === null) { + return error; + } + + // but allow changing to a new error + return err; +}); +``` + +### error + +`error` occurs after the player has errored out, after `beforeerror` has allowed updating the error, and after an `error` event has been triggered on the player in question. It is purely an informative event which allows you to get all errors from all players. + +`error` hook functions: + +* Take two arguments: + 1. `player`: the player that the error occurred on + 1. `error`: the Error object that was resolved with the `beforeerror` hooks +* Don't have to return anything + +#### Example + +```js +videojs.hook('error', function(player, err) { + console.log(`player ${player.id()} has errored out with code ${err.code} ${err.message}`); +}); +``` + ## Usage ### Adding diff --git a/src/js/player.js b/src/js/player.js index 2a1c0285d1..8804af0921 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -33,6 +33,8 @@ import * as middleware from './tech/middleware.js'; import {ALL as TRACK_TYPES} from './tracks/track-types'; import filterSource from './utils/filter-source'; import {getMimetype, findMimetype} from './utils/mimetypes'; +import {hooks} from './utils/hooks'; +import {isObject} from './utils/obj'; import keycode from 'keycode'; // The following imports are used only to ensure that the corresponding modules @@ -3949,6 +3951,23 @@ class Player extends Component { return this.error_ || null; } + // allow hooks to modify error object + hooks('beforeerror').forEach((hookFunction) => { + const newErr = hookFunction(this, err); + + if (!( + (isObject(newErr) && !Array.isArray(newErr)) || + typeof newErr === 'string' || + typeof newErr === 'number' || + newErr === null + )) { + this.log.error('please return a value that MediaError expects in beforeerror hooks'); + return; + } + + err = newErr; + }); + // Suppress the first error message for no compatible source until // user interaction if (this.options_.suppressNotSupportedError && @@ -3991,6 +4010,9 @@ class Player extends Component { */ this.trigger('error'); + // notify hooks of the per player error + hooks('error').forEach((hookFunction) => hookFunction(this, this.error_)); + return; } diff --git a/src/js/utils/hooks.js b/src/js/utils/hooks.js new file mode 100644 index 0000000000..2ea26cbb4c --- /dev/null +++ b/src/js/utils/hooks.js @@ -0,0 +1,93 @@ +/** + * An Object that contains lifecycle hooks as keys which point to an array + * of functions that are run when a lifecycle is triggered + * + * @private + */ +const hooks_ = {}; + +/** + * Get a list of hooks for a specific lifecycle + * + * @param {string} type + * the lifecyle to get hooks from + * + * @param {Function|Function[]} [fn] + * Optionally add a hook (or hooks) to the lifecycle that your are getting. + * + * @return {Array} + * an array of hooks, or an empty array if there are none. + */ +const hooks = function(type, fn) { + hooks_[type] = hooks_[type] || []; + if (fn) { + hooks_[type] = hooks_[type].concat(fn); + } + return hooks_[type]; +}; + +/** + * Add a function hook to a specific videojs lifecycle. + * + * @param {string} type + * the lifecycle to hook the function to. + * + * @param {Function|Function[]} + * The function or array of functions to attach. + */ +const hook = function(type, fn) { + hooks(type, fn); +}; + +/** + * Remove a hook from a specific videojs lifecycle. + * + * @param {string} type + * the lifecycle that the function hooked to + * + * @param {Function} fn + * The hooked function to remove + * + * @return {boolean} + * The function that was removed or undef + */ +const removeHook = function(type, fn) { + const index = hooks(type).indexOf(fn); + + if (index <= -1) { + return false; + } + + hooks_[type] = hooks_[type].slice(); + hooks_[type].splice(index, 1); + + return true; +}; + +/** + * Add a function hook that will only run once to a specific videojs lifecycle. + * + * @param {string} type + * the lifecycle to hook the function to. + * + * @param {Function|Function[]} + * The function or array of functions to attach. + */ +const hookOnce = function(type, fn) { + hooks(type, [].concat(fn).map(original => { + const wrapper = (...args) => { + removeHook(type, wrapper); + return original(...args); + }; + + return wrapper; + })); +}; + +export { + hooks_, + hooks, + hook, + hookOnce, + removeHook +}; diff --git a/src/js/video.js b/src/js/video.js index 704075c25f..dec6ccf2ce 100644 --- a/src/js/video.js +++ b/src/js/video.js @@ -4,6 +4,13 @@ */ import {version} from '../../package.json'; import window from 'global/window'; +import { + hooks_, + hooks, + hook, + hookOnce, + removeHook +} from './utils/hooks'; import * as setup from './setup'; import * as stylesheet from './utils/stylesheet.js'; import Component from './component'; @@ -155,7 +162,7 @@ function videojs(id, options, ready) { options = options || {}; - videojs.hooks('beforesetup').forEach((hookFunction) => { + hooks('beforesetup').forEach((hookFunction) => { const opts = hookFunction(el, mergeOptions(options)); if (!isObject(opts) || Array.isArray(opts)) { @@ -172,96 +179,16 @@ function videojs(id, options, ready) { player = new PlayerComponent(el, options, ready); - videojs.hooks('setup').forEach((hookFunction) => hookFunction(player)); + hooks('setup').forEach((hookFunction) => hookFunction(player)); return player; } -/** - * An Object that contains lifecycle hooks as keys which point to an array - * of functions that are run when a lifecycle is triggered - * - * @private - */ -videojs.hooks_ = {}; - -/** - * Get a list of hooks for a specific lifecycle - * - * @param {string} type - * the lifecyle to get hooks from - * - * @param {Function|Function[]} [fn] - * Optionally add a hook (or hooks) to the lifecycle that your are getting. - * - * @return {Array} - * an array of hooks, or an empty array if there are none. - */ -videojs.hooks = function(type, fn) { - videojs.hooks_[type] = videojs.hooks_[type] || []; - if (fn) { - videojs.hooks_[type] = videojs.hooks_[type].concat(fn); - } - return videojs.hooks_[type]; -}; - -/** - * Add a function hook to a specific videojs lifecycle. - * - * @param {string} type - * the lifecycle to hook the function to. - * - * @param {Function|Function[]} - * The function or array of functions to attach. - */ -videojs.hook = function(type, fn) { - videojs.hooks(type, fn); -}; - -/** - * Add a function hook that will only run once to a specific videojs lifecycle. - * - * @param {string} type - * the lifecycle to hook the function to. - * - * @param {Function|Function[]} - * The function or array of functions to attach. - */ -videojs.hookOnce = function(type, fn) { - videojs.hooks(type, [].concat(fn).map(original => { - const wrapper = (...args) => { - videojs.removeHook(type, wrapper); - return original(...args); - }; - - return wrapper; - })); -}; - -/** - * Remove a hook from a specific videojs lifecycle. - * - * @param {string} type - * the lifecycle that the function hooked to - * - * @param {Function} fn - * The hooked function to remove - * - * @return {boolean} - * The function that was removed or undef - */ -videojs.removeHook = function(type, fn) { - const index = videojs.hooks(type).indexOf(fn); - - if (index <= -1) { - return false; - } - - videojs.hooks_[type] = videojs.hooks_[type].slice(); - videojs.hooks_[type].splice(index, 1); - - return true; -}; +videojs.hooks_ = hooks_; +videojs.hooks = hooks; +videojs.hook = hook; +videojs.hookOnce = hookOnce; +videojs.removeHook = removeHook; // Add default styles if (window.VIDEOJS_NO_DYNAMIC_STYLE !== true && Dom.isReal()) { diff --git a/test/unit/player.test.js b/test/unit/player.test.js index 1173af82b1..fd5a120da4 100644 --- a/test/unit/player.test.js +++ b/test/unit/player.test.js @@ -1181,6 +1181,105 @@ QUnit.test('player should handle different error types', function(assert) { player.dispose(); }); +QUnit.test('beforeerror hook allows us to modify errors', function(assert) { + const player = TestHelpers.makePlayer({}); + const beforeerrorHook = function(p, err) { + assert.equal(player, p, 'the players match'); + assert.equal(err.code, 4, 'we got code 4 in beforeerror hook'); + return { code: 1 }; + }; + const errorHook = function(p, err) { + assert.equal(player, p, 'the players match'); + assert.equal(err.code, 1, 'we got code 1 in error hook'); + }; + + videojs.hook('beforeerror', beforeerrorHook); + videojs.hook('error', errorHook); + + player.error({code: 4}); + + player.dispose(); + videojs.removeHook('beforeerror', beforeerrorHook); + videojs.removeHook('error', errorHook); +}); + +QUnit.test('beforeerror hook logs a warning if the incorrect type is returned', function(assert) { + const player = TestHelpers.makePlayer({}); + const stub = sinon.stub(player.log, 'error'); + let errorReturnValue; + + const beforeerrorHook = function(p, err) { + return errorReturnValue; + }; + + videojs.hook('beforeerror', beforeerrorHook); + + stub.reset(); + errorReturnValue = {code: 4}; + player.error({code: 4}); + assert.ok(stub.notCalled, '{code: 4} is supported'); + + stub.reset(); + errorReturnValue = 1; + player.error({code: 4}); + assert.ok(stub.notCalled, 'number is supported'); + + stub.reset(); + errorReturnValue = null; + player.error({code: 4}); + assert.ok(stub.notCalled, 'null is supported'); + + stub.reset(); + errorReturnValue = 'hello'; + player.error({code: 4}); + assert.ok(stub.notCalled, 'string is supported'); + + stub.reset(); + errorReturnValue = new Error('hello'); + player.error({code: 4}); + assert.ok(stub.notCalled, 'Error object is supported'); + + stub.reset(); + errorReturnValue = [1, 2, 3]; + player.error({code: 4}); + assert.ok(stub.called, 'array is not supported'); + + stub.reset(); + errorReturnValue = undefined; + player.error({code: 4}); + assert.ok(stub.called, 'undefined is not supported'); + + stub.reset(); + errorReturnValue = true; + player.error({code: 4}); + assert.ok(stub.called, 'booleans are not supported'); + + videojs.removeHook('beforeerror', beforeerrorHook); + player.dispose(); +}); + +QUnit.test('player should trigger error related hooks', function(assert) { + const player = TestHelpers.makePlayer({}); + const beforeerrorHook = function(p, err) { + assert.equal(player, p, 'the players match'); + assert.equal(err.code, 4, 'we got code 4 in beforeerror hook'); + return err; + }; + const errorHook = function(p, err) { + assert.equal(player, p, 'the players match'); + assert.equal(err.code, 4, 'we got code 4 in error hook'); + }; + + videojs.hook('beforeerror', beforeerrorHook); + videojs.hook('error', errorHook); + + player.error({code: 4}); + + player.dispose(); + videojs.removeHook('beforeerror', beforeerrorHook); + videojs.removeHook('error', errorHook); +}); + QUnit.test('Data attributes on the video element should persist in the new wrapper element', function(assert) { const dataId = 123; diff --git a/test/unit/videojs-hooks.test.js b/test/unit/videojs-hooks.test.js index 35ab7780c8..b58c504a85 100644 --- a/test/unit/videojs-hooks.test.js +++ b/test/unit/videojs-hooks.test.js @@ -4,12 +4,14 @@ import document from 'global/document'; import sinon from 'sinon'; import log from '../../src/js/utils/log.js'; +const clearObj = (obj) => Object.keys(obj).forEach((key) => delete obj[key]); + QUnit.module('video.js:hooks ', { beforeEach() { - videojs.hooks_ = {}; + clearObj(videojs.hooks_); }, afterEach() { - videojs.hooks_ = {}; + clearObj(videojs.hooks_); } });