diff options
author | Sam Potts <sam@potts.es> | 2018-01-22 23:15:10 +1100 |
---|---|---|
committer | Sam Potts <sam@potts.es> | 2018-01-22 23:15:10 +1100 |
commit | 5671235fd93eac4554d2c522461df813b589a7f4 (patch) | |
tree | 4278b90afa91cb8237b9faa5e50909c4ea832fc6 /src | |
parent | 1dd5c9efd92bddff8b3ecb04ef53775c3c9b5b04 (diff) | |
download | plyr-5671235fd93eac4554d2c522461df813b589a7f4.tar.lz plyr-5671235fd93eac4554d2c522461df813b589a7f4.tar.xz plyr-5671235fd93eac4554d2c522461df813b589a7f4.zip |
Formatting, events and ad countdown added
Diffstat (limited to 'src')
-rw-r--r-- | src/js/defaults.js | 18 | ||||
-rw-r--r-- | src/js/plugins/ads.js | 220 | ||||
-rw-r--r-- | src/js/ui.js | 26 | ||||
-rw-r--r-- | src/js/utils.js | 26 | ||||
-rw-r--r-- | src/sass/plugins/ads.scss | 10 |
5 files changed, 193 insertions, 107 deletions
diff --git a/src/js/defaults.js b/src/js/defaults.js index cd78a8fd..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,7 +179,7 @@ const defaults = { reset: 'Reset', none: 'None', disabled: 'Disabled', - advertisement: 'Advertisement', + adCountdown: 'Ad - {countdown}', }, // URLs @@ -251,6 +254,17 @@ const defaults = { 'statechange', 'qualitychange', 'qualityrequested', + + // Ads + 'adsloaded', + 'adscontentpause', + 'adsconentresume', + 'adstarted', + 'adsmidpoint', + 'adscomplete', + 'adsallcomplete', + 'adsimpression', + 'adsclick', ], // Selectors diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js index 2f3b0669..8c9baf71 100644 --- a/src/js/plugins/ads.js +++ b/src/js/plugins/ads.js @@ -38,13 +38,16 @@ class Ads { * Get the ads instance ready. */ ready() { - this.adsContainer = null; - this.adDisplayContainer = null; - this.adsManager = null; - this.adsLoader = null; - this.adsCuePoints = null; + 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(); @@ -54,21 +57,17 @@ class Ads { this.startSafetyTimer(12000, 'ready()'); // Setup a simple promise to resolve if the IMA loader is ready - this.adsLoaderPromise = new Promise(resolve => { + this.loaderPromise = new Promise(resolve => { this.on('ADS_LOADER_LOADED', () => resolve()); }); - this.adsLoaderPromise.then(() => { - this.player.debug.log('Ads loader resolved!', this.adsLoader); - }); // Setup a promise to resolve if the IMA manager is ready - this.adsManagerPromise = new Promise(resolve => { + this.managerPromise = new Promise(resolve => { this.on('ADS_MANAGER_LOADED', () => resolve()); }); - this.adsManagerPromise.then(() => { - this.player.debug.log('Ads manager resolved!', this.adsManager); - // Clear the safety timer + // Clear the safety timer + this.managerPromise.then(() => { this.clearSafetyTimer('onAdsManagerLoaded()'); }); @@ -86,23 +85,21 @@ class Ads { */ setupIMA() { // Create the container for our advertisements - this.adsContainer = utils.createElement('div', { + this.elements.container = utils.createElement('div', { class: this.player.config.classNames.ads, - 'data-badge-text': this.player.config.i18n.advertisement, hidden: '', }); - this.player.elements.container.appendChild(this.adsContainer); + this.player.elements.container.appendChild(this.elements.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'); + 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.adDisplayContainer = new google.ima.AdDisplayContainer(this.adsContainer); + this.elements.displayContainer = new google.ima.AdDisplayContainer(this.elements.container); // Request video ads to be pre-loaded this.requestAds(); @@ -116,27 +113,27 @@ class Ads { try { // Create ads loader - this.adsLoader = new google.ima.AdsLoader(this.adDisplayContainer); + this.loader = new google.ima.AdsLoader(this.elements.displayContainer); // 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); + 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 adsRequest = new google.ima.AdsRequest(); - adsRequest.adTagUrl = this.player.config.ads.tagUrl; + 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 - adsRequest.linearAdSlotWidth = container.offsetWidth; - adsRequest.linearAdSlotHeight = container.offsetHeight; - adsRequest.nonLinearAdSlotWidth = container.offsetWidth; - adsRequest.nonLinearAdSlotHeight = container.offsetHeight; + 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. - adsRequest.forceNonLinearFullSlot = false; + request.forceNonLinearFullSlot = false; - this.adsLoader.requestAds(adsRequest); + this.loader.requestAds(request); this.handleEventListeners('ADS_LOADER_LOADED'); } catch (e) { @@ -145,6 +142,25 @@ class Ads { } /** + * 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 */ @@ -158,33 +174,42 @@ class Ads { // 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.adsManager = adsManagerLoadedEvent.getAdsManager(this.player, settings); + this.manager = adsManagerLoadedEvent.getAdsManager(this.player, settings); // Get the cue points for any mid-rolls by filtering out the pre- and post-roll - this.adsCuePoints = this.adsManager.getCuePoints(); + this.cuePoints = this.manager.getCuePoints(); // Add advertisement cue's within the time line if available - this.adsCuePoints.forEach(cuePoint => { + 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.adsManager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error)); + 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.adsManager.addEventListener(google.ima.AdEvent.Type[type], event => this.onAdEvent(event)); + this.manager.addEventListener(google.ima.AdEvent.Type[type], event => this.onAdEvent(event)); }); // Resolve our adsManager @@ -194,25 +219,52 @@ class Ads { /** * 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; - // Listen for events if debugging - this.player.debug.log(`Ads event: ${event.type}`); - // 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'); - // Todo: Example for what happens when a next video in a playlist would be loaded. + // 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. @@ -232,7 +284,7 @@ class Ads { // '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 + // 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(); @@ -243,7 +295,9 @@ class Ads { // 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: @@ -252,22 +306,31 @@ class Ads { // 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.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'); + case google.ima.AdEvent.Type.STARTED: + dispatchEvent('started'); + break; - if (!ad.isLinear()) { - // Position AdDisplayContainer correctly for overlay - ad.width = container.offsetWidth; - ad.height = container.offsetHeight; - } + case google.ima.AdEvent.Type.MIDPOINT: + dispatchEvent('midpoint'); + break; - // console.info('Ad type: ' + event.getAd().getAdPodInfo().getPodIndex()); - // console.info('Ad time: ' + event.getAd().getAdPodInfo().getTimeOffset()); + 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: @@ -295,7 +358,7 @@ class Ads { // Add listeners to the required events this.player.on('ended', () => { - this.adsLoader.contentComplete(); + this.loader.contentComplete(); }); this.player.on('seeking', () => { @@ -306,17 +369,18 @@ class Ads { this.player.on('seeked', () => { const seekedTime = this.player.currentTime; - this.adsCuePoints.forEach((cuePoint, index) => { + this.cuePoints.forEach((cuePoint, index) => { if (time < cuePoint && cuePoint < seekedTime) { - this.adsManager.discardAdBreak(); - this.adsCuePoints.splice(index, 1); + 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.adsManager.resize(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL); + this.manager.resize(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL); }); } @@ -326,23 +390,23 @@ class Ads { play() { const { container } = this.player.elements; - if (!this.adsManagerPromise) { + if (!this.managerPromise) { return; } // Play the requested advertisement whenever the adsManager is ready - this.adsManagerPromise.then(() => { + this.managerPromise.then(() => { // Initialize the container. Must be done via a user action on mobile devices - this.adDisplayContainer.initialize(); + this.elements.displayContainer.initialize(); try { if (!this.initialized) { // Initialize the ads manager. Ad rules playlist will start at this time - this.adsManager.init(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL); + 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.adsManager.start(); + this.manager.start(); } this.initialized = true; @@ -358,10 +422,8 @@ class Ads { * Resume our video. */ resumeContent() { - this.player.debug.log('Resume video'); - // Hide our ad container - utils.toggleHidden(this.adsContainer, true); + utils.toggleHidden(this.elements.container, true); // Ad is stopped this.playing = false; @@ -376,10 +438,8 @@ class Ads { * Pause our video */ pauseContent() { - this.player.debug.log('Pause video'); - // Show our ad container. - utils.toggleHidden(this.adsContainer, false); + utils.toggleHidden(this.elements.container, false); // Ad is playing. this.playing = true; @@ -395,8 +455,6 @@ class Ads { * media-ads/docs/sdks/android/faq#8 */ cancel() { - this.player.debug.warn('Ad cancelled'); - // Pause our video this.resumeContent(); @@ -412,16 +470,16 @@ class Ads { */ loadAds() { // Tell our adsManager to go bye bye - this.adsManagerPromise.then(() => { + this.managerPromise.then(() => { // Destroy our adsManager - if (this.adsManager) { - this.adsManager.destroy(); + if (this.manager) { + this.manager.destroy(); } // Re-set our adsManager promises - this.adsManagerPromise = new Promise(resolve => { + this.managerPromise = new Promise(resolve => { this.on('ADS_MANAGER_LOADED', () => resolve()); - this.player.debug.log(this.adsManager); + this.player.debug.log(this.manager); }); // Make sure we can re-call advertisements @@ -437,7 +495,7 @@ class Ads { * @param {string} event - Event type */ handleEventListeners(event) { - if (typeof this.events[event] !== 'undefined') { + if (utils.is.function(this.events[event])) { this.events[event].call(this); } } @@ -458,11 +516,12 @@ class Ads { * 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 + * @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()'); @@ -471,13 +530,14 @@ class Ads { /** * Clear our safety timer(s) - * @param {String} from + * @param {string} from */ clearSafetyTimer(from) { - if (typeof this.safetyTimer !== 'undefined' && this.safetyTimer !== null) { + if (!utils.is.nullOrUndefined(this.safetyTimer)) { this.player.debug.log(`Safety timer cleared from: ${from}`); + clearTimeout(this.safetyTimer); - this.safetyTimer = undefined; + this.safetyTimer = null; } } } 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 62ad8f9d..f82df2a4 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -605,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) { diff --git a/src/sass/plugins/ads.scss b/src/sass/plugins/ads.scss index 18be5736..60751851 100644 --- a/src/sass/plugins/ads.scss +++ b/src/sass/plugins/ads.scss @@ -14,16 +14,20 @@ &::after { background: rgba($plyr-color-gunmetal, 0.8); border-radius: 2px; - bottom: ($plyr-control-spacing * 2); + bottom: $plyr-control-spacing; color: #fff; content: attr(data-badge-text); - font-size: $plyr-font-size-captions-small; + font-size: 10px; padding: 2px 6px; pointer-events: none; position: absolute; - right: ($plyr-control-spacing * 2); + right: $plyr-control-spacing; z-index: 3; } + + &::after:empty { + display: none; + } } // Advertisement cue's for the progress bar. |