Source: ui/controls.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.ui.Controls');
  7. goog.provide('shaka.ui.ControlsPanel');
  8. goog.require('goog.asserts');
  9. goog.require('shaka.ads.Utils');
  10. goog.require('shaka.cast.CastProxy');
  11. goog.require('shaka.log');
  12. goog.require('shaka.ui.AdCounter');
  13. goog.require('shaka.ui.AdPosition');
  14. goog.require('shaka.ui.BigPlayButton');
  15. goog.require('shaka.ui.ContextMenu');
  16. goog.require('shaka.ui.HiddenFastForwardButton');
  17. goog.require('shaka.ui.HiddenRewindButton');
  18. goog.require('shaka.ui.Locales');
  19. goog.require('shaka.ui.Localization');
  20. goog.require('shaka.ui.SeekBar');
  21. goog.require('shaka.ui.SkipAdButton');
  22. goog.require('shaka.ui.Utils');
  23. goog.require('shaka.ui.VRManager');
  24. goog.require('shaka.util.Dom');
  25. goog.require('shaka.util.EventManager');
  26. goog.require('shaka.util.FakeEvent');
  27. goog.require('shaka.util.FakeEventTarget');
  28. goog.require('shaka.util.IDestroyable');
  29. goog.require('shaka.util.Timer');
  30. goog.requireType('shaka.Player');
  31. /**
  32. * A container for custom video controls.
  33. * @implements {shaka.util.IDestroyable}
  34. * @export
  35. */
  36. shaka.ui.Controls = class extends shaka.util.FakeEventTarget {
  37. /**
  38. * @param {!shaka.Player} player
  39. * @param {!HTMLElement} videoContainer
  40. * @param {!HTMLMediaElement} video
  41. * @param {?HTMLCanvasElement} vrCanvas
  42. * @param {shaka.extern.UIConfiguration} config
  43. */
  44. constructor(player, videoContainer, video, vrCanvas, config) {
  45. super();
  46. /** @private {boolean} */
  47. this.enabled_ = true;
  48. /** @private {shaka.extern.UIConfiguration} */
  49. this.config_ = config;
  50. /** @private {shaka.cast.CastProxy} */
  51. this.castProxy_ = new shaka.cast.CastProxy(
  52. video, player, this.config_.castReceiverAppId,
  53. this.config_.castAndroidReceiverCompatible);
  54. /** @private {boolean} */
  55. this.castAllowed_ = true;
  56. /** @private {HTMLMediaElement} */
  57. this.video_ = this.castProxy_.getVideo();
  58. /** @private {HTMLMediaElement} */
  59. this.localVideo_ = video;
  60. /** @private {shaka.Player} */
  61. this.player_ = this.castProxy_.getPlayer();
  62. /** @private {shaka.Player} */
  63. this.localPlayer_ = player;
  64. /** @private {!HTMLElement} */
  65. this.videoContainer_ = videoContainer;
  66. /** @private {?HTMLCanvasElement} */
  67. this.vrCanvas_ = vrCanvas;
  68. /** @private {shaka.extern.IAdManager} */
  69. this.adManager_ = this.player_.getAdManager();
  70. /** @private {?shaka.extern.IAd} */
  71. this.ad_ = null;
  72. /** @private {?shaka.extern.IUISeekBar} */
  73. this.seekBar_ = null;
  74. /** @private {boolean} */
  75. this.isSeeking_ = false;
  76. /** @private {!Array.<!HTMLElement>} */
  77. this.menus_ = [];
  78. /**
  79. * Individual controls which, when hovered or tab-focused, will force the
  80. * controls to be shown.
  81. * @private {!Array.<!Element>}
  82. */
  83. this.showOnHoverControls_ = [];
  84. /** @private {boolean} */
  85. this.recentMouseMovement_ = false;
  86. /**
  87. * This timer is used to detect when the user has stopped moving the mouse
  88. * and we should fade out the ui.
  89. *
  90. * @private {shaka.util.Timer}
  91. */
  92. this.mouseStillTimer_ = new shaka.util.Timer(() => {
  93. this.onMouseStill_();
  94. });
  95. /**
  96. * This timer is used to delay the fading of the UI.
  97. *
  98. * @private {shaka.util.Timer}
  99. */
  100. this.fadeControlsTimer_ = new shaka.util.Timer(() => {
  101. this.controlsContainer_.removeAttribute('shown');
  102. // If there's an overflow menu open, keep it this way for a couple of
  103. // seconds in case a user immediately initiates another mouse move to
  104. // interact with the menus. If that didn't happen, go ahead and hide
  105. // the menus.
  106. this.hideSettingsMenusTimer_.tickAfter(/* seconds= */ 2);
  107. });
  108. /**
  109. * This timer will be used to hide all settings menus. When the timer ticks
  110. * it will force all controls to invisible.
  111. *
  112. * Rather than calling the callback directly, |Controls| will always call it
  113. * through the timer to avoid conflicts.
  114. *
  115. * @private {shaka.util.Timer}
  116. */
  117. this.hideSettingsMenusTimer_ = new shaka.util.Timer(() => {
  118. for (const menu of this.menus_) {
  119. shaka.ui.Utils.setDisplay(menu, /* visible= */ false);
  120. }
  121. });
  122. /**
  123. * This timer is used to regularly update the time and seek range elements
  124. * so that we are communicating the current state as accurately as possibly.
  125. *
  126. * Unlike the other timers, this timer does not "own" the callback because
  127. * this timer is acting like a heartbeat.
  128. *
  129. * @private {shaka.util.Timer}
  130. */
  131. this.timeAndSeekRangeTimer_ = new shaka.util.Timer(() => {
  132. // Suppress timer-based updates if the controls are hidden.
  133. if (this.isOpaque()) {
  134. this.updateTimeAndSeekRange_();
  135. }
  136. });
  137. /** @private {?number} */
  138. this.lastTouchEventTime_ = null;
  139. /** @private {!Array.<!shaka.extern.IUIElement>} */
  140. this.elements_ = [];
  141. /** @private {shaka.ui.Localization} */
  142. this.localization_ = shaka.ui.Controls.createLocalization_();
  143. /** @private {shaka.util.EventManager} */
  144. this.eventManager_ = new shaka.util.EventManager();
  145. /** @private {?shaka.ui.VRManager} */
  146. this.vr_ = null;
  147. // Configure and create the layout of the controls
  148. this.configure(this.config_);
  149. this.addEventListeners_();
  150. this.setupMediaSession_();
  151. /**
  152. * The pressed keys set is used to record which keys are currently pressed
  153. * down, so we can know what keys are pressed at the same time.
  154. * Used by the focusInsideOverflowMenu_() function.
  155. * @private {!Set.<string>}
  156. */
  157. this.pressedKeys_ = new Set();
  158. // We might've missed a caststatuschanged event from the proxy between
  159. // the controls creation and initializing. Run onCastStatusChange_()
  160. // to ensure we have the casting state right.
  161. this.onCastStatusChange_();
  162. // Start this timer after we are finished initializing everything,
  163. this.timeAndSeekRangeTimer_.tickEvery(this.config_.refreshTickInSeconds);
  164. this.eventManager_.listen(this.localization_,
  165. shaka.ui.Localization.LOCALE_CHANGED, (e) => {
  166. const locale = e['locales'][0];
  167. this.adManager_.setLocale(locale);
  168. });
  169. this.adManager_.initInterstitial(
  170. this.getClientSideAdContainer(), this.localPlayer_, this.localVideo_);
  171. }
  172. /**
  173. * @override
  174. * @export
  175. */
  176. async destroy() {
  177. if (document.pictureInPictureElement == this.localVideo_) {
  178. await document.exitPictureInPicture();
  179. }
  180. if (this.eventManager_) {
  181. this.eventManager_.release();
  182. this.eventManager_ = null;
  183. }
  184. if (this.mouseStillTimer_) {
  185. this.mouseStillTimer_.stop();
  186. this.mouseStillTimer_ = null;
  187. }
  188. if (this.fadeControlsTimer_) {
  189. this.fadeControlsTimer_.stop();
  190. this.fadeControlsTimer_ = null;
  191. }
  192. if (this.hideSettingsMenusTimer_) {
  193. this.hideSettingsMenusTimer_.stop();
  194. this.hideSettingsMenusTimer_ = null;
  195. }
  196. if (this.timeAndSeekRangeTimer_) {
  197. this.timeAndSeekRangeTimer_.stop();
  198. this.timeAndSeekRangeTimer_ = null;
  199. }
  200. if (this.vr_) {
  201. this.vr_.release();
  202. this.vr_ = null;
  203. }
  204. // Important! Release all child elements before destroying the cast proxy
  205. // or player. This makes sure those destructions will not trigger event
  206. // listeners in the UI which would then invoke the cast proxy or player.
  207. this.releaseChildElements_();
  208. if (this.controlsContainer_) {
  209. this.videoContainer_.removeChild(this.controlsContainer_);
  210. this.controlsContainer_ = null;
  211. }
  212. if (this.castProxy_) {
  213. await this.castProxy_.destroy();
  214. this.castProxy_ = null;
  215. }
  216. if (this.spinnerContainer_) {
  217. this.videoContainer_.removeChild(this.spinnerContainer_);
  218. this.spinnerContainer_ = null;
  219. }
  220. if (this.clientAdContainer_) {
  221. this.videoContainer_.removeChild(this.clientAdContainer_);
  222. this.clientAdContainer_ = null;
  223. }
  224. if (this.localPlayer_) {
  225. await this.localPlayer_.destroy();
  226. this.localPlayer_ = null;
  227. }
  228. this.player_ = null;
  229. this.localVideo_ = null;
  230. this.video_ = null;
  231. this.localization_ = null;
  232. this.pressedKeys_.clear();
  233. this.removeMediaSession_();
  234. // FakeEventTarget implements IReleasable
  235. super.release();
  236. }
  237. /** @private */
  238. releaseChildElements_() {
  239. for (const element of this.elements_) {
  240. element.release();
  241. }
  242. this.elements_ = [];
  243. }
  244. /**
  245. * @param {string} name
  246. * @param {!shaka.extern.IUIElement.Factory} factory
  247. * @export
  248. */
  249. static registerElement(name, factory) {
  250. shaka.ui.ControlsPanel.elementNamesToFactories_.set(name, factory);
  251. }
  252. /**
  253. * @param {!shaka.extern.IUISeekBar.Factory} factory
  254. * @export
  255. */
  256. static registerSeekBar(factory) {
  257. shaka.ui.ControlsPanel.seekBarFactory_ = factory;
  258. }
  259. /**
  260. * This allows the application to inhibit casting.
  261. *
  262. * @param {boolean} allow
  263. * @export
  264. */
  265. allowCast(allow) {
  266. this.castAllowed_ = allow;
  267. this.onCastStatusChange_();
  268. }
  269. /**
  270. * Used by the application to notify the controls that a load operation is
  271. * complete. This allows the controls to recalculate play/paused state, which
  272. * is important for platforms like Android where autoplay is disabled.
  273. * @export
  274. */
  275. loadComplete() {
  276. // If we are on Android or if autoplay is false, video.paused should be
  277. // true. Otherwise, video.paused is false and the content is autoplaying.
  278. this.onPlayStateChange_();
  279. }
  280. /**
  281. * @param {!shaka.extern.UIConfiguration} config
  282. * @export
  283. */
  284. configure(config) {
  285. this.config_ = config;
  286. this.castProxy_.changeReceiverId(config.castReceiverAppId,
  287. config.castAndroidReceiverCompatible);
  288. // Deconstruct the old layout if applicable
  289. if (this.seekBar_) {
  290. this.seekBar_ = null;
  291. }
  292. if (this.playButton_) {
  293. this.playButton_ = null;
  294. }
  295. if (this.contextMenu_) {
  296. this.contextMenu_ = null;
  297. }
  298. if (this.vr_) {
  299. this.vr_.configure(config);
  300. }
  301. if (this.controlsContainer_) {
  302. shaka.util.Dom.removeAllChildren(this.controlsContainer_);
  303. this.releaseChildElements_();
  304. } else {
  305. this.addControlsContainer_();
  306. // The client-side ad container is only created once, and is never
  307. // re-created or uprooted in the DOM, even when the DOM is re-created,
  308. // since that seemingly breaks the IMA SDK.
  309. this.addClientAdContainer_();
  310. goog.asserts.assert(
  311. this.controlsContainer_, 'Should have a controlsContainer_!');
  312. goog.asserts.assert(this.localVideo_, 'Should have a localVideo_!');
  313. goog.asserts.assert(this.player_, 'Should have a player_!');
  314. this.vr_ = new shaka.ui.VRManager(this.controlsContainer_, this.vrCanvas_,
  315. this.localVideo_, this.player_, this.config_);
  316. }
  317. // Create the new layout
  318. this.createDOM_();
  319. // Init the play state
  320. this.onPlayStateChange_();
  321. // Elements that should not propagate clicks (controls panel, menus)
  322. const noPropagationElements = this.videoContainer_.getElementsByClassName(
  323. 'shaka-no-propagation');
  324. for (const element of noPropagationElements) {
  325. const cb = (event) => event.stopPropagation();
  326. this.eventManager_.listen(element, 'click', cb);
  327. this.eventManager_.listen(element, 'dblclick', cb);
  328. }
  329. }
  330. /**
  331. * Enable or disable the custom controls. Enabling disables native
  332. * browser controls.
  333. *
  334. * @param {boolean} enabled
  335. * @export
  336. */
  337. setEnabledShakaControls(enabled) {
  338. this.enabled_ = enabled;
  339. if (enabled) {
  340. this.videoContainer_.setAttribute('shaka-controls', 'true');
  341. // If we're hiding native controls, make sure the video element itself is
  342. // not tab-navigable. Our custom controls will still be tab-navigable.
  343. this.localVideo_.tabIndex = -1;
  344. this.localVideo_.controls = false;
  345. } else {
  346. this.videoContainer_.removeAttribute('shaka-controls');
  347. }
  348. // The effects of play state changes are inhibited while showing native
  349. // browser controls. Recalculate that state now.
  350. this.onPlayStateChange_();
  351. }
  352. /**
  353. * Enable or disable native browser controls. Enabling disables shaka
  354. * controls.
  355. *
  356. * @param {boolean} enabled
  357. * @export
  358. */
  359. setEnabledNativeControls(enabled) {
  360. // If we enable the native controls, the element must be tab-navigable.
  361. // If we disable the native controls, we want to make sure that the video
  362. // element itself is not tab-navigable, so that the element is skipped over
  363. // when tabbing through the page.
  364. this.localVideo_.controls = enabled;
  365. this.localVideo_.tabIndex = enabled ? 0 : -1;
  366. if (enabled) {
  367. this.setEnabledShakaControls(false);
  368. }
  369. }
  370. /**
  371. * @export
  372. * @return {?shaka.extern.IAd}
  373. */
  374. getAd() {
  375. return this.ad_;
  376. }
  377. /**
  378. * @export
  379. * @return {shaka.cast.CastProxy}
  380. */
  381. getCastProxy() {
  382. return this.castProxy_;
  383. }
  384. /**
  385. * @return {shaka.ui.Localization}
  386. * @export
  387. */
  388. getLocalization() {
  389. return this.localization_;
  390. }
  391. /**
  392. * @return {!HTMLElement}
  393. * @export
  394. */
  395. getVideoContainer() {
  396. return this.videoContainer_;
  397. }
  398. /**
  399. * @return {HTMLMediaElement}
  400. * @export
  401. */
  402. getVideo() {
  403. return this.video_;
  404. }
  405. /**
  406. * @return {HTMLMediaElement}
  407. * @export
  408. */
  409. getLocalVideo() {
  410. return this.localVideo_;
  411. }
  412. /**
  413. * @return {shaka.Player}
  414. * @export
  415. */
  416. getPlayer() {
  417. return this.player_;
  418. }
  419. /**
  420. * @return {shaka.Player}
  421. * @export
  422. */
  423. getLocalPlayer() {
  424. return this.localPlayer_;
  425. }
  426. /**
  427. * @return {!HTMLElement}
  428. * @export
  429. */
  430. getControlsContainer() {
  431. goog.asserts.assert(
  432. this.controlsContainer_, 'No controls container after destruction!');
  433. return this.controlsContainer_;
  434. }
  435. /**
  436. * @return {!HTMLElement}
  437. * @export
  438. */
  439. getServerSideAdContainer() {
  440. return this.daiAdContainer_;
  441. }
  442. /**
  443. * @return {!HTMLElement}
  444. * @export
  445. */
  446. getClientSideAdContainer() {
  447. goog.asserts.assert(
  448. this.clientAdContainer_, 'No client ad container after destruction!');
  449. return this.clientAdContainer_;
  450. }
  451. /**
  452. * @return {!shaka.extern.UIConfiguration}
  453. * @export
  454. */
  455. getConfig() {
  456. return this.config_;
  457. }
  458. /**
  459. * @return {boolean}
  460. * @export
  461. */
  462. isSeeking() {
  463. return this.isSeeking_;
  464. }
  465. /**
  466. * @param {boolean} seeking
  467. * @export
  468. */
  469. setSeeking(seeking) {
  470. this.isSeeking_ = seeking;
  471. }
  472. /**
  473. * @return {boolean}
  474. * @export
  475. */
  476. isCastAllowed() {
  477. return this.castAllowed_;
  478. }
  479. /**
  480. * @return {number}
  481. * @export
  482. */
  483. getDisplayTime() {
  484. return this.seekBar_ ? this.seekBar_.getValue() : this.video_.currentTime;
  485. }
  486. /**
  487. * @param {?number} time
  488. * @export
  489. */
  490. setLastTouchEventTime(time) {
  491. this.lastTouchEventTime_ = time;
  492. }
  493. /**
  494. * @return {boolean}
  495. * @export
  496. */
  497. anySettingsMenusAreOpen() {
  498. return this.menus_.some(
  499. (menu) => !menu.classList.contains('shaka-hidden'));
  500. }
  501. /** @export */
  502. hideSettingsMenus() {
  503. this.hideSettingsMenusTimer_.tickNow();
  504. }
  505. /**
  506. * @return {boolean}
  507. * @export
  508. */
  509. isFullScreenSupported() {
  510. if (document.fullscreenEnabled) {
  511. return true;
  512. }
  513. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  514. if (video.webkitSupportsFullscreen) {
  515. return true;
  516. }
  517. return false;
  518. }
  519. /**
  520. * @return {boolean}
  521. * @export
  522. */
  523. isFullScreenEnabled() {
  524. if (document.fullscreenEnabled) {
  525. return !!document.fullscreenElement;
  526. }
  527. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  528. if (video.webkitSupportsFullscreen) {
  529. return video.webkitDisplayingFullscreen;
  530. }
  531. return false;
  532. }
  533. /** @private */
  534. async enterFullScreen_() {
  535. try {
  536. if (document.fullscreenEnabled) {
  537. if (document.pictureInPictureElement) {
  538. await document.exitPictureInPicture();
  539. }
  540. const fullScreenElement = this.config_.fullScreenElement;
  541. await fullScreenElement.requestFullscreen({navigationUI: 'hide'});
  542. if (this.config_.forceLandscapeOnFullscreen && screen.orientation) {
  543. // Locking to 'landscape' should let it be either
  544. // 'landscape-primary' or 'landscape-secondary' as appropriate.
  545. // We ignore errors from this specific call, since it creates noise
  546. // on desktop otherwise.
  547. try {
  548. await screen.orientation.lock('landscape');
  549. } catch (error) {}
  550. }
  551. } else {
  552. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  553. if (video.webkitSupportsFullscreen) {
  554. video.webkitEnterFullscreen();
  555. }
  556. }
  557. } catch (error) {
  558. // Entering fullscreen can fail without user interaction.
  559. this.dispatchEvent(new shaka.util.FakeEvent(
  560. 'error', (new Map()).set('detail', error)));
  561. }
  562. }
  563. /** @private */
  564. async exitFullScreen_() {
  565. if (document.fullscreenEnabled) {
  566. if (screen.orientation) {
  567. screen.orientation.unlock();
  568. }
  569. await document.exitFullscreen();
  570. } else {
  571. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  572. if (video.webkitSupportsFullscreen) {
  573. video.webkitExitFullscreen();
  574. }
  575. }
  576. }
  577. /** @export */
  578. async toggleFullScreen() {
  579. if (this.isFullScreenEnabled()) {
  580. await this.exitFullScreen_();
  581. } else {
  582. await this.enterFullScreen_();
  583. }
  584. }
  585. /**
  586. * @return {boolean}
  587. * @export
  588. */
  589. isPiPAllowed() {
  590. if (this.castProxy_.isCasting()) {
  591. return false;
  592. }
  593. if ('documentPictureInPicture' in window &&
  594. this.config_.preferDocumentPictureInPicture) {
  595. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  596. return !video.disablePictureInPicture;
  597. }
  598. if (document.pictureInPictureEnabled) {
  599. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  600. return !video.disablePictureInPicture;
  601. }
  602. return false;
  603. }
  604. /**
  605. * @return {boolean}
  606. * @export
  607. */
  608. isPiPEnabled() {
  609. if ('documentPictureInPicture' in window &&
  610. this.config_.preferDocumentPictureInPicture) {
  611. return !!window.documentPictureInPicture.window;
  612. } else {
  613. return !!document.pictureInPictureElement;
  614. }
  615. }
  616. /** @export */
  617. async togglePiP() {
  618. try {
  619. if ('documentPictureInPicture' in window &&
  620. this.config_.preferDocumentPictureInPicture) {
  621. await this.toggleDocumentPictureInPicture_();
  622. } else if (!document.pictureInPictureElement) {
  623. // If you were fullscreen, leave fullscreen first.
  624. if (document.fullscreenElement) {
  625. document.exitFullscreen();
  626. }
  627. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  628. await video.requestPictureInPicture();
  629. } else {
  630. await document.exitPictureInPicture();
  631. }
  632. } catch (error) {
  633. this.dispatchEvent(new shaka.util.FakeEvent(
  634. 'error', (new Map()).set('detail', error)));
  635. }
  636. }
  637. /**
  638. * The Document Picture-in-Picture API makes it possible to open an
  639. * always-on-top window that can be populated with arbitrary HTML content.
  640. * https://developer.chrome.com/docs/web-platform/document-picture-in-picture
  641. * @private
  642. */
  643. async toggleDocumentPictureInPicture_() {
  644. // Close Picture-in-Picture window if any.
  645. if (window.documentPictureInPicture.window) {
  646. window.documentPictureInPicture.window.close();
  647. return;
  648. }
  649. // Open a Picture-in-Picture window.
  650. const pipPlayer = this.videoContainer_;
  651. const rectPipPlayer = pipPlayer.getBoundingClientRect();
  652. const pipWindow = await window.documentPictureInPicture.requestWindow({
  653. width: rectPipPlayer.width,
  654. height: rectPipPlayer.height,
  655. });
  656. // Copy style sheets to the Picture-in-Picture window.
  657. this.copyStyleSheetsToWindow_(pipWindow);
  658. // Add placeholder for the player.
  659. const parentPlayer = pipPlayer.parentNode || document.body;
  660. const placeholder = this.videoContainer_.cloneNode(true);
  661. placeholder.style.visibility = 'hidden';
  662. placeholder.style.height = getComputedStyle(pipPlayer).height;
  663. parentPlayer.appendChild(placeholder);
  664. // Make sure player fits in the Picture-in-Picture window.
  665. const styles = document.createElement('style');
  666. styles.append(`[data-shaka-player-container] {
  667. width: 100% !important; max-height: 100%}`);
  668. pipWindow.document.head.append(styles);
  669. // Move player to the Picture-in-Picture window.
  670. pipWindow.document.body.append(pipPlayer);
  671. // Listen for the PiP closing event to move the player back.
  672. this.eventManager_.listenOnce(pipWindow, 'pagehide', () => {
  673. placeholder.replaceWith(/** @type {!Node} */(pipPlayer));
  674. });
  675. }
  676. /** @private */
  677. copyStyleSheetsToWindow_(win) {
  678. const styleSheets = /** @type {!Iterable<*>} */(document.styleSheets);
  679. const allCSS = [...styleSheets]
  680. .map((sheet) => {
  681. try {
  682. return [...sheet.cssRules].map((rule) => rule.cssText).join('');
  683. } catch (e) {
  684. const link = /** @type {!HTMLLinkElement} */(
  685. document.createElement('link'));
  686. link.rel = 'stylesheet';
  687. link.type = sheet.type;
  688. link.media = sheet.media;
  689. link.href = sheet.href;
  690. win.document.head.appendChild(link);
  691. }
  692. return '';
  693. })
  694. .filter(Boolean)
  695. .join('\n');
  696. const style = document.createElement('style');
  697. style.textContent = allCSS;
  698. win.document.head.appendChild(style);
  699. }
  700. /** @export */
  701. showAdUI() {
  702. shaka.ui.Utils.setDisplay(this.adPanel_, true);
  703. shaka.ui.Utils.setDisplay(this.clientAdContainer_, true);
  704. this.controlsContainer_.setAttribute('ad-active', 'true');
  705. }
  706. /** @export */
  707. hideAdUI() {
  708. shaka.ui.Utils.setDisplay(this.adPanel_, false);
  709. shaka.ui.Utils.setDisplay(this.clientAdContainer_, false);
  710. this.controlsContainer_.removeAttribute('ad-active');
  711. }
  712. /**
  713. * Play or pause the current presentation.
  714. */
  715. playPausePresentation() {
  716. if (!this.enabled_) {
  717. return;
  718. }
  719. if (!this.video_.duration) {
  720. // Can't play yet. Ignore.
  721. return;
  722. }
  723. this.player_.cancelTrickPlay();
  724. if (this.presentationIsPaused()) {
  725. this.video_.play();
  726. } else {
  727. this.video_.pause();
  728. }
  729. }
  730. /**
  731. * Play or pause the current ad.
  732. */
  733. playPauseAd() {
  734. if (this.ad_ && this.ad_.isPaused()) {
  735. this.ad_.play();
  736. } else if (this.ad_) {
  737. this.ad_.pause();
  738. }
  739. }
  740. /**
  741. * Return true if the presentation is paused.
  742. *
  743. * @return {boolean}
  744. */
  745. presentationIsPaused() {
  746. // The video element is in a paused state while seeking, but we don't count
  747. // that.
  748. return this.video_.paused && !this.isSeeking();
  749. }
  750. /** @private */
  751. createDOM_() {
  752. this.videoContainer_.classList.add('shaka-video-container');
  753. this.localVideo_.classList.add('shaka-video');
  754. this.addScrimContainer_();
  755. if (this.config_.addBigPlayButton) {
  756. this.addPlayButton_();
  757. }
  758. if (this.config_.customContextMenu) {
  759. this.addContextMenu_();
  760. }
  761. if (!this.spinnerContainer_) {
  762. this.addBufferingSpinner_();
  763. }
  764. if (this.config_.seekOnTaps) {
  765. this.addFastForwardButtonOnControlsContainer_();
  766. this.addRewindButtonOnControlsContainer_();
  767. }
  768. this.addDaiAdContainer_();
  769. this.addControlsButtonPanel_();
  770. this.menus_ = Array.from(
  771. this.videoContainer_.getElementsByClassName('shaka-settings-menu'));
  772. this.menus_.push(...Array.from(
  773. this.videoContainer_.getElementsByClassName('shaka-overflow-menu')));
  774. this.addSeekBar_();
  775. this.showOnHoverControls_ = Array.from(
  776. this.videoContainer_.getElementsByClassName(
  777. 'shaka-show-controls-on-mouse-over'));
  778. }
  779. /** @private */
  780. addControlsContainer_() {
  781. /** @private {HTMLElement} */
  782. this.controlsContainer_ = shaka.util.Dom.createHTMLElement('div');
  783. this.controlsContainer_.classList.add('shaka-controls-container');
  784. this.videoContainer_.appendChild(this.controlsContainer_);
  785. // Use our controls by default, without anyone calling
  786. // setEnabledShakaControls:
  787. this.videoContainer_.setAttribute('shaka-controls', 'true');
  788. this.eventManager_.listen(this.controlsContainer_, 'touchstart', (e) => {
  789. this.onContainerTouch_(e);
  790. }, {passive: false});
  791. this.eventManager_.listen(this.controlsContainer_, 'click', () => {
  792. this.onContainerClick_();
  793. });
  794. this.eventManager_.listen(this.controlsContainer_, 'dblclick', () => {
  795. if (this.config_.doubleClickForFullscreen &&
  796. this.isFullScreenSupported()) {
  797. this.toggleFullScreen();
  798. }
  799. });
  800. }
  801. /** @private */
  802. addPlayButton_() {
  803. const playButtonContainer = shaka.util.Dom.createHTMLElement('div');
  804. playButtonContainer.classList.add('shaka-play-button-container');
  805. this.controlsContainer_.appendChild(playButtonContainer);
  806. /** @private {shaka.ui.BigPlayButton} */
  807. this.playButton_ =
  808. new shaka.ui.BigPlayButton(playButtonContainer, this);
  809. this.elements_.push(this.playButton_);
  810. }
  811. /** @private */
  812. addContextMenu_() {
  813. /** @private {shaka.ui.ContextMenu} */
  814. this.contextMenu_ =
  815. new shaka.ui.ContextMenu(this.controlsButtonPanel_, this);
  816. this.elements_.push(this.contextMenu_);
  817. }
  818. /** @private */
  819. addScrimContainer_() {
  820. // This is the container that gets styled by CSS to have the
  821. // black gradient scrim at the end of the controls.
  822. const scrimContainer = shaka.util.Dom.createHTMLElement('div');
  823. scrimContainer.classList.add('shaka-scrim-container');
  824. this.controlsContainer_.appendChild(scrimContainer);
  825. }
  826. /** @private */
  827. addAdControls_() {
  828. /** @private {!HTMLElement} */
  829. this.adPanel_ = shaka.util.Dom.createHTMLElement('div');
  830. this.adPanel_.classList.add('shaka-ad-controls');
  831. const showAdPanel = this.ad_ != null && this.ad_.isLinear();
  832. shaka.ui.Utils.setDisplay(this.adPanel_, showAdPanel);
  833. this.bottomControls_.appendChild(this.adPanel_);
  834. const adPosition = new shaka.ui.AdPosition(this.adPanel_, this);
  835. this.elements_.push(adPosition);
  836. const adCounter = new shaka.ui.AdCounter(this.adPanel_, this);
  837. this.elements_.push(adCounter);
  838. const skipButton = new shaka.ui.SkipAdButton(this.adPanel_, this);
  839. this.elements_.push(skipButton);
  840. }
  841. /** @private */
  842. addBufferingSpinner_() {
  843. /** @private {HTMLElement} */
  844. this.spinnerContainer_ = shaka.util.Dom.createHTMLElement('div');
  845. this.spinnerContainer_.classList.add('shaka-spinner-container');
  846. this.videoContainer_.appendChild(this.spinnerContainer_);
  847. const spinner = shaka.util.Dom.createHTMLElement('div');
  848. spinner.classList.add('shaka-spinner');
  849. this.spinnerContainer_.appendChild(spinner);
  850. // Svg elements have to be created with the svg xml namespace.
  851. const xmlns = 'http://www.w3.org/2000/svg';
  852. const svg =
  853. /** @type {!HTMLElement} */(document.createElementNS(xmlns, 'svg'));
  854. svg.classList.add('shaka-spinner-svg');
  855. svg.setAttribute('viewBox', '0 0 30 30');
  856. spinner.appendChild(svg);
  857. // These coordinates are relative to the SVG viewBox above. This is
  858. // distinct from the actual display size in the page, since the "S" is for
  859. // "Scalable." The radius of 14.5 is so that the edges of the 1-px-wide
  860. // stroke will touch the edges of the viewBox.
  861. const spinnerCircle = document.createElementNS(xmlns, 'circle');
  862. spinnerCircle.classList.add('shaka-spinner-path');
  863. spinnerCircle.setAttribute('cx', '15');
  864. spinnerCircle.setAttribute('cy', '15');
  865. spinnerCircle.setAttribute('r', '14.5');
  866. spinnerCircle.setAttribute('fill', 'none');
  867. spinnerCircle.setAttribute('stroke-width', '1');
  868. spinnerCircle.setAttribute('stroke-miterlimit', '10');
  869. svg.appendChild(spinnerCircle);
  870. }
  871. /**
  872. * Add fast-forward button on Controls container for moving video some
  873. * seconds ahead when the video is tapped more than once, video seeks ahead
  874. * some seconds for every extra tap.
  875. * @private
  876. */
  877. addFastForwardButtonOnControlsContainer_() {
  878. const hiddenFastForwardContainer = shaka.util.Dom.createHTMLElement('div');
  879. hiddenFastForwardContainer.classList.add(
  880. 'shaka-hidden-fast-forward-container');
  881. this.controlsContainer_.appendChild(hiddenFastForwardContainer);
  882. /** @private {shaka.ui.HiddenFastForwardButton} */
  883. this.hiddenFastForwardButton_ =
  884. new shaka.ui.HiddenFastForwardButton(hiddenFastForwardContainer, this);
  885. this.elements_.push(this.hiddenFastForwardButton_);
  886. }
  887. /**
  888. * Add Rewind button on Controls container for moving video some seconds
  889. * behind when the video is tapped more than once, video seeks behind some
  890. * seconds for every extra tap.
  891. * @private
  892. */
  893. addRewindButtonOnControlsContainer_() {
  894. const hiddenRewindContainer = shaka.util.Dom.createHTMLElement('div');
  895. hiddenRewindContainer.classList.add(
  896. 'shaka-hidden-rewind-container');
  897. this.controlsContainer_.appendChild(hiddenRewindContainer);
  898. /** @private {shaka.ui.HiddenRewindButton} */
  899. this.hiddenRewindButton_ =
  900. new shaka.ui.HiddenRewindButton(hiddenRewindContainer, this);
  901. this.elements_.push(this.hiddenRewindButton_);
  902. }
  903. /** @private */
  904. addControlsButtonPanel_() {
  905. /** @private {!HTMLElement} */
  906. this.bottomControls_ = shaka.util.Dom.createHTMLElement('div');
  907. this.bottomControls_.classList.add('shaka-bottom-controls');
  908. this.bottomControls_.classList.add('shaka-no-propagation');
  909. this.controlsContainer_.appendChild(this.bottomControls_);
  910. // Overflow menus are supposed to hide once you click elsewhere
  911. // on the page. The click event listener on window ensures that.
  912. // However, clicks on the bottom controls don't propagate to the container,
  913. // so we have to explicitly hide the menus onclick here.
  914. this.eventManager_.listen(this.bottomControls_, 'click', (e) => {
  915. // We explicitly deny this measure when clicking on buttons that
  916. // open submenus in the control panel.
  917. if (!e.target['closest']('.shaka-overflow-button')) {
  918. this.hideSettingsMenus();
  919. }
  920. });
  921. this.addAdControls_();
  922. /** @private {!HTMLElement} */
  923. this.controlsButtonPanel_ = shaka.util.Dom.createHTMLElement('div');
  924. this.controlsButtonPanel_.classList.add('shaka-controls-button-panel');
  925. this.controlsButtonPanel_.classList.add(
  926. 'shaka-show-controls-on-mouse-over');
  927. if (this.config_.enableTooltips) {
  928. this.controlsButtonPanel_.classList.add('shaka-tooltips-on');
  929. }
  930. this.bottomControls_.appendChild(this.controlsButtonPanel_);
  931. // Create the elements specified by controlPanelElements
  932. for (const name of this.config_.controlPanelElements) {
  933. if (shaka.ui.ControlsPanel.elementNamesToFactories_.get(name)) {
  934. const factory =
  935. shaka.ui.ControlsPanel.elementNamesToFactories_.get(name);
  936. const element = factory.create(this.controlsButtonPanel_, this);
  937. this.elements_.push(element);
  938. } else {
  939. shaka.log.alwaysWarn('Unrecognized control panel element requested:',
  940. name);
  941. }
  942. }
  943. }
  944. /**
  945. * Adds a container for server side ad UI with IMA SDK.
  946. *
  947. * @private
  948. */
  949. addDaiAdContainer_() {
  950. /** @private {!HTMLElement} */
  951. this.daiAdContainer_ = shaka.util.Dom.createHTMLElement('div');
  952. this.daiAdContainer_.classList.add('shaka-server-side-ad-container');
  953. this.controlsContainer_.appendChild(this.daiAdContainer_);
  954. }
  955. /**
  956. * Adds a seekbar depending on the configuration.
  957. * By default an instance of shaka.ui.SeekBar is created
  958. * This behaviour can be overriden by providing a SeekBar factory using the
  959. * registerSeekBarFactory function.
  960. *
  961. * @private
  962. */
  963. addSeekBar_() {
  964. if (this.config_.addSeekBar) {
  965. this.seekBar_ = shaka.ui.ControlsPanel.seekBarFactory_.create(
  966. this.bottomControls_, this);
  967. this.elements_.push(this.seekBar_);
  968. } else {
  969. // Settings menus need to be positioned lower if the seekbar is absent.
  970. for (const menu of this.menus_) {
  971. menu.classList.add('shaka-low-position');
  972. }
  973. }
  974. }
  975. /**
  976. * Adds a container for client side ad UI with IMA SDK.
  977. *
  978. * @private
  979. */
  980. addClientAdContainer_() {
  981. /** @private {HTMLElement} */
  982. this.clientAdContainer_ = shaka.util.Dom.createHTMLElement('div');
  983. this.clientAdContainer_.classList.add('shaka-client-side-ad-container');
  984. shaka.ui.Utils.setDisplay(this.clientAdContainer_, false);
  985. this.eventManager_.listen(this.clientAdContainer_, 'click', () => {
  986. this.onContainerClick_();
  987. });
  988. this.videoContainer_.appendChild(this.clientAdContainer_);
  989. }
  990. /**
  991. * Adds static event listeners. This should only add event listeners to
  992. * things that don't change (e.g. Player). Dynamic elements (e.g. controls)
  993. * should have their event listeners added when they are created.
  994. *
  995. * @private
  996. */
  997. addEventListeners_() {
  998. this.eventManager_.listen(this.player_, 'buffering', () => {
  999. this.onBufferingStateChange_();
  1000. });
  1001. // Set the initial state, as well.
  1002. this.onBufferingStateChange_();
  1003. // Listen for key down events to detect tab and enable outline
  1004. // for focused elements.
  1005. this.eventManager_.listen(window, 'keydown', (e) => {
  1006. this.onWindowKeyDown_(/** @type {!KeyboardEvent} */(e));
  1007. });
  1008. // Listen for click events to dismiss the settings menus.
  1009. this.eventManager_.listen(window, 'click', () => this.hideSettingsMenus());
  1010. // Avoid having multiple submenus open at the same time.
  1011. this.eventManager_.listen(
  1012. this, 'submenuopen', () => {
  1013. this.hideSettingsMenus();
  1014. });
  1015. this.eventManager_.listen(this.video_, 'play', () => {
  1016. this.onPlayStateChange_();
  1017. });
  1018. this.eventManager_.listen(this.video_, 'pause', () => {
  1019. this.onPlayStateChange_();
  1020. });
  1021. this.eventManager_.listen(this.videoContainer_, 'mousemove', (e) => {
  1022. this.onMouseMove_(e);
  1023. });
  1024. this.eventManager_.listen(this.videoContainer_, 'touchmove', (e) => {
  1025. this.onMouseMove_(e);
  1026. }, {passive: true});
  1027. this.eventManager_.listen(this.videoContainer_, 'touchend', (e) => {
  1028. this.onMouseMove_(e);
  1029. }, {passive: true});
  1030. this.eventManager_.listen(this.videoContainer_, 'mouseleave', () => {
  1031. this.onMouseLeave_();
  1032. });
  1033. this.eventManager_.listen(this.castProxy_, 'caststatuschanged', () => {
  1034. this.onCastStatusChange_();
  1035. });
  1036. this.eventManager_.listen(this.vr_, 'vrstatuschanged', () => {
  1037. this.dispatchEvent(new shaka.util.FakeEvent('vrstatuschanged'));
  1038. });
  1039. this.eventManager_.listen(this.videoContainer_, 'keydown', (e) => {
  1040. this.onControlsKeyDown_(/** @type {!KeyboardEvent} */(e));
  1041. });
  1042. this.eventManager_.listen(this.videoContainer_, 'keyup', (e) => {
  1043. this.onControlsKeyUp_(/** @type {!KeyboardEvent} */(e));
  1044. });
  1045. this.eventManager_.listen(
  1046. this.adManager_, shaka.ads.Utils.AD_STARTED, (e) => {
  1047. this.ad_ = (/** @type {!Object} */ (e))['ad'];
  1048. this.showAdUI();
  1049. this.onBufferingStateChange_();
  1050. });
  1051. this.eventManager_.listen(
  1052. this.adManager_, shaka.ads.Utils.AD_STOPPED, () => {
  1053. this.ad_ = null;
  1054. this.hideAdUI();
  1055. this.onBufferingStateChange_();
  1056. });
  1057. if (screen.orientation) {
  1058. this.eventManager_.listen(screen.orientation, 'change', async () => {
  1059. await this.onScreenRotation_();
  1060. });
  1061. }
  1062. }
  1063. /**
  1064. * @private
  1065. */
  1066. setupMediaSession_() {
  1067. if (!this.config_.setupMediaSession || !navigator.mediaSession) {
  1068. return;
  1069. }
  1070. const addMediaSessionHandler = (type, callback) => {
  1071. try {
  1072. navigator.mediaSession.setActionHandler(type, (details) => {
  1073. callback(details);
  1074. });
  1075. } catch (error) {
  1076. shaka.log.debug(
  1077. `The "${type}" media session action is not supported.`);
  1078. }
  1079. };
  1080. const updatePositionState = () => {
  1081. const seekRange = this.player_.seekRange();
  1082. let duration = seekRange.end - seekRange.start;
  1083. const position = parseFloat(
  1084. (this.video_.currentTime - seekRange.start).toFixed(2));
  1085. if (this.player_.isLive() && Math.abs(duration - position) < 1) {
  1086. // Positive infinity indicates media without a defined end, such as a
  1087. // live stream.
  1088. duration = Infinity;
  1089. }
  1090. try {
  1091. if ((this.ad_ && this.ad_.isLinear())) {
  1092. navigator.mediaSession.setPositionState();
  1093. } else {
  1094. navigator.mediaSession.setPositionState({
  1095. duration: Math.max(0, duration),
  1096. playbackRate: this.video_.playbackRate,
  1097. position: Math.max(0, position),
  1098. });
  1099. }
  1100. } catch (error) {
  1101. shaka.log.v2(
  1102. 'setPositionState in media session is not supported.');
  1103. }
  1104. };
  1105. const commonHandler = (details) => {
  1106. const keyboardSeekDistance = this.config_.keyboardSeekDistance;
  1107. const seekRange = this.player_.seekRange();
  1108. switch (details.action) {
  1109. case 'pause':
  1110. this.onPlayPauseClick_();
  1111. break;
  1112. case 'play':
  1113. this.onPlayPauseClick_();
  1114. break;
  1115. case 'seekbackward':
  1116. if (!this.ad_ || !this.ad_.isLinear()) {
  1117. this.seek_(this.seekBar_.getValue() -
  1118. (details.seekOffset || keyboardSeekDistance));
  1119. }
  1120. break;
  1121. case 'seekforward':
  1122. if (!this.ad_ || !this.ad_.isLinear()) {
  1123. this.seek_(this.seekBar_.getValue() +
  1124. (details.seekOffset || keyboardSeekDistance));
  1125. }
  1126. break;
  1127. case 'seekto':
  1128. if (!this.ad_ || !this.ad_.isLinear()) {
  1129. this.seek_(seekRange.start + details.seekTime);
  1130. }
  1131. break;
  1132. case 'stop':
  1133. this.player_.unload();
  1134. break;
  1135. case 'enterpictureinpicture':
  1136. if (!this.ad_ || !this.ad_.isLinear()) {
  1137. this.togglePiP();
  1138. }
  1139. break;
  1140. }
  1141. };
  1142. addMediaSessionHandler('pause', commonHandler);
  1143. addMediaSessionHandler('play', commonHandler);
  1144. addMediaSessionHandler('seekbackward', commonHandler);
  1145. addMediaSessionHandler('seekforward', commonHandler);
  1146. addMediaSessionHandler('seekto', commonHandler);
  1147. addMediaSessionHandler('stop', commonHandler);
  1148. if ('documentPictureInPicture' in window ||
  1149. document.pictureInPictureEnabled) {
  1150. addMediaSessionHandler('enterpictureinpicture', commonHandler);
  1151. }
  1152. this.eventManager_.listen(this.video_, 'timeupdate', () => {
  1153. updatePositionState();
  1154. });
  1155. this.eventManager_.listen(this.player_, 'metadata', (event) => {
  1156. const payload = event['payload'];
  1157. if (!payload) {
  1158. return;
  1159. }
  1160. let title;
  1161. if (payload['key'] == 'TIT2' && payload['data']) {
  1162. title = payload['data'];
  1163. }
  1164. let imageUrl;
  1165. if (payload['key'] == 'APIC' && payload['mimeType'] == '-->') {
  1166. imageUrl = payload['data'];
  1167. }
  1168. if (title) {
  1169. let metadata = {
  1170. title: title,
  1171. artwork: [],
  1172. };
  1173. if (navigator.mediaSession.metadata) {
  1174. metadata = navigator.mediaSession.metadata;
  1175. metadata.title = title;
  1176. }
  1177. navigator.mediaSession.metadata = new MediaMetadata(metadata);
  1178. }
  1179. if (imageUrl) {
  1180. const video = /** @type {HTMLVideoElement} */ (this.localVideo_);
  1181. if (imageUrl != video.poster) {
  1182. video.poster = imageUrl;
  1183. }
  1184. let metadata = {
  1185. title: '',
  1186. artwork: [{src: imageUrl}],
  1187. };
  1188. if (navigator.mediaSession.metadata) {
  1189. metadata = navigator.mediaSession.metadata;
  1190. metadata.artwork = [{src: imageUrl}];
  1191. }
  1192. navigator.mediaSession.metadata = new MediaMetadata(metadata);
  1193. }
  1194. });
  1195. }
  1196. /**
  1197. * @private
  1198. */
  1199. removeMediaSession_() {
  1200. if (!this.config_.setupMediaSession || !navigator.mediaSession) {
  1201. return;
  1202. }
  1203. try {
  1204. navigator.mediaSession.setPositionState();
  1205. } catch (error) {}
  1206. const disableMediaSessionHandler = (type) => {
  1207. try {
  1208. navigator.mediaSession.setActionHandler(type, null);
  1209. } catch (error) {}
  1210. };
  1211. disableMediaSessionHandler('pause');
  1212. disableMediaSessionHandler('play');
  1213. disableMediaSessionHandler('seekbackward');
  1214. disableMediaSessionHandler('seekforward');
  1215. disableMediaSessionHandler('seekto');
  1216. disableMediaSessionHandler('stop');
  1217. disableMediaSessionHandler('enterpictureinpicture');
  1218. }
  1219. /**
  1220. * When a mobile device is rotated to landscape layout, and the video is
  1221. * loaded, make the demo app go into fullscreen.
  1222. * Similarly, exit fullscreen when the device is rotated to portrait layout.
  1223. * @private
  1224. */
  1225. async onScreenRotation_() {
  1226. if (!this.video_ ||
  1227. this.video_.readyState == 0 ||
  1228. this.castProxy_.isCasting() ||
  1229. !this.config_.enableFullscreenOnRotation ||
  1230. !this.isFullScreenSupported()) {
  1231. return;
  1232. }
  1233. if (screen.orientation.type.includes('landscape') &&
  1234. !this.isFullScreenEnabled()) {
  1235. await this.enterFullScreen_();
  1236. } else if (screen.orientation.type.includes('portrait') &&
  1237. this.isFullScreenEnabled()) {
  1238. await this.exitFullScreen_();
  1239. }
  1240. }
  1241. /**
  1242. * Hiding the cursor when the mouse stops moving seems to be the only
  1243. * decent UX in fullscreen mode. Since we can't use pure CSS for that,
  1244. * we use events both in and out of fullscreen mode.
  1245. * Showing the control bar when a key is pressed, and hiding it after some
  1246. * time.
  1247. * @param {!Event} event
  1248. * @private
  1249. */
  1250. onMouseMove_(event) {
  1251. // Disable blue outline for focused elements for mouse navigation.
  1252. if (event.type == 'mousemove') {
  1253. this.controlsContainer_.classList.remove('shaka-keyboard-navigation');
  1254. this.computeOpacity();
  1255. }
  1256. if (event.type == 'touchstart' || event.type == 'touchmove' ||
  1257. event.type == 'touchend' || event.type == 'keyup') {
  1258. this.lastTouchEventTime_ = Date.now();
  1259. } else if (this.lastTouchEventTime_ + 1000 < Date.now()) {
  1260. // It has been a while since the last touch event, this is probably a real
  1261. // mouse moving, so treat it like a mouse.
  1262. this.lastTouchEventTime_ = null;
  1263. }
  1264. // When there is a touch, we can get a 'mousemove' event after touch events.
  1265. // This should be treated as part of the touch, which has already been
  1266. // handled.
  1267. if (this.lastTouchEventTime_ && event.type == 'mousemove') {
  1268. return;
  1269. }
  1270. // Use the cursor specified in the CSS file.
  1271. this.videoContainer_.classList.remove('no-cursor');
  1272. this.recentMouseMovement_ = true;
  1273. // Make sure we are not about to hide the settings menus and then force them
  1274. // open.
  1275. this.hideSettingsMenusTimer_.stop();
  1276. if (!this.isOpaque()) {
  1277. // Only update the time and seek range on mouse movement if it's the very
  1278. // first movement and we're about to show the controls. Otherwise, the
  1279. // seek bar will be updated much more rapidly during mouse movement. Do
  1280. // this right before making it visible.
  1281. this.updateTimeAndSeekRange_();
  1282. this.computeOpacity();
  1283. }
  1284. // Hide the cursor when the mouse stops moving.
  1285. // Only applies while the cursor is over the video container.
  1286. this.mouseStillTimer_.stop();
  1287. // Only start a timeout on 'touchend' or for 'mousemove' with no touch
  1288. // events.
  1289. if (event.type == 'touchend' ||
  1290. event.type == 'keyup'|| !this.lastTouchEventTime_) {
  1291. this.mouseStillTimer_.tickAfter(/* seconds= */ 3);
  1292. }
  1293. }
  1294. /** @private */
  1295. onMouseLeave_() {
  1296. // We sometimes get 'mouseout' events with touches. Since we can never
  1297. // leave the video element when touching, ignore.
  1298. if (this.lastTouchEventTime_) {
  1299. return;
  1300. }
  1301. // Stop the timer and invoke the callback now to hide the controls. If we
  1302. // don't, the opacity style we set in onMouseMove_ will continue to override
  1303. // the opacity in CSS and force the controls to stay visible.
  1304. this.mouseStillTimer_.tickNow();
  1305. }
  1306. /**
  1307. * This callback is for when we are pretty sure that the mouse has stopped
  1308. * moving (aka the mouse is still). This method should only be called via
  1309. * |mouseStillTimer_|. If this behaviour needs to be invoked directly, use
  1310. * |mouseStillTimer_.tickNow()|.
  1311. *
  1312. * @private
  1313. */
  1314. onMouseStill_() {
  1315. // Hide the cursor.
  1316. this.videoContainer_.classList.add('no-cursor');
  1317. this.recentMouseMovement_ = false;
  1318. this.computeOpacity();
  1319. }
  1320. /**
  1321. * @return {boolean} true if any relevant elements are hovered.
  1322. * @private
  1323. */
  1324. isHovered_() {
  1325. if (!window.matchMedia('hover: hover').matches) {
  1326. // This is primarily a touch-screen device, so the :hover query below
  1327. // doesn't make sense. In spite of this, the :hover query on an element
  1328. // can still return true on such a device after a touch ends.
  1329. // See https://bit.ly/34dBORX for details.
  1330. return false;
  1331. }
  1332. return this.showOnHoverControls_.some((element) => {
  1333. return element.matches(':hover');
  1334. });
  1335. }
  1336. /**
  1337. * Recompute whether the controls should be shown or hidden.
  1338. */
  1339. computeOpacity() {
  1340. const adIsPaused = this.ad_ ? this.ad_.isPaused() : false;
  1341. const videoIsPaused = this.video_.paused && !this.isSeeking_;
  1342. const keyboardNavigationMode = this.controlsContainer_.classList.contains(
  1343. 'shaka-keyboard-navigation');
  1344. // Keep showing the controls if the ad or video is paused, there has been
  1345. // recent mouse movement, we're in keyboard navigation, or one of a special
  1346. // class of elements is hovered.
  1347. if (adIsPaused ||
  1348. ((!this.ad_ || !this.ad_.isLinear()) && videoIsPaused) ||
  1349. this.recentMouseMovement_ ||
  1350. keyboardNavigationMode ||
  1351. this.isHovered_()) {
  1352. // Make sure the state is up-to-date before showing it.
  1353. this.updateTimeAndSeekRange_();
  1354. this.controlsContainer_.setAttribute('shown', 'true');
  1355. this.fadeControlsTimer_.stop();
  1356. } else {
  1357. this.fadeControlsTimer_.tickAfter(/* seconds= */ this.config_.fadeDelay);
  1358. }
  1359. }
  1360. /**
  1361. * @param {!Event} event
  1362. * @private
  1363. */
  1364. onContainerTouch_(event) {
  1365. if (!this.video_.duration) {
  1366. // Can't play yet. Ignore.
  1367. return;
  1368. }
  1369. if (this.isOpaque()) {
  1370. this.lastTouchEventTime_ = Date.now();
  1371. // The controls are showing.
  1372. // Let this event continue and become a click.
  1373. } else {
  1374. // The controls are hidden, so show them.
  1375. this.onMouseMove_(event);
  1376. // Stop this event from becoming a click event.
  1377. event.cancelable && event.preventDefault();
  1378. }
  1379. }
  1380. /** @private */
  1381. onContainerClick_() {
  1382. if (!this.enabled_ || this.isPlayingVR()) {
  1383. return;
  1384. }
  1385. if (this.anySettingsMenusAreOpen()) {
  1386. this.hideSettingsMenusTimer_.tickNow();
  1387. } else if (this.config_.singleClickForPlayAndPause) {
  1388. this.onPlayPauseClick_();
  1389. }
  1390. }
  1391. /** @private */
  1392. onPlayPauseClick_() {
  1393. if (this.ad_ && this.ad_.isLinear()) {
  1394. this.playPauseAd();
  1395. } else {
  1396. this.playPausePresentation();
  1397. }
  1398. }
  1399. /** @private */
  1400. onCastStatusChange_() {
  1401. const isCasting = this.castProxy_.isCasting();
  1402. this.dispatchEvent(new shaka.util.FakeEvent(
  1403. 'caststatuschanged', (new Map()).set('newStatus', isCasting)));
  1404. if (isCasting) {
  1405. this.controlsContainer_.setAttribute('casting', 'true');
  1406. } else {
  1407. this.controlsContainer_.removeAttribute('casting');
  1408. }
  1409. }
  1410. /** @private */
  1411. onPlayStateChange_() {
  1412. this.computeOpacity();
  1413. }
  1414. /**
  1415. * Support controls with keyboard inputs.
  1416. * @param {!KeyboardEvent} event
  1417. * @private
  1418. */
  1419. onControlsKeyDown_(event) {
  1420. const activeElement = document.activeElement;
  1421. const isVolumeBar = activeElement && activeElement.classList ?
  1422. activeElement.classList.contains('shaka-volume-bar') : false;
  1423. const isSeekBar = activeElement && activeElement.classList &&
  1424. activeElement.classList.contains('shaka-seek-bar');
  1425. // Show the control panel if it is on focus or any button is pressed.
  1426. if (this.controlsContainer_.contains(activeElement)) {
  1427. this.onMouseMove_(event);
  1428. }
  1429. if (!this.config_.enableKeyboardPlaybackControls) {
  1430. return;
  1431. }
  1432. const keyboardSeekDistance = this.config_.keyboardSeekDistance;
  1433. const keyboardLargeSeekDistance = this.config_.keyboardLargeSeekDistance;
  1434. switch (event.key) {
  1435. case 'ArrowLeft':
  1436. // If it's not focused on the volume bar, move the seek time backward
  1437. // for a few sec. Otherwise, the volume will be adjusted automatically.
  1438. if (this.seekBar_ && isSeekBar && !isVolumeBar &&
  1439. keyboardSeekDistance > 0) {
  1440. event.preventDefault();
  1441. this.seek_(this.seekBar_.getValue() - keyboardSeekDistance);
  1442. }
  1443. break;
  1444. case 'ArrowRight':
  1445. // If it's not focused on the volume bar, move the seek time forward
  1446. // for a few sec. Otherwise, the volume will be adjusted automatically.
  1447. if (this.seekBar_ && isSeekBar && !isVolumeBar &&
  1448. keyboardSeekDistance > 0) {
  1449. event.preventDefault();
  1450. this.seek_(this.seekBar_.getValue() + keyboardSeekDistance);
  1451. }
  1452. break;
  1453. case 'PageDown':
  1454. // PageDown is like ArrowLeft, but has a larger jump distance, and does
  1455. // nothing to volume.
  1456. if (this.seekBar_ && isSeekBar && keyboardSeekDistance > 0) {
  1457. event.preventDefault();
  1458. this.seek_(this.seekBar_.getValue() - keyboardLargeSeekDistance);
  1459. }
  1460. break;
  1461. case 'PageUp':
  1462. // PageDown is like ArrowRight, but has a larger jump distance, and does
  1463. // nothing to volume.
  1464. if (this.seekBar_ && isSeekBar && keyboardSeekDistance > 0) {
  1465. event.preventDefault();
  1466. this.seek_(this.seekBar_.getValue() + keyboardLargeSeekDistance);
  1467. }
  1468. break;
  1469. // Jump to the beginning of the video's seek range.
  1470. case 'Home':
  1471. if (this.seekBar_) {
  1472. this.seek_(this.player_.seekRange().start);
  1473. }
  1474. break;
  1475. // Jump to the end of the video's seek range.
  1476. case 'End':
  1477. if (this.seekBar_) {
  1478. this.seek_(this.player_.seekRange().end);
  1479. }
  1480. break;
  1481. case 'f':
  1482. if (this.isFullScreenSupported()) {
  1483. this.toggleFullScreen();
  1484. }
  1485. break;
  1486. case 'm':
  1487. if (this.ad_ && this.ad_.isLinear()) {
  1488. this.ad_.setMuted(!this.ad_.isMuted());
  1489. } else {
  1490. this.localVideo_.muted = !this.localVideo_.muted;
  1491. }
  1492. break;
  1493. case 'p':
  1494. if (this.isPiPAllowed()) {
  1495. this.togglePiP();
  1496. }
  1497. break;
  1498. // Pause or play by pressing space on the seek bar.
  1499. case ' ':
  1500. if (isSeekBar) {
  1501. this.onPlayPauseClick_();
  1502. }
  1503. break;
  1504. }
  1505. }
  1506. /**
  1507. * Support controls with keyboard inputs.
  1508. * @param {!KeyboardEvent} event
  1509. * @private
  1510. */
  1511. onControlsKeyUp_(event) {
  1512. // When the key is released, remove it from the pressed keys set.
  1513. this.pressedKeys_.delete(event.key);
  1514. }
  1515. /**
  1516. * Called both as an event listener and directly by the controls to initialize
  1517. * the buffering state.
  1518. * @private
  1519. */
  1520. onBufferingStateChange_() {
  1521. if (!this.enabled_) {
  1522. return;
  1523. }
  1524. if (this.ad_ && this.ad_.isClientRendering() && this.ad_.isLinear()) {
  1525. shaka.ui.Utils.setDisplay(this.spinnerContainer_, false);
  1526. return;
  1527. }
  1528. shaka.ui.Utils.setDisplay(
  1529. this.spinnerContainer_, this.player_.isBuffering());
  1530. }
  1531. /**
  1532. * @return {boolean}
  1533. * @export
  1534. */
  1535. isOpaque() {
  1536. if (!this.enabled_) {
  1537. return false;
  1538. }
  1539. return this.controlsContainer_.getAttribute('shown') != null ||
  1540. this.controlsContainer_.getAttribute('casting') != null;
  1541. }
  1542. /**
  1543. * Update the video's current time based on the keyboard operations.
  1544. *
  1545. * @param {number} currentTime
  1546. * @private
  1547. */
  1548. seek_(currentTime) {
  1549. goog.asserts.assert(
  1550. this.seekBar_, 'Caller of seek_ must check for seekBar_ first!');
  1551. this.seekBar_.changeTo(currentTime);
  1552. if (this.isOpaque()) {
  1553. // Only update the time and seek range if it's visible.
  1554. this.updateTimeAndSeekRange_();
  1555. }
  1556. }
  1557. /**
  1558. * Called when the seek range or current time need to be updated.
  1559. * @private
  1560. */
  1561. updateTimeAndSeekRange_() {
  1562. if (this.seekBar_) {
  1563. this.seekBar_.setValue(this.video_.currentTime);
  1564. this.seekBar_.update();
  1565. if (this.seekBar_.isShowing()) {
  1566. for (const menu of this.menus_) {
  1567. menu.classList.remove('shaka-low-position');
  1568. }
  1569. } else {
  1570. for (const menu of this.menus_) {
  1571. menu.classList.add('shaka-low-position');
  1572. }
  1573. }
  1574. }
  1575. this.dispatchEvent(new shaka.util.FakeEvent('timeandseekrangeupdated'));
  1576. }
  1577. /**
  1578. * Add behaviors for keyboard navigation.
  1579. * 1. Add blue outline for focused elements.
  1580. * 2. Allow exiting overflow settings menus by pressing Esc key.
  1581. * 3. When navigating on overflow settings menu by pressing Tab
  1582. * key or Shift+Tab keys keep the focus inside overflow menu.
  1583. *
  1584. * @param {!KeyboardEvent} event
  1585. * @private
  1586. */
  1587. onWindowKeyDown_(event) {
  1588. // Add the key to the pressed keys set when it's pressed.
  1589. this.pressedKeys_.add(event.key);
  1590. const anySettingsMenusAreOpen = this.anySettingsMenusAreOpen();
  1591. if (event.key == 'Tab') {
  1592. // Enable blue outline for focused elements for keyboard
  1593. // navigation.
  1594. this.controlsContainer_.classList.add('shaka-keyboard-navigation');
  1595. this.computeOpacity();
  1596. this.eventManager_.listen(window, 'mousedown', () => this.onMouseDown_());
  1597. }
  1598. // If escape key was pressed, close any open settings menus.
  1599. if (event.key == 'Escape') {
  1600. this.hideSettingsMenusTimer_.tickNow();
  1601. }
  1602. if (anySettingsMenusAreOpen && this.pressedKeys_.has('Tab')) {
  1603. // If Tab key or Shift+Tab keys are pressed when navigating through
  1604. // an overflow settings menu, keep the focus to loop inside the
  1605. // overflow menu.
  1606. this.keepFocusInMenu_(event);
  1607. }
  1608. }
  1609. /**
  1610. * When the user is using keyboard to navigate inside the overflow settings
  1611. * menu (pressing Tab key to go forward, or pressing Shift + Tab keys to go
  1612. * backward), make sure it's focused only on the elements of the overflow
  1613. * panel.
  1614. *
  1615. * This is called by onWindowKeyDown_() function, when there's a settings
  1616. * overflow menu open, and the Tab key / Shift+Tab keys are pressed.
  1617. *
  1618. * @param {!Event} event
  1619. * @private
  1620. */
  1621. keepFocusInMenu_(event) {
  1622. const openSettingsMenus = this.menus_.filter(
  1623. (menu) => !menu.classList.contains('shaka-hidden'));
  1624. if (!openSettingsMenus.length) {
  1625. // For example, this occurs when you hit escape to close the menu.
  1626. return;
  1627. }
  1628. const settingsMenu = openSettingsMenus[0];
  1629. if (settingsMenu.childNodes.length) {
  1630. // Get the first and the last displaying child element from the overflow
  1631. // menu.
  1632. let firstShownChild = settingsMenu.firstElementChild;
  1633. while (firstShownChild &&
  1634. firstShownChild.classList.contains('shaka-hidden')) {
  1635. firstShownChild = firstShownChild.nextElementSibling;
  1636. }
  1637. let lastShownChild = settingsMenu.lastElementChild;
  1638. while (lastShownChild &&
  1639. lastShownChild.classList.contains('shaka-hidden')) {
  1640. lastShownChild = lastShownChild.previousElementSibling;
  1641. }
  1642. const activeElement = document.activeElement;
  1643. // When only Tab key is pressed, navigate to the next elememnt.
  1644. // If it's currently focused on the last shown child element of the
  1645. // overflow menu, let the focus move to the first child element of the
  1646. // menu.
  1647. // When Tab + Shift keys are pressed at the same time, navigate to the
  1648. // previous element. If it's currently focused on the first shown child
  1649. // element of the overflow menu, let the focus move to the last child
  1650. // element of the menu.
  1651. if (this.pressedKeys_.has('Shift')) {
  1652. if (activeElement == firstShownChild) {
  1653. event.preventDefault();
  1654. lastShownChild.focus();
  1655. }
  1656. } else {
  1657. if (activeElement == lastShownChild) {
  1658. event.preventDefault();
  1659. firstShownChild.focus();
  1660. }
  1661. }
  1662. }
  1663. }
  1664. /**
  1665. * For keyboard navigation, we use blue borders to highlight the active
  1666. * element. If we detect that a mouse is being used, remove the blue border
  1667. * from the active element.
  1668. * @private
  1669. */
  1670. onMouseDown_() {
  1671. this.eventManager_.unlisten(window, 'mousedown');
  1672. }
  1673. /**
  1674. * @export
  1675. */
  1676. showUI() {
  1677. const event = new Event('mousemove', {bubbles: false, cancelable: false});
  1678. this.onMouseMove_(event);
  1679. }
  1680. /**
  1681. * @export
  1682. */
  1683. hideUI() {
  1684. this.onMouseLeave_();
  1685. }
  1686. /**
  1687. * @return {shaka.ui.VRManager}
  1688. */
  1689. getVR() {
  1690. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1691. return this.vr_;
  1692. }
  1693. /**
  1694. * Returns if a VR is capable.
  1695. *
  1696. * @return {boolean}
  1697. * @export
  1698. */
  1699. canPlayVR() {
  1700. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1701. return this.vr_.canPlayVR();
  1702. }
  1703. /**
  1704. * Returns if a VR is supported.
  1705. *
  1706. * @return {boolean}
  1707. * @export
  1708. */
  1709. isPlayingVR() {
  1710. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1711. return this.vr_.isPlayingVR();
  1712. }
  1713. /**
  1714. * Reset VR view.
  1715. */
  1716. resetVR() {
  1717. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1718. this.vr_.reset();
  1719. }
  1720. /**
  1721. * Get the angle of the north.
  1722. *
  1723. * @return {?number}
  1724. * @export
  1725. */
  1726. getVRNorth() {
  1727. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1728. return this.vr_.getNorth();
  1729. }
  1730. /**
  1731. * Returns the angle of the current field of view displayed in degrees.
  1732. *
  1733. * @return {?number}
  1734. * @export
  1735. */
  1736. getVRFieldOfView() {
  1737. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1738. return this.vr_.getFieldOfView();
  1739. }
  1740. /**
  1741. * Changing the field of view increases or decreases the portion of the video
  1742. * that is viewed at one time. If the field of view is decreased, a small
  1743. * part of the video will be seen, but with more detail. If the field of view
  1744. * is increased, a larger part of the video will be seen, but with less
  1745. * detail.
  1746. *
  1747. * @param {number} fieldOfView In degrees
  1748. * @export
  1749. */
  1750. setVRFieldOfView(fieldOfView) {
  1751. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1752. this.vr_.setFieldOfView(fieldOfView);
  1753. }
  1754. /**
  1755. * Toggle stereoscopic mode.
  1756. *
  1757. * @export
  1758. */
  1759. toggleStereoscopicMode() {
  1760. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1761. this.vr_.toggleStereoscopicMode();
  1762. }
  1763. /**
  1764. * Returns true if stereoscopic mode is enabled.
  1765. *
  1766. * @return {boolean}
  1767. */
  1768. isStereoscopicModeEnabled() {
  1769. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1770. return this.vr_.isStereoscopicModeEnabled();
  1771. }
  1772. /**
  1773. * Increment the yaw in X angle in degrees.
  1774. *
  1775. * @param {number} angle In degrees
  1776. * @export
  1777. */
  1778. incrementYaw(angle) {
  1779. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1780. this.vr_.incrementYaw(angle);
  1781. }
  1782. /**
  1783. * Increment the pitch in X angle in degrees.
  1784. *
  1785. * @param {number} angle In degrees
  1786. * @export
  1787. */
  1788. incrementPitch(angle) {
  1789. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1790. this.vr_.incrementPitch(angle);
  1791. }
  1792. /**
  1793. * Increment the roll in X angle in degrees.
  1794. *
  1795. * @param {number} angle In degrees
  1796. * @export
  1797. */
  1798. incrementRoll(angle) {
  1799. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1800. this.vr_.incrementRoll(angle);
  1801. }
  1802. /**
  1803. * Create a localization instance already pre-loaded with all the locales that
  1804. * we support.
  1805. *
  1806. * @return {!shaka.ui.Localization}
  1807. * @private
  1808. */
  1809. static createLocalization_() {
  1810. /** @type {string} */
  1811. const fallbackLocale = 'en';
  1812. /** @type {!shaka.ui.Localization} */
  1813. const localization = new shaka.ui.Localization(fallbackLocale);
  1814. shaka.ui.Locales.addTo(localization);
  1815. localization.changeLocale(navigator.languages || []);
  1816. return localization;
  1817. }
  1818. };
  1819. /**
  1820. * @event shaka.ui.Controls#CastStatusChangedEvent
  1821. * @description Fired upon receiving a 'caststatuschanged' event from
  1822. * the cast proxy.
  1823. * @property {string} type
  1824. * 'caststatuschanged'
  1825. * @property {boolean} newStatus
  1826. * The new status of the application. True for 'is casting' and
  1827. * false otherwise.
  1828. * @exportDoc
  1829. */
  1830. /**
  1831. * @event shaka.ui.Controls#VRStatusChangedEvent
  1832. * @description Fired when VR status change
  1833. * @property {string} type
  1834. * 'vrstatuschanged'
  1835. * @exportDoc
  1836. */
  1837. /**
  1838. * @event shaka.ui.Controls#SubMenuOpenEvent
  1839. * @description Fired when one of the overflow submenus is opened
  1840. * (e. g. language/resolution/subtitle selection).
  1841. * @property {string} type
  1842. * 'submenuopen'
  1843. * @exportDoc
  1844. */
  1845. /**
  1846. * @event shaka.ui.Controls#CaptionSelectionUpdatedEvent
  1847. * @description Fired when the captions/subtitles menu has finished updating.
  1848. * @property {string} type
  1849. * 'captionselectionupdated'
  1850. * @exportDoc
  1851. */
  1852. /**
  1853. * @event shaka.ui.Controls#ResolutionSelectionUpdatedEvent
  1854. * @description Fired when the resolution menu has finished updating.
  1855. * @property {string} type
  1856. * 'resolutionselectionupdated'
  1857. * @exportDoc
  1858. */
  1859. /**
  1860. * @event shaka.ui.Controls#LanguageSelectionUpdatedEvent
  1861. * @description Fired when the audio language menu has finished updating.
  1862. * @property {string} type
  1863. * 'languageselectionupdated'
  1864. * @exportDoc
  1865. */
  1866. /**
  1867. * @event shaka.ui.Controls#ErrorEvent
  1868. * @description Fired when something went wrong with the controls.
  1869. * @property {string} type
  1870. * 'error'
  1871. * @property {!shaka.util.Error} detail
  1872. * An object which contains details on the error. The error's 'category'
  1873. * and 'code' properties will identify the specific error that occurred.
  1874. * In an uncompiled build, you can also use the 'message' and 'stack'
  1875. * properties to debug.
  1876. * @exportDoc
  1877. */
  1878. /**
  1879. * @event shaka.ui.Controls#TimeAndSeekRangeUpdatedEvent
  1880. * @description Fired when the time and seek range elements have finished
  1881. * updating.
  1882. * @property {string} type
  1883. * 'timeandseekrangeupdated'
  1884. * @exportDoc
  1885. */
  1886. /**
  1887. * @event shaka.ui.Controls#UIUpdatedEvent
  1888. * @description Fired after a call to ui.configure() once the UI has finished
  1889. * updating.
  1890. * @property {string} type
  1891. * 'uiupdated'
  1892. * @exportDoc
  1893. */
  1894. /** @private {!Map.<string, !shaka.extern.IUIElement.Factory>} */
  1895. shaka.ui.ControlsPanel.elementNamesToFactories_ = new Map();
  1896. /** @private {?shaka.extern.IUISeekBar.Factory} */
  1897. shaka.ui.ControlsPanel.seekBarFactory_ = new shaka.ui.SeekBar.Factory();