diff --git a/dotcom-rendering/src/components/LoopVideo.importable.tsx b/dotcom-rendering/src/components/LoopVideo.importable.tsx index 2b80188a216..8871bf056a2 100644 --- a/dotcom-rendering/src/components/LoopVideo.importable.tsx +++ b/dotcom-rendering/src/components/LoopVideo.importable.tsx @@ -67,8 +67,8 @@ const dispatchOphanAttentionEvent = ( document.dispatchEvent(event); }; -const getOptimisedPosterImage = (mainImage: string): string => { - const resolution = window.devicePixelRatio >= 2 ? 'high' : 'low'; +const getOptimisedPosterImage = (mainImage: string, dpr: number): string => { + const resolution = dpr >= 2 ? 'high' : 'low'; return generateImageURL({ mainImage, @@ -168,6 +168,8 @@ export const LoopVideo = ({ const [hasBeenPlayed, setHasBeenPlayed] = useState(false); const [hasTrackedPlay, setHasTrackedPlay] = useState(false); + const [devicePixelRatio, setDevicePixelRatio] = useState(1); + const VISIBILITY_THRESHOLD = 0.5; const [isInView, setNode] = useIsInView({ @@ -337,6 +339,8 @@ export const LoopVideo = ({ } }); + setDevicePixelRatio(window.devicePixelRatio); + return () => { document.removeEventListener( customLoopPlayAudioEventName, @@ -636,7 +640,7 @@ export const LoopVideo = ({ const AudioIcon = isMuted ? SvgAudioMute : SvgAudio; const optimisedPosterImage = showPosterImage - ? getOptimisedPosterImage(posterImage) + ? getOptimisedPosterImage(posterImage, devicePixelRatio) : undefined; return ( diff --git a/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx b/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx new file mode 100644 index 00000000000..5295b989be7 --- /dev/null +++ b/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx @@ -0,0 +1,91 @@ +import type { ArticleFormat } from '../lib/articleFormat'; +import type { Source } from '../lib/video'; +import type { VideoAssets } from '../types/content'; +import { Caption } from './Caption'; +import type { Props as CardPictureProps } from './CardPicture'; +import { LoopVideo } from './LoopVideo.importable'; +import type { SubtitleSize } from './LoopVideoPlayer'; + +type LoopVideoInArticleProps = { + assets: VideoAssets[]; + atomId: string; + caption?: string; + fallbackImage: CardPictureProps['mainImage']; + fallbackImageAlt: CardPictureProps['alt']; + fallbackImageAspectRatio: CardPictureProps['aspectRatio']; + fallbackImageLoading: CardPictureProps['loading']; + fallbackImageSize: CardPictureProps['imageSize']; + format: ArticleFormat; + height: number; + isMainMedia: boolean; + linkTo: string; + posterImage: string; + uniqueId: string; + width: number; +}; + +// The looping video player types its `sources` attribute as `Sources` +// However, looping videos in articles are delivered as media atoms, which type their `assets` as `VideoAssets` +// Which means that we need to alter the shape of the incoming `assets` to match the requirements of the outgoing `sources` +const convertAssetsToSources = (assets: VideoAssets[]): Source[] => { + return assets.map((asset) => { + return { + src: asset.url ?? '', + mimeType: 'video/mp4', + }; + }); +}; + +const getSubtitleAsset = (assets: VideoAssets[]) => { + // Get the first subtitle asset from assets with a mimetype of 'text/vtt' + + const candidate = assets.find((asset) => asset.mimeType === 'text/vtt'); + return candidate?.url; +}; + +export const LoopVideoInArticle = ({ + assets, + atomId, + caption, + fallbackImage, + fallbackImageAlt, + fallbackImageAspectRatio, + fallbackImageLoading, + fallbackImageSize, + format, + height = 400, + isMainMedia, + linkTo, + posterImage, + uniqueId, + width = 500, +}: LoopVideoInArticleProps) => { + return ( + <> + + {!!caption && ( + + )} + + ); +}; diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index 035dd5f6be8..e10713b4a91 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -2698,6 +2698,9 @@ }, "duration": { "type": "number" + }, + "videoPlayerFormat": { + "type": "string" } }, "required": [ diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index cdfa0bc458f..5e0663641d5 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -29,6 +29,7 @@ import { Island } from '../components/Island'; import { ItemLinkBlockElement } from '../components/ItemLinkBlockElement'; import { KeyTakeaways } from '../components/KeyTakeaways'; import { KnowledgeQuizAtom } from '../components/KnowledgeQuizAtom.importable'; +import { LoopVideoInArticle } from '../components/LoopVideoInArticle.importable'; import { MainMediaEmbedBlockComponent } from '../components/MainMediaEmbedBlockComponent'; import { MapEmbedBlockComponent } from '../components/MapEmbedBlockComponent.importable'; import { MiniProfiles } from '../components/MiniProfiles'; @@ -490,15 +491,67 @@ export const renderElement = ({ ); case 'model.dotcomrendering.pageElements.MediaAtomBlockElement': - return ( - - ); + /* + - MediaAtomBlockElement is used for self-hosted videos + - Historically, these videos have been self-hosted for legal or sensitive reasons + - These videos play in the `VideoAtom` component + - Looping videos, introduced in July 2025, are also self-hosted + - Thus they are delivered as a MediaAtomBlockElement + - However they need to display in a different video player + - This is handled in the new `LoopVideoInArticle` component + - We need to differentiate between the two forms of video + - We can do this by interrogating the atom's metadata, which includes the new attribute `videoPlayerFormat` + + - Note: we'll probably extend this functionality to handle new 'Cinemagraph' videos + - These may use the looping video, or yet another new, video player + - But they will still be Media Atoms + */ + + if (element?.videoPlayerFormat === 'Loop') { + const posterImageExists = !!element.posterImage?.[0]?.url; + return ( + <> + {posterImageExists && ( + + + + )} + + ); + } else { + return ( + + ); + } case 'model.dotcomrendering.pageElements.MiniProfilesBlockElement': return (