/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.ui.Controls');
goog.provide('shaka.ui.ControlsPanel');
goog.require('goog.asserts');
goog.require('shaka.ads.AdManager');
goog.require('shaka.cast.CastProxy');
goog.require('shaka.log');
goog.require('shaka.ui.AdCounter');
goog.require('shaka.ui.AdPosition');
goog.require('shaka.ui.BigPlayButton');
goog.require('shaka.ui.ContextMenu');
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.IDestroyable');
goog.require('shaka.util.Timer');
goog.requireType('shaka.Player');
/**
* A container for custom video controls.
* @implements {shaka.util.IDestroyable}
* @export
*/
shaka.ui.Controls = class extends shaka.util.FakeEventTarget {
/**
* @param {!shaka.Player} player
* @param {!HTMLElement} videoContainer
* @param {!HTMLMediaElement} video
* @param {shaka.extern.UIConfiguration} config
*/
constructor(player, videoContainer, video, config) {
super();
/** @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.extern.IAdManager} */
this.adManager_ = this.player_.getAdManager();
/** @private {?shaka.extern.IAd} */
this.ad_ = null;
/** @private {?shaka.extern.IUISeekBar} */
this.seekBar_ = null;
/** @private {boolean} */
this.isSeeking_ = false;
/** @private {!Array.<!HTMLElement>} */
this.menus_ = [];
/**
* 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.menus_) {
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_();
// Start this timer after we are finished initializing everything,
this.timeAndSeekRangeTimer_.tickEvery(/* seconds= */ 0.125);
this.eventManager_.listen(this.localization_,
shaka.ui.Localization.LOCALE_CHANGED, (e) => {
const locale = e['locales'][0];
this.adManager_.setLocale(locale);
});
}
/**
* @override
* @export
*/
async destroy() {
if (document.pictureInPictureElement == this.localVideo_) {
await document.exitPictureInPicture();
}
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;
}
// Important! Release all child elements before destroying the cast proxy
// or player. This makes sure those destructions will not trigger event
// listeners in the UI which would then invoke the cast proxy or player.
this.releaseChildElements_();
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;
this.localization_ = null;
this.pressedKeys_.clear();
// FakeEventTarget implements IReleasable
super.release();
}
/** @private */
releaseChildElements_() {
for (const element of this.elements_) {
element.release();
}
this.elements_ = [];
}
/**
* @param {string} name
* @param {!shaka.extern.IUIElement.Factory} factory
* @export
*/
static registerElement(name, factory) {
shaka.ui.ControlsPanel.elementNamesToFactories_.set(name, factory);
}
/**
* @param {!shaka.extern.IUISeekBar.Factory} factory
* @export
*/
static registerSeekBar(factory) {
shaka.ui.ControlsPanel.seekBarFactory_ = factory;
}
/**
* This allows the application to inhibit casting.
*
* @param {boolean} allow
* @export
*/
allowCast(allow) {
this.castAllowed_ = allow;
this.onCastStatusChange_();
}
/**
* 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
*/
loadComplete() {
// 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
*/
configure(config) {
this.config_ = config;
this.castProxy_.changeReceiverId(config.castReceiverAppId);
// Deconstruct the old layout if applicable
if (this.seekBar_) {
this.seekBar_ = null;
}
if (this.playButton_) {
this.playButton_ = null;
}
if (this.contextMenu_) {
this.contextMenu_ = null;
}
if (this.controlsContainer_) {
shaka.util.Dom.removeAllChildren(this.controlsContainer_);
this.releaseChildElements_();
} else {
this.addControlsContainer_();
// The client-side ad container is only created once, and is never
// re-created or uprooted in the DOM, even when the DOM is re-created,
// since that seemingly breaks the IMA SDK.
this.addClientAdContainer_();
}
// 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
*/
setEnabledShakaControls(enabled) {
this.enabled_ = enabled;
if (enabled) {
this.videoContainer_.setAttribute('shaka-controls', '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.localVideo_.tabIndex = -1;
this.localVideo_.controls = false;
} else {
this.videoContainer_.removeAttribute('shaka-controls');
}
// 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
*/
setEnabledNativeControls(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.extern.IAd}
*/
getAd() {
return this.ad_;
}
/**
* @export
* @return {shaka.cast.CastProxy}
*/
getCastProxy() {
return this.castProxy_;
}
/**
* @return {shaka.ui.Localization}
* @export
*/
getLocalization() {
return this.localization_;
}
/**
* @return {!HTMLElement}
* @export
*/
getVideoContainer() {
return this.videoContainer_;
}
/**
* @return {HTMLMediaElement}
* @export
*/
getVideo() {
return this.video_;
}
/**
* @return {HTMLMediaElement}
* @export
*/
getLocalVideo() {
return this.localVideo_;
}
/**
* @return {shaka.Player}
* @export
*/
getPlayer() {
return this.player_;
}
/**
* @return {shaka.Player}
* @export
*/
getLocalPlayer() {
return this.localPlayer_;
}
/**
* @return {!HTMLElement}
* @export
*/
getControlsContainer() {
goog.asserts.assert(
this.controlsContainer_, 'No controls container after destruction!');
return this.controlsContainer_;
}
/**
* @return {!HTMLElement}
* @export
*/
getServerSideAdContainer() {
return this.daiAdContainer_;
}
/**
* @return {!HTMLElement}
* @export
*/
getClientSideAdContainer() {
return this.clientAdContainer_;
}
/**
* @return {!shaka.extern.UIConfiguration}
* @export
*/
getConfig() {
return this.config_;
}
/**
* @return {boolean}
* @export
*/
isSeeking() {
return this.isSeeking_;
}
/**
* @param {boolean} seeking
* @export
*/
setSeeking(seeking) {
this.isSeeking_ = seeking;
}
/**
* @return {boolean}
* @export
*/
isCastAllowed() {
return this.castAllowed_;
}
/**
* @return {number}
* @export
*/
getDisplayTime() {
return this.seekBar_ ? this.seekBar_.getValue() : this.video_.currentTime;
}
/**
* @param {?number} time
* @export
*/
setLastTouchEventTime(time) {
this.lastTouchEventTime_ = time;
}
/**
* @return {boolean}
* @export
*/
anySettingsMenusAreOpen() {
return this.menus_.some(
(menu) => !menu.classList.contains('shaka-hidden'));
}
/** @export */
hideSettingsMenus() {
this.hideSettingsMenusTimer_.tickNow();
}
/**
* @return {boolean}
* @export
*/
isFullScreenSupported() {
if (document.fullscreenEnabled) {
return true;
}
const video = /** @type {HTMLVideoElement} */(this.localVideo_);
if (video.webkitSupportsFullscreen) {
return true;
}
return false;
}
/**
* @return {boolean}
* @export
*/
isFullScreenEnabled() {
if (document.fullscreenEnabled) {
return !!document.fullscreenElement;
}
const video = /** @type {HTMLVideoElement} */(this.localVideo_);
if (video.webkitSupportsFullscreen) {
return video.webkitDisplayingFullscreen;
}
return false;
}
/** @export */
async toggleFullScreen() {
if (document.fullscreenEnabled) {
if (document.fullscreenElement) {
if (screen.orientation) {
screen.orientation.unlock();
}
await document.exitFullscreen();
} else {
// If we are in PiP mode, leave PiP mode first.
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
}
await this.videoContainer_.requestFullscreen({navigationUI: 'hide'});
if (this.config_.forceLandscapeOnFullscreen && screen.orientation) {
try {
// Locking to 'landscape' should let it be either
// 'landscape-primary' or 'landscape-secondary' as appropriate.
await screen.orientation.lock('landscape');
} catch (error) {
// If screen.orientation.lock does not work on a device, it will
// be rejected with an error. Suppress that error.
}
}
} catch (error) {
this.dispatchEvent(new shaka.util.FakeEvent(
'error', (new Map()).set('detail', error)));
}
}
} else {
const video = /** @type {HTMLVideoElement} */(this.localVideo_);
if (video.webkitSupportsFullscreen) {
if (video.webkitDisplayingFullscreen) {
video.webkitExitFullscreen();
} else {
video.webkitEnterFullscreen();
}
}
}
}
/** @export */
showAdUI() {
shaka.ui.Utils.setDisplay(this.adPanel_, true);
shaka.ui.Utils.setDisplay(this.clientAdContainer_, true);
this.controlsContainer_.setAttribute('ad-active', 'true');
}
/** @export */
hideAdUI() {
shaka.ui.Utils.setDisplay(this.adPanel_, false);
shaka.ui.Utils.setDisplay(this.clientAdContainer_, false);
this.controlsContainer_.removeAttribute('ad-active');
}
/**
* Play or pause the current presentation.
*/
playPausePresentation() {
if (!this.enabled_) {
return;
}
if (!this.video_.duration) {
// Can't play yet. Ignore.
return;
}
this.player_.cancelTrickPlay();
if (this.presentationIsPaused()) {
this.video_.play();
} else {
this.video_.pause();
}
}
/**
* Play or pause the current ad.
*/
playPauseAd() {
if (this.ad_ && this.ad_.isPaused()) {
this.ad_.play();
} else if (this.ad_) {
this.ad_.pause();
}
}
/**
* Return true if the presentation is paused.
*
* @return {boolean}
*/
presentationIsPaused() {
// The video element is in a paused state while seeking, but we don't count
// that.
return this.video_.paused && !this.isSeeking();
}
/** @private */
createDOM_() {
this.videoContainer_.classList.add('shaka-video-container');
this.localVideo_.classList.add('shaka-video');
this.addScrimContainer_();
if (this.config_.addBigPlayButton) {
this.addPlayButton_();
}
if (this.config_.customContextMenu) {
this.addContextMenu_();
}
if (!this.spinnerContainer_) {
this.addBufferingSpinner_();
}
this.addDaiAdContainer_();
this.addControlsButtonPanel_();
this.menus_ = Array.from(
this.videoContainer_.getElementsByClassName('shaka-settings-menu'));
this.menus_.push(...Array.from(
this.videoContainer_.getElementsByClassName('shaka-overflow-menu')));
this.addSeekBar_();
this.showOnHoverControls_ = Array.from(
this.videoContainer_.getElementsByClassName(
'shaka-show-controls-on-mouse-over'));
}
/** @private */
addControlsContainer_() {
/** @private {HTMLElement} */
this.controlsContainer_ = shaka.util.Dom.createHTMLElement('div');
this.controlsContainer_.classList.add('shaka-controls-container');
this.videoContainer_.appendChild(this.controlsContainer_);
// Use our controls by default, without anyone calling
// setEnabledShakaControls:
this.videoContainer_.setAttribute('shaka-controls', 'true');
this.eventManager_.listen(this.controlsContainer_, 'touchstart', (e) => {
this.onContainerTouch_(e);
}, {passive: false});
this.eventManager_.listen(this.controlsContainer_, 'click', () => {
this.onContainerClick_();
});
this.eventManager_.listen(this.controlsContainer_, 'dblclick', () => {
if (this.config_.doubleClickForFullscreen &&
this.isFullScreenSupported()) {
this.toggleFullScreen();
}
});
}
/** @private */
addPlayButton_() {
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);
this.elements_.push(this.playButton_);
}
/** @private */
addContextMenu_() {
/** @private {shaka.ui.ContextMenu} */
this.contextMenu_ =
new shaka.ui.ContextMenu(this.controlsButtonPanel_, this);
this.elements_.push(this.contextMenu_);
}
/** @private */
addScrimContainer_() {
// This is the container that gets styled by CSS to have the
// black gradient scrim at the end of the controls.
const scrimContainer = shaka.util.Dom.createHTMLElement('div');
scrimContainer.classList.add('shaka-scrim-container');
this.controlsContainer_.appendChild(scrimContainer);
}
/** @private */
addAdControls_() {
/** @private {!HTMLElement} */
this.adPanel_ = shaka.util.Dom.createHTMLElement('div');
this.adPanel_.classList.add('shaka-ad-controls');
const showAdPanel = this.ad_ != null && this.ad_.isLinear();
shaka.ui.Utils.setDisplay(this.adPanel_, showAdPanel);
this.bottomControls_.appendChild(this.adPanel_);
const adPosition = new shaka.ui.AdPosition(this.adPanel_, this);
this.elements_.push(adPosition);
const adCounter = new shaka.ui.AdCounter(this.adPanel_, this);
this.elements_.push(adCounter);
}
/** @private */
addBufferingSpinner_() {
/** @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.classList.add('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.classList.add('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 */
addControlsButtonPanel_() {
/** @private {!HTMLElement} */
this.bottomControls_ = shaka.util.Dom.createHTMLElement('div');
this.bottomControls_.classList.add('shaka-bottom-controls');
this.bottomControls_.classList.add('shaka-no-propagation');
this.controlsContainer_.appendChild(this.bottomControls_);
// Overflow menus are supposed to hide once you click elsewhere
// on the page. The click event listener on window ensures that.
// However, clicks on the bottom controls don't propagate to the container,
// so we have to explicitly hide the menus onclick here.
this.eventManager_.listen(this.bottomControls_, 'click', (e) => {
// We explicitly deny this measure when clicking on buttons that
// open submenus in the control panel.
if (!e.target['closest']('.shaka-overflow-button')) {
this.hideSettingsMenus();
}
});
this.addAdControls_();
/** @private {!HTMLElement} */
this.controlsButtonPanel_ = shaka.util.Dom.createHTMLElement('div');
this.controlsButtonPanel_.classList.add('shaka-controls-button-panel');
this.controlsButtonPanel_.classList.add(
'shaka-show-controls-on-mouse-over');
if (this.config_.enableTooltips) {
this.controlsButtonPanel_.classList.add('shaka-tooltips-on');
}
this.bottomControls_.appendChild(this.controlsButtonPanel_);
// Create the elements specified by controlPanelElements
for (const name of this.config_.controlPanelElements) {
if (shaka.ui.ControlsPanel.elementNamesToFactories_.get(name)) {
const factory =
shaka.ui.ControlsPanel.elementNamesToFactories_.get(name);
const element = factory.create(this.controlsButtonPanel_, this);
this.elements_.push(element);
} else {
shaka.log.alwaysWarn('Unrecognized control panel element requested:',
name);
}
}
}
/**
* Adds a container for server side ad UI with IMA SDK.
*
* @private
*/
addDaiAdContainer_() {
/** @private {!HTMLElement} */
this.daiAdContainer_ = shaka.util.Dom.createHTMLElement('div');
this.daiAdContainer_.classList.add('shaka-server-side-ad-container');
this.controlsContainer_.appendChild(this.daiAdContainer_);
}
/**
* Adds a seekbar depending on the configuration.
* By default an instance of shaka.ui.SeekBar is created
* This behaviour can be overriden by providing a SeekBar factory using the
* registerSeekBarFactory function.
*
* @private
*/
addSeekBar_() {
if (this.config_.addSeekBar) {
this.seekBar_ = shaka.ui.ControlsPanel.seekBarFactory_.create(
this.bottomControls_, this);
this.elements_.push(this.seekBar_);
} else {
// Settings menus need to be positioned lower if the seekbar is absent.
for (const menu of this.menus_) {
menu.classList.add('shaka-low-position');
}
}
}
/**
* Adds a container for client side ad UI with IMA SDK.
*
* @private
*/
addClientAdContainer_() {
/** @private {!HTMLElement} */
this.clientAdContainer_ = shaka.util.Dom.createHTMLElement('div');
this.clientAdContainer_.classList.add('shaka-client-side-ad-container');
shaka.ui.Utils.setDisplay(this.clientAdContainer_, false);
this.eventManager_.listen(this.clientAdContainer_, 'click', () => {
this.onContainerClick_();
});
this.videoContainer_.appendChild(this.clientAdContainer_);
}
/**
* 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
*/
addEventListeners_() {
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());
// Avoid having multiple submenus open at the same time.
this.eventManager_.listen(
this, 'submenuopen', () => {
this.hideSettingsMenus();
});
this.eventManager_.listen(this.video_, 'play', () => {
this.onPlayStateChange_();
});
this.eventManager_.listen(this.video_, 'pause', () => {
this.onPlayStateChange_();
});
this.eventManager_.listen(this.videoContainer_, 'mousemove', (e) => {
this.onMouseMove_(e);
});
this.eventManager_.listen(this.videoContainer_, 'touchmove', (e) => {
this.onMouseMove_(e);
}, {passive: true});
this.eventManager_.listen(this.videoContainer_, 'touchend', (e) => {
this.onMouseMove_(e);
}, {passive: true});
this.eventManager_.listen(this.videoContainer_, 'mouseleave', () => {
this.onMouseLeave_();
});
this.eventManager_.listen(this.castProxy_, 'caststatuschanged', () => {
this.onCastStatusChange_();
});
this.eventManager_.listen(this.videoContainer_, 'keydown', (e) => {
this.onControlsKeyDown_(/** @type {!KeyboardEvent} */(e));
});
this.eventManager_.listen(this.videoContainer_, 'keyup', (e) => {
this.onControlsKeyUp_(/** @type {!KeyboardEvent} */(e));
});
this.eventManager_.listen(
this.adManager_, shaka.ads.AdManager.AD_STARTED, (e) => {
this.ad_ = (/** @type {!Object} */ (e))['ad'];
this.showAdUI();
});
this.eventManager_.listen(
this.adManager_, shaka.ads.AdManager.AD_STOPPED, () => {
this.ad_ = null;
this.hideAdUI();
});
if (screen.orientation) {
this.eventManager_.listen(screen.orientation, 'change', async () => {
await this.onScreenRotation_();
});
}
this.eventManager_.listen(document, 'fullscreenchange', () => {
if (this.ad_) {
this.ad_.resize(
this.localVideo_.offsetWidth, this.localVideo_.offsetHeight);
}
});
}
/**
* 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
*/
async onScreenRotation_() {
if (!this.video_ ||
this.video_.readyState == 0 ||
this.castProxy_.isCasting() ||
!this.config_.enableFullscreenOnRotation) { return; }
if (screen.orientation.type.includes('landscape') &&
!document.fullscreenElement) {
await this.videoContainer_.requestFullscreen({navigationUI: 'hide'});
} else if (screen.orientation.type.includes('portrait') &&
document.fullscreenElement) {
await document.exitFullscreen();
}
}
/**
* 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
*/
onMouseMove_(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 much 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 */
onMouseLeave_() {
// 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
*/
onMouseStill_() {
// Hide the cursor.
this.videoContainer_.style.cursor = 'none';
this.recentMouseMovement_ = false;
this.computeOpacity();
}
/**
* @return {boolean} true if any relevant elements are hovered.
* @private
*/
isHovered_() {
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.
*/
computeOpacity() {
const adIsPaused = this.ad_ ? this.ad_.isPaused() : false;
const videoIsPaused = this.video_.paused && !this.isSeeking_;
const keyboardNavigationMode = this.controlsContainer_.classList.contains(
'shaka-keyboard-navigation');
// Keep showing the controls if the ad or 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 (adIsPaused ||
((!this.ad_ || !this.ad_.isLinear()) && 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
*/
onContainerTouch_(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();
}
}
/** @private */
onContainerClick_() {
if (!this.enabled_) {
return;
}
if (this.anySettingsMenusAreOpen()) {
this.hideSettingsMenusTimer_.tickNow();
} else if (this.config_.singleClickForPlayAndPause) {
this.onPlayPauseClick_();
}
}
/** @private */
onPlayPauseClick_() {
if (this.ad_ && this.ad_.isLinear()) {
this.playPauseAd();
} else {
this.playPausePresentation();
}
}
/** @private */
onCastStatusChange_() {
const isCasting = this.castProxy_.isCasting();
this.dispatchEvent(new shaka.util.FakeEvent(
'caststatuschanged', (new Map()).set('newStatus', isCasting)));
if (isCasting) {
this.controlsContainer_.setAttribute('casting', 'true');
} else {
this.controlsContainer_.removeAttribute('casting');
}
}
/** @private */
onPlayStateChange_() {
this.computeOpacity();
}
/**
* Support controls with keyboard inputs.
* @param {!KeyboardEvent} event
* @private
*/
onControlsKeyDown_(event) {
const activeElement = document.activeElement;
const isVolumeBar = activeElement && activeElement.classList ?
activeElement.classList.contains('shaka-volume-bar') : false;
const 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
*/
onControlsKeyUp_(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
*/
onBufferingStateChange_() {
if (!this.enabled_) {
return;
}
shaka.ui.Utils.setDisplay(
this.spinnerContainer_, this.player_.isBuffering());
}
/**
* @return {boolean}
* @export
*/
isOpaque() {
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
*/
seek_(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 seek range if it's visible.
this.updateTimeAndSeekRange_();
}
}
/**
* Called when the seek range or current time need to be updated.
* @private
*/
updateTimeAndSeekRange_() {
if (this.seekBar_) {
this.seekBar_.setValue(this.video_.currentTime);
this.seekBar_.update();
if (this.seekBar_.isShowing()) {
for (const menu of this.menus_) {
menu.classList.remove('shaka-low-position');
}
} else {
for (const menu of this.menus_) {
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
*/
onWindowKeyDown_(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_());
}
// 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
*/
keepFocusInMenu_(event) {
const openSettingsMenus = this.menus_.filter(
(menu) => !menu.classList.contains('shaka-hidden'));
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
*/
onMouseDown_() {
this.eventManager_.unlisten(window, 'mousedown');
}
/**
* Create a localization instance already pre-loaded with all the locales that
* we support.
*
* @return {!shaka.ui.Localization}
* @private
*/
static createLocalization_() {
/** @type {string} */
const fallbackLocale = 'en';
/** @type {!shaka.ui.Localization} */
const localization = new shaka.ui.Localization(fallbackLocale);
shaka.ui.Locales.addTo(localization);
localization.changeLocale(navigator.languages || []);
return localization;
}
};
/**
* @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
*/
/** @private {!Map.<string, !shaka.extern.IUIElement.Factory>} */
shaka.ui.ControlsPanel.elementNamesToFactories_ = new Map();
/** @private {?shaka.extern.IUISeekBar.Factory} */
shaka.ui.ControlsPanel.seekBarFactory_ = new shaka.ui.SeekBar.Factory();