From d822f0c6bfefddfbce3a987014077de9b68db00a Mon Sep 17 00:00:00 2001 From: Arthur Hulsman Date: Wed, 17 Jan 2018 13:58:39 +0100 Subject: [PATCH] Adsmanager is now re/pre-loaded with new ads when the video is done or an ad error appears. Will make it possible to request ads when a new video is loaded. Added comments and missing events within the adsmanagerloader method. --- src/js/plugins/ads.js | 353 ++++++++++++++++++++++++++++-------------- 1 file changed, 237 insertions(+), 116 deletions(-) diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js index 48a5e4da..8e6d90a0 100644 --- a/src/js/plugins/ads.js +++ b/src/js/plugins/ads.js @@ -42,8 +42,8 @@ class Ads { ready() { this.time = Date.now(); this.startEvents = getStartEvents(); + this.adsContainer = null; this.adDisplayContainer = null; - this.adsDisplayElement = null; this.adsManager = null; this.adsLoader = null; this.adsCuePoints = null; @@ -54,61 +54,125 @@ class Ads { // Set listeners on the Plyr instance. this.setupListeners(); + // Start ticking our safety timer. If the whole advertisement + // thing doesn't resolve within our set time; we bail. + this.startSafetyTimer(12000, 'ready()'); + // Setup a simple promise to resolve if the IMA loader is ready. this.adsLoaderPromise = new Promise((resolve) => { this.on('ADS_LOADER_LOADED', () => 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.adsManagerPromise = new Promise((resolve) => { this.on('ADS_MANAGER_LOADED', () => resolve()); - }); - this.adsManagerPromise.then(() => { + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] adsManager resolved!`, this.adsManager); + // 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(); - // Setup the IMA SDK. this.setupIMA(); } + /** + * setupIMA + * In order for the SDK to display ads for our video, we need to tell it + * where to put them, so here we define our ad container. This div is set + * up to render on top of the video player. Using the code below, we tell + * the SDK to render ads within that div. We also provide a handle to the + * content video player - the SDK will poll the current time of our player + * to properly place mid-rolls. After we create the ad display container, + * we initialize it. On mobile devices, this initialization is done as the + * result of a user action. + */ setupIMA() { - const { container } = this.player.elements; + // Create the container for our advertisements. + this.adsContainer = utils.createElement('div', { + class: this.player.config.classNames.ads, + }); + this.player.elements.container.appendChild(this.adsContainer); - // Create ads loader. - this.adsLoader = new google.ima.AdsLoader(this.adDisplayContainer); + // So we can run VPAID2. + google.ima.settings.setVpaidMode(google.ima.ImaSdkSettings.VpaidMode.ENABLED); - // Listen and respond to ads loaded and error events. - this.adsLoader.addEventListener(google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, event => this.onAdsManagerLoaded(event), false); - this.adsLoader.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error), false); + // Set language. + // Todo: Could make a config option out of this locale value. + google.ima.settings.setLocale('en'); - // Request video ads. - const adsRequest = new google.ima.AdsRequest(); - adsRequest.adTagUrl = this.player.config.ads.tagUrl; + // We assume the adContainer is the video container of the plyr element + // that will house the ads. + this.adDisplayContainer = new google.ima.AdDisplayContainer(this.adsContainer); - // Specify the linear and nonlinear slot sizes. This helps the SDK to - // select the correct creative if multiple are returned. - adsRequest.linearAdSlotWidth = container.offsetWidth; - adsRequest.linearAdSlotHeight = container.offsetHeight; - adsRequest.nonLinearAdSlotWidth = container.offsetWidth; - adsRequest.nonLinearAdSlotHeight = container.offsetHeight; + const adsDisplayElement = this.adsContainer.firstChild; - this.adsLoader.requestAds(adsRequest); + // The AdDisplayContainer call from google IMA sets the style attribute + // by default. We remove the inline style and set it through the stylesheet. + adsDisplayElement.removeAttribute('style'); - this.handleEventListeners('ADS_LOADER_LOADED'); + // Set class name on the adDisplayContainer element. + adsDisplayElement.setAttribute('class', this.player.config.classNames.ads); + + // Play ads when clicked. Wait until the adsManager and adsLoader + // are both resolved. + Promise.all([ + this.adsManagerPromise, + this.adsLoaderPromise, + ]).then(() => { + this.setOnClickHandler(adsDisplayElement, this.play); + }); + + // Request video ads to be pre-loaded. + this.requestAds(); } + /** + * Request advertisements. + */ + requestAds() { + const { container } = this.player.elements; + + try { + // Create ads loader. + this.adsLoader = new google.ima.AdsLoader(this.adDisplayContainer); + + // Listen and respond to ads loaded and error events. + this.adsLoader.addEventListener( + google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, + event => this.onAdsManagerLoaded(event), false); + this.adsLoader.addEventListener( + google.ima.AdErrorEvent.Type.AD_ERROR, + error => this.onAdError(error), false); + + // Request video ads. + const adsRequest = new google.ima.AdsRequest(); + adsRequest.adTagUrl = this.player.config.ads.tagUrl; + + // Specify the linear and nonlinear slot sizes. This helps the SDK + // to select the correct creative if multiple are returned. + adsRequest.linearAdSlotWidth = container.offsetWidth; + adsRequest.linearAdSlotHeight = container.offsetHeight; + adsRequest.nonLinearAdSlotWidth = container.offsetWidth; + adsRequest.nonLinearAdSlotHeight = container.offsetHeight; + + // We only overlay ads as we only support video. + adsRequest.forceNonLinearFullSlot = false; + + this.adsLoader.requestAds(adsRequest); + + this.handleEventListeners('ADS_LOADER_LOADED'); + } catch (e) { + this.onAdError(e); + } + } + + /** + * This method is called whenever the ads are ready inside + * the AdDisplayContainer. + * @param {Event} adsManagerLoadedEvent + */ onAdsManagerLoaded(adsManagerLoadedEvent) { // Get the ads manager. @@ -126,21 +190,44 @@ class Ads { this.adsCuePoints = this.adsManager.getCuePoints(); // Add listeners to the required events. + // Advertisement error events. this.adsManager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error)); + + // Advertisement regular events. + this.adsManager.addEventListener(google.ima.AdEvent.Type.AD_BREAK_READY, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.AD_METADATA, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.ALL_ADS_COMPLETED, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.CLICK, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.COMPLETE, event => this.onAdEvent(event)); this.adsManager.addEventListener(google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED, event => this.onAdEvent(event)); this.adsManager.addEventListener(google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED, event => this.onAdEvent(event)); - this.adsManager.addEventListener(google.ima.AdEvent.Type.ALL_ADS_COMPLETED, event => this.onAdEvent(event)); - this.adsManager.addEventListener(google.ima.AdEvent.Type.AD_BREAK_READY, event => this.onAdEvent(event)); - - // Listen to any additional events, if necessary. this.adsManager.addEventListener(google.ima.AdEvent.Type.LOADED, event => this.onAdEvent(event)); this.adsManager.addEventListener(google.ima.AdEvent.Type.STARTED, event => this.onAdEvent(event)); - this.adsManager.addEventListener(google.ima.AdEvent.Type.COMPLETE, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.DURATION_CHANGE, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.FIRST_QUARTILE, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.IMPRESSION, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.INTERACTION, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.LINEAR_CHANGED, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.MIDPOINT, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.PAUSED, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.RESUMED, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.SKIPPABLE_STATE_CHANGED, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.SKIPPED, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.THIRD_QUARTILE, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.USER_CLOSE, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.VOLUME_CHANGED, event => this.onAdEvent(event)); + this.adsManager.addEventListener(google.ima.AdEvent.Type.VOLUME_MUTED, event => this.onAdEvent(event)); // Resolve our adsManager. this.handleEventListeners('ADS_MANAGER_LOADED'); } + /** + * This is where all the event handling takes place. Retrieve the ad from + * the event. Some events (e.g. ALL_ADS_COMPLETED) don't have ad + * object associated. + * @param {Event} event + */ onAdEvent(event) { const { container } = this.player.elements; @@ -163,8 +250,45 @@ class Ads { 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: + // All ads for the current videos are done. We can now + // request new advertisements in case the video is re-played. 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'); + + // Todo: Example for what happens when a next video in a playlist would be loaded. + // So here we load a new video when all ads are done. + // Then we load new ads within a new adsManager. When the video + // Is started - after - the ads are loaded, then we get ads. + // You can also easily test cancelling and reloading by running + // player.ads.cancel() and player.ads.play from the console I guess. + // this.player.source = { + // type: 'video', + // title: 'View From A Blue Moon', + // sources: [{ + // src: 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.mp4', + // type: 'video/mp4', + // }], + // poster: 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.jpg', + // tracks: [ + // { + // kind: 'captions', + // label: 'English', + // srclang: 'en', + // src: 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.en.vtt', + // default: true, + // }, + // { + // kind: 'captions', + // label: 'French', + // srclang: 'fr', + // src: 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt', + // }, + // ], + // }; + + // Todo: So there is still this thing where a video should only be allowed to start playing when the IMA SDK is ready or has failed. + + this.loadAds(); 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.'); @@ -178,14 +302,7 @@ class Ads { // remaining time. 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'); - - // Show our advertiment container. - this.adsDisplayElement.style.display = 'block'; - - this.playing = true; - - // Pause our video. - this.player.pause(); + this.contentPause(); break; case google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED: // This event indicates the ad has finished - the video player @@ -193,16 +310,7 @@ class Ads { // remaining time detection. 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'); - - // Hide the advertisement container. - this.adsDisplayElement.style.display = 'none'; - - this.playing = false; - - // Play our video. - if (this.player.currentTime < this.player.duration) { - this.player.play(); - } + this.contentResume(); break; case google.ima.AdEvent.Type.LOADED: // This is the first event sent for an ad - it is possible to @@ -270,56 +378,15 @@ class Ads { } } + /** + * Any ad error handling comes through here. + * @param {Event} adErrorEvent + */ onAdError(adErrorEvent) { this.cancel(); this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] ERROR |`, adErrorEvent); } - setupAdDisplayContainer() { - // Create the container for our advertisements. - const container = utils.createElement('div', { - class: this.player.config.classNames.ads, - }); - this.player.elements.container.appendChild(container); - - // 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 google.ima.AdDisplayContainer(container); - - this.adsDisplayElement = container.firstChild; - - // The AdDisplayContainer call from google IMA sets the style attribute - // by default. We remove the inline style and set it through the stylesheet. - this.adsDisplayElement.removeAttribute('style'); - - // Set class name on the adDisplayContainer element. - this.adsDisplayElement.setAttribute('class', this.player.config.classNames.ads); - - // Make sure our advertisement container has the right z-index. - this.on('CONTENT_PAUSE_REQUESTED', () => { - container.style.zIndex = '3'; - }); - this.on('CONTENT_RESUME_REQUESTED', () => { - container.style.zIndex = '1'; - }); - - // Play ads when clicked. Wait until the adsManager and adsLoader - // are both resolved. - Promise.all([ - this.adsManagerPromise, - this.adsLoaderPromise, - ]).then(() => { - this.setOnClickHandler(this.adsDisplayElement, this.play); - }); - } - /** * Setup hooks for Plyr and window events. This ensures * the mid- and post-roll launch at the correct time. And @@ -380,17 +447,49 @@ class Ads { this.initialized = true; } catch (adError) { - // An error may be thrown if there was a problem with the VAST response. - this.adsDisplayElement.remove(); - - if (this.player.debug) { - throw new Error(adError); - } - this.player.play(); + // An error may be thrown if there was a problem with the + // VAST response. + this.onAdError(adError); } }); } + /** + * Resume our video. + */ + contentResume() { + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK]`, 'Resume video.'); + + // Hide our ad container. + this.adsContainer.style.display = 'none'; + this.adsContainer.style.zIndex = '1'; + + // Ad is stopped. + this.playing = false; + + // Play our video. + if (this.player.currentTime < this.player.duration) { + this.player.play(); + } + } + + /** + * Pause our video. + */ + contentPause() { + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK]`, 'Pause video.'); + + // Show our ad container. + this.adsContainer.style.display = 'block'; + this.adsContainer.style.zIndex = '3'; + + // Ad is playing. + this.playing = true; + + // Pause our video. + this.player.pause(); + } + /** * 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 @@ -401,14 +500,40 @@ class Ads { 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.adsDisplayElement.remove(); + // Pause our video. + this.contentResume(); + + // Tell our instance that we're done for now. + this.handleEventListeners('ERROR'); + + // Re-create our adsManager. + this.loadAds(); + } + + /** + * Re-create our adsManager. + */ + loadAds() { + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK]`, 'Re-loading advertisements.'); // Tell our adsManager to go bye bye. this.adsManagerPromise.then(() => { + // Destroy our adsManager. if (this.adsManager) { this.adsManager.destroy(); } + + // Re-set our adsManager promises. + this.adsManagerPromise = new Promise((resolve) => { + this.on('ADS_MANAGER_LOADED', () => resolve()); + this.player.debug.log(`[${(Date.now() - this.time) / 1000}s][IMA SDK] adsManager resolved!`, this.adsManager); + }); + + // Make sure we can re-call advertisements. + this.initialized = false; + + // Now request some new advertisements. + this.requestAds(); }); } @@ -453,15 +578,12 @@ class Ads { } /** - * 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. + * Setup a safety timer for when the ad network doesn't respond for + * whatever reason. The advertisement has 12 seconds to get its things + * 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}`); @@ -472,9 +594,8 @@ class Ads { } /** - * clearSafetyTimer + * Clear our safety timer(s). * @param {String} from - * @private */ clearSafetyTimer(from) { if (typeof this.safetyTimer !== 'undefined' && this.safetyTimer !== null) {