diff --git a/src/YoutubeAtom.stories.tsx b/src/YoutubeAtom.stories.tsx
index b5c4c7f9..e8f400c9 100644
--- a/src/YoutubeAtom.stories.tsx
+++ b/src/YoutubeAtom.stories.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
import { css } from '@emotion/react';
import { YoutubeAtom } from './YoutubeAtom';
@@ -9,7 +9,7 @@ export default {
component: YoutubeAtom,
};
-export const DefaultStory = (): JSX.Element => {
+export const NoConsent = (): JSX.Element => {
return (
{
]}
duration={252}
pillar={ArticlePillar.Culture}
+ height={450}
+ width={800}
+ />
+
+ );
+};
+
+export const NoOverlay = (): JSX.Element => {
+ return (
+
+ console.log(`analytics event ${e} called`),
+ ]}
+ consentState={{}}
+ duration={252}
+ pillar={ArticlePillar.Culture}
+ height={450}
+ width={800}
/>
);
@@ -47,6 +74,7 @@ export const WithOverrideImage = (): JSX.Element => {
(e) => console.log(`analytics event ${e} called`),
]}
duration={252}
+ consentState={{}}
pillar={ArticlePillar.News}
overrideImage={[
{
@@ -81,6 +109,7 @@ export const WithPosterImage = (): JSX.Element => {
]}
pillar={ArticlePillar.Sport}
duration={252}
+ consentState={{}}
posterImage={[
{
srcSet: [
@@ -107,6 +136,8 @@ export const WithPosterImage = (): JSX.Element => {
],
},
]}
+ height={450}
+ width={800}
/>
);
@@ -140,6 +171,7 @@ export const WithOverlayAndPosterImage = (): JSX.Element => {
],
},
]}
+ consentState={{}}
posterImage={[
{
srcSet: [
@@ -166,7 +198,49 @@ export const WithOverlayAndPosterImage = (): JSX.Element => {
],
},
]}
+ height={450}
+ width={800}
/>
);
};
+
+export const GiveConsent = (): JSX.Element => {
+ const [consented, setConsented] = useState(false);
+ return (
+ <>
+
+
+ console.log(`analytics event ${e} called`),
+ ]}
+ consentState={consented ? {} : undefined}
+ duration={252}
+ pillar={ArticlePillar.News}
+ overrideImage={[
+ {
+ 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}
+ />
+
+ >
+ );
+};
diff --git a/src/YoutubeAtom.test.tsx b/src/YoutubeAtom.test.tsx
index 6a1c7a03..904e7da7 100644
--- a/src/YoutubeAtom.test.tsx
+++ b/src/YoutubeAtom.test.tsx
@@ -3,12 +3,25 @@ import '@testing-library/jest-dom/extend-expect';
import { render } from '@testing-library/react';
import { YoutubeAtom } from './YoutubeAtom';
+import { AdTargeting } from './types';
const disabledAdsEmbedConfig =
'embed_config=%7B%22adsConfig%22%3A%7B%22disableAds%22%3Atrue%7D%7D';
+const overlayImage = [
+ {
+ 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',
+ },
+ ],
+ },
+];
+
describe('YoutubeAtom', () => {
- it('should render', async () => {
+ it('should directly render the youtube iframe if no overlay provided', async () => {
const atom = (
{
alt=""
role="inline"
eventEmitters={[]}
+ consentState={{}}
pillar={0}
/>
);
@@ -25,92 +39,157 @@ describe('YoutubeAtom', () => {
expect(iframe).toBeInTheDocument();
});
- it.each([
- {
- msg:
- 'should render an iframe with ads disabled when passed no ad targeting',
- adTargeting: undefined,
- consentState: undefined,
- expectedPartialEmbedStrings: [disabledAdsEmbedConfig],
- },
- {
- msg:
- 'should render an iframe with ads disabled when passed ad targeting with disabledAds flag true',
- adTargeting: {
- disableAds: true,
- adUnit: 'someAdUnit',
- customParams: {
- param1: 'param1',
- param2: 'param2',
- },
- },
- consentState: undefined,
- expectedPartialEmbedStrings: [disabledAdsEmbedConfig],
- },
- {
- msg:
- 'should render an iframe with ads disabled when passed ad targeting but no consent state',
- adTargeting: {
- disableAds: false,
- adUnit: 'someAdUnit',
- customParams: {
- param1: 'param1',
- param2: 'param2',
- },
- },
- consentState: undefined,
- expectedPartialEmbedStrings: [disabledAdsEmbedConfig],
- },
- {
- msg:
- 'should render an iframe with ad targeting when passed ad targeting and consent state',
- adTargeting: {
- disableAds: false,
- adUnit: 'someAdUnit',
- customParams: {
- param1: 'param1',
- param2: 'param2',
- },
- },
- consentState: {
- aus: {
- personalisedAdvertising: true,
- },
- },
- expectedPartialEmbedStrings: [
- // assert cust_params value is double uriComponent encoded e.g. % => %25
- // as per existing Frontend behaviour
+ it('should not render the youtube iframe if no consent state is given', async () => {
+ const atom = (
+
+ );
+ const { queryByTitle } = render(atom);
+
+ const iframe = (await queryByTitle(
+ 'My Youtube video!',
+ )) as HTMLIFrameElement;
+ expect(iframe).not.toBeInTheDocument();
+ });
+
+ it('should not render the youtube iframe if an overlay is provided', async () => {
+ const atom = (
+
+ );
+ const { queryByTitle } = render(atom);
+
+ const iframe = queryByTitle('My Youtube video!');
+ expect(iframe).not.toBeInTheDocument();
+ });
+
+ it('should show a placeholder if both overlay and consent are missing', async () => {
+ const atom = (
+
+ );
+ const { queryByTitle, getByTestId } = render(atom);
+
+ const iframe = queryByTitle('My Youtube video!');
+ expect(iframe).not.toBeInTheDocument();
+ const placeholder = getByTestId('placeholder');
+ expect(placeholder).toBeInTheDocument();
+ });
+
+ it('should render an iframe with ads disabled when passed no ad targeting', async () => {
+ const atom = (
+
+ );
+ const { findByTitle } = render(atom);
+
+ const iframe = (await findByTitle(
+ 'My Youtube video!',
+ )) as HTMLIFrameElement;
+ expect(iframe).toBeInTheDocument();
+ expect(iframe.src.includes(disabledAdsEmbedConfig)).toBe(true);
+ });
+
+ it('should render an iframe with ads disabled when passed ad targeting with disabledAds flag true', async () => {
+ const atom = (
+
+ );
+ const { findByTitle } = render(atom);
+
+ const iframe = (await findByTitle(
+ 'My Youtube video!',
+ )) as HTMLIFrameElement;
+ expect(iframe).toBeInTheDocument();
+ expect(iframe.src.includes(disabledAdsEmbedConfig)).toBe(true);
+ });
+
+ it('should render an iframe with ad targeting when passed ad targeting and consent state', async () => {
+ const atom = (
+
+ );
+ const { findByTitle } = render(atom);
+
+ const iframe = (await findByTitle(
+ 'My Youtube video!',
+ )) as HTMLIFrameElement;
+ expect(iframe).toBeInTheDocument();
+ // assert cust_params value is double uriComponent encoded e.g. % => %25
+ // as per existing Frontend behaviour
+ expect(
+ iframe.src.includes(
'%22cust_params%22%3A%22param1%253Dparam1%2526param2%253Dparam2',
- // encoded consent state
- '%22restrictedDataProcessor%22%3Afalse',
- ],
- },
- ])(
- '$msg',
- async ({ adTargeting, consentState, expectedPartialEmbedStrings }) => {
- const atom = (
-
- );
- const { findByTitle } = render(atom);
-
- const iframe = (await findByTitle(
- 'My Youtube video!',
- )) as HTMLIFrameElement;
- expect(iframe).toBeInTheDocument();
- expectedPartialEmbedStrings.forEach((expectedPartialEmbedString) =>
- expect(iframe.src.includes(expectedPartialEmbedString)).toBe(
- true,
- ),
- );
- },
- );
+ ),
+ ).toBe(true);
+ // encoded consent state
+ expect(
+ iframe.src.includes('%22restrictedDataProcessor%22%3Afalse'),
+ ).toBe(true);
+ });
});
diff --git a/src/YoutubeAtom.tsx b/src/YoutubeAtom.tsx
index de120e07..35b26aaa 100644
--- a/src/YoutubeAtom.tsx
+++ b/src/YoutubeAtom.tsx
@@ -12,6 +12,7 @@ import {
import { SvgPlay } from '@guardian/source-react-components';
import { MaintainAspectRatio } from './common/MaintainAspectRatio';
+import { Placeholder } from './common/Placeholder';
import { formatTime } from './lib/formatTime';
import { Picture } from './Picture';
import { AdTargeting, ImageSource, RoleType } from './types';
@@ -164,11 +165,60 @@ export const YoutubeAtom = ({
const [hasUserLaunchedPlay, setHasUserLaunchedPlay] = useState(
false,
);
+ const [hasUserHovered, setHasUserHovered] = useState(false);
const player = useRef();
+ const hasOverlay = overrideImage || posterImage;
+
+ /**
+ * Show the overlay if:
+ * - It exists
+ *
+ * and
+ *
+ * - It hasn't been clicked upon
+ */
+ const showOverlay = hasOverlay && !hasUserLaunchedPlay;
+ /**
+ * Show a placeholder if:
+ *
+ * - We don't have an iframe source yet (probably because we don't have consent)
+ *
+ * and
+ *
+ * - There's no overlay to replace it with or the reader clicked to play but we're
+ * still waiting on consent
+ *
+ */
+ const showPlaceholder = !iframeSrc && (!hasOverlay || hasUserLaunchedPlay);
+ /**
+ * Load the you tube iframe if:
+ *
+ * - We have a source string defined (i.e. We have consent)
+ *
+ * and
+ *
+ * - One of these 3 things are true
+ * - We don't have an overlay - so we have to load the video straight away
+ * - The user has clicked on the overlay, so load the video iframe!
+ * - The user has moved their mouse over the overlay so lets pre load
+ * the content
+ */
+ const loadIframe =
+ iframeSrc && (!hasOverlay || hasUserHovered || hasUserLaunchedPlay);
+
useEffect(() => {
- // Set the iframe client side after hydration
- // This is so we can dynamically build adsConfig using client side data (primarily consent)
+ /**
+ * Build the iframe source url
+ *
+ * We do this on the client following hydration so we can dynamically build
+ * adsConfig using client side data (primarily consent)
+ *
+ */
+
+ // We don't want to ever load the iframe until we know the reader's consent preferences
+ if (!consentState) return;
+
const adsConfig: AdsConfig =
!adTargeting || adTargeting.disableAds
? disabledAds
@@ -176,19 +226,26 @@ export const YoutubeAtom = ({
false,
adTargeting.adUnit,
adTargeting.customParams,
- consentState || {},
+ consentState,
);
const embedConfig = encodeURIComponent(JSON.stringify({ adsConfig }));
const originString = origin
? `&origin=${encodeURIComponent(origin)}`
: '';
+ // `autoplay`?
+ // We don't typically autoplay videos but in this case, where we know the reader has
+ // already clicked to play, we use this param to ensure the video plays. Why would it
+ // not play? Because when a reader clicks, we call player.current.playVideo() but at
+ // that point the video may not have loaded and the click event won't work. Autoplay
+ // is a failsafe for this scenario.
+ const autoplay = hasUserLaunchedPlay ? '&autoplay=1' : '';
setIframeSrc(
- `https://www.youtube.com/embed/${assetId}?embed_config=${embedConfig}&enablejsapi=1&widgetid=1&modestbranding=1${originString}`,
+ `https://www.youtube.com/embed/${assetId}?embed_config=${embedConfig}&enablejsapi=1&widgetid=1&modestbranding=1${originString}${autoplay}`,
);
- }, []);
+ }, [consentState, hasUserLaunchedPlay]);
useEffect(() => {
- if (iframeSrc) {
+ if (loadIframe) {
if (!player.current) {
player.current = YouTubePlayer(`youtube-video-${assetId}`);
}
@@ -279,11 +336,29 @@ export const YoutubeAtom = ({
listener && player.current && player.current.off(listener);
};
}
- }, [eventEmitters, iframeSrc]);
+ }, [eventEmitters, loadIframe]);
return (
- {iframeSrc && (
+ {showPlaceholder && (
+
+ )}
+
+ {loadIframe && (
)}
- {(overrideImage || posterImage) && (
+ {showOverlay && (
{
setHasUserLaunchedPlay(true);
- player.current && player.current.playVideo();
+ iframeSrc &&
+ player.current &&
+ player.current.playVideo();
}}
onKeyDown={(e) => {
- const spaceKey = 32;
- const enterKey = 13;
- if (e.keyCode === spaceKey || e.keyCode === enterKey) {
+ if (e.code === 'Space' || e.code === 'Enter') {
setHasUserLaunchedPlay(true);
- player.current && player.current.playVideo();
+ iframeSrc &&
+ player.current &&
+ player.current.playVideo();
}
}}
+ onMouseEnter={() => setHasUserHovered(true)}
css={[
overlayStyles,
hasUserLaunchedPlay ? hideOverlayStyling : '',
diff --git a/src/common/Placeholder.tsx b/src/common/Placeholder.tsx
new file mode 100644
index 00000000..87406bfc
--- /dev/null
+++ b/src/common/Placeholder.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { css, keyframes } from '@emotion/react';
+
+import { neutral } from '@guardian/source-foundations';
+
+const BACKGROUND_COLOUR = neutral[93];
+
+type Props = {
+ height: number;
+ rootId?: string;
+ width?: number;
+ shouldShimmer?: boolean;
+};
+
+const shimmer = keyframes`
+ 0% {
+ background-position: -1500px 0;
+ }
+ 100% {
+ background-position: 1500px 0;
+ }
+`;
+
+const shimmerStyles = css`
+ animation: ${shimmer} 2s infinite linear;
+ background: linear-gradient(
+ to right,
+ ${BACKGROUND_COLOUR} 4%,
+ ${neutral[86]} 25%,
+ ${BACKGROUND_COLOUR} 36%
+ );
+ background-size: 1500px 100%;
+`;
+
+export const Placeholder = ({
+ height,
+ rootId,
+ width,
+ shouldShimmer = true,
+}: Props): JSX.Element => (
+
+);