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