mirror of
https://github.com/videojs/video.js.git
synced 2026-06-19 22:15:04 +02:00
f8e9bfcd98
Do not allow to register new Player component, if any instance of the current component still exists. Fixes #8925 Co-authored-by: mister-ben <1676039+mister-ben@users.noreply.github.com>
2064 lines
64 KiB
JavaScript
2064 lines
64 KiB
JavaScript
/**
|
|
* 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:
|
|
// <span><svg><use>....</use></svg></span>
|
|
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;
|