Merge pull request #760 from sampotts/beta-with-ads

Beta with ads
This commit is contained in:
Sam Potts 2018-01-22 23:15:52 +11:00 committed by GitHub
commit 3aa5747c90
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 698 additions and 55 deletions

2
.gitignore vendored
View File

@ -7,3 +7,5 @@ index-*.html
npm-debug.log npm-debug.log
*.webm *.webm
/package-lock.json /package-lock.json
.idea/

2
demo/dist/demo.css vendored

File diff suppressed because one or more lines are too long

2
demo/dist/demo.js vendored
View File

@ -1,3 +1,3 @@
document.addEventListener("DOMContentLoaded",function(){window.shr&&window.shr.setup({count:{classname:"button__count"}});var e="tab-focus";document.addEventListener("focusout",function(t){t.target.classList.remove(e)}),document.addEventListener("keydown",function(t){9===t.keyCode&&window.setTimeout(function(){document.activeElement.classList.add(e)},0)});var t=new window.Plyr("#player",{debug:!0,title:"View From A Blue Moon",iconUrl:"../dist/plyr.svg",keyboard:{global:!0},tooltips:{controls:!0},captions:{active:!0},keys:{google:"AIzaSyDrNwtN3nLH_8rjCmu5Wq3ZCm4MNAVdc0c"}});window.player=t;var o=document.querySelectorAll("[data-source]"),i={video:"video",audio:"audio",youtube:"youtube",vimeo:"vimeo"},r=window.location.hash.replace("#",""),n=window.history&&window.history.pushState;function a(e,t,o){e&&e.classList[o?"add":"remove"](t)}function s(e,n){if(e in i&&(n||e!==r)&&(r.length||e!==i.video)){switch(e){case i.video:t.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:!0},{kind:"captions",label:"French",srclang:"fr",src:"https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt"}]};break;case i.audio:t.source={type:"audio",title:"Kishi Bashi – “It All Began With A Burst”",sources:[{src:"https://cdn.plyr.io/static/demo/Kishi_Bashi_-_It_All_Began_With_a_Burst.mp3",type:"audio/mp3"},{src:"https://cdn.plyr.io/static/demo/Kishi_Bashi_-_It_All_Began_With_a_Burst.ogg",type:"audio/ogg"}]};break;case i.youtube:t.source={type:"video",title:"View From A Blue Moon",sources:[{src:"https://youtube.com/watch?v=bTqVqk7FSmY",provider:"youtube"}]};break;case i.vimeo:t.source={type:"video",sources:[{src:"https://vimeo.com/76979871",provider:"vimeo"}]}}r=e,Array.from(o).forEach(function(e){return a(e.parentElement,"active",!1)}),a(document.querySelector('[data-source="'+e+'"]'),"active",!0),Array.from(document.querySelectorAll(".plyr__cite")).forEach(function(e){e.setAttribute("hidden","")}),document.querySelector(".plyr__cite--"+e).removeAttribute("hidden")}}if(Array.from(o).forEach(function(e){e.addEventListener("click",function(){var t=e.getAttribute("data-source");s(t),n&&window.history.pushState({type:t},"","#"+t)})}),window.addEventListener("popstate",function(e){e.state&&"type"in e.state&&s(e.state.type)}),n){var c=!r.length;c&&(r=i.video),r in i&&window.history.replaceState({type:r},"",c?"":"#"+r),r!==i.video&&s(r,!0)}}),"plyr.io"===window.location.host&&(!function(e,t,o,i,r,n,a){e.GoogleAnalyticsObject="ga",e.ga=e.ga||function(){(e.ga.q=e.ga.q||[]).push(arguments)},e.ga.l=1*new Date,n=t.createElement("script"),a=t.getElementsByTagName("script")[0],n.async=1,n.src="//www.google-analytics.com/analytics.js",a.parentNode.insertBefore(n,a)}(window,document),window.ga("create","UA-40881672-11","auto"),window.ga("send","pageview")); !function(){"use strict";var e,t,o,i;document.addEventListener("DOMContentLoaded",function(){window.shr&&window.shr.setup({count:{classname:"button__count"}});document.addEventListener("focusout",function(e){e.target.classList.remove("tab-focus")}),document.addEventListener("keydown",function(e){9===e.keyCode&&window.setTimeout(function(){document.activeElement.classList.add("tab-focus")},0)});var e=new window.Plyr("#player",{debug:!0,title:"View From A Blue Moon",iconUrl:"../dist/plyr.svg",keyboard:{global:!0},tooltips:{controls:!0},captions:{active:!0},keys:{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"}});window.player=e;var t=document.querySelectorAll("[data-source]"),o={video:"video",audio:"audio",youtube:"youtube",vimeo:"vimeo"},i=window.location.hash.replace("#",""),r=window.history&&window.history.pushState;function a(e,t,o){e&&e.classList[o?"add":"remove"](t)}function n(r,n){if(r in o&&(n||r!==i)&&(i.length||r!==o.video)){switch(r){case o.video:e.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:!0},{kind:"captions",label:"French",srclang:"fr",src:"https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt"}]};break;case o.audio:e.source={type:"audio",title:"Kishi Bashi – “It All Began With A Burst”",sources:[{src:"https://cdn.plyr.io/static/demo/Kishi_Bashi_-_It_All_Began_With_a_Burst.mp3",type:"audio/mp3"},{src:"https://cdn.plyr.io/static/demo/Kishi_Bashi_-_It_All_Began_With_a_Burst.ogg",type:"audio/ogg"}]};break;case o.youtube:e.source={type:"video",title:"View From A Blue Moon",sources:[{src:"https://youtube.com/watch?v=bTqVqk7FSmY",provider:"youtube"}]};break;case o.vimeo:e.source={type:"video",sources:[{src:"https://vimeo.com/76979871",provider:"vimeo"}]}}i=r,Array.from(t).forEach(function(e){return a(e.parentElement,"active",!1)}),a(document.querySelector('[data-source="'+r+'"]'),"active",!0),Array.from(document.querySelectorAll(".plyr__cite")).forEach(function(e){e.setAttribute("hidden","")}),document.querySelector(".plyr__cite--"+r).removeAttribute("hidden")}}if(Array.from(t).forEach(function(e){e.addEventListener("click",function(){var t=e.getAttribute("data-source");n(t),r&&window.history.pushState({type:t},"","#"+t)})}),window.addEventListener("popstate",function(e){e.state&&"type"in e.state&&n(e.state.type)}),r){var s=!i.length;s&&(i=o.video),i in o&&window.history.replaceState({type:i},"",s?"":"#"+i),i!==o.video&&n(i,!0)}}),"plyr.io"===window.location.host&&(e=window,t=document,e.GoogleAnalyticsObject="ga",e.ga=e.ga||function(){(e.ga.q=e.ga.q||[]).push(arguments)},e.ga.l=1*new Date,o=t.createElement("script"),i=t.getElementsByTagName("script")[0],o.async=1,o.src="//www.google-analytics.com/analytics.js",i.parentNode.insertBefore(o,i),window.ga("create","UA-40881672-11","auto"),window.ga("send","pageview"))}();
//# sourceMappingURL=demo.js.map //# sourceMappingURL=demo.js.map

File diff suppressed because one or more lines are too long

View File

@ -51,6 +51,9 @@ document.addEventListener('DOMContentLoaded', () => {
keys: { keys: {
google: 'AIzaSyDrNwtN3nLH_8rjCmu5Wq3ZCm4MNAVdc0c', 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',
},
}); });
// Expose for testing // Expose for testing

2
dist/plyr.css vendored

File diff suppressed because one or more lines are too long

2
dist/plyr.js vendored

File diff suppressed because one or more lines are too long

2
dist/plyr.js.map vendored

File diff suppressed because one or more lines are too long

View File

@ -195,7 +195,7 @@ build.sprite('plyr');
// Demo files // Demo files
build.sass(bundles.demo.sass, 'demo'); build.sass(bundles.demo.sass, 'demo');
build.js(bundles.demo.js, 'demo', { format: 'es' }); build.js(bundles.demo.js, 'demo', { format: 'iife' });
// Build all JS // Build all JS
gulp.task('js', () => { gulp.task('js', () => {

View File

@ -1,4 +1,7 @@
// Default config // ==========================================================================
// Plyr default config
// ==========================================================================
const defaults = { const defaults = {
// Disable // Disable
enabled: true, enabled: true,
@ -176,6 +179,7 @@ const defaults = {
reset: 'Reset', reset: 'Reset',
none: 'None', none: 'None',
disabled: 'Disabled', disabled: 'Disabled',
adCountdown: 'Ad - {countdown}',
}, },
// URLs // URLs
@ -186,6 +190,9 @@ const defaults = {
youtube: { youtube: {
api: 'https://www.youtube.com/iframe_api', api: 'https://www.youtube.com/iframe_api',
}, },
googleIMA: {
api: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js',
},
}, },
// Custom control listeners // Custom control listeners
@ -247,6 +254,17 @@ const defaults = {
'statechange', 'statechange',
'qualitychange', 'qualitychange',
'qualityrequested', 'qualityrequested',
// Ads
'adsloaded',
'adscontentpause',
'adsconentresume',
'adstarted',
'adsmidpoint',
'adscomplete',
'adsallcomplete',
'adsimpression',
'adsclick',
], ],
// Selectors // Selectors
@ -299,6 +317,7 @@ const defaults = {
classNames: { classNames: {
video: 'plyr__video-wrapper', video: 'plyr__video-wrapper',
embed: 'plyr__video-embed', embed: 'plyr__video-embed',
ads: 'plyr__ads',
control: 'plyr__control', control: 'plyr__control',
type: 'plyr--{0}', type: 'plyr--{0}',
provider: 'plyr--{0}', provider: 'plyr--{0}',
@ -308,6 +327,7 @@ const defaults = {
error: 'plyr--has-error', error: 'plyr--has-error',
hover: 'plyr--hover', hover: 'plyr--hover',
tooltip: 'plyr__tooltip', tooltip: 'plyr__tooltip',
cues: 'plyr__cues',
hidden: 'plyr__sr-only', hidden: 'plyr__sr-only',
hideControls: 'plyr--hide-controls', hideControls: 'plyr--hide-controls',
isIos: 'plyr--is-ios', isIos: 'plyr--is-ios',
@ -342,6 +362,11 @@ const defaults = {
keys: { keys: {
google: null, google: null,
}, },
// Ads
ads: {
tagUrl: null,
},
}; };
export default defaults; export default defaults;

545
src/js/plugins/ads.js Normal file
View File

@ -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;

View File

@ -12,6 +12,7 @@ import utils from './utils';
import Console from './console'; import Console from './console';
import Storage from './storage'; import Storage from './storage';
import Ads from './plugins/ads';
import captions from './captions'; import captions from './captions';
import controls from './controls'; import controls from './controls';
@ -273,6 +274,11 @@ class Plyr {
if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) { if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) {
ui.build.call(this); 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() { 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();
} }
/** /**

View File

@ -293,33 +293,15 @@ const ui = {
// Update the displayed time // Update the displayed time
updateTimeDisplay(target = null, time = 0, inverted = false) { updateTimeDisplay(target = null, time = 0, inverted = false) {
// Bail if there's no element to display or the value isn't a number // 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; return;
} }
// Format time component to add leading zero // Always display hours if duration is over an hour
const format = value => `0${value}`.slice(-2); const displayHours = utils.getHours(this.duration) > 0;
// 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 = '';
}
// Render
// eslint-disable-next-line no-param-reassign // 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 // Handle time change event

View File

@ -50,6 +50,9 @@ const utils = {
track(input) { track(input) {
return this.instanceof(input, TextTrack) || (!this.nullOrUndefined(input) && this.string(input.kind)); 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) { nullOrUndefined(input) {
return input === null || typeof input === 'undefined'; return input === null || typeof input === 'undefined';
}, },
@ -602,6 +605,32 @@ const utils = {
return (current / max * 100).toFixed(2); 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 // Deep extend destination object with N more objects
extend(target = {}, ...sources) { extend(target = {}, ...sources) {
if (!sources.length) { if (!sources.length) {
@ -746,9 +775,9 @@ const utils = {
// Force repaint of element // Force repaint of element
repaint(element) { repaint(element) {
window.setTimeout(() => { window.setTimeout(() => {
element.setAttribute('hidden', ''); utils.toggleHidden(element, true);
element.offsetHeight; // eslint-disable-line element.offsetHeight; // eslint-disable-line
element.removeAttribute('hidden'); utils.toggleHidden(element, false);
}, 0); }, 0);
}, },
}; };

45
src/sass/plugins/ads.scss Normal file
View File

@ -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.
}

View File

@ -40,5 +40,7 @@
@import 'states/error'; @import 'states/error';
@import 'states/fullscreen'; @import 'states/fullscreen';
@import 'plugins/ads';
@import 'utils/animation'; @import 'utils/animation';
@import 'utils/hidden'; @import 'utils/hidden';

View File

@ -887,12 +887,6 @@ braces@^2.3.0:
split-string "^3.0.2" split-string "^3.0.2"
to-regex "^3.0.1" to-regex "^3.0.1"
browser-resolve@^1.11.0:
version "1.11.2"
resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.2.tgz#8ff09b0a2c421718a1051c260b32e48f442938ce"
dependencies:
resolve "1.1.7"
browserslist@^2.1.2, browserslist@^2.10.0: browserslist@^2.1.2, browserslist@^2.10.0:
version "2.10.0" version "2.10.0"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-2.10.0.tgz#bac5ee1cc69ca9d96403ffb8a3abdc5b6aed6346" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-2.10.0.tgz#bac5ee1cc69ca9d96403ffb8a3abdc5b6aed6346"
@ -1176,6 +1170,10 @@ commander@^2.9.0, commander@~2.12.1:
version "2.12.2" version "2.12.2"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555" resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555"
commander@~2.13.0:
version "2.13.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c"
component-emitter@^1.2.1: component-emitter@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
@ -4529,10 +4527,6 @@ resolve-url@^0.2.1, resolve-url@~0.2.1:
version "0.2.1" version "0.2.1"
resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
resolve@1.1.7:
version "1.1.7"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
resolve@^1.1.6, resolve@^1.1.7, resolve@^1.2.0, resolve@^1.4.0: resolve@^1.1.6, resolve@^1.1.7, resolve@^1.2.0, resolve@^1.4.0:
version "1.5.0" version "1.5.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.5.0.tgz#1f09acce796c9a762579f31b2c1cc4c3cddf9f36" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.5.0.tgz#1f09acce796c9a762579f31b2c1cc4c3cddf9f36"
@ -4568,11 +4562,10 @@ rollup-plugin-commonjs@^8.2.6:
resolve "^1.4.0" resolve "^1.4.0"
rollup-pluginutils "^2.0.1" rollup-pluginutils "^2.0.1"
rollup-plugin-node-resolve@^3.0.0: rollup-plugin-node-resolve@^3.0.2:
version "3.0.0" version "3.0.2"
resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.0.0.tgz#8b897c4c3030d5001277b0514b25d2ca09683ee0" resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.0.2.tgz#38babc12fd404cc2ba1ff68648fe43fa3ffee6b0"
dependencies: dependencies:
browser-resolve "^1.11.0"
builtin-modules "^1.1.0" builtin-modules "^1.1.0"
is-module "^1.0.0" is-module "^1.0.0"
resolve "^1.1.6" resolve "^1.1.6"
@ -4999,9 +4992,9 @@ stylelint-config-recommended@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/stylelint-config-recommended/-/stylelint-config-recommended-2.0.1.tgz#4746119ec85f5f4663c7b5107c05c13ed0e2ab0d" resolved "https://registry.yarnpkg.com/stylelint-config-recommended/-/stylelint-config-recommended-2.0.1.tgz#4746119ec85f5f4663c7b5107c05c13ed0e2ab0d"
stylelint-config-sass-guidelines@^4.0.1: stylelint-config-sass-guidelines@^4.1.0:
version "4.0.1" version "4.1.0"
resolved "https://registry.yarnpkg.com/stylelint-config-sass-guidelines/-/stylelint-config-sass-guidelines-4.0.1.tgz#c64f353e07575ad06f6405d6dd902066aa5bc8c4" resolved "https://registry.yarnpkg.com/stylelint-config-sass-guidelines/-/stylelint-config-sass-guidelines-4.1.0.tgz#08d1d9a597704fd9945eb85422abb24f8608a170"
stylelint-config-standard@^18.0.0: stylelint-config-standard@^18.0.0:
version "18.0.0" version "18.0.0"
@ -5286,11 +5279,11 @@ typedarray@^0.0.6:
version "0.0.6" version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
uglify-es@^3.3.5: uglify-es@^3.3.7:
version "3.3.5" version "3.3.7"
resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.5.tgz#cf7e695da81999f85196b15e2978862f13212f88" resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.7.tgz#d1249af668666aba7cb1163e277455be9eb393cf"
dependencies: dependencies:
commander "~2.12.1" commander "~2.13.0"
source-map "~0.6.1" source-map "~0.6.1"
uglify-js@^3.0.9: uglify-js@^3.0.9: