Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions dotcom-rendering/src/components/LoopVideo.importable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -168,6 +168,8 @@ export const LoopVideo = ({
const [hasBeenPlayed, setHasBeenPlayed] = useState(false);
const [hasTrackedPlay, setHasTrackedPlay] = useState(false);

const [devicePixelRatio, setDevicePixelRatio] = useState(1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Introducing state feels a bit unnecessary here, what was your thinking?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bug in the new LoopVideo player. The bug tries to access the browser window environment for the DPR value before the element has been mounted. So I decided to move the value to state and do the window check in a useUpdate hook. Thus once we get the correct DPR the component will rectify itself.


const VISIBILITY_THRESHOLD = 0.5;

const [isInView, setNode] = useIsInView({
Expand Down Expand Up @@ -337,6 +339,8 @@ export const LoopVideo = ({
}
});

setDevicePixelRatio(window.devicePixelRatio);

return () => {
document.removeEventListener(
customLoopPlayAudioEventName,
Expand Down Expand Up @@ -636,7 +640,7 @@ export const LoopVideo = ({
const AudioIcon = isMuted ? SvgAudioMute : SvgAudio;

const optimisedPosterImage = showPosterImage
? getOptimisedPosterImage(posterImage)
? getOptimisedPosterImage(posterImage, devicePixelRatio)
: undefined;

return (
Expand Down
91 changes: 91 additions & 0 deletions dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<LoopVideo
atomId={atomId}
fallbackImage={fallbackImage}
fallbackImageAlt={fallbackImageAlt}
fallbackImageAspectRatio={fallbackImageAspectRatio}
fallbackImageLoading={fallbackImageLoading}
fallbackImageSize={fallbackImageSize}
height={height}
linkTo={linkTo}
posterImage={posterImage}
sources={convertAssetsToSources(assets)}
subtitleSize={'small' as SubtitleSize}
subtitleSource={getSubtitleAsset(assets)}
uniqueId={uniqueId}
width={width}
/>
{!!caption && (
<Caption
captionText={caption}
format={format}
isMainMedia={isMainMedia}
mediaType="SelfHostedVideo"
/>
)}
</>
);
};
3 changes: 3 additions & 0 deletions dotcom-rendering/src/frontend/schemas/feArticle.json
Original file line number Diff line number Diff line change
Expand Up @@ -2698,6 +2698,9 @@
},
"duration": {
"type": "number"
},
"videoPlayerFormat": {
"type": "string"
}
},
"required": [
Expand Down
71 changes: 62 additions & 9 deletions dotcom-rendering/src/lib/renderElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -490,15 +491,67 @@ export const renderElement = ({
</Island>
);
case 'model.dotcomrendering.pageElements.MediaAtomBlockElement':
return (
<VideoAtom
format={format}
assets={element.assets}
poster={element.posterImage?.[0]?.url}
caption={element.title}
isMainMedia={isMainMedia}
/>
);
/*
- 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 && (
<Island
priority="critical"
defer={{ until: 'visible' }}
>
<LoopVideoInArticle
assets={element.assets}
atomId={element.id}
uniqueId={element.id}
height={400}
width={500}
Comment on lines +523 to +524
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these numbers come from somewhere?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic numbers: currently represents the 5:4 aspect ratio used by loop videos. Could double to 1000/800 maybe for people using very wide displays? I think CSS overrides this in any case - need to check.

posterImage={
element.posterImage?.[0]?.url ?? ''
}
fallbackImage={
element.posterImage?.[0]?.url ?? ''
}
fallbackImageSize="small"
fallbackImageLoading="lazy"
fallbackImageAlt={element.title}
fallbackImageAspectRatio="5:4"
linkTo="Article-embed-MediaAtomBlockElement"
format={format}
caption={element.title}
isMainMedia={isMainMedia}
/>
</Island>
)}
</>
);
} else {
return (
<VideoAtom
format={format}
assets={element.assets}
poster={element.posterImage?.[0]?.url}
caption={element.title}
isMainMedia={isMainMedia}
/>
);
}
case 'model.dotcomrendering.pageElements.MiniProfilesBlockElement':
return (
<MiniProfiles
Expand Down
3 changes: 3 additions & 0 deletions dotcom-rendering/src/model/block-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2186,6 +2186,9 @@
},
"duration": {
"type": "number"
},
"videoPlayerFormat": {
"type": "string"
}
},
"required": [
Expand Down
3 changes: 2 additions & 1 deletion dotcom-rendering/src/types/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ interface MediaAtomBlockElement {
}[];
title?: string;
duration?: number;
videoPlayerFormat?: string;
}

export interface MultiImageBlockElement {
Expand Down Expand Up @@ -939,7 +940,7 @@ export interface Image {
url: string;
}

interface VideoAssets {
export interface VideoAssets {
url: string;
mimeType?: string;
fields?: {
Expand Down
Loading