/** * Player Component - Base class for all UI objects * * @file component.js */ import document from 'global/document'; import window from 'global/window'; import evented from './mixins/evented'; import stateful from './mixins/stateful'; import * as Dom from './utils/dom.js'; import * as Fn from './utils/fn.js'; import * as Guid from './utils/guid.js'; import {toTitleCase, toLowerCase} from './utils/str.js'; import {merge} from './utils/obj.js'; /** @import Player from './player' */ /** * A callback to be called if and when the component is ready. * `this` will be the Component instance. * * @callback ReadyCallback * @returns {void} */ /** * Base class for all UI Components. * Components are UI objects which represent both a javascript object and an element * in the DOM. They can be children of other components, and can have * children themselves. * * Components can also use methods from {@link EventTarget} */ class Component { /** * Creates an instance of this class. * * @param {Player} player * The `Player` that this class should be attached to. * * @param {Object} [options] * The key/value store of component options. * * @param {Object[]} [options.children] * An array of children objects to initialize this component with. Children objects have * a name property that will be used if more than one component of the same type needs to be * added. * * @param {string} [options.className] * A class or space separated list of classes to add the component * * @param {ReadyCallback} [ready] * Function that gets called when the `Component` is ready. */ constructor(player, options, ready) { // The component might be the player itself and we can't pass `this` to super if (!player && this.play) { this.player_ = player = this; // eslint-disable-line } else { this.player_ = player; } this.isDisposed_ = false; // Hold the reference to the parent component via `addChild` method this.parentComponent_ = null; // Make a copy of prototype.options_ to protect against overriding defaults this.options_ = merge({}, this.options_); // Updated options with supplied options options = this.options_ = merge(this.options_, options); // Get ID from options or options element if one is supplied this.id_ = options.id || (options.el && options.el.id); // If there was no ID from the options, generate one if (!this.id_) { // Don't require the player ID function in the case of mock players const id = player && player.id && player.id() || 'no_player'; this.id_ = `${id}_component_${Guid.newGUID()}`; } this.name_ = options.name || null; // Create element if one wasn't provided in options if (options.el) { this.el_ = options.el; } else if (options.createEl !== false) { this.el_ = this.createEl(); } if (options.className && this.el_) { options.className.split(' ').forEach(c => this.addClass(c)); } // Remove the placeholder event methods. If the component is evented, the // real methods are added next ['on', 'off', 'one', 'any', 'trigger'].forEach(fn => { this[fn] = undefined; }); // if evented is anything except false, we want to mixin in evented if (options.evented !== false) { // Make this an evented object and use `el_`, if available, as its event bus evented(this, {eventBusKey: this.el_ ? 'el_' : null}); this.handleLanguagechange = this.handleLanguagechange.bind(this); this.on(this.player_, 'languagechange', this.handleLanguagechange); } stateful(this, this.constructor.defaultState); this.children_ = []; this.childIndex_ = {}; this.childNameIndex_ = {}; this.setTimeoutIds_ = new Set(); this.setIntervalIds_ = new Set(); this.rafIds_ = new Set(); this.namedRafs_ = new Map(); this.clearingTimersOnDispose_ = false; // Add any child components in options if (options.initChildren !== false) { this.initChildren(); } // Don't want to trigger ready here or it will go before init is actually // finished for all children that run this constructor this.ready(ready); if (options.reportTouchActivity !== false) { this.enableTouchActivity(); } } // `on`, `off`, `one`, `any` and `trigger` are here so tsc includes them in definitions. // They are replaced or removed in the constructor /** * Adds an `event listener` to an instance of an `EventTarget`. An `event listener` is a * function that will get called when an event with a certain name gets triggered. * * @param {string|string[]} type * An event name or an array of event names. * * @param {Function} fn * The function to call with `EventTarget`s */ /* start-delete-from-build */ on(type, fn) {} /* end-delete-from-build */ /** * Removes an `event listener` for a specific event from an instance of `EventTarget`. * This makes it so that the `event listener` will no longer get called when the * named event happens. * * @param {string|string[]} type * An event name or an array of event names. * * @param {Function} [fn] * The function to remove. If not specified, all listeners managed by Video.js will be removed. */ /* start-delete-from-build */ off(type, fn) {} /* end-delete-from-build */ /** * This function will add an `event listener` that gets triggered only once. After the * first trigger it will get removed. This is like adding an `event listener` * with {@link EventTarget#on} that calls {@link EventTarget#off} on itself. * * @param {string|string[]} type * An event name or an array of event names. * * @param {Function} fn * The function to be called once for each event name. */ /* start-delete-from-build */ one(type, fn) {} /* end-delete-from-build */ /** * This function will add an `event listener` that gets triggered only once and is * removed from all events. This is like adding an array of `event listener`s * with {@link EventTarget#on} that calls {@link EventTarget#off} on all events the * first time it is triggered. * * @param {string|string[]} type * An event name or an array of event names. * * @param {Function} fn * The function to be called once for each event name. */ /* start-delete-from-build */ any(type, fn) {} /* end-delete-from-build */ /** * This function causes an event to happen. This will then cause any `event listeners` * that are waiting for that event, to get called. If there are no `event listeners` * for an event then nothing will happen. * * If the name of the `Event` that is being triggered is in `EventTarget.allowedEvents_`. * Trigger will also call the `on` + `uppercaseEventName` function. * * Example: * 'click' is in `EventTarget.allowedEvents_`, so, trigger will attempt to call * `onClick` if it exists. * * @param {string|Event|Object} event * The name of the event, an `Event`, or an object with a key of type set to * an event name. * * @param {Object} [hash] * Optionally extra argument to pass through to an event listener */ /* start-delete-from-build */ trigger(event, hash) {} /* end-delete-from-build */ /** * Dispose of the `Component` and all child components. * * @fires Component#dispose * * @param {Object} options * @param {Element} options.originalEl element with which to replace player element */ dispose(options = {}) { // Bail out if the component has already been disposed. if (this.isDisposed_) { return; } if (this.readyQueue_) { this.readyQueue_.length = 0; } /** * Triggered when a `Component` is disposed. * * @event Component#dispose * @type {Event} * * @property {boolean} [bubbles=false] * set to false so that the dispose event does not * bubble up */ this.trigger({type: 'dispose', bubbles: false}); this.isDisposed_ = true; // Dispose all children. if (this.children_) { for (let i = this.children_.length - 1; i >= 0; i--) { if (this.children_[i].dispose) { this.children_[i].dispose(); } } } // Delete child references this.children_ = null; this.childIndex_ = null; this.childNameIndex_ = null; this.parentComponent_ = null; if (this.el_) { // Remove element from DOM if (this.el_.parentNode) { if (options.restoreEl) { this.el_.parentNode.replaceChild(options.restoreEl, this.el_); } else { this.el_.parentNode.removeChild(this.el_); } } this.el_ = null; } // remove reference to the player after disposing of the element this.player_ = null; } /** * Determine whether or not this component has been disposed. * * @return {boolean} * If the component has been disposed, will be `true`. Otherwise, `false`. */ isDisposed() { return Boolean(this.isDisposed_); } /** * Return the {@link Player} that the `Component` has attached to. * * @return {Player} * The player that this `Component` has attached to. */ player() { return this.player_; } /** * Deep merge of options objects with new options. * > Note: When both `obj` and `options` contain properties whose values are objects. * The two properties get merged using {@link module:obj.merge} * * @param {Object} obj * The object that contains new options. * * @return {Object} * A new object of `this.options_` and `obj` merged together. */ options(obj) { if (!obj) { return this.options_; } this.options_ = merge(this.options_, obj); return this.options_; } /** * Get the `Component`s DOM element * * @return {Element} * The DOM element for this `Component`. */ el() { return this.el_; } /** * Create the `Component`s DOM element. * * @param {string} [tagName] * Element's DOM node type. e.g. 'div' * * @param {Object} [properties] * An object of properties that should be set. * * @param {Object} [attributes] * An object of attributes that should be set. * * @return {Element} * The element that gets created. */ createEl(tagName, properties, attributes) { return Dom.createEl(tagName, properties, attributes); } /** * Localize a string given the string in english. * * If tokens are provided, it'll try and run a simple token replacement on the provided string. * The tokens it looks for look like `{1}` with the index being 1-indexed into the tokens array. * * If a `defaultValue` is provided, it'll use that over `string`, * if a value isn't found in provided language files. * This is useful if you want to have a descriptive key for token replacement * but have a succinct localized string and not require `en.json` to be included. * * Currently, it is used for the progress bar timing. * ```js * { * "progress bar timing: currentTime={1} duration={2}": "{1} of {2}" * } * ``` * It is then used like so: * ```js * this.localize('progress bar timing: currentTime={1} duration{2}', * [this.player_.currentTime(), this.player_.duration()], * '{1} of {2}'); * ``` * * Which outputs something like: `01:23 of 24:56`. * * * @param {string} string * The string to localize and the key to lookup in the language files. * @param {string[]} [tokens] * If the current item has token replacements, provide the tokens here. * @param {string} [defaultValue] * Defaults to `string`. Can be a default value to use for token replacement * if the lookup key is needed to be separate. * * @return {string} * The localized string or if no localization exists the english string. */ localize(string, tokens, defaultValue = string) { const code = this.player_.language && this.player_.language(); const languages = this.player_.languages && this.player_.languages(); const language = languages && languages[code]; const primaryCode = code && code.split('-')[0]; const primaryLang = languages && languages[primaryCode]; let localizedString = defaultValue; if (language && language[string]) { localizedString = language[string]; } else if (primaryLang && primaryLang[string]) { localizedString = primaryLang[string]; } if (tokens) { localizedString = localizedString.replace(/\{(\d+)\}/g, function(match, index) { const value = tokens[index - 1]; let ret = value; if (typeof value === 'undefined') { ret = match; } return ret; }); } return localizedString; } /** * Handles language change for the player in components. Should be overridden by sub-components. * * @abstract */ handleLanguagechange() {} /** * Return the `Component`s DOM element. This is where children get inserted. * This will usually be the the same as the element returned in {@link Component#el}. * * @return {Element} * The content element for this `Component`. */ contentEl() { return this.contentEl_ || this.el_; } /** * Get this `Component`s ID * * @return {string} * The id of this `Component` */ id() { return this.id_; } /** * Get the `Component`s name. The name gets used to reference the `Component` * and is set during registration. * * @return {string} * The name of this `Component`. */ name() { return this.name_; } /** * Get an array of all child components * * @return {Array} * The children */ children() { return this.children_; } /** * Returns the child `Component` with the given `id`. * * @param {string} id * The id of the child `Component` to get. * * @return {Component|undefined} * The child `Component` with the given `id` or undefined. */ getChildById(id) { return this.childIndex_[id]; } /** * Returns the child `Component` with the given `name`. * * @param {string} name * The name of the child `Component` to get. * * @return {Component|undefined} * The child `Component` with the given `name` or undefined. */ getChild(name) { if (!name) { return; } return this.childNameIndex_[name]; } /** * Returns the descendant `Component` following the givent * descendant `names`. For instance ['foo', 'bar', 'baz'] would * try to get 'foo' on the current component, 'bar' on the 'foo' * component and 'baz' on the 'bar' component and return undefined * if any of those don't exist. * * @param {...string[]|...string} names * The name of the child `Component` to get. * * @return {Component|undefined} * The descendant `Component` following the given descendant * `names` or undefined. */ getDescendant(...names) { // flatten array argument into the main array names = names.reduce((acc, n) => acc.concat(n), []); let currentChild = this; for (let i = 0; i < names.length; i++) { currentChild = currentChild.getChild(names[i]); if (!currentChild || !currentChild.getChild) { return; } } return currentChild; } /** * Adds an SVG icon element to another element or component. * * @param {string} iconName * The name of icon. A list of all the icon names can be found at 'sandbox/svg-icons.html' * * @param {Element} [el=this.el()] * Element to set the title on. Defaults to the current Component's element. * * @return {Element} * The newly created icon element. */ setIcon(iconName, el = this.el()) { // TODO: In v9 of video.js, we will want to remove font icons entirely. // This means this check, as well as the others throughout the code, and // the unecessary CSS for font icons, will need to be removed. // See https://github.com/videojs/video.js/pull/8260 as to which components // need updating. if (!this.player_.options_.experimentalSvgIcons) { return; } const xmlnsURL = 'http://www.w3.org/2000/svg'; // The below creates an element in the format of: // .... const iconContainer = Dom.createEl('span', { className: 'vjs-icon-placeholder vjs-svg-icon' }, {'aria-hidden': 'true'}); const svgEl = document.createElementNS(xmlnsURL, 'svg'); svgEl.setAttributeNS(null, 'viewBox', '0 0 512 512'); const useEl = document.createElementNS(xmlnsURL, 'use'); svgEl.appendChild(useEl); useEl.setAttributeNS(null, 'href', `#vjs-icon-${iconName}`); iconContainer.appendChild(svgEl); // Replace a pre-existing icon if one exists. if (this.iconIsSet_) { el.replaceChild(iconContainer, el.querySelector('.vjs-icon-placeholder')); } else { el.appendChild(iconContainer); } this.iconIsSet_ = true; return iconContainer; } /** * Add a child `Component` inside the current `Component`. * * @param {string|Component} child * The name or instance of a child to add. * * @param {Object} [options={}] * The key/value store of options that will get passed to children of * the child. * * @param {number} [index=this.children_.length] * The index to attempt to add a child into. * * * @return {Component} * The `Component` that gets added as a child. When using a string the * `Component` will get created by this process. */ addChild(child, options = {}, index = this.children_.length) { let component; let componentName; // If child is a string, create component with options if (typeof child === 'string') { componentName = toTitleCase(child); const componentClassName = options.componentClass || componentName; // Set name through options options.name = componentName; // Create a new object & element for this controls set // If there's no .player_, this is a player const ComponentClass = Component.getComponent(componentClassName); if (!ComponentClass) { throw new Error(`Component ${componentClassName} does not exist`); } // data stored directly on the videojs object may be // misidentified as a component to retain // backwards-compatibility with 4.x. check to make sure the // component class can be instantiated. if (typeof ComponentClass !== 'function') { return null; } component = new ComponentClass(this.player_ || this, options); // child is a component instance } else { component = child; } if (component.parentComponent_) { component.parentComponent_.removeChild(component); } this.children_.splice(index, 0, component); component.parentComponent_ = this; if (typeof component.id === 'function') { this.childIndex_[component.id()] = component; } // If a name wasn't used to create the component, check if we can use the // name function of the component componentName = componentName || (component.name && toTitleCase(component.name())); if (componentName) { this.childNameIndex_[componentName] = component; this.childNameIndex_[toLowerCase(componentName)] = component; } // Add the UI object's element to the container div (box) // Having an element is not required if (typeof component.el === 'function' && component.el()) { // If inserting before a component, insert before that component's element let refNode = null; if (this.children_[index + 1]) { // Most children are components, but the video tech is an HTML element if (this.children_[index + 1].el_) { refNode = this.children_[index + 1].el_; } else if (Dom.isEl(this.children_[index + 1])) { refNode = this.children_[index + 1]; } } this.contentEl().insertBefore(component.el(), refNode); } // Return so it can stored on parent object if desired. return component; } /** * Remove a child `Component` from this `Component`s list of children. Also removes * the child `Component`s element from this `Component`s element. * * @param {Component} component * The child `Component` to remove. */ removeChild(component) { if (typeof component === 'string') { component = this.getChild(component); } if (!component || !this.children_) { return; } let childFound = false; for (let i = this.children_.length - 1; i >= 0; i--) { if (this.children_[i] === component) { childFound = true; this.children_.splice(i, 1); break; } } if (!childFound) { return; } component.parentComponent_ = null; this.childIndex_[component.id()] = null; this.childNameIndex_[toTitleCase(component.name())] = null; this.childNameIndex_[toLowerCase(component.name())] = null; const compEl = component.el(); if (compEl && compEl.parentNode === this.contentEl()) { this.contentEl().removeChild(component.el()); } } /** * Add and initialize default child `Component`s based upon options. */ initChildren() { const children = this.options_.children; if (children) { // `this` is `parent` const parentOptions = this.options_; const handleAdd = (child) => { const name = child.name; let opts = child.opts; // Allow options for children to be set at the parent options // e.g. videojs(id, { controlBar: false }); // instead of videojs(id, { children: { controlBar: false }); if (parentOptions[name] !== undefined) { opts = parentOptions[name]; } // Allow for disabling default components // e.g. options['children']['posterImage'] = false if (opts === false) { return; } // Allow options to be passed as a simple boolean if no configuration // is necessary. if (opts === true) { opts = {}; } // We also want to pass the original player options // to each component as well so they don't need to // reach back into the player for options later. opts.playerOptions = this.options_.playerOptions; // Create and add the child component. // Add a direct reference to the child by name on the parent instance. // If two of the same component are used, different names should be supplied // for each const newChild = this.addChild(name, opts); if (newChild) { this[name] = newChild; } }; // Allow for an array of children details to passed in the options let workingChildren; const Tech = Component.getComponent('Tech'); if (Array.isArray(children)) { workingChildren = children; } else { workingChildren = Object.keys(children); } workingChildren // children that are in this.options_ but also in workingChildren would // give us extra children we do not want. So, we want to filter them out. .concat(Object.keys(this.options_) .filter(function(child) { return !workingChildren.some(function(wchild) { if (typeof wchild === 'string') { return child === wchild; } return child === wchild.name; }); })) .map((child) => { let name; let opts; if (typeof child === 'string') { name = child; opts = children[name] || this.options_[name] || {}; } else { name = child.name; opts = child; } return {name, opts}; }) .filter((child) => { // we have to make sure that child.name isn't in the techOrder since // techs are registered as Components but can't aren't compatible // See https://github.com/videojs/video.js/issues/2772 const c = Component.getComponent(child.opts.componentClass || toTitleCase(child.name)); return c && !Tech.isTech(c); }) .forEach(handleAdd); } } /** * Builds the default DOM class name. Should be overridden by sub-components. * * @return {string} * The DOM class name for this object. * * @abstract */ buildCSSClass() { // Child classes can include a function that does: // return 'CLASS NAME' + this._super(); return ''; } /** * 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. * * @param {ReadyCallback} fn * Function that gets called when the `Component` is ready. */ ready(fn, sync = false) { if (!fn) { return; } if (!this.isReady_) { this.readyQueue_ = this.readyQueue_ || []; this.readyQueue_.push(fn); return; } if (sync) { fn.call(this); } else { // Call the function asynchronously by default for consistency this.setTimeout(fn, 1); } } /** * Trigger all the ready listeners for this `Component`. * * @fires Component#ready */ triggerReady() { this.isReady_ = true; // Ensure ready is triggered asynchronously this.setTimeout(function() { const readyQueue = this.readyQueue_; // Reset Ready Queue this.readyQueue_ = []; if (readyQueue && readyQueue.length > 0) { readyQueue.forEach(function(fn) { fn.call(this); }, this); } // Allow for using event listeners also /** * Triggered when a `Component` is ready. * * @event Component#ready * @type {Event} */ this.trigger('ready'); }, 1); } /** * Find a single DOM element matching a `selector`. This can be within the `Component`s * `contentEl()` or another custom context. * * @param {string} selector * A valid CSS selector, which will be passed to `querySelector`. * * @param {Element|string} [context=this.contentEl()] * A DOM element within which to query. Can also be a selector string in * which case the first matching element will get used as context. If * missing `this.contentEl()` gets used. If `this.contentEl()` returns * nothing it falls back to `document`. * * @return {Element|null} * the dom element that was found, or null * * @see [Information on CSS Selectors](https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_Started/Selectors) */ $(selector, context) { return Dom.$(selector, context || this.contentEl()); } /** * Finds all DOM element matching a `selector`. This can be within the `Component`s * `contentEl()` or another custom context. * * @param {string} selector * A valid CSS selector, which will be passed to `querySelectorAll`. * * @param {Element|string} [context=this.contentEl()] * A DOM element within which to query. Can also be a selector string in * which case the first matching element will get used as context. If * missing `this.contentEl()` gets used. If `this.contentEl()` returns * nothing it falls back to `document`. * * @return {NodeList} * a list of dom elements that were found * * @see [Information on CSS Selectors](https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_Started/Selectors) */ $$(selector, context) { return Dom.$$(selector, context || this.contentEl()); } /** * Check if a component's element has a CSS class name. * * @param {string} classToCheck * CSS class name to check. * * @return {boolean} * - True if the `Component` has the class. * - False if the `Component` does not have the class` */ hasClass(classToCheck) { return Dom.hasClass(this.el_, classToCheck); } /** * Add a CSS class name to the `Component`s element. * * @param {...string} classesToAdd * One or more CSS class name to add. */ addClass(...classesToAdd) { Dom.addClass(this.el_, ...classesToAdd); } /** * Remove a CSS class name from the `Component`s element. * * @param {...string} classesToRemove * One or more CSS class name to remove. */ removeClass(...classesToRemove) { Dom.removeClass(this.el_, ...classesToRemove); } /** * Add or remove a CSS class name from the component's element. * - `classToToggle` gets added when {@link Component#hasClass} would return false. * - `classToToggle` gets removed when {@link Component#hasClass} would return true. * * @param {string} classToToggle * The class to add or remove. Passed to DOMTokenList's toggle() * * @param {boolean|Dom.PredicateCallback} [predicate] * A boolean or function that returns a boolean. Passed to DOMTokenList's toggle(). */ toggleClass(classToToggle, predicate) { Dom.toggleClass(this.el_, classToToggle, predicate); } /** * Show the `Component`s element if it is hidden by removing the * 'vjs-hidden' class name from it. */ show() { this.removeClass('vjs-hidden'); } /** * Hide the `Component`s element if it is currently showing by adding the * 'vjs-hidden` class name to it. */ hide() { this.addClass('vjs-hidden'); } /** * Lock a `Component`s element in its visible state by adding the 'vjs-lock-showing' * class name to it. Used during fadeIn/fadeOut. * * @private */ lockShowing() { this.addClass('vjs-lock-showing'); } /** * Unlock a `Component`s element from its visible state by removing the 'vjs-lock-showing' * class name from it. Used during fadeIn/fadeOut. * * @private */ unlockShowing() { this.removeClass('vjs-lock-showing'); } /** * Get the value of an attribute on the `Component`s element. * * @param {string} attribute * Name of the attribute to get the value from. * * @return {string|null} * - The value of the attribute that was asked for. * - Can be an empty string on some browsers if the attribute does not exist * or has no value * - Most browsers will return null if the attribute does not exist or has * no value. * * @see [DOM API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Element/getAttribute} */ getAttribute(attribute) { return Dom.getAttribute(this.el_, attribute); } /** * Set the value of an attribute on the `Component`'s element * * @param {string} attribute * Name of the attribute to set. * * @param {string} value * Value to set the attribute to. * * @see [DOM API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute} */ setAttribute(attribute, value) { Dom.setAttribute(this.el_, attribute, value); } /** * Remove an attribute from the `Component`s element. * * @param {string} attribute * Name of the attribute to remove. * * @see [DOM API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Element/removeAttribute} */ removeAttribute(attribute) { Dom.removeAttribute(this.el_, attribute); } /** * Get or set the width of the component based upon the CSS styles. * See {@link Component#dimension} for more detailed information. * * @param {number|string} [num] * The width that you want to set postfixed with '%', 'px' or nothing. * * @param {boolean} [skipListeners] * Skip the componentresize event trigger * * @return {number|undefined} * The width when getting, zero if there is no width */ width(num, skipListeners) { return this.dimension('width', num, skipListeners); } /** * Get or set the height of the component based upon the CSS styles. * See {@link Component#dimension} for more detailed information. * * @param {number|string} [num] * The height that you want to set postfixed with '%', 'px' or nothing. * * @param {boolean} [skipListeners] * Skip the componentresize event trigger * * @return {number|undefined} * The height when getting, zero if there is no height */ height(num, skipListeners) { return this.dimension('height', num, skipListeners); } /** * Set both the width and height of the `Component` element at the same time. * * @param {number|string} width * Width to set the `Component`s element to. * * @param {number|string} height * Height to set the `Component`s element to. */ dimensions(width, height) { // Skip componentresize listeners on width for optimization this.width(width, true); this.height(height); } /** * Get or set width or height of the `Component` element. This is the shared code * for the {@link Component#width} and {@link Component#height}. * * Things to know: * - If the width or height in an number this will return the number postfixed with 'px'. * - If the width/height is a percent this will return the percent postfixed with '%' * - Hidden elements have a width of 0 with `window.getComputedStyle`. This function * defaults to the `Component`s `style.width` and falls back to `window.getComputedStyle`. * See [this]{@link http://www.foliotek.com/devblog/getting-the-width-of-a-hidden-element-with-jquery-using-width/} * for more information * - If you want the computed style of the component, use {@link Component#currentWidth} * and {@link {Component#currentHeight} * * @fires Component#componentresize * * @param {string} widthOrHeight 8 'width' or 'height' * * @param {number|string} [num] 8 New dimension * * @param {boolean} [skipListeners] * Skip componentresize event trigger * * @return {number|undefined} * The dimension when getting or 0 if unset */ dimension(widthOrHeight, num, skipListeners) { if (num !== undefined) { // Set to zero if null or literally NaN (NaN !== NaN) if (num === null || num !== num) { num = 0; } // Check if using css width/height (% or px) and adjust if (('' + num).indexOf('%') !== -1 || ('' + num).indexOf('px') !== -1) { this.el_.style[widthOrHeight] = num; } else if (num === 'auto') { this.el_.style[widthOrHeight] = ''; } else { this.el_.style[widthOrHeight] = num + 'px'; } // skipListeners allows us to avoid triggering the resize event when setting both width and height if (!skipListeners) { /** * Triggered when a component is resized. * * @event Component#componentresize * @type {Event} */ this.trigger('componentresize'); } return; } // Not setting a value, so getting it // Make sure element exists if (!this.el_) { return 0; } // Get dimension value from style const val = this.el_.style[widthOrHeight]; const pxIndex = val.indexOf('px'); if (pxIndex !== -1) { // Return the pixel value with no 'px' return parseInt(val.slice(0, pxIndex), 10); } // No px so using % or no style was set, so falling back to offsetWidth/height // If component has display:none, offset will return 0 // TODO: handle display:none and no dimension style using px return parseInt(this.el_['offset' + toTitleCase(widthOrHeight)], 10); } /** * Get the computed width or the height of the component's element. * * Uses `window.getComputedStyle`. * * @param {string} widthOrHeight * A string containing 'width' or 'height'. Whichever one you want to get. * * @return {number} * The dimension that gets asked for or 0 if nothing was set * for that dimension. */ currentDimension(widthOrHeight) { let computedWidthOrHeight = 0; if (widthOrHeight !== 'width' && widthOrHeight !== 'height') { throw new Error('currentDimension only accepts width or height value'); } computedWidthOrHeight = Dom.computedStyle(this.el_, widthOrHeight); // remove 'px' from variable and parse as integer computedWidthOrHeight = parseFloat(computedWidthOrHeight); // if the computed value is still 0, it's possible that the browser is lying // and we want to check the offset values. // This code also runs wherever getComputedStyle doesn't exist. if (computedWidthOrHeight === 0 || isNaN(computedWidthOrHeight)) { const rule = `offset${toTitleCase(widthOrHeight)}`; computedWidthOrHeight = this.el_[rule]; } return computedWidthOrHeight; } /** * An object that contains width and height values of the `Component`s * computed style. Uses `window.getComputedStyle`. * * @typedef {Object} Component~DimensionObject * * @property {number} width * The width of the `Component`s computed style. * * @property {number} height * The height of the `Component`s computed style. */ /** * Get an object that contains computed width and height values of the * component's element. * * Uses `window.getComputedStyle`. * * @return {Component~DimensionObject} * The computed dimensions of the component's element. */ currentDimensions() { return { width: this.currentDimension('width'), height: this.currentDimension('height') }; } /** * Get the computed width of the component's element. * * Uses `window.getComputedStyle`. * * @return {number} * The computed width of the component's element. */ currentWidth() { return this.currentDimension('width'); } /** * Get the computed height of the component's element. * * Uses `window.getComputedStyle`. * * @return {number} * The computed height of the component's element. */ currentHeight() { return this.currentDimension('height'); } /** * Retrieves the position and size information of the component's element. * * @return {Object} An object with `boundingClientRect` and `center` properties. * - `boundingClientRect`: An object with properties `x`, `y`, `width`, * `height`, `top`, `right`, `bottom`, and `left`, representing * the bounding rectangle of the element. * - `center`: An object with properties `x` and `y`, representing * the center point of the element. `width` and `height` are set to 0. */ getPositions() { const rect = this.el_.getBoundingClientRect(); // Creating objects that mirror DOMRectReadOnly for boundingClientRect and center const boundingClientRect = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left }; // Calculating the center position const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, width: 0, height: 0, top: rect.top + rect.height / 2, right: rect.left + rect.width / 2, bottom: rect.top + rect.height / 2, left: rect.left + rect.width / 2 }; return { boundingClientRect, center }; } /** * Set the focus to this component */ focus() { this.el_.focus(); } /** * Remove the focus from this component */ blur() { this.el_.blur(); } /** * When this Component receives a `keydown` event which it does not process, * it passes the event to the Player for handling. * * @param {KeyboardEvent} event * The `keydown` event that caused this function to be called. */ handleKeyDown(event) { if (this.player_) { // We only stop propagation here because we want unhandled events to fall // back to the browser. Exclude Tab for focus trapping, exclude also when spatialNavigation is enabled. if (event.key !== 'Tab' && !(this.player_.options_.playerOptions.spatialNavigation && this.player_.options_.playerOptions.spatialNavigation.enabled)) { event.stopPropagation(); } this.player_.handleKeyDown(event); } } /** * Many components used to have a `handleKeyPress` method, which was poorly * named because it listened to a `keydown` event. This method name now * delegates to `handleKeyDown`. This means anyone calling `handleKeyPress` * will not see their method calls stop working. * * @param {KeyboardEvent} event * The event that caused this function to be called. */ handleKeyPress(event) { this.handleKeyDown(event); } /** * Emit a 'tap' events when touch event support gets detected. This gets used to * support toggling the controls through a tap on the video. They get enabled * because every sub-component would have extra overhead otherwise. * * @protected * @fires Component#tap * @listens Component#touchstart * @listens Component#touchmove * @listens Component#touchleave * @listens Component#touchcancel * @listens Component#touchend */ emitTapEvents() { // Track the start time so we can determine how long the touch lasted let touchStart = 0; let firstTouch = null; // Maximum movement allowed during a touch event to still be considered a tap // Other popular libs use anywhere from 2 (hammer.js) to 15, // so 10 seems like a nice, round number. const tapMovementThreshold = 10; // The maximum length a touch can be while still being considered a tap const touchTimeThreshold = 200; let couldBeTap; this.on('touchstart', function(event) { // If more than one finger, don't consider treating this as a click if (event.touches.length === 1) { // Copy pageX/pageY from the object firstTouch = { pageX: event.touches[0].pageX, pageY: event.touches[0].pageY }; // Record start time so we can detect a tap vs. "touch and hold" touchStart = window.performance.now(); // Reset couldBeTap tracking couldBeTap = true; } }); this.on('touchmove', function(event) { // If more than one finger, don't consider treating this as a click if (event.touches.length > 1) { couldBeTap = false; } else if (firstTouch) { // Some devices will throw touchmoves for all but the slightest of taps. // So, if we moved only a small distance, this could still be a tap const xdiff = event.touches[0].pageX - firstTouch.pageX; const ydiff = event.touches[0].pageY - firstTouch.pageY; const touchDistance = Math.sqrt(xdiff * xdiff + ydiff * ydiff); if (touchDistance > tapMovementThreshold) { couldBeTap = false; } } }); const noTap = function() { couldBeTap = false; }; // TODO: Listen to the original target. http://youtu.be/DujfpXOKUp8?t=13m8s this.on('touchleave', noTap); this.on('touchcancel', noTap); // When the touch ends, measure how long it took and trigger the appropriate // event this.on('touchend', function(event) { firstTouch = null; // Proceed only if the touchmove/leave/cancel event didn't happen if (couldBeTap === true) { // Measure how long the touch lasted const touchTime = window.performance.now() - touchStart; // Make sure the touch was less than the threshold to be considered a tap if (touchTime < touchTimeThreshold) { // Don't let browser turn this into a click event.preventDefault(); /** * Triggered when a `Component` is tapped. * * @event Component#tap * @type {MouseEvent} */ this.trigger('tap'); // It may be good to copy the touchend event object and change the // type to tap, if the other event properties aren't exact after // Events.fixEvent runs (e.g. event.target) } } }); } /** * This function reports user activity whenever touch events happen. This can get * turned off by any sub-components that wants touch events to act another way. * * Report user touch activity when touch events occur. User activity gets used to * determine when controls should show/hide. It is simple when it comes to mouse * events, because any mouse event should show the controls. So we capture mouse * events that bubble up to the player and report activity when that happens. * With touch events it isn't as easy as `touchstart` and `touchend` toggle player * controls. So touch events can't help us at the player level either. * * User activity gets checked asynchronously. So what could happen is a tap event * on the video turns the controls off. Then the `touchend` event bubbles up to * the player. Which, if it reported user activity, would turn the controls right * back on. We also don't want to completely block touch events from bubbling up. * Furthermore a `touchmove` event and anything other than a tap, should not turn * controls back on. * * @listens Component#touchstart * @listens Component#touchmove * @listens Component#touchend * @listens Component#touchcancel */ enableTouchActivity() { // Don't continue if the root player doesn't support reporting user activity if (!this.player() || !this.player().reportUserActivity) { return; } // listener for reporting that the user is active const report = Fn.bind_(this.player(), this.player().reportUserActivity); let touchHolding; this.on('touchstart', function() { report(); // For as long as the they are touching the device or have their mouse down, // we consider them active even if they're not moving their finger or mouse. // So we want to continue to update that they are active this.clearInterval(touchHolding); // report at the same interval as activityCheck touchHolding = this.setInterval(report, 250); }); const touchEnd = function(event) { report(); // stop the interval that maintains activity if the touch is holding this.clearInterval(touchHolding); }; this.on('touchmove', report); this.on('touchend', touchEnd); this.on('touchcancel', touchEnd); } /** * A callback that has no parameters and is bound into `Component`s context. * * @callback Component~GenericCallback * @this Component */ /** * Creates a function that runs after an `x` millisecond timeout. This function is a * wrapper around `window.setTimeout`. There are a few reasons to use this one * instead though: * 1. It gets cleared via {@link Component#clearTimeout} when * {@link Component#dispose} gets called. * 2. The function callback will gets turned into a {@link Component~GenericCallback} * * > Note: You can't use `window.clearTimeout` on the id returned by this function. This * will cause its dispose listener not to get cleaned up! Please use * {@link Component#clearTimeout} or {@link Component#dispose} instead. * * @param {Component~GenericCallback} fn * The function that will be run after `timeout`. * * @param {number} timeout * Timeout in milliseconds to delay before executing the specified function. * * @return {number} * Returns a timeout ID that gets used to identify the timeout. It can also * get used in {@link Component#clearTimeout} to clear the timeout that * was set. * * @listens Component#dispose * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowTimers/setTimeout} */ setTimeout(fn, timeout) { // declare as variables so they are properly available in timeout function // eslint-disable-next-line var timeoutId, disposeFn; fn = Fn.bind_(this, fn); this.clearTimersOnDispose_(); timeoutId = window.setTimeout(() => { if (this.setTimeoutIds_.has(timeoutId)) { this.setTimeoutIds_.delete(timeoutId); } fn(); }, timeout); this.setTimeoutIds_.add(timeoutId); return timeoutId; } /** * Clears a timeout that gets created via `window.setTimeout` or * {@link Component#setTimeout}. If you set a timeout via {@link Component#setTimeout} * use this function instead of `window.clearTimout`. If you don't your dispose * listener will not get cleaned up until {@link Component#dispose}! * * @param {number} timeoutId * The id of the timeout to clear. The return value of * {@link Component#setTimeout} or `window.setTimeout`. * * @return {number} * Returns the timeout id that was cleared. * * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowTimers/clearTimeout} */ clearTimeout(timeoutId) { if (this.setTimeoutIds_.has(timeoutId)) { this.setTimeoutIds_.delete(timeoutId); window.clearTimeout(timeoutId); } return timeoutId; } /** * Creates a function that gets run every `x` milliseconds. This function is a wrapper * around `window.setInterval`. There are a few reasons to use this one instead though. * 1. It gets cleared via {@link Component#clearInterval} when * {@link Component#dispose} gets called. * 2. The function callback will be a {@link Component~GenericCallback} * * @param {Component~GenericCallback} fn * The function to run every `x` seconds. * * @param {number} interval * Execute the specified function every `x` milliseconds. * * @return {number} * Returns an id that can be used to identify the interval. It can also be be used in * {@link Component#clearInterval} to clear the interval. * * @listens Component#dispose * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowTimers/setInterval} */ setInterval(fn, interval) { fn = Fn.bind_(this, fn); this.clearTimersOnDispose_(); const intervalId = window.setInterval(fn, interval); this.setIntervalIds_.add(intervalId); return intervalId; } /** * Clears an interval that gets created via `window.setInterval` or * {@link Component#setInterval}. If you set an interval via {@link Component#setInterval} * use this function instead of `window.clearInterval`. If you don't your dispose * listener will not get cleaned up until {@link Component#dispose}! * * @param {number} intervalId * The id of the interval to clear. The return value of * {@link Component#setInterval} or `window.setInterval`. * * @return {number} * Returns the interval id that was cleared. * * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowTimers/clearInterval} */ clearInterval(intervalId) { if (this.setIntervalIds_.has(intervalId)) { this.setIntervalIds_.delete(intervalId); window.clearInterval(intervalId); } return intervalId; } /** * Queues up a callback to be passed to requestAnimationFrame (rAF), but * with a few extra bonuses: * * - Supports browsers that do not support rAF by falling back to * {@link Component#setTimeout}. * * - The callback is turned into a {@link Component~GenericCallback} (i.e. * bound to the component). * * - Automatic cancellation of the rAF callback is handled if the component * is disposed before it is called. * * @param {Component~GenericCallback} fn * A function that will be bound to this component and executed just * before the browser's next repaint. * * @return {number} * Returns an rAF ID that gets used to identify the timeout. It can * also be used in {@link Component#cancelAnimationFrame} to cancel * the animation frame callback. * * @listens Component#dispose * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame} */ requestAnimationFrame(fn) { this.clearTimersOnDispose_(); // declare as variables so they are properly available in rAF function // eslint-disable-next-line var id; fn = Fn.bind_(this, fn); id = window.requestAnimationFrame(() => { if (this.rafIds_.has(id)) { this.rafIds_.delete(id); } fn(); }); this.rafIds_.add(id); return id; } /** * Request an animation frame, but only one named animation * frame will be queued. Another will never be added until * the previous one finishes. * * @param {string} name * The name to give this requestAnimationFrame * * @param {Component~GenericCallback} fn * A function that will be bound to this component and executed just * before the browser's next repaint. */ requestNamedAnimationFrame(name, fn) { if (this.namedRafs_.has(name)) { this.cancelNamedAnimationFrame(name); } this.clearTimersOnDispose_(); fn = Fn.bind_(this, fn); const id = this.requestAnimationFrame(() => { fn(); if (this.namedRafs_.has(name)) { this.namedRafs_.delete(name); } }); this.namedRafs_.set(name, id); return name; } /** * Cancels a current named animation frame if it exists. * * @param {string} name * The name of the requestAnimationFrame to cancel. */ cancelNamedAnimationFrame(name) { if (!this.namedRafs_.has(name)) { return; } this.cancelAnimationFrame(this.namedRafs_.get(name)); this.namedRafs_.delete(name); } /** * Cancels a queued callback passed to {@link Component#requestAnimationFrame} * (rAF). * * If you queue an rAF callback via {@link Component#requestAnimationFrame}, * use this function instead of `window.cancelAnimationFrame`. If you don't, * your dispose listener will not get cleaned up until {@link Component#dispose}! * * @param {number} id * The rAF ID to clear. The return value of {@link Component#requestAnimationFrame}. * * @return {number} * Returns the rAF ID that was cleared. * * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/window/cancelAnimationFrame} */ cancelAnimationFrame(id) { if (this.rafIds_.has(id)) { this.rafIds_.delete(id); window.cancelAnimationFrame(id); } return id; } /** * A function to setup `requestAnimationFrame`, `setTimeout`, * and `setInterval`, clearing on dispose. * * > Previously each timer added and removed dispose listeners on it's own. * For better performance it was decided to batch them all, and use `Set`s * to track outstanding timer ids. * * @private */ clearTimersOnDispose_() { if (this.clearingTimersOnDispose_) { return; } this.clearingTimersOnDispose_ = true; this.one('dispose', () => { [ ['namedRafs_', 'cancelNamedAnimationFrame'], ['rafIds_', 'cancelAnimationFrame'], ['setTimeoutIds_', 'clearTimeout'], ['setIntervalIds_', 'clearInterval'] ].forEach(([idName, cancelName]) => { // for a `Set` key will actually be the value again // so forEach((val, val) =>` but for maps we want to use // the key. this[idName].forEach((val, key) => this[cancelName](key)); }); this.clearingTimersOnDispose_ = false; }); } /** * Decide whether an element is actually disabled or not. * * @function isActuallyDisabled * @param element {Node} * @return {boolean} * * @see {@link https://html.spec.whatwg.org/multipage/semantics-other.html#concept-element-disabled} */ getIsDisabled() { return Boolean(this.el_.disabled); } /** * Decide whether the element is expressly inert or not. * * @see {@link https://html.spec.whatwg.org/multipage/interaction.html#expressly-inert} * @function isExpresslyInert * @param element {Node} * @return {boolean} */ getIsExpresslyInert() { return this.el_.inert && !this.el_.ownerDocument.documentElement.inert; } /** * Determine whether or not this component can be considered as focusable component. * * @param {HTMLElement} el - The HTML element representing the component. * @return {boolean} * If the component can be focused, will be `true`. Otherwise, `false`. */ getIsFocusable(el) { const element = el || this.el_; return element.tabIndex >= 0 && !(this.getIsDisabled() || this.getIsExpresslyInert()); } /** * Determine whether or not this component is currently visible/enabled/etc... * * @param {HTMLElement} el - The HTML element representing the component. * @return {boolean} * If the component can is currently visible & enabled, will be `true`. Otherwise, `false`. */ getIsAvailableToBeFocused(el) { /** * Decide the style property of this element is specified whether it's visible or not. * * @function isVisibleStyleProperty * @param element {CSSStyleDeclaration} * @return {boolean} */ function isVisibleStyleProperty(element) { const elementStyle = window.getComputedStyle(element, null); const thisVisibility = elementStyle.getPropertyValue('visibility'); const thisDisplay = elementStyle.getPropertyValue('display'); const invisibleStyle = ['hidden', 'collapse']; return (thisDisplay !== 'none' && !invisibleStyle.includes(thisVisibility)); } /** * Decide whether the element is being rendered or not. * 1. If an element has the style as "visibility: hidden | collapse" or "display: none", it is not being rendered. * 2. If an element has the style as "opacity: 0", it is not being rendered.(that is, invisible). * 3. If width and height of an element are explicitly set to 0, it is not being rendered. * 4. If a parent element is hidden, an element itself is not being rendered. * (CSS visibility property and display property are inherited.) * * @see {@link https://html.spec.whatwg.org/multipage/rendering.html#being-rendered} * @function isBeingRendered * @param element {Node} * @return {boolean} */ function isBeingRendered(element) { if (!isVisibleStyleProperty(element.parentElement)) { return false; } if (!isVisibleStyleProperty(element) || (element.style.opacity === '0') || (window.getComputedStyle(element).height === '0px' || window.getComputedStyle(element).width === '0px')) { return false; } return true; } /** * Determine if the element is visible for the user or not. * 1. If an element sum of its offsetWidth, offsetHeight, height and width is less than 1 is not visible. * 2. If elementCenter.x is less than is not visible. * 3. If elementCenter.x is more than the document's width is not visible. * 4. If elementCenter.y is less than 0 is not visible. * 5. If elementCenter.y is the document's height is not visible. * * @function isVisible * @param element {Node} * @return {boolean} */ function isVisible(element) { if ((element.offsetWidth + element.offsetHeight + element.getBoundingClientRect().height + element.getBoundingClientRect().width) === 0) { return false; } // Define elementCenter object with props of x and y // x: Left position relative to the viewport plus element's width (no margin) divided between 2. // y: Top position relative to the viewport plus element's height (no margin) divided between 2. const elementCenter = { x: element.getBoundingClientRect().left + element.offsetWidth / 2, y: element.getBoundingClientRect().top + element.offsetHeight / 2 }; if (elementCenter.x < 0) { return false; } if (elementCenter.x > (document.documentElement.clientWidth || window.innerWidth)) { return false; } if (elementCenter.y < 0) { return false; } if (elementCenter.y > (document.documentElement.clientHeight || window.innerHeight)) { return false; } let pointContainer = document.elementFromPoint(elementCenter.x, elementCenter.y); while (pointContainer) { if (pointContainer === element) { return true; } if (pointContainer.parentNode) { pointContainer = pointContainer.parentNode; } else { return false; } } } // If no DOM element was passed as argument use this component's element. if (!el) { el = this.el(); } // If element is visible, is being rendered & either does not have a parent element or its tabIndex is not negative. if (isVisible(el) && isBeingRendered(el) && ((!el.parentElement) || (el.tabIndex >= 0))) { return true; } return false; } /** * Register a `Component` with `videojs` given the name and the component. * * > NOTE: {@link Tech}s should not be registered as a `Component`. {@link Tech}s * should be registered using {@link Tech.registerTech} or * {@link videojs:videojs.registerTech}. * * > NOTE: This function can also be seen on videojs as * {@link videojs:videojs.registerComponent}. * * @param {string} name * The name of the `Component` to register. * * @param {Component} ComponentToRegister * The `Component` class to register. * * @return {Component} * The `Component` that was registered. */ static registerComponent(name, ComponentToRegister) { if (typeof name !== 'string' || !name) { throw new Error(`Illegal component name, "${name}"; must be a non-empty string.`); } const Tech = Component.getComponent('Tech'); // We need to make sure this check is only done if Tech has been registered. const isTech = Tech && Tech.isTech(ComponentToRegister); const isComp = Component === ComponentToRegister || Component.prototype.isPrototypeOf(ComponentToRegister.prototype); if (isTech || !isComp) { let reason; if (isTech) { reason = 'techs must be registered using Tech.registerTech()'; } else { reason = 'must be a Component subclass'; } throw new Error(`Illegal component, "${name}"; ${reason}.`); } name = toTitleCase(name); if (!Component.components_) { Component.components_ = {}; } const Player = Component.getComponent('Player'); if (name === 'Player' && Player && Player.players) { const players = Player.players; const playerNames = Object.keys(players); // If we have players that were disposed, then their name will still be // in Players.players. So, we must loop through and verify that the value // for each item is null. This allows registration of the Player component // after all players have been disposed or before any were created. if (players && playerNames.length > 0) { for (let i = 0; i < playerNames.length; i++) { if (players[playerNames[i]] !== null) { throw new Error('Can not register Player component after player has been created.'); } } } } Component.components_[name] = ComponentToRegister; Component.components_[toLowerCase(name)] = ComponentToRegister; return ComponentToRegister; } /** * Get a `Component` based on the name it was registered with. * * @param {string} name * The Name of the component to get. * * @return {typeof Component} * The `Component` that got registered under the given name. */ static getComponent(name) { if (!name || !Component.components_) { return; } return Component.components_[name]; } } Component.registerComponent('Component', Component); export default Component;