diff options
Diffstat (limited to 'javascript/videojs/src/js/control-bar')
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; |
