Source: lib/ads/server_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.ServerSideAdManager');
  11. goog.require('goog.asserts');
  12. goog.require('shaka.ads.ServerSideAd');
  13. goog.require('shaka.log');
  14. goog.require('shaka.util.EventManager');
  15. goog.require('shaka.util.Error');
  16. goog.require('shaka.util.FakeEvent');
  17. goog.require('shaka.util.IReleasable');
  18. goog.require('shaka.util.PublicPromise');
  19. /**
  20. * A class responsible for server-side ad interactions.
  21. * @implements {shaka.util.IReleasable}
  22. */
  23. shaka.ads.ServerSideAdManager = class {
  24. /**
  25. * @param {HTMLElement} adContainer
  26. * @param {HTMLMediaElement} video
  27. * @param {string} locale
  28. * @param {function(!shaka.util.FakeEvent)} onEvent
  29. */
  30. constructor(adContainer, video, locale, onEvent) {
  31. /** @private {HTMLElement} */
  32. this.adContainer_ = adContainer;
  33. /** @private {HTMLMediaElement} */
  34. this.video_ = video;
  35. /** @private {?shaka.extern.AdsConfiguration} */
  36. this.config_ = null;
  37. /** @private
  38. {?shaka.util.PublicPromise.<string>} */
  39. this.streamPromise_ = null;
  40. /** @private {number} */
  41. this.streamRequestStartTime_ = NaN;
  42. /** @private {function(!shaka.util.FakeEvent)} */
  43. this.onEvent_ = onEvent;
  44. /** @private {boolean} */
  45. this.isLiveContent_ = false;
  46. /**
  47. * Time to seek to after an ad if that ad was played as the result of
  48. * snapback.
  49. * @private {?number}
  50. */
  51. this.snapForwardTime_ = null;
  52. /** @private {shaka.ads.ServerSideAd} */
  53. this.ad_ = null;
  54. /** @private {?google.ima.dai.api.AdProgressData} */
  55. this.adProgressData_ = null;
  56. /** @private {string} */
  57. this.backupUrl_ = '';
  58. /** @private {!Array.<!shaka.extern.AdCuePoint>} */
  59. this.currentCuePoints_ = [];
  60. /** @private {shaka.util.EventManager} */
  61. this.eventManager_ = new shaka.util.EventManager();
  62. /** @private {google.ima.dai.api.UiSettings} */
  63. const uiSettings = new google.ima.dai.api.UiSettings();
  64. uiSettings.setLocale(locale);
  65. /** @private {google.ima.dai.api.StreamManager} */
  66. this.streamManager_ = new google.ima.dai.api.StreamManager(
  67. this.video_, this.adContainer_, uiSettings);
  68. this.onEvent_(new shaka.util.FakeEvent(
  69. shaka.ads.AdManager.IMA_STREAM_MANAGER_LOADED,
  70. (new Map()).set('imaStreamManager', this.streamManager_)));
  71. // Events
  72. this.eventManager_.listen(this.streamManager_,
  73. google.ima.dai.api.StreamEvent.Type.LOADED, (e) => {
  74. shaka.log.info('Ad SS Loaded');
  75. this.onLoaded_(
  76. /** @type {!google.ima.dai.api.StreamEvent} */ (e));
  77. });
  78. this.eventManager_.listen(this.streamManager_,
  79. google.ima.dai.api.StreamEvent.Type.ERROR, () => {
  80. shaka.log.info('Ad SS Error');
  81. this.onError_();
  82. });
  83. this.eventManager_.listen(this.streamManager_,
  84. google.ima.dai.api.StreamEvent.Type.AD_BREAK_STARTED, () => {
  85. shaka.log.info('Ad Break Started');
  86. });
  87. this.eventManager_.listen(this.streamManager_,
  88. google.ima.dai.api.StreamEvent.Type.STARTED, (e) => {
  89. shaka.log.info('Ad Started');
  90. this.onAdStart_(/** @type {!google.ima.dai.api.StreamEvent} */ (e));
  91. });
  92. this.eventManager_.listen(this.streamManager_,
  93. google.ima.dai.api.StreamEvent.Type.AD_BREAK_ENDED, () => {
  94. shaka.log.info('Ad Break Ended');
  95. this.onAdBreakEnded_();
  96. });
  97. this.eventManager_.listen(this.streamManager_,
  98. google.ima.dai.api.StreamEvent.Type.AD_PROGRESS, (e) => {
  99. this.onAdProgress_(
  100. /** @type {!google.ima.dai.api.StreamEvent} */ (e));
  101. });
  102. this.eventManager_.listen(this.streamManager_,
  103. google.ima.dai.api.StreamEvent.Type.FIRST_QUARTILE, () => {
  104. shaka.log.info('Ad event: First Quartile');
  105. this.onEvent_(
  106. new shaka.util.FakeEvent(shaka.ads.AdManager.AD_FIRST_QUARTILE));
  107. });
  108. this.eventManager_.listen(this.streamManager_,
  109. google.ima.dai.api.StreamEvent.Type.MIDPOINT, () => {
  110. shaka.log.info('Ad event: Midpoint');
  111. this.onEvent_(
  112. new shaka.util.FakeEvent(shaka.ads.AdManager.AD_MIDPOINT));
  113. });
  114. this.eventManager_.listen(this.streamManager_,
  115. google.ima.dai.api.StreamEvent.Type.THIRD_QUARTILE, () => {
  116. shaka.log.info('Ad event: Third Quartile');
  117. this.onEvent_(
  118. new shaka.util.FakeEvent(shaka.ads.AdManager.AD_THIRD_QUARTILE));
  119. });
  120. this.eventManager_.listen(this.streamManager_,
  121. google.ima.dai.api.StreamEvent.Type.COMPLETE, () => {
  122. shaka.log.info('Ad event: Complete');
  123. this.onEvent_(
  124. new shaka.util.FakeEvent(shaka.ads.AdManager.AD_COMPLETE));
  125. this.onEvent_(
  126. new shaka.util.FakeEvent(shaka.ads.AdManager.AD_STOPPED));
  127. this.adContainer_.removeAttribute('ad-active');
  128. this.ad_ = null;
  129. });
  130. this.eventManager_.listen(this.streamManager_,
  131. google.ima.dai.api.StreamEvent.Type.SKIPPED, () => {
  132. shaka.log.info('Ad event: Skipped');
  133. this.onEvent_(
  134. new shaka.util.FakeEvent(shaka.ads.AdManager.AD_SKIPPED));
  135. this.onEvent_(
  136. new shaka.util.FakeEvent(shaka.ads.AdManager.AD_STOPPED));
  137. });
  138. this.eventManager_.listen(this.streamManager_,
  139. google.ima.dai.api.StreamEvent.Type.CUEPOINTS_CHANGED, (e) => {
  140. shaka.log.info('Ad event: Cue points changed');
  141. this.onCuePointsChanged_(
  142. /** @type {!google.ima.dai.api.StreamEvent} */ (e));
  143. });
  144. }
  145. /**
  146. * Called by the AdManager to provide an updated configuration any time it
  147. * changes.
  148. *
  149. * @param {shaka.extern.AdsConfiguration} config
  150. */
  151. configure(config) {
  152. this.config_ = config;
  153. }
  154. /**
  155. * @param {!google.ima.dai.api.StreamRequest} streamRequest
  156. * @param {string=} backupUrl
  157. * @return {!Promise.<string>}
  158. */
  159. streamRequest(streamRequest, backupUrl) {
  160. if (this.streamPromise_) {
  161. return Promise.reject(new shaka.util.Error(
  162. shaka.util.Error.Severity.RECOVERABLE,
  163. shaka.util.Error.Category.ADS,
  164. shaka.util.Error.Code.CURRENT_DAI_REQUEST_NOT_FINISHED));
  165. }
  166. if (streamRequest instanceof google.ima.dai.api.LiveStreamRequest) {
  167. this.isLiveContent_ = true;
  168. }
  169. this.streamPromise_ = new shaka.util.PublicPromise();
  170. this.streamManager_.requestStream(streamRequest);
  171. this.backupUrl_ = backupUrl || '';
  172. this.streamRequestStartTime_ = Date.now() / 1000;
  173. return this.streamPromise_;
  174. }
  175. /**
  176. * @param {Object} adTagParameters
  177. */
  178. replaceAdTagParameters(adTagParameters) {
  179. this.streamManager_.replaceAdTagParameters(adTagParameters);
  180. }
  181. /**
  182. * Resets the stream manager and removes any continuous polling.
  183. */
  184. stop() {
  185. // TODO:
  186. // For SS DAI streams, if a different asset gets unloaded as
  187. // part of the process
  188. // of loading a DAI asset, stream manager state gets reset and we
  189. // don't get any ad events.
  190. // We need to figure out if it makes sense to stop the SS
  191. // manager on unload, and, if it does, find
  192. // a way to do it safely.
  193. // this.streamManager_.reset();
  194. this.backupUrl_ = '';
  195. this.snapForwardTime_ = null;
  196. this.currentCuePoints_ = [];
  197. }
  198. /** @override */
  199. release() {
  200. this.stop();
  201. if (this.eventManager_) {
  202. this.eventManager_.release();
  203. }
  204. }
  205. /**
  206. * @param {string} type
  207. * @param {Uint8Array|string} data
  208. * Comes as string in DASH and as Uint8Array in HLS.
  209. * @param {number} timestamp (in seconds)
  210. */
  211. onTimedMetadata(type, data, timestamp) {
  212. this.streamManager_.processMetadata(type, data, timestamp);
  213. }
  214. /**
  215. * @param {shaka.extern.MetadataFrame} value
  216. */
  217. onCueMetadataChange(value) {
  218. // Native HLS over Safari/iOS/iPadOS
  219. // For live event streams, the stream needs some way of informing the SDK
  220. // that an ad break is coming up or ending. In the IMA DAI SDK, this is
  221. // done through timed metadata. Timed metadata is carried as part of the
  222. // DAI stream content and carries ad break timing information used by the
  223. // SDK to track ad breaks.
  224. if (value.key && value.data) {
  225. const metadata = {};
  226. metadata[value.key] = value.data;
  227. this.streamManager_.onTimedMetadata(metadata);
  228. }
  229. }
  230. /**
  231. * @return {!Array.<!shaka.extern.AdCuePoint>}
  232. */
  233. getCuePoints() {
  234. return this.currentCuePoints_;
  235. }
  236. /**
  237. * If a seek jumped over the ad break, return to the start of the
  238. * ad break, then complete the seek after the ad played through.
  239. * @private
  240. */
  241. checkForSnapback_() {
  242. const currentTime = this.video_.currentTime;
  243. if (currentTime == 0) {
  244. return;
  245. }
  246. this.streamManager_.streamTimeForContentTime(currentTime);
  247. const previousCuePoint =
  248. this.streamManager_.previousCuePointForStreamTime(currentTime);
  249. // The cue point gets marked as 'played' as soon as the playhead hits it
  250. // (at the start of an ad), so when we come back to this method as a result
  251. // of seeking back to the user-selected time, the 'played' flag will be set.
  252. if (previousCuePoint && !previousCuePoint.played) {
  253. shaka.log.info('Seeking back to the start of the ad break at ' +
  254. previousCuePoint.start + ' and will return to ' + currentTime);
  255. this.snapForwardTime_ = currentTime;
  256. this.video_.currentTime = previousCuePoint.start;
  257. }
  258. }
  259. /**
  260. * @param {!google.ima.dai.api.StreamEvent} e
  261. * @private
  262. */
  263. onAdStart_(e) {
  264. goog.asserts.assert(this.streamManager_,
  265. 'Should have a stream manager at this point!');
  266. const imaAd = e.getAd();
  267. this.ad_ = new shaka.ads.ServerSideAd(imaAd, this.video_);
  268. // Ad object and ad progress data come from two different IMA events.
  269. // It's a race, and we don't know, which one will fire first - the
  270. // event that contains an ad object (AD_STARTED) or the one that
  271. // contains ad progress info (AD_PROGRESS).
  272. // If the progress event fired first, we must've saved the progress
  273. // info and can now add it to the ad object.
  274. if (this.adProgressData_) {
  275. this.ad_.setProgressData(this.adProgressData_);
  276. }
  277. this.onEvent_(new shaka.util.FakeEvent(shaka.ads.AdManager.AD_STARTED,
  278. (new Map()).set('ad', this.ad_)));
  279. this.adContainer_.setAttribute('ad-active', 'true');
  280. }
  281. /**
  282. * @private
  283. */
  284. onAdBreakEnded_() {
  285. this.adContainer_.removeAttribute('ad-active');
  286. const currentTime = this.video_.currentTime;
  287. // If the ad break was a result of snapping back (a user seeked over
  288. // an ad break and was returned to it), seek forward to the point,
  289. // originally chosen by the user.
  290. if (this.snapForwardTime_ && this.snapForwardTime_ > currentTime) {
  291. this.video_.currentTime = this.snapForwardTime_;
  292. this.snapForwardTime_ = null;
  293. }
  294. }
  295. /**
  296. * @param {!google.ima.dai.api.StreamEvent} e
  297. * @private
  298. */
  299. onLoaded_(e) {
  300. const now = Date.now() / 1000;
  301. const loadTime = now - this.streamRequestStartTime_;
  302. this.onEvent_(new shaka.util.FakeEvent(shaka.ads.AdManager.ADS_LOADED,
  303. (new Map()).set('loadTime', loadTime)));
  304. const streamData = e.getStreamData();
  305. const url = streamData.url;
  306. this.streamPromise_.resolve(url);
  307. this.streamPromise_ = null;
  308. if (!this.isLiveContent_) {
  309. this.eventManager_.listen(this.video_, 'seeked', () => {
  310. this.checkForSnapback_();
  311. });
  312. }
  313. }
  314. /**
  315. * @private
  316. */
  317. onError_() {
  318. if (!this.backupUrl_.length) {
  319. this.streamPromise_.reject('IMA Stream request returned an error ' +
  320. 'and there was no backup asset uri provided.');
  321. this.streamPromise_ = null;
  322. return;
  323. }
  324. shaka.log.warning('IMA stream request returned an error. ' +
  325. 'Falling back to the backup asset uri.');
  326. this.streamPromise_.resolve(this.backupUrl_);
  327. this.streamPromise_ = null;
  328. }
  329. /**
  330. * @param {!google.ima.dai.api.StreamEvent} e
  331. * @private
  332. */
  333. onAdProgress_(e) {
  334. const streamData = e.getStreamData();
  335. const adProgressData = streamData.adProgressData;
  336. this.adProgressData_ = adProgressData;
  337. if (this.ad_) {
  338. this.ad_.setProgressData(this.adProgressData_);
  339. }
  340. }
  341. /**
  342. * @param {!google.ima.dai.api.StreamEvent} e
  343. * @private
  344. */
  345. onCuePointsChanged_(e) {
  346. const streamData = e.getStreamData();
  347. /** @type {!Array.<!shaka.extern.AdCuePoint>} */
  348. const cuePoints = [];
  349. for (const point of streamData.cuepoints) {
  350. /** @type {shaka.extern.AdCuePoint} */
  351. const shakaCuePoint = {
  352. start: point.start,
  353. end: point.end,
  354. };
  355. cuePoints.push(shakaCuePoint);
  356. }
  357. this.currentCuePoints_ = cuePoints;
  358. this.onEvent_(new shaka.util.FakeEvent(
  359. shaka.ads.AdManager.CUEPOINTS_CHANGED,
  360. (new Map()).set('cuepoints', cuePoints)));
  361. }
  362. };