From 520930c6650cc95dd773ea9486176c62f097b9bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=81lvaro=20Velad=20Galv=C3=A1n?= <ladvan91@hotmail.com>
Date: Thu, 23 May 2024 09:25:33 +0200
Subject: [PATCH] feat(Ads): Support CS on devices that don't support multiple
 media elements (#6575)

Closes https://github.com/shaka-project/shaka-player/issues/2792
---
 demo/config.js                   |   4 +-
 externs/shaka/player.js          |  12 +-
 lib/player.js                    |  82 +++++++++++++
 lib/util/player_configuration.js |  15 ++-
 test/ads/ad_manager_unit.js      |   3 +
 test/ads_integration.js          | 191 +++++++++++++++++++++++++++++++
 6 files changed, 296 insertions(+), 11 deletions(-)
 create mode 100644 test/ads_integration.js

diff --git a/demo/config.js b/demo/config.js
index 25c58d0d8a..f91dd857db 100644
--- a/demo/config.js
+++ b/demo/config.js
@@ -361,7 +361,9 @@ shakaDemo.Config = class {
         .addBoolInput_('Custom playhead tracker',
             'ads.customPlayheadTracker')
         .addBoolInput_('Skip play detection',
-            'ads.skipPlayDetection');
+            'ads.skipPlayDetection')
+        .addBoolInput_('Supports multiple media elements',
+            'ads.supportsMultipleMediaElements');
   }
 
   /**
diff --git a/externs/shaka/player.js b/externs/shaka/player.js
index d0fb15264c..da4f3f44d8 100644
--- a/externs/shaka/player.js
+++ b/externs/shaka/player.js
@@ -1519,7 +1519,8 @@ shaka.extern.MediaSourceConfiguration;
 /**
  * @typedef {{
  *   customPlayheadTracker: boolean,
- *   skipPlayDetection: boolean
+ *   skipPlayDetection: boolean,
+ *   supportsMultipleMediaElements: boolean
  * }}
  *
  * @description
@@ -1529,13 +1530,20 @@ shaka.extern.MediaSourceConfiguration;
  *   If this is <code>true</code>, we create a custom playhead tracker for
  *   Client Side. This is useful because it allows you to implement the use of
  *   IMA on platforms that do not support multiple video elements.
- *   This value defaults to <code>false</code>.
+ *   Defaults to <code>false</code> except on Tizen, WebOS, Chromecast,
+ *   Hisense, PlayStation 4, PlayStation5, Xbox whose default value is
+ *   <code>true</code>.
  * @property {boolean} skipPlayDetection
  *   If this is true, we will load Client Side ads without waiting for a play
  *   event.
  *   Defaults to <code>false</code> except on Tizen, WebOS, Chromecast,
  *   Hisense, PlayStation 4, PlayStation5, Xbox whose default value is
  *   <code>true</code>.
+ * @property {boolean} supportsMultipleMediaElements
+ *   If this is true, the browser supports multiple media elements.
+ *   Defaults to <code>true</code> except on Tizen, WebOS, Chromecast,
+ *   Hisense, PlayStation 4, PlayStation5, Xbox whose default value is
+ *   <code>false</code>.
  *
  * @exportDoc
  */
diff --git a/lib/player.js b/lib/player.js
index 16896ddddb..34fa0352e2 100644
--- a/lib/player.js
+++ b/lib/player.js
@@ -582,6 +582,12 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
      */
     this.trickPlayEventManager_ = new shaka.util.EventManager();
 
+    /**
+     * For listeners scoped to the lifetime of the ad manager.
+     * @private {shaka.util.EventManager}
+     */
+    this.adManagerEventManager_ = new shaka.util.EventManager();
+
     /** @private {shaka.net.NetworkingEngine} */
     this.networkingEngine_ = null;
 
@@ -764,9 +770,72 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
     /** @private {shaka.extern.IAdManager} */
     this.adManager_ = null;
 
