Skip to content

Commit

Permalink
feat(Ads): Add basic VAST support without IMA (#7052)
Browse files Browse the repository at this point in the history
This only includes playback, no tracking is sent.
  • Loading branch information
avelad authored Jul 17, 2024
1 parent 8b70bb6 commit c59922b
Show file tree
Hide file tree
Showing 13 changed files with 240 additions and 119 deletions.
10 changes: 10 additions & 0 deletions externs/shaka/ads.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ shaka.extern.AdCuePoint;
* endTime: ?number,
* uri: string,
* isSkippable: boolean,
* skipOffset: ?number,
* canJump: boolean,
* resumeOffset: ?number,
* playoutLimit: ?number,
Expand All @@ -87,6 +88,9 @@ shaka.extern.AdCuePoint;
* ShakaPlayer supports (either in MSE or src=)
* @property {boolean} isSkippable
* Indicate if the interstitial is skippable.
* @property {?number} skipOffset
* Time value that identifies when skip controls are made available to the
* end user.
* @property {boolean} canJump
* Indicate if the interstitial is jumpable.
* @property {?number} resumeOffset
Expand Down Expand Up @@ -250,6 +254,12 @@ shaka.extern.IAdManager = class extends EventTarget {
* @param {shaka.extern.AdInterstitial} interstitial
*/
addCustomInterstitial(interstitial) {}

/**
* @param {string} url
* @return {!Promise}
*/
addAdUrlInterstitial(url) {}
};


Expand Down
14 changes: 14 additions & 0 deletions lib/ads/ad_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,20 @@ shaka.ads.AdManager = class extends shaka.util.FakeEventTarget {
this.interstitialAdManager_.addInterstitials([interstitial]);
}

/**
* @override
* @export
*/
addAdUrlInterstitial(url) {
if (!this.interstitialAdManager_) {
throw new shaka.util.Error(
shaka.util.Error.Severity.RECOVERABLE,
shaka.util.Error.Category.ADS,
shaka.util.Error.Code.INTERSTITIAL_AD_MANAGER_NOT_INITIALIZED);
}
return this.interstitialAdManager_.addAdUrlInterstitial(url);
}

/**
* @param {!shaka.util.FakeEvent} event
* @private
Expand Down
84 changes: 83 additions & 1 deletion lib/ads/ad_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,95 @@

goog.provide('shaka.ads.Utils');

goog.require('shaka.util.TextParser');
goog.require('shaka.util.TXml');


/**
* A class responsible for ad utils.
* @export
*/
shaka.ads.Utils = class {

/**
* @param {!shaka.extern.xml.Node} vast
* @param {?number} currentTime
* @return {!Array.<shaka.extern.AdInterstitial>}
*/
static parseVastToInterstitials(vast, currentTime) {
const TXml = shaka.util.TXml;
/** @type {!Array.<shaka.extern.AdInterstitial>} */
const interstitials = [];

let startTime = 0;
if (currentTime != null) {
startTime = currentTime;
}

for (const ad of TXml.findChildren(vast, 'Ad')) {
const inline = TXml.findChild(ad, 'InLine');
if (!inline) {
continue;
}
const creatives = TXml.findChild(inline, 'Creatives');
if (!creatives) {
continue;
}
for (const creative of TXml.findChildren(creatives, 'Creative')) {
const linear = TXml.findChild(creative, 'Linear');
if (!linear) {
continue;
}
let skipOffset = null;
if (linear.attributes['skipoffset']) {
skipOffset = shaka.util.TextParser.parseTime(
linear.attributes['skipoffset']);
if (isNaN(skipOffset)) {
skipOffset = null;
}
}
const mediaFiles = TXml.findChild(linear, 'MediaFiles');
if (!mediaFiles) {
continue;
}
const medias = TXml.findChildren(mediaFiles, 'MediaFile');
let checkMedias = medias;
const streamingMedias = medias.filter((media) => {
return media.attributes['delivery'] == 'streaming';
});
if (streamingMedias.length) {
checkMedias = streamingMedias;
}
const sortedMedias = checkMedias.sort((a, b) => {
const aHeight = parseInt(a.attributes['height'], 10) || 0;
const bHeight = parseInt(b.attributes['height'], 10) || 0;
return bHeight - aHeight;
});
for (const media of sortedMedias) {
const adUrl = TXml.getTextContents(media);
if (!adUrl) {
continue;
}
interstitials.push({
id: null,
startTime: startTime,
endTime: null,
uri: adUrl,
isSkippable: skipOffset != null,
skipOffset,
canJump: false,
resumeOffset: 0,
playoutLimit: null,
once: true,
pre: currentTime == null,
post: currentTime == Infinity,
timelineRange: false,
});
break;
}
}
}
return interstitials;
}
};

/**
Expand Down
11 changes: 9 additions & 2 deletions lib/ads/interstitial_ad.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,22 @@ shaka.ads.InterstitialAd = class {
/**
* @param {HTMLMediaElement} video
* @param {boolean} isSkippable
* @param {?number} skipOffset
* @param {function()} onSkip
* @param {number} sequenceLength
* @param {number} adPosition
*/
constructor(video, isSkippable, onSkip, sequenceLength, adPosition) {
constructor(video, isSkippable, skipOffset, onSkip,
sequenceLength, adPosition) {
/** @private {HTMLMediaElement} */
this.video_ = video;

/** @private {boolean} */
this.isSkippable_ = isSkippable;

/** @private {?number} */
this.skipOffset_ = skipOffset;

/** @private {function()} */
this.onSkip_ = onSkip;

Expand Down Expand Up @@ -91,7 +96,9 @@ shaka.ads.InterstitialAd = class {
*/
getTimeUntilSkippable() {
if (this.isSkippable_) {
return 0;
const canSkipIn =
this.getRemainingTime() + this.skipOffset_ - this.getDuration();
return Math.max(canSkipIn, 0);
}
return Math.max(this.getRemainingTime(), 0);
}
Expand Down
76 changes: 61 additions & 15 deletions lib/ads/interstitial_ad_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ goog.require('shaka.log');
goog.require('shaka.media.PreloadManager');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.Dom');
goog.require('shaka.util.Error');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.Platform');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.Timer');
goog.require('shaka.util.TXml');


/**
Expand Down Expand Up @@ -223,6 +225,32 @@ shaka.ads.InterstitialAdManager = class {
}
}

/**
* @param {string} url
* @return {!Promise}
*/
async addAdUrlInterstitial(url) {
const type = shaka.net.NetworkingEngine.RequestType.ADS;
const request = shaka.net.NetworkingEngine.makeRequest(
[url],
shaka.net.NetworkingEngine.defaultRetryParameters());
const op = this.basePlayer_.getNetworkingEngine().request(type, request);
const response = await op.promise;
const data = shaka.util.TXml.parseXml(response.data, 'VAST,vmap:VMAP');
if (!data) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.ADS,
shaka.util.Error.Code.VAST_INVALID_XML);
}
let interstitials = [];
if (data.tagName == 'VAST') {
interstitials = shaka.ads.Utils.parseVastToInterstitials(
data, this.lastTime_);
}
this.addInterstitials(interstitials);
}


/**
* @param {!Array.<shaka.extern.AdInterstitial>} interstitials
Expand Down Expand Up @@ -488,7 +516,8 @@ shaka.ads.InterstitialAdManager = class {
};

const ad = new shaka.ads.InterstitialAd(this.video_,
interstitial.isSkippable, onSkip, sequenceLength, adPosition);
interstitial.isSkippable, interstitial.skipOffset,
onSkip, sequenceLength, adPosition);
if (!this.usingBaseVideo_) {
ad.setMuted(this.baseVideo_.muted);
ad.setVolume(this.baseVideo_.volume);
Expand All @@ -502,21 +531,36 @@ shaka.ads.InterstitialAdManager = class {
this.onEvent_(new shaka.util.FakeEvent(
shaka.ads.Utils.AD_SKIP_STATE_CHANGED));
}
const eventsSent = new Set();
this.adEventManager_.listenOnce(this.player_, 'error', error);
this.adEventManager_.listenOnce(this.player_, 'firstquartile', () => {
updateBaseVideoTime();
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.Utils.AD_FIRST_QUARTILE));
});
this.adEventManager_.listenOnce(this.player_, 'midpoint', () => {
updateBaseVideoTime();
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.Utils.AD_MIDPOINT));
});
this.adEventManager_.listenOnce(this.player_, 'thirdquartile', () => {
updateBaseVideoTime();
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.Utils.AD_THIRD_QUARTILE));
this.adEventManager_.listen(this.video_, 'timeupdate', () => {
const duration = this.video_.duration;
if (!duration) {
return;
}
if (interstitial.isSkippable && interstitial.skipOffset &&
ad.canSkipNow() && ad.getRemainingTime() > 0 &&
ad.getDuration() > 0) {
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.Utils.AD_SKIP_STATE_CHANGED));
}
const currentPercent = 100 * this.video_.currentTime / duration;
if (currentPercent >= 25 && !eventsSent.has('firstquartile')) {
updateBaseVideoTime();
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.Utils.AD_FIRST_QUARTILE));
eventsSent.add('firstquartile');
} else if (currentPercent >= 50 && !eventsSent.has('midpoint')) {
updateBaseVideoTime();
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.Utils.AD_MIDPOINT));
eventsSent.add('midpoint');
} else if (currentPercent >= 75 && !eventsSent.has('thirdquartile')) {
updateBaseVideoTime();
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.Utils.AD_THIRD_QUARTILE));
eventsSent.add('thirdquartile');
}
});
this.adEventManager_.listenOnce(this.player_, 'complete', complete);
this.adEventManager_.listen(this.video_, 'play', () => {
Expand Down Expand Up @@ -647,6 +691,7 @@ shaka.ads.InterstitialAdManager = class {
endTime: hlsInterstitial.endTime,
uri,
isSkippable,
skipOffset: isSkippable ? 0 : null,
canJump,
resumeOffset,
playoutLimit,
Expand Down Expand Up @@ -679,6 +724,7 @@ shaka.ads.InterstitialAdManager = class {
endTime: hlsInterstitial.endTime,
uri: asset['URI'],
isSkippable,
skipOffset: isSkippable ? 0 : null,
canJump,
resumeOffset,
playoutLimit,
Expand Down
40 changes: 1 addition & 39 deletions lib/ads/media_tailor_ad.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ shaka.ads.MediaTailorAd = class {
this.ad_ = mediaTailorAd;

/** @private {?number} */
this.skipOffset_ = this.parseTime_(this.ad_.skipOffset);
this.skipOffset_ = shaka.util.TextParser.parseTime(this.ad_.skipOffset);

/** @private {HTMLMediaElement} */
this.video_ = video;
Expand Down Expand Up @@ -311,42 +311,4 @@ shaka.ads.MediaTailorAd = class {
isSkipped() {
return this.isSkipped_;
}

/**
* Parses a time from string.
*
* @param {?string} time
* @return {?number}
* @private
*/
parseTime_(time) {
if (!time) {
return null;
}
const parser = new shaka.util.TextParser(time);
const results = parser.readRegex(shaka.ads.MediaTailorAd.timeFormat_);
if (results == null) {
return null;
}
// This capture is optional, but will still be in the array as undefined,
// in which case it is 0.
const hours = Number(results[1]) || 0;
const minutes = Number(results[2]);
const seconds = Number(results[3]);
const milliseconds = Number(results[4]) || 0;
if (minutes > 59 || seconds > 59) {
return null;
}

return (milliseconds / 1000) + seconds + (minutes * 60) + (hours * 3600);
}
};

/**
* @const
* @private {!RegExp}
* @example 00:00.000 or 00:00:00.000 or 0:00:00.000 or
* 00:00.00 or 00:00:00.00 or 0:00:00.00 or 00:00:00
*/
shaka.ads.MediaTailorAd.timeFormat_ =
/(?:(\d{1,}):)?(\d{2}):(\d{2})((\.(\d{1,3})))?/g;
Loading

0 comments on commit c59922b

Please sign in to comment.