diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json index 40e754b714c..8ef8e365f16 100644 --- a/dotcom-rendering/package.json +++ b/dotcom-rendering/package.json @@ -141,7 +141,7 @@ "@types/webpack-env": "1.18.0", "@types/webpack-node-externals": "3.0.0", "@types/yarnpkg__lockfile": "1.1.6", - "@types/youtube": "0.0.46", + "@types/youtube": "0.0.47", "@typescript-eslint/eslint-plugin": "5.61.0", "@typescript-eslint/eslint-plugin-tslint": "5.61.0", "@typescript-eslint/parser": "5.61.0", @@ -201,6 +201,7 @@ "html-minifier-terser": "7.1.0", "htmlparser2": "8.0.1", "inquirer": "8.2.5", + "is-mobile": "3.1.1", "jest": "29.5.0", "jest-environment-jsdom": "29.5.0", "jest-teamcity-reporter": "0.9.0", diff --git a/dotcom-rendering/src/components/YoutubeAtom/Picture.tsx b/dotcom-rendering/src/components/YoutubeAtom/Picture.tsx new file mode 100644 index 00000000000..db3ee3b7448 --- /dev/null +++ b/dotcom-rendering/src/components/YoutubeAtom/Picture.tsx @@ -0,0 +1,146 @@ +import { css } from '@emotion/react'; +import { breakpoints } from '@guardian/source-foundations'; +import type { ImageSource, RoleType, SrcSetItem } from '../../types/content'; + +type Props = { + imageSources: ImageSource[]; + role: RoleType; + alt: string; + height: number; + width: number; + isMainMedia?: boolean; +}; + +type ResolutionType = 'hdpi' | 'mdpi'; + +const getClosestSetForWidth = ( + desiredWidth: number, + inlineSrcSets: SrcSetItem[], +): SrcSetItem => { + // For a desired width, find the SrcSetItem which is the closest match + const sorted = inlineSrcSets.sort((a, b) => b.width - a.width); + return sorted.reduce((best, current) => { + if (current.width < best.width && current.width >= desiredWidth) { + return current; + } + return best; + }); +}; + +const getSourcesForRoleAndResolution = ( + imageSources: ImageSource[], + resolution: ResolutionType, +) => { + const srcSetItems = imageSources[0]?.srcSet ?? []; + + return resolution === 'hdpi' + ? srcSetItems.filter((set) => set.src.includes('dpr=2')) + : srcSetItems.filter((set) => !set.src.includes('dpr=2')); +}; + +const getFallback = ( + resolution: ResolutionType, + imageSources: ImageSource[], +): string | undefined => { + // Get the sources for this role and resolution + const sources: SrcSetItem[] = getSourcesForRoleAndResolution( + imageSources, + resolution, + ); + if (sources.length === 0) return undefined; + // The assumption here is readers on devices that do not support srcset are likely to be on poor + // network connections so we're going to fallback to a small image + return getClosestSetForWidth(300, sources).src; +}; + +const getSources = ( + resolution: ResolutionType, + imageSources: ImageSource[], +): string => { + // Get the sources for this role and resolution + const sources: SrcSetItem[] = getSourcesForRoleAndResolution( + imageSources, + resolution, + ); + + return sources.map((srcSet) => `${srcSet.src} ${srcSet.width}w`).join(','); +}; + +/** + * mobile: 320 + * mobileMedium: 375 + * mobileLandscape: 480 + * phablet: 660 + * tablet: 740 + * desktop: 980 + * leftCol: 1140 + * wide: 1300 + */ + +const getSizes = (role: RoleType, isMainMedia: boolean): string => { + switch (role) { + case 'inline': + return `(min-width: ${breakpoints.phablet}px) 620px, 100vw`; + case 'halfWidth': + return `(min-width: ${breakpoints.phablet}px) 300px, 50vw`; + case 'thumbnail': + return '140px'; + case 'immersive': + // Immersive MainMedia elements fill the height of the viewport, meaning + // on mobile devices even though the viewport width is small, we'll need + // a larger image to maintain quality. To solve this problem we're using + // the viewport height (vh) to calculate width. The value of 167vh + // relates to an assumed image ratio of 5:3 which is equal to + // 167 (viewport height) : 100 (viewport width). + + // Immersive body images stretch the full viewport width below wide, + // but do not stretch beyond 1300px after that. + return isMainMedia + ? `(orientation: portrait) 167vh, 100vw` + : `(min-width: ${breakpoints.wide}px) 1300px, 100vw`; + case 'supporting': + return `(min-width: ${breakpoints.wide}px) 380px, 300px`; + case 'showcase': + return isMainMedia + ? `(min-width: ${breakpoints.wide}px) 1020px, (min-width: ${breakpoints.leftCol}px) 940px, (min-width: ${breakpoints.tablet}px) 700px, (min-width: ${breakpoints.phablet}px) 660px, 100vw` + : `(min-width: ${breakpoints.wide}px) 860px, (min-width: ${breakpoints.leftCol}px) 780px, (min-width: ${breakpoints.phablet}px) 620px, 100vw`; + } +}; + +export const Picture = ({ + imageSources, + role, + alt, + height, + width, + isMainMedia = false, +}: Props): JSX.Element => { + const hdpiSources = getSources('hdpi', imageSources); + const mdpiSources = getSources('mdpi', imageSources); + const fallbackSrc = getFallback('hdpi', imageSources); + const sizes = getSizes(role, isMainMedia); + + return ( + + {/* HDPI Source (DPR2) - images in this srcset have `dpr=2&quality=45` in the url */} + + {/* MDPI Source (DPR1) - images in this srcset have `quality=85` in the url */} + + {alt} + + ); +}; diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtom.stories.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtom.stories.tsx new file mode 100644 index 00000000000..544c8a23f29 --- /dev/null +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtom.stories.tsx @@ -0,0 +1,1084 @@ +import type { ConsentState } from '@guardian/consent-management-platform/dist/types'; +import { ArticleDesign, ArticleDisplay, Pillar } from '@guardian/libs'; +import { useState } from 'react'; +import { YoutubeAtom } from './YoutubeAtom'; + +export default { + title: 'YoutubeAtom', + component: YoutubeAtom, +}; + +const containerStyle = { width: '800px', margin: '24px' }; +const containerStyleSmall = { width: '400px', margin: '24px' }; +const explainerStyle = { + fontSize: '20px', + margin: '0 0 20px', + width: '750px', +}; +const boldStyle = { fontWeight: 'bold' }; + +const OverlayAutoplayExplainer = () => ( +

+ If you're viewing this in the composed storybook please be aware the + autoplay functionality in the player will not work correctly.{' '} + + To view the correct functionality please view this story in the + external atoms-rendering storybook by clicking the link in the + sidebar. + +

+); + +const adTargeting: AdTargeting = { + disableAds: true, +}; + +const consentStateCanTarget: ConsentState = { + tcfv2: { + vendorConsents: { abc: false }, + addtlConsent: 'xyz', + gdprApplies: true, + tcString: 'YAAA', + consents: { '1': true, '2': true }, + eventStatus: 'useractioncomplete', + }, + canTarget: true, + framework: 'tcfv2', +}; + +export const NoConsent = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={false} + isMainMedia={false} + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + /> +
+ ); +}; + +export const NoOverlay = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={false} + isMainMedia={false} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + /> +
+ ); +}; + +NoOverlay.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const WithOverrideImage = (): JSX.Element => { + return ( +
+ + console.log(`analytics event ${e} called`), + ]} + duration={252} + consentState={consentStateCanTarget} + format={{ + theme: Pillar.News, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + overrideImage={[ + { + weighting: 'inline', + srcSet: [ + { + width: 500, + src: 'https://i.guim.co.uk/img/media/4b3808707ec341629932a9d443ff5a812cf4df14/0_309_1800_1081/master/1800.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctZGVmYXVsdC5wbmc&enable=upscale&s=aff4b8255693eb449f13070df88e9cac', + }, + ], + }, + ]} + shouldStick={false} + isMainMedia={false} + title="How to stop the spread of coronavirus" + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + /> +
+ ); +}; + +export const WithPosterImage = (): JSX.Element => { + return ( +
+ + console.log(`analytics event ${e} called`), + ]} + format={{ + theme: Pillar.Sport, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + duration={252} + consentState={consentStateCanTarget} + posterImage={[ + { + weighting: 'inline', + srcSet: [ + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/1000.jpg', + width: 1000, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/500.jpg', + width: 500, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/140.jpg', + width: 140, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/1920.jpg', + width: 1920, + }, + ], + }, + ]} + height={450} + width={800} + shouldStick={false} + isMainMedia={false} + videoCategory="documentary" + title="How Donald Trump’s broken promises failed Ohio | Anywhere but Washington" + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + /> +
+ ); +}; + +export const WithOverlayAndPosterImage = (): JSX.Element => { + return ( +
+ + console.log(`analytics event ${e} called`), + ]} + duration={252} + format={{ + theme: Pillar.Opinion, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + videoCategory="live" + overrideImage={[ + { + weighting: 'inline', + srcSet: [ + { + src: 'https://i.guim.co.uk/img/media/4b3808707ec341629932a9d443ff5a812cf4df14/0_309_1800_1081/master/1800.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctZGVmYXVsdC5wbmc&enable=upscale&s=aff4b8255693eb449f13070df88e9cac', + width: 1000, + }, + ], + }, + ]} + consentState={consentStateCanTarget} + posterImage={[ + { + weighting: 'inline', + srcSet: [ + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/1000.jpg', + width: 1000, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/500.jpg', + width: 500, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/140.jpg', + width: 140, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/1920.jpg', + width: 1920, + }, + ], + }, + ]} + height={450} + width={800} + shouldStick={false} + isMainMedia={false} + title="How Donald Trump’s broken promises failed Ohio" + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + kicker="Breaking News" + showTextOverlay={true} + /> +
+ ); +}; + +export const GiveConsent = (): JSX.Element => { + const [consented, setConsented] = useState(false); + return ( + <> + + +
+ console.log(`analytics event ${e} called`), + ]} + consentState={consented ? consentStateCanTarget : undefined} + duration={252} + format={{ + theme: Pillar.News, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + overrideImage={[ + { + weighting: 'inline', + srcSet: [ + { + width: 500, + src: 'https://i.guim.co.uk/img/media/4b3808707ec341629932a9d443ff5a812cf4df14/0_309_1800_1081/master/1800.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctZGVmYXVsdC5wbmc&enable=upscale&s=aff4b8255693eb449f13070df88e9cac', + }, + ], + }, + ]} + height={450} + width={800} + shouldStick={false} + isMainMedia={false} + title="How to stop the spread of coronavirus" + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + /> +
+ + ); +}; + +export const Sticky = (): JSX.Element => { + return ( +
+
Scroll down...
+
+ console.log(`analytics event ${e} called`), + ]} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + isMainMedia={true} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + shouldPauseOutOfView={true} + /> +
+
+ ); +}; + +export const StickyMainMedia = (): JSX.Element => { + return ( +
+
Scroll down...
+
+ console.log(`analytics event ${e} called`), + ]} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + isMainMedia={true} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + /> +
+
+ ); +}; + +/** + * Tests duplicate YoutubeAtoms on the same page. + * Players should play independently. + * If another video is played any other playing video should pause. + */ +export const DuplicateVideos = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + /> +
+ console.log(`analytics event ${e} called`), + ]} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + /> +
+ ); +}; + +DuplicateVideos.parameters = { + chromatic: { disableSnapshot: true }, +}; + +/** + * Tests multiple YoutubeAtoms on the same page. + * If a video is playing and the user scrolls past the video the video should stick. + * If another video is played any other playing video should pause. + * Closing a sticky video should pause the video. + */ +export const MultipleStickyVideos = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + isMainMedia={true} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + /> + console.log(`analytics event ${e} called`), + ]} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + isMainMedia={true} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + /> + console.log(`analytics event ${e} called`), + ]} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + isMainMedia={true} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + /> +
+ ); +}; + +MultipleStickyVideos.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const PausesOffscreen = (): JSX.Element => { + return ( +
+
Scroll down...
+ console.log(`analytics event ${e} called`), + ]} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={false} + isMainMedia={true} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={false} + abTestParticipations={{}} + adTargeting={adTargeting} + shouldPauseOutOfView={true} + /> +
+

It stopped playing!

