Skip to content

Commit

Permalink
feat(Ads): Support CS on devices that don't support multiple media el…
Browse files Browse the repository at this point in the history
  • Loading branch information
avelad committed May 23, 2024
1 parent 20df1a0 commit 520930c
Show file tree
Hide file tree
Showing 6 changed files with 296 additions and 11 deletions.
4 changes: 3 additions & 1 deletion demo/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down
12 changes: 10 additions & 2 deletions externs/shaka/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -1519,7 +1519,8 @@ shaka.extern.MediaSourceConfiguration;
/**
* @typedef {{
* customPlayheadTracker: boolean,
* skipPlayDetection: boolean
* skipPlayDetection: boolean,
* supportsMultipleMediaElements: boolean
* }}
*
* @description
Expand All @@ -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
*/
Expand Down
82 changes: 82 additions & 0 deletions lib/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
15 changes: 7 additions & 8 deletions lib/util/player_configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
3 changes: 3 additions & 0 deletions test/ads/ad_manager_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
191 changes: 191 additions & 0 deletions test/ads_integration.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
}
});

0 comments on commit 520930c

Please sign in to comment.