diff options
author | Sam Potts <sam@selz.com> | 2018-01-22 23:15:52 +1100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-01-22 23:15:52 +1100 |
commit | 3aa5747c9018b99fe7bc1d8ad4d67cea62ae5489 (patch) | |
tree | 4278b90afa91cb8237b9faa5e50909c4ea832fc6 /src | |
parent | 6831c3053470d092c11536a837ebf8c1a8b5c530 (diff) | |
parent | 5671235fd93eac4554d2c522461df813b589a7f4 (diff) | |
download | plyr-3aa5747c9018b99fe7bc1d8ad4d67cea62ae5489.tar.lz plyr-3aa5747c9018b99fe7bc1d8ad4d67cea62ae5489.tar.xz plyr-3aa5747c9018b99fe7bc1d8ad4d67cea62ae5489.zip |
Merge pull request #760 from sampotts/beta-with-ads
Beta with ads
Diffstat (limited to 'src')
-rw-r--r-- | src/js/defaults.js | 27 | ||||
-rw-r--r-- | src/js/plugins/ads.js | 545 | ||||
-rw-r--r-- | src/js/plyr.js | 21 | ||||
-rw-r--r-- | src/js/ui.js | 26 | ||||
-rw-r--r-- | src/js/utils.js | 33 | ||||
-rw-r--r-- | src/sass/plugins/ads.scss | 45 | ||||
-rw-r--r-- | src/sass/plyr.scss | 2 |
7 files changed, 672 insertions, 27 deletions
diff --git a/src/js/defaults.js b/src/js/defaults.js index 15fadac7..31a1f8a8 100644 --- a/src/js/defaults.js +++ b/src/js/defaults.js @@ -1,4 +1,7 @@ -// Default config +// ========================================================================== +// Plyr default config +// ========================================================================== + const defaults = { // Disable enabled: true, @@ -176,6 +179,7 @@ const defaults = { reset: 'Reset', none: 'None', disabled: 'Disabled', + adCountdown: 'Ad - {countdown}', }, // URLs @@ -186,6 +190,9 @@ const defaults = { youtube: { api: 'https://www.youtube.com/iframe_api', }, + googleIMA: { + api: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js', + }, }, // Custom control listeners @@ -247,6 +254,17 @@ const defaults = { 'statechange', 'qualitychange', 'qualityrequested', + + // Ads + 'adsloaded', + 'adscontentpause', + 'adsconentresume', + 'adstarted', + 'adsmidpoint', + 'adscomplete', + 'adsallcomplete', + 'adsimpression', + 'adsclick', ], // Selectors @@ -299,6 +317,7 @@ const defaults = { classNames: { video: 'plyr__video-wrapper', embed: 'plyr__video-embed', + ads: 'plyr__ads', control: 'plyr__control', type: 'plyr--{0}', provider: 'plyr--{0}', @@ -308,6 +327,7 @@ const defaults = { error: 'plyr--has-error', hover: 'plyr--hover', tooltip: 'plyr__tooltip', + cues: 'plyr__cues', hidden: 'plyr__sr-only', hideControls: 'plyr--hide-controls', isIos: 'plyr--is-ios', @@ -342,6 +362,11 @@ const defaults = { keys: { google: null, }, + + // Ads + ads: { + tagUrl: null, + }, }; export default defaults; diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js new file mode 100644 index 00000000..8c9baf71 --- /dev/null +++ b/src/js/plugins/ads.js @@ -0,0 +1,545 @@ +// ========================================================================== +// Advertisement plugin using Google IMA HTML5 SDK +// Create an account with our ad partner, vi here: +// https://www.vi.ai/publisher-video-monetization/ +// ========================================================================== + +/* global google */ + +import utils from '../utils'; + +class Ads { + /** + * Ads constructor. + * @param {object} player + * @return {Ads} + */ + constructor(player) { + this.player = player; + this.playing = false; + this.initialized = false; + + // Check if a tag URL is provided. + if (!utils.is.url(player.config.ads.tagUrl)) { + return this; + } + + // Check if the Google IMA3 SDK is loaded + if (!utils.is.object(window.google)) { + utils.loadScript(player.config.urls.googleIMA.api, () => { + this.ready(); + }); + } else { + this.ready(); + } + } + + /** + * Get the ads instance ready. + */ + ready() { + this.elements = { + container: null, + displayContainer: null, + }; + this.manager = null; + this.loader = null; + this.cuePoints = null; + this.events = {}; + this.safetyTimer = null; + this.countdownTimer = null; + + // Set listeners on the Plyr instance + this.listeners(); + + // 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.loaderPromise = new Promise(resolve => { + this.on('ADS_LOADER_LOADED', () => resolve()); + }); + + // Setup a promise to resolve if the IMA manager is ready + this.managerPromise = new Promise(resolve => { + this.on('ADS_MANAGER_LOADED', () => resolve()); + }); + + // Clear the safety timer + this.managerPromise.then(() => { + this.clearSafetyTimer('onAdsManagerLoaded()'); + }); + + // Setup the IMA SDK + this.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() { + // Create the container for our advertisements + this.elements.container = utils.createElement('div', { + class: this.player.config.classNames.ads, + hidden: '', + }); + this.player.elements.container.appendChild(this.elements.container); + + // So we can run VPAID2 + google.ima.settings.setVpaidMode(google.ima.ImaSdkSettings.VpaidMode.ENABLED); + + // Set language + google.ima.settings.setLocale(this.player.config.ads.language); + + // We assume the adContainer is the video container of the plyr element + // that will house the ads + this.elements.displayContainer = new google.ima.AdDisplayContainer(this.elements.container); + + // Request video ads to be pre-loaded + this.requestAds(); + } + + /** + * Request advertisements + */ + requestAds() { + const { container } = this.player.elements; + + try { + // Create ads loader + this.loader = new google.ima.AdsLoader(this.elements.displayContainer); + + // Listen and respond to ads loaded and error events + this.loader.addEventListener(google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, event => this.onAdsManagerLoaded(event), false); + this.loader.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error), false); + + // Request video ads + const request = new google.ima.AdsRequest(); + request.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 + request.linearAdSlotWidth = container.offsetWidth; + request.linearAdSlotHeight = container.offsetHeight; + request.nonLinearAdSlotWidth = container.offsetWidth; + request.nonLinearAdSlotHeight = container.offsetHeight; + + // We only overlay ads as we only support video. + request.forceNonLinearFullSlot = false; + + this.loader.requestAds(request); + + this.handleEventListeners('ADS_LOADER_LOADED'); + } catch (e) { + this.onAdError(e); + } + } + + /** + * Update the ad countdown + * @param {boolean} start + */ + pollCountdown(start = false) { + if (!start) { + window.clearInterval(this.countdownTimer); + this.elements.container.removeAttribute('data-badge-text'); + } + + const update = () => { + const time = utils.formatTime(this.manager.getRemainingTime()); + const text = this.player.config.i18n.adCountdown.replace('{countdown}', time); + this.elements.container.setAttribute('data-badge-text', text); + }; + + this.countdownTimer = window.setInterval(update, 500); + } + + /** + * This method is called whenever the ads are ready inside the AdDisplayContainer + * @param {Event} adsManagerLoadedEvent + */ + onAdsManagerLoaded(adsManagerLoadedEvent) { + // Get the ads manager + const settings = new google.ima.AdsRenderingSettings(); + + // Tell the SDK to save and restore content video state on our behalf + settings.restoreCustomPlaybackStateOnAdBreakComplete = true; + settings.enablePreloading = true; + + // The SDK is polling currentTime on the contentPlayback. And needs a duration + // so it can determine when to start the mid- and post-roll + this.manager = adsManagerLoadedEvent.getAdsManager(this.player, settings); + + // Get the cue points for any mid-rolls by filtering out the pre- and post-roll + this.cuePoints = this.manager.getCuePoints(); + + // Add advertisement cue's within the time line if available + this.cuePoints.forEach(cuePoint => { + if (cuePoint !== 0 && cuePoint !== -1) { + const seekElement = this.player.elements.progress; + + if (seekElement) { + const cuePercentage = 100 / this.player.duration * cuePoint; + const cue = utils.createElement('span', { + class: this.player.config.classNames.cues, + }); + + cue.style.left = `${cuePercentage.toString()}%`; + seekElement.appendChild(cue); + } + } + }); + + // Get skippable state + // TODO: Skip button + // this.manager.getAdSkippableState(); + + // Set volume to match player + this.manager.setVolume(this.player.volume); + + // Add listeners to the required events + // Advertisement error events + this.manager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error)); + + // Advertisement regular events + Object.keys(google.ima.AdEvent.Type).forEach(type => { + this.manager.addEventListener(google.ima.AdEvent.Type[type], 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 the ad object associated + * https://developers.google.com/interactive-media-ads/docs/sdks/html5/v3/apis#ima.AdEvent.Type + * @param {Event} event + */ + onAdEvent(event) { + const { container } = this.player.elements; + + // Retrieve the ad from the event. Some events (e.g. ALL_ADS_COMPLETED) + // don't have ad object associated + const ad = event.getAd(); + + // Proxy event + const dispatchEvent = type => { + utils.dispatchEvent.call(this.player, this.player.media, `ads${type}`); + }; + + switch (event.type) { + 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.handleEventListeners('LOADED'); + + // Bubble event + dispatchEvent('loaded'); + + // Start countdown + this.pollCountdown(true); + + if (!ad.isLinear()) { + // Position AdDisplayContainer correctly for overlay + ad.width = container.offsetWidth; + ad.height = container.offsetHeight; + } + + // console.info('Ad type: ' + event.getAd().getAdPodInfo().getPodIndex()); + // console.info('Ad time: ' + event.getAd().getAdPodInfo().getTimeOffset()); + 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.handleEventListeners('ALL_ADS_COMPLETED'); + + // Fire event + dispatchEvent('allcomplete'); + + // 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.CONTENT_PAUSE_REQUESTED: + // This event indicates the ad has started - the video player can adjust the UI, + // for example display a pause button and remaining time. Fired when content should + // be paused. This usually happens right before an ad is about to cover the content + this.handleEventListeners('CONTENT_PAUSE_REQUESTED'); + dispatchEvent('contentpause'); + this.pauseContent(); + + break; + + case google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED: + // This event indicates the ad has finished - the video player can perform + // appropriate UI actions, such as removing the timer for remaining time detection. + // Fired when content should be resumed. This usually happens when an ad finishes + // or collapses + this.handleEventListeners('CONTENT_RESUME_REQUESTED'); + dispatchEvent('contentresume'); + this.resumeContent(); + break; + + case google.ima.AdEvent.Type.STARTED: + dispatchEvent('started'); + break; + + case google.ima.AdEvent.Type.MIDPOINT: + dispatchEvent('midpoint'); + break; + + case google.ima.AdEvent.Type.COMPLETE: + dispatchEvent('complete'); + + // End countdown + this.pollCountdown(); + break; + + case google.ima.AdEvent.Type.IMPRESSION: + dispatchEvent('impression'); + break; + + case google.ima.AdEvent.Type.CLICK: + dispatchEvent('click'); + break; + + default: + break; + } + } + + /** + * Any ad error handling comes through here + * @param {Event} event + */ + onAdError(event) { + this.cancel(); + this.player.debug.log('Ads error', event); + } + + /** + * Setup hooks for Plyr and window events. This ensures + * the mid- and post-roll launch at the correct time. And + * resize the advertisement when the player resizes + */ + listeners() { + const { container } = this.player.elements; + let time; + + // Add listeners to the required events + this.player.on('ended', () => { + this.loader.contentComplete(); + }); + + this.player.on('seeking', () => { + time = this.player.currentTime; + return time; + }); + + this.player.on('seeked', () => { + const seekedTime = this.player.currentTime; + + this.cuePoints.forEach((cuePoint, index) => { + if (time < cuePoint && cuePoint < seekedTime) { + this.manager.discardAdBreak(); + this.cuePoints.splice(index, 1); + } + }); + }); + + // Listen to the resizing of the window. And resize ad accordingly + // TODO: eventually implement ResizeObserver + window.addEventListener('resize', () => { + this.manager.resize(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL); + }); + } + + /** + * Initialize the adsManager and start playing advertisements + */ + play() { + const { container } = this.player.elements; + + if (!this.managerPromise) { + return; + } + + // Play the requested advertisement whenever the adsManager is ready + this.managerPromise.then(() => { + // Initialize the container. Must be done via a user action on mobile devices + this.elements.displayContainer.initialize(); + + try { + if (!this.initialized) { + // Initialize the ads manager. Ad rules playlist will start at this time + this.manager.init(container.offsetWidth, container.offsetHeight, 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.manager.start(); + } + + this.initialized = true; + } catch (adError) { + // An error may be thrown if there was a problem with the + // VAST response + this.onAdError(adError); + } + }); + } + + /** + * Resume our video. + */ + resumeContent() { + // Hide our ad container + utils.toggleHidden(this.elements.container, true); + + // Ad is stopped + this.playing = false; + + // Play our video + if (this.player.currentTime < this.player.duration) { + this.player.play(); + } + } + + /** + * Pause our video + */ + pauseContent() { + // Show our ad container. + utils.toggleHidden(this.elements.container, false); + + // 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 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() { + // Pause our video + this.resumeContent(); + + // Tell our instance that we're done for now + this.handleEventListeners('ERROR'); + + // Re-create our adsManager + this.loadAds(); + } + + /** + * Re-create our adsManager + */ + loadAds() { + // Tell our adsManager to go bye bye + this.managerPromise.then(() => { + // Destroy our adsManager + if (this.manager) { + this.manager.destroy(); + } + + // Re-set our adsManager promises + this.managerPromise = new Promise(resolve => { + this.on('ADS_MANAGER_LOADED', () => resolve()); + this.player.debug.log(this.manager); + }); + + // Make sure we can re-call advertisements + this.initialized = false; + + // Now request some new advertisements + this.requestAds(); + }); + } + + /** + * Handles callbacks after an ad event was invoked + * @param {string} event - Event type + */ + handleEventListeners(event) { + if (utils.is.function(this.events[event])) { + this.events[event].call(this); + } + } + + /** + * Add event listeners + * @param {string} event - Event type + * @param {function} callback - Callback for when event occurs + * @return {Ads} + */ + on(event, callback) { + this.events[event] = callback; + return this; + } + + /** + * 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 + */ + startSafetyTimer(time, from) { + this.player.debug.log(`Safety timer invoked from: ${from}`); + + this.safetyTimer = window.setTimeout(() => { + this.cancel(); + this.clearSafetyTimer('startSafetyTimer()'); + }, time); + } + + /** + * Clear our safety timer(s) + * @param {string} from + */ + clearSafetyTimer(from) { + if (!utils.is.nullOrUndefined(this.safetyTimer)) { + this.player.debug.log(`Safety timer cleared from: ${from}`); + + clearTimeout(this.safetyTimer); + this.safetyTimer = null; + } + } +} + +export default Ads; diff --git a/src/js/plyr.js b/src/js/plyr.js index dfb07302..08c608c3 100644 --- a/src/js/plyr.js +++ b/src/js/plyr.js @@ -12,6 +12,7 @@ import utils from './utils'; import Console from './console'; import Storage from './storage'; +import Ads from './plugins/ads'; import captions from './captions'; import controls from './controls'; @@ -273,6 +274,11 @@ class Plyr { if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) { ui.build.call(this); } + + // Setup ads if provided + if (utils.is.url(this.config.ads.tagUrl)) { + this.ads = new Ads(this); + } } // --------------------------------------- @@ -302,10 +308,21 @@ class Plyr { } /** - * Play the media + * Play the media, or play the advertisement */ play() { - return this.media.play(); + if (utils.is.url(this.config.ads.tagUrl)) { + if (this.ads.playing) { + return; + } + if (!this.ads.initialized) { + this.ads.play(); + } + if (!this.ads.playing) { + this.media.play(); + } + } + this.media.play(); } /** diff --git a/src/js/ui.js b/src/js/ui.js index 1ad0c43a..d5d224a1 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -293,33 +293,15 @@ const ui = { // Update the displayed time updateTimeDisplay(target = null, time = 0, inverted = false) { // Bail if there's no element to display or the value isn't a number - if (!utils.is.element(target) || !utils.is.number(time)) { + if (!utils.is.element(target)) { return; } - // Format time component to add leading zero - const format = value => `0${value}`.slice(-2); - - // Helpers - const getHours = value => parseInt((value / 60 / 60) % 60, 10); - const getMinutes = value => parseInt((value / 60) % 60, 10); - const getSeconds = value => parseInt(value % 60, 10); - - // Breakdown to hours, mins, secs - let hours = getHours(time); - const mins = getMinutes(time); - const secs = getSeconds(time); - - // Do we need to display hours? - if (getHours(this.duration) > 0) { - hours = `${hours}:`; - } else { - hours = ''; - } + // Always display hours if duration is over an hour + const displayHours = utils.getHours(this.duration) > 0; - // Render // eslint-disable-next-line no-param-reassign - target.textContent = `${inverted ? '-' : ''}${hours}${format(mins)}:${format(secs)}`; + target.textContent = utils.formatTime(time, displayHours, inverted); }, // Handle time change event diff --git a/src/js/utils.js b/src/js/utils.js index 81da8821..f82df2a4 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -50,6 +50,9 @@ const utils = { track(input) { return this.instanceof(input, TextTrack) || (!this.nullOrUndefined(input) && this.string(input.kind)); }, + url(input) { + return !this.nullOrUndefined(input) && /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(input); + }, nullOrUndefined(input) { return input === null || typeof input === 'undefined'; }, @@ -602,6 +605,32 @@ const utils = { return (current / max * 100).toFixed(2); }, + // Time helpers + getHours(value) { return parseInt((value / 60 / 60) % 60, 10); }, + getMinutes(value) { return parseInt((value / 60) % 60, 10); }, + getSeconds(value) { return parseInt(value % 60, 10); }, + + // Format time to UI friendly string + formatTime(time = 0, displayHours = false, inverted = false) { + // Format time component to add leading zero + const format = value => `0${value}`.slice(-2); + + // Breakdown to hours, mins, secs + let hours = this.getHours(time); + const mins = this.getMinutes(time); + const secs = this.getSeconds(time); + + // Do we need to display hours? + if (displayHours || hours > 0) { + hours = `${hours}:`; + } else { + hours = ''; + } + + // Render + return `${inverted ? '-' : ''}${hours}${format(mins)}:${format(secs)}`; + }, + // Deep extend destination object with N more objects extend(target = {}, ...sources) { if (!sources.length) { @@ -746,9 +775,9 @@ const utils = { // Force repaint of element repaint(element) { window.setTimeout(() => { - element.setAttribute('hidden', ''); + utils.toggleHidden(element, true); element.offsetHeight; // eslint-disable-line - element.removeAttribute('hidden'); + utils.toggleHidden(element, false); }, 0); }, }; diff --git a/src/sass/plugins/ads.scss b/src/sass/plugins/ads.scss new file mode 100644 index 00000000..60751851 --- /dev/null +++ b/src/sass/plugins/ads.scss @@ -0,0 +1,45 @@ +// ========================================================================== +// Advertisments +// ========================================================================== + +.plyr__ads { + bottom: 0; + cursor: pointer; + left: 0; + position: absolute; + right: 0; + top: 0; + z-index: 3; // Above the controls. + + &::after { + background: rgba($plyr-color-gunmetal, 0.8); + border-radius: 2px; + bottom: $plyr-control-spacing; + color: #fff; + content: attr(data-badge-text); + font-size: 10px; + padding: 2px 6px; + pointer-events: none; + position: absolute; + right: $plyr-control-spacing; + z-index: 3; + } + + &::after:empty { + display: none; + } +} + +// Advertisement cue's for the progress bar. +.plyr__cues { + background: currentColor; + display: block; + height: $plyr-range-track-height; + left: 0; + margin: -($plyr-range-track-height / 2) 0 0; + opacity: 0.8; + position: absolute; + top: 50%; + width: 3px; + z-index: 3; // Between progress and thumb. +} diff --git a/src/sass/plyr.scss b/src/sass/plyr.scss index 362c89b7..e8615989 100644 --- a/src/sass/plyr.scss +++ b/src/sass/plyr.scss @@ -40,5 +40,7 @@ @import 'states/error'; @import 'states/fullscreen'; +@import 'plugins/ads'; + @import 'utils/animation'; @import 'utils/hidden'; |