+    /** @private {?shaka.media.PreloadManager} */
+    this.preloadDueAdManager_ = null;
+
+    /** @private {HTMLMediaElement} */
+    this.preloadDueAdManagerVideo_ = null;
+
+    /** @private {shaka.util.Timer} */
+    this.preloadDueAdManagerTimer_ = new shaka.util.Timer(async () => {
+      if (this.preloadDueAdManager_) {
+        goog.asserts.assert(this.preloadDueAdManagerVideo_, 'Must have video');
+        await this.attach(
+            this.preloadDueAdManagerVideo_, /* initializeMediaSource= */ true);
+        await this.load(this.preloadDueAdManager_);
+        this.preloadDueAdManagerVideo_.play();
+        this.preloadDueAdManager_ = null;
+      }
+    });
+
     if (shaka.Player.adManagerFactory_) {
       this.adManager_ = shaka.Player.adManagerFactory_();
       this.adManager_.configure(this.config_.ads);
+
+      // Note: we don't use shaka.ads.AdManager.AD_STARTED to avoid add a
+      // optional module in the player.
+      this.adManagerEventManager_.listen(
+          this.adManager_, 'ad-started', async (e) => {
+            if (this.config_.ads.supportsMultipleMediaElements) {
+              return;
+            }
+            const event = /** @type {?google.ima.AdEvent} */
+              (e['originalEvent']);
+            if (!event) {
+              return;
+            }
+            const contentPauseRequested =
+                google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED;
+            if (event.type != contentPauseRequested) {
+              return;
+            }
+            this.preloadDueAdManagerTimer_.stop();
+            if (!this.preloadDueAdManager_) {
+              this.preloadDueAdManagerVideo_ = this.video_;
+              this.preloadDueAdManager_ =
+                await this.detachAndSavePreload(true);
+            }
+          });
+
+      // Note: we don't use shaka.ads.AdManager.AD_STOPPED to avoid add a
+      // optional module in the player.
+      this.adManagerEventManager_.listen(
+          this.adManager_, 'ad-stopped', (e) => {
+            if (this.config_.ads.supportsMultipleMediaElements) {
+              return;
+            }
+            const event = /** @type {?google.ima.AdEvent} */
+              (e['originalEvent']);
+            if (!event) {
+              return;
+            }
+            const contentResumeRequested =
+                google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED;
+            if (event.type != contentResumeRequested) {
+              return;
+            }
+            this.preloadDueAdManagerTimer_.tickAfter(0.1);
+          });
     }
 
     // If the browser comes back online after being offline, then try to play
@@ -917,6 +986,10 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
       this.trickPlayEventManager_.release();
       this.trickPlayEventManager_ = null;
     }
+    if (this.adManagerEventManager_) {
+      this.adManagerEventManager_.release();
+      this.adManagerEventManager_ = null;
+    }
 
     this.abrManagerFactory_ = null;
     this.config_ = null;
@@ -1310,6 +1383,15 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
         this.adManager_.onAssetUnload();
       }
 
+      if (this.preloadDueAdManager_ && !keepAdManager) {
+        this.preloadDueAdManager_.destroy();
+        this.preloadDueAdManager_ = null;
+      }
+
+      if (!keepAdManager) {
+        this.preloadDueAdManagerTimer_.stop();
+      }
+
       if (this.cmsdManager_) {
         this.cmsdManager_.reset();
       }
diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js
index b38259e4dc..6fdc853aba 100644
--- a/lib/util/player_configuration.js
+++ b/lib/util/player_configuration.js
@@ -373,20 +373,19 @@ shaka.util.PlayerConfiguration = class {
       },
     };
 
+    let customPlayheadTracker = false;
     let skipPlayDetection = false;
