diff --git a/.gitignore b/.gitignore index bc982e9d..0269b6a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ node_modules/ dist/ dist-cdn/ -e2e/ +certs/ # IDEs and editors /.idea @@ -18,3 +18,9 @@ e2e/ !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json + +# E2E +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0183ad94..0ca0ba23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,199 @@ # CHANGELOG +## 3.59.0 (2026-07-01) +* [Pull #933](https://github.com/fluid-player/fluid-player/pull/933) prevent skip-forward/back icons from getting stuck visible +* [Pull #934](https://github.com/fluid-player/fluid-player/pull/934) Panolens deprecation and security concerns + +## 3.58.0 (2026-02-24) +* [Pull #922](https://github.com/fluid-player/fluid-player/pull/922) In video banner, VAST impression is not tracked + +## 3.57.0 (2025-09-15) +* [Pull #904](https://github.com/fluid-player/fluid-player/pull/904) Add custom tracking events to Fluid Player + +## 3.56.0 (2025-09-04) +* [Pull #905](https://github.com/fluid-player/fluid-player/pull/905) Fix control bar width on VR cardboard mode toggle + +## 3.55.0 (2025-07-16) +* [Pull #901](https://github.com/fluid-player/fluid-player/pull/901) Subtitle vtt files are requested with credentials + +## 3.54.0 (2025-06-18) +* [Pull #897](https://github.com/fluid-player/fluid-player/pull/897) Video will start from beginning after VAST end + +## 3.53.0 (2025-05-13) +* [Pull #885](https://github.com/fluid-player/fluid-player/pull/885) Trying to download data from AdParameters vast/vpaid tag +* [Pull #889](https://github.com/fluid-player/fluid-player/pull/889) VPAID not playing (not initialized properly?) + +## 3.52.0 (2025-04-02) +* [Pull #882](https://github.com/fluid-player/fluid-player/pull/882) Add an option to set the subtitles' size + +## 3.51.0 (2025-02-20) +* [Pull #876](https://github.com/fluid-player/fluid-player/pull/876) Preload Ignored with HLS > Manifest and fragments loading twice before preroll, and after preroll +* [Pull #877](https://github.com/fluid-player/fluid-player/pull/877) Change in quality for hls broke video size + +## 3.50.0 (2025-01-23) +* [Pull #871](https://github.com/fluid-player/fluid-player/pull/871) ControlForwardBackward on double click doesnt work on Mobile Devices +* [Pull #872](https://github.com/fluid-player/fluid-player/pull/872) Subtitles Do Not Adjust Position When Controls Are Hidden + +## 3.49.0 (2025-01-16) +* [Pull #868](https://github.com/fluid-player/fluid-player/pull/868) HLS video quality switcher showing wrong values +* [Pull #869](https://github.com/fluid-player/fluid-player/pull/869) layoutControls.roundedCorners is not type defined + +## 3.48.0 (2024-12-18) +* [Pull #862](https://github.com/fluid-player/fluid-player/pull/862) Live Indicator + +## 3.47.0 (2024-12-13) +* [Pull #860](https://github.com/fluid-player/fluid-player/pull/860) Revert "Live Indicator" + +## 3.46.0 (2024-12-12) +* [Pull #854](https://github.com/fluid-player/fluid-player/pull/854) Add support for automatic landscape screen orientation + +## 3.45.0 (2024-12-09) +* [Pull #859](https://github.com/fluid-player/fluid-player/pull/859) Create E2E project for Fluid Player +* [Pull #857](https://github.com/fluid-player/fluid-player/pull/857) Live Indicator + +## 3.44.0 (2024-11-14) +* [Pull #851](https://github.com/fluid-player/fluid-player/pull/851) Mobile on click show control bar instead of pausing video + +## 3.43.0 (2024-11-13) +* [Pull #855](https://github.com/fluid-player/fluid-player/pull/855) Fluidplayer doesn't play Wrapper ads + +## 3.42.0 (2024-11-05) +* [Pull #836](https://github.com/fluid-player/fluid-player/pull/836) FallbackVastTags may not be working + +## 3.41.0 (2024-11-04) +* [Pull #850](https://github.com/fluid-player/fluid-player/pull/850) New event system breaks current implementations + +## 3.40.0 (2024-10-24) +* [Pull #844](https://github.com/fluid-player/fluid-player/pull/844) MiniPlayer Follow Up > Improving UX and be Google compliant +* [Pull #846](https://github.com/fluid-player/fluid-player/pull/846) Rounded corners on player +* [Pull #848](https://github.com/fluid-player/fluid-player/pull/848) Uncaught TypeError: Cannot read properties of undefined (reading 'Parser') when using subtitles + +## 3.39.0 (2024-10-15) +* [Pull #842](https://github.com/fluid-player/fluid-player/pull/842) Ads events +* [Pull #843](https://github.com/fluid-player/fluid-player/pull/843) Mid Roll is not working with Live stream videos + +## 3.38.0 (2024-10-02) +* [Pull #838](https://github.com/fluid-player/fluid-player/pull/838) TheatreAdvanced is not working and breaks player + +## 3.37.0 (2024-09-17) +* [Pull #821](https://github.com/fluid-player/fluid-player/pull/821) Added support for VAST ViewableImpression +* [Pull #833](https://github.com/fluid-player/fluid-player/pull/833) doubleclickFullscreen not working + +## 3.36.0 (2024-09-05) +* [Pull #832](https://github.com/fluid-player/fluid-player/pull/832) Show quality change icon in player when working with hls (m3u8) + +## 3.35.0 (2024-08-01) +* [Pull #825](https://github.com/fluid-player/fluid-player/pull/825) Fix image freezing +* [Pull #826](https://github.com/fluid-player/fluid-player/pull/826) Ads cannot be displayed when XML content has two Creative tags + +## 3.34.0 (2024-07-11) +* [Pull #819](https://github.com/fluid-player/fluid-player/pull/819) When users don't have suggested videos, there's a console error + +## 3.33.0 (2024-07-02) +* [Pull #818](https://github.com/fluid-player/fluid-player/pull/818) Suggested Videos Feature in Fluid Player + +## 3.32.0 (2024-03-21) +* [Pull #804](https://github.com/fluid-player/fluid-player/pull/804) htmlOnPauseBlock not being displayed with last version of FP + +## 3.31.0 (2024-01-25) +* [Pull #788](https://github.com/fluid-player/fluid-player/pull/788) Fluid Player doesn't work with Next.js +* [Pull #791](https://github.com/fluid-player/fluid-player/pull/791) Impression events aren't called for some VAST tags within the VAST wrapper chain + +## 3.30.0 (2024-01-11) +* [Pull #789](https://github.com/fluid-player/fluid-player/pull/789) Uncaught TypeError: Cannot set properties of null (setting 'slotIframe') while testing on VOD with VPAID Non Linear + +## 3.29.0 (2023-12-14) +* [Pull #783](https://github.com/fluid-player/fluid-player/pull/783) Fix test cases in the development sever +* [Pull #785](https://github.com/fluid-player/fluid-player/pull/785) FluidPlayer > Error > onPauseRoll In Video Banner VAST + +## 3.28.0 (2023-11-16) +* [Pull #776](https://github.com/fluid-player/fluid-player/pull/776) Fluid Player doesn't work with shadow DOM + +## 3.27.0 (2023-10-19) +* [Pull #771](https://github.com/fluid-player/fluid-player/pull/771) Method loadVpaid create frame without body + +## 3.26.0 (2023-10-12) +* [Pull #768](https://github.com/fluid-player/fluid-player/pull/768) Stretch posterImage to playerwindow +* [Pull #769](https://github.com/fluid-player/fluid-player/pull/769) Events don't differ from Ad and Main video + +## 3.25.0 (2023-09-21) +* [Pull #766](https://github.com/fluid-player/fluid-player/pull/766) adClickable and CTA parameters don't work when 2 VAST ad is loaded + +## 3.24.0 (2023-09-08) +* [Pull #763](https://github.com/fluid-player/fluid-player/pull/763) Player can't be restarted by destroying it and initializing it again + +## 3.23.0 (2023-08-28) +* [Pull #757](https://github.com/fluid-player/fluid-player/pull/757) Preload doesn't work for .m3u8 files when the player is serving in-stream ads +* [Pull #760](https://github.com/fluid-player/fluid-player/pull/760) Lighthouse says: Does not use passive listeners to improve scrolling performance + +## 3.22.0 (2023-08-17) +* [Pull #755](https://github.com/fluid-player/fluid-player/pull/755) Percentages are not accepted for "timer" property + +## 3.21.0 (2023-08-08) +* [Pull #748](https://github.com/fluid-player/fluid-player/pull/748) CurrentTime reset after switch HLS source on IOS +* [Pull #752](https://github.com/fluid-player/fluid-player/pull/752) Add "controlForwardBackward" setting inside the player instead of the control bar + +## 3.20.0 (2023-07-24) +* [Pull #749](https://github.com/fluid-player/fluid-player/pull/749) Mouse disappears +* [Pull #750](https://github.com/fluid-player/fluid-player/pull/750) In mobile and when using .m3u8 files, user needs to click the player twice in order to play the video + +## 3.19.0 (2023-07-12) +* [Pull #746](https://github.com/fluid-player/fluid-player/pull/746) Could not find a declaration file for module 'fluid-player' + +## 3.18.0 (2023-06-30) +* [Pull #744](https://github.com/fluid-player/fluid-player/pull/744) Failed to resolve import "cheerio/lib/api/traversing" + +## 3.17.0 (2023-06-16) +* [Pull #734](https://github.com/fluid-player/fluid-player/pull/734) MiniPlayer Mobile support +* [Pull #735](https://github.com/fluid-player/fluid-player/pull/735) MiniPlayer position configuration +* [Pull #739](https://github.com/fluid-player/fluid-player/pull/739) MiniPlayer Activate miniplayer when scrolling goes out of viewport +* [Pull #740](https://github.com/fluid-player/fluid-player/pull/740) MiniPlayer Allowing trigger MiniPlayer by code +* [Pull #736](https://github.com/fluid-player/fluid-player/pull/736) Fluid player doesn't track clicks on some sites + +## 3.16.0 (2023-05-31) +* [Pull #721](https://github.com/fluid-player/fluid-player/pull/721) Fluid Player loads midRoll even if timer is longer than main video +* [Pull #732](https://github.com/fluid-player/fluid-player/pull/732) MiniPlayer MVP + +## 3.15.0 (2023-05-09) +* [Pull #727](https://github.com/fluid-player/fluid-player/pull/727) Player required 2 clicks to play video New Iphone 11 Pro Chrome + +## 3.14.0 (2023-04-27) +* [Pull #722](https://github.com/fluid-player/fluid-player/pull/722) Support Video Waterfall VAST Response + +## 3.13.0 (2023-03-09) +* [Pull #714](https://github.com/fluid-player/fluid-player/pull/714) Support Video Waterfall VAST Response + +## 3.12.0 (2023-01-10) +* [Pull #707](https://github.com/fluid-player/fluid-player/pull/707) Decrease volumes & revenue on In-stream zones + +## 3.11.1 (2023-01-03) +* Update dependencies + +## 3.11.0 (2023-01-03) +* [Pull #614](https://github.com/fluid-player/fluid-player/pull/614) Add check for process being defined +* [Pull #561](https://github.com/fluid-player/fluid-player/pull/561) Allow customizing playbackRates + +## 3.10.0 (2022-12-15) +* [Pull #687](https://github.com/fluid-player/fluid-player/pull/687) LocalStorage not available in Chrome incognito mode is breaking JS +* [Pull #704](https://github.com/fluid-player/fluid-player/pull/704) In ad serving, the skip button when is without time delay it should trigger immediately the "skip ad" + +## 3.9.0 (2022-10-18) +* [Pull #688](https://github.com/fluid-player/fluid-player/pull/688) Selecting subtitle by default + +## 3.8.0 (2022-10-07) +* [Pull #685](https://github.com/fluid-player/fluid-player/pull/685) Fluidplayer > 'autoHide' in Desktop only works when the cursor is on top of the player. +* [Pull #689](https://github.com/fluid-player/fluid-player/pull/689) Mobile view_ Error in console and wrong positioning of the CTA text + +## 3.7.0 (2022-09-28) +* [Pull #650](https://github.com/fluid-player/fluid-player/pull/650) Play main video after preRoll ends not work + +## 3.6.0 (2022-08-24) +* [Pull #666](https://github.com/fluid-player/fluid-player/pull/666) CTA Overlay > cannot be removed by publisher + +## 3.5.0 (2022-07-20) +* [Pull #669](https://github.com/fluid-player/fluid-player/pull/669) Video CTA / Fluid Player +* [Pull #671](https://github.com/fluid-player/fluid-player/pull/671) Video CTA - update FP to support new CTA structure from VAST Tag + ## 3.4.0 (2022-07-05) * [Pull #652](https://github.com/fluid-player/fluid-player/pull/652) Video on demand and linear VPAID does not work on iOS Safari * [Pull #664](https://github.com/fluid-player/fluid-player/pull/664) Video CTA / Fluid Player diff --git a/e2e/ads_linear.spec.ts b/e2e/ads_linear.spec.ts new file mode 100644 index 00000000..03bc0504 --- /dev/null +++ b/e2e/ads_linear.spec.ts @@ -0,0 +1,181 @@ +import { test, expect } from '@playwright/test'; +import { waitForVideoToPlay, setVideoCurrentTime, getVideoCurrentTime } from './functions/video'; +import { waitForSpecificNetworkCall } from './functions/network'; + +test.describe('desktop ads', () => { + + test.beforeEach(async ({ page }) => { + console.log(`Running ${test.info().title}`); + await page.goto('/ads_linear.html'); + }); + + test('should navigate to the publishers advertised website on click', async ({ page }) => { + const fullPlayer = page.locator('#fluid_video_wrapper_fluid-player-e2e-case'); + const video = page.locator('video'); + + // start the video + fullPlayer.click(); + + await waitForVideoToPlay(video); + + // Set up a listener for the 'popup' event + // This listener listens for a new _blank tab to open + const [popupPromise] = await Promise.all([ + page.waitForEvent('popup'), // Listen for the popup event + fullPlayer.click() // click ad to open advertisers link + ]); + + // Prevent the tab from fully opening + const popup = popupPromise; + + // Verify the URL of the popup + const popupUrl = popup.url(); + console.log(`Popup URL: ${popupUrl}`); + expect(popupUrl).toBe('http://www.example.com/'); + + // Close the popup to prevent extra tabs, in case the above failed to prevent the opening of the new tab + await popup.close(); + }); + + test('should fire pre-, mid- and postRoll based on time', async ({ page }) => { + const fullPlayer = page.locator('#fluid_video_wrapper_fluid-player-e2e-case'); + const skipButton = page.locator('.skip_button'); + const video = page.locator('video'); + + // Start the video + fullPlayer.click(); + await waitForVideoToPlay(video); + + /** + * PREROLL + */ + await expect(skipButton).toHaveText(/Skip ad in 2/); + // Wait for skip ad timer + await page.waitForTimeout(2500); + await expect(skipButton).toHaveText(/Skip Ad /); + + // Skip the ad + await skipButton.click(); + + /** + * MIDROLL + */ + await page.waitForFunction(() => { + const videoElement = document.querySelector('video') as HTMLVideoElement; + // 15 is the length of the ad + return videoElement && Math.floor(videoElement.duration) !== 15; + }); + + // Midrolls don't trigger if you seek less then 5 seconds before their time + await setVideoCurrentTime(video, 35); + await page.waitForTimeout(5500); + await expect(skipButton).toHaveText(/Skip ad in 2/); + // Wait for skip ad timer + await page.waitForTimeout(2500); + await expect(skipButton).toHaveText(/Skip Ad /); + + // Skip the ad + await skipButton.click(); + + await page.waitForTimeout(500); + + await waitForVideoToPlay(video); + + const currentTime = await getVideoCurrentTime(video); + + // Check if the video resumes after the midroll at the correct time + expect(Math.floor(currentTime)).toEqual(39); + + /** + * POSTROLL + */ + + // Skip to the end + await page.waitForTimeout(500); + await video.evaluate((videoEl) => { + const vid = (videoEl as HTMLVideoElement); + vid.currentTime = Math.max(0, vid.duration) - 1; + }); + await page.waitForTimeout(1000); + await expect(skipButton).toHaveText(/Skip ad in 2/); + // Wait for skip ad timer + await page.waitForTimeout(2500); + await expect(skipButton).toHaveText(/Skip Ad /); + + await skipButton.waitFor({ state: 'visible', timeout: 5000 }); + // Skip the ad + await skipButton.click(); + + // Check if video is marked as ended + await page.waitForFunction(() => { + const video = document.querySelector('video'); + return video && !video.ended; + }); + }); + + test('ad should not be skipped when the ad countdown is not done', async ({ page }) => { + const fullPlayer = page.locator('#fluid_video_wrapper_fluid-player-e2e-case'); + const skipButton = page.locator('.skip_button'); + const video = page.locator('video'); + + // Start the video + fullPlayer.click(); + + await page.waitForFunction(() => { + const videoElement = document.querySelector('video'); + return videoElement && videoElement.duration > 0; + }, { timeout: 5000 }); + + const adDuration = await video.evaluate((vid) => { + const videoElement = vid as HTMLVideoElement; + return videoElement.duration; + }); + + // Click the button but it should not be skipped + // NOTE: don't add 'await' because it will wait until it can skip + skipButton.click(); + + // If the ad still has the same video duration, that means the video is not skipped + const videoDurationAfterClick = await video.evaluate((vid) => { + const videoElement = vid as HTMLVideoElement; + return videoElement.duration; + }); + + expect(videoDurationAfterClick).not.toBeFalsy(); + expect(adDuration).not.toBeFalsy(); + + expect(videoDurationAfterClick).toEqual(adDuration); + + await page.waitForTimeout(2000); + // Skip Ad + await skipButton.click(); + await page.waitForTimeout(500); + + const videoDuration = await video.evaluate((vid) => { + const videoElement = vid as HTMLVideoElement; + return videoElement.duration; + }); + + expect(videoDuration).not.toBeFalsy(); + expect(adDuration).not.toBeFalsy(); + + expect(videoDuration).not.toEqual(adDuration); + }); + + test('impression url should be called', async ({ page }) => { + const fullPlayer = page.locator('#fluid_video_wrapper_fluid-player-e2e-case'); + + // start the video + fullPlayer.click(); + + const request = await waitForSpecificNetworkCall( + page, + 'http://www.example.com/impression', + 'GET' + ); + + expect(request.url()).toBe('http://www.example.com/impression'); + }); + +}); + diff --git a/e2e/controls.spec.ts b/e2e/controls.spec.ts new file mode 100644 index 00000000..348e9c61 --- /dev/null +++ b/e2e/controls.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from '@playwright/test'; + +test.describe('desktop controls', () => { + + test.beforeEach(async ({ page }) => { + console.log(`Running ${test.info().title}`); + await page.goto('/controls.html'); + }); + + test('should toggle play/pause when clicking the player', async ({ page }) => { + // Selectors + const fullPlayer = page.locator('#fluid_video_wrapper_fluid-player-e2e-case'); + const playButton = page.locator('.fluid_button_play'); + const pauseButton = page.locator('.fluid_button_pause'); + + // Initial state checks + await expect(playButton).toBeVisible(); + await expect(pauseButton).not.toBeVisible(); + + // Video player should start playing + fullPlayer.click(); + + // Wait for video to start playing + await page.waitForFunction(() => { + const video = document.querySelector('video'); + return video && !video.paused; + }); + + // Verify playing state + await expect(playButton).not.toBeVisible(); + await expect(pauseButton).toBeVisible(); + + // Wait for 500ms so the browser doesn't reject the click + await page.waitForTimeout(1000); + // Video player should pause + fullPlayer.click(); + + // Wait for video to pause + await page.waitForFunction(() => { + const video = document.querySelector('video'); + return video && video.paused; + }); + + // Verify paused state + await expect(playButton).toBeVisible(); + await expect(pauseButton).not.toBeVisible(); + }); + + test('mouse should disappear when hovering the video', async ({ page }) => { + const video = page.locator('video'); + const playButton = page.locator('.fluid_button_play'); + + await playButton.click(); + + // Hover over the video + await video.hover(); + + await page.waitForTimeout(1500); + + // Evaluate the cursor CSS property of the video element or its parent + const isCursorHidden = await video.evaluate((vid) => { + const computedStyle = window.getComputedStyle(vid); + return computedStyle.cursor === 'none'; + }); + + expect(isCursorHidden).toBeTruthy(); // Assert that the cursor is hidden + }); +}); + diff --git a/e2e/functions/network.ts b/e2e/functions/network.ts new file mode 100644 index 00000000..4e84326d --- /dev/null +++ b/e2e/functions/network.ts @@ -0,0 +1,21 @@ +import { Page, Request } from 'playwright'; + +/** + * Wait for a specific network request and log it. + * + * @param page - The Playwright page instance. + * @param url - The URL of the request to wait for. + * @param method - The HTTP method of the request (default is 'GET'). + * @returns The intercepted request object. + */ +export async function waitForSpecificNetworkCall( + page: Page, + url: string, + method: string = 'GET' +): Promise { + const request = await page.waitForRequest((req) => + req.url() === url && req.method() === method + ); + + return request; +} \ No newline at end of file diff --git a/e2e/functions/video.ts b/e2e/functions/video.ts new file mode 100644 index 00000000..b3d8250b --- /dev/null +++ b/e2e/functions/video.ts @@ -0,0 +1,86 @@ +import { Locator, Page } from 'playwright'; + +/** + * Seek to a given time in the video + * + * @param video - Playwright video locator + * @param time - The time you want to seek to + */ +export async function setVideoCurrentTime(video: Locator, time: number): Promise { + await video.page().waitForFunction( + (vid) => { + const videoElement = vid as HTMLVideoElement | null; + return videoElement && videoElement.readyState >= 2; + }, + await video.elementHandle(), + { timeout: 10000 } + ); + + // Seek to the specified time + await video.evaluate((vid, t) => { + const videoElement = vid as HTMLVideoElement; + videoElement.currentTime = t; + }, time); +} + +/** + * Wait until the video duration has changed + * This way you can detect if the ad or content is loaded in + * + * @param page - The Playwright page instance + * @param initialDuration - The initial duration of the video element + * @param timeout + */ +export async function waitForVideoDurationChange( + page: Page, + initialDuration: number, + timeout: number = 10000 +): Promise { + await page.waitForFunction( + (initialDur) => { + const videoElement = document.querySelector('video') as HTMLVideoElement; + return videoElement.duration !== initialDur; + }, + initialDuration, + { timeout } + ); +} + +/** + * Get the current duration of the video + * + * @param video - Playwright video locator + * @returns video duration time + */ +export async function getVideoDuration(video: Locator): Promise { + return await video.evaluate((vid) => { + const videoElement = vid as HTMLVideoElement; + return videoElement.duration; + }); +} + +/** + * Get the current time of the video + * + * @param video - Playwright video locator + * @returns video current time + */ +export async function getVideoCurrentTime(video: Locator): Promise { + return await video.evaluate((vid) => { + const videoElement = vid as HTMLVideoElement; + return videoElement.currentTime; + }); +} + +/** + * Waits until the given video element starts playing. + * + * @param video - The Playwright Locator for the video element. + */ +export async function waitForVideoToPlay(video: Locator): Promise { + await video.evaluate((vid) => { + return new Promise((resolve) => { + vid.addEventListener('playing', () => resolve(), { once: true }); + }); + }); +} \ No newline at end of file diff --git a/e2e/skip_stuck.spec.ts b/e2e/skip_stuck.spec.ts new file mode 100644 index 00000000..3c906555 --- /dev/null +++ b/e2e/skip_stuck.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; + +/** + * Regression test for the skip-forward / skip-backward animation icons + * getting stuck visible when arrow keys are mashed during video buffering. + * + * Root cause: skipRelative() adds the `.animate` class and relies on a + * transitionend event to remove it. Under main-thread pressure (rapid + * seeking that forces buffering), the browser can drop transition events + * and the class never gets removed, leaving the +10s / -10s icon + * permanently visible on the player. + */ +test.describe('skip animation icons', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/skip_return.html'); + // Click the player to start playback AND install the document-level + // keyboard handler (captureKey is bound on first wrapper click). + await page.locator('#fluid_video_wrapper_fluid-player-e2e-case').click(); + await page.waitForFunction(() => { + const v = document.querySelector('video'); + return v && !v.paused && v.readyState >= 2; + }); + }); + + test('icons do not get stuck when alternating arrow keys rapidly', async ({ page }) => { + // Seek near the end so each skip bounces around an unbuffered region, + // creating the main-thread pressure that surfaces the bug. + await page.evaluate(() => { + const v = document.querySelector('video')!; + v.currentTime = Math.max(0, (v.duration || 60) - 30); + }); + + // Try the alternation pattern multiple times; the bug is racy so a + // single burst may not trigger pre-fix. After the fix, no burst + // should ever leave the class stuck. + for (let attempt = 0; attempt < 5; attempt++) { + for (let i = 0; i < 40; i++) { + await page.keyboard.press(i % 2 === 0 ? 'ArrowRight' : 'ArrowLeft'); + } + + // Give transitions time to settle: 150ms fade-in + 400ms fade-out + slack. + await page.waitForTimeout(1200); + + const stuck = await page.evaluate(() => { + const f = document.querySelector('.fluid_player_skip_offset__forward-icon')!; + const b = document.querySelector('.fluid_player_skip_offset__backward-icon')!; + return { + fwd: { anim: f.classList.contains('animate'), op: getComputedStyle(f).opacity }, + back: { anim: b.classList.contains('animate'), op: getComputedStyle(b).opacity }, + }; + }); + + expect(stuck.fwd.anim, `attempt ${attempt}: forward icon stuck`).toBe(false); + expect(stuck.back.anim, `attempt ${attempt}: backward icon stuck`).toBe(false); + } + }); +}); diff --git a/e2e/snapshots/baseline-sv-grid.png b/e2e/snapshots/baseline-sv-grid.png new file mode 100644 index 00000000..1c424044 Binary files /dev/null and b/e2e/snapshots/baseline-sv-grid.png differ diff --git a/e2e/suggested_videos.spec.ts b/e2e/suggested_videos.spec.ts new file mode 100644 index 00000000..47d365a1 --- /dev/null +++ b/e2e/suggested_videos.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; +import { waitForVideoToPlay, setVideoCurrentTime, getVideoDuration } from './functions/video'; + +test.describe('suggested videos', () => { + + test.beforeEach(async ({ page }) => { + console.log(`Running ${test.info().title}`); + }); + + test('should show up in 4x3 grid at the end of the video if it is configured and after postRoll', async ({ page }) => { + await page.goto('/suggested_videos_e2e.html'); + + const fullPlayer = page.locator('#fluid_video_wrapper_fluid-player-e2e-case'); + const video = page.locator('video'); + + fullPlayer.click(); + await waitForVideoToPlay(video); + + const videoDuration = await getVideoDuration(video); + await setVideoCurrentTime(video, videoDuration - 5); + + await waitForVideoToPlay(video); + await page.waitForTimeout(5500); + + const suggestedVideosGrid = page.locator('.suggested_tile_grid'); + await expect(suggestedVideosGrid).not.toBeVisible(); + + await page.waitForTimeout(3000); + const skipButton = page.locator('.skip_button'); + skipButton.click(); + + await expect(suggestedVideosGrid).toBeVisible(); + expect(await suggestedVideosGrid.screenshot()).toMatchSnapshot('baseline-sv-grid.png', { threshold: 0.02 }); + }); + +}); + diff --git a/package.json b/package.json index 134c2e45..91fd602a 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,21 @@ { "name": "fluid-player", - "version": "3.4.0", + "version": "3.59.0", "description": "Fluid Player is a free HTML5 video player", "main": "src/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", "build": "webpack --mode=production", - "build-cdn": "webpack --mode=production --dist=current && webpack --mode=production --dist=versioned", + "build-cdn": "webpack --mode=production --env dist='current' && webpack --mode=production --env dist='versioned'", "build-dev": "webpack --mode=development", "build-dev-debug": "webpack --mode=development --debug", - "dev-server": "webpack-dev-server --mode=development --host 0.0.0.0", - "dev-server-debug": "webpack-dev-server --mode=development --debug --host 0.0.0.0", - "dev-server-test": "webpack-dev-server --mode=production --no-live-reload --host 0.0.0.0" + "dev-server": "webpack serve --mode=development --host 0.0.0.0", + "dev-server-https": "webpack serve --mode=development --host 0.0.0.0 --env https", + "dev-server-debug": "webpack serve --mode=development --env debug --host 0.0.0.0", + "dev-server-test": "webpack serve --mode=production --no-live-reload --host 0.0.0.0", + "start": "npm run dev-server-debug", + "e2e-ui": "npx playwright test --ui", + "e2e-report": "npx playwright show-report", + "e2e": "npx playwright test" }, "repository": { "type": "git", @@ -32,25 +36,30 @@ "IE 11" ], "dependencies": { - "dashjs": "~3.1.1", - "hls.js": "~0.13.2", - "panolens": "~0.11.0", - "videojs-vtt.js": "~0.15.2", - "es6-promise": "^4.2.8" + "@photo-sphere-viewer/core": "^5.14.1", + "@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.1", + "@photo-sphere-viewer/gyroscope-plugin": "^5.14.1", + "@photo-sphere-viewer/video-plugin": "^5.14.1", + "dashjs": "^4.5.2", + "es6-promise": "^4.2.8", + "hls.js": "^1.5.13", + "three": "^0.179.1", + "videojs-vtt.js": "^0.15.4" }, "devDependencies": { - "@babel/core": "^7.9.6", - "@babel/preset-env": "^7.9.6", - "babel-loader": "^8.1.0", + "@babel/core": "^7.20.12", + "@babel/preset-env": "^7.20.2", + "@playwright/test": "^1.49.0", + "@types/node": "^22.9.1", + "babel-loader": "^9.1.2", "cheerio": "^1.0.0-rc.3", - "copy-webpack-plugin": "^5.1.1", - "css-loader": "^3.4.2", - "html-webpack-plugin": "^4.3.0", + "copy-webpack-plugin": "^11.0.0", + "css-loader": "^6.7.3", + "html-webpack-plugin": "^5.5.0", "semver": "^7.3.2", - "style-loader": "^1.1.3", - "svg-url-loader": "^4.0.0", - "webpack": "^4.43.0", - "webpack-cli": "^3.3.11", - "webpack-dev-server": "^3.10.3" + "style-loader": "^3.3.1", + "webpack": "^5.75.0", + "webpack-cli": "^5.1.1", + "webpack-dev-server": "^4.11.1" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..e71cea4b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,73 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:8080', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + // { + // name: 'chromium', + // use: { ...devices['Desktop Chrome'] }, + // }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + snapshotPathTemplate: '{testDir}/snapshots/{arg}{ext}', // Use the specified filename + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/src/constants/constants.js b/src/constants/constants.js new file mode 100644 index 00000000..fef3daf8 --- /dev/null +++ b/src/constants/constants.js @@ -0,0 +1,38 @@ +export const trackingEventTypes = { + // Player Operation Metrics (use in linear and non-linear ads) + muteEvent: 'mute', + unmuteEvent: 'unmute', + pauseEvent: 'pause', + resumeEvent: 'resume', + rewindEvent: 'rewind', + fullscreenEvent: 'fullscreen', + skipEvent: 'skip', + playerExpandEvent: 'playerExpand', + playerCollapseEvent: 'playerCollapse', + impressionEvent: 'impression', + viewImpressionEvent: 'viewImpression', + + // Use in linear ad metrics + startEvent: 'start', + firstQuartileEvent: 'firstQuartile', + midpointEvent: 'midpoint', + thirdQuartileEvent: 'thirdQuartile', + completeEvent: 'complete', + progressEvent: 'progress', + + // Use in non-linear ad metrics + creativeViewEvent: 'creativeView', + collapseEvent: 'collapse', + adCollapseEvent: 'adCollapse', + closeEvent: 'close', + acceptInvitationEvent: 'acceptInvitation', + + iconClickThroughEvent: 'iconClickThrough', +} + +export const displayModes = { + FULLSCREEN: 'fullscreen', + THEATER: 'theaterMode', + MINI_PLAYER: 'miniPlayer', + NORMAL: 'normal', +} diff --git a/src/css/fluidplayer.css b/src/css/fluidplayer.css index ca5fc65d..2e9f6704 100644 --- a/src/css/fluidplayer.css +++ b/src/css/fluidplayer.css @@ -1,3 +1,6 @@ +@import "./suggestedVideos.css"; +@import "./menus.css"; + .fluid_video_wrapper { animation: none; animation-delay: 0; @@ -306,6 +309,11 @@ cursor: pointer; z-index: 10; padding: 13px 21px 13px 13px; + max-width: 50%; +} + +.fluid_ad_cta.left { + text-align: left; } .fluid_ad_cta a { @@ -320,6 +328,7 @@ } .fluid_html_on_pause, +.fluid_html_on_pause_container, .fluid_pseudo_poster { position: absolute; top: 0; @@ -335,13 +344,14 @@ pointer-events: none; } +.fluid_html_on_pause_container *, .fluid_html_on_pause * { pointer-events: auto; } /*Mobile Layout*/ .fluid_video_wrapper.mobile .skip_button { - bottom: 75px; + bottom: 50px; } /* @@ -390,8 +400,7 @@ /* Duration */ .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_fluid_control_duration { display: inline-block; - position: absolute; - left: 32px; + white-space: nowrap; height: 24px; font-family: -apple-system, BlinkMacSystemFont, 'segoe ui', roboto, oxygen-sans, ubuntu, cantarell, 'helvetica neue', 'arial', sans-serif, 'apple color emoji', 'segoe ui emoji', 'segoe ui symbol'; font-size: 13px; @@ -437,9 +446,22 @@ opacity: 1 !important; } +/* Expands the player wrapper to fill the viewport in VR mode so the stereo + split is always centred and the canvas stays within the player's bounds. */ +.fluid_video_wrapper.fluid_vr_active { + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + z-index: 9999 !important; + border-radius: 0 !important; +} + .fluid_video_wrapper.fluid_player_layout_default .fluid_vr_container { color: white; position: absolute; + top: 0; bottom: 0; left: 0; right: 0; @@ -453,6 +475,11 @@ pointer-events: none; } +.fluid_video_wrapper.fluid_player_layout_default .fluid_vr_container .psv-container, +.fluid_video_wrapper .fluid_vr_container canvas { + pointer-events: auto; +} + .fluid_video_wrapper.fluid_player_layout_default .fluid_vr_container .fluid_vr_joystick_panel { height: 96px; width: 72px; @@ -608,6 +635,7 @@ height: auto; z-index: 1; text-align: center; + transition: bottom 0.2s ease; } .fluid_video_wrapper.fluid_player_layout_default .fluid_subtitles_container div { @@ -714,6 +742,58 @@ _:-ms-lang(x), user-select: none; } +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_control_duration { + display: flex; + align-items: center; +} + +.fluid_button_live_indicator { + margin-right: 10px; + display: inline-flex; + align-items: center; + padding: 2px 5px; + background-color: red; + color: white; + border-radius: 4px; + font-weight: bold; + font-size: 11px; + margin-bottom: 3px; +} + +.live_circle { + position: relative; + display: inline-block; + width: 6px; + height: 6px; + background-color: transparent; + border: 1px solid #ffffff; + border-radius: 50%; + margin-left: 3px; +} + +.live_circle::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 4px; + height: 4px; + background-color: #ffffff; + border-radius: 50%; +} + +/* On smaller devices (mobile) */ +@media (max-width: 768px) { + .fluid_button_live_indicator { + position: absolute; + font-size: 8px; + top: -30px; + left: -40px; + padding: 2px 3px !important; + } +} + .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right { left: 60px; right: 20px; @@ -888,7 +968,8 @@ _:-ms-lang(x), } .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button.fluid_button_fullscreen, -.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button.fluid_button_fullscreen_exit { +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button.fluid_button_fullscreen_exit, +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button.fluid_button_mini_player { float: right; } @@ -917,6 +998,7 @@ _:-ms-lang(x), } .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_subtitles .fluid_subtitles_list, +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_subtitles .fluid_subtitle_size_menu, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_video_source .fluid_video_sources_list, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_video_playback_rates { position: absolute; @@ -934,9 +1016,11 @@ _:-ms-lang(x), text-align: start; width: max-content; padding: 0.5em; + overflow: auto; } .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_subtitles .fluid_subtitles_list .fluid_subtitle_list_item, +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_subtitles .fluid_subtitle_size_menu .fluid_subtitle_size_button, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_video_source .fluid_video_sources_list .fluid_video_source_list_item { padding: 12px 34px 12px 24px; line-height: 15px; @@ -944,6 +1028,7 @@ _:-ms-lang(x), .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_video_source .fluid_video_sources_list .fluid_video_source_list_item:hover, .fluid_video_playback_rates_item:hover, +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_subtitles .fluid_subtitle_size_menu .fluid_subtitle_size_button:hover, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_subtitles .fluid_subtitles_list .fluid_subtitle_list_item:hover { background-color: #3a3a3a; } @@ -961,6 +1046,7 @@ _:-ms-lang(x), } /* Button Icons */ +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_live_indicator, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_play, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_pause, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_skip_back, @@ -974,7 +1060,8 @@ _:-ms-lang(x), .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_download, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_theatre, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_subtitles, -.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_cardboard { +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_cardboard, +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_mini_player { display: inline-block; text-align: left; height: 24px; @@ -995,7 +1082,8 @@ _:-ms-lang(x), .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_download:before, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_theatre:before, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_subtitles:before, -.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_cardboard:before { +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_cardboard:before, +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_mini_player:before{ background: url('../static/fluid-icons.svg') no-repeat; position: absolute; height: 24px; @@ -1070,6 +1158,16 @@ _:-ms-lang(x), background-position: -2px -2px; } +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_mini_player:before { + background: url('../static/miniplayer-toggle-on.svg') no-repeat 0 0; + background-size: 20px; +} + +.fluid_video_wrapper.fluid_mini_player_mode .fluid_controls_container .fluid_button.fluid_button_mini_player:before { + background: url('../static/miniplayer-toggle-off.svg') no-repeat 0 0; + background-size: 20px; +} + .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_skip_back { margin-left: 5px; } @@ -1087,7 +1185,8 @@ _:-ms-lang(x), .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_download:hover:before, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_theatre:hover:before, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_subtitles:hover:before, -.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_cardboard:hover:before { +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_cardboard:hover:before, +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_button.fluid_button_mini_player:hover:before { opacity: 1; } @@ -1205,6 +1304,11 @@ _:-ms-lang(x), .add_icon_clickthrough { color: #F2C94C; line-height: 18px; + text-overflow: ellipsis; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + display: inline-block; } .add_icon_clickthrough:before { @@ -1212,7 +1316,7 @@ _:-ms-lang(x), height: 18px; width: 18px; top: 30px; - padding: 1px 22px 0 0; + padding: 3px 22px 0 0; content: ""; background-position: -162px -57px; } @@ -1237,6 +1341,7 @@ _:-ms-lang(x), } .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_subtitles .fluid_subtitles_list, +.fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_subtitles .fluid_subtitle_size_menu, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_button_video_source .fluid_video_sources_list, .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_video_playback_rates { z-index: 888888 !important; @@ -1257,6 +1362,13 @@ _:-ms-lang(x), box-shadow: 0px 15px 25px rgba(0, 0, 0, 0.8); } +.fluid_mini_player_mode { + position: fixed; + bottom: 10px; + right: 10px; + z-index: 10; +} + .source_button_icon { background: url('../static/fluid-icons.svg') no-repeat; float: left; @@ -1281,17 +1393,17 @@ _:-ms-lang(x), opacity: 1 !important; } -.subtitle_selected { +.subtitle_selected, .subtitle_size_selected { opacity: 1 !important; } @media only screen and (min-device-width: 375px) { - .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_fluid_control_duration { - left: 105px; + .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_control_duration { + padding-left: 95px; } - .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container.no_volume_bar .fluid_fluid_control_duration { - left: 32px; + .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_control_duration.no_volume_bar { + padding-left: 32px; } .fluid_video_wrapper.fluid_player_layout_default .fluid_controls_container .fluid_controls_right .fluid_control_volume_container { @@ -1315,3 +1427,189 @@ _:-ms-lang(x), content: 'HD'; padding-left: 3px; } + +/** MiniPlayer */ + +.fluid_video_wrapper.fluid_player_layout_default .mini-player-close-button-wrapper { + display: none; +} + +.fluid_video_wrapper.fluid_mini_player_mode .mini-player-close-button-wrapper { + position: absolute; + background: rgb(0,0,0); + background: linear-gradient(45deg, rgba(0,0,0,0) 90%, rgba(0,0,0,0.6) 110%); + height: 100%; + width: 100%; + top: 0; + right: 0; + z-index: 31; + display: block; + opacity: 0; + -webkit-transition: opacity 0.3s ease-in-out; + -moz-transition: opacity 0.3s ease-in-out; + -ms-transition: opacity 0.3s ease-in-out; + -o-transition: opacity 0.3s ease-in-out; + transition: opacity 0.3s ease-in-out; + pointer-events: none; +} + +.fluid_video_wrapper.fluid_mini_player_mode .mini-player-close-button { + position: absolute; + background: transparent url("../static/close-icon.svg") no-repeat scroll center center; + height: 22px; + width: 22px; + top: 6px; + right: 6px; + background-size: 22px; + cursor: pointer; + z-index: 32; + display: block; + pointer-events: all; +} + +.fluid_video_wrapper.fluid_mini_player_mode:hover .mini-player-close-button-wrapper { + opacity: 1; +} + +.fluid_video_wrapper.fluid_mini_player_mode .disable-mini-player-mobile { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.fluidplayer-miniplayer-player-placeholder { + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 1.5rem; + font-family: -apple-system, BlinkMacSystemFont, 'segoe ui', roboto, oxygen-sans, ubuntu, cantarell, 'helvetica neue', 'arial', sans-serif, 'apple color emoji', 'segoe ui emoji', 'segoe ui symbol'; + background: #000 url('../static/miniplayer-toggle-on.svg') no-repeat 50% calc(50% - 48px); + background-size: 48px; + cursor: pointer; +} + +.fluid_video_wrapper.fluid_mini_player_mode.fluid_video_wrapper.fluid_mini_player_mode--top-left { + top: 10px; + left: 10px; +} + +.fluid_video_wrapper.fluid_mini_player_mode.fluid_video_wrapper.fluid_mini_player_mode--top-right { + top: 10px; + right: 10px; +} + +.fluid_video_wrapper.fluid_mini_player_mode.fluid_video_wrapper.fluid_mini_player_mode--bottom-left { + bottom: 10px; + left: 10px; +} + +.fluid_video_wrapper.fluid_mini_player_mode.fluid_video_wrapper.fluid_mini_player_mode--bottom-right { + bottom: 10px; + right: 10px; +} + +@media screen and (max-width: 768px) { + .fluid_video_wrapper.fluid_mini_player_mode > *:not(video, .ad_countdown, .fluid_nonLinear_ad, .disable-mini-player-mobile) { + display: none; + } + + .fluid_video_wrapper.fluid_mini_player_mode .fluid_nonLinear_ad { + z-index: 100; + } + + .fluid_video_wrapper.fluid_mini_player_mode .fluid_nonLinear_bottom { + bottom: 16px; + } + + .fluid_video_wrapper.fluid_mini_player_mode .fluid_nonLinear_top { + top: 16px; + } + + .fluid_video_wrapper.fluid_mini_player_mode .ad_countdown { + display: inline-block !important; + } + + .fluid_video_wrapper.fluid_mini_player_mode .disable-mini-player-mobile { + display: block; + touch-action: none; + } + + .fluidplayer-miniplayer-player-placeholder { + font-size: 1.25rem !important; + background-size: 32px !important; + background-position-y: calc(50% - 32px) !important; + } +} + +.fluid_video_wrapper .fluid_player_skip_offset { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + display: grid; + grid-template-columns: 1fr 1fr; + pointer-events: none; +} + +.fluid_video_wrapper .fluid_player_skip_offset__backward { + display: flex; + align-items: center; + margin: 10% 10% 10% 0; +} + +.fluid_video_wrapper .fluid_player_skip_offset__backward-icon { + background: rgba(0, 0, 0, .5) url('../static/skip-backward.svg') no-repeat -2px 3px; + width: 150px; + height: 150px; + background-size: contain; + opacity: 0; + transition: opacity 400ms ease-in; + border-radius: 150px; + margin-left: 20%; + pointer-events: none; + background-origin: content-box; + padding: 10px; +} + +.fluid_video_wrapper .fluid_player_skip_offset__forward { + display: flex; + align-items: center; + flex-direction: row-reverse; + margin: 10% 0 10% 10%; +} + +.fluid_video_wrapper .fluid_player_skip_offset__forward-icon { + background: rgba(0, 0, 0, .5) url('../static/skip-forward.svg') no-repeat -2px 3px; + width: 150px; + height: 150px; + background-size: cover; + opacity: 0; + transition: opacity 400ms ease-in; + border-radius: 150px; + margin-right: 20%; + pointer-events: none; + background-origin: content-box; + padding: 10px; +} + +.fluid_video_wrapper .fluid_player_skip_offset__backward-icon.animate, +.fluid_video_wrapper .fluid_player_skip_offset__forward-icon.animate { + opacity: 1; + transition: opacity 150ms ease-out; +} + +@media screen and (max-width: 768px) { + .fluid_video_wrapper .fluid_player_skip_offset__backward-icon, + .fluid_video_wrapper .fluid_player_skip_offset__forward-icon { + width: 50px; + height: 50px; + border-radius: 50px; + background-position-x: 0; + background-position-y: 0; + padding: 5px; + } +} diff --git a/src/css/menus.css b/src/css/menus.css new file mode 100644 index 00000000..ccdb69a5 --- /dev/null +++ b/src/css/menus.css @@ -0,0 +1,34 @@ +/** +* Fluid menu styles +*/ + + +/** +* Fluid sub menu styles +*/ +.fluid_sub_menu_button { + position: relative; + cursor: pointer; + padding: 0 20px; + border-bottom: 1px solid #e0e0e0; +} + +.fluid_sub_menu_button.arrow-right::after { + content: '▶'; + position: absolute; + right: 5px; + top: 50%; + transform: translateY(-50%); + font-size: 12px; + color: #ffffff; +} + +.fluid_sub_menu_button.arrow-left::before { + content: '◀'; + position: absolute; + left: 5px; + top: 50%; + transform: translateY(-50%); + font-size: 12px; + color: #ffffff; +} \ No newline at end of file diff --git a/src/css/suggestedVideos.css b/src/css/suggestedVideos.css new file mode 100644 index 00000000..18c1126f --- /dev/null +++ b/src/css/suggestedVideos.css @@ -0,0 +1,143 @@ +.suggested_tile_grid { + --thumbnail-height: 120px; + } + +.suggested_tile_grid { + position: absolute; + left: 0; + width: 100%; + z-index: 100; + display: flex; + height: var(--thumbnail-height); + overflow-x: auto; + overflow-y: hidden; + justify-content: flex-start; + bottom: 53px; + top: initial; + column-gap: 10px; + row-gap: 10px; + white-space: nowrap; +} + +.suggested_tile { + aspect-ratio: 16/9; + display: flex; + justify-content: center; + align-items: center; + position: relative; + border-radius: 2px; + flex-shrink: 0; + width: calc(var(--thumbnail-height) * (16/9)); + height: var(--thumbnail-height); + + img { + height: -webkit-fill-available; + } +} + +.suggested_tile_image { + max-width: 100%; + max-height: 100%; +} + +.suggested_tile:first-child { + margin-left: 20px; +} + +.suggested_tile:last-child { + margin-right: 20px; +} + +.suggested_tile_overlay { + position: absolute; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + color: #ffffff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.25s; +} + +.suggested_tile_overlay .suggested_tile_title { + position: absolute; + bottom: 0px; + left: 10px; +} + +.suggested_tile_overlay--blur { + backdrop-filter: blur(5px); +} + +.suggested_tile_overlay>* { + transform: translateY(20px); + transition: transform 0.25s; +} + +.suggested_tile_overlay:hover { + opacity: 1; +} + +.suggested_tile_overlay:hover>* { + transform: translateY(0); +} + +.suggested_tile:hover { + cursor: pointer; +} + +@media only screen and (max-width: 600px) and (orientation: portrait) { + .suggested_tile_grid { + display: none; + } +} + +/* Medium devices like tablet portrait */ +@media only screen and (min-width: 992px) { + .suggested_tile_grid { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: calc(100% - 53px); + display: grid; + grid-template-columns: repeat(3, 20%); + grid-template-rows: min-content min-content; + column-gap: 40px; + row-gap: 10px; + z-index: 100; + align-content: center; + justify-content: center; + } + + /* hide the last 6 video tiles */ + .suggested_tile:nth-child(n+7) { + display: none; + } + + .suggested_tile:first-child { + margin-left: 0px; + } + + .suggested_tile:last-child { + margin-right: 0px; + } +} + +/* Desktop */ +@media only screen and (min-width: 1200px) { + .suggested_tile_grid { + grid-template-columns: repeat(4, 20%); + column-gap: 10px; + } + .suggested_tile { + width: initial; + height: initial; + } + .suggested_tile:nth-child(n+7) { + display: flex; + } +} diff --git a/src/fluidplayer.js b/src/fluidplayer.js index 34520eef..14d39fd6 100644 --- a/src/fluidplayer.js +++ b/src/fluidplayer.js @@ -1,5 +1,7 @@ 'use strict'; +import { displayModes } from './constants/constants'; + // Player modules import VPAIDModule from './modules/vpaid'; import VASTModule from './modules/vast'; @@ -8,7 +10,9 @@ import SubtitleModule from './modules/subtitles'; import TimelineModule from './modules/timeline'; import AdSupportModule from './modules/adsupport'; import StreamingModule from './modules/streaming'; -import UtilsModule from './modules/utils' +import UtilsModule from './modules/utils'; +import SuggestedVideosModule from './modules/suggestedVideos'; +import MiniPlayerModule from './modules/miniplayer'; const FP_MODULES = [ VPAIDModule, @@ -18,7 +22,9 @@ const FP_MODULES = [ TimelineModule, AdSupportModule, StreamingModule, - UtilsModule + UtilsModule, + SuggestedVideosModule, + MiniPlayerModule ]; // Determine build mode @@ -77,8 +83,13 @@ const fluidPlayerClass = function () { throw 'Could not find a HTML node to attach to for target ' + playerTarget + '"'; } + if (playerNode.classList.contains('js-fluid-player')) { + throw 'Invalid initializer - player target already is initialized'; + } + playerNode.setAttribute('playsinline', ''); playerNode.setAttribute('webkit-playsinline', ''); + playerNode.classList.add('js-fluid-player'); self.domRef.player = playerNode; self.vrROTATION_POSITION = 0.1; @@ -90,13 +101,12 @@ const fluidPlayerClass = function () { self.vpaidAdUnit = null; self.vastOptions = null; /** - * @deprecated Nothing should RELY on this. An internal ID generator - * should be used where absolutely necessary and DOM objects under FP control - * MUST be referenced in domRef. + * Don't use this as a way to change the DOM. DOM manipulation should be done with domRef. */ self.videoPlayerId = playerNode.id; self.originalSrc = self.getCurrentSrc(); self.isCurrentlyPlayingAd = false; + self.isCurrentlyShowingNonLinearAd = false; self.recentWaiting = false; self.latestVolume = 1; self.currentVideoDuration = 0; @@ -108,7 +118,7 @@ const fluidPlayerClass = function () { self.isTimer = false; self.timer = null; self.timerPool = {}; - self.adList = {}; + self.rollsById = {}; self.adPool = {}; self.adGroupedByRolls = {}; self.onPauseRollAdPods = []; @@ -149,6 +159,11 @@ const fluidPlayerClass = function () { self.fluidPseudoPause = false; self.mobileInfo = self.getMobileOs(); self.events = {}; + self.timeSkipOffsetAmount = 10; + // Only for linear ads, non linear are not taken into account + self.currentMediaSourceType = 'source'; + self.isLiveStream = null; + self.previousTime = 0; //Default options self.displayOptions = { @@ -170,10 +185,12 @@ const fluidPlayerClass = function () { allowDownload: false, playbackRateEnabled: false, subtitlesEnabled: false, + subtitlesOnByDefault: true, showCardBoardView: false, showCardBoardJoystick: false, allowTheatre: true, doubleclickFullscreen: true, + autoRotateFullScreen: false, theatreSettings: { width: '100%', height: '60%', @@ -181,7 +198,9 @@ const fluidPlayerClass = function () { horizontalAlign: 'center', keepPosition: false }, - theatreAdvanced: false, + theatreAdvanced: { + theatreElement: null, + }, title: null, logo: { imageUrl: null, @@ -196,7 +215,8 @@ const fluidPlayerClass = function () { controlBar: { autoHide: false, autoHideTimeout: 3, - animated: true + animated: true, + playbackRates: ['x2', 'x1.5', 'x1', 'x0.5'] }, timelinePreview: { spriteImage: false, @@ -217,12 +237,26 @@ const fluidPlayerClass = function () { theatre: true }, controlForwardBackward: { - show: false + show: false, + doubleTapMobile: true }, contextMenu: { controls: true, links: [] }, + miniPlayer: { + enabled: true, + width: 400, + height: 225, + widthMobile: 50, + placeholderText: 'Playing in Miniplayer', + position: 'bottom right', + autoToggle: false, + }, + roundedCorners: 0 + }, + suggestedVideos: { + configUrl: null }, vastOptions: { adList: {}, @@ -252,6 +286,9 @@ const fluidPlayerClass = function () { }) } }, + hls: { + overrideNative: false + }, captions: { play: 'Play', pause: 'Pause', @@ -292,23 +329,31 @@ const fluidPlayerClass = function () { 'Use module callbacks instead!') } - // Overriding the default options - for (let key in options) { - if (!options.hasOwnProperty(key)) { - continue; - } - if (typeof options[key] == "object") { - for (let subKey in options[key]) { - if (!options[key].hasOwnProperty(subKey)) { - continue; - } - self.displayOptions[key][subKey] = options[key][subKey]; + /** + * Replaces values from objects without replacing the default object + * + * @param defaults + * @param options + * @returns {object} + */ + function overrideDefaults(defaults, options) { + Object.keys(options).forEach(defaultKey => { + if ( + typeof options[defaultKey] === 'object' && + options[defaultKey] !== null && + !Array.isArray(options[defaultKey]) + ) { + overrideDefaults(defaults[defaultKey], options[defaultKey]); + } else if (typeof options[defaultKey] !== 'undefined') { + defaults[defaultKey] = options[defaultKey]; } - } else { - self.displayOptions[key] = options[key]; - } + }); + + return defaults; } + overrideDefaults(self.displayOptions, options); + self.domRef.wrapper = self.setupPlayerWrapper(); playerNode.addEventListener('webkitfullscreenchange', self.recalculateAdDimensions); @@ -320,6 +365,7 @@ const fluidPlayerClass = function () { playerNode.addEventListener('durationchange', () => { self.currentVideoDuration = self.getCurrentVideoDuration(); }); + playerNode.addEventListener('seeked', self.onVideoSeeked); // 'loadedmetadata' inconsistently fires because the audio can already be loaded when the listener is added. // Here we use readystate to see if metadata has already loaded @@ -374,12 +420,11 @@ const fluidPlayerClass = function () { self.setPersistentSettings(); - // DO NOT initialize streamers if there are pre-rolls. It will break the streamers! - // Streamers will re-initialize once ad has been shown. - const preRolls = self.findRoll('preRoll'); - if (!preRolls || 0 === preRolls.length) { - self.initialiseStreamers(); - } + self.generateSuggestedVideoList(); + + // Previously prevented to be initialized if preRolls were set up + // but now the streamers support reinitialization + self.initialiseStreamers(); const _play_videoPlayer = playerNode.play; @@ -464,17 +509,17 @@ const fluidPlayerClass = function () { playerNode.play(); } - const videoWrapper = document.getElementById('fluid_video_wrapper_' + playerNode.id); - if (!self.mobileInfo.userOs) { - videoWrapper.addEventListener('mouseleave', self.handleMouseleave, false); - videoWrapper.addEventListener('mouseenter', self.showControlBar, false); - videoWrapper.addEventListener('mouseenter', self.showTitle, false); + if (!self.displayOptions.layoutControls.controlBar.autoHide) { + self.domRef.wrapper.addEventListener('mouseleave', self.handleMouseleave, false); + } + self.domRef.wrapper.addEventListener('mouseenter', self.showControlBar, false); + self.domRef.wrapper.addEventListener('mouseenter', self.showTitle, false); } else { //On mobile mouseleave behavior does not make sense, so it's better to keep controls, once the playback starts //Autohide behavior on timer is a separate functionality self.hideControlBar(); - videoWrapper.addEventListener('touchstart', self.showControlBar, false); + self.domRef.wrapper.addEventListener('touchstart', self.showControlBar, { passive: true }); } //Keyboard Controls @@ -509,9 +554,11 @@ const fluidPlayerClass = function () { self.toggleLoader = (showLoader) => { self.isLoading = !!showLoader; - const loaderDiv = document.getElementById('vast_video_loading_' + self.videoPlayerId); + const loaderDiv = self.domRef.wrapper.querySelector('.vast_video_loading'); - loaderDiv.style.display = showLoader ? 'table' : 'none'; + if (loaderDiv) { + loaderDiv.style.display = showLoader ? 'table' : 'none'; + } }; self.sendRequest = (url, withCredentials, timeout, functionReadyStateChange) => { @@ -530,6 +577,40 @@ const fluidPlayerClass = function () { xmlHttpReq.send(); }; + /** + * Makes a XMLHttpRequest encapsulated by a Promise + * + * @param url + * @param withCredentials + * @param timeout + * @returns {Promise} + */ + self.sendRequestAsync = async (url, withCredentials, timeout) => { + return await new Promise((resolve, reject) => { + const xmlHttpReq = new XMLHttpRequest(); + + xmlHttpReq.onreadystatechange = (event) => { + const response = event.target; + + if (response.readyState === 4 && response.status >= 200 && response.status < 300) { + resolve(response); + } else if (response.readyState === 4) { + reject(response); + } + }; + + self.displayOptions.onBeforeXMLHttpRequestOpen(xmlHttpReq); + + xmlHttpReq.open('GET', url, true); + xmlHttpReq.withCredentials = withCredentials; + xmlHttpReq.timeout = timeout; + + self.displayOptions.onBeforeXMLHttpRequest(xmlHttpReq); + + xmlHttpReq.send(); + }) + }; + // TODO: rename self.announceLocalError = (code, msg) => { const parsedCode = typeof (code) !== 'undefined' ? parseInt(code) : 900; @@ -539,9 +620,11 @@ const fluidPlayerClass = function () { }; // TODO: move this somewhere else and refactor - self.debugMessage = (msg) => { + self.debugMessage = (...msg) => { + const style = 'color: #fff; font-weight: bold; background-color: #1a5e87; padding: 3px 6px; border-radius: 3px;'; + if (self.displayOptions.debug) { - console.log(msg); + console.log('%cFP DEBUG', style, ...msg); } }; @@ -566,6 +649,13 @@ const fluidPlayerClass = function () { self.switchToMainVideo(); self.playPauseToggle(); } + + // Event listener doesn't wait on flags to be flipped from post roll ads, needs small time out to compensate + setTimeout(() => { + if (!self.isCurrentlyPlayingAd && self.displayOptions.suggestedVideos.configUrl) { + self.displaySuggestedVideos(); + } + }, 100); }; self.getCurrentTime = () => { @@ -641,13 +731,11 @@ const fluidPlayerClass = function () { // Loader controls.loader = document.createElement('div'); controls.loader.className = 'vast_video_loading'; - controls.loader.id = 'vast_video_loading_' + self.videoPlayerId; controls.loader.style.display = 'none'; // Root element controls.root = document.createElement('div'); controls.root.className = 'fluid_controls_container'; - controls.root.id = self.videoPlayerId + '_fluid_controls_container'; if (!options.displayVolumeBar) { controls.root.className = controls.root.className + ' no_volume_bar'; @@ -665,27 +753,23 @@ const fluidPlayerClass = function () { // Left container -> Play/Pause controls.playPause = document.createElement('div'); controls.playPause.className = 'fluid_button fluid_button_play fluid_control_playpause'; - controls.playPause.id = self.videoPlayerId + '_fluid_control_playpause'; controls.leftContainer.appendChild(controls.playPause); if (options.controlForwardBackward) { // Left container -> Skip backwards controls.skipBack = document.createElement('div'); controls.skipBack.className = 'fluid_button fluid_button_skip_back'; - controls.skipBack.id = self.videoPlayerId + '_fluid_control_skip_back'; controls.leftContainer.appendChild(controls.skipBack); // Left container -> Skip forward controls.skipForward = document.createElement('div'); controls.skipForward.className = 'fluid_button fluid_button_skip_forward'; - controls.skipForward.id = self.videoPlayerId + '_fluid_control_skip_forward'; controls.leftContainer.appendChild(controls.skipForward); } // Progress container controls.progressContainer = document.createElement('div'); controls.progressContainer.className = 'fluid_controls_progress_container fluid_slider'; - controls.progressContainer.id = self.videoPlayerId + '_fluid_controls_progress_container'; controls.root.appendChild(controls.progressContainer); // Progress container -> Progress wrapper @@ -696,26 +780,22 @@ const fluidPlayerClass = function () { // Progress container -> Progress wrapper -> Current progress controls.progressCurrent = document.createElement('div'); controls.progressCurrent.className = 'fluid_controls_currentprogress'; - controls.progressCurrent.id = self.videoPlayerId + '_vast_control_currentprogress'; controls.progressCurrent.style.backgroundColor = options.primaryColor; controls.progressWrapper.appendChild(controls.progressCurrent); // Progress container -> Progress wrapper -> Current progress -> Marker controls.progress_current_marker = document.createElement('div'); controls.progress_current_marker.className = 'fluid_controls_currentpos'; - controls.progress_current_marker.id = self.videoPlayerId + '_vast_control_currentpos'; controls.progressCurrent.appendChild(controls.progress_current_marker); // Progress container -> Buffered indicator controls.bufferedIndicator = document.createElement('div'); controls.bufferedIndicator.className = 'fluid_controls_buffered'; - controls.bufferedIndicator.id = self.videoPlayerId + '_buffered_amount'; controls.progressContainer.appendChild(controls.bufferedIndicator); // Progress container -> Ad markers controls.adMarkers = document.createElement('div'); controls.adMarkers.className = 'fluid_controls_ad_markers_holder'; - controls.adMarkers.id = self.videoPlayerId + '_ad_markers_holder'; controls.progressContainer.appendChild(controls.adMarkers); // Right container @@ -725,96 +805,134 @@ const fluidPlayerClass = function () { // Right container -> Fullscreen controls.fullscreen = document.createElement('div'); - controls.fullscreen.id = self.videoPlayerId + '_fluid_control_fullscreen'; controls.fullscreen.className = 'fluid_button fluid_control_fullscreen fluid_button_fullscreen'; + controls.fullscreen.title = 'Full Screen'; controls.rightContainer.appendChild(controls.fullscreen); + if (options.miniPlayer.enabled) { + // Right container -> MiniPlayer + controls.miniPlayer = document.createElement('div'); + controls.miniPlayer.className = 'fluid_button fluid_control_mini_player fluid_button_mini_player'; + controls.miniPlayer.title = 'Mini Player'; + controls.rightContainer.appendChild(controls.miniPlayer); + } + // Right container -> Theatre controls.theatre = document.createElement('div'); - controls.theatre.id = self.videoPlayerId + '_fluid_control_theatre'; controls.theatre.className = 'fluid_button fluid_control_theatre fluid_button_theatre'; + controls.theatre.title = 'Theatre Mode'; controls.rightContainer.appendChild(controls.theatre); // Right container -> Cardboard controls.cardboard = document.createElement('div'); - controls.cardboard.id = self.videoPlayerId + '_fluid_control_cardboard'; controls.cardboard.className = 'fluid_button fluid_control_cardboard fluid_button_cardboard'; + controls.cardboard.title = 'Cardboard'; controls.rightContainer.appendChild(controls.cardboard); // Right container -> Subtitles controls.subtitles = document.createElement('div'); - controls.subtitles.id = self.videoPlayerId + '_fluid_control_subtitles'; - controls.subtitles.className = 'fluid_button fluid_button_subtitles'; + controls.subtitles.className = 'fluid_button fluid_control_subtitles fluid_button_subtitles'; + controls.subtitles.title = 'Captions'; controls.rightContainer.appendChild(controls.subtitles); // Right container -> Video source controls.videoSource = document.createElement('div'); - controls.videoSource.id = self.videoPlayerId + '_fluid_control_video_source'; - controls.videoSource.className = 'fluid_button fluid_button_video_source'; + controls.videoSource.className = 'fluid_button fluid_control_video_source fluid_button_video_source'; + controls.videoSource.title = 'Source'; controls.rightContainer.appendChild(controls.videoSource); // Right container -> Playback rate controls.playbackRate = document.createElement('div'); - controls.playbackRate.id = self.videoPlayerId + '_fluid_control_playback_rate'; - controls.playbackRate.className = 'fluid_button fluid_button_playback_rate'; + controls.playbackRate.className = 'fluid_button fluid_control_playback_rate fluid_button_playback_rate'; + controls.playbackRate.title = 'Playback Rate'; controls.rightContainer.appendChild(controls.playbackRate); // Right container -> Download controls.download = document.createElement('div'); - controls.download.id = self.videoPlayerId + '_fluid_control_download'; - controls.download.className = 'fluid_button fluid_button_download'; + controls.download.className = 'fluid_button fluid_control_download fluid_button_download'; + controls.download.title = 'Download'; controls.rightContainer.appendChild(controls.download); // Right container -> Volume container controls.volumeContainer = document.createElement('div'); - controls.volumeContainer.id = self.videoPlayerId + '_fluid_control_volume_container'; controls.volumeContainer.className = 'fluid_control_volume_container fluid_slider'; controls.rightContainer.appendChild(controls.volumeContainer); // Right container -> Volume container -> Volume controls.volume = document.createElement('div'); - controls.volume.id = self.videoPlayerId + '_fluid_control_volume'; controls.volume.className = 'fluid_control_volume'; controls.volumeContainer.appendChild(controls.volume); // Right container -> Volume container -> Volume -> Current controls.volumeCurrent = document.createElement('div'); - controls.volumeCurrent.id = self.videoPlayerId + '_fluid_control_currentvolume'; controls.volumeCurrent.className = 'fluid_control_currentvolume'; controls.volume.appendChild(controls.volumeCurrent); // Right container -> Volume container -> Volume -> Current -> position controls.volumeCurrentPos = document.createElement('div'); - controls.volumeCurrentPos.id = self.videoPlayerId + '_fluid_control_volume_currentpos'; controls.volumeCurrentPos.className = 'fluid_control_volume_currentpos'; controls.volumeCurrent.appendChild(controls.volumeCurrentPos); // Right container -> Volume container controls.mute = document.createElement('div'); - controls.mute.id = self.videoPlayerId + '_fluid_control_mute'; controls.mute.className = 'fluid_button fluid_button_volume fluid_control_mute'; controls.rightContainer.appendChild(controls.mute); - // Right container -> Volume container + // Right container -> Volume Control + Live Steam Button + const durationContainer = document.createElement('div'); + durationContainer.className = 'fluid_control_duration'; + controls.duration = document.createElement('div'); - controls.duration.id = self.videoPlayerId + '_fluid_control_duration'; - controls.duration.className = 'fluid_control_duration fluid_fluid_control_duration'; + controls.duration.className = 'fluid_fluid_control_duration'; controls.duration.innerText = '00:00 / 00:00'; - controls.rightContainer.appendChild(controls.duration); + + if (!options.displayVolumeBar) { + durationContainer.className = durationContainer.className + ' no_volume_bar'; + } + + controls.live_indicator = document.createElement('div'); + controls.live_indicator.className = 'fluid_control_live_indicator'; + durationContainer.append(controls.live_indicator, controls.duration); + controls.rightContainer.appendChild(durationContainer); return controls; }; + self.showLiveIndicator = () => { + const liveIndicatorButton = self.domRef.player.parentNode.getElementsByClassName('fluid_button_live_indicator'); + if (!liveIndicatorButton.length) { + const liveIndicator = self.domRef.player.parentNode.getElementsByClassName('fluid_control_live_indicator'); + const liveIndicatorButton = document.createElement('span'); + liveIndicatorButton.className = 'fluid_button_live_indicator'; + liveIndicatorButton.innerHTML = `LIVE`; + liveIndicatorButton.addEventListener('click', () => { + self.domRef.player.currentTime = self.currentVideoDuration; + }); + + for (let i = 0; i < liveIndicator.length; i++) { + liveIndicator[i].appendChild(liveIndicatorButton); + } + } + }; + + self.hideLiveIndicator = () => { + const liveIndicatorButton = self.domRef.player.parentNode.getElementsByClassName('fluid_button_live_indicator')[0]; + + if (liveIndicatorButton) { + liveIndicatorButton.remove(); + } + }; + self.controlPlayPauseToggle = () => { const playPauseButton = self.domRef.player.parentNode.getElementsByClassName('fluid_control_playpause'); - const menuOptionPlay = document.getElementById(self.videoPlayerId + 'context_option_play'); + const menuOptionPlay = self.domRef.wrapper.querySelector('.context_option_play'); const controlsDisplay = self.domRef.player.parentNode.getElementsByClassName('fluid_controls_container'); - const fpLogo = document.getElementById(self.videoPlayerId + '_logo'); + const fpLogo = self.domRef.wrapper.querySelector('.logo_holder'); - const initialPlay = document.getElementById(self.videoPlayerId + '_fluid_initial_play'); + const initialPlay = self.domRef.wrapper.querySelector('.fluid_initial_play'); if (initialPlay) { - document.getElementById(self.videoPlayerId + '_fluid_initial_play').style.display = "none"; - document.getElementById(self.videoPlayerId + '_fluid_initial_play_button').style.opacity = "1"; + self.domRef.wrapper.querySelector('.fluid_initial_play').style.display = "none"; + self.domRef.wrapper.querySelector('.fluid_initial_play_button_container').style.opacity = "1"; } if (!self.domRef.player.paused) { @@ -846,8 +964,8 @@ const fluidPlayerClass = function () { } if (self.isCurrentlyPlayingAd && self.displayOptions.vastOptions.showPlayButton) { - document.getElementById(self.videoPlayerId + '_fluid_initial_play').style.display = "block"; - document.getElementById(self.videoPlayerId + '_fluid_initial_play_button').style.opacity = "1"; + self.domRef.wrapper.querySelector('.fluid_initial_play').style.display = "block"; + self.domRef.wrapper.querySelector('.fluid_initial_play_button_container').style.opacity = "1"; } if (fpLogo) { @@ -864,24 +982,26 @@ const fluidPlayerClass = function () { return; } + const playButtonElement = self.domRef.wrapper.querySelector('.fluid_initial_play_button, .fluid_initial_pause_button'); + if (play) { - document.getElementById(self.videoPlayerId + '_fluid_state_button').classList.remove('fluid_initial_pause_button'); - document.getElementById(self.videoPlayerId + '_fluid_state_button').classList.add('fluid_initial_play_button'); + playButtonElement.classList.remove('fluid_initial_pause_button'); + playButtonElement.classList.add('fluid_initial_play_button'); } else { - document.getElementById(self.videoPlayerId + '_fluid_state_button').classList.remove('fluid_initial_play_button'); - document.getElementById(self.videoPlayerId + '_fluid_state_button').classList.add('fluid_initial_pause_button'); + playButtonElement.classList.remove('fluid_initial_play_button'); + playButtonElement.classList.add('fluid_initial_pause_button'); } - document.getElementById(self.videoPlayerId + '_fluid_initial_play').classList.add('transform-active'); + self.domRef.wrapper.querySelector('.fluid_initial_play').classList.add('transform-active'); setTimeout( function () { - document.getElementById(self.videoPlayerId + '_fluid_initial_play').classList.remove('transform-active'); + self.domRef.wrapper.querySelector('.fluid_initial_play').classList.remove('transform-active'); }, 800 ); }; - self.contolProgressbarUpdate = () => { + self.controlProgressbarUpdate = () => { const currentProgressTag = self.domRef.player.parentNode.getElementsByClassName('fluid_controls_currentprogress'); for (let i = 0; i < currentProgressTag.length; i++) { @@ -910,17 +1030,32 @@ const fluidPlayerClass = function () { const timePlaceholder = self.domRef.player.parentNode.getElementsByClassName('fluid_control_duration'); for (let i = 0; i < timePlaceholder.length; i++) { - timePlaceholder[i].innerHTML = durationText; + timePlaceholder[i].innerHTML = ''; + + if (self.isLiveStream) { + const liveIndicatorButton = document.createElement('span'); + liveIndicatorButton.className = 'fluid_button_live_indicator'; + liveIndicatorButton.innerHTML = `LIVE`; + liveIndicatorButton.addEventListener('pointerdown', () => { + self.domRef.player.currentTime = self.currentVideoDuration; + }); + timePlaceholder[i].appendChild(liveIndicatorButton); + } + + const durationTextElement = document.createElement('span'); + durationTextElement.className = 'fluid_fluid_control_duration'; + durationTextElement.innerText = durationText; + timePlaceholder[i].appendChild(durationTextElement); } }; - self.contolVolumebarUpdate = () => { - const currentVolumeTag = document.getElementById(self.videoPlayerId + '_fluid_control_currentvolume'); - const volumeposTag = document.getElementById(self.videoPlayerId + '_fluid_control_volume_currentpos'); - const volumebarTotalWidth = document.getElementById(self.videoPlayerId + '_fluid_control_volume').clientWidth; + self.controlVolumebarUpdate = () => { + const currentVolumeTag = self.domRef.wrapper.querySelector('.fluid_control_currentvolume'); + const volumeposTag = self.domRef.wrapper.querySelector('.fluid_control_volume_currentpos'); + const volumebarTotalWidth = self.domRef.wrapper.querySelector('.fluid_control_volume').clientWidth; const volumeposTagWidth = volumeposTag.clientWidth; const muteButtonTag = self.domRef.player.parentNode.getElementsByClassName('fluid_control_mute'); - const menuOptionMute = document.getElementById(self.videoPlayerId + 'context_option_mute'); + const menuOptionMute = self.domRef.wrapper.querySelector('.context_option_mute'); if (0 !== self.domRef.player.volume) { self.latestVolume = self.domRef.player.volume; @@ -963,10 +1098,12 @@ const fluidPlayerClass = function () { // Persistent settings self.fluidStorage.fluidVolume = self.latestVolume; self.fluidStorage.fluidMute = self.domRef.player.muted; + + self.trackMuteChange(); }; - self.checkFullscreenSupport = (videoPlayerWrapperId) => { - const videoPlayerWrapper = document.getElementById(videoPlayerWrapperId); + self.checkFullscreenSupport = () => { + const videoPlayerWrapper = self.domRef.wrapper; if (videoPlayerWrapper.mozRequestFullScreen) { return { @@ -1028,17 +1165,15 @@ const fluidPlayerClass = function () { self.fullscreenMode = true; }; - self.fullscreenToggle = () => { + self.fullscreenToggle = (toAnotherDisplayTarget = false) => { + self.debugMessage(`Toggling Full Screen`); const videoPlayerTag = self.domRef.player; - const fullscreenTag = document.getElementById('fluid_video_wrapper_' + self.videoPlayerId); - const requestFullscreenFunctionNames = self.checkFullscreenSupport('fluid_video_wrapper_' + self.videoPlayerId); + const fullscreenTag = self.domRef.wrapper; + const requestFullscreenFunctionNames = self.checkFullscreenSupport(); const fullscreenButton = videoPlayerTag.parentNode.getElementsByClassName('fluid_control_fullscreen'); - const menuOptionFullscreen = document.getElementById(self.videoPlayerId + 'context_option_fullscreen'); - - // Disable Theatre mode if it's on while we toggle fullscreen - if (self.theatreMode) { - self.theatreToggle(); - } + const menuOptionFullscreen = fullscreenTag.querySelector('.context_option_fullscreen'); + const previousDisplayMode = self.getPreviousDisplayMode(); + self.resetDisplayMode(displayModes.FULLSCREEN); let functionNameToExecute; @@ -1075,6 +1210,14 @@ const fluidPlayerClass = function () { self.resizeVpaidAuto(); + // Listen for fullscreen exit event on safari, as the fullscreen mode uses the native UI in iOS + self.domRef.player.addEventListener('webkitendfullscreen', () => { + self.fullscreenOff(fullscreenButton, menuOptionFullscreen); + }); + + if (!toAnotherDisplayTarget) { + self.trackPlayerSizeChanged(previousDisplayMode); + } }; self.findClosestParent = (el, selector) => { @@ -1158,15 +1301,10 @@ const fluidPlayerClass = function () { self.getEventOffsetY = (evt, el) => { let fullscreenMultiplier = 1; - const videoWrapper = self.findClosestParent(el, 'div[id^="fluid_video_wrapper_"]'); - if (videoWrapper) { - const videoPlayerId = videoWrapper.id.replace('fluid_video_wrapper_', ''); - - const requestFullscreenFunctionNames = self.checkFullscreenSupport('fluid_video_wrapper_' + videoPlayerId); - if (requestFullscreenFunctionNames && document[requestFullscreenFunctionNames.isFullscreen]) { - fullscreenMultiplier = 0; - } + const requestFullscreenFunctionNames = self.checkFullscreenSupport(); + if (requestFullscreenFunctionNames && document[requestFullscreenFunctionNames.isFullscreen]) { + fullscreenMultiplier = 0; } let y = 0; @@ -1193,7 +1331,7 @@ const fluidPlayerClass = function () { if (self.displayOptions.layoutControls.showCardBoardView) { initialPosition = self.getEventOffsetX(event, event.target.parentNode); } else { - initialPosition = self.getEventOffsetX(event, document.getElementById(self.videoPlayerId + '_fluid_controls_progress_container')); + initialPosition = self.getEventOffsetX(event, self.domRef.wrapper.querySelector('.fluid_controls_progress_container')); } if (self.isCurrentlyPlayingAd) { @@ -1207,19 +1345,32 @@ const fluidPlayerClass = function () { self.domRef.player.pause(); } + self.updatePreviousTime(); + const shiftTime = timeBarX => { - const totalWidth = document.getElementById(self.videoPlayerId + '_fluid_controls_progress_container').clientWidth; - if (totalWidth) { - self.domRef.player.currentTime = self.currentVideoDuration * timeBarX / totalWidth; + const totalWidth = self.domRef.wrapper.querySelector('.fluid_controls_progress_container').clientWidth; + if (!totalWidth) { + self.hideSuggestedVideos(); + return; + } + + const currentTime = Math.abs(self.currentVideoDuration * timeBarX / totalWidth); + self.domRef.player.currentTime = currentTime; + // Workaround for Safari: store main video time when moving progress bar before initial play & preRoll ad + const isSafari = self.getBrowserVersion().browserName.toLowerCase() === 'safari'; + if (isSafari) { + self.domRef.player.safariPlayheadPosition = currentTime; } + + self.hideSuggestedVideos(); }; const onProgressbarMouseMove = event => { const currentX = self.getEventOffsetX(event, event.target.parentNode); initialPosition = NaN; // mouse up will fire after the move, we don't want to trigger the initial position in the event of iOS shiftTime(currentX); - self.contolProgressbarUpdate(self.videoPlayerId); - self.controlDurationUpdate(self.videoPlayerId); + self.controlProgressbarUpdate(); + self.controlDurationUpdate(); }; const onProgressbarMouseUp = event => { @@ -1252,9 +1403,9 @@ const fluidPlayerClass = function () { }; document.addEventListener('mouseup', onProgressbarMouseUp); - document.addEventListener('touchend', onProgressbarMouseUp); + document.addEventListener('touchend', onProgressbarMouseUp, { passive: true }); document.addEventListener('mousemove', onProgressbarMouseMove); - document.addEventListener('touchmove', onProgressbarMouseMove); + document.addEventListener('touchmove', onProgressbarMouseMove, { passive: true }); }; self.onVolumeBarMouseDown = () => { @@ -1298,25 +1449,34 @@ const fluidPlayerClass = function () { } document.addEventListener('mouseup', onVolumeBarMouseUp); - document.addEventListener('touchend', onVolumeBarMouseUp); + document.addEventListener('touchend', onVolumeBarMouseUp, { passive: true }); document.addEventListener('mousemove', onVolumeBarMouseMove); - document.addEventListener('touchmove', onVolumeBarMouseMove); + document.addEventListener('touchmove', onVolumeBarMouseMove, { passive: true }); + }; + + self.onVideoSeeked = () => { + // Track rewind events (not forward seeks) + if (self.previousTime && self.domRef.player.currentTime < self.previousTime) { + self.debugMessage('Video rewound from', self.previousTime, 'to', self.domRef.player.currentTime); + self.trackRewindAd(); + } + self.updatePreviousTime(); }; self.findRoll = (roll) => { const ids = []; ids.length = 0; - if (!roll || !self.hasOwnProperty('adList')) { + if (!roll || !self.hasOwnProperty('rollsById')) { return; } - for (let key in self.adList) { - if (!self.adList.hasOwnProperty(key)) { + for (let key in self.rollsById) { + if (!self.rollsById.hasOwnProperty(key)) { continue; } - if (self.adList[key].roll === roll) { + if (self.rollsById[key].roll === roll) { ids.push(key); } } @@ -1358,14 +1518,6 @@ const fluidPlayerClass = function () { let newCurrentTime = currentTime; switch (keyCode) { - case 37://left arrow - newCurrentTime -= 5; - newCurrentTime = (newCurrentTime < 5) ? 0 : newCurrentTime; - break; - case 39://right arrow - newCurrentTime += 5; - newCurrentTime = (newCurrentTime > duration - 5) ? duration : newCurrentTime; - break; case 35://End newCurrentTime = duration; break; @@ -1435,7 +1587,11 @@ const fluidPlayerClass = function () { event.preventDefault(); break; case 37://left arrow + self.skipRelative(-self.timeSkipOffsetAmount); + break; case 39://right arrow + self.skipRelative(self.timeSkipOffsetAmount); + break; case 35://End case 36://Home case 48://0 @@ -1451,6 +1607,26 @@ const fluidPlayerClass = function () { self.onKeyboardSeekPosition(keyCode); event.preventDefault(); break; + case 73: // i + self.toggleMiniPlayer(undefined, true); + break; + case 187: // + in Chrome/Edge/Safari on most layouts + case 61: // + in Firefox/some international layouts + case 43: // + in older browsers (ASCII value) + case 107: // + on numeric keypad (all browsers) + case 171: // + in some European layouts + if (self.displayOptions.layoutControls.subtitlesEnabled) { + self.adjustSubtitleSizeByOperation('+'); + } + break; + case 189: // - in Chrome/Edge/Safari + case 173: // - in older Firefox + case 45: // - in older browsers (ASCII value) + case 109: // - on numeric keypad (all browsers) + if (self.displayOptions.layoutControls.subtitlesEnabled) { + self.adjustSubtitleSizeByOperation('-'); + } + break; } return false; @@ -1475,12 +1651,12 @@ const fluidPlayerClass = function () { self.handleWindowClick = (e) => { if (!self.domRef.wrapper) { - console.warn('Dangling click event listener should be collected for unknown wrapper ' + self.videoPlayerId - + '. Did you forget to call destroy on player instance?'); + console.warn('Dangling click event listener should be collected for unknown wrapper.' + + 'Did you forget to call destroy on player instance?'); return; } - const inScopeClick = self.domRef.wrapper.contains(e.target) || e.target.id === 'skipHref_' + self.videoPlayerId; + const inScopeClick = self.domRef.wrapper.contains(e.target) || e.target.classList.contains('.js-skipHref'); if (inScopeClick) { return; @@ -1511,10 +1687,10 @@ const fluidPlayerClass = function () { if (!self.displayOptions.layoutControls.playButtonShowing) { // Controls always showing until the video is first played - const initialControlsDisplay = document.getElementById(self.videoPlayerId + '_fluid_controls_container'); + const initialControlsDisplay = self.domRef.wrapper.querySelector('.fluid_controls_container'); initialControlsDisplay.classList.remove('initial_controls_show'); // The logo shows before playing but may need to be removed - const fpPlayer = document.getElementById(self.videoPlayerId + '_logo'); + const fpPlayer = self.domRef.wrapper.querySelector('.logo_holder'); if (fpPlayer) { fpPlayer.classList.remove('initial_controls_show'); } @@ -1527,6 +1703,7 @@ const fluidPlayerClass = function () { }; self.playPauseToggle = () => { + self.hideSuggestedVideos(); const isFirstStart = !self.firstPlayLaunched; const preRolls = self.findRoll('preRoll'); @@ -1542,16 +1719,26 @@ const fluidPlayerClass = function () { self.resumeVpaidAd(); } else { // Check if video has ended. If so, replay - if (Math.floor(self.currentVideoDuration) === Math.floor(self.domRef.player.currentTime)) { + if ( + Math.floor(self.domRef.player.currentTime) !== 0 && + Math.floor(self.currentVideoDuration) === Math.floor(self.domRef.player.currentTime) + ) { self.initialiseStreamers(); self.domRef.player.currentTime = 0; } + if (self.hlsPlayer && !self.hlsPlayer.config.autoStartLoad) { + self.hlsPlayer.startLoad(); + } + // resume the regular linear vast or content video player if (self.dashPlayer) { self.dashPlayer.play(); } else { self.domRef.player.play(); + if (!self.fluidPseudoPause) { + self.trackPlayPauseChange(); + } } } @@ -1564,6 +1751,7 @@ const fluidPlayerClass = function () { } else { // pause the regular linear vast or content video player self.domRef.player.pause(); + self.trackPlayPauseChange(); } self.playPauseAnimationToggle(false); @@ -1597,13 +1785,12 @@ const fluidPlayerClass = function () { const prepareVastAdsThatKnowDuration = () => { self.prepareVast('onPauseRoll'); - self.prepareVast('postRoll'); - self.prepareVast('midRoll'); + self.scheduleOnDemandRolls(); }; if (isFirstStart) { // Remove the div that was placed as a fix for poster image and DASH streaming, if it exists - const pseudoPoster = document.getElementById(self.videoPlayerId + '_fluid_pseudo_poster'); + const pseudoPoster = self.domRef.wrapper.querySelector('.fluid_pseudo_poster'); if (pseudoPoster) { pseudoPoster.parentNode.removeChild(pseudoPoster); } @@ -1617,7 +1804,7 @@ const fluidPlayerClass = function () { self.adTimer(); - const blockOnPause = document.getElementById(self.videoPlayerId + '_fluid_html_on_pause'); + const blockOnPause = self.domRef.wrapper.querySelector('.fluid_html_on_pause_container'); if (blockOnPause && !self.isCurrentlyPlayingAd) { if (self.domRef.player.paused) { @@ -1640,7 +1827,7 @@ const fluidPlayerClass = function () { self.domRef.player.addEventListener('play', () => { self.controlPlayPauseToggle(); - self.contolVolumebarUpdate(); + self.controlVolumebarUpdate(); }, false); self.domRef.player.addEventListener('fluidplayerpause', () => { @@ -1649,7 +1836,7 @@ const fluidPlayerClass = function () { //Set the progressbar self.domRef.player.addEventListener('timeupdate', () => { - self.contolProgressbarUpdate(); + self.controlProgressbarUpdate(); self.controlDurationUpdate(); }); @@ -1665,15 +1852,15 @@ const fluidPlayerClass = function () { false ); } else { - document.getElementById(self.videoPlayerId + '_fluid_controls_progress_container') - .addEventListener(eventOn, event => self.onProgressbarMouseDown(event), false); + self.domRef.wrapper.querySelector('.fluid_controls_progress_container') + .addEventListener(eventOn, event => self.onProgressbarMouseDown(event), { passive: true }); } //Set the volume controls - document.getElementById(self.videoPlayerId + '_fluid_control_volume_container') - .addEventListener(eventOn, event => self.onVolumeBarMouseDown(), false); + self.domRef.wrapper.querySelector('.fluid_control_volume_container') + .addEventListener(eventOn, event => self.onVolumeBarMouseDown(), { passive: true }); - self.domRef.player.addEventListener('volumechange', () => self.contolVolumebarUpdate()); + self.domRef.player.addEventListener('volumechange', () => self.controlVolumebarUpdate()); self.trackEvent(self.domRef.player.parentNode, 'click', '.fluid_control_mute', () => self.muteToggle()); @@ -1684,10 +1871,15 @@ const fluidPlayerClass = function () { // Theatre mode if (self.displayOptions.layoutControls.allowTheatre && !self.isInIframe) { - document.getElementById(self.videoPlayerId + '_fluid_control_theatre').style.display = 'inline-block'; + self.domRef.wrapper.querySelector('.fluid_control_theatre').style.display = 'inline-block'; self.trackEvent(self.domRef.player.parentNode, 'click', '.fluid_control_theatre', () => self.theatreToggle()); } else { - document.getElementById(self.videoPlayerId + '_fluid_control_theatre').style.display = 'none'; + self.domRef.wrapper.querySelector('.fluid_control_theatre').style.display = 'none'; + } + + // Mini Player + if (self.displayOptions.layoutControls.miniPlayer.enabled && !self.isInIframe) { + self.trackEvent(self.domRef.player.parentNode, 'click', '.fluid_control_mini_player', () => self.toggleMiniPlayer(undefined, true)); } self.domRef.player.addEventListener('ratechange', () => { @@ -1703,10 +1895,9 @@ const fluidPlayerClass = function () { return; } - const progressContainer = document.getElementById(self.videoPlayerId + '_fluid_controls_progress_container'); + const progressContainer = self.domRef.wrapper.querySelector('.fluid_controls_progress_container'); const previewContainer = document.createElement('div'); - previewContainer.id = self.videoPlayerId + '_fluid_timeline_preview'; previewContainer.className = 'fluid_timeline_preview'; previewContainer.style.display = 'none'; previewContainer.style.position = 'absolute'; @@ -1714,11 +1905,11 @@ const fluidPlayerClass = function () { progressContainer.appendChild(previewContainer); // Set up hover for time position preview display - document.getElementById(self.videoPlayerId + '_fluid_controls_progress_container') + self.domRef.wrapper.querySelector('.fluid_controls_progress_container') .addEventListener('mousemove', event => { - const progressContainer = document.getElementById(self.videoPlayerId + '_fluid_controls_progress_container'); + const progressContainer = self.domRef.wrapper.querySelector('.fluid_controls_progress_container'); const totalWidth = progressContainer.clientWidth; - const hoverTimeItem = document.getElementById(self.videoPlayerId + '_fluid_timeline_preview'); + const hoverTimeItem = self.domRef.wrapper.querySelector('.fluid_timeline_preview'); const hoverQ = self.getEventOffsetX(event, progressContainer); const hoverSecondQ = self.currentVideoDuration * hoverQ / totalWidth; @@ -1729,9 +1920,9 @@ const fluidPlayerClass = function () { }, false); // Hide timeline preview on mouseout - document.getElementById(self.videoPlayerId + '_fluid_controls_progress_container') + self.domRef.wrapper.querySelector('.fluid_controls_progress_container') .addEventListener('mouseout', () => { - const hoverTimeItem = document.getElementById(self.videoPlayerId + '_fluid_timeline_preview'); + const hoverTimeItem = self.domRef.wrapper.querySelector('.fluid_timeline_preview'); hoverTimeItem.style.display = 'none'; }, false); }; @@ -1744,7 +1935,6 @@ const fluidPlayerClass = function () { //Create own context menu const divContextMenu = document.createElement('div'); - divContextMenu.id = self.videoPlayerId + '_fluid_context_menu'; divContextMenu.className = 'fluid_context_menu'; divContextMenu.style.display = 'none'; divContextMenu.style.position = 'absolute'; @@ -1755,7 +1945,6 @@ const fluidPlayerClass = function () { if (Array.isArray(extraLinks)) { extraLinks.forEach(function appendExtraLinks(link, index) { const linkItem = document.createElement('li'); - linkItem.id = self.videoPlayerId + '_extra_link_' + index; linkItem.innerHTML = link.label; linkItem.addEventListener('click', () => window.open(link.href, '_blank'), false); contextMenuList.appendChild(linkItem); @@ -1764,26 +1953,25 @@ const fluidPlayerClass = function () { if (showDefaultControls) { const menuItemPlay = document.createElement('li'); - menuItemPlay.id = self.videoPlayerId + 'context_option_play'; + menuItemPlay.className = 'context_option_play'; menuItemPlay.innerHTML = self.displayOptions.captions.play; menuItemPlay.addEventListener('click', () => self.playPauseToggle(), false); contextMenuList.appendChild(menuItemPlay); const menuItemMute = document.createElement('li'); - menuItemMute.id = self.videoPlayerId + 'context_option_mute'; + menuItemMute.className = 'context_option_mute'; menuItemMute.innerHTML = self.displayOptions.captions.mute; menuItemMute.addEventListener('click', () => self.muteToggle(), false); contextMenuList.appendChild(menuItemMute); const menuItemFullscreen = document.createElement('li'); - menuItemFullscreen.id = self.videoPlayerId + 'context_option_fullscreen'; + menuItemFullscreen.className = 'context_option_fullscreen'; menuItemFullscreen.innerHTML = self.displayOptions.captions.fullscreen; menuItemFullscreen.addEventListener('click', () => self.fullscreenToggle(), false); contextMenuList.appendChild(menuItemFullscreen); } const menuItemVersion = document.createElement('li'); - menuItemVersion.id = self.videoPlayerId + 'context_option_homepage'; menuItemVersion.innerHTML = 'Fluid Player ' + self.version; menuItemVersion.addEventListener('click', () => window.open(self.homepage, '_blank'), false) contextMenuList.appendChild(menuItemVersion); @@ -1817,7 +2005,8 @@ const fluidPlayerClass = function () { primaryColor: self.displayOptions.layoutControls.primaryColor ? self.displayOptions.layoutControls.primaryColor : 'red', - controlForwardBackward: !!self.displayOptions.layoutControls.controlForwardBackward.show + controlForwardBackward: !!self.displayOptions.layoutControls.controlForwardBackward.show, + miniPlayer: self.displayOptions.layoutControls.miniPlayer, }); // Remove the default controls @@ -1840,17 +2029,37 @@ const fluidPlayerClass = function () { clearInterval(initiateVolumebarTimerId); } else if (self.checkIfVolumebarIsRendered()) { clearInterval(initiateVolumebarTimerId); - self.contolVolumebarUpdate(self.videoPlayerId); + self.controlVolumebarUpdate(); } else { remainingAttemptsToInitiateVolumeBar--; } }; let initiateVolumebarTimerId = setInterval(initiateVolumebar, 100); + self.destructors.push(() => clearInterval(initiateVolumebarTimerId)); - if (self.displayOptions.layoutControls.doubleclickFullscreen) { + if (self.displayOptions.layoutControls.doubleclickFullscreen && !(self.isTouchDevice() || !self.displayOptions.layoutControls.controlForwardBackward.doubleTapMobile)) { self.domRef.player.addEventListener('dblclick', self.fullscreenToggle); } + if (self.getMobileOs().userOs === 'iOS') { + let orientationListenerAdded = false; + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + self.domRef.player.inView = true; + if (self.displayOptions.layoutControls.autoRotateFullScreen && self.isTouchDevice() && !orientationListenerAdded) { + window.matchMedia("(orientation: landscape)").addEventListener('change', self.handleOrientationChange); + orientationListenerAdded = true; + } + } else { + self.domRef.player.inView = false; + } + }); + }); + + observer.observe(self.domRef.player); + } + self.initHtmlOnPauseBlock(); self.setCustomControls(); @@ -1869,27 +2078,191 @@ const fluidPlayerClass = function () { self.createDownload(); + self.toggleMiniPlayerScreenDetection(); + if (!!self.displayOptions.layoutControls.controlForwardBackward.show) { self.initSkipControls(); } + + if (!!self.displayOptions.layoutControls.controlForwardBackward.doubleTapMobile) { + self.initDoubleTapSkip(); + } + + self.initSkipAnimationElements(); }; self.initSkipControls = () => { - const skipFunction = (period) => { - if (self.isCurrentlyPlayingAd) { + self.domRef.controls.skipBack.addEventListener('click', self.skipRelative.bind(this, -self.timeSkipOffsetAmount)); + self.domRef.controls.skipForward.addEventListener('click', self.skipRelative.bind(this, self.timeSkipOffsetAmount)); + }; + + // Function to handle fullscreen toggle based on orientation + self.handleOrientationChange = () => { + const isLandscape = window.matchMedia("(orientation: landscape)").matches; + const videoPlayerTag = self.domRef.player; + const fullscreenTag = self.domRef.wrapper; + const requestFullscreenFunctionNames = self.checkFullscreenSupport(); + const fullscreenButton = videoPlayerTag.parentNode.getElementsByClassName('fluid_control_fullscreen'); + const menuOptionFullscreen = fullscreenTag.querySelector('.context_option_fullscreen'); + let functionNameToExecute; + if (isLandscape && self.domRef.player.inView) { + if (requestFullscreenFunctionNames) { + if (requestFullscreenFunctionNames.goFullscreen === 'webkitEnterFullscreen') { + functionNameToExecute = 'videoPlayerTag.' + requestFullscreenFunctionNames.goFullscreen + '();'; + self.fullscreenOn(fullscreenButton, menuOptionFullscreen); + new Function('videoPlayerTag', functionNameToExecute)(videoPlayerTag); + } else { + if (document[requestFullscreenFunctionNames.isFullscreen] === null) { + functionNameToExecute = 'videoPlayerTag.' + requestFullscreenFunctionNames.goFullscreen + '();'; + self.fullscreenOn(fullscreenButton, menuOptionFullscreen); + } + new Function('videoPlayerTag', functionNameToExecute)(fullscreenTag); + } + } else { + fullscreenTag.className += ' pseudo_fullscreen'; + self.fullscreenOn(fullscreenButton, menuOptionFullscreen); + } + } else { + fullscreenTag.className = fullscreenTag.className.replace(/\bpseudo_fullscreen\b/g, ''); + if (requestFullscreenFunctionNames) { + functionNameToExecute = 'document.' + requestFullscreenFunctionNames.exitFullscreen + '();'; + self.fullscreenOff(fullscreenButton, menuOptionFullscreen); + new Function('videoPlayerTag', functionNameToExecute)(fullscreenTag); + } else { + if (fullscreenTag.className.search(/\bpseudo_fullscreen\b/g) !== -1) { + fullscreenTag.className = fullscreenTag.className.replace(/\bpseudo_fullscreen\b/g, ''); + self.fullscreenOff(fullscreenButton, menuOptionFullscreen); + } + } + } + self.resizeVpaidAuto(); + }; + + /** + * Updates the previous time position of the video player to the current time. + * This function should be called before the current time is changed, as a way to store the 'previous' time position. + */ + self.updatePreviousTime = () => { + self.previousTime = self.domRef.player.currentTime; + }; + + /** + * Returns the previous display mode of the player. + * @returns {string} + */ + self.getPreviousDisplayMode = () => { + if (self.fullscreenMode) { + return displayModes.FULLSCREEN; + } else if (self.theatreMode) { + return displayModes.THEATER; + } else if (self.miniPlayerToggledOn) { + return displayModes.MINI_PLAYER; + } else { + return displayModes.NORMAL; + } + }; + + /** + * Creates the skip animation elements and appends them to the player + * + * @returns {void} + */ + self.initSkipAnimationElements = function initSkipAnimationElements() { + const skipAnimationWrapper = document.createElement('div'); + skipAnimationWrapper.classList.add('fluid_player_skip_offset'); + + const skipAnimationBackward = document.createElement('div'); + skipAnimationBackward.classList.add('fluid_player_skip_offset__backward'); + skipAnimationWrapper.appendChild(skipAnimationBackward); + + const skipAnimationBackwardIcon = document.createElement('div'); + skipAnimationBackwardIcon.classList.add('fluid_player_skip_offset__backward-icon'); + skipAnimationBackward.appendChild(skipAnimationBackwardIcon); + + const skipAnimationForward = document.createElement('div'); + skipAnimationForward.classList.add('fluid_player_skip_offset__forward'); + skipAnimationWrapper.appendChild(skipAnimationForward); + + const skipAnimationForwardIcon = document.createElement('div'); + skipAnimationForwardIcon.classList.add('fluid_player_skip_offset__forward-icon'); + skipAnimationForward.appendChild(skipAnimationForwardIcon); + + self.domRef.player.parentNode.insertBefore(skipAnimationWrapper, self.domRef.player.nextSibling); + } + + /** + * Initialises the double tap skip functionality + */ + self.initDoubleTapSkip = () => { + let hasDoubleClicked = false; + let timeouts = []; + + function clearTimeouts() { + timeouts.forEach(timeout => clearTimeout(timeout)); + timeouts = []; + } + + self.domRef.player.addEventListener('pointerdown', (event) => { + // Check if it's mobile on the fly and prevent double click skip if it is + if (!self.isTouchDevice()) { return; } - let skipTo = self.domRef.player.currentTime + period; - if (skipTo < 0) { - skipTo = 0; + // Save current state here, if you check the state in the settimeout it will always be true + const isControlBarVisible = self.isControlBarVisible(); + + const { offsetX } = event + const { clientWidth } = self.domRef.player; + + // Simulates default behaviour if it's a single click + timeouts.push(setTimeout(() => { + hasDoubleClicked = false; + if (isControlBarVisible) { + self.playPauseToggle(); + } + }, 300)); + + // Skips video time if it's a double click + if (hasDoubleClicked) { + clearTimeouts(); + hasDoubleClicked = false; + return self.skipRelative(offsetX < clientWidth / 2 ? -self.timeSkipOffsetAmount : self.timeSkipOffsetAmount); } - self.domRef.player.currentTime = skipTo; - }; - self.domRef.controls.skipBack.addEventListener('click', skipFunction.bind(this, -10)); - self.domRef.controls.skipForward.addEventListener('click', skipFunction.bind(this, 10)); - }; + hasDoubleClicked = true; + }); + } + + /** + * Skips the video time by timeOffset relative to the current video time + * + * @param {number} timeOffset + */ + self.skipRelative = function skipRelative(timeOffset) { + self.debugMessage('skipping video time by ', timeOffset); + if (self.isCurrentlyPlayingAd) { + return; + } + self.updatePreviousTime(); + + let skipTo = self.domRef.player.currentTime + timeOffset; + if (skipTo < 0) { + skipTo = 0; + } + self.domRef.player.currentTime = skipTo; + + // Trigger animation. Use a timer for cleanup instead of `transitionend`: + // under main-thread pressure (e.g. rapid seeking that forces buffering), + // the browser can drop transition events and the `.animate` class would + // never be removed, leaving the icon permanently visible. + const iconSelector = timeOffset >= 0 + ? '.fluid_player_skip_offset__forward-icon' + : '.fluid_player_skip_offset__backward-icon'; + const icon = self.domRef.wrapper.querySelector(iconSelector); + clearTimeout(icon._fadeTimer); + icon.classList.add('animate'); + icon._fadeTimer = setTimeout(() => icon.classList.remove('animate'), 600); + } /** * Checks if the volumebar is rendered and the styling applied by comparing @@ -1898,8 +2271,8 @@ const fluidPlayerClass = function () { * @returns Boolean */ self.checkIfVolumebarIsRendered = () => { - const volumeposTag = document.getElementById(self.videoPlayerId + '_fluid_control_volume_currentpos'); - const volumebarTotalWidth = document.getElementById(self.videoPlayerId + '_fluid_control_volume').clientWidth; + const volumeposTag = self.domRef.wrapper.querySelector('.fluid_control_volume_currentpos'); + const volumebarTotalWidth = self.domRef.wrapper.querySelector('.fluid_control_volume').clientWidth; const volumeposTagWidth = volumeposTag.clientWidth; return volumeposTagWidth !== volumebarTotalWidth; @@ -1907,8 +2280,9 @@ const fluidPlayerClass = function () { self.setLayout = () => { //All other browsers - const listenTo = (self.isTouchDevice()) ? 'touchend' : 'click'; - self.domRef.player.addEventListener(listenTo, () => self.playPauseToggle(), false); + if (!self.isTouchDevice()) { + self.domRef.player.addEventListener('click', () => self.playPauseToggle(), false); + } //Mobile Safari - because it does not emit a click event on initial click of the video self.domRef.player.addEventListener('play', self.initialPlay, false); self.setDefaultLayout(); @@ -1947,6 +2321,18 @@ const fluidPlayerClass = function () { wrapper.style.width = self.domRef.player.clientWidth + 'px'; } + const parseBorderRadius = () => { + const roundedCorners = self.displayOptions.layoutControls.roundedCorners; + const parsedValue = Number(roundedCorners); + + return !isNaN(parsedValue) && parsedValue !== 0 + ? `${parsedValue}px` + : roundedCorners; + } + + wrapper.style.borderRadius = self.domRef.player.style.borderRadius = parseBorderRadius(); + wrapper.style.overflow = 'hidden'; + self.domRef.player.style.height = '100%'; self.domRef.player.style.width = '100%'; @@ -1963,7 +2349,7 @@ const fluidPlayerClass = function () { } }; - self.createVideoSourceSwitch = () => { + self.createVideoSourceSwitch = (initialLoad = true) => { const sources = []; const sourcesList = self.domRef.player.querySelectorAll('source'); [].forEach.call(sourcesList, source => { @@ -1976,18 +2362,22 @@ const fluidPlayerClass = function () { } }); + const sourceChangeButton = self.domRef.wrapper.querySelector('.fluid_control_video_source'); self.videoSources = sources; + + if (self.videoSources.length > 1) { + sourceChangeButton.style.display = 'inline-block'; + } else { + sourceChangeButton.style.display = 'none'; + } + if (self.videoSources.length <= 1) { return; } - const sourceChangeButton = document.getElementById(self.videoPlayerId + '_fluid_control_video_source'); - sourceChangeButton.style.display = 'inline-block'; - let appendSourceChange = false; const sourceChangeList = document.createElement('div'); - sourceChangeList.id = self.videoPlayerId + '_fluid_control_video_source_list'; sourceChangeList.className = 'fluid_video_sources_list'; sourceChangeList.style.display = 'none'; @@ -1999,12 +2389,17 @@ const fluidPlayerClass = function () { continue; } + // On suggested videos, if the resolution doesn't exist in the new source list, use the first one in the list + // This gets overwritten if it's needed by setPersistentSettings() + if(firstSource && !initialLoad) { + self.domRef.player.src = source.url; + } + const sourceSelected = (firstSource) ? "source_selected" : ""; const hdElement = (source.isHD) ? '' : ''; firstSource = false; const sourceChangeDiv = document.createElement('div'); - sourceChangeDiv.id = 'source_' + self.videoPlayerId + '_' + source.title; - sourceChangeDiv.className = 'fluid_video_source_list_item'; + sourceChangeDiv.className = 'fluid_video_source_list_item js-source_' + source.title; sourceChangeDiv.innerHTML = '' + source.title + hdElement; sourceChangeDiv.addEventListener('click', function (event) { @@ -2014,7 +2409,7 @@ const fluidPlayerClass = function () { self.domRef.player.style.height = self.domRef.player.clientHeight + 'px'; const videoChangedTo = this; - const sourceIcons = document.getElementsByClassName('source_button_icon'); + const sourceIcons = self.domRef.wrapper.getElementsByClassName('source_button_icon'); for (let i = 0; i < sourceIcons.length; i++) { sourceIcons[i].className = sourceIcons[i].className.replace('source_selected', ''); } @@ -2037,19 +2432,19 @@ const fluidPlayerClass = function () { if (appendSourceChange) { sourceChangeButton.appendChild(sourceChangeList); - sourceChangeButton.addEventListener('click', () => { - self.openCloseVideoSourceSwitch(); - }); + // To reset player for suggested videos, in case the event listener already exists + sourceChangeButton.removeEventListener('click', self.openCloseVideoSourceSwitch); + sourceChangeButton.addEventListener('click', self.openCloseVideoSourceSwitch); } else { // Didn't give any source options - document.getElementById(self.videoPlayerId + '_fluid_control_video_source').style.display = 'none'; + self.domRef.wrapper.querySelector('.fluid_control_video_source').style.display = 'none'; } }; self.openCloseVideoSourceSwitch = () => { - const sourceChangeList = document.getElementById(self.videoPlayerId + '_fluid_control_video_source_list'); + const sourceChangeList = self.domRef.wrapper.querySelector('.fluid_video_sources_list'); - if (self.isCurrentlyPlayingAd) { + if (self.isCurrentlyPlayingAd || self.isShowingSuggestedVideos()) { sourceChangeList.style.display = 'none'; return; } @@ -2107,7 +2502,7 @@ const fluidPlayerClass = function () { self.domRef.player.play(); } else { self.domRef.player.pause(); - self.controlPlayPauseToggle(self.videoPlayerId); + self.controlPlayPauseToggle(); } self.isSwitchingSource = false; @@ -2130,20 +2525,19 @@ const fluidPlayerClass = function () { } const titleHolder = document.createElement('div'); - titleHolder.id = self.videoPlayerId + '_title'; self.domRef.player.parentNode.insertBefore(titleHolder, null); titleHolder.innerHTML += self.displayOptions.layoutControls.title; titleHolder.classList.add('fp_title'); }; self.hasTitle = () => { - const title = document.getElementById(self.videoPlayerId + '_title'); + const title = self.domRef.wrapper.querySelector('.fp_title'); const titleOption = self.displayOptions.layoutControls.title; return title && titleOption != null; }; self.hideTitle = () => { - const titleHolder = document.getElementById(self.videoPlayerId + '_title'); + const titleHolder = self.domRef.wrapper.querySelector('.fp_title'); if (!self.hasTitle()) { return; @@ -2153,7 +2547,7 @@ const fluidPlayerClass = function () { }; self.showTitle = () => { - const titleHolder = document.getElementById(self.videoPlayerId + '_title'); + const titleHolder = self.domRef.wrapper.querySelector('.fp_title'); if (!self.hasTitle()) { return; @@ -2170,7 +2564,7 @@ const fluidPlayerClass = function () { // Container div for the logo // This is to allow for fade in and out logo_maintain_display const logoHolder = document.createElement('div'); - logoHolder.id = self.videoPlayerId + '_logo'; + logoHolder.className = 'logo_holder'; if (self.displayOptions.layoutControls.logo.hideWithControls) { logoHolder.classList.add('initial_controls_show', 'fp_logo'); } else { @@ -2178,7 +2572,6 @@ const fluidPlayerClass = function () { } // The logo itself const logoImage = document.createElement('img'); - logoImage.id = self.videoPlayerId + '_logo_image'; if (self.displayOptions.layoutControls.logo.imageUrl) { logoImage.src = self.displayOptions.layoutControls.logo.imageUrl; } @@ -2234,8 +2627,7 @@ const fluidPlayerClass = function () { } const containerDiv = document.createElement('div'); - containerDiv.id = self.videoPlayerId + '_fluid_html_on_pause'; - containerDiv.className = 'fluid_html_on_pause'; + containerDiv.className = 'fluid_html_on_pause_container'; containerDiv.style.display = 'none'; containerDiv.innerHTML = self.displayOptions.layoutControls.htmlOnPauseBlock.html; containerDiv.onclick = function (event) { @@ -2257,22 +2649,22 @@ const fluidPlayerClass = function () { * Play button in the middle when the video loads */ self.initPlayButton = () => { - // Create the html fpr the play button + // Create the html for the play button const containerDiv = document.createElement('div'); - containerDiv.id = self.videoPlayerId + '_fluid_initial_play_button'; - containerDiv.className = 'fluid_html_on_pause'; + containerDiv.className = 'fluid_html_on_pause fluid_initial_play_button_container'; const backgroundColor = (self.displayOptions.layoutControls.primaryColor) ? self.displayOptions.layoutControls.primaryColor : "#333333"; - containerDiv.innerHTML = '
'; + containerDiv.innerHTML = '
'; + const initPlayEventTypes = ['click', 'touchend']; const initPlayFunction = function () { self.playPauseToggle(); - containerDiv.removeEventListener('click', initPlayFunction); + initPlayEventTypes.forEach(eventType => containerDiv.removeEventListener(eventType, initPlayFunction)) }; - containerDiv.addEventListener('click', initPlayFunction); + initPlayEventTypes.forEach(eventType => containerDiv.addEventListener(eventType, initPlayFunction)) // If the user has chosen to not show the play button we'll make it invisible // We don't hide altogether because animations might still be used if (!self.displayOptions.layoutControls.playButtonShowing) { - const initialControlsDisplay = document.getElementById(self.videoPlayerId + '_fluid_controls_container'); + const initialControlsDisplay = self.domRef.wrapper.querySelector('.fluid_controls_container'); initialControlsDisplay.classList.add('initial_controls_show'); containerDiv.style.opacity = '0'; } @@ -2311,7 +2703,7 @@ const fluidPlayerClass = function () { self.newActivity = true; }; - setInterval(() => { + const intervalId = setInterval(() => { if (self.newActivity !== true) { return; } @@ -2341,17 +2733,19 @@ const fluidPlayerClass = function () { }, self.displayOptions.layoutControls.controlBar.autoHideTimeout * 1000); }, 300); + self.destructors.push(() => clearInterval(intervalId)); + const listenTo = (self.isTouchDevice()) ? ['touchstart', 'touchmove', 'touchend'] : ['mousemove', 'mousedown', 'mouseup']; for (let i = 0; i < listenTo.length; i++) { - videoPlayer.addEventListener(listenTo[i], activity); + videoPlayer.addEventListener(listenTo[i], activity, { passive: true }); } }; self.hasControlBar = () => { - return !!document.getElementById(self.videoPlayerId + '_fluid_controls_container'); + return !!self.domRef.wrapper.querySelector('.fluid_controls_container'); }; self.isControlBarVisible = () => { @@ -2359,7 +2753,7 @@ const fluidPlayerClass = function () { return false; } - const controlBar = document.getElementById(self.videoPlayerId + '_fluid_controls_container'); + const controlBar = self.domRef.wrapper.querySelector('.fluid_controls_container'); const style = window.getComputedStyle(controlBar, null); return !(style.opacity === 0 || style.visibility === 'hidden'); }; @@ -2406,6 +2800,8 @@ const fluidPlayerClass = function () { } } } + + self.repositionSubtitlesContainer('12px'); }; self.showControlBar = (event) => { @@ -2413,7 +2809,7 @@ const fluidPlayerClass = function () { self.toggleAdCountdown(false); } - if (event.type === 'mouseenter') { + if (event.type === 'mouseenter' || event.type === 'userActive') { self.domRef.player.style.cursor = 'default'; } @@ -2445,6 +2841,8 @@ const fluidPlayerClass = function () { } } } + + self.repositionSubtitlesContainer('46px'); }; self.linkControlBarUserActivity = () => { @@ -2502,25 +2900,31 @@ const fluidPlayerClass = function () { } }; progressInterval = setInterval(logProgress, 500); + self.destructors.push(() => clearInterval(progressInterval)); }; self.createPlaybackList = () => { - const playbackRates = ['x2', 'x1.5', 'x1', 'x0.5']; - if (!self.displayOptions.layoutControls.playbackRateEnabled) { return; } - document.getElementById(self.videoPlayerId + '_fluid_control_playback_rate').style.display = 'inline-block'; - - const sourceChangeButton = document.getElementById(self.videoPlayerId + '_fluid_control_playback_rate'); + const sourceChangeButton = self.domRef.wrapper.querySelector('.fluid_control_playback_rate'); + sourceChangeButton.style.display = 'inline-block'; const sourceChangeList = document.createElement('div'); - sourceChangeList.id = self.videoPlayerId + '_fluid_control_video_playback_rate'; sourceChangeList.className = 'fluid_video_playback_rates'; sourceChangeList.style.display = 'none'; - playbackRates.forEach(function (rate) { + if ( + !Array.isArray(self.displayOptions.layoutControls.controlBar.playbackRates) + || self.displayOptions.layoutControls.controlBar.playbackRates.some( + rate => typeof rate !== 'string' || Number.isNaN(Number(rate.replace('x', ''))) + ) + ) { + self.displayOptions.layoutControls.controlBar.playbackRates = ['x2', 'x1.5', 'x1', 'x0.5']; + } + + self.displayOptions.layoutControls.controlBar.playbackRates.forEach(function (rate) { const sourceChangeDiv = document.createElement('div'); sourceChangeDiv.className = 'fluid_video_playback_rates_item'; sourceChangeDiv.innerText = rate; @@ -2542,7 +2946,7 @@ const fluidPlayerClass = function () { }; self.openCloseVideoPlaybackRate = () => { - const sourceChangeList = document.getElementById(self.videoPlayerId + '_fluid_control_video_playback_rate'); + const sourceChangeList = self.domRef.wrapper.querySelector('.fluid_video_playback_rates'); if (self.isCurrentlyPlayingAd || 'none' !== sourceChangeList.style.display) { sourceChangeList.style.display = 'none'; @@ -2558,14 +2962,14 @@ const fluidPlayerClass = function () { }; self.createDownload = () => { - const downloadOption = document.getElementById(self.videoPlayerId + '_fluid_control_download'); + const downloadOption = self.domRef.wrapper.querySelector('.fluid_control_download'); if (!self.displayOptions.layoutControls.allowDownload) { return; } downloadOption.style.display = 'inline-block'; let downloadClick = document.createElement('a'); - downloadClick.id = self.videoPlayerId + '_download'; + downloadClick.className = 'fp_download_click'; downloadClick.onclick = function (e) { const linkItem = this; @@ -2573,7 +2977,7 @@ const fluidPlayerClass = function () { e.stopImmediatePropagation(); } - setInterval(function () { + setTimeout(function () { linkItem.download = ''; linkItem.href = ''; }, 100); @@ -2582,26 +2986,25 @@ const fluidPlayerClass = function () { downloadOption.appendChild(downloadClick); downloadOption.addEventListener('click', function () { - const downloadItem = document.getElementById(self.videoPlayerId + '_download'); + const downloadItem = self.domRef.wrapper.querySelector('.fp_download_click'); downloadItem.download = self.originalSrc; downloadItem.href = self.originalSrc; downloadClick.click(); }); }; - self.theatreToggle = () => { + self.theatreToggle = (toAnotherDisplayTarget = false) => { + self.debugMessage(`Toggling Theater Mode`); if (self.isInIframe) { return; } - // Theatre and fullscreen, it's only one or the other - if (self.fullscreenMode) { - self.fullscreenToggle(); - } + const previousDisplayMode = self.getPreviousDisplayMode(); + this.resetDisplayMode(displayModes.THEATER); // Advanced Theatre mode if specified if (self.displayOptions.layoutControls.theatreAdvanced) { - const elementForTheatre = document.getElementById(self.displayOptions.layoutControls.theatreAdvanced.theatreElement); + const elementForTheatre = self.domRef.wrapper.querySelector(`#${self.displayOptions.layoutControls.theatreAdvanced.theatreElement}`); const theatreClassToApply = self.displayOptions.layoutControls.theatreAdvanced.classToApply; if (elementForTheatre != null && theatreClassToApply != null) { if (!self.theatreMode) { @@ -2630,11 +3033,15 @@ const fluidPlayerClass = function () { event.initEvent(theatreEvent, false, true); self.domRef.player.dispatchEvent(event); + if (!toAnotherDisplayTarget) { + self.trackPlayerSizeChanged(previousDisplayMode); + } + self.resizeVpaidAuto(); }; self.defaultTheatre = () => { - const videoWrapper = document.getElementById('fluid_video_wrapper_' + self.videoPlayerId); + const videoWrapper = self.domRef.wrapper; if (self.theatreMode) { videoWrapper.classList.remove('fluid_theatre_mode'); @@ -2693,7 +3100,6 @@ const fluidPlayerClass = function () { } const containerDiv = document.createElement('div'); - containerDiv.id = self.videoPlayerId + '_fluid_pseudo_poster'; containerDiv.className = 'fluid_pseudo_poster'; if (['auto', 'contain', 'cover'].indexOf(self.displayOptions.layoutControls.posterImageSize) === -1) { console.log('[FP_ERROR] Not allowed value in posterImageSize'); @@ -2728,8 +3134,12 @@ const fluidPlayerClass = function () { } }; - self.setPersistentSettings = () => { - if (!(typeof (Storage) !== 'undefined' && typeof (localStorage) !== 'undefined')) { + self.setPersistentSettings = (ignoreMute = false) => { + try { + if (!(typeof (Storage) !== 'undefined' && typeof (localStorage) !== 'undefined')) { + return; + } + } catch (e) { return; } @@ -2744,7 +3154,8 @@ const fluidPlayerClass = function () { self.fluidStorage = localStorage; if (typeof (self.fluidStorage.fluidVolume) !== 'undefined' - && self.displayOptions.layoutControls.persistentSettings.volume) { + && self.displayOptions.layoutControls.persistentSettings.volume + && !ignoreMute) { self.setVolume(self.fluidStorage.fluidVolume); if (typeof (self.fluidStorage.fluidMute) !== 'undefined' && self.fluidStorage.fluidMute === 'true') { @@ -2754,8 +3165,8 @@ const fluidPlayerClass = function () { if (typeof (self.fluidStorage.fluidQuality) !== 'undefined' && self.displayOptions.layoutControls.persistentSettings.quality) { - const sourceOption = document.getElementById('source_' + self.videoPlayerId + '_' + self.fluidStorage.fluidQuality); - const sourceChangeButton = document.getElementById(self.videoPlayerId + '_fluid_control_video_source'); + const sourceOption = self.domRef.wrapper.querySelector('.js-source_' + self.fluidStorage.fluidQuality); + const sourceChangeButton = self.domRef.wrapper.querySelector('.fluid_control_video_source'); if (sourceOption) { sourceOption.click(); sourceChangeButton.click(); @@ -2780,6 +3191,7 @@ const fluidPlayerClass = function () { return; } self.playPauseToggle(); + return true; }; @@ -2825,12 +3237,11 @@ const fluidPlayerClass = function () { return false; } - const htmlBlock = document.getElementById(self.videoPlayerId + '_fluid_html_on_pause'); + const htmlBlock = self.domRef.wrapper.querySelector('.fluid_html_on_pause_container'); // We create the HTML block from scratch if it doesn't already exist if (!htmlBlock) { const containerDiv = document.createElement('div'); - containerDiv.id = self.videoPlayerId + '_fluid_html_on_pause'; containerDiv.className = 'fluid_html_on_pause'; containerDiv.style.display = 'none'; containerDiv.innerHTML = passedHtml.html; @@ -2862,7 +3273,7 @@ const fluidPlayerClass = function () { }; self.toggleControlBar = (show) => { - const controlBar = document.getElementById(self.videoPlayerId + 'fluid_controls_container'); + const controlBar = self.domRef.wrapper.querySelector('.fluid_controls_container'); if (show) { controlBar.className += ' initial_controls_show'; @@ -2872,42 +3283,47 @@ const fluidPlayerClass = function () { controlBar.className = controlBar.className.replace(' initial_controls_show', ''); }; - self.on = (eventCall, functionCall) => { - switch (eventCall) { - case 'play': - self.domRef.player.onplay = functionCall; - break; - case 'seeked': - self.domRef.player.onseeked = functionCall; - break; - case 'ended': - self.domRef.player.onended = functionCall; - break; - case 'pause': - self.domRef.player.addEventListener('pause', () => { - if (!self.fluidPseudoPause) { - functionCall(); - } - }); - break; - case 'playing': - self.domRef.player.addEventListener('playing', functionCall); - break; - case 'theatreModeOn': - self.domRef.player.addEventListener('theatreModeOn', functionCall); - break; - case 'theatreModeOff': - self.domRef.player.addEventListener('theatreModeOff', functionCall); - break; - case 'timeupdate': - self.domRef.player.addEventListener('timeupdate', () => { - functionCall(self.getCurrentTime()) - }); - break; - default: - console.log('[FP_ERROR] Event not recognised'); - break; + self.on = (eventCall, callback) => { + /** + * Improves events by adding player info to the callbacks + * + * source | preRoll | midRoll | postRoll + */ + const getAdditionalInfo = () => ({ + mediaSourceType: self.currentMediaSourceType + }); + + const functionCall = (event, additionalEventData = {}) => { + const additionalInfo = {...getAdditionalInfo(), ...additionalEventData} + return callback(event, additionalInfo); + } + + const eventHandlers = { + play: () => self.domRef.player.addEventListener('play', functionCall), + seeked: () => self.domRef.player.addEventListener('seeked', functionCall), + ended: () => self.domRef.player.addEventListener('ended', functionCall), + pause: () => self.domRef.player.addEventListener('pause', (event) => { + if (!self.fluidPseudoPause) { + functionCall(event) + } + }), + playing: () => self.domRef.player.addEventListener('playing', functionCall), + waiting: () => self.domRef.player.addEventListener('waiting', functionCall), + theatreModeOn: () => self.domRef.player.addEventListener('theatreModeOn', functionCall), + theatreModeOff: () => self.domRef.player.addEventListener('theatreModeOff', functionCall), + timeupdate: () => self.domRef.player.addEventListener('timeupdate', (event) => { + functionCall(event, { currentTime: self.domRef.player.currentTime }); + }), + miniPlayerToggle: () => self.domRef.player.addEventListener('miniPlayerToggle', functionCall) + }; + + if (!eventHandlers[eventCall]) { + console.error(`[FP_ERROR] Event "${eventCall}" is not recognized`); + return; } + + // Call event handler + eventHandlers[eventCall](); }; self.toggleLogo = (logo) => { @@ -2915,7 +3331,7 @@ const fluidPlayerClass = function () { return false; } - const logoBlock = document.getElementById(self.videoPlayerId + "_logo"); + const logoBlock = self.domRef.wrapper.querySelector('.fp_logo'); // We create the logo from scratch if it doesn't already exist, they might not give everything correctly so we self.displayOptions.layoutControls.logo.imageUrl = (logo.imageUrl) ? logo.imageUrl : null; @@ -2974,21 +3390,39 @@ const fluidPlayerClass = function () { } }; + /** + * Resets all display types that are not the target display mode + * + * @param {'fullscreen'|'theaterMode'|'miniPlayer'} displayTarget + */ + self.resetDisplayMode = (displayTarget) => { + if (self.fullscreenMode && displayTarget !== displayModes.FULLSCREEN) { + self.fullscreenToggle(true); + } + + if (self.theatreMode && displayTarget !== displayModes.THEATER) { + self.theatreToggle(true); + } + + if (self.miniPlayerToggledOn && displayTarget !== displayModes.MINI_PLAYER) { + self.toggleMiniPlayer('off', false, true); + } + } + self.destroy = () => { + self.domRef.player.classList.remove('js-fluid-player'); const numDestructors = self.destructors.length; if (0 === numDestructors) { return; } - for (let i = 0; i < numDestructors; ++i) { - self.destructors[i].bind(this)(); - } + self.destructors.forEach(destructor => destructor.call(self)); - const container = document.getElementById('fluid_video_wrapper_' + self.videoPlayerId); + const container = self.domRef.wrapper; if (!container) { - console.warn('Unable to remove wrapper element for Fluid Player instance - element not found ' + self.videoPlayerId); + console.warn('Unable to remove wrapper element for Fluid Player instance - element not found'); return; } @@ -3002,7 +3436,7 @@ const fluidPlayerClass = function () { return; } - console.error('Unable to remove wrapper element for Fluid Player instance - no parent' + self.videoPlayerId); + console.error('Unable to remove wrapper element for Fluid Player instance - no parent'); } }; @@ -3043,6 +3477,14 @@ const fluidPlayerInterface = function (instance) { return instance.fullscreenToggle(state) }; + this.toggleMiniPlayer = (state) => { + if (state === undefined) { + state = !instance.miniPlayerToggledOn; + } + + return instance.toggleMiniPlayer(state ? 'on' : 'off', true); + }; + this.destroy = () => { return instance.destroy() }; @@ -3058,6 +3500,10 @@ const fluidPlayerInterface = function (instance) { this.on = (event, callback) => { return instance.on(event, callback) }; + + this.setDebug = (value) => { + instance.displayOptions.debug = value; + } } /** diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 00000000..a69279d4 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,230 @@ +declare module 'fluid-player' { + function fluidPlayer( + target: HTMLVideoElement | String | string, + options?: Partial + ): FluidPlayerInstance; + + export default fluidPlayer; +} + +declare type AdditionalEventInfo = { mediaSourceType: 'source' | 'preRoll' | 'midRoll' | 'postRoll' }; +declare type OnPlay = (event: 'play', callback: (additionalInfo: AdditionalEventInfo) => void) => void; +declare type OnPlaying = + (event: 'playing', callback: (event: Event, additionalInfo: AdditionalEventInfo) => void) => void; +declare type OnPause = (event: 'pause', callback: (additionalInfo: AdditionalEventInfo) => void) => void; +declare type OnEnded = (event: 'ended', callback: (additionalInfo: AdditionalEventInfo) => void) => void; +declare type OnSeeked = (event: 'seeked', callback: (additionalInfo: AdditionalEventInfo) => void) => void; +declare type OnTheaterModeOn = + (event: 'theatreModeOn', callback: (event: Event, additionalInfo: AdditionalEventInfo) => void) => void; +declare type OnTheaterModeOff = + (event: 'theatreModeOff', callback: (event: Event, additionalInfo: AdditionalEventInfo) => void) => void; +declare type OnTimeUpdate = + (event: 'timeupdate', callback: (time: number, additionalInfo: AdditionalEventInfo) => void) => void; +declare type OnMiniPlayerToggle = + (event: 'miniPlayerToggle', callback: (event: CustomEvent<{ + isToggledOn: boolean + }>, additionalInfo: AdditionalEventInfo) => void) => void; + +declare interface FluidPlayerInstance { + play: () => void; + pause: () => void; + skipTo: (seconds: number) => void; + setPlaybackSpeed: (speed: number) => void; + setVolume: (volume: number) => void; + toggleControlBar: (shouldToggle: boolean) => void; + toggleFullScreen: (shouldToggle: boolean) => void; + toggleMiniPlayer: (shouldToggle: boolean) => void; + setHtmlOnPauseBlock: (pauseBlock: { html: string; width: number; height: number; }) => void; + destroy: () => void; + dashInstance: () => any | null; + hlsInstance: () => any | null; + on: OnPlay & OnPlaying & OnPause & OnEnded & OnSeeked & OnTheaterModeOn & OnTheaterModeOff & OnTimeUpdate & + OnMiniPlayerToggle; +} + +declare interface LayoutControls { + primaryColor: false | string; + posterImage: false | string; + posterImageSize: 'auto' | 'cover' | 'contain'; + playButtonShowing: boolean; + playPauseAnimation: boolean; + fillToContainer: boolean; + autoPlay: boolean; + preload: 'none' | 'metadata' | 'auto' | string; + mute: boolean; + doubleclickFullscreen: boolean; + subtitlesEnabled: boolean; + keyboardControl: boolean; + title: string; + loop: boolean; + logo: Partial<{ + imageUrl: string | null; + position: 'top right' | 'top left' | 'bottom right' | 'bottom left'; + clickUrl: string | null; + opacity: number; + mouseOverImageUrl: string | null; + imageMargin: string; + hideWithControls: boolean; + showOverAds: boolean; + }>; + controlBar: Partial<{ + autoHide: boolean; + autoHideTimeout: number; + animated: boolean; + }>; + timelinePreview: VTTPreviewOptions | StaticPreviewOptions; + htmlOnPauseBlock: Partial<{ + html: string | null; + height: number | null; + width: number | null; + }>; + layout: 'default' | string; + allowDownload: boolean; + playbackRateEnabled: boolean; + allowTheatre: boolean; + theatreAdvanced: Partial<{ + theatreElement: string; + classToApply: string; + }>; + theatreSettings: Partial<{ + width: string; + height: string; + marginTop: number; + horizontalAlign: 'center' | 'left' | 'right'; + }>; + playerInitCallback: () => void; + persistentSettings: Partial<{ + volume: boolean; + quality: boolean; + speed: boolean; + theatre: boolean; + }>; + controlForwardBackward: Partial<{ + show: boolean; + doubleTapMobile: boolean; + }>; + contextMenu: Partial<{ + controls: boolean; + links: Array<{ + href: string; + label: string; + }>; + }>; + miniPlayer: Partial<{ + enabled: boolean; + width: number; + height: number; + widthMobile: number; + placeholderText: string; + position: 'top right' | 'top left' | 'bottom right' | 'bottom left'; + autoToggle: boolean; + }>; + showCardBoardView: boolean; + showCardBoardJoystick: boolean; + roundedCorners: number; + autoRotateFullScreen: boolean; +} + +declare interface VTTPreviewOptions { + file: string; + type: 'VTT'; + spriteRelativePath?: boolean; + sprite?: string; +} + +declare interface StaticPreviewOptions { + type: 'static'; + frames: Array<{ + startTime: number; + endTime: number; + image: string; + x: number; + y: number; + w: number; + h: number; + }> +} + +declare interface VastOptions { + adList: Array; + skipButtonCaption: string; + skipButtonClickCaption: string; + adText: string; + adTextPosition: 'top right' | 'top left' | 'bottom right' | 'bottom left'; + adCTAText: string | boolean; + adCTATextPosition: 'top right' | 'top left' | 'bottom right' | 'bottom left'; + adCTATextVast: boolean; + vastTimeout: number; + showPlayButton: boolean; + maxAllowedVastTagRedirects: number; + showProgressbarMarkers: boolean; + adClickable: boolean; + allowVPAID: boolean; + vastAdvanced: Partial<{ + vastLoadedCallback: () => void; + noVastVideoCallback: () => void; + vastVideoSkippedCallback: () => void; + vastVideoEndedCallback: () => void; + }>; +} + +declare interface AdOptions { + vastTag: string; + roll: string; + fallbackVastTags?: Array; + adText?: string; + adTextPosition?: 'top right' | 'top left' | 'bottom right' | 'bottom left'; + adClickable?: boolean; + vAlign?: 'top' | 'middle' | 'bottom'; + nonLinearDuration?: number; + size?: '468x60' | '300x250' | '728x90'; +} + +declare interface PreRollAdOptions extends AdOptions { + roll: 'preRoll'; +} + +declare interface MidRollAdOptions extends AdOptions { + roll: 'midRoll'; + timer: number | string; +} + +declare interface PostRollAdOptions extends AdOptions { + roll: 'postRoll'; +} + +declare interface OnPauseRollAdOptions extends AdOptions { + roll: 'onPauseRoll'; +} + +declare interface ModulesOptions { + configureHls: (options: any) => any; + onBeforeInitHls: (hls: any) => void; + onAfterInitHls: (hls: any) => void; + configureDash: (options: any) => any; + onBeforeInitDash: (dash: any) => void; + onAfterInitDash: (dash: any) => void; +} + +declare interface FluidPlayerOptions { + layoutControls: Partial; + vastOptions: Partial; + modules: Partial; + onBeforeXMLHttpRequestOpen?: (request: XMLHttpRequest) => void; + onBeforeXMLHttpRequest?: (request: XMLHttpRequest) => void; + debug?: boolean; + captions: Partial<{ + play: string; + pause: string; + mute: string; + unmute: string; + fullscreen: string; + exitFullscreen: string; + }>; + suggestedVideos?: { + configUrl: string | null; + }; + hls?: { + overrideNative: boolean; + }; +} diff --git a/src/index.js b/src/index.js index 13cc5544..d26f47e4 100644 --- a/src/index.js +++ b/src/index.js @@ -1,31 +1,30 @@ -'use strict'; if ('undefined' === typeof FP_HOMEPAGE) { - global.FP_HOMEPAGE = 'https://fluidplayer.com'; + globalThis.FP_HOMEPAGE = 'https://fluidplayer.com'; } if ('undefined' === typeof FP_BUILD_VERSION) { - global.FP_BUILD_VERSION = 'v3'; + globalThis.FP_BUILD_VERSION = 'v3'; } if ('undefined' === typeof FP_ENV) { - const isLocalhost = window - && window.location - && (window.location.hostname === 'localhost' - || window.location.hostname === '127.0.0.1' - || window.location.hostname === ''); + const isLocalhost = globalThis + && globalThis.location + && (globalThis.location.hostname === 'localhost' + || globalThis.location.hostname === '127.0.0.1' + || globalThis.location.hostname === ''); - if (process && process.env && process.env.NODE_ENV) { - global.FP_ENV = process.env.NODE_ENV; - } else if (window && !isLocalhost) { - global.FP_ENV = 'production'; + if ('undefined' !== typeof process && process && process.env && process.env.NODE_ENV) { + globalThis.FP_ENV = process.env.NODE_ENV; + } else if (globalThis && !isLocalhost) { + globalThis.FP_ENV = 'production'; } else { - global.FP_ENV = 'development'; + globalThis.FP_ENV = 'development'; } } if ('undefined' === typeof FP_DEBUG) { - global.FP_DEBUG = false; + globalThis.FP_DEBUG = false; } import './polyfills'; diff --git a/src/modules/adsupport.js b/src/modules/adsupport.js index 36f6d652..0d8992fb 100644 --- a/src/modules/adsupport.js +++ b/src/modules/adsupport.js @@ -1,28 +1,55 @@ -'use strict'; +import { displayModes, trackingEventTypes } from '../constants/constants'; export default function (playerInstance, options) { const VPAID_VERSION = '2.0'; - - playerInstance.renderLinearAd = (adListId, backupTheVideoTime) => { + const { + muteEvent, + unmuteEvent, + pauseEvent, + resumeEvent, + rewindEvent, + fullscreenEvent, + skipEvent, + playerExpandEvent, + playerCollapseEvent, + impressionEvent, + viewImpressionEvent, + startEvent, + firstQuartileEvent, + midpointEvent, + thirdQuartileEvent, + completeEvent, + progressEvent, + creativeViewEvent, + collapseEvent, + adCollapseEvent, + closeEvent, + acceptInvitationEvent + } = trackingEventTypes; + + playerInstance.renderLinearAd = (ad, backupTheVideoTime) => { playerInstance.toggleLoader(false); + // Reset live indicator + playerInstance.hideLiveIndicator(); + playerInstance.isLiveStream = null; + //get the proper ad - playerInstance.vastOptions = playerInstance.adPool[adListId]; + playerInstance.vastOptions = ad; if (backupTheVideoTime) { - playerInstance.backupMainVideoContentTime(adListId); + playerInstance.backupMainVideoContentTime(ad.rollListId); } - const playVideoPlayer = adListId => { - playerInstance.switchPlayerToVpaidMode = adListId => { + const playVideoPlayer = ad => { + playerInstance.switchPlayerToVpaidMode = ad => { playerInstance.debugMessage('starting function switchPlayerToVpaidMode'); - const vpaidIframe = playerInstance.videoPlayerId + "_" + adListId + "_fluid_vpaid_iframe"; + const vpaidIframe = "fp_" + ad.id + "_fluid_vpaid_iframe"; const creativeData = {}; - creativeData.AdParameters = playerInstance.adPool[adListId].adParameters; + creativeData.AdParameters = ad.adParameters; const slotElement = document.createElement('div'); - slotElement.id = playerInstance.videoPlayerId + "_fluid_vpaid_slot"; slotElement.className = 'fluid_vpaid_slot'; - slotElement.setAttribute('adListId', adListId); + slotElement.setAttribute('adListId', ad.id); playerInstance.domRef.player.parentNode.insertBefore(slotElement, vpaidIframe.nextSibling); @@ -37,7 +64,7 @@ export default function (playerInstance, options) { const compare = playerInstance.compareVersion(VPAID_VERSION, ver); if (compare === 1) { //VPAID version of ad is lower than we need - playerInstance.adList[adListId].error = true; + ad.error = true; playerInstance.playMainVideoWhenVpaidFails(403); return false; } @@ -50,7 +77,7 @@ export default function (playerInstance, options) { playerInstance.domRef.player.removeAttribute('controls'); //Remove the default Controls playerInstance.vpaidCallbackListenersAttach(); - const mode = (playerInstance.fullscreenMode ? 'fullscreen' : 'normal'); + const mode = (playerInstance.fullscreenMode ? displayModes.FULLSCREEN : displayModes.NORMAL); const adWidth = playerInstance.domRef.player.offsetWidth; const adHeight = playerInstance.domRef.player.offsetHeight; playerInstance.vpaidAdUnit.initAd(adWidth, adHeight, mode, 3000, creativeData, environmentVars); @@ -61,7 +88,7 @@ export default function (playerInstance, options) { } playerInstance.toggleLoader(false); - playerInstance.adList[adListId].played = true; + ad.played = true; playerInstance.adFinished = false; }; @@ -74,22 +101,25 @@ export default function (playerInstance, options) { if (playerInstance.displayOptions.layoutControls.showCardBoardView) { - if (!playerInstance.adList[adListId].landingPage) { - playerInstance.addCTAButton(playerInstance.adPool[adListId].clickthroughUrl); + if (!ad.landingPage) { + playerInstance.addCTAButton(ad.clickthroughUrl); } else { - playerInstance.addCTAButton(playerInstance.adList[adListId].landingPage); + playerInstance.addCTAButton(ad.landingPage); } } else { + let idAdClickable = [undefined, true] + .includes(playerInstance.displayOptions.vastOptions.adClickable); - const addClickthroughLayer = (typeof playerInstance.adList[adListId].adClickable != "undefined") ? playerInstance.adList[adListId].adClickable : playerInstance.displayOptions.vastOptions.adClickable; - - if (addClickthroughLayer) { - playerInstance.addClickthroughLayer(playerInstance.videoPlayerId); + if (playerInstance.rollsById[ad.rollListId].adClickable !== undefined) { + idAdClickable = playerInstance.rollsById[ad.rollListId].adClickable; } - playerInstance.addCTAButton(playerInstance.adList[adListId].landingPage); + if (idAdClickable) { + playerInstance.addClickthroughLayer(); + } + playerInstance.addCTAButton(ad.landingPage); } if (playerInstance.vastOptions.skipoffset !== false) { @@ -109,29 +139,29 @@ export default function (playerInstance, options) { progressbarContainer[i].style.backgroundColor = playerInstance.displayOptions.layoutControls.adProgressColor; } - if (playerInstance.displayOptions.vastOptions.adText || playerInstance.adList[adListId].adText) { - const adTextToShow = (playerInstance.adList[adListId].adText !== null) ? playerInstance.adList[adListId].adText : playerInstance.displayOptions.vastOptions.adText; + if (playerInstance.rollsById[ad.rollListId].adText || ad.adText) { + const adTextToShow = ad.adText ? ad.adText : playerInstance.rollsById[ad.rollListId].adText; playerInstance.addAdPlayingText(adTextToShow); } - playerInstance.positionTextElements(playerInstance.adList[adListId]); + playerInstance.positionTextElements(ad); playerInstance.toggleLoader(false); - playerInstance.adList[adListId].played = true; + ad.played = true; playerInstance.adFinished = false; playerInstance.domRef.player.play(); //Announce the impressions - playerInstance.trackSingleEvent('impression'); + playerInstance.trackSingleEvent(impressionEvent); playerInstance.domRef.player.removeEventListener('loadedmetadata', playerInstance.switchPlayerToVastMode); // if in vr mode then do not show if (playerInstance.vrMode) { - const adCountDownTimerText = document.getElementById('ad_countdown' + playerInstance.videoPlayerId); - const ctaButton = document.getElementById(playerInstance.videoPlayerId + '_fluid_cta'); - const addAdPlayingTextOverlay = document.getElementById(playerInstance.videoPlayerId + '_fluid_ad_playing'); - const skipBtn = document.getElementById('skip_button_' + playerInstance.videoPlayerId); + const adCountDownTimerText = playerInstance.domRef.wrapper.querySelector('.ad_countdown'); + const ctaButton = playerInstance.domRef.wrapper.querySelector('.fluid_ad_cta'); + const addAdPlayingTextOverlay = playerInstance.domRef.wrapper.querySelector('.fluid_ad_playing'); + const skipBtn = playerInstance.domRef.wrapper.querySelector('.skip_button'); if (adCountDownTimerText) { adCountDownTimerText.style.display = 'none'; @@ -160,8 +190,8 @@ export default function (playerInstance, options) { const selectedMediaFile = playerInstance.getSupportedMediaFileObject(playerInstance.vastOptions.mediaFileList); // if player in cardboard mode then, linear ads media type should be a '360' video - if (playerInstance.displayOptions.layoutControls.showCardBoardView && playerInstance.adList[adListId].mediaType !== '360') { - playerInstance.adList[adListId].error = true; + if (playerInstance.displayOptions.layoutControls.showCardBoardView && ad.mediaType !== '360') { + ad.error = true; playerInstance.playMainVideoWhenVastFails(403); return false; } @@ -173,6 +203,7 @@ export default function (playerInstance, options) { window.Hls = it.default; const hls = new Hls({ debug: typeof FP_DEBUG !== 'undefined' && FP_DEBUG === true, + startPosition: 0, p2pConfig: { logLevel: false, }, @@ -184,6 +215,15 @@ export default function (playerInstance, options) { hls.loadSource(selectedMediaFile.src); playerInstance.isCurrentlyPlayingAd = true; + hls.once(Hls.Events.LEVEL_LOADED, function (event, level) { + playerInstance.isLiveStream = !!level.details.live; + if (!!level.details.live) { + playerInstance.showLiveIndicator(); + } else { + playerInstance.hideLiveIndicator(); + } + }); + playerInstance.hlsPlayer = hls; playerInstance.domRef.player.addEventListener('loadedmetadata', playerInstance.switchPlayerToVastMode); @@ -199,7 +239,7 @@ export default function (playerInstance, options) { } else if (!isVpaid) { if (selectedMediaFile.src === false) { // Couldn’t find MediaFile that is supported by this video player, based on the attributes of the MediaFile element. - playerInstance.adList[adListId].error = true; + ad.error = true; playerInstance.playMainVideoWhenVastFails(403); return false; } @@ -219,7 +259,7 @@ export default function (playerInstance, options) { playerInstance.domRef.player.addEventListener('ended', playerInstance.onVastAdEnded); } else { - playerInstance.loadVpaid(adListId, selectedMediaFile.src); + playerInstance.loadVpaid(ad, selectedMediaFile.src); if (playerInstance.displayOptions.vastOptions.showProgressbarMarkers) { playerInstance.hideAdMarkers(); @@ -248,35 +288,36 @@ export default function (playerInstance, options) { }; - playVideoPlayer(adListId); + playVideoPlayer(ad); playerInstance.domRef.player.addEventListener('timeupdate', videoPlayerTimeUpdate); }; - playerInstance.playRoll = (adListId) => { + playerInstance.playRoll = (adList) => { // register all the ad pods - for (let i = 0; i < adListId.length; i++) { - if (!playerInstance.adPool.hasOwnProperty(adListId[i])) { - playerInstance.announceLocalError(101); - return; - } - playerInstance.temporaryAdPods.push(playerInstance.adList[adListId[i]]); + const newPods = []; + for (let i = 0; i < adList.length; i++) { + newPods.push(adList[i]); } + playerInstance.temporaryAdPods = newPods; if (playerInstance.vastOptions !== null && playerInstance.vastOptions.adType.toLowerCase() === 'linear') { return; } - const adListIdToPlay = playerInstance.getNextAdPod(); + const adToPlay = playerInstance.getNextAdPod(); - if (adListIdToPlay !== null) { - playerInstance.renderLinearAd(adListIdToPlay, true); + if (adToPlay !== null) { + playerInstance.renderLinearAd(adToPlay, true); } + + const roll = playerInstance.rollsById[adToPlay.rollListId].roll; + playerInstance.currentMediaSourceType = roll; }; - playerInstance.backupMainVideoContentTime = (adListId) => { - const roll = playerInstance.adList[adListId].roll; + playerInstance.backupMainVideoContentTime = (rollListId) => { + const roll = playerInstance.rollsById[rollListId].roll; //spec configs by roll switch (roll) { @@ -291,8 +332,9 @@ export default function (playerInstance, options) { break; case 'preRoll': - if (playerInstance.domRef.player.currentTime > 0) { - playerInstance.domRef.player.mainVideoCurrentTime = playerInstance.domRef.player.currentTime - 1; + if (playerInstance.domRef.player.currentTime > 0 || playerInstance.domRef.player.safariPlayheadPosition) { + const currentTime = playerInstance.domRef.player.safariPlayheadPosition ?? playerInstance.domRef.player.currentTime; + playerInstance.domRef.player.mainVideoCurrentTime = currentTime - 1; } break; } @@ -358,30 +400,46 @@ export default function (playerInstance, options) { playerInstance.scheduleTrackingEvent = (currentTime, duration) => { if (currentTime === 0) { - playerInstance.trackSingleEvent('start'); + playerInstance.trackSingleEvent(startEvent); + playerInstance.observe(); + playerInstance.domRef.player.timeInView = 0; } - if ((typeof playerInstance.vastOptions.tracking['progress'] !== 'undefined') && - (playerInstance.vastOptions.tracking['progress'].length) && - (typeof playerInstance.vastOptions.tracking['progress'][currentTime] !== 'undefined')) { + // View Impression is defined by IAB as: Watching at least 2 seconds of the video where at least 50% of the ad’s pixels are visible on the screen + if (playerInstance.domRef.player.inView) { + if (playerInstance.domRef.player.timeInView > 2) { + playerInstance.trackSingleEvent(viewImpressionEvent); + } else { + playerInstance.domRef.player.timeInView += currentTime; + } + } + + if (typeof playerInstance.vastOptions.tracking.progress !== 'undefined' && + playerInstance.vastOptions.tracking.progress.length && + typeof playerInstance.vastOptions.tracking.progress[currentTime] !== 'undefined') { + playerInstance.trackSingleEvent(progressEvent, currentTime); + } - playerInstance.trackSingleEvent('progress', currentTime); + if (typeof playerInstance.vastOptions.tracking.creativeView !== 'undefined' && + playerInstance.vastOptions.tracking.creativeView.length && + typeof playerInstance.vastOptions.tracking.creativeView[currentTime] !== 'undefined') { + playerInstance.trackSingleEvent(creativeViewEvent, currentTime); } if (currentTime === (Math.floor(duration / 4))) { - playerInstance.trackSingleEvent('firstQuartile'); + playerInstance.trackSingleEvent(firstQuartileEvent); } if (currentTime === (Math.floor(duration / 2))) { - playerInstance.trackSingleEvent('midpoint'); + playerInstance.trackSingleEvent(midpointEvent); } if (currentTime === (Math.floor(duration * 3 / 4))) { - playerInstance.trackSingleEvent('thirdQuartile'); + playerInstance.trackSingleEvent(thirdQuartileEvent); } if (currentTime >= (duration - 1)) { - playerInstance.trackSingleEvent('complete'); + playerInstance.trackSingleEvent(completeEvent); } }; @@ -396,11 +454,15 @@ export default function (playerInstance, options) { trackingUris.length = 0; switch (eventType) { - case 'start': - case 'firstQuartile': - case 'midpoint': - case 'thirdQuartile': - case 'complete': + case startEvent: + case firstQuartileEvent: + case midpointEvent: + case thirdQuartileEvent: + case completeEvent: + case closeEvent: + case skipEvent: + case acceptInvitationEvent: + // currently 'acceptInvitation' can only be triggered by VPAID ads if (playerInstance.vastOptions.stopTracking[eventType] === false) { if (playerInstance.vastOptions.tracking[eventType] !== null) { trackingUris = playerInstance.vastOptions.tracking[eventType]; @@ -410,20 +472,31 @@ export default function (playerInstance, options) { } break; - case 'progress': - playerInstance.vastOptions.tracking['progress'][eventSubType].elements.forEach(function (currentValue, index) { + case progressEvent: + playerInstance.vastOptions.tracking.progress[eventSubType].elements.forEach(function (currentValue, index) { if ( - (playerInstance.vastOptions.tracking['progress'][eventSubType].stopTracking === false) && - (playerInstance.vastOptions.tracking['progress'][eventSubType].elements.length) + (playerInstance.vastOptions.tracking.progress[eventSubType].stopTracking === false) && + (playerInstance.vastOptions.tracking.progress[eventSubType].elements.length) ) { - trackingUris = playerInstance.vastOptions.tracking['progress'][eventSubType].elements; + trackingUris = playerInstance.vastOptions.tracking.progress[eventSubType].elements; } - playerInstance.vastOptions.tracking['progress'][eventSubType].stopTracking = true; + playerInstance.vastOptions.tracking.progress[eventSubType].stopTracking = true; }); break; - case 'impression': + case creativeViewEvent: + if (!playerInstance.vastOptions.tracking.creativeView || !playerInstance.vastOptions.tracking.creativeView[eventSubType] || playerInstance.vastOptions.tracking.creativeView[eventSubType]?.stopTracking === true) { + break; + } + + if (playerInstance.vastOptions.tracking.creativeView[eventSubType]?.elements.length) { + trackingUris = playerInstance.vastOptions.tracking.creativeView[eventSubType].elements; + } + playerInstance.vastOptions.tracking.creativeView[eventSubType].stopTracking = true; + break; + + case impressionEvent: if ( (typeof playerInstance.vastOptions.impression !== 'undefined') && (playerInstance.vastOptions.impression !== null) && @@ -433,6 +506,37 @@ export default function (playerInstance, options) { } break; + case viewImpressionEvent: + if (playerInstance.vastOptions.stopTracking.viewImpression === true) { + break; + } + + if ( + (typeof playerInstance.vastOptions.viewImpression !== 'undefined') && + (playerInstance.vastOptions.viewImpression !== null) && + (typeof playerInstance.vastOptions.viewImpression.length !== 'undefined') + ) { + trackingUris = playerInstance.vastOptions.viewImpression; + playerInstance.vastOptions.stopTracking.viewImpression = true; + } + break; + + case muteEvent: + case unmuteEvent: + case pauseEvent: + case resumeEvent: + case rewindEvent: + case fullscreenEvent: + case playerExpandEvent: + case playerCollapseEvent: + case collapseEvent: + case adCollapseEvent: + // currently 'collapse'/'adCollapse' can only be triggered by VPAID ads + if (!!playerInstance.vastOptions.tracking[eventType]) { + trackingUris = playerInstance.vastOptions.tracking[eventType]; + } + break; + default: break; } @@ -441,11 +545,12 @@ export default function (playerInstance, options) { }; // ADS - playerInstance.completeNonLinearStatic = (adListId) => { - playerInstance.closeNonLinear(adListId); + playerInstance.completeNonLinearStatic = (ad) => { + playerInstance.closeNonLinear(ad.id); if (playerInstance.adFinished === false) { playerInstance.adFinished = true; - playerInstance.trackSingleEvent('complete'); + playerInstance.isCurrentlyShowingNonLinearAd = false; + playerInstance.trackSingleEvent(completeEvent); } clearInterval(playerInstance.nonLinearTracking); }; @@ -454,23 +559,21 @@ export default function (playerInstance, options) { /** * Show up a nonLinear static creative */ - playerInstance.createNonLinearStatic = (adListId) => { - if (!playerInstance.adPool.hasOwnProperty(adListId) || playerInstance.adPool[adListId].error === true) { - playerInstance.announceLocalError(101); - return; - } - + playerInstance.createNonLinearStatic = (ad) => { //get the proper ad - playerInstance.vastOptions = playerInstance.adPool[adListId]; - playerInstance.createBoard(adListId); - if (playerInstance.adList[adListId].error === true) { + playerInstance.vastOptions = ad; + playerInstance.createBoard(ad); + if (playerInstance.rollsById[ad.rollListId].error === true || ad.error === true) { + playerInstance.announceLocalError(101); return; } playerInstance.adFinished = false; - let duration; + playerInstance.isCurrentlyShowingNonLinearAd = true; + let duration = (playerInstance.rollsById[ad.rollListId].nonLinearDuration) ? playerInstance.rollsById[ad.rollListId].nonLinearDuration : false; if (!playerInstance.vastOptions.vpaid) { - playerInstance.trackSingleEvent('start'); - duration = (playerInstance.adList[adListId].nonLinearDuration) ? playerInstance.adList[adListId].nonLinearDuration : playerInstance.vastOptions.duration; + playerInstance.trackSingleEvent(startEvent); + playerInstance.trackSingleEvent(creativeViewEvent, 0); + duration = duration || playerInstance.vastOptions.duration; playerInstance.nonLinearTracking = setInterval(function () { if (playerInstance.adFinished === true) { @@ -481,47 +584,47 @@ export default function (playerInstance, options) { playerInstance.scheduleTrackingEvent(currentTime, duration); if (currentTime >= (duration - 1)) { playerInstance.adFinished = true; + playerInstance.isCurrentlyShowingNonLinearAd = false; } }, 400); + playerInstance.destructors.push(() => clearInterval(playerInstance.nonLinearTracking)); } const time = parseInt(playerInstance.getCurrentTime()) + parseInt(duration); - playerInstance.scheduleTask({time: time, closeStaticAd: adListId}); + playerInstance.scheduleTask({ time: time, closeStaticAd: ad, rollListId: ad.rollListId }); }; // ADS - playerInstance.createVpaidNonLinearBoard = (adListId) => { + playerInstance.createVpaidNonLinearBoard = (ad) => { // create iframe // pass the js - const vastSettings = playerInstance.adPool[adListId]; - - playerInstance.loadVpaidNonlinearAssets = function (adListId) { - + playerInstance.loadVpaidNonlinearAssets = function (ad) { + playerInstance.vastOptions = ad; playerInstance.debugMessage('starting function switchPlayerToVpaidMode'); - const vAlign = (playerInstance.adList[adListId].vAlign) ? playerInstance.adList[adListId].vAlign : playerInstance.nonLinearVerticalAlign; - const showCloseButton = (playerInstance.adList[adListId].vpaidNonLinearCloseButton) ? playerInstance.adList[adListId].vpaidNonLinearCloseButton : playerInstance.vpaidNonLinearCloseButton; - const vpaidIframe = playerInstance.videoPlayerId + "_" + adListId + "_fluid_vpaid_iframe"; + const vAlign = (ad.vAlign) ? ad.vAlign : playerInstance.nonLinearVerticalAlign; + const showCloseButton = (ad.vpaidNonLinearCloseButton) ? ad.vpaidNonLinearCloseButton : playerInstance.vpaidNonLinearCloseButton; + const vpaidIframe = "fp_" + ad.id + "_fluid_vpaid_iframe"; const creativeData = {}; - creativeData.AdParameters = playerInstance.adPool[adListId].adParameters; + creativeData.AdParameters = ad.adParameters; const slotWrapper = document.createElement('div'); - slotWrapper.id = 'fluid_vpaidNonLinear_' + adListId; + slotWrapper.id = 'fluid_vpaidNonLinear_' + ad.id; slotWrapper.className = 'fluid_vpaidNonLinear_' + vAlign; slotWrapper.className += ' fluid_vpaidNonLinear_ad'; - slotWrapper.setAttribute('adListId', adListId); + slotWrapper.setAttribute('adListId', ad.id); // Default values in case nothing defined in VAST data or ad settings let adWidth = Math.min(468, playerInstance.domRef.player.offsetWidth); let adHeight = Math.min(60, Math.floor(playerInstance.domRef.player.offsetHeight / 4)); - if (typeof playerInstance.adList[adListId].size !== 'undefined') { - const dimensions = playerInstance.adList[adListId].size.split('x'); + if (typeof ad.size !== 'undefined') { + const dimensions = ad.size.split('x'); adWidth = dimensions[0]; adHeight = dimensions[1]; - } else if (vastSettings.dimension.width && vastSettings.dimension.height) { - adWidth = vastSettings.dimension.width; - adHeight = vastSettings.dimension.height; + } else if (ad.dimension.width && ad.dimension.height) { + adWidth = ad.dimension.width; + adHeight = ad.dimension.height; } slotWrapper.style.width = '100%'; @@ -536,26 +639,30 @@ export default function (playerInstance, options) { slotWrapper.appendChild(slotFrame); const closeBtn = document.createElement('div'); - closeBtn.id = 'close_button_' + playerInstance.videoPlayerId; closeBtn.className = 'close_button'; closeBtn.innerHTML = ''; closeBtn.title = playerInstance.displayOptions.layoutControls.closeButtonCaption; - const tempadListId = adListId; + const [tempadListId] = ad.id.split('_'); closeBtn.onclick = function (event) { playerInstance.hardStopVpaidAd(''); + playerInstance.trackCloseNonLinearAd(); if (typeof event.stopImmediatePropagation !== 'undefined') { event.stopImmediatePropagation(); } playerInstance.adFinished = true; - //if any other onPauseRoll then render it - if (playerInstance.adList[tempadListId].roll === 'onPauseRoll' && playerInstance.onPauseRollAdPods[0]) { - const getNextOnPauseRollAd = playerInstance.onPauseRollAdPods[0]; - playerInstance.createBoard(getNextOnPauseRollAd); + if (playerInstance.rollsById[tempadListId].roll === 'onPauseRoll') { + // keep new currentOnPauseRollAd or undefined if none available playerInstance.currentOnPauseRollAd = playerInstance.onPauseRollAdPods[0]; - delete playerInstance.onPauseRollAdPods[0]; + + //if any other onPauseRoll then render it + if (playerInstance.onPauseRollAdPods[0]) { + const getNextOnPauseRollAd = playerInstance.onPauseRollAdPods[0]; + playerInstance.createBoard(getNextOnPauseRollAd); + delete playerInstance.onPauseRollAdPods[0]; + } } return false; @@ -566,7 +673,7 @@ export default function (playerInstance, options) { } const slotIframe = document.createElement('iframe'); - slotIframe.id = playerInstance.videoPlayerId + "non_linear_vapid_slot_iframe"; + slotIframe.id = playerInstance.videoPlayerId + 'non_linear_vapid_slot_iframe'; slotIframe.className = 'fluid_vpaid_nonlinear_slot_iframe'; slotIframe.setAttribute('width', adWidth + 'px'); slotIframe.setAttribute('height', adHeight + 'px'); @@ -592,14 +699,14 @@ export default function (playerInstance, options) { videoSlotCanAutoPlay: true }; - playerInstance.debugMessage(playerInstance.adList[adListId]); + playerInstance.debugMessage(ad); // calls this functions after ad unit is loaded in iframe const ver = playerInstance.vpaidAdUnit.handshakeVersion(VPAID_VERSION); const compare = playerInstance.compareVersion(VPAID_VERSION, ver); if (compare === 1) { //VPAID version of ad is lower than we need - playerInstance.adList[adListId].error = true; + ad.error = true; playerInstance.playMainVideoWhenVpaidFails(403); return false; } @@ -608,39 +715,36 @@ export default function (playerInstance, options) { playerInstance.domRef.player.removeAttribute('controls'); //Remove the default Controls playerInstance.vpaidCallbackListenersAttach(); - const mode = (playerInstance.fullscreenMode ? 'fullscreen' : 'normal'); + const mode = (playerInstance.fullscreenMode ? displayModes.FULLSCREEN : displayModes.NORMAL); playerInstance.vpaidAdUnit.initAd(adWidth, adHeight, mode, 3000, creativeData, environmentVars); playerInstance.toggleLoader(false); - playerInstance.adList[adListId].played = true; + ad.played = true; playerInstance.adFinished = false; }; - playerInstance.loadVpaid(adListId, vastSettings.staticResource); + playerInstance.loadVpaid(ad, ad.staticResource); playerInstance.debugMessage('create non linear vpaid'); }; // ADS - playerInstance.createNonLinearBoard = (adListId) => { - const vastSettings = playerInstance.adPool[adListId]; - - playerInstance.adList[adListId].played = true; - const playerWidth = playerInstance.domRef.player.clientWidth; - const playerHeight = playerInstance.domRef.player.clientHeight; + playerInstance.createNonLinearBoard = (ad) => { + ad.played = true; const board = document.createElement('div'); - const vAlign = (playerInstance.adList[adListId].vAlign) ? playerInstance.adList[adListId].vAlign : playerInstance.nonLinearVerticalAlign; + const vAlign = (playerInstance.rollsById[ad.rollListId].vAlign) ? playerInstance.rollsById[ad.rollListId].vAlign : playerInstance.nonLinearVerticalAlign; const creative = new Image(); - creative.src = vastSettings.staticResource; - creative.id = 'fluid_nonLinear_imgCreative_' + adListId + '_' + playerInstance.videoPlayerId; + creative.src = ad.staticResource; + creative.id = 'fluid_nonLinear_imgCreative_' + ad.id + '_' + playerInstance.videoPlayerId; creative.onerror = function () { - playerInstance.adList[adListId].error = true; + playerInstance.rollsById[ad.rollListId].error = true; playerInstance.announceError(500); }; creative.onload = function () { + const playerWidth = playerInstance.domRef.player.clientWidth; let origWidth; let origHeight; let newBannerWidth; @@ -650,12 +754,12 @@ export default function (playerInstance, options) { // 1. adList -> roll -> size // 2. VAST XML width/height attriubute (VAST 3.) // 3. VAST XML static resource dimension - if (typeof playerInstance.adList[adListId].size !== 'undefined') { - origWidth = playerInstance.adList[adListId].size.split('x')[0]; - origHeight = playerInstance.adList[adListId].size.split('x')[1]; - } else if (vastSettings.dimension.width && vastSettings.dimension.height) { - origWidth = vastSettings.dimension.width; - origHeight = vastSettings.dimension.height; + if (typeof playerInstance.rollsById[ad.rollListId].size !== 'undefined') { + origWidth = playerInstance.rollsById[ad.rollListId].size.split('x')[0]; + origHeight = playerInstance.rollsById[ad.rollListId].size.split('x')[1]; + } else if (ad.dimension.width && ad.dimension.height) { + origWidth = ad.dimension.width; + origHeight = ad.dimension.height; } else { origWidth = creative.width; origHeight = creative.height; @@ -669,19 +773,23 @@ export default function (playerInstance, options) { newBannerHeight = origHeight; } - if (playerInstance.adList[adListId].roll !== 'onPauseRoll') { + if (playerInstance.rollsById[ad.rollListId].roll !== 'onPauseRoll') { //Show the board only if media loaded - document.getElementById('fluid_nonLinear_' + adListId).style.display = ''; + const nonLinear = playerInstance.domRef.wrapper.querySelector('#fluid_nonLinear_' + ad.id); + + if (nonLinear) { + nonLinear.style.display = '' + } } - const img = document.getElementById(creative.id); + const img = playerInstance.domRef.wrapper.querySelector('#' + creative.id); img.width = newBannerWidth; img.height = newBannerHeight; - playerInstance.trackSingleEvent('impression'); + playerInstance.trackSingleEvent(impressionEvent); }; - board.id = 'fluid_nonLinear_' + adListId; + board.id = 'fluid_nonLinear_' + ad.id; board.className = 'fluid_nonLinear_' + vAlign; board.className += ' fluid_nonLinear_ad'; board.innerHTML = creative.outerHTML; @@ -689,40 +797,47 @@ export default function (playerInstance, options) { //Bind the Onclick event board.onclick = function () { - if (typeof vastSettings.clickthroughUrl !== 'undefined') { - window.open(vastSettings.clickthroughUrl); + if (typeof ad.clickthroughUrl !== 'undefined') { + window.open(ad.clickthroughUrl); } //Tracking the NonLinearClickTracking events - if (typeof vastSettings.clicktracking !== 'undefined') { - playerInstance.callUris([vastSettings.clicktracking]); + if (typeof ad.clicktracking !== 'undefined') { + playerInstance.callUris([ad.clicktracking]); } }; - if (typeof vastSettings.clickthroughUrl !== 'undefined') { + if (typeof ad.clickthroughUrl !== 'undefined') { board.style.cursor = 'pointer'; } const closeBtn = document.createElement('div'); - closeBtn.id = 'close_button_' + playerInstance.videoPlayerId; closeBtn.className = 'close_button'; closeBtn.innerHTML = ''; closeBtn.title = playerInstance.displayOptions.layoutControls.closeButtonCaption; - const tempadListId = adListId; + const tempRollListId = ad.rollListId; closeBtn.onclick = function (event) { + playerInstance.trackCloseNonLinearAd(); this.parentElement.remove(); if (typeof event.stopImmediatePropagation !== 'undefined') { event.stopImmediatePropagation(); } playerInstance.adFinished = true; - clearInterval(playerInstance.nonLinearTracking); + playerInstance.isCurrentlyShowingNonLinearAd = false; + if (playerInstance.nonLinearTracking) { + clearInterval(playerInstance.nonLinearTracking); + } - //if any other onPauseRoll then render it - if (playerInstance.adList[tempadListId].roll === 'onPauseRoll' && playerInstance.onPauseRollAdPods[0]) { - const getNextOnPauseRollAd = playerInstance.onPauseRollAdPods[0]; - playerInstance.createBoard(getNextOnPauseRollAd); + if (playerInstance.rollsById[tempRollListId].roll === 'onPauseRoll') { + // keep new currentOnPauseRollAd or undefined if none available playerInstance.currentOnPauseRollAd = playerInstance.onPauseRollAdPods[0]; - delete playerInstance.onPauseRollAdPods[0]; + + //if any other onPauseRoll then render it + if (playerInstance.onPauseRollAdPods[0]) { + const getNextOnPauseRollAd = playerInstance.onPauseRollAdPods[0]; + playerInstance.createBoard(getNextOnPauseRollAd); + delete playerInstance.onPauseRollAdPods[0]; + } } return false; @@ -738,46 +853,44 @@ export default function (playerInstance, options) { * * currently only image/gif, image/jpeg, image/png supported */ - playerInstance.createBoard = (adListId) => { - const vastSettings = playerInstance.adPool[adListId]; - + playerInstance.createBoard = (ad) => { // create nonLinear Vpaid // create nonLinear regular - if (vastSettings.vpaid) { + if (ad.vpaid) { playerInstance.hardStopVpaidAd(''); - playerInstance.createVpaidNonLinearBoard(adListId); - + playerInstance.createVpaidNonLinearBoard(ad); } else { + if ( + typeof ad.staticResource === 'undefined' || + playerInstance.supportedStaticTypes.indexOf(ad.creativeType) === -1 + ) { + // Couldn’t find NonLinear resource with supported type. + ad.error = true; - if (typeof vastSettings.staticResource === 'undefined' - || playerInstance.supportedStaticTypes.indexOf(vastSettings.creativeType) === -1) { - //Couldn’t find NonLinear resource with supported type. - playerInstance.adList[adListId].error = true; if (!playerInstance.vastOptions || typeof playerInstance.vastOptions.errorUrl === 'undefined') { playerInstance.announceLocalError(503); } else { playerInstance.announceError(503); } + return; } - - playerInstance.createNonLinearBoard(adListId); - + playerInstance.createNonLinearBoard(ad); } - }; - playerInstance.closeNonLinear = (adListId) => { - const element = document.getElementById('fluid_nonLinear_' + adListId); + playerInstance.closeNonLinear = (adId) => { + const element = playerInstance.domRef.wrapper.querySelector('#fluid_nonLinear_' + adId + ', #fluid_vpaidNonLinear_' + adId); if (element) { element.remove(); + playerInstance.isCurrentlyShowingNonLinearAd = false; } }; playerInstance.rollGroupContainsLinear = (groupedRolls) => { let found = false; for (let i = 0; i < groupedRolls.length; i++) { - if (playerInstance.adList[groupedRolls[i].id].adType && playerInstance.adList[groupedRolls[i].id].adType === 'linear') { + if (playerInstance.rollsById[groupedRolls[i].id].adType && playerInstance.rollsById[groupedRolls[i].id].adType === 'linear') { found = true; break; } @@ -787,7 +900,7 @@ export default function (playerInstance, options) { playerInstance.rollGroupContainsNonlinear = (groupedRolls) => { let found = false; for (let i = 0; i < groupedRolls.length; i++) { - if (playerInstance.adList[groupedRolls[i].id].adType.toLowerCase() === 'nonlinear') { + if (playerInstance.rollsById[groupedRolls[i].id].adType.toLowerCase() === 'nonlinear') { found = true; break; } @@ -817,7 +930,7 @@ export default function (playerInstance, options) { playerInstance.preRollAdsPlay = () => { const time = 0; - const adListIds = playerInstance.preRollAdPods; + const rollListIds = playerInstance.preRollAdPods; const adsByType = { linear: [], nonLinear: [] @@ -825,20 +938,22 @@ export default function (playerInstance, options) { playerInstance.firstPlayLaunched = true; - for (let index = 0; index < adListIds.length; index++) { + for (let index = 0; index < rollListIds.length; index++) { + playerInstance.rollsById[rollListIds[index]].ads.forEach(ad => { + if (ad.played === true) { + return; + } - if (playerInstance.adList[adListIds[index]].played === true) { - return - } + if (ad.adType === 'linear') { + adsByType.linear.push(ad); + } - if (playerInstance.adList[adListIds[index]].adType === 'linear') { - adsByType.linear.push(adListIds[index]); - } + if (ad.adType === 'nonLinear') { + adsByType.nonLinear.push(ad); + playerInstance.scheduleTask({time: time, playRoll: 'midRoll', rollListId: ad.rollListId }); + } + }); - if (playerInstance.adList[adListIds[index]].adType === 'nonLinear') { - adsByType.nonLinear.push(adListIds[index]); - playerInstance.scheduleTask({time: time, playRoll: 'midRoll', adListId: adsByType.nonLinear.shift()}); - } } if (adsByType.linear.length > 0) { @@ -854,24 +969,24 @@ export default function (playerInstance, options) { const vastObj = event.vastObj; playerInstance.domRef.player.removeEventListener(event.type, playerInstance.preRoll); - const adListId = []; - adListId[0] = event.type.replace('adId_', ''); + const rollListIds = []; + rollListIds[0] = event.type.replace('adId_', ''); const time = 0; - if (playerInstance.adList[adListId[0]].played === true) { + if (playerInstance.rollsById[rollListIds[0]].played === true) { return; } - playerInstance.preRollAdPods.push(adListId[0]); + playerInstance.preRollAdPods.push(rollListIds[0]); playerInstance.preRollSuccess(vastObj); }; playerInstance.createAdMarker = (adListId, time) => { - const markersHolder = document.getElementById(playerInstance.videoPlayerId + '_ad_markers_holder'); + const markersHolder = playerInstance.domRef.wrapper.querySelector('.fluid_controls_ad_markers_holder'); const adMarker = document.createElement('div'); - adMarker.id = 'ad_marker_' + playerInstance.videoPlayerId + "_" + adListId; - adMarker.className = 'fluid_controls_ad_marker'; + adMarker.className = 'fluid_controls_ad_marker fluid_controls_ad_marker_' + adListId; + adMarker.dataset.adListId = adListId; adMarker.style.left = (time / playerInstance.mainVideoDuration * 100) + '%'; if (playerInstance.isCurrentlyPlayingAd) { adMarker.style.display = 'none'; @@ -880,27 +995,26 @@ export default function (playerInstance, options) { }; playerInstance.hideAdMarker = (adListId) => { - const element = document.getElementById('ad_marker_' + playerInstance.videoPlayerId + "_" + adListId); + const element = playerInstance.domRef.wrapper.querySelector('fluid_controls_ad_marker_' + adListId); if (element) { element.style.display = 'none'; } }; playerInstance.showAdMarkers = () => { - const markersHolder = document.getElementById(playerInstance.videoPlayerId + '_ad_markers_holder'); + const markersHolder = playerInstance.domRef.wrapper.querySelector('.fluid_controls_ad_markers_holder'); const adMarkers = markersHolder.getElementsByClassName('fluid_controls_ad_marker'); - const idPrefix = 'ad_marker_' + playerInstance.videoPlayerId + "_"; for (let i = 0; i < adMarkers.length; ++i) { const item = adMarkers[i]; - const adListId = item.id.replace(idPrefix, ''); - if (playerInstance.adList[adListId].played === false) { + const rollListId = item.dataset.adListId; + if (playerInstance.rollsById[rollListId].played === false) { item.style.display = ''; } } }; playerInstance.hideAdMarkers = () => { - const markersHolder = document.getElementById(playerInstance.videoPlayerId + '_ad_markers_holder'); + const markersHolder = playerInstance.domRef.wrapper.querySelector('.fluid_controls_ad_markers_holder'); const adMarkers = markersHolder.getElementsByClassName('fluid_controls_ad_marker'); for (let i = 0; i < adMarkers.length; ++i) { const item = adMarkers[i]; @@ -911,12 +1025,12 @@ export default function (playerInstance, options) { playerInstance.midRoll = (event) => { playerInstance.domRef.player.removeEventListener(event.type, playerInstance.midRoll); - const adListId = event.type.replace('adId_', ''); - if (playerInstance.adList[adListId].played === true) { + const rollListId = event.type.replace('adId_', ''); + if (playerInstance.rollsById[rollListId].played === true) { return; } - let time = playerInstance.adList[adListId].timer; + let time = playerInstance.rollsById[rollListId].timer; if (typeof time == 'string' && time.indexOf("%") !== -1) { time = time.replace('%', ''); @@ -924,52 +1038,48 @@ export default function (playerInstance, options) { } if (playerInstance.displayOptions.vastOptions.showProgressbarMarkers && - playerInstance.adList[adListId].adType === "nonLinear") { - playerInstance.createAdMarker(adListId, time); + playerInstance.rollsById[rollListId].adType === "nonLinear") { + playerInstance.createAdMarker(rollListId, time); } playerInstance.scheduleTask({ time: time, - playRoll: 'midRoll', - adListId: adListId + playRoll: 'midRoll', + rollListId }); }; playerInstance.postRoll = (event) => { playerInstance.domRef.player.removeEventListener(event.type, playerInstance.postRoll); - const adListId = event.type.replace('adId_', ''); + const rollListId = event.type.replace('adId_', ''); playerInstance.scheduleTask({ time: Math.floor(playerInstance.mainVideoDuration), playRoll: 'postRoll', - adListId: adListId + rollListId }); }; playerInstance.onPauseRoll = (event) => { playerInstance.domRef.player.removeEventListener(event.type, playerInstance.onPauseRoll); - const adListId = event.type.replace('adId_', ''); + const rollListId = event.type.replace('adId_', ''); - if (playerInstance.adList[adListId].adType === 'nonLinear') { - if (!playerInstance.adPool.hasOwnProperty(adListId) || playerInstance.adPool[adListId].error === true) { - playerInstance.announceLocalError(101); - return; - } + playerInstance.rollsById[rollListId].ads.forEach(ad => { + if (ad.adType === 'nonLinear') { + if (playerInstance.rollsById[ad.rollListId].error === true || ad.error === true) { + playerInstance.announceLocalError(101); + return; + } - //var playerWrapper = document.getElementById('fluid_video_wrapper_' + playerInstance.videoPlayerId); - const nonLinearAdExists = document.getElementsByClassName('fluid_nonLinear_ad')[0]; - if (!nonLinearAdExists) { - playerInstance.createBoard(adListId); - playerInstance.currentOnPauseRollAd = adListId; - let onPauseAd = document.getElementById('fluid_nonLinear_' + adListId); - if (onPauseAd) { - onPauseAd.style.display = 'none'; + const nonLinearAdExists = playerInstance.domRef.wrapper.getElementsByClassName('fluid_nonLinear_ad')[0]; + if (!nonLinearAdExists) { + playerInstance.currentOnPauseRollAd = rollListId; + } else { + playerInstance.onPauseRollAdPods.push(rollListId); } - } else { - playerInstance.onPauseRollAdPods.push(adListId); - } - } + } + }); }; /** @@ -979,7 +1089,13 @@ export default function (playerInstance, options) { // TODO should be only one. Add validator to allow only one onPause roll const onPauseAd = playerInstance.findRoll('onPauseRoll'); - return (onPauseAd.length !== 0 && playerInstance.adList[onPauseAd[0]] && playerInstance.adList[onPauseAd[0]].error === false); + return ( + onPauseAd.length !== 0 && + playerInstance.rollsById[onPauseAd[0]] && + playerInstance.rollsById[onPauseAd[0]].error === false && + playerInstance.rollsById[onPauseAd[0]].ads.length && + playerInstance.rollsById[onPauseAd[0]].ads[0].error !== true + ); }; /** @@ -987,28 +1103,41 @@ export default function (playerInstance, options) { */ playerInstance.toggleOnPauseAd = () => { playerInstance.toggleLoader(false); - if (playerInstance.hasValidOnPauseAd() && !playerInstance.isCurrentlyPlayingAd) { - const onPauseRoll = playerInstance.findRoll('onPauseRoll'); - let adListId; - if (playerInstance.currentOnPauseRollAd !== '') { - adListId = playerInstance.currentOnPauseRollAd; - } else { - adListId = onPauseRoll[0]; + if (playerInstance.hasValidOnPauseAd() && !playerInstance.isCurrentlyPlayingAd && playerInstance.currentOnPauseRollAd) { + const ad = playerInstance.rollsById[playerInstance.currentOnPauseRollAd].ads[0]; + + playerInstance.vastOptions = ad; + let onPauseAd = ''; + for (const child of playerInstance.domRef.wrapper.children) { + if (child.id === 'fluid_nonLinear_' + ad.id) { + onPauseAd = child; + } } - playerInstance.vastOptions = playerInstance.adPool[adListId]; - const onPauseAd = document.getElementById('fluid_nonLinear_' + adListId); + if (playerInstance.domRef.player.paused && !onPauseAd) { + playerInstance.createBoard(ad); + + for (const child of playerInstance.domRef.wrapper.children) { + if (child.id === 'fluid_nonLinear_' + ad.id) { + onPauseAd = child; + } + } + + if (!onPauseAd) { + onPauseAd.style.display = 'none'; + } + } if (onPauseAd && playerInstance.domRef.player.paused) { setTimeout(function () { onPauseAd.style.display = 'flex'; - playerInstance.adList[adListId].played = false; - playerInstance.trackingOnPauseNonLinearAd(adListId, 'start'); + ad.played = false; + playerInstance.trackingOnPauseNonLinearAd(ad, startEvent); }, 500); } else if (onPauseAd && !playerInstance.domRef.player.paused) { onPauseAd.style.display = 'none'; playerInstance.adFinished = true; - playerInstance.trackingOnPauseNonLinearAd(adListId, 'complete'); + playerInstance.trackingOnPauseNonLinearAd(ad, completeEvent); } } }; @@ -1016,13 +1145,13 @@ export default function (playerInstance, options) { /** * Helper function for tracking onPause Ads */ - playerInstance.trackingOnPauseNonLinearAd = (adListId, status) => { - if (!playerInstance.adPool.hasOwnProperty(adListId) || playerInstance.adPool[adListId].error === true) { + playerInstance.trackingOnPauseNonLinearAd = (ad, status) => { + if (playerInstance.rollsById[ad.rollListId].error === true || ad.error === true) { playerInstance.announceLocalError(101); return; } - playerInstance.vastOptions = playerInstance.adPool[adListId]; + playerInstance.vastOptions = ad; playerInstance.trackSingleEvent(status); }; @@ -1030,7 +1159,7 @@ export default function (playerInstance, options) { const adListIds = []; for (let i = 0; i < keyTimeLinearObj.length; i++) { - if (playerInstance.adList[keyTimeLinearObj[i].adListId].played === false) { + if (playerInstance.rollsById[keyTimeLinearObj[i].adListId].played === false) { adListIds.push(keyTimeLinearObj[i].adListId); } } @@ -1038,6 +1167,11 @@ export default function (playerInstance, options) { return adListIds; }; + /** + * Handle scheduled tasks for a given key time + * + * @param keyTime key time in seconds + */ playerInstance.adKeytimePlay = (keyTime) => { if (!playerInstance.timerPool[keyTime] || playerInstance.isCurrentlyPlayingAd) { return; @@ -1046,9 +1180,15 @@ export default function (playerInstance, options) { const timerPoolKeytimeCloseStaticAdsLength = playerInstance.timerPool[keyTime]['closeStaticAd'].length; const timerPoolKeytimeLinearAdsLength = playerInstance.timerPool[keyTime]['linear'].length; const timerPoolKeytimeNonlinearAdsLength = playerInstance.timerPool[keyTime]['nonLinear'].length; + const timerPoolKeytimeLoadVastLength = playerInstance.timerPool[keyTime]['loadVast'].length; // remove the item from keytime if no ads to play - if (timerPoolKeytimeCloseStaticAdsLength === 0 && timerPoolKeytimeLinearAdsLength === 0 && timerPoolKeytimeNonlinearAdsLength === 0) { + if ([ + timerPoolKeytimeCloseStaticAdsLength, + timerPoolKeytimeLinearAdsLength, + timerPoolKeytimeNonlinearAdsLength, + timerPoolKeytimeLoadVastLength + ].every(timerPoolLength => timerPoolLength === 0)) { delete playerInstance.timerPool[keyTime]; return; } @@ -1056,9 +1196,9 @@ export default function (playerInstance, options) { // Task: close nonLinear ads if (timerPoolKeytimeCloseStaticAdsLength > 0) { for (let index = 0; index < timerPoolKeytimeCloseStaticAdsLength; index++) { - const adListId = playerInstance.timerPool[keyTime]['closeStaticAd'][index].closeStaticAd; - if (playerInstance.adList[adListId].played === true) { - playerInstance.completeNonLinearStatic(adListId); + const adToClose = playerInstance.timerPool[keyTime]['closeStaticAd'][index]; + if (adToClose.played === true) { + playerInstance.completeNonLinearStatic(adToClose); } } @@ -1068,9 +1208,8 @@ export default function (playerInstance, options) { // Task: play linear ads if (timerPoolKeytimeLinearAdsLength > 0) { - const adListIds = playerInstance.getLinearAdsFromKeyTime(playerInstance.timerPool[keyTime]['linear']); - if (adListIds.length > 0) { - playerInstance.playRoll(adListIds); + if (playerInstance.timerPool[keyTime]['linear'].length > 0) { + playerInstance.playRoll(playerInstance.timerPool[keyTime]['linear']); // empty the linear ads from the timerpool after played playerInstance.timerPool[keyTime]['linear'] = []; @@ -1083,14 +1222,15 @@ export default function (playerInstance, options) { // Task: play nonLinear ads if (timerPoolKeytimeNonlinearAdsLength > 0) { for (let index = 0; index < timerPoolKeytimeNonlinearAdsLength; index++) { - const adListId = playerInstance.timerPool[keyTime]['nonLinear'][index].adListId; - const vastOptions = playerInstance.adPool[adListId]; + const ad = playerInstance.timerPool[keyTime]['nonLinear'][index]; + const rollListId = ad.rollListId; + const vastOptions = playerInstance.adPool[rollListId]; // we are not supporting nonLinear ads in cardBoard mode - if (playerInstance.adList[adListId].played === false && !playerInstance.displayOptions.layoutControls.showCardBoardView) { - playerInstance.createNonLinearStatic(adListId); + if (ad.played === false && !playerInstance.displayOptions.layoutControls.showCardBoardView) { + playerInstance.createNonLinearStatic(ad); if (playerInstance.displayOptions.vastOptions.showProgressbarMarkers) { - playerInstance.hideAdMarker(adListId); + playerInstance.hideAdMarker(rollListId); } // delete nonLinear after playing @@ -1103,6 +1243,23 @@ export default function (playerInstance, options) { } } + // Task: Load VAST on demand + if (timerPoolKeytimeLoadVastLength > 0) { + playerInstance.timerPool[keyTime]['loadVast'].forEach((roll) => { + if (roll.roll === `postRoll` && roll.voidPostRollTasks) { + // As postRoll schedules more than one task to cover the last few seconds of the video, we need to + // prevent any other loadVast task from running for that postRoll + return; + } else if (roll.roll === `postRoll`) { + roll.voidPostRollTasks = true; + } + + playerInstance.debugMessage(`Handling on-demand VAST load for roll ${roll.id}`) + playerInstance.processVastWithRetries(roll); + }); + + playerInstance.timerPool[keyTime]['loadVast'] = []; + } }; playerInstance.adTimer = () => { @@ -1117,32 +1274,117 @@ export default function (playerInstance, options) { const keyTime = Math.floor(playerInstance.getCurrentTime()); playerInstance.adKeytimePlay(keyTime) }, 800); + playerInstance.destructors.push(() => playerInstance.timer); }; - // ADS + /** + * Schedule tasks that need to be run with the main video timer + * + * @param {{ time: number, rollListId: any, loadVast: any }} task + */ playerInstance.scheduleTask = (task) => { + if (task.time > playerInstance.mainVideoDuration || task.time < 0 || Number.isNaN(task.time)) { + console.warn(`Scheduled task has invalid time`, task.time, '. Check your configuration.'); + return; + } + + playerInstance.debugMessage(`Scheduling task`, task); + if (!playerInstance.timerPool.hasOwnProperty(task.time)) { - playerInstance.timerPool[task.time] = {linear: [], nonLinear: [], closeStaticAd: []}; + playerInstance.timerPool[task.time] = {linear: [], nonLinear: [], closeStaticAd: [], loadVast: []}; } - if (task.hasOwnProperty('playRoll') && playerInstance.adList[task.adListId].adType === 'linear') { - playerInstance.timerPool[task.time]['linear'].push(task); - } else if (task.hasOwnProperty('playRoll') && playerInstance.adList[task.adListId].adType === 'nonLinear') { - playerInstance.timerPool[task.time]['nonLinear'].push(task); - } else if (task.hasOwnProperty('closeStaticAd')) { - playerInstance.timerPool[task.time]['closeStaticAd'].push(task); + // Handle AD rendering + if (task.rollListId) { + const roll = playerInstance.rollsById[task.rollListId]; + + roll.ads + .filter(({ adType }) => { + if (task.time === 0) { // Only non-linear should be scheduled on "preRoll" + return adType !== 'linear'; + } + + return true; + }) + .forEach(ad => { + if (task.hasOwnProperty('playRoll') && ad.adType === 'linear') { + playerInstance.timerPool[task.time]['linear'].push(ad); + } else if (task.hasOwnProperty('playRoll') && ad.adType === 'nonLinear') { + playerInstance.timerPool[task.time]['nonLinear'].push(ad); + } else if (task.hasOwnProperty('closeStaticAd')) { + playerInstance.timerPool[task.time]['closeStaticAd'].push(ad); + } + }); } + // Handle Loading VAST on demand + if (task.loadVast) { + playerInstance.timerPool[task.time]['loadVast'].push(task.roll) + } }; + /** + * Adds on demand rolls (midRoll, postRoll) to schedule + */ + playerInstance.scheduleOnDemandRolls = () => { + const midRollListIds = playerInstance.findRoll(`midRoll`) || []; + const postRollListIds = playerInstance.findRoll(`postRoll`) || []; + + [...midRollListIds, ...postRollListIds] + .map(rollListId => playerInstance.rollsById[rollListId]) + .filter(rollAd => rollAd.vastLoaded !== true && rollAd.error !== true) + .forEach(rollAd => { + // Request will have the vastTimeout time to load + if (rollAd.roll === `midRoll`) { + if (typeof rollAd.timer === 'string') { + // This can result in NaN, in that case the midRoll will simply not happen (user configuration error) + rollAd.timer = Math.floor(playerInstance.mainVideoDuration / 100 * rollAd.timer.replace('%', '')); + playerInstance.debugMessage(`Replaced midRoll from percentage to timer value ${rollAd.timer} seconds`); + } + + const time = rollAd.timer - (playerInstance.displayOptions.vastOptions.vastTimeout / 1000); + + // Handles cases where the midRoll should be loaded now, skipping the task scheduler + if (time <= Number(playerInstance.getCurrentTime())) { + playerInstance.debugMessage(`Loading Mid Roll VAST immediately as it needs to be played soon`); + playerInstance.processVastWithRetries(rollAd); + } else { + playerInstance.scheduleTask({ loadVast: true, time, roll: rollAd }) + } + } else { + const backwardScheduleTime = parseInt(playerInstance.mainVideoDuration); + const scheduleTimeAmount = (playerInstance.displayOptions.vastOptions.vastTimeout / 1000); + + // Used to prevent loading more than one of the tasks bellow + rollAd.voidPostRollTasks = false; + + for (let i = 1; i <= scheduleTimeAmount; i++) { + // Sets tasks for the last N seconds based on vastTimeout + playerInstance.scheduleTask({ loadVast: true, time: backwardScheduleTime - i, roll: rollAd }); + } + + } + }); + } + // ADS playerInstance.switchToMainVideo = () => { playerInstance.debugMessage('starting main video'); + // Reset live indicator + playerInstance.hideLiveIndicator(); + playerInstance.isLiveStream = null; + playerInstance.domRef.player.src = playerInstance.originalSrc; + playerInstance.currentMediaSourceType = 'source'; + playerInstance.initialiseStreamers(); + if (playerInstance.hlsPlayer && !playerInstance.hlsPlayer.config.autoStartLoad) { + playerInstance.hlsPlayer.startLoad(); + } + const newCurrentTime = (typeof playerInstance.domRef.player.mainVideoCurrentTime !== 'undefined') ? Math.floor(playerInstance.domRef.player.mainVideoCurrentTime) : 0; @@ -1165,7 +1407,7 @@ export default function (playerInstance, options) { playerInstance.vastOptions = null; playerInstance.setBuffering(); - const progressbarContainer = document.getElementById(playerInstance.videoPlayerId + '_fluid_controls_progress_container'); + const progressbarContainer = playerInstance.domRef.wrapper.querySelector('.fluid_controls_progress_container'); if (progressbarContainer !== null) { const backgroundColor = (playerInstance.displayOptions.layoutControls.primaryColor) ? playerInstance.displayOptions.layoutControls.primaryColor : "white"; @@ -1184,23 +1426,18 @@ export default function (playerInstance, options) { } if (playerInstance.hasTitle()) { - const title = document.getElementById(playerInstance.videoPlayerId + '_title'); + const title = playerInstance.domRef.wrapper.querySelector('.fp_title'); title.style.display = 'inline'; } }; // ADS playerInstance.getNextAdPod = () => { - const getFirstUnPlayedAd = false; - let adListId = null; - - // if temporaryAdPods is not empty if (playerInstance.temporaryAdPods.length > 0) { - const temporaryAdPods = playerInstance.temporaryAdPods.shift(); - adListId = temporaryAdPods.id; + return playerInstance.temporaryAdPods.shift(); } - return adListId; + return null; }; // ADS @@ -1224,13 +1461,17 @@ export default function (playerInstance, options) { * Adds a Skip Button */ playerInstance.addSkipButton = () => { - // TODO: ahh yes, the DIVbutton... const divSkipButton = document.createElement('div'); - divSkipButton.id = 'skip_button_' + playerInstance.videoPlayerId; divSkipButton.className = 'skip_button skip_button_disabled'; - divSkipButton.innerHTML = playerInstance.displayOptions.vastOptions.skipButtonCaption.replace('[seconds]', playerInstance.vastOptions.skipoffset); + if (playerInstance.vastOptions.skipoffset > 0) { + divSkipButton.innerHTML = playerInstance.displayOptions.vastOptions.skipButtonCaption.replace('[seconds]', playerInstance.vastOptions.skipoffset); + } + + playerInstance.domRef.wrapper.appendChild(divSkipButton); - document.getElementById('fluid_video_wrapper_' + playerInstance.videoPlayerId).appendChild(divSkipButton); + if (playerInstance.vastOptions.skipoffset === 0) { + playerInstance.decreaseSkipOffset(); + } playerInstance.domRef.player.addEventListener('timeupdate', playerInstance.decreaseSkipOffset, false); }; @@ -1243,13 +1484,12 @@ export default function (playerInstance, options) { return; // Shouldn't show countdown if ad is a video live stream } - const videoWrapper = document.getElementById('fluid_video_wrapper_' + playerInstance.videoPlayerId); + const videoWrapper = playerInstance.domRef.wrapper; const divAdCountdown = document.createElement('div'); // Create element const adCountdown = playerInstance.pad(parseInt(playerInstance.currentVideoDuration / 60)) + ':' + playerInstance.pad(parseInt(playerInstance.currentVideoDuration % 60)); const durationText = parseInt(adCountdown); - divAdCountdown.id = 'ad_countdown' + playerInstance.videoPlayerId; divAdCountdown.className = 'ad_countdown'; divAdCountdown.innerHTML = "Ad - " + durationText; @@ -1263,7 +1503,7 @@ export default function (playerInstance, options) { playerInstance.decreaseAdCountdown = function decreaseAdCountdown() { const sec = parseInt(playerInstance.currentVideoDuration) - parseInt(playerInstance.domRef.player.currentTime); - const btn = document.getElementById('ad_countdown' + playerInstance.videoPlayerId); + const btn = playerInstance.domRef.wrapper.querySelector('.ad_countdown'); if (btn && isNaN(sec)) { btn.parentNode.removeChild(btn); @@ -1278,14 +1518,14 @@ export default function (playerInstance, options) { }; playerInstance.removeAdCountdown = () => { - const btn = document.getElementById('ad_countdown' + playerInstance.videoPlayerId); + const btn = playerInstance.domRef.wrapper.querySelector('.ad_countdown'); if (btn) { btn.parentElement.removeChild(btn); } }; playerInstance.toggleAdCountdown = (showing) => { - const btn = document.getElementById('ad_countdown' + playerInstance.videoPlayerId); + const btn = playerInstance.domRef.wrapper.querySelector('.ad_countdown'); if (btn) { if (showing) { btn.style.display = 'inline-block'; @@ -1297,7 +1537,6 @@ export default function (playerInstance, options) { playerInstance.addAdPlayingText = (textToShow) => { const adPlayingDiv = document.createElement('div'); - adPlayingDiv.id = playerInstance.videoPlayerId + '_fluid_ad_playing'; if (playerInstance.displayOptions.layoutControls.primaryColor) { adPlayingDiv.style.backgroundColor = playerInstance.displayOptions.layoutControls.primaryColor; @@ -1307,15 +1546,15 @@ export default function (playerInstance, options) { adPlayingDiv.className = 'fluid_ad_playing'; adPlayingDiv.innerText = textToShow; - document.getElementById('fluid_video_wrapper_' + playerInstance.videoPlayerId).appendChild(adPlayingDiv); + playerInstance.domRef.wrapper.appendChild(adPlayingDiv); }; playerInstance.positionTextElements = (adListData) => { const allowedPosition = ['top left', 'top right', 'bottom left', 'bottom right']; - const skipButton = document.getElementById('skip_button_' + playerInstance.videoPlayerId); - const adPlayingDiv = document.getElementById(playerInstance.videoPlayerId + '_fluid_ad_playing'); - const ctaButton = document.getElementById(playerInstance.videoPlayerId + '_fluid_cta'); + const skipButton = playerInstance.domRef.wrapper.querySelector('.skip_button'); + const adPlayingDiv = playerInstance.domRef.wrapper.querySelector('.fluid_ad_playing'); + const ctaButton = playerInstance.domRef.wrapper.querySelector('.fluid_ad_cta'); let ctaButtonHeightWithSpacing = 0; let adPlayingDivHeightWithSpacing = 0; @@ -1326,12 +1565,12 @@ export default function (playerInstance, options) { const defaultPositions = { top: { - left: {h: 34, v: 34}, - right: {h: 0, v: 34} + left: { h: 34, v: 34 }, + right: { h: 0, v: 34 }, }, bottom: { - left: {h: 34, v: 50}, - right: {h: 0, v: 50} + left: { h: 34, v: 50 }, + right: { h: 0, v: 50 }, } }; @@ -1341,20 +1580,30 @@ export default function (playerInstance, options) { const wrapperElement = playerInstance.domRef.wrapper; if (wrapperElement.classList.contains('mobile')) { - defaultPositions.bottom.left.v = 75; - defaultPositions.bottom.right.v = 75; + defaultPositions.top = { + left: { h: 0, v: 8 }, + right: { h: 0, v: 8 }, + } + defaultPositions.bottom = { + left: { h: 0, v: 50 }, + right: { h: 0, v: 50 }, + } } } let CTATextPosition; if (ctaButton !== null) { - CTATextPosition = playerInstance.displayOptions.vastOptions.adCTATextPosition.toLowerCase(); + CTATextPosition = playerInstance.rollsById[adListData.rollListId].adCTATextPosition ? + playerInstance.rollsById[adListData.rollListId].adCTATextPosition.toLowerCase() : + playerInstance.displayOptions.vastOptions.adCTATextPosition; if (allowedPosition.indexOf(CTATextPosition) === -1) { console.log('[FP Error] Invalid position for CTAText. Reverting to "bottom right"'); CTATextPosition = 'bottom right'; } + ctaButton.classList.add.apply(ctaButton.classList, CTATextPosition.split(' ')); + positionsCTA = CTATextPosition.split(' '); isBottom = positionsCTA[0] === 'bottom'; @@ -1372,7 +1621,9 @@ export default function (playerInstance, options) { let adPlayingDivPosition; let positionsAdText; if (adPlayingDiv !== null) { - adPlayingDivPosition = (adListData.adTextPosition !== null) ? adListData.adTextPosition.toLowerCase() : playerInstance.displayOptions.vastOptions.adTextPosition.toLowerCase(); + adPlayingDivPosition = playerInstance.rollsById[adListData.rollListId].adTextPosition ? + playerInstance.rollsById[adListData.rollListId].adTextPosition.toLowerCase() : + playerInstance.displayOptions.vastOptions.adTextPosition; if (allowedPosition.indexOf(adPlayingDivPosition) === -1) { console.log('[FP Error] Invalid position for adText. Reverting to "top left"'); @@ -1399,7 +1650,7 @@ export default function (playerInstance, options) { }; playerInstance.removeAdPlayingText = () => { - const div = document.getElementById(playerInstance.videoPlayerId + '_fluid_ad_playing'); + const div = playerInstance.domRef.wrapper.querySelector('.fluid_ad_playing'); if (!div) { return; } @@ -1417,7 +1668,7 @@ export default function (playerInstance, options) { return playerInstance.createAndAppendCTAButton(text, link, tracking); } - if (landingPage) { + if (landingPage && typeof playerInstance.displayOptions.vastOptions.adCTAText === 'string') { return playerInstance.createAndAppendCTAButton( playerInstance.displayOptions.vastOptions.adCTAText, landingPage, @@ -1437,11 +1688,16 @@ export default function (playerInstance, options) { */ playerInstance.createAndAppendCTAButton = (adCTAText, displayUrl, trackingUrl) => { const ctaButton = document.createElement('div'); - ctaButton.id = playerInstance.videoPlayerId + '_fluid_cta'; ctaButton.className = 'fluid_ad_cta'; const link = document.createElement('span'); - link.innerHTML = adCTAText + "
" + displayUrl + ""; + let innerHTML = adCTAText; + + if (displayUrl) { + innerHTML += "
" + displayUrl + "" + } + + link.innerHTML = innerHTML; ctaButton.addEventListener('click', () => { if (!playerInstance.domRef.player.paused) { @@ -1455,11 +1711,11 @@ export default function (playerInstance, options) { ctaButton.appendChild(link); - document.getElementById('fluid_video_wrapper_' + playerInstance.videoPlayerId).appendChild(ctaButton); + playerInstance.domRef.wrapper.appendChild(ctaButton); }; playerInstance.removeCTAButton = () => { - const btn = document.getElementById(playerInstance.videoPlayerId + '_fluid_cta'); + const btn = playerInstance.domRef.wrapper.querySelector('.fluid_ad_cta'); if (!btn) { return; } @@ -1468,8 +1724,11 @@ export default function (playerInstance, options) { }; playerInstance.decreaseSkipOffset = () => { + if (typeof playerInstance.vastOptions === 'undefined' || playerInstance.vastOptions === null) { + return; + } let sec = playerInstance.vastOptions.skipoffset - Math.floor(playerInstance.domRef.player.currentTime); - const btn = document.getElementById('skip_button_' + playerInstance.videoPlayerId); + const btn = playerInstance.domRef.wrapper.querySelector('.skip_button'); if (!btn) { playerInstance.domRef.player.removeEventListener('timeupdate', playerInstance.decreaseSkipOffset); @@ -1485,7 +1744,7 @@ export default function (playerInstance, options) { // TODO: refactored, but this is still terrible - remove all this and just make the button clickable... const skipLink = document.createElement('a'); skipLink.href = '#'; - skipLink.id = 'skipHref_' + playerInstance.videoPlayerId; + skipLink.className = 'js-skipHref'; skipLink.innerHTML = playerInstance.displayOptions.vastOptions.skipButtonClickCaption; skipLink.onclick = (e) => { e.preventDefault(); @@ -1513,6 +1772,7 @@ export default function (playerInstance, options) { return; } + playerInstance.trackSkipAd(); // skip the regular linear vast playerInstance.displayOptions.vastOptions.vastAdvanced.vastVideoSkippedCallback(); const event = document.createEvent('Event'); @@ -1521,7 +1781,7 @@ export default function (playerInstance, options) { }; playerInstance.removeSkipButton = () => { - const btn = document.getElementById('skip_button_' + playerInstance.videoPlayerId); + const btn = playerInstance.domRef.wrapper.querySelector('.skip_button'); if (btn) { btn.parentElement.removeChild(btn); } @@ -1535,7 +1795,6 @@ export default function (playerInstance, options) { const divClickThrough = document.createElement('div'); divClickThrough.className = 'vast_clickthrough_layer'; - divClickThrough.id = 'vast_clickthrough_layer_' + playerInstance.videoPlayerId; divClickThrough.setAttribute( 'style', 'position: absolute; cursor: pointer; top: 0; left: 0; width: ' + @@ -1555,7 +1814,7 @@ export default function (playerInstance, options) { } }; - const clickthroughLayer = document.getElementById('vast_clickthrough_layer_' + playerInstance.videoPlayerId); + const clickthroughLayer = playerInstance.domRef.wrapper.querySelector('.vast_clickthrough_layer'); const isIos9orLower = (playerInstance.mobileInfo.device === 'iPhone') && (playerInstance.mobileInfo.userOsMajor !== false) && (playerInstance.mobileInfo.userOsMajor <= 9); clickthroughLayer.onclick = () => { @@ -1580,7 +1839,7 @@ export default function (playerInstance, options) { * Remove the Clickthrough layer */ playerInstance.removeClickthrough = () => { - const clickthroughLayer = document.getElementById('vast_clickthrough_layer_' + playerInstance.videoPlayerId); + const clickthroughLayer = playerInstance.domRef.wrapper.querySelector('.vast_clickthrough_layer'); if (clickthroughLayer) { clickthroughLayer.parentNode.removeChild(clickthroughLayer); diff --git a/src/modules/cardboard.js b/src/modules/cardboard.js index fd6456aa..4a0adfb3 100644 --- a/src/modules/cardboard.js +++ b/src/modules/cardboard.js @@ -1,10 +1,131 @@ -'use strict'; +let _psvModules = null; + +const CARDBOARD_VERT = ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +`; + +const CARDBOARD_FRAG = ` + uniform sampler2D tDiffuse; + uniform vec2 uK; + uniform float uVignette; + varying vec2 vUv; + void main() { + float isRight = step(0.5, vUv.x); + float eyeU = (vUv.x - isRight * 0.5) * 2.0; + vec2 p = vec2(eyeU, vUv.y) * 2.0 - 1.0; + float r2 = dot(p, p); + vec2 pDist = p * (1.0 + uK.x * r2 + uK.y * r2 * r2); + if (abs(pDist.x) > 1.0 || abs(pDist.y) > 1.0) { + gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); + return; + } + vec2 sampleUv = pDist * 0.5 + 0.5; + sampleUv.x = sampleUv.x * 0.5 + isRight * 0.5; + float r = length(p); + float v = 1.0 - smoothstep(uVignette, 1.0, r); + gl_FragColor = vec4(texture2D(tDiffuse, sampleUv).rgb * v, 1.0); + } +`; + +function buildCardboardEffect(THREE) { + const { StereoCamera, Vector2, WebGLRenderTarget, OrthographicCamera, Scene, PlaneGeometry, Mesh, ShaderMaterial } = THREE; + + return class CardboardEffect { + constructor(renderer) { + const _stereo = new StereoCamera(); + _stereo.aspect = 0.5; + // _logSize: CSS/logical pixels — passed to setViewport/setScissor (Three.js + // multiplies these by pixelRatio before sending to GL). + // _phySize: physical/drawing-buffer pixels — used to size the WebGLRenderTarget + // (setRenderTarget copies rt.viewport to GL without any pixelRatio scaling). + const _logSize = new Vector2(); + const _phySize = new Vector2(); + renderer.getDrawingBufferSize(_phySize); + + const _rt = new WebGLRenderTarget(_phySize.x, _phySize.y); + const _postCam = new OrthographicCamera(-1, 1, 1, -1, 0, 1); + const _postScene = new Scene(); + const _mat = new ShaderMaterial({ + uniforms: { + tDiffuse: { value: _rt.texture }, + uK: { value: new Vector2(0.22, 0.08) }, + uVignette: { value: 0.72 }, + }, + vertexShader: CARDBOARD_VERT, + fragmentShader: CARDBOARD_FRAG, + depthTest: false, + depthWrite: false, + }); + const _quad = new Mesh(new PlaneGeometry(2, 2), _mat); + _quad.frustumCulled = false; + _postScene.add(_quad); + + this.setEyeSeparation = (sep) => { _stereo.eyeSep = sep; }; + + this.setSize = (w, h) => { + renderer.setSize(w, h); + renderer.getDrawingBufferSize(_phySize); + _rt.setSize(_phySize.x, _phySize.y); + }; + + this.dispose = () => { + _rt.dispose(); + _mat.dispose(); + _quad.geometry.dispose(); + }; + + this.render = (scene, camera) => { + if (scene.matrixWorldAutoUpdate) scene.updateMatrixWorld(); + if (camera.parent === null && camera.matrixWorldAutoUpdate) camera.updateMatrixWorld(); + _stereo.update(camera); + + // Logical (CSS) pixels — Three.js multiplies these by pixelRatio + // before sending to GL in setViewport/setScissor. + renderer.getSize(_logSize); + const lw = _logSize.x, lh = _logSize.y; + + // Physical pixels — setRenderTarget copies rt.viewport to GL + // directly with NO pixelRatio scaling, so the render target must + // be sized in actual device pixels. + renderer.getDrawingBufferSize(_phySize); + if (_rt.width !== _phySize.x || _rt.height !== _phySize.y) { + _rt.setSize(_phySize.x, _phySize.y); + } + + const prevTarget = renderer.getRenderTarget(); + const prevAutoClear = renderer.autoClear; + + renderer.setRenderTarget(_rt); + renderer.autoClear = false; + renderer.clear(); + renderer.setScissorTest(true); + renderer.setScissor(0, 0, lw / 2, lh); + renderer.setViewport(0, 0, lw / 2, lh); + renderer.render(scene, _stereo.cameraL); + renderer.setScissor(lw / 2, 0, lw / 2, lh); + renderer.setViewport(lw / 2, 0, lw / 2, lh); + renderer.render(scene, _stereo.cameraR); + renderer.setScissorTest(false); + + renderer.setRenderTarget(prevTarget); + renderer.setViewport(0, 0, lw, lh); + renderer.autoClear = true; + renderer.render(_postScene, _postCam); + renderer.autoClear = prevAutoClear; + }; + } + }; +} + export default function (playerInstance, options) { playerInstance.createCardboardJoystickButton = (identity) => { - const vrJoystickPanel = document.getElementById(playerInstance.videoPlayerId + '_fluid_vr_joystick_panel'); + const vrJoystickPanel = playerInstance.domRef.wrapper.querySelector('.fluid_vr_joystick_panel'); const joystickButton = document.createElement('div'); - joystickButton.id = playerInstance.videoPlayerId + '_fluid_vr_joystick_' + identity; joystickButton.className = 'fluid_vr_button fluid_vr_joystick_' + identity; vrJoystickPanel.appendChild(joystickButton); @@ -12,41 +133,24 @@ export default function (playerInstance, options) { }; playerInstance.cardboardRotateLeftRight = (param /* 0 - right, 1 - left */) => { - const go = playerInstance.vrROTATION_POSITION; - const back = -playerInstance.vrROTATION_POSITION; - const pos = param < 1 ? go : back; - const easing = {val: pos}; - const tween = new TWEEN.Tween(easing) - .to({val: 0}, playerInstance.vrROTATION_SPEED) - .easing(TWEEN.Easing.Quadratic.InOut) - .onUpdate(function () { - playerInstance.vrViewer.OrbitControls.rotateLeft(easing.val) - }).start(); + const { yaw, pitch } = playerInstance.vrViewer.getPosition(); + const delta = param < 1 ? playerInstance.vrROTATION_POSITION : -playerInstance.vrROTATION_POSITION; + playerInstance.vrViewer.animate({ yaw: yaw + delta, pitch, speed: playerInstance.vrROTATION_SPEED }); }; - playerInstance.cardboardRotateUpDown = (param /* 0 - down, 1- up */) => { - const go = playerInstance.vrROTATION_POSITION; - const back = -playerInstance.vrROTATION_POSITION; - const pos = param < 1 ? go : back; - const easing = {val: pos}; - const tween = new TWEEN.Tween(easing) - .to({val: 0}, playerInstance.vrROTATION_SPEED) - .easing(TWEEN.Easing.Quadratic.InOut) - .onUpdate(function () { - playerInstance.vrViewer.OrbitControls.rotateUp(easing.val) - }).start(); + playerInstance.cardboardRotateUpDown = (param /* 0 - down, 1 - up */) => { + const { yaw, pitch } = playerInstance.vrViewer.getPosition(); + const delta = param < 1 ? -playerInstance.vrROTATION_POSITION : playerInstance.vrROTATION_POSITION; + playerInstance.vrViewer.animate({ yaw, pitch: pitch + delta, speed: playerInstance.vrROTATION_SPEED }); }; playerInstance.createCardboardJoystick = () => { - const vrContainer = document.getElementById(playerInstance.videoPlayerId + '_fluid_vr_container'); + const vrContainer = playerInstance.domRef.wrapper.querySelector('.fluid_vr_container'); - // Create a JoyStick and append to VR container const vrJoystickPanel = document.createElement('div'); - vrJoystickPanel.id = playerInstance.videoPlayerId + '_fluid_vr_joystick_panel'; vrJoystickPanel.className = 'fluid_vr_joystick_panel'; vrContainer.appendChild(vrJoystickPanel); - // Create Joystick buttons const upButton = playerInstance.createCardboardJoystickButton('up'); const leftButton = playerInstance.createCardboardJoystickButton('left'); const rightButton = playerInstance.createCardboardJoystickButton('right'); @@ -55,125 +159,107 @@ export default function (playerInstance, options) { const zoomInButton = playerInstance.createCardboardJoystickButton('zoomin'); const zoomOutButton = playerInstance.createCardboardJoystickButton('zoomout'); - // Camera movement buttons - upButton.addEventListener('click', function () { - //player.vrViewer.OrbitControls.rotateUp(-0.1); - playerInstance.cardboardRotateUpDown(1); - }); + upButton.addEventListener('click', () => playerInstance.cardboardRotateUpDown(1)); + downButton.addEventListener('click', () => playerInstance.cardboardRotateUpDown(0)); + rightButton.addEventListener('click', () => playerInstance.cardboardRotateLeftRight(0)); + leftButton.addEventListener('click', () => playerInstance.cardboardRotateLeftRight(1)); + zoomDefaultButton.addEventListener('click', () => playerInstance.vrViewer.zoom(50)); + zoomOutButton.addEventListener('click', () => playerInstance.vrViewer.zoomOut(10)); + zoomInButton.addEventListener('click', () => playerInstance.vrViewer.zoomIn(10)); + }; - downButton.addEventListener('click', function () { - //player.vrViewer.OrbitControls.rotateUp(0.1); - playerInstance.cardboardRotateUpDown(0); - }); + playerInstance.cardBoardResize = () => { + playerInstance.domRef.player.removeEventListener('theatreModeOn', handleWindowResize); + playerInstance.domRef.player.addEventListener('theatreModeOn', handleWindowResize); - rightButton.addEventListener('click', function () { - //player.vrViewer.OrbitControls.rotateLeft(0.1); - playerInstance.cardboardRotateLeftRight(0); - }); + playerInstance.domRef.player.removeEventListener('theatreModeOff', handleWindowResize); + playerInstance.domRef.player.addEventListener('theatreModeOff', handleWindowResize); - leftButton.addEventListener('click', function () { - //player.vrViewer.OrbitControls.rotateLeft(-0.1); - playerInstance.cardboardRotateLeftRight(1); - }); + window.removeEventListener('resize', handleWindowResize); + window.addEventListener('resize', handleWindowResize); + }; - zoomDefaultButton.addEventListener('click', function () { - playerInstance.vrViewer.camera.fov = 60; - playerInstance.vrViewer.camera.updateProjectionMatrix(); - }); + function handleWindowResize() { + playerInstance.vrViewer.autoSize(); + } - // Camera Zoom buttons - zoomOutButton.addEventListener('click', function () { - playerInstance.vrViewer.camera.fov *= 1.1; - playerInstance.vrViewer.camera.updateProjectionMatrix(); - }); + playerInstance.cardBoardSwitchToNormal = () => { + const vrJoystickPanel = playerInstance.domRef.wrapper.querySelector('.fluid_vr_joystick_panel'); + const videoPlayerTag = playerInstance.domRef.player; + let controlBars = videoPlayerTag.parentNode.getElementsByClassName('fluid_controls_container'); - zoomInButton.addEventListener('click', function () { - playerInstance.vrViewer.camera.fov *= 0.9; - playerInstance.vrViewer.camera.updateProjectionMatrix(); - }); + // Stop gyroscope and release orientation lock + const gyroPlugin = playerInstance.vrViewer.getPlugin('gyroscope'); + if (gyroPlugin?.isEnabled()) { + gyroPlugin.stop(); + } + try { screen.orientation?.unlock(); } catch (_) {} - }; + // Restore mouse/touch drag navigation + playerInstance.vrViewer.setOption('mousemove', true); - playerInstance.cardBoardResize = () => { - playerInstance.domRef.player.addEventListener('theatreModeOn', function () { - playerInstance.vrViewer.onWindowResize(); - }); + // Dispose barrel-distortion resources and remove the custom renderer. + playerInstance._vrCardboardEffect?.dispose(); + playerInstance._vrCardboardEffect = null; + playerInstance.vrViewer.renderer.setCustomRenderer(null); - playerInstance.domRef.player.addEventListener('theatreModeOff', function () { - playerInstance.vrViewer.onWindowResize(); - }); - }; + const { width, height } = playerInstance.vrViewer.getSize(); + playerInstance.vrViewer.renderer.renderer.setViewport(0, 0, width, height); - playerInstance.cardBoardSwitchToNormal = () => { - const vrJoystickPanel = document.getElementById(playerInstance.videoPlayerId + '_fluid_vr_joystick_panel'); - const controlBar = document.getElementById(playerInstance.videoPlayerId + '_fluid_controls_container'); - const videoPlayerTag = playerInstance.domRef.player; + playerInstance.domRef.wrapper.classList.remove('fluid_vr_active'); + playerInstance._vrManualStereo = false; - playerInstance.vrViewer.enableEffect(PANOLENS.MODES.NORMAL); - playerInstance.vrViewer.onWindowResize(); playerInstance.vrMode = false; - // remove dual control bar - const newControlBar = videoPlayerTag.parentNode.getElementsByClassName('fluid_vr2_controls_container')[0]; - videoPlayerTag.parentNode.removeChild(newControlBar); + const controlBarsArr = Array.from(controlBars); + const secondControlBarIndex = controlBarsArr.findIndex(control => control.classList.contains('fluid_vr2_controls_container')); + + if (secondControlBarIndex !== -1) { + const secondControlBar = controlBars[secondControlBarIndex]; + const originalControlBar = controlBarsArr.find((el, i) => i !== secondControlBarIndex); + + videoPlayerTag.parentNode.removeChild(secondControlBar); + originalControlBar?.classList.remove("fluid_vr_controls_container"); + } if (playerInstance.displayOptions.layoutControls.showCardBoardJoystick && vrJoystickPanel) { vrJoystickPanel.style.display = "block"; } - controlBar.classList.remove("fluid_vr_controls_container"); - // show volume control bar - const volumeContainer = document.getElementById(playerInstance.videoPlayerId + '_fluid_control_volume_container'); + const volumeContainer = playerInstance.domRef.wrapper.querySelector('.fluid_control_volume_container'); volumeContainer.style.display = "block"; - // show all ads overlays if any - const adCountDownTimerText = document.getElementById('ad_countdown' + playerInstance.videoPlayerId); - const ctaButton = document.getElementById(playerInstance.videoPlayerId + '_fluid_cta'); - const addAdPlayingTextOverlay = document.getElementById(playerInstance.videoPlayerId + '_fluid_ad_playing'); - const skipBtn = document.getElementById('skip_button_' + playerInstance.videoPlayerId); - - if (adCountDownTimerText) { - adCountDownTimerText.style.display = 'block'; - } - - if (ctaButton) { - ctaButton.style.display = 'block'; - } + const adCountDownTimerText = playerInstance.domRef.wrapper.querySelector('.ad_countdown'); + const ctaButton = playerInstance.domRef.wrapper.querySelector('.fluid_ad_cta'); + const addAdPlayingTextOverlay = playerInstance.domRef.wrapper.querySelector('.fluid_ad_playing'); + const skipBtn = playerInstance.domRef.wrapper.querySelector('.skip_button'); - if (addAdPlayingTextOverlay) { - addAdPlayingTextOverlay.style.display = 'block'; - } - - if (skipBtn) { - skipBtn.style.display = 'block'; - } + if (adCountDownTimerText) { adCountDownTimerText.style.display = 'block'; } + if (ctaButton) { ctaButton.style.display = 'block'; } + if (addAdPlayingTextOverlay) { addAdPlayingTextOverlay.style.display = 'block'; } + if (skipBtn) { skipBtn.style.display = 'block'; } }; playerInstance.cardBoardHideDefaultControls = () => { - const vrJoystickPanel = document.getElementById(playerInstance.videoPlayerId + '_fluid_vr_joystick_panel'); - const initialPlay = document.getElementById(playerInstance.videoPlayerId + '_fluid_initial_play'); - const volumeContainer = document.getElementById(playerInstance.videoPlayerId + '_fluid_control_volume_container'); + const vrJoystickPanel = playerInstance.domRef.wrapper.querySelector('.fluid_vr_joystick_panel'); + const initialPlay = playerInstance.domRef.wrapper.querySelector('.fluid_initial_play'); + const volumeContainer = playerInstance.domRef.wrapper.querySelector('.fluid_control_volume_container'); - // hide the joystick in VR mode if (playerInstance.displayOptions.layoutControls.showCardBoardJoystick && vrJoystickPanel) { vrJoystickPanel.style.display = "none"; } - // hide big play icon if (initialPlay) { - document.getElementById(playerInstance.videoPlayerId + '_fluid_initial_play').style.display = "none"; - document.getElementById(playerInstance.videoPlayerId + '_fluid_initial_play_button').style.opacity = "1"; + playerInstance.domRef.wrapper.querySelector('.fluid_initial_play').style.display = "none"; + playerInstance.domRef.wrapper.querySelector('.fluid_initial_play_button_container').style.opacity = "1"; } - // hide volume control bar volumeContainer.style.display = "none"; - }; playerInstance.cardBoardCreateVRControls = () => { - const controlBar = document.getElementById(playerInstance.videoPlayerId + '_fluid_controls_container'); + const controlBar = playerInstance.domRef.wrapper.querySelector('.fluid_controls_container'); - // create and append dual control bar const newControlBar = controlBar.cloneNode(true); newControlBar.removeAttribute('id'); newControlBar.querySelectorAll('*').forEach(function (node) { @@ -186,54 +272,63 @@ export default function (playerInstance, options) { }; playerInstance.cardBoardSwitchToVR = () => { - const controlBar = document.getElementById(playerInstance.videoPlayerId + '_fluid_controls_container'); - - playerInstance.vrViewer.enableEffect(PANOLENS.MODES.CARDBOARD); - - playerInstance.vrViewer.onWindowResize(); - playerInstance.vrViewer.disableReticleControl(); + const controlBar = playerInstance.domRef.wrapper.querySelector('.fluid_controls_container'); + const { CardboardEffect } = _psvModules; + const isMobile = playerInstance.getMobileOs().userOs === 'Android' || playerInstance.getMobileOs().userOs === 'iOS'; playerInstance.vrMode = true; + playerInstance._vrManualStereo = true; - controlBar.classList.add("fluid_vr_controls_container"); + const adCountDownTimerText = playerInstance.domRef.wrapper.querySelector('.ad_countdown'); + const ctaButton = playerInstance.domRef.wrapper.querySelector('.fluid_ad_cta'); + const addAdPlayingTextOverlay = playerInstance.domRef.wrapper.querySelector('.fluid_ad_playing'); + const skipBtn = playerInstance.domRef.wrapper.querySelector('.skip_button'); - playerInstance.cardBoardHideDefaultControls(); - playerInstance.cardBoardCreateVRControls(); + if (adCountDownTimerText) { adCountDownTimerText.style.display = 'none'; } + if (ctaButton) { ctaButton.style.display = 'none'; } + if (addAdPlayingTextOverlay) { addAdPlayingTextOverlay.style.display = 'none'; } + if (skipBtn) { skipBtn.style.display = 'none'; } - // hide all ads overlays - const adCountDownTimerText = document.getElementById('ad_countdown' + playerInstance.videoPlayerId); - const ctaButton = document.getElementById(playerInstance.videoPlayerId + '_fluid_cta'); - const addAdPlayingTextOverlay = document.getElementById(playerInstance.videoPlayerId + '_fluid_ad_playing'); - const skipBtn = document.getElementById('skip_button_' + playerInstance.videoPlayerId); + // Expand the player wrapper to fill the viewport so the stereo split is + // always centred and the canvas never breaks outside the player bounds. + playerInstance.domRef.wrapper.classList.add('fluid_vr_active'); - if (adCountDownTimerText) { - adCountDownTimerText.style.display = 'none'; - } + // Dual control bars — one per eye, sized to 50% of the now-fullscreen wrapper. + controlBar.classList.add("fluid_vr_controls_container"); + playerInstance.cardBoardCreateVRControls(); - if (ctaButton) { - ctaButton.style.display = 'none'; - } + if (isMobile) { + playerInstance.cardBoardHideDefaultControls(); - if (addAdPlayingTextOverlay) { - addAdPlayingTextOverlay.style.display = 'none'; + // DeviceOrientationEvent.requestPermission() must be called within a + // user gesture, which this click handler satisfies. + playerInstance.vrViewer.getPlugin('gyroscope').start().then(() => { + screen.orientation?.lock('landscape')?.catch(() => {}); + }).catch(() => { + // Permission denied or no gyroscope — static split-screen only. + }); } - if (skipBtn) { - skipBtn.style.display = 'none'; - } + // Disable mouse/touch drag — in VR mode the scene should only move + // via the gyroscope, not cursor input. + playerInstance.vrViewer.setOption('mousemove', false); + // Apply the split-screen stereo renderer with barrel distortion. + playerInstance.vrViewer.renderer.setCustomRenderer((renderer) => { + const effect = new CardboardEffect(renderer); + playerInstance._vrCardboardEffect = effect; + return effect; + }); }; playerInstance.cardBoardMoveTimeInfo = () => { - const timePlaceholder = document.getElementById(playerInstance.videoPlayerId + '_fluid_control_duration'); - const controlBar = document.getElementById(playerInstance.videoPlayerId + '_fluid_controls_container'); + const timePlaceholder = playerInstance.domRef.wrapper.querySelector('.fluid_control_duration'); + const controlBar = playerInstance.domRef.wrapper.querySelector('.fluid_controls_container'); timePlaceholder.classList.add("cardboard_time"); controlBar.appendChild(timePlaceholder); - // override the time display function for this instance playerInstance.controlDurationUpdate = function () { - const currentPlayTime = playerInstance.formatTime(playerInstance.domRef.player.currentTime); const totalTime = playerInstance.formatTime(playerInstance.currentVideoDuration); const timePlaceholder = playerInstance.domRef.player.parentNode.getElementsByClassName('fluid_control_duration'); @@ -258,7 +353,7 @@ export default function (playerInstance, options) { for (let i = 0; i < timePlaceholder.length; i++) { timePlaceholder[i].innerHTML = durationText; } - } + }; }; playerInstance.cardBoardAlterDefaultControls = () => { @@ -266,55 +361,74 @@ export default function (playerInstance, options) { }; playerInstance.createCardboardView = () => { - // Create a container for 360degree + const { Viewer } = _psvModules.core; + const { EquirectangularVideoAdapter } = _psvModules.videoAdapter; + const { VideoPlugin } = _psvModules.videoPlugin; + const { GyroscopePlugin } = _psvModules.gyroscopePlugin; + const vrContainer = document.createElement('div'); - vrContainer.id = playerInstance.videoPlayerId + '_fluid_vr_container'; vrContainer.className = 'fluid_vr_container'; playerInstance.domRef.player.parentNode.insertBefore(vrContainer, playerInstance.domRef.player.nextSibling); - // OverRide some conflicting functions from panolens - PANOLENS.VideoPanorama.prototype.pauseVideo = function () { - }; - PANOLENS.VideoPanorama.prototype.playVideo = function () { - }; - - playerInstance.vrPanorama = new PANOLENS.VideoPanorama('', { - videoElement: playerInstance.domRef.player, - autoplay: playerInstance.displayOptions.layoutControls.autoPlay, - loop: !!playerInstance.displayOptions.layoutControls.loop + // PSV mounts to an inner element so its overflow:hidden doesn't clip + // the joystick panel, which is appended directly to vrContainer + const psvContainer = document.createElement('div'); + Object.assign(psvContainer.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%' }); + vrContainer.appendChild(psvContainer); + + playerInstance.vrViewer = new Viewer({ + container: psvContainer, + adapter: [EquirectangularVideoAdapter, { autoplay: false, muted: true }], + panorama: { source: playerInstance.domRef.player.currentSrc }, + plugins: [ + [VideoPlugin, { progressbar: false, bigbutton: false }], + [GyroscopePlugin, { moveMode: 'smooth' }], + ], + navbar: false, + loadingTxt: '', }); - playerInstance.vrViewer = new PANOLENS.Viewer({ - container: vrContainer, - controlBar: true, - controlButtons: [], - enableReticle: false - }); - playerInstance.vrViewer.add(playerInstance.vrPanorama); + // Sync PSV's muted video with fluid-player's playback state. + // PSV's video is muted; audio continues from fluid-player's video + // element which still plays in the background. + playerInstance.vrViewer.addEventListener('panorama-loaded', () => { + const masterVideo = playerInstance.domRef.player; + const videoPlugin = playerInstance.vrViewer.getPlugin(VideoPlugin); + + if (videoPlugin.video) { + videoPlugin.video.currentTime = masterVideo.currentTime; + if (!masterVideo.paused) { + videoPlugin.play(); + } + } - playerInstance.vrViewer.enableEffect(PANOLENS.MODES.NORMAL); - playerInstance.vrViewer.onWindowResize(); + const onPlay = () => videoPlugin.play(); + const onPause = () => videoPlugin.pause(); + const onSeeked = () => { + if (videoPlugin.video) { + videoPlugin.video.currentTime = masterVideo.currentTime; + } + }; - // if Mobile device then enable controls using gyroscope - if (playerInstance.getMobileOs().userOs === 'Android' || playerInstance.getMobileOs().userOs === 'iOS') { - playerInstance.vrViewer.enableControl(1); - } + masterVideo.addEventListener('play', onPlay); + masterVideo.addEventListener('pause', onPause); + masterVideo.addEventListener('seeked', onSeeked); - // Make Changes for default skin - playerInstance.cardBoardAlterDefaultControls(); + playerInstance._vrVideoCleanup = () => { + masterVideo.removeEventListener('play', onPlay); + masterVideo.removeEventListener('pause', onPause); + masterVideo.removeEventListener('seeked', onSeeked); + }; + }); - // resize on toggle theater mode + playerInstance.cardBoardAlterDefaultControls(); playerInstance.cardBoardResize(); - // Store initial camera position - playerInstance.vrViewer.initialCameraPosition = JSON.parse(JSON.stringify(playerInstance.vrViewer.camera.position)); - if (playerInstance.displayOptions.layoutControls.showCardBoardJoystick) { if (!(playerInstance.getMobileOs().userOs === 'Android' || playerInstance.getMobileOs().userOs === 'iOS')) { playerInstance.createCardboardJoystick(); } - // Disable zoom if showing joystick - playerInstance.vrViewer.OrbitControls.noZoom = true; + playerInstance.vrViewer.setOption('mousewheel', false); } playerInstance.trackEvent(playerInstance.domRef.player.parentNode, 'click', '.fluid_control_cardboard', function () { @@ -331,14 +445,19 @@ export default function (playerInstance, options) { return; } - document - .getElementById(playerInstance.videoPlayerId + '_fluid_control_cardboard') - .style - .display = 'inline-block'; - - if (!window.PANOLENS) { - import(/* webpackChunkName: "panolens" */ 'panolens').then((it) => { - window.PANOLENS = it; + playerInstance.domRef.wrapper.querySelector('.fluid_control_cardboard').style.display = 'inline-block'; + + if (!_psvModules) { + Promise.all([ + import(/* webpackChunkName: "psv" */ '@photo-sphere-viewer/core'), + import(/* webpackChunkName: "psv" */ '@photo-sphere-viewer/core/index.css'), + import('@photo-sphere-viewer/equirectangular-video-adapter'), + import('@photo-sphere-viewer/video-plugin'), + import(/* webpackChunkName: "psv" */ '@photo-sphere-viewer/video-plugin/index.css'), + import('@photo-sphere-viewer/gyroscope-plugin'), + import('three'), + ]).then(([core, , videoAdapter, videoPlugin, , gyroscopePlugin, THREE]) => { + _psvModules = { core, videoAdapter, videoPlugin, gyroscopePlugin, CardboardEffect: buildCardboardEffect(THREE) }; playerInstance.createCardboardView(); }); } else { diff --git a/src/modules/miniplayer.js b/src/modules/miniplayer.js new file mode 100644 index 00000000..34dc859e --- /dev/null +++ b/src/modules/miniplayer.js @@ -0,0 +1,393 @@ +import { displayModes } from '../constants/constants'; + +export default function (playerInstance) { + // Module constants + const MINIMUM_WIDTH = 400; // Pixels + const MINIMUM_HEIGHT = 225; // Pixels + const MINIMUM_WIDTH_MOBILE = 40; // Percentage + const TOGGLE_BY_VISIBILITY_DETECTION_RATE = 1000 / 60; // ms + + const DISABLE_MINI_PLAYER_MOBILE_ANIMATION_CLAMP = 50; + const DISABLE_MINI_PLAYER_MOBILE_ANIMATION_DEADZONE = 5; + + const DESKTOP_ONLY_MEDIA_QUERY = '(max-width: 768px)'; + + const FLUID_PLAYER_WRAPPER_CLASS = 'fluid_mini_player_mode'; + const CLOSE_BUTTON_WRAPPER_CLASS = 'mini-player-close-button-wrapper'; + const CLOSE_BUTTON_CLASS = 'mini-player-close-button'; + const PLACEHOLDER_CLASS = 'fluidplayer-miniplayer-player-placeholder' + const DISABLE_MINI_PLAYER_MOBILE_CLASS = 'disable-mini-player-mobile'; + + const LINEAR_CLICKTHROUGH_SELECTOR = '.vast_clickthrough_layer'; + const NON_LINEAR_SELECTOR = '.fluid_nonLinear_ad img, .fluid_vpaid_nonlinear_slot_iframe'; + const VPAID_FRAME_SELECTOR = '.fluid_vpaidNonLinear_frame'; + + const MINI_PLAYER_TOGGLE_EVENT = 'miniPlayerToggle'; + + // Module variables + let originalWidth = null; + let originalHeight = null; + let originalNonLinearWidth = null + let originalNonLinearHeight = null; + let isSetup = false; + /** @type null | Element */ + let placeholderElement = null; + let isMobile = false; + /** @type boolean */ + let toggleByVisibilityControl = false; + + /** + * Toggles the MiniPlayer given that it's enabled. Resets all other display modes. + * + * @param {'on'|'off'} [forceToggle] + * @param {boolean} manualToggle + * @param toAnotherDisplayTarget + */ + function toggleMiniPlayer(forceToggle, manualToggle = false, toAnotherDisplayTarget = false) { + playerInstance.debugMessage(`[MiniPlayer] Toggling MiniPlayer, forceToggle: ${forceToggle}`); + + const miniPlayerOptions = playerInstance.displayOptions.layoutControls.miniPlayer; + + if (!miniPlayerOptions.enabled) { + playerInstance.debugMessage(`[MiniPlayer] Prevent toggle MiniPlayer, it's currently disabled`); + return; + } + + if ((forceToggle === 'on' && playerInstance.miniPlayerToggledOn) || (forceToggle === 'off' && !playerInstance.miniPlayerToggledOn)) { + playerInstance.debugMessage(`[MiniPlayer] Can't force toggle Mini Player to it's same state`); + return; + } + + if (manualToggle) { + toggleScreenDetection(); + } + + if (window.matchMedia(DESKTOP_ONLY_MEDIA_QUERY).matches) { + isMobile = true; + } + + const previousDisplayMode = playerInstance.getPreviousDisplayMode(); + // Important as the player can be in full screen or theater mode + playerInstance.resetDisplayMode(displayModes.MINI_PLAYER); + + if (!isSetup) { + // Setups JIT to avoid extra processing + setupMiniPlayer(); + } + + if (forceToggle === 'off' || playerInstance.miniPlayerToggledOn) { + toggleMiniPlayerOff(); + } else if (forceToggle === 'on' || !playerInstance.miniPlayerToggledOn) { + toggleMiniPlayerOn(miniPlayerOptions.width, miniPlayerOptions.height, miniPlayerOptions.widthMobile, miniPlayerOptions.position); + } + + if (!toAnotherDisplayTarget) { + playerInstance.trackPlayerSizeChanged(previousDisplayMode); + } + } + + /** + * Setups custom Mini Player DOM + */ + function setupMiniPlayer() { + const hasCloseButton = Boolean(playerInstance.domRef.player.parentNode.querySelector(`.${CLOSE_BUTTON_CLASS}`)); + + if (!hasCloseButton) { + const closeButtonWrapper = document.createElement('div'); + closeButtonWrapper.classList.add(CLOSE_BUTTON_WRAPPER_CLASS); + + const closeButton = document.createElement('span'); + closeButton.classList.add(CLOSE_BUTTON_CLASS); + closeButton.addEventListener('click', () => { + toggleMiniPlayer('off', true); + + if (!playerInstance.domRef.player.paused) { + playerInstance.playPauseToggle(); + } + }); + + closeButtonWrapper.appendChild(closeButton); + playerInstance.domRef.player.parentNode.append(closeButtonWrapper); + } + + if (isMobile) { + setupMobile(); + } + + isSetup = true; + } + + /** + * Toggles the MiniPlayer off and restores previous functionality to player + */ + function toggleMiniPlayerOff() { + const videoWrapper = playerInstance.domRef.wrapper; + + removePlayerPlaceholder(); + + videoWrapper.classList.remove(FLUID_PLAYER_WRAPPER_CLASS); + videoWrapper.style.width = `${originalWidth}px`; + videoWrapper.style.height = `${originalHeight}px`; + + originalWidth = null; + originalHeight = null; + + adaptNonLinearSize(); + adaptLinearSize(); + playerInstance.miniPlayerToggledOn = false; + emitToggleEvent(); + } + + /** + * Toggles the MiniPlayer on, stores the original size of the player. + * + * @param {number} width + * @param {number} height + * @param {number} mobileWidth + * @param {'top left'|'top right'|'bottom left'|'bottom right'} position + */ + function toggleMiniPlayerOn(width, height, mobileWidth, position) { + const videoWrapper = playerInstance.domRef.wrapper; + const targetWidth = width > MINIMUM_WIDTH ? width : MINIMUM_WIDTH; + const targetHeight = height > MINIMUM_HEIGHT ? height : MINIMUM_HEIGHT; + const targetMobileWidth = mobileWidth > MINIMUM_WIDTH_MOBILE ? mobileWidth : MINIMUM_WIDTH_MOBILE; + + originalWidth = extractSizeFromElement(videoWrapper, 'width', 'clientWidth'); + originalHeight = extractSizeFromElement(videoWrapper, 'height', 'clientHeight'); + + videoWrapper.classList.add( + FLUID_PLAYER_WRAPPER_CLASS, + `${FLUID_PLAYER_WRAPPER_CLASS}--${position.trim().replace(/\s/, '-')}` + ); + + if (!isMobile) { + videoWrapper.style.width = `${targetWidth}px`; + videoWrapper.style.height = `${targetHeight}px`; + } else { + videoWrapper.style.width = `${targetMobileWidth}vw`; + videoWrapper.style.height = `auto`; + videoWrapper.style.aspectRatio = `16 / 9`; + } + + createPlayerPlaceholder(originalWidth, originalHeight); + adaptNonLinearSize(targetWidth, targetHeight, targetMobileWidth); + adaptLinearSize(); + playerInstance.miniPlayerToggledOn = true; + emitToggleEvent(); + } + + /** + * Emits event to Fluid Player Event API + */ + function emitToggleEvent() { + playerInstance.domRef.player.dispatchEvent( + new CustomEvent(MINI_PLAYER_TOGGLE_EVENT, { detail: { isToggledOn: playerInstance.miniPlayerToggledOn } }) + ); + } + + /** + * Extracts size from an element checking multiple element properties + * + * @param {HTMLElement} element + * @param {'width'|'height'|null} styleProperty + * @param {'clientWidth'|'clientHeight'|'width'|'height'} htmlProperty + * @returns {number} + */ + function extractSizeFromElement(element, styleProperty, htmlProperty) { + if (styleProperty && element.style[styleProperty] && element.style[styleProperty].match('px')) { + return parseInt(element.style[styleProperty]); + } else { + return String(element[htmlProperty]).match('px') ? parseInt(element[htmlProperty]) : element[htmlProperty]; + } + } + + /** + * Adapts NonLinear size (if present) to fit MiniPlayer view + * + * @param {number} [width] + * @param {number} [height] + * @param {number} [mobileWidth] + */ + function adaptNonLinearSize(width, height, mobileWidth) { + /** @type HTMLImageElement|HTMLIFrameElement */ + const nonLinear = playerInstance.domRef.wrapper.querySelector(NON_LINEAR_SELECTOR); + /** @type HTMLElement */ + const vpaidFrame = playerInstance.domRef.wrapper.querySelector(VPAID_FRAME_SELECTOR); + + if (!nonLinear) return; + + if (isMobile) { + width = window.innerWidth * mobileWidth / 100; // Transforms vw to px + } + + const nonLinearWidth = extractSizeFromElement(nonLinear, null, 'width'); + const nonLinearHeight = extractSizeFromElement(nonLinear, null, 'height'); + + if (originalNonLinearWidth && originalNonLinearHeight) { + nonLinear.width = originalNonLinearWidth; + nonLinear.height = originalNonLinearHeight; + + if (vpaidFrame) { + vpaidFrame.style.width = `${originalNonLinearWidth}px`; + vpaidFrame.style.height = `${originalNonLinearHeight}px`; + } + + originalNonLinearWidth = originalNonLinearHeight = null; + } else if (nonLinearWidth > width || nonLinearHeight > height) { + const targetRatio = (width - (isMobile ? 4 : 32)) / nonLinearWidth; + + originalNonLinearWidth = nonLinearWidth; + originalNonLinearHeight = nonLinearHeight; + + nonLinear.width = Math.round(nonLinearWidth * targetRatio); + nonLinear.height = Math.round(nonLinearHeight * targetRatio); + + if (vpaidFrame) { + vpaidFrame.style.width = `${Math.round(nonLinearWidth * targetRatio)}px`; + vpaidFrame.style.height = `${Math.round(nonLinearHeight * targetRatio)}px`; + } + } + } + + /** + * Adapts Linear size (if present) to fit MiniPlayer view + */ + function adaptLinearSize() { + const clickTroughLayer = playerInstance.domRef.wrapper.querySelector(LINEAR_CLICKTHROUGH_SELECTOR); + + if (clickTroughLayer) { + clickTroughLayer.style.width = `${playerInstance.domRef.player.offsetWidth}px`; + clickTroughLayer.style.height = `${playerInstance.domRef.player.offsetHeight}px`; + } + } + + /** + * Setups mobile disable element + */ + function setupMobile() { + const disableMiniPlayerMobile = document.createElement('div'); + let animationAmount = 0; + let startTimestamp = 0; + let startScreenX = 0; + let hasTriggeredAnimation; + disableMiniPlayerMobile.classList.add(DISABLE_MINI_PLAYER_MOBILE_CLASS); + const closeButton = document.createElement('span'); + closeButton.classList.add(CLOSE_BUTTON_CLASS); + disableMiniPlayerMobile.appendChild(closeButton); + + disableMiniPlayerMobile.ontouchstart = event => { + hasTriggeredAnimation = false; + startTimestamp = event.timeStamp; + startScreenX = event.changedTouches[0].screenX; + event.preventDefault(); + } + + disableMiniPlayerMobile.ontouchmove = event => { + animationAmount = Math.min( + Math.max( + startScreenX - event.changedTouches[0].screenX, + DISABLE_MINI_PLAYER_MOBILE_ANIMATION_CLAMP * -1), + DISABLE_MINI_PLAYER_MOBILE_ANIMATION_CLAMP + ); + + if (Math.abs(animationAmount) > DISABLE_MINI_PLAYER_MOBILE_ANIMATION_DEADZONE) { + // Moves the element the same amount as the touch event moved + playerInstance.domRef.wrapper.style.transform = `translateX(${animationAmount * -1}px)`; + hasTriggeredAnimation = true; + } else { + playerInstance.domRef.wrapper.style.transform = `translateX(0px)` + } + } + + disableMiniPlayerMobile.ontouchend = event => { + if (Math.abs(animationAmount) > DISABLE_MINI_PLAYER_MOBILE_ANIMATION_DEADZONE) { + // Scroll X behaviour - Disable mini player and pauses video + toggleMiniPlayer('off', true); + + if (!playerInstance.domRef.player.paused) { + playerInstance.playPauseToggle(); + } + event.preventDefault(); + } else if (!hasTriggeredAnimation) { + // Tap behaviour - Disable mini player and moves screen to video + toggleMiniPlayer('off', true); + setTimeout(() => { + playerInstance.domRef.wrapper.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }) + } + + animationAmount = 0; + playerInstance.domRef.wrapper.style.transform = ``; + } + + // Fallback for when there is no touch event + disableMiniPlayerMobile.onmouseup = () => toggleMiniPlayer('off', true) + + playerInstance.domRef.wrapper.insertBefore(disableMiniPlayerMobile, playerInstance.domRef.player.nextSibling); + } + + /** + * Creates a placeholder element in place where the video player was + * + * @param {number} placeholderWidth + * @param {number} placeholderHeight + */ + function createPlayerPlaceholder(placeholderWidth, placeholderHeight) { + placeholderElement = document.createElement('div'); + placeholderElement.classList.add(PLACEHOLDER_CLASS); + placeholderElement.style.height = `${placeholderHeight}px`; + placeholderElement.style.width = `${placeholderWidth}px`; + placeholderElement.innerText = playerInstance.displayOptions.layoutControls.miniPlayer.placeholderText || ''; + placeholderElement.onclick = () => toggleMiniPlayer('off', true); + + playerInstance.domRef.wrapper.parentElement.insertBefore(placeholderElement, playerInstance.domRef.wrapper); + } + + /** + * Removes the placeholder that was in place where video player was + */ + function removePlayerPlaceholder() { + playerInstance.domRef.wrapper.parentElement.removeChild(placeholderElement); + placeholderElement = null; + } + + /** + * Toggles auto toggle for mini player + */ + function toggleScreenDetection() { + const autoToggle = playerInstance.displayOptions.layoutControls.miniPlayer.autoToggle; + + if (toggleByVisibilityControl || !autoToggle) { + document.removeEventListener('scroll', toggleMiniPlayerByVisibility); + return; + } + + toggleByVisibilityControl = true; + document.addEventListener('scroll', toggleMiniPlayerByVisibility, { passive: true }); + } + + /** + * Checks for player visibility and toggles mini player based on it + */ + const toggleMiniPlayerByVisibility = playerInstance.throttle(function toggleMiniPlayerByVisibility() { + if (playerInstance.domRef.player.paused) { + return; + } + + const isPlayerVisible = playerInstance.isElementVisible(playerInstance.domRef.player); + const isPlaceholderVisible = playerInstance.isElementVisible(playerInstance.domRef.wrapper.querySelector(`.${PLACEHOLDER_CLASS}`)); + + if (!isPlayerVisible && !playerInstance.miniPlayerToggledOn) { + toggleMiniPlayer('on'); + } else if (isPlaceholderVisible && playerInstance.miniPlayerToggledOn) { + toggleMiniPlayer('off'); + } + }, TOGGLE_BY_VISIBILITY_DETECTION_RATE); + + // Exposes public module functions + playerInstance.toggleMiniPlayer = toggleMiniPlayer; + playerInstance.toggleMiniPlayerScreenDetection = toggleScreenDetection; +} diff --git a/src/modules/streaming.js b/src/modules/streaming.js index 3f497e0d..5497e5da 100644 --- a/src/modules/streaming.js +++ b/src/modules/streaming.js @@ -1,4 +1,3 @@ -'use strict'; // Prevent DASH.js from automatically attaching to video sources by default. // Whoever thought this is a good idea?! @@ -25,6 +24,16 @@ export default function (playerInstance, options) { } break; case 'application/x-mpegurl': // HLS + const { displayOptions, domRef } = playerInstance; + const { player } = domRef; + const { hls } = displayOptions; + + // Doesn't load hls.js if player can play it natively + if (player.canPlayType('application/x-mpegurl') && !hls.overrideNative) { + playerInstance.debugMessage('Native HLS support found, skipping hls.js'); + break; + } + if (!playerInstance.hlsScriptLoaded && !window.Hls) { playerInstance.hlsScriptLoaded = true; import(/* webpackChunkName: "hlsjs" */ 'hls.js').then((it) => { @@ -74,6 +83,13 @@ export default function (playerInstance, options) { playerInstance.toggleLoader(false); }); + dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => { + playerInstance.isLiveStream = !!dashPlayer.isDynamic() + if (!!dashPlayer.isDynamic()) { + playerInstance.showLiveIndicator(); + } + }); + playerInstance.displayOptions.modules.onAfterInitDash(dashPlayer); playerInstance.dashPlayer = dashPlayer; @@ -84,10 +100,12 @@ export default function (playerInstance, options) { }; playerInstance.initialiseHls = () => { - if (Hls.isSupported()) { + if (typeof Hls !== 'undefined' && Hls.isSupported()) { + playerInstance.debugMessage('Initializing hls.js'); const defaultOptions = { debug: typeof FP_DEBUG !== 'undefined' && FP_DEBUG === true, + startPosition: 0, p2pConfig: { logLevel: false, }, @@ -102,6 +120,15 @@ export default function (playerInstance, options) { hls.attachMedia(playerInstance.domRef.player); hls.loadSource(playerInstance.originalSrc); + hls.once(Hls.Events.LEVEL_LOADED, function (event, level) { + playerInstance.isLiveStream = !!level.details.live; + if (!!level.details.live) { + playerInstance.showLiveIndicator(); + } else { + playerInstance.hideLiveIndicator(); + } + }); + playerInstance.displayOptions.modules.onAfterInitHls(hls); playerInstance.hlsPlayer = hls; @@ -109,12 +136,165 @@ export default function (playerInstance, options) { if (!playerInstance.firstPlayLaunched && playerInstance.displayOptions.layoutControls.autoPlay) { playerInstance.domRef.player.play(); } + + playerInstance.createHLSVideoSourceSwitch(); } else { playerInstance.nextSource(); console.log('[FP_WARNING] Media type not supported by this browser using HLS.js. (application/x-mpegURL)'); } }; + playerInstance.createHLSVideoSourceSwitch = () => { + playerInstance.hlsPlayer.on(Hls.Events.MANIFEST_PARSED, function () { + try { + const levels = createHLSLevels(); + const sortedLevels = sortLevels(levels); + playerInstance.videoSources = sortedLevels; + + // <=2 because of the added auto function + if (sortedLevels.length <= 2) return; + + const sourceChangeButton = playerInstance.domRef.wrapper.querySelector('.fluid_control_video_source'); + + toggleSourceChangeButtonVisibility(sortedLevels, sourceChangeButton); + + const sourceChangeList = createSourceChangeList(sortedLevels); + attachSourceChangeList(sourceChangeButton, sourceChangeList); + + // Set initial level based on persisted quality or default to auto + setInitialLevel(sortedLevels); + } catch (err) { + console.error(err); + } + }); + }; + + function createHLSLevels() { + const HLSLevels = playerInstance.hlsPlayer.levels + .map((level, index) => ({ + id: index, + title: level.height + 'p', + isHD: level.videoRange === 'HDR', + bitrate: level.bitrate + })); + + const autoLevel = { + id: -1, + title: 'auto', + isHD: false, + bitrate: 0 + }; + + return [...HLSLevels, autoLevel]; + } + + function toggleSourceChangeButtonVisibility(levels, sourceChangeButton) { + if (levels.length > 1) { + sourceChangeButton.style.display = 'inline-block'; + } else { + sourceChangeButton.style.display = 'none'; + } + } + + function createSourceChangeList(levels) { + const sourceChangeList = document.createElement('div'); + sourceChangeList.className = 'fluid_video_sources_list'; + sourceChangeList.style.display = 'none'; + + levels.forEach(level => { + const sourceChangeDiv = createSourceChangeItem(level); + sourceChangeList.appendChild(sourceChangeDiv); + }); + + return sourceChangeList; + } + + function createSourceChangeItem(level) { + const sourceSelectedClass = getSourceSelectedClass(level); + const hdIndicator = level.isHD ? `` : ''; + + const sourceChangeDiv = document.createElement('div'); + sourceChangeDiv.className = `fluid_video_source_list_item js-source_${level.title}`; + sourceChangeDiv.innerHTML = `${level.title}${hdIndicator}`; + + sourceChangeDiv.addEventListener('click', event => onSourceChangeClick(event, level)); + + return sourceChangeDiv; + } + + function getSourceSelectedClass(level) { + const matchingLevels = playerInstance.videoSources.filter(source => source.title === playerInstance.fluidStorage.fluidQuality); + + // If there are multiple matching levels, use the first one + if (matchingLevels.length > 1) { + if (matchingLevels[0].id === level.id) { + return "source_selected"; + } + } else if (matchingLevels.length === 1) { + return matchingLevels[0].id === level.id ? "source_selected" : ""; + } + + // Fallback to auto selection if no persistent level exists + if (!matchingLevels.length && level.title === 'auto') { + return "source_selected"; + } + + return ""; + } + + function onSourceChangeClick(event, selectedLevel) { + event.stopPropagation(); + + const videoChangedTo = event.currentTarget; + clearSourceSelectedIcons(); + + videoChangedTo.firstChild.classList.add('source_selected'); + + playerInstance.videoSources.forEach(source => { + if (source.title === videoChangedTo.innerText.replace(/(\r\n\t|\n|\r\t)/gm, '')) { + playerInstance.hlsPlayer.currentLevel = selectedLevel.id; + playerInstance.fluidStorage.fluidQuality = selectedLevel.title; + } + }); + + playerInstance.openCloseVideoSourceSwitch(); + } + + function clearSourceSelectedIcons() { + const sourceIcons = playerInstance.domRef.wrapper.getElementsByClassName('source_button_icon'); + Array.from(sourceIcons).forEach(icon => icon.classList.remove('source_selected')); + } + + function attachSourceChangeList(sourceChangeButton, sourceChangeList) { + sourceChangeButton.appendChild(sourceChangeList); + sourceChangeButton.removeEventListener('click', playerInstance.openCloseVideoSourceSwitch); + sourceChangeButton.addEventListener('click', playerInstance.openCloseVideoSourceSwitch); + } + + function setInitialLevel(levels) { + // Check if a persistency level exists and set the current level accordingly + const persistedLevel = levels.find(level => level.title === playerInstance.fluidStorage.fluidQuality); + + if (persistedLevel) { + playerInstance.hlsPlayer.currentLevel = persistedLevel.id; + } else { + // Default to 'auto' if no persisted level is found + const autoLevel = levels.find(level => level.title === 'auto'); + playerInstance.hlsPlayer.currentLevel = autoLevel.id; + } + } + + function sortLevels(levels) { + return [...levels].sort((a, b) => { + // First sort by height in descending order + if (b.height !== a.height) { + return b.height - a.height; + } + // If height is the same, sort by bitrate in descending order + return b.bitrate - a.bitrate; + }); + } + playerInstance.detachStreamers = () => { if (playerInstance.dashPlayer) { playerInstance.dashPlayer.reset(); diff --git a/src/modules/subtitles.js b/src/modules/subtitles.js index 05332ade..67237123 100644 --- a/src/modules/subtitles.js +++ b/src/modules/subtitles.js @@ -1,9 +1,12 @@ -'use strict'; export default function (playerInstance, options) { + // Define the subtitle size levels + playerInstance.subtitleSizeLevels = [50, 75, 100, 150, 200]; // in percentage + playerInstance.currentSubtitleSizeIndex = playerInstance.subtitleSizeLevels.indexOf(100); // Default to 100% + playerInstance.subtitleFetchParse = (subtitleItem) => { playerInstance.sendRequest( subtitleItem.url, - true, + false, playerInstance.displayOptions.vastOptions.vastTimeout, function () { const convertVttRawData = function (vttRawData) { @@ -66,7 +69,7 @@ export default function (playerInstance, options) { if (!playerInstance.displayOptions.layoutControls.subtitlesEnabled) { // No other video subtitles - document.getElementById(playerInstance.videoPlayerId + '_fluid_control_subtitles').style.display = 'none'; + playerInstance.domRef.wrapper.querySelector('.fluid_control_subtitles').style.display = 'none'; return; } @@ -77,34 +80,46 @@ export default function (playerInstance, options) { [].forEach.call(tracksList, function (track) { if (track.kind === 'metadata' && track.src) { - tracks.push({'label': track.label, 'url': track.src, 'lang': track.srclang}); + tracks.push({'label': track.label, 'url': track.src, 'lang': track.srclang, 'default': track.default}); } }); playerInstance.subtitlesTracks = tracks; - const subtitlesChangeButton = document.getElementById(playerInstance.videoPlayerId + '_fluid_control_subtitles'); + const subtitlesChangeButton = playerInstance.domRef.wrapper.querySelector('.fluid_control_subtitles'); subtitlesChangeButton.style.display = 'inline-block'; let appendSubtitleChange = false; const subtitlesChangeList = document.createElement('div'); - subtitlesChangeList.id = playerInstance.videoPlayerId + '_fluid_control_subtitles_list'; subtitlesChangeList.className = 'fluid_subtitles_list'; subtitlesChangeList.style.display = 'none'; - let firstSubtitle = true; + playerInstance.addSubtitleSizeControls(subtitlesChangeList, subtitlesChangeButton); + + let hasSelectedSubtitle = false; + const hasDefault = !!playerInstance.subtitlesTracks.find(track => track.default); playerInstance.subtitlesTracks.forEach(function (subtitle) { + let subtitleSelected = '' + + const subtitlesOnByDefault = playerInstance.displayOptions.layoutControls.subtitlesOnByDefault; + + if (!hasSelectedSubtitle && (subtitlesOnByDefault && subtitle.default || + (!hasDefault && subtitle.label !== subtitlesOff) || + playerInstance.subtitlesTracks.length === 1) || + !subtitlesOnByDefault && subtitle.label === subtitlesOff + ) { + subtitleSelected = 'subtitle_selected'; + playerInstance.subtitleFetchParse(subtitle); + hasSelectedSubtitle = true; + } - const subtitleSelected = (firstSubtitle) ? "subtitle_selected" : ""; - firstSubtitle = false; const subtitlesChangeDiv = document.createElement('div'); - subtitlesChangeDiv.id = 'subtitle_' + playerInstance.videoPlayerId + '_' + subtitle.label; subtitlesChangeDiv.className = 'fluid_subtitle_list_item'; subtitlesChangeDiv.innerHTML = '' + subtitle.label; subtitlesChangeDiv.addEventListener('click', function (event) { event.stopPropagation(); const subtitleChangedTo = this; - const subtitleIcons = document.getElementsByClassName('subtitle_button_icon'); + const subtitleIcons = playerInstance.domRef.wrapper.getElementsByClassName('subtitle_button_icon'); for (let i = 0; i < subtitleIcons.length; i++) { subtitleIcons[i].className = subtitleIcons[i].className.replace("subtitle_selected", ""); @@ -132,30 +147,62 @@ export default function (playerInstance, options) { if (appendSubtitleChange) { subtitlesChangeButton.appendChild(subtitlesChangeList); - subtitlesChangeButton.addEventListener('click', function () { - playerInstance.openCloseSubtitlesSwitch(); - }); + subtitlesChangeButton.removeEventListener('click', handleSubtitlesChange); + subtitlesChangeButton.addEventListener('click', handleSubtitlesChange); } else { // Didn't give any subtitle options - document.getElementById(playerInstance.videoPlayerId + '_fluid_control_subtitles').style.display = 'none'; + playerInstance.domRef.wrapper.querySelector('.fluid_control_subtitles').style.display = 'none'; } - //attach subtitles to show based on time - //this function is for rendering of subtitles when content is playing - const videoPlayerSubtitlesUpdate = function () { - playerInstance.renderSubtitles(); - }; - + playerInstance.domRef.player.removeEventListener('timeupdate', videoPlayerSubtitlesUpdate); playerInstance.domRef.player.addEventListener('timeupdate', videoPlayerSubtitlesUpdate); }; + function handleSubtitlesChange(event) { + const subtitlesChangeButton = playerInstance.domRef.wrapper.querySelector('.fluid_control_subtitles'); + let hasOpenMenuOrSubMenu = false; + + // Check if menu or any submenu is open + for (let i = 0; i < subtitlesChangeButton.children.length; i++) { + if (subtitlesChangeButton.children[i].style.display === 'block') { + hasOpenMenuOrSubMenu = true; + subtitlesChangeButton.children[i].style.display = 'none'; + } + } + + // Only open subtitle menu if no menu or submenu (direct child) is visible + if (!hasOpenMenuOrSubMenu) { + playerInstance.openCloseSubtitlesSwitch(); + } + } + + //attach subtitles to show based on time + //this function is for rendering of subtitles when content is playing + function videoPlayerSubtitlesUpdate() { + playerInstance.renderSubtitles(); + } + + /** + * Creates and appends the subtitle size button and menu + * + * @param {HTMLElement} subtitlesChangeList The container for the subtitles change list + * @param {HTMLElement} subtitlesChangeButton The button to toggle the subtitles menu + */ + playerInstance.addSubtitleSizeControls = (subtitlesChangeList, subtitlesChangeButton) => { + const subtitlesSizeButton = playerInstance.createSubtitleSizeButton(); + const subtitlesSizeMenu = playerInstance.createSubtitleSizeMenu(); + subtitlesSizeMenu.style.display = 'none'; + subtitlesChangeList.appendChild(subtitlesSizeButton); + subtitlesChangeButton.appendChild(subtitlesSizeMenu); + }; + playerInstance.renderSubtitles = () => { const videoPlayer = playerInstance.domRef.player; //if content is playing then no subtitles let currentTime = Math.floor(videoPlayer.currentTime); let subtitlesAvailable = false; - let subtitlesContainer = document.getElementById(playerInstance.videoPlayerId + '_fluid_subtitles_container'); + let subtitlesContainer = playerInstance.domRef.wrapper.querySelector('.fluid_subtitles_container'); if (playerInstance.isCurrentlyPlayingAd) { subtitlesContainer.innerHTML = ''; @@ -176,7 +223,7 @@ export default function (playerInstance, options) { }; playerInstance.openCloseSubtitlesSwitch = () => { - const subtitleChangeList = document.getElementById(playerInstance.videoPlayerId + '_fluid_control_subtitles_list'); + const subtitleChangeList = playerInstance.domRef.wrapper.querySelector('.fluid_subtitles_list'); if (playerInstance.isCurrentlyPlayingAd) { subtitleChangeList.style.display = 'none'; @@ -193,11 +240,12 @@ export default function (playerInstance, options) { } else { subtitleChangeList.style.display = 'none'; } + + playerInstance.setMenusMaxHeight(); }; playerInstance.createSubtitles = () => { const divSubtitlesContainer = document.createElement('div'); - divSubtitlesContainer.id = playerInstance.videoPlayerId + '_fluid_subtitles_container'; divSubtitlesContainer.className = 'fluid_subtitles_container'; playerInstance.domRef.player.parentNode.insertBefore(divSubtitlesContainer, playerInstance.domRef.player.nextSibling); @@ -206,8 +254,185 @@ export default function (playerInstance, options) { } import(/* webpackChunkName: "vttjs" */ 'videojs-vtt.js').then((it) => { - window.WebVTT = it.WebVTT; + window.WebVTT = it.WebVTT || it.default.WebVTT; playerInstance.createSubtitlesSwitch(); }); }; + + /** + * Resize the subtitles font size + * + * @param size size in percentage, 100% is the default size + */ + playerInstance.resizeSubtitles = (size) => { + const subtitlesContainer = playerInstance.domRef.wrapper.querySelector('.fluid_subtitles_container'); + const fontSizeInEm = size / 100; // Convert percentage to em + subtitlesContainer.style.fontSize = `${fontSizeInEm}em`; + } + + /** + * Adjusts the subtitle size to the next or previous level. + * + * @param {'+'|'-'} operation '+' to increase, '-' to decrease + */ + playerInstance.adjustSubtitleSizeByOperation = (operation) => { + // Validate the operation and the current subtitle size index + if (operation === '+' && playerInstance.currentSubtitleSizeIndex < playerInstance.subtitleSizeLevels.length - 1) { + playerInstance.currentSubtitleSizeIndex++; + } else if (operation === '-' && playerInstance.currentSubtitleSizeIndex > 0) { + playerInstance.currentSubtitleSizeIndex--; + } else { + return; // invalid operation + } + + const newSize = playerInstance.subtitleSizeLevels[playerInstance.currentSubtitleSizeIndex]; + playerInstance.resizeSubtitles(newSize); + + // Update the checkmark in the subtitle size menu + playerInstance.updateSubtitleSizeMenuSelection(); + }; + + /** + * Updates the subtitle size menu to show the correct checkmark + * based on the current subtitle size index + */ + playerInstance.updateSubtitleSizeMenuSelection = () => { + const sizeMenu = playerInstance.domRef.wrapper.querySelector('.fluid_subtitle_size_menu'); + if (!sizeMenu) return; + + // Clear all existing checkmarks + const sizeIcons = sizeMenu.querySelectorAll('.subtitle_button_icon'); + for (let i = 0; i < sizeIcons.length; i++) { + sizeIcons[i].className = sizeIcons[i].className.replace("subtitle_size_selected", ""); + } + + // Get all size buttons (skip the back button) + const sizeButtons = Array.from(sizeMenu.querySelectorAll('.fluid_subtitle_size_button')) + .filter(button => !button.classList.contains('fluid_sub_menu_button')); + + // Add the checkmark to the current size button if it exists + if (sizeButtons[playerInstance.currentSubtitleSizeIndex]) { + const icon = sizeButtons[playerInstance.currentSubtitleSizeIndex].querySelector('.subtitle_button_icon'); + if (icon) { + icon.className += ' subtitle_size_selected'; + } + } + }; + + /** + * Creates the subtitle size button element + * + * @returns {HTMLElement} The subtitle size button element + */ + playerInstance.createSubtitleSizeButton = () => { + const subtitleSizeMenuButton = document.createElement('div'); + subtitleSizeMenuButton.className = 'fluid_subtitle_list_item fluid_sub_menu_button arrow-right'; + subtitleSizeMenuButton.innerHTML = 'Font Size'; + + subtitleSizeMenuButton.addEventListener('click', (event) => { + event.stopPropagation(); + playerInstance.toggleSubtitleSizeMenu(); + playerInstance.openCloseSubtitlesSwitch(); + }); + + return subtitleSizeMenuButton; + } + + /** + * Creates the subtitle size menu element + * + * @returns {HTMLElement} The subtitle size menu element + */ + playerInstance.createSubtitleSizeMenu = () => { + const subtitleSizeMenu = document.createElement('div'); + subtitleSizeMenu.className = 'fluid_subtitle_size_menu'; + + // Add the "Back" button + const backButton = playerInstance.createBackButtonForSubtitleSizeMenu(); + subtitleSizeMenu.appendChild(backButton); + + // Add the font size options + playerInstance.subtitleSizeLevels.forEach((size, index) => { + const subtitleSizeButton = document.createElement('div'); + subtitleSizeButton.className = 'fluid_subtitle_size_button'; + + const isSelected = size === 100; // Default size is 100% + subtitleSizeButton.innerHTML = '' + size + '%'; + + subtitleSizeButton.addEventListener('click', (event) => { + // Stop the click event from propagating to parent elements, + // preventing unintended actions like toggling the entire menu. + event.stopPropagation(); + playerInstance.toggleSubtitleSizeMenu(); + playerInstance.resizeSubtitles(size); + playerInstance.currentSubtitleSizeIndex = index; + + // Update the checkmark for all size buttons + playerInstance.updateSubtitleSizeMenuSelection(); + }); + + subtitleSizeMenu.appendChild(subtitleSizeButton); + }); + + return subtitleSizeMenu; + }; + + /** + * Creates a "Back" button for the subtitle size menu. + */ + playerInstance.createBackButtonForSubtitleSizeMenu = () => { + const backButton = document.createElement('div'); + backButton.className = 'fluid_subtitle_size_button fluid_sub_menu_button arrow-left'; + backButton.innerHTML = 'Font Size'; + + backButton.addEventListener('click', (event) => { + event.stopPropagation(); + playerInstance.toggleSubtitleSizeMenu(); + playerInstance.openCloseSubtitlesSwitch(); + }); + + return backButton; + }; + + /** + * Toggles the visibility of the subtitle size menu + */ + playerInstance.toggleSubtitleSizeMenu = () => { + const subtitleSizeMenu = playerInstance.domRef.wrapper.querySelector('.fluid_subtitle_size_menu'); + + const mouseOut = function (event) { + subtitleSizeMenu.removeEventListener('mouseleave', mouseOut); + subtitleSizeMenu.style.display = 'none'; + }; + + if (subtitleSizeMenu.style.display === 'block') { + subtitleSizeMenu.style.display = 'none'; + subtitleSizeMenu.removeEventListener('mouseleave', mouseOut); + } else { + subtitleSizeMenu.style.display = 'block'; + subtitleSizeMenu.addEventListener('mouseleave', mouseOut); + } + + playerInstance.setMenusMaxHeight(); + }; + + /** + * Calculate and set the max height of the all menus for small screens + */ + playerInstance.setMenusMaxHeight = () => { + const playerHeight = playerInstance.domRef.wrapper.offsetHeight; + const controlsHeight = playerInstance.domRef.wrapper.querySelector('.fluid_controls_container').offsetHeight; + + const maxHeight = playerHeight - controlsHeight; + const subtitleSizeMenu = playerInstance.domRef.wrapper.querySelector('.fluid_subtitle_size_menu'); + const subtitleChangeList = playerInstance.domRef.wrapper.querySelector('.fluid_subtitles_list'); + + subtitleSizeMenu.style.maxHeight = `${maxHeight}px`; + subtitleChangeList.style.maxHeight = `${maxHeight}px`; + }; + + playerInstance.repositionSubtitlesContainer = (size) => { + const subtitlesContainer = playerInstance.domRef.wrapper.querySelector('.fluid_subtitles_container'); + subtitlesContainer.style.bottom = size; + }; } diff --git a/src/modules/suggestedVideos.js b/src/modules/suggestedVideos.js new file mode 100644 index 00000000..7f3ab8ae --- /dev/null +++ b/src/modules/suggestedVideos.js @@ -0,0 +1,197 @@ +export default function (playerInstance, options) { + playerInstance.suggestedVideosGrid = null; + + playerInstance.generateSuggestedVideoList = () => { + const configUrl = playerInstance.displayOptions.suggestedVideos.configUrl; + + if (!configUrl) { + return; + } + + playerInstance.sendRequestAsync(configUrl, false, 5000).then(({response}) => { + const config = JSON.parse(response); + const suggestedVideosGrid = playerInstance.generateSuggestedVideoGrid(config); + + playerInstance.suggestedVideosGrid = suggestedVideosGrid; + + }).catch(err => { + console.error('[FP_ERROR] given suggested videos config url is invalid or not found.', err); + }) + }; + + playerInstance.generateSuggestedVideoGrid = (config) => { + const suggestedVideosGrid = document.createElement('div'); + suggestedVideosGrid.className = 'suggested_tile_grid'; + + for (let i = 0; i < 12; i++) { + const videoTile = playerInstance.createVideoTile(config[i]); + suggestedVideosGrid.appendChild(videoTile); + } + + return suggestedVideosGrid; + }; + + playerInstance.displaySuggestedVideos = () => { + const PlayerDOM = playerInstance.domRef.wrapper; + PlayerDOM.appendChild(playerInstance.suggestedVideosGrid); + }; + + playerInstance.clickSuggestedVideo = (sources, subtitles, configUrl) => { + playerInstance.toggleLoader(true); + playerInstance.hideSuggestedVideos(); + playerInstance.resetPlayer(sources, subtitles, configUrl); + + playerInstance.generateSuggestedVideoList(); + }; + + playerInstance.resetPlayer = (sources, subtitles, configUrl) => { + const videoDOM = playerInstance.domRef.wrapper.querySelector(`#${playerInstance.videoPlayerId}`); + videoDOM.innerHTML = ''; + + let sourcesHTML = ''; + if (sources) { + sources.forEach(source => { + sourcesHTML += ``; + }); + } + if (subtitles) { + subtitles.forEach(subtitle => { + sourcesHTML += ``; + }); + } + + videoDOM.innerHTML = sourcesHTML; + + playerInstance.removeVideoSourcesListFromDOM(); + + const videoSourceList = playerInstance.domRef.wrapper.getElementsByClassName('fluid_control_video_source')[0]; + videoSourceList.innerHTML = ''; + playerInstance.domRef.player.src = ''; + playerInstance.domRef.player.removeAttribute('src'); + playerInstance.setVideoSource(sources[0].url); + playerInstance.createVideoSourceSwitch(false); + playerInstance.resetSubtitles(); + playerInstance.setPersistentSettings(true); + playerInstance.loadInNewVideo(); + playerInstance.resetAds(); + + // set new API config url + if (configUrl) { + playerInstance.displayOptions.suggestedVideos.configUrl = configUrl; + playerInstance.generateSuggestedVideoList(); + } + } + + playerInstance.createVideoTile = (config) => { + const videoTile = document.createElement('div'); + videoTile.addEventListener('click', function () { + playerInstance.clickSuggestedVideo(config.sources, config.subtitles, config.configUrl); + }, false); + videoTile.className = 'suggested_tile'; + videoTile.id = 'suggested_tile_' + config.id; + + playerInstance.getImageTwoMostProminentColours(config.thumbnailUrl).then(mostProminentColors => { + if (mostProminentColors && mostProminentColors.length) { + videoTile.style = `background: ${mostProminentColors[0]};`; + } + }); + + const videoImage = document.createElement('img'); + videoImage.src = config.thumbnailUrl; + videoImage.className = 'suggested_tile_image'; + + const videoTileOverlay = document.createElement('div'); + videoTileOverlay.className='suggested_tile_overlay'; + const title = document.createElement('p'); + title.className = 'suggested_tile_title'; + title.innerText = config.title; + videoTileOverlay.appendChild(title); + + videoTile.appendChild(videoImage); + videoTile.appendChild(videoTileOverlay); + + return videoTile; + } + + playerInstance.resetSubtitles = () => { + playerInstance.removeSubtitlesListFromDOM(); + const videoSubtitlesList = playerInstance.domRef.wrapper.getElementsByClassName('fluid_control_subtitles')[0]; + videoSubtitlesList.innerHTML = ''; + playerInstance.domRef.player.load(); + playerInstance.createSubtitles(false); + } + + playerInstance.loadInNewVideo = () => { + playerInstance.displayOptions.layoutControls.mediaType = playerInstance.getCurrentSrcType(); + playerInstance.initialiseStreamers(); + playerInstance.domRef.player.currentTime = 0; + playerInstance.domRef.player.mainVideoCurrentTime = 0; + playerInstance.setBuffering(); + } + + playerInstance.resetAds = () => { + // Clear midroll and postroll ads + playerInstance.timerPool = {}; + playerInstance.rollsById = {}; + playerInstance.adPool = {}; + playerInstance.adGroupedByRolls = {}; + playerInstance.onPauseRollAdPods = []; + playerInstance.currentOnPauseRollAd = ''; + + // Reset variables and flags, needed for assigning the different rolls correctly + playerInstance.isTimer = false; + playerInstance.timer = null; + playerInstance.firstPlayLaunched = false; + + // Clear preroll ads + playerInstance.preRollAdsResolved = false; + playerInstance.preRollAdPods = []; + playerInstance.preRollAdPodsLength = 0; + playerInstance.preRollVastResolved = 0; + playerInstance.autoplayAfterAd = true; + + // Wait until new selected video is buffered so we can get the video length + // This is needed for mid and post rolls to assign the correct time key to their respective triggers + const checkMainVideoDuration = () => { + if (!isNaN(playerInstance.domRef.player.duration) && playerInstance.domRef.player.duration > 0) { + playerInstance.toggleLoader(true); + playerInstance.mainVideoDuration = playerInstance.domRef.player.duration; + + clearInterval(intervalId); + + // Set up ads + playerInstance.setVastList(); + playerInstance.checkForNextAd(); + playerInstance.playPauseToggle(); + } + }; + + const intervalId = setInterval(checkMainVideoDuration, 100); + } + + playerInstance.removeVideoSourcesListFromDOM = () => { + const sourcesDOM = playerInstance.domRef.wrapper.getElementsByClassName('fluid_video_source_list_item'); + for (let i = 0; i < sourcesDOM.length; i++) { + sourcesDOM[i].remove(); + } + }; + + playerInstance.removeSubtitlesListFromDOM = () => { + const tracksDOM = playerInstance.domRef.wrapper.getElementsByClassName('fluid_subtitle_list_item'); + for (let i = 0; i < tracksDOM.length; i++) { + tracksDOM[i].remove(); + } + }; + + playerInstance.hideSuggestedVideos = () => { + const suggestedVideosDOM = playerInstance.domRef.wrapper.getElementsByClassName('suggested_tile_grid')[0]; + if (suggestedVideosDOM) { + suggestedVideosDOM.remove(); + } + }; + + playerInstance.isShowingSuggestedVideos = () => { + return !!playerInstance.domRef.wrapper.getElementsByClassName('suggested_tile_grid')[0]; + } + +} diff --git a/src/modules/timeline.js b/src/modules/timeline.js index 05979228..2d2a47c7 100644 --- a/src/modules/timeline.js +++ b/src/modules/timeline.js @@ -1,9 +1,8 @@ -'use strict'; export default function (playerInstance, options) { playerInstance.setupThumbnailPreviewVtt = () => { playerInstance.sendRequest( playerInstance.displayOptions.layoutControls.timelinePreview.file, - true, + false, playerInstance.displayOptions.vastOptions.vastTimeout, function () { const convertVttRawData = function (vttRawData) { @@ -86,10 +85,9 @@ export default function (playerInstance, options) { }; playerInstance.generateTimelinePreviewTags = () => { - const progressContainer = document.getElementById(playerInstance.videoPlayerId + '_fluid_controls_progress_container'); + const progressContainer = playerInstance.domRef.wrapper.querySelector('.fluid_controls_progress_container'); const previewContainer = document.createElement('div'); - previewContainer.id = playerInstance.videoPlayerId + '_fluid_timeline_preview_container'; previewContainer.className = 'fluid_timeline_preview_container'; previewContainer.style.display = 'none'; previewContainer.style.position = 'absolute'; @@ -98,7 +96,6 @@ export default function (playerInstance, options) { //Shadow is needed to not trigger mouseleave event, that stops showing thumbnails, in case one scrubs a bit too fast and leaves current thumb before new one drawn. const previewContainerShadow = document.createElement('div'); - previewContainerShadow.id = playerInstance.videoPlayerId + '_fluid_timeline_preview_container_shadow'; previewContainerShadow.className = 'fluid_timeline_preview_container_shadow'; previewContainerShadow.style.position = 'absolute'; previewContainerShadow.style.display = 'none'; @@ -119,9 +116,9 @@ export default function (playerInstance, options) { }; playerInstance.drawTimelinePreview = (event) => { - const timelinePreviewTag = document.getElementById(playerInstance.videoPlayerId + '_fluid_timeline_preview_container'); - const timelinePreviewShadow = document.getElementById(playerInstance.videoPlayerId + '_fluid_timeline_preview_container_shadow'); - const progressContainer = document.getElementById(playerInstance.videoPlayerId + '_fluid_controls_progress_container'); + const timelinePreviewTag = playerInstance.domRef.wrapper.querySelector('.fluid_timeline_preview_container'); + const timelinePreviewShadow = playerInstance.domRef.wrapper.querySelector('.fluid_timeline_preview_container_shadow'); + const progressContainer = playerInstance.domRef.wrapper.querySelector('.fluid_controls_progress_container'); const totalWidth = progressContainer.clientWidth; if (playerInstance.isCurrentlyPlayingAd) { @@ -174,17 +171,17 @@ export default function (playerInstance, options) { eventOn = 'touchmove'; eventOff = 'touchend'; } - document.getElementById(playerInstance.videoPlayerId + '_fluid_controls_progress_container') + playerInstance.domRef.wrapper.querySelector('.fluid_controls_progress_container') .addEventListener(eventOn, playerInstance.drawTimelinePreview.bind(playerInstance), false); - document.getElementById(playerInstance.videoPlayerId + '_fluid_controls_progress_container') + playerInstance.domRef.wrapper.querySelector('.fluid_controls_progress_container') .addEventListener(eventOff, function (event) { - const progress = document.getElementById(playerInstance.videoPlayerId + '_fluid_controls_progress_container'); + const progress = playerInstance.domRef.wrapper.querySelector('.fluid_controls_progress_container'); if (typeof event.clientX !== 'undefined' && progress.contains(document.elementFromPoint(event.clientX, event.clientY))) { //False positive (Chrome bug when fast click causes leave event) return; } - document.getElementById(playerInstance.videoPlayerId + '_fluid_timeline_preview_container').style.display = 'none'; - document.getElementById(playerInstance.videoPlayerId + '_fluid_timeline_preview_container_shadow').style.display = 'none'; + playerInstance.domRef.wrapper.querySelector('.fluid_timeline_preview_container').style.display = 'none'; + playerInstance.domRef.wrapper.querySelector('.fluid_timeline_preview_container_shadow').style.display = 'none'; }, false); playerInstance.generateTimelinePreviewTags(); diff --git a/src/modules/utils.js b/src/modules/utils.js index a97b70d0..5b63bb39 100644 --- a/src/modules/utils.js +++ b/src/modules/utils.js @@ -1,4 +1,3 @@ -'use strict'; export default function (playerInstance, options) { playerInstance.isTouchDevice = () => { return !!('ontouchstart' in window // works on most browsers @@ -165,4 +164,163 @@ export default function (playerInstance, options) { } return value; }; + + /** + * Checks if element is fully visible in the viewport + * + * @param {Element} element + * @returns {boolean|null} + */ + playerInstance.isElementVisible = (element) => { + if (!element) { return null; } + + const rect = element.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); + } + + playerInstance.observe = () => { + var observer = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.intersectionRatio >= 0.5) { + playerInstance.domRef.player.inView = true; + } + + if (entry.intersectionRatio == 0 && entry.target.glast) { + playerInstance.domRef.player.inView = false; + } + }); + }, + { + threshold: [0.0, 0.5], + }, + ); + + observer.observe(playerInstance.domRef.wrapper); + } + + /** + * Throttles callback by time + * + * @param callback + * @param time + * @returns {function(): void} + */ + playerInstance.throttle = function throttle(callback, time) { + let throttleControl = false; + + return function () { + if (!throttleControl) { + callback.apply(this, arguments); + throttleControl = true; + setTimeout(function () { + throttleControl = false; + }, time); + } + } + } + + playerInstance.getImageTwoMostProminentColours = (imageUrl) => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'Anonymous'; + img.src = imageUrl; + + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0, img.width, img.height); + const imageData = ctx.getImageData(0, 0, img.width, img.height).data; + + const colorCount = {}; + for (let i = 0; i < imageData.length; i += 4) { + const r = imageData[i]; + const g = imageData[i + 1]; + const b = imageData[i + 2]; + const color = `rgb(${r},${g},${b})`; + + if (colorCount[color]) { + colorCount[color]++; + } else { + colorCount[color] = 1; + } + } + + const rgbToHsl = (r, g, b) => { + r /= 255, g /= 255, b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + + return [h, s, l]; + }; + + const sortedColors = Object.keys(colorCount).map(color => { + const [r, g, b] = color.match(/\d+/g).map(Number); + const [h, s, l] = rgbToHsl(r, g, b); + return { color, h, s, l, score: s + (1 - Math.abs(2 * l - 1)) }; + }).sort((a, b) => b.score - a.score); + + const isCloseToBlack = (color) => { + const rgb = color.match(/\d+/g).map(Number); + const blackThreshold = 40; + return rgb[0] < blackThreshold && rgb[1] < blackThreshold && rgb[2] < blackThreshold; + }; + + const minHueDifference = 0.1; + const minSaturationDifference = 0.1; + const minLightnessDifference = 0.1; + + let mostVibrantColors = []; + + for (const colorObj of sortedColors) { + if (mostVibrantColors.length === 2) break; + if (!isCloseToBlack(colorObj.color)) { + const enoughDifference = mostVibrantColors.every(existingColor => { + const hueDifference = Math.abs(colorObj.h - existingColor.h); + const saturationDifference = Math.abs(colorObj.s - existingColor.s); + const lightnessDifference = Math.abs(colorObj.l - existingColor.l); + return ( + hueDifference >= minHueDifference && + saturationDifference >= minSaturationDifference && + lightnessDifference >= minLightnessDifference + ); + }); + if (enoughDifference) { + mostVibrantColors.push(colorObj); + } + } + } + + if (mostVibrantColors.length < 2) { + mostVibrantColors = sortedColors.slice(0, 2); + } + + resolve(mostVibrantColors.map(colorObj => colorObj.color)); + }; + + img.onerror = () => { + reject(new Error('Failed to load image')); + }; + }); + } } diff --git a/src/modules/vast.js b/src/modules/vast.js index 10436131..5241c2f2 100644 --- a/src/modules/vast.js +++ b/src/modules/vast.js @@ -1,6 +1,61 @@ // VAST support module -'use strict'; + +import { displayModes, trackingEventTypes } from '../constants/constants'; + +/* Type declarations */ + +/** + * @typedef {Object} RawAdTree + * @property {Array} children + * @property {XMLDocument} data + * @property {'inLine'|'wrapper'} tagType + * @property {boolean|undefined} fallbackOnNoAd + * @property {Array | undefined} wrappers +*/ + +/** + * @typedef {Object} RawAd + * @property {XMLDocument} data + * @property {Array} wrappers + * @property {'inLine' | 'wrapper'} tagType + */ + +/** + * @typedef {Object & RawAd} Ad + * @property {Array} clicktracking + * @property {string} errorUrl + * @property {Array} impressions + * @property {Array} viewImpression + * @property {Array} stopTracking + * @property {Array} tracking + * @property {number|null} sequence + * @property {number} duration + * @property {boolean} played + */ + export default function (playerInstance, options) { + const { + muteEvent, + unmuteEvent, + pauseEvent, + resumeEvent, + rewindEvent, + fullscreenEvent, + skipEvent, + playerExpandEvent, + playerCollapseEvent, + startEvent, + firstQuartileEvent, + midpointEvent, + thirdQuartileEvent, + completeEvent, + progressEvent, + creativeViewEvent, + collapseEvent, + adCollapseEvent, + closeEvent, + acceptInvitationEvent + } = trackingEventTypes; /** * Gets CTA parameters from VAST and sets them on tempOptions * @@ -14,17 +69,19 @@ export default function (playerInstance, options) { if (playerInstance.displayOptions.vastOptions.adCTATextVast && titleCtaElement) { const mobileText = playerInstance.extractNodeDataByTagName(titleCtaElement, 'MobileText'); const desktopText = playerInstance.extractNodeDataByTagName(titleCtaElement, 'PCText'); - const link = playerInstance.extractNodeDataByTagName(titleCtaElement, 'Link'); + const link = + playerInstance.extractNodeDataByTagName(titleCtaElement, 'DisplayUrl') || + playerInstance.extractNodeDataByTagName(titleCtaElement, 'Link'); const tracking = playerInstance.extractNodeDataByTagName(titleCtaElement, 'Tracking'); const isMobile = window.matchMedia('(max-width: 768px)').matches; - if ((desktopText || mobileText) && (link || tracking)) { + if ((desktopText || mobileText) && tracking) { tmpOptions.titleCTA = { text: isMobile ? mobileText || desktopText : desktopText || mobileText, - link: link || tracking, - tracking: tracking || link + link: link || null, + tracking, } } } @@ -82,7 +139,7 @@ export default function (playerInstance, options) { return result; }; - playerInstance.getTrackingFromLinear = (linear) => { + playerInstance.getTrackingFromCreative = (linear) => { const trackingEvents = linear.getElementsByTagName('TrackingEvents'); if (trackingEvents.length) {//There should be no more than one node @@ -265,38 +322,38 @@ export default function (playerInstance, options) { return fallbackStaticResource; }; - playerInstance.registerTrackingEvents = (creativeLinear, tmpOptions) => { - const trackingEvents = playerInstance.getTrackingFromLinear(creativeLinear); + playerInstance.registerTrackingEvents = (creative, tmpOptions) => { + const trackingEvents = playerInstance.getTrackingFromCreative(creative); let eventType = ''; let oneEventOffset = 0; for (let i = 0; i < trackingEvents.length; i++) { eventType = trackingEvents[i].getAttribute('event'); - switch (eventType) { - case 'start': - case 'firstQuartile': - case 'midpoint': - case 'thirdQuartile': - case 'complete': - if (typeof tmpOptions.tracking[eventType] === 'undefined') { - tmpOptions.tracking[eventType] = []; - } + if (typeof tmpOptions.tracking[eventType] === 'undefined') { + tmpOptions.tracking[eventType] = []; + } + switch (eventType) { + case startEvent: + case firstQuartileEvent: + case midpointEvent: + case thirdQuartileEvent: + case completeEvent: + case closeEvent: + case skipEvent: + case acceptInvitationEvent: if (typeof tmpOptions.stopTracking[eventType] === 'undefined') { tmpOptions.stopTracking[eventType] = []; } - tmpOptions.tracking[eventType].push(trackingEvents[i].childNodes[0].nodeValue); + tmpOptions.tracking[eventType].push(trackingEvents[i].textContent.trim()); tmpOptions.stopTracking[eventType] = false; break; - case 'progress': - if (typeof tmpOptions.tracking[eventType] === 'undefined') { - tmpOptions.tracking[eventType] = []; - } - - oneEventOffset = playerInstance.convertTimeStringToSeconds(trackingEvents[i].getAttribute('offset')); + case progressEvent: + case creativeViewEvent: + oneEventOffset = playerInstance.convertTimeStringToSeconds(trackingEvents[i].getAttribute('offset')) || 0; if (typeof tmpOptions.tracking[eventType][oneEventOffset] === 'undefined') { tmpOptions.tracking[eventType][oneEventOffset] = { @@ -305,8 +362,21 @@ export default function (playerInstance, options) { }; } - tmpOptions.tracking[eventType][oneEventOffset].elements.push(trackingEvents[i].childNodes[0].nodeValue); + tmpOptions.tracking[eventType][oneEventOffset].elements.push(trackingEvents[i].textContent.trim()); + + break; + case muteEvent: + case unmuteEvent: + case pauseEvent: + case resumeEvent: + case rewindEvent: + case fullscreenEvent: + case playerExpandEvent: + case playerCollapseEvent: + case collapseEvent: + case adCollapseEvent: + tmpOptions.tracking[eventType].push(trackingEvents[i].textContent.trim()); break; default: @@ -330,6 +400,17 @@ export default function (playerInstance, options) { }; + playerInstance.registerViewableImpressionEvents = (viewableImpressionTags, tmpOptions) => { + if (!viewableImpressionTags.length) { + return; + } + + for (let i = 0; i < viewableImpressionTags.length; i++) { + const viewableImpressionEvent = playerInstance.extractNodeData(viewableImpressionTags[i]); + tmpOptions.viewImpression.push(viewableImpressionEvent); + } + }; + playerInstance.registerImpressionEvents = (impressionTags, tmpOptions) => { if (!impressionTags.length) { return; @@ -416,17 +497,17 @@ export default function (playerInstance, options) { }; playerInstance.recalculateAdDimensions = () => { - const videoPlayer = document.getElementById(playerInstance.videoPlayerId); - const divClickThrough = document.getElementById('vast_clickthrough_layer_' + playerInstance.videoPlayerId); + const videoPlayer = playerInstance.domRef.player; + const divClickThrough = playerInstance.domRef.wrapper.querySelector('.vast_clickthrough_layer'); if (divClickThrough) { divClickThrough.style.width = videoPlayer.offsetWidth + 'px'; divClickThrough.style.height = videoPlayer.offsetHeight + 'px'; } - const requestFullscreenFunctionNames = playerInstance.checkFullscreenSupport('fluid_video_wrapper_' + playerInstance.videoPlayerId); - const fullscreenButton = document.getElementById(playerInstance.videoPlayerId + '_fluid_control_fullscreen'); - const menuOptionFullscreen = document.getElementById(playerInstance.videoPlayerId + 'context_option_fullscreen'); + const requestFullscreenFunctionNames = playerInstance.checkFullscreenSupport(); + const fullscreenButton = playerInstance.domRef.wrapper.querySelector('.fluid_control_fullscreen'); + const menuOptionFullscreen = playerInstance.domRef.wrapper.querySelector('.context_option_fullscreen'); if (requestFullscreenFunctionNames) { // this will go other way around because we already exited full screen @@ -440,7 +521,7 @@ export default function (playerInstance, options) { } else { // TODO: I am fairly certain this fallback does not work... //The browser does not support the Fullscreen API, so a pseudo-fullscreen implementation is used - const fullscreenTag = document.getElementById('fluid_video_wrapper_' + playerInstance.videoPlayerId); + const fullscreenTag = playerInstance.domRef.wrapper; if (fullscreenTag.className.search(/\bpseudo_fullscreen\b/g) !== -1) { fullscreenTag.className += ' pseudo_fullscreen'; @@ -452,22 +533,25 @@ export default function (playerInstance, options) { } }; + /** + * Prepares VAST for instant ads + * + * @param roll + */ playerInstance.prepareVast = (roll) => { let list = playerInstance.findRoll(roll); for (let i = 0; i < list.length; i++) { - const adListId = list[i]; + const rollListId = list[i]; - if (!(playerInstance.adList[adListId].vastLoaded !== true && playerInstance.adList[adListId].error !== true)) { + if (!(playerInstance.rollsById[rollListId].vastLoaded !== true && playerInstance.rollsById[rollListId].error !== true)) { continue; } - playerInstance.processVastWithRetries(playerInstance.adList[adListId]); - playerInstance.domRef.player.addEventListener('adId_' + adListId, playerInstance[roll]); + playerInstance.processVastWithRetries(playerInstance.rollsById[rollListId]); } }; - playerInstance.playMainVideoWhenVastFails = (errorCode) => { playerInstance.debugMessage('playMainVideoWhenVastFails called'); playerInstance.domRef.player.removeEventListener('loadedmetadata', playerInstance.switchPlayerToVastMode); @@ -491,191 +575,185 @@ export default function (playerInstance, options) { /** * Process the XML response * - * @param xmlResponse - * @param tmpOptions - * @param callBack + * @param ad */ - playerInstance.processVastXml = (xmlResponse, tmpOptions, callBack) => { - let clickTracks; + function processAdCreatives(ad) { + const adElement = ad.data; - if (!xmlResponse) { - callBack(false); + if (!adElement) { return; } - //Get impression tag - const impression = xmlResponse.getElementsByTagName('Impression'); - if (impression !== null) { - playerInstance.registerImpressionEvents(impression, tmpOptions); - } - - //Get the error tag, if any - const errorTags = xmlResponse.getElementsByTagName('Error'); - if (errorTags !== null) { - playerInstance.registerErrorEvents(errorTags, tmpOptions); - } - - // Sets CTA from vast - const titleCta = xmlResponse.getElementsByTagName('TitleCTA'); - if (titleCta !== null && titleCta.length) { - playerInstance.setCTAFromVast(titleCta[0], tmpOptions); - } - - //Get Creative - const creative = xmlResponse.getElementsByTagName('Creative'); - - //Currently only 1 creative and 1 linear is supported - if ((typeof creative !== 'undefined') && creative.length) { - const arrayCreativeLinears = creative[0].getElementsByTagName('Linear'); - - if ((typeof arrayCreativeLinears !== 'undefined') && (arrayCreativeLinears !== null) && arrayCreativeLinears.length) { - - const creativeLinear = arrayCreativeLinears[0]; - playerInstance.registerTrackingEvents(creativeLinear, tmpOptions); - - clickTracks = playerInstance.getClickTrackingEvents(creativeLinear); - playerInstance.registerClickTracking(clickTracks, tmpOptions); - - //Extract the Ad data if it is actually the Ad (!wrapper) - if (!playerInstance.hasVastAdTagUri(xmlResponse) && playerInstance.hasInLine(xmlResponse)) { - - //Set initial values - tmpOptions.adFinished = false; - tmpOptions.adType = 'linear'; - tmpOptions.vpaid = false; - - //Extract the necessary data from the Linear node - tmpOptions.skipoffset = playerInstance.convertTimeStringToSeconds(creativeLinear.getAttribute('skipoffset')); - tmpOptions.clickthroughUrl = playerInstance.getClickThroughUrlFromLinear(creativeLinear); - tmpOptions.duration = playerInstance.getDurationFromLinear(creativeLinear); - tmpOptions.mediaFileList = playerInstance.getMediaFileListFromLinear(creativeLinear); - tmpOptions.adParameters = playerInstance.getAdParametersFromLinear(creativeLinear); - tmpOptions.iconClick = tmpOptions.iconClick || playerInstance.getIconClickThroughFromLinear(creativeLinear); - - if (tmpOptions.adParameters) { - tmpOptions.vpaid = true; + const creativeElements = Array.from(adElement.getElementsByTagName('Creative')); + + if (creativeElements.length) { + for (let i = 0; i < creativeElements.length; i++) { + const creativeElement = creativeElements[i]; + + try { + if (ad.adType === 'linear') { + const linearCreatives = creativeElement.getElementsByTagName('Linear'); + const creativeLinear = linearCreatives[0]; + + //Extract the Ad data if it is actually the Ad (!wrapper) + if (!playerInstance.hasVastAdTagUri(adElement) && playerInstance.hasInLine(adElement)) { + //Set initial values + ad.adFinished = false; + ad.vpaid = false; + + //Extract the necessary data from the Linear node + ad.skipoffset = playerInstance.convertTimeStringToSeconds(creativeLinear.getAttribute('skipoffset')); + ad.clickthroughUrl = playerInstance.getClickThroughUrlFromLinear(creativeLinear); + ad.duration = playerInstance.getDurationFromLinear(creativeLinear); + ad.mediaFileList = playerInstance.getMediaFileListFromLinear(creativeLinear); + ad.adParameters = playerInstance.getAdParametersFromLinear(creativeLinear); + ad.iconClick = ad.iconClick || playerInstance.getIconClickThroughFromLinear(creativeLinear); + + if (ad?.mediaFileList?.length && ad.mediaFileList[0]?.apiFramework === 'VPAID') { + ad.vpaid = true; + } else if (!ad?.mediaFileList?.length && ad?.adParameters) { + // Inject adParameters media as mediaFileList + // In VAST, it's also allowed to provide the media in AdParameters, instead of MediaFiles. Although, not industry standard + // This is a workaround, since the player expects VAST to not have adParameters + try { + const mediaFileObj = JSON.parse(ad?.adParameters?.trim()); + if (mediaFileObj) { + ad.mediaFileList = [{ + 'src': mediaFileObj?.videos?.length ? mediaFileObj?.videos[0]?.url : '', + 'type': mediaFileObj?.videos?.length ? mediaFileObj?.videos[0]?.mimetype : '' + }]; + } + } catch (error) { + console.error("Error parsing media file URL:", error); + } + ad.adParameters = null; + } + } } - } - } - - const arrayCreativeNonLinears = creative[0].getElementsByTagName('NonLinearAds'); - if ((typeof arrayCreativeNonLinears !== 'undefined') && (arrayCreativeNonLinears !== null) && arrayCreativeNonLinears.length) { - - const creativeNonLinear = arrayCreativeNonLinears[0]; - playerInstance.registerTrackingEvents(creativeNonLinear, tmpOptions); - - clickTracks = playerInstance.getNonLinearClickTrackingEvents(creativeNonLinear); - playerInstance.registerClickTracking(clickTracks, tmpOptions); - - //Extract the Ad data if it is actually the Ad (!wrapper) - if (!playerInstance.hasVastAdTagUri(xmlResponse) && playerInstance.hasInLine(xmlResponse)) { - - //Set initial values - tmpOptions.adType = 'nonLinear'; - tmpOptions.vpaid = false; - - //Extract the necessary data from the NonLinear node - tmpOptions.clickthroughUrl = playerInstance.getClickThroughUrlFromNonLinear(creativeNonLinear); - tmpOptions.duration = playerInstance.getDurationFromNonLinear(creativeNonLinear); // VAST version < 4.0 - tmpOptions.dimension = playerInstance.getDimensionFromNonLinear(creativeNonLinear); // VAST version < 4.0 - tmpOptions.staticResource = playerInstance.getStaticResourceFromNonLinear(creativeNonLinear); - tmpOptions.creativeType = playerInstance.getCreativeTypeFromStaticResources(creativeNonLinear); - tmpOptions.adParameters = playerInstance.getAdParametersFromLinear(creativeNonLinear); - - if (tmpOptions.adParameters) { - tmpOptions.vpaid = true; + if (ad.adType === 'nonLinear') { + const nonLinearCreatives = creativeElement.getElementsByTagName('NonLinearAds'); + const creativeNonLinear = nonLinearCreatives[0]; + + //Extract the Ad data if it is actually the Ad (!wrapper) + if (!playerInstance.hasVastAdTagUri(adElement) && playerInstance.hasInLine(adElement)) { + //Set initial values + ad.vpaid = false; + + //Extract the necessary data from the NonLinear node + ad.clickthroughUrl = playerInstance.getClickThroughUrlFromNonLinear(creativeNonLinear); + ad.duration = playerInstance.getDurationFromNonLinear(creativeNonLinear); // VAST version < 4.0 + ad.dimension = playerInstance.getDimensionFromNonLinear(creativeNonLinear); // VAST version < 4.0 + ad.staticResource = playerInstance.getStaticResourceFromNonLinear(creativeNonLinear); + ad.creativeType = playerInstance.getCreativeTypeFromStaticResources(creativeNonLinear); + ad.adParameters = playerInstance.getAdParametersFromLinear(creativeNonLinear); + + if (ad.adParameters) { + ad.vpaid = true; + } + } } + // Current support is for only one creative element + // break the loop if creative was successful + break; + } catch (err) { + if (creativeElement.firstElementChild && + !(creativeElement.firstElementChild.tagName === 'Linear' || + creativeElement.firstElementChild.tagName === 'NonLinearAds')) { + console.warn('Skipping ' + creativeElement.firstElementChild.tagName + ', this might not be supported yet.') + } + console.error(err); } - } - - //Extract the Ad data if it is actually the Ad (!wrapper) - if (!playerInstance.hasVastAdTagUri(xmlResponse) && playerInstance.hasInLine(xmlResponse)) { - if (typeof tmpOptions.mediaFileList !== 'undefined' || typeof tmpOptions.staticResource !== 'undefined') { - callBack(true, tmpOptions); - } else { - callBack(false); - } - } - } else { - callBack(false); + }; } - }; + + return ad; + } /** * Parse the VAST Tag * - * @param vastTag - * @param adListId + * @param vastObj */ - playerInstance.processVastWithRetries = (vastObj) => { let vastTag = vastObj.vastTag; - const adListId = vastObj.id; + const rollListId = vastObj.id; + + playerInstance.domRef.player.addEventListener('adId_' + rollListId, playerInstance[vastObj.roll]); - const handleVastResult = function (pass, tmpOptions) { - if (pass && typeof tmpOptions !== 'undefined' && tmpOptions.vpaid && !playerInstance.displayOptions.vastOptions.allowVPAID) { - pass = false; + const handleVastResult = function (pass, adOptionsList) { + if (pass && Array.isArray(adOptionsList) && !playerInstance.displayOptions.vastOptions.allowVPAID && adOptionsList.some(adOptions => adOptions.vpaid)) { + adOptionsList = adOptionsList.filter(adOptions => adOptions.vpaid !== true); playerInstance.announceLocalError('103', 'VPAID not allowed, so skipping this VAST tag.') } - if (pass) { - // ok - if (tmpOptions.adType === 'linear') { + if (pass && Array.isArray(adOptionsList) && adOptionsList.length) { + + playerInstance.adPool[rollListId] = []; + + adOptionsList.forEach((tmpOptions, index) => { + tmpOptions.id = rollListId + '_AD' + index; + tmpOptions.rollListId = rollListId; + + if (tmpOptions.adType === 'linear') { + + if ((typeof tmpOptions.iconClick !== 'undefined') && (tmpOptions.iconClick !== null) && tmpOptions.iconClick.length) { + tmpOptions.landingPage = tmpOptions.iconClick; + } + + const selectedMediaFile = playerInstance.getSupportedMediaFileObject(tmpOptions.mediaFileList); + if (selectedMediaFile) { + tmpOptions.mediaType = selectedMediaFile.mediaType; + } - if ((typeof tmpOptions.iconClick !== 'undefined') && (tmpOptions.iconClick !== null) && tmpOptions.iconClick.length) { - playerInstance.adList[adListId].landingPage = tmpOptions.iconClick; } - const selectedMediaFile = playerInstance.getSupportedMediaFileObject(tmpOptions.mediaFileList); - if (selectedMediaFile) { - playerInstance.adList[adListId].mediaType = selectedMediaFile.mediaType; + tmpOptions.adType = tmpOptions.adType ? tmpOptions.adType : 'unknown'; + playerInstance.adPool[rollListId].push(Object.assign({}, tmpOptions)); + + if (playerInstance.hasTitle()) { + const title = playerInstance.domRef.wrapper.querySelector('.fp_title'); + title.style.display = 'none'; } - } + playerInstance.rollsById[rollListId].ads.push(tmpOptions); + }); - playerInstance.adList[adListId].adType = tmpOptions.adType ? tmpOptions.adType : 'unknown'; - playerInstance.adList[adListId].vastLoaded = true; - playerInstance.adPool[adListId] = Object.assign({}, tmpOptions); + playerInstance.rollsById[rollListId].vastLoaded = true; const event = document.createEvent('Event'); - event.initEvent('adId_' + adListId, false, true); + event.initEvent('adId_' + rollListId, false, true); playerInstance.domRef.player.dispatchEvent(event); playerInstance.displayOptions.vastOptions.vastAdvanced.vastLoadedCallback(); - - if (playerInstance.hasTitle()) { - const title = document.getElementById(playerInstance.videoPlayerId + '_title'); - title.style.display = 'none'; - } - } else { // when vast failed playerInstance.announceLocalError('101'); if (vastObj.hasOwnProperty('fallbackVastTags') && vastObj.fallbackVastTags.length > 0) { vastTag = vastObj.fallbackVastTags.shift(); - playerInstance.processUrl(vastTag, handleVastResult); + playerInstance.processUrl(vastTag, handleVastResult, rollListId); } else { if (vastObj.roll === 'preRoll') { playerInstance.preRollFail(vastObj); } - playerInstance.adList[adListId].error = true; + playerInstance.rollsById[rollListId].error = true; } } }; - playerInstance.processUrl(vastTag, handleVastResult); + playerInstance.processUrl(vastTag, handleVastResult, rollListId); }; - playerInstance.processUrl = (vastTag, callBack) => { + playerInstance.processUrl = (vastTag, callBack, rollListId) => { const numberOfRedirects = 0; const tmpOptions = { tracking: [], stopTracking: [], impression: [], + viewImpression: [], clicktracking: [], vastLoaded: false }; @@ -684,96 +762,299 @@ export default function (playerInstance, options) { vastTag, numberOfRedirects, tmpOptions, - callBack + callBack, + rollListId ); }; - playerInstance.resolveVastTag = (vastTag, numberOfRedirects, tmpOptions, callBack) => { - if (!vastTag || vastTag === '') { - callBack(false); - return; + /** + * Gets first stand-alone ad + * + * @param {Array} ads + * @returns {Array} + */ + function getFirstStandAloneAd(ads) { + for (const ad of ads) { + const isAdPod = ad.data.attributes.sequence !== undefined; + + if (!isAdPod) { + return [ad]; + } } - const handleXmlHttpReq = function () { - const xmlHttpReq = this; - let xmlResponse = false; + return []; + } - if (xmlHttpReq.readyState === 4 && xmlHttpReq.status === 404) { - callBack(false); - return; + /** + * Resolves ad requests recursively and returns a tree of "Ad" and "Wrapper" elements + * + * @param {string} url vast resource url + * @param {number} maxDepth depth of recursive calls (wrapper depth) + * @param {Partial} baseNode used for recursive calls as base node + * @param {number} currentDepth used internally to track depth + * @param {boolean} followAdditionalWrappers used internally to track nested wrapper calls + * @returns {Promise} + */ + async function resolveAdTreeRequests(url, maxDepth, baseNode = {}, currentDepth = 0, followAdditionalWrappers = true) { + const adTree = { ...baseNode, children: [] }; + const { responseXML } = await playerInstance.sendRequestAsync(url, true, playerInstance.displayOptions.vastOptions.vastTimeout); + const adElements = Array.from(responseXML.getElementsByTagName('Ad')); + + for (const adElement of adElements) { + const vastAdTagUri = playerInstance.getVastAdTagUriFromWrapper(adElement); + const isAdPod = adElement.attributes.sequence !== undefined; + const adNode = { data: adElement }; + + const adType = (adElement.getElementsByTagName('Linear').length && 'linear') || + (adElement.getElementsByTagName('NonLinearAds').length && 'nonLinear') || ''; + + if (vastAdTagUri && currentDepth <= maxDepth && followAdditionalWrappers) { + const [wrapperElement] = adElement.getElementsByTagName('Wrapper'); + const disableAdditionalWrappers = wrapperElement.attributes.followAdditionalWrappers && ["false", "0"].includes(wrapperElement.attributes.followAdditionalWrappers.value); // See VAST Wrapper spec + const allowMultipleAds = wrapperElement.attributes.allowMultipleAds && ["true", "1"].includes(wrapperElement.attributes.allowMultipleAds.value); // See VAST Wrapper spec + const fallbackOnNoAd = wrapperElement.attributes.fallbackOnNoAd && ["true", "1"].includes(wrapperElement.attributes.fallbackOnNoAd.value); + + try { + const wrapperResponse = await resolveAdTreeRequests(vastAdTagUri, maxDepth, { tagType: 'wrapper', ...adNode, fallbackOnNoAd }, currentDepth+1, !disableAdditionalWrappers); + wrapperResponse.fallbackOnNoAd = fallbackOnNoAd; + + if (!allowMultipleAds || isAdPod) { + wrapperResponse.children = getFirstStandAloneAd(wrapperResponse.children); + } + + adTree.children.push(wrapperResponse); + } catch (e) { + adTree.children.push({ tagType: `wrapper`, fallbackOnNoAd, httpError: true }) + playerInstance.debugMessage(`Error when loading Wrapper, will trigger fallback if available`, e); + } + } else if (!vastAdTagUri) { + if (adType === 'nonLinear') { + // No extra checks needed on fallback for non linear ads + adTree.children.push({ tagType: 'inLine', ...adNode }); + } else if (Array.from(adElement.getElementsByTagName('MediaFiles')).length) { + // Check if there are mediaFiles + const mediaFiles = Array.from(adElement.getElementsByTagName('MediaFiles')); + const mediaFile = mediaFiles[0]?.getElementsByTagName('MediaFile'); + const mediaFileUrl = mediaFile[0]?.textContent.trim(); + + // In case of vpaid, no need to check if media file is valid + // VPAID uses a JS file in this attribute, not a media file + if (mediaFile[0].getAttribute('apiFramework') === 'VPAID') { + adTree.children.push({ tagType: 'inLine', ...adNode }); + } else { + // If valid, add it to the ad tree + const mediaFileIsValid = await validateMediaFile(mediaFileUrl); + if (mediaFileIsValid) { + adTree.children.push({ tagType: 'inLine', ...adNode }); + } + } + } else if (Array.from(adElement.getElementsByTagName('AdParameters')).length) { + // check if there are adParameters + const adParameters = Array.from(adElement.getElementsByTagName('AdParameters')); + for (const adParameter of adParameters) { + try { + const mediaFileObj = JSON.parse(adParameter.textContent.trim()); + const mediaFileUrl = mediaFileObj?.videos?.length ? mediaFileObj?.videos[0]?.url : ''; + const mediaFileIsValid = await validateMediaFile(mediaFileUrl); + // If valid, add it to the ad tree + if (mediaFileIsValid) { + adTree.children.push({ tagType: 'inLine', ...adNode }); + } + } catch (error) { + console.error("Error parsing media file URL:", error); + } + } + } } + } - if (xmlHttpReq.readyState === 4 && xmlHttpReq.status === 0) { - callBack(false); //Most likely that Ad Blocker exists - return; + return adTree; + } + + + /** + * Validate Media File to check if videos play + * + * @param {mediaFileUrl} + */ + async function validateMediaFile(mediaFileUrl) { + if (!mediaFileUrl) { + return false; + } + try { + const response = await fetch(mediaFileUrl); + if (!response.ok || response.headers.get('content-type').indexOf('video') === -1) { + return false; } + const videoElement = document.createElement('video'); + videoElement.src = mediaFileUrl; + const canPlay = await videoElement.canPlayType(response.headers.get('content-type')); + return canPlay !== ""; + } catch (error) { + console.error('Failed to load media file:', error); + return false; + } + } - if (!((xmlHttpReq.readyState === 4) && (xmlHttpReq.status === 200))) { - return; + /** + * Transforms an Ad Tree to a 1-dimensional array of Ads with wrapper data attached to each ad + * + * @param {RawAdTree} root + * @param {Array} ads + * @param {Array} wrappers + * @returns {Array} + */ + function flattenAdTree(root, ads = [], wrappers = []) { + const currentWrappers = [...wrappers, root.data]; + + if (Array.isArray(root.children) && root.children.length) { + root.children.forEach(child => flattenAdTree(child, ads, currentWrappers)); + } + + if (root.tagType === 'inLine') { + ads.push({ ...root, wrappers: currentWrappers.filter(Boolean) }); + } + + return ads; + } + + /** + * Register Ad element properties to an Ad based on its data and its wrapper data if available + * + * @param {RawAd} rawAd + * @param {{ tracking: Array, stopTracking: Array, impression: Array, viewImpression: Array, clicktracking: Array }} options + * @returns {Ad} + */ + function registerAdProperties(rawAd, options) { + const ad = { ...rawAd, ...JSON.parse(JSON.stringify(options)) }; + + ad.adType = (ad.data.getElementsByTagName('Linear').length && 'linear') || + (ad.data.getElementsByTagName('NonLinearAds').length && 'nonLinear') || 'unknown'; + + [...(ad.wrappers || []), ad.data].filter(Boolean).forEach(dataSource => { + // Register impressions + const impression = dataSource.getElementsByTagName('Impression'); + if (impression !== null) { + playerInstance.registerImpressionEvents(impression, ad); } - if ((xmlHttpReq.readyState === 4) && (xmlHttpReq.status !== 200)) { - callBack(false); - return; + // Register viewable impressions + const viewableImpression = dataSource.getElementsByTagName('Viewable'); + if (viewableImpression !== null) { + playerInstance.registerViewableImpressionEvents(viewableImpression, ad); } - try { - xmlResponse = xmlHttpReq.responseXML; - } catch (e) { - callBack(false); - return; + // Get the error tag, if any + const errorTags = dataSource.getElementsByTagName('Error'); + if (errorTags !== null) { + playerInstance.registerErrorEvents(errorTags, ad); } - if (!xmlResponse) { - callBack(false); - return; + // Sets CTA from vast + const [titleCta] = dataSource.getElementsByTagName('TitleCTA'); + if (titleCta) { + playerInstance.setCTAFromVast(titleCta, ad); } - playerInstance.inLineFound = playerInstance.hasInLine(xmlResponse); + // Register tracking events + playerInstance.registerTrackingEvents(dataSource, ad); + const clickTracks = ad.adType === 'linear' ? + playerInstance.getClickTrackingEvents(dataSource) : + playerInstance.getNonLinearClickTrackingEvents(dataSource); + playerInstance.registerClickTracking(clickTracks, ad); + }); - if (!playerInstance.inLineFound && playerInstance.hasVastAdTagUri(xmlResponse)) { + ad.sequence = ad.data.attributes.sequence ? Number(ad.data.attributes.sequence.value) : null; + ad.played = false; - const vastAdTagUri = playerInstance.getVastAdTagUriFromWrapper(xmlResponse); - if (vastAdTagUri) { - playerInstance.resolveVastTag(vastAdTagUri, numberOfRedirects, tmpOptions, callBack); - } else { - callBack(false); - return; + return ad; + } + + /** + * Handles selection of ad pod or standalone ad to be played + * + * @param {Array} ads + * @param {number} maxDuration + * @param {number} maxQuantity + * @param {boolean} forceStandAloneAd + */ + function getPlayableAds(ads, maxDuration, maxQuantity, forceStandAloneAd) { + const { adPod } = ads + .filter(ad => Boolean(ad.sequence)) + .sort((adX, adY) => adX.sequence - adY.sequence) + .reduce((playableAds, ad) => { + if (playableAds.adPod.length < maxQuantity && (playableAds.totalDuration + ad.duration) <= maxDuration) { + playableAds.adPod.push(ad); } - } - if (numberOfRedirects > playerInstance.displayOptions.vastOptions.maxAllowedVastTagRedirects && !playerInstance.inLineFound) { - callBack(false); - return; - } + return playableAds; + }, { adPod: [], totalDuration: 0 }); + const adBuffet = ads.filter(ad => !Boolean(ad.sequence) && ad.duration < maxDuration); - playerInstance.processVastXml(xmlResponse, tmpOptions, callBack); - }; + const isValidAdPodFormats = adPod.map(ad => ad.adType).slice(0, -1).every(adType => adType === 'linear'); - if (numberOfRedirects <= playerInstance.displayOptions.vastOptions.maxAllowedVastTagRedirects) { + if (adPod.length > 0 && !forceStandAloneAd && isValidAdPodFormats) { + playerInstance.debugMessage('Playing valid adPod', adPod); + return adPod; + } else { + playerInstance.debugMessage('Trying to play single ad, adBuffet:', adBuffet); + return adBuffet.length > 0 ? [adBuffet[0]] : []; + } + } - playerInstance.sendRequest( - vastTag, - true, - playerInstance.displayOptions.vastOptions.vastTimeout, - handleXmlHttpReq - ); + /** + * @param vastTag + * @param numberOfRedirects + * @param tmpOptions + * @param callback + * @param rollListId + */ + playerInstance.resolveVastTag = (vastTag, numberOfRedirects, tmpOptions, callback, rollListId) => { + if (!vastTag || vastTag === '') { + return callback(false); } - numberOfRedirects++; + resolveAdTreeRequests(vastTag, playerInstance.displayOptions.vastOptions.maxAllowedVastTagRedirects) + .then(result => { + try { + /** @see VAST 4.0 Wrapper.fallbackOnNoAd */ + const triggerFallbackOnNoAd = result.children.some(ad => + ad.tagType === 'wrapper' && ad.fallbackOnNoAd && (!/"tagType":"ad"/.test(JSON.stringify(ad)) || ad.httpError) + ); + + if (triggerFallbackOnNoAd) { + playerInstance.debugMessage('Error on VAST Wrapper, triggering fallbackOnNoAd. Ad tree:', result); + } + + result = flattenAdTree(result).map(ad => processAdCreatives(registerAdProperties(ad, tmpOptions))); + + const playableAds = getPlayableAds( + result, + playerInstance.rollsById[rollListId].maxTotalDuration || Number.MAX_SAFE_INTEGER, + playerInstance.rollsById[rollListId].maxTotalQuantity || Number.MAX_SAFE_INTEGER, + triggerFallbackOnNoAd, + ); + + (playableAds && playableAds.length) ? callback(true, playableAds) : callback(false); + } catch (error) { + callback(false); + } + }) + .catch(() => { + return callback(false); + }); }; playerInstance.setVastList = () => { - const ads = {}; - const adGroupedByRolls = { preRoll: [], postRoll: [], midRoll: [], onPauseRoll: [] }; + const rolls = {}; + const rollsGroupedByType = { preRoll: [], postRoll: [], midRoll: [], onPauseRoll: [] }; const def = { id: null, roll: null, - played: false, vastLoaded: false, error: false, adText: null, - adTextPosition: null + adTextPosition: null, }; let idPart = 0; @@ -819,20 +1100,21 @@ export default function (playerInstance, options) { for (let key in playerInstance.displayOptions.vastOptions.adList) { - let adItem = playerInstance.displayOptions.vastOptions.adList[key]; + let rollItem = playerInstance.displayOptions.vastOptions.adList[key]; - if (validateRequiredParams(adItem)) { + if (validateRequiredParams(rollItem)) { playerInstance.announceLocalError(102, 'Wrong adList parameters.'); continue; } const id = 'ID' + idPart; - ads[id] = Object.assign({}, def); - ads[id] = Object.assign(ads[id], playerInstance.displayOptions.vastOptions.adList[key]); - if (adItem.roll == 'midRoll') { - ads[id].error = validateVastList('midRoll', adItem); + rolls[id] = Object.assign({}, def); + rolls[id] = Object.assign(rolls[id], playerInstance.displayOptions.vastOptions.adList[key]); + if (rollItem.roll === 'midRoll') { + rolls[id].error = validateVastList('midRoll', rollItem); } - ads[id].id = id; + rolls[id].id = id; + rolls[id].ads = []; idPart++; } @@ -840,34 +1122,35 @@ export default function (playerInstance, options) { // group the ads by roll // pushing object references and forming json - Object.keys(ads).map(function (e) { - switch (ads[e].roll.toLowerCase()) { + Object.keys(rolls).map(function (e) { + switch (rolls[e].roll.toLowerCase()) { case 'preRoll'.toLowerCase(): - adGroupedByRolls.preRoll.push(ads[e]); + rollsGroupedByType.preRoll.push(rolls[e]); break; case 'midRoll'.toLowerCase(): - adGroupedByRolls.midRoll.push(ads[e]); + rollsGroupedByType.midRoll.push(rolls[e]); break; case 'postRoll'.toLowerCase(): - adGroupedByRolls.postRoll.push(ads[e]); + rollsGroupedByType.postRoll.push(rolls[e]); break; case 'onPauseRoll'.toLowerCase(): - adGroupedByRolls.onPauseRoll.push(ads[e]); + rollsGroupedByType.onPauseRoll.push(rolls[e]); break; default: - console.error(`${ads[e].roll.toLowerCase()} is not a recognized roll`); + console.error(`${rolls[e].roll.toLowerCase()} is not a recognized roll`); break; } }); - playerInstance.adGroupedByRolls = adGroupedByRolls; - playerInstance.adList = ads; + playerInstance.adGroupedByRolls = rollsGroupedByType; + playerInstance.rollsById = rolls; }; playerInstance.onVastAdEnded = (event) => { if (event) { event.stopImmediatePropagation(); } + playerInstance.vastOptions.adFinished = true; //"this" is the HTML5 video tag, because it dispatches the "ended" event playerInstance.deleteVastAdElements(); playerInstance.checkForNextAd(); @@ -875,10 +1158,9 @@ export default function (playerInstance, options) { playerInstance.vastLogoBehaviour = (vastPlaying) => { if (!playerInstance.displayOptions.layoutControls.logo.showOverAds) { - const logoHolder = document.getElementById(playerInstance.videoPlayerId + '_logo'); - const logoImage = document.getElementById(playerInstance.videoPlayerId + '_logo_image'); + const logoHolder = playerInstance.domRef.wrapper.querySelector('.logo_holder'); - if (!logoHolder || !logoImage) { + if (!logoHolder) { return; } @@ -894,4 +1176,125 @@ export default function (playerInstance, options) { playerInstance.removeCTAButton(); playerInstance.vastLogoBehaviour(false); }; + + /** + * Track if the player is muted or unmuted when playing an ad and add tracking to mute/unmute events + */ + playerInstance.trackMuteChange = () => { + if ((!playerInstance.isCurrentlyPlayingAd && !playerInstance.isCurrentlyShowingNonLinearAd) || !playerInstance.vastOptions || !playerInstance.vastOptions.tracking || (!playerInstance.vastOptions.tracking.mute && !playerInstance.vastOptions.tracking.unmute)) { + return; + } + + if (playerInstance.domRef.player.muted) { + playerInstance.trackSingleEvent(muteEvent); + } else { + playerInstance.trackSingleEvent(unmuteEvent); + } + }; + + /** + * Track if the player is paused or resumed when playing an ad and add tracking to pause/resume events + */ + playerInstance.trackPlayPauseChange = () => { + if ((!playerInstance.isCurrentlyPlayingAd && !playerInstance.isCurrentlyShowingNonLinearAd) || !playerInstance.vastOptions || !playerInstance.vastOptions.tracking || (!playerInstance.vastOptions.tracking.pause && !playerInstance.vastOptions.tracking.resume)) { + return; + } + + if (playerInstance.domRef.player.paused) { + playerInstance.trackSingleEvent(pauseEvent); + } else { + playerInstance.trackSingleEvent(resumeEvent); + } + }; + + /** + * Track if the non-linear ad is closed on the 'close' button and add tracking to 'close' event + */ + playerInstance.trackCloseNonLinearAd = () => { + if (!playerInstance.vastOptions || !playerInstance.vastOptions.tracking || !playerInstance.vastOptions.tracking.close) { + return; + } + playerInstance.trackSingleEvent(closeEvent); + }; + + /** + * Track if the ad is skipped and add tracking to 'skip' event + */ + playerInstance.trackSkipAd = () => { + if (!playerInstance.vastOptions || !playerInstance.vastOptions.tracking || !playerInstance.vastOptions.tracking.skip) { + return; + } + playerInstance.trackSingleEvent(skipEvent); + }; + + /** + * Track if the ad is rewound and add tracking to 'rewind' event + */ + playerInstance.trackRewindAd = () => { + if (!playerInstance.isCurrentlyShowingNonLinearAd || !playerInstance.vastOptions || !playerInstance.vastOptions.tracking || !playerInstance.vastOptions.tracking.rewind) { + return; + } + playerInstance.trackSingleEvent(rewindEvent); + }; + + /** + * Track if the video player size has changed and add tracking for related events + * + * @param previousDisplayMode + */ + playerInstance.trackPlayerSizeChanged = (previousDisplayMode) => { + const { + isCurrentlyPlayingAd, + isCurrentlyShowingNonLinearAd, + fullscreenMode, + theatreMode, + miniPlayerToggledOn, + vastOptions, + trackSingleEvent, + } = playerInstance; + + if ((!isCurrentlyPlayingAd && !isCurrentlyShowingNonLinearAd) || !vastOptions?.tracking) { + return; + } + + const { fullscreen, playerExpand, playerCollapse } = vastOptions.tracking; + + if (!fullscreen && !playerExpand && !playerCollapse) { + return; + } + + // Fullscreen mode - always expansion + if (fullscreenMode) { + if (fullscreen) { + trackSingleEvent(fullscreenEvent); + } + if (fullscreen || playerExpand) { + trackSingleEvent(playerExpandEvent); + } + return; + } + + // Theatre mode - expand from smaller modes, collapse otherwise + if (theatreMode) { + const isExpandingToTheatre = previousDisplayMode === displayModes.MINI_PLAYER || previousDisplayMode === displayModes.NORMAL; + if (isExpandingToTheatre && playerExpand) { + trackSingleEvent(playerExpandEvent); + } else if (!isExpandingToTheatre && playerCollapse) { + trackSingleEvent(playerCollapseEvent); + } + return; + } + + // Mini player mode - always collapse + if (miniPlayerToggledOn && playerCollapse) { + trackSingleEvent(playerCollapseEvent); + return; + } + + if (previousDisplayMode === displayModes.MINI_PLAYER && playerExpand) { + trackSingleEvent(playerExpandEvent); + } else if (previousDisplayMode !== displayModes.MINI_PLAYER && playerCollapse) { + trackSingleEvent(playerCollapseEvent); + } + }; } diff --git a/src/modules/vpaid.js b/src/modules/vpaid.js index 66308598..ef18d37a 100644 --- a/src/modules/vpaid.js +++ b/src/modules/vpaid.js @@ -1,6 +1,24 @@ // VPAID support module -'use strict'; +import { displayModes, trackingEventTypes } from '../constants/constants'; + export default function (playerInstance, options) { + const { + pauseEvent, + resumeEvent, + skipEvent, + impressionEvent, + startEvent, + firstQuartileEvent, + midpointEvent, + thirdQuartileEvent, + completeEvent, + creativeViewEvent, + collapseEvent, + adCollapseEvent, + closeEvent, + acceptInvitationEvent + } = trackingEventTypes; + const callbacks = { AdStarted: () => playerInstance.onStartVpaidAd, AdStopped: () => playerInstance.onStopVpaidAd, @@ -51,12 +69,18 @@ export default function (playerInstance, options) { playerInstance.onVpaidAdPaused = () => { playerInstance.vpaidTimeoutTimerClear(); playerInstance.debugMessage("onAdPaused"); + if (playerInstance.vastOptions.tracking.pause) { + playerInstance.trackSingleEvent(pauseEvent); + } }; // Callback for AdPlaying playerInstance.onVpaidAdPlaying = () => { playerInstance.vpaidTimeoutTimerClear(); playerInstance.debugMessage("onAdPlaying"); + if (playerInstance.vastOptions.tracking.resume) { + playerInstance.trackSingleEvent(resumeEvent); + } }; // Callback for AdError @@ -74,19 +98,32 @@ export default function (playerInstance, options) { // Callback for AdUserAcceptInvitation playerInstance.onVpaidAdUserAcceptInvitation = () => { playerInstance.debugMessage("onAdUserAcceptInvitation"); + if (playerInstance.vastOptions.tracking.acceptInvitation) { + playerInstance.trackSingleEvent(acceptInvitationEvent); + } }; // Callback for AdUserMinimize playerInstance.onVpaidAdUserMinimize = () => { playerInstance.debugMessage("onAdUserMinimize"); + if (playerInstance.vastOptions.tracking.collapse) { + playerInstance.trackSingleEvent(collapseEvent); + } + if (playerInstance.vastOptions.tracking.adCollapse) { + playerInstance.trackSingleEvent(adCollapseEvent); + } + }; // Callback for AdUserClose playerInstance.onVpaidAdUserClose = () => { playerInstance.debugMessage("onAdUserClose"); + if (playerInstance.vastOptions.tracking.close) { + playerInstance.trackSingleEvent(closeEvent); + } }; - // Callback for AdUserClose + // Callback for AdSkippableStateChange playerInstance.onVpaidAdSkippableStateChange = () => { if (!playerInstance.vpaidAdUnit) { return; @@ -94,7 +131,7 @@ export default function (playerInstance, options) { playerInstance.debugMessage("Ad Skippable State Changed to: " + playerInstance.vpaidAdUnit.getAdSkippableState()); }; - // Callback for AdUserClose + // Callback for AdExpandedChange playerInstance.onVpaidAdExpandedChange = () => { if (!playerInstance.vpaidAdUnit) { return; @@ -161,7 +198,7 @@ export default function (playerInstance, options) { playerInstance.debugMessage("Ad Impression"); //Announce the impressions - playerInstance.trackSingleEvent('impression'); + playerInstance.trackSingleEvent(impressionEvent); }; // Callback for AdClickThru @@ -187,37 +224,37 @@ export default function (playerInstance, options) { // Callback for AdVideoStart playerInstance.onVpaidAdVideoStart = () => { playerInstance.debugMessage("Video 0% completed"); - playerInstance.trackSingleEvent('start'); + playerInstance.trackSingleEvent(startEvent); }; - // Callback for AdUserClose + // Callback for AdVideoFirstQuartile playerInstance.onVpaidAdVideoFirstQuartile = () => { playerInstance.debugMessage("Video 25% completed"); - playerInstance.trackSingleEvent('firstQuartile'); + playerInstance.trackSingleEvent(firstQuartileEvent); }; - // Callback for AdUserClose + // Callback for AdVideoMidpoint playerInstance.onVpaidAdVideoMidpoint = () => { playerInstance.debugMessage("Video 50% completed"); - playerInstance.trackSingleEvent('midpoint'); + playerInstance.trackSingleEvent(midpointEvent); }; - // Callback for AdUserClose + // Callback for AdVideoThirdQuartile playerInstance.onVpaidAdVideoThirdQuartile = () => { playerInstance.debugMessage("Video 75% completed"); - playerInstance.trackSingleEvent('thirdQuartile'); + playerInstance.trackSingleEvent(thirdQuartileEvent); }; // Callback for AdVideoComplete playerInstance.onVpaidAdVideoComplete = () => { playerInstance.debugMessage("Video 100% completed"); - playerInstance.trackSingleEvent('complete'); + playerInstance.trackSingleEvent(completeEvent); }; // Callback for AdLinearChange playerInstance.onVpaidAdLinearChange = () => { - const vpaidNonLinearSlot = document.getElementsByClassName("fluid_vpaidNonLinear_ad")[0]; - const closeBtn = document.getElementById('close_button_' + playerInstance.videoPlayerId); + const vpaidNonLinearSlot = playerInstance.domRef.wrapper.getElementsByClassName("fluid_vpaidNonLinear_ad")[0]; + const closeBtn = playerInstance.domRef.wrapper.querySelector('.close_button'); const adListId = vpaidNonLinearSlot.getAttribute('adlistid'); playerInstance.debugMessage("Ad linear has changed: " + playerInstance.vpaidAdUnit.getAdLinear()); @@ -225,7 +262,7 @@ export default function (playerInstance, options) { return; } - playerInstance.backupMainVideoContentTime(adListId); + playerInstance.backupMainVideoContentTime(adListId.split('_')[0]); playerInstance.isCurrentlyPlayingAd = true; if (closeBtn) { @@ -233,7 +270,6 @@ export default function (playerInstance, options) { } vpaidNonLinearSlot.className = 'fluid_vpaid_slot'; - vpaidNonLinearSlot.id = playerInstance.videoPlayerId + "_fluid_vpaid_slot"; playerInstance.domRef.player.loop = false; playerInstance.domRef.player.removeAttribute('controls'); @@ -252,6 +288,19 @@ export default function (playerInstance, options) { return playerInstance.vpaidAdUnit.getAdLinear(); }; + // Custom implementation, not VPAID standard + // Pass through for getAdLinear if exists, otherwise fallback to VAST metadata + playerInstance.isAdLinear = (ad) => { + try { + return playerInstance.getVpaidAdLinear(); + } catch (error) { + playerInstance.debugMessage( + "Error calling getAdLinear(), handshake -> initAd() must be set before calling getAdLinear(). Falling back to VAST metadata: " + error + ); + return ad.adType && ad.adType.toLowerCase() === 'linear'; + } + }; + // Pass through for startAd() playerInstance.startVpaidAd = () => { playerInstance.debugMessage("startAd"); @@ -270,6 +319,9 @@ export default function (playerInstance, options) { // Callback for StartAd() playerInstance.onStartVpaidAd = () => { playerInstance.debugMessage("Ad has started"); + if (playerInstance.vastOptions.tracking.creativeView) { + playerInstance.trackSingleEvent(creativeViewEvent, 0); + } playerInstance.vpaidTimeoutTimerClear(); }; @@ -280,7 +332,7 @@ export default function (playerInstance, options) { }; // Hard Pass through for stopAd() excluding deleteOtherVpaidAdsApart - playerInstance.hardStopVpaidAd = (deleteOtherVpaidAdsApart) => { + playerInstance.hardStopVpaidAd = (ad) => { // this is hard stop of vpaid ads // we delete all the vpaid assets so the new one can be loaded // delete all assets apart from the ad from deleteOtherVpaidAdsApart @@ -289,24 +341,24 @@ export default function (playerInstance, options) { playerInstance.vpaidAdUnit = null; } - const vpaidIframes = document.getElementsByClassName("fluid_vpaid_iframe"); - const vpaidSlots = document.getElementsByClassName("fluid_vpaid_slot"); - const vpaidNonLinearSlots = document.getElementsByClassName("fluid_vpaidNonLinear_ad"); + const vpaidIframes = playerInstance.domRef.wrapper.getElementsByClassName("fluid_vpaid_iframe"); + const vpaidSlots = playerInstance.domRef.wrapper.getElementsByClassName("fluid_vpaid_slot"); + const vpaidNonLinearSlots = playerInstance.domRef.wrapper.getElementsByClassName("fluid_vpaidNonLinear_ad"); for (let i = 0; i < vpaidIframes.length; i++) { - if (vpaidIframes[i].getAttribute('adListId') !== deleteOtherVpaidAdsApart) { + if (vpaidIframes[i].getAttribute('adListId') !== ad.id) { vpaidIframes[i].remove(); } } for (let j = 0; j < vpaidSlots.length; j++) { - if (vpaidSlots[j].getAttribute('adListId') !== deleteOtherVpaidAdsApart) { + if (vpaidSlots[j].getAttribute('adListId') !== ad.id) { vpaidSlots[j].remove(); } } for (let k = 0; k < vpaidNonLinearSlots.length; k++) { - if (vpaidNonLinearSlots[k].getAttribute('adListId') !== deleteOtherVpaidAdsApart) { + if (vpaidNonLinearSlots[k].getAttribute('adListId') !== ad.id) { vpaidNonLinearSlots[k].remove(); } } @@ -319,10 +371,12 @@ export default function (playerInstance, options) { playerInstance.onVpaidEnded(); }; - // Callback for AdUserClose + // Callback for AdSkipped playerInstance.onSkipVpaidAd = () => { playerInstance.debugMessage("Ad was skipped"); - + if (playerInstance.vastOptions.tracking.skip) { + playerInstance.trackSingleEvent(skipEvent); + } playerInstance.vpaidTimeoutTimerClear(); playerInstance.onVpaidEnded(); }; @@ -333,7 +387,7 @@ export default function (playerInstance, options) { if (!playerInstance.vpaidAdUnit) { return; } - playerInstance.vpaidAdUnit.skipAd() + playerInstance.vpaidAdUnit.skipAd(); playerInstance.vpaidTimeoutTimerClear(); playerInstance.onVpaidEnded(); }; @@ -366,12 +420,12 @@ export default function (playerInstance, options) { if (playerInstance.vastOptions !== null && playerInstance.vastOptions.vpaid && playerInstance.vastOptions.linear) { const adWidth = playerInstance.domRef.player.offsetWidth; const adHeight = playerInstance.domRef.player.offsetHeight; - const mode = (playerInstance.fullscreenMode ? 'fullscreen' : 'normal'); + const mode = (playerInstance.fullscreenMode ? displayModes.FULLSCREEN : displayModes.NORMAL); playerInstance.resizeVpaidAd(adWidth, adHeight, mode); } }; - //Passthrough for resizeAd + // Passthrough for resizeAd playerInstance.resizeVpaidAd = (width, height, viewMode) => { if (!playerInstance.vpaidAdUnit) { return; @@ -397,7 +451,7 @@ export default function (playerInstance, options) { playerInstance.vpaidAdUnit.resumeAd(); }; - //Passthrough for expandAd() + // Passthrough for expandAd() playerInstance.expandVpaidAd = () => { if (!playerInstance.vpaidAdUnit) { return; @@ -405,7 +459,7 @@ export default function (playerInstance, options) { playerInstance.vpaidAdUnit.expandAd(); }; - //Passthrough for collapseAd() + // Passthrough for collapseAd() playerInstance.collapseVpaidAd = () => { if (!playerInstance.vpaidAdUnit) { return; @@ -430,7 +484,7 @@ export default function (playerInstance, options) { }; playerInstance.vpaidCallbackListenersAttach = () => { - //The key of the object is the event name and the value is a reference to the callback function that is registered with the creative + // The key of the object is the event name and the value is a reference to the callback function that is registered with the creative // Looping through the object and registering each of the callbacks with the creative for (let eventName in callbacks) { playerInstance.vpaidAdUnit.subscribe(callbacks[eventName](), eventName, playerInstance); @@ -446,61 +500,68 @@ export default function (playerInstance, options) { } }; - playerInstance.loadVpaid = (adListId, vpaidJsUrl) => { + playerInstance.loadVpaid = (ad, vpaidJsUrl) => { const vpaidIframe = document.createElement('iframe'); - vpaidIframe.id = playerInstance.videoPlayerId + "_" + adListId + "_fluid_vpaid_iframe"; + vpaidIframe.id = "fp_" + ad.id + "_fluid_vpaid_iframe"; vpaidIframe.className = 'fluid_vpaid_iframe'; - vpaidIframe.setAttribute('adListId', adListId); + vpaidIframe.setAttribute('adListId', ad.id); vpaidIframe.setAttribute('frameborder', '0'); playerInstance.domRef.player.parentNode.insertBefore(vpaidIframe, playerInstance.domRef.player.nextSibling); - vpaidIframe.contentWindow.document.write(' + + + diff --git a/test/html/e2e/controls.html b/test/html/e2e/controls.html new file mode 100644 index 00000000..661c5c4f --- /dev/null +++ b/test/html/e2e/controls.html @@ -0,0 +1,37 @@ + + + + + + + Controls + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/e2e/suggested_videos_e2e.tpl.html b/test/html/e2e/suggested_videos_e2e.tpl.html new file mode 100644 index 00000000..45d0e1d4 --- /dev/null +++ b/test/html/e2e/suggested_videos_e2e.tpl.html @@ -0,0 +1,42 @@ + + + + + + + Suggested videos with subtitles + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/hls_live.tpl.html b/test/html/hls_live.tpl.html index abdf42e0..2ebf8c60 100644 --- a/test/html/hls_live.tpl.html +++ b/test/html/hls_live.tpl.html @@ -16,7 +16,7 @@ <%= htmlWebpackPlugin.tags.bodyTags %> diff --git a/test/html/hls_live_vast.tpl.html b/test/html/hls_live_vast.tpl.html index d1e5d9b5..ac59b8ca 100644 --- a/test/html/hls_live_vast.tpl.html +++ b/test/html/hls_live_vast.tpl.html @@ -16,7 +16,7 @@ <%= htmlWebpackPlugin.tags.bodyTags %> @@ -28,11 +28,11 @@ adList: [ { roll: 'preRoll', - vastTag: '/static/vpaid_linear.xml' + vastTag: '/static/vast_linear.xml' }, { roll: 'midRoll', - vastTag: '/static/vpaid_nonlinear.xml', + vastTag: '/static/vast_nonlinear.xml', timer: 4 }, ] diff --git a/test/html/hls_vod.tpl.html b/test/html/hls_vod.tpl.html index f235b336..50c39023 100644 --- a/test/html/hls_vod.tpl.html +++ b/test/html/hls_vod.tpl.html @@ -16,7 +16,7 @@ <%= htmlWebpackPlugin.tags.bodyTags %> diff --git a/test/html/hls_vod_suggested_videos.html b/test/html/hls_vod_suggested_videos.html new file mode 100644 index 00000000..3eff57ec --- /dev/null +++ b/test/html/hls_vod_suggested_videos.html @@ -0,0 +1,51 @@ + + + + + + + Hls VOD suggested videos + <%= htmlWebpackPlugin.tags.headTags %> + + + + +

Hls with suggested videos

+ +
    +
  • Initial video is an mp4
  • +
      +
    • no auto option
    • +
    +
  • All suggested videos in the initial video are Hls
  • +
  • Suggested videos at the end of the second video are mp4's
  • +
      +
    • no auto option
    • +
    +
+ + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/hls_vod_vast.tpl.html b/test/html/hls_vod_vast.tpl.html index 47ff84f6..96396906 100644 --- a/test/html/hls_vod_vast.tpl.html +++ b/test/html/hls_vod_vast.tpl.html @@ -16,7 +16,7 @@ <%= htmlWebpackPlugin.tags.bodyTags %> @@ -28,11 +28,11 @@ adList: [ { roll: 'preRoll', - vastTag: '/static/vpaid_linear.xml' + vastTag: '/static/vast_linear.xml' }, { roll: 'midRoll', - vastTag: '/static/vpaid_nonlinear.xml', + vastTag: '/static/vast_nonlinear.xml', timer: 4 }, ] diff --git a/test/html/player-reinitialization.tpl.html b/test/html/player-reinitialization.tpl.html new file mode 100644 index 00000000..9e0fecb4 --- /dev/null +++ b/test/html/player-reinitialization.tpl.html @@ -0,0 +1,82 @@ + + + + + + + Player Reinitialization + <%= htmlWebpackPlugin.tags.headTags %> + + + + +
+ +
+ + + + + +
+ +

This test is meant to check if the Fluid Player instance is properly clean up after destroying it

+ +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/skip_return.tpl.html b/test/html/skip_return.tpl.html index adcd406c..4fcf4105 100644 --- a/test/html/skip_return.tpl.html +++ b/test/html/skip_return.tpl.html @@ -16,9 +16,9 @@ <%= htmlWebpackPlugin.tags.bodyTags %> @@ -28,6 +28,11 @@ layoutControls: { controlForwardBackward: { show: true + }, + controlBar: { + autoHide: true, // Default false + autoHideTimeout: 3, // Default 3 + animated: false // Default true } } }); diff --git a/test/html/special-cases/_README.md b/test/html/special-cases/_README.md new file mode 100644 index 00000000..21d95fc9 --- /dev/null +++ b/test/html/special-cases/_README.md @@ -0,0 +1,18 @@ +# Special cases + +Templates put here will not appear in the e2e file list, but will be accessible directly by URL in the root of the test server. +These templates should be used to share a link of an issue to an issue when creating a PR. + +To avoid conflicts with any naming, always use identifiers that point to the cause, for example: + +* Internal issues: `internal-vast-click-tracking-issue.tpl.html` +* Github issues: `issue-215.tpl.html` + +These files would be accessible in the following URLs when running the dev server: + +* http://localhost:8080/internal-vast-click-tracking-issue.html +* http://localhost:8080/issues-215.html + +To use specific static files, please create them in `test/static/special-cases`, no special naming is required. + +For more information about how these files are being loaded, check `webpack.config.js` diff --git a/test/html/special-cases/internal-vast-click-tracking-issue.html b/test/html/special-cases/internal-vast-click-tracking-issue.html new file mode 100644 index 00000000..3bc64cd5 --- /dev/null +++ b/test/html/special-cases/internal-vast-click-tracking-issue.html @@ -0,0 +1,38 @@ + + + + + + + Fluid player doesn't track clicks on some sites + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/special-cases/issue-702.tpl.html b/test/html/special-cases/issue-702.tpl.html new file mode 100644 index 00000000..ad764288 --- /dev/null +++ b/test/html/special-cases/issue-702.tpl.html @@ -0,0 +1,34 @@ + + + + + + + Issue 702 - CurrentTime reset after switch HLS source on IOS + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/suggested_videos_ads.tpl.html b/test/html/suggested_videos_ads.tpl.html new file mode 100644 index 00000000..6104711a --- /dev/null +++ b/test/html/suggested_videos_ads.tpl.html @@ -0,0 +1,66 @@ + + + + + + + Suggested videos with ads + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/suggested_videos_subtitles.tpl.html b/test/html/suggested_videos_subtitles.tpl.html new file mode 100644 index 00000000..cc2d5d32 --- /dev/null +++ b/test/html/suggested_videos_subtitles.tpl.html @@ -0,0 +1,57 @@ + + + + + + + Suggested videos with subtitles + <%= htmlWebpackPlugin.tags.headTags %> + + + + +

Video with no subtitles initially, two video sources

+ + +

Video with no subtitles initially, only 1 video source

+ + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + + + diff --git a/test/html/vod_basic.tpl.html b/test/html/vod_basic.tpl.html index 40928326..b342e5ed 100644 --- a/test/html/vod_basic.tpl.html +++ b/test/html/vod_basic.tpl.html @@ -16,9 +16,9 @@ <%= htmlWebpackPlugin.tags.bodyTags %> diff --git a/test/html/vod_basic_autohide.tpl.html b/test/html/vod_basic_autohide.tpl.html index df5faa8a..de144a15 100644 --- a/test/html/vod_basic_autohide.tpl.html +++ b/test/html/vod_basic_autohide.tpl.html @@ -16,9 +16,9 @@ <%= htmlWebpackPlugin.tags.bodyTags %> @@ -28,7 +28,7 @@ layoutControls: { controlBar: { autoHide: true, - autoHideTimeout: 3, + autoHideTimeout: 1, animated: true }, } diff --git a/test/html/vod_basic_by_ref.tpl.html b/test/html/vod_basic_by_ref.tpl.html index 15c3c4e0..bea153bd 100644 --- a/test/html/vod_basic_by_ref.tpl.html +++ b/test/html/vod_basic_by_ref.tpl.html @@ -16,9 +16,9 @@ <%= htmlWebpackPlugin.tags.bodyTags %> diff --git a/test/html/vod_basic_cta_from_config.tpl.html b/test/html/vod_basic_cta_from_config.tpl.html new file mode 100644 index 00000000..083c9378 --- /dev/null +++ b/test/html/vod_basic_cta_from_config.tpl.html @@ -0,0 +1,42 @@ + + + + + + + VOD with CTA set from Player Configurations + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_basic_cta_from_vast.tpl.html b/test/html/vod_basic_cta_from_vast.tpl.html index 19098839..1a2b4be4 100644 --- a/test/html/vod_basic_cta_from_vast.tpl.html +++ b/test/html/vod_basic_cta_from_vast.tpl.html @@ -9,22 +9,29 @@ <%= htmlWebpackPlugin.tags.headTags %> <%= htmlWebpackPlugin.tags.bodyTags %> + + + diff --git a/test/html/vod_basic_multiple.tpl.html b/test/html/vod_basic_multiple.tpl.html index d2c38927..d8d1c05d 100644 --- a/test/html/vod_basic_multiple.tpl.html +++ b/test/html/vod_basic_multiple.tpl.html @@ -21,15 +21,15 @@ <%= htmlWebpackPlugin.tags.bodyTags %> diff --git a/test/html/vod_basic_vtt.tpl.html b/test/html/vod_basic_vtt.tpl.html index 1e112020..5278bf63 100644 --- a/test/html/vod_basic_vtt.tpl.html +++ b/test/html/vod_basic_vtt.tpl.html @@ -5,7 +5,7 @@ - VOD with thumbnails + VOD with timeline thumbnails <%= htmlWebpackPlugin.tags.headTags %> + + + + + +

Check your console for the messages beginning with typeCallback

+ +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_extended.tpl.html b/test/html/vod_extended.tpl.html index 4e5ae6d0..acc8cef8 100644 --- a/test/html/vod_extended.tpl.html +++ b/test/html/vod_extended.tpl.html @@ -16,9 +16,9 @@ <%= htmlWebpackPlugin.tags.bodyTags %> diff --git a/test/html/vod_linear_tracking_events.html b/test/html/vod_linear_tracking_events.html new file mode 100644 index 00000000..0f33807f --- /dev/null +++ b/test/html/vod_linear_tracking_events.html @@ -0,0 +1,96 @@ + + + + + + + VOD with Event Tracking on Linear Ads + <%= htmlWebpackPlugin.tags.headTags %> + + + +
+
+

VAST

+ +
+ +
+

VPAID

+ +
+
+ +
+

Linear Tracking Events

+
    +
  • Start
  • +
  • First Quartile
  • +
  • Midpoint
  • +
  • Midpoint
  • +
  • Third Quartile
  • +
  • Complete
  • +
  • Progress
  • +
  • Impression
  • +
  • Mute
  • +
  • Unmute
  • +
  • Pause
  • +
  • Resume
  • +
  • Skip
  • +
  • Fullscreen (VAST 3.x)
  • +
  • Creative View
  • +
  • Player Expand
  • +
  • Player Collapse
  • +
  • Accept Invitation - VPAID only
  • + +
+
+ +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_live_ad.tpl.html b/test/html/vod_live_ad.tpl.html index c3ca8abf..1d105329 100644 --- a/test/html/vod_live_ad.tpl.html +++ b/test/html/vod_live_ad.tpl.html @@ -15,10 +15,12 @@ +

First ad is a Live hls file, second ad at 15 seconds is a VOD

+ <%= htmlWebpackPlugin.tags.bodyTags %> @@ -33,7 +35,7 @@ adList: [ { roll: 'preRoll', - vastTag: '/static/vast_hls.xml', + vastTag: '/static/vast_hls_live.xml', }, { roll: 'midRoll', diff --git a/test/html/vod_miniplayer.tpl.html b/test/html/vod_miniplayer.tpl.html new file mode 100644 index 00000000..bdf569dc --- /dev/null +++ b/test/html/vod_miniplayer.tpl.html @@ -0,0 +1,136 @@ + + + + + + + VOD with Mini Player + <%= htmlWebpackPlugin.tags.headTags %> + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + +
+
+
+
+
+
+
+
+
+
+ +

+
+ +
+ + + + + diff --git a/test/html/vod_non_linear_tracking_events.html b/test/html/vod_non_linear_tracking_events.html new file mode 100644 index 00000000..50c7aa4e --- /dev/null +++ b/test/html/vod_non_linear_tracking_events.html @@ -0,0 +1,97 @@ + + + + + + + VOD with Event Tracking on Non-Linear Ads + <%= htmlWebpackPlugin.tags.headTags %> + + + +
+
+

VAST

+ +
+ +
+

VPAID

+ +
+
+ +
+

Non-linear Tracking Events

+
    +
  • Start
  • +
  • First Quartile
  • +
  • Midpoint
  • +
  • Midpoint
  • +
  • Third Quartile
  • +
  • Complete
  • +
  • Progress
  • +
  • Impression
  • +
  • Mute
  • +
  • Unmute
  • +
  • Pause
  • +
  • Resume
  • +
  • Close
  • +
  • Rewind
  • +
  • Creative View
  • +
  • Player Expand
  • +
  • Player Collapse
  • +
  • Ad Collapse (i.e. Collapse in VAST 3.x) - VPAID only
  • +
  • Accept Invitation - VPAID only
  • + +
+
+ +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_reponsive.tpl.html b/test/html/vod_responsive.tpl.html similarity index 81% rename from test/html/vod_reponsive.tpl.html rename to test/html/vod_responsive.tpl.html index 40b8f823..1115a3bc 100644 --- a/test/html/vod_reponsive.tpl.html +++ b/test/html/vod_responsive.tpl.html @@ -42,9 +42,9 @@
diff --git a/test/html/vod_vast__linear.html b/test/html/vod_vast__linear.html new file mode 100644 index 00000000..565c22fa --- /dev/null +++ b/test/html/vod_vast__linear.html @@ -0,0 +1,60 @@ + + + + + + + VOD with VAST Linear + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast__non_linear.html b/test/html/vod_vast__non_linear.html new file mode 100644 index 00000000..f7710319 --- /dev/null +++ b/test/html/vod_vast__non_linear.html @@ -0,0 +1,60 @@ + + + + + + + VOD with VAST Non Linear + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_ad_buffet.tpl.html b/test/html/vod_vast_ad_buffet.tpl.html new file mode 100644 index 00000000..4340c3a2 --- /dev/null +++ b/test/html/vod_vast_ad_buffet.tpl.html @@ -0,0 +1,42 @@ + + + + + + + VOD with VAST Ad Buffet + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_ad_buffet_with_error.tpl.html b/test/html/vod_vast_ad_buffet_with_error.tpl.html new file mode 100644 index 00000000..c379f194 --- /dev/null +++ b/test/html/vod_vast_ad_buffet_with_error.tpl.html @@ -0,0 +1,44 @@ + + + + + + + VOD with VAST Ad Buffet With Error + <%= htmlWebpackPlugin.tags.headTags %> + + + + +

First AD doesn't have a Creative, fallbacks to second ad.

+ + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_ad_pod.tpl.html b/test/html/vod_vast_ad_pod.tpl.html new file mode 100644 index 00000000..92045d53 --- /dev/null +++ b/test/html/vod_vast_ad_pod.tpl.html @@ -0,0 +1,42 @@ + + + + + + + VOD with VAST Ad Pod + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_ad_pod_from_wrapper.tpl.html b/test/html/vod_vast_ad_pod_from_wrapper.tpl.html new file mode 100644 index 00000000..534941d4 --- /dev/null +++ b/test/html/vod_vast_ad_pod_from_wrapper.tpl.html @@ -0,0 +1,42 @@ + + + + + + + VOD with VAST Ad Pod - Loaded from a VAST Wrapper + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_ad_pod_truncated.tpl.html b/test/html/vod_vast_ad_pod_truncated.tpl.html new file mode 100644 index 00000000..e59b1b82 --- /dev/null +++ b/test/html/vod_vast_ad_pod_truncated.tpl.html @@ -0,0 +1,46 @@ + + + + + + + VOD with VAST Ad Pod - Truncated by maxTotalQuantity + <%= htmlWebpackPlugin.tags.headTags %> + + + + +

In this scenario VAST returns an Ad Pod with 3 Ads, but we limit the Ad Pods to play only 2 Ads

+ + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_followAdditionalWrappers_false.tpl.html b/test/html/vod_vast_followAdditionalWrappers_false.tpl.html new file mode 100644 index 00000000..1dec9b04 --- /dev/null +++ b/test/html/vod_vast_followAdditionalWrappers_false.tpl.html @@ -0,0 +1,43 @@ + + + + + + + VOD with VAST Wrapper - followAdditionalWrappers: false + <%= htmlWebpackPlugin.tags.headTags %> + + + + +

Expected: No AD should play, as it won't follow the nested VAST Wrappers

+ + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_followAdditionalWrappers_true.tpl.html b/test/html/vod_vast_followAdditionalWrappers_true.tpl.html new file mode 100644 index 00000000..63a3aeb4 --- /dev/null +++ b/test/html/vod_vast_followAdditionalWrappers_true.tpl.html @@ -0,0 +1,41 @@ + + + + + + + VOD with VAST Wrapper - followAdditionalWrappers: true + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_on-demand.html b/test/html/vod_vast_on-demand.html new file mode 100644 index 00000000..bbffafc0 --- /dev/null +++ b/test/html/vod_vast_on-demand.html @@ -0,0 +1,65 @@ + + + + + + + VOD with VAST loaded on demand + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +
    +
  • midRoll 1 (Linear) - Will be loaded on video start, since its timer is set to 3
  • +
  • midRoll 2 (Linear) - Will be loaded at 10 seconds, since its timer is set to 15
  • +
  • midRoll 3 (Non-Linear) - Will be loaded at 25 seconds, since its timer is set to 30
  • +
  • postRoll (Linear) - Will be loaded in any of the 5 last seconds of the video
  • +
+ +

Check DevTools to see the VAST requests being loaded on demand

+ +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_waterfall_false.tpl.html b/test/html/vod_vast_waterfall_false.tpl.html new file mode 100644 index 00000000..6bc8a28a --- /dev/null +++ b/test/html/vod_vast_waterfall_false.tpl.html @@ -0,0 +1,47 @@ + + + + + + + VOD with VAST Wrapper - VAST Waterfall Off (fallbackOnNoAd=false) + <%= htmlWebpackPlugin.tags.headTags %> + + + + +VOD with VAST Wrapper - VAST Waterfall Off (fallbackOnNoAd=false) +

This scenario represents the following statement from the fallbackOnNoAd description: "(...) instruction for using an available Ad when the requested VAST response returns no ads. (...) If false and the Wrapper represents an Ad in a Pod, the video player should move on to the next Ad in a Pod;".

+ + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_waterfall_false_http_error.tpl.html b/test/html/vod_vast_waterfall_false_http_error.tpl.html new file mode 100644 index 00000000..bc87dfff --- /dev/null +++ b/test/html/vod_vast_waterfall_false_http_error.tpl.html @@ -0,0 +1,47 @@ + + + + + + + VOD with VAST Wrapper - VAST Waterfall Off (fallbackOnNoAd=false) - Http Error + <%= htmlWebpackPlugin.tags.headTags %> + + + + +VOD with VAST Wrapper - VAST Waterfall Off (fallbackOnNoAd=false) +

This scenario represents the following statement from the fallbackOnNoAd description: "(...) instruction for using an available Ad when the requested VAST response returns no ads. (...) If false and the Wrapper represents an Ad in a Pod, the video player should move on to the next Ad in a Pod;".

+ + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_waterfall_true.tpl.html b/test/html/vod_vast_waterfall_true.tpl.html new file mode 100644 index 00000000..a3ea7031 --- /dev/null +++ b/test/html/vod_vast_waterfall_true.tpl.html @@ -0,0 +1,47 @@ + + + + + + + VOD with VAST Wrapper - VAST Waterfall On (fallbackOnNoAd=true) + <%= htmlWebpackPlugin.tags.headTags %> + + + + +VOD with VAST Wrapper - VAST Waterfall On (fallbackOnNoAd=true) +

This scenario represents the following statement from the fallbackOnNoAd description: "(...) instruction for using an available Ad when the requested VAST response returns no ads. If true, the video player should select from any stand-alone ads available.".

+ + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_waterfall_true_http_error.tpl.html b/test/html/vod_vast_waterfall_true_http_error.tpl.html new file mode 100644 index 00000000..98aeb93a --- /dev/null +++ b/test/html/vod_vast_waterfall_true_http_error.tpl.html @@ -0,0 +1,47 @@ + + + + + + + VOD with VAST Wrapper - VAST Waterfall On (fallbackOnNoAd=true) - Http Error + <%= htmlWebpackPlugin.tags.headTags %> + + + + +VOD with VAST Wrapper - VAST Waterfall On (fallbackOnNoAd=true) +

This scenario represents the following statement from the fallbackOnNoAd description: "(...) instruction for using an available Ad when the requested VAST response returns no ads. If true, the video player should select from any stand-alone ads available.".

+ + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_wrapper.tpl.html b/test/html/vod_vast_wrapper.tpl.html new file mode 100644 index 00000000..f34fa051 --- /dev/null +++ b/test/html/vod_vast_wrapper.tpl.html @@ -0,0 +1,41 @@ + + + + + + + VOD with VAST Wrapper + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vast_wrapper_cyclical.tpl.html b/test/html/vod_vast_wrapper_cyclical.tpl.html new file mode 100644 index 00000000..0cd34b7b --- /dev/null +++ b/test/html/vod_vast_wrapper_cyclical.tpl.html @@ -0,0 +1,43 @@ + + + + + + + VOD with VAST Wrapper - Cyclical reference + <%= htmlWebpackPlugin.tags.headTags %> + + + + +

Expected: No AD should play, as it will stop following the cyclical wrapper references

+ + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vpaid_linear.html b/test/html/vod_vpaid_linear.html new file mode 100644 index 00000000..deae0b89 --- /dev/null +++ b/test/html/vod_vpaid_linear.html @@ -0,0 +1,41 @@ + + + + + + + VOD with VPAID Linear + <%= htmlWebpackPlugin.tags.headTags %> + + + + + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_vpaid_linear_viewableImpresson.html b/test/html/vod_vpaid_linear_viewableImpresson.html new file mode 100644 index 00000000..09ea3099 --- /dev/null +++ b/test/html/vod_vpaid_linear_viewableImpresson.html @@ -0,0 +1,80 @@ + + + + + + + VOD with VPAID Linear Viewable Impression + <%= htmlWebpackPlugin.tags.headTags %> + + + + +

Viewable impression tests

+ +

Regular scenario: Here you should see the viewable impression being called in the network tab

+ + + +

Player off screen, so no viewable impression is going to be called

+

Order of what's happening

+
    +
  • Player starts -> 2 seconds later it will go off screen
  • +
  • Player starts playing ad after 5 seconds, there should be no viewable impression
  • +
  • The moment you scroll down to the player, the viewable impression should be called in the network tab (if the ad is still rolling)
  • +
+ + + +<%= htmlWebpackPlugin.tags.bodyTags %> + + + + + diff --git a/test/html/vod_basic_vast.tpl.html b/test/html/vod_vpaid_non_linear.html similarity index 61% rename from test/html/vod_basic_vast.tpl.html rename to test/html/vod_vpaid_non_linear.html index abeeba89..1cf6dfd8 100644 --- a/test/html/vod_basic_vast.tpl.html +++ b/test/html/vod_vpaid_non_linear.html @@ -5,7 +5,7 @@ - VOD with VAST + VOD with VPAID Non Linear <%= htmlWebpackPlugin.tags.headTags %>