shaka-player
Version:
DASH/EME video player library
1,700 lines (1,457 loc) • 86.6 kB
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.ads.Utils');
goog.require('shaka.cast.CastProxy');
goog.require('shaka.Deprecate');
goog.require('shaka.device.DeviceFactory');
goog.require('shaka.device.IDevice');
goog.require('shaka.log');
goog.require('shaka.ui.AdInfo');
goog.require('shaka.ui.ContextMenu');
goog.require('shaka.ui.Enums');
goog.require('shaka.ui.Icon');
goog.require('shaka.ui.HiddenFastForwardButton');
goog.require('shaka.ui.HiddenRewindButton');
goog.require('shaka.ui.Locales');
goog.require('shaka.ui.Localization');
goog.require('shaka.ui.MediaSession');
goog.require('shaka.ui.SeekBar');
goog.require('shaka.ui.SkipAdButton');
goog.require('shaka.ui.Utils');
goog.require('shaka.ui.VRManager');
goog.require('shaka.util.ArrayUtils');
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.LanguageUtils');
goog.require('shaka.util.Timer');
goog.require('shaka.util.Functional');
goog.requireType('shaka.Player');
goog.requireType('shaka.cast.CastReceiver');
/**
* @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.SubMenuCloseEvent
* @description Fired when one of the overflow submenus is closed
* (e. g. language/resolution/subtitle selection).
* @property {string} type
* 'submenuclose'
* @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
*/
/**
* @event shaka.ui.Controls.ChaptersUpdatedEvent
* @description Fired when the chapters has finished updating.
* @property {string} type
* 'chaptersupdated'
* @exportDoc
*/
/**
* 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 {?HTMLCanvasElement} vrCanvas
* @param {shaka.extern.UIConfiguration} config
*/
constructor(player, videoContainer, video, vrCanvas, 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,
this.config_.castAndroidReceiverCompatible);
/** @private {?shaka.cast.CastReceiver} */
this.castReceiver_ = null;
/** @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 {?HTMLCanvasElement} */
this.vrCanvas_ = vrCanvas;
/** @private {shaka.extern.IAdManager} */
this.adManager_ = this.castProxy_.getAdManager();
/** @private {shaka.extern.IQueueManager} */
this.queueManager_ = this.player_.getQueueManager();
this.queueManager_.setCustomPlayer(this.player_);
/** @private {?shaka.extern.IAd} */
this.ad_ = this.adManager_.getCurrentAd();
/** @private {!Array<!shaka.extern.AdCuePoint>} */
this.adCuePoints_ = [];
/** @private {!Array<!shaka.extern.Chapter>} */
this.chapters_ = [];
/** @private {?shaka.extern.IUISeekBar} */
this.seekBar_ = null;
/** @private {boolean} */
this.isSeeking_ = false;
/** @private {!Array<!HTMLElement>} */
this.menus_ = [];
/** @private {!Array<!HTMLElement>} */
this.contextMenus_ = [];
/** @private {?shaka.extern.TextTrack} */
this.lastSelectedTextTrack_ = null;
/**
* 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(() => {
if (this.shouldShowUIAlways_()) {
return;
}
if (this.config_.menuOpenUntilUserClosesIt &&
this.anySettingsMenusAreOpen()) {
return;
}
this.controlsContainer_.removeAttribute('shown');
this.dispatchVisibilityEvent_();
this.computeShakaTextContainerSize_();
if (this.contextMenu_) {
this.contextMenu_.closeMenu();
}
// 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= */ this.config_.closeMenusDelay);
});
/**
* 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);
}
if (this.config_.enableTooltips) {
this.controlsButtonPanel_.classList.add('shaka-tooltips-on');
}
if (this.config_.enableTooltips) {
this.topControlsButtonPanel_.classList.add('shaka-tooltips-on');
}
});
/** @private {shaka.util.Timer} */
this.hideUITimer_ = new shaka.util.Timer(() => {
this.hideUI();
});
/**
* 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 {?number} */
this.lastContainerTouchEventTime_ = 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();
/** @private {?shaka.ui.VRManager} */
this.vr_ = null;
// 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(this.config_.refreshTickInSeconds);
this.eventManager_.listen(this.localization_,
shaka.ui.Localization.LOCALE_UPDATED, () => {
this.updateChapters_();
});
this.eventManager_.listen(this.localization_,
shaka.ui.Localization.LOCALE_CHANGED, (e) => {
const locale = e['locales'][0];
this.adManager_.setLocale(locale);
this.videoContainer_.setAttribute('lang', locale);
this.updateChapters_();
});
this.adManager_.setContainers(
this.getClientSideAdContainer(), this.getServerSideAdContainer());
this.eventManager_.listen(this.player_, 'textchanged', () => {
this.computeShakaTextContainerSize_();
const tracks = this.player_.getTextTracks();
const selectedTrack = tracks.find((track) => track.active);
if (selectedTrack) {
// Store the most recently active track to restore selection
// when the user toggles visibility.
this.lastSelectedTextTrack_ = selectedTrack;
}
});
this.eventManager_.listenMulti(
this.player_,
[
'trackschanged',
'manifestupdated',
'loaded',
], () => {
if (this.player_.isFullyLoaded()) {
this.updateChapters_();
}
});
this.eventManager_.listen(this.player_, 'unloading', (event) => {
if (this.ad_) {
return;
}
const isSwitchingContent = event['isSwitchingContent'] || false;
this.adCuePoints_ = [];
this.lastSelectedTextTrack_ = null;
if (this.isFullScreenEnabled() && !isSwitchingContent) {
this.exitFullScreen_();
}
if (this.isPiPEnabled() && !isSwitchingContent) {
this.togglePiP();
}
if (this.chapters_.length) {
this.chapters_ = [];
this.dispatchEvent(new shaka.util.FakeEvent('chaptersupdated'));
}
});
/** @private {shaka.ui.MediaSession} */
this.mediaSession_ = new shaka.ui.MediaSession(this);
}
/**
* @param {boolean=} forceDisconnect If true, force the receiver app to shut
* down by disconnecting. Does nothing if not connected.
* @override
* @export
*/
async destroy(forceDisconnect = false) {
if (document.pictureInPictureElement == this.localVideo_) {
await document.exitPictureInPicture();
}
this.eventManager_?.release();
this.eventManager_ = null;
this.mouseStillTimer_?.stop();
this.mouseStillTimer_ = null;
this.fadeControlsTimer_?.stop();
this.fadeControlsTimer_ = null;
this.hideSettingsMenusTimer_?.stop();
this.hideSettingsMenusTimer_ = null;
this.hideUITimer_?.stop();
this.hideUITimer_ = null;
this.timeAndSeekRangeTimer_?.stop();
this.timeAndSeekRangeTimer_ = null;
this.vr_?.release();
this.vr_ = 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(forceDisconnect);
this.castProxy_ = null;
}
if (this.spinnerContainer_) {
this.videoContainer_.removeChild(this.spinnerContainer_);
this.spinnerContainer_ = null;
}
if (this.clientSideAdContainer_) {
this.videoContainer_.removeChild(this.clientSideAdContainer_);
this.clientSideAdContainer_ = null;
}
if (this.serverSideAdContainer_) {
this.videoContainer_.removeChild(this.serverSideAdContainer_);
this.serverSideAdContainer_ = 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();
if (this.mediaSession_) {
this.mediaSession_.release();
this.mediaSession_ = null;
}
// 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 {string} name
* @param {!shaka.extern.IUIElement.Factory} factory
* @export
*/
static registerBigElement(name, factory) {
shaka.ui.ControlsPanel.bigElementNamesToFactories_.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,
config.castAndroidReceiverCompatible);
// Deconstruct the old layout if applicable
if (this.seekBar_) {
this.seekBar_ = null;
}
if (this.contextMenu_) {
this.contextMenu_ = null;
}
if (this.vr_) {
this.vr_.configure(config);
}
if (this.controlsContainer_) {
shaka.util.Dom.removeAllChildren(this.controlsContainer_);
this.releaseChildElements_();
} else {
this.addControlsContainer_();
// The 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.addAdContainers_();
goog.asserts.assert(
this.controlsContainer_, 'Should have a controlsContainer_!');
goog.asserts.assert(this.localVideo_, 'Should have a localVideo_!');
goog.asserts.assert(this.player_, 'Should have a player_!');
this.vr_ = new shaka.ui.VRManager(this.controlsContainer_, this.vrCanvas_,
this.localVideo_, this.player_, this.config_, this);
}
// 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);
if (navigator.maxTouchPoints > 0) {
const touchCb = (event) => {
if (!this.isOpaque()) {
return;
}
event.stopPropagation();
};
this.eventManager_.listen(element, 'touchend', touchCb);
}
}
if (this.mediaSession_) {
this.mediaSession_.configure(this.config_);
}
}
/**
* 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);
}
}
/**
* @param {!shaka.cast.CastReceiver} receiver
* @export
*/
setCastReceiver(receiver) {
this.castReceiver_ = receiver;
}
/**
* @export
* @return {?shaka.extern.IAd}
*/
getAd() {
return this.ad_;
}
/**
* @export
* @return {!Array<!shaka.extern.AdCuePoint>}
*/
getAdCuePoints() {
return this.adCuePoints_;
}
/**
* @export
* @return {!Array<!shaka.extern.Chapter>}
*/
getChapters() {
return this.chapters_;
}
/**
* @export
* @return {shaka.cast.CastProxy}
*/
getCastProxy() {
return this.castProxy_;
}
/**
* @export
* @return {?shaka.cast.CastReceiver}
*/
getCastReceiver() {
return this.castReceiver_;
}
/**
* @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 {shaka.extern.IAdManager}
* @export
*/
getAdManager() {
return this.adManager_;
}
/**
* @return {shaka.extern.IQueueManager}
* @export
*/
getQueueManager() {
return this.queueManager_;
}
/**
* @return {shaka.ui.MediaSession}
* @export
*/
getMediaSession() {
return this.mediaSession_;
}
/**
* @return {!HTMLElement}
* @export
*/
getControlsContainer() {
goog.asserts.assert(
this.controlsContainer_, 'No controls container after destruction!');
return this.controlsContainer_;
}
/**
* @return {!HTMLElement}
* @export
*/
getServerSideAdContainer() {
goog.asserts.assert(this.serverSideAdContainer_,
'No server side ad container after destruction!');
return this.serverSideAdContainer_;
}
/**
* @return {!HTMLElement}
* @export
*/
getClientSideAdContainer() {
goog.asserts.assert(this.clientSideAdContainer_,
'No client side ad container after destruction!');
return this.clientSideAdContainer_;
}
/**
* @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;
if (seeking) {
this.mouseStillTimer_.stop();
} else {
this.mouseStillTimer_.tickAfter(/* seconds= */ 3);
}
}
/**
* @return {boolean}
* @export
*/
isCastAllowed() {
return this.castAllowed_;
}
/**
* @return {number}
* @export
*/
getDisplayTime() {
return this.seekBar_ ? this.seekBar_.getValue() : this.video_.currentTime;
}
/**
* @param {?number} time
* @param {boolean} container
* @export
*/
setLastTouchEventTime(time, container) {
shaka.Deprecate.deprecateFeature(6,
'setLastTouchEventTime',
'This method is no longer used.');
}
/**
* @return {boolean}
* @export
*/
anySettingsMenusAreOpen() {
return this.menus_.some(
(menu) => !menu.classList.contains('shaka-hidden'));
}
/** @export */
hideSettingsMenus() {
this.hideSettingsMenusTimer_.tickNow();
}
/**
* @return {boolean}
* @export
*/
anyContextMenusAreOpen() {
return this.contextMenus_.some(
(menu) => !menu.classList.contains('shaka-hidden'));
}
/** @export */
hideContextMenus() {
for (const menu of this.contextMenus_) {
shaka.ui.Utils.setDisplay(menu, /* visible= */ false);
}
}
/**
* @return {boolean}
* @private
*/
shouldUseDocumentFullscreen_() {
if (!document.fullscreenEnabled) {
return false;
}
// When the preferVideoFullScreenInVisionOS configuration value applies,
// we avoid using document fullscreen, even if it is available.
const video = /** @type {HTMLVideoElement} */(this.localVideo_);
if (video.webkitSupportsFullscreen &&
this.config_.preferVideoFullScreenInVisionOS) {
const device = shaka.device.DeviceFactory.getDevice();
if (device.getDeviceType() == shaka.device.IDevice.DeviceType.APPLE_VR) {
return false;
}
}
return true;
}
/**
* @return {boolean}
* @private
*/
shouldUseDocumentPictureInPicture_() {
return 'documentPictureInPicture' in window &&
this.config_.documentPictureInPicture.enabled;
}
/**
* @return {boolean}
* @export
*/
isFullScreenSupported() {
if (this.castProxy_.isCasting()) {
return false;
}
if (this.shouldUseDocumentFullscreen_()) {
return true;
}
if (!this.ad_ || !this.ad_.isUsingAnotherMediaElement()) {
const video = /** @type {HTMLVideoElement} */(this.localVideo_);
if (video.webkitSupportsFullscreen) {
return true;
}
}
return false;
}
/**
* @return {boolean}
* @export
*/
isFullScreenEnabled() {
if (this.shouldUseDocumentFullscreen_()) {
return !!document.fullscreenElement;
}
const video = /** @type {HTMLVideoElement} */(this.localVideo_);
if (video.webkitSupportsFullscreen) {
return video.webkitDisplayingFullscreen;
}
return false;
}
/** @private */
async enterFullScreen_() {
try {
if (this.shouldUseDocumentFullscreen_()) {
if (this.isPiPEnabled()) {
await this.togglePiP();
if (this.shouldUseDocumentPictureInPicture_()) {
// This is necessary because we need a small delay when
// executing actions when returning from document PiP.
await shaka.util.Functional.delay(0.05);
}
}
const fullScreenElement = this.config_.fullScreenElement;
await fullScreenElement.requestFullscreen({navigationUI: 'hide'});
if (this.config_.forceLandscapeOnFullscreen && screen.orientation) {
// Locking to 'landscape' should let it be either
// 'landscape-primary' or 'landscape-secondary' as appropriate.
// We ignore errors from this specific call, since it creates noise
// on desktop otherwise.
try {
await screen.orientation.lock('landscape');
} catch (error) {}
}
} else {
const video = /** @type {HTMLVideoElement} */(this.localVideo_);
if (video.webkitSupportsFullscreen) {
video.webkitEnterFullscreen();
}
}
} catch (error) {
// Entering fullscreen can fail without user interaction.
this.dispatchEvent(new shaka.util.FakeEvent(
'error', (new Map()).set('detail', error)));
}
}
/** @private */
async exitFullScreen_() {
if (this.shouldUseDocumentFullscreen_()) {
if (screen.orientation) {
screen.orientation.unlock();
}
await document.exitFullscreen();
} else {
const video = /** @type {HTMLVideoElement} */(this.localVideo_);
if (video.webkitSupportsFullscreen) {
video.webkitExitFullscreen();
}
}
}
/** @export */
async toggleFullScreen() {
if (this.isFullScreenEnabled()) {
await this.exitFullScreen_();
} else {
await this.enterFullScreen_();
}
}
/**
* @return {boolean}
* @export
*/
isPiPAllowed() {
if (this.castProxy_.isCasting()) {
return false;
}
if (document.pictureInPictureEnabled ||
this.shouldUseDocumentPictureInPicture_()) {
const video = /** @type {HTMLVideoElement} */(this.localVideo_);
return !video.disablePictureInPicture;
}
return false;
}
/**
* @return {boolean}
* @export
*/
isPiPEnabled() {
return !!((window.documentPictureInPicture &&
window.documentPictureInPicture.window) ||
document.pictureInPictureElement);
}
/** @export */
async togglePiP() {
try {
if (this.shouldUseDocumentPictureInPicture_()) {
// If you were fullscreen, leave fullscreen first.
if (this.isFullScreenEnabled()) {
await this.exitFullScreen_();
}
await this.toggleDocumentPictureInPicture_();
} else if (!document.pictureInPictureElement) {
// If you were fullscreen, leave fullscreen first.
if (this.isFullScreenEnabled()) {
// When using this PiP API, we can't use an await because in Safari,
// the PiP action wouldn't come from the user's direct input.
// However, this works fine in all browsers.
this.exitFullScreen_();
}
const video = /** @type {HTMLVideoElement} */(this.localVideo_);
await video.requestPictureInPicture();
} else {
await document.exitPictureInPicture();
}
} catch (error) {
this.dispatchEvent(new shaka.util.FakeEvent(
'error', (new Map()).set('detail', error)));
}
}
/**
* The Document Picture-in-Picture API makes it possible to open an
* always-on-top window that can be populated with arbitrary HTML content.
* https://developer.chrome.com/docs/web-platform/document-picture-in-picture
* @private
*/
async toggleDocumentPictureInPicture_() {
// Close Picture-in-Picture window if any.
if (window.documentPictureInPicture.window) {
window.documentPictureInPicture.window.close();
return;
}
// Open a Picture-in-Picture window.
const pipPlayer = this.videoContainer_;
const rectPipPlayer = pipPlayer.getBoundingClientRect();
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: rectPipPlayer.width,
height: rectPipPlayer.height,
disallowReturnToOpener:
this.config_.documentPictureInPicture.disallowReturnToOpener,
preferInitialWindowPlacement:
this.config_.documentPictureInPicture.preferInitialWindowPlacement,
});
// Copy style sheets to the Picture-in-Picture window.
this.copyStyleSheetsToWindow_(pipWindow);
// Add placeholder for the player.
const parentPlayer = pipPlayer.parentNode || document.body;
const placeholder = shaka.util.Dom.createHTMLElement('div');
placeholder.classList.add('shaka-video-container');
placeholder.classList.add('pip-placeholder');
const video = /** @type {HTMLVideoElement} */ (this.video_);
if (video?.poster) {
const posterDiv = document.createElement('div');
posterDiv.classList.add('pip-poster');
posterDiv.style.backgroundImage = `url("${video.poster}")`;
const videoWidth = video.videoWidth || video.clientWidth;
const videoHeight = video.videoHeight || video.clientHeight;
if (videoWidth && videoHeight) {
posterDiv.style.setProperty('aspect-ratio',
`${videoWidth} / ${videoHeight}`);
placeholder.appendChild(posterDiv);
}
}
const iconWrapper = document.createElement('div');
iconWrapper.classList.add('pip-icon-wrapper');
placeholder.appendChild(iconWrapper);
const pipIcon = (new shaka.ui.Icon(iconWrapper,
shaka.ui.Enums.MaterialDesignSVGIcons['EXIT_PIP'])).getSvgElement();
const pipAction = () => this.togglePiP();
this.eventManager_.listenOnce(pipIcon, 'click', pipAction);
const style = getComputedStyle(pipPlayer);
placeholder.style.height = style.height;
parentPlayer.appendChild(placeholder);
// Make sure player fits in the Picture-in-Picture window.
const styles = document.createElement('style');
styles.append(`[data-shaka-player-container] {
width: 100% !important; max-height: 100%}`);
pipWindow.document.head.append(styles);
// Move player to the Picture-in-Picture window.
pipWindow.document.body.append(pipPlayer);
pipPlayer.classList.add('pip-mode');
// Listen for the PiP closing event to move the player back.
this.eventManager_.listenOnce(pipWindow, 'pagehide', () => {
this.eventManager_.unlisten(pipIcon, 'click', pipAction);
pipPlayer.classList.remove('pip-mode');
placeholder.replaceWith(/** @type {!Node} */(pipPlayer));
});
}
/** @private */
copyStyleSheetsToWindow_(win) {
const styleSheets = /** @type {!Iterable<*>} */(document.styleSheets);
const allCSS = [...styleSheets]
.map((sheet) => {
try {
return [...sheet.cssRules].map((rule) => rule.cssText).join('');
} catch (e) {
const link = /** @type {!HTMLLinkElement} */(
document.createElement('link'));
link.rel = 'stylesheet';
link.type = sheet.type;
link.media = sheet.media;
link.href = sheet.href;
win.document.head.appendChild(link);
}
return '';
})
.filter(Boolean)
.join('\n');
const style = document.createElement('style');
style.textContent = allCSS;
win.document.head.appendChild(style);
}
/** @export */
showAdUI() {
shaka.ui.Utils.setDisplay(this.adPanel_, true);
if (this.clientSideAdContainer_ &&
this.clientSideAdContainer_.hasChildNodes()) {
shaka.ui.Utils.setDisplay(this.clientSideAdContainer_, true);
}
if (this.serverSideAdContainer_ &&
this.serverSideAdContainer_.hasChildNodes()) {
shaka.ui.Utils.setDisplay(this.serverSideAdContainer_, true);
}
if (this.ad_.hasCustomClick()) {
this.controlsContainer_.setAttribute('ad-active', 'true');
} else {
this.controlsContainer_.removeAttribute('ad-active');
}
}
/** @export */
hideAdUI() {
shaka.ui.Utils.setDisplay(this.adPanel_, false);
shaka.ui.Utils.setDisplay(this.clientSideAdContainer_, false);
shaka.ui.Utils.setDisplay(this.serverSideAdContainer_, false);
this.controlsContainer_.removeAttribute('ad-active');
}
/**
* Play or pause the current presentation.
*/
playPausePresentation() {
if (!this.enabled_) {
return;
}
if (this.ad_) {
this.playPauseAd();
if (this.ad_.isLinear()) {
return;
}
}
if (!this.video_.duration) {
// Can't play yet. Ignore.
return;
}
if (this.presentationIsPaused()) {
// If we are at the end, go back to the beginning.
if (this.player_.isEnded()) {
this.video_.currentTime = this.player_.seekRange().start;
}
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_.bigButtons.length) {
this.addBigButtons_();
}
if (!this.spinnerContainer_) {
this.addBufferingSpinner_();
}
if (this.config_.seekOnTaps) {
this.addFastForwardButtonOnControlsContainer_();
this.addRewindButtonOnControlsContainer_();
}
this.addControlsButtonPanel_();
if (this.config_.customContextMenu) {
this.addContextMenu_();
}
this.menus_ = Array.from(
this.videoContainer_.getElementsByClassName('shaka-settings-menu'));
this.menus_.push(...Array.from(
this.videoContainer_.getElementsByClassName('shaka-overflow-menu')));
this.contextMenus_ = Array.from(
this.videoContainer_.getElementsByClassName('shaka-context-menu'));
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_, 'touchend', (e) => {
this.onContainerTouch(e);
});
this.eventManager_.listen(this.controlsContainer_, 'click', () => {
this.onContainerClick();
});
this.eventManager_.listen(this.controlsContainer_, 'dblclick', () => {
if (this.config_.doubleClickForFullscreen &&
this.isFullScreenSupported()) {
this.toggleFullScreen();
}
});
}
/** @private */
addBigButtons_() {
const bigButtonsContainer = shaka.util.Dom.createHTMLElement('div');
bigButtonsContainer.classList.add('shaka-big-buttons-container');
this.controlsContainer_.appendChild(bigButtonsContainer);
const elementNamesToFactories =
shaka.ui.ControlsPanel.bigElementNamesToFactories_;
for (const name of this.config_.bigButtons) {
if (elementNamesToFactories.has(name)) {
const factory = elementNamesToFactories.get(name);
const element = factory.create(bigButtonsContainer, this);
this.elements_.push(element);
} else {
shaka.log.alwaysWarn(
'Unrecognized big button element requested:', name);
}
}
}
/** @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 skipButton = new shaka.ui.SkipAdButton(this.adPanel_, this);
this.elements_.push(skipButton);
}
/** @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);
const str = `<svg focusable="false" stroke="currentColor"
viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg"
width="50px" height="50px" class="q-spinner text-grey-9">
<g transform="translate(1 1)" stroke-width="6" fill="none"
fill-rule="evenodd">
<circle stroke-opacity=".5" cx="18" cy="18" r="16"></circle>
<path d="M34 18c0-9.94-8.06-16-16-16">
<animateTransform attributeName="transform" type="rotate"
from="0 18 18" to="360 18 18" dur="1s" repeatCount="indefinite">
</animateTransform>
</path>
</g>
</svg>`;
spinner.insertAdjacentHTML('beforeend', str);
}
/**
* Add fast-forward button on Controls container for moving video some
* seconds ahead when the video is tapped more than once, video seeks ahead
* some seconds for every extra tap.
* @private
*/
addFastForwardButtonOnControlsContainer_() {
const hiddenFastForwardContainer = shaka.util.Dom.createHTMLElement('div');
hiddenFastForwardContainer.classList.add(
'shaka-hidden-fast-forward-container');
this.controlsContainer_.appendChild(hiddenFastForwardContainer);
/** @private {shaka.ui.HiddenFastForwardButton} */
this.hiddenFastForwardButton_ =
new shaka.ui.HiddenFastForwardButton(hiddenFastForwardContainer, this);
this.elements_.push(this.hiddenFastForwardButton_);
}
/**
* Add Rewind button on Controls container for moving video some seconds
* behind when the video is tapped more than once, video seeks behind some
* seconds for every extra tap.
* @private
*/
addRewindButtonOnControlsContainer_() {
const hiddenRewindContainer = shaka.util.Dom.createHTMLElement('div');
hiddenRewindContainer.classList.add(
'shaka-hidden-rewind-container');
this.controlsContainer_.appendChild(hiddenRewindContainer);
/** @private {shaka.ui.HiddenRewindButton} */
this.hiddenRewindButton_ =
new shaka.ui.HiddenRewindButton(hiddenRewindContainer, this);
this.elements_.push(this.hiddenRewindButton_);
}
/** @private */
addControlsButtonPanel_() {
/** @private {!HTMLElement} */
this.topControls_ = shaka.util.Dom.createHTMLElement('div');
this.topControls_.classList.add('shaka-top-controls');
this.topControls_.classList.add('shaka-no-propagation');
this.controlsContainer_.appendChild(this.topControls_);
/** @private {!HTMLElement} */
this.topControlsButtonPanel_ = shaka.util.Dom.createHTMLElement('div');
this.topControlsButtonPanel_.classList.add(
'shaka-controls-top-button-panel');
this.topControlsButtonPanel_.classList.add(
'shaka-show-controls-on-mouse-over');
if (this.config_.enableTooltips) {
this.topControlsButtonPanel_.classList.add('shaka-tooltips-on');
this.topControlsButtonPanel_.classList.add('shaka-tooltips-bottom');
}
this.topControls_.appendChild(this.topControlsButtonPanel_);
// Create the elements specified by topControlPanelElements
for (const name of this.config_.topControlPanelElements) {
if (shaka.ui.ControlsPanel.elementNamesToFactories_.has(name)) {
const factory =
shaka.ui.ControlsPanel.elementNamesToFactories_.get(name);
const element = factory.create(this.topControlsButtonPanel_, this);
this.elements_.push(element);
if (name == 'time_and_duration') {
const adInfo =
new shaka.ui.AdInfo(this.topControlsButtonPanel_, this);
this.elements_.push(adInfo);
}
} else {
shaka.log.alwaysWarn(
'Unrecognized top control panel element requested:', name);
}
}
/** @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_();
this.addSeekBar_();
/** @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_.has(name)) {
const factory =
shaka.ui.ControlsPanel.elementNamesToFactories_.get(name);
const element = factory.create(this.controlsButtonPanel_, this);
this.elements_.push(element);
if (name == 'time_and_duration') {
const adInfo = new shaka.ui.AdInfo(this.controlsButtonPanel_, this);
this.elements_.push(adInfo);
}
} else {
shaka.log.alwaysWarn('Unrecognized control panel element requested:',
name);
}
}
}
/**
* Adds a seekbar depending on the configuration.
* By default an instance of shaka.ui.SeekBar is created
* This behaviour can be overridden 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');
}
// Tooltips need to be positioned lower if the seekbar is absent.
const controlsButtonPanel = this.controlsButtonPanel_;
if (controlsButtonPanel.classList.contains('shaka-tooltips-on')) {
controlsButtonPanel.classList.add('shaka-tooltips-low-position');
}
}
}
/**
* Adds several ads containers.
*
* @private
*/
addAdContainers_() {
/** @private {HTMLElement} */
this.clientSideAdContainer_ = shaka.util.Dom.createHTMLElement('div');
this.clientSideAdContainer_.classList.add('shaka-client-side-ad-container');
shaka.ui.Utils.setDisplay(this.clientSideAdContainer_, false);
this.eventManager_.listen(this.clientSideAdContainer_, 'click', () => {
this.onContainerClick();
});
this.videoContainer_.appendChild(this.clientSideAdContainer_);
/** @private {HTMLElement} */
this.serverSideAdContainer_ = shaka.util.Dom.createHTMLElement('div');
this.serverSideAdContainer_.classList.add('shaka-server-side-ad-container');
shaka.ui.Utils.setDisplay(this.serverSideAdContainer_, false);
this.eventManager_.listen(this.serverSideAdContainer_, 'click', () => {
this.onContainerClick();
});
this.videoContainer_.appendChild(this.serverSideAdContainer_);
}
/**
* 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());
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.videoContainer_, 'wheel', (e) => {
this.onMouseMove_(e);
}, {passive: true});
this.eventManager_.listen(this.castProxy_, 'caststatuschanged', () => {
this.onCastStatusChange_();
});
this.eventManager_.listen(this.vr_, 'vrstatuschanged', () => {
this.dispatchEvent(new shaka.util.FakeEvent('vrstatuschanged'));
});
this.eventManager_.listen(this.videoContainer_, 'keydown', (e) => {
if (!this.config_.enableKeyboardPlaybackControlsInWindow &&
!this.isFullScreenEnabled()) {
this.onControlsKeyDown_(/** @type {!KeyboardEvent} */(e));
}
});
this.eventManager_.listen(this.videoContainer_, 'keyup', (e) => {
if (!this.config_.enableKeyboardPlaybackControlsInWindow &&
!this.isFullScreenEnabled()) {
this.onControlsKeyUp_(/** @type {!KeyboardEvent} */(e));
}
});
this.eventManager_.listen(window, 'keydown', (e) => {
if (this.config_.enableKeyboardPlaybackControlsInWindow ||
this.isFullScreenEnabled()) {
this.onControlsKeyDown_(/** @type {!KeyboardEvent} */(e));
}
});
this.eventManager_.listen(window, 'keyup', (e) => {
if (this.config_.enableKeyboardPlaybackControlsInWindow ||
this.isFullScreenEnabled()) {
this.onControlsKeyUp_(/** @type {!KeyboardEvent} */(e));
}
});
this.eventManager_.listen(
this.adManager_, shaka.ads.Utils.AD_STARTED, () => {
this.ad_ = this.adManager_.getCurrentAd();
this.showAdUI();
this.onBufferingStateChange_();
});
this.eventManager_.listen(
this.adManager_, shaka.ads.Utils.AD_STOPPED, ()