-    if (shaka.util.Platform.isWebOS() ||
-        shaka.util.Platform.isTizen() ||
-        shaka.util.Platform.isChromecast() ||
-        shaka.util.Platform.isHisense() ||
-        shaka.util.Platform.isPS5() ||
-        shaka.util.Platform.isPS4() ||
-        shaka.util.Platform.isXboxOne()) {
+    let supportsMultipleMediaElements = true;
+    if (shaka.util.Platform.isSmartTV()) {
+      customPlayheadTracker = true;
       skipPlayDetection = true;
+      supportsMultipleMediaElements = false;
     }
 
     const ads = {
-      customPlayheadTracker: false,
+      customPlayheadTracker,
       skipPlayDetection,
+      supportsMultipleMediaElements,
     };
 
     const textDisplayer = {
diff --git a/test/ads/ad_manager_unit.js b/test/ads/ad_manager_unit.js
index 286466f9c4..22eda3c6e9 100644
--- a/test/ads/ad_manager_unit.js
+++ b/test/ads/ad_manager_unit.js
@@ -25,6 +25,9 @@ describe('Ad manager', () => {
     expect(adManager instanceof shaka.ads.AdManager).toBe(true);
 
     const config = shaka.util.PlayerConfiguration.createDefault().ads;
+    // Since we are using a fake video we cannot use a custom playhead tracker
+    // in these tests.
+    config.customPlayheadTracker = false;
     adManager.configure(config);
 
     adContainer =
diff --git a/test/ads_integration.js b/test/ads_integration.js
new file mode 100644
index 0000000000..36a029ab39
--- /dev/null
+++ b/test/ads_integration.js
@@ -0,0 +1,191 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+describe('Ads', () => {
+  const Util = shaka.test.Util;
+
+  /** @type {!jasmine.Spy} */
+  let onErrorSpy;
+
+  /** @type {!HTMLScriptElement} */
+  let imaScript;
+  /** @type {!HTMLVideoElement} */
+  let video;
+  /** @type {!HTMLElement} */
+  let adContainer;
+  /** @type {shaka.Player} */
+  let player;
+  /** @type {shaka.extern.IAdManager} */
+  let adManager;
+  /** @type {!shaka.util.EventManager} */
+  let eventManager;
+
+  let compiledShaka;
+
+  /** @type {!shaka.test.Waiter} */
+  let waiter;
+
+  /** @type {string} */
+  const streamUri = '/base/test/test/assets/dash-multi-codec/dash.mpd';
+
+  /** @type {string} */
+  const adUri = 'https://pubads.g.doubleclick.net/gampad/ads?' +
+      'sz=640x480&iu=/124319096/external/single_ad_samples&' +
+      'ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&' +
+      'unviewed_position_start=1&' +
+      'cust_params=deployment%3Ddevsite%26sample_ct%3Dlinear&correlator=';
+
+  // Load IMA script breaks Tizen 3, so we need avoid load to the script.
+  if (!shaka.util.Platform.isTizen3()) {
+    beforeAll(async () => {
+      await new Promise((resolve, reject) => {
+        imaScript = /** @type {!HTMLScriptElement} */(
+          document.createElement('script'));
+        imaScript.defer = false;
+        imaScript['async'] = false;
+        imaScript.onload = resolve;
+        imaScript.onerror = reject;
+        imaScript.setAttribute('src',
+            'https://imasdk.googleapis.com/js/sdkloader/ima3.js');
+        document.head.appendChild(imaScript);
+      });
+      video = shaka.test.UiUtils.createVideoElement();
+      document.body.appendChild(video);
+      adContainer =
+        /** @type {!HTMLElement} */ (document.createElement('div'));
+      document.body.appendChild(adContainer);
+      compiledShaka =
+          await shaka.test.Loader.loadShaka(getClientArg('uncompiled'));
+    });
+
+    beforeEach(async () => {
+      await shaka.test.TestScheme.createManifests(compiledShaka, '_compiled');
+      player = new compiledShaka.Player();
+      adManager = player.getAdManager();
+      await player.attach(video);
+
+      player.configure('streaming.useNativeHlsOnSafari', false);
+
+      // Disable stall detection, which can interfere with playback tests.
+      player.configure('streaming.stallEnabled', false);
+
+      // Grab event manager from the uncompiled library:
+      eventManager = new shaka.util.EventManager();
+      waiter = new shaka.test.Waiter(eventManager);
+      waiter.setPlayer(player);
+
+      onErrorSpy = jasmine.createSpy('onError');
+      onErrorSpy.and.callFake((event) => {
+        fail(event.detail);
+      });
+      eventManager.listen(player, 'error', Util.spyFunc(onErrorSpy));
+      eventManager.listen(adManager, shaka.ads.AdManager.AD_ERROR,
+          Util.spyFunc(onErrorSpy));
+    });
+
+    afterEach(async () => {
+      eventManager.release();
+      await player.destroy();
+    });
+
+    afterAll(() => {
+      document.head.removeChild(imaScript);
+      document.body.removeChild(video);
+      document.body.removeChild(adContainer);
+    });
+
+    describe('supports IMA SDK with vast', () => {
+      it('with support for multiple media elements', async () => {
+        if (shaka.util.Platform.isSmartTV()) {
+          pending('Platform without support for multiple media elements.');
+        }
+        player.configure('ads.customPlayheadTracker', false);
+        player.configure('ads.skipPlayDetection', false);
+        player.configure('ads.supportsMultipleMediaElements', true);
+
+        adManager.initClientSide(
+            adContainer, video, /** adsRenderingSettings= **/ null);
+
+        await player.load(streamUri);
+        await video.play();
+        expect(player.isLive()).toBe(false);
+
+        // Wait for the video to start playback.  If it takes longer than 10
+        // seconds, fail the test.
+        await waiter.waitForMovementOrFailOnTimeout(video, 10);
+
+        // Play for 5 seconds, but stop early if the video ends.  If it takes
+        // longer than 20 seconds, fail the test.
+        await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 5, 20);
+
+        const adRequest = new google.ima.AdsRequest();
+        adRequest.adTagUrl = adUri;
+        adManager.requestClientSideAds(adRequest);
+
+        // Wait a maximum of 10 seconds before the ad starts playing.
+        await waiter.timeoutAfter(10)
+            .waitForEvent(adManager, shaka.ads.AdManager.AD_STARTED);
+        await waiter.timeoutAfter(10)
+            .waitForEvent(adManager, shaka.ads.AdManager.AD_FIRST_QUARTILE);
+        await waiter.timeoutAfter(10)
+            .waitForEvent(adManager, shaka.ads.AdManager.AD_MIDPOINT);
+        await waiter.timeoutAfter(10)
+            .waitForEvent(adManager, shaka.ads.AdManager.AD_THIRD_QUARTILE);
+        await waiter.timeoutAfter(10)
+            .waitForEvent(adManager, shaka.ads.AdManager.AD_STOPPED);
+
+        // Play for 10 seconds, but stop early if the video ends.  If it takes
+        // longer than 30 seconds, fail the test.
+        await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 10, 30);
+
+        await player.unload();
+      });
+
+      it('without support for multiple media elements', async () => {
+        player.configure('ads.customPlayheadTracker', true);
+        player.configure('ads.skipPlayDetection', true);
+        player.configure('ads.supportsMultipleMediaElements', false);
+
+        adManager.initClientSide(
+            adContainer, video, /** adsRenderingSettings= **/ null);
+
+        await player.load(streamUri);
+        await video.play();
+        expect(player.isLive()).toBe(false);
+
+        // Wait for the video to start playback.  If it takes longer than 10
+        // seconds, fail the test.
+        await waiter.waitForMovementOrFailOnTimeout(video, 10);
+
+        // Play for 5 seconds, but stop early if the video ends.  If it takes
+        // longer than 20 seconds, fail the test.
+        await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 5, 20);
+
+        const adRequest = new google.ima.AdsRequest();
+        adRequest.adTagUrl = adUri;
+        adManager.requestClientSideAds(adRequest);
+
+        // Wait a maximum of 10 seconds before the ad starts playing.
+        await waiter.timeoutAfter(10)
+            .waitForEvent(adManager, shaka.ads.AdManager.AD_STARTED);
+        await waiter.timeoutAfter(10)
+            .waitForEvent(adManager, shaka.ads.AdManager.AD_FIRST_QUARTILE);
+        await waiter.timeoutAfter(10)
+            .waitForEvent(adManager, shaka.ads.AdManager.AD_MIDPOINT);
+        await waiter.timeoutAfter(10)
+            .waitForEvent(adManager, shaka.ads.AdManager.AD_THIRD_QUARTILE);
+        await waiter.timeoutAfter(10)
+            .waitForEvent(adManager, shaka.ads.AdManager.AD_STOPPED);
+
+        // Play for 10 seconds, but stop early if the video ends.  If it takes
+        // longer than 30 seconds, fail the test.
+        await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 10, 30);
+
+        await player.unload();
+      });
+    });
+  }
+});