From 4b0005c28eaab460be1b4f5f22fa5d1f3ffae522 Mon Sep 17 00:00:00 2001 From: Arthur Hulsman Date: Mon, 15 Jan 2018 14:47:34 +0100 Subject: [PATCH] Added promises, missing events, new ad tag and additional logging. --- .gitignore | 2 + demo/src/js/demo.js | 8 +- src/js/plugins/ads.js | 281 ++++++++++++++++++++++++++++++++---------- 3 files changed, 221 insertions(+), 70 deletions(-) diff --git a/.gitignore b/.gitignore index b01afe98..b17cd7c7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ index-*.html npm-debug.log *.webm /package-lock.json +.idea/ + diff --git a/demo/src/js/demo.js b/demo/src/js/demo.js index d20cecdb..b8650896 100644 --- a/demo/src/js/demo.js +++ b/demo/src/js/demo.js @@ -52,8 +52,12 @@ document.addEventListener('DOMContentLoaded', () => { google: 'AIzaSyDrNwtN3nLH_8rjCmu5Wq3ZCm4MNAVdc0c', }, ads: { - tagUrl: - 'http://go.aniview.com/api/adserver6/vast/?AV_PUBLISHERID=58c25bb0073ef448b1087ad6&AV_CHANNELID=5a0458dc28a06145e4519d21&AV_URL=127.0.0.1:3000&cb=1&AV_WIDTH=640&AV_HEIGHT=480', + tagUrl: 'https://pubads.g.doubleclick.net/gampad/ads' + + '?sz=640x480&iu=/124319096/external/ad_rule_samples&' + + 'ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&' + + 'output=vmap&unviewed_position_start=1&cust_params=d' + + 'eployment%3Ddevsite%26sample_ar%3Dpremidpostoptimiz' + + 'edpod&cmsid=496&vid=short_onecue&correlator=', }, }); diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js index 06b4eee5..956fbb6e 100644 --- a/src/js/plugins/ads.js +++ b/src/js/plugins/ads.js @@ -1,6 +1,6 @@ import utils from '../utils'; -// Events are different on various devices. We det the correct events, based on userAgent. +// Events are different on various devices. We set the correct events, based on userAgent. const getStartEvents = () => { let events = ['click']; @@ -38,16 +38,43 @@ export default class Ads { } ready() { + this.time = Date.now(); this.startEvents = getStartEvents(); this.adDisplayContainer = null; this.adDisplayElement = null; this.adsManager = null; this.adsLoader = null; - this.adCuePoints = null; + this.adsCuePoints = null; this.currentAd = null; this.events = {}; + this.safetyTimer = null; this.videoElement = document.createElement('video'); + // Setup a simple promise to resolve if the IMA loader is ready. + this.adsLoaderResolve = () => {}; + this.adsLoaderPromise = new Promise((resolve) => { + this.adsLoaderResolve = resolve; + }); + this.adsLoaderPromise.then(() => { + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] adsLoader resolved!`, this.adsLoader); + }); + + // Setup a promise to resolve if the IMA manager is ready. + this.adsManagerResolve = () => {}; + this.adsManagerPromise = new Promise((resolve) => { + // Resolve our promise. + this.adsManagerResolve = resolve; + }); + this.adsManagerPromise.then(() => { + // Clear the safety timer. + this.clearSafetyTimer('onAdsManagerLoaded()'); + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] adsManager resolved!`, this.adsManager); + }); + + // Start ticking our safety timer. If the whole advertisement + // thing doesn't resolve within our set time; we bail. + this.startSafetyTimer(12000, 'ready()'); + // Setup the ad display container. this.setupAdDisplayContainer(); @@ -83,6 +110,8 @@ export default class Ads { adsRequest.nonLinearAdSlotHeight = container.offsetHeight; this.adsLoader.requestAds(adsRequest); + + this.adsLoaderResolve(); } onAdsManagerLoaded(adsManagerLoadedEvent) { @@ -111,6 +140,9 @@ export default class Ads { this.adsManager.addEventListener(window.google.ima.AdEvent.Type.LOADED, event => this.onAdEvent(event)); this.adsManager.addEventListener(window.google.ima.AdEvent.Type.STARTED, event => this.onAdEvent(event)); this.adsManager.addEventListener(window.google.ima.AdEvent.Type.COMPLETE, event => this.onAdEvent(event)); + + // Resolve our adsManager. + this.adsManagerResolve(); } onAdEvent(event) { @@ -127,9 +159,58 @@ export default class Ads { // let intervalTimer; switch (event.type) { - case window.google.ima.AdEvent.Type.LOADED: + + case google.ima.AdEvent.Type.AD_BREAK_READY: + // This event indicates that a mid-roll ad is ready to start. + // We pause the player and tell the adsManager to start playing the ad. + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] AD_BREAK_READY |`, 'Fired when an ad rule or a VMAP ad break would have played if autoPlayAdBreaks is false.'); + + this.player.pause(); + + this.adsManager.start(); + + this.handleEventListeners('AD_BREAK_READY'); + break; + case google.ima.AdEvent.Type.AD_METADATA: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] AD_METADATA |`, 'Fired when an ads list is loaded.'); + break; + case google.ima.AdEvent.Type.ALL_ADS_COMPLETED: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] ALL_ADS_COMPLETED |`, 'Fired when the ads manager is done playing all the ads.'); + this.handleEventListeners('ALL_ADS_COMPLETED'); + break; + case google.ima.AdEvent.Type.CLICK: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] CLICK |`, 'Fired when the ad is clicked.'); + break; + case google.ima.AdEvent.Type.COMPLETE: + // This event indicates the ad has finished - the video player + // can perform appropriate UI actions, such as removing the timer for + // remaining time detection. + // clearInterval(intervalTimer); + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] COMPLETE |`, 'Fired when the ad completes playing.'); + + this.handleEventListeners('COMPLETE'); + + this.adDisplayElement.style.display = 'none'; + + if (this.player.currentTime < this.player.duration) { + this.player.play(); + } + break; + case window.google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] CONTENT_PAUSE_REQUESTED |`, 'Fired when content should be paused. This usually happens right before an ad is about to cover the content.'); + + this.handleEventListeners('CONTENT_PAUSE_REQUESTED'); + break; + + case window.google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] CONTENT_RESUME_REQUESTED |`, 'Fired when content should be resumed. This usually happens when an ad finishes or collapses.'); + + this.handleEventListeners('CONTENT_RESUME_REQUESTED'); + break; + case google.ima.AdEvent.Type.LOADED: // This is the first event sent for an ad - it is possible to // determine whether the ad is a video ad or an overlay. + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] LOADED |`, event.getAd().getContentType()); // Show the ad display element. this.adDisplayElement.style.display = 'block'; @@ -141,59 +222,60 @@ export default class Ads { ad.width = container.offsetWidth; ad.height = container.offsetHeight; } - break; - case window.google.ima.AdEvent.Type.STARTED: + // console.info('Ad type: ' + event.getAd().getAdPodInfo().getPodIndex()); + // console.info('Ad time: ' + event.getAd().getAdPodInfo().getTimeOffset()); + break; + case google.ima.AdEvent.Type.STARTED: // This event indicates the ad has started - the video player // can adjust the UI, for example display a pause button and // remaining time. + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] STARTED |`, 'Fired when the ad starts playing.'); this.player.pause(); this.handleEventListeners('STARTED'); - - // if (ad.isLinear()) { - // For a linear ad, a timer can be started to poll for - // the remaining time. - // intervalTimer = setInterval( - // () => { - // let remainingTime = this.adsManager.getRemainingTime(); - // console.log(remainingTime); - // }, - // 300); // every 300ms - // } break; - - case window.google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED: - this.handleEventListeners('CONTENT_PAUSE_REQUESTED'); + case google.ima.AdEvent.Type.DURATION_CHANGE: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] DURATION_CHANGE |`, 'Fired when the ad\'s duration changes.'); break; - - case window.google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED: - this.handleEventListeners('CONTENT_RESUME_REQUESTED'); + case google.ima.AdEvent.Type.FIRST_QUARTILE: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] FIRST_QUARTILE |`, 'Fired when the ad playhead crosses first quartile.'); break; - - case window.google.ima.AdEvent.Type.AD_BREAK_READY: - // This event indicates that a mid-roll ad is ready to start. - // We pause the player and tell the adsManager to start playing the ad. - this.player.pause(); - this.adsManager.start(); - this.handleEventListeners('AD_BREAK_READY'); + case google.ima.AdEvent.Type.IMPRESSION: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] IMPRESSION |`, 'Fired when the impression URL has been pinged.'); break; - - case window.google.ima.AdEvent.Type.COMPLETE: - // This event indicates the ad has finished - the video player - // can perform appropriate UI actions, such as removing the timer for - // remaining time detection. - // clearInterval(intervalTimer); - this.handleEventListeners('COMPLETE'); - - this.adDisplayElement.style.display = 'none'; - if (this.player.currentTime < this.player.duration) { - this.player.play(); - } + case google.ima.AdEvent.Type.INTERACTION: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] INTERACTION |`, 'Fired when an ad triggers the interaction callback. Ad interactions contain an interaction ID string in the ad data.'); break; - - case window.google.ima.AdEvent.Type.ALL_ADS_COMPLETED: - this.handleEventListeners('ALL_ADS_COMPLETED'); + case google.ima.AdEvent.Type.LINEAR_CHANGED: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] LINEAR_CHANGED |`, 'Fired when the displayed ad changes from linear to nonlinear, or vice versa.'); + break; + case google.ima.AdEvent.Type.MIDPOINT: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] MIDPOINT |`, 'Fired when the ad playhead crosses midpoint.'); + break; + case google.ima.AdEvent.Type.PAUSED: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] PAUSED |`, 'Fired when the ad is paused.'); + break; + case google.ima.AdEvent.Type.RESUMED: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] RESUMED |`, 'Fired when the ad is resumed.'); + break; + case google.ima.AdEvent.Type.SKIPPABLE_STATE_CHANGED: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] SKIPPABLE_STATE_CHANGED |`, 'Fired when the displayed ads skippable state is changed.'); + break; + case google.ima.AdEvent.Type.SKIPPED: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] SKIPPED |`, 'Fired when the ad is skipped by the user.'); + break; + case google.ima.AdEvent.Type.THIRD_QUARTILE: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] THIRD_QUARTILE |`, 'Fired when the ad playhead crosses third quartile.'); + break; + case google.ima.AdEvent.Type.USER_CLOSE: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] USER_CLOSE |`, 'Fired when the ad is closed by the user.'); + break; + case google.ima.AdEvent.Type.VOLUME_CHANGED: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] VOLUME_CHANGED |`, 'Fired when the ad volume has changed.'); + break; + case google.ima.AdEvent.Type.VOLUME_MUTED: + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] VOLUME_MUTED |`, 'Fired when the ad volume has been muted.'); break; default: @@ -202,21 +284,44 @@ export default class Ads { } onAdError(adErrorEvent) { - // Handle the error logging. - this.adDisplayElement.remove(); - - if (this.adsManager) { - this.adsManager.destroy(); - } + this.cancel(); if (this.player.debug) { throw new Error(adErrorEvent); } } + /** + * Destroy the adsManager so we can grab new ads after this. + * If we don't then we're not allowed to call new ads based + * on google policies, as they interpret this as an accidental + * video requests. https://developers.google.com/interactive- + * media-ads/docs/sdks/android/faq#8 + */ + cancel() { + this.player.debug.warn(`[${(Date.now() - this.time) / 1000}s][IMA SDK]`, 'Advertisement cancelled.'); + + // Todo: Removing the ad container might be problematic if we were to recreate the adsManager. Think of playlists. Every new video you need to request a new VAST xml and preload the advertisement. + this.adDisplayElement.remove(); + + // Tell our adsManager to go bye bye. + this.adsManagerPromise.then(() => { + if (this.adsManager) { + this.adsManager.destroy(); + } + }); + } + setupAdDisplayContainer() { const { container } = this.player.elements; + // So we can run VPAID2. + google.ima.settings.setVpaidMode(google.ima.ImaSdkSettings.VpaidMode.ENABLED); + + // Set language. + // Todo: Could make a config option out of this locale value. + google.ima.settings.setLocale('en'); + // We assume the adContainer is the video container of the plyr element // that will house the ads. this.adDisplayContainer = new window.google.ima.AdDisplayContainer(container); @@ -230,32 +335,41 @@ export default class Ads { // Set class name on the adDisplayContainer element. this.adDisplayElement.setAttribute('class', this.player.config.classNames.ads); - // Play ads when clicked. - this.setOnClickHandler(this.adDisplayElement, this.playAds); + // Play ads when clicked. Wait until the adsManager and adsLoader + // are both resolved. + Promise.all([ + this.adsManagerPromise, + this.adsLoaderPromise, + ]).then(() => { + this.setOnClickHandler(this.adDisplayElement, this.playAds); + }); } playAds() { const { container } = this.player.elements; - // Initialize the container. Must be done via a user action on mobile devices. - this.adDisplayContainer.initialize(); + // Play the requested advertisement whenever the adsManager is ready. + this.adsManagerPromise.then(() => { + // Initialize the container. Must be done via a user action on mobile devices. + this.adDisplayContainer.initialize(); - try { - // Initialize the ads manager. Ad rules playlist will start at this time. - this.adsManager.init(container.offsetWidth, container.offsetHeight, window.google.ima.ViewMode.NORMAL); + try { + // Initialize the ads manager. Ad rules playlist will start at this time. + this.adsManager.init(container.offsetWidth, container.offsetHeight, window.google.ima.ViewMode.NORMAL); - // Call play to start showing the ad. Single video and overlay ads will - // start at this time; the call will be ignored for ad rules. - this.adsManager.start(); - } catch (adError) { - // An error may be thrown if there was a problem with the VAST response. - this.player.play(); - this.adDisplayElement.remove(); + // Call play to start showing the ad. Single video and overlay ads will + // start at this time; the call will be ignored for ad rules. + this.adsManager.start(); + } catch (adError) { + // An error may be thrown if there was a problem with the VAST response. + this.player.play(); + this.adDisplayElement.remove(); - if (this.player.debug) { - throw new Error(adError); + if (this.player.debug) { + throw new Error(adError); + } } - } + }); } /** @@ -314,7 +428,6 @@ export default class Ads { * @param {element} element - The element on which to set the listener * @param {function} callback - The callback which will be invoked once triggered. */ - setOnClickHandler(element, callback) { for (let i = 0; i < this.startEvents.length; i += 1) { const startEvent = this.startEvents[i]; @@ -339,4 +452,36 @@ export default class Ads { this.events[event] = callback; return this; } + + /** + * startSafetyTimer + * Setup a safety timer for when the ad network + * doesn't respond for whatever reason. The advertisement has 12 seconds + * to get its shit together. We stop this timer when the advertisement + * is playing, or when a user action is required to start, then we + * clear the timer on ad ready. + * @param {Number} time + * @param {String} from + * @private + */ + startSafetyTimer(time, from) { + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK]`, `Safety timer invoked timer from: ${from}`); + this.safetyTimer = window.setTimeout(() => { + this.cancel(); + this.clearSafetyTimer('startSafetyTimer()'); + }, time); + } + + /** + * clearSafetyTimer + * @param {String} from + * @private + */ + clearSafetyTimer(from) { + if (typeof this.safetyTimer !== 'undefined' && this.safetyTimer !== null) { + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK]`, `Safety timer cleared timer from: ${from}`); + clearTimeout(this.safetyTimer); + this.safetyTimer = undefined; + } + } }