/**
* @file player.js
*/
// Subclasses Component
import Component from './component.js';
import {version} from '../../package.json';
import document from 'global/document';
import window from 'global/window';
import evented from './mixins/evented';
import {isEvented, addEventedCallback} from './mixins/evented';
import * as Events from './utils/events.js';
import * as Dom from './utils/dom.js';
import * as Fn from './utils/fn.js';
import * as Guid from './utils/guid.js';
import * as browser from './utils/browser.js';
import {IS_CHROME, IS_WINDOWS} from './utils/browser.js';
import log, { createLogger } from './utils/log.js';
import {toTitleCase, titleCaseEquals} from './utils/str.js';
import { createTimeRange } from './utils/time.js';
import { bufferedPercent } from './utils/buffer.js';
import * as stylesheet from './utils/stylesheet.js';
import FullscreenApi from './fullscreen-api.js';
import MediaError from './media-error.js';
import safeParseTuple from 'safe-json-parse/tuple';
import {merge} from './utils/obj';
import {silencePromise, isPromise} from './utils/promise';
import textTrackConverter from './tracks/text-track-list-converter.js';
import ModalDialog from './modal-dialog';
import Tech from './tech/tech.js';
import * as middleware from './tech/middleware.js';
import {ALL as TRACK_TYPES} from './tracks/track-types';
import filterSource from './utils/filter-source';
import {getMimetype, findMimetype} from './utils/mimetypes';
import {hooks} from './utils/hooks';
import {isObject} from './utils/obj';
import keycode from 'keycode';
import icons from '../images/icons.svg';
import SpatialNavigation from './spatial-navigation.js';
// The following imports are used only to ensure that the corresponding modules
// are always included in the video.js package. Importing the modules will
// execute them and they will register themselves with video.js.
import './tech/loader.js';
import './poster-image.js';
import './tracks/text-track-display.js';
import './loading-spinner.js';
import './big-play-button.js';
import './close-button.js';
import './control-bar/control-bar.js';
import './error-display.js';
import './tracks/text-track-settings.js';
import './resize-manager.js';
import './live-tracker.js';
import './title-bar.js';
// Import Html5 tech, at least for disposing the original video tag.
import './tech/html5.js';
// The following tech events are simply re-triggered
// on the player when they happen
const TECH_EVENTS_RETRIGGER = [
/**
* Fired while the user agent is downloading media data.
*
* @event Player#progress
* @type {Event}
*/
/**
* Retrigger the `progress` event that was triggered by the {@link Tech}.
*
* @private
* @method Player#handleTechProgress_
* @fires Player#progress
* @listens Tech#progress
*/
'progress',
/**
* Fires when the loading of an audio/video is aborted.
*
* @event Player#abort
* @type {Event}
*/
/**
* Retrigger the `abort` event that was triggered by the {@link Tech}.
*
* @private
* @method Player#handleTechAbort_
* @fires Player#abort
* @listens Tech#abort
*/
'abort',
/**
* Fires when the browser is intentionally not getting media data.
*
* @event Player#suspend
* @type {Event}
*/
/**
* Retrigger the `suspend` event that was triggered by the {@link Tech}.
*
* @private
* @method Player#handleTechSuspend_
* @fires Player#suspend
* @listens Tech#suspend
*/
'suspend',
/**
* Fires when the current playlist is empty.
*
* @event Player#emptied
* @type {Event}
*/
/**
* Retrigger the `emptied` event that was triggered by the {@link Tech}.
*
* @private
* @method Player#handleTechEmptied_
* @fires Player#emptied
* @listens Tech#emptied
*/
'emptied',
/**
* Fires when the browser is trying to get media data, but data is not available.
*
* @event Player#stalled
* @type {Event}
*/
/**
* Retrigger the `stalled` event that was triggered by the {@link Tech}.
*
* @private
* @method Player#handleTechStalled_
* @fires Player#stalled
* @listens Tech#stalled
*/
'stalled',
/**
* Fires when the browser has loaded meta data for the audio/video.
*
* @event Player#loadedmetadata
* @type {Event}
*/
/**
* Retrigger the `loadedmetadata` event that was triggered by the {@link Tech}.
*
* @private
* @method Player#handleTechLoadedmetadata_
* @fires Player#loadedmetadata
* @listens Tech#loadedmetadata
*/
'loadedmetadata',
/**
* Fires when the browser has loaded the current frame of the audio/video.
*
* @event Player#loadeddata
* @type {event}
*/
/**
* Retrigger the `loadeddata` event that was triggered by the {@link Tech}.
*
* @private
* @method Player#handleTechLoaddeddata_
* @fires Player#loadeddata
* @listens Tech#loadeddata
*/
'loadeddata',
/**
* Fires when the current playback position has changed.
*
* @event Player#timeupdate
* @type {event}
*/
/**
* Retrigger the `timeupdate` event that was triggered by the {@link Tech}.
*
* @private
* @method Player#handleTechTimeUpdate_
* @fires Player#timeupdate
* @listens Tech#timeupdate
*/
'timeupdate',
/**
* Fires when the video's intrinsic dimensions change
*
* @event Player#resize
* @type {event}
*/
/**
* Retrigger the `resize` event that was triggered by the {@link Tech}.
*
* @private
* @method Player#handleTechResize_
* @fires Player#resize
* @listens Tech#resize
*/
'resize',
/**
* Fires when the volume has been changed
*
* @event Player#volumechange
* @type {event}
*/
/**
* Retrigger the `volumechange` event that was triggered by the {@link Tech}.
*
* @private
* @method Player#handleTechVolumechange_
* @fires Player#volumechange
* @listens Tech#volumechange
*/
'volumechange',
/**
* Fires when the text track has been changed
*
* @event Player#texttrackchange
* @type {event}
*/
/**
* Retrigger the `texttrackchange` event that was triggered by the {@link Tech}.
*
* @private
* @method Player#handleTechTexttrackchange_
* @fires Player#texttrackchange
* @listens Tech#texttrackchange
*/
'texttrackchange'
];
// events to queue when playback rate is zero
// this is a hash for the sole purpose of mapping non-camel-cased event names
// to camel-cased function names
const TECH_EVENTS_QUEUE = {
canplay: 'CanPlay',
canplaythrough: 'CanPlayThrough',
playing: 'Playing',
seeked: 'Seeked'
};
const BREAKPOINT_ORDER = [
'tiny',
'xsmall',
'small',
'medium',
'large',
'xlarge',
'huge'
];
const BREAKPOINT_CLASSES = {};
// grep: vjs-layout-tiny
// grep: vjs-layout-x-small
// grep: vjs-layout-small
// grep: vjs-layout-medium
// grep: vjs-layout-large
// grep: vjs-layout-x-large
// grep: vjs-layout-huge
BREAKPOINT_ORDER.forEach(k => {
const v = k.charAt(0) === 'x' ? `x-${k.substring(1)}` : k;
BREAKPOINT_CLASSES[k] = `vjs-layout-${v}`;
});
const DEFAULT_BREAKPOINTS = {
tiny: 210,
xsmall: 320,
small: 425,
medium: 768,
large: 1440,
xlarge: 2560,
huge: Infinity
};
/**
* An instance of the `Player` class is created when any of the Video.js setup methods
* are used to initialize a video.
*
* After an instance has been created it can be accessed globally in three ways:
* 1. By calling `videojs.getPlayer('example_video_1');`
* 2. By calling `videojs('example_video_1');` (not recommended)
* 2. By using it directly via `videojs.players.example_video_1;`
*
* @extends Component
* @global
*/
class Player extends Component {
/**
* Create an instance of this class.
*
* @param {Element} tag
* The original video DOM element used for configuring options.
*
* @param {Object} [options]
* Object of option names and values.
*
* @param {Function} [ready]
* Ready callback function.
*/
constructor(tag, options, ready) {
// Make sure tag ID exists
// also here.. probably better
tag.id = tag.id || options.id || `vjs_video_${Guid.newGUID()}`;
// Set Options
// The options argument overrides options set in the video tag
// which overrides globally set options.
// This latter part coincides with the load order
// (tag must exist before Player)
options = Object.assign(Player.getTagSettings(tag), options);
// Delay the initialization of children because we need to set up
// player properties first, and can't use `this` before `super()`
options.initChildren = false;
// Same with creating the element
options.createEl = false;
// don't auto mixin the evented mixin
options.evented = false;
// we don't want the player to report touch activity on itself
// see enableTouchActivity in Component
options.reportTouchActivity = false;
// If language is not set, get the closest lang attribute
if (!options.language) {
const closest = tag.closest('[lang]');
if (closest) {
options.language = closest.getAttribute('lang');
}
}
// Run base component initializing with new options
super(null, options, ready);
// Create bound methods for document listeners.
this.boundDocumentFullscreenChange_ = (e) => this.documentFullscreenChange_(e);
this.boundFullWindowOnEscKey_ = (e) => this.fullWindowOnEscKey(e);
this.boundUpdateStyleEl_ = (e) => this.updateStyleEl_(e);
this.boundApplyInitTime_ = (e) => this.applyInitTime_(e);
this.boundUpdateCurrentBreakpoint_ = (e) => this.updateCurrentBreakpoint_(e);
this.boundHandleTechClick_ = (e) => this.handleTechClick_(e);
this.boundHandleTechDoubleClick_ = (e) => this.handleTechDoubleClick_(e);
this.boundHandleTechTouchStart_ = (e) => this.handleTechTouchStart_(e);
this.boundHandleTechTouchMove_ = (e) => this.handleTechTouchMove_(e);
this.boundHandleTechTouchEnd_ = (e) => this.handleTechTouchEnd_(e);
this.boundHandleTechTap_ = (e) => this.handleTechTap_(e);
this.boundUpdatePlayerHeightOnAudioOnlyMode_ = (e) => this.updatePlayerHeightOnAudioOnlyMode_(e);
// default isFullscreen_ to false
this.isFullscreen_ = false;
// create logger
this.log = createLogger(this.id_);
// Hold our own reference to fullscreen api so it can be mocked in tests
this.fsApi_ = FullscreenApi;
// Tracks when a tech changes the poster
this.isPosterFromTech_ = false;
// Holds callback info that gets queued when playback rate is zero
// and a seek is happening
this.queuedCallbacks_ = [];
// Turn off API access because we're loading a new tech that might load asynchronously
this.isReady_ = false;
// Init state hasStarted_
this.hasStarted_ = false;
// Init state userActive_
this.userActive_ = false;
// Init debugEnabled_
this.debugEnabled_ = false;
// Init state audioOnlyMode_
this.audioOnlyMode_ = false;
// Init state audioPosterMode_
this.audioPosterMode_ = false;
// Init state audioOnlyCache_
this.audioOnlyCache_ = {
controlBarHeight: null,
playerHeight: null,
hiddenChildren: []
};
// if the global option object was accidentally blown away by
// someone, bail early with an informative error
if (!this.options_ ||
!this.options_.techOrder ||
!this.options_.techOrder.length) {
throw new Error('No techOrder specified. Did you overwrite ' +
'videojs.options instead of just changing the ' +
'properties you want to override?');
}
// Store the original tag used to set options
this.tag = tag;
// Store the tag attributes used to restore html5 element
this.tagAttributes = tag && Dom.getAttributes(tag);
// Update current language
this.language(this.options_.language);
// Update Supported Languages
if (options.languages) {
// Normalise player option languages to lowercase
const languagesToLower = {};
Object.getOwnPropertyNames(options.languages).forEach(function(name) {
languagesToLower[name.toLowerCase()] = options.languages[name];
});
this.languages_ = languagesToLower;
} else {
this.languages_ = Player.prototype.options_.languages;
}
this.resetCache_();
// Set poster
/** @type string */
this.poster_ = options.poster || '';
// Set controls
/** @type {boolean} */
this.controls_ = !!options.controls;
// Original tag settings stored in options
// now remove immediately so native controls don't flash.
// May be turned back on by HTML5 tech if nativeControlsForTouch is true
tag.controls = false;
tag.removeAttribute('controls');
this.changingSrc_ = false;
this.playCallbacks_ = [];
this.playTerminatedQueue_ = [];
// the attribute overrides the option
if (tag.hasAttribute('autoplay')) {
this.autoplay(true);
} else {
// otherwise use the setter to validate and
// set the correct value.
this.autoplay(this.options_.autoplay);
}
// check plugins
if (options.plugins) {
Object.keys(options.plugins).forEach((name) => {
if (typeof this[name] !== 'function') {
throw new Error(`plugin "${name}" does not exist`);
}
});
}
/*
* Store the internal state of scrubbing
*
* @private
* @return {Boolean} True if the user is scrubbing
*/
this.scrubbing_ = false;
this.el_ = this.createEl();
// Make this an evented object and use `el_` as its event bus.
evented(this, {eventBusKey: 'el_'});
// listen to document and player fullscreenchange handlers so we receive those events
// before a user can receive them so we can update isFullscreen appropriately.
// make sure that we listen to fullscreenchange events before everything else to make sure that
// our isFullscreen method is updated properly for internal components as well as external.
if (this.fsApi_.requestFullscreen) {
Events.on(document, this.fsApi_.fullscreenchange, this.boundDocumentFullscreenChange_);
this.on(this.fsApi_.fullscreenchange, this.boundDocumentFullscreenChange_);
}
if (this.fluid_) {
this.on(['playerreset', 'resize'], this.boundUpdateStyleEl_);
}
// We also want to pass the original player options to each component and plugin
// as well so they don't need to reach back into the player for options later.
// We also need to do another copy of this.options_ so we don't end up with
// an infinite loop.
const playerOptionsCopy = merge(this.options_);
// Load plugins
if (options.plugins) {
Object.keys(options.plugins).forEach((name) => {
this[name](options.plugins[name]);
});
}
// Enable debug mode to fire debugon event for all plugins.
if (options.debug) {
this.debug(true);
}
this.options_.playerOptions = playerOptionsCopy;
this.middleware_ = [];
this.playbackRates(options.playbackRates);
if (options.experimentalSvgIcons) {
// Add SVG Sprite to the DOM
const parser = new window.DOMParser();
const parsedSVG = parser.parseFromString(icons, 'image/svg+xml');
const errorNode = parsedSVG.querySelector('parsererror');
if (errorNode) {
log.warn('Failed to load SVG Icons. Falling back to Font Icons.');
this.options_.experimentalSvgIcons = null;
} else {
const sprite = parsedSVG.documentElement;
sprite.style.display = 'none';
this.el_.appendChild(sprite);
this.addClass('vjs-svg-icons-enabled');
}
}
this.initChildren();
// Set isAudio based on whether or not an audio tag was used
this.isAudio(tag.nodeName.toLowerCase() === 'audio');
// Update controls className. Can't do this when the controls are initially
// set because the element doesn't exist yet.
if (this.controls()) {
this.addClass('vjs-controls-enabled');
} else {
this.addClass('vjs-controls-disabled');
}
// Set ARIA label and region role depending on player type
this.el_.setAttribute('role', 'region');
if (this.isAudio()) {
this.el_.setAttribute('aria-label', this.localize('Audio Player'));
} else {
this.el_.setAttribute('aria-label', this.localize('Video Player'));
}
if (this.isAudio()) {
this.addClass('vjs-audio');
}
// Check if spatial navigation is enabled in the options.
// If enabled, instantiate the SpatialNavigation class.
if (options.spatialNavigation && options.spatialNavigation.enabled) {
this.spatialNavigation = new SpatialNavigation(this);
this.addClass('vjs-spatial-navigation-enabled');
}
// TODO: Make this smarter. Toggle user state between touching/mousing
// using events, since devices can have both touch and mouse events.
// TODO: Make this check be performed again when the window switches between monitors
// (See https://github.com/videojs/video.js/issues/5683)
if (browser.TOUCH_ENABLED) {
this.addClass('vjs-touch-enabled');
}
// iOS Safari has broken hover handling
if (!browser.IS_IOS) {
this.addClass('vjs-workinghover');
}
// Make player easily findable by ID
Player.players[this.id_] = this;
// Add a major version class to aid css in plugins
const majorVersion = version.split('.')[0];
this.addClass(`vjs-v${majorVersion}`);
// When the player is first initialized, trigger activity so components
// like the control bar show themselves if needed
this.userActive(true);
this.reportUserActivity();
this.one('play', (e) => this.listenForUserActivity_(e));
this.on('keydown', (e) => this.handleKeyDown(e));
this.on('languagechange', (e) => this.handleLanguagechange(e));
this.breakpoints(this.options_.breakpoints);
this.responsive(this.options_.responsive);
// Calling both the audio mode methods after the player is fully
// setup to be able to listen to the events triggered by them
this.on('ready', () => {
// Calling the audioPosterMode method first so that
// the audioOnlyMode can take precedence when both options are set to true
this.audioPosterMode(this.options_.audioPosterMode);
this.audioOnlyMode(this.options_.audioOnlyMode);
});
}
/**
* Destroys the video player and does any necessary cleanup.
*
* This is especially helpful if you are dynamically adding and removing videos
* to/from the DOM.
*
* @fires Player#dispose
*/
dispose() {
/**
* Called when the player is being disposed of.
*
* @event Player#dispose
* @type {Event}
*/
this.trigger('dispose');
// prevent dispose from being called twice
this.off('dispose');
// Make sure all player-specific document listeners are unbound. This is
Events.off(document, this.fsApi_.fullscreenchange, this.boundDocumentFullscreenChange_);
Events.off(document, 'keydown', this.boundFullWindowOnEscKey_);
if (this.styleEl_ && this.styleEl_.parentNode) {
this.styleEl_.parentNode.removeChild(this.styleEl_);
this.styleEl_ = null;
}
// Kill reference to this player
Player.players[this.id_] = null;
if (this.tag && this.tag.player) {
this.tag.player = null;
}
if (this.el_ && this.el_.player) {
this.el_.player = null;
}
if (this.tech_) {
this.tech_.dispose();
this.isPosterFromTech_ = false;
this.poster_ = '';
}
if (this.playerElIngest_) {
this.playerElIngest_ = null;
}
if (this.tag) {
this.tag = null;
}
middleware.clearCacheForPlayer(this);
// remove all event handlers for track lists
// all tracks and track listeners are removed on
// tech dispose
TRACK_TYPES.names.forEach((name) => {
const props = TRACK_TYPES[name];
const list = this[props.getterName]();
// if it is not a native list
// we have to manually remove event listeners
if (list && list.off) {
list.off();
}
});
// the actual .el_ is removed here, or replaced if
super.dispose({restoreEl: this.options_.restoreEl});
}
/**
* Create the `Player`'s DOM element.
*
* @return {Element}
* The DOM element that gets created.
*/
createEl() {
let tag = this.tag;
let el;
let playerElIngest = this.playerElIngest_ = tag.parentNode && tag.parentNode.hasAttribute && tag.parentNode.hasAttribute('data-vjs-player');
const divEmbed = this.tag.tagName.toLowerCase() === 'video-js';
if (playerElIngest) {
el = this.el_ = tag.parentNode;
} else if (!divEmbed) {
el = this.el_ = super.createEl('div');
}
// Copy over all the attributes from the tag, including ID and class
// ID will now reference player box, not the video tag
const attrs = Dom.getAttributes(tag);
if (divEmbed) {
el = this.el_ = tag;
tag = this.tag = document.createElement('video');
while (el.children.length) {
tag.appendChild(el.firstChild);
}
if (!Dom.hasClass(el, 'video-js')) {
Dom.addClass(el, 'video-js');
}
el.appendChild(tag);
playerElIngest = this.playerElIngest_ = el;
// move properties over from our custom `video-js` element
// to our new `video` element. This will move things like
// `src` or `controls` that were set via js before the player
// was initialized.
Object.keys(el).forEach((k) => {
try {
tag[k] = el[k];
} catch (e) {
// we got a a property like outerHTML which we can't actually copy, ignore it
}
});
}
// set tabindex to -1 to remove the video element from the focus order
tag.setAttribute('tabindex', '-1');
attrs.tabindex = '-1';
// Workaround for #4583 on Chrome (on Windows) with JAWS.
// See https://github.com/FreedomScientific/VFO-standards-support/issues/78
// Note that we can't detect if JAWS is being used, but this ARIA attribute
// doesn't change behavior of Chrome if JAWS is not being used
if (IS_CHROME && IS_WINDOWS) {
tag.setAttribute('role', 'application');
attrs.role = 'application';
}
// Remove width/height attrs from tag so CSS can make it 100% width/height
tag.removeAttribute('width');
tag.removeAttribute('height');
if ('width' in attrs) {
delete attrs.width;
}
if ('height' in attrs) {
delete attrs.height;
}
Object.getOwnPropertyNames(attrs).forEach(function(attr) {
// don't copy over the class attribute to the player element when we're in a div embed
// the class is already set up properly in the divEmbed case
// and we want to make sure that the `video-js` class doesn't get lost
if (!(divEmbed && attr === 'class')) {
el.setAttribute(attr, attrs[attr]);
}
if (divEmbed) {
tag.setAttribute(attr, attrs[attr]);
}
});
// Update tag id/class for use as HTML5 playback tech
// Might think we should do this after embedding in container so .vjs-tech class
// doesn't flash 100% width/height, but class only applies with .video-js parent
tag.playerId = tag.id;
tag.id += '_html5_api';
tag.className = 'vjs-tech';
// Make player findable on elements
tag.player = el.player = this;
// Default state of video is paused
this.addClass('vjs-paused');
const deviceClassNames = [
'IS_SMART_TV',
'IS_TIZEN',
'IS_WEBOS',
'IS_ANDROID',
'IS_IPAD',
'IS_IPHONE'
].filter(key => browser[key]).map(key => {
return 'vjs-device-' + key.substring(3).toLowerCase().replace(/\_/g, '-');
});
this.addClass(...deviceClassNames);
// Add a style element in the player that we'll use to set the width/height
// of the player in a way that's still overridable by CSS, just like the
// video element
if (window.VIDEOJS_NO_DYNAMIC_STYLE !== true) {
this.styleEl_ = stylesheet.createStyleElement('vjs-styles-dimensions');
const defaultsStyleEl = Dom.$('.vjs-styles-defaults');
const head = Dom.$('head');
head.insertBefore(this.styleEl_, defaultsStyleEl ? defaultsStyleEl.nextSibling : head.firstChild);
}
this.fill_ = false;
this.fluid_ = false;
// Pass in the width/height/aspectRatio options which will update the style el
this.width(this.options_.width);
this.height(this.options_.height);
this.fill(this.options_.fill);
this.fluid(this.options_.fluid);
this.aspectRatio(this.options_.aspectRatio);
// support both crossOrigin and crossorigin to reduce confusion and issues around the name
this.crossOrigin(this.options_.crossOrigin || this.options_.crossorigin);
// Hide any links within the video/audio tag,
// because IE doesn't hide them completely from screen readers.
const links = tag.getElementsByTagName('a');
for (let i = 0; i < links.length; i++) {
const linkEl = links.item(i);
Dom.addClass(linkEl, 'vjs-hidden');
linkEl.setAttribute('hidden', 'hidden');
}
// insertElFirst seems to cause the networkState to flicker from 3 to 2, so
// keep track of the original for later so we can know if the source originally failed
tag.initNetworkState_ = tag.networkState;
// Wrap video tag in div (el/box) container
if (tag.parentNode && !playerElIngest) {
tag.parentNode.insertBefore(el, tag);
}
// insert the tag as the first child of the player element
// then manually add it to the children array so that this.addChild
// will work properly for other components
//
// Breaks iPhone, fixed in HTML5 setup.
Dom.prependTo(tag, el);
this.children_.unshift(tag);
// Set lang attr on player to ensure CSS :lang() in consistent with player
// if it's been set to something different to the doc
this.el_.setAttribute('lang', this.language_);
this.el_.setAttribute('translate', 'no');
this.el_ = el;
return el;
}
/**
* Get or set the `Player`'s crossOrigin option. For the HTML5 player, this
* sets the `crossOrigin` property on the `<video>` tag to control the CORS
* behavior.
*
* @see [Video Element Attributes]{@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video#attr-crossorigin}
*
* @param {string|null} [value]
* The value to set the `Player`'s crossOrigin to. If an argument is
* given, must be one of `'anonymous'` or `'use-credentials'`, or 'null'.
*
* @return {string|null|undefined}
* - The current crossOrigin value of the `Player` when getting.
* - undefined when setting
*/
crossOrigin(value) {
// `null` can be set to unset a value
if (typeof value === 'undefined') {
return this.techGet_('crossOrigin');
}
if (value !== null && value !== 'anonymous' && value !== 'use-credentials') {
log.warn(`crossOrigin must be null, "anonymous" or "use-credentials", given "${value}"`);
return;
}
this.techCall_('setCrossOrigin', value);
if (this.posterImage) {
this.posterImage.crossOrigin(value);
}
return;
}
/**
* A getter/setter for the `Player`'s width. Returns the player's configured value.
* To get the current width use `currentWidth()`.
*
* @param {number|string} [value]
* CSS value to set the `Player`'s width to.
*
* @return {number|undefined}
* - The current width of the `Player` when getting.
* - Nothing when setting
*/
width(value) {
return this.dimension('width', value);
}
/**
* A getter/setter for the `Player`'s height. Returns the player's configured value.
* To get the current height use `currentheight()`.
*
* @param {number|string} [value]
* CSS value to set the `Player`'s height to.
*
* @return {number|undefined}
* - The current height of the `Player` when getting.
* - Nothing when setting
*/
height(value) {
return this.dimension('height', value);
}
/**
* A getter/setter for the `Player`'s width & height.
*
* @param {string} dimension
* This string can be:
* - 'width'
* - 'height'
*
* @param {number|string} [value]
* Value for dimension specified in the first argument.
*
* @return {number}
* The dimension arguments value when getting (width/height).
*/
dimension(dimension, value) {
const privDimension = dimension + '_';
if (value === undefined) {
return this[privDimension] || 0;
}
if (value === '' || value === 'auto') {
// If an empty string is given, reset the dimension to be automatic
this[privDimension] = undefined;
this.updateStyleEl_();
return;
}
const parsedVal = parseFloat(value);
if (isNaN(parsedVal)) {
log.error(`Improper value "${value}" supplied for for ${dimension}`);
return;
}
this[privDimension] = parsedVal;
this.updateStyleEl_();
}
/**
* A getter/setter/toggler for the vjs-fluid `className` on the `Player`.
*
* Turning this on will turn off fill mode.
*
* @param {boolean} [bool]
* - A value of true adds the class.
* - A value of false removes the class.
* - No value will be a getter.
*
* @return {boolean|undefined}
* - The value of fluid when getting.
* - `undefined` when setting.
*/
fluid(bool) {
if (bool === undefined) {
return !!this.fluid_;
}
this.fluid_ = !!bool;
if (isEvented(this)) {
this.off(['playerreset', 'resize'], this.boundUpdateStyleEl_);
}
if (bool) {
this.addClass('vjs-fluid');
this.fill(false);
addEventedCallback(this, () => {
this.on(['playerreset', 'resize'], this.boundUpdateStyleEl_);
});
} else {
this.removeClass('vjs-fluid');
}
this.updateStyleEl_();
}
/**
* A getter/setter/toggler for the vjs-fill `className` on the `Player`.
*
* Turning this on will turn off fluid mode.
*
* @param {boolean} [bool]
* - A value of true adds the class.
* - A value of false removes the class.
* - No value will be a getter.
*
* @return {boolean|undefined}
* - The value of fluid when getting.
* - `undefined` when setting.
*/
fill(bool) {
if (bool === undefined) {
return !!this.fill_;
}
this.fill_ = !!bool;
if (bool) {
this.addClass('vjs-fill');
this.fluid(false);
} else {
this.removeClass('vjs-fill');
}
}
/**
* Get/Set the aspect ratio
*
* @param {string} [ratio]
* Aspect ratio for player
*
* @return {string|undefined}
* returns the current aspect ratio when getting
*/
/**
* A getter/setter for the `Player`'s aspect ratio.
*
* @param {string} [ratio]
* The value to set the `Player`'s aspect ratio to.
*
* @return {string|undefined}
* - The current aspect ratio of the `Player` when getting.
* - undefined when setting
*/
aspectRatio(ratio) {
if (ratio === undefined) {
return this.aspectRatio_;
}
// Check for width:height format
if (!(/^\d+\:\d+$/).test(ratio)) {
throw new Error('Improper value supplied for aspect ratio. The format should be width:height, for example 16:9.');
}
this.aspectRatio_ = ratio;
// We're assuming if you set an aspect ratio you want fluid mode,
// because in fixed mode you could calculate width and height yourself.
this.fluid(true);
this.updateStyleEl_();
}
/**
* Update styles of the `Player` element (height, width and aspect ratio).
*
* @private
* @listens Tech#loadedmetadata
*/
updateStyleEl_() {
if (window.VIDEOJS_NO_DYNAMIC_STYLE === true) {
const width = typeof this.width_ === 'number' ? this.width_ : this.options_.width;
const height = typeof this.height_ === 'number' ? this.height_ : this.options_.height;
const techEl = this.tech_ && this.tech_.el();
if (techEl) {
if (width >= 0) {
techEl.width = width;
}
if (height >= 0) {
techEl.height = height;
}
}
return;
}
let width;
let height;
let aspectRatio;
let idClass;
// The aspect ratio is either used directly or to calculate width and height.
if (this.aspectRatio_ !== undefined && this.aspectRatio_ !== 'auto') {
// Use any aspectRatio that's been specifically set
aspectRatio = this.aspectRatio_;
} else if (this.videoWidth() > 0) {
// Otherwise try to get the aspect ratio from the video metadata
aspectRatio = this.videoWidth() + ':' + this.videoHeight();
} else {
// Or use a default. The video element's is 2:1, but 16:9 is more common.
aspectRatio = '16:9';
}
// Get the ratio as a decimal we can use to calculate dimensions
const ratioParts = aspectRatio.split(':');
const ratioMultiplier = ratioParts[1] / ratioParts[0];
if (this.width_ !== undefined) {
// Use any width that's been specifically set
width = this.width_;
} else if (this.height_ !== undefined) {
// Or calculate the width from the aspect ratio if a height has been set
width = this.height_ / ratioMultiplier;
} else {
// Or use the video's metadata, or use the video el's default of 300
width = this.videoWidth() || 300;
}
if (this.height_ !== undefined) {
// Use any height that's been specifically set
height = this.height_;
} else {
// Otherwise calculate the height from the ratio and the width
height = width * ratioMultiplier;
}
// Ensure the CSS class is valid by starting with an alpha character
if ((/^[^a-zA-Z]/).test(this.id())) {
idClass = 'dimensions-' + this.id();
} else {
idClass = this.id() + '-dimensions';
}
// Ensure the right class is still on the player for the style element
this.addClass(idClass);
stylesheet.setTextContent(this.styleEl_, `
.${idClass} {
width: ${width}px;
height: ${height}px;
}
.${idClass}.vjs-fluid:not(.vjs-audio-only-mode) {
padding-top: ${ratioMultiplier * 100}%;
}
`);
}
/**
* Load/Create an instance of playback {@link Tech} including element
* and API methods. Then append the `Tech` element in `Player` as a child.
*
* @param {string} techName
* name of the playback technology
*
* @param {string} source
* video source
*
* @private
*/
loadTech_(techName, source) {
// Pause and remove current playback technology
if (this.tech_) {
this.unloadTech_();
}
const titleTechName = toTitleCase(techName);
const camelTechName = techName.charAt(0).toLowerCase() + techName.slice(1);
// get rid of the HTML5 video tag as soon as we are using another tech
if (titleTechName !== 'Html5' && this.tag) {
Tech.getTech('Html5').disposeMediaElement(this.tag);
this.tag.player = null;
this.tag = null;
}
this.techName_ = titleTechName;
// Turn off API access because we're loading a new tech that might load asynchronously
this.isReady_ = false;
let autoplay = this.autoplay();
// if autoplay is a string (or `true` with normalizeAutoplay: true) we pass false to the tech
// because the player is going to handle autoplay on `loadstart`
if (typeof this.autoplay() === 'string' || this.autoplay() === true && this.options_.normalizeAutoplay) {
autoplay = false;
}
// Grab tech-specific options from player options and add source and parent element to use.
const techOptions = {
source,
autoplay,
'nativeControlsForTouch': this.options_.nativeControlsForTouch,
'playerId': this.id(),
'techId': `${this.id()}_${camelTechName}_api`,
'playsinline': this.options_.playsinline,
'preload': this.options_.preload,
'loop': this.options_.loop,
'disablePictureInPicture': this.options_.disablePictureInPicture,
'muted': this.options_.muted,
'poster': this.poster(),
'language': this.language(),
'playerElIngest': this.playerElIngest_ || false,
'vtt.js': this.options_['vtt.js'],
'canOverridePoster': !!this.options_.techCanOverridePoster,
'enableSourceset': this.options_.enableSourceset
};
TRACK_TYPES.names.forEach((name) => {
const props = TRACK_TYPES[name];
techOptions[props.getterName] = this[props.privateName];
});
Object.assign(techOptions, this.options_[titleTechName]);
Object.assign(techOptions, this.options_[camelTechName]);
Object.assign(techOptions, this.options_[techName.toLowerCase()]);
if (this.tag) {
techOptions.tag = this.tag;
}
if (source && source.src === this.cache_.src && this.cache_.currentTime > 0) {
techOptions.startTime = this.cache_.currentTime;
}
// Initialize tech instance
const TechClass = Tech.getTech(techName);
if (!TechClass) {
throw new Error(`No Tech named '${titleTechName}' exists! '${titleTechName}' should be registered using videojs.registerTech()'`);
}
this.tech_ = new TechClass(techOptions);
// player.triggerReady is always async, so don't need this to be async
this.tech_.ready(Fn.bind_(this, this.handleTechReady_), true);
textTrackConverter.jsonToTextTracks(this.textTracksJson_ || [], this.tech_);
// Listen to all HTML5-defined events and trigger them on the player
TECH_EVENTS_RETRIGGER.forEach((event) => {
this.on(this.tech_, event, (e) => this[`handleTech${toTitleCase(event)}_`](e));
});
Object.keys(TECH_EVENTS_QUEUE).forEach((event) => {
this.on(this.tech_, event, (eventObj) => {
if (this.tech_.playbackRate() === 0 && this.tech_.seeking()) {
this.queuedCallbacks_.push({
callback: this[`handleTech${TECH_EVENTS_QUEUE[event]}_`].bind(this),
event: eventObj
});
return;
}
this[`handleTech${TECH_EVENTS_QUEUE[event]}_`](eventObj);
});
});
this.on(this.tech_, 'loadstart', (e) => this.handleTechLoadStart_(e));
this.on(this.tech_, 'sourceset', (e) => this.handleTechSourceset_(e));
this.on(this.tech_, 'waiting', (e) => this.handleTechWaiting_(e));
this.on(this.tech_, 'ended', (e) => this.handleTechEnded_(e));
this.on(this.tech_, 'seeking', (e) => this.handleTechSeeking_(e));
this.on(this.tech_, 'play', (e) => this.handleTechPlay_(e));
this.on(this.tech_, 'pause', (e) => this.handleTechPause_(e));
this.on(this.tech_, 'durationchange', (e) => this.handleTechDurationChange_(e));
this.on(this.tech_, 'fullscreenchange', (e, data) => this.handleTechFullscreenChange_(e, data));
this.on(this.tech_, 'fullscreenerror', (e, err) => this.handleTechFullscreenError_(e, err));
this.on(this.tech_, 'enterpictureinpicture', (e) => this.handleTechEnterPictureInPicture_(e));
this.on(this.tech_, 'leavepictureinpicture', (e) => this.handleTechLeavePictureInPicture_(e));
this.on(this.tech_, 'error', (e) => this.handleTechError_(e));
this.on(this.tech_, 'posterchange', (e) => this.handleTechPosterChange_(e));
this.on(this.tech_, 'textdata', (e) => this.handleTechTextData_(e));
this.on(this.tech_, 'ratechange', (e) => this.handleTechRateChange_(e));
this.on(this.tech_, 'loadedmetadata', this.boundUpdateStyleEl_);
this.usingNativeControls(this.techGet_('controls'));
if (this.controls() && !this.usingNativeControls()) {
this.addTechControlsListeners_();
}
// Add the tech element in the DOM if it was not already there
// Make sure to not insert the original video element if using Html5
if (this.tech_.el().parentNode !== this.el() && (titleTechName !== 'Html5' || !this.tag)) {
Dom.prependTo(this.tech_.el(), this.el());
}
// Get rid of the original video tag reference after the first tech is loaded
if (this.tag) {
this.tag.player = null;
this.tag = null;
}
}
/**
* Unload and dispose of the current playback {@link Tech}.
*
* @private
*/
unloadTech_() {
// Save the current text tracks so that we can reuse the same text tracks with the next tech
TRACK_TYPES.names.forEach((name) => {
const props = TRACK_TYPES[name];
this[props.privateName] = this[props.getterName]();
});
this.textTracksJson_ = textTrackConverter.textTracksToJson(this.tech_);
this.isReady_ = false;
this.tech_.dispose();
this.tech_ = false;
if (this.isPosterFromTech_) {
this.poster_ = '';
this.trigger('posterchange');
}
this.isPosterFromTech_ = false;
}
/**
* Return a reference to the current {@link Tech}.
* It will print a warning by default about the danger of using the tech directly
* but any argument that is passed in will silence the warning.
*
* @param {*} [safety]
* Anything passed in to silence the warning
*
* @return {Tech}
* The Tech
*/
tech(safety) {
if (safety === undefined) {
log.warn('Using the tech directly can be dangerous. I hope you know what you\'re doing.\n' +
'See https://github.com/videojs/video.js/issues/2617 for more info.\n');
}
return this.tech_;
}
/**
* An object that contains Video.js version.
*
* @typedef {Object} PlayerVersion
*
* @property {string} 'video.js' - Video.js version
*/
/**
* Returns an object with Video.js version.
*
* @return {PlayerVersion}
* An object with Video.js version.
*/
version() {
return {
'video.js': version
};
}
/**
* Set up click and touch listeners for the playback element
*
* - On desktops: a click on the video itself will toggle playback
* - On mobile devices: a click on the video toggles controls
* which is done by toggling the user state between active and
* inactive
* - A tap can signal that a user has become active or has become inactive
* e.g. a quick tap on an iPhone movie should reveal the controls. Another
* quick tap should hide them again (signaling the user is in an inactive
* viewing state)
* - In addition to this, we still want the user to be considered inactive after
* a few seconds of inactivity.
*
* > Note: the only part of iOS interaction we can't mimic with this setup
* is a touch and hold on the video element counting as activity in order to
* keep the controls showing, but that shouldn't be an issue. A touch and hold
* on any controls will still keep the user active
*
* @private
*/
addTechControlsListeners_() {
// Make sure to remove all the previous listeners in case we are called multiple times.
this.removeTechControlsListeners_();
this.on(this.tech_, 'click', this.boundHandleTechClick_);
this.on(this.tech_, 'dblclick', this.boundHandleTechDoubleClick_);
// If the controls were hidden we don't want that to change without a tap event
// so we'll check if the controls were already showing before reporting user
// activity
this.on(this.tech_, 'touchstart', this.boundHandleTechTouchStart_);
this.on(this.tech_, 'touchmove', this.boundHandleTechTouchMove_);
this.on(this.tech_, 'touchend', this.boundHandleTechTouchEnd_);
// The tap listener needs to come after the touchend listener because the tap
// listener cancels out any reportedUserActivity when setting userActive(false)
this.on(this.tech_, 'tap', this.boundHandleTechTap_);
}
/**
* Remove the listeners used for click and tap controls. This is needed for
* toggling to controls disabled, where a tap/touch should do nothing.
*
* @private
*/
removeTechControlsListeners_() {
// We don't want to just use `this.off()` because there might be other needed
// listeners added by techs that extend this.
this.off(this.tech_, 'tap', this.boundHandleTechTap_);
this.off(this.tech_, 'touchstart', this.boundHandleTechTouchStart_);
this.off(this.tech_, 'touchmove', this.boundHandleTechTouchMove_);
this.off(this.tech_, 'touchend', this.boundHandleTechTouchEnd_);
this.off(this.tech_, 'click', this.boundHandleTechClick_);
this.off(this.tech_, 'dblclick', this.boundHandleTechDoubleClick_);
}
/**
* Player waits for the tech to be ready
*
* @private
*/
handleTechReady_() {
this.triggerReady();
// Keep the same volume as before
if (this.cache_.volume) {
this.techCall_('setVolume', this.cache_.volume);
}
// Look if the tech found a higher resolution poster while loading
this.handleTechPosterChange_();
// Update the duration if available
this.handleTechDurationChange_();
}
/**
* Retrigger the `loadstart` event that was triggered by the {@link Tech}.
*
* @fires Player#loadstart
* @listens Tech#loadstart
* @private
*/
handleTechLoadStart_() {
// TODO: Update to use `emptied` event instead. See #1277.
this.removeClass('vjs-ended', 'vjs-seeking');
// reset the error state
this.error(null);
// Update the duration
this.handleTechDurationChange_();
if (!this.paused()) {
/**
* Fired when the user agent begins looking for media data
*
* @event Player#loadstart
* @type {Event}
*/
this.trigger('loadstart');
} else {
// reset the hasStarted state
this.hasStarted(false);
this.trigger('loadstart');
}
// autoplay happens after loadstart for the browser,
// so we mimic that behavior
this.manualAutoplay_(this.autoplay() === true && this.options_.normalizeAutoplay ? 'play' : this.autoplay());
}
/**
* Handle autoplay string values, rather than the typical boolean
* values that should be handled by the tech. Note that this is not
* part of any specification. Valid values and what they do can be
* found on the autoplay getter at Player#autoplay()
*/
manualAutoplay_(type) {
if (!this.tech_ || typeof type !== 'string') {
return;
}
// Save original muted() value, set muted to true, and attempt to play().
// On promise rejection, restore muted from saved value
const resolveMuted = () => {
const previouslyMuted = this.muted();
this.muted(true);
const restoreMuted = () => {
this.muted(previouslyMuted);
};
// restore muted on play terminatation
this.playTerminatedQueue_.push(restoreMuted);
const mutedPromise = this.play();
if (!isPromise(mutedPromise)) {
return;
}
return mutedPromise.catch(err => {
restoreMuted();
throw new Error(`Rejection at manualAutoplay. Restoring muted value. ${err ? err : ''}`);
});
};
let promise;
// if muted defaults to true
// the only thing we can do is call play
if (type === 'any' && !this.muted()) {
promise = this.play();
if (isPromise(promise)) {
promise = promise.catch(resolveMuted);
}
} else if (type === 'muted' && !this.muted()) {
promise = resolveMuted();
} else {
promise = this.play();
}
if (!isPromise(promise)) {
return;
}
return promise.then(() => {
this.trigger({type: 'autoplay-success', autoplay: type});
}).catch(() => {
this.trigger({type: 'autoplay-failure', autoplay: type});
});
}
/**
* Update the internal source caches so that we return the correct source from
* `src()`, `currentSource()`, and `currentSources()`.
*
* > Note: `currentSources` will not be updated if the source that is passed in exists
* in the current `currentSources` cache.
*
*
* @param {Tech~SourceObject} srcObj
* A string or object source to update our caches to.
*/
updateSourceCaches_(srcObj = '') {
let src = srcObj;
let type = '';
if (typeof src !== 'string') {
src = srcObj.src;
type = srcObj.type;
}
// make sure all the caches are set to default values
// to prevent null checking
this.cache_.source = this.cache_.source || {};
this.cache_.sources = this.cache_.sources || [];
// try to get the type of the src that was passed in
if (src && !type) {
type = findMimetype(this, src);
}
// update `currentSource` cache always
this.cache_.source = merge({}, srcObj, {src, type});
const matchingSources = this.cache_.sources.filter((s) => s.src && s.src === src);
const sourceElSources = [];
const sourceEls = this.$$('source');
const matchingSourceEls = [];
for (let i = 0; i < sourceEls.length; i++) {
const sourceObj = Dom.getAttributes(sourceEls[i]);
sourceElSources.push(sourceObj);
if (sourceObj.src && sourceObj.src === src) {
matchingSourceEls.push(sourceObj.src);
}
}
// if we have matching source els but not matching sources
// the current source cache is not up to date
if (matchingSourceEls.length && !matchingSources.length) {
this.cache_.sources = sourceElSources;
// if we don't have matching source or source els set the
// sources cache to the `currentSource` cache
} else if (!matchingSources.length) {
this.cache_.sources = [this.cache_.source];
}
// update the tech `src` cache
this.cache_.src = src;
}
/**
* *EXPERIMENTAL* Fired when the source is set or changed on the {@link Tech}
* causing the media element to reload.
*
* It will fire for the initial source and each subsequent source.
* This event is a custom event from Video.js and is triggered by the {@link Tech}.
*
* The event object for this event contains a `src` property that will contain the source
* that was available when the event was triggered. This is generally only necessary if Video.js
* is switching techs while the source was being changed.
*
* It is also fired when `load` is called on the player (or media element)
* because the {@link https://html.spec.whatwg.org/multipage/media.html#dom-media-load|specification for `load`}
* says that the resource selection algorithm needs to be aborted and restarted.
* In this case, it is very likely that the `src` property will be set to the
* empty string `""` to indicate we do not know what the source will be but
* that it is changing.
*
* *This event is currently still experimental and may change in minor releases.*
* __To use this, pass `enableSourceset` option to the player.__
*
* @event Player#sourceset
* @type {Event}
* @prop {string} src
* The source url available when the `sourceset` was triggered.
* It will be an empty string if we cannot know what the source is
* but know that the source will change.
*/
/**
* Retrigger the `sourceset` event that was triggered by the {@link Tech}.
*
* @fires Player#sourceset
* @listens Tech#sourceset
* @private
*/
handleTechSourceset_(event) {
// only update the source cache when the source
// was not updated using the player api
if (!this.changingSrc_) {
let updateSourceCaches = (src) => this.updateSourceCaches_(src);
const playerSrc = this.currentSource().src;
const eventSrc = event.src;
// if we have a playerSrc that is not a blob, and a tech src that is a blob
if (playerSrc && !(/^blob:/).test(playerSrc) && (/^blob:/).test(eventSrc)) {
// if both the tech source and the player source were updated we assume
// something like @videojs/http-streaming did the sourceset and skip updating the source cache.
if (!this.lastSource_ || (this.lastSource_.tech !== eventSrc && this.lastSource_.player !== playerSrc)) {
updateSourceCaches = () => {};
}
}
// update the source to the initial source right away
// in some cases this will be empty string
updateSourceCaches(eventSrc);
// if the `sourceset` `src` was an empty string
// wait for a `loadstart` to update the cache to `currentSrc`.
// If a sourceset happens before a `loadstart`, we reset the state
if (!event.src) {
this.tech_.any(['sourceset', 'loadstart'], (e) => {
// if a sourceset happens before a `loadstart` there
// is nothing to do as this `handleTechSourceset_`
// will be called again and this will be handled there.
if (e.type === 'sourceset') {
return;
}
const techSrc = this.techGet_('currentSrc');
this.lastSource_.tech = techSrc;
this.updateSourceCaches_(techSrc);
});
}
}
this.lastSource_ = {player: this.currentSource().src, tech: event.src};
this.trigger({
src: event.src,
type: 'sourceset'
});
}
/**
* Add/remove the vjs-has-started class
*
*
* @param {boolean} request
* - true: adds the class
* - false: remove the class
*
* @return {boolean}
* the boolean value of hasStarted_
*/
hasStarted(request) {
if (request === undefined) {
// act as getter, if we have no request to change
return this.hasStarted_;
}
if (request === this.hasStarted_) {
return;
}
this.hasStarted_ = request;
if (this.hasStarted_) {
this.addClass('vjs-has-started');
} else {
this.removeClass('vjs-has-started');
}
}
/**
* Fired whenever the media begins or resumes playback
*
* @see [Spec]{@link https://html.spec.whatwg.org/multipage/embedded-content.html#dom-media-play}
* @fires Player#play
* @listens Tech#play
* @private
*/
handleTechPlay_() {
this.removeClass('vjs-ended', 'vjs-paused');
this.addClass('vjs-playing');
// hide the poster when the user hits play
this.hasStarted(true);
/**
* Triggered whenever an {@link Tech#play} event happens. Indicates that
* playback has started or resumed.
*
* @event Player#play
* @type {Event}
*/
this.trigger('play');
}
/**
* Retrigger the `ratechange` event that was triggered by the {@link Tech}.
*
* If there were any events queued while the playback rate was zero, fire
* those events now.
*
* @private
* @method Player#handleTechRateChange_
* @fires Player#ratechange
* @listens Tech#ratechange
*/
handleTechRateChange_() {
if (this.tech_.playbackRate() > 0 && this.cache_.lastPlaybackRate === 0) {
this.queuedCallbacks_.forEach((queued) => queued.callback(queued.event));
this.queuedCallbacks_ = [];
}
this.cache_.lastPlaybackRate = this.tech_.playbackRate();
/**
* Fires when the playing speed of the audio/video is changed
*
* @event Player#ratechange
* @type {event}
*/
this.trigger('ratechange');
}
/**
* Retrigger the `waiting` event that was triggered by the {@link Tech}.
*
* @fires Player#waiting
* @listens Tech#waiting
* @private
*/
handleTechWaiting_() {
this.addClass('vjs-waiting');
/**
* A readyState change on the DOM element has caused playback to stop.
*
* @event Player#waiting
* @type {Event}
*/
this.trigger('waiting');
// Browsers may emit a timeupdate event after a waiting event. In order to prevent
// premature removal of the waiting class, wait for the time to change.
const timeWhenWaiting = this.currentTime();
const timeUpdateListener = () => {
if (timeWhenWaiting !== this.currentTime()) {
this.removeClass('vjs-waiting');
this.off('timeupdate', timeUpdateListener);
}
};
this.on('timeupdate', timeUpdateListener);
}
/**
* Retrigger the `canplay` event that was triggered by the {@link Tech}.
* > Note: This is not consistent between browsers. See #1351
*
* @fires Player#canplay
* @listens Tech#canplay
* @private
*/
handleTechCanPlay_() {
this.removeClass('vjs-waiting');
/**
* The media has a readyState of HAVE_FUTURE_DATA or greater.
*
* @event Player#canplay
* @type {Event}
*/
this.trigger('canplay');
}
/**
* Retrigger the `canplaythrough` event that was triggered by the {@link Tech}.
*
* @fires Player#canplaythrough
* @listens Tech#canplaythrough
* @private
*/
handleTechCanPlayThrough_() {
this.removeClass('vjs-waiting');
/**
* The media has a readyState of HAVE_ENOUGH_DATA or greater. This means that the
* entire media file can be played without buffering.
*
* @event Player#canplaythrough
* @type {Event}
*/
this.trigger('canplaythrough');
}
/**
* Retrigger the `playing` event that was triggered by the {@link Tech}.
*
* @fires Player#playing
* @listens Tech#playing
* @private
*/
handleTechPlaying_() {
this.removeClass('vjs-waiting');
/**
* The media is no longer blocked from playback, and has started playing.
*
* @event Player#playing
* @type {Event}
*/
this.trigger('playing');
}
/**
* Retrigger the `seeking` event that was triggered by the {@link Tech}.
*
* @fires Player#seeking
* @listens Tech#seeking
* @private
*/
handleTechSeeking_() {
this.addClass('vjs-seeking');
/**
* Fired whenever the player is jumping to a new time
*
* @event Player#seeking
* @type {Event}
*/
this.trigger('seeking');
}
/**
* Retrigger the `seeked` event that was triggered by the {@link Tech}.
*
* @fires Player#seeked
* @listens Tech#seeked
* @private
*/
handleTechSeeked_() {
this.removeClass('vjs-seeking', 'vjs-ended');
/**
* Fired when the player has finished jumping to a new time
*
* @event Player#seeked
* @type {Event}
*/
this.trigger('seeked');
}
/**
* Retrigger the `pause` event that was triggered by the {@link Tech}.
*
* @fires Player#pause
* @listens Tech#pause
* @private
*/
handleTechPause_() {
this.removeClass('vjs-playing');
this.addClass('vjs-paused');
/**
* Fired whenever the media has been paused
*
* @event Player#pause
* @type {Event}
*/
this.trigger('pause');
}
/**
* Retrigger the `ended` event that was triggered by the {@link Tech}.
*
* @fires Player#ended
* @listens Tech#ended
* @private
*/
handleTechEnded_() {
this.addClass('vjs-ended');
this.removeClass('vjs-waiting');
if (this.options_.loop) {
this.currentTime(0);
this.play();
} else if (!this.paused()) {
this.pause();
}
/**
* Fired when the end of the media resource is reached (currentTime == duration)
*
* @event Player#ended
* @type {Event}
*/
this.trigger('ended');
}
/**
* Fired when the duration of the media resource is first known or changed
*
* @listens Tech#durationchange
* @private
*/
handleTechDurationChange_() {
this.duration(this.techGet_('duration'));
}
/**
* Handle a click on the media element to play/pause
*
* @param {Event} event
* the event that caused this function to trigger
*
* @listens Tech#click
* @private
*/
handleTechClick_(event) {
// When controls are disabled a click should not toggle playback because
// the click is considered a control
if (!this.controls_) {
return;
}
if (
this.options_ === undefined ||
this.options_.userActions === undefined ||
this.options_.userActions.click === undefined ||
this.options_.userActions.click !== false
) {
if (
this.options_ !== undefined &&
this.options_.userActions !== undefined &&
typeof this.options_.userActions.click === 'function'
) {
this.options_.userActions.click.call(this, event);
} else if (this.paused()) {
silencePromise(this.play());
} else {
this.pause();
}
}
}
/**
* Handle a double-click on the media element to enter/exit fullscreen
*
* @param {Event} event
* the event that caused this function to trigger
*
* @listens Tech#dblclick
* @private
*/
handleTechDoubleClick_(event) {
if (!this.controls_) {
return;
}
// we do not want to toggle fullscreen state
// when double-clicking inside a control bar or a modal
const inAllowedEls = Array.prototype.some.call(
this.$$('.vjs-control-bar, .vjs-modal-dialog'),
el => el.contains(event.target)
);
if (!inAllowedEls) {
/*
* options.userActions.doubleClick
*
* If `undefined` or `true`, double-click toggles fullscreen if controls are present
* Set to `false` to disable double-click handling
* Set to a function to substitute an external double-click handler
*/
if (
this.options_ === undefined ||
this.options_.userActions === undefined ||
this.options_.userActions.doubleClick === undefined ||
this.options_.userActions.doubleClick !== false
) {
if (
this.options_ !== undefined &&
this.options_.userActions !== undefined &&
typeof this.options_.userActions.doubleClick === 'function'
) {
this.options_.userActions.doubleClick.call(this, event);
} else if (this.isFullscreen()) {
this.exitFullscreen();
} else {
this.requestFullscreen();
}
}
}
}
/**
* Handle a tap on the media element. It will toggle the user
* activity state, which hides and shows the controls.
*
* @listens Tech#tap
* @private
*/
handleTechTap_() {
this.userActive(!this.userActive());
}
/**
* Handle touch to start
*
* @listens Tech#touchstart
* @private
*/
handleTechTouchStart_() {
this.userWasActive = this.userActive();
}
/**
* Handle touch to move
*
* @listens Tech#touchmove
* @private
*/
handleTechTouchMove_() {
if (this.userWasActive) {
this.reportUserActivity();
}
}
/**
* Handle touch to end
*
* @param {Event} event
* the touchend event that triggered
* this function
*
* @listens Tech#touchend
* @private
*/
handleTechTouchEnd_(event) {
// Stop the mouse events from also happening
if (event.cancelable) {
event.preventDefault();
}
}
/**
* @private
*/
toggleFullscreenClass_() {
if (this.isFullscreen()) {
this.addClass('vjs-fullscreen');
} else {
this.removeClass('vjs-fullscreen');
}
}
/**
* when the document fschange event triggers it calls this
*/
documentFullscreenChange_(e) {
const targetPlayer = e.target.player;
// if another player was fullscreen
// do a null check for targetPlayer because older firefox's would put document as e.target
if (targetPlayer && targetPlayer !== this) {
return;
}
const el = this.el();
let isFs = document[this.fsApi_.fullscreenElement] === el;
if (!isFs && el.matches) {
isFs = el.matches(':' + this.fsApi_.fullscreen);
}
this.isFullscreen(isFs);
}
/**
* Handle Tech Fullscreen Change
*
* @param {Event} event
* the fullscreenchange event that triggered this function
*
* @param {Object} data
* the data that was sent with the event
*
* @private
* @listens Tech#fullscreenchange
* @fires Player#fullscreenchange
*/
handleTechFullscreenChange_(event, data) {
if (data) {
if (data.nativeIOSFullscreen) {
this.addClass('vjs-ios-native-fs');
this.tech_.one('webkitendfullscreen', () => {
this.removeClass('vjs-ios-native-fs');
});
}
this.isFullscreen(data.isFullscreen);
}
}
handleTechFullscreenError_(event, err) {
this.trigger('fullscreenerror', err);
}
/**
* @private
*/
togglePictureInPictureClass_() {
if (this.isInPictureInPicture()) {
this.addClass('vjs-picture-in-picture');
} else {
this.removeClass('vjs-picture-in-picture');
}
}
/**
* Handle Tech Enter Picture-in-Picture.
*
* @param {Event} event
* the enterpictureinpicture event that triggered this function
*
* @private
* @listens Tech#enterpictureinpicture
*/
handleTechEnterPictureInPicture_(event) {
this.isInPictureInPicture(true);
}
/**
* Handle Tech Leave Picture-in-Picture.
*
* @param {Event} event
* the leavepictureinpicture event that triggered this function
*
* @private
* @listens Tech#leavepictureinpicture
*/
handleTechLeavePictureInPicture_(event) {
this.isInPictureInPicture(false);
}
/**
* Fires when an error occurred during the loading of an audio/video.
*
* @private
* @listens Tech#error
*/
handleTechError_() {
const error = this.tech_.error();
if (error) {
this.error(error);
}
}
/**
* Retrigger the `textdata` event that was triggered by the {@link Tech}.
*
* @fires Player#textdata
* @listens Tech#textdata
* @private
*/
handleTechTextData_() {
let data = null;
if (arguments.length > 1) {
data = arguments[1];
}
/**
* Fires when we get a textdata event from tech
*
* @event Player#textdata
* @type {Event}
*/
this.trigger('textdata', data);
}
/**
* Get object for cached values.
*
* @return {Object}
* get the current object cache
*/
getCache() {
return this.cache_;
}
/**
* Resets the internal cache object.
*
* Using this function outside the player constructor or reset method may
* have unintended side-effects.
*
* @private
*/
resetCache_() {
this.cache_ = {
// Right now, the currentTime is not _really_ cached because it is always
// retrieved from the tech (see: currentTime). However, for completeness,
// we set it to zero here to ensure that if we do start actually caching
// it, we reset it along with everything else.
currentTime: 0,
initTime: 0,
inactivityTimeout: this.options_.inactivityTimeout,
duration: NaN,
lastVolume: 1,
lastPlaybackRate: this.defaultPlaybackRate(),
media: null,
src: '',
source: {},
sources: [],
playbackRates: [],
volume: 1
};
}
/**
* Pass values to the playback tech
*
* @param {string} [method]
* the method to call
*
* @param {Object} [arg]
* the argument to pass
*
* @private
*/
techCall_(method, arg) {
// If it's not ready yet, call method when it is
this.ready(function() {
if (method in middleware.allowedSetters) {
return middleware.set(this.middleware_, this.tech_, method, arg);
} else if (method in middleware.allowedMediators) {
return middleware.mediate(this.middleware_, this.tech_, method, arg);
}
try {
if (this.tech_) {
this.tech_[method](arg);
}
} catch (e) {
log(e);
throw e;
}
}, true);
}
/**
* Mediate attempt to call playback tech method
* and return the value of the method called.
*
* @param {string} method
* Tech method
*
* @return {*}
* Value returned by the tech method called, undefined if tech
* is not ready or tech method is not present
*
* @private
*/
techGet_(method) {
if (!this.tech_ || !this.tech_.isReady_) {
return;
}
if (method in middleware.allowedGetters) {
return middleware.get(this.middleware_, this.tech_, method);
} else if (method in middleware.allowedMediators) {
return middleware.mediate(this.middleware_, this.tech_, method);
}
// Log error when playback tech object is present but method
// is undefined or unavailable
try {
return this.tech_[method]();
} catch (e) {
// When building additional tech libs, an expected method may not be defined yet
if (this.tech_[method] === undefined) {
log(`Video.js: ${method} method not defined for ${this.techName_} playback technology.`, e);
throw e;
}
// When a method isn't available on the object it throws a TypeError
if (e.name === 'TypeError') {
log(`Video.js: ${method} unavailable on ${this.techName_} playback technology element.`, e);
this.tech_.isReady_ = false;
throw e;
}
// If error unknown, just log and throw
log(e);
throw e;
}
}
/**
* Attempt to begin playback at the first opportunity.
*
* @return {Promise|undefined}
* Returns a promise if the browser supports Promises (or one
* was passed in as an option). This promise will be resolved on
* the return value of play. If this is undefined it will fulfill the
* promise chain otherwise the promise chain will be fulfilled when
* the promise from play is fulfilled.
*/
play() {
return new Promise((resolve) => {
this.play_(resolve);
});
}
/**
* The actual logic for play, takes a callback that will be resolved on the
* return value of play. This allows us to resolve to the play promise if there
* is one on modern browsers.
*
* @private
* @param {Function} [callback]
* The callback that should be called when the techs play is actually called
*/
play_(callback = silencePromise) {
this.playCallbacks_.push(callback);
const isSrcReady = Boolean(!this.changingSrc_ && (this.src() || this.currentSrc()));
const isSafariOrIOS = Boolean(browser.IS_ANY_SAFARI || browser.IS_IOS);
// treat calls to play_ somewhat like the `one` event function
if (this.waitToPlay_) {
this.off(['ready', 'loadstart'], this.waitToPlay_);
this.waitToPlay_ = null;
}
// if the player/tech is not ready or the src itself is not ready
// queue up a call to play on `ready` or `loadstart`
if (!this.isReady_ || !isSrcReady) {
this.waitToPlay_ = (e) => {
this.play_();
};
this.one(['ready', 'loadstart'], this.waitToPlay_);
// if we are in Safari, there is a high chance that loadstart will trigger after the gesture timeperiod
// in that case, we need to prime the video element by calling load so it'll be ready in time
if (!isSrcReady && isSafariOrIOS) {
this.load();
}
return;
}
// If the player/tech is ready and we have a source, we can attempt playback.
const val = this.techGet_('play');
// For native playback, reset the progress bar if we get a play call from a replay.
const isNativeReplay = isSafariOrIOS && this.hasClass('vjs-ended');
if (isNativeReplay) {
this.resetProgressBar_();
}
// play was terminated if the returned value is null
if (val === null) {
this.runPlayTerminatedQueue_();
} else {
this.runPlayCallbacks_(val);
}
}
/**
* These functions will be run when if play is terminated. If play
* runPlayCallbacks_ is run these function will not be run. This allows us
* to differentiate between a terminated play and an actual call to play.
*/
runPlayTerminatedQueue_() {
const queue = this.playTerminatedQueue_.slice(0);
this.playTerminatedQueue_ = [];
queue.forEach(function(q) {
q();
});
}
/**
* When a callback to play is delayed we have to run these
* callbacks when play is actually called on the tech. This function
* runs the callbacks that were delayed and accepts the return value
* from the tech.
*
* @param {undefined|Promise} val
* The return value from the tech.
*/
runPlayCallbacks_(val) {
const callbacks = this.playCallbacks_.slice(0);
this.playCallbacks_ = [];
// clear play terminatedQueue since we finished a real play
this.playTerminatedQueue_ = [];
callbacks.forEach(function(cb) {
cb(val);
});
}
/**
* Pause the video playback
*/
pause() {
this.techCall_('pause');
}
/**
* Check if the player is paused or has yet to play
*
* @return {boolean}
* - false: if the media is currently playing
* - true: if media is not currently playing
*/
paused() {
// The initial state of paused should be true (in Safari it's actually false)
return (this.techGet_('paused') === false) ? false : true;
}
/**
* Get a TimeRange object representing the current ranges of time that the user
* has played.
*
* @return { import('./utils/time').TimeRange }
* A time range object that represents all the increments of time that have
* been played.
*/
played() {
return this.techGet_('played') || createTimeRange(0, 0);
}
/**
* Sets or returns whether or not the user is "scrubbing". Scrubbing is
* when the user has clicked the progress bar handle and is
* dragging it along the progress bar.
*
* @param {boolean} [isScrubbing]
* whether the user is or is not scrubbing
*
* @return {boolean|undefined}
* - The value of scrubbing when getting
* - Nothing when setting
*/
scrubbing(isScrubbing) {
if (typeof isScrubbing === 'undefined') {
return this.scrubbing_;
}
this.scrubbing_ = !!isScrubbing;
this.techCall_('setScrubbing', this.scrubbing_);
if (isScrubbing) {
this.addClass('vjs-scrubbing');
} else {
this.removeClass('vjs-scrubbing');
}
}
/**
* Get or set the current time (in seconds)
*
* @param {number|string} [seconds]
* The time to seek to in seconds
*
* @return {number|undefined}
* - the current time in seconds when getting
* - Nothing when setting
*/
currentTime(seconds) {
if (seconds === undefined) {
// cache last currentTime and return. default to 0 seconds
//
// Caching the currentTime is meant to prevent a massive amount of reads on the tech's
// currentTime when scrubbing, but may not provide much performance benefit after all.
// Should be tested. Also something has to read the actual current time or the cache will
// never get updated.
this.cache_.currentTime = (this.techGet_('currentTime') || 0);
return this.cache_.currentTime;
}
if (seconds < 0) {
seconds = 0;
}
if (!this.isReady_ || this.changingSrc_ || !this.tech_ || !this.tech_.isReady_) {
this.cache_.initTime = seconds;
this.off('canplay', this.boundApplyInitTime_);
this.one('canplay', this.boundApplyInitTime_);
return;
}
this.techCall_('setCurrentTime', seconds);
this.cache_.initTime = 0;
if (isFinite(seconds)) {
this.cache_.currentTime = Number(seconds);
}
}
/**
* Apply the value of initTime stored in cache as currentTime.
*
* @private
*/
applyInitTime_() {
this.currentTime(this.cache_.initTime);
}
/**
* Normally gets the length in time of the video in seconds;
* in all but the rarest use cases an argument will NOT be passed to the method
*
* > **NOTE**: The video must have started loading before the duration can be
* known, and depending on preload behaviour may not be known until the video starts
* playing.
*
* @fires Player#durationchange
*
* @param {number} [seconds]
* The duration of the video to set in seconds
*
* @return {number|undefined}
* - The duration of the video in seconds when getting
* - Nothing when setting
*/
duration(seconds) {
if (seconds === undefined) {
// return NaN if the duration is not known
return this.cache_.duration !== undefined ? this.cache_.duration : NaN;
}
seconds = parseFloat(seconds);
// Standardize on Infinity for signaling video is live
if (seconds < 0) {
seconds = Infinity;
}
if (seconds !== this.cache_.duration) {
// Cache the last set value for optimized scrubbing
this.cache_.duration = seconds;
if (seconds === Infinity) {
this.addClass('vjs-live');
} else {
this.removeClass('vjs-live');
}
if (!isNaN(seconds)) {
// Do not fire durationchange unless the duration value is known.
// @see [Spec]{@link https://www.w3.org/TR/2011/WD-html5-20110113/video.html#media-element-load-algorithm}
/**
* @event Player#durationchange
* @type {Event}
*/
this.trigger('durationchange');
}
}
}
/**
* Calculates how much time is left in the video. Not part
* of the native video API.
*
* @return {number}
* The time remaining in seconds
*/
remainingTime() {
return this.duration() - this.currentTime();
}
/**
* A remaining time function that is intended to be used when
* the time is to be displayed directly to the user.
*
* @return {number}
* The rounded time remaining in seconds
*/
remainingTimeDisplay() {
return Math.floor(this.duration()) - Math.floor(this.currentTime());
}
//
// Kind of like an array of portions of the video that have been downloaded.
/**
* Get a TimeRange object with an array of the times of the video
* that have been downloaded. If you just want the percent of the
* video that's been downloaded, use bufferedPercent.
*
* @see [Buffered Spec]{@link http://dev.w3.org/html5/spec/video.html#dom-media-buffered}
*
* @return { import('./utils/time').TimeRange }
* A mock {@link TimeRanges} object (following HTML spec)
*/
buffered() {
let buffered = this.techGet_('buffered');
if (!buffered || !buffered.length) {
buffered = createTimeRange(0, 0);
}
return buffered;
}
/**
* Get the TimeRanges of the media that are currently available
* for seeking to.
*
* @see [Seekable Spec]{@link https://html.spec.whatwg.org/multipage/media.html#dom-media-seekable}
*
* @return { import('./utils/time').TimeRange }
* A mock {@link TimeRanges} object (following HTML spec)
*/
seekable() {
let seekable = this.techGet_('seekable');
if (!seekable || !seekable.length) {
seekable = createTimeRange(0, 0);
}
return seekable;
}
/**
* Returns whether the player is in the "seeking" state.
*
* @return {boolean} True if the player is in the seeking state, false if not.
*/
seeking() {
return this.techGet_('seeking');
}
/**
* Returns whether the player is in the "ended" state.
*
* @return {boolean} True if the player is in the ended state, false if not.
*/
ended() {
return this.techGet_('ended');
}
/**
* Returns the current state of network activity for the element, from
* the codes in the list below.
* - NETWORK_EMPTY (numeric value 0)
* The element has not yet been initialised. All attributes are in
* their initial states.
* - NETWORK_IDLE (numeric value 1)
* The element's resource selection algorithm is active and has
* selected a resource, but it is not actually using the network at
* this time.
* - NETWORK_LOADING (numeric value 2)
* The user agent is actively trying to download data.
* - NETWORK_NO_SOURCE (numeric value 3)
* The element's resource selection algorithm is active, but it has
* not yet found a resource to use.
*
* @see https://html.spec.whatwg.org/multipage/embedded-content.html#network-states
* @return {number} the current network activity state
*/
networkState() {
return this.techGet_('networkState');
}
/**
* Returns a value that expresses the current state of the element
* with respect to rendering the current playback position, from the
* codes in the list below.
* - HAVE_NOTHING (numeric value 0)
* No information regarding the media resource is available.
* - HAVE_METADATA (numeric value 1)
* Enough of the resource has been obtained that the duration of the
* resource is available.
* - HAVE_CURRENT_DATA (numeric value 2)
* Data for the immediate current playback position is available.
* - HAVE_FUTURE_DATA (numeric value 3)
* Data for the immediate current playback position is available, as
* well as enough data for the user agent to advance the current
* playback position in the direction of playback.
* - HAVE_ENOUGH_DATA (numeric value 4)
* The user agent estimates that enough data is available for
* playback to proceed uninterrupted.
*
* @see https://html.spec.whatwg.org/multipage/embedded-content.html#dom-media-readystate
* @return {number} the current playback rendering state
*/
readyState() {
return this.techGet_('readyState');
}
/**
* Get the percent (as a decimal) of the video that's been downloaded.
* This method is not a part of the native HTML video API.
*
* @return {number}
* A decimal between 0 and 1 representing the percent
* that is buffered 0 being 0% and 1 being 100%
*/
bufferedPercent() {
return bufferedPercent(this.buffered(), this.duration());
}
/**
* Get the ending time of the last buffered time range
* This is used in the progress bar to encapsulate all time ranges.
*
* @return {number}
* The end of the last buffered time range
*/
bufferedEnd() {
const buffered = this.buffered();
const duration = this.duration();
let end = buffered.end(buffered.length - 1);
if (end > duration) {
end = duration;
}
return end;
}
/**
* Get or set the current volume of the media
*
* @param {number} [percentAsDecimal]
* The new volume as a decimal percent:
* - 0 is muted/0%/off
* - 1.0 is 100%/full
* - 0.5 is half volume or 50%
*
* @return {number|undefined}
* The current volume as a percent when getting
*/
volume(percentAsDecimal) {
let vol;
if (percentAsDecimal !== undefined) {
// Force value to between 0 and 1
vol = Math.max(0, Math.min(1, percentAsDecimal));
this.cache_.volume = vol;
this.techCall_('setVolume', vol);
if (vol > 0) {
this.lastVolume_(vol);
}
return;
}
// Default to 1 when returning current volume.
vol = parseFloat(this.techGet_('volume'));
return (isNaN(vol)) ? 1 : vol;
}
/**
* Get the current muted state, or turn mute on or off
*
* @param {boolean} [muted]
* - true to mute
* - false to unmute
*
* @return {boolean|undefined}
* - true if mute is on and getting
* - false if mute is off and getting
* - nothing if setting
*/
muted(muted) {
if (muted !== undefined) {
this.techCall_('setMuted', muted);
return;
}
return this.techGet_('muted') || false;
}
/**
* Get the current defaultMuted state, or turn defaultMuted on or off. defaultMuted
* indicates the state of muted on initial playback.
*
* ```js
* var myPlayer = videojs('some-player-id');
*
* myPlayer.src("http://www.example.com/path/to/video.mp4");
*
* // get, should be false
* console.log(myPlayer.defaultMuted());
* // set to true
* myPlayer.defaultMuted(true);
* // get should be true
* console.log(myPlayer.defaultMuted());
* ```
*
* @param {boolean} [defaultMuted]
* - true to mute
* - false to unmute
*
* @return {boolean|undefined}
* - true if defaultMuted is on and getting
* - false if defaultMuted is off and getting
* - Nothing when setting
*/
defaultMuted(defaultMuted) {
if (defaultMuted !== undefined) {
this.techCall_('setDefaultMuted', defaultMuted);
}
return this.techGet_('defaultMuted') || false;
}
/**
* Get the last volume, or set it
*
* @param {number} [percentAsDecimal]
* The new last volume as a decimal percent:
* - 0 is muted/0%/off
* - 1.0 is 100%/full
* - 0.5 is half volume or 50%
*
* @return {number|undefined}
* - The current value of lastVolume as a percent when getting
* - Nothing when setting
*
* @private
*/
lastVolume_(percentAsDecimal) {
if (percentAsDecimal !== undefined && percentAsDecimal !== 0) {
this.cache_.lastVolume = percentAsDecimal;
return;
}
return this.cache_.lastVolume;
}
/**
* Check if current tech can support native fullscreen
* (e.g. with built in controls like iOS)
*
* @return {boolean}
* if native fullscreen is supported
*/
supportsFullScreen() {
return this.techGet_('supportsFullScreen') || false;
}
/**
* Check if the player is in fullscreen mode or tell the player that it
* is or is not in fullscreen mode.
*
* > NOTE: As of the latest HTML5 spec, isFullscreen is no longer an official
* property and instead document.fullscreenElement is used. But isFullscreen is
* still a valuable property for internal player workings.
*
* @param {boolean} [isFS]
* Set the players current fullscreen state
*
* @return {boolean|undefined}
* - true if fullscreen is on and getting
* - false if fullscreen is off and getting
* - Nothing when setting
*/
isFullscreen(isFS) {
if (isFS !== undefined) {
const oldValue = this.isFullscreen_;
this.isFullscreen_ = Boolean(isFS);
// if we changed fullscreen state and we're in prefixed mode, trigger fullscreenchange
// this is the only place where we trigger fullscreenchange events for older browsers
// fullWindow mode is treated as a prefixed event and will get a fullscreenchange event as well
if (this.isFullscreen_ !== oldValue && this.fsApi_.prefixed) {
/**
* @event Player#fullscreenchange
* @type {Event}
*/
this.trigger('fullscreenchange');
}
this.toggleFullscreenClass_();
return;
}
return this.isFullscreen_;
}
/**
* Increase the size of the video to full screen
* In some browsers, full screen is not supported natively, so it enters
* "full window mode", where the video fills the browser window.
* In browsers and devices that support native full screen, sometimes the
* browser's default controls will be shown, and not the Video.js custom skin.
* This includes most mobile devices (iOS, Android) and older versions of
* Safari.
*
* @param {Object} [fullscreenOptions]
* Override the player fullscreen options
*
* @fires Player#fullscreenchange
*/
requestFullscreen(fullscreenOptions) {
if (this.isInPictureInPicture()) {
this.exitPictureInPicture();
}
const self = this;
return new Promise((resolve, reject) => {
function offHandler() {
self.off('fullscreenerror', errorHandler);
self.off('fullscreenchange', changeHandler);
}
function changeHandler() {
offHandler();
resolve();
}
function errorHandler(e, err) {
offHandler();
reject(err);
}
self.one('fullscreenchange', changeHandler);
self.one('fullscreenerror', errorHandler);
const promise = self.requestFullscreenHelper_(fullscreenOptions);
if (promise) {
promise.then(offHandler, offHandler);
promise.then(resolve, reject);
}
});
}
requestFullscreenHelper_(fullscreenOptions) {
let fsOptions;
// Only pass fullscreen options to requestFullscreen in spec-compliant browsers.
// Use defaults or player configured option unless passed directly to this method.
if (!this.fsApi_.prefixed) {
fsOptions = this.options_.fullscreen && this.options_.fullscreen.options || {};
if (fullscreenOptions !== undefined) {
fsOptions = fullscreenOptions;
}
}
// This method works as follows:
// 1. if a fullscreen api is available, use it
// 1. call requestFullscreen with potential options
// 2. if we got a promise from above, use it to update isFullscreen()
// 2. otherwise, if the tech supports fullscreen, call `enterFullScreen` on it.
// This is particularly used for iPhone, older iPads, and non-safari browser on iOS.
// 3. otherwise, use "fullWindow" mode
if (this.fsApi_.requestFullscreen) {
const promise = this.el_[this.fsApi_.requestFullscreen](fsOptions);
// Even on browsers with promise support this may not return a promise
if (promise) {
promise.then(() => this.isFullscreen(true), () => this.isFullscreen(false));
}
return promise;
} else if (this.tech_.supportsFullScreen() && !this.options_.preferFullWindow === true) {
// we can't take the video.js controls fullscreen but we can go fullscreen
// with native controls
this.techCall_('enterFullScreen');
} else {
// fullscreen isn't supported so we'll just stretch the video element to
// fill the viewport
this.enterFullWindow();
}
}
/**
* Return the video to its normal size after having been in full screen mode
*
* @fires Player#fullscreenchange
*/
exitFullscreen() {
const self = this;
return new Promise((resolve, reject) => {
function offHandler() {
self.off('fullscreenerror', errorHandler);
self.off('fullscreenchange', changeHandler);
}
function changeHandler() {
offHandler();
resolve();
}
function errorHandler(e, err) {
offHandler();
reject(err);
}
self.one('fullscreenchange', changeHandler);
self.one('fullscreenerror', errorHandler);
const promise = self.exitFullscreenHelper_();
if (promise) {
promise.then(offHandler, offHandler);
// map the promise to our resolve/reject methods
promise.then(resolve, reject);
}
});
}
exitFullscreenHelper_() {
if (this.fsApi_.requestFullscreen) {
const promise = document[this.fsApi_.exitFullscreen]();
// Even on browsers with promise support this may not return a promise
if (promise) {
// we're splitting the promise here, so, we want to catch the
// potential error so that this chain doesn't have unhandled errors
silencePromise(promise.then(() => this.isFullscreen(false)));
}
return promise;
} else if (this.tech_.supportsFullScreen() && !this.options_.preferFullWindow === true) {
this.techCall_('exitFullScreen');
} else {
this.exitFullWindow();
}
}
/**
* When fullscreen isn't supported we can stretch the
* video container to as wide as the browser will let us.
*
* @fires Player#enterFullWindow
*/
enterFullWindow() {
this.isFullscreen(true);
this.isFullWindow = true;
// Storing original doc overflow value to return to when fullscreen is off
this.docOrigOverflow = document.documentElement.style.overflow;
// Add listener for esc key to exit fullscreen
Events.on(document, 'keydown', this.boundFullWindowOnEscKey_);
// Hide any scroll bars
document.documentElement.style.overflow = 'hidden';
// Apply fullscreen styles
Dom.addClass(document.body, 'vjs-full-window');
/**
* @event Player#enterFullWindow
* @type {Event}
*/
this.trigger('enterFullWindow');
}
/**
* Check for call to either exit full window or
* full screen on ESC key
*
* @param {string} event
* Event to check for key press
*/
fullWindowOnEscKey(event) {
if (keycode.isEventKey(event, 'Esc')) {
if (this.isFullscreen() === true) {
if (!this.isFullWindow) {
this.exitFullscreen();
} else {
this.exitFullWindow();
}
}
}
}
/**
* Exit full window
*
* @fires Player#exitFullWindow
*/
exitFullWindow() {
this.isFullscreen(false);
this.isFullWindow = false;
Events.off(document, 'keydown', this.boundFullWindowOnEscKey_);
// Unhide scroll bars.
document.documentElement.style.overflow = this.docOrigOverflow;
// Remove fullscreen styles
Dom.removeClass(document.body, 'vjs-full-window');
// Resize the box, controller, and poster to original sizes
// this.positionAll();
/**
* @event Player#exitFullWindow
* @type {Event}
*/
this.trigger('exitFullWindow');
}
/**
* Get or set disable Picture-in-Picture mode.
*
* @param {boolean} [value]
* - true will disable Picture-in-Picture mode
* - false will enable Picture-in-Picture mode
*/
disablePictureInPicture(value) {
if (value === undefined) {
return this.techGet_('disablePictureInPicture');
}
this.techCall_('setDisablePictureInPicture', value);
this.options_.disablePictureInPicture = value;
this.trigger('disablepictureinpicturechanged');
}
/**
* Check if the player is in Picture-in-Picture mode or tell the player that it
* is or is not in Picture-in-Picture mode.
*
* @param {boolean} [isPiP]
* Set the players current Picture-in-Picture state
*
* @return {boolean|undefined}
* - true if Picture-in-Picture is on and getting
* - false if Picture-in-Picture is off and getting
* - nothing if setting
*/
isInPictureInPicture(isPiP) {
if (isPiP !== undefined) {
this.isInPictureInPicture_ = !!isPiP;
this.togglePictureInPictureClass_();
return;
}
return !!this.isInPictureInPicture_;
}
/**
* Create a floating video window always on top of other windows so that users may
* continue consuming media while they interact with other content sites, or
* applications on their device.
*
* This can use document picture-in-picture or element picture in picture
*
* Set `enableDocumentPictureInPicture` to `true` to use docPiP on a supported browser
* Else set `disablePictureInPicture` to `false` to disable elPiP on a supported browser
*
*
* @see [Spec]{@link https://w3c.github.io/picture-in-picture/}
* @see [Spec]{@link https://wicg.github.io/document-picture-in-picture/}
*
* @fires Player#enterpictureinpicture
*
* @return {Promise}
* A promise with a Picture-in-Picture window.
*/
requestPictureInPicture() {
if (this.options_.enableDocumentPictureInPicture && window.documentPictureInPicture) {
const pipContainer = document.createElement(this.el().tagName);
pipContainer.classList = this.el().classList;
pipContainer.classList.add('vjs-pip-container');
if (this.posterImage) {
pipContainer.appendChild(this.posterImage.el().cloneNode(true));
}
if (this.titleBar) {
pipContainer.appendChild(this.titleBar.el().cloneNode(true));
}
pipContainer.appendChild(Dom.createEl('p', { className: 'vjs-pip-text' }, {}, this.localize('Playing in picture-in-picture')));
return window.documentPictureInPicture.requestWindow({
// The aspect ratio won't be correct, Chrome bug https://crbug.com/1407629
width: this.videoWidth(),
height: this.videoHeight()
}).then(pipWindow => {
Dom.copyStyleSheetsToWindow(pipWindow);
this.el_.parentNode.insertBefore(pipContainer, this.el_);
pipWindow.document.body.appendChild(this.el_);
pipWindow.document.body.classList.add('vjs-pip-window');
this.player_.isInPictureInPicture(true);
this.player_.trigger({type: 'enterpictureinpicture', pipWindow});
// Listen for the PiP closing event to move the video back.
pipWindow.addEventListener('pagehide', (event) => {
const pipVideo = event.target.querySelector('.video-js');
pipContainer.parentNode.replaceChild(pipVideo, pipContainer);
this.player_.isInPictureInPicture(false);
this.player_.trigger('leavepictureinpicture');
});
return pipWindow;
});
}
if ('pictureInPictureEnabled' in document && this.disablePictureInPicture() === false) {
/**
* This event fires when the player enters picture in picture mode
*
* @event Player#enterpictureinpicture
* @type {Event}
*/
return this.techGet_('requestPictureInPicture');
}
return Promise.reject('No PiP mode is available');
}
/**
* Exit Picture-in-Picture mode.
*
* @see [Spec]{@link https://wicg.github.io/picture-in-picture}
*
* @fires Player#leavepictureinpicture
*
* @return {Promise}
* A promise.
*/
exitPictureInPicture() {
if (window.documentPictureInPicture && window.documentPictureInPicture.window) {
// With documentPictureInPicture, Player#leavepictureinpicture is fired in the pagehide handler
window.documentPictureInPicture.window.close();
return Promise.resolve();
}
if ('pictureInPictureEnabled' in document) {
/**
* This event fires when the player leaves picture in picture mode
*
* @event Player#leavepictureinpicture
* @type {Event}
*/
return document.exitPictureInPicture();
}
}
/**
* Called when this Player has focus and a key gets pressed down, or when
* any Component of this player receives a key press that it doesn't handle.
* This allows player-wide hotkeys (either as defined below, or optionally
* by an external function).
*
* @param {KeyboardEvent} event
* The `keydown` event that caused this function to be called.
*
* @listens keydown
*/
handleKeyDown(event) {
const {userActions} = this.options_;
// Bail out if hotkeys are not configured.
if (!userActions || !userActions.hotkeys) {
return;
}
// Function that determines whether or not to exclude an element from
// hotkeys handling.
const excludeElement = (el) => {
const tagName = el.tagName.toLowerCase();
// The first and easiest test is for `contenteditable` elements.
if (el.isContentEditable) {
return true;
}
// Inputs matching these types will still trigger hotkey handling as
// they are not text inputs.
const allowedInputTypes = [
'button',
'checkbox',
'hidden',
'radio',
'reset',
'submit'
];
if (tagName === 'input') {
return allowedInputTypes.indexOf(el.type) === -1;
}
// The final test is by tag name. These tags will be excluded entirely.
const excludedTags = ['textarea'];
return excludedTags.indexOf(tagName) !== -1;
};
// Bail out if the user is focused on an interactive form element.
if (excludeElement(this.el_.ownerDocument.activeElement)) {
return;
}
if (typeof userActions.hotkeys === 'function') {
userActions.hotkeys.call(this, event);
} else {
this.handleHotkeys(event);
}
}
/**
* Called when this Player receives a hotkey keydown event.
* Supported player-wide hotkeys are:
*
* f - toggle fullscreen
* m - toggle mute
* k or Space - toggle play/pause
*
* @param {Event} event
* The `keydown` event that caused this function to be called.
*/
handleHotkeys(event) {
const hotkeys = this.options_.userActions ? this.options_.userActions.hotkeys : {};
// set fullscreenKey, muteKey, playPauseKey from `hotkeys`, use defaults if not set
const {
fullscreenKey = keydownEvent => keycode.isEventKey(keydownEvent, 'f'),
muteKey = keydownEvent => keycode.isEventKey(keydownEvent, 'm'),
playPauseKey = keydownEvent => (keycode.isEventKey(keydownEvent, 'k') || keycode.isEventKey(keydownEvent, 'Space'))
} = hotkeys;
if (fullscreenKey.call(this, event)) {
event.preventDefault();
event.stopPropagation();
const FSToggle = Component.getComponent('FullscreenToggle');
if (document[this.fsApi_.fullscreenEnabled] !== false) {
FSToggle.prototype.handleClick.call(this, event);
}
} else if (muteKey.call(this, event)) {
event.preventDefault();
event.stopPropagation();
const MuteToggle = Component.getComponent('MuteToggle');
MuteToggle.prototype.handleClick.call(this, event);
} else if (playPauseKey.call(this, event)) {
event.preventDefault();
event.stopPropagation();
const PlayToggle = Component.getComponent('PlayToggle');
PlayToggle.prototype.handleClick.call(this, event);
}
}
/**
* Check whether the player can play a given mimetype
*
* @see https://www.w3.org/TR/2011/WD-html5-20110113/video.html#dom-navigator-canplaytype
*
* @param {string} type
* The mimetype to check
*
* @return {string}
* 'probably', 'maybe', or '' (empty string)
*/
canPlayType(type) {
let can;
// Loop through each playback technology in the options order
for (let i = 0, j = this.options_.techOrder; i < j.length; i++) {
const techName = j[i];
let tech = Tech.getTech(techName);
// Support old behavior of techs being registered as components.
// Remove once that deprecated behavior is removed.
if (!tech) {
tech = Component.getComponent(techName);
}
// Check if the current tech is defined before continuing
if (!tech) {
log.error(`The "${techName}" tech is undefined. Skipped browser support check for that tech.`);
continue;
}
// Check if the browser supports this technology
if (tech.isSupported()) {
can = tech.canPlayType(type);
if (can) {
return can;
}
}
}
return '';
}
/**
* Select source based on tech-order or source-order
* Uses source-order selection if `options.sourceOrder` is truthy. Otherwise,
* defaults to tech-order selection
*
* @param {Array} sources
* The sources for a media asset
*
* @return {Object|boolean}
* Object of source and tech order or false
*/
selectSource(sources) {
// Get only the techs specified in `techOrder` that exist and are supported by the
// current platform
const techs =
this.options_.techOrder
.map((techName) => {
return [techName, Tech.getTech(techName)];
})
.filter(([techName, tech]) => {
// Check if the current tech is defined before continuing
if (tech) {
// Check if the browser supports this technology
return tech.isSupported();
}
log.error(`The "${techName}" tech is undefined. Skipped browser support check for that tech.`);
return false;
});
// Iterate over each `innerArray` element once per `outerArray` element and execute
// `tester` with both. If `tester` returns a non-falsy value, exit early and return
// that value.
const findFirstPassingTechSourcePair = function(outerArray, innerArray, tester) {
let found;
outerArray.some((outerChoice) => {
return innerArray.some((innerChoice) => {
found = tester(outerChoice, innerChoice);
if (found) {
return true;
}
});
});
return found;
};
let foundSourceAndTech;
const flip = (fn) => (a, b) => fn(b, a);
const finder = ([techName, tech], source) => {
if (tech.canPlaySource(source, this.options_[techName.toLowerCase()])) {
return {source, tech: techName};
}
};
// Depending on the truthiness of `options.sourceOrder`, we swap the order of techs and sources
// to select from them based on their priority.
if (this.options_.sourceOrder) {
// Source-first ordering
foundSourceAndTech = findFirstPassingTechSourcePair(sources, techs, flip(finder));
} else {
// Tech-first ordering
foundSourceAndTech = findFirstPassingTechSourcePair(techs, sources, finder);
}
return foundSourceAndTech || false;
}
/**
* Executes source setting and getting logic
*
* @param {Tech~SourceObject|Tech~SourceObject[]|string} [source]
* A SourceObject, an array of SourceObjects, or a string referencing
* a URL to a media source. It is _highly recommended_ that an object
* or array of objects is used here, so that source selection
* algorithms can take the `type` into account.
*
* If not provided, this method acts as a getter.
* @param {boolean} [isRetry]
* Indicates whether this is being called internally as a result of a retry
*
* @return {string|undefined}
* If the `source` argument is missing, returns the current source
* URL. Otherwise, returns nothing/undefined.
*/
handleSrc_(source, isRetry) {
// getter usage
if (typeof source === 'undefined') {
return this.cache_.src || '';
}
// Reset retry behavior for new source
if (this.resetRetryOnError_) {
this.resetRetryOnError_();
}
// filter out invalid sources and turn our source into
// an array of source objects
const sources = filterSource(source);
// if a source was passed in then it is invalid because
// it was filtered to a zero length Array. So we have to
// show an error
if (!sources.length) {
this.setTimeout(function() {
this.error({ code: 4, message: this.options_.notSupportedMessage });
}, 0);
return;
}
// initial sources
this.changingSrc_ = true;
// Only update the cached source list if we are not retrying a new source after error,
// since in that case we want to include the failed source(s) in the cache
if (!isRetry) {
this.cache_.sources = sources;
}
this.updateSourceCaches_(sources[0]);
// middlewareSource is the source after it has been changed by middleware
middleware.setSource(this, sources[0], (middlewareSource, mws) => {
this.middleware_ = mws;
// since sourceSet is async we have to update the cache again after we select a source since
// the source that is selected could be out of order from the cache update above this callback.
if (!isRetry) {
this.cache_.sources = sources;
}
this.updateSourceCaches_(middlewareSource);
const err = this.src_(middlewareSource);
if (err) {
if (sources.length > 1) {
return this.handleSrc_(sources.slice(1));
}
this.changingSrc_ = false;
// We need to wrap this in a timeout to give folks a chance to add error event handlers
this.setTimeout(function() {
this.error({ code: 4, message: this.options_.notSupportedMessage });
}, 0);
// we could not find an appropriate tech, but let's still notify the delegate that this is it
// this needs a better comment about why this is needed
this.triggerReady();
return;
}
middleware.setTech(mws, this.tech_);
});
// Try another available source if this one fails before playback.
if (sources.length > 1) {
const retry = () => {
// Remove the error modal
this.error(null);
this.handleSrc_(sources.slice(1), true);
};
const stopListeningForErrors = () => {
this.off('error', retry);
};
this.one('error', retry);
this.one('playing', stopListeningForErrors);
this.resetRetryOnError_ = () => {
this.off('error', retry);
this.off('playing', stopListeningForErrors);
};
}
}
/**
* Get or set the video source.
*
* @param {Tech~SourceObject|Tech~SourceObject[]|string} [source]
* A SourceObject, an array of SourceObjects, or a string referencing
* a URL to a media source. It is _highly recommended_ that an object
* or array of objects is used here, so that source selection
* algorithms can take the `type` into account.
*
* If not provided, this method acts as a getter.
*
* @return {string|undefined}
* If the `source` argument is missing, returns the current source
* URL. Otherwise, returns nothing/undefined.
*/
src(source) {
return this.handleSrc_(source, false);
}
/**
* Set the source object on the tech, returns a boolean that indicates whether
* there is a tech that can play the source or not
*
* @param {Tech~SourceObject} source
* The source object to set on the Tech
*
* @return {boolean}
* - True if there is no Tech to playback this source
* - False otherwise
*
* @private
*/
src_(source) {
const sourceTech = this.selectSource([source]);
if (!sourceTech) {
return true;
}
if (!titleCaseEquals(sourceTech.tech, this.techName_)) {
this.changingSrc_ = true;
// load this technology with the chosen source
this.loadTech_(sourceTech.tech, sourceTech.source);
this.tech_.ready(() => {
this.changingSrc_ = false;
});
return false;
}
// wait until the tech is ready to set the source
// and set it synchronously if possible (#2326)
this.ready(function() {
// The setSource tech method was added with source handlers
// so older techs won't support it
// We need to check the direct prototype for the case where subclasses
// of the tech do not support source handlers
if (this.tech_.constructor.prototype.hasOwnProperty('setSource')) {
this.techCall_('setSource', source);
} else {
this.techCall_('src', source.src);
}
this.changingSrc_ = false;
}, true);
return false;
}
/**
* Begin loading the src data.
*/
load() {
// Workaround to use the load method with the VHS.
// Does not cover the case when the load method is called directly from the mediaElement.
if (this.tech_ && this.tech_.vhs) {
this.src(this.currentSource());
return;
}
this.techCall_('load');
}
/**
* Reset the player. Loads the first tech in the techOrder,
* removes all the text tracks in the existing `tech`,
* and calls `reset` on the `tech`.
*/
reset() {
if (this.paused()) {
this.doReset_();
} else {
const playPromise = this.play();
silencePromise(playPromise.then(() => this.doReset_()));
}
}
doReset_() {
if (this.tech_) {
this.tech_.clearTracks('text');
}
this.removeClass('vjs-playing');
this.addClass('vjs-paused');
this.resetCache_();
this.poster('');
this.loadTech_(this.options_.techOrder[0], null);
this.techCall_('reset');
this.resetControlBarUI_();
this.error(null);
if (this.titleBar) {
this.titleBar.update({
title: undefined,
description: undefined
});
}
if (isEvented(this)) {
this.trigger('playerreset');
}
}
/**
* Reset Control Bar's UI by calling sub-methods that reset
* all of Control Bar's components
*/
resetControlBarUI_() {
this.resetProgressBar_();
this.resetPlaybackRate_();
this.resetVolumeBar_();
}
/**
* Reset tech's progress so progress bar is reset in the UI
*/
resetProgressBar_() {
this.currentTime(0);
const {
currentTimeDisplay,
durationDisplay,
progressControl,
remainingTimeDisplay
} = this.controlBar || {};
const { seekBar } = progressControl || {};
if (currentTimeDisplay) {
currentTimeDisplay.updateContent();
}
if (durationDisplay) {
durationDisplay.updateContent();
}
if (remainingTimeDisplay) {
remainingTimeDisplay.updateContent();
}
if (seekBar) {
seekBar.update();
if (seekBar.loadProgressBar) {
seekBar.loadProgressBar.update();
}
}
}
/**
* Reset Playback ratio
*/
resetPlaybackRate_() {
this.playbackRate(this.defaultPlaybackRate());
this.handleTechRateChange_();
}
/**
* Reset Volume bar
*/
resetVolumeBar_() {
this.volume(1.0);
this.trigger('volumechange');
}
/**
* Returns all of the current source objects.
*
* @return {Tech~SourceObject[]}
* The current source objects
*/
currentSources() {
const source = this.currentSource();
const sources = [];
// assume `{}` or `{ src }`
if (Object.keys(source).length !== 0) {
sources.push(source);
}
return this.cache_.sources || sources;
}
/**
* Returns the current source object.
*
* @return {Tech~SourceObject}
* The current source object
*/
currentSource() {
return this.cache_.source || {};
}
/**
* Returns the fully qualified URL of the current source value e.g. http://mysite.com/video.mp4
* Can be used in conjunction with `currentType` to assist in rebuilding the current source object.
*
* @return {string}
* The current source
*/
currentSrc() {
return this.currentSource() && this.currentSource().src || '';
}
/**
* Get the current source type e.g. video/mp4
* This can allow you rebuild the current source object so that you could load the same
* source and tech later
*
* @return {string}
* The source MIME type
*/
currentType() {
return this.currentSource() && this.currentSource().type || '';
}
/**
* Get or set the preload attribute
*
* @param {'none'|'auto'|'metadata'} [value]
* Preload mode to pass to tech
*
* @return {string|undefined}
* - The preload attribute value when getting
* - Nothing when setting
*/
preload(value) {
if (value !== undefined) {
this.techCall_('setPreload', value);
this.options_.preload = value;
return;
}
return this.techGet_('preload');
}
/**
* Get or set the autoplay option. When this is a boolean it will
* modify the attribute on the tech. When this is a string the attribute on
* the tech will be removed and `Player` will handle autoplay on loadstarts.
*
* @param {boolean|'play'|'muted'|'any'} [value]
* - true: autoplay using the browser behavior
* - false: do not autoplay
* - 'play': call play() on every loadstart
* - 'muted': call muted() then play() on every loadstart
* - 'any': call play() on every loadstart. if that fails call muted() then play().
* - *: values other than those listed here will be set `autoplay` to true
*
* @return {boolean|string|undefined}
* - The current value of autoplay when getting
* - Nothing when setting
*/
autoplay(value) {
// getter usage
if (value === undefined) {
return this.options_.autoplay || false;
}
let techAutoplay;
// if the value is a valid string set it to that, or normalize `true` to 'play', if need be
if (typeof value === 'string' && (/(any|play|muted)/).test(value) || value === true && this.options_.normalizeAutoplay) {
this.options_.autoplay = value;
this.manualAutoplay_(typeof value === 'string' ? value : 'play');
techAutoplay = false;
// any falsy value sets autoplay to false in the browser,
// lets do the same
} else if (!value) {
this.options_.autoplay = false;
// any other value (ie truthy) sets autoplay to true
} else {
this.options_.autoplay = true;
}
techAutoplay = typeof techAutoplay === 'undefined' ? this.options_.autoplay : techAutoplay;
// if we don't have a tech then we do not queue up
// a setAutoplay call on tech ready. We do this because the
// autoplay option will be passed in the constructor and we
// do not need to set it twice
if (this.tech_) {
this.techCall_('setAutoplay', techAutoplay);
}
}
/**
* Set or unset the playsinline attribute.
* Playsinline tells the browser that non-fullscreen playback is preferred.
*
* @param {boolean} [value]
* - true means that we should try to play inline by default
* - false means that we should use the browser's default playback mode,
* which in most cases is inline. iOS Safari is a notable exception
* and plays fullscreen by default.
*
* @return {string|undefined}
* - the current value of playsinline
* - Nothing when setting
*
* @see [Spec]{@link https://html.spec.whatwg.org/#attr-video-playsinline}
*/
playsinline(value) {
if (value !== undefined) {
this.techCall_('setPlaysinline', value);
this.options_.playsinline = value;
}
return this.techGet_('playsinline');
}
/**
* Get or set the loop attribute on the video element.
*
* @param {boolean} [value]
* - true means that we should loop the video
* - false means that we should not loop the video
*
* @return {boolean|undefined}
* - The current value of loop when getting
* - Nothing when setting
*/
loop(value) {
if (value !== undefined) {
this.techCall_('setLoop', value);
this.options_.loop = value;
return;
}
return this.techGet_('loop');
}
/**
* Get or set the poster image source url
*
* @fires Player#posterchange
*
* @param {string} [src]
* Poster image source URL
*
* @return {string|undefined}
* - The current value of poster when getting
* - Nothing when setting
*/
poster(src) {
if (src === undefined) {
return this.poster_;
}
// The correct way to remove a poster is to set as an empty string
// other falsey values will throw errors
if (!src) {
src = '';
}
if (src === this.poster_) {
return;
}
// update the internal poster variable
this.poster_ = src;
// update the tech's poster
this.techCall_('setPoster', src);
this.isPosterFromTech_ = false;
// alert components that the poster has been set
/**
* This event fires when the poster image is changed on the player.
*
* @event Player#posterchange
* @type {Event}
*/
this.trigger('posterchange');
}
/**
* Some techs (e.g. YouTube) can provide a poster source in an
* asynchronous way. We want the poster component to use this
* poster source so that it covers up the tech's controls.
* (YouTube's play button). However we only want to use this
* source if the player user hasn't set a poster through
* the normal APIs.
*
* @fires Player#posterchange
* @listens Tech#posterchange
* @private
*/
handleTechPosterChange_() {
if ((!this.poster_ || this.options_.techCanOverridePoster) && this.tech_ && this.tech_.poster) {
const newPoster = this.tech_.poster() || '';
if (newPoster !== this.poster_) {
this.poster_ = newPoster;
this.isPosterFromTech_ = true;
// Let components know the poster has changed
this.trigger('posterchange');
}
}
}
/**
* Get or set whether or not the controls are showing.
*
* @fires Player#controlsenabled
*
* @param {boolean} [bool]
* - true to turn controls on
* - false to turn controls off
*
* @return {boolean|undefined}
* - The current value of controls when getting
* - Nothing when setting
*/
controls(bool) {
if (bool === undefined) {
return !!this.controls_;
}
bool = !!bool;
// Don't trigger a change event unless it actually changed
if (this.controls_ === bool) {
return;
}
this.controls_ = bool;
if (this.usingNativeControls()) {
this.techCall_('setControls', bool);
}
if (this.controls_) {
this.removeClass('vjs-controls-disabled');
this.addClass('vjs-controls-enabled');
/**
* @event Player#controlsenabled
* @type {Event}
*/
this.trigger('controlsenabled');
if (!this.usingNativeControls()) {
this.addTechControlsListeners_();
}
} else {
this.removeClass('vjs-controls-enabled');
this.addClass('vjs-controls-disabled');
/**
* @event Player#controlsdisabled
* @type {Event}
*/
this.trigger('controlsdisabled');
if (!this.usingNativeControls()) {
this.removeTechControlsListeners_();
}
}
}
/**
* Toggle native controls on/off. Native controls are the controls built into
* devices (e.g. default iPhone controls) or other techs
* (e.g. Vimeo Controls)
* **This should only be set by the current tech, because only the tech knows
* if it can support native controls**
*
* @fires Player#usingnativecontrols
* @fires Player#usingcustomcontrols
*
* @param {boolean} [bool]
* - true to turn native controls on
* - false to turn native controls off
*
* @return {boolean|undefined}
* - The current value of native controls when getting
* - Nothing when setting
*/
usingNativeControls(bool) {
if (bool === undefined) {
return !!this.usingNativeControls_;
}
bool = !!bool;
// Don't trigger a change event unless it actually changed
if (this.usingNativeControls_ === bool) {
return;
}
this.usingNativeControls_ = bool;
if (this.usingNativeControls_) {
this.addClass('vjs-using-native-controls');
/**
* player is using the native device controls
*
* @event Player#usingnativecontrols
* @type {Event}
*/
this.trigger('usingnativecontrols');
} else {
this.removeClass('vjs-using-native-controls');
/**
* player is using the custom HTML controls
*
* @event Player#usingcustomcontrols
* @type {Event}
*/
this.trigger('usingcustomcontrols');
}
}
/**
* Set or get the current MediaError
*
* @fires Player#error
*
* @param {MediaError|string|number} [err]
* A MediaError or a string/number to be turned
* into a MediaError
*
* @return {MediaError|null|undefined}
* - The current MediaError when getting (or null)
* - Nothing when setting
*/
error(err) {
if (err === undefined) {
return this.error_ || null;
}
// allow hooks to modify error object
hooks('beforeerror').forEach((hookFunction) => {
const newErr = hookFunction(this, err);
if (!(
(isObject(newErr) && !Array.isArray(newErr)) ||
typeof newErr === 'string' ||
typeof newErr === 'number' ||
newErr === null
)) {
this.log.error('please return a value that MediaError expects in beforeerror hooks');
return;
}
err = newErr;
});
// Suppress the first error message for no compatible source until
// user interaction
if (this.options_.suppressNotSupportedError &&
err && err.code === 4
) {
const triggerSuppressedError = function() {
this.error(err);
};
this.options_.suppressNotSupportedError = false;
this.any(['click', 'touchstart'], triggerSuppressedError);
this.one('loadstart', function() {
this.off(['click', 'touchstart'], triggerSuppressedError);
});
return;
}
// restoring to default
if (err === null) {
this.error_ = null;
this.removeClass('vjs-error');
if (this.errorDisplay) {
this.errorDisplay.close();
}
return;
}
this.error_ = new MediaError(err);
// add the vjs-error classname to the player
this.addClass('vjs-error');
// log the name of the error type and any message
// IE11 logs "[object object]" and required you to expand message to see error object
log.error(`(CODE:${this.error_.code} ${MediaError.errorTypes[this.error_.code]})`, this.error_.message, this.error_);
/**
* @event Player#error
* @type {Event}
*/
this.trigger('error');
// notify hooks of the per player error
hooks('error').forEach((hookFunction) => hookFunction(this, this.error_));
return;
}
/**
* Report user activity
*
* @param {Object} event
* Event object
*/
reportUserActivity(event) {
this.userActivity_ = true;
}
/**
* Get/set if user is active
*
* @fires Player#useractive
* @fires Player#userinactive
*
* @param {boolean} [bool]
* - true if the user is active
* - false if the user is inactive
*
* @return {boolean|undefined}
* - The current value of userActive when getting
* - Nothing when setting
*/
userActive(bool) {
if (bool === undefined) {
return this.userActive_;
}
bool = !!bool;
if (bool === this.userActive_) {
return;
}
this.userActive_ = bool;
if (this.userActive_) {
this.userActivity_ = true;
this.removeClass('vjs-user-inactive');
this.addClass('vjs-user-active');
/**
* @event Player#useractive
* @type {Event}
*/
this.trigger('useractive');
return;
}
// Chrome/Safari/IE have bugs where when you change the cursor it can
// trigger a mousemove event. This causes an issue when you're hiding
// the cursor when the user is inactive, and a mousemove signals user
// activity. Making it impossible to go into inactive mode. Specifically
// this happens in fullscreen when we really need to hide the cursor.
//
// When this gets resolved in ALL browsers it can be removed
// https://code.google.com/p/chromium/issues/detail?id=103041
if (this.tech_) {
this.tech_.one('mousemove', function(e) {
e.stopPropagation();
e.preventDefault();
});
}
this.userActivity_ = false;
this.removeClass('vjs-user-active');
this.addClass('vjs-user-inactive');
/**
* @event Player#userinactive
* @type {Event}
*/
this.trigger('userinactive');
}
/**
* Listen for user activity based on timeout value
*
* @private
*/
listenForUserActivity_() {
let mouseInProgress;
let lastMoveX;
let lastMoveY;
const handleActivity = Fn.bind_(this, this.reportUserActivity);
const handleMouseMove = function(e) {
// #1068 - Prevent mousemove spamming
// Chrome Bug: https://code.google.com/p/chromium/issues/detail?id=366970
if (e.screenX !== lastMoveX || e.screenY !== lastMoveY) {
lastMoveX = e.screenX;
lastMoveY = e.screenY;
handleActivity();
}
};
const handleMouseDown = function() {
handleActivity();
// 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(mouseInProgress);
// Setting userActivity=true now and setting the interval to the same time
// as the activityCheck interval (250) should ensure we never miss the
// next activityCheck
mouseInProgress = this.setInterval(handleActivity, 250);
};
const handleMouseUpAndMouseLeave = function(event) {
handleActivity();
// Stop the interval that maintains activity if the mouse/touch is down
this.clearInterval(mouseInProgress);
};
// Any mouse movement will be considered user activity
this.on('mousedown', handleMouseDown);
this.on('mousemove', handleMouseMove);
this.on('mouseup', handleMouseUpAndMouseLeave);
this.on('mouseleave', handleMouseUpAndMouseLeave);
const controlBar = this.getChild('controlBar');
// Fixes bug on Android & iOS where when tapping progressBar (when control bar is displayed)
// controlBar would no longer be hidden by default timeout.
if (controlBar && !browser.IS_IOS && !browser.IS_ANDROID) {
controlBar.on('mouseenter', function(event) {
if (this.player().options_.inactivityTimeout !== 0) {
this.player().cache_.inactivityTimeout = this.player().options_.inactivityTimeout;
}
this.player().options_.inactivityTimeout = 0;
});
controlBar.on('mouseleave', function(event) {
this.player().options_.inactivityTimeout = this.player().cache_.inactivityTimeout;
});
}
// Listen for keyboard navigation
// Shouldn't need to use inProgress interval because of key repeat
this.on('keydown', handleActivity);
this.on('keyup', handleActivity);
// Run an interval every 250 milliseconds instead of stuffing everything into
// the mousemove/touchmove function itself, to prevent performance degradation.
// `this.reportUserActivity` simply sets this.userActivity_ to true, which
// then gets picked up by this loop
// http://ejohn.org/blog/learning-from-twitter/
let inactivityTimeout;
/** @this Player */
const activityCheck = function() {
// Check to see if mouse/touch activity has happened
if (!this.userActivity_) {
return;
}
// Reset the activity tracker
this.userActivity_ = false;
// If the user state was inactive, set the state to active
this.userActive(true);
// Clear any existing inactivity timeout to start the timer over
this.clearTimeout(inactivityTimeout);
const timeout = this.options_.inactivityTimeout;
if (timeout <= 0) {
return;
}
// In <timeout> milliseconds, if no more activity has occurred the
// user will be considered inactive
inactivityTimeout = this.setTimeout(function() {
// Protect against the case where the inactivityTimeout can trigger just
// before the next user activity is picked up by the activity check loop
// causing a flicker
if (!this.userActivity_) {
this.userActive(false);
}
}, timeout);
};
this.setInterval(activityCheck, 250);
}
/**
* Gets or sets the current playback rate. A playback rate of
* 1.0 represents normal speed and 0.5 would indicate half-speed
* playback, for instance.
*
* @see https://html.spec.whatwg.org/multipage/embedded-content.html#dom-media-playbackrate
*
* @param {number} [rate]
* New playback rate to set.
*
* @return {number|undefined}
* - The current playback rate when getting or 1.0
* - Nothing when setting
*/
playbackRate(rate) {
if (rate !== undefined) {
// NOTE: this.cache_.lastPlaybackRate is set from the tech handler
// that is registered above
this.techCall_('setPlaybackRate', rate);
return;
}
if (this.tech_ && this.tech_.featuresPlaybackRate) {
return this.cache_.lastPlaybackRate || this.techGet_('playbackRate');
}
return 1.0;
}
/**
* Gets or sets the current default playback rate. A default playback rate of
* 1.0 represents normal speed and 0.5 would indicate half-speed playback, for instance.
* defaultPlaybackRate will only represent what the initial playbackRate of a video was, not
* not the current playbackRate.
*
* @see https://html.spec.whatwg.org/multipage/embedded-content.html#dom-media-defaultplaybackrate
*
* @param {number} [rate]
* New default playback rate to set.
*
* @return {number|undefined}
* - The default playback rate when getting or 1.0
* - Nothing when setting
*/
defaultPlaybackRate(rate) {
if (rate !== undefined) {
return this.techCall_('setDefaultPlaybackRate', rate);
}
if (this.tech_ && this.tech_.featuresPlaybackRate) {
return this.techGet_('defaultPlaybackRate');
}
return 1.0;
}
/**
* Gets or sets the audio flag
*
* @param {boolean} [bool]
* - true signals that this is an audio player
* - false signals that this is not an audio player
*
* @return {boolean|undefined}
* - The current value of isAudio when getting
* - Nothing when setting
*/
isAudio(bool) {
if (bool !== undefined) {
this.isAudio_ = !!bool;
return;
}
return !!this.isAudio_;
}
updatePlayerHeightOnAudioOnlyMode_() {
const controlBar = this.getChild('ControlBar');
if (!controlBar || this.audioOnlyCache_.controlBarHeight === controlBar.currentHeight()) {
return;
}
this.audioOnlyCache_.controlBarHeight = controlBar.currentHeight();
this.height(this.audioOnlyCache_.controlBarHeight);
}
enableAudioOnlyUI_() {
// Update styling immediately to show the control bar so we can get its height
this.addClass('vjs-audio-only-mode');
const playerChildren = this.children();
const controlBar = this.getChild('ControlBar');
const controlBarHeight = controlBar && controlBar.currentHeight();
// Hide all player components except the control bar. Control bar components
// needed only for video are hidden with CSS
playerChildren.forEach(child => {
if (child === controlBar) {
return;
}
if (child.el_ && !child.hasClass('vjs-hidden')) {
child.hide();
this.audioOnlyCache_.hiddenChildren.push(child);
}
});
this.audioOnlyCache_.playerHeight = this.currentHeight();
this.audioOnlyCache_.controlBarHeight = controlBarHeight;
this.on('playerresize', this.boundUpdatePlayerHeightOnAudioOnlyMode_);
// Set the player height the same as the control bar
this.height(controlBarHeight);
this.trigger('audioonlymodechange');
}
disableAudioOnlyUI_() {
this.removeClass('vjs-audio-only-mode');
this.off('playerresize', this.boundUpdatePlayerHeightOnAudioOnlyMode_);
// Show player components that were previously hidden
this.audioOnlyCache_.hiddenChildren.forEach(child => child.show());
// Reset player height
this.height(this.audioOnlyCache_.playerHeight);
this.trigger('audioonlymodechange');
}
/**
* Get the current audioOnlyMode state or set audioOnlyMode to true or false.
*
* Setting this to `true` will hide all player components except the control bar,
* as well as control bar components needed only for video.
*
* @param {boolean} [value]
* The value to set audioOnlyMode to.
*
* @return {Promise|boolean}
* A Promise is returned when setting the state, and a boolean when getting
* the present state
*/
audioOnlyMode(value) {
if (typeof value !== 'boolean' || value === this.audioOnlyMode_) {
return this.audioOnlyMode_;
}
this.audioOnlyMode_ = value;
// Enable Audio Only Mode
if (value) {
const exitPromises = [];
// Fullscreen and PiP are not supported in audioOnlyMode, so exit if we need to.
if (this.isInPictureInPicture()) {
exitPromises.push(this.exitPictureInPicture());
}
if (this.isFullscreen()) {
exitPromises.push(this.exitFullscreen());
}
if (this.audioPosterMode()) {
exitPromises.push(this.audioPosterMode(false));
}
return Promise.all(exitPromises).then(() => this.enableAudioOnlyUI_());
}
// Disable Audio Only Mode
return Promise.resolve().then(() => this.disableAudioOnlyUI_());
}
enablePosterModeUI_() {
// Hide the video element and show the poster image to enable posterModeUI
const tech = this.tech_ && this.tech_;
tech.hide();
this.addClass('vjs-audio-poster-mode');
this.trigger('audiopostermodechange');
}
disablePosterModeUI_() {
// Show the video element and hide the poster image to disable posterModeUI
const tech = this.tech_ && this.tech_;
tech.show();
this.removeClass('vjs-audio-poster-mode');
this.trigger('audiopostermodechange');
}
/**
* Get the current audioPosterMode state or set audioPosterMode to true or false
*
* @param {boolean} [value]
* The value to set audioPosterMode to.
*
* @return {Promise|boolean}
* A Promise is returned when setting the state, and a boolean when getting
* the present state
*/
audioPosterMode(value) {
if (typeof value !== 'boolean' || value === this.audioPosterMode_) {
return this.audioPosterMode_;
}
this.audioPosterMode_ = value;
if (value) {
if (this.audioOnlyMode()) {
const audioOnlyModePromise = this.audioOnlyMode(false);
return audioOnlyModePromise.then(() => {
// enable audio poster mode after audio only mode is disabled
this.enablePosterModeUI_();
});
}
return Promise.resolve().then(() => {
// enable audio poster mode
this.enablePosterModeUI_();
});
}
return Promise.resolve().then(() => {
// disable audio poster mode
this.disablePosterModeUI_();
});
}
/**
* A helper method for adding a {@link TextTrack} to our
* {@link TextTrackList}.
*
* In addition to the W3C settings we allow adding additional info through options.
*
* @see http://www.w3.org/html/wg/drafts/html/master/embedded-content-0.html#dom-media-addtexttrack
*
* @param {string} [kind]
* the kind of TextTrack you are adding
*
* @param {string} [label]
* the label to give the TextTrack label
*
* @param {string} [language]
* the language to set on the TextTrack
*
* @return {TextTrack|undefined}
* the TextTrack that was added or undefined
* if there is no tech
*/
addTextTrack(kind, label, language) {
if (this.tech_) {
return this.tech_.addTextTrack(kind, label, language);
}
}
/**
* Create a remote {@link TextTrack} and an {@link HTMLTrackElement}.
*
* @param {Object} options
* Options to pass to {@link HTMLTrackElement} during creation. See
* {@link HTMLTrackElement} for object properties that you should use.
*
* @param {boolean} [manualCleanup=false] if set to true, the TextTrack will not be removed
* from the TextTrackList and HtmlTrackElementList
* after a source change
*
* @return { import('./tracks/html-track-element').default }
* the HTMLTrackElement that was created and added
* to the HtmlTrackElementList and the remote
* TextTrackList
*
*/
addRemoteTextTrack(options, manualCleanup) {
if (this.tech_) {
return this.tech_.addRemoteTextTrack(options, manualCleanup);
}
}
/**
* Remove a remote {@link TextTrack} from the respective
* {@link TextTrackList} and {@link HtmlTrackElementList}.
*
* @param {Object} track
* Remote {@link TextTrack} to remove
*
* @return {undefined}
* does not return anything
*/
removeRemoteTextTrack(obj = {}) {
let {track} = obj;
if (!track) {
track = obj;
}
// destructure the input into an object with a track argument, defaulting to arguments[0]
// default the whole argument to an empty object if nothing was passed in
if (this.tech_) {
return this.tech_.removeRemoteTextTrack(track);
}
}
/**
* Gets available media playback quality metrics as specified by the W3C's Media
* Playback Quality API.
*
* @see [Spec]{@link https://wicg.github.io/media-playback-quality}
*
* @return {Object|undefined}
* An object with supported media playback quality metrics or undefined if there
* is no tech or the tech does not support it.
*/
getVideoPlaybackQuality() {
return this.techGet_('getVideoPlaybackQuality');
}
/**
* Get video width
*
* @return {number}
* current video width
*/
videoWidth() {
return this.tech_ && this.tech_.videoWidth && this.tech_.videoWidth() || 0;
}
/**
* Get video height
*
* @return {number}
* current video height
*/
videoHeight() {
return this.tech_ && this.tech_.videoHeight && this.tech_.videoHeight() || 0;
}
/**
* Set or get the player's language code.
*
* Changing the language will trigger
* [languagechange]{@link Player#event:languagechange}
* which Components can use to update control text.
* ClickableComponent will update its control text by default on
* [languagechange]{@link Player#event:languagechange}.
*
* @fires Player#languagechange
*
* @param {string} [code]
* the language code to set the player to
*
* @return {string|undefined}
* - The current language code when getting
* - Nothing when setting
*/
language(code) {
if (code === undefined) {
return this.language_;
}
if (this.language_ !== String(code).toLowerCase()) {
this.language_ = String(code).toLowerCase();
// during first init, it's possible some things won't be evented
if (isEvented(this)) {
/**
* fires when the player language change
*
* @event Player#languagechange
* @type {Event}
*/
this.trigger('languagechange');
}
}
}
/**
* Get the player's language dictionary
* Merge every time, because a newly added plugin might call videojs.addLanguage() at any time
* Languages specified directly in the player options have precedence
*
* @return {Array}
* An array of of supported languages
*/
languages() {
return merge(Player.prototype.options_.languages, this.languages_);
}
/**
* returns a JavaScript object representing the current track
* information. **DOES not return it as JSON**
*
* @return {Object}
* Object representing the current of track info
*/
toJSON() {
const options = merge(this.options_);
const tracks = options.tracks;
options.tracks = [];
for (let i = 0; i < tracks.length; i++) {
let track = tracks[i];
// deep merge tracks and null out player so no circular references
track = merge(track);
track.player = undefined;
options.tracks[i] = track;
}
return options;
}
/**
* Creates a simple modal dialog (an instance of the {@link ModalDialog}
* component) that immediately overlays the player with arbitrary
* content and removes itself when closed.
*
* @param {string|Function|Element|Array|null} content
* Same as {@link ModalDialog#content}'s param of the same name.
* The most straight-forward usage is to provide a string or DOM
* element.
*
* @param {Object} [options]
* Extra options which will be passed on to the {@link ModalDialog}.
*
* @return {ModalDialog}
* the {@link ModalDialog} that was created
*/
createModal(content, options) {
options = options || {};
options.content = content || '';
const modal = new ModalDialog(this, options);
this.addChild(modal);
modal.on('dispose', () => {
this.removeChild(modal);
});
modal.open();
return modal;
}
/**
* Change breakpoint classes when the player resizes.
*
* @private
*/
updateCurrentBreakpoint_() {
if (!this.responsive()) {
return;
}
const currentBreakpoint = this.currentBreakpoint();
const currentWidth = this.currentWidth();
for (let i = 0; i < BREAKPOINT_ORDER.length; i++) {
const candidateBreakpoint = BREAKPOINT_ORDER[i];
const maxWidth = this.breakpoints_[candidateBreakpoint];
if (currentWidth <= maxWidth) {
// The current breakpoint did not change, nothing to do.
if (currentBreakpoint === candidateBreakpoint) {
return;
}
// Only remove a class if there is a current breakpoint.
if (currentBreakpoint) {
this.removeClass(BREAKPOINT_CLASSES[currentBreakpoint]);
}
this.addClass(BREAKPOINT_CLASSES[candidateBreakpoint]);
this.breakpoint_ = candidateBreakpoint;
break;
}
}
}
/**
* Removes the current breakpoint.
*
* @private
*/
removeCurrentBreakpoint_() {
const className = this.currentBreakpointClass();
this.breakpoint_ = '';
if (className) {
this.removeClass(className);
}
}
/**
* Get or set breakpoints on the player.
*
* Calling this method with an object or `true` will remove any previous
* custom breakpoints and start from the defaults again.
*
* @param {Object|boolean} [breakpoints]
* If an object is given, it can be used to provide custom
* breakpoints. If `true` is given, will set default breakpoints.
* If this argument is not given, will simply return the current
* breakpoints.
*
* @param {number} [breakpoints.tiny]
* The maximum width for the "vjs-layout-tiny" class.
*
* @param {number} [breakpoints.xsmall]
* The maximum width for the "vjs-layout-x-small" class.
*
* @param {number} [breakpoints.small]
* The maximum width for the "vjs-layout-small" class.
*
* @param {number} [breakpoints.medium]
* The maximum width for the "vjs-layout-medium" class.
*
* @param {number} [breakpoints.large]
* The maximum width for the "vjs-layout-large" class.
*
* @param {number} [breakpoints.xlarge]
* The maximum width for the "vjs-layout-x-large" class.
*
* @param {number} [breakpoints.huge]
* The maximum width for the "vjs-layout-huge" class.
*
* @return {Object}
* An object mapping breakpoint names to maximum width values.
*/
breakpoints(breakpoints) {
// Used as a getter.
if (breakpoints === undefined) {
return Object.assign(this.breakpoints_);
}
this.breakpoint_ = '';
this.breakpoints_ = Object.assign({}, DEFAULT_BREAKPOINTS, breakpoints);
// When breakpoint definitions change, we need to update the currently
// selected breakpoint.
this.updateCurrentBreakpoint_();
// Clone the breakpoints before returning.
return Object.assign(this.breakpoints_);
}
/**
* Get or set a flag indicating whether or not this player should adjust
* its UI based on its dimensions.
*
* @param {boolean} [value]
* Should be `true` if the player should adjust its UI based on its
* dimensions; otherwise, should be `false`.
*
* @return {boolean|undefined}
* Will be `true` if this player should adjust its UI based on its
* dimensions; otherwise, will be `false`.
* Nothing if setting
*/
responsive(value) {
// Used as a getter.
if (value === undefined) {
return this.responsive_;
}
value = Boolean(value);
const current = this.responsive_;
// Nothing changed.
if (value === current) {
return;
}
// The value actually changed, set it.
this.responsive_ = value;
// Start listening for breakpoints and set the initial breakpoint if the
// player is now responsive.
if (value) {
this.on('playerresize', this.boundUpdateCurrentBreakpoint_);
this.updateCurrentBreakpoint_();
// Stop listening for breakpoints if the player is no longer responsive.
} else {
this.off('playerresize', this.boundUpdateCurrentBreakpoint_);
this.removeCurrentBreakpoint_();
}
return value;
}
/**
* Get current breakpoint name, if any.
*
* @return {string}
* If there is currently a breakpoint set, returns a the key from the
* breakpoints object matching it. Otherwise, returns an empty string.
*/
currentBreakpoint() {
return this.breakpoint_;
}
/**
* Get the current breakpoint class name.
*
* @return {string}
* The matching class name (e.g. `"vjs-layout-tiny"` or
* `"vjs-layout-large"`) for the current breakpoint. Empty string if
* there is no current breakpoint.
*/
currentBreakpointClass() {
return BREAKPOINT_CLASSES[this.breakpoint_] || '';
}
/**
* An object that describes a single piece of media.
*
* Properties that are not part of this type description will be retained; so,
* this can be viewed as a generic metadata storage mechanism as well.
*
* @see {@link https://wicg.github.io/mediasession/#the-mediametadata-interface}
* @typedef {Object} Player~MediaObject
*
* @property {string} [album]
* Unused, except if this object is passed to the `MediaSession`
* API.
*
* @property {string} [artist]
* Unused, except if this object is passed to the `MediaSession`
* API.
*
* @property {Object[]} [artwork]
* Unused, except if this object is passed to the `MediaSession`
* API. If not specified, will be populated via the `poster`, if
* available.
*
* @property {string} [poster]
* URL to an image that will display before playback.
*
* @property {Tech~SourceObject|Tech~SourceObject[]|string} [src]
* A single source object, an array of source objects, or a string
* referencing a URL to a media source. It is _highly recommended_
* that an object or array of objects is used here, so that source
* selection algorithms can take the `type` into account.
*
* @property {string} [title]
* Unused, except if this object is passed to the `MediaSession`
* API.
*
* @property {Object[]} [textTracks]
* An array of objects to be used to create text tracks, following
* the {@link https://www.w3.org/TR/html50/embedded-content-0.html#the-track-element|native track element format}.
* For ease of removal, these will be created as "remote" text
* tracks and set to automatically clean up on source changes.
*
* These objects may have properties like `src`, `kind`, `label`,
* and `language`, see {@link Tech#createRemoteTextTrack}.
*/
/**
* Populate the player using a {@link Player~MediaObject|MediaObject}.
*
* @param {Player~MediaObject} media
* A media object.
*
* @param {Function} ready
* A callback to be called when the player is ready.
*/
loadMedia(media, ready) {
if (!media || typeof media !== 'object') {
return;
}
const crossOrigin = this.crossOrigin();
this.reset();
// Clone the media object so it cannot be mutated from outside.
this.cache_.media = merge(media);
const {artist, artwork, description, poster, src, textTracks, title} = this.cache_.media;
// If `artwork` is not given, create it using `poster`.
if (!artwork && poster) {
this.cache_.media.artwork = [{
src: poster,
type: getMimetype(poster)
}];
}
if (crossOrigin) {
this.crossOrigin(crossOrigin);
}
if (src) {
this.src(src);
}
if (poster) {
this.poster(poster);
}
if (Array.isArray(textTracks)) {
textTracks.forEach(tt => this.addRemoteTextTrack(tt, false));
}
if (this.titleBar) {
this.titleBar.update({
title,
description: description || artist || ''
});
}
this.ready(ready);
}
/**
* Get a clone of the current {@link Player~MediaObject} for this player.
*
* If the `loadMedia` method has not been used, will attempt to return a
* {@link Player~MediaObject} based on the current state of the player.
*
* @return {Player~MediaObject}
*/
getMedia() {
if (!this.cache_.media) {
const poster = this.poster();
const src = this.currentSources();
const textTracks = Array.prototype.map.call(this.remoteTextTracks(), (tt) => ({
kind: tt.kind,
label: tt.label,
language: tt.language,
src: tt.src
}));
const media = {src, textTracks};
if (poster) {
media.poster = poster;
media.artwork = [{
src: media.poster,
type: getMimetype(media.poster)
}];
}
return media;
}
return merge(this.cache_.media);
}
/**
* Gets tag settings
*
* @param {Element} tag
* The player tag
*
* @return {Object}
* An object containing all of the settings
* for a player tag
*/
static getTagSettings(tag) {
const baseOptions = {
sources: [],
tracks: []
};
const tagOptions = Dom.getAttributes(tag);
const dataSetup = tagOptions['data-setup'];
if (Dom.hasClass(tag, 'vjs-fill')) {
tagOptions.fill = true;
}
if (Dom.hasClass(tag, 'vjs-fluid')) {
tagOptions.fluid = true;
}
// Check if data-setup attr exists.
if (dataSetup !== null) {
// Parse options JSON
// If empty string, make it a parsable json object.
const [err, data] = safeParseTuple(dataSetup || '{}');
if (err) {
log.error(err);
}
Object.assign(tagOptions, data);
}
Object.assign(baseOptions, tagOptions);
// Get tag children settings
if (tag.hasChildNodes()) {
const children = tag.childNodes;
for (let i = 0, j = children.length; i < j; i++) {
const child = children[i];
// Change case needed: http://ejohn.org/blog/nodename-case-sensitivity/
const childName = child.nodeName.toLowerCase();
if (childName === 'source') {
baseOptions.sources.push(Dom.getAttributes(child));
} else if (childName === 'track') {
baseOptions.tracks.push(Dom.getAttributes(child));
}
}
}
return baseOptions;
}
/**
* Set debug mode to enable/disable logs at info level.
*
* @param {boolean} enabled
* @fires Player#debugon
* @fires Player#debugoff
* @return {boolean|undefined}
*/
debug(enabled) {
if (enabled === undefined) {
return this.debugEnabled_;
}
if (enabled) {
this.trigger('debugon');
this.previousLogLevel_ = this.log.level;
this.log.level('debug');
this.debugEnabled_ = true;
} else {
this.trigger('debugoff');
this.log.level(this.previousLogLevel_);
this.previousLogLevel_ = undefined;
this.debugEnabled_ = false;
}
}
/**
* Set or get current playback rates.
* Takes an array and updates the playback rates menu with the new items.
* Pass in an empty array to hide the menu.
* Values other than arrays are ignored.
*
* @fires Player#playbackrateschange
* @param {number[]} [newRates]
* The new rates that the playback rates menu should update to.
* An empty array will hide the menu
* @return {number[]} When used as a getter will return the current playback rates
*/
playbackRates(newRates) {
if (newRates === undefined) {
return this.cache_.playbackRates;
}
// ignore any value that isn't an array
if (!Array.isArray(newRates)) {
return;
}
// ignore any arrays that don't only contain numbers
if (!newRates.every((rate) => typeof rate === 'number')) {
return;
}
this.cache_.playbackRates = newRates;
/**
* fires when the playback rates in a player are changed
*
* @event Player#playbackrateschange
* @type {Event}
*/
this.trigger('playbackrateschange');
}
}
/**
* Get the {@link VideoTrackList}
*
* @link https://html.spec.whatwg.org/multipage/embedded-content.html#videotracklist
*
* @return {VideoTrackList}
* the current video track list
*
* @method Player.prototype.videoTracks
*/
/**
* Get the {@link AudioTrackList}
*
* @link https://html.spec.whatwg.org/multipage/embedded-content.html#audiotracklist
*
* @return {AudioTrackList}
* the current audio track list
*
* @method Player.prototype.audioTracks
*/
/**
* Get the {@link TextTrackList}
*
* @link http://www.w3.org/html/wg/drafts/html/master/embedded-content-0.html#dom-media-texttracks
*
* @return {TextTrackList}
* the current text track list
*
* @method Player.prototype.textTracks
*/
/**
* Get the remote {@link TextTrackList}
*
* @return {TextTrackList}
* The current remote text track list
*
* @method Player.prototype.remoteTextTracks
*/
/**
* Get the remote {@link HtmlTrackElementList} tracks.
*
* @return {HtmlTrackElementList}
* The current remote text track element list
*
* @method Player.prototype.remoteTextTrackEls
*/
TRACK_TYPES.names.forEach(function(name) {
const props = TRACK_TYPES[name];
Player.prototype[props.getterName] = function() {
if (this.tech_) {
return this.tech_[props.getterName]();
}
// if we have not yet loadTech_, we create {video,audio,text}Tracks_
// these will be passed to the tech during loading
this[props.privateName] = this[props.privateName] || new props.ListClass();
return this[props.privateName];
};
});
/**
* Get or set the `Player`'s crossorigin option. For the HTML5 player, this
* sets the `crossOrigin` property on the `<video>` tag to control the CORS
* behavior.
*
* @see [Video Element Attributes]{@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video#attr-crossorigin}
*
* @param {string} [value]
* The value to set the `Player`'s crossorigin to. If an argument is
* given, must be one of `anonymous` or `use-credentials`.
*
* @return {string|undefined}
* - The current crossorigin value of the `Player` when getting.
* - undefined when setting
*/
Player.prototype.crossorigin = Player.prototype.crossOrigin;
/**
* Global enumeration of players.
*
* The keys are the player IDs and the values are either the {@link Player}
* instance or `null` for disposed players.
*
* @type {Object}
*/
Player.players = {};
const navigator = window.navigator;
/*
* Player instance options, surfaced using options
* options = Player.prototype.options_
* Make changes in options, not here.
*
* @type {Object}
* @private
*/
Player.prototype.options_ = {
// Default order of fallback technology
techOrder: Tech.defaultTechOrder_,
html5: {},
// enable sourceset by default
enableSourceset: true,
// default inactivity timeout
inactivityTimeout: 2000,
// default playback rates
playbackRates: [],
// Add playback rate selection by adding rates
// 'playbackRates': [0.5, 1, 1.5, 2],
liveui: false,
// Included control sets
children: [
'mediaLoader',
'posterImage',
'titleBar',
'textTrackDisplay',
'loadingSpinner',
'bigPlayButton',
'liveTracker',
'controlBar',
'errorDisplay',
'textTrackSettings',
'resizeManager'
],
language: navigator && (navigator.languages && navigator.languages[0] || navigator.userLanguage || navigator.language) || 'en',
// locales and their language translations
languages: {},
// Default message to show when a video cannot be played.
notSupportedMessage: 'No compatible source was found for this media.',
normalizeAutoplay: false,
fullscreen: {
options: {
navigationUI: 'hide'
}
},
breakpoints: {},
responsive: false,
audioOnlyMode: false,
audioPosterMode: false,
spatialNavigation: {
enabled: false,
horizontalSeek: false
},
// Default smooth seeking to false
enableSmoothSeeking: false
};
TECH_EVENTS_RETRIGGER.forEach(function(event) {
Player.prototype[`handleTech${toTitleCase(event)}_`] = function() {
return this.trigger(event);
};
});
/**
* Fired when the player has initial duration and dimension information
*
* @event Player#loadedmetadata
* @type {Event}
*/
/**
* Fired when the player has downloaded data at the current playback position
*
* @event Player#loadeddata
* @type {Event}
*/
/**
* Fired when the current playback position has changed *
* During playback this is fired every 15-250 milliseconds, depending on the
* playback technology in use.
*
* @event Player#timeupdate
* @type {Event}
*/
/**
* Fired when the volume changes
*
* @event Player#volumechange
* @type {Event}
*/
/**
* Reports whether or not a player has a plugin available.
*
* This does not report whether or not the plugin has ever been initialized
* on this player. For that, [usingPlugin]{@link Player#usingPlugin}.
*
* @method Player#hasPlugin
* @param {string} name
* The name of a plugin.
*
* @return {boolean}
* Whether or not this player has the requested plugin available.
*/
/**
* Reports whether or not a player is using a plugin by name.
*
* For basic plugins, this only reports whether the plugin has _ever_ been
* initialized on this player.
*
* @method Player#usingPlugin
* @param {string} name
* The name of a plugin.
*
* @return {boolean}
* Whether or not this player is using the requested plugin.
*/
Component.registerComponent('Player', Player);
export default Player;