summaryrefslogtreecommitdiff
path: root/javascript/videojs/src/js/control-bar
diff options
context:
space:
mode:
Diffstat (limited to 'javascript/videojs/src/js/control-bar')
-rw-r--r--javascript/videojs/src/js/control-bar/audio-track-controls/audio-track-button.js85
-rw-r--r--javascript/videojs/src/js/control-bar/audio-track-controls/audio-track-menu-item.js118
-rw-r--r--javascript/videojs/src/js/control-bar/control-bar.js81
-rw-r--r--javascript/videojs/src/js/control-bar/fullscreen-toggle.js95
-rw-r--r--javascript/videojs/src/js/control-bar/live-display.js88
-rw-r--r--javascript/videojs/src/js/control-bar/mute-toggle.js154
-rw-r--r--javascript/videojs/src/js/control-bar/picture-in-picture-toggle.js159
-rw-r--r--javascript/videojs/src/js/control-bar/play-toggle.js151
-rw-r--r--javascript/videojs/src/js/control-bar/playback-rate-menu/playback-rate-menu-button.js176
-rw-r--r--javascript/videojs/src/js/control-bar/playback-rate-menu/playback-rate-menu-item.js82
-rw-r--r--javascript/videojs/src/js/control-bar/progress-control/load-progress-bar.js127
-rw-r--r--javascript/videojs/src/js/control-bar/progress-control/mouse-time-display.js80
-rw-r--r--javascript/videojs/src/js/control-bar/progress-control/play-progress-bar.js99
-rw-r--r--javascript/videojs/src/js/control-bar/progress-control/progress-control.js249
-rw-r--r--javascript/videojs/src/js/control-bar/progress-control/seek-bar.js610
-rw-r--r--javascript/videojs/src/js/control-bar/progress-control/time-tooltip.js170
-rw-r--r--javascript/videojs/src/js/control-bar/seek-to-live.js108
-rw-r--r--javascript/videojs/src/js/control-bar/skip-buttons/skip-backward.js77
-rw-r--r--javascript/videojs/src/js/control-bar/skip-buttons/skip-forward.js80
-rw-r--r--javascript/videojs/src/js/control-bar/spacer-controls/custom-control-spacer.js41
-rw-r--r--javascript/videojs/src/js/control-bar/spacer-controls/spacer.js41
-rw-r--r--javascript/videojs/src/js/control-bar/text-track-controls/caption-settings-menu-item.js71
-rw-r--r--javascript/videojs/src/js/control-bar/text-track-controls/captions-button.js87
-rw-r--r--javascript/videojs/src/js/control-bar/text-track-controls/chapters-button.js224
-rw-r--r--javascript/videojs/src/js/control-bar/text-track-controls/chapters-track-menu-item.js60
-rw-r--r--javascript/videojs/src/js/control-bar/text-track-controls/descriptions-button.js105
-rw-r--r--javascript/videojs/src/js/control-bar/text-track-controls/off-text-track-menu-item.js115
-rw-r--r--javascript/videojs/src/js/control-bar/text-track-controls/subs-caps-button.js99
-rw-r--r--javascript/videojs/src/js/control-bar/text-track-controls/subs-caps-menu-item.js43
-rw-r--r--javascript/videojs/src/js/control-bar/text-track-controls/subtitles-button.js66
-rw-r--r--javascript/videojs/src/js/control-bar/text-track-controls/text-track-button.js93
-rw-r--r--javascript/videojs/src/js/control-bar/text-track-controls/text-track-menu-item.js182
-rw-r--r--javascript/videojs/src/js/control-bar/time-controls/current-time-display.js67
-rw-r--r--javascript/videojs/src/js/control-bar/time-controls/duration-display.js93
-rw-r--r--javascript/videojs/src/js/control-bar/time-controls/remaining-time-display.js105
-rw-r--r--javascript/videojs/src/js/control-bar/time-controls/time-display.js164
-rw-r--r--javascript/videojs/src/js/control-bar/time-controls/time-divider.js44
-rw-r--r--javascript/videojs/src/js/control-bar/track-button.js56
-rw-r--r--javascript/videojs/src/js/control-bar/volume-control/check-mute-support.js31
-rw-r--r--javascript/videojs/src/js/control-bar/volume-control/check-volume-support.js31
-rw-r--r--javascript/videojs/src/js/control-bar/volume-control/mouse-volume-level-display.js89
-rw-r--r--javascript/videojs/src/js/control-bar/volume-control/volume-bar.js212
-rw-r--r--javascript/videojs/src/js/control-bar/volume-control/volume-control.js148
-rw-r--r--javascript/videojs/src/js/control-bar/volume-control/volume-level-tooltip.js135
-rw-r--r--javascript/videojs/src/js/control-bar/volume-control/volume-level.js36
-rw-r--r--javascript/videojs/src/js/control-bar/volume-panel.js207
46 files changed, 5434 insertions, 0 deletions
diff --git a/javascript/videojs/src/js/control-bar/audio-track-controls/audio-track-button.js b/javascript/videojs/src/js/control-bar/audio-track-controls/audio-track-button.js
new file mode 100644
index 0000000..80eb7f0
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/audio-track-controls/audio-track-button.js
@@ -0,0 +1,85 @@
+/**
+ * @file audio-track-button.js
+ */
+import TrackButton from '../track-button.js';
+import Component from '../../component.js';
+import AudioTrackMenuItem from './audio-track-menu-item.js';
+
+/**
+ * The base class for buttons that toggle specific {@link AudioTrack} types.
+ *
+ * @extends TrackButton
+ */
+class AudioTrackButton extends TrackButton {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options={}]
+ * The key/value store of player options.
+ */
+ constructor(player, options = {}) {
+ options.tracks = player.audioTracks();
+
+ super(player, options);
+
+ this.setIcon('audio');
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-audio-button ${super.buildCSSClass()}`;
+ }
+
+ buildWrapperCSSClass() {
+ return `vjs-audio-button ${super.buildWrapperCSSClass()}`;
+ }
+
+ /**
+ * Create a menu item for each audio track
+ *
+ * @param {AudioTrackMenuItem[]} [items=[]]
+ * An array of existing menu items to use.
+ *
+ * @return {AudioTrackMenuItem[]}
+ * An array of menu items
+ */
+ createItems(items = []) {
+ // if there's only one audio track, there no point in showing it
+ this.hideThreshold_ = 1;
+
+ const tracks = this.player_.audioTracks();
+
+ for (let i = 0; i < tracks.length; i++) {
+ const track = tracks[i];
+
+ items.push(new AudioTrackMenuItem(this.player_, {
+ track,
+ // MenuItem is selectable
+ selectable: true,
+ // MenuItem is NOT multiSelectable (i.e. only one can be marked "selected" at a time)
+ multiSelectable: false
+ }));
+ }
+
+ return items;
+ }
+}
+
+/**
+ * The text that should display over the `AudioTrackButton`s controls. Added for localization.
+ *
+ * @type {string}
+ * @protected
+ */
+AudioTrackButton.prototype.controlText_ = 'Audio Track';
+Component.registerComponent('AudioTrackButton', AudioTrackButton);
+export default AudioTrackButton;
diff --git a/javascript/videojs/src/js/control-bar/audio-track-controls/audio-track-menu-item.js b/javascript/videojs/src/js/control-bar/audio-track-controls/audio-track-menu-item.js
new file mode 100644
index 0000000..be26f6b
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/audio-track-controls/audio-track-menu-item.js
@@ -0,0 +1,118 @@
+/**
+ * @file audio-track-menu-item.js
+ */
+import MenuItem from '../../menu/menu-item.js';
+import Component from '../../component.js';
+import * as Dom from '../../utils/dom.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * An {@link AudioTrack} {@link MenuItem}
+ *
+ * @extends MenuItem
+ */
+class AudioTrackMenuItem extends MenuItem {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ const track = options.track;
+ const tracks = player.audioTracks();
+
+ // Modify options for parent MenuItem class's init.
+ options.label = track.label || track.language || 'Unknown';
+ options.selected = track.enabled;
+
+ super(player, options);
+
+ this.track = track;
+
+ this.addClass(`vjs-${track.kind}-menu-item`);
+
+ const changeHandler = (...args) => {
+ this.handleTracksChange.apply(this, args);
+ };
+
+ tracks.addEventListener('change', changeHandler);
+ this.on('dispose', () => {
+ tracks.removeEventListener('change', changeHandler);
+ });
+ }
+
+ createEl(type, props, attrs) {
+ const el = super.createEl(type, props, attrs);
+ const parentSpan = el.querySelector('.vjs-menu-item-text');
+
+ if (['main-desc', 'descriptions'].indexOf(this.options_.track.kind) >= 0) {
+ parentSpan.appendChild(Dom.createEl('span', {
+ className: 'vjs-icon-placeholder'
+ }, {
+ 'aria-hidden': true
+ }));
+ parentSpan.appendChild(Dom.createEl('span', {
+ className: 'vjs-control-text',
+ textContent: ' ' + this.localize('Descriptions')
+ }));
+ }
+
+ return el;
+ }
+
+ /**
+ * This gets called when an `AudioTrackMenuItem is "clicked". See {@link ClickableComponent}
+ * for more detailed information on what a click can be.
+ *
+ * @param {Event} [event]
+ * The `keydown`, `tap`, or `click` event that caused this function to be
+ * called.
+ *
+ * @listens tap
+ * @listens click
+ */
+ handleClick(event) {
+ super.handleClick(event);
+
+ // the audio track list will automatically toggle other tracks
+ // off for us.
+ this.track.enabled = true;
+
+ // when native audio tracks are used, we want to make sure that other tracks are turned off
+ if (this.player_.tech_.featuresNativeAudioTracks) {
+ const tracks = this.player_.audioTracks();
+
+ for (let i = 0; i < tracks.length; i++) {
+ const track = tracks[i];
+
+ // skip the current track since we enabled it above
+ if (track === this.track) {
+ continue;
+ }
+
+ track.enabled = track === this.track;
+ }
+ }
+ }
+
+ /**
+ * Handle any {@link AudioTrack} change.
+ *
+ * @param {Event} [event]
+ * The {@link AudioTrackList#change} event that caused this to run.
+ *
+ * @listens AudioTrackList#change
+ */
+ handleTracksChange(event) {
+ this.selected(this.track.enabled);
+ }
+}
+
+Component.registerComponent('AudioTrackMenuItem', AudioTrackMenuItem);
+export default AudioTrackMenuItem;
diff --git a/javascript/videojs/src/js/control-bar/control-bar.js b/javascript/videojs/src/js/control-bar/control-bar.js
new file mode 100644
index 0000000..cc03fe9
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/control-bar.js
@@ -0,0 +1,81 @@
+/**
+ * @file control-bar.js
+ */
+import Component from '../component.js';
+
+// Required children
+import './play-toggle.js';
+import './time-controls/current-time-display.js';
+import './time-controls/duration-display.js';
+import './time-controls/time-divider.js';
+import './time-controls/remaining-time-display.js';
+import './live-display.js';
+import './seek-to-live.js';
+import './progress-control/progress-control.js';
+import './picture-in-picture-toggle.js';
+import './fullscreen-toggle.js';
+import './volume-panel.js';
+import './skip-buttons/skip-forward.js';
+import './skip-buttons/skip-backward.js';
+import './text-track-controls/chapters-button.js';
+import './text-track-controls/descriptions-button.js';
+import './text-track-controls/subtitles-button.js';
+import './text-track-controls/captions-button.js';
+import './text-track-controls/subs-caps-button.js';
+import './audio-track-controls/audio-track-button.js';
+import './playback-rate-menu/playback-rate-menu-button.js';
+import './spacer-controls/custom-control-spacer.js';
+
+/**
+ * Container of main controls.
+ *
+ * @extends Component
+ */
+class ControlBar extends Component {
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ return super.createEl('div', {
+ className: 'vjs-control-bar',
+ dir: 'ltr'
+ });
+ }
+}
+
+/**
+ * Default options for `ControlBar`
+ *
+ * @type {Object}
+ * @private
+ */
+ControlBar.prototype.options_ = {
+ children: [
+ 'playToggle',
+ 'skipBackward',
+ 'skipForward',
+ 'volumePanel',
+ 'currentTimeDisplay',
+ 'timeDivider',
+ 'durationDisplay',
+ 'progressControl',
+ 'liveDisplay',
+ 'seekToLive',
+ 'remainingTimeDisplay',
+ 'customControlSpacer',
+ 'playbackRateMenuButton',
+ 'chaptersButton',
+ 'descriptionsButton',
+ 'subsCapsButton',
+ 'audioTrackButton',
+ 'pictureInPictureToggle',
+ 'fullscreenToggle'
+ ]
+};
+
+Component.registerComponent('ControlBar', ControlBar);
+export default ControlBar;
diff --git a/javascript/videojs/src/js/control-bar/fullscreen-toggle.js b/javascript/videojs/src/js/control-bar/fullscreen-toggle.js
new file mode 100644
index 0000000..2c6271b
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/fullscreen-toggle.js
@@ -0,0 +1,95 @@
+/**
+ * @file fullscreen-toggle.js
+ */
+import Button from '../button.js';
+import Component from '../component.js';
+import document from 'global/document';
+
+/** @import Player from './player' */
+
+/**
+ * Toggle fullscreen video
+ *
+ * @extends Button
+ */
+class FullscreenToggle extends Button {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+ this.setIcon('fullscreen-enter');
+ this.on(player, 'fullscreenchange', (e) => this.handleFullscreenChange(e));
+
+ if (document[player.fsApi_.fullscreenEnabled] === false) {
+ this.disable();
+ }
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-fullscreen-control ${super.buildCSSClass()}`;
+ }
+
+ /**
+ * Handles fullscreenchange on the player and change control text accordingly.
+ *
+ * @param {Event} [event]
+ * The {@link Player#fullscreenchange} event that caused this function to be
+ * called.
+ *
+ * @listens Player#fullscreenchange
+ */
+ handleFullscreenChange(event) {
+ if (this.player_.isFullscreen()) {
+ this.controlText('Exit Fullscreen');
+ this.setIcon('fullscreen-exit');
+ } else {
+ this.controlText('Fullscreen');
+ this.setIcon('fullscreen-enter');
+ }
+ }
+
+ /**
+ * This gets called when an `FullscreenToggle` is "clicked". See
+ * {@link ClickableComponent} for more detailed information on what a click can be.
+ *
+ * @param {Event} [event]
+ * The `keydown`, `tap`, or `click` event that caused this function to be
+ * called.
+ *
+ * @listens tap
+ * @listens click
+ */
+ handleClick(event) {
+ if (!this.player_.isFullscreen()) {
+ this.player_.requestFullscreen();
+ } else {
+ this.player_.exitFullscreen();
+ }
+ }
+
+}
+
+/**
+ * The text that should display over the `FullscreenToggle`s controls. Added for localization.
+ *
+ * @type {string}
+ * @protected
+ */
+FullscreenToggle.prototype.controlText_ = 'Fullscreen';
+
+Component.registerComponent('FullscreenToggle', FullscreenToggle);
+export default FullscreenToggle;
diff --git a/javascript/videojs/src/js/control-bar/live-display.js b/javascript/videojs/src/js/control-bar/live-display.js
new file mode 100644
index 0000000..d62c4cc
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/live-display.js
@@ -0,0 +1,88 @@
+/**
+ * @file live-display.js
+ */
+import Component from '../component';
+import * as Dom from '../utils/dom.js';
+import document from 'global/document';
+
+/** @import Player from './player' */
+
+// TODO - Future make it click to snap to live
+
+/**
+ * Displays the live indicator when duration is Infinity.
+ *
+ * @extends Component
+ */
+class LiveDisplay extends Component {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+
+ this.updateShowing();
+ this.on(this.player(), 'durationchange', (e) => this.updateShowing(e));
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ const el = super.createEl('div', {
+ className: 'vjs-live-control vjs-control'
+ });
+
+ this.contentEl_ = Dom.createEl('div', {
+ className: 'vjs-live-display'
+ }, {
+ 'aria-live': 'off'
+ });
+
+ this.contentEl_.appendChild(Dom.createEl('span', {
+ className: 'vjs-control-text',
+ textContent: `${this.localize('Stream Type')}\u00a0`
+ }));
+ this.contentEl_.appendChild(document.createTextNode(this.localize('LIVE')));
+
+ el.appendChild(this.contentEl_);
+ return el;
+ }
+
+ dispose() {
+ this.contentEl_ = null;
+
+ super.dispose();
+ }
+
+ /**
+ * Check the duration to see if the LiveDisplay should be showing or not. Then show/hide
+ * it accordingly
+ *
+ * @param {Event} [event]
+ * The {@link Player#durationchange} event that caused this function to run.
+ *
+ * @listens Player#durationchange
+ */
+ updateShowing(event) {
+ if (this.player().duration() === Infinity) {
+ this.show();
+ } else {
+ this.hide();
+ }
+ }
+
+}
+
+Component.registerComponent('LiveDisplay', LiveDisplay);
+export default LiveDisplay;
diff --git a/javascript/videojs/src/js/control-bar/mute-toggle.js b/javascript/videojs/src/js/control-bar/mute-toggle.js
new file mode 100644
index 0000000..091a739
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/mute-toggle.js
@@ -0,0 +1,154 @@
+/**
+ * @file mute-toggle.js
+ */
+import Button from '../button';
+import Component from '../component';
+import * as Dom from '../utils/dom.js';
+import checkMuteSupport from './volume-control/check-mute-support';
+import * as browser from '../utils/browser.js';
+
+/** @import Player from './player' */
+
+/**
+ * A button component for muting the audio.
+ *
+ * @extends Button
+ */
+class MuteToggle extends Button {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+
+ // hide this control if volume support is missing
+ checkMuteSupport(this, player);
+
+ this.on(player, ['loadstart', 'volumechange'], (e) => this.update(e));
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-mute-control ${super.buildCSSClass()}`;
+ }
+
+ /**
+ * This gets called when an `MuteToggle` is "clicked". See
+ * {@link ClickableComponent} for more detailed information on what a click can be.
+ *
+ * @param {Event} [event]
+ * The `keydown`, `tap`, or `click` event that caused this function to be
+ * called.
+ *
+ * @listens tap
+ * @listens click
+ */
+ handleClick(event) {
+ const vol = this.player_.volume();
+ const lastVolume = this.player_.lastVolume_();
+
+ if (vol === 0) {
+ const volumeToSet = lastVolume < 0.1 ? 0.1 : lastVolume;
+
+ this.player_.volume(volumeToSet);
+ this.player_.muted(false);
+ } else {
+ this.player_.muted(this.player_.muted() ? false : true);
+ }
+ }
+
+ /**
+ * Update the `MuteToggle` button based on the state of `volume` and `muted`
+ * on the player.
+ *
+ * @param {Event} [event]
+ * The {@link Player#loadstart} event if this function was called
+ * through an event.
+ *
+ * @listens Player#loadstart
+ * @listens Player#volumechange
+ */
+ update(event) {
+ this.updateIcon_();
+ this.updateControlText_();
+ }
+
+ /**
+ * Update the appearance of the `MuteToggle` icon.
+ *
+ * Possible states (given `level` variable below):
+ * - 0: crossed out
+ * - 1: zero bars of volume
+ * - 2: one bar of volume
+ * - 3: two bars of volume
+ *
+ * @private
+ */
+ updateIcon_() {
+ const vol = this.player_.volume();
+ let level = 3;
+
+ this.setIcon('volume-high');
+
+ // in iOS when a player is loaded with muted attribute
+ // and volume is changed with a native mute button
+ // we want to make sure muted state is updated
+ if (browser.IS_IOS && this.player_.tech_ && this.player_.tech_.el_) {
+ this.player_.muted(this.player_.tech_.el_.muted);
+ }
+
+ if (vol === 0 || this.player_.muted()) {
+ this.setIcon('volume-mute');
+ level = 0;
+ } else if (vol < 0.33) {
+ this.setIcon('volume-low');
+ level = 1;
+ } else if (vol < 0.67) {
+ this.setIcon('volume-medium');
+ level = 2;
+ }
+
+ Dom.removeClass(this.el_, [0, 1, 2, 3].reduce((str, i) => str + `${i ? ' ' : ''}vjs-vol-${i}`, ''));
+ Dom.addClass(this.el_, `vjs-vol-${level}`);
+ }
+
+ /**
+ * If `muted` has changed on the player, update the control text
+ * (`title` attribute on `vjs-mute-control` element and content of
+ * `vjs-control-text` element).
+ *
+ * @private
+ */
+ updateControlText_() {
+ const soundOff = this.player_.muted() || this.player_.volume() === 0;
+ const text = soundOff ? 'Unmute' : 'Mute';
+
+ if (this.controlText() !== text) {
+ this.controlText(text);
+ }
+ }
+
+}
+
+/**
+ * The text that should display over the `MuteToggle`s controls. Added for localization.
+ *
+ * @type {string}
+ * @protected
+ */
+MuteToggle.prototype.controlText_ = 'Mute';
+
+Component.registerComponent('MuteToggle', MuteToggle);
+export default MuteToggle;
diff --git a/javascript/videojs/src/js/control-bar/picture-in-picture-toggle.js b/javascript/videojs/src/js/control-bar/picture-in-picture-toggle.js
new file mode 100644
index 0000000..9a37643
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/picture-in-picture-toggle.js
@@ -0,0 +1,159 @@
+/**
+ * @file picture-in-picture-toggle.js
+ */
+import Button from '../button.js';
+import Component from '../component.js';
+import document from 'global/document';
+import window from 'global/window';
+
+/** @import Player from './player' */
+
+/**
+ * Toggle Picture-in-Picture mode
+ *
+ * @extends Button
+ */
+class PictureInPictureToggle extends Button {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ *
+ * @listens Player#enterpictureinpicture
+ * @listens Player#leavepictureinpicture
+ */
+ constructor(player, options) {
+ super(player, options);
+
+ this.setIcon('picture-in-picture-enter');
+
+ this.on(player, ['enterpictureinpicture', 'leavepictureinpicture'], (e) => this.handlePictureInPictureChange(e));
+ this.on(player, ['disablepictureinpicturechanged', 'loadedmetadata'], (e) => this.handlePictureInPictureEnabledChange(e));
+ this.on(player, ['loadedmetadata', 'audioonlymodechange', 'audiopostermodechange'], () => this.handlePictureInPictureAudioModeChange());
+
+ // TODO: Deactivate button on player emptied event.
+ this.disable();
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-picture-in-picture-control vjs-hidden ${super.buildCSSClass()}`;
+ }
+
+ /**
+ * Displays or hides the button depending on the audio mode detection.
+ * Exits picture-in-picture if it is enabled when switching to audio mode.
+ */
+ handlePictureInPictureAudioModeChange() {
+ // This audio detection will not detect HLS or DASH audio-only streams because there was no reliable way to detect them at the time
+ const isSourceAudio = this.player_.currentType().substring(0, 5) === 'audio';
+ const isAudioMode =
+ isSourceAudio || this.player_.audioPosterMode() || this.player_.audioOnlyMode();
+
+ if (!isAudioMode) {
+ this.show();
+
+ return;
+ }
+
+ if (this.player_.isInPictureInPicture()) {
+ this.player_.exitPictureInPicture();
+ }
+
+ this.hide();
+ }
+
+ /**
+ * Enables or disables button based on availability of a Picture-In-Picture mode.
+ *
+ * Enabled if
+ * - `player.options().enableDocumentPictureInPicture` is true and
+ * window.documentPictureInPicture is available; or
+ * - `player.disablePictureInPicture()` is false and
+ * element.requestPictureInPicture is available
+ */
+ handlePictureInPictureEnabledChange() {
+ if (
+ (document.pictureInPictureEnabled && this.player_.disablePictureInPicture() === false) ||
+ (this.player_.options_.enableDocumentPictureInPicture && 'documentPictureInPicture' in window)
+ ) {
+ this.enable();
+ } else {
+ this.disable();
+ }
+ }
+
+ /**
+ * Handles enterpictureinpicture and leavepictureinpicture on the player and change control text accordingly.
+ *
+ * @param {Event} [event]
+ * The {@link Player#enterpictureinpicture} or {@link Player#leavepictureinpicture} event that caused this function to be
+ * called.
+ *
+ * @listens Player#enterpictureinpicture
+ * @listens Player#leavepictureinpicture
+ */
+ handlePictureInPictureChange(event) {
+ if (this.player_.isInPictureInPicture()) {
+ this.setIcon('picture-in-picture-exit');
+ this.controlText('Exit Picture-in-Picture');
+ } else {
+ this.setIcon('picture-in-picture-enter');
+ this.controlText('Picture-in-Picture');
+ }
+ this.handlePictureInPictureEnabledChange();
+ }
+
+ /**
+ * This gets called when an `PictureInPictureToggle` is "clicked". See
+ * {@link ClickableComponent} for more detailed information on what a click can be.
+ *
+ * @param {Event} [event]
+ * The `keydown`, `tap`, or `click` event that caused this function to be
+ * called.
+ *
+ * @listens tap
+ * @listens click
+ */
+ handleClick(event) {
+ if (!this.player_.isInPictureInPicture()) {
+ this.player_.requestPictureInPicture();
+ } else {
+ this.player_.exitPictureInPicture();
+ }
+ }
+
+ /**
+ * Show the `Component`s element if it is hidden by removing the
+ * 'vjs-hidden' class name from it only in browsers that support the Picture-in-Picture API.
+ */
+ show() {
+ // Does not allow to display the pictureInPictureToggle in browsers that do not support the Picture-in-Picture API, e.g. Firefox.
+ if (typeof document.exitPictureInPicture !== 'function') {
+ return;
+ }
+
+ super.show();
+ }
+}
+
+/**
+ * The text that should display over the `PictureInPictureToggle`s controls. Added for localization.
+ *
+ * @type {string}
+ * @protected
+ */
+PictureInPictureToggle.prototype.controlText_ = 'Picture-in-Picture';
+
+Component.registerComponent('PictureInPictureToggle', PictureInPictureToggle);
+export default PictureInPictureToggle;
diff --git a/javascript/videojs/src/js/control-bar/play-toggle.js b/javascript/videojs/src/js/control-bar/play-toggle.js
new file mode 100644
index 0000000..424f579
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/play-toggle.js
@@ -0,0 +1,151 @@
+/**
+ * @file play-toggle.js
+ */
+import Button from '../button.js';
+import Component from '../component.js';
+import {silencePromise} from '../utils/promise';
+
+/** @import Player from './player' */
+
+/**
+ * Button to toggle between play and pause.
+ *
+ * @extends Button
+ */
+class PlayToggle extends Button {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options={}]
+ * The key/value store of player options.
+ */
+ constructor(player, options = {}) {
+ super(player, options);
+
+ // show or hide replay icon
+ options.replay = options.replay === undefined || options.replay;
+
+ this.setIcon('play');
+
+ this.on(player, 'play', (e) => this.handlePlay(e));
+ this.on(player, 'pause', (e) => this.handlePause(e));
+
+ if (options.replay) {
+ this.on(player, 'ended', (e) => this.handleEnded(e));
+ }
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-play-control ${super.buildCSSClass()}`;
+ }
+
+ /**
+ * This gets called when an `PlayToggle` is "clicked". See
+ * {@link ClickableComponent} for more detailed information on what a click can be.
+ *
+ * @param {Event} [event]
+ * The `keydown`, `tap`, or `click` event that caused this function to be
+ * called.
+ *
+ * @listens tap
+ * @listens click
+ */
+ handleClick(event) {
+ if (this.player_.paused()) {
+ silencePromise(this.player_.play());
+ } else {
+ this.player_.pause();
+ }
+ }
+
+ /**
+ * This gets called once after the video has ended and the user seeks so that
+ * we can change the replay button back to a play button.
+ *
+ * @param {Event} [event]
+ * The event that caused this function to run.
+ *
+ * @listens Player#seeked
+ */
+ handleSeeked(event) {
+ this.removeClass('vjs-ended');
+
+ if (this.player_.paused()) {
+ this.handlePause(event);
+ } else {
+ this.handlePlay(event);
+ }
+ }
+
+ /**
+ * Add the vjs-playing class to the element so it can change appearance.
+ *
+ * @param {Event} [event]
+ * The event that caused this function to run.
+ *
+ * @listens Player#play
+ */
+ handlePlay(event) {
+ this.removeClass('vjs-ended', 'vjs-paused');
+ this.addClass('vjs-playing');
+ // change the button text to "Pause"
+ this.setIcon('pause');
+ this.controlText('Pause');
+ }
+
+ /**
+ * Add the vjs-paused class to the element so it can change appearance.
+ *
+ * @param {Event} [event]
+ * The event that caused this function to run.
+ *
+ * @listens Player#pause
+ */
+ handlePause(event) {
+ this.removeClass('vjs-playing');
+ this.addClass('vjs-paused');
+ // change the button text to "Play"
+ this.setIcon('play');
+ this.controlText('Play');
+ }
+
+ /**
+ * Add the vjs-ended class to the element so it can change appearance
+ *
+ * @param {Event} [event]
+ * The event that caused this function to run.
+ *
+ * @listens Player#ended
+ */
+ handleEnded(event) {
+ this.removeClass('vjs-playing');
+ this.addClass('vjs-ended');
+ // change the button text to "Replay"
+ this.setIcon('replay');
+ this.controlText('Replay');
+
+ // on the next seek remove the replay button
+ this.one(this.player_, 'seeked', (e) => this.handleSeeked(e));
+ }
+}
+
+/**
+ * The text that should display over the `PlayToggle`s controls. Added for localization.
+ *
+ * @type {string}
+ * @protected
+ */
+PlayToggle.prototype.controlText_ = 'Play';
+
+Component.registerComponent('PlayToggle', PlayToggle);
+export default PlayToggle;
diff --git a/javascript/videojs/src/js/control-bar/playback-rate-menu/playback-rate-menu-button.js b/javascript/videojs/src/js/control-bar/playback-rate-menu/playback-rate-menu-button.js
new file mode 100644
index 0000000..147f040
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/playback-rate-menu/playback-rate-menu-button.js
@@ -0,0 +1,176 @@
+/**
+ * @file playback-rate-menu-button.js
+ */
+import MenuButton from '../../menu/menu-button.js';
+import PlaybackRateMenuItem from './playback-rate-menu-item.js';
+import Component from '../../component.js';
+import * as Dom from '../../utils/dom.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * The component for controlling the playback rate.
+ *
+ * @extends MenuButton
+ */
+class PlaybackRateMenuButton extends MenuButton {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+
+ this.menuButton_.el_.setAttribute('aria-describedby', this.labelElId_);
+
+ this.updateVisibility();
+ this.updateLabel();
+
+ this.on(player, 'loadstart', (e) => this.updateVisibility(e));
+ this.on(player, 'ratechange', (e) => this.updateLabel(e));
+ this.on(player, 'playbackrateschange', (e) => this.handlePlaybackRateschange(e));
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ const el = super.createEl();
+
+ this.labelElId_ = 'vjs-playback-rate-value-label-' + this.id_;
+
+ this.labelEl_ = Dom.createEl('div', {
+ className: 'vjs-playback-rate-value',
+ id: this.labelElId_,
+ textContent: '1x'
+ });
+
+ el.appendChild(this.labelEl_);
+
+ return el;
+ }
+
+ dispose() {
+ this.labelEl_ = null;
+
+ super.dispose();
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-playback-rate ${super.buildCSSClass()}`;
+ }
+
+ buildWrapperCSSClass() {
+ return `vjs-playback-rate ${super.buildWrapperCSSClass()}`;
+ }
+
+ /**
+ * Create the list of menu items. Specific to each subclass.
+ *
+ */
+ createItems() {
+ const rates = this.playbackRates();
+ const items = [];
+
+ for (let i = rates.length - 1; i >= 0; i--) {
+ items.push(new PlaybackRateMenuItem(this.player(), {rate: rates[i] + 'x'}));
+ }
+
+ return items;
+ }
+
+ /**
+ * On playbackrateschange, update the menu to account for the new items.
+ *
+ * @listens Player#playbackrateschange
+ */
+ handlePlaybackRateschange(event) {
+ this.update();
+ }
+
+ /**
+ * Get possible playback rates
+ *
+ * @return {Array}
+ * All possible playback rates
+ */
+ playbackRates() {
+ const player = this.player();
+
+ return (player.playbackRates && player.playbackRates()) || [];
+ }
+
+ /**
+ * Get whether playback rates is supported by the tech
+ * and an array of playback rates exists
+ *
+ * @return {boolean}
+ * Whether changing playback rate is supported
+ */
+ playbackRateSupported() {
+ return this.player().tech_ &&
+ this.player().tech_.featuresPlaybackRate &&
+ this.playbackRates() &&
+ this.playbackRates().length > 0
+ ;
+ }
+
+ /**
+ * Hide playback rate controls when they're no playback rate options to select
+ *
+ * @param {Event} [event]
+ * The event that caused this function to run.
+ *
+ * @listens Player#loadstart
+ */
+ updateVisibility(event) {
+ if (this.playbackRateSupported()) {
+ this.removeClass('vjs-hidden');
+ } else {
+ this.addClass('vjs-hidden');
+ }
+ }
+
+ /**
+ * Update button label when rate changed
+ *
+ * @param {Event} [event]
+ * The event that caused this function to run.
+ *
+ * @listens Player#ratechange
+ */
+ updateLabel(event) {
+ if (this.playbackRateSupported()) {
+ this.labelEl_.textContent = this.player().playbackRate() + 'x';
+ }
+ }
+
+}
+
+/**
+ * The text that should display over the `PlaybackRateMenuButton`s controls.
+ *
+ * Added for localization.
+ *
+ * @type {string}
+ * @protected
+ */
+PlaybackRateMenuButton.prototype.controlText_ = 'Playback Rate';
+
+Component.registerComponent('PlaybackRateMenuButton', PlaybackRateMenuButton);
+export default PlaybackRateMenuButton;
diff --git a/javascript/videojs/src/js/control-bar/playback-rate-menu/playback-rate-menu-item.js b/javascript/videojs/src/js/control-bar/playback-rate-menu/playback-rate-menu-item.js
new file mode 100644
index 0000000..b0b145a
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/playback-rate-menu/playback-rate-menu-item.js
@@ -0,0 +1,82 @@
+/**
+ * @file playback-rate-menu-item.js
+ */
+import MenuItem from '../../menu/menu-item.js';
+import Component from '../../component.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * The specific menu item type for selecting a playback rate.
+ *
+ * @extends MenuItem
+ */
+class PlaybackRateMenuItem extends MenuItem {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ const label = options.rate;
+ const rate = parseFloat(label, 10);
+
+ // Modify options for parent MenuItem class's init.
+ options.label = label;
+ options.selected = rate === player.playbackRate();
+ options.selectable = true;
+ options.multiSelectable = false;
+
+ super(player, options);
+
+ this.label = label;
+ this.rate = rate;
+
+ this.on(player, 'ratechange', (e) => this.update(e));
+ }
+
+ /**
+ * This gets called when an `PlaybackRateMenuItem` is "clicked". See
+ * {@link ClickableComponent} for more detailed information on what a click can be.
+ *
+ * @param {Event} [event]
+ * The `keydown`, `tap`, or `click` event that caused this function to be
+ * called.
+ *
+ * @listens tap
+ * @listens click
+ */
+ handleClick(event) {
+ super.handleClick();
+ this.player().playbackRate(this.rate);
+ }
+
+ /**
+ * Update the PlaybackRateMenuItem when the playbackrate changes.
+ *
+ * @param {Event} [event]
+ * The `ratechange` event that caused this function to run.
+ *
+ * @listens Player#ratechange
+ */
+ update(event) {
+ this.selected(this.player().playbackRate() === this.rate);
+ }
+
+}
+
+/**
+ * The text that should display over the `PlaybackRateMenuItem`s controls. Added for localization.
+ *
+ * @type {string}
+ * @private
+ */
+PlaybackRateMenuItem.prototype.contentElType = 'button';
+
+Component.registerComponent('PlaybackRateMenuItem', PlaybackRateMenuItem);
+export default PlaybackRateMenuItem;
diff --git a/javascript/videojs/src/js/control-bar/progress-control/load-progress-bar.js b/javascript/videojs/src/js/control-bar/progress-control/load-progress-bar.js
new file mode 100644
index 0000000..0da4ff2
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/progress-control/load-progress-bar.js
@@ -0,0 +1,127 @@
+/**
+ * @file load-progress-bar.js
+ */
+import Component from '../../component.js';
+import * as Dom from '../../utils/dom.js';
+import {clamp} from '../../utils/num';
+import document from 'global/document';
+
+/** @import Player from '../../player' */
+
+// get the percent width of a time compared to the total end
+const percentify = (time, end) => clamp((time / end) * 100, 0, 100).toFixed(2) + '%';
+
+/**
+ * Shows loading progress
+ *
+ * @extends Component
+ */
+class LoadProgressBar extends Component {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+ this.partEls_ = [];
+ this.on(player, 'progress', (e) => this.update(e));
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ const el = super.createEl('div', {className: 'vjs-load-progress'});
+ const wrapper = Dom.createEl('span', {className: 'vjs-control-text'});
+ const loadedText = Dom.createEl('span', {textContent: this.localize('Loaded')});
+ const separator = document.createTextNode(': ');
+
+ this.percentageEl_ = Dom.createEl('span', {
+ className: 'vjs-control-text-loaded-percentage',
+ textContent: '0%'
+ });
+
+ el.appendChild(wrapper);
+ wrapper.appendChild(loadedText);
+ wrapper.appendChild(separator);
+ wrapper.appendChild(this.percentageEl_);
+
+ return el;
+ }
+
+ dispose() {
+ this.partEls_ = null;
+ this.percentageEl_ = null;
+
+ super.dispose();
+ }
+
+ /**
+ * Update progress bar
+ *
+ * @param {Event} [event]
+ * The `progress` event that caused this function to run.
+ *
+ * @listens Player#progress
+ */
+ update(event) {
+ this.requestNamedAnimationFrame('LoadProgressBar#update', () => {
+ const liveTracker = this.player_.liveTracker;
+ const buffered = this.player_.buffered();
+ const duration = (liveTracker && liveTracker.isLive()) ? liveTracker.seekableEnd() : this.player_.duration();
+ const bufferedEnd = this.player_.bufferedEnd();
+ const children = this.partEls_;
+ const percent = percentify(bufferedEnd, duration);
+
+ if (this.percent_ !== percent) {
+ // update the width of the progress bar
+ this.el_.style.width = percent;
+ // update the control-text
+ Dom.textContent(this.percentageEl_, percent);
+ this.percent_ = percent;
+ }
+
+ // add child elements to represent the individual buffered time ranges
+ for (let i = 0; i < buffered.length; i++) {
+ const start = buffered.start(i);
+ const end = buffered.end(i);
+ let part = children[i];
+
+ if (!part) {
+ part = this.el_.appendChild(Dom.createEl());
+ children[i] = part;
+ }
+
+ // only update if changed
+ if (part.dataset.start === start && part.dataset.end === end) {
+ continue;
+ }
+
+ part.dataset.start = start;
+ part.dataset.end = end;
+
+ // set the percent based on the width of the progress bar (bufferedEnd)
+ part.style.left = percentify(start, bufferedEnd);
+ part.style.width = percentify(end - start, bufferedEnd);
+ }
+
+ // remove unused buffered range elements
+ for (let i = children.length; i > buffered.length; i--) {
+ this.el_.removeChild(children[i - 1]);
+ }
+ children.length = buffered.length;
+ });
+ }
+}
+
+Component.registerComponent('LoadProgressBar', LoadProgressBar);
+export default LoadProgressBar;
diff --git a/javascript/videojs/src/js/control-bar/progress-control/mouse-time-display.js b/javascript/videojs/src/js/control-bar/progress-control/mouse-time-display.js
new file mode 100644
index 0000000..f14bde1
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/progress-control/mouse-time-display.js
@@ -0,0 +1,80 @@
+/**
+ * @file mouse-time-display.js
+ */
+import Component from '../../component.js';
+import * as Fn from '../../utils/fn.js';
+
+/** @import Player from '../../player' */
+
+import './time-tooltip';
+
+/**
+ * The {@link MouseTimeDisplay} component tracks mouse movement over the
+ * {@link ProgressControl}. It displays an indicator and a {@link TimeTooltip}
+ * indicating the time which is represented by a given point in the
+ * {@link ProgressControl}.
+ *
+ * @extends Component
+ */
+class MouseTimeDisplay extends Component {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The {@link Player} that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+ this.update = Fn.throttle(Fn.bind_(this, this.update), Fn.UPDATE_REFRESH_INTERVAL);
+ }
+
+ /**
+ * Create the DOM element for this class.
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ return super.createEl('div', {
+ className: 'vjs-mouse-display'
+ });
+ }
+
+ /**
+ * Enqueues updates to its own DOM as well as the DOM of its
+ * {@link TimeTooltip} child.
+ *
+ * @param {Object} seekBarRect
+ * The `ClientRect` for the {@link SeekBar} element.
+ *
+ * @param {number} seekBarPoint
+ * A number from 0 to 1, representing a horizontal reference point
+ * from the left edge of the {@link SeekBar}
+ */
+ update(seekBarRect, seekBarPoint) {
+ const time = seekBarPoint * this.player_.duration();
+
+ this.getChild('timeTooltip').updateTime(seekBarRect, seekBarPoint, time, () => {
+ this.el_.style.left = `${seekBarRect.width * seekBarPoint}px`;
+ });
+ }
+}
+
+/**
+ * Default options for `MouseTimeDisplay`
+ *
+ * @type {Object}
+ * @private
+ */
+MouseTimeDisplay.prototype.options_ = {
+ children: [
+ 'timeTooltip'
+ ]
+};
+
+Component.registerComponent('MouseTimeDisplay', MouseTimeDisplay);
+export default MouseTimeDisplay;
diff --git a/javascript/videojs/src/js/control-bar/progress-control/play-progress-bar.js b/javascript/videojs/src/js/control-bar/progress-control/play-progress-bar.js
new file mode 100644
index 0000000..cc22e99
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/progress-control/play-progress-bar.js
@@ -0,0 +1,99 @@
+/**
+ * @file play-progress-bar.js
+ */
+import Component from '../../component.js';
+import {IS_IOS, IS_ANDROID} from '../../utils/browser.js';
+import * as Fn from '../../utils/fn.js';
+
+/** @import Player from '../../player' */
+
+import './time-tooltip';
+
+/**
+ * Used by {@link SeekBar} to display media playback progress as part of the
+ * {@link ProgressControl}.
+ *
+ * @extends Component
+ */
+class PlayProgressBar extends Component {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The {@link Player} that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+ this.setIcon('circle');
+ this.update = Fn.throttle(Fn.bind_(this, this.update), Fn.UPDATE_REFRESH_INTERVAL);
+ }
+
+ /**
+ * Create the the DOM element for this class.
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ return super.createEl('div', {
+ className: 'vjs-play-progress vjs-slider-bar'
+ }, {
+ 'aria-hidden': 'true'
+ });
+ }
+
+ /**
+ * Enqueues updates to its own DOM as well as the DOM of its
+ * {@link TimeTooltip} child.
+ *
+ * @param {Object} seekBarRect
+ * The `ClientRect` for the {@link SeekBar} element.
+ *
+ * @param {number} seekBarPoint
+ * A number from 0 to 1, representing a horizontal reference point
+ * from the left edge of the {@link SeekBar}
+ *
+ * @param {Event} [event]
+ * The `timeupdate` event that caused this function to run.
+ */
+ update(seekBarRect, seekBarPoint, event) {
+ const timeTooltip = this.getChild('timeTooltip');
+
+ if (!timeTooltip) {
+ return;
+ }
+
+ // Combined logic: if an event with a valid pendingSeekTime getter exists, use it.
+ const time = (event &&
+ event.target &&
+ typeof event.target.pendingSeekTime === 'function') ?
+ event.target.pendingSeekTime() :
+ (this.player_.scrubbing() ?
+ this.player_.getCache().currentTime :
+ this.player_.currentTime());
+
+ timeTooltip.updateTime(seekBarRect, seekBarPoint, time);
+ }
+}
+
+/**
+ * Default options for {@link PlayProgressBar}.
+ *
+ * @type {Object}
+ * @private
+ */
+PlayProgressBar.prototype.options_ = {
+ children: []
+};
+
+// Time tooltips should not be added to a player on mobile devices
+if (!IS_IOS && !IS_ANDROID) {
+ PlayProgressBar.prototype.options_.children.push('timeTooltip');
+}
+
+Component.registerComponent('PlayProgressBar', PlayProgressBar);
+export default PlayProgressBar;
diff --git a/javascript/videojs/src/js/control-bar/progress-control/progress-control.js b/javascript/videojs/src/js/control-bar/progress-control/progress-control.js
new file mode 100644
index 0000000..edb39d6
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/progress-control/progress-control.js
@@ -0,0 +1,249 @@
+/**
+ * @file progress-control.js
+ */
+import Component from '../../component.js';
+import * as Dom from '../../utils/dom.js';
+import {clamp} from '../../utils/num.js';
+import {bind_, throttle, UPDATE_REFRESH_INTERVAL} from '../../utils/fn.js';
+import {silencePromise} from '../../utils/promise';
+
+/** @import Player from '../../player' */
+
+import './seek-bar.js';
+
+/**
+ * The Progress Control component contains the seek bar, load progress,
+ * and play progress.
+ *
+ * @extends Component
+ */
+class ProgressControl extends Component {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+ this.handleMouseMove = throttle(bind_(this, this.handleMouseMove), UPDATE_REFRESH_INTERVAL);
+ this.throttledHandleMouseSeek = throttle(bind_(this, this.handleMouseSeek), UPDATE_REFRESH_INTERVAL);
+ this.handleMouseUpHandler_ = (e) => this.handleMouseUp(e);
+ this.handleMouseDownHandler_ = (e) => this.handleMouseDown(e);
+
+ this.enable();
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ return super.createEl('div', {
+ className: 'vjs-progress-control vjs-control'
+ });
+ }
+
+ /**
+ * When the mouse moves over the `ProgressControl`, the pointer position
+ * gets passed down to the `MouseTimeDisplay` component.
+ *
+ * @param {Event} event
+ * The `mousemove` event that caused this function to run.
+ *
+ * @listen mousemove
+ */
+ handleMouseMove(event) {
+ const seekBar = this.getChild('seekBar');
+
+ if (!seekBar) {
+ return;
+ }
+
+ const playProgressBar = seekBar.getChild('playProgressBar');
+ const mouseTimeDisplay = seekBar.getChild('mouseTimeDisplay');
+
+ if (!playProgressBar && !mouseTimeDisplay) {
+ return;
+ }
+
+ const seekBarEl = seekBar.el();
+ const seekBarRect = Dom.findPosition(seekBarEl);
+ let seekBarPoint = Dom.getPointerPosition(seekBarEl, event).x;
+
+ // The default skin has a gap on either side of the `SeekBar`. This means
+ // that it's possible to trigger this behavior outside the boundaries of
+ // the `SeekBar`. This ensures we stay within it at all times.
+ seekBarPoint = clamp(seekBarPoint, 0, 1);
+
+ if (mouseTimeDisplay) {
+ mouseTimeDisplay.update(seekBarRect, seekBarPoint);
+ }
+
+ if (playProgressBar) {
+ playProgressBar.update(seekBarRect, seekBar.getProgress());
+ }
+
+ }
+
+ /**
+ * A throttled version of the {@link ProgressControl#handleMouseSeek} listener.
+ *
+ * @method ProgressControl#throttledHandleMouseSeek
+ * @param {Event} event
+ * The `mousemove` event that caused this function to run.
+ *
+ * @listen mousemove
+ * @listen touchmove
+ */
+
+ /**
+ * Handle `mousemove` or `touchmove` events on the `ProgressControl`.
+ *
+ * @param {Event} event
+ * `mousedown` or `touchstart` event that triggered this function
+ *
+ * @listens mousemove
+ * @listens touchmove
+ */
+ handleMouseSeek(event) {
+ const seekBar = this.getChild('seekBar');
+
+ if (seekBar) {
+ seekBar.handleMouseMove(event);
+ }
+ }
+
+ /**
+ * Are controls are currently enabled for this progress control.
+ *
+ * @return {boolean}
+ * true if controls are enabled, false otherwise
+ */
+ enabled() {
+ return this.enabled_;
+ }
+
+ /**
+ * Disable all controls on the progress control and its children
+ */
+ disable() {
+ this.children().forEach((child) => child.disable && child.disable());
+
+ if (!this.enabled()) {
+ return;
+ }
+
+ this.off(['mousedown', 'touchstart'], this.handleMouseDownHandler_);
+ this.off(this.el_, ['mousemove', 'touchmove'], this.handleMouseMove);
+
+ this.removeListenersAddedOnMousedownAndTouchstart();
+
+ this.addClass('disabled');
+
+ this.enabled_ = false;
+
+ // Restore normal playback state if controls are disabled while scrubbing
+ if (this.player_.scrubbing()) {
+ const seekBar = this.getChild('seekBar');
+
+ this.player_.scrubbing(false);
+
+ if (seekBar.videoWasPlaying) {
+ silencePromise(this.player_.play());
+ }
+ }
+ }
+
+ /**
+ * Enable all controls on the progress control and its children
+ */
+ enable() {
+ this.children().forEach((child) => child.enable && child.enable());
+
+ if (this.enabled()) {
+ return;
+ }
+
+ this.on(['mousedown', 'touchstart'], this.handleMouseDownHandler_);
+ this.on(this.el_, ['mousemove', 'touchmove'], this.handleMouseMove);
+ this.removeClass('disabled');
+
+ this.enabled_ = true;
+ }
+
+ /**
+ * Cleanup listeners after the user finishes interacting with the progress controls
+ */
+ removeListenersAddedOnMousedownAndTouchstart() {
+ const doc = this.el_.ownerDocument;
+
+ this.off(doc, 'mousemove', this.throttledHandleMouseSeek);
+ this.off(doc, 'touchmove', this.throttledHandleMouseSeek);
+ this.off(doc, 'mouseup', this.handleMouseUpHandler_);
+ this.off(doc, 'touchend', this.handleMouseUpHandler_);
+ }
+
+ /**
+ * Handle `mousedown` or `touchstart` events on the `ProgressControl`.
+ *
+ * @param {Event} event
+ * `mousedown` or `touchstart` event that triggered this function
+ *
+ * @listens mousedown
+ * @listens touchstart
+ */
+ handleMouseDown(event) {
+ const doc = this.el_.ownerDocument;
+ const seekBar = this.getChild('seekBar');
+
+ if (seekBar) {
+ seekBar.handleMouseDown(event);
+ }
+
+ this.on(doc, 'mousemove', this.throttledHandleMouseSeek);
+ this.on(doc, 'touchmove', this.throttledHandleMouseSeek);
+ this.on(doc, 'mouseup', this.handleMouseUpHandler_);
+ this.on(doc, 'touchend', this.handleMouseUpHandler_);
+ }
+
+ /**
+ * Handle `mouseup` or `touchend` events on the `ProgressControl`.
+ *
+ * @param {Event} event
+ * `mouseup` or `touchend` event that triggered this function.
+ *
+ * @listens touchend
+ * @listens mouseup
+ */
+ handleMouseUp(event) {
+ const seekBar = this.getChild('seekBar');
+
+ if (seekBar) {
+ seekBar.handleMouseUp(event);
+ }
+
+ this.removeListenersAddedOnMousedownAndTouchstart();
+ }
+}
+
+/**
+ * Default options for `ProgressControl`
+ *
+ * @type {Object}
+ * @private
+ */
+ProgressControl.prototype.options_ = {
+ children: [
+ 'seekBar'
+ ]
+};
+
+Component.registerComponent('ProgressControl', ProgressControl);
+export default ProgressControl;
diff --git a/javascript/videojs/src/js/control-bar/progress-control/seek-bar.js b/javascript/videojs/src/js/control-bar/progress-control/seek-bar.js
new file mode 100644
index 0000000..bc7d83c
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/progress-control/seek-bar.js
@@ -0,0 +1,610 @@
+/**
+ * @file seek-bar.js
+ */
+import Slider from '../../slider/slider.js';
+import Component from '../../component.js';
+import {IS_IOS, IS_ANDROID} from '../../utils/browser.js';
+import * as Dom from '../../utils/dom.js';
+import * as Fn from '../../utils/fn.js';
+import {formatTime} from '../../utils/time.js';
+import {silencePromise} from '../../utils/promise';
+import {merge} from '../../utils/obj';
+import document from 'global/document';
+
+/** @import Player from '../../player' */
+
+import './load-progress-bar.js';
+import './play-progress-bar.js';
+import './mouse-time-display.js';
+
+/**
+ * Seek bar and container for the progress bars. Uses {@link PlayProgressBar}
+ * as its `bar`.
+ *
+ * @extends Slider
+ */
+class SeekBar extends Slider {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ * @param {number} [options.stepSeconds=5]
+ * The number of seconds to increment on keyboard control
+ * @param {number} [options.pageMultiplier=12]
+ * The multiplier of stepSeconds that PgUp/PgDown move the timeline.
+ */
+ constructor(player, options) {
+ options = merge(SeekBar.prototype.options_, options);
+
+ // Avoid mutating the prototype's `children` array by creating a copy
+ options.children = [...options.children];
+
+ const shouldDisableSeekWhileScrubbing =
+ (player.options_.disableSeekWhileScrubbingOnMobile && (IS_IOS || IS_ANDROID)) ||
+ (player.options_.disableSeekWhileScrubbingOnSTV);
+
+ // Add the TimeTooltip as a child if we are on desktop, or on mobile with `disableSeekWhileScrubbingOnMobile: true`
+ if ((!IS_IOS && !IS_ANDROID) || shouldDisableSeekWhileScrubbing) {
+ options.children.splice(1, 0, 'mouseTimeDisplay');
+ }
+
+ super(player, options);
+
+ this.shouldDisableSeekWhileScrubbing_ = shouldDisableSeekWhileScrubbing;
+ this.pendingSeekTime_ = null;
+
+ this.setEventHandlers_();
+ }
+
+ /**
+ * Sets the event handlers
+ *
+ * @private
+ */
+ setEventHandlers_() {
+ this.update_ = Fn.bind_(this, this.update);
+ this.update = Fn.throttle(this.update_, Fn.UPDATE_REFRESH_INTERVAL);
+
+ this.on(this.player_, ['durationchange', 'timeupdate'], this.update);
+ this.on(this.player_, ['ended'], this.update_);
+ if (this.player_.liveTracker) {
+ this.on(this.player_.liveTracker, 'liveedgechange', this.update);
+ }
+
+ // when playing, let's ensure we smoothly update the play progress bar
+ // via an interval
+ this.updateInterval = null;
+
+ this.enableIntervalHandler_ = (e) => this.enableInterval_(e);
+ this.disableIntervalHandler_ = (e) => this.disableInterval_(e);
+
+ this.on(this.player_, ['playing'], this.enableIntervalHandler_);
+
+ this.on(this.player_, ['ended', 'pause', 'waiting'], this.disableIntervalHandler_);
+
+ // we don't need to update the play progress if the document is hidden,
+ // also, this causes the CPU to spike and eventually crash the page on IE11.
+ if ('hidden' in document && 'visibilityState' in document) {
+ this.on(document, 'visibilitychange', this.toggleVisibility_);
+ }
+ }
+
+ toggleVisibility_(e) {
+ if (document.visibilityState === 'hidden') {
+ this.cancelNamedAnimationFrame('SeekBar#update');
+ this.cancelNamedAnimationFrame('Slider#update');
+ this.disableInterval_(e);
+ } else {
+ if (!this.player_.ended() && !this.player_.paused()) {
+ this.enableInterval_();
+ }
+
+ // we just switched back to the page and someone may be looking, so, update ASAP
+ this.update();
+ }
+ }
+
+ enableInterval_() {
+ if (this.updateInterval) {
+ return;
+
+ }
+ this.updateInterval = this.setInterval(this.update, Fn.UPDATE_REFRESH_INTERVAL);
+ }
+
+ disableInterval_(e) {
+ if (this.player_.liveTracker && this.player_.liveTracker.isLive() && e && e.type !== 'ended') {
+ return;
+ }
+
+ if (!this.updateInterval) {
+ return;
+ }
+
+ this.clearInterval(this.updateInterval);
+ this.updateInterval = null;
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ return super.createEl('div', {
+ className: 'vjs-progress-holder'
+ }, {
+ 'aria-label': this.localize('Progress Bar')
+ });
+ }
+
+ /**
+ * This function updates the play progress bar and accessibility
+ * attributes to whatever is passed in.
+ *
+ * @param {Event} [event]
+ * The `timeupdate` or `ended` event that caused this to run.
+ *
+ * @listens Player#timeupdate
+ *
+ * @return {number}
+ * The current percent at a number from 0-1
+ */
+ update(event) {
+ // ignore updates while the tab is hidden
+ if (document.visibilityState === 'hidden') {
+ return;
+ }
+
+ const percent = super.update();
+
+ this.requestNamedAnimationFrame('SeekBar#update', () => {
+ const currentTime = this.player_.ended() ?
+ this.player_.duration() : this.getCurrentTime_();
+ const liveTracker = this.player_.liveTracker;
+ let duration = this.player_.duration();
+
+ if (liveTracker && liveTracker.isLive()) {
+ duration = this.player_.liveTracker.liveCurrentTime();
+ }
+
+ if (this.percent_ !== percent) {
+ // machine readable value of progress bar (percentage complete)
+ this.el_.setAttribute('aria-valuenow', (percent * 100).toFixed(2));
+ this.percent_ = percent;
+ }
+
+ if (this.currentTime_ !== currentTime || this.duration_ !== duration) {
+ // human readable value of progress bar (time complete)
+ this.el_.setAttribute(
+ 'aria-valuetext',
+ this.localize(
+ 'progress bar timing: currentTime={1} duration={2}',
+ [formatTime(currentTime, duration),
+ formatTime(duration, duration)],
+ '{1} of {2}'
+ )
+ );
+
+ this.currentTime_ = currentTime;
+ this.duration_ = duration;
+ }
+
+ // update the progress bar time tooltip with the current time
+ if (this.bar) {
+ this.bar.update(Dom.getBoundingClientRect(this.el()), this.getProgress(), event);
+ }
+ });
+
+ return percent;
+ }
+
+ /**
+ * Prevent liveThreshold from causing seeks to seem like they
+ * are not happening from a user perspective.
+ *
+ * @param {number} ct
+ * current time to seek to
+ */
+ userSeek_(ct) {
+ if (this.player_.liveTracker && this.player_.liveTracker.isLive()) {
+ this.player_.liveTracker.nextSeekedFromUser();
+ }
+
+ this.player_.currentTime(ct);
+ }
+
+ /**
+ * Get the value of current time but allows for smooth scrubbing,
+ * when player can't keep up.
+ *
+ * @return {number}
+ * The current time value to display
+ *
+ * @private
+ */
+ getCurrentTime_() {
+ return (this.player_.scrubbing()) ?
+ this.player_.getCache().currentTime :
+ this.player_.currentTime();
+ }
+
+ /**
+ * Getter and setter for pendingSeekTime.
+ * Ensures the value is clamped between 0 and duration.
+ *
+ * @param {number|null} [time] - Optional. The new pending seek time, can be a number or null.
+ * @return {number|null} - The current pending seek time.
+ */
+ pendingSeekTime(time) {
+ if (time !== undefined) {
+ if (time !== null) {
+ const duration = this.player_.duration();
+
+ this.pendingSeekTime_ = Math.max(0, Math.min(time, duration));
+ } else {
+ this.pendingSeekTime_ = null;
+ }
+ }
+ return this.pendingSeekTime_;
+ }
+
+ /**
+ * Get the percentage of media played so far.
+ *
+ * @return {number}
+ * The percentage of media played so far (0 to 1).
+ */
+ getPercent() {
+ // If we have a pending seek time, we are scrubbing on mobile and should set the slider percent
+ // to reflect the current scrub location.
+ if (this.pendingSeekTime() !== null) {
+ return this.pendingSeekTime() / this.player_.duration();
+ }
+
+ const currentTime = this.getCurrentTime_();
+ let percent;
+ const liveTracker = this.player_.liveTracker;
+
+ if (liveTracker && liveTracker.isLive()) {
+ percent = (currentTime - liveTracker.seekableStart()) / liveTracker.liveWindow();
+
+ // prevent the percent from changing at the live edge
+ if (liveTracker.atLiveEdge()) {
+ percent = 1;
+ }
+ } else {
+ percent = currentTime / this.player_.duration();
+ }
+
+ return percent;
+ }
+
+ /**
+ * Handle mouse down on seek bar
+ *
+ * @param {MouseEvent} event
+ * The `mousedown` event that caused this to run.
+ *
+ * @listens mousedown
+ */
+ handleMouseDown(event) {
+ if (!Dom.isSingleLeftClick(event)) {
+ return;
+ }
+
+ // Stop event propagation to prevent double fire in progress-control.js
+ event.stopPropagation();
+
+ this.videoWasPlaying = !this.player_.paused();
+
+ // Don't pause if we are on mobile and `disableSeekWhileScrubbingOnMobile: true`.
+ // In that case, playback should continue while the player scrubs to a new location.
+ if (!this.shouldDisableSeekWhileScrubbing_) {
+ this.player_.pause();
+ }
+
+ super.handleMouseDown(event);
+ }
+
+ /**
+ * Handle mouse move on seek bar
+ *
+ * @param {MouseEvent} event
+ * The `mousemove` event that caused this to run.
+ * @param {boolean} mouseDown this is a flag that should be set to true if `handleMouseMove` is called directly. It allows us to skip things that should not happen if coming from mouse down but should happen on regular mouse move handler. Defaults to false
+ *
+ * @listens mousemove
+ */
+ handleMouseMove(event, mouseDown = false) {
+ if (!Dom.isSingleLeftClick(event) || isNaN(this.player_.duration())) {
+ return;
+ }
+
+ if (!mouseDown && !this.player_.scrubbing()) {
+ this.player_.scrubbing(true);
+ }
+
+ let newTime;
+ const distance = this.calculateDistance(event);
+ const liveTracker = this.player_.liveTracker;
+
+ if (!liveTracker || !liveTracker.isLive()) {
+ newTime = distance * this.player_.duration();
+
+ // Don't let video end while scrubbing.
+ if (newTime === this.player_.duration()) {
+ newTime = newTime - 0.1;
+ }
+ } else {
+
+ if (distance >= 0.99) {
+ liveTracker.seekToLiveEdge();
+ return;
+ }
+ const seekableStart = liveTracker.seekableStart();
+ const seekableEnd = liveTracker.liveCurrentTime();
+
+ newTime = seekableStart + (distance * liveTracker.liveWindow());
+
+ // Don't let video end while scrubbing.
+ if (newTime >= seekableEnd) {
+ newTime = seekableEnd;
+ }
+
+ // Compensate for precision differences so that currentTime is not less
+ // than seekable start
+ if (newTime <= seekableStart) {
+ newTime = seekableStart + 0.1;
+ }
+
+ // On android seekableEnd can be Infinity sometimes,
+ // this will cause newTime to be Infinity, which is
+ // not a valid currentTime.
+ if (newTime === Infinity) {
+ return;
+ }
+ }
+
+ // if on mobile and `disableSeekWhileScrubbingOnMobile: true`, keep track of the desired seek point but we won't initiate the seek until 'touchend'
+ if (this.shouldDisableSeekWhileScrubbing_) {
+ this.pendingSeekTime(newTime);
+ } else {
+ this.userSeek_(newTime);
+ }
+
+ if (this.player_.options_.enableSmoothSeeking) {
+ this.update();
+ }
+ }
+
+ enable() {
+ super.enable();
+ const mouseTimeDisplay = this.getChild('mouseTimeDisplay');
+
+ if (!mouseTimeDisplay) {
+ return;
+ }
+
+ mouseTimeDisplay.show();
+ }
+
+ disable() {
+ super.disable();
+ const mouseTimeDisplay = this.getChild('mouseTimeDisplay');
+
+ if (!mouseTimeDisplay) {
+ return;
+ }
+
+ mouseTimeDisplay.hide();
+ }
+
+ /**
+ * Handle mouse up on seek bar
+ *
+ * @param {MouseEvent} event
+ * The `mouseup` event that caused this to run.
+ *
+ * @listens mouseup
+ */
+ handleMouseUp(event) {
+ super.handleMouseUp(event);
+
+ // Stop event propagation to prevent double fire in progress-control.js
+ if (event) {
+ event.stopPropagation();
+ }
+ this.player_.scrubbing(false);
+
+ // If we have a pending seek time, then we have finished scrubbing on mobile and should initiate a seek.
+ if (this.pendingSeekTime() !== null) {
+ this.userSeek_(this.pendingSeekTime());
+
+ this.pendingSeekTime(null);
+ }
+
+ /**
+ * Trigger timeupdate because we're done seeking and the time has changed.
+ * This is particularly useful for if the player is paused to time the time displays.
+ *
+ * @event Tech#timeupdate
+ * @type {Event}
+ */
+ this.player_.trigger({ type: 'timeupdate', target: this, manuallyTriggered: true });
+ if (this.videoWasPlaying) {
+ silencePromise(this.player_.play());
+ } else {
+ // We're done seeking and the time has changed.
+ // If the player is paused, make sure we display the correct time on the seek bar.
+ this.update_();
+ }
+ }
+
+ /**
+ * Handles pending seek time when `disableSeekWhileScrubbingOnSTV` is enabled.
+ *
+ * @param {number} stepAmount - The number of seconds to step (positive for forward, negative for backward).
+ */
+ handlePendingSeek_(stepAmount) {
+ if (!this.player_.paused()) {
+ this.player_.pause();
+ }
+
+ const currentPos = this.pendingSeekTime() !== null ?
+ this.pendingSeekTime() :
+ this.player_.currentTime();
+
+ this.pendingSeekTime(currentPos + stepAmount);
+ this.player_.trigger({ type: 'timeupdate', target: this, manuallyTriggered: true });
+ }
+
+ /**
+ * Move more quickly fast forward for keyboard-only users
+ */
+ stepForward() {
+ // if `disableSeekWhileScrubbingOnSTV: true`, keep track of the desired seek point but we won't initiate the seek
+ if (this.shouldDisableSeekWhileScrubbing_) {
+ this.handlePendingSeek_(this.options().stepSeconds);
+ } else {
+ this.userSeek_(this.player_.currentTime() + this.options().stepSeconds);
+ }
+ }
+
+ /**
+ * Move more quickly rewind for keyboard-only users
+ */
+ stepBack() {
+ // if `disableSeekWhileScrubbingOnSTV: true`, keep track of the desired seek point but we won't initiate the seek
+ if (this.shouldDisableSeekWhileScrubbing_) {
+ this.handlePendingSeek_(-this.options().stepSeconds);
+ } else {
+ this.userSeek_(this.player_.currentTime() - this.options().stepSeconds);
+ }
+ }
+
+ /**
+ * Toggles the playback state of the player
+ * This gets called when enter or space is used on the seekbar
+ *
+ * @param {KeyboardEvent} event
+ * The `keydown` event that caused this function to be called
+ *
+ */
+ handleAction(event) {
+ if (this.pendingSeekTime() !== null) {
+ this.userSeek_(this.pendingSeekTime());
+ this.pendingSeekTime(null);
+ }
+ if (this.player_.paused()) {
+ this.player_.play();
+ } else {
+ this.player_.pause();
+ }
+ }
+
+ /**
+ * Called when this SeekBar has focus and a key gets pressed down.
+ * Supports the following keys:
+ *
+ * Space or Enter key fire a click event
+ * Home key moves to start of the timeline
+ * End key moves to end of the timeline
+ * Digit "0" through "9" keys move to 0%, 10% ... 80%, 90% of the timeline
+ * PageDown key moves back a larger step than ArrowDown
+ * PageUp key moves forward a large step
+ *
+ * @param {KeyboardEvent} event
+ * The `keydown` event that caused this function to be called.
+ *
+ * @listens keydown
+ */
+ handleKeyDown(event) {
+ const liveTracker = this.player_.liveTracker;
+
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ event.stopPropagation();
+ this.handleAction(event);
+ } else if (event.key === 'Home') {
+ event.preventDefault();
+ event.stopPropagation();
+ this.userSeek_(0);
+ } else if (event.key === 'End') {
+ event.preventDefault();
+ event.stopPropagation();
+ if (liveTracker && liveTracker.isLive()) {
+ this.userSeek_(liveTracker.liveCurrentTime());
+ } else {
+ this.userSeek_(this.player_.duration());
+ }
+ } else if (/^[0-9]$/.test(event.key)) {
+ event.preventDefault();
+ event.stopPropagation();
+ const gotoFraction = parseInt(event.key, 10) * 0.1;
+
+ if (liveTracker && liveTracker.isLive()) {
+ this.userSeek_(liveTracker.seekableStart() + (liveTracker.liveWindow() * gotoFraction));
+ } else {
+ this.userSeek_(this.player_.duration() * gotoFraction);
+ }
+ } else if (event.key === 'PageDown') {
+ event.preventDefault();
+ event.stopPropagation();
+ this.userSeek_(this.player_.currentTime() - (this.options().stepSeconds * this.options().pageMultiplier));
+ } else if (event.key === 'PageUp') {
+ event.preventDefault();
+ event.stopPropagation();
+ this.userSeek_(this.player_.currentTime() + (this.options().stepSeconds * this.options().pageMultiplier));
+ } else {
+ // Pass keydown handling up for unsupported keys
+ super.handleKeyDown(event);
+ }
+ }
+
+ dispose() {
+ this.disableInterval_();
+
+ this.off(this.player_, ['durationchange', 'timeupdate'], this.update);
+ this.off(this.player_, ['ended'], this.update_);
+ if (this.player_.liveTracker) {
+ this.off(this.player_.liveTracker, 'liveedgechange', this.update);
+ }
+
+ this.off(this.player_, ['playing'], this.enableIntervalHandler_);
+ this.off(this.player_, ['ended', 'pause', 'waiting'], this.disableIntervalHandler_);
+
+ // we don't need to update the play progress if the document is hidden,
+ // also, this causes the CPU to spike and eventually crash the page on IE11.
+ if ('hidden' in document && 'visibilityState' in document) {
+ this.off(document, 'visibilitychange', this.toggleVisibility_);
+ }
+
+ super.dispose();
+ }
+}
+
+/**
+ * Default options for the `SeekBar`
+ *
+ * @type {Object}
+ * @private
+ */
+SeekBar.prototype.options_ = {
+ children: [
+ 'loadProgressBar',
+ 'playProgressBar'
+ ],
+ barName: 'playProgressBar',
+ stepSeconds: 5,
+ pageMultiplier: 12
+};
+
+Component.registerComponent('SeekBar', SeekBar);
+export default SeekBar;
diff --git a/javascript/videojs/src/js/control-bar/progress-control/time-tooltip.js b/javascript/videojs/src/js/control-bar/progress-control/time-tooltip.js
new file mode 100644
index 0000000..aecd8b3
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/progress-control/time-tooltip.js
@@ -0,0 +1,170 @@
+/**
+ * @file time-tooltip.js
+ */
+import Component from '../../component';
+import * as Dom from '../../utils/dom.js';
+import {formatTime} from '../../utils/time.js';
+import * as Fn from '../../utils/fn.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * Time tooltips display a time above the progress bar.
+ *
+ * @extends Component
+ */
+class TimeTooltip extends Component {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The {@link Player} that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+ this.update = Fn.throttle(Fn.bind_(this, this.update), Fn.UPDATE_REFRESH_INTERVAL);
+ }
+
+ /**
+ * Create the time tooltip DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ return super.createEl('div', {
+ className: 'vjs-time-tooltip'
+ }, {
+ 'aria-hidden': 'true'
+ });
+ }
+
+ /**
+ * Updates the position of the time tooltip relative to the `SeekBar`.
+ *
+ * @param {Object} seekBarRect
+ * The `ClientRect` for the {@link SeekBar} element.
+ *
+ * @param {number} seekBarPoint
+ * A number from 0 to 1, representing a horizontal reference point
+ * from the left edge of the {@link SeekBar}
+ */
+ update(seekBarRect, seekBarPoint, content) {
+ const tooltipRect = Dom.findPosition(this.el_);
+ const playerRect = Dom.getBoundingClientRect(this.player_.el());
+ const seekBarPointPx = seekBarRect.width * seekBarPoint;
+
+ // do nothing if either rect isn't available
+ // for example, if the player isn't in the DOM for testing
+ if (!playerRect || !tooltipRect) {
+ return;
+ }
+
+ // This is the space left of the `seekBarPoint` available within the bounds
+ // of the player. We calculate any gap between the left edge of the player
+ // and the left edge of the `SeekBar` and add the number of pixels in the
+ // `SeekBar` before hitting the `seekBarPoint`
+ let spaceLeftOfPoint = (seekBarRect.left - playerRect.left) + seekBarPointPx;
+
+ // This is the space right of the `seekBarPoint` available within the bounds
+ // of the player. We calculate the number of pixels from the `seekBarPoint`
+ // to the right edge of the `SeekBar` and add to that any gap between the
+ // right edge of the `SeekBar` and the player.
+ let spaceRightOfPoint = (seekBarRect.width - seekBarPointPx) +
+ (playerRect.right - seekBarRect.right);
+
+ // spaceRightOfPoint is always NaN for mouse time display
+ // because the seekbarRect does not have a right property. This causes
+ // the mouse tool tip to be truncated when it's close to the right edge of the player.
+ // In such cases, we ignore the `playerRect.right - seekBarRect.right` value when calculating.
+ // For the sake of consistency, we ignore seekBarRect.left - playerRect.left for the left edge.
+ if (!spaceRightOfPoint) {
+ spaceRightOfPoint = seekBarRect.width - seekBarPointPx;
+ spaceLeftOfPoint = seekBarPointPx;
+ }
+ // This is the number of pixels by which the tooltip will need to be pulled
+ // further to the right to center it over the `seekBarPoint`.
+ let pullTooltipBy = tooltipRect.width / 2;
+
+ // Adjust the `pullTooltipBy` distance to the left or right depending on
+ // the results of the space calculations above.
+ if (spaceLeftOfPoint < pullTooltipBy) {
+ pullTooltipBy += pullTooltipBy - spaceLeftOfPoint;
+ } else if (spaceRightOfPoint < pullTooltipBy) {
+ pullTooltipBy = spaceRightOfPoint;
+ }
+
+ // Due to the imprecision of decimal/ratio based calculations and varying
+ // rounding behaviors, there are cases where the spacing adjustment is off
+ // by a pixel or two. This adds insurance to these calculations.
+ if (pullTooltipBy < 0) {
+ pullTooltipBy = 0;
+ } else if (pullTooltipBy > tooltipRect.width) {
+ pullTooltipBy = tooltipRect.width;
+ }
+
+ // prevent small width fluctuations within 0.4px from
+ // changing the value below.
+ // This really helps for live to prevent the play
+ // progress time tooltip from jittering
+ pullTooltipBy = Math.round(pullTooltipBy);
+
+ this.el_.style.right = `-${pullTooltipBy}px`;
+ this.write(content);
+ }
+
+ /**
+ * Write the time to the tooltip DOM element.
+ *
+ * @param {string} content
+ * The formatted time for the tooltip.
+ */
+ write(content) {
+ Dom.textContent(this.el_, content);
+ }
+
+ /**
+ * Updates the position of the time tooltip relative to the `SeekBar`.
+ *
+ * @param {Object} seekBarRect
+ * The `ClientRect` for the {@link SeekBar} element.
+ *
+ * @param {number} seekBarPoint
+ * A number from 0 to 1, representing a horizontal reference point
+ * from the left edge of the {@link SeekBar}
+ *
+ * @param {number} time
+ * The time to update the tooltip to, not used during live playback
+ *
+ * @param {Function} cb
+ * A function that will be called during the request animation frame
+ * for tooltips that need to do additional animations from the default
+ */
+ updateTime(seekBarRect, seekBarPoint, time, cb) {
+ this.requestNamedAnimationFrame('TimeTooltip#updateTime', () => {
+ let content;
+ const duration = this.player_.duration();
+
+ if (this.player_.liveTracker && this.player_.liveTracker.isLive()) {
+ const liveWindow = this.player_.liveTracker.liveWindow();
+ const secondsBehind = liveWindow - (seekBarPoint * liveWindow);
+
+ content = (secondsBehind < 1 ? '' : '-') + formatTime(secondsBehind, liveWindow);
+ } else {
+ content = formatTime(time, duration);
+ }
+
+ this.update(seekBarRect, seekBarPoint, content);
+ if (cb) {
+ cb();
+ }
+ });
+ }
+}
+
+Component.registerComponent('TimeTooltip', TimeTooltip);
+export default TimeTooltip;
diff --git a/javascript/videojs/src/js/control-bar/seek-to-live.js b/javascript/videojs/src/js/control-bar/seek-to-live.js
new file mode 100644
index 0000000..d448267
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/seek-to-live.js
@@ -0,0 +1,108 @@
+/**
+ * @file seek-to-live.js
+ */
+import Button from '../button';
+import Component from '../component';
+import * as Dom from '../utils/dom.js';
+
+/** @import Player from './player' */
+
+/**
+ * Displays the live indicator when duration is Infinity.
+ *
+ * @extends Component
+ */
+class SeekToLive extends Button {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+
+ this.updateLiveEdgeStatus();
+
+ if (this.player_.liveTracker) {
+ this.updateLiveEdgeStatusHandler_ = (e) => this.updateLiveEdgeStatus(e);
+ this.on(this.player_.liveTracker, 'liveedgechange', this.updateLiveEdgeStatusHandler_);
+ }
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ const el = super.createEl('button', {
+ className: 'vjs-seek-to-live-control vjs-control'
+ });
+
+ this.setIcon('circle', el);
+
+ this.textEl_ = Dom.createEl('span', {
+ className: 'vjs-seek-to-live-text',
+ textContent: this.localize('LIVE')
+ }, {
+ 'aria-hidden': 'true'
+ });
+
+ el.appendChild(this.textEl_);
+ return el;
+ }
+
+ /**
+ * Update the state of this button if we are at the live edge
+ * or not
+ */
+ updateLiveEdgeStatus() {
+ // default to live edge
+ if (!this.player_.liveTracker || this.player_.liveTracker.atLiveEdge()) {
+ this.setAttribute('aria-disabled', true);
+ this.addClass('vjs-at-live-edge');
+ this.controlText('Seek to live, currently playing live');
+ } else {
+ this.setAttribute('aria-disabled', false);
+ this.removeClass('vjs-at-live-edge');
+ this.controlText('Seek to live, currently behind live');
+ }
+ }
+
+ /**
+ * On click bring us as near to the live point as possible.
+ * This requires that we wait for the next `live-seekable-change`
+ * event which will happen every segment length seconds.
+ */
+ handleClick() {
+ this.player_.liveTracker.seekToLiveEdge();
+ }
+
+ /**
+ * Dispose of the element and stop tracking
+ */
+ dispose() {
+ if (this.player_.liveTracker) {
+ this.off(this.player_.liveTracker, 'liveedgechange', this.updateLiveEdgeStatusHandler_);
+ }
+ this.textEl_ = null;
+
+ super.dispose();
+ }
+}
+/**
+ * The text that should display over the `SeekToLive`s control. Added for localization.
+ *
+ * @type {string}
+ * @protected
+ */
+SeekToLive.prototype.controlText_ = 'Seek to live, currently playing live';
+
+Component.registerComponent('SeekToLive', SeekToLive);
+export default SeekToLive;
diff --git a/javascript/videojs/src/js/control-bar/skip-buttons/skip-backward.js b/javascript/videojs/src/js/control-bar/skip-buttons/skip-backward.js
new file mode 100644
index 0000000..90b8fa5
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/skip-buttons/skip-backward.js
@@ -0,0 +1,77 @@
+import Button from '../../button';
+import Component from '../../component';
+
+/**
+ * Button to skip backward a configurable amount of time
+ * through a video. Renders in the control bar.
+ *
+ * * e.g. options: {controlBar: {skipButtons: backward: 5}}
+ *
+ * @extends Button
+ */
+class SkipBackward extends Button {
+ constructor(player, options) {
+ super(player, options);
+
+ this.validOptions = [5, 10, 30];
+ this.skipTime = this.getSkipBackwardTime();
+
+ if (this.skipTime && this.validOptions.includes(this.skipTime)) {
+ this.setIcon(`replay-${this.skipTime}`);
+ this.controlText(this.localize('Skip backward {1} seconds', [this.skipTime.toLocaleString(player.language())]));
+ this.show();
+ } else {
+ this.hide();
+ }
+ }
+
+ getSkipBackwardTime() {
+ const playerOptions = this.options_.playerOptions;
+
+ return playerOptions.controlBar && playerOptions.controlBar.skipButtons && playerOptions.controlBar.skipButtons.backward;
+ }
+
+ buildCSSClass() {
+ return `vjs-skip-backward-${this.getSkipBackwardTime()} ${super.buildCSSClass()}`;
+ }
+
+ /**
+ * On click, skips backward in the video by a configurable amount of seconds.
+ * If the current time in the video is less than the configured 'skip backward' time,
+ * skips to beginning of video or seekable range.
+ *
+ * Handle a click on a `SkipBackward` button
+ *
+ * @param {EventTarget~Event} event
+ * The `click` event that caused this function
+ * to be called
+ */
+ handleClick(event) {
+ const currentVideoTime = this.player_.currentTime();
+ const liveTracker = this.player_.liveTracker;
+ const seekableStart = liveTracker && liveTracker.isLive() && liveTracker.seekableStart();
+ let newTime;
+
+ if (seekableStart && (currentVideoTime - this.skipTime <= seekableStart)) {
+ newTime = seekableStart;
+ } else if (currentVideoTime >= this.skipTime) {
+ newTime = currentVideoTime - this.skipTime;
+ } else {
+ newTime = 0;
+ }
+ this.player_.currentTime(newTime);
+ }
+
+ /**
+ * Update control text on languagechange
+ */
+ handleLanguagechange() {
+ this.controlText(this.localize('Skip backward {1} seconds', [this.skipTime]));
+ }
+}
+
+SkipBackward.prototype.controlText_ = 'Skip Backward';
+
+Component.registerComponent('SkipBackward', SkipBackward);
+
+export default SkipBackward;
diff --git a/javascript/videojs/src/js/control-bar/skip-buttons/skip-forward.js b/javascript/videojs/src/js/control-bar/skip-buttons/skip-forward.js
new file mode 100644
index 0000000..602bdc6
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/skip-buttons/skip-forward.js
@@ -0,0 +1,80 @@
+import Button from '../../button';
+import Component from '../../component';
+
+/**
+ * Button to skip forward a configurable amount of time
+ * through a video. Renders in the control bar.
+ *
+ * e.g. options: {controlBar: {skipButtons: forward: 5}}
+ *
+ * @extends Button
+ */
+class SkipForward extends Button {
+ constructor(player, options) {
+ super(player, options);
+
+ this.validOptions = [5, 10, 30];
+ this.skipTime = this.getSkipForwardTime();
+
+ if (this.skipTime && this.validOptions.includes(this.skipTime)) {
+ this.setIcon(`forward-${this.skipTime}`);
+ this.controlText(this.localize('Skip forward {1} seconds', [this.skipTime.toLocaleString(player.language())]));
+ this.show();
+ } else {
+ this.hide();
+ }
+ }
+
+ getSkipForwardTime() {
+ const playerOptions = this.options_.playerOptions;
+
+ return playerOptions.controlBar && playerOptions.controlBar.skipButtons && playerOptions.controlBar.skipButtons.forward;
+ }
+
+ buildCSSClass() {
+ return `vjs-skip-forward-${this.getSkipForwardTime()} ${super.buildCSSClass()}`;
+ }
+
+ /**
+ * On click, skips forward in the duration/seekable range by a configurable amount of seconds.
+ * If the time left in the duration/seekable range is less than the configured 'skip forward' time,
+ * skips to end of duration/seekable range.
+ *
+ * Handle a click on a `SkipForward` button
+ *
+ * @param {EventTarget~Event} event
+ * The `click` event that caused this function
+ * to be called
+ */
+ handleClick(event) {
+ if (isNaN(this.player_.duration())) {
+ return;
+ }
+
+ const currentVideoTime = this.player_.currentTime();
+ const liveTracker = this.player_.liveTracker;
+ const duration = (liveTracker && liveTracker.isLive()) ? liveTracker.seekableEnd() : this.player_.duration();
+ let newTime;
+
+ if (currentVideoTime + this.skipTime <= duration) {
+ newTime = currentVideoTime + this.skipTime;
+ } else {
+ newTime = duration;
+ }
+
+ this.player_.currentTime(newTime);
+ }
+
+ /**
+ * Update control text on languagechange
+ */
+ handleLanguagechange() {
+ this.controlText(this.localize('Skip forward {1} seconds', [this.skipTime]));
+ }
+}
+
+SkipForward.prototype.controlText_ = 'Skip Forward';
+
+Component.registerComponent('SkipForward', SkipForward);
+
+export default SkipForward;
diff --git a/javascript/videojs/src/js/control-bar/spacer-controls/custom-control-spacer.js b/javascript/videojs/src/js/control-bar/spacer-controls/custom-control-spacer.js
new file mode 100644
index 0000000..3913a8a
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/spacer-controls/custom-control-spacer.js
@@ -0,0 +1,41 @@
+/**
+ * @file custom-control-spacer.js
+ */
+import Spacer from './spacer.js';
+import Component from '../../component.js';
+
+/**
+ * Spacer specifically meant to be used as an insertion point for new plugins, etc.
+ *
+ * @extends Spacer
+ */
+class CustomControlSpacer extends Spacer {
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-custom-control-spacer ${super.buildCSSClass()}`;
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ return super.createEl('div', {
+ className: this.buildCSSClass(),
+ // No-flex/table-cell mode requires there be some content
+ // in the cell to fill the remaining space of the table.
+ textContent: '\u00a0'
+ });
+ }
+}
+
+Component.registerComponent('CustomControlSpacer', CustomControlSpacer);
+export default CustomControlSpacer;
diff --git a/javascript/videojs/src/js/control-bar/spacer-controls/spacer.js b/javascript/videojs/src/js/control-bar/spacer-controls/spacer.js
new file mode 100644
index 0000000..eff67f9
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/spacer-controls/spacer.js
@@ -0,0 +1,41 @@
+/**
+ * @file spacer.js
+ */
+import Component from '../../component.js';
+
+/**
+ * Just an empty spacer element that can be used as an append point for plugins, etc.
+ * Also can be used to create space between elements when necessary.
+ *
+ * @extends Component
+ */
+class Spacer extends Component {
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-spacer ${super.buildCSSClass()}`;
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl(tag = 'div', props = {}, attributes = {}) {
+ if (!props.className) {
+ props.className = this.buildCSSClass();
+ }
+
+ return super.createEl(tag, props, attributes);
+ }
+}
+
+Component.registerComponent('Spacer', Spacer);
+
+export default Spacer;
diff --git a/javascript/videojs/src/js/control-bar/text-track-controls/caption-settings-menu-item.js b/javascript/videojs/src/js/control-bar/text-track-controls/caption-settings-menu-item.js
new file mode 100644
index 0000000..5d08e6d
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/text-track-controls/caption-settings-menu-item.js
@@ -0,0 +1,71 @@
+/**
+ * @file caption-settings-menu-item.js
+ */
+import TextTrackMenuItem from './text-track-menu-item.js';
+import Component from '../../component.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * The menu item for caption track settings menu
+ *
+ * @extends TextTrackMenuItem
+ */
+class CaptionSettingsMenuItem extends TextTrackMenuItem {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ options.track = {
+ player,
+ kind: options.kind,
+ label: options.kind + ' settings',
+ selectable: false,
+ default: false,
+ mode: 'disabled'
+ };
+
+ // CaptionSettingsMenuItem has no concept of 'selected'
+ options.selectable = false;
+
+ options.name = 'CaptionSettingsMenuItem';
+
+ super(player, options);
+ this.addClass('vjs-texttrack-settings');
+ this.controlText(', opens ' + options.kind + ' settings dialog');
+ }
+
+ /**
+ * This gets called when an `CaptionSettingsMenuItem` is "clicked". See
+ * {@link ClickableComponent} for more detailed information on what a click can be.
+ *
+ * @param {Event} [event]
+ * The `keydown`, `tap`, or `click` event that caused this function to be
+ * called.
+ *
+ * @listens tap
+ * @listens click
+ */
+ handleClick(event) {
+ this.player().getChild('textTrackSettings').open();
+ }
+
+ /**
+ * Update control text and label on languagechange
+ */
+ handleLanguagechange() {
+ this.$('.vjs-menu-item-text').textContent = this.player_.localize(this.options_.kind + ' settings');
+
+ super.handleLanguagechange();
+ }
+}
+
+Component.registerComponent('CaptionSettingsMenuItem', CaptionSettingsMenuItem);
+export default CaptionSettingsMenuItem;
diff --git a/javascript/videojs/src/js/control-bar/text-track-controls/captions-button.js b/javascript/videojs/src/js/control-bar/text-track-controls/captions-button.js
new file mode 100644
index 0000000..8f5f6da
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/text-track-controls/captions-button.js
@@ -0,0 +1,87 @@
+/**
+ * @file captions-button.js
+ */
+import TextTrackButton from './text-track-button.js';
+import Component from '../../component.js';
+import CaptionSettingsMenuItem from './caption-settings-menu-item.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * The button component for toggling and selecting captions
+ *
+ * @extends TextTrackButton
+ */
+class CaptionsButton extends TextTrackButton {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ *
+ * @param {Function} [ready]
+ * The function to call when this component is ready.
+ */
+ constructor(player, options, ready) {
+ super(player, options, ready);
+
+ this.setIcon('captions');
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-captions-button ${super.buildCSSClass()}`;
+ }
+
+ buildWrapperCSSClass() {
+ return `vjs-captions-button ${super.buildWrapperCSSClass()}`;
+ }
+
+ /**
+ * Create caption menu items
+ *
+ * @return {CaptionSettingsMenuItem[]}
+ * The array of current menu items.
+ */
+ createItems() {
+ const items = [];
+
+ if (!(this.player().tech_ && this.player().tech_.featuresNativeTextTracks) &&
+ this.player().getChild('textTrackSettings')) {
+ items.push(new CaptionSettingsMenuItem(this.player_, {kind: this.kind_}));
+
+ this.hideThreshold_ += 1;
+ }
+
+ return super.createItems(items);
+ }
+
+}
+
+/**
+ * `kind` of TextTrack to look for to associate it with this menu.
+ *
+ * @type {string}
+ * @private
+ */
+CaptionsButton.prototype.kind_ = 'captions';
+
+/**
+ * The text that should display over the `CaptionsButton`s controls. Added for localization.
+ *
+ * @type {string}
+ * @protected
+ */
+CaptionsButton.prototype.controlText_ = 'Captions';
+
+Component.registerComponent('CaptionsButton', CaptionsButton);
+export default CaptionsButton;
diff --git a/javascript/videojs/src/js/control-bar/text-track-controls/chapters-button.js b/javascript/videojs/src/js/control-bar/text-track-controls/chapters-button.js
new file mode 100644
index 0000000..8366179
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/text-track-controls/chapters-button.js
@@ -0,0 +1,224 @@
+/**
+ * @file chapters-button.js
+ */
+import TextTrackButton from './text-track-button.js';
+import Component from '../../component.js';
+import ChaptersTrackMenuItem from './chapters-track-menu-item.js';
+import {toTitleCase} from '../../utils/str.js';
+
+/** @import Player from '../../player' */
+/** @import Menu from '../../menu/menu' */
+/** @import TextTrack from '../../tracks/text-track' */
+/** @import TextTrackMenuItem from '../text-track-controls/text-track-menu-item' */
+
+/**
+ * The button component for toggling and selecting chapters
+ * Chapters act much differently than other text tracks
+ * Cues are navigation vs. other tracks of alternative languages
+ *
+ * @extends TextTrackButton
+ */
+class ChaptersButton extends TextTrackButton {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ *
+ * @param {Function} [ready]
+ * The function to call when this function is ready.
+ */
+ constructor(player, options, ready) {
+ super(player, options, ready);
+
+ this.setIcon('chapters');
+
+ this.selectCurrentItem_ = () => {
+ this.items.forEach(item => {
+ item.selected(this.track_.activeCues[0] === item.cue);
+ });
+ };
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-chapters-button ${super.buildCSSClass()}`;
+ }
+
+ buildWrapperCSSClass() {
+ return `vjs-chapters-button ${super.buildWrapperCSSClass()}`;
+ }
+
+ /**
+ * Update the menu based on the current state of its items.
+ *
+ * @param {Event} [event]
+ * An event that triggered this function to run.
+ *
+ * @listens TextTrackList#addtrack
+ * @listens TextTrackList#removetrack
+ * @listens TextTrackList#change
+ */
+ update(event) {
+ if (event && event.track && event.track.kind !== 'chapters') {
+ return;
+ }
+
+ const track = this.findChaptersTrack();
+
+ if (track !== this.track_) {
+ this.setTrack(track);
+ super.update();
+ } else if (!this.items || (track && track.cues && track.cues.length !== this.items.length)) {
+ // Update the menu initially or if the number of cues has changed since set
+ super.update();
+ }
+ }
+
+ /**
+ * Set the currently selected track for the chapters button.
+ *
+ * @param {TextTrack} track
+ * The new track to select. Nothing will change if this is the currently selected
+ * track.
+ */
+ setTrack(track) {
+ if (this.track_ === track) {
+ return;
+ }
+
+ if (!this.updateHandler_) {
+ this.updateHandler_ = this.update.bind(this);
+ }
+
+ // here this.track_ refers to the old track instance
+ if (this.track_) {
+ const remoteTextTrackEl = this.player_.remoteTextTrackEls().getTrackElementByTrack_(this.track_);
+
+ if (remoteTextTrackEl) {
+ remoteTextTrackEl.removeEventListener('load', this.updateHandler_);
+ }
+
+ this.track_.removeEventListener('cuechange', this.selectCurrentItem_);
+
+ this.track_ = null;
+ }
+
+ this.track_ = track;
+
+ // here this.track_ refers to the new track instance
+ if (this.track_) {
+ this.track_.mode = 'hidden';
+
+ const remoteTextTrackEl = this.player_.remoteTextTrackEls().getTrackElementByTrack_(this.track_);
+
+ if (remoteTextTrackEl) {
+ remoteTextTrackEl.addEventListener('load', this.updateHandler_);
+ }
+
+ this.track_.addEventListener('cuechange', this.selectCurrentItem_);
+ }
+ }
+
+ /**
+ * Find the track object that is currently in use by this ChaptersButton
+ *
+ * @return {TextTrack|undefined}
+ * The current track or undefined if none was found.
+ */
+ findChaptersTrack() {
+ const tracks = this.player_.textTracks() || [];
+
+ for (let i = tracks.length - 1; i >= 0; i--) {
+ // We will always choose the last track as our chaptersTrack
+ const track = tracks[i];
+
+ if (track.kind === this.kind_) {
+ return track;
+ }
+ }
+ }
+
+ /**
+ * Get the caption for the ChaptersButton based on the track label. This will also
+ * use the current tracks localized kind as a fallback if a label does not exist.
+ *
+ * @return {string}
+ * The tracks current label or the localized track kind.
+ */
+ getMenuCaption() {
+ if (this.track_ && this.track_.label) {
+ return this.track_.label;
+ }
+ return this.localize(toTitleCase(this.kind_));
+ }
+
+ /**
+ * Create menu from chapter track
+ *
+ * @return {Menu}
+ * New menu for the chapter buttons
+ */
+ createMenu() {
+ this.options_.title = this.getMenuCaption();
+ return super.createMenu();
+ }
+
+ /**
+ * Create a menu item for each text track
+ *
+ * @return {TextTrackMenuItem[]}
+ * Array of menu items
+ */
+ createItems() {
+ const items = [];
+
+ if (!this.track_) {
+ return items;
+ }
+
+ const cues = this.track_.cues;
+
+ if (!cues) {
+ return items;
+ }
+
+ for (let i = 0, l = cues.length; i < l; i++) {
+ const cue = cues[i];
+ const mi = new ChaptersTrackMenuItem(this.player_, { track: this.track_, cue });
+
+ items.push(mi);
+ }
+
+ return items;
+ }
+
+}
+
+/**
+ * `kind` of TextTrack to look for to associate it with this menu.
+ *
+ * @type {string}
+ * @private
+ */
+ChaptersButton.prototype.kind_ = 'chapters';
+
+/**
+ * The text that should display over the `ChaptersButton`s controls. Added for localization.
+ *
+ * @type {string}
+ * @protected
+ */
+ChaptersButton.prototype.controlText_ = 'Chapters';
+
+Component.registerComponent('ChaptersButton', ChaptersButton);
+export default ChaptersButton;
diff --git a/javascript/videojs/src/js/control-bar/text-track-controls/chapters-track-menu-item.js b/javascript/videojs/src/js/control-bar/text-track-controls/chapters-track-menu-item.js
new file mode 100644
index 0000000..2463564
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/text-track-controls/chapters-track-menu-item.js
@@ -0,0 +1,60 @@
+/**
+ * @file chapters-track-menu-item.js
+ */
+import MenuItem from '../../menu/menu-item.js';
+import Component from '../../component.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * The chapter track menu item
+ *
+ * @extends MenuItem
+ */
+class ChaptersTrackMenuItem extends MenuItem {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ const track = options.track;
+ const cue = options.cue;
+ const currentTime = player.currentTime();
+
+ // Modify options for parent MenuItem class's init.
+ options.selectable = true;
+ options.multiSelectable = false;
+ options.label = cue.text;
+ options.selected = (cue.startTime <= currentTime && currentTime < cue.endTime);
+ super(player, options);
+
+ this.track = track;
+ this.cue = cue;
+ }
+
+ /**
+ * This gets called when an `ChaptersTrackMenuItem` is "clicked". See
+ * {@link ClickableComponent} for more detailed information on what a click can be.
+ *
+ * @param {Event} [event]
+ * The `keydown`, `tap`, or `click` event that caused this function to be
+ * called.
+ *
+ * @listens tap
+ * @listens click
+ */
+ handleClick(event) {
+ super.handleClick();
+ this.player_.currentTime(this.cue.startTime);
+ }
+
+}
+
+Component.registerComponent('ChaptersTrackMenuItem', ChaptersTrackMenuItem);
+export default ChaptersTrackMenuItem;
diff --git a/javascript/videojs/src/js/control-bar/text-track-controls/descriptions-button.js b/javascript/videojs/src/js/control-bar/text-track-controls/descriptions-button.js
new file mode 100644
index 0000000..320cc35
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/text-track-controls/descriptions-button.js
@@ -0,0 +1,105 @@
+/**
+ * @file descriptions-button.js
+ */
+import TextTrackButton from './text-track-button.js';
+import Component from '../../component.js';
+import * as Fn from '../../utils/fn.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * The button component for toggling and selecting descriptions
+ *
+ * @extends TextTrackButton
+ */
+class DescriptionsButton extends TextTrackButton {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ *
+ * @param {Function} [ready]
+ * The function to call when this component is ready.
+ */
+ constructor(player, options, ready) {
+ super(player, options, ready);
+
+ this.setIcon('audio-description');
+
+ const tracks = player.textTracks();
+ const changeHandler = Fn.bind_(this, this.handleTracksChange);
+
+ tracks.addEventListener('change', changeHandler);
+ this.on('dispose', function() {
+ tracks.removeEventListener('change', changeHandler);
+ });
+ }
+
+ /**
+ * Handle text track change
+ *
+ * @param {Event} event
+ * The event that caused this function to run
+ *
+ * @listens TextTrackList#change
+ */
+ handleTracksChange(event) {
+ const tracks = this.player().textTracks();
+ let disabled = false;
+
+ // Check whether a track of a different kind is showing
+ for (let i = 0, l = tracks.length; i < l; i++) {
+ const track = tracks[i];
+
+ if (track.kind !== this.kind_ && track.mode === 'showing') {
+ disabled = true;
+ break;
+ }
+ }
+
+ // If another track is showing, disable this menu button
+ if (disabled) {
+ this.disable();
+ } else {
+ this.enable();
+ }
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-descriptions-button ${super.buildCSSClass()}`;
+ }
+
+ buildWrapperCSSClass() {
+ return `vjs-descriptions-button ${super.buildWrapperCSSClass()}`;
+ }
+}
+
+/**
+ * `kind` of TextTrack to look for to associate it with this menu.
+ *
+ * @type {string}
+ * @private
+ */
+DescriptionsButton.prototype.kind_ = 'descriptions';
+
+/**
+ * The text that should display over the `DescriptionsButton`s controls. Added for localization.
+ *
+ * @type {string}
+ * @protected
+ */
+DescriptionsButton.prototype.controlText_ = 'Descriptions';
+
+Component.registerComponent('DescriptionsButton', DescriptionsButton);
+export default DescriptionsButton;
diff --git a/javascript/videojs/src/js/control-bar/text-track-controls/off-text-track-menu-item.js b/javascript/videojs/src/js/control-bar/text-track-controls/off-text-track-menu-item.js
new file mode 100644
index 0000000..7f117a1
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/text-track-controls/off-text-track-menu-item.js
@@ -0,0 +1,115 @@
+/**
+ * @file off-text-track-menu-item.js
+ */
+import TextTrackMenuItem from './text-track-menu-item.js';
+import Component from '../../component.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * A special menu item for turning off a specific type of text track
+ *
+ * @extends TextTrackMenuItem
+ */
+class OffTextTrackMenuItem extends TextTrackMenuItem {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ // Create pseudo track info
+ // Requires options['kind']
+ options.track = {
+ player,
+ // it is no longer necessary to store `kind` or `kinds` on the track itself
+ // since they are now stored in the `kinds` property of all instances of
+ // TextTrackMenuItem, but this will remain for backwards compatibility
+ kind: options.kind,
+ kinds: options.kinds,
+ default: false,
+ mode: 'disabled'
+ };
+
+ if (!options.kinds) {
+ options.kinds = [options.kind];
+ }
+
+ if (options.label) {
+ options.track.label = options.label;
+ } else {
+ options.track.label = options.kinds.join(' and ') + ' off';
+ }
+
+ // MenuItem is selectable
+ options.selectable = true;
+ // MenuItem is NOT multiSelectable (i.e. only one can be marked "selected" at a time)
+ options.multiSelectable = false;
+
+ super(player, options);
+ }
+
+ /**
+ * Handle text track change
+ *
+ * @param {Event} event
+ * The event that caused this function to run
+ */
+ handleTracksChange(event) {
+ const tracks = this.player().textTracks();
+ let shouldBeSelected = true;
+
+ for (let i = 0, l = tracks.length; i < l; i++) {
+ const track = tracks[i];
+
+ if ((this.options_.kinds.indexOf(track.kind) > -1) && track.mode === 'showing') {
+ shouldBeSelected = false;
+ break;
+ }
+ }
+
+ // Prevent redundant selected() calls because they may cause
+ // screen readers to read the appended control text unnecessarily
+ if (shouldBeSelected !== this.isSelected_) {
+ this.selected(shouldBeSelected);
+ }
+ }
+
+ handleSelectedLanguageChange(event) {
+ const tracks = this.player().textTracks();
+ let allHidden = true;
+
+ for (let i = 0, l = tracks.length; i < l; i++) {
+ const track = tracks[i];
+
+ if ((['captions', 'descriptions', 'subtitles'].indexOf(track.kind) > -1) && track.mode === 'showing') {
+ allHidden = false;
+ break;
+ }
+ }
+
+ if (allHidden) {
+ this.player_.cache_.selectedLanguage = {
+ enabled: false
+ };
+ }
+ }
+
+ /**
+ * Update control text and label on languagechange
+ */
+ handleLanguagechange() {
+ this.$('.vjs-menu-item-text').textContent = this.player_.localize(this.options_.label);
+
+ super.handleLanguagechange();
+ }
+
+}
+
+Component.registerComponent('OffTextTrackMenuItem', OffTextTrackMenuItem);
+export default OffTextTrackMenuItem;
diff --git a/javascript/videojs/src/js/control-bar/text-track-controls/subs-caps-button.js b/javascript/videojs/src/js/control-bar/text-track-controls/subs-caps-button.js
new file mode 100644
index 0000000..576b4d0
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/text-track-controls/subs-caps-button.js
@@ -0,0 +1,99 @@
+/**
+ * @file sub-caps-button.js
+ */
+import TextTrackButton from './text-track-button.js';
+import Component from '../../component.js';
+import CaptionSettingsMenuItem from './caption-settings-menu-item.js';
+import SubsCapsMenuItem from './subs-caps-menu-item.js';
+import {toTitleCase} from '../../utils/str.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * The button component for toggling and selecting captions and/or subtitles
+ *
+ * @extends TextTrackButton
+ */
+class SubsCapsButton extends TextTrackButton {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ *
+ * @param {Function} [ready]
+ * The function to call when this component is ready.
+ */
+ constructor(player, options = {}) {
+ super(player, options);
+
+ // Although North America uses "captions" in most cases for
+ // "captions and subtitles" other locales use "subtitles"
+ this.label_ = 'subtitles';
+ this.setIcon('subtitles');
+ if (['en', 'en-us', 'en-ca', 'fr-ca'].indexOf(this.player_.language_) > -1) {
+ this.label_ = 'captions';
+ this.setIcon('captions');
+ }
+ this.menuButton_.controlText(toTitleCase(this.label_));
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-subs-caps-button ${super.buildCSSClass()}`;
+ }
+
+ buildWrapperCSSClass() {
+ return `vjs-subs-caps-button ${super.buildWrapperCSSClass()}`;
+ }
+
+ /**
+ * Create caption/subtitles menu items
+ *
+ * @return {CaptionSettingsMenuItem[]}
+ * The array of current menu items.
+ */
+ createItems() {
+ let items = [];
+
+ if (!(this.player().tech_ && this.player().tech_.featuresNativeTextTracks) &&
+ this.player().getChild('textTrackSettings')) {
+ items.push(new CaptionSettingsMenuItem(this.player_, {kind: this.label_}));
+
+ this.hideThreshold_ += 1;
+ }
+
+ items = super.createItems(items, SubsCapsMenuItem);
+ return items;
+ }
+
+}
+
+/**
+ * `kind`s of TextTrack to look for to associate it with this menu.
+ *
+ * @type {array}
+ * @private
+ */
+SubsCapsButton.prototype.kinds_ = ['captions', 'subtitles'];
+
+/**
+ * The text that should display over the `SubsCapsButton`s controls.
+ *
+ *
+ * @type {string}
+ * @protected
+ */
+SubsCapsButton.prototype.controlText_ = 'Subtitles';
+
+Component.registerComponent('SubsCapsButton', SubsCapsButton);
+export default SubsCapsButton;
diff --git a/javascript/videojs/src/js/control-bar/text-track-controls/subs-caps-menu-item.js b/javascript/videojs/src/js/control-bar/text-track-controls/subs-caps-menu-item.js
new file mode 100644
index 0000000..7bcbe30
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/text-track-controls/subs-caps-menu-item.js
@@ -0,0 +1,43 @@
+/**
+ * @file subs-caps-menu-item.js
+ */
+import TextTrackMenuItem from './text-track-menu-item.js';
+import Component from '../../component.js';
+import {createEl} from '../../utils/dom.js';
+
+/**
+ * SubsCapsMenuItem has an [cc] icon to distinguish captions from subtitles
+ * in the SubsCapsMenu.
+ *
+ * @extends TextTrackMenuItem
+ */
+class SubsCapsMenuItem extends TextTrackMenuItem {
+
+ createEl(type, props, attrs) {
+ const el = super.createEl(type, props, attrs);
+ const parentSpan = el.querySelector('.vjs-menu-item-text');
+
+ if (this.options_.track.kind === 'captions') {
+ if (this.player_.options_.experimentalSvgIcons) {
+ this.setIcon('captions', el);
+ } else {
+ parentSpan.appendChild(createEl('span', {
+ className: 'vjs-icon-placeholder'
+ }, {
+ 'aria-hidden': true
+ }));
+ }
+ parentSpan.appendChild(createEl('span', {
+ className: 'vjs-control-text',
+ // space added as the text will visually flow with the
+ // label
+ textContent: ` ${this.localize('Captions')}`
+ }));
+ }
+
+ return el;
+ }
+}
+
+Component.registerComponent('SubsCapsMenuItem', SubsCapsMenuItem);
+export default SubsCapsMenuItem;
diff --git a/javascript/videojs/src/js/control-bar/text-track-controls/subtitles-button.js b/javascript/videojs/src/js/control-bar/text-track-controls/subtitles-button.js
new file mode 100644
index 0000000..f7c530c
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/text-track-controls/subtitles-button.js
@@ -0,0 +1,66 @@
+/**
+ * @file subtitles-button.js
+ */
+import TextTrackButton from './text-track-button.js';
+import Component from '../../component.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * The button component for toggling and selecting subtitles
+ *
+ * @extends TextTrackButton
+ */
+class SubtitlesButton extends TextTrackButton {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ *
+ * @param {Function} [ready]
+ * The function to call when this component is ready.
+ */
+ constructor(player, options, ready) {
+ super(player, options, ready);
+
+ this.setIcon('subtitles');
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return `vjs-subtitles-button ${super.buildCSSClass()}`;
+ }
+
+ buildWrapperCSSClass() {
+ return `vjs-subtitles-button ${super.buildWrapperCSSClass()}`;
+ }
+}
+
+/**
+ * `kind` of TextTrack to look for to associate it with this menu.
+ *
+ * @type {string}
+ * @private
+ */
+SubtitlesButton.prototype.kind_ = 'subtitles';
+
+/**
+ * The text that should display over the `SubtitlesButton`s controls. Added for localization.
+ *
+ * @type {string}
+ * @protected
+ */
+SubtitlesButton.prototype.controlText_ = 'Subtitles';
+
+Component.registerComponent('SubtitlesButton', SubtitlesButton);
+export default SubtitlesButton;
diff --git a/javascript/videojs/src/js/control-bar/text-track-controls/text-track-button.js b/javascript/videojs/src/js/control-bar/text-track-controls/text-track-button.js
new file mode 100644
index 0000000..0744986
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/text-track-controls/text-track-button.js
@@ -0,0 +1,93 @@
+/**
+ * @file text-track-button.js
+ */
+import TrackButton from '../track-button.js';
+import Component from '../../component.js';
+import TextTrackMenuItem from './text-track-menu-item.js';
+import OffTextTrackMenuItem from './off-text-track-menu-item.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * The base class for buttons that toggle specific text track types (e.g. subtitles)
+ *
+ * @extends MenuButton
+ */
+class TextTrackButton extends TrackButton {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options={}]
+ * The key/value store of player options.
+ */
+ constructor(player, options = {}) {
+ options.tracks = player.textTracks();
+
+ super(player, options);
+ }
+
+ /**
+ * Create a menu item for each text track
+ *
+ * @param {TextTrackMenuItem[]} [items=[]]
+ * Existing array of items to use during creation
+ *
+ * @return {TextTrackMenuItem[]}
+ * Array of menu items that were created
+ */
+ createItems(items = [], TrackMenuItem = TextTrackMenuItem) {
+
+ // Label is an override for the [track] off label
+ // USed to localise captions/subtitles
+ let label;
+
+ if (this.label_) {
+ label = `${this.label_} off`;
+ }
+ // Add an OFF menu item to turn all tracks off
+ items.push(new OffTextTrackMenuItem(this.player_, {
+ kinds: this.kinds_,
+ kind: this.kind_,
+ label
+ }));
+
+ this.hideThreshold_ += 1;
+
+ const tracks = this.player_.textTracks();
+
+ if (!Array.isArray(this.kinds_)) {
+ this.kinds_ = [this.kind_];
+ }
+
+ for (let i = 0; i < tracks.length; i++) {
+ const track = tracks[i];
+
+ // only add tracks that are of an appropriate kind and have a label
+ if (this.kinds_.indexOf(track.kind) > -1) {
+
+ const item = new TrackMenuItem(this.player_, {
+ track,
+ kinds: this.kinds_,
+ kind: this.kind_,
+ // MenuItem is selectable
+ selectable: true,
+ // MenuItem is NOT multiSelectable (i.e. only one can be marked "selected" at a time)
+ multiSelectable: false
+ });
+
+ item.addClass(`vjs-${track.kind}-menu-item`);
+ items.push(item);
+ }
+ }
+
+ return items;
+ }
+
+}
+
+Component.registerComponent('TextTrackButton', TextTrackButton);
+export default TextTrackButton;
diff --git a/javascript/videojs/src/js/control-bar/text-track-controls/text-track-menu-item.js b/javascript/videojs/src/js/control-bar/text-track-controls/text-track-menu-item.js
new file mode 100644
index 0000000..757e18b
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/text-track-controls/text-track-menu-item.js
@@ -0,0 +1,182 @@
+/**
+ * @file text-track-menu-item.js
+ */
+import MenuItem from '../../menu/menu-item.js';
+import Component from '../../component.js';
+import window from 'global/window';
+import document from 'global/document';
+
+/** @import Player from '../../player' */
+
+/**
+ * The specific menu item type for selecting a language within a text track kind
+ *
+ * @extends MenuItem
+ */
+class TextTrackMenuItem extends MenuItem {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ const track = options.track;
+ const tracks = player.textTracks();
+
+ // Modify options for parent MenuItem class's init.
+ options.label = track.label || track.language || 'Unknown';
+ options.selected = track.mode === 'showing';
+
+ super(player, options);
+
+ this.track = track;
+ // Determine the relevant kind(s) of tracks for this component and filter
+ // out empty kinds.
+ this.kinds = (options.kinds || [options.kind || this.track.kind]).filter(Boolean);
+
+ const changeHandler = (...args) => {
+ this.handleTracksChange.apply(this, args);
+ };
+ const selectedLanguageChangeHandler = (...args) => {
+ this.handleSelectedLanguageChange.apply(this, args);
+ };
+
+ player.on(['loadstart', 'texttrackchange'], changeHandler);
+ tracks.addEventListener('change', changeHandler);
+ tracks.addEventListener('selectedlanguagechange', selectedLanguageChangeHandler);
+ this.on('dispose', function() {
+ player.off(['loadstart', 'texttrackchange'], changeHandler);
+ tracks.removeEventListener('change', changeHandler);
+ tracks.removeEventListener('selectedlanguagechange', selectedLanguageChangeHandler);
+ });
+
+ // iOS7 doesn't dispatch change events to TextTrackLists when an
+ // associated track's mode changes. Without something like
+ // Object.observe() (also not present on iOS7), it's not
+ // possible to detect changes to the mode attribute and polyfill
+ // the change event. As a poor substitute, we manually dispatch
+ // change events whenever the controls modify the mode.
+ if (tracks.onchange === undefined) {
+ let event;
+
+ this.on(['tap', 'click'], function() {
+ if (typeof window.Event !== 'object') {
+ // Android 2.3 throws an Illegal Constructor error for window.Event
+ try {
+ event = new window.Event('change');
+ } catch (err) {
+ // continue regardless of error
+ }
+ }
+
+ if (!event) {
+ event = document.createEvent('Event');
+ event.initEvent('change', true, true);
+ }
+
+ tracks.dispatchEvent(event);
+ });
+ }
+
+ // set the default state based on current tracks
+ this.handleTracksChange();
+ }
+
+ /**
+ * This gets called when an `TextTrackMenuItem` is "clicked". See
+ * {@link ClickableComponent} for more detailed information on what a click can be.
+ *
+ * @param {Event} event
+ * The `keydown`, `tap`, or `click` event that caused this function to be
+ * called.
+ *
+ * @listens tap
+ * @listens click
+ */
+ handleClick(event) {
+ const referenceTrack = this.track;
+ const tracks = this.player_.textTracks();
+
+ super.handleClick(event);
+
+ if (!tracks) {
+ return;
+ }
+
+ for (let i = 0; i < tracks.length; i++) {
+ const track = tracks[i];
+
+ // If the track from the text tracks list is not of the right kind,
+ // skip it. We do not want to affect tracks of incompatible kind(s).
+ if (this.kinds.indexOf(track.kind) === -1) {
+ continue;
+ }
+
+ // If this text track is the component's track and it is not showing,
+ // set it to showing.
+ if (track === referenceTrack) {
+ if (track.mode !== 'showing') {
+ track.mode = 'showing';
+ }
+
+ // If this text track is not the component's track and it is not
+ // disabled, set it to disabled.
+ } else if (track.mode !== 'disabled') {
+ track.mode = 'disabled';
+ }
+ }
+ }
+
+ /**
+ * Handle text track list change
+ *
+ * @param {Event} event
+ * The `change` event that caused this function to be called.
+ *
+ * @listens TextTrackList#change
+ */
+ handleTracksChange(event) {
+ const shouldBeSelected = this.track.mode === 'showing';
+
+ // Prevent redundant selected() calls because they may cause
+ // screen readers to read the appended control text unnecessarily
+ if (shouldBeSelected !== this.isSelected_) {
+ this.selected(shouldBeSelected);
+ }
+ }
+
+ handleSelectedLanguageChange(event) {
+ if (this.track.mode === 'showing') {
+ const selectedLanguage = this.player_.cache_.selectedLanguage;
+
+ // Don't replace the kind of track across the same language
+ if (selectedLanguage && selectedLanguage.enabled &&
+ selectedLanguage.language === this.track.language &&
+ selectedLanguage.kind !== this.track.kind) {
+ return;
+ }
+
+ this.player_.cache_.selectedLanguage = {
+ enabled: true,
+ language: this.track.language,
+ kind: this.track.kind
+ };
+ }
+ }
+
+ dispose() {
+ // remove reference to track object on dispose
+ this.track = null;
+
+ super.dispose();
+ }
+
+}
+
+Component.registerComponent('TextTrackMenuItem', TextTrackMenuItem);
+export default TextTrackMenuItem;
diff --git a/javascript/videojs/src/js/control-bar/time-controls/current-time-display.js b/javascript/videojs/src/js/control-bar/time-controls/current-time-display.js
new file mode 100644
index 0000000..5047df6
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/time-controls/current-time-display.js
@@ -0,0 +1,67 @@
+/**
+ * @file current-time-display.js
+ */
+import TimeDisplay from './time-display';
+import Component from '../../component.js';
+
+/**
+ * Displays the current time
+ *
+ * @extends Component
+ */
+class CurrentTimeDisplay extends TimeDisplay {
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return 'vjs-current-time';
+ }
+
+ /**
+ * Update current time display
+ *
+ * @param {Event} [event]
+ * The `timeupdate` event that caused this function to run.
+ *
+ * @listens Player#timeupdate
+ */
+ updateContent(event) {
+ // Allows for smooth scrubbing, when player can't keep up.
+ let time;
+
+ if (this.player_.ended()) {
+ time = this.player_.duration();
+ } else if (event && event.target && typeof event.target.pendingSeekTime === 'function' && event.target.pendingSeekTime() !== null) {
+ time = event.target.pendingSeekTime();
+ } else {
+ time = (this.player_.scrubbing()) ? this.player_.getCache().currentTime : this.player_.currentTime();
+ }
+
+ this.updateTextNode_(time);
+ }
+}
+
+/**
+ * The text that is added to the `CurrentTimeDisplay` for screen reader users.
+ *
+ * @type {string}
+ * @private
+ */
+CurrentTimeDisplay.prototype.labelText_ = 'Current Time';
+
+/**
+ * The text that should display over the `CurrentTimeDisplay`s controls. Added to for localization.
+ *
+ * @type {string}
+ * @protected
+ *
+ * @deprecated in v7; controlText_ is not used in non-active display Components
+ */
+CurrentTimeDisplay.prototype.controlText_ = 'Current Time';
+
+Component.registerComponent('CurrentTimeDisplay', CurrentTimeDisplay);
+export default CurrentTimeDisplay;
diff --git a/javascript/videojs/src/js/control-bar/time-controls/duration-display.js b/javascript/videojs/src/js/control-bar/time-controls/duration-display.js
new file mode 100644
index 0000000..bd02d69
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/time-controls/duration-display.js
@@ -0,0 +1,93 @@
+/**
+ * @file duration-display.js
+ */
+import TimeDisplay from './time-display';
+import Component from '../../component.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * Displays the duration
+ *
+ * @extends Component
+ */
+class DurationDisplay extends TimeDisplay {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+
+ const updateContent = (e) => this.updateContent(e);
+
+ // we do not want to/need to throttle duration changes,
+ // as they should always display the changed duration as
+ // it has changed
+ this.on(player, 'durationchange', updateContent);
+
+ // Listen to loadstart because the player duration is reset when a new media element is loaded,
+ // but the durationchange on the user agent will not fire.
+ // @see [Spec]{@link https://www.w3.org/TR/2011/WD-html5-20110113/video.html#media-element-load-algorithm}
+ this.on(player, 'loadstart', updateContent);
+
+ // Also listen for timeupdate (in the parent) and loadedmetadata because removing those
+ // listeners could have broken dependent applications/libraries. These
+ // can likely be removed for 7.0.
+ this.on(player, 'loadedmetadata', updateContent);
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return 'vjs-duration';
+ }
+
+ /**
+ * Update duration time display.
+ *
+ * @param {Event} [event]
+ * The `durationchange`, `timeupdate`, or `loadedmetadata` event that caused
+ * this function to be called.
+ *
+ * @listens Player#durationchange
+ * @listens Player#timeupdate
+ * @listens Player#loadedmetadata
+ */
+ updateContent(event) {
+ const duration = this.player_.duration();
+
+ this.updateTextNode_(duration);
+ }
+}
+
+/**
+ * The text that is added to the `DurationDisplay` for screen reader users.
+ *
+ * @type {string}
+ * @private
+ */
+DurationDisplay.prototype.labelText_ = 'Duration';
+
+/**
+ * The text that should display over the `DurationDisplay`s controls. Added to for localization.
+ *
+ * @type {string}
+ * @protected
+ *
+ * @deprecated in v7; controlText_ is not used in non-active display Components
+ */
+DurationDisplay.prototype.controlText_ = 'Duration';
+
+Component.registerComponent('DurationDisplay', DurationDisplay);
+export default DurationDisplay;
diff --git a/javascript/videojs/src/js/control-bar/time-controls/remaining-time-display.js b/javascript/videojs/src/js/control-bar/time-controls/remaining-time-display.js
new file mode 100644
index 0000000..b3842c8
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/time-controls/remaining-time-display.js
@@ -0,0 +1,105 @@
+/**
+ * @file remaining-time-display.js
+ */
+import TimeDisplay from './time-display';
+import Component from '../../component.js';
+import * as Dom from '../../utils/dom.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * Displays the time left in the video
+ *
+ * @extends Component
+ */
+class RemainingTimeDisplay extends TimeDisplay {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+ this.on(player, 'durationchange', (e) => this.updateContent(e));
+ }
+
+ /**
+ * Builds the default DOM `className`.
+ *
+ * @return {string}
+ * The DOM `className` for this object.
+ */
+ buildCSSClass() {
+ return 'vjs-remaining-time';
+ }
+
+ /**
+ * Create the `Component`'s DOM element with the "minus" character prepend to the time
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ const el = super.createEl();
+
+ if (this.options_.displayNegative !== false) {
+ el.insertBefore(Dom.createEl('span', {}, {'aria-hidden': true}, '-'), this.contentEl_);
+ }
+ return el;
+ }
+
+ /**
+ * Update remaining time display.
+ *
+ * @param {Event} [event]
+ * The `timeupdate` or `durationchange` event that caused this to run.
+ *
+ * @listens Player#timeupdate
+ * @listens Player#durationchange
+ */
+ updateContent(event) {
+ if (typeof this.player_.duration() !== 'number') {
+ return;
+ }
+
+ let time;
+
+ // @deprecated We should only use remainingTimeDisplay
+ // as of video.js 7
+ if (this.player_.ended()) {
+ time = 0;
+ } else if (this.player_.remainingTimeDisplay) {
+ time = this.player_.remainingTimeDisplay();
+ } else {
+ time = this.player_.remainingTime();
+ }
+
+ this.updateTextNode_(time);
+ }
+}
+
+/**
+ * The text that is added to the `RemainingTimeDisplay` for screen reader users.
+ *
+ * @type {string}
+ * @private
+ */
+RemainingTimeDisplay.prototype.labelText_ = 'Remaining Time';
+
+/**
+ * The text that should display over the `RemainingTimeDisplay`s controls. Added to for localization.
+ *
+ * @type {string}
+ * @protected
+ *
+ * @deprecated in v7; controlText_ is not used in non-active display Components
+ */
+RemainingTimeDisplay.prototype.controlText_ = 'Remaining Time';
+
+Component.registerComponent('RemainingTimeDisplay', RemainingTimeDisplay);
+export default RemainingTimeDisplay;
diff --git a/javascript/videojs/src/js/control-bar/time-controls/time-display.js b/javascript/videojs/src/js/control-bar/time-controls/time-display.js
new file mode 100644
index 0000000..2fa558d
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/time-controls/time-display.js
@@ -0,0 +1,164 @@
+/**
+ * @file time-display.js
+ */
+import document from 'global/document';
+import Component from '../../component.js';
+import * as Dom from '../../utils/dom.js';
+import {formatTime} from '../../utils/time.js';
+import log from '../../utils/log.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * Displays time information about the video
+ *
+ * @extends Component
+ */
+class TimeDisplay extends Component {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+
+ this.on(player, ['timeupdate', 'ended', 'seeking'], (e) => this.update(e));
+ this.updateTextNode_();
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ const className = this.buildCSSClass();
+ const el = super.createEl('div', {
+ className: `${className} vjs-time-control vjs-control`
+ });
+ const span = Dom.createEl('span', {
+ className: 'vjs-control-text',
+ textContent: `${this.localize(this.labelText_)}\u00a0`
+ }, {
+ role: 'presentation'
+ });
+
+ el.appendChild(span);
+
+ this.contentEl_ = Dom.createEl('span', {
+ className: `${className}-display`
+ }, {
+ // span elements have no implicit role, but some screen readers (notably VoiceOver)
+ // treat them as a break between items in the DOM when using arrow keys
+ // (or left-to-right swipes on iOS) to read contents of a page. Using
+ // role='presentation' causes VoiceOver to NOT treat this span as a break.
+ role: 'presentation'
+ });
+
+ el.appendChild(this.contentEl_);
+ return el;
+ }
+
+ dispose() {
+ this.contentEl_ = null;
+ this.textNode_ = null;
+
+ super.dispose();
+ }
+
+ /**
+ * Updates the displayed time according to the `updateContent` function which is defined in the child class.
+ *
+ * @param {Event} [event]
+ * The `timeupdate`, `ended` or `seeking` (if enableSmoothSeeking is true) event that caused this function to be called.
+ */
+ update(event) {
+ if (!this.player_.options_.enableSmoothSeeking && event.type === 'seeking') {
+ return;
+ }
+
+ this.updateContent(event);
+ }
+
+ /**
+ * Updates the time display text node with a new time
+ *
+ * @param {number} [time=0] the time to update to
+ *
+ * @private
+ */
+ updateTextNode_(time = 0) {
+ time = formatTime(time);
+
+ if (this.formattedTime_ === time) {
+ return;
+ }
+
+ this.formattedTime_ = time;
+
+ this.requestNamedAnimationFrame('TimeDisplay#updateTextNode_', () => {
+ if (!this.contentEl_) {
+ return;
+ }
+
+ let oldNode = this.textNode_;
+
+ if (oldNode && this.contentEl_.firstChild !== oldNode) {
+ oldNode = null;
+
+ log.warn('TimeDisplay#updateTextnode_: Prevented replacement of text node element since it was no longer a child of this node. Appending a new node instead.');
+ }
+
+ this.textNode_ = document.createTextNode(this.formattedTime_);
+
+ if (!this.textNode_) {
+ return;
+ }
+
+ if (oldNode) {
+ this.contentEl_.replaceChild(this.textNode_, oldNode);
+ } else {
+ this.contentEl_.appendChild(this.textNode_);
+ }
+ });
+ }
+
+ /**
+ * To be filled out in the child class, should update the displayed time
+ * in accordance with the fact that the current time has changed.
+ *
+ * @param {Event} [event]
+ * The `timeupdate` event that caused this to run.
+ *
+ * @listens Player#timeupdate
+ */
+ updateContent(event) {}
+}
+
+/**
+ * The text that is added to the `TimeDisplay` for screen reader users.
+ *
+ * @type {string}
+ * @private
+ */
+TimeDisplay.prototype.labelText_ = 'Time';
+
+/**
+ * The text that should display over the `TimeDisplay`s controls. Added to for localization.
+ *
+ * @type {string}
+ * @protected
+ *
+ * @deprecated in v7; controlText_ is not used in non-active display Components
+ */
+TimeDisplay.prototype.controlText_ = 'Time';
+
+Component.registerComponent('TimeDisplay', TimeDisplay);
+export default TimeDisplay;
diff --git a/javascript/videojs/src/js/control-bar/time-controls/time-divider.js b/javascript/videojs/src/js/control-bar/time-controls/time-divider.js
new file mode 100644
index 0000000..970e2d1
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/time-controls/time-divider.js
@@ -0,0 +1,44 @@
+/**
+ * @file time-divider.js
+ */
+import Component from '../../component.js';
+
+/**
+ * The separator between the current time and duration.
+ * Can be hidden if it's not needed in the design.
+ *
+ * @extends Component
+ */
+class TimeDivider extends Component {
+
+ /**
+ * Create the component's DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ const el = super.createEl('div', {
+ className: 'vjs-time-control vjs-time-divider'
+ }, {
+ // this element and its contents can be hidden from assistive techs since
+ // it is made extraneous by the announcement of the control text
+ // for the current time and duration displays
+ 'aria-hidden': true
+ });
+
+ const div = super.createEl('div');
+ const span = super.createEl('span', {
+ textContent: '/'
+ });
+
+ div.appendChild(span);
+ el.appendChild(div);
+
+ return el;
+ }
+
+}
+
+Component.registerComponent('TimeDivider', TimeDivider);
+export default TimeDivider;
diff --git a/javascript/videojs/src/js/control-bar/track-button.js b/javascript/videojs/src/js/control-bar/track-button.js
new file mode 100644
index 0000000..99ab39c
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/track-button.js
@@ -0,0 +1,56 @@
+/**
+ * @file track-button.js
+ */
+import MenuButton from '../menu/menu-button.js';
+import Component from '../component.js';
+import * as Fn from '../utils/fn.js';
+
+/** @import Player from './player' */
+
+/**
+ * The base class for buttons that toggle specific track types (e.g. subtitles).
+ *
+ * @extends MenuButton
+ */
+class TrackButton extends MenuButton {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ const tracks = options.tracks;
+
+ super(player, options);
+
+ if (this.items.length <= 1) {
+ this.hide();
+ }
+
+ if (!tracks) {
+ return;
+ }
+
+ const updateHandler = Fn.bind_(this, this.update);
+
+ tracks.addEventListener('removetrack', updateHandler);
+ tracks.addEventListener('addtrack', updateHandler);
+ tracks.addEventListener('labelchange', updateHandler);
+ this.player_.on('ready', updateHandler);
+
+ this.player_.on('dispose', function() {
+ tracks.removeEventListener('removetrack', updateHandler);
+ tracks.removeEventListener('addtrack', updateHandler);
+ tracks.removeEventListener('labelchange', updateHandler);
+ });
+ }
+
+}
+
+Component.registerComponent('TrackButton', TrackButton);
+export default TrackButton;
diff --git a/javascript/videojs/src/js/control-bar/volume-control/check-mute-support.js b/javascript/videojs/src/js/control-bar/volume-control/check-mute-support.js
new file mode 100644
index 0000000..8665883
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/volume-control/check-mute-support.js
@@ -0,0 +1,31 @@
+/** @import Component from '../../component' */
+/** @import Player from '../../player' */
+
+/**
+ * Check if muting volume is supported and if it isn't hide the mute toggle
+ * button.
+ *
+ * @param {Component} self
+ * A reference to the mute toggle button
+ *
+ * @param {Player} player
+ * A reference to the player
+ *
+ * @private
+ */
+const checkMuteSupport = function(self, player) {
+ // hide mute toggle button if it's not supported by the current tech
+ if (player.tech_ && !player.tech_.featuresMuteControl) {
+ self.addClass('vjs-hidden');
+ }
+
+ self.on(player, 'loadstart', function() {
+ if (!player.tech_.featuresMuteControl) {
+ self.addClass('vjs-hidden');
+ } else {
+ self.removeClass('vjs-hidden');
+ }
+ });
+};
+
+export default checkMuteSupport;
diff --git a/javascript/videojs/src/js/control-bar/volume-control/check-volume-support.js b/javascript/videojs/src/js/control-bar/volume-control/check-volume-support.js
new file mode 100644
index 0000000..88f287b
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/volume-control/check-volume-support.js
@@ -0,0 +1,31 @@
+/** @import Component from '../../component' */
+/** @import Player from '../../player' */
+
+/**
+ * Check if volume control is supported and if it isn't hide the
+ * `Component` that was passed using the `vjs-hidden` class.
+ *
+ * @param {Component} self
+ * The component that should be hidden if volume is unsupported
+ *
+ * @param {Player} player
+ * A reference to the player
+ *
+ * @private
+ */
+const checkVolumeSupport = function(self, player) {
+ // hide volume controls when they're not supported by the current tech
+ if (player.tech_ && !player.tech_.featuresVolumeControl) {
+ self.addClass('vjs-hidden');
+ }
+
+ self.on(player, 'loadstart', function() {
+ if (!player.tech_.featuresVolumeControl) {
+ self.addClass('vjs-hidden');
+ } else {
+ self.removeClass('vjs-hidden');
+ }
+ });
+};
+
+export default checkVolumeSupport;
diff --git a/javascript/videojs/src/js/control-bar/volume-control/mouse-volume-level-display.js b/javascript/videojs/src/js/control-bar/volume-control/mouse-volume-level-display.js
new file mode 100644
index 0000000..467d7b5
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/volume-control/mouse-volume-level-display.js
@@ -0,0 +1,89 @@
+/**
+ * @file mouse-volume-level-display.js
+ */
+import Component from '../../component.js';
+import * as Fn from '../../utils/fn.js';
+
+/** @import Player from '../../player' */
+
+import './volume-level-tooltip';
+
+/**
+ * The {@link MouseVolumeLevelDisplay} component tracks mouse movement over the
+ * {@link VolumeControl}. It displays an indicator and a {@link VolumeLevelTooltip}
+ * indicating the volume level which is represented by a given point in the
+ * {@link VolumeBar}.
+ *
+ * @extends Component
+ */
+class MouseVolumeLevelDisplay extends Component {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The {@link Player} that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+ this.update = Fn.throttle(Fn.bind_(this, this.update), Fn.UPDATE_REFRESH_INTERVAL);
+ }
+
+ /**
+ * Create the DOM element for this class.
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ return super.createEl('div', {
+ className: 'vjs-mouse-display'
+ });
+ }
+
+ /**
+ * Enquires updates to its own DOM as well as the DOM of its
+ * {@link VolumeLevelTooltip} child.
+ *
+ * @param {Object} rangeBarRect
+ * The `ClientRect` for the {@link VolumeBar} element.
+ *
+ * @param {number} rangeBarPoint
+ * A number from 0 to 1, representing a horizontal/vertical reference point
+ * from the left edge of the {@link VolumeBar}
+ *
+ * @param {boolean} vertical
+ * Referees to the Volume control position
+ * in the control bar{@link VolumeControl}
+ *
+ */
+ update(rangeBarRect, rangeBarPoint, vertical) {
+ const volume = 100 * rangeBarPoint;
+
+ this.getChild('volumeLevelTooltip').updateVolume(rangeBarRect, rangeBarPoint, vertical, volume, () => {
+ if (vertical) {
+ this.el_.style.bottom = `${rangeBarRect.height * rangeBarPoint}px`;
+ } else {
+ this.el_.style.left = `${rangeBarRect.width * rangeBarPoint}px`;
+ }
+ });
+ }
+}
+
+/**
+ * Default options for `MouseVolumeLevelDisplay`
+ *
+ * @type {Object}
+ * @private
+ */
+MouseVolumeLevelDisplay.prototype.options_ = {
+ children: [
+ 'volumeLevelTooltip'
+ ]
+};
+
+Component.registerComponent('MouseVolumeLevelDisplay', MouseVolumeLevelDisplay);
+export default MouseVolumeLevelDisplay;
diff --git a/javascript/videojs/src/js/control-bar/volume-control/volume-bar.js b/javascript/videojs/src/js/control-bar/volume-control/volume-bar.js
new file mode 100644
index 0000000..4e2a02c
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/volume-control/volume-bar.js
@@ -0,0 +1,212 @@
+/**
+ * @file volume-bar.js
+ */
+import Slider from '../../slider/slider.js';
+import Component from '../../component.js';
+import * as Dom from '../../utils/dom.js';
+import {clamp} from '../../utils/num.js';
+import {IS_IOS, IS_ANDROID} from '../../utils/browser.js';
+
+/** @import Player from '../../player' */
+
+// Required children
+import './volume-level.js';
+import './mouse-volume-level-display.js';
+
+/**
+ * The bar that contains the volume level and can be clicked on to adjust the level
+ *
+ * @extends Slider
+ */
+class VolumeBar extends Slider {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+ this.on('slideractive', (e) => this.updateLastVolume_(e));
+ this.on(player, 'volumechange', (e) => this.updateARIAAttributes(e));
+ player.ready(() => this.updateARIAAttributes());
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ return super.createEl('div', {
+ className: 'vjs-volume-bar vjs-slider-bar'
+ }, {
+ 'aria-label': this.localize('Volume Level'),
+ 'aria-live': 'polite'
+ });
+ }
+
+ /**
+ * Handle mouse down on volume bar
+ *
+ * @param {Event} event
+ * The `mousedown` event that caused this to run.
+ *
+ * @listens mousedown
+ */
+ handleMouseDown(event) {
+ if (!Dom.isSingleLeftClick(event)) {
+ return;
+ }
+
+ super.handleMouseDown(event);
+ }
+
+ /**
+ * Handle movement events on the {@link VolumeMenuButton}.
+ *
+ * @param {Event} event
+ * The event that caused this function to run.
+ *
+ * @listens mousemove
+ */
+ handleMouseMove(event) {
+ const mouseVolumeLevelDisplay = this.getChild('mouseVolumeLevelDisplay');
+
+ if (mouseVolumeLevelDisplay) {
+ const volumeBarEl = this.el();
+ const volumeBarRect = Dom.getBoundingClientRect(volumeBarEl);
+ const vertical = this.vertical();
+ let volumeBarPoint = Dom.getPointerPosition(volumeBarEl, event);
+
+ volumeBarPoint = vertical ? volumeBarPoint.y : volumeBarPoint.x;
+ // The default skin has a gap on either side of the `VolumeBar`. This means
+ // that it's possible to trigger this behavior outside the boundaries of
+ // the `VolumeBar`. This ensures we stay within it at all times.
+ volumeBarPoint = clamp(volumeBarPoint, 0, 1);
+
+ mouseVolumeLevelDisplay.update(volumeBarRect, volumeBarPoint, vertical);
+ }
+
+ if (!Dom.isSingleLeftClick(event)) {
+ return;
+ }
+
+ this.checkMuted();
+ this.player_.volume(this.calculateDistance(event));
+ }
+
+ /**
+ * If the player is muted unmute it.
+ */
+ checkMuted() {
+ if (this.player_.muted()) {
+ this.player_.muted(false);
+ }
+ }
+
+ /**
+ * Get percent of volume level
+ *
+ * @return {number}
+ * Volume level percent as a decimal number.
+ */
+ getPercent() {
+ if (this.player_.muted()) {
+ return 0;
+ }
+ return this.player_.volume();
+ }
+
+ /**
+ * Increase volume level for keyboard users
+ */
+ stepForward() {
+ this.checkMuted();
+ this.player_.volume(this.player_.volume() + 0.1);
+ }
+
+ /**
+ * Decrease volume level for keyboard users
+ */
+ stepBack() {
+ this.checkMuted();
+ this.player_.volume(this.player_.volume() - 0.1);
+ }
+
+ /**
+ * Update ARIA accessibility attributes
+ *
+ * @param {Event} [event]
+ * The `volumechange` event that caused this function to run.
+ *
+ * @listens Player#volumechange
+ */
+ updateARIAAttributes(event) {
+ const ariaValue = this.player_.muted() ? 0 : this.volumeAsPercentage_();
+
+ this.el_.setAttribute('aria-valuenow', ariaValue);
+ this.el_.setAttribute('aria-valuetext', ariaValue + '%');
+ }
+
+ /**
+ * Returns the current value of the player volume as a percentage
+ *
+ * @private
+ */
+ volumeAsPercentage_() {
+ return Math.round(this.player_.volume() * 100);
+ }
+
+ /**
+ * When user starts dragging the VolumeBar, store the volume and listen for
+ * the end of the drag. When the drag ends, if the volume was set to zero,
+ * set lastVolume to the stored volume.
+ *
+ * @listens slideractive
+ * @private
+ */
+ updateLastVolume_() {
+ const volumeBeforeDrag = this.player_.volume();
+
+ this.one('sliderinactive', () => {
+ if (this.player_.volume() === 0) {
+ this.player_.lastVolume_(volumeBeforeDrag);
+ }
+ });
+ }
+
+}
+
+/**
+ * Default options for the `VolumeBar`
+ *
+ * @type {Object}
+ * @private
+ */
+VolumeBar.prototype.options_ = {
+ children: [
+ 'volumeLevel'
+ ],
+ barName: 'volumeLevel'
+};
+
+// MouseVolumeLevelDisplay tooltip should not be added to a player on mobile devices
+if (!IS_IOS && !IS_ANDROID) {
+ VolumeBar.prototype.options_.children.splice(0, 0, 'mouseVolumeLevelDisplay');
+}
+
+/**
+ * Call the update event for this Slider when this event happens on the player.
+ *
+ * @type {string}
+ */
+VolumeBar.prototype.playerEvent = 'volumechange';
+
+Component.registerComponent('VolumeBar', VolumeBar);
+export default VolumeBar;
diff --git a/javascript/videojs/src/js/control-bar/volume-control/volume-control.js b/javascript/videojs/src/js/control-bar/volume-control/volume-control.js
new file mode 100644
index 0000000..723e9c3
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/volume-control/volume-control.js
@@ -0,0 +1,148 @@
+/**
+ * @file volume-control.js
+ */
+import Component from '../../component.js';
+import checkVolumeSupport from './check-volume-support';
+import {isPlain} from '../../utils/obj';
+import {throttle, bind_, UPDATE_REFRESH_INTERVAL} from '../../utils/fn.js';
+
+/** @import Player from '../../player' */
+
+// Required children
+import './volume-bar.js';
+
+/**
+ * The component for controlling the volume level
+ *
+ * @extends Component
+ */
+class VolumeControl extends Component {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options={}]
+ * The key/value store of player options.
+ */
+ constructor(player, options = {}) {
+ options.vertical = options.vertical || false;
+
+ // Pass the vertical option down to the VolumeBar if
+ // the VolumeBar is turned on.
+ if (typeof options.volumeBar === 'undefined' || isPlain(options.volumeBar)) {
+ options.volumeBar = options.volumeBar || {};
+ options.volumeBar.vertical = options.vertical;
+ }
+
+ super(player, options);
+
+ // hide this control if volume support is missing
+ checkVolumeSupport(this, player);
+
+ this.throttledHandleMouseMove = throttle(bind_(this, this.handleMouseMove), UPDATE_REFRESH_INTERVAL);
+ this.handleMouseUpHandler_ = (e) => this.handleMouseUp(e);
+
+ this.on('mousedown', (e) => this.handleMouseDown(e));
+ this.on('touchstart', (e) => this.handleMouseDown(e));
+ this.on('mousemove', (e) => this.handleMouseMove(e));
+
+ // while the slider is active (the mouse has been pressed down and
+ // is dragging) or in focus we do not want to hide the VolumeBar
+ this.on(this.volumeBar, ['focus', 'slideractive'], () => {
+ this.volumeBar.addClass('vjs-slider-active');
+ this.addClass('vjs-slider-active');
+ this.trigger('slideractive');
+ });
+
+ this.on(this.volumeBar, ['blur', 'sliderinactive'], () => {
+ this.volumeBar.removeClass('vjs-slider-active');
+ this.removeClass('vjs-slider-active');
+ this.trigger('sliderinactive');
+ });
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ let orientationClass = 'vjs-volume-horizontal';
+
+ if (this.options_.vertical) {
+ orientationClass = 'vjs-volume-vertical';
+ }
+
+ return super.createEl('div', {
+ className: `vjs-volume-control vjs-control ${orientationClass}`
+ });
+ }
+
+ /**
+ * Handle `mousedown` or `touchstart` events on the `VolumeControl`.
+ *
+ * @param {Event} event
+ * `mousedown` or `touchstart` event that triggered this function
+ *
+ * @listens mousedown
+ * @listens touchstart
+ */
+ handleMouseDown(event) {
+ const doc = this.el_.ownerDocument;
+
+ this.on(doc, 'mousemove', this.throttledHandleMouseMove);
+ this.on(doc, 'touchmove', this.throttledHandleMouseMove);
+ this.on(doc, 'mouseup', this.handleMouseUpHandler_);
+ this.on(doc, 'touchend', this.handleMouseUpHandler_);
+ }
+
+ /**
+ * Handle `mouseup` or `touchend` events on the `VolumeControl`.
+ *
+ * @param {Event} event
+ * `mouseup` or `touchend` event that triggered this function.
+ *
+ * @listens touchend
+ * @listens mouseup
+ */
+ handleMouseUp(event) {
+ const doc = this.el_.ownerDocument;
+
+ this.off(doc, 'mousemove', this.throttledHandleMouseMove);
+ this.off(doc, 'touchmove', this.throttledHandleMouseMove);
+ this.off(doc, 'mouseup', this.handleMouseUpHandler_);
+ this.off(doc, 'touchend', this.handleMouseUpHandler_);
+ }
+
+ /**
+ * Handle `mousedown` or `touchstart` events on the `VolumeControl`.
+ *
+ * @param {Event} event
+ * `mousedown` or `touchstart` event that triggered this function
+ *
+ * @listens mousedown
+ * @listens touchstart
+ */
+ handleMouseMove(event) {
+ this.volumeBar.handleMouseMove(event);
+ }
+}
+
+/**
+ * Default options for the `VolumeControl`
+ *
+ * @type {Object}
+ * @private
+ */
+VolumeControl.prototype.options_ = {
+ children: [
+ 'volumeBar'
+ ]
+};
+
+Component.registerComponent('VolumeControl', VolumeControl);
+export default VolumeControl;
diff --git a/javascript/videojs/src/js/control-bar/volume-control/volume-level-tooltip.js b/javascript/videojs/src/js/control-bar/volume-control/volume-level-tooltip.js
new file mode 100644
index 0000000..9df9843
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/volume-control/volume-level-tooltip.js
@@ -0,0 +1,135 @@
+/**
+ * @file volume-level-tooltip.js
+ */
+import Component from '../../component';
+import * as Dom from '../../utils/dom.js';
+import * as Fn from '../../utils/fn.js';
+
+/** @import Player from '../../player' */
+
+/**
+ * Volume level tooltips display a volume above or side by side the volume bar.
+ *
+ * @extends Component
+ */
+class VolumeLevelTooltip extends Component {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The {@link Player} that this class should be attached to.
+ *
+ * @param {Object} [options]
+ * The key/value store of player options.
+ */
+ constructor(player, options) {
+ super(player, options);
+ this.update = Fn.throttle(Fn.bind_(this, this.update), Fn.UPDATE_REFRESH_INTERVAL);
+ }
+
+ /**
+ * Create the volume tooltip DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ return super.createEl('div', {
+ className: 'vjs-volume-tooltip'
+ }, {
+ 'aria-hidden': 'true'
+ });
+ }
+
+ /**
+ * Updates the position of the tooltip relative to the `VolumeBar` and
+ * its content text.
+ *
+ * @param {Object} rangeBarRect
+ * The `ClientRect` for the {@link VolumeBar} element.
+ *
+ * @param {number} rangeBarPoint
+ * A number from 0 to 1, representing a horizontal/vertical reference point
+ * from the left edge of the {@link VolumeBar}
+ *
+ * @param {boolean} vertical
+ * Referees to the Volume control position
+ * in the control bar{@link VolumeControl}
+ *
+ */
+ update(rangeBarRect, rangeBarPoint, vertical, content) {
+ if (!vertical) {
+ const tooltipRect = Dom.getBoundingClientRect(this.el_);
+ const playerRect = Dom.getBoundingClientRect(this.player_.el());
+ const volumeBarPointPx = rangeBarRect.width * rangeBarPoint;
+
+ if (!playerRect || !tooltipRect) {
+ return;
+ }
+
+ const spaceLeftOfPoint = (rangeBarRect.left - playerRect.left) + volumeBarPointPx;
+ const spaceRightOfPoint = (rangeBarRect.width - volumeBarPointPx) +
+ (playerRect.right - rangeBarRect.right);
+ let pullTooltipBy = tooltipRect.width / 2;
+
+ if (spaceLeftOfPoint < pullTooltipBy) {
+ pullTooltipBy += pullTooltipBy - spaceLeftOfPoint;
+ } else if (spaceRightOfPoint < pullTooltipBy) {
+ pullTooltipBy = spaceRightOfPoint;
+ }
+
+ if (pullTooltipBy < 0) {
+ pullTooltipBy = 0;
+ } else if (pullTooltipBy > tooltipRect.width) {
+ pullTooltipBy = tooltipRect.width;
+ }
+
+ this.el_.style.right = `-${pullTooltipBy}px`;
+ }
+ this.write(`${content}%`);
+ }
+
+ /**
+ * Write the volume to the tooltip DOM element.
+ *
+ * @param {string} content
+ * The formatted volume for the tooltip.
+ */
+ write(content) {
+ Dom.textContent(this.el_, content);
+ }
+
+ /**
+ * Updates the position of the volume tooltip relative to the `VolumeBar`.
+ *
+ * @param {Object} rangeBarRect
+ * The `ClientRect` for the {@link VolumeBar} element.
+ *
+ * @param {number} rangeBarPoint
+ * A number from 0 to 1, representing a horizontal/vertical reference point
+ * from the left edge of the {@link VolumeBar}
+ *
+ * @param {boolean} vertical
+ * Referees to the Volume control position
+ * in the control bar{@link VolumeControl}
+ *
+ * @param {number} volume
+ * The volume level to update the tooltip to
+ *
+ * @param {Function} cb
+ * A function that will be called during the request animation frame
+ * for tooltips that need to do additional animations from the default
+ */
+ updateVolume(rangeBarRect, rangeBarPoint, vertical, volume, cb) {
+ this.requestNamedAnimationFrame('VolumeLevelTooltip#updateVolume', () => {
+ this.update(rangeBarRect, rangeBarPoint, vertical, volume.toFixed(0));
+ if (cb) {
+ cb();
+ }
+ });
+ }
+}
+
+Component.registerComponent('VolumeLevelTooltip', VolumeLevelTooltip);
+export default VolumeLevelTooltip;
diff --git a/javascript/videojs/src/js/control-bar/volume-control/volume-level.js b/javascript/videojs/src/js/control-bar/volume-control/volume-level.js
new file mode 100644
index 0000000..f5de046
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/volume-control/volume-level.js
@@ -0,0 +1,36 @@
+/**
+ * @file volume-level.js
+ */
+import Component from '../../component.js';
+
+/**
+ * Shows volume level
+ *
+ * @extends Component
+ */
+class VolumeLevel extends Component {
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ const el = super.createEl('div', {
+ className: 'vjs-volume-level'
+ });
+
+ this.setIcon('circle', el);
+
+ el.appendChild(super.createEl('span', {
+ className: 'vjs-control-text'
+ }));
+
+ return el;
+ }
+
+}
+
+Component.registerComponent('VolumeLevel', VolumeLevel);
+export default VolumeLevel;
diff --git a/javascript/videojs/src/js/control-bar/volume-panel.js b/javascript/videojs/src/js/control-bar/volume-panel.js
new file mode 100644
index 0000000..209b493
--- /dev/null
+++ b/javascript/videojs/src/js/control-bar/volume-panel.js
@@ -0,0 +1,207 @@
+/**
+ * @file volume-control.js
+ */
+import Component from '../component.js';
+import {isPlain} from '../utils/obj';
+import * as Events from '../utils/events.js';
+import document from 'global/document';
+
+/** @import Player from './player' */
+
+// Required children
+import './volume-control/volume-control.js';
+import './mute-toggle.js';
+
+/**
+ * A Component to contain the MuteToggle and VolumeControl so that
+ * they can work together.
+ *
+ * @extends Component
+ */
+class VolumePanel extends Component {
+
+ /**
+ * Creates an instance of this class.
+ *
+ * @param {Player} player
+ * The `Player` that this class should be attached to.
+ *
+ * @param {Object} [options={}]
+ * The key/value store of player options.
+ */
+ constructor(player, options = {}) {
+ if (typeof options.inline !== 'undefined') {
+ options.inline = options.inline;
+ } else {
+ options.inline = true;
+ }
+
+ // pass the inline option down to the VolumeControl as vertical if
+ // the VolumeControl is on.
+ if (typeof options.volumeControl === 'undefined' || isPlain(options.volumeControl)) {
+ options.volumeControl = options.volumeControl || {};
+ options.volumeControl.vertical = !options.inline;
+ }
+
+ super(player, options);
+
+ // this handler is used by mouse handler methods below
+ this.handleKeyPressHandler_ = (e) => this.handleKeyPress(e);
+
+ this.on(player, ['loadstart'], (e) => this.volumePanelState_(e));
+ this.on(this.muteToggle, 'keyup', (e) => this.handleKeyPress(e));
+ this.on(this.volumeControl, 'keyup', (e) => this.handleVolumeControlKeyUp(e));
+ this.on('keydown', (e) => this.handleKeyPress(e));
+ this.on('mouseover', (e) => this.handleMouseOver(e));
+ this.on('mouseout', (e) => this.handleMouseOut(e));
+
+ // while the slider is active (the mouse has been pressed down and
+ // is dragging) we do not want to hide the VolumeBar
+ this.on(this.volumeControl, ['slideractive'], this.sliderActive_);
+
+ this.on(this.volumeControl, ['sliderinactive'], this.sliderInactive_);
+ }
+
+ /**
+ * Add vjs-slider-active class to the VolumePanel
+ *
+ * @listens VolumeControl#slideractive
+ * @private
+ */
+ sliderActive_() {
+ this.addClass('vjs-slider-active');
+ }
+
+ /**
+ * Removes vjs-slider-active class to the VolumePanel
+ *
+ * @listens VolumeControl#sliderinactive
+ * @private
+ */
+ sliderInactive_() {
+ this.removeClass('vjs-slider-active');
+ }
+
+ /**
+ * Adds vjs-hidden or vjs-mute-toggle-only to the VolumePanel
+ * depending on MuteToggle and VolumeControl state
+ *
+ * @listens Player#loadstart
+ * @private
+ */
+ volumePanelState_() {
+ // hide volume panel if neither volume control or mute toggle
+ // are displayed
+ if (this.volumeControl.hasClass('vjs-hidden') && this.muteToggle.hasClass('vjs-hidden')) {
+ this.addClass('vjs-hidden');
+ }
+
+ // if only mute toggle is visible we don't want
+ // volume panel expanding when hovered or active
+ if (this.volumeControl.hasClass('vjs-hidden') && !this.muteToggle.hasClass('vjs-hidden')) {
+ this.addClass('vjs-mute-toggle-only');
+ }
+ }
+
+ /**
+ * Create the `Component`'s DOM element
+ *
+ * @return {Element}
+ * The element that was created.
+ */
+ createEl() {
+ let orientationClass = 'vjs-volume-panel-horizontal';
+
+ if (!this.options_.inline) {
+ orientationClass = 'vjs-volume-panel-vertical';
+ }
+
+ return super.createEl('div', {
+ className: `vjs-volume-panel vjs-control ${orientationClass}`
+ });
+ }
+
+ /**
+ * Dispose of the `volume-panel` and all child components.
+ */
+ dispose() {
+ this.handleMouseOut();
+ super.dispose();
+ }
+
+ /**
+ * Handles `keyup` events on the `VolumeControl`, looking for ESC, which closes
+ * the volume panel and sets focus on `MuteToggle`.
+ *
+ * @param {Event} event
+ * The `keyup` event that caused this function to be called.
+ *
+ * @listens keyup
+ */
+ handleVolumeControlKeyUp(event) {
+ if (event.key === 'Escape') {
+ this.muteToggle.focus();
+ }
+ }
+
+ /**
+ * This gets called when a `VolumePanel` gains hover via a `mouseover` event.
+ * Turns on listening for `mouseover` event. When they happen it
+ * calls `this.handleMouseOver`.
+ *
+ * @param {Event} event
+ * The `mouseover` event that caused this function to be called.
+ *
+ * @listens mouseover
+ */
+ handleMouseOver(event) {
+ this.addClass('vjs-hover');
+ Events.on(document, 'keyup', this.handleKeyPressHandler_);
+ }
+
+ /**
+ * This gets called when a `VolumePanel` gains hover via a `mouseout` event.
+ * Turns on listening for `mouseout` event. When they happen it
+ * calls `this.handleMouseOut`.
+ *
+ * @param {Event} event
+ * The `mouseout` event that caused this function to be called.
+ *
+ * @listens mouseout
+ */
+ handleMouseOut(event) {
+ this.removeClass('vjs-hover');
+ Events.off(document, 'keyup', this.handleKeyPressHandler_);
+ }
+
+ /**
+ * Handles `keyup` event on the document or `keydown` event on the `VolumePanel`,
+ * looking for ESC, which hides the `VolumeControl`.
+ *
+ * @param {Event} event
+ * The keypress that triggered this event.
+ *
+ * @listens keydown | keyup
+ */
+ handleKeyPress(event) {
+ if (event.key === 'Escape') {
+ this.handleMouseOut();
+ }
+ }
+}
+
+/**
+ * Default options for the `VolumeControl`
+ *
+ * @type {Object}
+ * @private
+ */
+VolumePanel.prototype.options_ = {
+ children: [
+ 'muteToggle',
+ 'volumeControl'
+ ]
+};
+
+Component.registerComponent('VolumePanel', VolumePanel);
+export default VolumePanel;