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