/** * Basic Ad support plugin for video.js. * * Common code to support ad integrations. */ (function(window, videojs, undefined) { 'use strict'; var VIDEO_EVENTS = videojs.getComponent('Html5').Events, /** * If ads are not playing, pauses the player at the next available * opportunity. Has no effect if ads have started. This function is necessary * because pausing a video element while processing a `play` event on iOS can * cause the video element to continuously toggle between playing and paused * states. * * @param {object} player The video player */ cancelContentPlay = function(player) { if (player.ads.cancelPlayTimeout) { // another cancellation is already in flight, so do nothing return; } player.ads.cancelPlayTimeout = window.setTimeout(function() { // deregister the cancel timeout so subsequent cancels are scheduled player.ads.cancelPlayTimeout = null; // pause playback so ads can be handled. if (!player.paused()) { player.pause(); } // add a contentplayback handler to resume playback when ads finish. player.one('contentplayback', function() { if (player.paused()) { player.play(); } }); }, 1); }, /** * Returns an object that captures the portions of player state relevant to * video playback. The result of this function can be passed to * restorePlayerSnapshot with a player to return the player to the state it * was in when this function was invoked. * @param {object} player The videojs player object */ getPlayerSnapshot = function(player) { var tech = player.$('.vjs-tech'), tracks = player.remoteTextTracks ? player.remoteTextTracks() : [], track, i, suppressedTracks = [], snapshot = { ended: player.ended(), currentSrc: player.currentSrc(), src: player.src(), currentTime: player.currentTime(), type: player.currentType() }; if (tech) { snapshot.nativePoster = tech.poster; snapshot.style = tech.getAttribute('style'); } i = tracks.length; while (i--) { track = tracks[i]; suppressedTracks.push({ track: track, mode: track.mode }); track.mode = 'disabled'; } snapshot.suppressedTracks = suppressedTracks; return snapshot; }, /** * Attempts to modify the specified player so that its state is equivalent to * the state of the snapshot. * @param {object} snapshot - the player state to apply */ restorePlayerSnapshot = function(player, snapshot) { var // the playback tech tech = player.$('.vjs-tech'), // the number of remaining attempts to restore the snapshot attempts = 20, suppressedTracks = snapshot.suppressedTracks, trackSnapshot, restoreTracks = function() { var i = suppressedTracks.length; while (i--) { trackSnapshot = suppressedTracks[i]; trackSnapshot.track.mode = trackSnapshot.mode; } }, // finish restoring the playback state resume = function() { var ended = false, updateEnded = function() { ended = true; }; player.currentTime(snapshot.currentTime); // Resume playback if this wasn't a postroll if (!snapshot.ended) { player.play(); } else { // On iOS 8.1, the "ended" event will not fire if you seek // directly to the end of a video. To make that behavior // consistent with the standard, fire a synthetic event if // "ended" does not fire within 250ms. Note that the ended // event should occur whether the browser actually has data // available for that position // (https://html.spec.whatwg.org/multipage/embedded-content.html#seeking), // so it should not be necessary to wait for the seek to // indicate completion. player.ads.resumeEndedTimeout = window.setTimeout(function() { if (!ended) { player.play(); } player.off('ended', updateEnded); player.ads.resumeEndedTimeout = null; }, 250); player.on('ended', updateEnded); // Need to clear the resume/ended timeout on dispose. If it fires // after a player is disposed, an error will be thrown! player.on('dispose', function() { window.clearTimeout(player.ads.resumeEndedTimeout); }); } }, // determine if the video element has loaded enough of the snapshot source // to be ready to apply the rest of the state tryToResume = function() { // tryToResume can either have been called through the `contentcanplay` // event or fired through setTimeout. // When tryToResume is called, we should make sure to clear out the other // way it could've been called by removing the listener and clearing out // the timeout. player.off('contentcanplay', tryToResume); if (player.ads.tryToResumeTimeout_) { player.clearTimeout(player.ads.tryToResumeTimeout_); player.ads.tryToResumeTimeout_ = null; } // Tech may have changed depending on the differences in sources of the // original video and that of the ad tech = player.el().querySelector('.vjs-tech'); if (tech.readyState > 1) { // some browsers and media aren't "seekable". // readyState greater than 1 allows for seeking without exceptions return resume(); } if (tech.seekable === undefined) { // if the tech doesn't expose the seekable time ranges, try to // resume playback immediately return resume(); } if (tech.seekable.length > 0) { // if some period of the video is seekable, resume playback return resume(); } // delay a bit and then check again unless we're out of attempts if (attempts--) { window.setTimeout(tryToResume, 50); } else { (function() { try { resume(); } catch(e) { videojs.log.warn('Failed to resume the content after an advertisement', e); } })(); } }, // whether the video element has been modified since the // snapshot was taken srcChanged; if (snapshot.nativePoster) { tech.poster = snapshot.nativePoster; } if ('style' in snapshot) { // overwrite all css style properties to restore state precisely tech.setAttribute('style', snapshot.style || ''); } // Determine whether the player needs to be restored to its state // before ad playback began. With a custom ad display or burned-in // ads, the content player state hasn't been modified and so no // restoration is required srcChanged = player.src() !== snapshot.src || player.currentSrc() !== snapshot.currentSrc; if (srcChanged) { // on ios7, fiddling with textTracks too early will cause safari to crash player.one('contentloadedmetadata', restoreTracks); // if the src changed for ad playback, reset it player.src({ src: snapshot.currentSrc, type: snapshot.type }); // safari requires a call to `load` to pick up a changed source player.load(); // and then resume from the snapshots time once the original src has loaded // in some browsers (firefox) `canplay` may not fire correctly. // Reace the `canplay` event with a timeout. player.one('contentcanplay', tryToResume); player.ads.tryToResumeTimeout_ = player.setTimeout(tryToResume, 2000); } else if (!player.ended() || !snapshot.ended) { // if we didn't change the src, just restore the tracks restoreTracks(); // the src didn't change and this wasn't a postroll // just resume playback at the current time. player.play(); } }, /** * 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 */ removeNativePoster = function(player) { var tech = player.$('.vjs-tech'); if (tech) { tech.removeAttribute('poster'); } }, // --------------------------------------------------------------------------- // Ad Framework // --------------------------------------------------------------------------- // default framework settings 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 }, adFramework = function(options) { var player = this; var settings = videojs.mergeOptions(defaults, options); var fsmHandler; // 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 (function() { var videoEvents = VIDEO_EVENTS.concat([ 'firstplay', 'loadedalldata' ]); var returnTrue = function() { return true; }; var triggerEvent = function(type, event) { // pretend we called stopImmediatePropagation because we want the native // element events to continue propagating event.isImmediatePropagationStopped = returnTrue; event.cancelBubble = true; event.isPropagationStopped = returnTrue; player.trigger({ type: type + event.type, state: player.ads.state, originalEvent: event }); }; player.on(videoEvents, function redispatch(event) { if (player.ads.state === 'ad-playback') { triggerEvent('ad', event); } else if (player.ads.state === 'content-playback' && event.type === 'ended') { triggerEvent('content', event); } else if (player.ads.state === 'content-resuming') { if (player.ads.snapshot) { // the video element was recycled for ad playback if (player.currentSrc() !== player.ads.snapshot.currentSrc) { if (event.type === 'loadstart') { return; } return triggerEvent('content', event); // we ended playing postrolls and the video itself // the content src is back in place } else if (player.ads.snapshot.ended) { if ((event.type === 'pause' || event.type === 'ended')) { // after loading a video, the natural state is to not be started // in this case, it actually has, so, we do it manually player.addClass('vjs-has-started'); // let `pause` and `ended` events through, naturally return; } // prefix all other events in content-resuming with `content` return triggerEvent('content', event); } } if (event.type !== 'playing') { triggerEvent('content', event); } } }); })(); // 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 allow 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; }); // replace the ad initializer with the ad namespace player.ads = { state: 'content-set', // Call this when an ad response has been received and there are // linear ads ready to be played. startLinearAdMode: function() { 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: function() { if (player.ads.state === 'ad-playback') { player.trigger('adend'); } }, // 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: function() { if (player.ads.state !== 'ad-playback') { player.trigger('adskip'); } } }; fsmHandler = function(event) { // Ad Playback State Machine var fsm = { 'content-set': { events: { 'adscanceled': function() { this.state = 'content-playback'; }, 'adsready': function() { this.state = 'ads-ready'; }, 'play': function() { this.state = 'ads-ready?'; cancelContentPlay(player); // remove the poster so it doesn't flash between videos removeNativePoster(player); }, 'adserror': function() { this.state = 'content-playback'; }, 'adskip': function() { this.state = 'content-playback'; } } }, 'ads-ready': { events: { 'play': function() { this.state = 'preroll?'; cancelContentPlay(player); }, 'adskip': function() { this.state = 'content-playback'; }, 'adserror': function() { this.state = 'content-playback'; } } }, 'preroll?': { enter: function() { if (player.ads.nopreroll_) { // This will start the ads manager in case there are later ads player.trigger('readyforpreroll'); // Don't wait for a preroll player.trigger('nopreroll'); } 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: function() { window.clearTimeout(player.ads.adTimeoutTimeout); player.removeClass('vjs-ad-loading'); }, events: { 'play': function() { cancelContentPlay(player); }, 'adstart': function() { this.state = 'ad-playback'; }, 'adskip': function() { this.state = 'content-playback'; }, 'adtimeout': function() { this.state = 'content-playback'; }, 'adserror': function() { this.state = 'content-playback'; }, 'nopreroll': function() { this.state = 'content-playback'; } } }, 'ads-ready?': { enter: function() { player.addClass('vjs-ad-loading'); player.ads.adTimeoutTimeout = window.setTimeout(function() { player.trigger('adtimeout'); }, settings.timeout); }, leave: function() { window.clearTimeout(player.ads.adTimeoutTimeout); player.removeClass('vjs-ad-loading'); }, events: { 'play': function() { cancelContentPlay(player); }, 'adscanceled': function() { this.state = 'content-playback'; }, 'adsready': function() { this.state = 'preroll?'; }, 'adskip': function() { this.state = 'content-playback'; }, 'adtimeout': function() { this.state = 'content-playback'; }, 'adserror': function() { this.state = 'content-playback'; } } }, 'ad-playback': { enter: function() { // capture current player state snapshot (playing, currentTime, src) this.snapshot = getPlayerSnapshot(player); // add css to the element to indicate and ad is playing. player.addClass('vjs-ad-playing'); // 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) { window.clearTimeout(player.ads.cancelPlayTimeout); player.ads.cancelPlayTimeout = null; } }, leave: function() { player.removeClass('vjs-ad-playing'); restorePlayerSnapshot(player, this.snapshot); // trigger 'adend' as a consistent notification // event that we're exiting ad-playback. if (player.ads.triggerevent !== 'adend') { player.trigger('adend'); } }, events: { 'adend': function() { this.state = 'content-resuming'; }, 'adserror': function() { this.state = 'content-resuming'; } } }, 'content-resuming': { enter: function() { if (this.snapshot.ended) { 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: function() { window.clearTimeout(player.ads._fireEndedTimeout); }, events: { 'contentupdate': function() { this.state = 'content-set'; }, contentresumed: function() { this.state = 'content-playback'; }, 'playing': function() { this.state = 'content-playback'; }, 'ended': function() { this.state = 'content-playback'; } } }, 'postroll?': { enter: function() { this.snapshot = getPlayerSnapshot(player); player.addClass('vjs-ad-loading'); player.ads.adTimeoutTimeout = window.setTimeout(function() { player.trigger('adtimeout'); }, settings.postrollTimeout); }, leave: function() { window.clearTimeout(player.ads.adTimeoutTimeout); player.removeClass('vjs-ad-loading'); }, events: { 'adstart': function() { this.state = 'ad-playback'; }, 'adskip': function() { this.state = 'content-resuming'; window.setTimeout(function() { player.trigger('ended'); }, 1); }, 'adtimeout': function() { this.state = 'content-resuming'; window.setTimeout(function() { player.trigger('ended'); }, 1); }, 'adserror': function() { this.state = 'content-resuming'; window.setTimeout(function() { player.trigger('ended'); }, 1); } } }, 'content-playback': { enter: function() { // make sure that any cancelPlayTimeout is cleared if (player.ads.cancelPlayTimeout) { window.clearTimeout(player.ads.cancelPlayTimeout); player.ads.cancelPlayTimeout = null; } // this will cause content to start if a user initiated // 'play' event was canceled earlier. player.trigger({ type: 'contentplayback', triggerevent: player.ads.triggerevent }); }, events: { // in the case of a timeout, adsready might come in late. 'adsready': function() { player.trigger('readyforpreroll'); }, 'adstart': function() { this.state = 'ad-playback'; }, 'contentupdate': function() { if (player.paused()) { this.state = 'content-set'; } else { this.state = 'ads-ready?'; } }, 'contentended': function() { this.state = 'postroll?'; } } } }; (function(state) { var noop = function() {}; // process the current event with a noop default handler ((fsm[state].events || {})[event.type] || noop).apply(player.ads); // check whether the state has changed if (state !== player.ads.state) { // record the event that caused the state transition player.ads.triggerevent = event.type; // execute leave/enter callbacks if present (fsm[state].leave || noop).apply(player.ads); (fsm[player.ads.state].enter || noop).apply(player.ads); // output debug logging if (settings.debug) { videojs.log('ads', player.ads.triggerevent + ' triggered: ' + state + ' -> ' + player.ads.state); } } })(player.ads.state); }; // register for the events we're interested in player.on(VIDEO_EVENTS.concat([ // events emitted by ad plugin 'adtimeout', 'contentupdate', 'contentplaying', 'contentended', 'contentresumed', // events emitted by third party ad implementors 'adsready', 'adserror', 'adscanceled', 'adstart', // startLinearAdMode() 'adend', // endLinearAdMode() 'adskip', // skipLinearAdMode() 'nopreroll' ]), fsmHandler); // keep track of the current content source // if you want to change the src of the video without triggering // the ad workflow to restart, you can update this variable before // modifying the player's source player.ads.contentSrc = player.currentSrc(); // implement 'contentupdate' event. (function(){ var // check if a new src has been set, if so, trigger contentupdate checkSrc = function() { var src; if (player.ads.state !== 'ad-playback') { src = player.currentSrc(); if (src !== player.ads.contentSrc) { player.trigger({ type: 'contentupdate', oldValue: player.ads.contentSrc, newValue: src }); player.ads.contentSrc = src; } } }; // loadstart reliably indicates a new src has been set player.on('loadstart', checkSrc); // check immediately in case we missed the loadstart window.setTimeout(checkSrc, 1); })(); // kick off the fsm if (!player.paused()) { // simulate a play event if we're autoplaying fsmHandler({type:'play'}); } }; // register the ad plugin framework videojs.plugin('ads', adFramework); })(window, videojs);