/**
* @license
* Copyright 2016 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
goog.provide('shaka.ui.Controls');
goog.require('goog.asserts');
goog.require('shaka.ui.Constants');
goog.require('shaka.ui.Enums');
goog.require('shaka.ui.Locales');
goog.require('shaka.ui.Localization');
goog.require('shaka.ui.Utils');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.Timer');
/**
* A container for custom video controls.
* @param {!shaka.Player} player
* @param {!HTMLElement} videoContainer
* @param {!HTMLMediaElement} video
* @param {shaka.extern.UIConfiguration} config
* @constructor
* @struct
* @implements {shaka.util.IDestroyable}
* @extends {shaka.util.FakeEventTarget}
* @export
*/
shaka.ui.Controls = function(player, videoContainer, video, config) {
shaka.util.FakeEventTarget.call(this);
/** @private {boolean} */
this.enabled_ = true;
/** @private {boolean} */
this.overrideCssShowControls_ = false;
/** shaka.extern.UIConfiguration */
this.config_ = config;
/** @private {!shaka.cast.CastProxy} */
this.castProxy_ = new shaka.cast.CastProxy(
video, player, this.config_.castReceiverAppId);
/** @private {boolean} */
this.castAllowed_ = true;
/** @private {!HTMLMediaElement} */
this.video_ = this.castProxy_.getVideo();
/** @private {!HTMLMediaElement} */
this.localVideo_ = video;
/** @private {!shaka.Player} */
this.player_ = this.castProxy_.getPlayer();
/** @private {!HTMLElement} */
this.videoContainer_ = videoContainer;
/** @private {boolean} */
this.isSeeking_ = false;
/**
* This timer is used to introduce a delay between the user scrubbing across
* the seek bar and the seek being sent to the player.
*
* @private {shaka.util.Timer}
*/
this.seekTimer_ = new shaka.util.Timer(() => {
goog.asserts.assert(this.seekBar_ != null, 'Seekbar should not be null!');
this.video_.currentTime = parseFloat(this.seekBar_.value);
});
/**
* This timer is used to detect when the user has stopped moving the mouse
* and we should fade out the ui.
*
* @private {shaka.util.Timer}
*/
this.mouseStillTimer_ = new shaka.util.Timer(() => {
this.onMouseStill_();
});
/**
* This timer will be used to hide all settings menus. When the timer ticks
* it will force all controls to invisible.
*
* Rather than calling the callback directly, |Controls| will always call it
* through the timer to avoid conflicts.
*
* @private {shaka.util.Timer}
*/
this.hideSettingsMenusTimer_ = new shaka.util.Timer(() => {
/** type {function(!HTMLElement)} */
const hide = (control) => {
shaka.ui.Controls.setDisplay(control, /* visible= */ false);
};
for (const menu of this.settingsMenus_) {
hide(/** @type {!HTMLElement} */ (menu));
}
});
/**
* This timer is used to regularly update the time and seek range elements
* so that we are communicating the current state as accurately as possibly.
*
* Unlike the other timers, this timer does not "own" the callback because
* this timer is acting like a heartbeat.
*
* @private {shaka.util.Timer}
*/
this.timeAndSeekRangeTimer_ = new shaka.util.Timer(() => {
this.updateTimeAndSeekRange_();
});
/** @private {?number} */
this.lastTouchEventTime_ = null;
/** @private {!Array.<!shaka.extern.IUIElement>} */
this.elements_ = [];
/** @private {shaka.ui.Localization} */
this.localization_ = shaka.ui.Controls.createLocalization_();
this.createDOM_();
this.updateLocalizedStrings_();
/** @private {shaka.util.EventManager} */
this.eventManager_ = new shaka.util.EventManager();
this.addEventListeners_();
/**
* The pressed keys set is used to record which keys are currently pressed
* down, so we can know what keys are pressed at the same time.
* Used by the focusInsideOverflowMenu_() function.
* @private {!Set.<number>}
*/
this.pressedKeys_ = new Set();
// We might've missed a caststatuschanged event from the proxy between
// the controls creation and initializing. Run onCastStatusChange_()
// to ensure we have the casting state right.
this.onCastStatusChange_(null);
// Start this timer after we are finished initializing everything,
this.timeAndSeekRangeTimer_.start(/* seconds= */ 0.125,
/* repeating= */ true);
};
goog.inherits(shaka.ui.Controls, shaka.util.FakeEventTarget);
/** @private {!Map.<string, !shaka.extern.IUIElement.Factory>} */
shaka.ui.Controls.elementNamesToFactories_ = new Map();
/**
* @override
* @export
*/
shaka.ui.Controls.prototype.destroy = function() {
if (this.eventManager_) {
this.eventManager_.release();
this.eventManager_ = null;
}
if (this.seekTimer_) {
this.seekTimer_.stop();
this.seekTimer_ = null;
}
if (this.mouseStillTimer_) {
this.mouseStillTimer_.stop();
this.mouseStillTimer_ = null;
}
if (this.hideSettingsMenusTimer_) {
this.hideSettingsMenusTimer_.stop();
this.hideSettingsMenusTimer_ = null;
}
if (this.timeAndSeekRangeTimer_) {
this.timeAndSeekRangeTimer_.stop();
this.timeAndSeekRangeTimer_ = null;
}
this.localization_ = null;
this.pressedKeys_.clear();
return Promise.resolve();
};
/**
* @param {string} name
* @param {!shaka.extern.IUIElement.Factory} factory
* @export
*/
shaka.ui.Controls.registerElement = function(name, factory) {
shaka.ui.Controls.elementNamesToFactories_.set(name, factory);
};
/**
* This allows the application to inhibit casting.
*
* @param {boolean} allow
* @export
*/
shaka.ui.Controls.prototype.allowCast = function(allow) {
this.castAllowed_ = allow;
this.onCastStatusChange_(null);
};
/**
* Used by the application to notify the controls that a load operation is
* complete. This allows the controls to recalculate play/paused state, which
* is important for platforms like Android where autoplay is disabled.
* @export
*/
shaka.ui.Controls.prototype.loadComplete = function() {
// If we are on Android or if autoplay is false, video.paused should be true.
// Otherwise, video.paused is false and the content is autoplaying.
this.onPlayStateChange_();
};
/**
* Enable or disable the custom controls. Enabling disables native
* browser controls.
*
* @param {boolean} enabled
* @export
*/
shaka.ui.Controls.prototype.setEnabledShakaControls = function(enabled) {
this.enabled_ = enabled;
if (enabled) {
shaka.ui.Controls.setDisplay(
this.controlsButtonPanel_.parentElement, true);
// If we're hiding native controls, make sure the video element itself is
// not tab-navigable. Our custom controls will still be tab-navigable.
this.video_.tabIndex = -1;
this.video_.controls = false;
} else {
shaka.ui.Controls.setDisplay(
this.controlsButtonPanel_.parentElement, false);
}
// The effects of play state changes are inhibited while showing native
// browser controls. Recalculate that state now.
this.onPlayStateChange_();
};
/**
* Enable or disable native browser controls. Enabling disables shaka
* controls.
*
* @param {boolean} enabled
* @export
*/
shaka.ui.Controls.prototype.setEnabledNativeControls = function(enabled) {
// If we enable the native controls, the element must be tab-navigable.
// If we disable the native controls, we want to make sure that the video
// element itself is not tab-navigable, so that the element is skipped over
// when tabbing through the page.
this.video_.controls = enabled;
this.video_.tabIndex = enabled ? 0 : -1;
if (enabled) {
this.setEnabledShakaControls(false);
}
};
/**
* @export
* @return {!shaka.cast.CastProxy}
*/
shaka.ui.Controls.prototype.getCastProxy = function() {
return this.castProxy_;
};
/**
* @return {shaka.ui.Localization}
* @export
*/
shaka.ui.Controls.prototype.getLocalization = function() {
return this.localization_;
};
/**
* @return {!HTMLElement}
* @export
*/
shaka.ui.Controls.prototype.getVideoContainer = function() {
return this.videoContainer_;
};
/**
* @return {!HTMLMediaElement}
* @export
*/
shaka.ui.Controls.prototype.getVideo = function() {
return this.video_;
};
/**
* @return {!HTMLMediaElement}
* @export
*/
shaka.ui.Controls.prototype.getLocalVideo = function() {
return this.localVideo_;
};
/**
* @return {!shaka.Player}
* @export
*/
shaka.ui.Controls.prototype.getPlayer = function() {
return this.player_;
};
/**
* @return {!HTMLElement}
* @export
*/
shaka.ui.Controls.prototype.getControlsContainer = function() {
return this.controlsContainer_;
};
/**
* @return {!shaka.extern.UIConfiguration}
* @export
*/
shaka.ui.Controls.prototype.getConfig = function() {
return this.config_;
};
/**
* @return {boolean}
* @export
*/
shaka.ui.Controls.prototype.isSeeking = function() {
return this.isSeeking_;
};
/**
* @return {boolean}
* @export
*/
shaka.ui.Controls.prototype.isCastAllowed = function() {
return this.castAllowed_;
};
/**
* @return {number}
* @export
*/
shaka.ui.Controls.prototype.getDisplayTime = function() {
const displayTime = this.isSeeking_ ?
Number(this.seekBar_.value) :
Number(this.video_.currentTime);
return displayTime;
};
/**
* @param {?number} time
* @export
*/
shaka.ui.Controls.prototype.setLastTouchEventTime = function(time) {
this.lastTouchEventTime_ = time;
};
/**
* Depending on the value of display, sets/removes css class of element to
* either display it or hide.
*
* @param {Element} element
* @param {boolean} display
* @export
*/
shaka.ui.Controls.setDisplay = function(element, display) {
if (!element) return;
if (display) {
element.classList.add('shaka-displayed');
// Removing a non-existent class doesn't throw, so, even if
// the element is not hidden, this should be fine. Same for displayed
// below.
element.classList.remove('shaka-hidden');
} else {
element.classList.add('shaka-hidden');
element.classList.remove('shaka-displayed');
}
};
/**
* Display controls even if css says overwise.
* Normally, controls opacity is controled by CSS, but there are
* a few special cases where we want controls to be displayed no
* matter what. For example, if the focus is on one of the settings
* menus. This method is called when we want to signal an exception
* to normal CSS opacity rules and keep the controls visible.
*
* @export
*/
shaka.ui.Controls.prototype.overrideCssShowControls = function() {
this.overrideCssShowControls_ = true;
};
/**
* @return {boolean}
* @export
*/
shaka.ui.Controls.prototype.anySettingsMenusAreOpen = function() {
return this.settingsMenus_.some(
(menu) => menu.classList.contains('shaka-displayed'));
};
/**
* @export
*/
shaka.ui.Controls.prototype.hideSettingsMenus = function() {
this.hideSettingsMenusTimer_.tick();
};
/**
* @private
*/
shaka.ui.Controls.prototype.updateLocalizedStrings_ = function() {
const LocIds = shaka.ui.Locales.Ids;
if (this.seekBar_) {
this.seekBar_.setAttribute(shaka.ui.Constants.ARIA_LABEL,
this.localization_.resolve(LocIds.ARIA_LABEL_SEEK));
}
// Localize state-dependant labels
const makePlayNotPause = this.video_.paused && !this.isSeeking_;
const playButtonAriaLabelId = makePlayNotPause ? LocIds.ARIA_LABEL_PLAY :
LocIds.ARIA_LABEL_PAUSE;
this.playButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL,
this.localization_.resolve(playButtonAriaLabelId));
};
/**
* @private
*/
shaka.ui.Controls.prototype.initOptionalElementsToNull_ = function() {
// TODO: encapsulate/abstract range inputs and their containers
/** @private {HTMLElement} */
this.seekBarContainer_ = null;
/** @private {HTMLInputElement} */
this.seekBar_ = null;
};
/**
* @private
*/
shaka.ui.Controls.prototype.createDOM_ = function() {
this.initOptionalElementsToNull_();
this.videoContainer_.classList.add('shaka-video-container');
this.video_.classList.add('shaka-video');
this.addControlsContainer_();
this.addPlayButton_();
this.addBufferingSpinner_();
this.addControlsButtonPanel_();
// Seek bar
if (this.config_.addSeekBar) {
this.addSeekBar_();
}
/** @private {!Array.<!Element>} */
this.settingsMenus_ = Array.from(
this.videoContainer_.getElementsByClassName('shaka-settings-menu'));
// Settings menus need to be positioned lower, if the seekbar is absent.
if (!this.seekBar_) {
for (let menu of this.settingsMenus_) {
menu.classList.add('shaka-low-position');
}
}
};
/**
* @private
*/
shaka.ui.Controls.prototype.addControlsContainer_ = function() {
/** @private {!HTMLElement} */
this.controlsContainer_ = shaka.ui.Utils.createHTMLElement('div');
this.controlsContainer_.classList.add('shaka-controls-container');
this.videoContainer_.appendChild(this.controlsContainer_);
};
/**
* @private
*/
shaka.ui.Controls.prototype.addPlayButton_ = function() {
/** @private {!HTMLElement} */
this.playButtonContainer_ = shaka.ui.Utils.createHTMLElement('div');
this.playButtonContainer_.classList.add('shaka-play-button-container');
this.controlsContainer_.appendChild(this.playButtonContainer_);
/** @private {!HTMLElement} */
this.playButton_ = shaka.ui.Utils.createHTMLElement('button');
this.playButton_.classList.add('shaka-play-button');
this.playButton_.setAttribute('icon', 'play');
this.playButtonContainer_.appendChild(this.playButton_);
};
/**
* @private
*/
shaka.ui.Controls.prototype.addBufferingSpinner_ = function() {
goog.asserts.assert(this.playButtonContainer_,
'Must have play button container before spinner!');
// Svg elements have to be created with the svg xml namespace.
const xmlns = 'http://www.w3.org/2000/svg';
/** @private {!HTMLElement} */
this.bufferingSpinner_ =
/** @type {!HTMLElement} */(document.createElementNS(xmlns, 'svg'));
// NOTE: SVG elements do not have a classList on IE, so use setAttribute.
this.bufferingSpinner_.setAttribute('class', 'shaka-spinner-svg');
this.bufferingSpinner_.setAttribute('viewBox', '0 0 30 30');
this.playButton_.appendChild(this.bufferingSpinner_);
// These coordinates are relative to the SVG viewBox above. This is distinct
// from the actual display size in the page, since the "S" is for "Scalable."
// The radius of 14.5 is so that the edges of the 1-px-wide stroke will touch
// the edges of the viewBox.
const spinnerCircle = document.createElementNS(xmlns, 'circle');
spinnerCircle.setAttribute('class', 'shaka-spinner-path');
spinnerCircle.setAttribute('cx', '15');
spinnerCircle.setAttribute('cy', '15');
spinnerCircle.setAttribute('r', '14.5');
spinnerCircle.setAttribute('fill', 'none');
spinnerCircle.setAttribute('stroke-width', '1');
spinnerCircle.setAttribute('stroke-miterlimit', '10');
this.bufferingSpinner_.appendChild(spinnerCircle);
};
/**
* @private
*/
shaka.ui.Controls.prototype.addControlsButtonPanel_ = function() {
/** @private {!HTMLElement} */
this.controlsButtonPanel_ = shaka.ui.Utils.createHTMLElement('div');
this.controlsButtonPanel_.classList.add('shaka-controls-button-panel');
this.controlsButtonPanel_.classList.add('shaka-no-propagation');
this.controlsButtonPanel_.classList.add('shaka-show-controls-on-mouse-over');
this.controlsContainer_.appendChild(this.controlsButtonPanel_);
// Create the elements specified by controlPanelElements
for (let i = 0; i < this.config_.controlPanelElements.length; i++) {
const name = this.config_.controlPanelElements[i];
if (shaka.ui.Controls.elementNamesToFactories_.get(name)) {
if (shaka.ui.Controls.controlPanelElements_.indexOf(name) == -1) {
// Not a control panel element, skip
shaka.log.warning('Element is not part of control panel ' +
'elements and will be skipped', name);
continue;
}
const factory = shaka.ui.Controls.elementNamesToFactories_.get(name);
this.elements_.push(factory.create(this.controlsButtonPanel_, this));
}
}
};
/**
* @private
*/
shaka.ui.Controls.prototype.addEventListeners_ = function() {
// TODO: Convert adding event listers to the "() =>" form.
this.player_.addEventListener(
'buffering', this.onBufferingStateChange_.bind(this));
// Listen for key down events to detect tab and enable outline
// for focused elements.
this.eventManager_.listen(window, 'keydown', this.onKeyDown_.bind(this));
this.video_.addEventListener(
'play', this.onPlayStateChange_.bind(this));
this.video_.addEventListener(
'pause', this.onPlayStateChange_.bind(this));
// Since videos go into a paused state at the end, Chrome and Edge both fire
// the 'pause' event when a video ends. IE 11 only fires the 'ended' event.
this.video_.addEventListener(
'ended', this.onPlayStateChange_.bind(this));
if (this.seekBar_) {
this.seekBar_.addEventListener(
'mousedown', this.onSeekStart_.bind(this));
this.seekBar_.addEventListener(
'touchstart', this.onSeekStart_.bind(this), {passive: true});
this.seekBar_.addEventListener(
'input', this.onSeekInput_.bind(this));
this.seekBar_.addEventListener(
'touchend', this.onSeekEnd_.bind(this));
this.seekBar_.addEventListener(
'mouseup', this.onSeekEnd_.bind(this));
}
this.controlsContainer_.addEventListener(
'touchstart', this.onContainerTouch_.bind(this), {passive: false});
this.controlsContainer_.addEventListener(
'click', this.onContainerClick_.bind(this));
// Elements that should not propagate clicks (controls panel, menus)
const noPropagationElements = this.videoContainer_.getElementsByClassName(
'shaka-no-propagation');
for (let i = 0; i < noPropagationElements.length; i++) {
let element = noPropagationElements[i];
element.addEventListener(
'click', function(event) { event.stopPropagation(); });
}
// Keep showing controls if one of those elements is hovered
let showControlsElements = this.videoContainer_.getElementsByClassName(
'shaka-show-controls-on-mouse-over');
for (let i = 0; i < showControlsElements.length; i++) {
let element = showControlsElements[i];
element.addEventListener(
'mouseover', () => {
this.overrideCssShowControls_ = true;
});
element.addEventListener(
'mouseleave', () => {
this.overrideCssShowControls_ = false;
});
}
this.videoContainer_.addEventListener(
'mousemove', this.onMouseMove_.bind(this));
this.videoContainer_.addEventListener(
'touchmove', this.onMouseMove_.bind(this), {passive: true});
this.videoContainer_.addEventListener(
'touchend', this.onMouseMove_.bind(this), {passive: true});
this.videoContainer_.addEventListener(
'mouseleave', this.onMouseLeave_.bind(this));
// Overflow menus are supposed to hide once you click elsewhere
// on the video element. The code in onContainerClick_ ensures that.
// However, clicks on controls panel don't propagate to the container,
// so we have to explicitely hide the menus onclick here.
this.controlsButtonPanel_.addEventListener('click', () => {
this.hideSettingsMenusTimer_.tick();
});
this.castProxy_.addEventListener(
'caststatuschanged', (e) => {
this.onCastStatusChange_(e);
});
this.videoContainer_.addEventListener('keyup', this.onKeyUp_.bind(this));
this.localization_.addEventListener(
shaka.ui.Localization.LOCALE_UPDATED,
(e) => this.updateLocalizedStrings_());
this.localization_.addEventListener(
shaka.ui.Localization.LOCALE_CHANGED,
(e) => this.updateLocalizedStrings_());
};
/**
* @private
*/
shaka.ui.Controls.prototype.addSeekBar_ = function() {
// This container is to support IE 11. See detailed notes in
// less/range_elements.less for a complete explanation.
// TODO: Factor this into a range-element component.
this.seekBarContainer_ = shaka.ui.Utils.createHTMLElement('div');
this.seekBarContainer_.classList.add('shaka-seek-bar-container');
this.seekBar_ =
/** @type {!HTMLInputElement} */ (document.createElement('input'));
this.seekBar_.classList.add('shaka-seek-bar');
this.seekBar_.type = 'range';
// NOTE: step=any causes keyboard nav problems on IE 11.
this.seekBar_.setAttribute('step', 'any');
this.seekBar_.setAttribute('min', '0');
this.seekBar_.setAttribute('max', '1');
this.seekBar_.value = '0';
this.seekBar_.classList.add('shaka-no-propagation');
this.seekBar_.classList.add('shaka-show-controls-on-mouse-over');
this.seekBarContainer_.appendChild(this.seekBar_);
this.controlsContainer_.appendChild(this.seekBarContainer_);
};
/**
* Hiding the cursor when the mouse stops moving seems to be the only decent UX
* in fullscreen mode. Since we can't use pure CSS for that, we use events both
* in and out of fullscreen mode.
* Showing the control bar when a key is pressed, and hiding it after some time.
* @param {!Event} event
* @private
*/
shaka.ui.Controls.prototype.onMouseMove_ = function(event) {
// Disable blue outline for focused elements for mouse navigation.
if (event.type == 'mousemove') {
this.controlsContainer_.classList.remove('shaka-keyboard-navigation');
}
if (event.type == 'touchstart' || event.type == 'touchmove' ||
event.type == 'touchend' || event.type == 'keyup') {
this.lastTouchEventTime_ = Date.now();
} else if (this.lastTouchEventTime_ + 1000 < Date.now()) {
// It has been a while since the last touch event, this is probably a real
// mouse moving, so treat it like a mouse.
this.lastTouchEventTime_ = null;
}
// When there is a touch, we can get a 'mousemove' event after touch events.
// This should be treated as part of the touch, which has already been handled
if (this.lastTouchEventTime_ && event.type == 'mousemove') {
return;
}
// Use the cursor specified in the CSS file.
this.videoContainer_.style.cursor = '';
// Make sure we are not about to hide the settings menus and then force them
// open.
this.hideSettingsMenusTimer_.stop();
this.setControlsOpacity_(shaka.ui.Enums.Opacity.OPAQUE);
this.updateTimeAndSeekRange_();
// Hide the cursor when the mouse stops moving.
// Only applies while the cursor is over the video container.
this.mouseStillTimer_.stop();
// Only start a timeout on 'touchend' or for 'mousemove' with no touch events.
if (event.type == 'touchend' ||
event.type == 'keyup'|| !this.lastTouchEventTime_) {
this.mouseStillTimer_.start(/* seconds= */ 3, /* repeating= */ false);
}
};
/** @private */
shaka.ui.Controls.prototype.onMouseLeave_ = function() {
// We sometimes get 'mouseout' events with touches. Since we can never leave
// the video element when touching, ignore.
if (this.lastTouchEventTime_) return;
// Stop the timer and invoke the callback now to hide the controls. If we
// don't, the opacity style we set in onMouseMove_ will continue to override
// the opacity in CSS and force the controls to stay visible.
this.mouseStillTimer_.tick();
};
/**
* This callback is for when we are pretty sure that the mouse has stopped
* moving (aka the mouse is still). This method should only be called via
* |mouseStillTimer_|. If this behaviour needs to be invoked directly, use
* |mouseStillTimer_.tick()|.
*
* @private
*/
shaka.ui.Controls.prototype.onMouseStill_ = function() {
// Hide the cursor. (NOTE: not supported on IE)
this.videoContainer_.style.cursor = 'none';
// Keep showing the controls if video is paused or one of the control menus
// is hovered.
if ((this.video_.paused && !this.isSeeking_) ||
this.overrideCssShowControls_) {
this.setControlsOpacity_(shaka.ui.Enums.Opacity.OPAQUE);
} else {
this.setControlsOpacity_(shaka.ui.Enums.Opacity.TRANSPARENT);
}
};
/**
* @param {!Event} event
* @private
*/
shaka.ui.Controls.prototype.onContainerTouch_ = function(event) {
if (!this.video_.duration) {
// Can't play yet. Ignore.
return;
}
if (this.isOpaque_()) {
this.lastTouchEventTime_ = Date.now();
// The controls are showing.
// Let this event continue and become a click.
} else {
// The controls are hidden, so show them.
this.onMouseMove_(event);
// Stop this event from becoming a click event.
event.preventDefault();
}
};
/**
* @param {!Event} event
* @private
*/
shaka.ui.Controls.prototype.onContainerClick_ = function(event) {
if (!this.enabled_) return;
if (this.anySettingsMenusAreOpen()) {
this.hideSettingsMenusTimer_.tick();
} else {
this.onPlayPauseClick_();
}
};
/** @private */
shaka.ui.Controls.prototype.onPlayPauseClick_ = function() {
if (!this.enabled_) return;
if (!this.video_.duration) {
// Can't play yet. Ignore.
return;
}
this.player_.cancelTrickPlay();
if (this.video_.paused) {
this.video_.play();
} else {
this.video_.pause();
}
};
/**
* @param {Event} event
* @private
*/
shaka.ui.Controls.prototype.onCastStatusChange_ = function(event) {
const isCasting = this.castProxy_.isCasting();
this.dispatchEvent(new shaka.util.FakeEvent('caststatuschanged', {
newStatus: isCasting,
}));
if (isCasting) {
this.controlsContainer_.setAttribute('casting', 'true');
} else {
this.controlsContainer_.removeAttribute('casting');
}
};
/** @private */
shaka.ui.Controls.prototype.onPlayStateChange_ = function() {
// On IE 11, a video may end without going into a paused state. To correct
// both the UI state and the state of the video tag itself, we explicitly
// pause the video if that happens.
if (this.video_.ended && !this.video_.paused) {
this.video_.pause();
}
// Video is paused during seek, so don't show the play arrow while seeking:
if (this.enabled_ && this.video_.paused && !this.isSeeking_) {
this.playButton_.setAttribute('icon', 'play');
this.playButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL,
this.localization_.resolve(shaka.ui.Locales.Ids.ARIA_LABEL_PLAY));
} else {
this.playButton_.setAttribute('icon', 'pause');
this.playButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL,
this.localization_.resolve(shaka.ui.Locales.Ids.ARIA_LABEL_PAUSE));
}
};
/** @private */
shaka.ui.Controls.prototype.onSeekStart_ = function() {
if (!this.enabled_) return;
this.isSeeking_ = true;
this.video_.pause();
};
/** @private */
shaka.ui.Controls.prototype.onSeekInput_ = function() {
if (!this.enabled_) return;
if (!this.video_.duration) {
// Can't seek yet. Ignore.
return;
}
// Update the UI right away.
this.updateTimeAndSeekRange_();
// We want to wait until the user has stopped moving the seek bar for a
// little bit to avoid the number of times we ask the player to seek.
//
// To do this, we will start a timer that will fire in a little bit, but if
// we see another seek bar change, we will cancel that timer and re-start it.
//
// Calling |start| on an already pending timer will cancel the old request
// and start the new one.
this.seekTimer_.start(/* seconds= */ 0.125, /* repeating */ false);
};
/** @private */
shaka.ui.Controls.prototype.onSeekEnd_ = function() {
if (!this.enabled_) return;
// They just let go of the seek bar, so cancel the timer and manually
// call the event so that we can respond immediately.
this.seekTimer_.tick();
this.isSeeking_ = false;
this.video_.play();
};
/**
* Support controls with keyboard inputs.
* @param {!Event} event
* @private
*/
shaka.ui.Controls.prototype.onKeyUp_ = function(event) {
let key = event.key;
let activeElement = document.activeElement;
let isVolumeBar = activeElement && activeElement.classList ?
activeElement.classList.contains('shaka-volume-bar') : false;
let isSeekBar = activeElement && activeElement.classList &&
activeElement.classList.contains('shaka-seek-bar');
// Show the control panel if it is on focus or any button is pressed.
if (this.controlsContainer_.contains(activeElement)) {
this.onMouseMove_(event);
}
// When the key is released, remove it from the pressed keys set.
this.pressedKeys_.delete(event.keyCode);
switch (key) {
case 'ArrowLeft':
// If it's not focused on the volume bar, move the seek time backward
// for 5 sec. Otherwise, the volume will be adjusted automatically.
if (!isVolumeBar) {
this.seek_(this.video_.currentTime - 5, event);
}
break;
case 'ArrowRight':
// If it's not focused on the volume bar, move the seek time forward
// for 5 sec. Otherwise, the volume will be adjusted automatically.
if (!isVolumeBar) {
this.seek_(this.video_.currentTime + 5, event);
}
break;
// Jump to the beginning of the video's seek range.
case 'Home':
this.seek_(this.player_.seekRange().start, event);
break;
// Jump to the end of the video's seek range.
case 'End':
this.seek_(this.player_.seekRange().end, event);
break;
// Pause or play by pressing space on the seek bar.
case ' ':
if (isSeekBar) {
this.onPlayPauseClick_();
}
break;
}
};
/**
* @param {!Event} event
* @private
*/
shaka.ui.Controls.prototype.onBufferingStateChange_ = function(event) {
// Using [] notation to access buffering property to work around
// a compiler error.
const isBuffering = event['buffering'];
// Don't use setDisplay_ here, since the SVG spinner doesn't have classList
// on IE.
if (isBuffering) {
this.bufferingSpinner_.setAttribute(
'class', 'shaka-spinner-svg');
} else {
this.bufferingSpinner_.setAttribute(
'class', 'shaka-spinner-svg shaka-hidden');
}
};
/**
* @return {boolean}
* @private
*/
shaka.ui.Controls.prototype.isOpaque_ = function() {
if (!this.enabled_) return false;
// TODO: refactor into a single property
// While you are casting, the UI is always opaque.
if (this.castProxy_ && this.castProxy_.isCasting()) return true;
return this.controlsContainer_.getAttribute('shown') != null;
};
/**
* Update the video's current time based on the keyboard operations.
* @param {number} currentTime
* @param {!Event} event
* @private
*/
shaka.ui.Controls.prototype.seek_ = function(currentTime, event) {
this.video_.currentTime = currentTime;
this.updateTimeAndSeekRange_();
};
/**
* Called when the seek range or current time need to be updated.
* @private
*/
shaka.ui.Controls.prototype.updateTimeAndSeekRange_ = function() {
// Suppress updates if the controls are hidden.
if (!this.isOpaque_()) {
return;
}
this.dispatchEvent(new shaka.util.FakeEvent('timeandseekrangeupdated'));
const Constants = shaka.ui.Constants;
let displayTime = this.isSeeking_ ?
Number(this.seekBar_.value) :
Number(this.video_.currentTime);
let bufferedLength = this.video_.buffered.length;
let bufferedStart = bufferedLength ? this.video_.buffered.start(0) : 0;
let bufferedEnd =
bufferedLength ? this.video_.buffered.end(bufferedLength - 1) : 0;
let seekRange = this.player_.seekRange();
let seekRangeSize = seekRange.end - seekRange.start;
if (this.seekBar_) {
this.seekBar_.min = seekRange.start;
this.seekBar_.max = seekRange.end;
}
if (this.player_.isLive()) {
// The amount of time we are behind the live edge.
let behindLive = Math.floor(seekRange.end - displayTime);
displayTime = Math.max(0, behindLive);
if (!this.isSeeking_ && this.seekBar_) {
this.seekBar_.value = seekRange.end - displayTime;
}
} else {
if (!this.isSeeking_ && this.seekBar_) {
this.seekBar_.value = displayTime;
}
}
if (this.seekBar_) {
// Hide seekbar seek window is very small.
const seekRange = this.player_.seekRange();
const seekWindow = seekRange.end - seekRange.start;
if (seekWindow < Constants.MIN_SEEK_WINDOW_TO_SHOW_SEEKBAR) {
shaka.ui.Controls.setDisplay(this.seekBarContainer_, false);
for (let menu of this.settingsMenus_) {
menu.classList.add('shaka-low-position');
}
} else {
shaka.ui.Controls.setDisplay(this.seekBarContainer_, true);
for (let menu of this.settingsMenus_) {
menu.classList.remove('shaka-low-position');
}
let gradient = ['to right'];
if (bufferedLength == 0) {
gradient.push('#000 0%');
} else {
const clampedBufferStart = Math.max(bufferedStart, seekRange.start);
const clampedBufferEnd = Math.min(bufferedEnd, seekRange.end);
const bufferStartDistance = clampedBufferStart - seekRange.start;
const bufferEndDistance = clampedBufferEnd - seekRange.start;
const playheadDistance = displayTime - seekRange.start;
// NOTE: the fallback to zero eliminates NaN.
const bufferStartFraction = (bufferStartDistance / seekRangeSize) || 0;
const bufferEndFraction = (bufferEndDistance / seekRangeSize) || 0;
const playheadFraction = (playheadDistance / seekRangeSize) || 0;
gradient.push(Constants.SEEK_BAR_BASE_COLOR + ' ' +
(bufferStartFraction * 100) + '%');
gradient.push(Constants.SEEK_BAR_PLAYED_COLOR + ' ' +
(bufferStartFraction * 100) + '%');
gradient.push(Constants.SEEK_BAR_PLAYED_COLOR + ' ' +
(playheadFraction * 100) + '%');
gradient.push(Constants.SEEK_BAR_BUFFERED_COLOR + ' ' +
(playheadFraction * 100) + '%');
gradient.push(Constants.SEEK_BAR_BUFFERED_COLOR + ' ' +
(bufferEndFraction * 100) + '%');
gradient.push(Constants.SEEK_BAR_BASE_COLOR + ' ' +
(bufferEndFraction * 100) + '%');
}
this.seekBarContainer_.style.background =
'linear-gradient(' + gradient.join(',') + ')';
}
}
};
/**
* Add behaviors for keyboard navigation.
* 1. Add blue outline for focused elements.
* 2. Allow exiting overflow settings menus by pressing Esc key.
* 3. When navigating on overflow settings menu by pressing Tab
* key or Shift+Tab keys keep the focus inside overflow menu.
*
* @param {!Event} event
* @private
*/
shaka.ui.Controls.prototype.onKeyDown_ = function(event) {
// Add the key code to the pressed keys set when it's pressed.
this.pressedKeys_.add(event.keyCode);
const anySettingsMenusAreOpen = this.anySettingsMenusAreOpen();
if (event.keyCode == shaka.ui.Constants.KEYCODE_TAB) {
// Enable blue outline for focused elements for keyboard
// navigation.
this.controlsContainer_.classList.add('shaka-keyboard-navigation');
this.eventManager_.listen(window, 'mousedown',
this.onMouseDown_.bind(this));
}
// If escape key was pressed, close any open settings menus.
if (event.keyCode == shaka.ui.Constants.KEYCODE_ESCAPE) {
this.hideSettingsMenusTimer_.tick();
}
if (anySettingsMenusAreOpen &&
this.pressedKeys_.has(shaka.ui.Constants.KEYCODE_TAB)) {
// If Tab key or Shift+Tab keys are pressed when navigating through
// an overflow settings menu, keep the focus to loop inside the
// overflow menu.
this.keepFocusInMenu_(event);
}
};
/**
* When the user is using keyboard to navigate inside the overflow settings
* menu (pressing Tab key to go forward, or pressing Shift + Tab keys to go
* backward), make sure it's focused only on the elements of the overflow
* panel.
* This is called by onKeyDown_() function, when there's a settings overflow
* menu open, and the Tab key / Shift+Tab keys are pressed.
* @param {!Event} event
* @private
*/
shaka.ui.Controls.prototype.keepFocusInMenu_ = function(event) {
const openSettingsMenus = this.settingsMenus_.filter(
(menu) => menu.classList.contains('shaka-displayed'));
const settingsMenu = openSettingsMenus[0];
if (settingsMenu.childNodes.length) {
// Get the first and the last displaying child element from the overflow
// menu.
let firstShownChild = settingsMenu.firstElementChild;
while (firstShownChild &&
firstShownChild.classList.contains('shaka-hidden')) {
firstShownChild = firstShownChild.nextElementSibling;
}
let lastShownChild = settingsMenu.lastElementChild;
while (lastShownChild &&
lastShownChild.classList.contains('shaka-hidden')) {
lastShownChild = lastShownChild.previousElementSibling;
}
const activeElement = document.activeElement;
// When only Tab key is pressed, navigate to the next elememnt.
// If it's currently focused on the last shown child element of the
// overflow menu, let the focus move to the first child element of the
// menu.
// When Tab + Shift keys are pressed at the same time, navigate to the
// previous element. If it's currently focused on the first shown child
// element of the overflow menu, let the focus move to the last child
// element of the menu.
if (this.pressedKeys_.has(shaka.ui.Constants.KEYCODE_SHIFT)) {
if (activeElement == firstShownChild) {
event.preventDefault();
lastShownChild.focus();
}
} else {
if (activeElement == lastShownChild) {
event.preventDefault();
firstShownChild.focus();
}
}
}
};
/**
* Removes class for keyboard navigation if mouse navigation
* is active.
* @private
*/
shaka.ui.Controls.prototype.onMouseDown_ = function() {
this.eventManager_.unlisten(window, 'mousedown');
this.eventManager_.listen(window, 'keydown', this.onKeyDown_.bind(this));
};
/**
* @param {!shaka.ui.Enums.Opacity} opacity
* @private
*/
shaka.ui.Controls.prototype.setControlsOpacity_ = function(opacity) {
if (opacity == shaka.ui.Enums.Opacity.OPAQUE) {
this.controlsContainer_.setAttribute('shown', 'true');
} else {
this.controlsContainer_.removeAttribute('shown');
// If there's an overflow menu open, keep it this way for a couple of
// seconds in case a user immidiately initiaites another mouse move to
// interact with the menus. If that didn't happen, go ahead and hide
// the menus.
this.hideSettingsMenusTimer_.start(/* seconds= */ 2,
/* repeating= */ false);
}
};
/**
* Create a localization instance already pre-loaded with all the locales that
* we support.
*
* @return {!shaka.ui.Localization}
* @private
*/
shaka.ui.Controls.createLocalization_ = function() {
/** @type {string} */
const fallbackLocale = 'en';
/** @type {!shaka.ui.Localization} */
const localization = new shaka.ui.Localization(fallbackLocale);
shaka.ui.Locales.apply(localization);
localization.changeLocale(navigator.languages || []);
return localization;
};
/** @private {!Array.<string>} */
shaka.ui.Controls.controlPanelElements_ = [
'time_and_duration',
'mute',
'volume',
'fullscreen',
'overflow_menu',
'rewind',
'fast_forward',
'spacer',
];