From 3b3d4980201007070309314c2d189ac763ae5caf Mon Sep 17 00:00:00 2001 From: Dominik Lander Date: Wed, 21 Jan 2026 19:47:36 +0000 Subject: [PATCH 1/2] Loops on Feature Cards --- dotcom-rendering/fixtures/manual/trails.ts | 36 ++++---- .../src/components/FeatureCard.stories.tsx | 10 +- .../src/components/FeatureCard.tsx | 91 ++++++++++++------- .../components/FlexibleGeneral.stories.tsx | 22 ++--- .../components/FlexibleSpecial.stories.tsx | 4 +- .../components/ScrollableFeature.stories.tsx | 81 +++++++++++++++++ .../components/SelfHostedVideo.importable.tsx | 7 ++ .../src/components/SelfHostedVideoPlayer.tsx | 41 +++++---- .../components/StaticFeatureTwo.stories.tsx | 46 ++++++++-- .../src/components/SubtitleOverlay.tsx | 18 ++-- 10 files changed, 259 insertions(+), 97 deletions(-) diff --git a/dotcom-rendering/fixtures/manual/trails.ts b/dotcom-rendering/fixtures/manual/trails.ts index 112fa1c30ce..7c5771bc521 100644 --- a/dotcom-rendering/fixtures/manual/trails.ts +++ b/dotcom-rendering/fixtures/manual/trails.ts @@ -648,11 +648,11 @@ export const newsletterTrails: [DCRFrontCard, DCRFrontCard] = [ }, ]; -export const selfHostedVideo54Card = { +export const selfHostedLoopVideo54Card = { ...defaultCardProps, dataLinkName: 'news | group-0 | card-@2', url: '/uk-news/2025/jan/22/prince-harry-says-sun-publisher-made-historic-admission-as-he-settles-case', - headline: 'Self-hosted 5:4 video card', + headline: 'Self-hosted 5:4 loop video card', trailText: 'Trail text for looping video card', mainMedia: { type: 'SelfHostedVideo', @@ -676,11 +676,11 @@ export const selfHostedVideo54Card = { showVideo: true, } satisfies DCRFrontCard; -export const selfHostedVideo45Card = { - ...selfHostedVideo54Card, - headline: 'Self-hosted 4:5 video card', +export const selfHostedLoopVideo45Card = { + ...selfHostedLoopVideo54Card, + headline: 'Self-hosted 4:5 loop video card', mainMedia: { - ...selfHostedVideo54Card.mainMedia, + ...selfHostedLoopVideo54Card.mainMedia, sources: [ { mimeType: 'video/mp4', @@ -692,11 +692,11 @@ export const selfHostedVideo45Card = { }, } satisfies DCRFrontCard; -export const selfHostedVideo53Card = { - ...selfHostedVideo54Card, - headline: 'Self-hosted 5:3 video card', +export const selfHostedLoopVideo53Card = { + ...selfHostedLoopVideo54Card, + headline: 'Self-hosted 5:3 loop video card', mainMedia: { - ...selfHostedVideo54Card.mainMedia, + ...selfHostedLoopVideo54Card.mainMedia, sources: [ { mimeType: 'video/mp4', @@ -708,11 +708,11 @@ export const selfHostedVideo53Card = { }, } satisfies DCRFrontCard; -export const selfHostedVideo916Card = { - ...selfHostedVideo54Card, - headline: 'Self-hosted 9:16 video card', +export const selfHostedLoopVideo916Card = { + ...selfHostedLoopVideo54Card, + headline: 'Self-hosted 9:16 loop video card', mainMedia: { - ...selfHostedVideo54Card.mainMedia, + ...selfHostedLoopVideo54Card.mainMedia, sources: [ { mimeType: 'video/mp4', @@ -724,11 +724,11 @@ export const selfHostedVideo916Card = { }, } satisfies DCRFrontCard; -export const selfHostedVideo169Card = { - ...selfHostedVideo54Card, - headline: 'Self-hosted 16:9 video card', +export const selfHostedLoopVideo169Card = { + ...selfHostedLoopVideo54Card, + headline: 'Self-hosted 16:9 loop video card', mainMedia: { - ...selfHostedVideo54Card.mainMedia, + ...selfHostedLoopVideo54Card.mainMedia, sources: [ { mimeType: 'video/mp4', diff --git a/dotcom-rendering/src/components/FeatureCard.stories.tsx b/dotcom-rendering/src/components/FeatureCard.stories.tsx index 49b16702bbc..28dc1570c41 100644 --- a/dotcom-rendering/src/components/FeatureCard.stories.tsx +++ b/dotcom-rendering/src/components/FeatureCard.stories.tsx @@ -414,8 +414,8 @@ export const WithSelfHostedLoopVideo = { atomId: 'atom-id-123', sources: [ { - src: 'https://uploads.guim.co.uk/2025/11/27/4_5_Test--1d34df3e-8c92-4090-8bb6-d79fc7fb9467-1.0.mp4', - mimeType: 'video/mp4', + src: 'https://uploads.guim.co.uk/2026/01/09/Front_loop__Iran_TiF_Latest--64220ebf-d63d-48dd-9317-16b3b150a4ac-1.1.m3u8', + mimeType: 'application/vnd.apple.mpegurl', }, ], height: 720, @@ -432,6 +432,12 @@ export const WithSelfHostedCinemagraphVideo = { mainMedia: { ...WithSelfHostedLoopVideo.args.mainMedia, videoStyle: 'Cinemagraph', + sources: [ + { + src: 'https://uploads.guim.co.uk/2025/11/27/4_5_Test--1d34df3e-8c92-4090-8bb6-d79fc7fb9467-1.0.mp4', + mimeType: 'video/mp4', + }, + ], }, }, } satisfies Story; diff --git a/dotcom-rendering/src/components/FeatureCard.tsx b/dotcom-rendering/src/components/FeatureCard.tsx index d4fb685aa56..bbd00854b17 100644 --- a/dotcom-rendering/src/components/FeatureCard.tsx +++ b/dotcom-rendering/src/components/FeatureCard.tsx @@ -76,6 +76,13 @@ const baseCardStyles = css` text-decoration: none; `; +const underlineOnHoverStyles = css` + /* Only underline the headline element we want to target (not kickers/sublink headlines) */ + :hover .card-headline .show-underline { + text-decoration: underline; + } +`; + const hoverStyles = css` :hover .media-overlay { position: absolute; @@ -86,14 +93,9 @@ const hoverStyles = css` background-color: ${palette('--card-background-hover')}; } - /* Only underline the headline element we want to target (not kickers/sublink headlines) */ - :hover .card-headline .show-underline { - text-decoration: underline; - } -`; + ${underlineOnHoverStyles} -/** When we hover on sublinks, we want to prevent the general hover styles applying */ -const sublinkHoverStyles = css` + /** When we hover on sublinks, we want to prevent the general hover styles applying */ :has(ul.sublinks:hover, .branding-logo:hover) { .card-headline .show-underline { text-decoration: none; @@ -118,6 +120,7 @@ const overlayContainerStyles = css` left: 0; width: 100%; cursor: pointer; + z-index: ${getZIndex('mediaOverlay')}; `; const immersiveOverlayContainerStyles = css` @@ -130,15 +133,11 @@ const immersiveOverlayContainerStyles = css` * 48px is to ensure the gradient does not render the content inaccessible. */ width: 268px; - z-index: 1; + z-index: ${getZIndex('mediaOverlay')}; } `; -const cinemagraphOverlayStyles = css` - /* Needs to be above the video player */ - z-index: ${getZIndex('mediaOverlay')}; - - /* The whole card is clickable on cinemagraphs */ +const noPointerEvents = css` pointer-events: none; `; @@ -178,8 +177,11 @@ const overlayStyles = css` } ${overlayMaskGradientStyles('180deg')}; - /* Ensure the waveform is behind the other elements, e.g. headline, pill */ - > :not(.waveform) { + /* + * Ensure the waveform is behind the other elements, e.g. headline, pill. + * Links define their own z-index. + */ + > :not(.waveform):not(a) { z-index: 1; } `; @@ -198,7 +200,7 @@ const immersiveOverlayStyles = css` const podcastImageContainerStyles = css` position: relative; - /* Needs to display above of the image mask overlay */ + /* Needs to display above the image mask overlay */ z-index: ${getZIndex('card-podcast-image')}; `; @@ -443,15 +445,22 @@ export const FeatureCard = ({ showVideo: showVideo && canPlayInline, }); + if (!media) return null; + const showCardAge = webPublicationDate !== undefined && showClock !== undefined; const showCommentCount = discussionId !== undefined; + const isYoutubeVideo = media.type === 'youtube-video'; + const isSelfHostedVideo = - media?.type === 'loop-video' || - media?.type === 'default-video' || - media?.type === 'cinemagraph'; + media.type === 'loop-video' || + media.type === 'default-video' || + media.type === 'cinemagraph'; + + const isSelfHostedVideoWithControls = + media.type === 'loop-video' || media.type === 'default-video'; const labsDataAttributes = branding ? getOphanComponents({ @@ -464,13 +473,16 @@ export const FeatureCard = ({ const aspectRatioNumber = isImmersive ? 5 / 3 : 4 / 5; - if (!media) return null; - return ( -
- {media.type !== 'youtube-video' && ( +
+ {!isYoutubeVideo && !isSelfHostedVideoWithControls && ( )}
- {media.type === 'youtube-video' && ( + {isYoutubeVideo && (
)} - {media.type !== 'youtube-video' && ( + {!isYoutubeVideo && (
@@ -621,15 +636,17 @@ export const FeatureCard = ({ )} {/* This overlay is styled when the CardLink is hovered */} -
- + {!isSelfHostedVideoWithControls && ( +
+ )}
{mainMedia?.type === 'Audio' && @@ -656,8 +673,20 @@ export const FeatureCard = ({ overlayStyles, isImmersive && immersiveOverlayStyles, + isSelfHostedVideoWithControls && + underlineOnHoverStyles, ]} > + {/** Only the overlay is a link for self-hosted videos with controls. */} + {isSelfHostedVideoWithControls && ( + + )} + {isImmersive && mainMedia?.type === 'Audio' && !!mainMedia.podcastImage?.src && ( diff --git a/dotcom-rendering/src/components/FlexibleGeneral.stories.tsx b/dotcom-rendering/src/components/FlexibleGeneral.stories.tsx index 7a19a8fcb28..957bb11c40c 100644 --- a/dotcom-rendering/src/components/FlexibleGeneral.stories.tsx +++ b/dotcom-rendering/src/components/FlexibleGeneral.stories.tsx @@ -6,11 +6,11 @@ import { galleryTrails, getSublinks, opinionTrails, - selfHostedVideo169Card, - selfHostedVideo45Card, - selfHostedVideo53Card, - selfHostedVideo54Card, - selfHostedVideo916Card, + selfHostedLoopVideo169Card, + selfHostedLoopVideo45Card, + selfHostedLoopVideo53Card, + selfHostedLoopVideo54Card, + selfHostedLoopVideo916Card, slideshowCard, trails, youtubeVideoTrails, @@ -592,11 +592,11 @@ export const SelfHostedVideoCardsInSplashSlots: Story = { ] as BoostLevel[]; const videos = [ - selfHostedVideo54Card, - selfHostedVideo45Card, - selfHostedVideo53Card, - selfHostedVideo916Card, - selfHostedVideo169Card, + selfHostedLoopVideo54Card, + selfHostedLoopVideo45Card, + selfHostedLoopVideo53Card, + selfHostedLoopVideo916Card, + selfHostedLoopVideo169Card, ]; return ( @@ -623,7 +623,7 @@ export const SelfHostedVideoInStandardSlot: Story = { 'This slot is too small for video. Show image instead', groupedTrails: { ...emptyGroupedTrails, - standard: [selfHostedVideo54Card], // Self-hosted video is disabled at standard card size + standard: [selfHostedLoopVideo54Card], // Self-hosted video is disabled at standard card size }, }, }; diff --git a/dotcom-rendering/src/components/FlexibleSpecial.stories.tsx b/dotcom-rendering/src/components/FlexibleSpecial.stories.tsx index 97fca0ac9eb..1f7b80aa690 100644 --- a/dotcom-rendering/src/components/FlexibleSpecial.stories.tsx +++ b/dotcom-rendering/src/components/FlexibleSpecial.stories.tsx @@ -4,7 +4,7 @@ import { discussionApiUrl } from '../../fixtures/manual/discussionApiUrl'; import { getSublinks, opinionTrails, - selfHostedVideo54Card, + selfHostedLoopVideo54Card, slideshowCard, snapLink, trails, @@ -303,7 +303,7 @@ export const SelfHostedVideoCard: Story = { groupedTrails: { ...emptyGroupedTrails, snap: [], - standard: [selfHostedVideo54Card], + standard: [selfHostedLoopVideo54Card], }, collectionId: 1, }, diff --git a/dotcom-rendering/src/components/ScrollableFeature.stories.tsx b/dotcom-rendering/src/components/ScrollableFeature.stories.tsx index 5e25560ee01..83ae3abe82d 100644 --- a/dotcom-rendering/src/components/ScrollableFeature.stories.tsx +++ b/dotcom-rendering/src/components/ScrollableFeature.stories.tsx @@ -4,6 +4,10 @@ import { discussionApiUrl } from '../../fixtures/manual/discussionApiUrl'; import { audioTrails, galleryTrails, + selfHostedLoopVideo45Card, + selfHostedLoopVideo53Card, + selfHostedLoopVideo54Card, + selfHostedLoopVideo916Card, youtubeVideoTrails, } from '../../fixtures/manual/trails'; import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; @@ -108,6 +112,83 @@ export const MoreMedia = { }, } satisfies Story; +export const SelfHostedVideo = { + render: (args) => { + const Section = ({ + title, + videos, + }: { + title: string; + videos: DCRFrontCard[]; + }) => ( + + + + ); + + return ( + <> +
+
+
+ + ); + }, + args: { + ...Default, + trails: [selfHostedLoopVideo45Card], + }, + parameters: { + chromatic: { + disableSnapshot: true, + }, + }, +} satisfies Story; + export const WithPrimaryContainer = { render: (args) => ( diff --git a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx index 255d25d2e4e..8096ded1702 100644 --- a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx @@ -22,6 +22,7 @@ import { SubtitleOverlay } from './SubtitleOverlay'; import { VideoProgressBar } from './VideoProgressBar'; export type SubtitleSize = 'small' | 'medium' | 'large'; +export type ControlsPosition = 'top' | 'bottom'; const videoStyles = (aspectRatio: number) => css` position: relative; @@ -57,15 +58,17 @@ const playIconStyles = css` padding: 0; `; -const audioButtonStyles = css` +const audioButtonStyles = (position: ControlsPosition) => css` border: none; background: none; padding: 0; position: absolute; - /* Take into account the progress bar height */ - bottom: ${space[3]}px; - right: ${space[2]}px; cursor: pointer; + + right: ${space[2]}px; + /* Take into account the progress bar height */ + ${position === 'bottom' && `bottom: ${space[3]}px;`} + ${position === 'top' && `top: ${space[2]}px;`} `; const audioIconContainerStyles = css` @@ -121,8 +124,10 @@ type Props = { posterImage?: string; preloadPartialData: boolean; showPlayIcon: boolean; + showProgressBar: boolean; subtitleSource?: string; subtitleSize?: SubtitleSize; + controlsPosition: ControlsPosition; /* used in custom subtitle overlays */ activeCue?: ActiveCue | null; }; @@ -130,12 +135,10 @@ type Props = { /** * Note that in React 19, forwardRef is no longer necessary: * https://react.dev/reference/react/forwardRef - */ -/** - * NB: To develop the video player locally, use `https://r.thegulocal.com/` instead of `localhost`. + * + * NB: When DEVELOPING LOCALLY, use `https://r.thegulocal.com/` instead of `localhost`. * This is required because CORS restrictions prevent accessing the subtitles and video file from localhost. */ - export const SelfHostedVideoPlayer = forwardRef( ( { @@ -163,8 +166,10 @@ export const SelfHostedVideoPlayer = forwardRef( AudioIcon, preloadPartialData, showPlayIcon, + showProgressBar, subtitleSource, subtitleSize, + controlsPosition, activeCue, }: Props, ref: React.ForwardedRef, @@ -249,12 +254,12 @@ export const SelfHostedVideoPlayer = forwardRef( {showSubtitles && !!activeCue?.text && ( )} {showControls && ( <> - {/* Play icon */} {showPlayIcon && ( )} - {/* Progress bar */} - - {/* Audio icon */} + {showProgressBar && ( + + )} {AudioIcon && (