From d11ef39ae8987f39d2a5749a16ca0291b8e9d602 Mon Sep 17 00:00:00 2001 From: Greg Smith Date: Fri, 23 Feb 2018 10:42:44 -0500 Subject: [PATCH] Ad state refactor (#315) --- README.md | 171 +++--- ad-states.graffle | Bin 2951 -> 0 bytes ad-states.png | Bin 58867 -> 18211 bytes example/app.js | 8 +- example/example-integration.js | 13 +- logo.png | Bin 0 -> 18625 bytes migration-guides/migrating-to-6.0.md | 53 ++ package.json | 5 +- scripts/umd.rollup.config.js | 2 +- src/adBreak.js | 76 +++ src/cancelContentPlay.js | 8 +- src/contentupdate.js | 17 +- src/plugin.js | 563 ++---------------- src/redispatch.js | 12 +- src/snapshot.js | 4 +- src/states.js | 25 + src/states/AdsDone.js | 19 + src/states/BeforePreroll.js | 79 +++ src/states/ContentPlayback.js | 49 ++ src/states/Midroll.js | 39 ++ src/states/Postroll.js | 145 +++++ src/states/Preroll.js | 228 ++++++++ src/states/abstract/AdState.js | 57 ++ src/states/abstract/ContentState.js | 27 + src/states/abstract/State.js | 128 +++++ test/karma.conf.js | 4 +- test/states/abstract/test.AdState.js | 62 ++ test/states/abstract/test.ContentState.js | 46 ++ test/states/abstract/test.State.js | 65 +++ test/states/test.AdsDone.js | 30 + test/states/test.BeforePreroll.js | 84 +++ test/states/test.ContentPlayback.js | 94 ++++ test/states/test.Midroll.js | 48 ++ test/states/test.Postroll.js | 154 +++++ test/states/test.Preroll.js | 145 +++++ test/test.ads.js | 658 ++++++++-------------- test/test.events-midroll.js | 9 +- test/test.events-no-postroll.js | 5 +- test/test.events-no-preroll.js | 1 - test/test.events-postroll.js | 1 - test/test.events-preroll.js | 1 - test/test.redispatch.js | 2 - test/test.snapshot.js | 4 - 43 files changed, 2087 insertions(+), 1054 deletions(-) delete mode 100644 ad-states.graffle create mode 100644 logo.png create mode 100644 migration-guides/migrating-to-6.0.md create mode 100644 src/adBreak.js create mode 100644 src/states.js create mode 100644 src/states/AdsDone.js create mode 100644 src/states/BeforePreroll.js create mode 100644 src/states/ContentPlayback.js create mode 100644 src/states/Midroll.js create mode 100644 src/states/Postroll.js create mode 100644 src/states/Preroll.js create mode 100644 src/states/abstract/AdState.js create mode 100644 src/states/abstract/ContentState.js create mode 100644 src/states/abstract/State.js create mode 100644 test/states/abstract/test.AdState.js create mode 100644 test/states/abstract/test.ContentState.js create mode 100644 test/states/abstract/test.State.js create mode 100644 test/states/test.AdsDone.js create mode 100644 test/states/test.BeforePreroll.js create mode 100644 test/states/test.ContentPlayback.js create mode 100644 test/states/test.Midroll.js create mode 100644 test/states/test.Postroll.js create mode 100644 test/states/test.Preroll.js 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 a63786ffea30d6421d6f47b93166a73cc336e9eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2951 zcmV;23wZP&iwFP!000030PS5_SKGQ4elEYlmzVo+AhG2kP7kFk3@!9B9TMQ4uB>&J zVk?Obj+NR9L%aCzSF#h&8JarKfck(PYisb<{`Mrv-TC-1aHKor`IxvLHkFFJDIwP) zHgEdedAg8KWWWWji>V-#!$fJ)Ztg&rYp<+{elXVleo*E8& zYGaG;ZoJ#MMUT4;i{d+U0w0lg?AqvIr^=tQLhMr1L*A~uQ=QySs<6JX1u1LtI&3+r z1U(P(n|C`t_1G)#vJqCuz{Nd}jE0qScK6wX-L8X-$|rH08X>^soU*KHC9$DL-X93RB=6TX5Zi$k&|eRNPvIT>*@)jnYdwH}ALDv&`UOB=}H0kK8{K$JjH4Qxq@ zX>cI%IaTE}ii%(qfLZDUN@FhRMhd4z zK+Qq1iybG4#JIQ^s?)HV0P92v^r4-6rBd6@D425EPTum0nc2&XAzd1aRNJFnr43yl z2Y;+#PXRh;Tf`!e7iqE<{_sZ~_FJpT8$a4z;!@gi+FiNbbuqhIedzg!%F+>X?hs|> z>PF~#_F>~_iYY2g$F(-N^UGy4HV68;x| z7WhQm9T+=D`S(6T(4IboBsdceu9`t-rp;nVg91oj5cVj^xyU6+h@md)Tasp&Pnj1^ zl{3N2PH^1TBI@I1LB7wCkZLZi~q`iRGo?c_~gs zj9{LugC@mEj)ZkjP!C#w4%MGKA{&^#SPo z@F43F=0vztB)&?K+tpjZEh=ybr?fc(5yfh!Ce)5lJ524Q3gi;C5s*%??GeWj`NFbt z0JSAj0GhFE3cxUnLil+p0+B8h0siU(e6z#=zs6VKHKn$03x2EeR^;E%ThWBKBD|Gi zZ$&P}SGgdZ^TJtqVP{2M)>$!)+0II>#+?PzfrTaEa=CVLPV0s5dDGW)A2 zAh;MNzW|^G7gOdJRSihgdO)IS0f{aLB&;^S7)n55a8ttD#hwf4$b-&8hGi0yeSDht ztqu`t{k)wpe(9Ll3_Br0sn-`usp6>S3f2aIzN({|nrVomn$NY5GfOG9K8SLW2U1TmJBcfD8CBhF5}HaRy!Bwrt!^;F-c~63j32MU40ihQUvp*47!e;(N~m zwxw;iMEC8Du~#g1iz z&e>!1tQ+D?u^;X&?1$s)haG6$mbf1-*t|L3ZSy>`Dh|WFYFUN$D#R4%&#h7oA*QeT z%x3t}xsN5vsA7OtmZ?dD!(q>9?8SMa zHxWOvx0x%YaTX_F@n;X)$&^_%1)+?%aOk%PCyM5wc#C7)e$eU&+sHp5)-8%E7D`8g z`WpMV6HDZjZi3X#1?-_n@`{B##KM3P?lTxtlbhl~K!4~ot8^$$8_j8+_CPHkqWJIS6 zQNzUpNKsCJGTR4vsf9g@oMQI?`|)dzGp=TwagAx4h=BuAOxz54YM)3z?*zj=$~K7y3)D%;#j?X_S-(cQoi;( zN7qh+mG2+wtrqz5?d#2%+&I2DY&5?ynokeT{r>0K!#_b?`YY_=>SR{?7tG1>QcFJt z&F44nN8jF2Kqb%(TCJ|@OgX5`%d4jsTnaknVsVM?n2kXNvbqlQa>*T)RZdKc3|G;7 zTBQaPaqc3H=_#(jB?}79UFUH=*e}U_!@<40%29GEhmkgi&|;M%;y~rAMy&e-+s@T- z3)8$_+K28P^i#e=SYyDP$Y2-)CRU-x)vzeOTg2fCDs!6*T?_qW-76P=8=*KVnep~l zK00(M_GYpDy4d&2YwsKEviN1Xj@=)59^*v7$yk&ojimQB1QiY+hR|hT`(Mc7gliR> z!|@`sm-OlqYZ0c(PYs4=^XoF;Ypiu69r}%@H$C!qJJcJYYO4Hl-k!m(>A=nr;`q+ECP2@+GGn88iFzsZ6>&n|lEO5i%K!hrx@s0M;VU77 xtPhy!hLc=F7Load%AjEBy8s@=o0apAlBy`DHsb5hVK~{{_&=ds-ad7e005fqxYqyx diff --git a/ad-states.png b/ad-states.png index fa9375552deab0e14c0eee72b31c369ecd8c914a..6eced6cc40659220cffa8b176a3a03ca4309a3af 100644 GIT binary patch literal 18211 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%0DE&w2%iw#7Dq)rH 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 YrjN(Jf*;;g;o&wF1 " + 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 0000000000000000000000000000000000000000..e811beed3b4f7c87760c6ceb1241d3824b6c3549 GIT binary patch literal 18625 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 { + 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'); });