/**
* @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.provide('shaka.ui.ControlsPanel');
goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.ui.Locales');
goog.require('shaka.ui.Localization');
goog.require('shaka.ui.SeekBar');
goog.require('shaka.ui.Utils');
goog.require('shaka.util.Dom');
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 {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 {shaka.Player} */
this.localPlayer_ = player;
/** @private {!HTMLElement} */
this.videoContainer_ = videoContainer;
/** @private {shaka.ui.SeekBar} */
this.seekBar_ = null;
/** @private {boolean} */
this.isSeeking_ = false;
/** @private {!Array.<!HTMLElement>} */
this.settingsMenus_ = [];
/**
* Individual controls which, when hovered or tab-focused, will force the
* controls to be shown.
* @private {!Array.<!Element>}
*/
this.showOnHoverControls_ = [];
/** @private {boolean} */
this.recentMouseMovement_ = false;
/**
* 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 is used to delay the fading of the UI.
*
* @private {shaka.util.Timer}
*/
this.fadeControlsTimer_ = new shaka.util.Timer(() => {
this.controlsContainer_.removeAttribute('shown');
// If there's an overflow menu open, keep it this way for a couple of
// seconds in case a user immediately initiates another mouse move to
// interact with the menus. If that didn't happen, go ahead and hide
// the menus.
this.hideSettingsMenusTimer_.tickAfter(/* seconds= */ 2);
});
/**
* 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(() => {
for (const menu of this.settingsMenus_) {
shaka.ui.Utils.setDisplay(menu, /* visible= */ false);
}
});
/**
* 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(() => {
// Suppress timer-based updates if the controls are hidden.
if (this.isOpaque()) {
this.updateTimeAndSeekRange_();
}
});
/** @private {?number} */
this.lastTouchEventTime_ = null;
/** @private {!Array.<!shaka.extern.IUIElement>} */
this.elements_ = [];
/** @private {shaka.ui.Localization} */
this.localization_ = shaka.ui.Controls.createLocalization_();
/** @private {shaka.util.EventManager} */
this.eventManager_ = new shaka.util.EventManager();
// Configure and create the layout of the controls
this.configure(this.config_);
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.<string>}
*/
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_.tickEvery(/* seconds= */ 0.125);
};
goog.inherits(shaka.ui.Controls, shaka.util.FakeEventTarget);
/** @private {!Map.<string, !shaka.extern.IUIElement.Factory>} */
shaka.ui.ControlsPanel.elementNamesToFactories_ = new Map();
/**
* @override
* @export
*/
shaka.ui.Controls.prototype.destroy = async function() {
if (this.eventManager_) {
this.eventManager_.release();
this.eventManager_ = null;
}
if (this.mouseStillTimer_) {
this.mouseStillTimer_.stop();
this.mouseStillTimer_ = null;
}
if (this.fadeControlsTimer_) {
this.fadeControlsTimer_.stop();
this.fadeControlsTimer_ = null;
}
if (this.hideSettingsMenusTimer_) {
this.hideSettingsMenusTimer_.stop();
this.hideSettingsMenusTimer_ = null;
}
if (this.timeAndSeekRangeTimer_) {
this.timeAndSeekRangeTimer_.stop();
this.timeAndSeekRangeTimer_ = null;
}
if (this.controlsContainer_) {
this.videoContainer_.removeChild(this.controlsContainer_);
this.controlsContainer_ = null;
}
if (this.castProxy_) {
await this.castProxy_.destroy();
this.castProxy_ = null;
}
if (this.localPlayer_) {
await this.localPlayer_.destroy();
this.localPlayer_ = null;
}
this.player_ = null;
this.localVideo_ = null;
this.video_ = null;
// TODO: remove any elements created
this.localization_ = null;
this.pressedKeys_.clear();
await Promise.all(this.elements_.map((element) => element.destroy()));
this.elements_ = [];
if (this.seekBar_) {
await this.seekBar_.destroy();
}
};
/**
* @event shaka.ui.Controls#CastStatusChangedEvent
* @description Fired upon receiving a 'caststatuschanged' event from
* the cast proxy.
* @property {string} type
* 'caststatuschanged'
* @property {boolean} newStatus
* The new status of the application. True for 'is casting' and
* false otherwise.
* @exportDoc
*/
/**
* @event shaka.ui.Controls#SubMenuOpenEvent
* @description Fired when one of the overflow submenus is opened
* (e. g. language/resolution/subtitle selection).
* @property {string} type
* 'submenuopen'
* @exportDoc
*/
/**
* @event shaka.ui.Controls#CaptionSelectionUpdatedEvent
* @description Fired when the captions/subtitles menu has finished updating.
* @property {string} type
* 'captionselectionupdated'
* @exportDoc
*/
/**
* @event shaka.ui.Controls#ResolutionSelectionUpdatedEvent
* @description Fired when the resolution menu has finished updating.
* @property {string} type
* 'resolutionselectionupdated'
* @exportDoc
*/
/**
* @event shaka.ui.Controls#LanguageSelectionUpdatedEvent
* @description Fired when the audio language menu has finished updating.
* @property {string} type
* 'languageselectionupdated'
* @exportDoc
*/
/**
* @event shaka.ui.Controls#ErrorEvent
* @description Fired when something went wrong with the controls.
* @property {string} type
* 'error'
* @property {!shaka.util.Error} detail
* An object which contains details on the error. The error's 'category' and
* 'code' properties will identify the specific error that occurred. In an
* uncompiled build, you can also use the 'message' and 'stack' properties
* to debug.
* @exportDoc
*/
/**
* @event shaka.ui.Controls#TimeAndSeekRangeUpdatedEvent
* @description Fired when the time and seek range elements have finished
* updating.
* @property {string} type
* 'timeandseekrangeupdated'
* @exportDoc
*/
/**
* @event shaka.ui.Controls#UIUpdatedEvent
* @description Fired after a call to ui.configure() once the UI has finished
* updating.
* @property {string} type
* 'uiupdated'
* @exportDoc
*/
/**
* @param {string} name
* @param {!shaka.extern.IUIElement.Factory} factory
* @export
*/
shaka.ui.Controls.registerElement = function(name, factory) {
shaka.ui.ControlsPanel.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_();
};
/**
* @param {!shaka.extern.UIConfiguration} config
* @export
*/
shaka.ui.Controls.prototype.configure = function(config) {
this.config_ = config;
this.castProxy_.changeReceiverId(config.castReceiverAppId);
// Deconstruct the old layout if applicable
if (this.seekBar_) {
this.seekBar_.destroy();
this.seekBar_ = null;
}
if (this.playButton_) {
this.playButton_.destroy();
this.playButton_ = null;
}
if (this.controlsContainer_) {
shaka.util.Dom.removeAllChildren(this.controlsContainer_);
this.videoContainer_.removeChild(this.spinnerContainer_);
} else {
this.addControlsContainer_();
}
// Create the new layout
this.createDOM_();
// Init the play state
this.onPlayStateChange_();
// Elements that should not propagate clicks (controls panel, menus)
const noPropagationElements = this.videoContainer_.getElementsByClassName(
'shaka-no-propagation');
for (const element of noPropagationElements) {
const cb = (event) => event.stopPropagation();
this.eventManager_.listen(element, 'click', cb);
this.eventManager_.listen(element, 'dblclick', cb);
}
};
/**
* 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.Utils.setDisplay(this.controlsContainer_, true);
// Spinner lives outside of the main controls div
shaka.ui.Utils.setDisplay(
this.spinnerContainer_, this.player_.isBuffering());
// 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.localVideo_.tabIndex = -1;
this.localVideo_.controls = false;
} else {
shaka.ui.Utils.setDisplay(this.controlsContainer_, false);
// Spinner lives outside of the main controls div
shaka.ui.Utils.setDisplay(this.spinnerContainer_, false);
}
// The effects of play state changes are inhibited while showing native
// browser controls. Recalculate that state now.
this.onPlayStateChange_();
};
/**
* @param {boolean} seeking
* @export
*/
shaka.ui.Controls.prototype.setSeeking = function(seeking) {
this.isSeeking_ = seeking;
};
/**
* 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.localVideo_.controls = enabled;
this.localVideo_.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_;
};
/** @export */
shaka.ui.Controls.prototype.toggleFullScreen = async function() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
// If we are in PiP mode, leave PiP mode first.
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
}
} catch (error) {
this.dispatchEvent(new shaka.util.FakeEvent('error', {
detail: error,
}));
}
await this.videoContainer_.requestFullscreen({navigationUI: 'hide'});
}
};
/**
* @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 {shaka.Player}
* @export
*/
shaka.ui.Controls.prototype.getLocalPlayer = function() {
return this.localPlayer_;
};
/**
* @return {!HTMLElement}
* @export
*/
shaka.ui.Controls.prototype.getControlsContainer = function() {
goog.asserts.assert(
this.controlsContainer_, 'No controls container after destruction!');
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() {
return this.seekBar_ ? this.seekBar_.getValue() : this.video_.currentTime;
};
/**
* @param {?number} time
* @export
*/
shaka.ui.Controls.prototype.setLastTouchEventTime = function(time) {
this.lastTouchEventTime_ = time;
};
/**
* @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_.tickNow();
};
/**
* @private
*/
shaka.ui.Controls.prototype.createDOM_ = function() {
this.videoContainer_.classList.add('shaka-video-container');
this.localVideo_.classList.add('shaka-video');
this.addSkimContainer_();
if (this.config_.addBigPlayButton) {
this.addPlayButton_();
}
this.addBufferingSpinner_();
this.addControlsButtonPanel_();
this.settingsMenus_ = Array.from(
this.videoContainer_.getElementsByClassName('shaka-settings-menu'));
if (this.config_.addSeekBar) {
this.seekBar_ = new shaka.ui.SeekBar(this.bottomControls_, this);
} else {
// Settings menus need to be positioned lower, if the seekbar is absent.
for (let menu of this.settingsMenus_) {
menu.classList.add('shaka-low-position');
}
}
this.showOnHoverControls_ = Array.from(
this.videoContainer_.getElementsByClassName(
'shaka-show-controls-on-mouse-over'));
};
/**
* @private
*/
shaka.ui.Controls.prototype.addControlsContainer_ = function() {
/** @private {HTMLElement} */
this.controlsContainer_ = shaka.util.Dom.createHTMLElement('div');
this.controlsContainer_.classList.add('shaka-controls-container');
this.videoContainer_.appendChild(this.controlsContainer_);
this.eventManager_.listen(this.controlsContainer_, 'touchstart', (e) => {
this.onContainerTouch_(e);
}, {passive: false});
this.eventManager_.listen(this.controlsContainer_, 'click', (e) => {
this.onContainerClick_(e);
});
this.eventManager_.listen(this.controlsContainer_, 'dblclick', () => {
if (this.config_.doubleClickForFullscreen && document.fullscreenEnabled) {
this.toggleFullScreen();
}
});
};
/**
* @private
*/
shaka.ui.Controls.prototype.addPlayButton_ = function() {
const playButtonContainer = shaka.util.Dom.createHTMLElement('div');
playButtonContainer.classList.add('shaka-play-button-container');
this.controlsContainer_.appendChild(playButtonContainer);
/** @private {shaka.ui.BigPlayButton} */
this.playButton_ =
new shaka.ui.BigPlayButton(playButtonContainer, this);
};
/** @private */
shaka.ui.Controls.prototype.addSkimContainer_ = function() {
// This is the container that gets styled by CSS to have the
// black gradient skim at the end of the controls.
const skimContainer = shaka.util.Dom.createHTMLElement('div');
skimContainer.classList.add('shaka-skim-container');
skimContainer.classList.add('shaka-fade-out-on-mouse-out');
this.controlsContainer_.appendChild(skimContainer);
};
/**
* @private
*/
shaka.ui.Controls.prototype.addBufferingSpinner_ = function() {
/** @private {!HTMLElement} */
this.spinnerContainer_ = shaka.util.Dom.createHTMLElement('div');
this.spinnerContainer_.classList.add('shaka-spinner-container');
this.videoContainer_.appendChild(this.spinnerContainer_);
const spinner = shaka.util.Dom.createHTMLElement('div');
spinner.classList.add('shaka-spinner');
this.spinnerContainer_.appendChild(spinner);
// Svg elements have to be created with the svg xml namespace.
const xmlns = 'http://www.w3.org/2000/svg';
const svg =
/** @type {!HTMLElement} */(document.createElementNS(xmlns, 'svg'));
svg.setAttribute('class', 'shaka-spinner-svg');
svg.setAttribute('viewBox', '0 0 30 30');
spinner.appendChild(svg);
// 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');
svg.appendChild(spinnerCircle);
};
/**
* @private
*/
shaka.ui.Controls.prototype.addControlsButtonPanel_ = function() {
/** @private {!HTMLElement} */
this.bottomControls_ = shaka.util.Dom.createHTMLElement('div');
this.bottomControls_.classList.add('shaka-bottom-controls');
this.controlsContainer_.appendChild(this.bottomControls_);
/** @private {!HTMLElement} */
this.controlsButtonPanel_ = shaka.util.Dom.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.bottomControls_.appendChild(this.controlsButtonPanel_);
// Overflow menus are supposed to hide once you click elsewhere
// on the page. The click event listener on window ensures that.
// However, clicks on controls panel don't propagate to the container,
// so we have to explicitly hide the menus onclick here.
this.eventManager_.listen(this.controlsButtonPanel_, 'click', () => {
this.hideSettingsMenus();
});
// 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.ControlsPanel.elementNamesToFactories_.get(name)) {
const factory = shaka.ui.ControlsPanel.elementNamesToFactories_.get(name);
this.elements_.push(factory.create(this.controlsButtonPanel_, this));
} else {
shaka.log.alwaysWarn('Unrecognized control panel element requested:',
name);
}
}
};
/**
* When a mobile device is rotated to landscape layout, and the video is
* loaded, make the demo app go into fullscreen.
* Similarly, exit fullscreen when the device is rotated to portrait layout.
* @private
*/
shaka.ui.Controls.prototype.onScreenRotation_ = function() {
if (!this.video_ ||
this.video_.readyState == 0 ||
this.castProxy_.isCasting() ||
!this.config_.enableFullscreenOnRotation) { return; }
if (screen.orientation.type.includes('landscape') &&
!document.fullscreenElement) {
this.videoContainer_.requestFullscreen({navigationUI: 'hide'});
} else if (screen.orientation.type.includes('portrait') &&
document.fullscreenElement) {
document.exitFullscreen();
}
};
/**
* Adds static event listeners. This should only add event listeners to
* things that don't change (e.g. Player). Dynamic elements (e.g. controls)
* should have their event listeners added when they are created.
* @private
*/
shaka.ui.Controls.prototype.addEventListeners_ = function() {
// TODO: Convert adding event listers to the "() =>" form.
this.eventManager_.listen(this.player_, 'buffering', () => {
this.onBufferingStateChange_();
});
// Set the initial state, as well.
this.onBufferingStateChange_();
// Listen for key down events to detect tab and enable outline
// for focused elements.
this.eventManager_.listen(window, 'keydown', (e) => {
this.onWindowKeyDown_(/** @type {!KeyboardEvent} */(e));
});
// Listen for click events to dismiss the settings menus.
this.eventManager_.listen(window, 'click', () => this.hideSettingsMenus());
this.eventManager_.listen(this.video_,
'play', this.onPlayStateChange_.bind(this));
this.eventManager_.listen(this.video_,
'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.eventManager_.listen(this.video_,
'ended', this.onPlayStateChange_.bind(this));
this.eventManager_.listen(this.videoContainer_,
'mousemove', this.onMouseMove_.bind(this));
this.eventManager_.listen(this.videoContainer_,
'touchmove', this.onMouseMove_.bind(this), {passive: true});
this.eventManager_.listen(this.videoContainer_,
'touchend', this.onMouseMove_.bind(this), {passive: true});
this.eventManager_.listen(this.videoContainer_,
'mouseleave', this.onMouseLeave_.bind(this));
this.eventManager_.listen(this.castProxy_,
'caststatuschanged', (e) => {
this.onCastStatusChange_(e);
});
this.eventManager_.listen(this.videoContainer_, 'keydown', (e) => {
this.onControlsKeyDown_(/** @type {!KeyboardEvent} */(e));
});
this.eventManager_.listen(this.videoContainer_, 'keyup', (e) => {
this.onControlsKeyUp_(/** @type {!KeyboardEvent} */(e));
});
if (screen.orientation) {
this.eventManager_.listen(screen.orientation, 'change', () => {
this.onScreenRotation_();
});
}
};
/**
* 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');
this.computeOpacity();
}
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 = '';
this.recentMouseMovement_ = true;
// Make sure we are not about to hide the settings menus and then force them
// open.
this.hideSettingsMenusTimer_.stop();
if (!this.isOpaque()) {
// Only update the time and seek range on mouse movement if it's the very
// first movement and we're about to show the controls. Otherwise the
// seek bar will be updated mich more rapidly during mouse movement. Do
// this right before making it visible.
this.updateTimeAndSeekRange_();
this.computeOpacity();
}
// 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_.tickAfter(/* seconds= */ 3);
}
};
/** @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_.tickNow();
};
/**
* 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_.tickNow()|.
*
* @private
*/
shaka.ui.Controls.prototype.onMouseStill_ = function() {
// Hide the cursor. (NOTE: not supported on IE)
this.videoContainer_.style.cursor = 'none';
this.recentMouseMovement_ = false;
this.computeOpacity();
};
/**
* @return {boolean} true if any relevant elements are hovered.
* @private
*/
shaka.ui.Controls.prototype.isHovered_ = function() {
if (!window.matchMedia('hover: hover').matches) {
// This is primarily a touch-screen device, so the :hover query below
// doesn't make sense. In spite of this, the :hover query on an element
// can still return true on such a device after a touch ends.
// See https://bit.ly/34dBORX for details.
return false;
}
return this.showOnHoverControls_.some((element) => {
return element.matches(':hover');
});
};
/**
* Recompute whether the controls should be shown or hidden.
*/
shaka.ui.Controls.prototype.computeOpacity = function() {
const videoIsPaused = this.video_.paused && !this.isSeeking_;
const keyboardNavigationMode = this.controlsContainer_.classList.contains(
'shaka-keyboard-navigation');
// Keep showing the controls if the video is paused, there has been recent
// mouse movement, we're in keyboard navigation, or one of a special class of
// elements is hovered.
if (videoIsPaused ||
this.recentMouseMovement_ ||
keyboardNavigationMode ||
this.isHovered_()) {
// Make sure the state is up-to-date before showing it.
this.updateTimeAndSeekRange_();
this.controlsContainer_.setAttribute('shown', 'true');
this.fadeControlsTimer_.stop();
} else {
this.fadeControlsTimer_.tickAfter(/* seconds= */ this.config_.fadeDelay);
}
};
/**
* @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_.tickNow();
} 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();
}
this.computeOpacity();
};
/**
* Support controls with keyboard inputs.
* @param {!KeyboardEvent} event
* @private
*/
shaka.ui.Controls.prototype.onControlsKeyDown_ = function(event) {
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);
}
if (!this.config_.enableKeyboardPlaybackControls) {
return;
}
switch (event.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 (this.seekBar_ && !isVolumeBar) {
event.preventDefault();
this.seek_(this.seekBar_.getValue() - 5);
}
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 (this.seekBar_ && !isVolumeBar) {
event.preventDefault();
this.seek_(this.seekBar_.getValue() + 5);
}
break;
// Jump to the beginning of the video's seek range.
case 'Home':
if (this.seekBar_) {
this.seek_(this.player_.seekRange().start);
}
break;
// Jump to the end of the video's seek range.
case 'End':
if (this.seekBar_) {
this.seek_(this.player_.seekRange().end);
}
break;
// Pause or play by pressing space on the seek bar.
case ' ':
if (isSeekBar) {
this.onPlayPauseClick_();
}
break;
}
};
/**
* Support controls with keyboard inputs.
* @param {!KeyboardEvent} event
* @private
*/
shaka.ui.Controls.prototype.onControlsKeyUp_ = function(event) {
// When the key is released, remove it from the pressed keys set.
this.pressedKeys_.delete(event.key);
};
/**
* Called both as an event listener and directly by the controls to initialize
* the buffering state.
* @private
*/
shaka.ui.Controls.prototype.onBufferingStateChange_ = function() {
if (!this.enabled_) {
return;
}
shaka.ui.Utils.setDisplay(
this.spinnerContainer_, this.player_.isBuffering());
};
/**
* @return {boolean}
* @export
*/
shaka.ui.Controls.prototype.isOpaque = function() {
if (!this.enabled_) return false;
return this.controlsContainer_.getAttribute('shown') != null ||
this.controlsContainer_.getAttribute('casting') != null;
};
/**
* Update the video's current time based on the keyboard operations.
* @param {number} currentTime
* @private
*/
shaka.ui.Controls.prototype.seek_ = function(currentTime) {
goog.asserts.assert(
this.seekBar_, 'Caller of seek_ must check for seekBar_ first!');
this.seekBar_.changeTo(currentTime);
if (this.isOpaque()) {
// Only update the time and esek range if it's visible.
this.updateTimeAndSeekRange_();
}
};
/**
* Called when the seek range or current time need to be updated.
* @private
*/
shaka.ui.Controls.prototype.updateTimeAndSeekRange_ = function() {
if (this.seekBar_) {
this.seekBar_.setValue(this.video_.currentTime);
this.seekBar_.update();
if (this.seekBar_.isShowing()) {
for (let menu of this.settingsMenus_) {
menu.classList.remove('shaka-low-position');
}
} else {
for (let menu of this.settingsMenus_) {
menu.classList.add('shaka-low-position');
}
}
}
this.dispatchEvent(new shaka.util.FakeEvent('timeandseekrangeupdated'));
};
/**
* 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 {!KeyboardEvent} event
* @private
*/
shaka.ui.Controls.prototype.onWindowKeyDown_ = function(event) {
// Add the key to the pressed keys set when it's pressed.
this.pressedKeys_.add(event.key);
const anySettingsMenusAreOpen = this.anySettingsMenusAreOpen();
if (event.key == 'Tab') {
// Enable blue outline for focused elements for keyboard
// navigation.
this.controlsContainer_.classList.add('shaka-keyboard-navigation');
this.computeOpacity();
this.eventManager_.listen(window, 'mousedown',
this.onMouseDown_.bind(this));
}
// If escape key was pressed, close any open settings menus.
if (event.key == 'Escape') {
this.hideSettingsMenusTimer_.tickNow();
}
if (anySettingsMenusAreOpen && this.pressedKeys_.has('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 onWindowKeyDown_() 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'));
if (!openSettingsMenus.length) {
// For example, this occurs when you hit escape to close the menu.
return;
}
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('Shift')) {
if (activeElement == firstShownChild) {
event.preventDefault();
lastShownChild.focus();
}
} else {
if (activeElement == lastShownChild) {
event.preventDefault();
firstShownChild.focus();
}
}
}
};
/**
* For keyboard navigation, we use blue borders to highlight the active
* element. If we detect that a mouse is being used, remove the blue border
* from the active element.
* @private
*/
shaka.ui.Controls.prototype.onMouseDown_ = function() {
this.eventManager_.unlisten(window, 'mousedown');
};
/**
* 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;
};