diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx
index 8fc2bb6b977..e91d41f2bb7 100644
--- a/dotcom-rendering/src/components/Card/Card.tsx
+++ b/dotcom-rendering/src/components/Card/Card.tsx
@@ -96,7 +96,8 @@ export type Props = {
avatarUrl?: string;
showClock?: boolean;
mainMedia?: MainMedia;
- /** Note YouTube recommends a minimum width of 480px @see https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-youtube-player-size
+ /**
+ * Note YouTube recommends a minimum width of 480px @see https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-youtube-player-size
* At 300px or below, the player will begin to lose functionality e.g. volume controls being omitted.
* Youtube requires a minimum width 200px.
*/
@@ -133,8 +134,14 @@ export type Props = {
isTagPage?: boolean;
/** Allows the consumer to set an aspect ratio on the image of 5:3, 5:4, 4:5 or 1:1 */
aspectRatio?: AspectRatio;
+ /** The index of the card in a carousel */
index?: number;
- /** The Splash card in a flexible container gets a different visual treatment to other cards*/
+ /**
+ * Useful for videos. Has the form: collection-{collection ID}-{card grouping type}-{card index}
+ * For example, the first splash card in the second collection would be: "collection-1-splash-0"
+ */
+ uniqueId?: string;
+ /** The Splash card in a flexible container gets a different visual treatment to other cards */
isFlexSplash?: boolean;
showTopBarDesktop?: boolean;
showTopBarMobile?: boolean;
@@ -402,6 +409,7 @@ export const Card = ({
isTagPage = false,
aspectRatio,
index = 0,
+ uniqueId = '',
isFlexSplash,
showTopBarDesktop = true,
showTopBarMobile = true,
@@ -896,7 +904,6 @@ export const Card = ({
src={media.mainMedia.videoId}
height={media.mainMedia.height}
width={media.mainMedia.width}
- videoId={media.mainMedia.videoId}
thumbnailImage={
media.mainMedia.thumbnailImage ?? ''
}
@@ -909,6 +916,7 @@ export const Card = ({
aspectRatio={aspectRatio}
/>
}
+ uniqueId={uniqueId}
/>
)}
diff --git a/dotcom-rendering/src/components/DecideContainer.tsx b/dotcom-rendering/src/components/DecideContainer.tsx
index 85e33692f1b..04bb36f3d0f 100644
--- a/dotcom-rendering/src/components/DecideContainer.tsx
+++ b/dotcom-rendering/src/components/DecideContainer.tsx
@@ -64,7 +64,6 @@ export const DecideContainer = ({
collectionId,
containerLevel,
}: Props) => {
- // If you add a new container type which contains an MPU, you must also add it to
switch (containerType) {
case 'dynamic/fast':
return (
@@ -255,6 +254,7 @@ export const DecideContainer = ({
absoluteServerTimes={absoluteServerTimes}
imageLoading={imageLoading}
aspectRatio={aspectRatio}
+ collectionId={collectionId}
/>
);
case 'flexible/general':
diff --git a/dotcom-rendering/src/components/FlexibleGeneral.tsx b/dotcom-rendering/src/components/FlexibleGeneral.tsx
index 0726ff34b5b..c6f5072ff7b 100644
--- a/dotcom-rendering/src/components/FlexibleGeneral.tsx
+++ b/dotcom-rendering/src/components/FlexibleGeneral.tsx
@@ -80,6 +80,14 @@ export const decideCardPositions = (cards: DCRFrontCard[]): GroupedCards => {
}, []);
};
+type ImmersiveCardLayoutProps = {
+ card: DCRFrontCard;
+ containerPalette?: DCRContainerPalette;
+ absoluteServerTimes: boolean;
+ imageLoading: Loading;
+ collectionId: number;
+};
+
/**
* ImmersiveCardLayout is a special case of the card layout that is used for cards with the isImmersive property.
* It is a single feature card that takes up the full width of the container on all breakpoints.
@@ -92,17 +100,11 @@ const ImmersiveCardLayout = ({
absoluteServerTimes,
imageLoading,
collectionId,
-}: {
- card: DCRFrontCard;
- containerPalette?: DCRContainerPalette;
- absoluteServerTimes: boolean;
- imageLoading: Loading;
- collectionId: number;
-}) => {
+}: ImmersiveCardLayoutProps) => {
const isLoopingVideo = card.mainMedia?.type === 'LoopVideo';
return (
-
+
{
+}: SplashCardLayoutProps) => {
const card = cards[0];
if (!card) return null;
@@ -380,6 +384,19 @@ const decideCardProperties = (
}
};
+type FullWidthCardLayoutProps = {
+ cards: DCRFrontCard[];
+ imageLoading: Loading;
+ containerPalette?: DCRContainerPalette;
+ showAge?: boolean;
+ absoluteServerTimes: boolean;
+ aspectRatio: AspectRatio;
+ isFirstRow: boolean;
+ isLastRow: boolean;
+ containerLevel: DCRContainerLevel;
+ collectionId: number;
+};
+
const FullWidthCardLayout = ({
cards,
containerPalette,
@@ -391,18 +408,7 @@ const FullWidthCardLayout = ({
isLastRow,
containerLevel,
collectionId,
-}: {
- cards: DCRFrontCard[];
- imageLoading: Loading;
- containerPalette?: DCRContainerPalette;
- showAge?: boolean;
- absoluteServerTimes: boolean;
- aspectRatio: AspectRatio;
- isFirstRow: boolean;
- isLastRow: boolean;
- containerLevel: DCRContainerLevel;
- collectionId: number;
-}) => {
+}: FullWidthCardLayoutProps) => {
const card = cards[0];
if (!card) return null;
@@ -436,7 +442,6 @@ const FullWidthCardLayout = ({
showTopBar={!isFirstRow}
padBottom={!isLastRow}
hasLargeSpacing={!isLastRow}
- key={card.url}
>
{
+}: HalfWidthCardLayoutProps) => {
if (cards.length === 0) return null;
return (
@@ -516,7 +521,6 @@ const HalfWidthCardLayout = ({
showTopBar={!isFirstRow}
/** We use one full top bar for the first row and use a split one for subsequent rows */
splitTopBar={!isFirstStandardRow}
- key={row}
>
{cards.map((card, cardIndex) => {
return (
@@ -577,8 +581,16 @@ export const FlexibleGeneral = ({
containerLevel = 'Primary',
collectionId,
}: Props) => {
- const splash = [...groupedTrails.splash].slice(0, 1);
- const cards = [...groupedTrails.standard].slice(0, 19);
+ const splash = [...groupedTrails.splash].slice(0, 1).map((snap) => ({
+ ...snap,
+ uniqueId: `collection-${collectionId}-splash-0`,
+ }));
+ const cards = [...groupedTrails.standard]
+ .slice(0, 19)
+ .map((standard, i) => ({
+ ...standard,
+ uniqueId: `collection-${collectionId}-standard-${i}`,
+ }));
const groupedCards = decideCardPositions(cards);
return (
@@ -612,6 +624,7 @@ export const FlexibleGeneral = ({
isLastRow={i === groupedCards.length - 1}
containerLevel={containerLevel}
collectionId={collectionId}
+ key={row.cards[0]?.uniqueId}
/>
);
@@ -628,9 +641,9 @@ export const FlexibleGeneral = ({
isFirstRow={!splash.length && i === 0}
isFirstStandardRow={i === 0}
aspectRatio={aspectRatio}
- row={i + 1}
isLastRow={i === groupedCards.length - 1}
containerLevel={containerLevel}
+ key={row.cards[0]?.uniqueId}
/>
);
}
diff --git a/dotcom-rendering/src/components/FlexibleSpecial.stories.tsx b/dotcom-rendering/src/components/FlexibleSpecial.stories.tsx
index f3aa25b093f..ae00e0cc064 100644
--- a/dotcom-rendering/src/components/FlexibleSpecial.stories.tsx
+++ b/dotcom-rendering/src/components/FlexibleSpecial.stories.tsx
@@ -127,6 +127,7 @@ export const One: Story = {
snap: [],
standard: trails.slice(0, 1),
},
+ collectionId: 1,
},
};
export const Two: Story = {
@@ -137,6 +138,7 @@ export const Two: Story = {
snap: [],
standard: trails.slice(0, 2),
},
+ collectionId: 1,
},
};
export const Three: Story = {
@@ -147,6 +149,7 @@ export const Three: Story = {
snap: [],
standard: trails.slice(0, 3),
},
+ collectionId: 1,
},
};
export const Four: Story = {
@@ -157,6 +160,7 @@ export const Four: Story = {
snap: [],
standard: trails.slice(0, 4),
},
+ collectionId: 1,
},
};
export const Five: Story = {
@@ -167,6 +171,7 @@ export const Five: Story = {
snap: [],
standard: trails.slice(0, 5),
},
+ collectionId: 1,
},
};
export const DefaultSplashWithImageSupression: Story = {
@@ -178,6 +183,7 @@ export const DefaultSplashWithImageSupression: Story = {
snap: [],
standard: [{ ...trails[0], image: undefined }],
},
+ collectionId: 1,
},
};
@@ -190,6 +196,7 @@ export const BoostedSplashWithImageSupression: Story = {
snap: [],
standard: [{ ...trails[0], boostLevel: 'boost', image: undefined }],
},
+ collectionId: 1,
},
};
@@ -204,6 +211,7 @@ export const MegaBoostedSplashWithImageSupression: Story = {
{ ...trails[0], boostLevel: 'megaboost', image: undefined },
],
},
+ collectionId: 1,
},
};
@@ -218,6 +226,7 @@ export const GigaBoostedSplashWithImageSupression: Story = {
{ ...trails[0], boostLevel: 'gigaboost', image: undefined },
],
},
+ collectionId: 1,
},
};
@@ -230,6 +239,7 @@ export const DefaultSplashWithLiveUpdates: Story = {
snap: [],
standard: [{ ...liveUpdatesCard }],
},
+ collectionId: 1,
},
};
@@ -242,6 +252,7 @@ export const BoostedSplashWithLiveUpdates: Story = {
snap: [],
standard: [{ ...liveUpdatesCard, boostLevel: 'boost' }],
},
+ collectionId: 1,
},
};
@@ -254,6 +265,7 @@ export const MegaBoostedSplashWithLiveUpdates: Story = {
snap: [],
standard: [{ ...liveUpdatesCard, boostLevel: 'megaboost' }],
},
+ collectionId: 1,
},
};
@@ -266,6 +278,7 @@ export const GigaBoostedSplashWithLiveUpdates: Story = {
snap: [],
standard: [{ ...liveUpdatesCard, boostLevel: 'gigaboost' }],
},
+ collectionId: 1,
},
};
@@ -293,6 +306,7 @@ export const WithSpecialPaletteVariations = {
snap: [],
standard: trails.slice(0, 5),
},
+ collectionId: 1,
},
render: (args) => (
<>
diff --git a/dotcom-rendering/src/components/FlexibleSpecial.tsx b/dotcom-rendering/src/components/FlexibleSpecial.tsx
index 95e092f9567..57813ee1509 100644
--- a/dotcom-rendering/src/components/FlexibleSpecial.tsx
+++ b/dotcom-rendering/src/components/FlexibleSpecial.tsx
@@ -26,6 +26,7 @@ type Props = {
absoluteServerTimes: boolean;
aspectRatio: AspectRatio;
containerLevel?: DCRContainerLevel;
+ collectionId: number;
};
type BoostProperties = {
@@ -106,6 +107,18 @@ const determineCardProperties = (
}
};
+type OneCardLayoutProps = {
+ cards: DCRFrontCard[];
+ imageLoading: Loading;
+ containerPalette?: DCRContainerPalette;
+ showAge?: boolean;
+ absoluteServerTimes: boolean;
+ aspectRatio: AspectRatio;
+ isLastRow: boolean;
+ isFirstRow: boolean;
+ containerLevel: DCRContainerLevel;
+};
+
export const OneCardLayout = ({
cards,
containerPalette,
@@ -116,17 +129,7 @@ export const OneCardLayout = ({
isLastRow,
isFirstRow,
containerLevel,
-}: {
- cards: DCRFrontCard[];
- imageLoading: Loading;
- containerPalette?: DCRContainerPalette;
- showAge?: boolean;
- absoluteServerTimes: boolean;
- aspectRatio: AspectRatio;
- isLastRow: boolean;
- isFirstRow: boolean;
- containerLevel: DCRContainerLevel;
-}) => {
+}: OneCardLayoutProps) => {
const card = cards[0];
if (!card) return null;
@@ -191,17 +194,7 @@ const getImagePosition = (
return 'bottom';
};
-const TwoCardOrFourCardLayout = ({
- cards,
- containerPalette,
- showAge,
- absoluteServerTimes,
- showImage = true,
- imageLoading,
- aspectRatio,
- isFirstRow,
- containerLevel,
-}: {
+type TwoOrFourCardLayoutProps = {
cards: DCRFrontCard[];
imageLoading: Loading;
containerPalette?: DCRContainerPalette;
@@ -211,7 +204,19 @@ const TwoCardOrFourCardLayout = ({
aspectRatio: AspectRatio;
isFirstRow: boolean;
containerLevel: DCRContainerLevel;
-}) => {
+};
+
+const TwoOrFourCardLayout = ({
+ cards,
+ containerPalette,
+ showAge,
+ absoluteServerTimes,
+ showImage = true,
+ imageLoading,
+ aspectRatio,
+ isFirstRow,
+ containerLevel,
+}: TwoOrFourCardLayoutProps) => {
if (cards.length === 0) return null;
const hasTwoOrFewerCards = cards.length <= 2;
@@ -267,10 +272,20 @@ export const FlexibleSpecial = ({
imageLoading,
aspectRatio,
containerLevel = 'Primary',
+ collectionId,
}: Props) => {
- const snaps = [...groupedTrails.snap].slice(0, 1);
- const splash = [...groupedTrails.standard].slice(0, 1);
- const cards = [...groupedTrails.standard].slice(1, 5);
+ const snaps = [...groupedTrails.snap].slice(0, 1).map((snap) => ({
+ ...snap,
+ uniqueId: `collection-${collectionId}-snap-0`,
+ }));
+ const splash = [...groupedTrails.standard].slice(0, 1).map((snap) => ({
+ ...snap,
+ uniqueId: `collection-${collectionId}-splash-0`,
+ }));
+ const cards = [...groupedTrails.standard].slice(1, 5).map((snap, i) => ({
+ ...snap,
+ uniqueId: `collection-${collectionId}-standard-${i}`,
+ }));
return (
<>
@@ -296,7 +311,7 @@ export const FlexibleSpecial = ({
isFirstRow={!isNonEmptyArray(snaps)}
containerLevel={containerLevel}
/>
- {
slideshowImages: trail.slideshowImages,
showLivePlayable: trail.showLivePlayable,
showVideo: trail.showVideo,
+ uniqueId: trail.uniqueId,
};
return Card({ ...defaultProps, ...cardProps });
diff --git a/dotcom-rendering/src/components/LoopVideo.importable.tsx b/dotcom-rendering/src/components/LoopVideo.importable.tsx
index 5969f41d969..47cd956d881 100644
--- a/dotcom-rendering/src/components/LoopVideo.importable.tsx
+++ b/dotcom-rendering/src/components/LoopVideo.importable.tsx
@@ -14,9 +14,24 @@ const videoContainerStyles = css`
position: relative;
`;
+type CustomPlayEventDetail = { uniqueId: string };
+const customPlayAudioEventName = 'looping-video:play-with-audio';
+
+/**
+ * Dispatches a custom play audio event so that other videos listening
+ * for this event will be muted.
+ */
+export const dispatchCustomPlayAudioEvent = (uniqueId: string) => {
+ document.dispatchEvent(
+ new CustomEvent(customPlayAudioEventName, {
+ detail: { uniqueId },
+ }),
+ );
+};
+
type Props = {
src: string;
- videoId: string;
+ uniqueId: string;
width: number;
height: number;
thumbnailImage: string;
@@ -25,7 +40,7 @@ type Props = {
export const LoopVideo = ({
src,
- videoId,
+ uniqueId,
width,
height,
thumbnailImage,
@@ -57,26 +72,60 @@ export const LoopVideo = ({
});
/**
+ * Setup.
+ *
* Register the users motion preferences.
+ * Creates an event listener to ensure we don't play audio from multiple loops
*/
useEffect(() => {
const userPrefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
setPrefersReducedMotion(userPrefersReducedMotion);
- }, []);
+
+ /**
+ * Pause the current video when another video is played
+ * Triggered by the CustomEvent sent by each player on play
+ */
+ const handleCustomPlayAudioEvent = (
+ event: CustomEventInit,
+ ) => {
+ if (event.detail) {
+ const playedVideoId = event.detail.uniqueId;
+ const thisVideoId = uniqueId;
+
+ if (playedVideoId !== thisVideoId) {
+ setIsMuted(true);
+ }
+ }
+ };
+
+ document.addEventListener(
+ customPlayAudioEventName,
+ handleCustomPlayAudioEvent,
+ );
+
+ return () =>
+ document.removeEventListener(
+ customPlayAudioEventName,
+ handleCustomPlayAudioEvent,
+ );
+ }, [uniqueId]);
/**
- * Autoplays the video when it comes into view.
+ * Autoplay the video when it comes into view.
*/
useEffect(() => {
- if (!vidRef.current || playerState === 'PAUSED_BY_USER') return;
-
- if (isInView && isPlayable && playerState !== 'PLAYING') {
- if (prefersReducedMotion !== false) {
- return;
- }
+ if (!vidRef.current || prefersReducedMotion !== false) {
+ return;
+ }
+ if (
+ isInView &&
+ isPlayable &&
+ (playerState === 'NOT_STARTED' ||
+ playerState === 'PAUSED_BY_INTERSECTION_OBSERVER')
+ ) {
setPlayerState('PLAYING');
setHasBeenInView(true);
@@ -98,9 +147,11 @@ export const LoopVideo = ({
setIsMuted(true);
}
- // If a user action paused the video, they have indicated
- // that they don't want to watch the video. Therefore, don't
- // resume the video when it comes back in view
+ /**
+ * If a user action paused the video, they have indicated
+ * that they don't want to watch the video. Therefore, don't
+ * resume the video when it comes back in view
+ */
const isBackInView =
playerState === 'PAUSED_BY_INTERSECTION_OBSERVER' && isInView;
if (isBackInView) {
@@ -139,11 +190,23 @@ export const LoopVideo = ({
}
};
- const handleClick = (event: React.SyntheticEvent) => {
+ const handlePlayPauseClick = (event: React.SyntheticEvent) => {
event.preventDefault();
playPauseVideo();
};
+ const handleAudioClick = (event: React.SyntheticEvent) => {
+ event.stopPropagation(); // Don't pause the video
+
+ if (isMuted) {
+ // Emit video play audio event so other components are aware when a video is played with sound
+ dispatchCustomPlayAudioEvent(uniqueId);
+ setIsMuted(false);
+ } else {
+ setIsMuted(true);
+ }
+ };
+
const onError = () => {
window.guardian.modules.sentry.reportError(
new Error(`Loop video could not be played. source: ${src}`),
@@ -225,7 +288,7 @@ export const LoopVideo = ({
>
>;
playerState: (typeof PLAYER_STATES)[number];
- setPlayerState: Dispatch>;
currentTime: number;
setCurrentTime: Dispatch>;
isMuted: boolean;
- setIsMuted: Dispatch>;
- handleClick: (event: SyntheticEvent) => void;
+ handlePlayPauseClick: (event: SyntheticEvent) => void;
+ handleAudioClick: (event: SyntheticEvent) => void;
handleKeyDown: (event: React.KeyboardEvent) => void;
onError: (event: SyntheticEvent) => void;
AudioIcon: (iconProps: IconProps) => JSX.Element;
@@ -89,7 +88,7 @@ export const LoopVideoPlayer = forwardRef(
(
{
src,
- videoId,
+ uniqueId,
width,
height,
fallbackImageComponent,
@@ -97,12 +96,11 @@ export const LoopVideoPlayer = forwardRef(
isPlayable,
setIsPlayable,
playerState,
- setPlayerState,
currentTime,
setCurrentTime,
isMuted,
- setIsMuted,
- handleClick,
+ handlePlayPauseClick,
+ handleAudioClick,
handleKeyDown,
onError,
AudioIcon,
@@ -111,8 +109,7 @@ export const LoopVideoPlayer = forwardRef(
}: Props,
ref: React.ForwardedRef,
) => {
- // Assumes that the video is unique on the page.
- const loopVideoId = `loop-video-${videoId}`;
+ const loopVideoId = `loop-video-${uniqueId}`;
return (
<>
@@ -127,9 +124,6 @@ export const LoopVideoPlayer = forwardRef(
height={height}
width={width}
poster={posterImage}
- onPlaying={() => {
- setPlayerState('PLAYING');
- }}
onCanPlay={() => {
setIsPlayable(true);
}}
@@ -143,7 +137,7 @@ export const LoopVideoPlayer = forwardRef(
setCurrentTime(ref.current.currentTime);
}
}}
- onClick={handleClick}
+ onClick={handlePlayPauseClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
@@ -160,7 +154,7 @@ export const LoopVideoPlayer = forwardRef(
{showPlayIcon && (
@@ -175,10 +169,7 @@ export const LoopVideoPlayer = forwardRef(
{/* Audio icon */}
{
- event.stopPropagation(); // Don't pause the video
- setIsMuted(!isMuted);
- }}
+ onClick={handleAudioClick}
css={audioButtonStyles}
>
diff --git a/dotcom-rendering/src/components/LoopVideoProgressBar.tsx b/dotcom-rendering/src/components/LoopVideoProgressBar.tsx
index 34cc4ce38b6..05d8a68f675 100644
--- a/dotcom-rendering/src/components/LoopVideoProgressBar.tsx
+++ b/dotcom-rendering/src/components/LoopVideoProgressBar.tsx
@@ -18,7 +18,7 @@ const foregroundStyles = (progressPercentage: number) => css`
width: ${progressPercentage}%;
z-index: ${getZIndex('loop-video-progress-bar-foreground')};
background-color: ${palette('--loop-video-progress-bar-value')};
- transition: width 0.3s linear;
+ transition: width 0.25s linear;
`;
type Props = {
diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx
index 0e1fc63acaf..73695004f8e 100644
--- a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx
+++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx
@@ -40,7 +40,7 @@ type Props = {
};
type CustomPlayEventDetail = { uniqueId: string };
-const customPlayEventName = 'video:play';
+const customPlayEventName = 'youtube-video:play';
type ProgressEvents = {
hasSentPlayEvent: boolean;
diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts
index 78731c214ac..f9aab095973 100644
--- a/dotcom-rendering/src/types/front.ts
+++ b/dotcom-rendering/src/types/front.ts
@@ -102,6 +102,7 @@ export type DCRFrontCard = {
branding?: Branding;
slideshowImages?: DCRSlideshowImage[];
showVideo?: boolean;
+ uniqueId?: string;
};
export type DCRSlideshowImage = {