UNPKG

shaka-player

Version:
1,700 lines (1,457 loc) 86.6 kB
/*! @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, ()