diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a184176c..18d9307a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,30 +1,3 @@ # CONTRIBUTING -We welcome contributions from everyone! - -## Getting Started - -Make sure you have NodeJS 0.10 or higher and npm installed. - -1. Fork this repository and clone your fork -1. Install dependencies: `npm install` -1. Run a development server: `npm start` - -### Making Changes - -Refer to the [video.js plugin conventions][conventions] for more detail on best practices and tooling for video.js plugin authorship. - -When you've made your changes, push your commit(s) to your fork and issue a pull request against the original repository. - -### Running Tests - -Testing is a crucial part of any software project. For all but the most trivial changes (typos, etc) test cases are expected. Tests are run in actual browsers using [Karma][karma]. - -- In all available and supported browsers: `npm test` -- In a specific browser: `npm run test:chrome`, `npm run test:firefox`, etc. -- While development server is running (`npm start`), navigate to [`http://localhost:9999/test/`][local] - - -[karma]: http://karma-runner.github.io/ -[local]: http://localhost:9999/test/ -[conventions]: https://github.com/videojs/generator-videojs-plugin/blob/master/docs/conventions.md +See: [docs/developer/index.md](docs/developer/index.md) diff --git a/docs/developer/getting-started.md b/docs/developer/getting-started.md index 8663bd30..bd44d9c1 100644 --- a/docs/developer/getting-started.md +++ b/docs/developer/getting-started.md @@ -35,7 +35,7 @@ npm run test ### In browser -Run `./node_modules/.bin/karma start --no-single-run --browsers Chrome test/karma.conf.js` then open `localhost:9876/debug.html`. This can be useful for debugging tests. +Run `npm start` and a Chrome instance will launch with Karma's debug interface at `localhost:9876`, allowing you to debug tests. Also, a static server will run and allow you to look at examples at `localhost:9999`. ## What's Next diff --git a/examples/stitched-ad-plugin/app.css b/examples/stitched-ad-plugin/app.css new file mode 100644 index 00000000..3d7e9b8f --- /dev/null +++ b/examples/stitched-ad-plugin/app.css @@ -0,0 +1,25 @@ +.ad-event { + background-color: #ffe400; +} +.content-event { + background-color: #66a8cc; +} +.content-adplugin-event { + background-color: #00a866; +} +ol { + list-style-type: none; + padding: 0; + font-family: monospace; + white-space: pre; +} + +/* cue-text-tracks-example Styles */ + +.ad-container .vjs-play-progress.vjs-slider-bar { + background-color: #ff0000; +} + +.ad-container .vjs-big-play-button { + display: none; +} diff --git a/examples/stitched-ad-plugin/app.js b/examples/stitched-ad-plugin/app.js new file mode 100644 index 00000000..fd18764a --- /dev/null +++ b/examples/stitched-ad-plugin/app.js @@ -0,0 +1,99 @@ +(function() { + 'use strict'; + var pad = function(n, x, c) { + return (new Array(n).join(c || '0') + x).slice(-n); + }; + var padRight = function(n, x, c) { + return (x + (new Array(n).join(c || '0'))).slice(0, n); + }; + + var player = window.player = videojs('examplePlayer'); + + // initalize example ad plugin for this player + player.exampleStitchedAds({ + debug: true + }); + + var log = document.querySelector('.log'); + var Html5 = videojs.getTech('Html5'); + + var eventList = Html5.Events.concat(Html5.Events.map(function(evt) { + return 'ad' + evt; + })).concat(Html5.Events.map(function(evt) { + return 'content' + evt; + })).concat([ + // events emitted by ad plugin + 'adtimeout', + 'contentupdate', + 'contentplayback', + 'readyforpreroll', + 'readyforpostroll', + // events emitted by third party ad implementors + 'adsready', + 'adscanceled', + 'adplaying', + 'adstart', // startLinearAdMode() + 'adend', // endLinearAdMode() + 'nopreroll', + 'nopostroll' + + ]).filter(function(evt) { + var events = { + progress: 1, + timeupdate: 1, + suspend: 1, + emptied: 1, + durationchange: 1, + contentprogress: 1, + contenttimeupdate: 1, + contentsuspend: 1, + contentemptied: 1, + adprogress: 1, + adtimeupdate: 1, + adsuspend: 1, + ademptied: 1, + adabort: 1 + } + + return !(evt in events); + }); + + player.on(eventList, function(event) { + var d , str, li, evt; + + evt = event.type; + li = document.createElement('li'); + + d = new Date(); + d = '' + + pad(2, d.getHours()) + ':' + + pad(2, d.getMinutes()) + ':' + + pad(2, d.getSeconds()) + '.' + + pad(3, d.getMilliseconds()); + + if (event.type.indexOf('ad') === 0) { + li.className = 'ad-event'; + } else if (event.type.indexOf('content') === 0) { + li.className = 'content-event'; + } + + str = d + ' ' + evt; + + if (evt === 'contentupdate') { + str += ' ' + event.oldValue + " -> " + event.newValue; + li.className = 'content-adplugin-event'; + } + if (evt === 'contentchanged') { + li.className = 'content-adplugin-event'; + } + if (evt === 'contentplayback') { + li.className = 'content-adplugin-event'; + } + if (evt === 'adplay') { + player.trigger('ads-ad-started'); + } + + li.innerHTML = str; + log.insertBefore(li, log.firstChild); + }); +})(); diff --git a/examples/stitched-ad-plugin/index.html b/examples/stitched-ad-plugin/index.html new file mode 100644 index 00000000..e0609729 --- /dev/null +++ b/examples/stitched-ad-plugin/index.html @@ -0,0 +1,38 @@ + + + + + Stitched Ads Example + + + + + + + +

