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(); + }); + }); + } +});