diff --git a/README.md b/README.md index 85971273..ea7fdecd 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,27 @@ -# videojs-contrib-ads [![Build Status](https://travis-ci.org/videojs/videojs-contrib-ads.svg)](https://travis-ci.org/videojs/videojs-contrib-ads) [![Greenkeeper badge](https://badges.greenkeeper.io/videojs/videojs-contrib-ads.svg)](https://greenkeeper.io/) +![Contrib Ads: A Tool for Building Video.js Ad Plugins](logo.png) -The `videojs-contrib-ads` plugin provides common functionality needed by video advertisement libraries working with [video.js.](http://www.videojs.com/) +[![Build Status](https://travis-ci.org/videojs/videojs-contrib-ads.svg)](https://travis-ci.org/videojs/videojs-contrib-ads) [![Greenkeeper badge](https://badges.greenkeeper.io/videojs/videojs-contrib-ads.svg)](https://greenkeeper.io/) + +`videojs-contrib-ads` provides common functionality needed by video advertisement libraries working with [video.js.](http://www.videojs.com/) It takes care of a number of concerns for you, reducing the code you have to write for your ad integration. +`videojs-contrib-ads` is not a stand-alone ad plugin. It is a library that is used by +other ad plugins (called "integrations") in order to fully support video.js. If you want +to build an ad plugin, you've come to the right place. If you want to play ads in video.js +without writing code, this is not the right project for you. + Lead Maintainer: Greg Smith [https://github.com/incompl](https://github.com/incompl) -Maintenance Status: Stabler Than Ever +Maintenance Status: Stable + +## Benefits + +* Ad timeouts are implemented by default. If ads take too long to load, content automatically plays. +* Player state is automatically restored after ad playback, even if the ad played back in the content's video element. +* Content is automatically paused and a loading spinner is shown while preroll ads load. +* [Media events](https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Media_events) will fire as though ads don't exist. For more information, read the section on [Redispatch](https://github.com/videojs/videojs-contrib-ads#redispatch). +* Useful macros in ad server URLs are provided. +* Preroll checks automatically happen again when the video source changes. ## Getting Started @@ -38,6 +54,14 @@ videojs('video', {}, function() { You may also use the Javascript and CSS links from the following to get started: [https://cdnjs.com/libraries/videojs-contrib-ads](https://cdnjs.com/libraries/videojs-contrib-ads) +### Using a module system + +If you are loading `videojs-contrib-ads` using modules, do this: + +https://github.com/videojs/videojs-contrib-ads/pull/312 + +TODO: Update that link once that PR is merged. + With this basic structure in place, you're ready to develop an ad integration. ## Important Note About Initialization @@ -51,42 +75,50 @@ The plugin will emit an error if it detects that it it missed a `loadstart` even ## Developing an Integration -Once you call `player.ads()` to initialize the plugin, it provides six interaction points (four events and two methods) which you can use in your integration. - -Here are the events that communicate information to your integration from the ads plugin: +First you call `player.ads()` to initialize the plugin. Afterwards, the flow of interaction +between your ad integration and contrib-ads looks like this: + +* Player triggers `play` (EVENT) -- This media event is triggered when there is a request to play your player. +videojs-contrib-ads responds by preventing content playback and showing a loading spinner. +* Integration triggers `adsready` (EVENT) -- Your integration should trigger this event on the player to indicate that +it is initialized. This can happen before or after the `play` event. +* Contrib Ads triggers `readyforpreroll` (EVENT) -- This event is fired after both `play` and `adsready` have ocurred. +This signals that the integration may begin an ad break by calling `startLinearAdMode`. +* Integration calls `player.ads.startLinearAdMode()` (METHOD) -- This begins an ad break. During this time, your integration +plays ads. videojs-contrib-ads does not handle actual ad playback. +* Integration triggers `ads-ad-started` (EVENT) - Trigger this when each individual ad begins. This removes the loading spinner, which otherwise stays up during the ad break. It's possible for an ad break +to end without an ad starting, in which case the spinner stays up the whole time. +* Integration calls `player.ads.endLinearAdMode()` (METHOD) -- This ends an ad break. As a result, content will play. +* Content plays. +* To play a Midroll ad, start and end an ad break with `player.ads.startLinearAdMode()` and `player.ads.endLinearAdMode()` at any time during content playback. +* Contrib Ads triggers `contentended` (EVENT) -- This event means that it's time to play a postroll ad. +* To play a Postroll ad, start and end an ad break with `player.ads.startLinearAdMode()` and `player.ads.endLinearAdMode()`. +* Contrib Ads triggers `ended` (EVENT) -- This standard media event happens when all ads and content have completed. After this, no additional ads are expected, even if the user seeks backwards. + +This is the basic flow for a simple use case, but there are other things the integration can do: + +* `skipLinearAdMode` (METHOD) -- At a time when `startLinearAdMode` is expected, calling `skipLinearAdMode` will immediately resume content playback instead. +* `nopreroll` (EVENT) -- You can trigger this event even before `readyforpreroll` to indicate that no preroll will play. The ad plugin will not check for prerolls and will instead begin content playback after the `play` event (or immediately, if playback was already requested). +* `nopostroll` (EVENT) -- Similar to `nopreroll`, you can trigger this event even before `contentended` to indicate that no postroll will play. The ad plugin will not wait for a postroll to play and will instead immediately trigger the `ended` event. +* `adserror` (EVENT) -- This event skips prerolls when seen before a preroll ad break. It skips postrolls if called after contentended and before a postroll ad break. It ends linear ad mode if seen during an ad break. +* `contentresumed` (EVENT) - If your integration does not result in a "playing" event when resuming content after an ad, send this event to signal that content can resume. This was added to support stitched ads and is not normally necessary. - * `contentupdate` (EVENT) — Fires when a new content video has been assigned to the player, so your integration can update its ad inventory. _NOTE: This will NOT fire while your ad integration is playing a linear Ad._ - * `readyforpreroll` (EVENT) — Fires when a content video is about to play for the first time, so your integration can indicate that it wants to play a preroll. +There are some other useful events that videojs-contrib-ads may trigger: -Note: A `contentplayback` event is sent but should not be used as it is being removed. The `playing` event has the same meaning and is far more reliable. - -And here are the interaction points you use to send information to the ads plugin: + * `contentchanged` (EVENT) -- Fires when a new content video has been loaded in the player (specifically, at the same time as the `loadstart` media event for the new source). This means the ad workflow has restarted from the beginning. Your integration will need to trigger `adsready` again, for example. Note that when changing sources, the playback state of the player is retained: if the previous source was playing, the new source will also be playing and the ad workflow will not wait for a new `play` event. -* `adsready` (EVENT) — Trigger this event after to signal that your integration is ready to play ads. -* `adplaying` (EVENT) - Trigger this event when an ads starts playing. If your integration triggers `playing` event when an ad begins, it will automatically be redispatched as `adplaying`. -* `adscanceled` (EVENT) — Trigger this event after starting up the player or setting a new video to skip ads entirely. This event is optional; if you always plan on displaying ads, you don't need to worry about triggering it. -* `adserror` (EVENT) - Trigger this event to indicate that an error in the ad integration has ocurred and any ad states should abort so that content can resume. -* `nopreroll` (EVENT) - Trigger this event to indicate that there will be no preroll ad. Otherwise, the player will wait until a timeout occurs before playing content. This event is optional, but can improve user experience. -* `nopostroll` (EVENT) - Trigger this event to indicate that there will be no postroll ad. Otherwise, contrib-ads will trigger an adtimeout event after content ends if there is no postroll. -* `ads-ad-started` (EVENT) - Trigger this when each individual ad begins. -* `contentresumed` (EVENT) - If your integration does not result in a "playing" event when resuming content after an ad, send this event to signal that content can resume. This was added to support stitched ads and is not normally necessary. -* `ads.startLinearAdMode()` (METHOD) — Call this method to signal that your integration is about to play a linear ad. This method triggers `adstart` to be emitted by the player. -* `ads.endLinearAdMode()` (METHOD) — Call this method to signal that your integration is finished playing linear ads, ready for content video to resume. This method triggers `adend` to be emitted by the player. -* `ads.skipLinearAdMode()` (METHOD) — Call this method to signal that your integration has received an ad response but is not going to play a linear ad. This method triggers `adskip` to be emitted by the player. -* `ads.stitchedAds()` (METHOD) — Get or set the `stitchedAds` setting. -* `ads.videoElementRecycled()` (METHOD) - Returns true if ad playback is taking place in the content element. +Deprecated events: -In addition, video.js provides a number of events and APIs that might be useful to you. -For example, the `ended` event signals that the content video has played to completion. +* `contentupdate` (EVENT) -- Replaced by `contentchanged`, which is more reliable. +* `adscanceled` (EVENT) -- Intended to cancel all ads, it was never fully implemented. Instead, use `nopreroll` and `nopostroll`. ### Public Methods -These are methods that can be called at runtime to inspect the ad plugin's state. You do -not need to implement them yourself. +These are methods on `player.ads` that can be called at runtime to inspect the ad plugin's state. You do not need to implement them yourself. #### isInAdMode() -Returns true if player is in ad mode. +Returns true if the player is in ad mode. ##### Ad mode definition: @@ -106,17 +138,19 @@ Returns true if player is in ad mode. * Content playback has not been requested * Content playback is paused * An asynchronous ad request is ongoing while content is playing -* A non-linear ad is active +* A non-linear ad (such as an overlay) is active #### isContentResuming() Returns true if content is resuming after an ad. This is part of ad mode. +#### inAdBreak() + +This method returns true during the time between startLinearAdMode and endLinearAdMode where an integration may play ads. This is part of ad mode. + #### isAdPlaying() -Returns true if a linear ad is playing. This is part of ad mode. -This relies on `startLinearAdMode` and `endLinearAdMode` because that is the -most authoritative way of determinining if an ad is playing. +Deprecated. Does the same thing as `inAdBreak` but has a misleading name. ### Additional Events And Properties Your Integration May Want To Include @@ -301,7 +335,8 @@ For a more involved example that plays both prerolls and midrolls, see the [exam ## State Diagram -To manage communication between your ad integration and the video.js player, the ads plugin goes through a number of states. +To manage communication between your ad integration and the video.js player, the ads plugin goes through a number of states. You don't need to be aware of this to build an integration, but it may be useful for videojs-contrib-ads developers or for debugging. + Here's a state diagram which shows the states of the ads plugin and how it transitions between them: ![](ad-states.png) @@ -323,62 +358,23 @@ The current set of options are described in detail below. Type: `number` Default Value: 5000 -The maximum amount of time to wait for an ad implementation to initialize before playback, in milliseconds. -If the viewer has requested playback and the ad implementation does not fire `adsready` before this timeout expires, the content video will begin playback. -It's still possible for an ad implementation to play ads after this waiting period has finished but video playback will already be in progress. - -Once the ad plugin starts waiting for the `adsready` event, one of these things will happen: - - * integration ready within the timeout — this is the best case, preroll(s) will play without the user seeing any content video first. - * integration ready, but after timeout has expired — preroll(s) still play, but the user will see a bit of content video. - * integration never becomes ready — content video starts playing after timeout. +The maximum amount of time to wait in ad mode before an ad begins. If this time elapses, ad mode ends and content resumes. -This timeout is necessary to ensure a good viewer experience in cases where the ad implementation suffers an unexpected or irreparable error and never fires an `adsready` event. -Without this timeout, the ads plugin would wait forever, and neither the content video nor ads would ever play. - -If the ad implementation takes a long time to initialize and this timeout is too short, then the content video will beging playing before the first preroll opportunity. -This has the jarring effect that the viewer would see a little content before the preroll cuts in. - -During development, we found that five seconds seemed to be long enough to accommodate slow initialization in most cases, but still short enough that failures to initialize didn't look like failures of the player or content video. +Some ad plugins may want to play a preroll ad even after the timeout has expired and content has begun playing. To facilitate this, videojs-contrib-ads will respond to an `adsready` event during content playback with a `readyforpreroll` event. If you want to avoid this behavior, make sure your plugin does not send `adsready` if `player.ads.isInAdMode()` is `false`. ### prerollTimeout Type: `number` -Default Value: 100 - -The maximum amount of time to wait for an ad implementation to initiate a preroll, in milliseconds. -If `readyforpreroll` has been fired and the ad implementation does not call `startLinearAdMode()` before `prerollTimeout` expires, the content video will begin playback. -`prerollTimeout` is cumulative with the standard timeout parameter. +No Default Value -Once the ad plugin fires `readyforpreroll`, one of these things will happen: - - * `startLinearAdMode()` called within the timeout — preroll(s) will play without the user seeing any content video first. - * `skipLinearAdMode()` is called within the timeout because there are no linear ads in the response or you already know you won't be making a preroll request - content video plays without preroll(s). - * `startLinearAdMode()` is never called — content video plays without preroll(s). - * `startLinearAdMode()` is called, but after the prerollTimeout expired — bad user experience; content video plays a bit, then preroll(s) cut in. - -The prerollTimeout should be as short as possible so that the viewer does not have to wait unnecessarily if no preroll is scheduled for a video. -Make this longer if your ad integration needs a long time to decide whether it has preroll inventory to play or not. -Ideally, your ad integration should already know if it wants to play a preroll before the `readyforpreroll` event. In this case, skipLinearAdMode() should be called to resume content quickly. +Override the `timeout` setting just for preroll ads (the time between `play` and `startLinearAdMode`) ### postrollTimeout Type: `number` -Default Value: 100 - -The maximum amount of time to wait for an ad implementation to initiate a postroll, in milliseconds. -If `contentended` has been fired and the ad implementation does not call `startLinearAdMode()` before `postrollTimeout` expires, the content video will end playback. +No Default Value -Once the ad plugin fires `contentended`, one of these things will happen: - - * `startLinearAdMode()` called within the timeout — postroll(s) will play without the user seeing any content video first. - * `skipLinearAdMode()` is called within the timeout - content video stops. - * `startLinearAdMode()` is never called — content video stops. - * `startLinearAdMode()` is called, but after the postrollTimeout expired — content video stops - -The postrollTimeout should be as short as possible so that the viewer does not have to wait unnecessarily if no postroll is scheduled for a video. -Make this longer if your ad integration needs a long time to decide whether it has postroll inventory to play or not. -Ideally, your ad integration should already know if it wants to play a postroll before the `contentended` event. +Override the `timeout` setting just for preroll ads (the time between `contentended` and `startLinearAdMode`) ### stitchedAds @@ -392,7 +388,7 @@ Set this to true if you are using ads stitched into the content video. This is n Type: `boolean` Default Value: false -If debug is set to true, the ads plugin will output additional information about its current state during playback. +If debug is set to true, the ads plugin will output additional debugging information. This can be handy for diagnosing issues or unexpected behavior in an ad integration. ## Plugin Events @@ -402,13 +398,13 @@ The plugin triggers a number of custom events on the player during its operation The player has entered linear ad playback mode. This event is fired directly as a consequence of calling `startLinearAdMode()`. This event only indicates that an ad break has begun; the start and end of individual ads must be signalled through some other mechanism. ### adend -The player has returned from linear ad playback mode. This event is fired directly as a consequence of calling `startLinearAdMode()`. Note that multiple ads may have played back between `adstart` and `adend`. +The player has returned from linear ad playback mode. This event is fired directly as a consequence of calling `endLinearAdMode()`. Note that multiple ads may have played back in the ad break between `adstart` and `adend`. ### adskip -The player is skipping a linear ad opportunity and content-playback should resume immediately. This event is fired directly as a consequence of calling `skipLinearAdMode()`. It can indicate that an ad response was made but returned no linear ad content or that no ad call is going to be made at either the preroll or postroll timeout opportunities. +The player is skipping a linear ad opportunity and content-playback should resume immediately. This event is fired directly as a consequence of calling `skipLinearAdMode()`. For example, it can indicate that an ad response was received but it included no linear ad content or that no ad call is going to be made due to an error. ### adtimeout -A timeout managed by the plugin has expired and regular video content has begun to play. Ad integrations have a fixed amount of time to inform the plugin of their intent during playback. If the ad integration is blocked by network conditions or an error, this event will fire and regular playback resumes rather than stalling the player indefinitely. +A timeout managed by videojs-contrib-ads has expired and regular video content has begun to play. Ad integrations have a fixed amount of time to start an ad break when an opportunity arises. For example, if the ad integration is blocked by network conditions or an error, this event will fire and regular playback will resume rather than the player stalling indefinitely. ## Runtime Settings Once the plugin is initialized, there are a couple properties you can @@ -435,16 +431,18 @@ player.src('movie-high.mp4'); ``` ### disableNextSnapshotRestore -Prevents videojs-contrib-ads from restoring the previous video source +Advanced option. Prevents videojs-contrib-ads from restoring the previous video source. -If you need to change the video source during ad playback, you can use _disableNextSnapshotRestore_ to prevent videojs-contrib-ads to restore to the previous video source. +If you need to change the video source during an ad break, you can use _disableNextSnapshotRestore_ to prevent videojs-contrib-ads from restoring the snapshot from the previous video source. ```js -if (player.ads.state === 'ad-playback') { +if (player.ads.inAdBreak()) { player.ads.disableNextSnapshotRestore = true; player.src('another-video.mp4'); } ``` +Keep in mind that you still need to end linear ad mode. + ### Redispatch This project includes a feature called `redispatch` which will monitor all [media @@ -499,6 +497,7 @@ that certain expectations are met. The next section describes those expectations * [Migrating to 3.0](migration-guides/migrating-to-3.0.md) * [Migrating to 4.0](migration-guides/migrating-to-4.0.md) * [Migrating to 5.0](migration-guides/migrating-to-5.0.md) +* [Migrating to 6.0](migration-guides/migrating-to-6.0.md) ## Testing diff --git a/ad-states.graffle b/ad-states.graffle deleted file mode 100644 index a63786ff..00000000 Binary files a/ad-states.graffle and /dev/null differ diff --git a/ad-states.png b/ad-states.png index fa937555..6eced6cc 100644 Binary files a/ad-states.png and b/ad-states.png differ diff --git a/example/app.js b/example/app.js index e9850d5f..02dc8d6b 100644 --- a/example/app.js +++ b/example/app.js @@ -68,11 +68,13 @@ li.className = 'content-event'; } - str = '[' + (d) + '] ' + padRight(19, '[' + (event.state ? event.state : player.ads.state + '*') + ']', ' ') + ' ' + evt; + str = evt; if (evt === 'contentupdate') { - str += "\toldValue: " + event.oldValue + "\n" + - "\tnewValue: " + event.newValue + "\n"; + str += ' ' + event.oldValue + " -> " + event.newValue; + li.className = 'content-adplugin-event'; + } + if (evt === 'contentchanged') { li.className = 'content-adplugin-event'; } if (evt === 'contentplayback') { diff --git a/example/example-integration.js b/example/example-integration.js index 60b31f1b..090b3c31 100644 --- a/example/example-integration.js +++ b/example/example-integration.js @@ -92,15 +92,16 @@ // initialize the ads plugin, passing in any relevant options player.ads(options); - // request ad inventory whenever the player gets new content to play - player.on('contentupdate', requestAds); - // if there's already content loaded, request an add immediately - if (player.currentSrc()) { + // request ads right away + requestAds(); + + // request ad inventory whenever the player gets content to play + player.on('contentchanged', () => { requestAds(); - } + }); player.on('contentended', function() { - if (!state.postrollPlayed && player.ads.state === 'postroll?' && playPostroll) { + if (!state.postrollPlayed && playPostroll) { state.postrollPlayed = true; playAd(); } diff --git a/logo.png b/logo.png new file mode 100644 index 00000000..e811beed Binary files /dev/null and b/logo.png differ diff --git a/migration-guides/migrating-to-6.0.md b/migration-guides/migrating-to-6.0.md new file mode 100644 index 00000000..b5aa82ec --- /dev/null +++ b/migration-guides/migrating-to-6.0.md @@ -0,0 +1,53 @@ +# Migrating to videojs-contrib-ads 6.0.0 + +Version 6 of videojs-contrib-ads includes a major refactor and cleanup of the state management logic. + +## Migration + +* Timeouts have a more intuitive behavior. See the next section for more information. +* Ended events are no longer delayed by 1 second. +* Ended events due to an ad ending will no longer be allowed to replace the ended event +that is triggered by linear ad mode ending. Integrations must not emit ended events +after the end of linear ad mode. +* There will no longer be a `contentended` event when content ends after the first time content ends. +* `ads.state` has been removed. Methods have been added to replace state checks, such as `ads.isInAdMode()`. See the README for a list of available methods. `ads._state` has been +added, but it is not compatible with the old `ads.state` and should not be inspected +by integrations. +* The event parameter `triggerevent` has been removed. It is unlikely that integrations used it, but any usage must be migrated. +* We no longer trigger a `readyforpreroll` event after receiving a `nopreroll` event. +* adTimeoutTimeout has been removed. It was not part of the documented interface, but make note if your integration inspected it. +* There is no longer a snapshot object while checking for postrolls. Now a snapshot is only taken when a postroll ad break actually begins. +* The `contentplayback` event (removed in [4.0.0](https://github.com/videojs/videojs-contrib-ads/blob/cc664517aa0d07398decc0aa5d41974330efc4e4/CHANGELOG.md#400), re-added as deprecated in [4.1.1](https://github.com/videojs/videojs-contrib-ads/blob/cc664517aa0d07398decc0aa5d41974330efc4e4/CHANGELOG.md#411)), has been removed. Use the `playing` event instead. + +## Deprecation + +Deprecated interfaces will be removed in a future major version update. + +* `contentupdate` is now deprecated. It has been replaced by `contentchanged`. `contentupdate` was never intended to fire for the initial source, but over time its behavior eroded. To make migration easier for anyone who depends on the current behavior, we're providing a deprecation period and a new event with correct behavior. +* `adscanceled` is now deprecated. Instead, use `nopreroll` and `nopostroll`. `adscanceled` was initially intended to function similarly to calling both `nopreroll` and `nopostroll` but it was never fully implemented. + +## Timeout behavior changes + +Previous behavior: + +* The `timeout` setting was the number of milliseconds that we waited for `adsready` after the `play` event if `adsready` was not before `play`. +* The `prerollTimeout` setting was the number of milliseconds we waited for `startLinearAdMode` after `readyforpreroll`. It was a separate timeout period after `timeout`. +* The `postrollTimeout` setting was the number of milliseconds we waited for `startLinearAdMode` after `contentended`. + +Previous Defaults: + +* timeout: 5000 +* prerollTimeout: 100 +* postrollTimeout: 100 + +New Behavior: + +* The `timeout` setting is now the default setting for all timeouts. It can be overridden by `prerollTimeout` and/or `postrollTimeout`. +* `prerollTimeout` overrides `timeout` for the number of milliseconds we wait for a preroll ad (the time between `play` and `startLinearAdMode`). +* `postrollTimeout` overrides `timeout` for the number of milliseconds we wait for a postroll ad (the time between `contentended` and `startLinearAdMode`). + +New Defaults: + +* timeout: 5000 +* prerollTimeout: no default +* postrollTimeout: no default diff --git a/package.json b/package.json index 66f9c7c5..d9a2bcd3 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "node-sass": "^4.5.3", "node-static": "^0.7.9", "npm-run-all": "^4.0.2", - "qunitjs": "^1.23.1", + "qunitjs": "^2.1.1", "rimraf": "^2.6.1", "rollup": "^0.50", "rollup-plugin-babel": "^2.7.1", @@ -96,7 +96,8 @@ "eslintConfig": { "extends": "videojs", "rules": { - "require-jsdoc": "off" + "require-jsdoc": "off", + "consistent-this": "off" } }, "files": [ diff --git a/scripts/umd.rollup.config.js b/scripts/umd.rollup.config.js index e6db9ff8..8fb344c0 100644 --- a/scripts/umd.rollup.config.js +++ b/scripts/umd.rollup.config.js @@ -40,7 +40,7 @@ export default { }] ], plugins: [ - // 'external-helpers', + 'external-helpers', 'transform-object-assign' ] }) diff --git a/src/adBreak.js b/src/adBreak.js new file mode 100644 index 00000000..32664e46 --- /dev/null +++ b/src/adBreak.js @@ -0,0 +1,76 @@ +/* + * Encapsulates logic for starting and ending ad breaks. An ad break + * is the time between startLinearAdMode and endLinearAdMode. The ad + * integration may play 0 or more ads during this time. + */ + +import * as snapshot from './snapshot.js'; + +function start(player) { + + player.ads.debug('Starting ad break'); + + player.ads._inLinearAdMode = true; + + // No longer does anything, used to move us to ad-playback + player.trigger('adstart'); + + // Capture current player state snapshot + if (!player.ads.shouldPlayContentBehindAd(player)) { + player.ads.snapshot = snapshot.getPlayerSnapshot(player); + } + + // Mute the player behind the ad + if (player.ads.shouldPlayContentBehindAd(player)) { + player.ads.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'); + } + + // This removes the native poster so the ads don't show the content + // poster if content element is reused for ad playback. The snapshot + // will restore it afterwards. + player.ads.removeNativePoster(); +} + +function end(player) { + player.ads.debug('Ending ad break'); + + player.ads.adType = null; + + player.ads._inLinearAdMode = false; + + // Signals the end of the ad break to anyone listening. + player.trigger('adend'); + + 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, player.ads.snapshot); + } + + // Reset the volume to pre-ad levels + if (player.ads.shouldPlayContentBehindAd(player)) { + player.volume(player.ads.preAdVolume_); + } + +} + +const obj = {start, end}; + +export default obj; diff --git a/src/cancelContentPlay.js b/src/cancelContentPlay.js index 1e47a2a3..8ad8fad4 100644 --- a/src/cancelContentPlay.js +++ b/src/cancelContentPlay.js @@ -5,8 +5,6 @@ It does this by pausing the player immediately after a "play" where ads will be then signalling that we should play after the ad is done. */ -import window from 'global/window'; - export default function cancelContentPlay(player) { if (player.ads.cancelPlayTimeout) { // another cancellation is already in flight, so do nothing @@ -16,10 +14,14 @@ export default function cancelContentPlay(player) { // The timeout 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. - player.ads.cancelPlayTimeout = window.setTimeout(function() { + player.ads.cancelPlayTimeout = player.setTimeout(function() { // deregister the cancel timeout so subsequent cancels are scheduled player.ads.cancelPlayTimeout = null; + if (!player.ads.isInAdMode()) { + return; + } + // pause playback so ads can be handled. if (!player.paused()) { player.pause(); diff --git a/src/contentupdate.js b/src/contentupdate.js index f0314e4b..16400002 100644 --- a/src/contentupdate.js +++ b/src/contentupdate.js @@ -2,8 +2,6 @@ This feature sends a `contentupdate` event when the player source changes. */ -import window from 'global/window'; - // Start sending contentupdate events export default function initializeContentupdate(player) { @@ -13,12 +11,21 @@ export default function initializeContentupdate(player) { // modifying the player's source player.ads.contentSrc = player.currentSrc(); + player.ads._seenInitialLoadstart = false; + // Check if a new src has been set, if so, trigger contentupdate const checkSrc = function() { - if (player.ads.state !== 'ad-playback') { + if (!player.ads.inAdBreak()) { const src = player.currentSrc(); if (src !== player.ads.contentSrc) { + + if (player.ads._seenInitialLoadstart) { + player.trigger({ + type: 'contentchanged' + }); + } + player.trigger({ type: 'contentupdate', oldValue: player.ads.contentSrc, @@ -26,11 +33,11 @@ export default function initializeContentupdate(player) { }); player.ads.contentSrc = src; } + + player.ads._seenInitialLoadstart = true; } }; // 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); } diff --git a/src/plugin.js b/src/plugin.js index 9d947813..3a8c9b70 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -3,34 +3,16 @@ This main plugin file is responsible for integration logic and enabling the feat that live in in separate files. */ -import window from 'global/window'; - import videojs from 'video.js'; import redispatch from './redispatch.js'; -import * as snapshot from './snapshot.js'; import initializeContentupdate from './contentupdate.js'; -import cancelContentPlay from './cancelContentPlay.js'; import adMacroReplacement from './macros.js'; import cueTextTracks from './cueTextTracks.js'; -const VIDEO_EVENTS = videojs.getTech('Html5').Events; +import {BeforePreroll} from './states.js'; -/* - * 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'); - } -}; +const VIDEO_EVENTS = videojs.getTech('Html5').Events; // --------------------------------------------------------------------------- // Ad Framework @@ -47,11 +29,11 @@ const defaults = { // 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, + prerollTimeout: undefined, // maximum amount of time in ms to wait for the ad implementation to start // linear ad mode after `contentended` has fired. - postrollTimeout: 100, + postrollTimeout: undefined, // 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 @@ -84,7 +66,7 @@ const contribAdsPlugin = function(options) { // If we haven't seen a loadstart after 5 seconds, the plugin was not initialized // correctly. - window.setTimeout(() => { + player.setTimeout(() => { if (!player.ads._hasThereBeenALoadStartDuringPlayerLife && player.src() !== '') { videojs.log.error('videojs-contrib-ads has not seen a loadstart event 5 seconds ' + 'after being initialized, but a source is present. This indicates that ' + @@ -115,7 +97,7 @@ const contribAdsPlugin = function(options) { // If an ad isn't playing, don't try to play an ad. This could result from prefixed // events when the player is blocked by a preroll check, but there is no preroll. - if (!player.ads.isAdPlaying()) { + if (!player.ads.inAdBreak()) { return; } @@ -123,22 +105,19 @@ const contribAdsPlugin = function(options) { }); player.on('nopreroll', function() { + player.ads.debug('Received nopreroll event'); player.ads.nopreroll_ = true; }); player.on('nopostroll', function() { + player.ads.debug('Received nopostroll event'); 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'); - }); - // Restart the cancelContentPlay process. player.on('playing', () => { player.ads._cancelledPlay = false; + player.ads._pausedOnContentupdate = false; }); player.one('loadstart', () => { @@ -155,7 +134,7 @@ const contribAdsPlugin = function(options) { // Replace the plugin constructor with the ad namespace player.ads = { - state: 'content-set', + settings, disableNextSnapshotRestore: false, // This is true if we have finished actual content playback but haven't @@ -188,7 +167,6 @@ const contribAdsPlugin = function(options) { VERSION: '__VERSION__', - // TODO reset state to content-set here instead of in every contentupdate case reset() { player.ads.disableNextSnapshotRestore = false; player.ads._contentEnding = false; @@ -198,39 +176,27 @@ const contribAdsPlugin = function(options) { player.ads._hasThereBeenALoadedData = false; player.ads._hasThereBeenALoadedMetaData = false; player.ads._cancelledPlay = false; + player.ads.nopreroll_ = false; + player.ads.nopostroll_ = false; }, // 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.ads._inLinearAdMode = true; - player.trigger('adstart'); - } + player.ads._state.startLinearAdMode(); }, // Call this when a linear ad pod has finished playing. endLinearAdMode() { - if (player.ads.state === 'ad-playback') { - player.ads._inLinearAdMode = false; - 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'); - } + player.ads._state.endLinearAdMode(); }, // 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 + // This has no effect if we are already in an ad break. Always // use endLinearAdMode() to exit from linear ad-playback state. skipLinearAdMode() { - if (player.ads.state !== 'ad-playback') { - player.trigger('adskip'); - } + player.ads._state.skipLinearAdMode(); }, stitchedAds(arg) { @@ -301,435 +267,65 @@ const contribAdsPlugin = function(options) { // * An asynchronous ad request is ongoing while content is playing // * A non-linear ad is active isInAdMode() { - - // Saw "play" but not "adsready" - return player.ads.state === 'ads-ready?' || - - // Waiting to learn about preroll - player.ads.state === 'preroll?' || - - // A linear ad is active - player.ads.state === 'ad-playback' || - - // Content is not playing again yet - player.ads.state === 'content-resuming'; + return this._state.isAdState(); }, // Returns true if content is resuming after an ad. This is part of ad mode. isContentResuming() { - return player.ads.state === 'content-resuming'; + return this._state.isContentResuming(); }, - // Returns true if a linear ad is playing. This is part of ad mode. - // This relies on startLinearAdMode and endLinearAdMode because that is the - // most authoritative way of determinining if an ad is playing. + // Deprecated because the name was misleading. Use inAdBreak instead. isAdPlaying() { - return this._inLinearAdMode; - } - - }; - - player.ads.stitchedAds(settings.stitchedAds); - - player.ads.cueTextTracks = cueTextTracks; - 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 - if (player.ads._hasThereBeenALoadStartDuringPlayerLife) { - player.trigger('readyforpreroll'); - - // Don't play preroll before loadstart, otherwise the content loadstart event - // will get misconstrued as an ad loadstart. This is only a concern for the - // initial source; for source changes the whole ad process is kicked off by - // loadstart so it has to have happened already. - } else { - player.one('loadstart', () => { - player.trigger('readyforpreroll'); - }); - } - } - }, - leave() { - window.clearTimeout(player.ads.adTimeoutTimeout); - }, - events: { - play() { - cancelContentPlay(player); - }, - adstart() { - this.state = 'ad-playback'; - player.ads.adType = 'preroll'; - }, - 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'; - } - } + return this._state.inAdBreak(); }, - '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'; - player.ads.adType = null; - }, - adserror() { - player.ads.endLinearAdMode(); - } - } - }, - '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'; - }, - - // This is for stitched ads only. - contentresumed() { - this.state = 'content-playback'; - }, - playing() { - this.state = 'content-playback'; - }, - ended() { - this.state = 'content-playback'; - } - } + // Returns true if an ad break is ongoing. This is part of ad mode. + // An ad break is the time between startLinearAdMode and endLinearAdMode. + inAdBreak() { + return this._state.inAdBreak(); }, - 'postroll?': { - enter() { - player.ads._contentEnding = true; - - 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'; - player.ads.adType = 'postroll'; - }, - 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?'; - } + /* + * 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() { + const tech = player.$('.vjs-tech'); + + if (tech) { + tech.removeAttribute('poster'); } }, - '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 cancelContentPlay happened and we haven't played yet. - // This happens if there was no preroll or if it errored, timed out, etc. - // Otherwise snapshot restore would play. - if (player.ads._cancelledPlay) { - 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'; - // This is a special case in which preroll is specifically set - if (player.ads.adType !== 'preroll') { - player.ads.adType = 'midroll'; - } - }, - contentupdate() { - 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._contentEnding = false; - this._contentHasEnded = true; - this.state = 'postroll?'; + debug(...args) { + if (this.settings.debug) { + if (args.length === 1 && typeof args[0] === 'string') { + videojs.log('ADS: ' + args[0]); + } else { + videojs.log('ADS:', ...args); } } } - }; - const processEvent = function(event) { - - const 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; + player.ads._state = new BeforePreroll(player); - // Execute "leave" method for the previous state - if (states[previousState].leave) { - states[previousState].leave.apply(player.ads); - } + player.ads.stitchedAds(settings.stitchedAds); - // Execute "enter" method for the new state - if (states[newState].enter) { - states[newState].enter.apply(player.ads); - } + player.ads.cueTextTracks = cueTextTracks; + player.ads.adMacroReplacement = adMacroReplacement.bind(player); - // Debug log message for state changes - if (settings.debug) { - videojs.log('ads', player.ads.triggerevent + ' triggered: ' + - previousState + ' -> ' + newState); - } - } + // Start sending contentupdate and contentchanged events for this player + initializeContentupdate(player); - }; + // Global contentchanged handler for resetting plugin state + player.on('contentchanged', player.ads.reset); // A utility method for textTrackChangeHandler to define the conditions // when text tracks should be disabled. @@ -740,7 +336,7 @@ const contribAdsPlugin = function(options) { // and this occurs during ad playback, we should disable tracks again. // If shouldPlayContentBehindAd, no special handling is needed. return !player.ads.shouldPlayContentBehindAd(player) && - player.ads.isAdPlaying() && + player.ads.inAdBreak() && player.tech_.featuresNativeTextTracks && videojs.browser.IS_IOS && // older versions of video.js did not use an emulated textTrackList @@ -772,56 +368,21 @@ const contribAdsPlugin = function(options) { player.textTracks().addEventListener('change', textTrackChangeHandler); }); - // 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); + // Event handling for the current state. + player.on([ + 'play', 'playing', 'ended', + 'adsready', 'adscanceled', 'adskip', 'adserror', 'adtimeout', + 'ads-ad-started', + 'contentchanged', 'contentresumed', 'contentended', + 'nopreroll', 'nopostroll'], (e) => { + player.ads._state.handleEvent(e.type); + }); // Clear timeouts and handlers when player is disposed player.on('dispose', function() { - if (player.ads.adTimeoutTimeout) { - window.clearTimeout(player.ads.adTimeoutTimeout); - } - - if (player.ads._fireEndedTimeout) { - window.clearTimeout(player.ads._fireEndedTimeout); - } - - if (player.ads.cancelPlayTimeout) { - window.clearTimeout(player.ads.cancelPlayTimeout); - } - - if (player.ads.tryToResumeTimeout_) { - player.clearTimeout(player.ads.tryToResumeTimeout_); - } - player.textTracks().removeEventListener('change', textTrackChangeHandler); }); - // If we're autoplaying, the state machine will immidiately process - // a synthetic play event - if (!player.paused()) { - processEvent({type: 'play'}); - } - }; const registerPlugin = videojs.registerPlugin || videojs.plugin; diff --git a/src/redispatch.js b/src/redispatch.js index b388fd56..d14fc6b8 100644 --- a/src/redispatch.js +++ b/src/redispatch.js @@ -30,7 +30,6 @@ const prefixEvent = (player, prefix, event) => { cancelEvent(player, event); player.trigger({ type: prefix + event.type, - state: player.ads.state, originalEvent: event }); }; @@ -70,7 +69,7 @@ const handlePlaying = (player, event) => { const handleEnded = (player, event) => { if (player.ads.isInAdMode()) { - // The true ended event fired by plugin.js either after the postroll + // The true ended event fired either after the postroll // or because there was no postroll. if (player.ads.isContentResuming()) { return; @@ -79,9 +78,8 @@ const handleEnded = (player, event) => { // Prefix ended due to ad ending. prefixEvent(player, 'ad', event); - } else { - - // Prefix ended due to content ending. + // Prefix ended due to content ending before preroll check + } else if (!player.ads._contentHasEnded) { prefixEvent(player, 'content', event); } }; @@ -101,7 +99,7 @@ const handleLoadEvent = (player, event) => { return; // Ad playing - } else if (player.ads.isAdPlaying()) { + } else if (player.ads.inAdBreak()) { prefixEvent(player, 'ad', event); // Source change @@ -130,7 +128,7 @@ const handleLoadEvent = (player, event) => { const handlePlay = (player, event) => { const resumingAfterNoPreroll = player.ads._cancelledPlay && !player.ads.isInAdMode(); - if (player.ads.isAdPlaying()) { + if (player.ads.inAdBreak()) { prefixEvent(player, 'ad', event); } else if (player.ads.isContentResuming() || resumingAfterNoPreroll) { prefixEvent(player, 'content', event); diff --git a/src/snapshot.js b/src/snapshot.js index fc4b2363..c2d3db3a 100644 --- a/src/snapshot.js +++ b/src/snapshot.js @@ -3,8 +3,6 @@ The snapshot feature is responsible for saving the player state before an ad, th restoring the player state after an ad. */ -import window from 'global/window'; - import videojs from 'video.js'; /* @@ -157,7 +155,7 @@ export function restorePlayerSnapshot(player, snapshotObject) { // delay a bit and then check again unless we're out of attempts if (attempts--) { - window.setTimeout(tryToResume, 50); + player.setTimeout(tryToResume, 50); } else { try { resume(); diff --git a/src/states.js b/src/states.js new file mode 100644 index 00000000..90fbe8f3 --- /dev/null +++ b/src/states.js @@ -0,0 +1,25 @@ +/* + * This file is necessary to avoid this rollup issue: + * https://github.com/rollup/rollup/issues/1089 + */ +import State from './states/abstract/State.js'; +import AdState from './states/abstract/AdState.js'; +import ContentState from './states/abstract/ContentState.js'; +import Preroll from './states/Preroll.js'; +import Midroll from './states/Midroll.js'; +import Postroll from './states/Postroll.js'; +import BeforePreroll from './states/BeforePreroll.js'; +import ContentPlayback from './states/ContentPlayback.js'; +import AdsDone from './states/AdsDone.js'; + +export { + State, + AdState, + ContentState, + Preroll, + Midroll, + Postroll, + BeforePreroll, + ContentPlayback, + AdsDone +}; diff --git a/src/states/AdsDone.js b/src/states/AdsDone.js new file mode 100644 index 00000000..3be52045 --- /dev/null +++ b/src/states/AdsDone.js @@ -0,0 +1,19 @@ +import videojs from 'video.js'; + +import {ContentState} from '../states.js'; + +export default class AdsDone extends ContentState { + + init(player) { + // From now on, `ended` events won't be redispatched + player.ads._contentHasEnded = true; + } + + /* + * Midrolls do not play after ads are done. + */ + startLinearAdMode() { + videojs.log.warn('Unexpected startLinearAdMode invocation (AdsDone)'); + } + +} diff --git a/src/states/BeforePreroll.js b/src/states/BeforePreroll.js new file mode 100644 index 00000000..1db964e9 --- /dev/null +++ b/src/states/BeforePreroll.js @@ -0,0 +1,79 @@ +import {ContentState, Preroll, ContentPlayback} from '../states.js'; +import cancelContentPlay from '../cancelContentPlay.js'; + +/* + * This is the initial state for a player with an ad plugin. Normally, it remains in this + * state until a "play" event is seen. After that, we enter the Preroll state to check for + * prerolls. This happens regardless of whether or not any prerolls ultimately will play. + * Errors and other conditions may lead us directly from here to ContentPlayback. + */ +export default class BeforePreroll extends ContentState { + + init(player) { + this.adsReady = false; + } + + /* + * The integration may trigger adsready before the play request. If so, + * we record that adsready already happened so the Preroll state will know. + */ + onAdsReady(player) { + player.ads.debug('Received adsready event (BeforePreroll)'); + this.adsReady = true; + } + + /* + * Ad mode officially begins on the play request, because at this point + * content playback is blocked by the ad plugin. + */ + onPlay(player) { + player.ads.debug('Received play event (BeforePreroll)'); + + // Don't start content playback yet + cancelContentPlay(player); + + // Check for prerolls + this.transitionTo(Preroll, this.adsReady); + } + + /* + * All ads for the entire video are canceled. + */ + onAdsCanceled(player) { + player.ads.debug('adscanceled (BeforePreroll)'); + + this.transitionTo(ContentPlayback); + } + + /* + * An ad error occured. Play content instead. + */ + onAdsError() { + this.transitionTo(ContentPlayback); + } + + /* + * If there is no preroll, don't wait for a play event to move forward. + */ + onNoPreroll() { + this.player.ads.debug('Skipping prerolls due to nopreroll event (BeforePreroll)'); + this.transitionTo(ContentPlayback); + } + + /* + * Prerolls skipped by integration. Play content instead. + */ + skipLinearAdMode() { + const player = this.player; + + player.trigger('adskip'); + this.transitionTo(ContentPlayback); + } + + /* + * Content source change before preroll is currently not handled. When + * developed, this is where to start. + */ + onContentChanged() {} + +} diff --git a/src/states/ContentPlayback.js b/src/states/ContentPlayback.js new file mode 100644 index 00000000..aa64ec99 --- /dev/null +++ b/src/states/ContentPlayback.js @@ -0,0 +1,49 @@ +import {ContentState, Midroll, Postroll} from '../states.js'; + +/* + * This state represents content playback the first time through before + * content ends. After content has ended once, we check for postrolls and + * move on to the AdsDone state rather than returning here. + */ +export default class ContentPlayback extends ContentState { + + init(player) { + // Play the content if cancelContentPlay happened or we paused on 'contentupdate' + // and we haven't played yet. This happens if there was no preroll or if it + // errored, timed out, etc. Otherwise snapshot restore would play. + if (player.paused() && + (player.ads._cancelledPlay || player.ads._pausedOnContentupdate)) { + player.play(); + } + } + + /* + * 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 behave otherwise by ignoring this event. + */ + onAdsReady(player) { + player.ads.debug('Received adsready event (ContentPlayback)'); + + if (!player.ads.nopreroll_) { + player.ads.debug('Triggered readyforpreroll event (ContentPlayback)'); + player.trigger('readyforpreroll'); + } + } + + /* + * Content ended before postroll checks. + */ + onContentEnded(player) { + player.ads.debug('Received contentended event'); + this.transitionTo(Postroll); + } + + /* + * This is how midrolls start. + */ + startLinearAdMode() { + this.transitionTo(Midroll); + } + +} diff --git a/src/states/Midroll.js b/src/states/Midroll.js new file mode 100644 index 00000000..b033bc0b --- /dev/null +++ b/src/states/Midroll.js @@ -0,0 +1,39 @@ +import {AdState} from '../states.js'; +import adBreak from '../adBreak.js'; + +export default class Midroll extends AdState { + + /* + * Midroll breaks happen when the integration calls startLinearAdMode, + * which can happen at any time during content playback. + */ + init(player) { + player.ads.adType = 'midroll'; + adBreak.start(player); + } + + /* + * Midroll break is done. + */ + endLinearAdMode() { + const player = this.player; + + if (this.inAdBreak()) { + this.contentResuming = true; + adBreak.end(player); + } + } + + /* + * End midroll break if there is an error. + */ + onAdsError(player) { + // In the future, we may not want to do this automatically. + // Integrations should be able to choose to continue the ad break + // if there was an error. + if (this.inAdBreak()) { + player.ads.endLinearAdMode(); + } + } + +} diff --git a/src/states/Postroll.js b/src/states/Postroll.js new file mode 100644 index 00000000..495047b3 --- /dev/null +++ b/src/states/Postroll.js @@ -0,0 +1,145 @@ +import videojs from 'video.js'; + +import {AdState, BeforePreroll, Preroll, AdsDone} from '../states.js'; +import adBreak from '../adBreak.js'; + +export default class Postroll extends AdState { + + init(player) { + // Legacy name that now simply means "handling postrolls". + player.ads._contentEnding = true; + + // Start postroll process. + if (!player.ads.nopostroll_) { + player.addClass('vjs-ad-loading'); + + // Determine postroll timeout based on plugin settings + let timeout = player.ads.settings.timeout; + + if (typeof player.ads.settings.postrollTimeout === 'number') { + timeout = player.ads.settings.postrollTimeout; + } + + this._postrollTimeout = player.setTimeout(function() { + player.trigger('adtimeout'); + }, timeout); + + // No postroll, ads are done + } else { + player.setTimeout(() => { + player.ads.debug('Triggered ended event (no postroll)'); + this.contentResuming = true; + player.trigger('ended'); + }, 1); + } + } + + /* + * Start the postroll if it's not too late. + */ + startLinearAdMode() { + const player = this.player; + + if (!player.ads.inAdBreak() && !this.isContentResuming()) { + player.ads.adType = 'postroll'; + player.clearTimeout(this._postrollTimeout); + adBreak.start(player); + } else { + videojs.log.warn('Unexpected startLinearAdMode invocation (Postroll)'); + } + } + + /* + * An ad has actually started playing. + * Remove the loading spinner. + */ + onAdStarted(player) { + player.removeClass('vjs-ad-loading'); + } + + endLinearAdMode() { + const player = this.player; + + if (this.inAdBreak()) { + player.removeClass('vjs-ad-loading'); + adBreak.end(player); + + this.contentResuming = true; + + player.ads.debug('Triggered ended event (endLinearAdMode)'); + player.trigger('ended'); + } + } + + skipLinearAdMode() { + const player = this.player; + + if (player.ads.inAdBreak() || this.isContentResuming()) { + videojs.log.warn('Unexpected skipLinearAdMode invocation'); + } else { + player.ads.debug('Postroll abort (skipLinearAdMode)'); + player.trigger('adskip'); + this.abort(); + } + } + + onAdTimeout(player) { + player.ads.debug('Postroll abort (adtimeout)'); + this.abort(); + } + + onAdsError(player) { + player.ads.debug('Postroll abort (adserror)'); + + // In the future, we may not want to do this automatically. + // Integrations should be able to choose to continue the ad break + // if there was an error. + if (player.ads.inAdBreak()) { + player.ads.endLinearAdMode(); + } + + this.abort(); + } + + onEnded() { + if (this.isContentResuming()) { + this.transitionTo(AdsDone); + } else { + videojs.log.warn('Unexpected ended event during postroll'); + } + } + + onContentChanged(player) { + if (this.isContentResuming()) { + this.transitionTo(BeforePreroll); + } else if (!this.inAdBreak()) { + this.transitionTo(Preroll); + } + } + + onNoPostroll(player) { + if (!this.isContentResuming() && !this.inAdBreak()) { + this.transitionTo(AdsDone); + } else { + videojs.log.warn('Unexpected nopostroll event (Postroll)'); + } + } + + abort() { + const player = this.player; + + this.contentResuming = true; + player.removeClass('vjs-ad-loading'); + + player.ads.debug('Triggered ended event (postroll abort)'); + player.trigger('ended'); + } + + cleanup() { + const player = this.player; + + player.clearTimeout(this._postrollTimeout); + player.ads._contentEnding = false; + } + +} diff --git a/src/states/Preroll.js b/src/states/Preroll.js new file mode 100644 index 00000000..175f8274 --- /dev/null +++ b/src/states/Preroll.js @@ -0,0 +1,228 @@ +import videojs from 'video.js'; + +import {AdState, ContentPlayback} from '../states.js'; +import cancelContentPlay from '../cancelContentPlay.js'; +import adBreak from '../adBreak.js'; + +/* + * This state encapsulates waiting for prerolls, preroll playback, and + * content restoration after a preroll. + */ +export default class Preroll extends AdState { + + init(player, adsReady) { + // Loading spinner from now until ad start or end of ad break. + player.addClass('vjs-ad-loading'); + + // Determine preroll timeout based on plugin settings + let timeout = player.ads.settings.timeout; + + if (typeof player.ads.settings.prerollTimeout === 'number') { + timeout = player.ads.settings.prerollTimeout; + } + + // Start the clock ticking for ad timeout + this._timeout = player.setTimeout(function() { + player.trigger('adtimeout'); + }, timeout); + + // If adsready already happened, lets get started. Otherwise, + // wait until onAdsReady. + if (adsReady) { + this.handleAdsReady(); + } else { + this.adsReady = false; + } + } + + onAdsReady(player) { + if (!player.ads.inAdBreak() && !player.ads.isContentResuming()) { + player.ads.debug('Received adsready event (Preroll)'); + this.handleAdsReady(); + } else { + videojs.log.warn('Unexpected adsready event (Preroll)'); + } + } + + /* + * Ad integration is ready. Let's get started on this preroll. + */ + handleAdsReady() { + this.adsReady = true; + if (this.player.ads.nopreroll_) { + this.noPreroll(); + } else { + this.readyForPreroll(); + } + } + + /* + * Helper to call a callback only after a loadstart event. + * If we start content or ads before loadstart, loadstart + * will not be prefixed correctly. + */ + afterLoadStart(callback) { + const player = this.player; + + if (player.ads._hasThereBeenALoadStartDuringPlayerLife) { + callback(); + } else { + player.ads.debug('Waiting for loadstart...'); + player.one('loadstart', () => { + player.ads.debug('Received loadstart event'); + callback(); + }); + } + } + + /* + * If there is no preroll, play content instead. + */ + noPreroll() { + this.afterLoadStart(() => { + this.player.ads.debug('Skipping prerolls due to nopreroll event (Preroll)'); + this.transitionTo(ContentPlayback); + }); + } + + /* + * Fire the readyforpreroll event. If loadstart hasn't happened yet, + * wait until loadstart first. + */ + readyForPreroll() { + const player = this.player; + + this.afterLoadStart(() => { + player.ads.debug('Triggered readyforpreroll event (Preroll)'); + player.trigger('readyforpreroll'); + }); + } + + /* + * Don't allow the content to start playing while we're dealing with ads. + */ + onPlay(player) { + player.ads.debug('Received play event (Preroll)'); + + if (!this.inAdBreak() && !this.isContentResuming()) { + cancelContentPlay(this.player); + } + } + + /* + * adscanceled cancels all ads for the source. Play content now. + */ + onAdsCanceled(player) { + player.ads.debug('adscanceled (Preroll)'); + + this.afterLoadStart(() => { + this.transitionTo(ContentPlayback); + }); + } + + /* + * An ad error occured. Play content instead. + */ + onAdsError(player) { + videojs.log('adserror (Preroll)'); + // In the future, we may not want to do this automatically. + // Integrations should be able to choose to continue the ad break + // if there was an error. + if (this.inAdBreak()) { + player.ads.endLinearAdMode(); + } + + this.afterLoadStart(() => { + this.transitionTo(ContentPlayback); + }); + } + + /* + * Integration invoked startLinearAdMode, the ad break starts now. + */ + startLinearAdMode() { + const player = this.player; + + if (this.adsReady && !player.ads.inAdBreak() && !this.isContentResuming()) { + player.clearTimeout(this._timeout); + player.ads.adType = 'preroll'; + adBreak.start(player); + } else { + videojs.log.warn('Unexpected startLinearAdMode invocation (Preroll)'); + } + } + + /* + * An ad has actually started playing. + * Remove the loading spinner. + */ + onAdStarted(player) { + player.removeClass('vjs-ad-loading'); + } + + /* + * Integration invoked endLinearAdMode, the ad break ends now. + */ + endLinearAdMode() { + const player = this.player; + + if (this.inAdBreak()) { + player.removeClass('vjs-ad-loading'); + adBreak.end(player); + this.contentResuming = true; + } + } + + /* + * Ad skipped by integration. Play content instead. + */ + skipLinearAdMode() { + const player = this.player; + + if (player.ads.inAdBreak() || this.isContentResuming()) { + videojs.log.warn('Unexpected skipLinearAdMode invocation'); + } else { + this.afterLoadStart(() => { + player.trigger('adskip'); + player.ads.debug('skipLinearAdMode (Preroll)'); + this.transitionTo(ContentPlayback); + }); + } + } + + /* + * Prerolls took too long! Play content instead. + */ + onAdTimeout(player) { + this.afterLoadStart(() => { + player.ads.debug('adtimeout (Preroll)'); + this.transitionTo(ContentPlayback); + }); + } + + /* + * Check if nopreroll event was too late before handling it. + */ + onNoPreroll(player) { + if (player.ads.inAdBreak() || this.isContentResuming()) { + videojs.log.warn('Unexpected nopreroll event (Preroll)'); + } else { + this.noPreroll(); + } + } + + /* + * Cleanup timeouts and spinner. + */ + cleanup() { + const player = this.player; + + if (!player.ads._hasThereBeenALoadStartDuringPlayerLife) { + videojs.log.warn('Leaving Preroll state before loadstart event can cause issues.'); + } + + player.removeClass('vjs-ad-loading'); + player.clearTimeout(this._timeout); + } + +} diff --git a/src/states/abstract/AdState.js b/src/states/abstract/AdState.js new file mode 100644 index 00000000..83451a25 --- /dev/null +++ b/src/states/abstract/AdState.js @@ -0,0 +1,57 @@ +import {State, ContentPlayback} from '../../states.js'; + +/* + * This class contains logic for all ads, be they prerolls, midrolls, or postrolls. + * Primarily, this involves handling startLinearAdMode and endLinearAdMode. + * It also handles content resuming. + */ +export default class AdState extends State { + + constructor(player) { + super(player); + this.contentResuming = false; + } + + /* + * Overrides State.isAdState + */ + isAdState() { + return true; + } + + /* + * We end the content-resuming process on the playing event because this is the exact + * moment that content playback is no longer blocked by ads. + */ + onPlaying() { + if (this.contentResuming) { + this.transitionTo(ContentPlayback); + } + } + + /* + * If the integration does not result in a playing event when resuming content after an + * ad, they should instead trigger a contentresumed event to signal that content should + * resume. The main use case for this is when ads are stitched into the content video. + */ + onContentResumed() { + if (this.contentResuming) { + this.transitionTo(ContentPlayback); + } + } + + /* + * Allows you to check if content is currently resuming after an ad break. + */ + isContentResuming() { + return this.contentResuming; + } + + /* + * Allows you to check if an ad break is in progress. + */ + inAdBreak() { + return this.player.ads._inLinearAdMode === true; + } + +} diff --git a/src/states/abstract/ContentState.js b/src/states/abstract/ContentState.js new file mode 100644 index 00000000..9745b008 --- /dev/null +++ b/src/states/abstract/ContentState.js @@ -0,0 +1,27 @@ +import {State, BeforePreroll, Preroll} from '../../states.js'; + +export default class ContentState extends State { + + /* + * Overrides State.isAdState + */ + isAdState() { + return false; + } + + /* + * Source change sends you back to preroll checks. contentchanged does not + * fire during ad breaks, so we don't need to worry about that. + */ + onContentChanged(player) { + player.ads.debug('Received contentchanged event (ContentState)'); + if (player.paused()) { + this.transitionTo(BeforePreroll); + } else { + this.transitionTo(Preroll, false); + player.pause(); + player.ads._pausedOnContentupdate = true; + } + } + +} diff --git a/src/states/abstract/State.js b/src/states/abstract/State.js new file mode 100644 index 00000000..3d7bf5ed --- /dev/null +++ b/src/states/abstract/State.js @@ -0,0 +1,128 @@ +import videojs from 'video.js'; + +export default class State { + + constructor(player) { + this.player = player; + } + + /* + * This is the only allowed way to perform state transitions. State transitions usually + * happen in player event handlers. They can also happen recursively in `init`. They + * should _not_ happen in `cleanup`. + */ + transitionTo(NewState, ...args) { + const player = this.player; + const previousState = this; + + previousState.cleanup(); + const newState = new NewState(player); + + player.ads._state = newState; + player.ads.debug(previousState.constructor.name + ' -> ' + newState.constructor.name); + newState.init(player, ...args); + } + + /* + * Implemented by subclasses to provide initialization logic when transitioning + * to a new state. + */ + init() {} + + /* + * Implemented by subclasses to provide cleanup logic when transitioning + * to a new state. + */ + cleanup() {} + + /* + * Default event handlers. Different states can override these to provide behaviors. + */ + onPlay() {} + onPlaying() {} + onEnded() {} + onAdsReady() { + videojs.log.warn('Unexpected adsready event'); + } + onAdsError() {} + onAdsCanceled() {} + onAdTimeout() {} + onAdStarted() {} + onContentChanged() {} + onContentResumed() {} + onContentEnded() { + videojs.log.warn('Unexpected contentended event'); + } + onNoPreroll() {} + onNoPostroll() {} + + /* + * Method handlers. Different states can override these to provide behaviors. + */ + startLinearAdMode() { + videojs.log.warn('Unexpected startLinearAdMode invocation ' + + '(State via ' + this.constructor.name + ')'); + } + endLinearAdMode() { + videojs.log.warn('Unexpected endLinearAdMode invocation ' + + '(State via ' + this.constructor.name + ')'); + } + skipLinearAdMode() { + videojs.log.warn('Unexpected skipLinearAdMode invocation ' + + '(State via ' + this.constructor.name + ')'); + } + + /* + * Overridden by ContentState and AdState. Should not be overriden elsewhere. + */ + isAdState() { + throw new Error('isAdState unimplemented for ' + this.constructor.name); + } + + /* + * Overridden by PrerollState, MidrollState, and PostrollState. + */ + isContentResuming() { + return false; + } + + inAdBreak() { + return false; + } + + /* + * Invoke event handler methods when events come in. + */ + handleEvent(type) { + const player = this.player; + + if (type === 'play') { + this.onPlay(player); + } else if (type === 'adsready') { + this.onAdsReady(player); + } else if (type === 'adserror') { + this.onAdsError(player); + } else if (type === 'adscanceled') { + this.onAdsCanceled(player); + } else if (type === 'adtimeout') { + this.onAdTimeout(player); + } else if (type === 'ads-ad-started') { + this.onAdStarted(player); + } else if (type === 'contentchanged') { + this.onContentChanged(player); + } else if (type === 'contentresumed') { + this.onContentResumed(player); + } else if (type === 'contentended') { + this.onContentEnded(player); + } else if (type === 'playing') { + this.onPlaying(player); + } else if (type === 'ended') { + this.onEnded(player); + } else if (type === 'nopreroll') { + this.onNoPreroll(player); + } else if (type === 'nopostroll') { + this.onNoPostroll(player); + } + } + +} diff --git a/test/karma.conf.js b/test/karma.conf.js index 2dc40964..5f290af0 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -3,9 +3,9 @@ module.exports = function(config) { enabled: false, usePhantomJS: false, postDetection: function(browsers) { - const toRemove = ['Safari', 'SafariTechPreview']; + const toKeep = ['Firefox', 'Chrome']; return browsers.filter((e) => { - return toRemove.indexOf(e) === -1; + return toKeep.indexOf(e) !== -1; }); } }; diff --git a/test/states/abstract/test.AdState.js b/test/states/abstract/test.AdState.js new file mode 100644 index 00000000..1e9edf2f --- /dev/null +++ b/test/states/abstract/test.AdState.js @@ -0,0 +1,62 @@ +import QUnit from 'qunit'; + +import {AdState} from '../../../src/states.js'; + +/* + * These tests are intended to be isolated unit tests for one state with all + * other modules mocked. + */ +QUnit.module('AdState', { + beforeEach: function() { + this.player = { + ads: {} + }; + + this.adState = new AdState(this.player); + this.adState.transitionTo = (newState) => { + this.newState = newState.name; + }; + } +}); + +QUnit.test('does not start out with content resuming', function(assert) { + assert.equal(this.adState.contentResuming, false); +}); + +QUnit.test('is an ad state', function(assert) { + assert.equal(this.adState.isAdState(), true); +}); + +QUnit.test('transitions to ContentPlayback on playing if content resuming', function(assert) { + this.adState.contentResuming = true; + this.adState.onPlaying(); + assert.equal(this.newState, 'ContentPlayback'); +}); + +QUnit.test('doesn\'t transition on playing if content not resuming', function(assert) { + this.adState.onPlaying(); + assert.equal(this.newState, undefined, 'no transition'); +}); + +QUnit.test('transitions to ContentPlayback on contentresumed if content resuming', function(assert) { + this.adState.contentResuming = true; + this.adState.onContentResumed(); + assert.equal(this.newState, 'ContentPlayback'); +}); + +QUnit.test('doesn\'t transition on contentresumed if content not resuming', function(assert) { + this.adState.onContentResumed(); + assert.equal(this.newState, undefined, 'no transition'); +}); + +QUnit.test('can check if content is resuming', function(assert) { + assert.equal(this.adState.isContentResuming(), false, 'not resuming'); + this.adState.contentResuming = true; + assert.equal(this.adState.isContentResuming(), true, 'resuming'); +}); + +QUnit.test('can check if in ad break', function(assert) { + assert.equal(this.adState.inAdBreak(), false, 'not in ad break'); + this.player.ads._inLinearAdMode = true; + assert.equal(this.adState.inAdBreak(), true, 'in ad break'); +}); diff --git a/test/states/abstract/test.ContentState.js b/test/states/abstract/test.ContentState.js new file mode 100644 index 00000000..7f0a18a7 --- /dev/null +++ b/test/states/abstract/test.ContentState.js @@ -0,0 +1,46 @@ +import QUnit from 'qunit'; + +import {ContentState} from '../../../src/states.js'; + +/* + * These tests are intended to be isolated unit tests for one state with all + * other modules mocked. + */ +QUnit.module('ContentState', { + beforeEach: function() { + this.player = { + ads: { + debug: () => {} + } + }; + + this.contentState = new ContentState(this.player); + this.contentState.transitionTo = (newState) => { + this.newState = newState.name; + }; + } +}); + +QUnit.test('is not an ad state', function(assert) { + assert.equal(this.contentState.isAdState(), false); +}); + +QUnit.test('handles content changed when not playing', function(assert) { + this.player.paused = () => true; + this.player.pause = sinon.stub(); + + this.contentState.onContentChanged(this.player); + assert.equal(this.newState, 'BeforePreroll'); + assert.equal(this.player.pause.callCount, 0, 'did not pause player'); + assert.ok(!this.player.ads._pausedOnContentupdate, 'did not set _pausedOnContentupdate'); +}); + +QUnit.test('handles content changed when playing', function(assert) { + this.player.paused = () => false; + this.player.pause = sinon.stub(); + + this.contentState.onContentChanged(this.player); + assert.equal(this.newState, 'Preroll'); + assert.equal(this.player.pause.callCount, 1, 'paused player'); + assert.equal(this.player.ads._pausedOnContentupdate, true, 'set _pausedOnContentupdate'); +}); diff --git a/test/states/abstract/test.State.js b/test/states/abstract/test.State.js new file mode 100644 index 00000000..305cc687 --- /dev/null +++ b/test/states/abstract/test.State.js @@ -0,0 +1,65 @@ +import QUnit from 'qunit'; + +import {State} from '../../../src/states.js'; + +/* + * These tests are intended to be isolated unit tests for one state with all + * other modules mocked. + */ +QUnit.module('State', { + beforeEach: function() { + this.player = { + ads: { + debug: () => {} + } + }; + + this.state = new State(this.player); + } +}); + +QUnit.test('sets this.player', function(assert) { + assert.equal(this.state.player, this.player); +}); + +QUnit.test('can transition to another state', function(assert) { + let mockStateInit = false; + + class MockState { + init() { + mockStateInit = true; + } + } + + this.state.cleanup = sinon.stub(); + + this.state.transitionTo(MockState); + assert.ok(this.state.cleanup.calledOnce, 'cleaned up old state'); + assert.equal(this.player.ads._state.constructor.name, 'MockState', 'set ads._state'); + assert.equal(mockStateInit, true, 'initialized new state'); +}); + +QUnit.test('throws error if isAdState is not implemented', function(assert) { + let error; + + try { + this.state.isAdState(); + } catch(e) { + error = e; + } + assert.equal(error.message, 'isAdState unimplemented for State'); +}); + +QUnit.test('is not resuming content by default', function(assert) { + assert.equal(this.state.isContentResuming(), false); +}); + +QUnit.test('is not in an ad break by default', function(assert) { + assert.equal(this.state.inAdBreak(), false); +}); + +QUnit.test('handles events', function(assert) { + this.state.onPlay = sinon.stub(); + this.state.handleEvent('play'); + assert.ok(this.state.onPlay.calledOnce); +}); diff --git a/test/states/test.AdsDone.js b/test/states/test.AdsDone.js new file mode 100644 index 00000000..b02fcc2b --- /dev/null +++ b/test/states/test.AdsDone.js @@ -0,0 +1,30 @@ +import QUnit from 'qunit'; + +import {AdsDone} from '../../src/states.js'; + +/* + * These tests are intended to be isolated unit tests for one state with all + * other modules mocked. + */ +QUnit.module('AdsDone', { + beforeEach: function() { + this.player = { + ads: {} + }; + + this.adsDone = new AdsDone(this.player); + } +}); + +QUnit.test('sets _contentHasEnded on init', function(assert) { + this.adsDone.init(this.player); + assert.equal(this.player.ads._contentHasEnded, true, 'content has ended'); +}); + +QUnit.test('does not play midrolls', function(assert) { + this.adsDone.transitionTo = sinon.spy(); + + this.adsDone.init(this.player); + this.adsDone.startLinearAdMode(); + assert.equal(this.adsDone.transitionTo.callCount, 0, 'no transition'); +}); diff --git a/test/states/test.BeforePreroll.js b/test/states/test.BeforePreroll.js new file mode 100644 index 00000000..e3780c66 --- /dev/null +++ b/test/states/test.BeforePreroll.js @@ -0,0 +1,84 @@ +import QUnit from 'qunit'; +import {BeforePreroll} from '../../src/states.js'; +import * as CancelContentPlay from '../../src/cancelContentPlay.js'; + +/* + * These tests are intended to be isolated unit tests for one state with all + * other modules mocked. + */ +QUnit.module('BeforePreroll', { + beforeEach: function() { + this.events = []; + + this.player = { + ads: { + debug: () => {} + }, + setTimeout: () => {}, + trigger: (event) => { + this.events.push(event); + } + }; + + this.beforePreroll = new BeforePreroll(this.player); + this.beforePreroll.transitionTo = (newState, arg) => { + this.newState = newState.name; + this.transitionArg = arg; + }; + + this.cancelContentPlayStub = sinon.stub(CancelContentPlay, 'cancelContentPlay'); + }, + + afterEach: function() { + this.cancelContentPlayStub.restore(); + } +}); + +QUnit.test('transitions to Preroll (adsready first)', function(assert) { + this.beforePreroll.init(); + assert.equal(this.beforePreroll.adsReady, false); + this.beforePreroll.onAdsReady(this.player); + assert.equal(this.beforePreroll.adsReady, true); + this.beforePreroll.onPlay(this.player); + assert.equal(this.newState, 'Preroll'); + assert.equal(this.transitionArg, true); +}); + +QUnit.test('transitions to Preroll (play first)', function(assert) { + this.beforePreroll.init(); + assert.equal(this.beforePreroll.adsReady, false); + this.beforePreroll.onPlay(this.player); + assert.equal(this.newState, 'Preroll'); + assert.equal(this.transitionArg, false); +}); + +QUnit.test('cancels ads', function(assert) { + this.beforePreroll.init(); + this.beforePreroll.onAdsCanceled(this.player); + assert.equal(this.newState, 'ContentPlayback'); +}); + +QUnit.test('transitions to content playback on error', function(assert) { + this.beforePreroll.init(); + this.beforePreroll.onAdsError(this.player); + assert.equal(this.newState, 'ContentPlayback'); +}); + +QUnit.test('has no preroll', function(assert) { + this.beforePreroll.init(); + this.beforePreroll.onNoPreroll(this.player); + assert.equal(this.newState, 'ContentPlayback'); +}); + +QUnit.test('skips the preroll', function(assert) { + this.beforePreroll.init(); + this.beforePreroll.skipLinearAdMode(); + assert.equal(this.events[0], 'adskip'); + assert.equal(this.newState, 'ContentPlayback'); +}); + +QUnit.test('does nothing on content change', function(assert) { + this.beforePreroll.init(); + this.beforePreroll.onContentChanged(this.player); + assert.equal(this.newState, undefined); +}); diff --git a/test/states/test.ContentPlayback.js b/test/states/test.ContentPlayback.js new file mode 100644 index 00000000..e4d0fe36 --- /dev/null +++ b/test/states/test.ContentPlayback.js @@ -0,0 +1,94 @@ +import QUnit from 'qunit'; +import {ContentPlayback} from '../../src/states.js'; + +/* + * These tests are intended to be isolated unit tests for one state with all + * other modules mocked. + */ +QUnit.module('ContentPlayback', { + beforeEach: function() { + this.events = []; + this.playTriggered = false; + + this.player = { + paused: () => false, + play: () => { + this.playTriggered = true; + }, + trigger: (event) => { + this.events.push(event); + }, + ads: { + debug: () => {} + } + }; + + this.contentPlayback = new ContentPlayback(this.player); + this.contentPlayback.transitionTo = (newState) => { + this.newState = newState.name; + }; + } +}); + +QUnit.test('only plays on init on correct conditions', function(assert) { + this.player.paused = () => false; + this.player.ads._cancelledPlay = false; + this.player.ads._pausedOnContentupdate = false; + this.contentPlayback.init(this.player); + assert.equal(this.playTriggered, false); + + this.player.paused = () => true; + this.player.ads._cancelledPlay = false; + this.player.ads._pausedOnContentupdate = false; + this.contentPlayback.init(this.player); + assert.equal(this.playTriggered, false); + + this.player.paused = () => false; + this.player.ads._cancelledPlay = true; + this.player.ads._pausedOnContentupdate = false; + this.contentPlayback.init(this.player); + assert.equal(this.playTriggered, false); + + this.player.paused = () => false; + this.player.ads._cancelledPlay = false; + this.player.ads._pausedOnContentupdate = true; + this.contentPlayback.init(this.player); + assert.equal(this.playTriggered, false); + + this.player.paused = () => true; + this.player.ads._cancelledPlay = true; + this.player.ads._pausedOnContentupdate = false; + this.contentPlayback.init(this.player); + assert.equal(this.playTriggered, true); + + this.player.paused = () => true; + this.player.ads._cancelledPlay = false; + this.player.ads._pausedOnContentupdate = true; + this.contentPlayback.init(this.player); + assert.equal(this.playTriggered, true); +}); + +QUnit.test('adsready triggers readyforpreroll', function(assert) { + this.contentPlayback.init(this.player); + this.contentPlayback.onAdsReady(this.player); + assert.equal(this.events[0], 'readyforpreroll'); +}); + +QUnit.test('no readyforpreroll if nopreroll_', function(assert) { + this.player.ads.nopreroll_ = true; + this.contentPlayback.init(this.player); + this.contentPlayback.onAdsReady(this.player); + assert.equal(this.events.length, 0, 'no events triggered'); +}); + +QUnit.test('transitions to Postroll on contentended', function(assert) { + this.contentPlayback.init(this.player, false); + this.contentPlayback.onContentEnded(this.player); + assert.equal(this.newState, 'Postroll', 'transitioned to Postroll'); +}); + +QUnit.test('transitions to Midroll on startlinearadmode', function(assert) { + this.contentPlayback.init(this.player, false); + this.contentPlayback.startLinearAdMode(); + assert.equal(this.newState, 'Midroll', 'transitioned to Midroll'); +}); diff --git a/test/states/test.Midroll.js b/test/states/test.Midroll.js new file mode 100644 index 00000000..a55b78b5 --- /dev/null +++ b/test/states/test.Midroll.js @@ -0,0 +1,48 @@ +import QUnit from 'qunit'; +import {Midroll} 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('Midroll', { + beforeEach: function() { + this.player = { + ads: { + _inLinearAdMode: true, + endLinearAdMode: () => { + this.calledEndLinearAdMode = true; + } + } + }; + + this.midroll = new Midroll(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.midroll.init(this.player); + assert.equal(this.player.ads.adType, 'midroll', 'ad type is midroll'); + assert.equal(this.adBreakStartStub.callCount, 1, 'ad break started'); +}); + +QUnit.test('ends an ad break on endLinearAdMode', function(assert) { + this.midroll.init(this.player); + this.midroll.endLinearAdMode(); + assert.equal(this.adBreakEndStub.callCount, 1, 'ad break ended'); +}); + +QUnit.test('adserror during ad break ends ad break', function(assert) { + this.midroll.init(this.player); + this.midroll.onAdsError(this.player); + assert.equal(this.calledEndLinearAdMode, true, 'linear ad mode ended'); +}); diff --git a/test/states/test.Postroll.js b/test/states/test.Postroll.js new file mode 100644 index 00000000..a10270e2 --- /dev/null +++ b/test/states/test.Postroll.js @@ -0,0 +1,154 @@ +import QUnit from 'qunit'; + +import {Postroll} 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('Postroll', { + beforeEach: function() { + this.events = []; + + this.player = { + ads: { + settings: {}, + debug: () => {}, + inAdBreak: () => false + }, + addClass: () => {}, + removeClass: () => {}, + setTimeout: () => {}, + trigger: (event) => { + this.events.push(event); + }, + clearTimeout: () => {} + }; + + this.postroll = new Postroll(this.player); + + this.postroll.transitionTo = (newState) => { + this.newState = newState.name; + }; + + this.adBreakStartStub = sinon.stub(adBreak, 'start'); + this.adBreakEndStub = sinon.stub(adBreak, 'end'); + }, + + afterEach() { + this.adBreakStartStub.restore(); + this.adBreakEndStub.restore(); + } +}); + +QUnit.test('sets _contentEnding on init', function(assert) { + this.postroll.init(this.player); + assert.equal(this.player.ads._contentEnding, true, 'content is ending'); +}); + +QUnit.test('startLinearAdMode starts ad break', function(assert) { + this.postroll.init(this.player); + this.postroll.startLinearAdMode(); + assert.equal(this.adBreakStartStub.callCount, 1, 'ad break started'); + assert.equal(this.player.ads.adType, 'postroll', 'ad type is postroll'); +}); + +QUnit.test('removes ad loading class on ad started', function(assert) { + this.player.removeClass = sinon.spy(); + this.postroll.init(this.player); + this.postroll.onAdStarted(this.player); + assert.ok(this.player.removeClass.calledWith('vjs-ad-loading')); +}); + +QUnit.test('ends linear ad mode & ended event on ads error', function(assert) { + this.player.ads.endLinearAdMode = sinon.spy(); + + this.postroll.init(this.player); + this.player.ads.inAdBreak = () => true; + this.postroll.onAdsError(this.player); + assert.equal(this.player.ads.endLinearAdMode.callCount, 1, 'linear ad mode ended'); + assert.equal(this.events[0], 'ended', 'saw ended event'); +}); + +QUnit.test('no endLinearAdMode on adserror if not in ad break', function(assert) { + this.player.ads.endLinearAdMode = sinon.spy(); + + this.postroll.init(this.player); + this.player.ads.inAdBreak = () => false; + this.postroll.onAdsError(this.player); + assert.equal(this.player.ads.endLinearAdMode.callCount, 0, 'linear ad mode ended'); + assert.equal(this.events[0], 'ended', 'saw ended event'); +}); + +QUnit.test('does not transition to AdsDone unless content resuming', function(assert) { + this.postroll.init(this.player); + this.postroll.onEnded(this.player); + assert.equal(this.newState, undefined, 'no transition'); +}); + +QUnit.test('transitions to AdsDone on ended', function(assert) { + this.postroll.isContentResuming = () => true; + this.postroll.init(this.player); + this.postroll.onEnded(this.player); + assert.equal(this.newState, 'AdsDone'); +}); + +QUnit.test('transitions to BeforePreroll on content changed after ad break', function(assert) { + this.postroll.isContentResuming = () => true; + this.postroll.init(this.player); + this.postroll.onContentChanged(this.player); + assert.equal(this.newState, 'BeforePreroll'); +}); + +QUnit.test('transitions to Preroll on content changed before ad break', function(assert) { + this.postroll.init(this.player); + this.postroll.onContentChanged(this.player); + assert.equal(this.newState, 'Preroll'); +}); + +QUnit.test('doesn\'t transition on content changed during ad break', function(assert) { + this.postroll.inAdBreak = () => true; + this.postroll.init(this.player); + this.postroll.onContentChanged(this.player); + assert.equal(this.newState, undefined, 'no transition'); +}); + +QUnit.test('transitions to AdsDone on nopostroll before ad break', function(assert) { + this.postroll.init(this.player); + this.postroll.onNoPostroll(this.player); + assert.equal(this.newState, 'AdsDone'); +}); + +QUnit.test('no transition on nopostroll during ad break', function(assert) { + this.postroll.inAdBreak = () => true; + this.postroll.init(this.player); + this.postroll.onNoPostroll(this.player); + assert.equal(this.newState, undefined, 'no transition'); +}); + +QUnit.test('no transition on nopostroll after ad break', function(assert) { + this.postroll.isContentResuming = () => true; + this.postroll.init(this.player); + this.postroll.onNoPostroll(this.player); + assert.equal(this.newState, undefined, 'no transition'); +}); + +QUnit.test('can abort', function(assert) { + const removeClassSpy = sinon.spy(this.player, 'removeClass'); + + this.postroll.init(this.player); + this.postroll.abort(); + assert.equal(this.postroll.contentResuming, true, 'contentResuming'); + assert.ok(removeClassSpy.calledWith('vjs-ad-loading'), 'loading class removed'); + assert.equal(this.events[0], 'ended', 'saw ended event'); +}); + +QUnit.test('can clean up', function(assert) { + const clearSpy = sinon.spy(this.player, 'clearTimeout'); + + this.postroll.init(this.player); + this.postroll.cleanup(); + assert.equal(this.player.ads._contentEnding, false, '_contentEnding'); + assert.ok(clearSpy.calledWith(this.postroll._postrollTimeout), 'cleared timeout'); +}); diff --git a/test/states/test.Preroll.js b/test/states/test.Preroll.js new file mode 100644 index 00000000..b5631513 --- /dev/null +++ b/test/states/test.Preroll.js @@ -0,0 +1,145 @@ +import QUnit from 'qunit'; +import {Preroll} from '../../src/states.js'; +import * as CancelContentPlay from '../../src/cancelContentPlay.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('Preroll', { + beforeEach: function() { + this.events = []; + + this.player = { + ads: { + debug: () => {}, + settings: {}, + inAdBreak: () => false, + isContentResuming: () => false + }, + setTimeout: () => {}, + clearTimeout: () => {}, + addClass: () => {}, + removeClass: () => {}, + one: () => {}, + trigger: (event) => { + this.events.push(event); + } + }; + + this.preroll = new Preroll(this.player); + + this.preroll.transitionTo = (newState, arg) => { + this.newState = newState.name; + this.transitionArg = arg; + }; + + this.preroll.afterLoadStart = (callback) => { + callback(); + }; + + this.adBreakStartStub = sinon.stub(adBreak, 'start'); + this.adBreakEndStub = sinon.stub(adBreak, 'end'); + }, + + afterEach() { + this.adBreakStartStub.restore(); + this.adBreakEndStub.restore(); + } +}); + +QUnit.test('plays a preroll (adsready true)', function(assert) { + this.preroll.init(this.player, true); + assert.equal(this.preroll.adsReady, true, 'adsready from init'); + assert.equal(this.events[0], 'readyforpreroll', 'readyforpreroll from init'); + assert.equal(this.preroll.inAdBreak(), false, 'not in ad break'); + + this.preroll.startLinearAdMode(); + // Because adBreak.start is mocked. + this.player.ads._inLinearAdMode = true; + assert.equal(this.adBreakStartStub.callCount, 1, 'ad break started'); + assert.equal(this.player.ads.adType, 'preroll', 'adType is preroll'); + assert.equal(this.preroll.isContentResuming(), false, 'content not resuming'); + assert.equal(this.preroll.inAdBreak(), true, 'in ad break'); + + this.preroll.endLinearAdMode(); + assert.equal(this.adBreakEndStub.callCount, 1, 'ad break ended'); + assert.equal(this.preroll.isContentResuming(), true, 'content resuming'); + + this.preroll.onPlaying(); + assert.equal(this.newState, 'ContentPlayback', 'transitioned to ContentPlayback'); +}); + +QUnit.test('plays a preroll (adsready false)', function(assert) { + this.preroll.init(this.player, false); + assert.equal(this.preroll.adsReady, false, 'not adsReady yet'); + + this.preroll.onAdsReady(this.player); + assert.equal(this.preroll.adsReady, true, 'adsready from init'); + assert.equal(this.events[0], 'readyforpreroll', 'readyforpreroll from init'); + assert.equal(this.preroll.inAdBreak(), false, 'not in ad break'); + + this.preroll.startLinearAdMode(); + // Because adBreak.start is mocked. + this.player.ads._inLinearAdMode = true; + assert.equal(this.adBreakStartStub.callCount, 1, 'ad break started'); + assert.equal(this.player.ads.adType, 'preroll', 'adType is preroll'); + assert.equal(this.preroll.isContentResuming(), false, 'content not resuming'); + assert.equal(this.preroll.inAdBreak(), true, 'in ad break'); + + this.preroll.endLinearAdMode(); + assert.equal(this.adBreakEndStub.callCount, 1, 'ad break ended'); + assert.equal(this.preroll.isContentResuming(), true, 'content resuming'); + + this.preroll.onPlaying(); + assert.equal(this.newState, 'ContentPlayback', 'transitioned to ContentPlayback'); +}); + +QUnit.test('can handle nopreroll event', function(assert) { + this.preroll.init(this.player, false); + this.preroll.onNoPreroll(this.player); + assert.equal(this.newState, 'ContentPlayback', 'transitioned to ContentPlayback'); +}); + +QUnit.test('can handle adscanceled', function(assert) { + this.preroll.init(this.player, false); + this.preroll.onAdsCanceled(this.player); + assert.equal(this.newState, 'ContentPlayback', 'transitioned to ContentPlayback'); +}); + +QUnit.test('can handle adserror', function(assert) { + this.preroll.init(this.player, false); + this.preroll.onAdsError(this.player); + assert.equal(this.newState, 'ContentPlayback', 'transitioned to ContentPlayback'); +}); + +QUnit.test('can skip linear ad mode', function(assert) { + this.preroll.init(this.player, false); + this.preroll.skipLinearAdMode(); + assert.equal(this.newState, 'ContentPlayback', 'transitioned to ContentPlayback'); +}); + +QUnit.test('plays content after ad timeout', function(assert) { + this.preroll.init(this.player, false); + this.preroll.onAdTimeout(this.player); + assert.equal(this.newState, 'ContentPlayback', 'transitioned to ContentPlayback'); +}); + +QUnit.test('removes ad loading class on ads started', function(assert) { + this.preroll.init(this.player, false); + + const removeClassSpy = sinon.spy(this.player, 'removeClass'); + + this.preroll.onAdStarted(this.player); + assert.ok(removeClassSpy.calledWith('vjs-ad-loading'), 'loading class removed'); +}); + +QUnit.test('remove ad loading class on cleanup', function(assert) { + this.preroll.init(this.player, false); + + const removeClassSpy = sinon.spy(this.player, 'removeClass'); + + this.preroll.cleanup(); + assert.ok(removeClassSpy.calledWith('vjs-ad-loading'), 'loading class removed'); +}); diff --git a/test/test.ads.js b/test/test.ads.js index 9219e1a0..8d15cfdb 100644 --- a/test/test.ads.js +++ b/test/test.ads.js @@ -1,13 +1,11 @@ -var timerExists = function(env, keyOrId) { - var timerId = _.isNumber(keyOrId) ? keyOrId : env.player.ads[String(keyOrId)]; - return env.clock.timers.hasOwnProperty(String(timerId)); +var timerExists = function(env, id) { + return env.clock.timers.hasOwnProperty(id); }; QUnit.module('Ad Framework', window.sharedModuleHooks()); -QUnit.test('begins in content-set', function(assert) { - assert.expect(1); - assert.strictEqual(this.player.ads.state, 'content-set'); +QUnit.test('begins in BeforePreroll', function(assert) { + assert.equal(this.player.ads._state.constructor.name, 'BeforePreroll'); }); QUnit.test('pauses to wait for prerolls when the plugin loads BEFORE play', function(assert) { @@ -47,29 +45,26 @@ QUnit.test('pauses to wait for prerolls when the plugin loads AFTER play', funct QUnit.test('stops canceling play events when an ad is playing', function(assert) { var setTimeoutSpy = sinon.spy(window, 'setTimeout'); - assert.expect(10); - // Throughout this test, we check both that the expected timeouts are // populated on the `clock` _and_ that `setTimeout` has been called the // expected number of times. - assert.notOk(timerExists(this, 'cancelPlayTimeout'), '`cancelPlayTimeout` does not exist'); - assert.notOk(timerExists(this, 'adTimeoutTimeout'), '`adTimeoutTimeout` does not exist'); + assert.notOk(timerExists(this, this.player.ads.cancelPlayTimeout), '`cancelPlayTimeout` does not exist'); this.player.trigger('play'); - assert.strictEqual(setTimeoutSpy.callCount, 2, 'two timers were created (`cancelPlayTimeout` and `adTimeoutTimeout`)'); - assert.ok(timerExists(this, 'cancelPlayTimeout'), '`cancelPlayTimeout` exists'); - assert.ok(timerExists(this, 'adTimeoutTimeout'), '`adTimeoutTimeout` exists'); + assert.strictEqual(setTimeoutSpy.callCount, 2, 'two timers were created (`cancelPlayTimeout` and `_prerollTimeout`)'); + assert.ok(timerExists(this, this.player.ads.cancelPlayTimeout), '`cancelPlayTimeout` exists'); + assert.ok(timerExists(this, this.player.ads._state._timeout), 'preroll timeout exists after play'); this.player.trigger('adsready'); - assert.strictEqual(setTimeoutSpy.callCount, 3, '`adTimeoutTimeout` was re-scheduled'); - assert.ok(timerExists(this, 'adTimeoutTimeout'), '`adTimeoutTimeout` exists'); + assert.ok(timerExists(this, this.player.ads._state._timeout), 'preroll timeout exists after adsready'); + this.player.ads.startLinearAdMode(); + assert.notOk(timerExists(this, this.player.ads._state._timeout), 'preroll timeout no longer exists'); + + // cancelPlayTimeout happens after a tick this.clock.tick(1); - this.player.trigger('adstart'); - assert.strictEqual(this.player.ads.state, 'ad-playback', 'ads are playing'); - assert.notOk(timerExists(this, 'adTimeoutTimeout'), '`adTimeoutTimeout` no longer exists'); - assert.notOk(timerExists(this, 'cancelPlayTimeout'), '`cancelPlayTimeout` no longer exists'); + assert.notOk(timerExists(this, this.player.ads.cancelPlayTimeout), '`cancelPlayTimeout` no longer exists'); window.setTimeout.restore(); }); @@ -96,50 +91,6 @@ QUnit.test('player has the .vjs-has-started class once a preroll begins', functi assert.ok(this.player.hasClass('vjs-has-started'), 'player has .vjs-has-started class'); }); -QUnit.test('moves to content-playback after a preroll', function(assert) { - assert.expect(2); - - this.player.trigger('adsready'); - this.player.trigger('play'); - this.player.ads.startLinearAdMode(); - this.player.ads.endLinearAdMode(); - assert.strictEqual(this.player.ads.state, 'content-resuming', 'the state is content-resuming'); - - this.player.trigger('playing'); - assert.strictEqual(this.player.ads.state, 'content-playback', 'the state is content-resuming'); -}); - -QUnit.test('moves to ad-playback if a midroll is requested', function(assert) { - assert.expect(1); - - this.player.trigger('adsready'); - this.player.trigger('play'); - this.player.trigger('adtimeout'); - this.player.ads.startLinearAdMode(); - assert.strictEqual(this.player.ads.state, 'ad-playback', 'the state is ad-playback'); -}); - -QUnit.test('moves to content-playback if the preroll times out', function(assert) { - this.player.trigger('adsready'); - this.player.trigger('play'); - this.player.trigger('adtimeout'); - assert.strictEqual(this.player.ads.state, 'content-playback', 'the state is content-playback'); -}); - -QUnit.test('waits for adsready if play is received first', function(assert) { - assert.expect(1); - - this.player.trigger('play'); - this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'preroll?', 'the state is preroll?'); -}); - -QUnit.test('moves to content-playback if a plugin does not finish initializing', function(assert) { - this.player.trigger('play'); - this.player.trigger('adtimeout'); - assert.strictEqual(this.player.ads.state, 'content-playback', 'the state is content-playback'); -}); - QUnit.test('calls start immediately on play when ads are ready', function(assert) { var readyForPrerollSpy = sinon.spy(); @@ -173,7 +124,6 @@ QUnit.test('removes the ad-mode class when a preroll finishes', function(assert) this.player.ads.endLinearAdMode(); el = this.player.el(); assert.notOk(this.player.hasClass('vjs-ad-playing'), 'the ad class should not be in "' + el.className + '"'); - assert.strictEqual(this.player.ads.triggerevent, 'adend', 'triggerevent for content-resuming should have been adend'); this.player.trigger('playing'); }); @@ -215,6 +165,7 @@ QUnit.test('removes the loading class when the preroll begins', function(assert) QUnit.test('removes the loading class when the preroll times out', function(assert) { var el; + this.player.trigger('loadstart'); this.player.trigger('adsready'); this.player.trigger('play'); this.player.trigger('adtimeout'); @@ -226,6 +177,7 @@ QUnit.test('removes the loading class when the preroll times out', function(asse QUnit.test('starts the content video if there is no preroll', function(assert) { var spy = sinon.spy(this.player, 'play'); + this.player.trigger('loadstart'); this.player.trigger('adsready'); this.player.trigger('play'); this.clock.tick(1); @@ -267,12 +219,11 @@ QUnit.test('changing the src triggers "contentupdate"', function(assert) { assert.strictEqual(spy.callCount, 1, 'one contentupdate event fired'); }); -QUnit.test('"contentupdate" should fire when src is changed in "content-resuming" state after postroll', function(assert) { - var spy = sinon.spy(); +QUnit.test('"contentupdate" should fire when src is changed after postroll', function(assert) { + var contentupdateSpy = sinon.spy(); - assert.expect(2); + this.player.on('contentupdate', contentupdateSpy); - this.player.on('contentupdate', spy); this.player.trigger('adsready'); this.player.trigger('play'); this.player.trigger('adtimeout'); @@ -282,16 +233,13 @@ QUnit.test('"contentupdate" should fire when src is changed in "content-resuming // set src and trigger synthetic 'loadstart' this.player.src('http://media.w3.org/2010/05/sintel/trailer.mp4'); this.player.trigger('loadstart'); - assert.strictEqual(spy.callCount, 1, 'one contentupdate event fired'); - assert.strictEqual(this.player.ads.state, 'content-set', 'we are in the content-set state'); + assert.strictEqual(contentupdateSpy.callCount, 1, 'one contentupdate event fired'); }); -QUnit.test('"contentupdate" should fire when src is changed in "content-playback" state after postroll', function(assert) { - var spy = sinon.spy(); - - assert.expect(2); +QUnit.test('"contentupdate" should fire when src is changed after postroll', function(assert) { + var contentupdateSpy = sinon.spy(); - this.player.on('contentupdate', spy); + this.player.on('contentupdate', contentupdateSpy); this.player.trigger('adsready'); this.player.trigger('play'); this.player.trigger('adtimeout'); @@ -302,8 +250,7 @@ QUnit.test('"contentupdate" should fire when src is changed in "content-playback // set src and trigger synthetic 'loadstart' this.player.src('http://media.w3.org/2010/05/sintel/trailer.mp4'); this.player.trigger('loadstart'); - assert.strictEqual(spy.callCount, 1, 'one contentupdate event fired'); - assert.strictEqual(this.player.ads.state, 'content-set', 'we are in the content-set state'); + assert.strictEqual(contentupdateSpy.callCount, 1, 'one contentupdate event fired'); }); QUnit.test('changing src does not trigger "contentupdate" during ad playback', function(assert) { @@ -325,299 +272,165 @@ QUnit.test('changing src does not trigger "contentupdate" during ad playback', f assert.strictEqual(spy.callCount, 0, 'no contentupdate events fired'); }); -QUnit.test('the `cancelPlayTimeout` timeout is cleared when exiting "preroll?"', function(assert) { - var setTimeoutSpy = sinon.spy(window, 'setTimeout'); - - assert.expect(5); - +QUnit.test('the `cancelPlayTimeout` timeout is cleared when exiting preroll', function(assert) { this.player.trigger('adsready'); this.player.trigger('play'); - assert.strictEqual(this.player.ads.state, 'preroll?', 'the player is waiting for prerolls'); - assert.strictEqual(setTimeoutSpy.callCount, 2, 'two timers were created (`cancelPlayTimeout` and `adTimeoutTimeout`)'); - assert.ok(timerExists(this, 'cancelPlayTimeout'), '`cancelPlayTimeout` exists'); - assert.ok(timerExists(this, 'adTimeoutTimeout'), '`adTimeoutTimeout` exists'); - - this.player.trigger('play'); - this.player.trigger('play'); - this.player.trigger('play'); - assert.strictEqual(setTimeoutSpy.callCount, 2, 'no additional timers were created on subsequent "play" events'); - - window.setTimeout.restore(); -}); - -QUnit.test('"adscanceled" allows us to transition from "content-set" to "content-playback"', function(assert) { - assert.strictEqual(this.player.ads.state, 'content-set'); - this.player.trigger('adscanceled'); - assert.strictEqual(this.player.ads.state, 'content-playback'); -}); - -QUnit.test('"adscanceled" allows us to transition from "ads-ready?" to "content-playback"', function(assert) { - var setTimeoutSpy = sinon.spy(window, 'setTimeout'); + const prerollState = this.player.ads._state; - assert.strictEqual(this.player.ads.state, 'content-set'); + assert.ok(timerExists(this, this.player.ads.cancelPlayTimeout), '`cancelPlayTimeout` exists'); + assert.ok(timerExists(this, prerollState._timeout), 'preroll timeout exists'); - this.player.trigger('play'); - assert.strictEqual(this.player.ads.state, 'ads-ready?'); - assert.strictEqual(setTimeoutSpy.callCount, 2, 'two timers were created (`cancelPlayTimeout` and `adTimeoutTimeout`)'); - assert.ok(timerExists(this, 'cancelPlayTimeout'), '`cancelPlayTimeout` exists'); - assert.ok(timerExists(this, 'adTimeoutTimeout'), '`adTimeoutTimeout` exists'); + this.player.ads.startLinearAdMode(); + this.player.ads.endLinearAdMode(); + this.player.trigger('playing'); - this.player.trigger('adscanceled'); - assert.strictEqual(this.player.ads.state, 'content-playback'); - assert.notOk(timerExists(this, 'cancelPlayTimeout'), '`cancelPlayTimeout` was canceled'); + this.clock.tick(1); - window.setTimeout.restore(); + assert.notOk(this.player.ads._cancelledPlay, 'cancelContentPlay does nothing in content playback'); + assert.notOk(timerExists(this, prerollState._timeout), 'preroll timeout cleared'); + }); -QUnit.test('content is resumed on contentplayback if a user initiated play event is canceled', function(assert) { - var playSpy = sinon.spy(this.player, 'play'); - var setTimeoutSpy = sinon.spy(window, 'setTimeout'); - - assert.expect(8); - - assert.strictEqual(this.player.ads.state, 'content-set'); +QUnit.test('"cancelContentPlay doesn\'t block play after adscanceled', function(assert) { + this.player.trigger('loadstart'); this.player.trigger('play'); - assert.strictEqual(this.player.ads.state, 'ads-ready?'); - assert.strictEqual(setTimeoutSpy.callCount, 2, 'two timers were created (`cancelPlayTimeout` and `adTimeoutTimeout`)'); - assert.ok(timerExists(this, 'cancelPlayTimeout'), '`cancelPlayTimeout` exists'); - assert.ok(timerExists(this, 'adTimeoutTimeout'), '`adTimeoutTimeout` exists'); + this.player.trigger('adscanceled'); this.clock.tick(1); - this.player.trigger('adserror'); - assert.strictEqual(this.player.ads.state, 'content-playback'); - assert.notOk(timerExists(this, 'cancelPlayTimeout'), '`cancelPlayTimeout` was canceled'); - assert.strictEqual(playSpy.callCount, 1, 'a play event should be triggered once we enter "content-playback" state if on was canceled.'); -}); - -QUnit.test('adserror in content-set transitions to content-playback', function(assert) { - assert.strictEqual(this.player.ads.state, 'content-set'); - this.player.trigger('adserror'); - assert.strictEqual(this.player.ads.state, 'content-playback'); -}); - -QUnit.test('adskip in content-set transitions to content-playback', function(assert) { - assert.strictEqual(this.player.ads.state, 'content-set'); - - this.player.trigger('adskip'); - assert.strictEqual(this.player.ads.state, 'content-playback'); -}); + assert.notOk(this.player.ads._cancelledPlay, 'cancelContentPlay does nothing in content playback'); -QUnit.test('adserror in ads-ready? transitions to content-playback', function(assert) { - assert.strictEqual(this.player.ads.state, 'content-set'); - - this.player.trigger('play'); - assert.strictEqual(this.player.ads.state, 'ads-ready?'); - - this.player.trigger('adserror'); - assert.strictEqual(this.player.ads.state, 'content-playback'); }); -QUnit.test('adskip in ads-ready? transitions to content-playback', function(assert) { - assert.strictEqual(this.player.ads.state, 'content-set'); +QUnit.test('content is resumed on contentplayback if a user initiated play event is canceled', function(assert) { + var playSpy = sinon.spy(this.player, 'play'); + var setTimeoutSpy = sinon.spy(window, 'setTimeout'); this.player.trigger('play'); - assert.strictEqual(this.player.ads.state, 'ads-ready?'); - - this.player.trigger('adskip'); - assert.strictEqual(this.player.ads.state, 'content-playback'); -}); - -QUnit.test('adserror in ads-ready transitions to content-playback', function(assert) { - - assert.strictEqual(this.player.ads.state, 'content-set'); - - this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); - - this.player.trigger('adserror'); - assert.strictEqual(this.player.ads.state, 'content-playback'); -}); - -QUnit.test('adskip in ads-ready transitions to content-playback', function(assert) { - assert.strictEqual(this.player.ads.state, 'content-set'); - this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); - this.player.trigger('adskip'); - assert.strictEqual(this.player.ads.state, 'content-playback'); -}); - -QUnit.test('adserror in preroll? transitions to content-playback', function(assert) { - assert.strictEqual(this.player.ads.state, 'content-set'); - - this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); + assert.strictEqual(setTimeoutSpy.callCount, 2, 'two timers were created (`cancelPlayTimeout` and `_prerollTimeout`)'); + assert.ok(timerExists(this, this.player.ads.cancelPlayTimeout), '`cancelPlayTimeout` exists'); + assert.ok(timerExists(this, this.player.ads._state._timeout), 'preroll timeout exists'); - this.player.trigger('play'); - assert.strictEqual(this.player.ads.state, 'preroll?'); - - this.player.trigger('adserror'); - assert.strictEqual(this.player.ads.state, 'content-playback'); + this.clock.tick(1); + this.player.ads.startLinearAdMode(); + this.player.ads.endLinearAdMode(); + assert.notOk(timerExists(this, this.player.ads.cancelPlayTimeout), '`cancelPlayTimeout` was canceled'); + assert.strictEqual(playSpy.callCount, 1, 'a play event should be triggered once we enter "content-playback" state if on was canceled.'); }); -QUnit.test('adskip in preroll? transitions to content-playback', function(assert) { - assert.strictEqual(this.player.ads.state, 'content-set'); - - this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); - - this.player.trigger('play'); - assert.strictEqual(this.player.ads.state, 'preroll?'); - - this.player.trigger('adskip'); - assert.strictEqual(this.player.ads.state, 'content-playback'); -}); +QUnit.test('ended event happens after postroll errors out', function(assert) { + var endedSpy = sinon.spy(); -QUnit.test('adserror in postroll? transitions to content-playback and fires ended', function(assert) { - assert.strictEqual(this.player.ads.state, 'content-set'); + this.player.on('ended', endedSpy); + this.player.trigger('loadstart'); this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); - this.player.trigger('play'); this.player.trigger('adtimeout'); this.player.trigger('ended'); - assert.strictEqual(this.player.ads.state, 'postroll?'); - this.player.trigger('adserror'); - assert.strictEqual(this.player.ads.state, 'content-resuming'); - assert.strictEqual(this.player.ads.triggerevent, 'adserror', 'adserror should be the trigger event'); this.clock.tick(1); - assert.strictEqual(this.player.ads.state, 'content-playback'); + assert.strictEqual(endedSpy.callCount, 1, 'ended event happened'); }); -QUnit.test('adtimeout in postroll? transitions to content-playback and fires ended', function(assert) { +QUnit.test('ended event happens after postroll timed out', function(assert) { + var endedSpy = sinon.spy(); - assert.strictEqual(this.player.ads.state, 'content-set'); + this.player.on('ended', endedSpy); + this.player.trigger('loadstart'); this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); - this.player.trigger('play'); this.player.trigger('adtimeout'); this.player.trigger('ended'); - assert.strictEqual(this.player.ads.state, 'postroll?'); - this.player.trigger('adtimeout'); - assert.strictEqual(this.player.ads.state, 'content-resuming'); - assert.strictEqual(this.player.ads.triggerevent, 'adtimeout', 'adtimeout should be the trigger event'); this.clock.tick(1); - assert.strictEqual(this.player.ads.state, 'content-playback'); + assert.strictEqual(endedSpy.callCount, 1, 'ended event happened'); }); -QUnit.test('adskip in postroll? transitions to content-playback and fires ended', function(assert) { +QUnit.test('ended event happens after postroll skipped', function(assert) { + var endedSpy = sinon.spy(); - assert.strictEqual(this.player.ads.state, 'content-set'); + this.player.on('ended', endedSpy); + this.player.trigger('loadstart'); this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); - this.player.trigger('play'); - this.player.trigger('adtimeout'); - this.player.trigger('ended'); - assert.strictEqual(this.player.ads.state, 'postroll?'); - - this.player.trigger('adskip'); - assert.strictEqual(this.player.ads.state, 'content-resuming'); - assert.strictEqual(this.player.ads.triggerevent, 'adskip', 'adskip should be the trigger event'); - + this.player.trigger('adtimeout'); // preroll times out + this.player.trigger('ended'); // content ends (contentended) + this.player.ads.skipLinearAdMode(); + this.clock.tick(1); - assert.strictEqual(this.player.ads.state, 'content-playback'); + assert.strictEqual(endedSpy.callCount, 1, 'ended event happened'); }); -QUnit.test('an "ended" event is fired in "content-resuming" via a timeout if not fired naturally', function(assert) { +QUnit.test('an "ended" event is fired after postroll if not fired naturally', function(assert) { var endedSpy = sinon.spy(); - assert.expect(6); - this.player.on('ended', endedSpy); - assert.strictEqual(this.player.ads.state, 'content-set'); + this.player.trigger('loadstart'); this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); - this.player.trigger('play'); - this.player.trigger('adtimeout'); - this.player.trigger('ended'); - assert.strictEqual(this.player.ads.state, 'postroll?'); + this.player.trigger('adtimeout'); // skip preroll + this.player.trigger('ended'); // will be redispatched as contentended - this.player.ads.startLinearAdMode(); - this.player.ads.endLinearAdMode(); - assert.strictEqual(this.player.ads.state, 'content-resuming'); - assert.strictEqual(endedSpy.callCount, 0, 'we should not have gotten an ended event yet'); + assert.strictEqual(endedSpy.callCount, 0, 'ended was redispatched as contentended'); - this.clock.tick(1000); - assert.strictEqual(endedSpy.callCount, 1, 'we should have fired ended from the timeout'); + this.player.ads.startLinearAdMode(); // start postroll + this.player.ads.endLinearAdMode(); + assert.strictEqual(endedSpy.callCount, 1, 'ended event happened'); }); -QUnit.test('an "ended" event is not fired in "content-resuming" via a timeout if fired naturally', function(assert) { +QUnit.test('ended events when content ends first and second time', function(assert) { var endedSpy = sinon.spy(); - - assert.expect(6); - this.player.on('ended', endedSpy); - assert.strictEqual(this.player.ads.state, 'content-set'); + this.player.trigger('loadstart'); this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); - this.player.trigger('play'); - this.player.trigger('adtimeout'); - this.player.trigger('ended'); - assert.strictEqual(this.player.ads.state, 'postroll?'); + this.player.trigger('adtimeout'); // Preroll times out + this.player.trigger('ended'); // Content ends (contentended) - this.player.ads.startLinearAdMode(); + this.player.ads.startLinearAdMode(); // Postroll starts this.player.ads.endLinearAdMode(); - assert.strictEqual(this.player.ads.state, 'content-resuming'); - assert.strictEqual(endedSpy.callCount, 0, 'we should not have gotten an ended event yet'); + + assert.strictEqual(endedSpy.callCount, 1, 'ended event after postroll'); this.player.trigger('ended'); - assert.strictEqual(endedSpy.callCount, 1, 'we should have fired ended from the timeout'); + assert.strictEqual(endedSpy.callCount, 2, 'ended event after ads done'); }); -QUnit.test('adserror in ad-playback transitions to content-playback and triggers adend', function(assert) { - var spy; +QUnit.test('endLinearAdMode during ad break triggers adend', function(assert) { + var adendSpy = sinon.spy(); - assert.strictEqual(this.player.ads.state, 'content-set'); + this.player.on('adend', adendSpy); + this.player.trigger('loadstart'); this.player.trigger('adsready'); - - assert.strictEqual(this.player.ads.state, 'ads-ready'); - this.player.trigger('play'); this.player.ads.startLinearAdMode(); - spy = sinon.spy(); - this.player.on('adend', spy); - this.player.trigger('adserror'); - assert.strictEqual(this.player.ads.state, 'content-resuming'); - assert.strictEqual(this.player.ads.triggerevent, 'adserror', 'The reason for content-resuming should have been adserror'); - this.player.trigger('playing'); - assert.strictEqual(this.player.ads.state, 'content-playback'); - assert.strictEqual(spy.getCall(0).args[0].type, 'adend', 'adend should be fired when we enter content-playback from adserror'); + assert.strictEqual(adendSpy.callCount, 0, 'no adend yet'); + + this.player.ads.endLinearAdMode(); + + assert.strictEqual(adendSpy.callCount, 1, 'adend happened'); }); QUnit.test('calling startLinearAdMode() when already in ad-playback does not trigger adstart', function(assert) { var spy = sinon.spy(); this.player.on('adstart', spy); - assert.strictEqual(this.player.ads.state, 'content-set'); - - // go through preroll flow this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); - this.player.trigger('play'); - assert.strictEqual(this.player.ads.state, 'preroll?'); - this.player.ads.startLinearAdMode(); - assert.strictEqual(this.player.ads.state, 'ad-playback'); assert.strictEqual(spy.callCount, 1, 'adstart should have fired'); // add an extraneous start call @@ -627,82 +440,54 @@ QUnit.test('calling startLinearAdMode() when already in ad-playback does not tri // make sure subsequent adstarts trigger again on exit/re-enter this.player.ads.endLinearAdMode(); this.player.trigger('playing'); - assert.strictEqual(this.player.ads.state, 'content-playback'); this.player.ads.startLinearAdMode(); assert.strictEqual(spy.callCount, 2, 'adstart should have fired'); }); -QUnit.test('calling endLinearAdMode() in any state but ad-playback does not trigger adend', function(assert) { - var spy; - - assert.expect(13); +QUnit.test('calling endLinearAdMode() outside of linear ad mode does not trigger adend', function(assert) { + var adendSpy; - spy = sinon.spy(); - this.player.on('adend', spy); - assert.strictEqual(this.player.ads.state, 'content-set'); + adendSpy = sinon.spy(); + this.player.on('adend', adendSpy); this.player.ads.endLinearAdMode(); - assert.strictEqual(spy.callCount, 0, 'adend should not have fired'); + assert.strictEqual(adendSpy.callCount, 0, 'adend should not have fired right away'); this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); this.player.ads.endLinearAdMode(); - assert.strictEqual(spy.callCount, 0, 'adend should not have fired'); + assert.strictEqual(adendSpy.callCount, 0, 'adend should not have fired after adsready'); this.player.trigger('play'); - assert.strictEqual(this.player.ads.state, 'preroll?'); this.player.ads.endLinearAdMode(); - assert.strictEqual(spy.callCount, 0, 'adend should not have fired'); + assert.strictEqual(adendSpy.callCount, 0, 'adend should not have fired after play'); this.player.trigger('adtimeout'); - assert.strictEqual(this.player.ads.state, 'content-playback'); this.player.ads.endLinearAdMode(); - assert.strictEqual(spy.callCount, 0, 'adend should not have fired'); + assert.strictEqual(adendSpy.callCount, 0, 'adend should not have fired after adtimeout'); this.player.ads.startLinearAdMode(); - assert.strictEqual(this.player.ads.state, 'ad-playback'); this.player.ads.endLinearAdMode(); - assert.strictEqual(spy.callCount, 1, 'adend should have fired'); - - this.player.trigger('playing'); - assert.strictEqual(this.player.ads.state, 'content-playback'); - - this.player.ads.startLinearAdMode(); - assert.strictEqual(this.player.ads.state, 'ad-playback'); - - this.player.trigger('adserror'); - assert.strictEqual(spy.callCount, 2, 'adend should have fired'); + assert.strictEqual(adendSpy.callCount, 1, 'adend should have fired after preroll'); }); -QUnit.test('skipLinearAdMode in ad-playback does not trigger adskip', function(assert) { - var spy; +QUnit.test('skipLinearAdMode during ad playback does not trigger adskip', function(assert) { + var adskipSpy; - spy = sinon.spy(); - this.player.on('adskip', spy); - assert.strictEqual(this.player.ads.state, 'content-set'); + adskipSpy = sinon.spy(); + this.player.on('adskip', adskipSpy); this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); - this.player.trigger('play'); this.player.ads.startLinearAdMode(); - assert.strictEqual(this.player.ads.state, 'ad-playback'); this.player.ads.skipLinearAdMode(); - assert.strictEqual(this.player.ads.state, 'ad-playback'); - assert.strictEqual(spy.callCount, 0, 'adskip event should not trigger when skipLinearAdMode called in ad-playback state'); - - this.player.ads.endLinearAdMode(); - assert.strictEqual(this.player.ads.state, 'content-resuming'); - assert.strictEqual(this.player.ads.triggerevent, 'adend', 'The reason for content-resuming should have been adend'); - - this.player.trigger('playing'); - assert.strictEqual(this.player.ads.state, 'content-playback'); + assert.strictEqual(adskipSpy.callCount, 0, + 'adskip event should not trigger when skipLinearAdMode is called during an ad'); }); QUnit.test('adsready in content-playback triggers readyforpreroll', function(assert) { @@ -710,14 +495,9 @@ QUnit.test('adsready in content-playback triggers readyforpreroll', function(ass spy = sinon.spy(); this.player.on('readyforpreroll', spy); - assert.strictEqual(this.player.ads.state, 'content-set'); - + this.player.trigger('loadstart'); this.player.trigger('play'); - assert.strictEqual(this.player.ads.state, 'ads-ready?'); - this.player.trigger('adtimeout'); - assert.strictEqual(this.player.ads.state, 'content-playback'); - this.player.trigger('adsready'); assert.strictEqual(spy.getCall(0).args[0].type, 'readyforpreroll', 'readyforpreroll should have been triggered.'); }); @@ -727,12 +507,17 @@ QUnit.test('adsready in content-playback triggers readyforpreroll', function(ass // ---------------------------------- QUnit.test('player events during prerolls are prefixed if tech is reused for ad', function(assert) { - var prefixed, unprefixed; - - assert.expect(2); - - prefixed = sinon.spy(); - unprefixed = sinon.spy(); + var sawLoadstart = sinon.spy(); + var sawPlaying = sinon.spy(); + var sawPause = sinon.spy(); + var sawEnded = sinon.spy(); + var sawFirstplay = sinon.spy(); + var sawLoadedalldata = sinon.spy(); + var sawAdloadstart = sinon.spy(); + var sawAdpause = sinon.spy(); + var sawAdended = sinon.spy(); + var sawAdfirstplay = sinon.spy(); + var sawAdloadedalldata = sinon.spy(); // play a preroll this.player.on('readyforpreroll', function() { @@ -748,16 +533,34 @@ QUnit.test('player events during prerolls are prefixed if tech is reused for ad' }; // simulate video events that should be prefixed - this.player.on(['loadstart', 'playing', 'pause', 'ended', 'firstplay', 'loadedalldata'], unprefixed); - this.player.on(['adloadstart', 'adpause', 'adended', 'adfirstplay', 'adloadedalldata'], prefixed); + this.player.on('loadstart', sawLoadstart); + this.player.on('playing', sawPlaying); + this.player.on('pause', sawPause); + this.player.on('ended', sawEnded); + this.player.on('firstplay', sawFirstplay); + this.player.on('loadedalldata', sawLoadedalldata); + this.player.on('adloadstart', sawAdloadstart); + this.player.on('adpause', sawAdpause); + this.player.on('adended', sawAdended); + this.player.on('adfirstplay', sawAdfirstplay); + this.player.on('adloadedalldata', sawAdloadedalldata); this.player.trigger('firstplay'); this.player.trigger('loadstart'); this.player.trigger('playing'); this.player.trigger('loadedalldata'); this.player.trigger('pause'); this.player.trigger('ended'); - assert.strictEqual(unprefixed.callCount, 0, 'no unprefixed events fired'); - assert.strictEqual(prefixed.callCount, 5, 'prefixed events fired'); + assert.strictEqual(sawLoadstart.callCount, 0, 'no loadstart fired'); + assert.strictEqual(sawPlaying.callCount, 0, 'no playing fired'); + assert.strictEqual(sawPause.callCount, 0, 'no pause fired'); + assert.strictEqual(sawEnded.callCount, 0, 'no ended fired'); + assert.strictEqual(sawFirstplay.callCount, 0, 'no firstplay fired'); + assert.strictEqual(sawLoadedalldata.callCount, 0, 'no loadedalldata fired'); + assert.strictEqual(sawAdloadstart.callCount, 1, 'adloadstart fired'); + assert.strictEqual(sawAdpause.callCount, 1, 'adpause fired'); + assert.strictEqual(sawAdended.callCount, 1, 'adended fired'); + assert.strictEqual(sawAdfirstplay.callCount, 1, 'adfirstplay fired'); + assert.strictEqual(sawAdloadedalldata.callCount, 1, 'adloadedalldata fired'); }); QUnit.test('player events during midrolls are prefixed if tech is reused for ad', function(assert) { @@ -864,6 +667,7 @@ QUnit.test('player events during content playback are not prefixed', function(as unprefixed = sinon.spy(); // play content + this.player.trigger('loadstart'); this.player.trigger('play'); this.player.trigger('adsready'); this.player.trigger('adtimeout'); @@ -889,85 +693,110 @@ QUnit.test('startLinearAdMode should only trigger adstart from correct states', var adstart = sinon.spy(); this.player.on('adstart', adstart); - this.player.ads.state = 'preroll?'; this.player.ads.startLinearAdMode(); - assert.strictEqual(adstart.callCount, 1, 'preroll? state'); + assert.strictEqual(adstart.callCount, 0, 'Before play'); + + this.player.trigger('play'); - this.player.ads.state = 'content-playback'; this.player.ads.startLinearAdMode(); - assert.strictEqual(adstart.callCount, 2, 'content-playback state'); + assert.strictEqual(adstart.callCount, 0, 'Before adsready'); - this.player.ads.state = 'postroll?'; + this.player.trigger('adsready'); this.player.ads.startLinearAdMode(); - assert.strictEqual(adstart.callCount, 3, 'postroll? state'); + assert.strictEqual(adstart.callCount, 1, 'Preroll'); - this.player.ads.state = 'content-set'; this.player.ads.startLinearAdMode(); - this.player.ads.state = 'ads-ready?'; + assert.strictEqual(adstart.callCount, 1, 'During preroll playback'); + + this.player.ads.endLinearAdMode(); + this.player.trigger('playing'); + + this.player.ads.startLinearAdMode(); + assert.strictEqual(adstart.callCount, 2, 'Midroll'); + this.player.ads.startLinearAdMode(); - this.player.ads.state = 'ads-ready'; + assert.strictEqual(adstart.callCount, 2, 'During midroll playback'); + + this.player.ads.endLinearAdMode(); + this.player.trigger('playing'); + + this.player.trigger('ended'); this.player.ads.startLinearAdMode(); - this.player.ads.state = 'ad-playback'; + assert.strictEqual(adstart.callCount, 3, 'Postroll'); + this.player.ads.startLinearAdMode(); - assert.strictEqual(adstart.callCount, 3, 'other states'); + assert.strictEqual(adstart.callCount, 3, 'During postroll playback'); + + this.player.ads.endLinearAdMode(); + assert.strictEqual(adstart.callCount, 3, 'Ads done'); + }); QUnit.test('ad impl can notify contrib-ads there is no preroll', function(assert) { + this.player.trigger('loadstart'); + this.player.trigger('nopreroll'); + this.player.trigger('play'); + this.player.trigger('adsready'); + + assert.strictEqual(this.player.ads.isInAdMode(), false, 'not in ad mode'); +}); - this.player.ads.state = 'preroll?'; +// Same test as above with different event order because this used to be broken. +QUnit.test('ad impl can notify contrib-ads there is no preroll 2', function(assert) { + this.player.trigger('loadstart'); this.player.trigger('nopreroll'); - assert.strictEqual(this.player.ads.state, 'content-playback', 'no longer in preroll?'); + this.player.trigger('adsready'); + this.player.trigger('play'); + assert.strictEqual(this.player.ads.isInAdMode(), false, 'not in ad mode'); }); -QUnit.test('ad impl can notify contrib-ads there is no postroll', function(assert) { +QUnit.test('ad impl can notify contrib-ads there is no preroll 3', function(assert) { + this.player.trigger('loadstart'); + this.player.trigger('play'); + this.player.trigger('nopreroll'); + this.player.trigger('adsready'); - this.player.trigger('nopostroll'); - this.player.ads.state = 'content-playback'; - this.player.trigger('contentended'); - this.clock.tick(5); - assert.strictEqual(this.player.ads.state, 'content-playback', 'no longer in postroll?'); + assert.strictEqual(this.player.ads.isInAdMode(), false, 'not in ad mode'); +}); +QUnit.test('ad impl can notify contrib-ads there is no preroll 4', function(assert) { + this.player.trigger('loadstart'); + this.player.trigger('adsready'); + this.player.trigger('nopreroll'); + this.player.trigger('play'); + + assert.strictEqual(this.player.ads.isInAdMode(), false, 'not in ad mode'); }); -QUnit.test('ended event is sent with postroll', function(assert) { +QUnit.test('ended event is sent after nopostroll', function(assert) { var ended = sinon.spy(); - this.player.tech_.el_ = { - ended: true, - hasChildNodes: function() { - return false; - }, - removeAttribute: function() { - - } - }; this.player.on('ended', ended); - this.player.ads.state = 'content-playback'; - this.player.trigger('contentended'); - - this.clock.tick(10000); + this.player.trigger('loadstart'); + this.player.trigger('nopostroll'); + this.player.trigger('play'); + this.player.trigger('adsready'); + this.player.ads.skipLinearAdMode(); + this.player.trigger('contentended'); + this.clock.tick(1); assert.ok(ended.calledOnce, 'Ended triggered'); }); -QUnit.test('ended event is sent without postroll', function(assert) { +QUnit.test('ended event is sent with postroll', function(assert) { - var ended = sinon.spy(); + this.player.trigger('loadstart'); + this.player.trigger('adsready'); + this.player.trigger('play'); + this.player.ads.skipLinearAdMode(); - this.player.tech_.el_ = { - ended: true, - hasChildNodes: function() { - return false; - }, - removeAttribute: function() { + var ended = sinon.spy(); - } - }; this.player.on('ended', ended); - this.player.ads.state = 'content-playback'; + this.player.trigger('contentended'); this.clock.tick(10000); @@ -1062,9 +891,10 @@ QUnit.test('Check incorrect addition of vjs-live during ad-playback', function(a QUnit.test('Check for existence of vjs-live after ad-end for LIVE videos', function(assert) { - this.player.trigger('adstart'); + this.player.trigger('loadstart'); + this.player.trigger('adsready'); + this.player.trigger('play'); this.player.ads.startLinearAdMode(); - this.player.ads.state = 'ad-playback'; this.player.duration = function() {return Infinity;}; this.player.ads.endLinearAdMode(); this.player.trigger('playing'); @@ -1072,21 +902,28 @@ QUnit.test('Check for existence of vjs-live after ad-end for LIVE videos', assert.ok(this.player.hasClass('vjs-live'), 'We should be having vjs-live class here'); }); -QUnit.test('Plugin state resets after contentupdate', function(assert) { +QUnit.test('Plugin state resets after contentchanged', function(assert) { assert.equal(this.player.ads.disableNextSnapshotRestore, false); assert.equal(this.player.ads._contentHasEnded, false); assert.equal(this.player.ads.snapshot, null); + assert.equal(this.player.ads.snapshot, null); + assert.equal(this.player.ads.nopreroll_, null); + assert.equal(this.player.ads.nopostroll_, null); this.player.ads.disableNextSnapshotRestore = true; this.player.ads._contentHasEnded = true; this.player.ads.snapshot = {}; + this.player.ads.nopreroll_ = true; + this.player.ads.nopostroll_ = true; - this.player.trigger('contentupdate'); + this.player.trigger('contentchanged'); assert.equal(this.player.ads.disableNextSnapshotRestore, false); assert.equal(this.player.ads._contentHasEnded, false); assert.equal(this.player.ads.snapshot, null); + assert.equal(this.player.ads.nopreroll_, false); + assert.equal(this.player.ads.nopostroll_, false); }); @@ -1095,72 +932,49 @@ QUnit.test('Plugin sets adType as expected', function(assert) { // adType is unset originally assert.strictEqual(this.player.ads.adType, null); - // begins in content-set, preroll happens, adType is preroll - this.player.ads.state = 'content-set'; + // before preroll + this.player.trigger('loadstart'); this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'ads-ready'); assert.strictEqual(this.player.ads.adType, null); this.player.trigger('play'); - this.clock.tick(1); - assert.strictEqual(this.player.ads.state, 'preroll?'); assert.strictEqual(this.player.ads.adType, null); - // ad starts and finishes - this.player.trigger('adstart'); + // preroll starts and finishes + this.player.ads.startLinearAdMode(); assert.strictEqual(this.player.ads.adType, 'preroll'); - this.player.trigger('adend'); - this.clock.tick(1); + this.player.ads.endLinearAdMode(); assert.strictEqual(this.player.ads.adType, null); // content is playing, midroll starts this.player.trigger('playing'); - this.clock.tick(1); - this.player.trigger('adstart'); + this.player.ads.startLinearAdMode(); assert.strictEqual(this.player.ads.adType, 'midroll'); // midroll ends, content is playing - this.player.trigger('adend'); - this.clock.tick(1); + this.player.ads.endLinearAdMode(); assert.strictEqual(this.player.ads.adType, null); this.player.trigger('playing'); - this.clock.tick(1); // postroll starts this.player.trigger('contentended'); - this.clock.tick(1); - this.player.trigger('adstart'); + this.player.ads.startLinearAdMode(); assert.strictEqual(this.player.ads.adType, 'postroll'); // postroll ends - this.player.trigger('adend'); - this.clock.tick(1); + this.player.ads.endLinearAdMode(); assert.strictEqual(this.player.ads.adType, null); - this.clock.tick(1); // reset values - this.player.trigger('contentupdate'); - assert.strictEqual(this.player.ads.state, 'content-set'); + this.player.trigger('contentchanged'); assert.strictEqual(this.player.ads.adType, null); // check preroll case where play is observed this.player.trigger('play'); - assert.strictEqual(this.player.ads.state, 'ads-ready?'); assert.strictEqual(this.player.ads.adType, null); this.player.trigger('adsready'); - assert.strictEqual(this.player.ads.state, 'preroll?'); assert.strictEqual(this.player.ads.adType, null); - this.player.trigger('adstart'); - assert.strictEqual(this.player.ads.adType, 'preroll'); -}); - -QUnit.test('adserror ends linear ad mode ', function(assert) { - assert.strictEqual(this.player.ads._inLinearAdMode, false, 'before ad'); - this.player.trigger('play'); - this.player.trigger('adsready'); this.player.ads.startLinearAdMode(); - assert.strictEqual(this.player.ads._inLinearAdMode, true, 'during ad'); - this.player.trigger('adserror'); - assert.strictEqual(this.player.ads._inLinearAdMode, false, 'after adserror'); + assert.strictEqual(this.player.ads.adType, 'preroll'); }); if (videojs.browser.IS_IOS) { diff --git a/test/test.events-midroll.js b/test/test.events-midroll.js index 10fae7c2..b3fa8a00 100644 --- a/test/test.events-midroll.js +++ b/test/test.events-midroll.js @@ -35,7 +35,6 @@ QUnit.module('Events and Midrolls', { afterEach: function() { this.player.dispose(); - this.fixture.parentNode.removeChild(this.fixture); } }); @@ -104,7 +103,8 @@ QUnit.test('Midrolls', function(assert) { }); this.player.on('timeupdate', () => { - if (this.player.currentTime() > 2) { + videojs.log(this.player.currentTime(), this.player.currentSrc()); + if (this.player.currentTime() > 1.1) { seenOutsideAdModeBefore.forEach((event) => { assert.ok(!/^ad/.test(event), event + ' has no ad prefix before midroll'); @@ -128,6 +128,11 @@ QUnit.test('Midrolls', function(assert) { } }); + // Seek to right before the midroll + this.player.one('playing', () => { + this.player.currentTime(.9); + }); + this.player.play(); }); diff --git a/test/test.events-no-postroll.js b/test/test.events-no-postroll.js index 821263f5..3416680f 100644 --- a/test/test.events-no-postroll.js +++ b/test/test.events-no-postroll.js @@ -27,15 +27,16 @@ QUnit.module('Final Events With No Postroll', { afterEach: function() { this.player.dispose(); - this.fixture.parentNode.removeChild(this.fixture); } }); QUnit.test('final ended event with no postroll: just 1', function(assert) { var done = assert.async(); - var endedEvents = 0; + // Prevent the test from timing out by making it run faster + this.player.ads.settings.postrollTimeout = 1; + this.player.on('ended', () => { endedEvents++; }); diff --git a/test/test.events-no-preroll.js b/test/test.events-no-preroll.js index 67d44b5a..5dd6bd4d 100644 --- a/test/test.events-no-preroll.js +++ b/test/test.events-no-preroll.js @@ -27,7 +27,6 @@ QUnit.module('Initial Events With No Preroll', { afterEach: function() { this.player.dispose(); - this.fixture.parentNode.removeChild(this.fixture); } }); diff --git a/test/test.events-postroll.js b/test/test.events-postroll.js index 500d2691..b27bcf3f 100644 --- a/test/test.events-postroll.js +++ b/test/test.events-postroll.js @@ -36,7 +36,6 @@ QUnit.module('Events and Postrolls', { afterEach: function() { this.player.dispose(); - this.fixture.parentNode.removeChild(this.fixture); } }); diff --git a/test/test.events-preroll.js b/test/test.events-preroll.js index 02170132..80af746d 100644 --- a/test/test.events-preroll.js +++ b/test/test.events-preroll.js @@ -35,7 +35,6 @@ QUnit.module('Events and Prerolls', { afterEach: function() { this.player.dispose(); - this.fixture.parentNode.removeChild(this.fixture); } }); diff --git a/test/test.redispatch.js b/test/test.redispatch.js index ad29f24f..a09baf0a 100644 --- a/test/test.redispatch.js +++ b/test/test.redispatch.js @@ -22,8 +22,6 @@ QUnit.module('Redispatch', { }, ads: { - state: 'content-set', - snapshot: { ended: false, currentSrc: 'my vid' diff --git a/test/test.snapshot.js b/test/test.snapshot.js index e8ef9e8d..30806c1f 100644 --- a/test/test.snapshot.js +++ b/test/test.snapshot.js @@ -150,7 +150,6 @@ QUnit.test('snapshot does not resume playback after post-rolls', function(assert this.player.ads.endLinearAdMode(); this.player.trigger('playing'); this.player.trigger('ended'); - assert.strictEqual(this.player.ads.state, 'content-playback', 'Player should be in content-playback state after a post-roll'); assert.strictEqual(playSpy.callCount, 0, 'content playback should not have been resumed'); }); @@ -182,7 +181,6 @@ QUnit.test('snapshot does not resume playback after a burned-in post-roll', func this.player.currentTime(50); this.player.ads.endLinearAdMode(); this.player.trigger('ended'); - assert.strictEqual(this.player.ads.state, 'content-playback', 'Player should be in content-playback state after a post-roll'); assert.strictEqual(this.player.currentTime(), 50, 'currentTime should not be reset using burned in ads'); assert.notOk(loadSpy.called, 'player.load() should not be called if the player is ended.'); assert.notOk(playSpy.called, 'content playback should not have been resumed'); @@ -224,7 +222,6 @@ QUnit.test('snapshot does not resume playback after multiple post-rolls', functi this.player.ads.endLinearAdMode(); this.player.trigger('playing'); this.player.trigger('ended'); - assert.strictEqual(this.player.ads.state, 'content-playback', 'Player should be in content-playback state after a post-roll'); assert.notOk(playSpy.called, 'content playback should not resume'); }); @@ -249,7 +246,6 @@ QUnit.test('changing the source and then timing out does not restore a snapshot' this.player.src('http://example.com/movie2.mp4'); this.player.trigger('loadstart'); this.player.trigger('adtimeout'); - assert.strictEqual(this.player.ads.state, 'content-playback', 'playing the new content video after the ad timeout'); assert.strictEqual('http://example.com/movie2.mp4', this.player.currentSrc(), 'playing the second video'); });