UNPKG

videojs-contrib-ads

Version:

A framework that provides common functionality needed by video advertisement libraries working with video.js.

634 lines (560 loc) 20.4 kB
/* This main plugin file is responsible for integration logic and enabling the features that live in in separate files. */ import videojs from 'video.js'; import redispatch from './redispatch.js'; import snapshot from './snapshot.js'; import initializeContentupdate from './contentupdate.js'; import cancelContentPlay from './cancelContentPlay.js'; import adMacroReplacement from './macros.js'; const VIDEO_EVENTS = videojs.getComponent('Html5').Events; /** * Remove the poster attribute from the video element tech, if present. When * reusing a video element for multiple videos, the poster image will briefly * reappear while the new source loads. Removing the attribute ahead of time * prevents the poster from showing up between videos. * @param {object} player The videojs player object */ const removeNativePoster = function(player) { const tech = player.$('.vjs-tech'); if (tech) { tech.removeAttribute('poster'); } }; // --------------------------------------------------------------------------- // Ad Framework // --------------------------------------------------------------------------- // default framework settings const defaults = { // maximum amount of time in ms to wait to receive `adsready` from the ad // implementation after play has been requested. Ad implementations are // expected to load any dynamic libraries and make any requests to determine // ad policies for a video during this time. timeout: 5000, // maximum amount of time in ms to wait for the ad implementation to start // linear ad mode after `readyforpreroll` has fired. This is in addition to // the standard timeout. prerollTimeout: 100, // maximum amount of time in ms to wait for the ad implementation to start // linear ad mode after `contentended` has fired. postrollTimeout: 100, // when truthy, instructs the plugin to output additional information about // plugin state to the video.js log. On most devices, the video.js log is // the same as the developer console. debug: false, // set this to true when using ads that are part of the content video stitchedAds: false }; const contribAdsPlugin = function(options) { const player = this; // eslint-disable-line consistent-this const settings = videojs.mergeOptions(defaults, options); // prefix all video element events during ad playback // if the video element emits ad-related events directly, // plugins that aren't ad-aware will break. prefixing allows // plugins that wish to handle ad events to do so while // avoiding the complexity for common usage const videoEvents = VIDEO_EVENTS.concat([ 'firstplay', 'loadedalldata', 'playing' ]); // Set up redispatching of player events player.on(videoEvents, redispatch); // "vjs-has-started" should be present at the end of a video. This makes sure it's // always there. player.on('ended', function() { if (!player.hasClass('vjs-has-started')) { player.addClass('vjs-has-started'); } }); // We now auto-play when an ad gets loaded if we're playing ads in the same video // element as the content. // The problem is that in IE11, we cannot play in addurationchange but in iOS8, we // cannot play from adcanplay. // This will prevent ad-integrations from needing to do this themselves. player.on(['addurationchange', 'adcanplay'], function() { if (player.currentSrc() === player.ads.snapshot.currentSrc) { return; } player.play(); }); player.on('nopreroll', function() { player.ads.nopreroll_ = true; }); player.on('nopostroll', function() { player.ads.nopostroll_ = true; }); // Remove ad-loading class when ad plays or when content plays (in case there was no ad) // If you remove this class too soon you can get a flash of content! player.on(['ads-ad-started', 'playing'], () => { player.removeClass('vjs-ad-loading'); }); // Replace the plugin constructor with the ad namespace player.ads = { state: 'content-set', disableNextSnapshotRestore: false, // This is set to true if the content has ended once. After that, the user can // seek backwards and replay content, but _contentHasEnded remains true. _contentHasEnded: false, VERSION: '__VERSION__', reset() { player.ads.disableNextSnapshotRestore = false; player.ads._contentHasEnded = false; player.ads.snapshot = null; }, // Call this when an ad response has been received and there are // linear ads ready to be played. startLinearAdMode() { if (player.ads.state === 'preroll?' || player.ads.state === 'content-playback' || player.ads.state === 'postroll?') { player.trigger('adstart'); } }, // Call this when a linear ad pod has finished playing. endLinearAdMode() { if (player.ads.state === 'ad-playback') { player.trigger('adend'); // In the case of an empty ad response, we want to make sure that // the vjs-ad-loading class is always removed. We could probably check for // duration on adPlayer for an empty ad but we remove it here just to make sure player.removeClass('vjs-ad-loading'); } }, // Call this when an ad response has been received but there are no // linear ads to be played (i.e. no ads available, or overlays). // This has no effect if we are already in a linear ad mode. Always // use endLinearAdMode() to exit from linear ad-playback state. skipLinearAdMode() { if (player.ads.state !== 'ad-playback') { player.trigger('adskip'); } }, stitchedAds(arg) { if (arg !== undefined) { this._stitchedAds = !!arg; } return this._stitchedAds; }, // Returns whether the video element has been modified since the // snapshot was taken. // We test both src and currentSrc because changing the src attribute to a URL that // AdBlocker is intercepting doesn't update currentSrc. videoElementRecycled() { let srcChanged; let currentSrcChanged; if (!this.snapshot) { throw new Error( 'You cannot use videoElementRecycled while there is no snapshot.'); } srcChanged = player.src() !== this.snapshot.src; currentSrcChanged = player.currentSrc() !== this.snapshot.currentSrc; return srcChanged || currentSrcChanged; }, // Returns a boolean indicating if given player is in live mode. // Can be replaced when this is fixed: https://github.com/videojs/video.js/issues/3262 isLive(somePlayer) { if (somePlayer.duration() === Infinity) { return true; } else if (videojs.browser.IOS_VERSION === '8' && somePlayer.duration() === 0) { return true; } return false; }, // Return true if content playback should mute and continue during ad breaks. // This is only done during live streams on platforms where it's supported. // This improves speed and accuracy when returning from an ad break. shouldPlayContentBehindAd(somePlayer) { return !videojs.browser.IS_IOS && !videojs.browser.IS_ANDROID && somePlayer.duration() === Infinity; } }; player.ads.stitchedAds(settings.stitchedAds); player.ads.adMacroReplacement = adMacroReplacement.bind(player); // Start sending contentupdate events for this player initializeContentupdate(player); // Global contentupdate handler for resetting plugin state player.on('contentupdate', player.ads.reset); // Ad Playback State Machine const states = { 'content-set': { events: { adscanceled() { this.state = 'content-playback'; }, adsready() { this.state = 'ads-ready'; }, play() { this.state = 'ads-ready?'; cancelContentPlay(player); // remove the poster so it doesn't flash between videos removeNativePoster(player); }, adserror() { this.state = 'content-playback'; }, adskip() { this.state = 'content-playback'; } } }, 'ads-ready': { events: { play() { this.state = 'preroll?'; cancelContentPlay(player); }, adskip() { this.state = 'content-playback'; }, adserror() { this.state = 'content-playback'; } } }, 'preroll?': { enter() { if (player.ads.nopreroll_) { // This will start the ads manager in case there are later ads player.trigger('readyforpreroll'); // If we don't wait a tick, entering content-playback will cancel // cancelPlayTimeout, causing the video to not pause for the ad window.setTimeout(function() { // Don't wait for a preroll player.trigger('nopreroll'); }, 1); } else { // change class to show that we're waiting on ads player.addClass('vjs-ad-loading'); // schedule an adtimeout event to fire if we waited too long player.ads.adTimeoutTimeout = window.setTimeout(function() { player.trigger('adtimeout'); }, settings.prerollTimeout); // signal to ad plugin that it's their opportunity to play a preroll player.trigger('readyforpreroll'); } }, leave() { window.clearTimeout(player.ads.adTimeoutTimeout); }, events: { play() { cancelContentPlay(player); }, adstart() { this.state = 'ad-playback'; }, adskip() { this.state = 'content-playback'; }, adtimeout() { this.state = 'content-playback'; }, adserror() { this.state = 'content-playback'; }, nopreroll() { this.state = 'content-playback'; } } }, 'ads-ready?': { enter() { player.addClass('vjs-ad-loading'); player.ads.adTimeoutTimeout = window.setTimeout(function() { player.trigger('adtimeout'); }, settings.timeout); }, leave() { window.clearTimeout(player.ads.adTimeoutTimeout); player.removeClass('vjs-ad-loading'); }, events: { play() { cancelContentPlay(player); }, adscanceled() { this.state = 'content-playback'; }, adsready() { this.state = 'preroll?'; }, adskip() { this.state = 'content-playback'; }, adtimeout() { this.state = 'content-playback'; }, adserror() { this.state = 'content-playback'; } } }, 'ad-playback': { enter() { // capture current player state snapshot (playing, currentTime, src) if (!player.ads.shouldPlayContentBehindAd(player)) { this.snapshot = snapshot.getPlayerSnapshot(player); } // Mute the player behind the ad if (player.ads.shouldPlayContentBehindAd(player)) { this.preAdVolume_ = player.volume(); player.volume(0); } // add css to the element to indicate and ad is playing. player.addClass('vjs-ad-playing'); // We should remove the vjs-live class if it has been added in order to // show the adprogress control bar on Android devices for falsely // determined LIVE videos due to the duration incorrectly reported as Infinity if (player.hasClass('vjs-live')) { player.removeClass('vjs-live'); } // remove the poster so it doesn't flash between ads removeNativePoster(player); // We no longer need to supress play events once an ad is playing. // Clear it if we were. if (player.ads.cancelPlayTimeout) { // If we don't wait a tick, we could cancel the pause for cancelContentPlay, // resulting in content playback behind the ad window.setTimeout(function() { window.clearTimeout(player.ads.cancelPlayTimeout); player.ads.cancelPlayTimeout = null; }, 1); } }, leave() { player.removeClass('vjs-ad-playing'); // We should add the vjs-live class back if the video is a LIVE video // If we dont do this, then for a LIVE Video, we will get an incorrect // styled control, which displays the time for the video if (player.ads.isLive(player)) { player.addClass('vjs-live'); } if (!player.ads.shouldPlayContentBehindAd(player)) { snapshot.restorePlayerSnapshot(player, this.snapshot); } // Reset the volume to pre-ad levels if (player.ads.shouldPlayContentBehindAd(player)) { player.volume(this.preAdVolume_); } }, events: { adend() { this.state = 'content-resuming'; }, adserror() { this.state = 'content-resuming'; // Trigger 'adend' to notify that we are exiting 'ad-playback' player.trigger('adend'); } } }, 'content-resuming': { enter() { if (this._contentHasEnded) { window.clearTimeout(player.ads._fireEndedTimeout); // in some cases, ads are played in a swf or another video element // so we do not get an ended event in this state automatically. // If we don't get an ended event we can use, we need to trigger // one ourselves or else we won't actually ever end the current video. player.ads._fireEndedTimeout = window.setTimeout(function() { player.trigger('ended'); }, 1000); } }, leave() { window.clearTimeout(player.ads._fireEndedTimeout); }, events: { contentupdate() { this.state = 'content-set'; }, contentresumed() { this.state = 'content-playback'; }, playing() { this.state = 'content-playback'; }, ended() { this.state = 'content-playback'; } } }, 'postroll?': { enter() { this.snapshot = snapshot.getPlayerSnapshot(player); if (player.ads.nopostroll_) { window.setTimeout(function() { // content-resuming happens after the timeout for backward-compatibility // with plugins that relied on a postrollTimeout before nopostroll was // implemented player.ads.state = 'content-resuming'; player.trigger('ended'); }, 1); } else { player.addClass('vjs-ad-loading'); player.ads.adTimeoutTimeout = window.setTimeout(function() { player.trigger('adtimeout'); }, settings.postrollTimeout); } }, leave() { window.clearTimeout(player.ads.adTimeoutTimeout); player.removeClass('vjs-ad-loading'); }, events: { adstart() { this.state = 'ad-playback'; }, adskip() { this.state = 'content-resuming'; window.setTimeout(function() { player.trigger('ended'); }, 1); }, adtimeout() { this.state = 'content-resuming'; window.setTimeout(function() { player.trigger('ended'); }, 1); }, adserror() { this.state = 'content-resuming'; window.setTimeout(function() { player.trigger('ended'); }, 1); }, contentupdate() { this.state = 'ads-ready?'; } } }, 'content-playback': { enter() { // make sure that any cancelPlayTimeout is cleared if (player.ads.cancelPlayTimeout) { window.clearTimeout(player.ads.cancelPlayTimeout); player.ads.cancelPlayTimeout = null; } // This was removed because now that "playing" is fixed to only play after // preroll, any integration should just use the "playing" event. However, // we found out some 3rd party code relied on this event, so we've temporarily // added it back in to give people more time to update their code. player.trigger({ type: 'contentplayback', triggerevent: player.ads.triggerevent }); // Play the content if (player.ads.cancelledPlay) { player.ads.cancelledPlay = false; if (player.paused()) { player.play(); } } }, events: { // In the case of a timeout, adsready might come in late. // This assumes the behavior that if an ad times out, it could still // interrupt the content and start playing. An integration could // still decide to behave otherwise. adsready() { player.trigger('readyforpreroll'); }, adstart() { this.state = 'ad-playback'; }, contentupdate() { // We know sources have changed, so we call CancelContentPlay // to avoid playback of video in the background of an ad. Playback Occurs on // Android devices if we do not call cancelContentPlay. This is because // the sources do not get updated in time on Android due to timing issues. // So instead of checking if the sources have changed in the play handler // and calling cancelContentPlay() there we call it here. // This does not happen on Desktop as the sources do get updated in time. if(!player.ads.shouldPlayContentBehindAd(player)) { cancelContentPlay(player); } if (player.paused()) { this.state = 'content-set'; } else { this.state = 'ads-ready?'; } }, contentended() { // If _contentHasEnded is true it means we already checked for postrolls and // played postrolls if needed, so now we're ready to send an ended event if (this._contentHasEnded) { // Causes ended event to trigger in content-resuming.enter. // From there, the ended event event is not redispatched. // Then we end up back in content-playback state. this.state = 'content-resuming'; return; } this._contentHasEnded = true; this.state = 'postroll?'; } } } }; const processEvent = function(event) { let state = player.ads.state; // Execute the current state's handler for this event const eventHandlers = states[state].events; if (eventHandlers) { const handler = eventHandlers[event.type]; if (handler) { handler.apply(player.ads); } } // If the state has changed... if (state !== player.ads.state) { const previousState = state; const newState = player.ads.state; // Record the event that caused the state transition player.ads.triggerevent = event.type; // Execute "leave" method for the previous state if (states[previousState].leave) { states[previousState].leave.apply(player.ads); } // Execute "enter" method for the new state if (states[newState].enter) { states[newState].enter.apply(player.ads); } // Debug log message for state changes if (settings.debug) { videojs.log('ads', player.ads.triggerevent + ' triggered: ' + previousState + ' -> ' + newState); } } }; // Register our handler for the events that the state machine will process player.on(VIDEO_EVENTS.concat([ // Events emitted by this plugin 'adtimeout', 'contentupdate', 'contentplaying', 'contentended', 'contentresumed', // Triggered by startLinearAdMode() 'adstart', // Triggered by endLinearAdMode() 'adend', // Triggered by skipLinearAdMode() 'adskip', // Events emitted by integrations 'adsready', 'adserror', 'adscanceled', 'nopreroll' ]), processEvent); // If we're autoplaying, the state machine will immidiately process // a synthetic play event if (!player.paused()) { processEvent({type: 'play'}); } }; // Register this plugin with videojs videojs.plugin('ads', contribAdsPlugin);