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