Skip to content

Commit c59922b

Browse files
authored
feat(Ads): Add basic VAST support without IMA (#7052)
This only includes playback, no tracking is sent.
1 parent 8b70bb6 commit c59922b

File tree

13 files changed

+240
-119
lines changed

13 files changed

+240
-119
lines changed

externs/shaka/ads.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ shaka.extern.AdCuePoint;
6464
* endTime: ?number,
6565
* uri: string,
6666
* isSkippable: boolean,
67+
* skipOffset: ?number,
6768
* canJump: boolean,
6869
* resumeOffset: ?number,
6970
* playoutLimit: ?number,
@@ -87,6 +88,9 @@ shaka.extern.AdCuePoint;
8788
* ShakaPlayer supports (either in MSE or src=)
8889
* @property {boolean} isSkippable
8990
* Indicate if the interstitial is skippable.
91+
* @property {?number} skipOffset
92+
* Time value that identifies when skip controls are made available to the
93+
* end user.
9094
* @property {boolean} canJump
9195
* Indicate if the interstitial is jumpable.
9296
* @property {?number} resumeOffset
@@ -250,6 +254,12 @@ shaka.extern.IAdManager = class extends EventTarget {
250254
* @param {shaka.extern.AdInterstitial} interstitial
251255
*/
252256
addCustomInterstitial(interstitial) {}
257+
258+
/**
259+
* @param {string} url
260+
* @return {!Promise}
261+
*/
262+
addAdUrlInterstitial(url) {}
253263
};
254264

255265

lib/ads/ad_manager.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,20 @@ shaka.ads.AdManager = class extends shaka.util.FakeEventTarget {
846846
this.interstitialAdManager_.addInterstitials([interstitial]);
847847
}
848848

849+
/**
850+
* @override
851+
* @export
852+
*/
853+
addAdUrlInterstitial(url) {
854+
if (!this.interstitialAdManager_) {
855+
throw new shaka.util.Error(
856+
shaka.util.Error.Severity.RECOVERABLE,
857+
shaka.util.Error.Category.ADS,
858+
shaka.util.Error.Code.INTERSTITIAL_AD_MANAGER_NOT_INITIALIZED);
859+
}
860+
return this.interstitialAdManager_.addAdUrlInterstitial(url);
861+
}
862+
849863
/**
850864
* @param {!shaka.util.FakeEvent} event
851865
* @private

lib/ads/ad_utils.js

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,95 @@
77

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

10+
goog.require('shaka.util.TextParser');
11+
goog.require('shaka.util.TXml');
12+
1013

1114
/**
1215
* A class responsible for ad utils.
1316
* @export
1417
*/
1518
shaka.ads.Utils = class {
16-
19+
/**
20+
* @param {!shaka.extern.xml.Node} vast
21+
* @param {?number} currentTime
22+
* @return {!Array.<shaka.extern.AdInterstitial>}
23+
*/
24+
static parseVastToInterstitials(vast, currentTime) {
25+
const TXml = shaka.util.TXml;
26+
/** @type {!Array.<shaka.extern.AdInterstitial>} */
27+
const interstitials = [];
28+
29+
let startTime = 0;
30+
if (currentTime != null) {
31+
startTime = currentTime;
32+
}
33+
34+
for (const ad of TXml.findChildren(vast, 'Ad')) {
35+
const inline = TXml.findChild(ad, 'InLine');
36+
if (!inline) {
37+
continue;
38+
}
39+
const creatives = TXml.findChild(inline, 'Creatives');
40+
if (!creatives) {
41+
continue;
42+
}
43+
for (const creative of TXml.findChildren(creatives, 'Creative')) {
44+
const linear = TXml.findChild(creative, 'Linear');
45+
if (!linear) {
46+
continue;
47+
}
48+
let skipOffset = null;
49+
if (linear.attributes['skipoffset']) {
50+
skipOffset = shaka.util.TextParser.parseTime(
51+
linear.attributes['skipoffset']);
52+
if (isNaN(skipOffset)) {
53+
skipOffset = null;
54+
}
55+
}
56+
const mediaFiles = TXml.findChild(linear, 'MediaFiles');
57+
if (!mediaFiles) {
58+
continue;
59+
}
60+
const medias = TXml.findChildren(mediaFiles, 'MediaFile');
61+
let checkMedias = medias;
62+
const streamingMedias = medias.filter((media) => {
63+
return media.attributes['delivery'] == 'streaming';
64+
});
65+
if (streamingMedias.length) {
66+
checkMedias = streamingMedias;
67+
}
68+
const sortedMedias = checkMedias.sort((a, b) => {
69+
const aHeight = parseInt(a.attributes['height'], 10) || 0;
70+
const bHeight = parseInt(b.attributes['height'], 10) || 0;
71+
return bHeight - aHeight;
72+
});
73+
for (const media of sortedMedias) {
74+
const adUrl = TXml.getTextContents(media);
75+
if (!adUrl) {
76+
continue;
77+
}
78+
interstitials.push({
79+
id: null,
80+
startTime: startTime,
81+
endTime: null,
82+
uri: adUrl,
83+
isSkippable: skipOffset != null,
84+
skipOffset,
85+
canJump: false,
86+
resumeOffset: 0,
87+
playoutLimit: null,
88+
once: true,
89+
pre: currentTime == null,
90+
post: currentTime == Infinity,
91+
timelineRange: false,
92+
});
93+
break;
94+
}
95+
}
96+
}
97+
return interstitials;
98+
}
1799
};
18100

19101
/**

lib/ads/interstitial_ad.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,22 @@ shaka.ads.InterstitialAd = class {
1515
/**
1616
* @param {HTMLMediaElement} video
1717
* @param {boolean} isSkippable
18+
* @param {?number} skipOffset
1819
* @param {function()} onSkip
1920
* @param {number} sequenceLength
2021
* @param {number} adPosition
2122
*/
22-
constructor(video, isSkippable, onSkip, sequenceLength, adPosition) {
23+
constructor(video, isSkippable, skipOffset, onSkip,
24+
sequenceLength, adPosition) {
2325
/** @private {HTMLMediaElement} */
2426
this.video_ = video;
2527

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

31+
/** @private {?number} */
32+
this.skipOffset_ = skipOffset;
33+
2934
/** @private {function()} */
3035
this.onSkip_ = onSkip;
3136

@@ -91,7 +96,9 @@ shaka.ads.InterstitialAd = class {
9196
*/
9297
getTimeUntilSkippable() {
9398
if (this.isSkippable_) {
94-
return 0;
99+
const canSkipIn =
100+
this.getRemainingTime() + this.skipOffset_ - this.getDuration();
101+
return Math.max(canSkipIn, 0);
95102
}
96103
return Math.max(this.getRemainingTime(), 0);
97104
}

lib/ads/interstitial_ad_manager.js

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ goog.require('shaka.log');
1515
goog.require('shaka.media.PreloadManager');
1616
goog.require('shaka.net.NetworkingEngine');
1717
goog.require('shaka.util.Dom');
18+
goog.require('shaka.util.Error');
1819
goog.require('shaka.util.EventManager');
1920
goog.require('shaka.util.FakeEvent');
2021
goog.require('shaka.util.IReleasable');
2122
goog.require('shaka.util.Platform');
2223
goog.require('shaka.util.PublicPromise');
2324
goog.require('shaka.util.StringUtils');
2425
goog.require('shaka.util.Timer');
26+
goog.require('shaka.util.TXml');
2527

2628

2729
/**
@@ -223,6 +225,32 @@ shaka.ads.InterstitialAdManager = class {
223225
}
224226
}
225227

228+
/**
229+
* @param {string} url
230+
* @return {!Promise}
231+
*/
232+
async addAdUrlInterstitial(url) {
233+
const type = shaka.net.NetworkingEngine.RequestType.ADS;
234+
const request = shaka.net.NetworkingEngine.makeRequest(
235+
[url],
236+
shaka.net.NetworkingEngine.defaultRetryParameters());
237+
const op = this.basePlayer_.getNetworkingEngine().request(type, request);
238+
const response = await op.promise;
239+
const data = shaka.util.TXml.parseXml(response.data, 'VAST,vmap:VMAP');
240+
if (!data) {
241+
throw new shaka.util.Error(
242+
shaka.util.Error.Severity.CRITICAL,
243+
shaka.util.Error.Category.ADS,
244+
shaka.util.Error.Code.VAST_INVALID_XML);
245+
}
246+
let interstitials = [];
247+
if (data.tagName == 'VAST') {
248+
interstitials = shaka.ads.Utils.parseVastToInterstitials(
249+
data, this.lastTime_);
250+
}
251+
this.addInterstitials(interstitials);
252+
}
253+
226254

227255
/**
228256
* @param {!Array.<shaka.extern.AdInterstitial>} interstitials
@@ -488,7 +516,8 @@ shaka.ads.InterstitialAdManager = class {
488516
};
489517

490518
const ad = new shaka.ads.InterstitialAd(this.video_,
491-
interstitial.isSkippable, onSkip, sequenceLength, adPosition);
519+
interstitial.isSkippable, interstitial.skipOffset,
520+
onSkip, sequenceLength, adPosition);
492521
if (!this.usingBaseVideo_) {
493522
ad.setMuted(this.baseVideo_.muted);
494523
ad.setVolume(this.baseVideo_.volume);
@@ -502,21 +531,36 @@ shaka.ads.InterstitialAdManager = class {
502531
this.onEvent_(new shaka.util.FakeEvent(
503532
shaka.ads.Utils.AD_SKIP_STATE_CHANGED));
504533
}
534+
const eventsSent = new Set();
505535
this.adEventManager_.listenOnce(this.player_, 'error', error);
506-
this.adEventManager_.listenOnce(this.player_, 'firstquartile', () => {
507-
updateBaseVideoTime();
508-
this.onEvent_(
509-
new shaka.util.FakeEvent(shaka.ads.Utils.AD_FIRST_QUARTILE));
510-
});
511-
this.adEventManager_.listenOnce(this.player_, 'midpoint', () => {
512-
updateBaseVideoTime();
513-
this.onEvent_(
514-
new shaka.util.FakeEvent(shaka.ads.Utils.AD_MIDPOINT));
515-
});
516-
this.adEventManager_.listenOnce(this.player_, 'thirdquartile', () => {
517-
updateBaseVideoTime();
518-
this.onEvent_(
519-
new shaka.util.FakeEvent(shaka.ads.Utils.AD_THIRD_QUARTILE));
536+
this.adEventManager_.listen(this.video_, 'timeupdate', () => {
537+
const duration = this.video_.duration;
538+
if (!duration) {
539+
return;
540+
}
541+
if (interstitial.isSkippable && interstitial.skipOffset &&
542+
ad.canSkipNow() && ad.getRemainingTime() > 0 &&
543+
ad.getDuration() > 0) {
544+
this.onEvent_(
545+
new shaka.util.FakeEvent(shaka.ads.Utils.AD_SKIP_STATE_CHANGED));
546+
}
547+
const currentPercent = 100 * this.video_.currentTime / duration;
548+
if (currentPercent >= 25 && !eventsSent.has('firstquartile')) {
549+
updateBaseVideoTime();
550+
this.onEvent_(
551+
new shaka.util.FakeEvent(shaka.ads.Utils.AD_FIRST_QUARTILE));
552+
eventsSent.add('firstquartile');
553+
} else if (currentPercent >= 50 && !eventsSent.has('midpoint')) {
554+
updateBaseVideoTime();
555+
this.onEvent_(
556+
new shaka.util.FakeEvent(shaka.ads.Utils.AD_MIDPOINT));
557+
eventsSent.add('midpoint');
558+
} else if (currentPercent >= 75 && !eventsSent.has('thirdquartile')) {
559+
updateBaseVideoTime();
560+
this.onEvent_(
561+
new shaka.util.FakeEvent(shaka.ads.Utils.AD_THIRD_QUARTILE));
562+
eventsSent.add('thirdquartile');
563+
}
520564
});
521565
this.adEventManager_.listenOnce(this.player_, 'complete', complete);
522566
this.adEventManager_.listen(this.video_, 'play', () => {
@@ -647,6 +691,7 @@ shaka.ads.InterstitialAdManager = class {
647691
endTime: hlsInterstitial.endTime,
648692
uri,
649693
isSkippable,
694+
skipOffset: isSkippable ? 0 : null,
650695
canJump,
651696
resumeOffset,
652697
playoutLimit,
@@ -679,6 +724,7 @@ shaka.ads.InterstitialAdManager = class {
679724
endTime: hlsInterstitial.endTime,
680725
uri: asset['URI'],
681726
isSkippable,
727+
skipOffset: isSkippable ? 0 : null,
682728
canJump,
683729
resumeOffset,
684730
playoutLimit,

lib/ads/media_tailor_ad.js

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ shaka.ads.MediaTailorAd = class {
2525
this.ad_ = mediaTailorAd;
2626

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

3030
/** @private {HTMLMediaElement} */
3131
this.video_ = video;
@@ -311,42 +311,4 @@ shaka.ads.MediaTailorAd = class {
311311
isSkipped() {
312312
return this.isSkipped_;
313313
}
314-
315-
/**
316-
* Parses a time from string.
317-
*
318-
* @param {?string} time
319-
* @return {?number}
320-
* @private
321-
*/
322-
parseTime_(time) {
323-
if (!time) {
324-
return null;
325-
}
326-
const parser = new shaka.util.TextParser(time);
327-
const results = parser.readRegex(shaka.ads.MediaTailorAd.timeFormat_);
328-
if (results == null) {
329-
return null;
330-
}
331-
// This capture is optional, but will still be in the array as undefined,
332-
// in which case it is 0.
333-
const hours = Number(results[1]) || 0;
334-
const minutes = Number(results[2]);
335-
const seconds = Number(results[3]);
336-
const milliseconds = Number(results[4]) || 0;
337-
if (minutes > 59 || seconds > 59) {
338-
return null;
339-
}
340-
341-
return (milliseconds / 1000) + seconds + (minutes * 60) + (hours * 3600);
342-
}
343314
};
344-
345-
/**
346-
* @const
347-
* @private {!RegExp}
348-
* @example 00:00.000 or 00:00:00.000 or 0:00:00.000 or
349-
* 00:00.00 or 00:00:00.00 or 0:00:00.00 or 00:00:00
350-
*/
351-
shaka.ads.MediaTailorAd.timeFormat_ =
352-
/(?:(\d{1,}):)?(\d{2}):(\d{2})((\.(\d{1,3})))?/g;

0 commit comments

Comments
 (0)