Source: ui/ui.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.ui.Overlay');
  7. goog.provide('shaka.ui.Overlay.FailReasonCode');
  8. goog.provide('shaka.ui.Overlay.TrackLabelFormat');
  9. goog.require('goog.asserts');
  10. goog.require('shaka.Player');
  11. goog.require('shaka.log');
  12. goog.require('shaka.polyfill');
  13. goog.require('shaka.ui.Controls');
  14. goog.require('shaka.ui.Watermark');
  15. goog.require('shaka.util.ConfigUtils');
  16. goog.require('shaka.util.Dom');
  17. goog.require('shaka.util.FakeEvent');
  18. goog.require('shaka.util.IDestroyable');
  19. goog.require('shaka.util.Platform');
  20. /**
  21. * @implements {shaka.util.IDestroyable}
  22. * @export
  23. */
  24. shaka.ui.Overlay = class {
  25. /**
  26. * @param {!shaka.Player} player
  27. * @param {!HTMLElement} videoContainer
  28. * @param {!HTMLMediaElement} video
  29. * @param {?HTMLCanvasElement=} vrCanvas
  30. */
  31. constructor(player, videoContainer, video, vrCanvas = null) {
  32. /** @private {shaka.Player} */
  33. this.player_ = player;
  34. /** @private {HTMLElement} */
  35. this.videoContainer_ = videoContainer;
  36. /** @private {!shaka.extern.UIConfiguration} */
  37. this.config_ = this.defaultConfig_();
  38. // Get and configure cast app id.
  39. let castAppId = '';
  40. // Get and configure cast Android Receiver Compatibility
  41. let castAndroidReceiverCompatible = false;
  42. // Cast receiver id can be specified on either container or video.
  43. // It should not be provided on both. If it was, we will use the last
  44. // one we saw.
  45. if (videoContainer['dataset'] &&
  46. videoContainer['dataset']['shakaPlayerCastReceiverId']) {
  47. const dataSet = videoContainer['dataset'];
  48. castAppId = dataSet['shakaPlayerCastReceiverId'];
  49. castAndroidReceiverCompatible =
  50. dataSet['shakaPlayerCastAndroidReceiverCompatible'] === 'true';
  51. } else if (video['dataset'] &&
  52. video['dataset']['shakaPlayerCastReceiverId']) {
  53. const dataSet = video['dataset'];
  54. castAppId = dataSet['shakaPlayerCastReceiverId'];
  55. castAndroidReceiverCompatible =
  56. dataSet['shakaPlayerCastAndroidReceiverCompatible'] === 'true';
  57. }
  58. if (castAppId.length) {
  59. this.config_.castReceiverAppId = castAppId;
  60. this.config_.castAndroidReceiverCompatible =
  61. castAndroidReceiverCompatible;
  62. }
  63. // Make sure this container is discoverable and that the UI can be reached
  64. // through it.
  65. videoContainer['dataset']['shakaPlayerContainer'] = '';
  66. videoContainer['ui'] = this;
  67. // Tag the container for mobile platforms, to allow different styles.
  68. if (this.isMobile()) {
  69. videoContainer.classList.add('shaka-mobile');
  70. }
  71. /** @private {shaka.ui.Controls} */
  72. this.controls_ = new shaka.ui.Controls(
  73. player, videoContainer, video, vrCanvas, this.config_);
  74. // If the browser's native controls are disabled, use UI TextDisplayer.
  75. if (!video.controls) {
  76. player.setVideoContainer(videoContainer);
  77. }
  78. videoContainer['ui'] = this;
  79. video['ui'] = this;
  80. /** @private {shaka.ui.Watermark} */
  81. this.watermark_ = new shaka.ui.Watermark(
  82. this.videoContainer_,
  83. this.controls_,
  84. );
  85. }
  86. /**
  87. * @override
  88. * @export
  89. */
  90. async destroy() {
  91. if (this.controls_) {
  92. await this.controls_.destroy();
  93. }
  94. this.controls_ = null;
  95. if (this.player_) {
  96. await this.player_.destroy();
  97. }
  98. this.player_ = null;
  99. this.watermark_ = null;
  100. }
  101. /**
  102. * Detects if this is a mobile platform, in case you want to choose a
  103. * different UI configuration on mobile devices.
  104. *
  105. * @return {boolean}
  106. * @export
  107. */
  108. isMobile() {
  109. return shaka.util.Platform.isMobile();
  110. }
  111. /**
  112. * @return {!shaka.extern.UIConfiguration}
  113. * @export
  114. */
  115. getConfiguration() {
  116. const ret = this.defaultConfig_();
  117. shaka.util.ConfigUtils.mergeConfigObjects(
  118. ret, this.config_, this.defaultConfig_(),
  119. /* overrides= */ {}, /* path= */ '');
  120. return ret;
  121. }
  122. /**
  123. * @param {string|!Object} config This should either be a field name or an
  124. * object following the form of {@link shaka.extern.UIConfiguration}, where
  125. * you may omit any field you do not wish to change.
  126. * @param {*=} value This should be provided if the previous parameter
  127. * was a string field name.
  128. * @export
  129. */
  130. configure(config, value) {
  131. goog.asserts.assert(typeof(config) == 'object' || arguments.length == 2,
  132. 'String configs should have values!');
  133. // ('fieldName', value) format
  134. if (arguments.length == 2 && typeof(config) == 'string') {
  135. config = shaka.util.ConfigUtils.convertToConfigObject(config, value);
  136. }
  137. goog.asserts.assert(typeof(config) == 'object', 'Should be an object!');
  138. const newConfig = /** @type {!shaka.extern.UIConfiguration} */(
  139. Object.assign({}, this.config_));
  140. shaka.util.ConfigUtils.mergeConfigObjects(
  141. newConfig, config, this.defaultConfig_(),
  142. /* overrides= */ {}, /* path= */ '');
  143. // If a cast receiver app id has been given, add a cast button to the UI
  144. if (newConfig.castReceiverAppId &&
  145. !newConfig.overflowMenuButtons.includes('cast')) {
  146. newConfig.overflowMenuButtons.push('cast');
  147. }
  148. goog.asserts.assert(this.player_ != null, 'Should have a player!');
  149. const diff = shaka.util.ConfigUtils.getDifferenceFromConfigObjects(
  150. newConfig, this.config_);
  151. if (!Object.keys(diff).length) {
  152. // No changes
  153. return;
  154. }
  155. this.config_ = newConfig;
  156. this.controls_.configure(this.config_);
  157. this.controls_.dispatchEvent(new shaka.util.FakeEvent('uiupdated'));
  158. }
  159. /**
  160. * @return {shaka.ui.Controls}
  161. * @export
  162. */
  163. getControls() {
  164. return this.controls_;
  165. }
  166. /**
  167. * Enable or disable the custom controls.
  168. *
  169. * @param {boolean} enabled
  170. * @export
  171. */
  172. setEnabled(enabled) {
  173. this.controls_.setEnabledShakaControls(enabled);
  174. }
  175. /**
  176. * @param {string} text
  177. * @param {?shaka.ui.Watermark.Options=} options
  178. * @export
  179. */
  180. setTextWatermark(text, options) {
  181. if (this.watermark_) {
  182. this.watermark_.setTextWatermark(text, options);
  183. }
  184. }
  185. /**
  186. * @export
  187. */
  188. removeWatermark() {
  189. if (this.watermark_) {
  190. this.watermark_.removeWatermark();
  191. }
  192. }
  193. /**
  194. * @return {!shaka.extern.UIConfiguration}
  195. * @private
  196. */
  197. defaultConfig_() {
  198. const config = {
  199. controlPanelElements: [
  200. 'play_pause',
  201. 'time_and_duration',
  202. 'spacer',
  203. 'mute',
  204. 'volume',
  205. 'fullscreen',
  206. 'overflow_menu',
  207. ],
  208. overflowMenuButtons: [
  209. 'captions',
  210. 'quality',
  211. 'language',
  212. 'chapter',
  213. 'picture_in_picture',
  214. 'cast',
  215. 'playback_rate',
  216. 'recenter_vr',
  217. 'toggle_stereoscopic',
  218. ],
  219. statisticsList: [
  220. 'width',
  221. 'height',
  222. 'corruptedFrames',
  223. 'decodedFrames',
  224. 'droppedFrames',
  225. 'drmTimeSeconds',
  226. 'licenseTime',
  227. 'liveLatency',
  228. 'loadLatency',
  229. 'bufferingTime',
  230. 'manifestTimeSeconds',
  231. 'estimatedBandwidth',
  232. 'streamBandwidth',
  233. 'maxSegmentDuration',
  234. 'pauseTime',
  235. 'playTime',
  236. 'completionPercent',
  237. 'manifestSizeBytes',
  238. 'bytesDownloaded',
  239. 'nonFatalErrorCount',
  240. 'manifestPeriodCount',
  241. 'manifestGapCount',
  242. ],
  243. adStatisticsList: [
  244. 'loadTimes',
  245. 'averageLoadTime',
  246. 'started',
  247. 'overlayAds',
  248. 'playedCompletely',
  249. 'skipped',
  250. 'errors',
  251. ],
  252. contextMenuElements: [
  253. 'loop',
  254. 'picture_in_picture',
  255. 'save_video_frame',
  256. 'statistics',
  257. 'ad_statistics',
  258. ],
  259. playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
  260. fastForwardRates: [2, 4, 8, 1],
  261. rewindRates: [-1, -2, -4, -8],
  262. addSeekBar: true,
  263. addBigPlayButton: false,
  264. customContextMenu: false,
  265. castReceiverAppId: '',
  266. castAndroidReceiverCompatible: false,
  267. clearBufferOnQualityChange: true,
  268. showUnbufferedStart: false,
  269. seekBarColors: {
  270. base: 'rgba(255, 255, 255, 0.3)',
  271. buffered: 'rgba(255, 255, 255, 0.54)',
  272. played: 'rgb(255, 255, 255)',
  273. adBreaks: 'rgb(255, 204, 0)',
  274. },
  275. volumeBarColors: {
  276. base: 'rgba(255, 255, 255, 0.54)',
  277. level: 'rgb(255, 255, 255)',
  278. },
  279. trackLabelFormat: shaka.ui.Overlay.TrackLabelFormat.LANGUAGE,
  280. textTrackLabelFormat: shaka.ui.Overlay.TrackLabelFormat.LANGUAGE,
  281. fadeDelay: 0,
  282. closeMenusDelay: 2,
  283. doubleClickForFullscreen: true,
  284. singleClickForPlayAndPause: true,
  285. enableKeyboardPlaybackControls: true,
  286. enableFullscreenOnRotation: true,
  287. forceLandscapeOnFullscreen: true,
  288. enableTooltips: false,
  289. keyboardSeekDistance: 5,
  290. keyboardLargeSeekDistance: 60,
  291. fullScreenElement: this.videoContainer_,
  292. preferDocumentPictureInPicture: true,
  293. showAudioChannelCountVariants: true,
  294. seekOnTaps: navigator.maxTouchPoints > 0,
  295. tapSeekDistance: 10,
  296. refreshTickInSeconds: 0.125,
  297. displayInVrMode: false,
  298. defaultVrProjectionMode: 'equirectangular',
  299. setupMediaSession: true,
  300. preferVideoFullScreenInVisionOS: false,
  301. showAudioCodec: true,
  302. showVideoCodec: true,
  303. };
  304. // eslint-disable-next-line no-restricted-syntax
  305. if ('remote' in HTMLMediaElement.prototype) {
  306. config.overflowMenuButtons.push('remote');
  307. } else if (window.WebKitPlaybackTargetAvailabilityEvent) {
  308. config.overflowMenuButtons.push('airplay');
  309. }
  310. // On mobile, by default, hide the volume slide and the small play/pause
  311. // button and show the big play/pause button in the center.
  312. // This is in line with default styles in Chrome.
  313. if (this.isMobile()) {
  314. config.addBigPlayButton = true;
  315. config.controlPanelElements = config.controlPanelElements.filter(
  316. (name) => name != 'play_pause' && name != 'volume');
  317. }
  318. // Set this button here to push it at the end.
  319. config.overflowMenuButtons.push('save_video_frame');
  320. return config;
  321. }
  322. /**
  323. * @private
  324. */
  325. static async scanPageForShakaElements_() {
  326. // Install built-in polyfills to patch browser incompatibilities.
  327. shaka.polyfill.installAll();
  328. // Check to see if the browser supports the basic APIs Shaka needs.
  329. if (!shaka.Player.isBrowserSupported()) {
  330. shaka.log.error('Shaka Player does not support this browser. ' +
  331. 'Please see https://tinyurl.com/y7s4j9tr for the list of ' +
  332. 'supported browsers.');
  333. // After scanning the page for elements, fire a special "loaded" event for
  334. // when the load fails. This will allow the page to react to the failure.
  335. shaka.ui.Overlay.dispatchLoadedEvent_('shaka-ui-load-failed',
  336. shaka.ui.Overlay.FailReasonCode.NO_BROWSER_SUPPORT);
  337. return;
  338. }
  339. // Look for elements marked 'data-shaka-player-container'
  340. // on the page. These will be used to create our default
  341. // UI.
  342. const containers = document.querySelectorAll(
  343. '[data-shaka-player-container]');
  344. // Look for elements marked 'data-shaka-player'. They will
  345. // either be used in our default UI or with native browser
  346. // controls.
  347. const videos = document.querySelectorAll(
  348. '[data-shaka-player]');
  349. // Look for elements marked 'data-shaka-player-canvas'
  350. // on the page. These will be used to create our default
  351. // UI.
  352. const canvases = document.querySelectorAll(
  353. '[data-shaka-player-canvas]');
  354. // Look for elements marked 'data-shaka-player-vr-canvas'
  355. // on the page. These will be used to create our default
  356. // UI.
  357. const vrCanvases = document.querySelectorAll(
  358. '[data-shaka-player-vr-canvas]');
  359. if (!videos.length && !containers.length) {
  360. // No elements have been tagged with shaka attributes.
  361. } else if (videos.length && !containers.length) {
  362. // Just the video elements were provided.
  363. for (const video of videos) {
  364. // If the app has already manually created a UI for this element,
  365. // don't create another one.
  366. if (video['ui']) {
  367. continue;
  368. }
  369. goog.asserts.assert(video.tagName.toLowerCase() == 'video',
  370. 'Should be a video element!');
  371. const container = document.createElement('div');
  372. const videoParent = video.parentElement;
  373. videoParent.replaceChild(container, video);
  374. container.appendChild(video);
  375. const {lcevcCanvas, vrCanvas} =
  376. shaka.ui.Overlay.findOrMakeSpecialCanvases_(
  377. container, canvases, vrCanvases);
  378. shaka.ui.Overlay.setupUIandAutoLoad_(
  379. container, video, lcevcCanvas, vrCanvas);
  380. }
  381. } else {
  382. for (const container of containers) {
  383. // If the app has already manually created a UI for this element,
  384. // don't create another one.
  385. if (container['ui']) {
  386. continue;
  387. }
  388. goog.asserts.assert(container.tagName.toLowerCase() == 'div',
  389. 'Container should be a div!');
  390. let currentVideo = null;
  391. for (const video of videos) {
  392. goog.asserts.assert(video.tagName.toLowerCase() == 'video',
  393. 'Should be a video element!');
  394. if (video.parentElement == container) {
  395. currentVideo = video;
  396. break;
  397. }
  398. }
  399. if (!currentVideo) {
  400. currentVideo = document.createElement('video');
  401. currentVideo.setAttribute('playsinline', '');
  402. container.appendChild(currentVideo);
  403. }
  404. const {lcevcCanvas, vrCanvas} =
  405. shaka.ui.Overlay.findOrMakeSpecialCanvases_(
  406. container, canvases, vrCanvases);
  407. try {
  408. // eslint-disable-next-line no-await-in-loop
  409. await shaka.ui.Overlay.setupUIandAutoLoad_(
  410. container, currentVideo, lcevcCanvas, vrCanvas);
  411. } catch (e) {
  412. // This can fail if, for example, not every player file has loaded.
  413. // Ad-block is a likely cause for this sort of failure.
  414. shaka.log.error('Error setting up Shaka Player', e);
  415. shaka.ui.Overlay.dispatchLoadedEvent_('shaka-ui-load-failed',
  416. shaka.ui.Overlay.FailReasonCode.PLAYER_FAILED_TO_LOAD);
  417. return;
  418. }
  419. }
  420. }
  421. // After scanning the page for elements, fire the "loaded" event. This will
  422. // let apps know they can use the UI library programmatically now, even if
  423. // they didn't have any Shaka-related elements declared in their HTML.
  424. shaka.ui.Overlay.dispatchLoadedEvent_('shaka-ui-loaded');
  425. }
  426. /**
  427. * @param {string} eventName
  428. * @param {shaka.ui.Overlay.FailReasonCode=} reasonCode
  429. * @private
  430. */
  431. static dispatchLoadedEvent_(eventName, reasonCode) {
  432. let detail = null;
  433. if (reasonCode != undefined) {
  434. detail = {
  435. 'reasonCode': reasonCode,
  436. };
  437. }
  438. const uiLoadedEvent = new CustomEvent(eventName, {detail});
  439. document.dispatchEvent(uiLoadedEvent);
  440. }
  441. /**
  442. * @param {!Element} container
  443. * @param {!Element} video
  444. * @param {!Element} lcevcCanvas
  445. * @param {!Element} vrCanvas
  446. * @private
  447. */
  448. static async setupUIandAutoLoad_(container, video, lcevcCanvas, vrCanvas) {
  449. // Create the UI
  450. const player = new shaka.Player();
  451. const ui = new shaka.ui.Overlay(player,
  452. shaka.util.Dom.asHTMLElement(container),
  453. shaka.util.Dom.asHTMLMediaElement(video),
  454. shaka.util.Dom.asHTMLCanvasElement(vrCanvas));
  455. // Attach Canvas used for LCEVC Decoding
  456. player.attachCanvas(/** @type {HTMLCanvasElement} */(lcevcCanvas));
  457. if (shaka.util.Dom.asHTMLMediaElement(video).controls) {
  458. ui.getControls().setEnabledNativeControls(true);
  459. }
  460. // Get the source and load it
  461. // Source can be specified either on the video element:
  462. // <video src='foo.m2u8'></video>
  463. // or as a separate element inside the video element:
  464. // <video>
  465. // <source src='foo.m2u8'/>
  466. // </video>
  467. // It should not be specified on both.
  468. const urls = [];
  469. const src = video.getAttribute('src');
  470. if (src) {
  471. urls.push(src);
  472. video.removeAttribute('src');
  473. }
  474. for (const source of video.getElementsByTagName('source')) {
  475. urls.push(/** @type {!HTMLSourceElement} */ (source).src);
  476. video.removeChild(source);
  477. }
  478. await player.attach(shaka.util.Dom.asHTMLMediaElement(video));
  479. for (const url of urls) {
  480. try { // eslint-disable-next-line no-await-in-loop
  481. await ui.getControls().getPlayer().load(url);
  482. break;
  483. } catch (e) {
  484. shaka.log.error('Error auto-loading asset', e);
  485. }
  486. }
  487. }
  488. /**
  489. * @param {!Element} container
  490. * @param {!NodeList<!Element>} canvases
  491. * @param {!NodeList<!Element>} vrCanvases
  492. * @return {{lcevcCanvas: !Element, vrCanvas: !Element}}
  493. * @private
  494. */
  495. static findOrMakeSpecialCanvases_(container, canvases, vrCanvases) {
  496. let lcevcCanvas = null;
  497. for (const canvas of canvases) {
  498. goog.asserts.assert(canvas.tagName.toLowerCase() == 'canvas',
  499. 'Should be a canvas element!');
  500. if (canvas.parentElement == container) {
  501. lcevcCanvas = canvas;
  502. break;
  503. }
  504. }
  505. if (!lcevcCanvas) {
  506. lcevcCanvas = document.createElement('canvas');
  507. lcevcCanvas.classList.add('shaka-canvas-container');
  508. container.appendChild(lcevcCanvas);
  509. }
  510. let vrCanvas = null;
  511. for (const canvas of vrCanvases) {
  512. goog.asserts.assert(canvas.tagName.toLowerCase() == 'canvas',
  513. 'Should be a canvas element!');
  514. if (canvas.parentElement == container) {
  515. vrCanvas = canvas;
  516. break;
  517. }
  518. }
  519. if (!vrCanvas) {
  520. vrCanvas = document.createElement('canvas');
  521. vrCanvas.classList.add('shaka-vr-canvas-container');
  522. container.appendChild(vrCanvas);
  523. }
  524. return {
  525. lcevcCanvas,
  526. vrCanvas,
  527. };
  528. }
  529. };
  530. /**
  531. * Describes what information should show up in labels for selecting audio
  532. * variants and text tracks.
  533. *
  534. * @enum {number}
  535. * @export
  536. */
  537. shaka.ui.Overlay.TrackLabelFormat = {
  538. 'LANGUAGE': 0,
  539. 'ROLE': 1,
  540. 'LANGUAGE_ROLE': 2,
  541. 'LABEL': 3,
  542. };
  543. /**
  544. * Describes the possible reasons that the UI might fail to load.
  545. *
  546. * @enum {number}
  547. * @export
  548. */
  549. shaka.ui.Overlay.FailReasonCode = {
  550. 'NO_BROWSER_SUPPORT': 0,
  551. 'PLAYER_FAILED_TO_LOAD': 1,
  552. };
  553. if (document.readyState == 'complete') {
  554. // Don't fire this event synchronously. In a compiled bundle, the "shaka"
  555. // namespace might not be exported to the window until after this point.
  556. (async () => {
  557. await Promise.resolve();
  558. shaka.ui.Overlay.scanPageForShakaElements_();
  559. })();
  560. } else {
  561. window.addEventListener('load', shaka.ui.Overlay.scanPageForShakaElements_);
  562. }