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