+
+ ); +}; + +PausesOffscreen.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const NoConsentWithIma = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + adTargeting={adTargeting} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={false} + isMainMedia={false} + imaEnabled={true} + abTestParticipations={{}} + /> +
+ ); +}; + +export const AdFreeWithIma = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + adTargeting={{ disableAds: true }} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={false} + isMainMedia={false} + imaEnabled={true} + abTestParticipations={{}} + /> +
+ ); +}; + +export const NoOverlayWithIma = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + adTargeting={adTargeting} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={false} + isMainMedia={false} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={true} + abTestParticipations={{}} + /> +
+ ); +}; + +export const WithOverrideImageWithIma = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + duration={252} + adTargeting={adTargeting} + consentState={consentStateCanTarget} + format={{ + theme: Pillar.News, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + overrideImage={[ + { + weighting: 'inline', + srcSet: [ + { + width: 500, + src: 'https://i.guim.co.uk/img/media/4b3808707ec341629932a9d443ff5a812cf4df14/0_309_1800_1081/master/1800.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctZGVmYXVsdC5wbmc&enable=upscale&s=aff4b8255693eb449f13070df88e9cac', + }, + ], + }, + ]} + shouldStick={false} + isMainMedia={false} + title="How to stop the spread of coronavirus" + imaEnabled={true} + abTestParticipations={{}} + /> +
+ ); +}; + +export const WithPosterImageWithIma = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + format={{ + theme: Pillar.Sport, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + duration={252} + adTargeting={adTargeting} + consentState={consentStateCanTarget} + posterImage={[ + { + weighting: 'inline', + srcSet: [ + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/1000.jpg', + width: 1000, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/500.jpg', + width: 500, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/140.jpg', + width: 140, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/1920.jpg', + width: 1920, + }, + ], + }, + ]} + height={450} + width={800} + shouldStick={false} + isMainMedia={false} + title="How Donald Trump’s broken promises failed Ohio | Anywhere but Washington" + imaEnabled={true} + abTestParticipations={{}} + /> +
+ ); +}; + +export const WithOverlayAndPosterImageWithIma = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + duration={252} + format={{ + theme: Pillar.Opinion, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + overrideImage={[ + { + weighting: 'inline', + srcSet: [ + { + src: 'https://i.guim.co.uk/img/media/4b3808707ec341629932a9d443ff5a812cf4df14/0_309_1800_1081/master/1800.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctZGVmYXVsdC5wbmc&enable=upscale&s=aff4b8255693eb449f13070df88e9cac', + width: 1000, + }, + ], + }, + ]} + adTargeting={adTargeting} + consentState={consentStateCanTarget} + posterImage={[ + { + weighting: 'inline', + srcSet: [ + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/1000.jpg', + width: 1000, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/500.jpg', + width: 500, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/140.jpg', + width: 140, + }, + { + src: 'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/1920.jpg', + width: 1920, + }, + ], + }, + ]} + height={450} + width={800} + shouldStick={false} + isMainMedia={false} + title="How Donald Trump’s broken promises failed Ohio" + imaEnabled={true} + abTestParticipations={{}} + /> +
+ ); +}; + +export const GiveConsentWithIma = (): JSX.Element => { + const [consented, setConsented] = useState(false); + return ( + <> + +
+ console.log(`analytics event ${e} called`), + ]} + adTargeting={adTargeting} + consentState={consented ? consentStateCanTarget : undefined} + duration={252} + format={{ + theme: Pillar.News, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + overrideImage={[ + { + weighting: 'inline', + srcSet: [ + { + width: 500, + src: 'https://i.guim.co.uk/img/media/4b3808707ec341629932a9d443ff5a812cf4df14/0_309_1800_1081/master/1800.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctZGVmYXVsdC5wbmc&enable=upscale&s=aff4b8255693eb449f13070df88e9cac', + }, + ], + }, + ]} + height={450} + width={800} + shouldStick={false} + isMainMedia={false} + title="How to stop the spread of coronavirus" + imaEnabled={true} + abTestParticipations={{}} + /> +
+ + ); +}; + +export const StickyWithIma = (): JSX.Element => { + return ( +
+
⬇️
+
+ console.log(`analytics event ${e} called`), + ]} + adTargeting={adTargeting} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + isMainMedia={true} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={true} + abTestParticipations={{}} + /> +
+
+ ); +}; + +export const StickyMainMediaWithIma = (): JSX.Element => { + return ( +
+
⬇️
+
+ console.log(`analytics event ${e} called`), + ]} + adTargeting={adTargeting} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + isMainMedia={true} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={true} + abTestParticipations={{}} + /> +
+
+ ); +}; + +export const DuplicateVideosWithIma = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + adTargeting={adTargeting} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + imaEnabled={true} + abTestParticipations={{}} + /> +
+ console.log(`analytics event ${e} called`), + ]} + adTargeting={adTargeting} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + imaEnabled={true} + abTestParticipations={{}} + /> +
+ ); +}; + +DuplicateVideos.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const MultipleStickyVideosWithIma = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + adTargeting={adTargeting} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + isMainMedia={true} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={true} + abTestParticipations={{}} + /> + console.log(`analytics event ${e} called`), + ]} + adTargeting={adTargeting} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + isMainMedia={true} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={true} + abTestParticipations={{}} + /> + console.log(`analytics event ${e} called`), + ]} + adTargeting={adTargeting} + consentState={consentStateCanTarget} + duration={252} + format={{ + theme: Pillar.Culture, + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + }} + height={450} + width={800} + shouldStick={true} + isMainMedia={true} + title="Rayshard Brooks: US justice system treats us like 'animals'" + imaEnabled={true} + abTestParticipations={{}} + /> +
+ ); +}; + +MultipleStickyVideosWithIma.parameters = { + chromatic: { disableSnapshot: true }, +}; diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtom.test.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtom.test.tsx new file mode 100644 index 00000000000..5a6d7498e27 --- /dev/null +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtom.test.tsx @@ -0,0 +1,291 @@ +import type { ConsentState } from '@guardian/consent-management-platform/dist/types'; +import { ArticleDesign, ArticleDisplay, Pillar } from '@guardian/libs'; +import '@testing-library/jest-dom/extend-expect'; +import { fireEvent, render } from '@testing-library/react'; +import type { ImageSource } from '../../types/content'; +import { YoutubeAtom } from './YoutubeAtom'; + +const overlayImage: ImageSource[] = [ + { + weighting: 'inline', + srcSet: [ + { + width: 500, + src: 'https://i.guim.co.uk/img/media/4b3808707ec341629932a9d443ff5a812cf4df14/0_309_1800_1081/master/1800.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctZGVmYXVsdC5wbmc&enable=upscale&s=aff4b8255693eb449f13070df88e9cac', + }, + ], + }, +]; + +const consentStateCanTarget: ConsentState = { + tcfv2: { + vendorConsents: { abc: false }, + addtlConsent: 'xyz', + gdprApplies: true, + tcString: 'YAAA', + consents: { '1': true, '2': true }, + eventStatus: 'useractioncomplete', + }, + canTarget: true, + framework: 'tcfv2', +}; + +describe('YoutubeAtom', () => { + it('Player initialises when no overlay and has consent state', () => { + const atom = ( + + ); + const { getByTestId } = render(atom); + const playerDiv = getByTestId('youtube-video-ZCvZmYlQD8-xyz'); + expect(playerDiv).toBeInTheDocument(); + }); + + it('Player initialises when overlay clicked and has consent state', () => { + const atom = ( + + ); + const { getByTestId } = render(atom); + const overlay = getByTestId('youtube-overlay-ZCvZmYlQD8-xyz'); + expect(overlay).toBeInTheDocument(); + + fireEvent.click(getByTestId('youtube-overlay-ZCvZmYlQD8-xyz')); + expect(overlay).not.toBeInTheDocument(); + + const playerDiv = getByTestId('youtube-video-ZCvZmYlQD8-xyz'); + expect(playerDiv).toBeInTheDocument(); + }); + + it('player div has correct title', () => { + const title = 'My Youtube video!'; + + const atom = ( + + ); + const { getByTestId } = render(atom); + const playerDiv = getByTestId('youtube-video-ZCvZmYlQD8-xyz'); + expect(playerDiv.title).toBe(title); + }); + + it('overlay has correct aria-label', () => { + const title = 'My Youtube video!'; + const atom = ( + + ); + const { getByTestId } = render(atom); + const overlay = getByTestId('youtube-overlay-ZCvZmYlQD8-xyz'); + const ariaLabel = overlay.getAttribute('aria-label'); + + expect(ariaLabel).toBe(`Play video: ${title}`); + }); + + it('shows a placeholder if overlay is missing', () => { + const atom = ( + + ); + const { getByTestId } = render(atom); + const placeholder = getByTestId('youtube-placeholder-ZCvZmYlQD8-xyz'); + expect(placeholder).toBeInTheDocument(); + }); + + it('shows an overlay if present', () => { + const atom = ( + + ); + const { getByTestId } = render(atom); + const overlay = getByTestId('youtube-overlay-ZCvZmYlQD8-xyz'); + expect(overlay).toBeInTheDocument(); + }); + + it('hides an overlay once it is clicked', () => { + const atom = ( + + ); + const { getByTestId } = render(atom); + const overlay = getByTestId('youtube-overlay-ZCvZmYlQD8-xyz'); + expect(overlay).toBeInTheDocument(); + + fireEvent.click(getByTestId('youtube-overlay-ZCvZmYlQD8-xyz')); + expect(overlay).not.toBeInTheDocument(); + }); + + it('when two Atoms - hides the overlay of the correct player if clicked', () => { + const atom = ( + <> + + + + ); + const { getByTestId } = render(atom); + const overlay1 = getByTestId('youtube-overlay-ZCvZmYlQD8-xyz'); + expect(overlay1).toBeInTheDocument(); + + fireEvent.click(getByTestId('youtube-overlay-ZCvZmYlQD8-xyz')); + expect(overlay1).not.toBeInTheDocument(); + + const overlay2 = getByTestId(`youtube-overlay-ZCvZmYlQD8_2-xyz`); + expect(overlay2).toBeInTheDocument(); + }); +}); diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtom.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtom.tsx new file mode 100644 index 00000000000..d4e4201c7de --- /dev/null +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtom.tsx @@ -0,0 +1,219 @@ +import type { Participations } from '@guardian/ab-core'; +import type { ConsentState } from '@guardian/consent-management-platform/dist/types'; +import type { ArticleFormat } from '@guardian/libs'; +import { useCallback, useState } from 'react'; +import type { ImageSource, RoleType } from '../../types/content'; +import { MaintainAspectRatio } from '../MaintainAspectRatio'; +import type { VideoCategory } from './YoutubeAtomOverlay'; +import { YoutubeAtomOverlay } from './YoutubeAtomOverlay'; +import { YoutubeAtomPlaceholder } from './YoutubeAtomPlaceholder'; +import { YoutubeAtomPlayer } from './YoutubeAtomPlayer'; +import { YoutubeAtomSticky } from './YoutubeAtomSticky'; + +export type VideoEventKey = + | 'play' + | '25' + | '50' + | '75' + | 'end' + | 'skip' + | 'cued' + | 'resume' + | 'pause'; + +type Props = { + elementId: string; + videoId: string; + overrideImage?: ImageSource[]; + posterImage?: ImageSource[]; + adTargeting?: AdTargeting; + consentState?: ConsentState; + height?: number; + width?: number; + title?: string; + alt: string; + role: RoleType; + duration?: number; // in seconds + origin?: string; + eventEmitters: Array<(event: VideoEventKey) => void>; + format: ArticleFormat; + shouldStick?: boolean; + isMainMedia?: boolean; + imaEnabled: boolean; + abTestParticipations: Participations; + videoCategory?: VideoCategory; + kicker?: string; + shouldPauseOutOfView?: boolean; + showTextOverlay?: boolean; +}; + +export const YoutubeAtom = ({ + elementId, + videoId, + overrideImage, + posterImage, + adTargeting, + consentState, + height = 259, + width = 460, + alt, + role, + title, + duration, + origin, + eventEmitters, + shouldStick, + isMainMedia, + imaEnabled, + abTestParticipations, + videoCategory, + kicker, + format, + shouldPauseOutOfView = false, + showTextOverlay = false, +}: Props): JSX.Element => { + const [overlayClicked, setOverlayClicked] = useState(false); + const [playerReady, setPlayerReady] = useState(false); + const [isActive, setIsActive] = useState(false); + const [isClosed, setIsClosed] = useState(false); + const [pauseVideo, setPauseVideo] = useState(false); + + const uniqueId = `${videoId}-${elementId}`; + const enableIma = + imaEnabled && + !!adTargeting && + !adTargeting.disableAds && + !!consentState && + consentState.canTarget; + + /** + * Update the isActive state based on video events + * + * @param {VideoEventKey} videoEvent the video event which triggers the callback + */ + const playerState = (videoEvent: VideoEventKey) => { + switch (videoEvent) { + case 'play': + case 'resume': + setPauseVideo(false); + setIsClosed(false); + setIsActive(true); + break; + case 'end': + case 'cued': + setIsActive(false); + break; + default: + break; + } + }; + + /** + * Combine the videoState and tracking event emitters + */ + const compositeEventEmitters = [playerState, ...eventEmitters]; + + const hasOverlay = !!(overrideImage ?? posterImage); + + /** + * Show an overlay if: + * + * - It exists + * + * AND + * + * - It hasn't been clicked + */ + const showOverlay = hasOverlay && !overlayClicked; + + /** + * Show a placeholder if: + * + * - We don't have an overlay OR the user has clicked the overlay + * + * AND + * + * - The player is not ready + */ + const showPlaceholder = (!hasOverlay ?? overlayClicked) && !playerReady; + + let loadPlayer; + if (!hasOverlay) { + // load the player if there is no overlay + loadPlayer = true; + } else if (overlayClicked) { + // load the player if the overlay has been clicked + loadPlayer = true; + } else { + loadPlayer = false; + } + + /** + * Create a stable callback as it will be a useEffect dependency in YoutubeAtomPlayer + */ + const playerReadyCallback = useCallback(() => setPlayerReady(true), []); + + return ( + + + {loadPlayer && consentState && adTargeting && ( + { + setIsActive(false); + }} + abTestParticipations={abTestParticipations} + /> + )} + {showOverlay && ( + setOverlayClicked(true)} + videoCategory={videoCategory} + kicker={kicker} + format={format} + showTextOverlay={showTextOverlay} + /> + )} + {showPlaceholder && ( + + )} + + + ); +}; diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomOverlay.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomOverlay.tsx new file mode 100644 index 00000000000..ecf33e1c703 --- /dev/null +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomOverlay.tsx @@ -0,0 +1,242 @@ +import { css } from '@emotion/react'; +import { + focusHalo, + from, + headline, + palette, + space, + textSans, +} from '@guardian/source-foundations'; +import { SvgMediaControlsPlay } from '@guardian/source-react-components'; +import { decidePalette } from '../../lib/decidePalette'; +import { formatTime } from '../../lib/formatTime'; +import type { ImageSource, RoleType } from '../../types/content'; +import type { Palette } from '../../types/palette'; +import { Picture } from './Picture'; + +export type VideoCategory = 'live' | 'documentary' | 'explainer'; + +type Props = { + uniqueId: string; + overrideImage?: ImageSource[]; + posterImage?: ImageSource[]; + height: number; + width: number; + alt: string; + role: RoleType; + duration?: number; // in seconds + title?: string; + onClick: () => void; + videoCategory?: VideoCategory; + kicker?: string; + format: ArticleFormat; + showTextOverlay?: boolean; +}; + +const overlayStyles = css` + background-size: cover; + background-position: 49% 49%; + background-repeat: no-repeat; + text-align: center; + height: 100%; + width: 100%; + position: absolute; + max-height: 100vh; + cursor: pointer; + border: 0; + padding: 0; + + img { + width: 100%; + height: 100%; + } + + /* hard code "overlay-play-button" to be able to give play button animation on focus/hover of overlay */ + :focus { + ${focusHalo} + .overlay-play-button { + transform: scale(1.15); + transition-duration: 300ms; + } + } + :hover { + .overlay-play-button { + transform: scale(1.15); + transition-duration: 300ms; + } + } +`; + +const svgStyles = css` + /* Nudge Icon to the right, so it appears optically centered + /* https://medium.com/design-bridges/optical-effects-9fca82b4cd9a#f9d2 */ + padding-left: ${space[2]}px; + svg { + transform-origin: center; + fill: ${palette.neutral[100]}; + height: 60px; + transform: scale(1.15); + transition-duration: 300ms; + } +`; +const playButtonStyles = css` + position: absolute; + top: 50%; + left: 50%; + margin-top: -40px; /* Half the height of the circle */ + margin-left: -40px; + background-color: rgba(18, 18, 18, 0.6); + border-radius: 100%; + height: 80px; + width: 80px; + transform: scale(1); + transition-duration: 300ms; + + display: flex; + align-items: center; + justify-content: center; +`; + +const pillStyles = css` + position: absolute; + top: ${space[2]}px; + right: ${space[2]}px; + ${textSans.xxsmall({ fontWeight: 'bold' })}; + background-color: rgba(0, 0, 0, 0.7); + color: ${palette.neutral[100]}; + border-radius: ${space[3]}px; + padding: 0 6px; + display: inline-flex; +`; + +const pillItemStyles = css` + /* Target all but the first element, and add a border */ + :nth-of-type(n + 2) { + border-left: 1px solid rgba(255, 255, 255, 0.5); + } +`; + +const pillTextStyles = css` + line-height: ${space[4]}px; + padding: ${space[1]}px 6px; +`; + +const liveStyles = css` + ::before { + content: ''; + width: 9px; + height: 9px; + border-radius: 50%; + background-color: ${palette.news[500]}; + display: inline-block; + position: relative; + margin-right: 0.1875rem; + } +`; + +const textOverlayStyles = css` + position: absolute; + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0.7) 25% + ); + width: 100%; + bottom: 0; + color: ${palette.neutral[100]}; + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: start; + padding: ${space[2]}px; + padding-top: ${space[9]}px; +`; + +const kickerStyles = (dcrPalette: Palette) => css` + color: ${dcrPalette.text.youtubeOverlayKicker}; + ${headline.xxxsmall({ fontWeight: 'bold' })}; + ${from.tablet} { + ${headline.xxsmall({ fontWeight: 'bold' })}; + } +`; + +const titleStyles = css` + ${headline.xxxsmall({ fontWeight: 'medium' })}; + ${from.tablet} { + ${headline.xxsmall({ fontWeight: 'medium' })}; + } +`; +const capitalise = (str: string): string => + str.charAt(0).toUpperCase() + str.slice(1); + +export const YoutubeAtomOverlay = ({ + uniqueId, + overrideImage, + posterImage, + height, + width, + alt, + role, + duration, + title, + onClick, + videoCategory, + kicker, + format, + showTextOverlay, +}: Props) => { + const id = `youtube-overlay-${uniqueId}`; + const hasDuration = duration !== undefined && duration > 0; + const showPill = !!videoCategory || hasDuration; + const isLive = videoCategory === 'live'; + const dcrPalette = decidePalette(format); + + return ( + + ); +}; diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlaceholder.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlaceholder.tsx new file mode 100644 index 00000000000..9fe6bebbafa --- /dev/null +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlaceholder.tsx @@ -0,0 +1,27 @@ +import { css } from '@emotion/react'; + +export const YoutubeAtomPlaceholder = ({ + uniqueId, +}: { + uniqueId: string; +}): JSX.Element => { + const id = `youtube-placeholder-${uniqueId}`; + return ( +
+ ); +}; diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx new file mode 100644 index 00000000000..f057012a070 --- /dev/null +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx @@ -0,0 +1,637 @@ +import type { Participations } from '@guardian/ab-core'; +import type { AdsConfig } from '@guardian/commercial'; +import { + buildAdsConfigWithConsent, + buildImaAdTagUrl, + disabledAds, +} from '@guardian/commercial'; +import type { ConsentState } from '@guardian/consent-management-platform/dist/types'; +import { log } from '@guardian/libs'; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import type { google } from './ima'; +import type { VideoEventKey } from './YoutubeAtom'; +import type { PlayerListenerName } from './YoutubePlayer'; +import { YouTubePlayer } from './YoutubePlayer'; + +export declare class ImaManager { + constructor( + player: YT.Player, + id: string, + adContainerId: string, + makeAdsRequestCallback: ( + adsRequest: { adTagUrl: string }, + adsRenderingSettings: google.ima.AdsRenderingSettings, + ) => void, + ); + getAdsLoader: () => google.ima.AdsLoader; + getAdsManager: () => google.ima.AdsManager; +} + +type Props = { + uniqueId: string; + videoId: string; + adTargeting: AdTargeting; + consentState: ConsentState; + height: number; + width: number; + title?: string; + origin?: string; + eventEmitters: Array<(event: VideoEventKey) => void>; + autoPlay: boolean; + onReady: () => void; + enableIma: boolean; + pauseVideo: boolean; + deactivateVideo: () => void; + abTestParticipations: Participations; + kicker?: string; +}; + +type CustomPlayEventDetail = { uniqueId: string }; +const customPlayEventName = 'video:play'; + +type ProgressEvents = { + hasSentPlayEvent: boolean; + hasSent25Event: boolean; + hasSent50Event: boolean; + hasSent75Event: boolean; + hasSentEndEvent: boolean; +}; + +/** + * E.g. + * name: onReady, onStateChange, etc... + * listener: YT.PlayerEventHandler, YT.PlayerEventHandler + */ +type PlayerListener = { + name: T; + listener: NonNullable; +}; + +type PlayerListeners = Array>; + +/** + * Given a YT.PlayerEventHandler, (e.g. PlayerEventHandler) + * return its event type (e.g. OnStateChangeEvent) + */ +type ExtractEventType = T extends YT.PlayerEventHandler ? X : never; + +const dispatchCustomPlayEvent = (uniqueId: string) => { + document.dispatchEvent( + new CustomEvent(customPlayEventName, { + detail: { uniqueId }, + }), + ); +}; + +/** + * ProgressEvents are a ref, see below + */ +const createOnStateChangeListener = + ( + videoId: string, + uniqueId: string, + progressEvents: ProgressEvents, + eventEmitters: Props['eventEmitters'], + ): YT.PlayerEventHandler => + (event) => { + const loggerFrom = 'YoutubeAtomPlayer onStateChange'; + log('dotcom', { + from: loggerFrom, + videoId, + event, + }); + + /** + * event.target is the actual underlying YT player + */ + const player = event.target; + + if (event.data === YT.PlayerState.PLAYING) { + /** + * Emit video play event so other components + * get aware when a video is played + */ + dispatchCustomPlayEvent(uniqueId); + + if (!progressEvents.hasSentPlayEvent) { + log('dotcom', { + from: loggerFrom, + videoId, + msg: 'start play', + event, + }); + for (const eventEmitter of eventEmitters) eventEmitter('play'); + progressEvents.hasSentPlayEvent = true; + + /** + * Set a timeout to check progress again in the future + */ + setTimeout(() => { + checkProgress(); + }, 3000); + } else { + log('dotcom', { + from: loggerFrom, + videoId, + msg: 'resume', + event, + }); + for (const eventEmitter of eventEmitters) + eventEmitter('resume'); + } + + const checkProgress = () => { + const currentTime = player.getCurrentTime(); + const duration = player.getDuration(); + + if (!duration || !currentTime) return; + + const percentPlayed = (currentTime / duration) * 100; + + if (!progressEvents.hasSent25Event && 25 < percentPlayed) { + log('dotcom', { + from: loggerFrom, + videoId, + msg: 'played 25%', + event, + }); + for (const eventEmitter of eventEmitters) + eventEmitter('25'); + progressEvents.hasSent25Event = true; + } + + if (!progressEvents.hasSent50Event && 50 < percentPlayed) { + log('dotcom', { + from: loggerFrom, + videoId, + msg: 'played 50%', + event, + }); + for (const eventEmitter of eventEmitters) + eventEmitter('50'); + progressEvents.hasSent50Event = true; + } + + if (!progressEvents.hasSent75Event && 75 < percentPlayed) { + log('dotcom', { + from: loggerFrom, + videoId, + msg: 'played 75%', + event, + }); + for (const eventEmitter of eventEmitters) + eventEmitter('75'); + progressEvents.hasSent75Event = true; + } + + const currentPlayerState = player.getPlayerState(); + + if (currentPlayerState !== YT.PlayerState.ENDED) { + /** + * Set a timeout to check progress again in the future + */ + setTimeout(() => checkProgress(), 3000); + } + + return null; + }; + } + + if (event.data === YT.PlayerState.PAUSED) { + log('dotcom', { + from: loggerFrom, + videoId, + msg: 'pause', + event, + }); + for (const eventEmitter of eventEmitters) eventEmitter('pause'); + } + + if (event.data === YT.PlayerState.CUED) { + log('dotcom', { + from: loggerFrom, + videoId, + msg: 'cued', + event, + }); + for (const eventEmitter of eventEmitters) eventEmitter('cued'); + progressEvents.hasSentPlayEvent = false; + } + + if ( + event.data === YT.PlayerState.ENDED && + !progressEvents.hasSentEndEvent + ) { + log('dotcom', { + from: loggerFrom, + videoId, + msg: 'ended', + event, + }); + for (const eventEmitter of eventEmitters) eventEmitter('end'); + progressEvents.hasSentEndEvent = true; + progressEvents.hasSentPlayEvent = false; + } + }; + +/** + * returns an onReady listener + */ +const createOnReadyListener = + ( + videoId: string, + onReadyCallback: () => void, + setPlayerReady: () => void, + instantiateImaManager?: (player: YT.Player) => void, + ) => + (event: YT.PlayerEvent) => { + log('dotcom', { + from: 'YoutubeAtomPlayer onReady', + videoId, + msg: 'Ready', + event, + }); + /** + * Callback to notify YoutubeAtom that the player is ready + */ + onReadyCallback(); + + /** + * Callback to set value of playerReady ref + */ + setPlayerReady(); + + /** + * instantiate IMA manager if IMA enabled + */ + if (instantiateImaManager) { + try { + instantiateImaManager(event.target); + } catch (e) { + log('commercial', 'error instantiating IMA manager:', e); + } + } + }; + +const createInstantiateImaManager = + ( + uniqueId: string, + id: string, + adContainerId: string, + adTargeting: AdTargeting | undefined, + consentState: ConsentState, + abTestParticipations: Participations, + imaManager: React.MutableRefObject, + adsManager: React.MutableRefObject, + ) => + (player: YT.Player) => { + const adTargetingEnabled = adTargeting && !adTargeting.disableAds; + const adUnit = + adTargetingEnabled && adTargeting.adUnit ? adTargeting.adUnit : ''; + const customParams = adTargetingEnabled ? adTargeting.customParams : {}; + + const makeAdsRequestCallback = ( + adsRequest: { adTagUrl: string }, + adsRenderingSettings: google.ima.AdsRenderingSettings, + ) => { + adsRequest.adTagUrl = buildImaAdTagUrl({ + adUnit, + customParams, + consentState, + clientSideParticipations: abTestParticipations, + }); + if (window.google) { + adsRenderingSettings.uiElements = [ + window.google.ima.UiElements.AdAttribution, + ]; + } + }; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- @types/youtube insists that window.YT cannot be undefined. This is very much untrue. + if (typeof window.YT?.ImaManager !== 'undefined') { + imaManager.current = new window.YT.ImaManager( + player, + id, + adContainerId, + makeAdsRequestCallback, + ); + const adsLoader = imaManager.current.getAdsLoader(); + + const onAdsManagerLoaded = () => { + adsManager.current = imaManager.current?.getAdsManager(); + if (window.google) { + adsManager.current?.addEventListener( + window.google.ima.AdEvent.Type.Started, + () => { + dispatchCustomPlayEvent(uniqueId); + }, + ); + } + }; + + if (window.google) { + adsLoader.addEventListener( + window.google.ima.AdsManagerLoadedEvent.Type + .AdsManagerLoaded, + onAdsManagerLoaded, + false, + ); + } + } else { + console.warn( + 'YT.ImaManager is undefined, probably because the youtube iframe_api script was fetched from ' + + "a domain that isn't allow-listed (theguardian.com, thegulocal.com, gutools.co.uk). " + + 'If you are running an app locally, use dev-nginx to proxy one of these domains to localhost.', + ); + } + }; + +export const YoutubeAtomPlayer = ({ + uniqueId, + videoId, + adTargeting, + consentState, + height, + width, + title, + origin, + eventEmitters, + autoPlay, + onReady, + enableIma, + pauseVideo, + deactivateVideo, + abTestParticipations, + kicker, +}: Props): JSX.Element => { + /** + * useRef for player and progressEvents + * Provides mutable persistent state for the player across renders + * Does not cause re-renders on update + */ + const player = useRef(); + const progressEvents = useRef({ + hasSentPlayEvent: false, + hasSent25Event: false, + hasSent50Event: false, + hasSent75Event: false, + hasSentEndEvent: false, + }); + + const [playerReady, setPlayerReady] = useState(false); + const playerReadyCallback = useCallback(() => setPlayerReady(true), []); + const playerListeners = useRef([]); + /** + * A map ref with a key of eventname and a value of eventHandler + */ + const customListeners = useRef< + Record) => void> + >({}); + + const imaManager = useRef(); + const adsManager = useRef(); + + const id = `youtube-video-${uniqueId}`; + const imaAdContainerId = `ima-ad-container-${uniqueId}`; + + /** + * Initialise player useEffect + */ + useEffect( + () => { + if (!player.current) { + log('dotcom', { + from: 'YoutubeAtomPlayer initialise', + videoId, + }); + + const adsConfig: AdsConfig = + adTargeting.disableAds || enableIma + ? disabledAds + : buildAdsConfigWithConsent({ + adUnit: adTargeting.adUnit, + clientSideParticipations: abTestParticipations, + consentState, + customParams: adTargeting.customParams, + isAdFreeUser: false, + }); + + const embedConfig = { + relatedChannels: [], + adsConfig, + enableIma, + /** + * YouTube recommends disabling related videos when IMA is enabled + */ + disableRelatedVideos: enableIma, + }; + + const instantiateImaManager = enableIma + ? createInstantiateImaManager( + uniqueId, + id, + imaAdContainerId, + adTargeting, + consentState, + abTestParticipations, + imaManager, + adsManager, + ) + : undefined; + + const onReadyListener = createOnReadyListener( + videoId, + onReady, + playerReadyCallback, + instantiateImaManager, + ); + + const onStateChangeListener = createOnStateChangeListener( + videoId, + uniqueId, + progressEvents.current, + eventEmitters, + ); + + player.current = new YouTubePlayer(id, { + height, + width, + videoId, + playerVars: { + modestbranding: 1, + origin, + playsinline: 1, + rel: 0, + }, + embedConfig, + events: { + onReady: onReadyListener, + onStateChange: onStateChangeListener, + }, + }); + + /** + * Pause the current video when another video is played + */ + const handleCustomPlayEvent = ( + event: CustomEventInit, + ) => { + if (event.detail) { + const playedVideoId = event.detail.uniqueId; + const thisVideoId = uniqueId; + + if (playedVideoId !== thisVideoId) { + const playerStatePromise = + player.current?.getPlayerState(); + void playerStatePromise?.then((playerState) => { + if ( + playerState && + playerState === YT.PlayerState.PLAYING + ) { + void player.current?.pauseVideo(); + } + }); + // pause ima ads playing on other videos + adsManager.current?.pause(); + // mark player as inactive + deactivateVideo(); + } + } + }; + + /** + * add listener for custom play event + */ + document.addEventListener( + customPlayEventName, + handleCustomPlayEvent, + ); + + customListeners.current[customPlayEventName] = + handleCustomPlayEvent; + + playerListeners.current.push( + { name: 'onReady', listener: onReadyListener }, + { name: 'onStateChange', listener: onStateChangeListener }, + ); + } + }, + /** + * useEffect dependencies are mostly static but added to array for correctness + */ + [ + adTargeting, + autoPlay, + consentState, + eventEmitters, + height, + onReady, + origin, + videoId, + width, + enableIma, + abTestParticipations, + uniqueId, + id, + imaAdContainerId, + playerReadyCallback, + deactivateVideo, + ], + ); + + /** + * Autoplay useEffect + */ + useEffect(() => { + if (playerReady && autoPlay) { + /** + * Autoplay is determined by the parent + * Typically true when there is a preceding overlay + */ + log('dotcom', { + from: 'YoutubeAtomPlayer autoplay', + videoId, + msg: 'Playing video', + }); + void player.current?.playVideo(); + } + }, [playerReady, autoPlay, videoId]); + + /** + * Player pause useEffect + */ + useEffect(() => { + /** + * if the 'pauseVideo' prop is true this should pause the video + * + * 'pauseVideo' is controlled by the close sticky video button + */ + if (pauseVideo) { + void player.current?.pauseVideo(); + } + }, [pauseVideo]); + + /** + * Unregister listeners useLayoutEffect + */ + useLayoutEffect(() => { + /** + * Unregister listeners before component unmount + * + * An empty dependency array will call its cleanup on unmount. + * + * Use useLayoutEffect to ensure the cleanup function is run + * before the component is removed from the DOM. Usually clean up + * functions will run after the render and commit phase. + * + * If we attempt to unregister listeners after the component is + * removed from the DOM the YouTube API logs a warning to the console. + */ + + const playerListenerNames = playerListeners.current; + const customListenersNames = customListeners.current; + + return () => { + for (const playerListener of playerListenerNames) { + type T = ExtractEventType; + player.current?.removeEventListener( + playerListener.name, + playerListener.listener, + ); + } + + for (const [eventName, eventHandler] of Object.entries( + customListenersNames, + )) { + document.removeEventListener(eventName, eventHandler); + } + }; + }, []); + + /** + * An element for the YouTube iFrame to hook into the dom + */ + return ( + <> +
+ {enableIma && ( +
+ )} + + ); +}; diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomSticky.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomSticky.tsx new file mode 100644 index 00000000000..e5bea063e05 --- /dev/null +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomSticky.tsx @@ -0,0 +1,286 @@ +import { css } from '@emotion/react'; +import { log } from '@guardian/libs'; +import { from, neutral, space } from '@guardian/source-foundations'; +import { SvgCross } from '@guardian/source-react-components'; +import detectMobile from 'is-mobile'; +import { useEffect, useState } from 'react'; +import { submitComponentEvent } from '../../client/ophan/ophan'; +import { useIsInView } from '../../lib/useIsInView'; +import type { VideoEventKey } from './YoutubeAtom'; + +const buttonStyles = css` + position: absolute; + left: -36px; + top: 0; + z-index: 22; + background-color: ${neutral[7]}; + height: 32px; + width: 32px; + border-radius: 50%; + border: 0; + padding: 0; + cursor: pointer; + display: none; + justify-content: center; + align-items: center; + transition: transform 0.2s; + + &:hover { + transform: scale(1.15); + } + + svg { + fill: ${neutral[100]}; + } +`; + +/** + * This extended hover area allows users to click the close video button more easily + */ +const hoverAreaStyles = (fullWidthOverlay: boolean) => { + const hoverAreaWidth = 37; + + return css` + position: absolute; + top: -4px; + bottom: 0; + left: -${hoverAreaWidth}px; + width: ${hoverAreaWidth}px; + + &:hover button { + display: flex; + } + + width: ${fullWidthOverlay + ? `calc(100% + ${hoverAreaWidth}px)` + : `${hoverAreaWidth}px`}; + `; +}; + +const stickyStyles = (showButton: boolean) => css` + @keyframes fade-in-up { + from { + transform: translateY(100%); + opacity: 0; + } + + to { + transform: translateY(0%); + opacity: 1; + } + } + + position: fixed; + bottom: 20px; + width: 215px; + z-index: 21; + animation: fade-in-up 1s ease both; + + ${from.tablet} { + width: 300px; + } + + figcaption { + display: none; + } + + button { + display: ${showButton ? 'flex' : 'none'}; + } + + &:hover button { + display: flex; + } +`; + +const stickyContainerStyles = (isMainMedia?: boolean) => { + return css` + height: 192px; + position: relative; + display: flex; + justify-content: flex-end; + padding-right: ${isMainMedia ? `${space[5]}px` : 0}; + + ${from.phablet} { + height: 349px; + } + `; +}; + +type Props = { + uniqueId: string; + videoId: string; + eventEmitters: Array<(event: VideoEventKey) => void>; + shouldStick?: boolean; + setPauseVideo: (state: boolean) => void; + isActive: boolean; + isMainMedia?: boolean; + children: JSX.Element; + isClosed: boolean; + setIsClosed: (state: boolean) => void; + shouldPauseOutOfView: boolean; +}; + +const isMobile = detectMobile({ tablet: true }); + +export const YoutubeAtomSticky = ({ + uniqueId, + videoId, + eventEmitters, + shouldStick, + setPauseVideo, + isActive, + isMainMedia, + children, + isClosed, + setIsClosed, + shouldPauseOutOfView, +}: Props): JSX.Element => { + const [isSticky, setIsSticky] = useState(false); + const [stickEventSent, setStickEventSent] = useState(false); + const [showOverlay, setShowOverlay] = useState(isMobile); + + const [isIntersecting, setRef] = useIsInView({ + threshold: 0.5, + repeat: true, + debounce: true, + }); + + /** + * Click handler for the sticky video close button + */ + const handleCloseClick = () => { + // unstick the video + setIsSticky(false); + // reset the sticky event sender + setStickEventSent(false); + // pause the video + setPauseVideo(true); + // set isClosed so that player won't restick + setIsClosed(true); + + // log a 'close' event + log('dotcom', { + from: `YoutubeAtom handleCloseClick`, + videoId, + msg: 'Close', + }); + + // submit a 'close' event to Ophan + submitComponentEvent({ + component: { + componentType: 'STICKY_VIDEO', + id: videoId, + }, + action: 'CLOSE', + }); + }; + + /** + * keydown event handler + * + * closes sticky video when Escape key is pressed + */ + const handleKeydown = (e: { key: string }) => { + if (e.key === 'Escape') { + handleCloseClick(); + } + }; + + /** + * useEffect to create keydown listener + */ + useEffect(() => { + window.addEventListener('keydown', handleKeydown); + return () => window.removeEventListener('keydown', handleKeydown); + }); + + /** + * useEffect for the sticky state + */ + useEffect(() => { + if (shouldStick) setIsSticky(isActive && !isIntersecting && !isClosed); + }, [isIntersecting, isActive, shouldStick, isClosed]); + + /** + * useEffect for the pausing youtubeAtoms that are out of view + */ + useEffect(() => { + // Sticky-ness should take precedence over pausing + if (!shouldStick && shouldPauseOutOfView) + setPauseVideo(isActive && !isIntersecting && !isClosed); + }, [ + isIntersecting, + shouldStick, + isActive, + shouldPauseOutOfView, + isClosed, + setPauseVideo, + ]); + + /** + * useEffect for the stick events + */ + useEffect(() => { + if (isSticky && !stickEventSent) { + setStickEventSent(true); + + log('dotcom', { + from: 'YoutubeAtom stick useEffect', + videoId, + msg: 'Stick', + }); + + submitComponentEvent({ + component: { + componentType: 'STICKY_VIDEO', + id: videoId, + }, + action: 'STICK', + }); + } + }, [isSticky, stickEventSent, videoId, eventEmitters]); + + /** + * useEffect for mobile only sticky overlay + * + * this allows mobile uses to tap to reveal the close button + */ + useEffect(() => { + setShowOverlay(isMobile && isSticky); + }, [isSticky]); + + const showCloseButton = !showOverlay && isMobile; + + return ( +
+
+ {children} + {isSticky && ( + <> + setShowOverlay(false)} + onKeyDown={() => setShowOverlay(false)} + role="button" + tabIndex={0} + /> + + + )} +
+
+ ); +}; diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubePlayer.ts b/dotcom-rendering/src/components/YoutubeAtom/YoutubePlayer.ts new file mode 100644 index 00000000000..ef229454a33 --- /dev/null +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubePlayer.ts @@ -0,0 +1,87 @@ +import type { AdsConfig } from '@guardian/commercial'; +import { log } from '@guardian/libs'; +import { loadYouTubeAPI } from './loadYouTubeIframeApi'; + +type EmbedConfig = { + embedConfig: { + relatedChannels: string[]; + adsConfig: AdsConfig; + enableIma: boolean; + }; +}; + +type PlayerOptions = YT.PlayerOptions & EmbedConfig; + +// PlayerEvent, OnStateChangeEvent, etc. +export type PlayerListenerName = keyof YT.Events; + +export class YouTubePlayer { + playerPromise: Promise; + private _player?: YT.Player; + + constructor(id: string, playerOptions: PlayerOptions) { + this.playerPromise = this.setPlayer(id, playerOptions); + } + + private async setPlayer(id: string, playerOptions: PlayerOptions) { + const YTAPI = await loadYouTubeAPI(playerOptions.embedConfig.enableIma); + const playerPromise = new Promise((resolve, reject) => { + try { + this._player = new YTAPI.Player(id, playerOptions); + resolve(this._player); + } catch (e) { + this.logError(e as Error); + reject(e); + } + }); + return playerPromise; + } + + private logError(e: Error) { + log('dotcom', `YouTubePlayer failed to load: ${e.message}`); + } + + getPlayerState(): Promise { + return this.playerPromise + .then((player) => { + return player.getPlayerState(); + }) + .catch((e: Error) => this.logError(e)); + } + + playVideo(): Promise { + return this.playerPromise + .then((player) => { + player.playVideo(); + }) + .catch((e: Error) => this.logError(e)); + } + + pauseVideo(): Promise { + return this.playerPromise + .then((player) => { + player.pauseVideo(); + }) + .catch((e: Error) => this.logError(e)); + } + + stopVideo(): Promise { + return this.playerPromise + .then((player) => { + player.stopVideo(); + }) + .catch((e: Error) => this.logError(e)); + } + + removeEventListener( + eventName: PlayerListenerName, + listener: YT.PlayerEventHandler, + ): void { + /** + * If the YouTube API hasn't finished loading, + * this._player may be undefined in which case removeEventListener + * will fail silently. + */ + this._player?.removeEventListener(eventName, listener); + } +} diff --git a/dotcom-rendering/src/components/YoutubeAtom/ima.d.ts b/dotcom-rendering/src/components/YoutubeAtom/ima.d.ts new file mode 100644 index 00000000000..565bff2b1ad --- /dev/null +++ b/dotcom-rendering/src/components/YoutubeAtom/ima.d.ts @@ -0,0 +1,1299 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/* Thanks, https://github.com/alugha/typed-ima-sdk */ +export namespace google { + /** + * The Google IMA SDK for HTML5 V3 allows developers to request and track VAST ads in a HTML5 video environment. For platform compatibility information and a detailed list of the video ad features supported by each of the IMA SDKs, see Support and Compatibility. + * + * Download the code samples to assist with implementing the IMA HTML5 SDK. + */ + namespace ima { + /** + * An ad class that's extended by classes representing different ad types. + */ + interface Ad { + /** + * Ad ID is used to synchronize master ad and companion ads. + * @returns The ID of the ad, or the empty string if this information is unavailable. + */ + getAdId(): string; + /** + * Returns the ad's pod information. + * @returns The ad's pod information. + */ + getAdPodInfo(): AdPodInfo; + /** + * The source ad server information included in the ad response. + * @returns The source ad server of the ad, or the empty string if this information is unavailable. + */ + getAdSystem(): string; + /** + * The advertiser name as defined by the serving party. + * @returns The advertiser name, or the empty string if this information is unavailable. + */ + getAdvertiserName(): string; + /** + * Identifies the API needed to execute the ad. This corresponds with the apiFramework specified in vast. + * @returns The API framework need to execute the ad, or null if this information is unavailable. + */ + getApiFramework(): string | null; + /** + * Gets the companion ads for this ad based on companion ad slot size. Optionally, advanced selection settings are accepted. Note that this method will only return non-empty array for ad instances acquired on or after STARTED event. Specifically, ads from the LOADED event will return an empty array. + * @param adSlotWidth Width of the companion ad slot. + * @param adSlotHeight Height of the companion ad slot. + * @param settings The selection settings for companion ads. + * @returns Array of companion ads that matches the settings and the slot size. + */ + getCompanionAds( + adSlotWidth: number, + adSlotHeight: number, + settings?: CompanionAdSelectionSettings, + ): CompanionAd[]; + /** + * Returns the content type of the currently selected creative, or empty string if no creative is selected or the content type is unavailable. For linear ads, the content type is only going to be available after the START event, when the media file is selected. + * @returns The content type, empty string if not available. + */ + getContentType(): string; + /** + * Returns the ISCI (Industry Standard Commercial Identifier) code for an ad, or empty string if the code is unavailable. This is the Ad-ID of the creative in the VAST response. + */ + getCreativeAdId(): string; + /** + * Retrieves the ID of the selected creative for the ad. + * @returns The ID of the selected creative for the ad, or the empty string if this information is unavailable. + */ + getCreativeId(): string; + /** + * Returns the first deal ID present in the wrapper chain for the current ad, starting from the top. Returns the empty string if unavailable. + */ + getDealId(): string; + /** + * Returns the description of this ad from the VAST response. + * @returns The description, empty if not specified. + */ + getDescription(): string; + /** + * Returns the duration of the selected creative, or -1 for non-linear creatives. + * @returns The selected creative duration in seconds, -1 if non-linear. + */ + getDuration(): number; + /** + * Returns the height of the selected non-linear creative. + * @returns The height of the selected non-linear creative or 0 for a linear creative. + */ + getHeight(): number; + /** + * Returns the URL of the media file chosen from the ad based on the media selection settings currently in use. Returns null if this information is unavailable. Available on STARTED event. + */ + getMediaUrl(): string | null; + /** + * Returns the minimum suggested duration in seconds that the nonlinear creative should be displayed. Returns -2 if the minimum suggested duration is unknown. For linear creative it returns the entire duration of the ad. + * @returns The minimum suggested duration in seconds that a creative should be displayed. + */ + getMinSuggestedDuration(): number; + /** + * The number of seconds of playback before the ad becomes skippable. -1 is returned for non skippable ads or if this is unavailable. + * @returns The offset in seconds, or -1. + */ + getSkipTimeOffset(): number; + /** + * Returns the URL associated with the survey for the given ad. Returns null if unavailable. + */ + getSurveyUrl(): string | null; + /** + * Returns the title of this ad from the VAST response. + * @returns The title, empty if not specified. + */ + getTitle(): string; + /** + * Gets custom parameters associated with the ad at the time of ad trafficking. + * @returns A mapping of trafficking keys to their values, or the empty Object if this information is not available. + */ + getTraffickingParameters(): any; + /** + * Gets custom parameters associated with the ad at the time of ad trafficking. Returns a raw string version of the parsed parameters from getTraffickingParameters. + * @returns Trafficking parameters, or the empty string if this information is not available. + */ + getTraffickingParametersString(): string; + /** + * Returns the UI elements that are being displayed when this ad is played. Refer to UiElements for possible elements of the array returned. + * @returns The UI elements being displayed. + */ + getUiElements(): UiElements[]; + /** + * The registry associated with cataloging the UniversalAdId of the selected creative for the ad. + * @returns Returns the registry value, or "unknown" if unavailable. + */ + getUniversalAdIdRegistry(): string; + /** + * The UniversalAdId of the selected creative for the ad. + * @returns Returns the id value or "unknown" if unavailable. + */ + getUniversalAdIdValue(): string; + /** + * Returns the VAST media height of the selected creative. + * @returns The VAST media height of the selected creative or 0 if none is selected. + */ + getVastMediaHeight(): number; + /** + * Returns the VAST media width of the selected creative. + * @returns The VAST media width of the selected creative or 0 if none is selected. + */ + getVastMediaWidth(): number; + /** + * Returns the width of the selected creative. + * @returns The width of the selected non-linear creative or 0 for a linear creative. + */ + getWidth(): number; + /** + * Ad IDs used for wrapper ads. The IDs returned starts at the inline ad (innermost) and traverses to the outermost wrapper ad. An empty array is returned if there are no wrapper ads. + * @returns The IDs of the ads, starting at the inline ad, or an empty array if there are no wrapper ads. + */ + getWrapperAdIds(): string[]; + /** + * Ad systems used for wrapper ads. The ad systems returned starts at the inline ad and traverses to the outermost wrapper ad. An empty array is returned if there are no wrapper ads. + * @returns The ad systems of the ads, starting at the inline ad, or an empty array if there are no wrapper ads. + */ + getWrapperAdSystems(): string[]; + /** + * Selected creative IDs used for wrapper ads. The creative IDs returned starts at the inline ad and traverses to the outermost wrapper ad. An empty array is returned if there are no wrapper ads. + * @returns The IDs of the ads' creatives, starting at the inline ad, or an empty array if there are no wrapper ads. + */ + getWrapperCreativeIds(): string[]; + /** + * Indicates whether the ad’s current mode of operation is linear or non-linear. If the value is true, it indicates that the ad is in linear playback mode; if false, it indicates non-linear mode. The player checks the linear property and updates its state according to the details of the ad placement. While the ad is in linear mode, the player pauses the content video. If linear is true initially, and the ad is a pre-roll (defined externally), the player may choose to delay loading the content video until near the end of the ad playback. + * @returns True if the ad is linear, false otherwise. + */ + isLinear(): boolean; + } + + /** + * This class represents a container for displaying ads. The SDK will automatically create structures inside the containerElement parameter to house video and overlay ads. + * + * When an instance of this class is created, it creates an IFRAME in the containerElement and loads the SDK core. This IFRAME must be preserved in order for the SDK to function properly. Once all ads have been played and the SDK is no longer needed, use the destroy() method to unload the SDK. + * + * The containerElement parameter must be an element that is part of the DOM. It is necessary to correctly position the containerElement in order for the ads to be displayed correctly. It is recommended to position it above the content video player and size it to cover the whole video player. Please refer to the SDK documentation for details about recommended implementations. + */ + class AdDisplayContainer { + /** + * + * @param containerElement The element to display the ads in. The element must be inserted into the DOM before creating ima.AdDisplayContainer. + * @param videoElement Specifies the alternative video ad playback element. We recommend always passing in your content video player. Refer to Custom Ad Playback for more information. + * @param clickTrackingElement Specifies the alternative video ad click element. Leave this null to let the SDK handle clicks. Even if supplied, the SDK will only use the custom click tracking element when non-AdSense/AdX creatives are displayed in environments that do not support UI elements overlaying a video player (e.g. iPhone or pre-4.0 Android). The custom click tracking element should never be rendered over the video player because it can intercept clicks to UI elements that the SDK renders. Also note that the SDK will not modify the visibility of the custom click tracking element. This means that if a custom click tracking element is supplied, it must be properly displayed when the linear ad is played. You can check ima.AdsManager.isCustomClickTrackingUsed when the google.ima.AdEvent.Type.STARTED event is fired to determine whether or not to display your custom click tracking element. If appropriate for your UI, you should hide the click tracking element when the google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED event fires. + */ + constructor( + containerElement: HTMLElement, + videoElement?: HTMLVideoElement, + clickTrackingElement?: HTMLElement, + ); + /** + * Destroys internal state and previously created DOM elements. The IMA SDK will be unloaded and no further calls to any APIs should be made. + */ + public destroy(): void; + /** + * Initializes the video playback. On mobile platforms, including iOS and Android browsers, first interaction with video playback is only allowed within a user action (a click or tap) to prevent unexpected bandwidth costs. Call this method as a direct result of a user action before starting the ad playback. This method has no effect on desktop platforms and when custom video playback is used. + */ + public initialize(): void; + } + + /** + * AdError surfaces information to the user about whether a failure occurred during ad loading or playing. The errorType accessor provides information about whether the error occurred during ad loading or ad playing. + */ + class AdError extends Error { + /** + * Constructs the ad error based on the error data. + * @param data The ad error message data. + * @returns The constructed ad error object. + */ + public static deserialize(data: any): AdError; + /** + * @returns The error code, as defined in google.ima.AdError.ErrorCode. + */ + public getErrorCode(): AdError.ErrorCode; + /** + * Returns the Error that caused this one. + * @returns Inner error that occurred during processing, or null if this information is unavailable. This error may either be a native error or an google.ima.AdError, a subclass of a native error. This may return null if the error that caused this one is not available. + */ + public getInnerError(): Error | null; + /** + * @returns The message for this error. + */ + public getMessage(): string; + /** + * @returns The type of this error, as defined in google.ima.AdError.Type. + */ + public getType(): string; + /** + * @returns If VAST error code is available, returns it, otherwise returns ima.AdError.ErrorCode.UNKNOWN_ERROR. + */ + public getVastErrorCode(): number; + /** + * Serializes an ad to JSON-friendly object for channel transmission. + * @returns The transmittable ad error. + */ + public serialize(): any; + public toString(): string; + } + + namespace AdError { + /** + * The possible error codes raised while loading or playing ads. + */ + enum ErrorCode { + /** + * There was a problem requesting ads from the server. VAST error code 1012 + */ + AdsRequestNetworkError = 1012, + /** + * There was an error with asset fallback. VAST error code 1021 + */ + AssetFallbackFailed = 1021, + /** + * The browser prevented playback initiated without user interaction. VAST error code 1205 + */ + AutoplayDisallowed = 1205, + /** + * A companion ad failed to load or render. VAST error code 603 + */ + CompanionAdLoadingFailed = 603, + /** + * Unable to display one or more required companions. The master ad is discarded since the required companions could not be displayed. VAST error code 602 + */ + CompanionRequiredError = 602, + /** + * There was a problem requesting ads from the server. VAST error code 1005 + */ + FailedToRequestAds = 1005, + /** + * The ad tag url specified was invalid. It needs to be properly encoded. VAST error code 1013 + */ + InvalidAdTag = 1013, + /** + * An invalid AdX extension was found. VAST error code 1105 + */ + InvalidAdxExtension = 1105, + /** + * Invalid arguments were provided to SDK methods. VAST error code 1101 + */ + InvalidArguements = 1101, + /** + * Unable to display NonLinear ad because creative dimensions do not align with creative display area (i.e. creative dimension too large). VAST error code 501 + */ + NonlinearDimensionsError = 501, + /** + * An overlay ad failed to load. VAST error code 502 + */ + OverlayAdLoadingFailed = 502, + /** + * An overlay ad failed to render. VAST error code 500 + */ + OverlayAdPlayingField = 500, + /** + * There was an error with stream initialization during server side ad insertion. VAST error code 1020 + */ + StreamInitializationFailed = 1020, + /** + * The ad response was not understood and cannot be parsed. VAST error code 1010 + */ + UnkownAdResponse = 1010, + /** + * An unexpected error occurred and the cause is not known. Refer to the inner error for more information. VAST error code 900 + */ + UnknownError = 900, + /** + * Locale specified for the SDK is not supported. VAST error code 1011 + */ + UnsupportedLocale = 1011, + /** + * No assets were found in the VAST ad response. VAST error code 1007 + */ + VastAssetNotFound = 1007, + /** + * Empty VAST response. VAST error code 1009 + */ + VastEmptyResponse = 1009, + /** + * Assets were found in the VAST ad response for linear ad, but none of them matched the video player's capabilities. VAST error code 403 + */ + VastLinearAssetMismatch = 403, + /** + * The VAST URI provided, or a VAST URI provided in a subsequent wrapper element, was either unavailable or reached a timeout, as defined by the video player. The timeout is 5 seconds for initial VAST requests and each subsequent wrapper. VAST error code 301 + */ + VastLoadTimeout = 301, + /** + * The ad response was not recognized as a valid VAST ad. VAST error code 100 + */ + VastMalformedResponse = 100, + /** + * Failed to load media assets from a VAST response. The default timeout for media loading is 8 seconds. VAST error code 402 + */ + VastMediaLoadTimeout = 402, + /** + * No Ads VAST response after one or more wrappers. VAST error code 303 + */ + VastNoAdsAfterWrapper = 303, + /** + * Assets were found in the VAST ad response for nonlinear ad, but none of them matched the video player's capabilities. VAST error code 503 + */ + VastNonlinearAssetMismatch = 503, + /** + * Problem displaying MediaFile. Currently used if video playback is stopped due to poor playback quality. VAST error code 405 + */ + VastProblemDisplayingMediaFile = 405, + /** + * VAST schema validation error. VAST error code 101 + */ + VastSchemaValidationError = 101, + /** + * The maximum number of VAST wrapper redirects has been reached. VAST error code 302 + */ + VastTooManyRedirects = 302, + /** + * Trafficking error. Video player received an ad type that it was not expecting and/or cannot display. VAST error code 200 + */ + VastTraffickingError = 200, + /** + * VAST duration is different from the actual media file duration. VAST error code 202 + */ + VastUnexpectedDurationError = 202, + /** + * Ad linearity is different from what the video player is expecting. VAST error code 201 + */ + VastUnexpectedLinearity = 201, + /** + * The ad response contained an unsupported VAST version. VAST error code 102 + */ + VastUnsupportedVersion = 102, + /** + * General VAST wrapper error. VAST error code 300 + */ + VastWrapperError = 300, + /** + * There was an error playing the video ad. VAST error code 400 + */ + VideoPlayError = 400, + /** + * A VPAID error occurred. Refer to the inner error for more information. VAST error code 901 + */ + VpaidError = 901, + } + + /** + * The possible error types for ad loading and playing. + */ + enum Type { + /** + * Indicates that the error was encountered when the ad was being loaded. Possible causes: there was no response from the ad server, malformed ad response was returned, or ad request parameters failed to pass validation. + */ + AdLoad = 'adLoadError', + /** + * Indicates that the error was encountered after the ad loaded, during ad play. Possible causes: ad assets could not be loaded, etc. + */ + AdPlay = 'adPlayError', + } + } + + /** + * This event is raised when an error occurs when loading an ad from the Google or DoubleClick servers. The types on which you can register for the event are AdsLoader and AdsManager. + */ + class AdErrorEvent { + /** + * @returns The AdError that caused this event. + */ + public getError(): AdError; + /** + * During ads load request it is possible to provide an object that is available once the ads load is complete or fails. One possible use case: relate ads response to a specific request and use user request content object as the key for identifying the response. If an error occurred during ads load, you can find out which request caused this failure. + * @returns Object that was provided during ads request. + */ + public getUserRequestContext(): any; + } + + namespace AdErrorEvent { + /** + * Types of AdErrorEvents + */ + enum Type { + /** + * Fired when an error occurred while the ad was loading or playing. + */ + // eslint-disable-next-line @typescript-eslint/no-shadow -- TODO: Fix shadowing + AdError = 'adError', + } + + type Listener = (event: AdErrorEvent) => void; + } + + /** + * This event type is raised by the ad as a notification when the ad state changes and when users interact with the ad. For example, when the ad starts playing, is clicked on, etc. You can register for the various state changed events on AdsManager. + */ + class AdEvent { + /** + * Get the current ad that is playing or just played. + * @returns The ad associated with the event, or null if there is no relevant ad. + */ + public getAd(): Ad | null; + /** + * Allows extra data to be passed from the ad. + * @returns Extra data for the event. Log events raised for error carry object of type 'google.ima.AdError' which can be accessed using 'adError' key. + */ + public getAdData(): any; + } + + namespace AdEvent { + /** + * Types of AdEvents + */ + enum Type { + /** + * Fired when an ad rule or a VMAP ad break would have played if autoPlayAdBreaks is false. + */ + AdBreakReady = 'adBreakReady', + /** + * Fired when the ad has stalled playback to buffer. + */ + AdBuffering = 'adBuffering', + /** + * Fired when an ads list is loaded. + */ + AdMetadata = 'adMetadata', + /** + * Fired when the ad's current time value changes. Calling getAdData() on this event will return an AdProgressData object. + */ + AdProgress = 'adProgress', + /** + * Fired when the ads manager is done playing all the ads. + */ + AllAdsCompleted = 'allAdsCompleted', + /** + * Fired when the ad is clicked. + */ + Click = 'click', + /** + * Fired when the ad completes playing. + */ + Complete = 'complete', + /** + * Fired when content should be paused. This usually happens right before an ad is about to cover the content. + */ + ContentPauseRequested = 'contentPauseRequested', + /** + * Fired when content should be resumed. This usually happens when an ad finishes or collapses. + */ + ContentResumeRequested = 'contentResumeRequested', + /** + * Fired when the ad's duration changes. + */ + DurationChange = 'durationChange', + /** + * Fired when the ad playhead crosses first quartile. + */ + FirstQuartile = 'firstQuartile', + /** + * Fired when the impression URL has been pinged. + */ + Impression = 'impression', + /** + * Fired when an ad triggers the interaction callback. Ad interactions contain an interaction ID string in the ad data. + */ + Interaction = 'interaction', + /** + * Fired when the displayed ad changes from linear to nonlinear, or vice versa. + */ + LinearChanged = 'linearChanged', + /** + * Fired when ad data is available. + */ + Loaded = 'loaded', + /** + * Fired when a non-fatal error is encountered. The user need not take any action since the SDK will continue with the same or next ad playback depending on the error situation. + */ + Log = 'log', + /** + * Fired when the ad playhead crosses midpoint. + */ + MIDPOINT = 'midpoint', + /** + * Fired when the ad is paused. + */ + Paused = 'pause', + /** + * Fired when the ad is resumed. + */ + Resumed = 'resume', + /** + * Fired when the displayed ads skippable state is changed. + */ + SkippableStateChanged = 'skippableStateChanged', + /** + * Fired when the ad is skipped by the user. + */ + Skipped = 'skip', + /** + * Fired when the ad starts playing. + */ + Started = 'start', + /** + * Fired when the ad playhead crosses third quartile. + */ + ThirdQuartile = 'thirdQuartile', + /** + * Fired when the ad is closed by the user. + */ + UserClose = 'userClose', + /** + * Fired when the ad volume has changed. + */ + VolumeChanged = 'volumeChange', + /** + * Fired when the ad volume has been muted. + */ + VolumeMuted = 'mute', + } + + type Listener = (event: AdEvent) => void; + } + + /** + * An ad may be part of a pod of ads. This object exposes metadata related to that pod, such as the number of ads in the pod and ad position within the pod. + * + * The getTotalAds API contained within this object is often correct, but in certain scenarios, it represents the SDK's best guess. See that method's documentation for more information. + */ + interface AdPodInfo { + /** + * Returns the position of the ad. + * @returns The position of the ad within the pod. The value returned is one-based, i.e. 1 of 2, 2 of 2, etc. + */ + getAdPosition(): number; + /** + * Returns true if the ad is a bumper ad. Bumper ads are short linear ads that can indicate to a user when the user is entering into or exiting from an ad break. + * @returns Whether the ad is a bumper ad. + */ + getIsBumper(): boolean; + /** + * The maximum duration of the pod in seconds. For unknown duration, -1 is returned. + * @returns The maximum duration of the ads in this pod in seconds. + */ + getMaxDuration(): number; + /** + * Returns the index of the ad pod. + * + * For preroll pod, 0 is returned. For midrolls, 1, 2, ... N is returned. For postroll, -1 is returned. + * + * For pods in VOD streams with dynamically inserted ads, 0...N is returned regardless of whether the ad is a pre-, mid-, or post-roll. + * + * Defaults to 0 if this ad is not part of a pod, or the pod is not part of an ad playlist. + * + * @returns The index of the pod in the ad playlist. + */ + getPodIndex(): number; + /** + * Returns the content time offset at which the current ad pod was scheduled. For pods in VOD streams with dynamically inserted ads, stream time is returned. + * + * For preroll pod, 0 is returned. For midrolls, the scheduled time is returned. For postroll, -1 is returned. + * + * Defaults to 0 if this ad is not part of a pod, or the pod is not part of an ad playlist. + * + * @returns The time offset for the current ad pod. + */ + getTimeOffset(): number; + /** + * The total number of ads contained within this pod, including bumpers. Bumper ads are short linear ads that can indicate to a user when the user is entering into or exiting from an ad break. + * + * Defaults to 1 if this ad is not part of a pod. + * + * In certain scenarios, the SDK does not know for sure how many ads are contained within this ad pod. These scenarios include ad pods, which are multiple ads within a single ad tag. In these scenarios, the first few AdEvents fired (AD_METADATA, LOADED, etc.) may have just the total number of ad tags from the playlist response. We recommend using the STARTED event as the event in which publishers pull information from this object and update the visual elements of the player, if any. + * + * @returns Total number of ads in the pod. + */ + getTotalAds(): number; + } + + /** + * AdsLoader allows clients to request ads from ad servers. To do so, users must register for the AdsManagerLoadedEvent event and then request ads. + */ + class AdsLoader { + /** + * @param container The display container for ads. + */ + constructor(container: AdDisplayContainer); + /** + * Adds an event listener for the specified type. + * @param type The event type to listen to. + * @param listener The function to call when the event is triggered. + * @param useCapture Optional + */ + public addEventListener( + type: AdsManagerLoadedEvent.Type, + listener: AdsManagerLoadedEvent.Listener, + useCapture?: boolean, + ): void; + /** + * Adds an event listener for the specified type. + * @param type The event type to listen to. + * @param listener The function to call when the event is triggered. + * @param useCapture Optional + */ + public addEventListener( + type: AdErrorEvent.Type, + listener: AdErrorEvent.Listener, + useCapture?: boolean, + ): void; + /** + * Removes an event listener for the specified type. + * @param type The event type for which to remove an event listener. + * @param listener The function of the event handler to remove from the event target. + * @param useCapture Optional + */ + public removeEventListener( + type: AdsManagerLoadedEvent.Type, + listener: AdsManagerLoadedEvent.Listener, + useCapture?: boolean, + ): void; + /** + * Removes an event listener for the specified type. + * @param type The event type for which to remove an event listener. + * @param listener The function of the event handler to remove from the event target. + * @param useCapture Optional + */ + public removeEventListener( + type: AdErrorEvent.Type, + listener: AdErrorEvent.Listener, + useCapture?: boolean, + ): void; + /** + * Signals to the SDK that the content is finished. This will allow the SDK to play post-roll ads, if any are loaded via ad rules. + */ + public contentComplete(): void; + /** + * Cleans up the internal state. + */ + public destroy(): void; + /** + * Returns the IMA SDK settings instance. To change the settings, just call the methods on the instance. The changes will apply for all the ad requests made with this ads loader. + * @returns The settings instance. + */ + public getSettings(): ImaSdkSettings; + /** + * Request ads from a server. + * @param adsRequest AdsRequest instance containing data for the ads request. + * @param userRequestContext User-provided object that is associated with the ads request. It can be retrieved when the ads are loaded. + */ + public requestAds( + adsRequest: AdsRequest, + userRequestContext?: any, + ): void; + } + + /** + * This class is responsible for playing ads. + */ + interface AdsManager { + /** + * Adds an event listener for the specified type. + * @param type The event type to listen to + * @param listener The function to call when the event is triggered + * @param useCapture Optional + */ + addEventListener( + type: AdEvent.Type, + listener: AdEvent.Listener, + useCapture?: boolean, + ): void; + /** + * Adds an event listener for the specified type. + * @param type The event type to listen to + * @param listener The function to call when the event is triggered + * @param useCapture Optional + */ + addEventListener( + type: AdErrorEvent.Type, + listener: AdErrorEvent.Listener, + useCapture?: boolean, + ): void; + /** + * Removes an event listener for the specified type. + * @param type The event type for which to remove an event listener. + * @param listener The function of the event handler to remove from the event target. + * @param useCapture Optional + */ + removeEventListener( + type: AdEvent.Type, + listener: AdEvent.Listener, + useCapture?: boolean, + ): void; + /** + * Removes an event listener for the specified type. + * @param type The event type for which to remove an event listener. + * @param listener The function of the event handler to remove from the event target. + * @param useCapture Optional + */ + removeEventListener( + type: AdErrorEvent.Type, + listener: AdErrorEvent.Listener, + useCapture?: boolean, + ): void; + /** + * Collapse the current ad. This is no-op for HTML5 SDK. + */ + collapse(): void; + /** + * Removes ad assets loaded at runtime that need to be properly removed at the time of ad completion and stops the ad and all tracking. + */ + destroy(): void; + /** + * If an ad break is currently playing, discard it and resume content. Otherwise, ignore the next scheduled ad break. For example, this can be called immediately after the ads manager loads to ignore a preroll without losing future midrolls or postrolls. This is a no-op unless the ad request returned a playlist or VMAP response. + */ + discardAdBreak(): void; + /** + * Expand the current ad. This is no-op for HTML5 SDK. + */ + expand(): void; + /** + * Returns true if the ad can currently be skipped. When this value changes, the AdsManager fires an AdEvent.SKIPPABLE_STATE_CHANGED event. + * @returns True if the ad can currently be skipped, false otherwise. + */ + getAdSkippableState(): boolean; + /** + * Returns an array of offsets in seconds indicating when a scheduled ad break will play. A preroll is represented by 0, and a postroll is represented by -1. An empty array indicates the ad or ad pod has no schedule and can be played at any time. + * @returns List of time offsets in seconds. + */ + getCuePoints(): number[]; + /** + * Get the remaining time of the current ad that is playing. If the ad is not loaded yet or has finished playing, the API would return -1. + * @returns Returns the time remaining for current ad. If the remaining time is undefined for the current ad (for example custom ads), the value returns -1. + */ + getRemainingTime(): number; + /** + * Get the volume for the current ad. + * @returns The volume of the current ad, from 0 (muted) to 1 (loudest). + */ + getVolume(): number; + /** + * Call init to initialize the ad experience on the ads manager. + * @param width The desired width of the ad. + * @param height The desired height of the ad. + * @param viewMode The desired view mode. + * @param videoElement The video element for custom playback. This video element overrides the one provided in the AdDisplayContainer constructor. Only use this property if absolutely necessary - otherwise we recommend specifying this video element while creating the AdDisplayContainer. + */ + init( + width: number, + height: number, + viewMode: ViewMode, + videoElement?: HTMLVideoElement, + ): void; + /** + * Returns true if a custom click tracking element is being used for click tracking on the current ad. Custom click tracking is only used when an optional click tracking element is provided to the AdDisplayContainer, custom playback is used, and the current ad is not an AdSense/AdX ad. + * @returns Whether custom click tracking is used. + */ + isCustomClickTrackingUsed(): boolean; + /** + * Returns true if a custom video element is being used to play the current ad. Custom playback occurs when an optional video element is provided to the AdDisplayContainer on platforms where a custom video element would provide a more seamless ad viewing experience. + * @returns Whether custom playback is used. + */ + isCustomPlaybackUsed(): boolean; + /** + * Pauses the current ad that is playing. This function will be no-op when a static overlay is being shown or if the ad is not loaded yet or is done playing. + */ + pause(): void; + /** + * Resizes the current ad. + * @param width New ad slot width. + * @param height New ad slot height. + * @param viewMode The new view mode. + */ + resize(width: number, height: number, viewMode: ViewMode): void; + /** + * Resumes the current ad that is loaded and paused. This function will be no-op when a static overlay is being shown or if the ad is not loaded yet or is done playing. + */ + resume(): void; + /** + * Set the volume for the current ad. + * @param volume The volume to set, from 0 (muted) to 1 (loudest). + */ + setVolume(volume: number): void; + /** + * Skips the current ad when AdsManager.getAdSkippableState() is true. When called under other circumstances, skip has no effect. After the skip is completed the AdsManager fires an AdEvent.SKIPPED event. + */ + skip(): void; + /** + * Start playing the ads. + */ + start(): void; + /** + * Stop playing the ads. Calling this will get publisher back to the content. + */ + stop(): void; + /** + * Updates the ads rendering settings. This should be used specifically for VMAP use cases between ad breaks when ads rendering settings such as bitrate need to be updated. + * @param adsRenderingSettings The updated ads rendering settings. + */ + updateAdsRenderingSettings( + adsRenderingSettings: Partial, + ): void; + } + + /** + * This event is raised when ads are successfully loaded from the Google or DoubleClick ad servers via an AdsLoader. You can register for this event on AdsLoader. + */ + class AdsManagerLoadedEvent { + /** + * After ads are loaded from the Google or DoubleClick ad servers, the publisher needs to play these ads either in their own video player or in the Google-provided video player. This method returns an AdsManager object. The AdsManager supports playing ads and allows the publisher to subscribe to various events during ad playback. + * @param contentPlayback Player that plays back publisher's content. This must be an object that contains the property currentTime, which allows the SDK to query playhead position to properly display midrolls in case ad server responds with an ad rule, and the duration property. The HMTL5 video element fulfills these requirements. You may optionally implement your own playhead tracker, as long as it fulfills the above requirements. + * @param adsRenderingSettings Optional settings to control the rendering of ads. + * @returns AdsManager that manages and plays ads. + */ + public getAdsManager( + contentPlayback: { + currentTime: number; + duration: number; + }, + adsRenderingSettings?: Partial, + ): AdsManager; + /** + * @returns During ads load request it is possible to provide an object that is available once the ads load is complete. One possible use case: relate ads response to a specific request and use user request content object as a key for identifying the response. + */ + public getUserRequestContext(): any; + } + + namespace AdsManagerLoadedEvent { + /** + * Types of AdsManagerLoadedEvents. + */ + enum Type { + /** + * Fired when the ads have been loaded and an AdsManager is available. + */ + AdsManagerLoaded = 'adsManagerLoaded', + } + + type Listener = (event: AdsManagerLoadedEvent) => void; + } + + /** + * Defines parameters that control the rendering of ads. + */ + class AdsRenderingSettings { + /** + * Set to false if you wish to have fine grained control over the positioning of all non-linear ads. If this value is true, the ad is positioned in the bottom center. If this value is false, the ad is positioned in the top left corner. The default value is true. + */ + public autoAlign: boolean; + /** + * Maximum recommended bitrate. The value is in kbit/s. The SDK will pick media with bitrate below the specified max, or the closest bitrate if there is no media with lower bitrate found. Default value, -1, means the bitrate will be selected by the SDK. + */ + public bitrate: number; + /** + * Enables preloading of video assets. For more info see our guide to preloading media. + */ + public enablePreloading: boolean; + /** + * Timeout (in milliseconds) when loading a video ad media file. If loading takes longer than this timeout, the ad playback is canceled and the next ad in the pod plays, if available. Use -1 for the default of 8 seconds. + */ + public loadVideoTimeout: number; + /** + * Only supported for linear video mime types. If specified, the SDK will include media that matches the MIME type(s) specified in the list and exclude media that does not match the specified MIME type(s). The format is a list of strings, e.g., [ 'video/mp4', 'video/webm', ... ] If not specified, the SDK will pick the media based on player capabilities. + */ + public mimeTypes: string[]; + /** + * For VMAP and ad rules playlists, only play ad breaks scheduled after this time (in seconds). This setting is strictly after - e.g. setting playAdsAfterTime to 15 will cause IMA to ignore an ad break scheduled to play at 15s. + */ + public playAdsAfterTime: number; + /** + * Specifies whether or not the SDK should restore the custom playback state after an ad break completes. This is setting is used primarily when the publisher passes in its content player to use for custom ad playback. + */ + public restoreCustomPlaybackStateOnAdBreakComplete: boolean; + /** + * Specifies whether the UI elements that should be displayed. The elements in this array are ignored for AdSense/AdX ads. + */ + public uiElements: UiElements[]; + /** + * Render linear ads with full UI styling. This setting does not apply to AdSense/AdX ads or ads played in a mobile context that already use full UI styling by default. + */ + public useStyledLinearAds: boolean; + /** + * Render non-linear ads with a close and recall button. + */ + public useStyledNonLinearAds: boolean; + } + + /** + * A class for specifying properties of the ad request. + */ + class AdsRequest { + /** + * Specifies a VAST 2.0 document to be used as the ads response instead of making a request via an ad tag url. This can be useful for debugging and other situations where a VAST response is already available. + * + * This parameter is optional. + */ + public adsResponse?: string; + /** + * Specifies the ad tag url that is requested from the ad server. For details on constructing the ad tag url, see Create a master video tag manually. + * + * This parameter is required. + */ + public adTagUrl: string; + /** + * Specifies the duration of the content in seconds to be shown. Used in AdX requests. + * + * This parameter is optional. + */ + public contentDuration?: number; + /** + * Specifies the keywords used to describe the content to be shown. Used in AdX requests. + * + * This parameter is optional. + */ + public contentKeywords?: string[]; + /** + * Specifies the title of the content to be shown. Used in AdX requests. + * + * This parameter is optional. + */ + public contentTitle?: string; + /** + * Forces non-linear AdSense ads to render as linear fullslot. If set, the content video will be paused and the non-linear text or image ad will be rendered as fullslot. The content video will resume once the ad has been skipped or closed. + */ + public forceNonLinearFullSlot?: boolean; + /** + * Specifies the height of the rectangular area within which a linear ad is displayed. This value is used as one of the criteria for ads selection. This value does not need to match actual ad's height. + * + * This parameter is required. + */ + public linearAdSlotHeight: number; + /** + * Specifies the width of the rectangular area within which a linear ad is displayed. This value is used as one of the criteria for ads selection. This value does not need to match actual ad's width. + * + * This parameter is required. + */ + public linearAdSlotWidth: number; + /** + * Specifies the maximum amount of time to wait in seconds, after calling requestAds, before requesting the ad tag URL. This can be used to stagger requests during a live-stream event, in order to mitigate spikes in the number of requests. + */ + public liveStreamPrefetchSeconds?: number; + /** + * Specifies the height of the rectangular area within which a non linear ad is displayed. This value is used as one of the criteria for ads selection. This value does not need to match actual ad's height. + * + * This parameter is required. + */ + public nonLinearAdSlotHeight: number; + /** + * Specifies the width of the rectangular area within which a non linear ad is displayed. This value is used as one of the criteria for ads selection. This value does not need to match actual ad's width. + * + * This parameter is required. + */ + public nonLinearAdSlotWidth: number; + /** + * Specifies the full url of the page that will be included in the Google ad request for targeting purposes. The url needs to be a valid url. If specified, this value will be used for the [PAGEURL] VAST macro. + * + * This parameter is optional. + */ + public pageUrl?: string; + /** + * Override for default VAST load timeout in milliseconds for a single wrapper. The default timeout is 5000ms. + * + * This parameter is optional. + */ + public vastLoadTimeout?: number; + + /** + * Notifies the SDK whether the player intends to start the content and ad in response to a user action or whether it will be automatically played. Changing this setting will have no impact on ad playback. + * @param autoPlay Whether the content and the ad will be autoplayed or whether it will be started by a user action. + */ + public setAdWillAutoPlay(autoPlay: boolean): void; + /** + * Notifies the SDK whether the player intends to start ad while muted. Changing this setting will have no impact on ad playback, but will send the appropriate signal in the ad request to allow buyers to bid on muted inventory. + * @param muted Whether the ad will be played while muted. + */ + public setAdWillPlayMuted(muted: boolean): void; + /** + * Notifies the SDK whether the player intends to continuously play the content videos one after another similar to TV broadcast. Changing this setting will have no impact on the ad playback, but will send the appropriate signal in this ad request to allow buyers to bid on the type of ad inventory. + * @param continuousPlayback Whether the content video is played one after another continuously. + */ + public setContinuousPlayback(continuousPlayback: boolean): void; + } + + /** + * A companion ad class that is extended by companion ads of different ad types. + */ + interface CompanionAd { + /** + * @returns Returns the ad slot id for this companion. + */ + getAdSlotId(): string; + /** + * Returns the HTML content for the companion ad that can be added to the publisher page. + * @returns The HTML content. + */ + getContent(): string; + /** + * @returns The content type of the Companion Ad. This may return null if the content type is not known (such as in the case of a VAST HTMLResource or IFrameResource). + */ + getContentType(): string | null; + /** + * @returns Returns the height of the companion in pixels. + */ + getHeight(): number; + /** + * @returns Returns the width of the companion in pixels. + */ + getWidth(): number; + } + + /** + * CompanionAdSelectionSettings object is used to define the selection criteria when calling the ima.Ad.getCompanionAds function. + */ + class CompanionAdSelectionSettings { + /** + * The companion ad slot ids to be used for matching set by the user. + */ + public adSlotIds: string[]; + /** + * Creative type setting set by the user. + */ + public creativeType: CompanionAdSelectionSettings.CreativeType; + /** + * The near fit percent set by the user. + */ + public nearMatchPercent: number; + /** + * Resource type setting set by the user. + */ + public resourceType: CompanionAdSelectionSettings.ResourceType; + /** + * Size criteria setting set by the user. + */ + public sizeCriteria: CompanionAdSelectionSettings.SizeCriteria; + } + + namespace CompanionAdSelectionSettings { + /** + * Available choices for creative type of a companion ad. The user can specify any of these choices as a criterion for selecting companion ads. + */ + enum CreativeType { + /** + * Specifies all creative types. + */ + ALL = 'All', + /** + * Specifies Flash creatives. + */ + FLASH = 'Flash', + /** + * Specifies image creatives (such as JPEG, PNG, GIF, etc). + */ + IMAGE = 'Image', + } + + /** + * Available choices for resource type of a companion ad. The user can specify any of these choices as a criterion for selecting companion ads. + */ + enum ResourceType { + /** + * Specifies that the resource can be any type of resource. + */ + All = 'All', + /** + * Specifies that the resource should be an HTML snippet. + */ + HTML = 'Html', + /** + * Specifies that the resource should be a URL that should be used as the source of an iframe. + */ + Iframe = 'IFrame', + /** + * Specifies that the resource should be a static file (usually the URL of an image of SWF). + */ + Static = 'Static', + } + + /** + * Available choices for size selection criteria. The user can specify any of these choices for selecting companion ads. + */ + enum SizeCriteria { + /** + * Specifies that size should be ignored when choosing companions. + */ + Ignore = 'IgnoreSize', + /** + * Specifies that only companions that match the size of the companion ad slot exactly should be chosen. + */ + SelectExactMatch = 'SelectExactMatch', + /** + * Specifies that any companion close to the size of the companion ad slot should be chosen. + */ + SelectNearMatch = 'SelectNearMatch', + } + } + + /** + * This class contains SDK-wide settings. + */ + class ImaSdkSettings { + /** + * Returns the current companion backfill mode. + * @returns The current value. + */ + public getCompanionBackfill(): ImaSdkSettings.CompanionBackfillMode; + /** + * Gets whether to disable custom playback on iOS 10+ browsers. The default value is false. + */ + public getDisableCustomPlaybackForIOS10Plus(): boolean; + /** + * @returns Whether flash ads should be disabled. + */ + public getDisableFlashAds(): boolean; + /** + * Returns the publisher provided locale. + * @returns Publisher provided locale. + */ + public getLocale(): string; + /** + * Returns the maximum number of redirects for subsequent redirects will be denied. + * @returns The maximum number of redirects. + */ + public getNumRedirects(): number; + /** + * Returns the partner provided player type. + * @returns Partner player type. + */ + public getPlayerType(): string; + /** + * Returns the partner provided player version. + * @returns Partner player version. + */ + public getPlayerVersion(): string; + /** + * Returns the publisher provided id. + * @returns Publisher provided id. + */ + public getPpid(): string; + /** + * Sets whether VMAP and ad rules ad breaks are automatically played + * @param autoPlayAdBreaks Whether to autoPlay the ad breaks. + */ + public setAutoPlayAdBreaks(autoPlayAdBreaks: boolean): void; + /** + * Sets the companion backfill mode. Please see the various modes available in google.ima.ImaSdkSettings.CompanionBackfillMode. + * + * The default mode is ima.ImaSdkSettings.CompanionBackfillMode.ALWAYS. + * + * @param mode The desired companion backfill mode. + */ + public setCompanionBackfill( + mode: ImaSdkSettings.CompanionBackfillMode, + ): void; + /** + * Sets whether to disable custom playback on iOS 10+ browsers. If true, ads will play inline if the content video is inline. This enables TrueView skippable ads. However, the ad will stay inline and not support iOS's native fullscreen. When false, ads will play in the same player as your content. The value set here when an AdDisplayContainer is created is used for the lifetime of the container. The default value is false. + * @param disable Whether or not to disable custom playback. + */ + public setDisableCustomPlaybackForIOS10Plus(disable: boolean): void; + /** + * Sets whether flash ads should be disabled. + * @param disableFlashAds Whether flash ads should be disabled. + */ + public setDisableFlashAds(disableFlashAds: boolean): void; + /** + * Sets the publisher provided locale. Must be called before creating AdsLoader or AdDisplayContainer. The locale specifies the language in which to display UI elements and can be any two-letter ISO 639-1 code. + * @param locale Publisher-provided locale. + */ + public setLocale(locale: string): void; + /** + * Specifies the maximum number of redirects before the subsequent redirects will be denied, and the ad load aborted. The number of redirects directly affects latency and thus user experience. This applies to all VAST wrapper ads. + * @param numRedirects The maximum number of redirects. + */ + public setNumRedirects(numRedirects: number): void; + /** + * Sets the partner provided player type. This setting should be used to specify the name of the player being integrated with the SDK. Player type greater than 20 characters will be truncated. The player type specified should be short and unique. This is an optional setting used to improve SDK usability by tracking player types. + * @param playerType The type of the partner player. + */ + public setPlayerType(playerType: string): void; + /** + * Sets the partner provided player version. This setting should be used to specify the version of the partner player being integrated with the SDK. Player versions greater than 20 characters will be truncated. This is an optional setting used to improve SDK usability by tracking player version. + * @param playerVersion The version of the partner player. + */ + public setPlayerVersion(playerVersion: string): void; + /** + * Sets the publisher provided id. + * @param ppid Publisher provided id. + */ + public setPpid(ppid: string): void; + /** + * Sets whether VPAID creatives are allowed. + * @param allowVpaid Whether to allow VPAID creatives. + * @deprecated Please use setVpaidMode. + */ + public setVpaidAllowed(allowVpaid: boolean): void; + /** + * Sets VPAID playback mode. + * @param vpaidMode Sets how VPAID ads will be played. Default is to not allow VPAID ads. + */ + public setVpaidMode(vpaidMode: ImaSdkSettings.VpaidMode): void; + } + + namespace ImaSdkSettings { + /** + * Defines a set of constants for the companion backfill setting. This setting indicates whether companions should be backfilled in various scenarios. + * + * The default value is ALWAYS. + * + * Note that client-side companion backfill requires tagging your companions properly with a Google Publisher Tag (GPT). + */ + enum CompanionBackfillMode { + /** + * If the value is ALWAYS, companion backfill will be attempted in all situations, even when there is no master ad returned. + */ + Always = 'always', + /** + * If the value is ON_MASTER_AD, companion backfill will be attempted if there is a master ad with fewer companions than there are companion slots. The missing companions will be backfilled. + */ + OnMasterAd = 'on_master_ad', + } + + /** + * A set of constants for enabling VPAID functionality. + */ + enum VpaidMode { + /** + * VPAID ads will not play and an error will be returned. + */ + Disabled = 0, + /** + * VPAID ads are enabled using a cross domain iframe. The VPAID ad cannot access the site. VPAID ads that depend on friendly iframe access may error. This is the default. + */ + Enbaled = 1, + /** + * VPAID ads are enabled using a friendly iframe. This allows the ad access to the site via JavaScript. + */ + Insecure = 2, + } + } + + /** + * Enum specifying different UI elements that can be configured to be displayed or hidden. These settings may be ignored for AdSense and ADX ads. + */ + enum UiElements { + /** + * Displays the "Ad" text in the ad UI. Must be present to show the countdown timer. + */ + AdAttribution = 'adAttribution', + /** + * Ad attribution is required for a countdown timer to be displayed. Both UiElements.COUNTDOWN and UiElements.AD_ATTRIBUTION must be present in AdsRenderingSettings.uiElements. + */ + Countdown = 'countdown', + } + + /** + * Enum specifying different VPAID view modes for ads. + */ + enum ViewMode { + /** + * Fullscreen ad view mode. Indicates to the ads manager that the publisher considers the current AdDisplayContainer arrangement as fullscreen (i.e. simulated fullscreen). This does not cause the ads manager to enter fullscreen. + */ + Fullscreen = 'fullscreen', + /** + * Normal ad view mode. + */ + Normal = 'normal', + } + } +} diff --git a/dotcom-rendering/src/components/YoutubeAtom/loadYouTubeIframeApi.ts b/dotcom-rendering/src/components/YoutubeAtom/loadYouTubeIframeApi.ts new file mode 100644 index 00000000000..3496a9d6ead --- /dev/null +++ b/dotcom-rendering/src/components/YoutubeAtom/loadYouTubeIframeApi.ts @@ -0,0 +1,76 @@ +import { loadScript, log } from '@guardian/libs'; + +let scriptsPromise: Promise> | undefined; +let youtubeAPIReadyPromise: Promise | undefined; + +const loadScripts = (enableIma = false) => { + /** + * Since loadScripts can be called multiple times on the same page for pages with more than one video, + * only attempt to load the scripts if this is the first call and return the same promise otherwise. + */ + if (scriptsPromise) { + return scriptsPromise; + } + let scripts; + if (enableIma) { + log('dotcom', 'loadYT: loading YT & IMA script'); + scripts = [ + /** + * The IMA version of the iframe api script can only be fetched from + * a domain that is on YouTube's allow list (theguardian.com, thegulocal.com, gutools.co.uk). + * If the request is made from a domain that isn't on the list (e.g. localhost), + * the standard, non-IMA version will be returned and IMA functionality will fail silently. + */ + loadScript('https://www.youtube.com/iframe_api?ima=1'), + loadScript('//imasdk.googleapis.com/js/sdkloader/ima3.js'), + ]; + } else { + log('dotcom', 'loadYT: loading YT script'); + scripts = [loadScript('https://www.youtube.com/iframe_api')]; + } + scriptsPromise = Promise.all(scripts); + return scriptsPromise; +}; + +/** + * The YouTube IFrame API will call `window.onYouTubeIframeAPIReady` + * when it has downloaded and is ready + */ +const youtubeAPIReady = () => { + /** + * Since youtubeAPIReady can be called multiple times on the same page for pages with more than one video, + * only overwrite window.onYouTubeIframeAPIReady if this is the first call and return the same promise otherwise. + */ + if (youtubeAPIReadyPromise) { + return youtubeAPIReadyPromise; + } + + youtubeAPIReadyPromise = new Promise((resolve) => { + window.onYouTubeIframeAPIReady = () => { + log('dotcom', 'loadYT: resolving YTAPI promise'); + resolve(window.YT); + }; + }); + return youtubeAPIReadyPromise; +}; + +const loadYouTubeAPI = (enableIma = false): Promise => { + /* If another part of the code has already loaded youtube api, return early. */ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- @types/youtube insists that window.YT cannot be undefined. This is very much untrue. + if (window.YT?.Player instanceof Function) { + log('dotcom', 'loadYT: returning window.YT'); + return Promise.resolve(window.YT); + } + + /* Create youtubeAPIReady promise before loading scripts so that + * window.onYouTubeIframeAPIReady is guaranteed to be defined + * by the time the youtube script calls it. + */ + const youtubeAPI = youtubeAPIReady(); + + return loadScripts(enableIma).then(() => { + return youtubeAPI; + }); +}; + +export { loadYouTubeAPI }; diff --git a/dotcom-rendering/src/components/YoutubeBlockComponent.importable.tsx b/dotcom-rendering/src/components/YoutubeBlockComponent.importable.tsx index dbf1fe53fd0..59a2056c24d 100644 --- a/dotcom-rendering/src/components/YoutubeBlockComponent.importable.tsx +++ b/dotcom-rendering/src/components/YoutubeBlockComponent.importable.tsx @@ -1,5 +1,4 @@ import { css } from '@emotion/react'; -import { YoutubeAtom } from '@guardian/atoms-rendering'; import type { ConsentState } from '@guardian/consent-management-platform/dist/types'; import { body, neutral, space } from '@guardian/source-foundations'; import { SvgAlertRound } from '@guardian/source-react-components'; @@ -10,6 +9,7 @@ import { useAB } from '../lib/useAB'; import { useAdTargeting } from '../lib/useAdTargeting'; import type { RoleType } from '../types/content'; import { Caption } from './Caption'; +import { YoutubeAtom } from './YoutubeAtom/YoutubeAtom'; type Props = { id: string; @@ -197,6 +197,7 @@ export const YoutubeBlockComponent = ({ overrideImage ? [ { + weighting: 'supporting', srcSet: [ { src: overrideImage, @@ -211,6 +212,7 @@ export const YoutubeBlockComponent = ({ posterImage.length > 0 ? [ { + weighting: 'supporting', srcSet: posterImage.map((img) => ({ src: img.url, width: img.width, @@ -228,7 +230,7 @@ export const YoutubeBlockComponent = ({ title={mediaTitle} duration={duration} eventEmitters={[ophanTracking, gaTracking]} - pillar={format.theme} + format={format} origin={process.env.NODE_ENV === 'development' ? '' : origin} shouldStick={stickyVideos} isMainMedia={isMainMedia} diff --git a/dotcom-rendering/src/lib/decidePalette.ts b/dotcom-rendering/src/lib/decidePalette.ts index e96cd8e02f6..e245560399f 100644 --- a/dotcom-rendering/src/lib/decidePalette.ts +++ b/dotcom-rendering/src/lib/decidePalette.ts @@ -2247,6 +2247,27 @@ const backgroundSubmeta = (format: ArticleFormat) => { return neutral[100]; }; +const textYoutubeOverlayKicker = (format: ArticleFormat) => { + switch (format.theme) { + case Pillar.News: + return news[400]; + case Pillar.Opinion: + return news[400]; + case Pillar.Sport: + return sport[400]; + case Pillar.Culture: + return culture[400]; + case Pillar.Lifestyle: + return lifestyle[400]; + case ArticleSpecial.SpecialReport: + return specialReport[400]; + case ArticleSpecial.Labs: + return labs[400]; + case ArticleSpecial.SpecialReportAlt: + return news[400]; + } +}; + const backgroundDynamoSublink = (_format: ArticleFormat): string => palette.neutral[97]; @@ -2323,6 +2344,7 @@ export const decidePalette = ( expandableAtom: textExpandableAtom(format), expandableAtomHover: textExpandableAtomHover(format), subNavLink: textSubNavLink(format), + youtubeOverlayKicker: textYoutubeOverlayKicker(format), }, background: { article: backgroundArticle(format), diff --git a/dotcom-rendering/src/lib/formatTime.ts b/dotcom-rendering/src/lib/formatTime.ts new file mode 100644 index 00000000000..64589120937 --- /dev/null +++ b/dotcom-rendering/src/lib/formatTime.ts @@ -0,0 +1,8 @@ +const formatNum = (t: number) => t.toFixed(0).padStart(2, '0'); + +export const formatTime = (t: number): string => { + const second = Math.floor(t % 60); + const minute = Math.floor((t % 3600) / 60); + const hour = Math.floor(t / 3600); + return `${formatNum(hour)}:${formatNum(minute)}:${formatNum(second)}`; +}; diff --git a/dotcom-rendering/src/types/palette.ts b/dotcom-rendering/src/types/palette.ts index 5debe746147..d63d1289784 100644 --- a/dotcom-rendering/src/types/palette.ts +++ b/dotcom-rendering/src/types/palette.ts @@ -63,6 +63,7 @@ export type Palette = { expandableAtom: Colour; expandableAtomHover: Colour; subNavLink: Colour; + youtubeOverlayKicker: Colour; }; background: { article: Colour; diff --git a/dotcom-rendering/window.guardian.ts b/dotcom-rendering/window.guardian.ts index 095cc5b55d1..5b586f0fb9c 100644 --- a/dotcom-rendering/window.guardian.ts +++ b/dotcom-rendering/window.guardian.ts @@ -7,6 +7,8 @@ import type { } from '@guardian/consent-management-platform/dist/types'; import type { WeeklyArticleHistory } from '@guardian/support-dotcom-components/dist/dotcom/src/types'; import type { OphanRecordFunction } from './src/client/ophan/ophan'; +import type { google } from './src/components/YoutubeAtom/ima'; +import type { ImaManager } from './src/components/YoutubeAtom/YoutubeAtomPlayer'; import type { DailyArticleHistory } from './src/lib/dailyArticleCount'; import type { ReaderRevenueDevUtils } from './src/lib/readerRevenueDevUtils'; import type { Guardian } from './src/model/guardian'; @@ -69,6 +71,11 @@ declare global { ) => boolean; }; mockLiveUpdate: (data: LiveUpdateType) => void; + google?: typeof google; + YT?: { + ImaManager: typeof ImaManager; + }; + onYouTubeIframeAPIReady?: () => void } } /* ~ this line is required as per TypeScript's global-modifying-module.d.ts instructions */ diff --git a/yarn.lock b/yarn.lock index a4201d9cdb7..fb9b7ca0f5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6102,10 +6102,10 @@ dependencies: "@types/node" "*" -"@types/youtube@0.0.46": - version "0.0.46" - resolved "https://registry.yarnpkg.com/@types/youtube/-/youtube-0.0.46.tgz#925afdf741f35279114da7c58c98013868a949f5" - integrity sha512-Yf1Y4bDj/QIn8v+zdy0l7+OW6s1uoUvzVn5cGqPNCsL4iUW4gYUlIdQIRtH9NOHVgwZNLbVeeRDEn6N4RMq6Nw== +"@types/youtube@0.0.47": + version "0.0.47" + resolved "https://registry.yarnpkg.com/@types/youtube/-/youtube-0.0.47.tgz#b6e24385ef3b8a6c972d7316d93e8e27c12d0e7c" + integrity sha512-uwqm0DUeg+2pff/8y9b22JJb+qWKOcG5aCn2yyT7hmLdK/M8+VECcK6QuNqdAR93IAqTmZeqK2nizTlQg5j+XA== "@typescript-eslint/eslint-plugin-tslint@5.61.0": version "5.61.0"