Stitched Ads Example

+ +

This page uses the built files, so you must do a local build before the example will work.

+ +

This is a limited example implementation that puts the player into and out of ad mode for 5 seconds at specific times.

+ + + +

Events

+ +
    + + + + + diff --git a/examples/stitched-ad-plugin/plugin.js b/examples/stitched-ad-plugin/plugin.js new file mode 100644 index 00000000..26f73e0e --- /dev/null +++ b/examples/stitched-ad-plugin/plugin.js @@ -0,0 +1,68 @@ + +// This is an example of a very simple, naive stitched ads plugin. This means +// there is a single source within which are the "ads". +// +// We'll simulate a preroll for the first 5 seconds of playback, a midroll at +// 15 seconds, and a postroll. +// +// In reality, it's left up to the integration to define when and where ad +// mode is started and ended via some kind of mapping or manifest. +// +videojs.registerPlugin('exampleStitchedAds', function(options) { + var player = this; + + options = options || {}; + options.stitchedAds = true; + + // Initialize contrib-ads. + player.ads(options); + + player.on('canplay', function() { + player.one('playing', function() { + var havePlayedPreroll = false; + var haveStartedMidroll = false; + var haveStartedPostroll = false; + var havePlayedMidroll = false; + + // Simulate a pre-roll immediately upon playback starting. + player.ads.startLinearAdMode(); + + // Simulate a mid-roll at 15 seconds and a post-roll at 5 seconds from + // the end. Need to listen to both timeupdate and adtimeupdate because + // redispatch will prefix during ads. + player.on(['timeupdate', 'adtimeupdate'], function(e) { + var currentTime = player.currentTime(); + var duration = player.duration(); + + // End the pre-roll. + if (!havePlayedPreroll && currentTime >= 5) { + havePlayedPreroll = true; + player.ads.endLinearAdMode(); + return; + } + + // Start the mid-roll. + if (!haveStartedMidroll && currentTime >= 15) { + haveStartedMidroll = true; + player.ads.startLinearAdMode(); + return; + } + + // End the mid-roll. + if (!havePlayedMidroll && currentTime >= 20) { + havePlayedMidroll = true; + player.ads.endLinearAdMode(); + return; + } + + // Start the post-roll. + // The post-roll will be ended automatically via the `adended` event. + if (!haveStartedPostroll && currentTime >= duration - 5) { + haveStartedPostroll = true; + player.ads.startLinearAdMode(); + return; + } + }); + }); + }); +}); diff --git a/index.html b/index.html index 3d613940..83a305f4 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,7 @@
  1. Example ad plugin.
  2. Example cueTextTracks plugin.
  3. Example using modules
  4. +
  5. Example stitched ad plugin.
  6. diff --git a/package.json b/package.json index f22a29eb..034e260d 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,9 @@ "postclean": "mkdirp dist test/dist", "lint": "eslint src", "prestart": "npm run build", - "start": "npm-run-all -p start:server watch", + "start": "npm-run-all -p start:* watch", "start:server": "static -a 0.0.0.0 -p 9999 -H '{\"Cache-Control\": \"no-cache, must-revalidate\"}' .", + "start:test": "karma start --no-single-run --browsers Chrome test/karma.conf.js", "pretest": "npm-run-all lint build", "test": "karma start test/karma.conf.js", "preversion": "npm test", diff --git a/scripts/modules.rollup.config.js b/scripts/modules.rollup.config.js index 682aacea..1b69f3c9 100644 --- a/scripts/modules.rollup.config.js +++ b/scripts/modules.rollup.config.js @@ -9,8 +9,8 @@ import babel from 'rollup-plugin-babel'; import json from 'rollup-plugin-json'; export default { - moduleName: 'videojsContribAds', - entry: 'src/plugin.js', + name: 'videojsContribAds', + input: 'src/plugin.js', external: [ 'global/document', 'global/window', @@ -19,6 +19,13 @@ export default { globals: { 'video.js': 'videojs' }, + output: [{ + file: 'dist/videojs-contrib-ads.cjs.js', + format: 'cjs', + }, { + file: 'dist/videojs-contrib-ads.es.js', + format: 'es' + }], legacy: true, plugins: [ json(), @@ -37,9 +44,5 @@ export default { 'transform-object-assign' ] }) - ], - targets: [ - {dest: 'dist/videojs-contrib-ads.cjs.js', format: 'cjs'}, - {dest: 'dist/videojs-contrib-ads.es.js', format: 'es'} ] }; diff --git a/scripts/test.rollup.config.js b/scripts/test.rollup.config.js index 1d0c2e71..a7cae9a3 100644 --- a/scripts/test.rollup.config.js +++ b/scripts/test.rollup.config.js @@ -10,10 +10,12 @@ import multiEntry from 'rollup-plugin-multi-entry'; import resolve from 'rollup-plugin-node-resolve'; export default { - moduleName: 'videojsContribAdsTests', - entry: 'test/**/test.*.js', - dest: 'test/dist/bundle.js', - format: 'iife', + name: 'videojsContribAdsTests', + input: 'test/**/test.*.js', + output: { + file: 'test/dist/bundle.js', + format: 'iife' + }, external: [ 'qunit', 'qunitjs', diff --git a/scripts/umd.rollup.config.js b/scripts/umd.rollup.config.js index 8fb344c0..d8ffc35f 100644 --- a/scripts/umd.rollup.config.js +++ b/scripts/umd.rollup.config.js @@ -10,10 +10,12 @@ import json from 'rollup-plugin-json'; import resolve from 'rollup-plugin-node-resolve'; export default { - moduleName: 'videojsContribAds', - entry: 'src/plugin.js', - dest: 'dist/videojs-contrib-ads.js', - format: 'umd', + name: 'videojsContribAds', + input: 'src/plugin.js', + output: { + file: 'dist/videojs-contrib-ads.js', + format: 'umd' + }, external: ['video.js'], globals: { 'video.js': 'videojs' diff --git a/src/adBreak.js b/src/adBreak.js index dead52c7..b67cf0f8 100644 --- a/src/adBreak.js +++ b/src/adBreak.js @@ -15,7 +15,7 @@ function start(player) { player.trigger('adstart'); // Capture current player state snapshot - if (!player.ads.shouldPlayContentBehindAd(player)) { + if (player.ads.shouldTakeSnapshots()) { player.ads.snapshot = snapshot.getPlayerSnapshot(player); } @@ -64,7 +64,7 @@ function end(player, callback) { } // Restore snapshot - if (!player.ads.shouldPlayContentBehindAd(player)) { + if (player.ads.shouldTakeSnapshots()) { snapshot.restorePlayerSnapshot(player, callback); // Reset the volume to pre-ad levels diff --git a/src/ads.js b/src/ads.js index 41ba2be0..ba2ffbd2 100644 --- a/src/ads.js +++ b/src/ads.js @@ -86,11 +86,20 @@ export default function getAds(player) { player.ads._state.skipLinearAdMode(); }, + // With no arguments, returns a boolean value indicating whether or not + // contrib-ads is set to treat ads as stitched with content in a single + // stream. With arguments, treated as a setter, but this behavior is + // deprecated. stitchedAds(arg) { if (arg !== undefined) { - this._stitchedAds = !!arg; + videojs.log.warn('Using player.ads.stitchedAds() as a setter is deprecated, it should be set as an option upon initialization of contrib-ads.'); + + // Keep the private property and the settings in sync. When this + // setter is removed, we can probably stop using the private property. + this.settings.stitchedAds = !!arg; } - return this._stitchedAds; + + return this.settings.stitchedAds; }, // Returns whether the video element has been modified since the @@ -116,7 +125,7 @@ export default function getAds(player) { // Returns a boolean indicating if given player is in live mode. // One reason for this: https://github.com/videojs/video.js/issues/3262 // Also, some live content can have a duration. - isLive(somePlayer) { + isLive(somePlayer = player) { if (typeof somePlayer.ads.settings.contentIsLive === 'boolean') { return somePlayer.ads.settings.contentIsLive; } else if (somePlayer.duration() === Infinity) { @@ -130,7 +139,7 @@ export default function getAds(player) { // 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) { + shouldPlayContentBehindAd(somePlayer = player) { if (!somePlayer) { throw new Error('shouldPlayContentBehindAd requires a player as a param'); } else if (!somePlayer.ads.settings.liveCuePoints) { @@ -142,6 +151,12 @@ export default function getAds(player) { } }, + // Return true if the ads plugin should save and restore snapshots of the + // player state when moving into and out of ad mode. + shouldTakeSnapshots(somePlayer = player) { + return !this.shouldPlayContentBehindAd(somePlayer) && !this.stitchedAds(); + }, + // Returns true if player is in ad mode. // // Ad mode definition: diff --git a/src/plugin.js b/src/plugin.js index dd59ab3f..5c331cac 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -13,7 +13,7 @@ import cueTextTracks from './cueTextTracks.js'; import initCancelContentPlay from './cancelContentPlay.js'; import playMiddlewareFeature from './playMiddleware.js'; -import {BeforePreroll} from './states.js'; +import {BeforePreroll, StitchedContentPlayback} from './states.js'; const { playMiddleware, isMiddlewareMediatorSupported } = playMiddlewareFeature; const VIDEO_EVENTS = videojs.getTech('Html5').Events; @@ -177,10 +177,21 @@ const contribAdsPlugin = function(options) { player.ads = getAds(player); player.ads.settings = settings; - player.ads._state = new BeforePreroll(player); - player.ads._state.init(player); - player.ads.stitchedAds(settings.stitchedAds); + // Set the stitched ads state. This needs to happen before the `_state` is + // initialized below - BeforePreroll needs to know whether contrib-ads is + // playing stitched ads or not. + // The setter is deprecated, so this does not use it. + // But first, cast to boolean. + settings.stitchedAds = !!settings.stitchedAds; + + if (settings.stitchedAds) { + player.ads._state = new StitchedContentPlayback(player); + } else { + player.ads._state = new BeforePreroll(player); + } + + player.ads._state.init(player); player.ads.cueTextTracks = cueTextTracks; player.ads.adMacroReplacement = adMacroReplacement.bind(player); @@ -235,7 +246,7 @@ const contribAdsPlugin = function(options) { // Event handling for the current state. player.on([ 'play', 'playing', 'ended', - 'adsready', 'adscanceled', 'adskip', 'adserror', 'adtimeout', + 'adsready', 'adscanceled', 'adskip', 'adserror', 'adtimeout', 'adended', 'ads-ad-started', 'contentchanged', 'dispose', 'contentresumed', 'readyforpostroll', 'nopreroll', 'nopostroll'], (e) => { diff --git a/src/redispatch.js b/src/redispatch.js index d7f90a90..267a0948 100644 --- a/src/redispatch.js +++ b/src/redispatch.js @@ -86,7 +86,7 @@ const handleEnded = (player, event) => { } // Prefix ended due to content ending before postroll check - } else if (!player.ads._contentHasEnded) { + } else if (!player.ads._contentHasEnded && !player.ads.stitchedAds()) { // This will change to cancelEvent after the contentended deprecation // period (contrib-ads 7) diff --git a/src/states.js b/src/states.js index 90fbe8f3..b68deaa9 100644 --- a/src/states.js +++ b/src/states.js @@ -11,6 +11,8 @@ import Postroll from './states/Postroll.js'; import BeforePreroll from './states/BeforePreroll.js'; import ContentPlayback from './states/ContentPlayback.js'; import AdsDone from './states/AdsDone.js'; +import StitchedAdRoll from './states/StitchedAdRoll.js'; +import StitchedContentPlayback from './states/StitchedContentPlayback.js'; export { State, @@ -21,5 +23,7 @@ export { Postroll, BeforePreroll, ContentPlayback, - AdsDone + AdsDone, + StitchedAdRoll, + StitchedContentPlayback }; diff --git a/src/states/StitchedAdRoll.js b/src/states/StitchedAdRoll.js new file mode 100644 index 00000000..e2d1659a --- /dev/null +++ b/src/states/StitchedAdRoll.js @@ -0,0 +1,54 @@ +import {AdState, StitchedContentPlayback} from '../states.js'; +import adBreak from '../adBreak.js'; + +export default class StitchedAdRoll extends AdState { + + /* + * Allows state name to be logged even after minification. + */ + static _getName() { + return 'StitchedAdRoll'; + } + + /* + * StitchedAdRoll breaks happen when the ad plugin calls startLinearAdMode, + * which can happen at any time during content playback. + */ + init() { + this.waitingForAdBreak = false; + this.contentResuming = false; + this.player.ads.adType = 'stitched'; + adBreak.start(this.player); + } + + /* + * For stitched ads, there is no "content resuming" scenario, so a "playing" + * event is not relevant. + */ + onPlaying() {} + + /* + * For stitched ads, there is no "content resuming" scenario, so a + * "contentresumed" event is not relevant. + */ + onContentResumed() {} + + /* + * When we see an "adended" event, it means that we are in a postroll that + * has ended (because the media ended and we are still in an ad state). + * + * In these cases, we transition back to content mode and fire ended. + */ + onAdEnded() { + this.endLinearAdMode(); + this.player.trigger('ended'); + } + + /* + * StitchedAdRoll break is done. + */ + endLinearAdMode() { + adBreak.end(this.player); + this.transitionTo(StitchedContentPlayback); + } +} diff --git a/src/states/StitchedContentPlayback.js b/src/states/StitchedContentPlayback.js new file mode 100644 index 00000000..cea12f6b --- /dev/null +++ b/src/states/StitchedContentPlayback.js @@ -0,0 +1,40 @@ +import {ContentState, StitchedAdRoll} from '../states.js'; + +/* + * This state represents content playback when stitched ads are in play. + */ +export default class StitchedContentPlayback extends ContentState { + + /* + * Allows state name to be logged even after minification. + */ + static _getName() { + return 'StitchedContentPlayback'; + } + + /* + * For state transitions to work correctly, initialization should + * happen here, not in a constructor. + */ + init() { + + // Don't block calls to play in stitched ad players, ever. + this.player.ads._shouldBlockPlay = false; + } + + /* + * Source change does not do anything for stitched ad players. + * contentchanged does not fire during ad breaks, so we don't need to + * worry about that. + */ + onContentChanged() { + this.player.ads.debug(`Received contentchanged event (${this._getName()})`); + } + + /* + * This is how stitched ads start. + */ + startLinearAdMode() { + this.transitionTo(StitchedAdRoll); + } +} diff --git a/src/states/abstract/State.js b/src/states/abstract/State.js index dd1a20a9..16cd238d 100644 --- a/src/states/abstract/State.js +++ b/src/states/abstract/State.js @@ -46,6 +46,7 @@ export default class State { onPlay() {} onPlaying() {} onEnded() {} + onAdEnded() {} onAdsReady() { videojs.log.warn('Unexpected adsready event'); } @@ -138,6 +139,8 @@ export default class State { this.onNoPreroll(player); } else if (type === 'nopostroll') { this.onNoPostroll(player); + } else if (type === 'adended') { + this.onAdEnded(player); } } diff --git a/test/index.html b/test/index.html deleted file mode 100644 index bd330bab..00000000 --- a/test/index.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - videojs-contrib-ads Unit Tests - - - - - -
    -
    - - - - - - - - - - diff --git a/test/integration/lib/shared-module-hooks.js b/test/integration/lib/shared-module-hooks.js index a19d9908..ed997f12 100644 --- a/test/integration/lib/shared-module-hooks.js +++ b/test/integration/lib/shared-module-hooks.js @@ -57,7 +57,7 @@ window.sharedModuleHooks = (function(){ return videojs.createTimeRange(0, 0); }; - this.player.ads(); + this.player.ads(this.adsOptions); }, afterEach: function() { diff --git a/test/integration/test.cancelContentPlay.js b/test/integration/test.cancelContentPlay.js index 57661146..6697a15d 100644 --- a/test/integration/test.cancelContentPlay.js +++ b/test/integration/test.cancelContentPlay.js @@ -20,10 +20,14 @@ const restoreVideojs = function() { }; // Run custom hooks before sharedModuleHooks, as videojs must be -// modified before seting up the player and videojs-contrib-ads +// modified before setting up the player and videojs-contrib-ads QUnit.module('Cancel Content Play', { - beforeEach: _.flow(fakeVideojs, sharedHooks.beforeEach), - afterEach: _.flow(restoreVideojs, sharedHooks.afterEach) + beforeEach: _.flow(function() { + this.adsOptions = {}; + }, fakeVideojs, sharedHooks.beforeEach), + afterEach: _.flow(function() { + this.adsOptions = null; + }, restoreVideojs, sharedHooks.afterEach) }); QUnit.test('pauses to wait for prerolls when the plugin loads BEFORE play', function(assert) { @@ -42,7 +46,6 @@ QUnit.test('pauses to wait for prerolls when the plugin loads BEFORE play', func assert.strictEqual(spy.callCount, 2, 'play attempts are paused'); }); - QUnit.test('pauses to wait for prerolls when the plugin loads AFTER play', function(assert) { var pauseSpy = sinon.spy(this.player, 'pause'); @@ -133,4 +136,47 @@ QUnit.test('content is resumed after ads if a user initiated play event is cance this.player.trigger('play'); assert.ok(pauseSpy.callCount, 1, 'pause was not called again'); -}); \ No newline at end of file +}); + +// Set up contrib-ads options and run custom hooks before sharedModuleHooks, as +// videojs must be modified before setting up the player and videojs-contrib-ads +QUnit.module('Cancel Content Play (w/ Stitched Ads)', { + beforeEach: _.flow(function() { + this.adsOptions = { + stitchedAds: true + }; + }, fakeVideojs, sharedHooks.beforeEach), + afterEach: _.flow(function() { + this.adsOptions = null; + }, restoreVideojs, sharedHooks.afterEach) +}); + +QUnit.test('does not pause to wait for prerolls when the plugin loads BEFORE play', function(assert) { + var spy = sinon.spy(this.player, 'pause'); + + this.player.paused = function() { + return false; + }; + + this.player.trigger('adsready'); + this.player.trigger('play'); + this.clock.tick(1); + this.player.trigger('play'); + this.clock.tick(1); + + assert.strictEqual(spy.callCount, 0, 'play attempts are not paused'); +}); + +QUnit.test('does not pause to wait for prerolls when the plugin loads AFTER play', function(assert) { + var pauseSpy = sinon.spy(this.player, 'pause'); + + this.player.paused = function() { + return false; + }; + + this.player.trigger('play'); + this.clock.tick(1); + this.player.trigger('play'); + this.clock.tick(1); + assert.equal(pauseSpy.callCount, 0, 'play attempts are not paused'); +}); diff --git a/test/integration/test.stitchedAds.js b/test/integration/test.stitchedAds.js new file mode 100644 index 00000000..3989ccfe --- /dev/null +++ b/test/integration/test.stitchedAds.js @@ -0,0 +1,137 @@ +/* +TODO: +* timeupdate, adtimeupdate, contenttimeupdate +* loadstart, adloadstart, contentloadstart +* play, adplay, contentplay +* loadeddata, adloadeddata, contentloadeddata +* loadedmetadata, adloadedmetadata, contentloadedmetadata +*/ + +import videojs from 'video.js'; +import '../../examples/stitched-ad-plugin/plugin.js'; + +let originalTestTimeout = QUnit.config.testTimeout; + +QUnit.module('Events and Stitched Ads', { + + before() { + QUnit.config.testTimeout = 30000; + }, + + beforeEach() { + this.video = document.createElement('video'); + + this.fixture = document.querySelector('#qunit-fixture'); + this.fixture.appendChild(this.video); + + this.player = videojs(this.video); + this.player.exampleStitchedAds(); + + this.player.src({ + src: 'http://vjs.zencdn.net/v/oceans.webm', + type: 'video/webm' + }); + }, + + afterEach() { + this.player.dispose(); + }, + + after() { + QUnit.config.testTimeout = originalTestTimeout; + } +}); + +QUnit.test('Stitched Ads', function(assert) { + const done = assert.async(); + + const seenBeforePreroll = []; + const seenDuringPreroll = []; + const seenAfterPreroll = []; + const seenDuringMidroll = []; + const seenAfterMidroll = []; + let currentEventLog = seenBeforePreroll; + + let events = [ + 'suspend', + 'abort', + 'error', + 'emptied', + 'stalled', + 'canplay', + 'canplaythrough', + 'waiting', + 'seeking', + 'durationchange', + 'progress', + 'pause', + 'ratechange', + 'volumechange', + 'firstplay', + 'suspend', + 'playing', + 'ended' + ]; + + events = events + .concat(events.map(e => 'ad' + e)) + .concat(events.map(e => 'content' + e)); + + const {player} = this; + + player.on('adstart', () => { + if (currentEventLog === seenBeforePreroll) { + currentEventLog = seenDuringPreroll; + } else { + currentEventLog = seenDuringMidroll; + } + }); + + player.on('adend', () => { + if (currentEventLog === seenDuringPreroll) { + currentEventLog = seenAfterPreroll; + } else { + currentEventLog = seenAfterMidroll; + } + }); + + player.on(events, (e) => currentEventLog.push(e.type)); + + player.on(['error', 'aderror'], () => { + assert.ok(false, 'no errors'); + done(); + }); + + player.on('timeupdate', () => { + videojs.log(player.currentTime(), player.currentSrc()); + + if (player.currentTime() > 21) { + seenBeforePreroll.forEach(event => { + assert.ok(!/^ad/.test(event), event + ' has no ad prefix before preroll'); + assert.ok(!/^content/.test(event), event + ' has no content prefix before preroll'); + }); + + seenDuringPreroll.forEach(event => { + assert.ok(/^ad/.test(event), event + ' has ad prefix during preroll'); + }); + + seenAfterPreroll.forEach(event => { + assert.ok(!/^ad/.test(event), event + ' has no ad prefix after preroll'); + assert.ok(!/^content/.test(event), event + ' has no content prefix after preroll'); + }); + + seenDuringMidroll.forEach(event => { + assert.ok(/^ad/.test(event), event + ' has ad prefix during midroll'); + }); + + seenAfterMidroll.forEach(event => { + assert.ok(!/^ad/.test(event), event + ' has no ad prefix after midroll'); + assert.ok(!/^content/.test(event), event + ' has no content prefix after midroll'); + }); + + done(); + } + }); + + player.ready(player.play); +}); diff --git a/test/karma.conf.js b/test/karma.conf.js index d7624168..c8fd5eb3 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -38,15 +38,15 @@ module.exports = function(config) { 'node_modules/es5-shim/es5-shim.js', 'node_modules/sinon/pkg/sinon.js', 'node_modules/video.js/dist/video.js', - 'dist/videojs-contrib-ads.js', - 'dist/videojs-contrib-ads.css', - 'test/integration/lib/shared-module-hooks.js', - 'test/dist/bundle.js', + {pattern: 'dist/videojs-contrib-ads.js', nocache: true}, + {pattern: 'dist/videojs-contrib-ads.css', nocache: true}, + {pattern: 'test/integration/lib/shared-module-hooks.js', nocache: true}, + {pattern: 'test/dist/bundle.js', nocache: true}, // Test Data {pattern: 'test/integration/lib/inventory.json', included: false, served: true}, {pattern: 'examples/basic-ad-plugin/superclip-low.webm', included: false, served: true}, - {pattern: 'test/integration/lib/testcaption.vtt', included:false, served:true} + {pattern: 'test/integration/lib/testcaption.vtt', included: false, served: true} ], customLaunchers: { travisChrome: { diff --git a/test/unit/states/test.StitchedAdRoll.js b/test/unit/states/test.StitchedAdRoll.js new file mode 100644 index 00000000..00a17319 --- /dev/null +++ b/test/unit/states/test.StitchedAdRoll.js @@ -0,0 +1,52 @@ +import {StitchedAdRoll} from '../../../src/states.js'; +import adBreak from '../../../src/adBreak.js'; + +/* + * These tests are intended to be isolated unit tests for one state with all + * other modules mocked. + */ +QUnit.module('StitchedAdRoll', { + beforeEach: function() { + this.player = { + addClass: () => {}, + removeClass: () => {}, + trigger: sinon.spy(), + ads: { + _inLinearAdMode: true, + debug: () => {} + } + }; + + this.adroll = new StitchedAdRoll(this.player); + + this.adBreakStartStub = sinon.stub(adBreak, 'start'); + this.adBreakEndStub = sinon.stub(adBreak, 'end'); + }, + + afterEach() { + this.adBreakStartStub.restore(); + this.adBreakEndStub.restore(); + } +}); + +QUnit.test('starts an ad break on init', function(assert) { + this.adroll.init(); + assert.equal(this.player.ads.adType, 'stitched', 'ad type is stitched'); + assert.equal(this.adBreakStartStub.callCount, 1, 'ad break started'); +}); + +QUnit.test('ends an ad break on endLinearAdMode', function(assert) { + this.adroll.init(); + this.adroll.endLinearAdMode(); + assert.equal(this.adBreakEndStub.callCount, 1, 'ad break ended'); +}); + +QUnit.test('adended during ad break leaves linear ad mode and re-triggers ended', function(assert) { + sinon.spy(this.adroll, 'endLinearAdMode'); + + this.adroll.init(); + this.adroll.onAdEnded(); + assert.ok(this.player.trigger.calledOnce, 'the player fired one event'); + assert.ok(this.player.trigger.calledWith('ended'), 'the event it fired was ended'); + assert.ok(this.adroll.endLinearAdMode.calledOnce, 'the ad roll called endLinearAdMode'); +}); diff --git a/test/unit/states/test.StitchedContentPlayback.js b/test/unit/states/test.StitchedContentPlayback.js new file mode 100644 index 00000000..63ebb759 --- /dev/null +++ b/test/unit/states/test.StitchedContentPlayback.js @@ -0,0 +1,43 @@ +import {StitchedContentPlayback} from '../../../src/states.js'; + +/* + * These tests are intended to be isolated unit tests for one state with all + * other modules mocked. + */ +QUnit.module('StitchedContentPlayback', { + beforeEach: function() { + this.events = []; + this.playTriggered = false; + + this.player = { + paused: () => false, + play: () => {}, + trigger: (event) => { + this.events.push(event); + }, + ads: { + debug: () => {}, + _contentHasEnded: false, + _shouldBlockPlay: true + } + }; + + this.stitchedContentPlayback = new StitchedContentPlayback(this.player); + this.stitchedContentPlayback.transitionTo = (newState) => { + this.newState = newState.name; + }; + } +}); + +QUnit.test('transitions to StitchedAdRoll when startLinearAdMode is called', function(assert) { + this.stitchedContentPlayback.init(); + this.stitchedContentPlayback.startLinearAdMode(); + assert.equal(this.newState, 'StitchedAdRoll', 'transitioned to StitchedAdRoll'); +}); + +QUnit.test('sets _shouldBlockPlay to false on init', function(assert) { + assert.equal(this.player.ads._shouldBlockPlay, true); + + this.stitchedContentPlayback.init(); + assert.equal(this.player.ads._shouldBlockPlay, false); +}); diff --git a/test/unit/test.ads.js b/test/unit/test.ads.js index 119a9624..b3b2f8a4 100644 --- a/test/unit/test.ads.js +++ b/test/unit/test.ads.js @@ -1,3 +1,4 @@ +import videojs from 'video.js'; import getAds from '../../src/ads.js'; QUnit.module('Ads Object', { @@ -47,6 +48,23 @@ QUnit.module('Ads Object', { assert.equal(this.player.ads.isLive(this.player), false); }); + QUnit.test('stitchedAds', function(assert) { + assert.notOk(this.player.ads.stitchedAds()); + + this.player.ads.settings.stitchedAds = true; + + assert.ok(this.player.ads.stitchedAds()); + + sinon.spy(videojs.log, 'warn'); + this.player.ads.stitchedAds(false); + + assert.ok(videojs.log.warn.calledOnce, 'using as a setter is deprecated'); + assert.notOk(this.player.ads.stitchedAds()); + assert.notOk(this.player.ads.settings.stitchedAds); + + videojs.log.warn.restore(); + }); + QUnit.test('shouldPlayContentBehindAd', function(assert) { // liveCuePoints true + finite duration @@ -125,4 +143,20 @@ QUnit.module('Ads Object', { videojs.browser.IS_ANDROID = true; assert.equal(this.player.ads.shouldPlayContentBehindAd(this.player), false); }); + + QUnit.test('shouldTakeSnapshots', function(assert) { + this.player.ads.shouldPlayContentBehindAd = () => false; + this.player.ads.stitchedAds = () => false; + + assert.ok(this.player.ads.shouldTakeSnapshots()); + + this.player.ads.shouldPlayContentBehindAd = () => true; + + assert.notOk(this.player.ads.shouldTakeSnapshots()); + + this.player.ads.shouldPlayContentBehindAd = () => false; + this.player.ads.stitchedAds = () => true; + + assert.notOk(this.player.ads.shouldTakeSnapshots()); + }); }); diff --git a/test/unit/test.redispatch.js b/test/unit/test.redispatch.js index 6861b47d..edf59042 100644 --- a/test/unit/test.redispatch.js +++ b/test/unit/test.redispatch.js @@ -118,4 +118,4 @@ QUnit.test('play events in different states', function(assert) { this.player.ads._playRequested = true; assert.strictEqual(this.redispatch('play'), 'adplay', 'should be adplay when in an ad break'); -}); \ No newline at end of file +});