From 86081e5c5f9f4a3f7118f2cb97a995119d327b8f Mon Sep 17 00:00:00 2001 From: Max Duval Date: Fri, 30 Jun 2023 15:04:39 +0100 Subject: [PATCH 1/4] refactor(Card): mediaType is available --- dotcom-rendering/src/components/Card/Card.tsx | 40 +++---------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 43cc72a82fd..6e465dfe7b5 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -88,19 +88,6 @@ export type Props = { showLivePlayable?: boolean; }; -const getMediaType = ( - design: ArticleDesign.Gallery | ArticleDesign.Audio | ArticleDesign.Video, -) => { - switch (design) { - case ArticleDesign.Gallery: - return 'Gallery'; - case ArticleDesign.Audio: - return 'Audio'; - case ArticleDesign.Video: - return 'Video'; - } -}; - const StarRatingComponent = ({ rating, cardHasImage, @@ -241,20 +228,6 @@ const isWithinTwelveHours = (webPublicationDate: string): boolean => { return timeDiffHours <= 12; }; -/** - * This function contains the business logic that determines whether the article contains a - * playable main media. It is used to determine which iconography should be displayed on the card. - * - */ -const decidePlayableMainMedia = ( - showMainVideo: boolean | undefined, - design: ArticleDesign, -) => { - if (showMainVideo) return true; - if (design === ArticleDesign.Video) return true; - return false; -}; - export const Card = ({ linkTo, format, @@ -272,6 +245,7 @@ export const Card = ({ trailText, avatarUrl, showClock, + mediaType, mediaDuration, showMainVideo, kickerText, @@ -374,10 +348,7 @@ export const Card = ({ ); } - const isPlayableMainMedia = decidePlayableMainMedia( - showMainVideo, - format.design, - ); + const isPlayableMainMedia = !!showMainVideo || mediaType === 'Video'; const image = getImage({ imageUrl, @@ -495,16 +466,15 @@ export const Card = ({ cardHasImage={imageUrl !== undefined} /> ) : null} - {format.design === ArticleDesign.Gallery || - format.design === ArticleDesign.Audio ? ( + {mediaType && mediaType !== 'Video' && ( - ) : undefined} + )} {/* This div is needed to push this content to the bottom of the card */}
From 2db824d791469c7e968da706c8f67bbbc59ed6d4 Mon Sep 17 00:00:00 2001 From: Max Duval Date: Fri, 30 Jun 2023 15:28:52 +0100 Subject: [PATCH 2/4] refactor(Card): narrow media variations renamed the method as it can handle more than images we can narrow our types with a discriminated union --- dotcom-rendering/src/components/Card/Card.tsx | 53 ++++++++----------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 6e465dfe7b5..69ce9bfb4e1 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -180,7 +180,7 @@ const CommentFooter = ({ ); }; -const getImage = ({ +const getMedia = ({ imageUrl, avatarUrl, isCrossword, @@ -190,18 +190,12 @@ const getImage = ({ avatarUrl?: string; isCrossword?: boolean; slideshowImages?: DCRSlideshowImage[]; -}): - | { - type: CardImageType; - src: string; - slideshowImages?: DCRSlideshowImage[]; - } - | undefined => { - if (slideshowImages) return { type: 'slideshow', src: '', slideshowImages }; - if (avatarUrl) return { type: 'avatar', src: avatarUrl }; +}) => { + if (slideshowImages) return { type: 'slideshow', slideshowImages } as const; + if (avatarUrl) return { type: 'avatar', avatarUrl } as const; if (imageUrl) { const type = isCrossword ? 'crossword' : 'mainMedia'; - return { type, src: imageUrl }; + return { type, imageUrl } as const; } return undefined; }; @@ -350,7 +344,7 @@ export const Card = ({ const isPlayableMainMedia = !!showMainVideo || mediaType === 'Video'; - const image = getImage({ + const media = getMedia({ imageUrl, avatarUrl, isCrossword, @@ -373,45 +367,44 @@ export const Card = ({ imagePosition={imagePosition} imagePositionOnMobile={imagePositionOnMobile} minWidthInPixels={minWidthInPixels} - imageType={image?.type} + imageType={media?.type} > - {image && ( + {media && ( - {image.type === 'slideshow' && - image.slideshowImages && ( - - )} - {image.type === 'avatar' && ( + {media.type === 'slideshow' && ( + + )} + {media.type === 'avatar' && ( )} - {image.type === 'mainMedia' && ( + {media.type === 'mainMedia' && ( )} - {image.type === 'crossword' && ( - + {media.type === 'crossword' && ( + )} {isPlayableMainMedia && @@ -428,7 +421,7 @@ export const Card = ({ )} @@ -484,7 +477,7 @@ export const Card = ({ format={format} imagePosition={imagePosition} imageSize={imageSize} - imageType={image?.type} + imageType={media?.type} >
Date: Fri, 30 Jun 2023 17:23:28 +0100 Subject: [PATCH 3/4] fix(posterImage): at least one image an empty array should result in no poster image available. --- .../src/components/YoutubeBlockComponent.importable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotcom-rendering/src/components/YoutubeBlockComponent.importable.tsx b/dotcom-rendering/src/components/YoutubeBlockComponent.importable.tsx index b006f7b514d..0a82fa8a4c0 100644 --- a/dotcom-rendering/src/components/YoutubeBlockComponent.importable.tsx +++ b/dotcom-rendering/src/components/YoutubeBlockComponent.importable.tsx @@ -80,7 +80,7 @@ export const YoutubeBlockComponent = ({ format, hideCaption, overrideImage, - posterImage, + posterImage = [], expired, role, isMainMedia, @@ -202,7 +202,7 @@ export const YoutubeBlockComponent = ({ : undefined } posterImage={ - posterImage + posterImage.length > 0 ? [ { srcSet: posterImage.map((img) => ({ From 14f65eafa028be53505964d39e99819f84adc8b3 Mon Sep 17 00:00:00 2001 From: Max Duval Date: Wed, 28 Jun 2023 11:31:03 +0100 Subject: [PATCH 4/4] feat(Card): Support mainVideo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings comes from the facia-tool via Frontend, when “Show Video” is enabled for a trail in a container, it should be displayed as a playable video inline instead or redirecting to the article itself. Currently, only Youtube Atoms are supported. There is currently no guarantee that the first atom will be the expected video, but it happens to be the case the vast majority of the time A minimum Card width of one third or three columns is required for the YouTube atom to be embedded, similar to the existing Frontend logic: https://github.com/guardian/frontend/blob/1c4555a9f/common/app/layout/cards/CardType.scala#L20-L23 --- dotcom-rendering/fixtures/manual/trails.ts | 58 ++-- dotcom-rendering/index.d.ts | 7 +- dotcom-rendering/makefile | 1 + .../src/components/Card/Card.stories.tsx | 83 ++--- dotcom-rendering/src/components/Card/Card.tsx | 114 +++++-- .../Card/components/ImageWrapper.tsx | 6 +- .../src/components/Carousel.importable.tsx | 15 +- .../src/components/DynamicPackage.stories.tsx | 2 +- dotcom-rendering/src/components/FrontCard.tsx | 5 +- .../components/SupportingContent.stories.tsx | 1 + dotcom-rendering/src/lib/cardWrappers.tsx | 8 + .../src/model/article-schema.json | 181 ++++++++++- dotcom-rendering/src/model/enhanceCards.ts | 78 +++-- dotcom-rendering/src/model/front-schema.json | 291 +++++++++++++++++- .../src/model/tag-front-schema.json | 112 ++++++- dotcom-rendering/src/types/front.ts | 26 +- dotcom-rendering/src/types/mainMedia.ts | 28 ++ dotcom-rendering/src/types/trails.ts | 6 +- 18 files changed, 864 insertions(+), 158 deletions(-) create mode 100644 dotcom-rendering/src/types/mainMedia.ts diff --git a/dotcom-rendering/fixtures/manual/trails.ts b/dotcom-rendering/fixtures/manual/trails.ts index a6df1cfe974..8cc2343d63d 100644 --- a/dotcom-rendering/fixtures/manual/trails.ts +++ b/dotcom-rendering/fixtures/manual/trails.ts @@ -71,7 +71,7 @@ export const trails: [ kickerText: 'Kicker', }, ], - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -87,11 +87,25 @@ export const trails: [ design: ArticleDesign.Video, display: ArticleDisplay.Standard, }, - mediaType: 'Video', - mediaDuration: 378, dataLinkName: 'news | group-0 | card-@2', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: { + type: 'Video', + elementId: 'abcdef', + videoId: 'abcd', + title: 'some title', + duration: 378, + width: 480, + height: 288, + origin: 'The Guardian', + expired: false, + images: [ + { + url: 'https://i.guim.co.uk/img/media/e060e9b7c92433b3dfeccc98b9206778cda8b8e8/0_180_6680_4009/master/6680.jpg?width=600&quality=45&dpr=2&s=none', + width: 600, + }, + ], + }, isExternalLink: false, showLivePlayable: false, }, @@ -110,7 +124,7 @@ export const trails: [ kickerText: 'Live', dataLinkName: 'news | group-0 | card-@3', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -128,7 +142,7 @@ export const trails: [ }, dataLinkName: 'news | group-0 | card-@4', showQuotedHeadline: true, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -146,7 +160,7 @@ export const trails: [ }, dataLinkName: 'news | group-0 | card-@5', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -165,7 +179,7 @@ export const trails: [ }, dataLinkName: 'news | group-0 | card-@6', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -185,7 +199,7 @@ export const trails: [ }, dataLinkName: 'news | group-0 | card-@7', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -204,7 +218,7 @@ export const trails: [ }, dataLinkName: 'news | group-0 | card-@8', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -225,7 +239,7 @@ export const trails: [ 'UK Covid live: England lockdown to be eased in stages, says PM, amid reports of nationwide mass testing', dataLinkName: 'news | group-0 | card-@9', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -245,7 +259,7 @@ export const trails: [ 'UK to infect up to 90 healthy volunteers with Covid in world first trial', dataLinkName: 'news | group-0 | card-@10', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -265,7 +279,7 @@ export const trails: [ 'Scottish government inadequately prepared for Covid, says watchdog', dataLinkName: 'news | group-0 | card-@11', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -285,7 +299,7 @@ export const trails: [ '‘Encouraging’ signs for Covid vaccine as over-80s deaths fall in England', dataLinkName: 'news | group-0 | card-@12', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -305,7 +319,7 @@ export const trails: [ 'Contact tracing alone has little impact on curbing Covid spread, report finds', dataLinkName: 'news | group-0 | card-@1', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -325,7 +339,7 @@ export const trails: [ 'Ethnicity and poverty are Covid risk factors, new Oxford modelling tool shows', dataLinkName: 'news | group-0 | card-@13', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -345,7 +359,7 @@ export const trails: [ 'UK Covid: 799 more deaths and 10,625 new cases reported; Scottish schools in phased return from Monday – as it happened', dataLinkName: 'news | group-0 | card-@14', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -365,7 +379,7 @@ export const trails: [ 'QCovid: how improved algorithm can identify more higher-risk adults', dataLinkName: 'news | group-0 | card-@1', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -383,7 +397,7 @@ export const trails: [ }, dataLinkName: 'news | group-0 | card-@15', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -402,7 +416,7 @@ export const trails: [ }, dataLinkName: 'news | group-0 | card-@16', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -421,7 +435,7 @@ export const trails: [ }, dataLinkName: 'news | group-0 | card-@17', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, @@ -440,7 +454,7 @@ export const trails: [ }, dataLinkName: 'news | group-0 | card-@18', showQuotedHeadline: false, - showMainVideo: false, + mainMedia: undefined, isExternalLink: false, showLivePlayable: false, }, diff --git a/dotcom-rendering/index.d.ts b/dotcom-rendering/index.d.ts index b5d5b838932..feacf018066 100644 --- a/dotcom-rendering/index.d.ts +++ b/dotcom-rendering/index.d.ts @@ -286,7 +286,12 @@ interface FEKeyEventsRequest { filterKeyEvents: boolean; } -type CardImageType = 'mainMedia' | 'avatar' | 'crossword' | 'slideshow'; +type CardImageType = + | 'picture' + | 'avatar' + | 'crossword' + | 'slideshow' + | 'video'; type SmallHeadlineSize = | 'tiny' diff --git a/dotcom-rendering/makefile b/dotcom-rendering/makefile index 229eba0c290..dd4a8550c28 100644 --- a/dotcom-rendering/makefile +++ b/dotcom-rendering/makefile @@ -206,6 +206,7 @@ gen-schema: @git add src/model/article-schema.json @git add src/model/front-schema.json @git add src/model/block-schema.json + @git add src/model/tag-front-schema.json check-stories: $(call log, "Checking Storybook stories") diff --git a/dotcom-rendering/src/components/Card/Card.stories.tsx b/dotcom-rendering/src/components/Card/Card.stories.tsx index 4d2519706e1..ee14c5bfcdb 100644 --- a/dotcom-rendering/src/components/Card/Card.stories.tsx +++ b/dotcom-rendering/src/components/Card/Card.stories.tsx @@ -7,6 +7,7 @@ import { } from '@guardian/libs'; import { from } from '@guardian/source-foundations'; import React from 'react'; +import type { MainMedia } from '../../types/mainMedia'; import { Section } from '../Section'; import type { Props as CardProps } from './Card'; import { Card } from './Card'; @@ -31,6 +32,7 @@ const basicCardProps: CardProps = { imagePosition: 'top', showAge: true, isExternalLink: false, + videoSize: 'large enough to play: at least 480px', }; const aBasicLink = { @@ -43,6 +45,31 @@ const aBasicLink = { }, }; +const mainVideo: MainMedia = { + type: 'Video', + elementId: '1234-abcdef-09876-xyz', + videoId: '8M_yH-e9cq8', + title: '’I care, but I don’t care’: Life after the Queen’s death | Anywhere but Westminster', + expired: false, + duration: 200, + images: [480, 640, 960, 1024, 1200].map((width) => ({ + url: `https://i.guim.co.uk/img/media/2eb01d138eb8fba6e59ce7589a60e3ff984f6a7a/0_0_1920_1080/1920.jpg?width=${width}&quality=45&dpr=2&s=none`, + width, + })), + width: 480, + height: 288, + origin: 'The Guardian', +}; + +const mainAudio: MainMedia = { + type: 'Audio', + duration: 24, +}; + +const mainGallery: MainMedia = { + type: 'Gallery', +}; + const CardWrapper = ({ children }: { children: React.ReactNode }) => { return (
{ design: ArticleDesign.Video, theme: ArticlePillar.Sport, }} - mediaType="Video" - mediaDuration={30} + mainMedia={{ ...mainVideo, duration: 30 }} headlineText="Video" /> @@ -258,8 +284,7 @@ export const WithMediaType = () => { design: ArticleDesign.Audio, theme: ArticlePillar.Sport, }} - mediaType="Audio" - mediaDuration={90} + mainMedia={mainAudio} headlineText="Audio" /> @@ -271,7 +296,7 @@ export const WithMediaType = () => { design: ArticleDesign.Gallery, theme: ArticlePillar.Sport, }} - mediaType="Gallery" + mainMedia={mainGallery} headlineText="Gallery" /> @@ -290,8 +315,7 @@ export const WithMediaTypeSpecialReportAlt = () => { design: ArticleDesign.Video, theme: ArticleSpecial.SpecialReportAlt, }} - mediaType="Video" - mediaDuration={30} + mainMedia={{ ...mainVideo, duration: 30 }} headlineText="Video" /> @@ -303,8 +327,7 @@ export const WithMediaTypeSpecialReportAlt = () => { design: ArticleDesign.Audio, theme: ArticleSpecial.SpecialReportAlt, }} - mediaType="Audio" - mediaDuration={90} + mainMedia={{ ...mainAudio, duration: 90 }} headlineText="Audio" /> @@ -316,7 +339,7 @@ export const WithMediaTypeSpecialReportAlt = () => { design: ArticleDesign.Gallery, theme: ArticleSpecial.SpecialReportAlt, }} - mediaType="Gallery" + mainMedia={mainGallery} headlineText="Gallery" /> @@ -917,9 +940,7 @@ export const WhenVideoWithPlayButton = () => { imagePosition="top" imageSize="jumbo" imagePositionOnMobile="top" - mediaDuration={200} - mediaType="Video" - showMainVideo={true} + mainMedia={mainVideo} /> @@ -935,9 +956,7 @@ export const WhenVideoWithPlayButton = () => { imagePosition="right" imageSize="large" imagePositionOnMobile="top" - mediaDuration={200} - mediaType="Video" - showMainVideo={true} + mainMedia={mainVideo} />
  • @@ -949,9 +968,8 @@ export const WhenVideoWithPlayButton = () => { theme: ArticlePillar.News, }} imagePosition="top" - mediaDuration={200} - mediaType="Video" - showMainVideo={true} + mainMedia={mainVideo} + videoSize="too small to play: 479px or less" />
  • @@ -967,9 +985,7 @@ export const WhenVideoWithPlayButton = () => { imagePosition="top" imageSize="medium" imagePositionOnMobile="bottom" - mediaDuration={200} - mediaType="Video" - showMainVideo={true} + mainMedia={mainVideo} />
  • @@ -983,9 +999,8 @@ export const WhenVideoWithPlayButton = () => { theme: ArticlePillar.News, }} imagePosition="left" - mediaDuration={200} - mediaType="Video" - showMainVideo={true} + mainMedia={mainVideo} + videoSize="too small to play: 479px or less" />
  • @@ -997,9 +1012,8 @@ export const WhenVideoWithPlayButton = () => { theme: ArticlePillar.News, }} imagePosition="right" - mediaDuration={200} - mediaType="Video" - showMainVideo={true} + mainMedia={mainVideo} + videoSize="too small to play: 479px or less" />
  • @@ -1012,9 +1026,8 @@ export const WhenVideoWithPlayButton = () => { theme: ArticlePillar.News, }} imagePosition="right" - mediaDuration={200} - mediaType="Video" - showMainVideo={true} + mainMedia={mainVideo} + videoSize="too small to play: 479px or less" /> @@ -1033,9 +1046,7 @@ export const WhenVideoWithPlayButton = () => { imagePosition="right" imageSize="large" imagePositionOnMobile="top" - mediaDuration={200} - mediaType="Video" - showMainVideo={true} + mainMedia={mainVideo} />
  • @@ -1049,9 +1060,7 @@ export const WhenVideoWithPlayButton = () => { imagePosition="top" imagePositionOnMobile="left" imageSize="medium" - mediaDuration={200} - mediaType="Video" - showMainVideo={true} + mainMedia={mainVideo} />
  • diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 69ce9bfb4e1..6a79f9da30f 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -13,6 +13,7 @@ import type { DCRSnapType, DCRSupportingContent, } from '../../types/front'; +import type { MainMedia } from '../../types/mainMedia'; import type { Palette } from '../../types/palette'; import { Avatar } from '../Avatar'; import { CardHeadline } from '../CardHeadline'; @@ -28,6 +29,7 @@ import { SnapCssSandbox } from '../SnapCssSandbox'; import { StarRating } from '../StarRating/StarRating'; import type { Alignment } from '../SupportingContent'; import { SupportingContent } from '../SupportingContent'; +import { YoutubeBlockComponent } from '../YoutubeBlockComponent.importable'; import { AvatarContainer } from './components/AvatarContainer'; import { CardAge } from './components/CardAge'; import { CardBranding } from './components/CardBranding'; @@ -44,6 +46,11 @@ import type { import { ImageWrapper } from './components/ImageWrapper'; import { TrailTextWrapper } from './components/TrailTextWrapper'; +/** Note YouTube recommends a minimum width of 480px @see https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-youtube-player-size */ +export type VideoSize = + | 'large enough to play: at least 480px' + | 'too small to play: 479px or less'; + export type Props = { linkTo: string; format: ArticleFormat; @@ -63,9 +70,8 @@ export type Props = { trailText?: string; avatarUrl?: string; showClock?: boolean; - mediaType?: MediaType; - mediaDuration?: number; - showMainVideo?: boolean; + mainMedia?: MainMedia; + videoSize: VideoSize; kickerText?: string; showPulsingDot?: boolean; starRating?: number; @@ -185,16 +191,27 @@ const getMedia = ({ avatarUrl, isCrossword, slideshowImages, + mainMedia, + videoSize, }: { imageUrl?: string; avatarUrl?: string; isCrossword?: boolean; slideshowImages?: DCRSlideshowImage[]; + mainMedia?: MainMedia; + videoSize: VideoSize; }) => { + if ( + mainMedia && + mainMedia.type === 'Video' && + videoSize === 'large enough to play: at least 480px' + ) { + return { type: 'video', mainMedia } as const; + } if (slideshowImages) return { type: 'slideshow', slideshowImages } as const; if (avatarUrl) return { type: 'avatar', avatarUrl } as const; if (imageUrl) { - const type = isCrossword ? 'crossword' : 'mainMedia'; + const type = isCrossword ? 'crossword' : 'picture'; return { type, imageUrl } as const; } return undefined; @@ -239,9 +256,8 @@ export const Card = ({ trailText, avatarUrl, showClock, - mediaType, - mediaDuration, - showMainVideo, + mainMedia, + videoSize, kickerText, showPulsingDot, starRating, @@ -342,13 +358,17 @@ export const Card = ({ ); } - const isPlayableMainMedia = !!showMainVideo || mediaType === 'Video'; + const showPlayIcon = + mainMedia?.type === 'Video' && + videoSize === 'too small to play: 479px or less'; const media = getMedia({ imageUrl, avatarUrl, isCrossword, slideshowImages, + mainMedia, + videoSize, }); return ( {media.type === 'slideshow' && ( )} - {media.type === 'mainMedia' && ( - + {media.type === 'video' && ( +
    + + + +
    + )} + {media.type === 'picture' && ( + <> + + {showPlayIcon && ( + + )} + )} {media.type === 'crossword' && ( )} - - {isPlayableMainMedia && - mediaDuration !== undefined && - mediaDuration > 0 && ( - - )} )} ) : null} - {mediaType && mediaType !== 'Video' && ( + {!!mainMedia && mainMedia.type !== 'Video' && ( )} diff --git a/dotcom-rendering/src/components/Card/components/ImageWrapper.tsx b/dotcom-rendering/src/components/Card/components/ImageWrapper.tsx index 9dd3ed5e986..749d7b4e91f 100644 --- a/dotcom-rendering/src/components/Card/components/ImageWrapper.tsx +++ b/dotcom-rendering/src/components/Card/components/ImageWrapper.tsx @@ -72,7 +72,7 @@ export const ImageWrapper = ({ flexBasisStyles({ imageSize, }), - imageType === 'mainMedia' && + (imageType === 'picture' || imageType === 'video') && isHorizontal && flexBasisStyles({ imageSize, @@ -123,10 +123,10 @@ export const ImageWrapper = ({ <> {children} {/* This image overlay is styled when the CardLink is hovered */} - {(imageType === 'mainMedia' || imageType === 'slideshow') && ( + {(imageType === 'picture' || imageType === 'slideshow') && (
    )} - {imageType === 'mainMedia' && showPlayIcon && ( + {imageType === 'picture' && showPlayIcon && ( (
  • ); @@ -870,6 +869,7 @@ export const Carousel = ({ kickerText, branding, discussion, + mainMedia, } = trail; // Don't try to render cards that have no publication date. This property is technically optional @@ -895,8 +895,7 @@ export const Carousel = ({ : undefined } branding={branding} - showMainVideo={trail.showMainVideo} - mediaDuration={trail.mediaDuration} + mainMedia={mainMedia} verticalDividerColour={ carouselColours.borderColour } diff --git a/dotcom-rendering/src/components/DynamicPackage.stories.tsx b/dotcom-rendering/src/components/DynamicPackage.stories.tsx index 72a1f02869d..585fcb6af9d 100644 --- a/dotcom-rendering/src/components/DynamicPackage.stories.tsx +++ b/dotcom-rendering/src/components/DynamicPackage.stories.tsx @@ -354,7 +354,7 @@ export const SpecialReportWithoutPalette = () => ( 'inside the firm that helps the super-rich hide their money', showQuotedHeadline: false, dataLinkName: 'news | group-0 | card-@1', - showMainVideo: false, + mainMedia: undefined, showLivePlayable: false, isExternalLink: false, webPublicationDate: '2016-04-08T12:15:09.000Z', diff --git a/dotcom-rendering/src/components/FrontCard.tsx b/dotcom-rendering/src/components/FrontCard.tsx index 772fc9c5009..074c00569ee 100644 --- a/dotcom-rendering/src/components/FrontCard.tsx +++ b/dotcom-rendering/src/components/FrontCard.tsx @@ -38,14 +38,13 @@ export const FrontCard = (props: Props) => { showClock: false, imageUrl: trail.image, isCrossword: trail.isCrossword, - mediaType: trail.mediaType, - mediaDuration: trail.mediaDuration, + videoSize: 'large enough to play: at least 480px', starRating: trail.starRating, dataLinkName: trail.dataLinkName, snapData: trail.snapData, discussionId: trail.discussionId, avatarUrl: trail.avatarUrl, - showMainVideo: trail.showMainVideo, + mainMedia: trail.mainMedia, isExternalLink: trail.isExternalLink, branding: trail.branding, slideshowImages: trail.slideshowImages, diff --git a/dotcom-rendering/src/components/SupportingContent.stories.tsx b/dotcom-rendering/src/components/SupportingContent.stories.tsx index a61a300bb97..4aaf8bdcbd3 100644 --- a/dotcom-rendering/src/components/SupportingContent.stories.tsx +++ b/dotcom-rendering/src/components/SupportingContent.stories.tsx @@ -32,6 +32,7 @@ const basicCardProps: CardProps = { imagePosition: 'top', isExternalLink: false, showLivePlayable: false, + videoSize: 'large enough to play: at least 480px', }; const aBasicLink = { diff --git a/dotcom-rendering/src/lib/cardWrappers.tsx b/dotcom-rendering/src/lib/cardWrappers.tsx index a30364c588d..c043d06bdb7 100644 --- a/dotcom-rendering/src/lib/cardWrappers.tsx +++ b/dotcom-rendering/src/lib/cardWrappers.tsx @@ -266,6 +266,7 @@ export const Card25Media25 = ({ imageSize="small" headlineSize="medium" headlineSizeOnMobile="medium" + videoSize="too small to play: 479px or less" /> ); }; @@ -301,6 +302,7 @@ export const Card25Media25SmallHeadline = ({ imageSize="small" headlineSize="small" headlineSizeOnMobile="medium" + videoSize="too small to play: 479px or less" /> ); }; @@ -343,6 +345,7 @@ export const Card25Media25Tall = ({ : undefined } supportingContent={trail.supportingContent?.slice(0, 2)} + videoSize="too small to play: 479px or less" /> ); }; @@ -377,6 +380,7 @@ export const Card25Media25TallNoTrail = ({ headlineSize="medium" headlineSizeOnMobile="medium" supportingContent={trail.supportingContent?.slice(0, 2)} + videoSize="too small to play: 479px or less" /> ); }; @@ -411,6 +415,7 @@ export const Card25Media25TallSmallHeadline = ({ headlineSize="small" headlineSizeOnMobile="medium" supportingContent={trail.supportingContent?.slice(0, 2)} + videoSize="too small to play: 479px or less" /> ); }; @@ -651,6 +656,7 @@ export const CardDefault = ({ avatarUrl={undefined} headlineSize="small" headlineSizeOnMobile="small" + videoSize="too small to play: 479px or less" /> ); }; @@ -682,6 +688,7 @@ export const CardDefaultMedia = ({ imagePositionOnMobile="none" headlineSize="small" headlineSizeOnMobile="small" + videoSize="too small to play: 479px or less" /> ); }; @@ -713,6 +720,7 @@ export const CardDefaultMediaMobile = ({ imagePositionOnMobile="left" headlineSize="small" headlineSizeOnMobile="small" + videoSize="too small to play: 479px or less" /> ); }; diff --git a/dotcom-rendering/src/model/article-schema.json b/dotcom-rendering/src/model/article-schema.json index 03bd581af31..721a9063d12 100644 --- a/dotcom-rendering/src/model/article-schema.json +++ b/dotcom-rendering/src/model/article-schema.json @@ -4029,9 +4029,6 @@ "avatarUrl": { "type": "string" }, - "mediaType": { - "$ref": "#/definitions/MediaType" - }, "mediaDuration": { "type": "number" }, @@ -4103,8 +4100,8 @@ "isCommentable" ] }, - "showMainVideo": { - "type": "boolean" + "mainMedia": { + "$ref": "#/definitions/MainMedia" } }, "required": [ @@ -4113,14 +4110,6 @@ "url" ] }, - "MediaType": { - "enum": [ - "Audio", - "Gallery", - "Video" - ], - "type": "string" - }, "Branding": { "type": "object", "properties": { @@ -4218,6 +4207,172 @@ "sponsorName" ] }, + "MainMedia": { + "anyOf": [ + { + "$ref": "#/definitions/Video", + "description": "For displaying embedded, playable videos directly in cards" + }, + { + "$ref": "#/definitions/Audio" + }, + { + "$ref": "#/definitions/Gallery" + } + ] + }, + "Video": { + "description": "For displaying embedded, playable videos directly in cards", + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "enum": [ + "Audio", + "Gallery", + "Video" + ], + "type": "string" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Video" + }, + "elementId": { + "type": "string" + }, + "videoId": { + "type": "string" + }, + "height": { + "type": "number" + }, + "width": { + "type": "number" + }, + "origin": { + "type": "string" + }, + "title": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "expired": { + "type": "boolean" + }, + "images": { + "type": "array", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "width": { + "type": "number" + } + }, + "required": [ + "url", + "width" + ] + } + } + }, + "required": [ + "duration", + "elementId", + "expired", + "height", + "images", + "origin", + "title", + "type", + "videoId", + "width" + ] + } + ] + }, + "Audio": { + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "enum": [ + "Audio", + "Gallery", + "Video" + ], + "type": "string" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Audio" + }, + "duration": { + "type": "number" + } + }, + "required": [ + "duration", + "type" + ] + } + ] + }, + "Gallery": { + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "enum": [ + "Audio", + "Gallery", + "Video" + ], + "type": "string" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Gallery" + } + }, + "required": [ + "type" + ] + } + ] + }, "OnwardsSource": { "enum": [ "curated-content", diff --git a/dotcom-rendering/src/model/enhanceCards.ts b/dotcom-rendering/src/model/enhanceCards.ts index a0725266480..b0c8db51dd6 100644 --- a/dotcom-rendering/src/model/enhanceCards.ts +++ b/dotcom-rendering/src/model/enhanceCards.ts @@ -10,8 +10,10 @@ import type { DCRSlideshowImage, DCRSupportingContent, FEFrontCard, + FEMediaAtoms, FESupportingContent, } from '../types/front'; +import type { MainMedia } from '../types/mainMedia'; import type { FETagType, TagType } from '../types/tag'; import { enhanceSnaps } from './enhanceSnaps'; @@ -124,19 +126,6 @@ const decideImage = (trail: FEFrontCard) => { return trail.properties.maybeContent?.trail.trailPicture?.allImages[0]?.url; }; -const decideMediaType = (format: ArticleFormat): MediaType | undefined => { - switch (format.design) { - case ArticleDesign.Gallery: - return 'Gallery'; - case ArticleDesign.Video: - return 'Video'; - case ArticleDesign.Audio: - return 'Audio'; - default: - return undefined; - } -}; - const decideKicker = ( trail: FEFrontCard, cardInTagFront: boolean, @@ -190,6 +179,60 @@ const enhanceTags = (tags: FETagType[]): TagType[] => { }); }; +/** + * While the first Media Atom is *not* guaranteed to be the main media, + * it *happens to be* correct in the majority of cases. + * @see https://github.com/guardian/frontend/pull/26247 for inspiration + */ +const decideMedia = ( + format: ArticleFormat, + mediaAtom?: FEMediaAtoms, +): MainMedia | undefined => { + switch (format.design) { + case ArticleDesign.Gallery: + return { type: 'Gallery' }; + + case ArticleDesign.Audio: + return { + type: 'Audio', + duration: mediaAtom?.duration ?? 0, + }; + + case ArticleDesign.Video: { + if (mediaAtom) { + const asset = mediaAtom.assets.find( + ({ version }) => version === mediaAtom.activeVersion, + ); + if (asset?.platform === 'Youtube') { + return { + type: 'Video', + elementId: mediaAtom.id, + videoId: asset.id, + duration: mediaAtom.duration ?? 0, + title: mediaAtom.title, + // Size fixed to a 5:3 ratio + width: 500, + height: 300, + origin: mediaAtom.source ?? 'Unknown origin', + expired: !!mediaAtom.expired, + images: + mediaAtom.posterImage?.allImages.map( + ({ url, fields: { width } }) => ({ + url, + width: Number(width), + }), + ) ?? [], + }; + } + } + return undefined; + } + + default: + return undefined; + } +}; + export const enhanceCards = ( collections: FEFrontCard[], { @@ -285,11 +328,10 @@ export const enhanceCards = ( faciaCard.properties.maybeContent.trail.byline, ) : undefined, - mediaType: decideMediaType(format), - mediaDuration: - faciaCard.properties.maybeContent?.elements.mediaAtoms[0] - ?.duration, - showMainVideo: faciaCard.properties.showMainVideo, + mainMedia: decideMedia( + format, + faciaCard.properties.maybeContent?.elements.mediaAtoms[0], + ), isExternalLink: faciaCard.card.cardStyle.type === 'ExternalLink', embedUri: faciaCard.properties.embedUri ?? undefined, branding, diff --git a/dotcom-rendering/src/model/front-schema.json b/dotcom-rendering/src/model/front-schema.json index 19bcc9b193a..f0a30b352a9 100644 --- a/dotcom-rendering/src/model/front-schema.json +++ b/dotcom-rendering/src/model/front-schema.json @@ -2837,10 +2837,120 @@ "FEMediaAtoms": { "type": "object", "properties": { + "id": { + "type": "string" + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/definitions/MediaAsset" + } + }, + "title": { + "type": "string" + }, "duration": { "type": "number" + }, + "source": { + "type": "string" + }, + "posterImage": { + "type": "object", + "properties": { + "allImages": { + "type": "array", + "items": { + "$ref": "#/definitions/Image" + } + } + }, + "required": [ + "allImages" + ] + }, + "expired": { + "type": "boolean" + }, + "activeVersion": { + "type": "number" } - } + }, + "required": [ + "assets", + "id", + "title" + ] + }, + "MediaAsset": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "version": { + "type": "number" + }, + "platform": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "id", + "platform", + "version" + ] + }, + "Image": { + "type": "object", + "properties": { + "index": { + "type": "number" + }, + "fields": { + "type": "object", + "properties": { + "height": { + "type": "string" + }, + "width": { + "type": "string" + }, + "isMaster": { + "type": "string" + }, + "source": { + "type": "string" + }, + "caption": { + "type": "string" + } + }, + "required": [ + "height", + "width" + ] + }, + "mediaType": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "fields", + "index", + "mediaType", + "mimeType", + "url" + ] }, "EditionId": { "enum": [ @@ -3306,9 +3416,6 @@ "avatarUrl": { "type": "string" }, - "mediaType": { - "$ref": "#/definitions/MediaType" - }, "mediaDuration": { "type": "number" }, @@ -3380,8 +3487,8 @@ "isCommentable" ] }, - "showMainVideo": { - "type": "boolean" + "mainMedia": { + "$ref": "#/definitions/MainMedia" } }, "required": [ @@ -3390,13 +3497,171 @@ "url" ] }, - "MediaType": { - "enum": [ - "Audio", - "Gallery", - "Video" - ], - "type": "string" + "MainMedia": { + "anyOf": [ + { + "$ref": "#/definitions/Video", + "description": "For displaying embedded, playable videos directly in cards" + }, + { + "$ref": "#/definitions/Audio" + }, + { + "$ref": "#/definitions/Gallery" + } + ] + }, + "Video": { + "description": "For displaying embedded, playable videos directly in cards", + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "enum": [ + "Audio", + "Gallery", + "Video" + ], + "type": "string" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Video" + }, + "elementId": { + "type": "string" + }, + "videoId": { + "type": "string" + }, + "height": { + "type": "number" + }, + "width": { + "type": "number" + }, + "origin": { + "type": "string" + }, + "title": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "expired": { + "type": "boolean" + }, + "images": { + "type": "array", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "width": { + "type": "number" + } + }, + "required": [ + "url", + "width" + ] + } + } + }, + "required": [ + "duration", + "elementId", + "expired", + "height", + "images", + "origin", + "title", + "type", + "videoId", + "width" + ] + } + ] + }, + "Audio": { + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "enum": [ + "Audio", + "Gallery", + "Video" + ], + "type": "string" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Audio" + }, + "duration": { + "type": "number" + } + }, + "required": [ + "duration", + "type" + ] + } + ] + }, + "Gallery": { + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "enum": [ + "Audio", + "Gallery", + "Video" + ], + "type": "string" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Gallery" + } + }, + "required": [ + "type" + ] + } + ] } }, "$schema": "http://json-schema.org/draft-07/schema#" diff --git a/dotcom-rendering/src/model/tag-front-schema.json b/dotcom-rendering/src/model/tag-front-schema.json index d8e03d3b909..8da5583ec81 100644 --- a/dotcom-rendering/src/model/tag-front-schema.json +++ b/dotcom-rendering/src/model/tag-front-schema.json @@ -1246,10 +1246,120 @@ "FEMediaAtoms": { "type": "object", "properties": { + "id": { + "type": "string" + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/definitions/MediaAsset" + } + }, + "title": { + "type": "string" + }, "duration": { "type": "number" + }, + "source": { + "type": "string" + }, + "posterImage": { + "type": "object", + "properties": { + "allImages": { + "type": "array", + "items": { + "$ref": "#/definitions/Image" + } + } + }, + "required": [ + "allImages" + ] + }, + "expired": { + "type": "boolean" + }, + "activeVersion": { + "type": "number" } - } + }, + "required": [ + "assets", + "id", + "title" + ] + }, + "MediaAsset": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "version": { + "type": "number" + }, + "platform": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "id", + "platform", + "version" + ] + }, + "Image": { + "type": "object", + "properties": { + "index": { + "type": "number" + }, + "fields": { + "type": "object", + "properties": { + "height": { + "type": "string" + }, + "width": { + "type": "string" + }, + "isMaster": { + "type": "string" + }, + "source": { + "type": "string" + }, + "caption": { + "type": "string" + } + }, + "required": [ + "height", + "width" + ] + }, + "mediaType": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "fields", + "index", + "mediaType", + "mimeType", + "url" + ] }, "EditionId": { "enum": [ diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index 2cc8c45c966..0776ae20c4a 100644 --- a/dotcom-rendering/src/types/front.ts +++ b/dotcom-rendering/src/types/front.ts @@ -4,7 +4,9 @@ import type { EditionId } from '../lib/edition'; import type { DCRBadgeType } from './badge'; import type { Branding } from './branding'; import type { ServerSideTests, Switches } from './config'; +import type { Image } from './content'; import type { FooterType } from './footer'; +import type { MainMedia } from './mainMedia'; import type { FETagType } from './tag'; import type { Territory } from './territory'; import type { FETrailType, TrailType } from './trails'; @@ -134,8 +136,26 @@ export type DCRContainerPalette = // TODO: These may need to be declared differently than the front types in the future export type DCRContainerType = FEContainerType; -interface FEMediaAtoms { +/** @see https://github.com/guardian/frontend/blob/0bf69f55a/common/app/model/content/Atom.scala#L191-L196 */ +interface MediaAsset { + id: string; + version: number; + platform: string; + mimeType?: string; +} + +/** @see https://github.com/guardian/frontend/blob/0bf69f55a/common/app/model/content/Atom.scala#L158-L169 */ +export interface FEMediaAtoms { + id: string; + // defaultHtml: string; // currently unused + assets: MediaAsset[]; + title: string; duration?: number; + source?: string; + posterImage?: { allImages: Image[] }; + expired?: boolean; + activeVersion?: number; + // channelId?: string; // currently unused } export type FEFrontCard = { @@ -289,9 +309,7 @@ export type DCRFrontCard = { byline?: string; showByline?: boolean; avatarUrl?: string; - mediaType?: MediaType; - mediaDuration?: number; - showMainVideo: boolean; + mainMedia?: MainMedia; isExternalLink: boolean; embedUri?: string; branding?: Branding; diff --git a/dotcom-rendering/src/types/mainMedia.ts b/dotcom-rendering/src/types/mainMedia.ts new file mode 100644 index 00000000000..dbb21ca7d38 --- /dev/null +++ b/dotcom-rendering/src/types/mainMedia.ts @@ -0,0 +1,28 @@ +type Media = { + type: 'Video' | 'Audio' | 'Gallery'; +}; + +/** For displaying embedded, playable videos directly in cards */ +type Video = Media & { + type: 'Video'; + elementId: string; + videoId: string; + height: number; + width: number; + origin: string; + title: string; + duration: number; + expired: boolean; + images: Array<{ url: string; width: number }>; +}; + +type Audio = Media & { + type: 'Audio'; + duration: number; +}; + +type Gallery = Media & { + type: 'Gallery'; +}; + +export type MainMedia = Video | Audio | Gallery; diff --git a/dotcom-rendering/src/types/trails.ts b/dotcom-rendering/src/types/trails.ts index 2331e325e11..e6b323c5f6e 100644 --- a/dotcom-rendering/src/types/trails.ts +++ b/dotcom-rendering/src/types/trails.ts @@ -1,7 +1,6 @@ import type { Branding } from './branding'; import type { DCRSnapType, DCRSupportingContent } from './front'; - -type MediaType = 'Video' | 'Audio' | 'Gallery'; +import type { MainMedia } from './mainMedia'; interface BaseTrailType { url: string; @@ -9,7 +8,6 @@ interface BaseTrailType { webPublicationDate?: string; image?: string; avatarUrl?: string; - mediaType?: MediaType; mediaDuration?: number; ageWarning?: string; byline?: string; @@ -29,7 +27,7 @@ interface BaseTrailType { isClosedForComments: boolean; discussionId?: string; }; - showMainVideo?: boolean; + mainMedia?: MainMedia; } export interface TrailType extends BaseTrailType {