Source: lib/ads/client_side_ad_manager.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. /**
  7. * @fileoverview
  8. * @suppress {missingRequire} TODO(b/152540451): this shouldn't be needed
  9. */
  10. goog.provide('shaka.ads.ClientSideAdManager');
  11. goog.require('goog.asserts');
  12. goog.require('shaka.ads.ClientSideAd');
  13. goog.require('shaka.log');
  14. goog.require('shaka.util.Dom');
  15. goog.require('shaka.util.EventManager');
  16. goog.require('shaka.util.FakeEvent');
  17. goog.require('shaka.util.IReleasable');
  18. /**
  19. * A class responsible for client-side ad interactions.
  20. * @implements {shaka.util.IReleasable}
  21. */
  22. shaka.ads.ClientSideAdManager = class {
  23. /**
  24. * @param {HTMLElement} adContainer
  25. * @param {HTMLMediaElement} video
  26. * @param {string} locale
  27. * @param {?google.ima.AdsRenderingSettings} adsRenderingSettings
  28. * @param {function(!shaka.util.FakeEvent)} onEvent
  29. */
  30. constructor(adContainer, video, locale, adsRenderingSettings, onEvent) {
  31. /** @private {HTMLElement} */
  32. this.adContainer_ = adContainer;
  33. /** @private {HTMLMediaElement} */
  34. this.video_ = video;
  35. /** @private {boolean} */
  36. this.videoPlayed_ = false;
  37. /** @private {?shaka.extern.AdsConfiguration} */
  38. this.config_ = null;
  39. /** @private {ResizeObserver} */
  40. this.resizeObserver_ = null;
  41. /** @private {number} */
  42. this.requestAdsStartTime_ = NaN;
  43. /** @private {function(!shaka.util.FakeEvent)} */
  44. this.onEvent_ = onEvent;
  45. /** @private {shaka.ads.ClientSideAd} */
  46. this.ad_ = null;
  47. /** @private {shaka.util.EventManager} */
  48. this.eventManager_ = new shaka.util.EventManager();
  49. google.ima.settings.setLocale(locale);
  50. google.ima.settings.setDisableCustomPlaybackForIOS10Plus(true);
  51. /** @private {!google.ima.AdDisplayContainer} */
  52. this.adDisplayContainer_ = new google.ima.AdDisplayContainer(
  53. this.adContainer_,
  54. this.video_);
  55. // TODO: IMA: Must be done as the result of a user action on mobile
  56. this.adDisplayContainer_.initialize();
  57. // IMA: This instance should be re-used for the entire lifecycle of
  58. // the page.
  59. this.adsLoader_ = new google.ima.AdsLoader(this.adDisplayContainer_);
  60. this.adsLoader_.getSettings().setPlayerType('shaka-player');
  61. this.adsLoader_.getSettings().setPlayerVersion(shaka.Player.version);
  62. /** @private {google.ima.AdsManager} */
  63. this.imaAdsManager_ = null;
  64. /** @private {!google.ima.AdsRenderingSettings} */
  65. this.adsRenderingSettings_ =
  66. adsRenderingSettings || new google.ima.AdsRenderingSettings();
  67. this.eventManager_.listen(this.adsLoader_,
  68. google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, (e) => {
  69. this.onAdsManagerLoaded_(
  70. /** @type {!google.ima.AdsManagerLoadedEvent} */ (e));
  71. });
  72. this.eventManager_.listen(this.adsLoader_,
  73. google.ima.AdErrorEvent.Type.AD_ERROR, (e) => {
  74. this.onAdError_( /** @type {!google.ima.AdErrorEvent} */ (e));
  75. });
  76. // Notify the SDK when the video has ended, so it can play post-roll ads.
  77. this.eventManager_.listen(this.video_, 'ended', () => {
  78. this.adsLoader_.contentComplete();
  79. });
  80. this.eventManager_.listenOnce(this.video_, 'play', () => {
  81. this.videoPlayed_ = true;
  82. });
  83. }
  84. /**
  85. * Called by the AdManager to provide an updated configuration any time it
  86. * changes.
  87. *
  88. * @param {shaka.extern.AdsConfiguration} config
  89. */
  90. configure(config) {
  91. this.config_ = config;
  92. }
  93. /**
  94. * @param {!google.ima.AdsRequest} imaRequest
  95. */
  96. requestAds(imaRequest) {
  97. goog.asserts.assert(
  98. imaRequest.adTagUrl || imaRequest.adsResponse,
  99. 'The ad tag needs to be set up before requesting ads, ' +
  100. 'or adsResponse must be filled.');
  101. // Destroy the current AdsManager, in case the tag you requested previously
  102. // contains post-rolls (don't play those now).
  103. if (this.imaAdsManager_) {
  104. this.imaAdsManager_.destroy();
  105. }
  106. // Your AdsLoader will be set up on page-load. You should re-use the same
  107. // AdsLoader for every request.
  108. if (this.adsLoader_) {
  109. // Reset the IMA SDK.
  110. this.adsLoader_.contentComplete();
  111. }
  112. this.requestAdsStartTime_ = Date.now() / 1000;
  113. this.adsLoader_.requestAds(imaRequest);
  114. }
  115. /**
  116. * @param {!google.ima.AdsRenderingSettings} adsRenderingSettings
  117. */
  118. updateAdsRenderingSettings(adsRenderingSettings) {
  119. this.adsRenderingSettings_ = adsRenderingSettings;
  120. if (this.imaAdsManager_) {
  121. this.imaAdsManager_.updateAdsRenderingSettings(
  122. this.adsRenderingSettings_);
  123. }
  124. }
  125. /**
  126. * Stop all currently playing ads.
  127. */
  128. stop() {
  129. // this.imaAdsManager_ might not be set yet... if, for example, an ad
  130. // blocker prevented the ads from ever loading.
  131. if (this.imaAdsManager_) {
  132. this.imaAdsManager_.stop();
  133. }
  134. if (this.adContainer_) {
  135. shaka.util.Dom.removeAllChildren(this.adContainer_);
  136. }
  137. }
  138. /** @override */
  139. release() {
  140. this.stop();
  141. if (this.resizeObserver_) {
  142. this.resizeObserver_.disconnect();
  143. }
  144. if (this.eventManager_) {
  145. this.eventManager_.release();
  146. }
  147. if (this.imaAdsManager_) {
  148. this.imaAdsManager_.destroy();
  149. }
  150. this.adsLoader_.destroy();
  151. this.adDisplayContainer_.destroy();
  152. }
  153. /**
  154. * @param {!google.ima.AdErrorEvent} e
  155. * @private
  156. */
  157. onAdError_(e) {
  158. shaka.log.warning(
  159. 'There was an ad error from the IMA SDK: ' + e.getError());
  160. shaka.log.warning('Resuming playback.');
  161. const data = (new Map()).set('originalEvent', e);
  162. this.onEvent_(new shaka.util.FakeEvent(shaka.ads.AdManager.AD_ERROR, data));
  163. this.onAdComplete_(/* adEvent= */ null);
  164. // Remove ad breaks from the timeline
  165. this.onEvent_(
  166. new shaka.util.FakeEvent(shaka.ads.AdManager.CUEPOINTS_CHANGED,
  167. (new Map()).set('cuepoints', [])));
  168. }
  169. /**
  170. * @param {!google.ima.AdsManagerLoadedEvent} e
  171. * @private
  172. */
  173. onAdsManagerLoaded_(e) {
  174. goog.asserts.assert(this.video_ != null, 'Video should not be null!');
  175. const now = Date.now() / 1000;
  176. const loadTime = now - this.requestAdsStartTime_;
  177. this.onEvent_(new shaka.util.FakeEvent(shaka.ads.AdManager.ADS_LOADED,
  178. (new Map()).set('loadTime', loadTime)));
  179. if (!this.config_.customPlayheadTracker) {
  180. this.imaAdsManager_ = e.getAdsManager(this.video_,
  181. this.adsRenderingSettings_);
  182. } else {
  183. const videoPlayHead = {
  184. currentTime: this.video_.currentTime,
  185. };
  186. this.imaAdsManager_ = e.getAdsManager(videoPlayHead,
  187. this.adsRenderingSettings_);
  188. if (this.video_.muted) {
  189. this.imaAdsManager_.setVolume(0);
  190. } else {
  191. this.imaAdsManager_.setVolume(this.video_.volume);
  192. }
  193. this.eventManager_.listen(this.video_, 'timeupdate', () => {
  194. if (!this.video_.duration) {
  195. return;
  196. }
  197. videoPlayHead.currentTime = this.video_.currentTime;
  198. });
  199. this.eventManager_.listen(this.video_, 'volumechange', () => {
  200. if (!this.ad_) {
  201. return;
  202. }
  203. this.ad_.setVolume(this.video_.volume);
  204. if (this.video_.muted) {
  205. this.ad_.setMuted(true);
  206. }
  207. });
  208. }
  209. this.onEvent_(new shaka.util.FakeEvent(
  210. shaka.ads.AdManager.IMA_AD_MANAGER_LOADED,
  211. (new Map()).set('imaAdManager', this.imaAdsManager_)));
  212. const cuePointStarts = this.imaAdsManager_.getCuePoints();
  213. if (cuePointStarts.length) {
  214. /** @type {!Array.<!shaka.extern.AdCuePoint>} */
  215. const cuePoints = [];
  216. for (const start of cuePointStarts) {
  217. /** @type {shaka.extern.AdCuePoint} */
  218. const shakaCuePoint = {
  219. start: start,
  220. end: null,
  221. };
  222. cuePoints.push(shakaCuePoint);
  223. }
  224. this.onEvent_(new shaka.util.FakeEvent(
  225. shaka.ads.AdManager.CUEPOINTS_CHANGED,
  226. (new Map()).set('cuepoints', cuePoints)));
  227. }
  228. this.addImaEventListeners_();
  229. try {
  230. const viewMode = this.isFullScreenEnabled_() ?
  231. google.ima.ViewMode.FULLSCREEN : google.ima.ViewMode.NORMAL;
  232. this.imaAdsManager_.init(this.video_.offsetWidth,
  233. this.video_.offsetHeight, viewMode);
  234. // Wait on the 'loadeddata' event rather than the 'loadedmetadata' event
  235. // because 'loadedmetadata' is sometimes called before the video resizes
  236. // on some platforms (e.g. Safari).
  237. this.eventManager_.listen(this.video_, 'loadeddata', () => {
  238. const viewMode = this.isFullScreenEnabled_() ?
  239. google.ima.ViewMode.FULLSCREEN : google.ima.ViewMode.NORMAL;
  240. this.imaAdsManager_.resize(this.video_.offsetWidth,
  241. this.video_.offsetHeight, viewMode);
  242. });
  243. if ('ResizeObserver' in window) {
  244. this.resizeObserver_ = new ResizeObserver(() => {
  245. const viewMode = this.isFullScreenEnabled_() ?
  246. google.ima.ViewMode.FULLSCREEN : google.ima.ViewMode.NORMAL;
  247. this.imaAdsManager_.resize(this.video_.offsetWidth,
  248. this.video_.offsetHeight, viewMode);
  249. });
  250. this.resizeObserver_.observe(this.video_);
  251. } else {
  252. this.eventManager_.listen(document, 'fullscreenchange', () => {
  253. const viewMode = this.isFullScreenEnabled_() ?
  254. google.ima.ViewMode.FULLSCREEN : google.ima.ViewMode.NORMAL;
  255. this.imaAdsManager_.resize(this.video_.offsetWidth,
  256. this.video_.offsetHeight, viewMode);
  257. });
  258. }
  259. // Single video and overlay ads will start at this time
  260. // TODO (ismena): Need a better inderstanding of what this does.
  261. // The docs say it's called to 'start playing the ads,' but I haven't
  262. // seen the ads actually play until requestAds() is called.
  263. // Note: We listen for a play event to avoid autoplay issues that might
  264. // crash IMA.
  265. if (this.videoPlayed_ || this.config_.skipPlayDetection) {
  266. this.imaAdsManager_.start();
  267. } else {
  268. this.eventManager_.listenOnce(this.video_, 'play', () => {
  269. this.videoPlayed_ = true;
  270. this.imaAdsManager_.start();
  271. });
  272. }
  273. } catch (adError) {
  274. // If there was a problem with the VAST response,
  275. // we we won't be getting an ad. Hide ad UI if we showed it already
  276. // and get back to the presentation.
  277. this.onAdComplete_(/* adEvent= */ null);
  278. }
  279. }
  280. /**
  281. * @return {boolean}
  282. * @private
  283. */
  284. isFullScreenEnabled_() {
  285. if (document.fullscreenEnabled) {
  286. return !!document.fullscreenElement;
  287. } else {
  288. const video = /** @type {HTMLVideoElement} */(this.video_);
  289. if (video.webkitSupportsFullscreen) {
  290. return video.webkitDisplayingFullscreen;
  291. }
  292. }
  293. return false;
  294. }
  295. /**
  296. * @private
  297. */
  298. addImaEventListeners_() {
  299. /**
  300. * @param {!Event} e
  301. * @param {string} type
  302. */
  303. const convertEventAndSend = (e, type) => {
  304. const data = (new Map()).set('originalEvent', e);
  305. this.onEvent_(new shaka.util.FakeEvent(type, data));
  306. };
  307. this.eventManager_.listen(this.imaAdsManager_,
  308. google.ima.AdErrorEvent.Type.AD_ERROR, (error) => {
  309. this.onAdError_(/** @type {!google.ima.AdErrorEvent} */ (error));
  310. });
  311. this.eventManager_.listen(this.imaAdsManager_,
  312. google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED, (e) => {
  313. this.onAdStart_(/** @type {!google.ima.AdEvent} */ (e));
  314. });
  315. this.eventManager_.listen(this.imaAdsManager_,
  316. google.ima.AdEvent.Type.STARTED, (e) => {
  317. this.onAdStart_(/** @type {!google.ima.AdEvent} */ (e));
  318. });
  319. this.eventManager_.listen(this.imaAdsManager_,
  320. google.ima.AdEvent.Type.FIRST_QUARTILE, (e) => {
  321. convertEventAndSend(e, shaka.ads.AdManager.AD_FIRST_QUARTILE);
  322. });
  323. this.eventManager_.listen(this.imaAdsManager_,
  324. google.ima.AdEvent.Type.MIDPOINT, (e) => {
  325. convertEventAndSend(e, shaka.ads.AdManager.AD_MIDPOINT);
  326. });
  327. this.eventManager_.listen(this.imaAdsManager_,
  328. google.ima.AdEvent.Type.THIRD_QUARTILE, (e) => {
  329. convertEventAndSend(e, shaka.ads.AdManager.AD_THIRD_QUARTILE);
  330. });
  331. this.eventManager_.listen(this.imaAdsManager_,
  332. google.ima.AdEvent.Type.COMPLETE, (e) => {
  333. convertEventAndSend(e, shaka.ads.AdManager.AD_COMPLETE);
  334. });
  335. this.eventManager_.listen(this.imaAdsManager_,
  336. google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED, (e) => {
  337. this.onAdComplete_(/** @type {!google.ima.AdEvent} */ (e));
  338. });
  339. this.eventManager_.listen(this.imaAdsManager_,
  340. google.ima.AdEvent.Type.ALL_ADS_COMPLETED, (e) => {
  341. this.onAdComplete_(/** @type {!google.ima.AdEvent} */ (e));
  342. });
  343. this.eventManager_.listen(this.imaAdsManager_,
  344. google.ima.AdEvent.Type.SKIPPED, (e) => {
  345. convertEventAndSend(e, shaka.ads.AdManager.AD_SKIPPED);
  346. });
  347. this.eventManager_.listen(this.imaAdsManager_,
  348. google.ima.AdEvent.Type.VOLUME_CHANGED, (e) => {
  349. convertEventAndSend(e, shaka.ads.AdManager.AD_VOLUME_CHANGED);
  350. });
  351. this.eventManager_.listen(this.imaAdsManager_,
  352. google.ima.AdEvent.Type.VOLUME_MUTED, (e) => {
  353. convertEventAndSend(e, shaka.ads.AdManager.AD_MUTED);
  354. });
  355. this.eventManager_.listen(this.imaAdsManager_,
  356. google.ima.AdEvent.Type.PAUSED, (e) => {
  357. if (this.ad_) {
  358. this.ad_.setPaused(true);
  359. convertEventAndSend(e, shaka.ads.AdManager.AD_PAUSED);
  360. }
  361. });
  362. this.eventManager_.listen(this.imaAdsManager_,
  363. google.ima.AdEvent.Type.RESUMED, (e) => {
  364. if (this.ad_) {
  365. this.ad_.setPaused(false);
  366. convertEventAndSend(e, shaka.ads.AdManager.AD_RESUMED);
  367. }
  368. });
  369. this.eventManager_.listen(this.imaAdsManager_,
  370. google.ima.AdEvent.Type.SKIPPABLE_STATE_CHANGED, (e) => {
  371. if (this.ad_) {
  372. convertEventAndSend(e, shaka.ads.AdManager.AD_SKIP_STATE_CHANGED);
  373. }
  374. });
  375. this.eventManager_.listen(this.imaAdsManager_,
  376. google.ima.AdEvent.Type.CLICK, (e) => {
  377. convertEventAndSend(e, shaka.ads.AdManager.AD_CLICKED);
  378. });
  379. this.eventManager_.listen(this.imaAdsManager_,
  380. google.ima.AdEvent.Type.AD_PROGRESS, (e) => {
  381. convertEventAndSend(e, shaka.ads.AdManager.AD_PROGRESS);
  382. });
  383. this.eventManager_.listen(this.imaAdsManager_,
  384. google.ima.AdEvent.Type.AD_BUFFERING, (e) => {
  385. convertEventAndSend(e, shaka.ads.AdManager.AD_BUFFERING);
  386. });
  387. this.eventManager_.listen(this.imaAdsManager_,
  388. google.ima.AdEvent.Type.IMPRESSION, (e) => {
  389. convertEventAndSend(e, shaka.ads.AdManager.AD_IMPRESSION);
  390. });
  391. this.eventManager_.listen(this.imaAdsManager_,
  392. google.ima.AdEvent.Type.DURATION_CHANGE, (e) => {
  393. convertEventAndSend(e, shaka.ads.AdManager.AD_DURATION_CHANGED);
  394. });
  395. this.eventManager_.listen(this.imaAdsManager_,
  396. google.ima.AdEvent.Type.USER_CLOSE, (e) => {
  397. convertEventAndSend(e, shaka.ads.AdManager.AD_CLOSED);
  398. });
  399. this.eventManager_.listen(this.imaAdsManager_,
  400. google.ima.AdEvent.Type.LOADED, (e) => {
  401. convertEventAndSend(e, shaka.ads.AdManager.AD_LOADED);
  402. });
  403. this.eventManager_.listen(this.imaAdsManager_,
  404. google.ima.AdEvent.Type.ALL_ADS_COMPLETED, (e) => {
  405. convertEventAndSend(e, shaka.ads.AdManager.ALL_ADS_COMPLETED);
  406. });
  407. this.eventManager_.listen(this.imaAdsManager_,
  408. google.ima.AdEvent.Type.LINEAR_CHANGED, (e) => {
  409. convertEventAndSend(e, shaka.ads.AdManager.AD_LINEAR_CHANGED);
  410. });
  411. this.eventManager_.listen(this.imaAdsManager_,
  412. google.ima.AdEvent.Type.AD_METADATA, (e) => {
  413. convertEventAndSend(e, shaka.ads.AdManager.AD_METADATA);
  414. });
  415. this.eventManager_.listen(this.imaAdsManager_,
  416. google.ima.AdEvent.Type.LOG, (e) => {
  417. convertEventAndSend(e, shaka.ads.AdManager.AD_RECOVERABLE_ERROR);
  418. });
  419. this.eventManager_.listen(this.imaAdsManager_,
  420. google.ima.AdEvent.Type.AD_BREAK_READY, (e) => {
  421. convertEventAndSend(e, shaka.ads.AdManager.AD_BREAK_READY);
  422. });
  423. this.eventManager_.listen(this.imaAdsManager_,
  424. google.ima.AdEvent.Type.INTERACTION, (e) => {
  425. convertEventAndSend(e, shaka.ads.AdManager.AD_INTERACTION);
  426. });
  427. }
  428. /**
  429. * @param {!google.ima.AdEvent} e
  430. * @private
  431. */
  432. onAdStart_(e) {
  433. goog.asserts.assert(this.imaAdsManager_,
  434. 'Should have an ads manager at this point!');
  435. const imaAd = e.getAd();
  436. if (!imaAd) {
  437. // Sometimes the IMA SDK will fire a CONTENT_PAUSE_REQUESTED or STARTED
  438. // event with no associated ad object.
  439. // We can't really play an ad in that situation, so just ignore the event.
  440. shaka.log.alwaysWarn(
  441. 'The IMA SDK fired a ' + e.type + ' event with no associated ad. ' +
  442. 'Unable to play ad!');
  443. return;
  444. }
  445. this.ad_ = new shaka.ads.ClientSideAd(imaAd,
  446. this.imaAdsManager_, this.video_);
  447. const data = new Map()
  448. .set('ad', this.ad_)
  449. .set('sdkAdObject', imaAd)
  450. .set('originalEvent', e);
  451. this.onEvent_(new shaka.util.FakeEvent(
  452. shaka.ads.AdManager.AD_STARTED, data));
  453. if (this.ad_.isLinear()) {
  454. this.adContainer_.setAttribute('ad-active', 'true');
  455. if (!this.config_.customPlayheadTracker) {
  456. this.video_.pause();
  457. }
  458. if (this.video_.muted) {
  459. this.ad_.setInitialMuted(this.video_.volume);
  460. } else {
  461. this.ad_.setVolume(this.video_.volume);
  462. }
  463. }
  464. }
  465. /**
  466. * @param {?google.ima.AdEvent} e
  467. * @private
  468. */
  469. onAdComplete_(e) {
  470. this.onEvent_(new shaka.util.FakeEvent(shaka.ads.AdManager.AD_STOPPED,
  471. (new Map()).set('originalEvent', e)));
  472. if (this.ad_ && this.ad_.isLinear()) {
  473. this.adContainer_.removeAttribute('ad-active');
  474. if (!this.config_.customPlayheadTracker && !this.video_.ended) {
  475. this.video_.play();
  476. }
  477. }
  478. }
  479. };