mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-15 16:06:41 +03:00
f1c1585afb
Before, we would count on all event listeners for FakeEventTargets to be cleaned up by the object that listens. Now, FakeEventTarget implements IReleasable, so that all listeners are removed when owners call release(). For objects extending FakeEventTarget and also implementing IDestroyable, the destroy() methods will call out to super.release() to clean up listeners then. The owner should use destroy() in those cases. Issue #3949 (memory leak in DASH live streams with inband EventStream)
1609 lines
46 KiB
JavaScript
1609 lines
46 KiB
JavaScript
/*! @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.Deprecate');
|
|
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);
|
|
|
|
if (typeof element.release != 'function') {
|
|
shaka.Deprecate.deprecateFeature(4,
|
|
'shaka.extern.IUIElement',
|
|
'Please update UI elements to have a release() method.');
|
|
|
|
// This cast works around compiler strictness about the IUIElement
|
|
// type being "@struct" (as ES6 classes are by default).
|
|
const moddableElement = /** @type {Object} */(element);
|
|
moddableElement['release'] = () => {
|
|
if (moddableElement['destroy']) {
|
|
moddableElement['destroy']();
|
|
}
|
|
};
|
|
}
|
|
|
|
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();
|