Skip to content

Commit 384995c

Browse files
committed
Support cinemagraphs in feature cards
1 parent 9038dfd commit 384995c

File tree

6 files changed

+206
-155
lines changed

6 files changed

+206
-155
lines changed

dotcom-rendering/src/components/FeatureCard.stories.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ const cardProps: CardProps = {
2424
altText: 'alt text',
2525
},
2626
isExternalLink: false,
27-
canPlayInline: true,
2827
imageLoading: 'eager',
2928
discussionApiUrl: 'https://discussion.theguardian.com/discussion-api/',
3029
aspectRatio: '4:5',
@@ -35,6 +34,7 @@ const cardProps: CardProps = {
3534
showClock: false,
3635
imageSize: 'feature',
3736
collectionId: 1,
37+
uniqueId: `collection-1-feature-0`,
3838
};
3939

4040
const aBasicLink = {
@@ -282,8 +282,7 @@ export const GalleryImmersive: Story = {
282282
},
283283
};
284284

285-
// A video article
286-
export const Video: Story = {
285+
export const YoutubeVideo: Story = {
287286
args: {
288287
format: {
289288
...cardProps.format,
@@ -308,17 +307,17 @@ export const Video: Story = {
308307
},
309308
};
310309

311-
export const VideoImmersive: Story = {
310+
export const YoutubeVideoImmersive: Story = {
312311
args: {
313-
...Video.args,
312+
...YoutubeVideo.args,
314313
...Immersive.args,
315314
},
316315
};
317316

318317
// A standard (non-video) article with a video main media
319-
export const VideoMainMedia: Story = {
318+
export const YoutubeVideoMainMedia: Story = {
320319
args: {
321-
...Video.args,
320+
...YoutubeVideo.args,
322321
image: {
323322
src: 'https://media.guim.co.uk/4612af5f4667888fa697139cf570b6373d93a710/2446_345_3218_1931/master/3218.jpg',
324323
altText: 'alt text',
@@ -330,9 +329,9 @@ export const VideoMainMedia: Story = {
330329
},
331330
};
332331

333-
export const VideoMainMediaImmersive: Story = {
332+
export const YoutubeVideoMainMediaImmersive: Story = {
334333
args: {
335-
...VideoMainMedia.args,
334+
...YoutubeVideoMainMedia.args,
336335
...Immersive.args,
337336
},
338337
};
@@ -379,3 +378,31 @@ export const WithSublinksLabsImmersive: Story = {
379378
...Immersive.args,
380379
},
381380
};
381+
382+
export const WithSelfHostedVideo: Story = {
383+
args: {
384+
...cardProps,
385+
showVideo: true,
386+
mainMedia: {
387+
type: 'SelfHostedVideo',
388+
videoStyle: 'Loop',
389+
atomId: 'atom-id-123',
390+
sources: [
391+
{
392+
src: 'https://uploads.guim.co.uk/2025%2F06%2F20%2Ftesting+only%2C+please+ignore--3cb22b60-2c3f-48d6-8bce-38c956907cce-3.mp4',
393+
mimeType: 'video/mp4',
394+
},
395+
],
396+
width: 900,
397+
height: 720,
398+
duration: 18,
399+
},
400+
},
401+
};
402+
403+
export const WithSelfHostedImmersiveVideo: Story = {
404+
args: {
405+
...WithSelfHostedVideo.args,
406+
isImmersive: true,
407+
},
408+
};

dotcom-rendering/src/components/FeatureCard.tsx

Lines changed: 83 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
DCRFrontImage,
2525
DCRSupportingContent,
2626
} from '../types/front';
27+
import type { CardMediaType } from '../types/layout';
2728
import type { MainMedia } from '../types/mainMedia';
2829
import { BrandingLabel } from './BrandingLabel';
2930
import { CardFooter } from './Card/components/CardFooter';
@@ -40,6 +41,7 @@ import { FormatBoundary } from './FormatBoundary';
4041
import { Island } from './Island';
4142
import { MediaDuration } from './MediaDuration';
4243
import { Pill } from './Pill';
44+
import { SelfHostedVideo } from './SelfHostedVideo.importable';
4345
import { StarRating } from './StarRating/StarRating';
4446
import { SupportingContent } from './SupportingContent';
4547
import { WaveForm } from './WaveForm';
@@ -125,7 +127,7 @@ const immersiveOverlayContainerStyles = css`
125127
* 48px is to ensure the gradient does not render the content inaccessible.
126128
*/
127129
width: 268px;
128-
z-index: 1;
130+
z-index: ${getZIndex('feature-card-overlay')};
129131
}
130132
`;
131133

@@ -152,11 +154,14 @@ const overlayMaskGradientStyles = (angle: string) => css`
152154
);
153155
`;
154156
const overlayStyles = css`
157+
position: relative;
155158
display: flex;
156159
flex-direction: column;
157160
text-align: start;
158161
gap: ${space[1]}px;
159162
padding: 64px ${space[2]}px ${space[2]}px;
163+
/* Needs to be above self-hosted video */
164+
z-index: ${getZIndex('feature-card-overlay')};
160165
backdrop-filter: blur(12px) brightness(0.5);
161166
@supports not (backdrop-filter: blur(12px)) {
162167
background-color: ${transparentColour(sourcePalette.neutral[10], 0.7)};
@@ -239,16 +244,35 @@ const getMedia = ({
239244
imageUrl,
240245
imageAltText,
241246
mainMedia,
242-
canPlayInline,
247+
showVideo,
243248
}: {
244249
imageUrl?: string;
245250
imageAltText?: string;
246251
mainMedia?: MainMedia;
247-
canPlayInline?: boolean;
252+
showVideo?: boolean;
248253
}) => {
249-
if (mainMedia && mainMedia.type === 'YoutubeVideo' && canPlayInline) {
254+
if (mainMedia?.type === 'SelfHostedVideo' && showVideo) {
255+
let type: CardMediaType;
256+
switch (mainMedia.videoStyle) {
257+
case 'Loop':
258+
type = 'loop-video';
259+
break;
260+
case 'Cinemagraph':
261+
type = 'cinemagraph';
262+
break;
263+
default:
264+
type = 'default-video';
265+
}
266+
267+
return {
268+
type,
269+
mainMedia,
270+
} as const;
271+
}
272+
273+
if (mainMedia?.type === 'YoutubeVideo' && showVideo) {
250274
return {
251-
type: 'video',
275+
type: 'youtube-video',
252276
mainMedia,
253277
} as const;
254278
}
@@ -305,12 +329,6 @@ export type Props = {
305329
showClock?: boolean;
306330
mainMedia?: MainMedia;
307331
trailText?: string;
308-
/**
309-
* Note YouTube recommends a minimum width of 480px @see https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-youtube-player-size
310-
* At 300px or below, the player will begin to lose functionality e.g. volume controls being omitted.
311-
* Youtube requires a minimum width 200px.
312-
*/
313-
canPlayInline?: boolean;
314332
kickerText?: string;
315333
showPulsingDot?: boolean;
316334
starRating?: Rating;
@@ -335,6 +353,7 @@ export type Props = {
335353
* The highlights container above the header is 0, the first container below the header is 1, etc.
336354
*/
337355
collectionId: number;
356+
uniqueId: string;
338357
isNewsletter?: boolean;
339358
/**
340359
* An immersive feature card variant. It dictates that the card has a full width background image on
@@ -360,7 +379,6 @@ export const FeatureCard = ({
360379
imageLoading,
361380
showClock,
362381
mainMedia,
363-
canPlayInline,
364382
kickerText,
365383
showPulsingDot,
366384
dataLinkName,
@@ -376,34 +394,37 @@ export const FeatureCard = ({
376394
starRating,
377395
showQuotes,
378396
collectionId,
397+
uniqueId,
379398
isNewsletter = false,
380399
isImmersive = false,
381400
showVideo = false,
382401
showLabsRedesign = false,
383402
}: Props) => {
384403
const hasSublinks = supportingContent && supportingContent.length > 0;
385404

386-
const isVideoMainMedia = mainMedia?.type === 'YoutubeVideo';
387405
const isVideoArticle = format.design === ArticleDesign.Video;
388406

389-
const videoDuration =
390-
mainMedia?.type === 'YoutubeVideo' ? mainMedia.duration : undefined;
391-
407+
/**
408+
* Determine which type of media to use for the card.
409+
* For example, a video might be available, but if we don't want to show it, use an image instead.
410+
*/
392411
const media = getMedia({
393412
imageUrl: image?.src,
394413
imageAltText: image?.altText,
395414
mainMedia,
396-
canPlayInline,
415+
showVideo,
397416
});
398417

399-
const showYoutubeVideo =
400-
canPlayInline && showVideo && mainMedia?.type === 'YoutubeVideo';
401-
402418
const showCardAge =
403419
webPublicationDate !== undefined && showClock !== undefined;
404420

405421
const showCommentCount = discussionId !== undefined;
406422

423+
const isSelfHostedVideo =
424+
media?.type === 'loop-video' ||
425+
media?.type === 'default-video' ||
426+
media?.type === 'cinemagraph';
427+
407428
const labsDataAttributes = branding
408429
? getOphanComponents({
409430
branding,
@@ -417,16 +438,16 @@ export const FeatureCard = ({
417438
<FormatBoundary format={format}>
418439
<ContainerOverrides containerPalette={containerPalette}>
419440
<div css={[baseCardStyles, hoverStyles, sublinkHoverStyles]}>
420-
{!showYoutubeVideo && (
441+
{media?.type !== 'youtube-video' && (
421442
<CardLink
422443
linkTo={linkTo}
423444
headlineText={headlineText}
424445
dataLinkName={dataLinkName}
425446
isExternalLink={isExternalLink}
426447
/>
427448
)}
428-
<div css={contentStyles}>
429-
{showYoutubeVideo && (
449+
<div className="contentStyles" css={contentStyles}>
450+
{media?.type === 'youtube-video' && (
430451
<div
431452
data-chromatic="ignore"
432453
data-component="youtube-atom"
@@ -441,15 +462,15 @@ export const FeatureCard = ({
441462
defer={{ until: 'visible' }}
442463
>
443464
<YoutubeBlockComponent
444-
id={mainMedia.id}
445-
assetId={mainMedia.videoId}
465+
id={media.mainMedia.id}
466+
assetId={media.mainMedia.videoId}
446467
index={collectionId}
447-
expired={mainMedia.expired}
468+
expired={media.mainMedia.expired}
448469
format={format}
449470
stickyVideos={false}
450471
enableAds={false}
451-
duration={mainMedia.duration}
452-
posterImage={mainMedia.image}
472+
duration={media.mainMedia.duration}
473+
posterImage={media.mainMedia.image}
453474
width={300}
454475
height={375}
455476
origin="The Guardian"
@@ -481,7 +502,7 @@ export const FeatureCard = ({
481502
</Island>
482503
</div>
483504
)}
484-
{!showYoutubeVideo && media && (
505+
{media?.type !== 'youtube-video' && (
485506
<div
486507
css={css`
487508
position: relative;
@@ -490,24 +511,41 @@ export const FeatureCard = ({
490511
)};
491512
`}
492513
>
493-
{media.type === 'video' && (
494-
<div>
495-
<CardPicture
496-
mainImage={
514+
{isSelfHostedVideo && (
515+
<Island
516+
priority="critical"
517+
defer={{ until: 'visible' }}
518+
>
519+
<SelfHostedVideo
520+
sources={media.mainMedia.sources}
521+
atomId={media.mainMedia.atomId}
522+
uniqueId={uniqueId}
523+
height={media.mainMedia.height}
524+
width={media.mainMedia.width}
525+
// Only cinemagraphs are currently supported in feature cards
526+
videoStyle="Cinemagraph"
527+
posterImage={
497528
media.mainMedia.image ?? ''
498529
}
499-
imageSize={imageSize}
500-
alt={headlineText}
501-
loading={imageLoading}
502-
aspectRatio={aspectRatio}
503-
mobileAspectRatio={
504-
mobileAspectRatio
530+
fallbackImage={
531+
media.mainMedia.image ?? ''
505532
}
533+
fallbackImageSize={imageSize}
534+
fallbackImageLoading={imageLoading}
535+
fallbackImageAlt={
536+
media.imageAltText
537+
}
538+
fallbackImageAspectRatio="5:4"
539+
linkTo={linkTo}
540+
subtitleSource={
541+
media.mainMedia.subtitleSource
542+
}
543+
subtitleSize="large"
506544
/>
507-
</div>
545+
</Island>
508546
)}
509547

510-
{media.type === 'picture' && (
548+
{media?.type === 'picture' && (
511549
<>
512550
<CardPicture
513551
mainImage={media.imageUrl}
@@ -519,7 +557,7 @@ export const FeatureCard = ({
519557
mobileAspectRatio
520558
}
521559
/>
522-
{isVideoMainMedia &&
560+
{mainMedia?.type === 'YoutubeVideo' &&
523561
mainMedia.duration > 0 && (
524562
<MediaDuration
525563
mediaDuration={
@@ -697,14 +735,14 @@ export const FeatureCard = ({
697735
</div>
698736
{/* On video article cards, the duration is displayed in the footer */}
699737
{!isVideoArticle &&
700-
isVideoMainMedia &&
701-
videoDuration !== undefined ? (
738+
mainMedia?.type === 'YoutubeVideo' &&
739+
!!Number(mainMedia.duration) ? (
702740
<div css={videoPillStyles}>
703741
<Pill
704742
content={
705743
<time>
706744
{secondsToDuration(
707-
videoDuration,
745+
mainMedia.duration,
708746
)}
709747
</time>
710748
}

0 commit comments

Comments
 (0)