Skip to content
Open
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
2 changes: 1 addition & 1 deletion dotcom-rendering/src/components/Caption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ export const Caption = ({
]}
data-spacefinder-role="inline"
>
{mediaType === 'YoutubeVideo' ? (
{mediaType === 'YoutubeVideo' || mediaType === 'SelfHostedVideo' ? (
<VideoIcon format={format} />
) : (
<CameraIcon format={format} />
Expand Down
62 changes: 62 additions & 0 deletions dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { FEAspectRatio } from '../frontend/feFront';
import type { ArticleFormat } from '../lib/articleFormat';
import {
convertAssetsToVideoSources,
getFirstVideoAsset,
getSubtitleAsset,
} from '../lib/video';
import type { MediaAtomBlockElement } from '../types/content';
import { Caption } from './Caption';
import { SelfHostedVideo } from './SelfHostedVideo.importable';

type LoopVideoInArticleProps = {
element: MediaAtomBlockElement;
format: ArticleFormat;
isMainMedia: boolean;
};

export const LoopVideoInArticle = ({
element,
format,
isMainMedia,
}: LoopVideoInArticleProps) => {
const posterImageUrl = element.posterImage?.[0]?.url;
const caption = element.title;
const firstVideoAsset = getFirstVideoAsset(element.assets);

if (!posterImageUrl) {
return null;
}

return (
<>
<SelfHostedVideo
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this need to be an island? Since SelfHostedVideo is an island, I don't think this needs to be. I don't believe there's any harm to it, since this PR, but it might be unnecessary.

Copy link
Contributor

Choose a reason for hiding this comment

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

Good catch, I've removed it: 5b3c6f0

atomId={element.id}
fallbackImage={posterImageUrl}
fallbackImageAlt={caption}
fallbackImageAspectRatio={
(firstVideoAsset?.aspectRatio ?? '5:4') as FEAspectRatio
}
fallbackImageLoading="lazy"
fallbackImageSize="small"
height={firstVideoAsset?.dimensions?.height ?? 400}
linkTo="Article-embed-MediaAtomBlockElement"
Copy link
Contributor

Choose a reason for hiding this comment

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

What is this magic string @RikRootsGuardian ?

Copy link
Author

Choose a reason for hiding this comment

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

This needs to be considered. It's used for tracking events eg when video first comes into view. This was developed with Fronts in mind, so I don't know if that tracking is useful for progression through the article? I put it in as a hardcoded text, but maybe a better approach would be to use the article URL here?

posterImage={posterImageUrl}
sources={convertAssetsToVideoSources(element.assets)}
subtitleSize="medium"
subtitleSource={getSubtitleAsset(element.assets)}
videoStyle="Loop"
uniqueId={element.id}
width={firstVideoAsset?.dimensions?.width ?? 500}
/>
{!!caption && (
<Caption
captionText={caption}
format={format}
isMainMedia={isMainMedia}
mediaType="SelfHostedVideo"
/>
)}
</>
);
};
10 changes: 7 additions & 3 deletions dotcom-rendering/src/components/SelfHostedVideo.importable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,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 @@ -190,6 +190,8 @@ export const SelfHostedVideo = ({
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.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think that the getOptimisedPosterImage function will run before the element has been mounted, since this function is only called if showPosterImage is true, which is only set to true in a useEffect.

However I can see why it looks that way as the code is confusing as it is and could be tidied up.

Maybe optimisedPosterImage could replace showPosterImage in state, and the call to getOptimisedPosterImage() could live in this useEffect. The function itself could remain as you have it and we access the window to obtain devicePixelRatio within the useEffect specified, then pass it into the function.

This would have the benefit of all the logic around the poster image living in one place, the accessing of the window object would explicitly live in the useEffect instead of a function.

Happy to chat through this further.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for this, I'll let @RikRootsGuardian respond when he's back


const VISIBILITY_THRESHOLD = 0.5;

/**
Expand Down Expand Up @@ -365,6 +367,8 @@ export const SelfHostedVideo = ({
}
});

setDevicePixelRatio(window.devicePixelRatio);

return () => {
document.removeEventListener(
customSelfHostedVideoPlayAudioEventName,
Expand Down Expand Up @@ -673,7 +677,7 @@ export const SelfHostedVideo = ({
const AudioIcon = isMuted ? SvgAudioMute : SvgAudio;

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

return (
Expand Down
37 changes: 29 additions & 8 deletions dotcom-rendering/src/frontend/schemas/feArticle.json
Original file line number Diff line number Diff line change
Expand Up @@ -2110,6 +2110,24 @@
"mimeType": {
"type": "string"
},
"dimensions": {
"type": "object",
"properties": {
"width": {
"type": "number"
},
"height": {
"type": "number"
}
},
"required": [
"height",
"width"
]
},
"aspectRatio": {
"type": "string"
},
"fields": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -2698,6 +2716,9 @@
},
"duration": {
"type": "number"
},
"videoPlayerFormat": {
"$ref": "#/definitions/VideoPlayerFormat"
}
},
"required": [
Expand All @@ -2707,6 +2728,14 @@
"id"
]
},
"VideoPlayerFormat": {
"enum": [
"Cinemagraph",
"Default",
"Loop"
],
"type": "string"
},
"MiniProfilesBlockElement": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -5510,14 +5539,6 @@
}
]
},
"VideoPlayerFormat": {
"enum": [
"Cinemagraph",
"Default",
"Loop"
],
"type": "string"
},
"Audio": {
"allOf": [
{
Expand Down
39 changes: 30 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,35 @@ 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
- 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`
*/
if (element.videoPlayerFormat === 'Loop') {
return (
<LoopVideoInArticle
element={element}
format={format}
isMainMedia={isMainMedia}
/>
);
} 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
33 changes: 33 additions & 0 deletions dotcom-rendering/src/lib/video.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { VideoAssets } from '../types/content';

export type CustomPlayEventDetail = { uniqueId: string };

export const customSelfHostedVideoPlayAudioEventName =
Expand All @@ -22,3 +24,34 @@ export const supportedVideoFileTypes = [
] as const;

export type SupportedVideoFileType = (typeof supportedVideoFileTypes)[number];

const isSupportedMimeType = (
mime: string | undefined,
): mime is SupportedVideoFileType => {
if (!mime) return false;

return (supportedVideoFileTypes as readonly string[]).includes(mime);
};

/**
* 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`.
*/
export const convertAssetsToVideoSources = (assets: VideoAssets[]): Source[] =>
assets
.filter((asset) => isSupportedMimeType(asset.mimeType))
.map((asset) => ({
src: asset.url,
mimeType: asset.mimeType as Source['mimeType'],
}));

export const getSubtitleAsset = (assets: VideoAssets[]): string | undefined =>
assets.find((asset) => asset.mimeType === 'text/vtt')?.url;

export const getFirstVideoAsset = (
assets: VideoAssets[],
): VideoAssets | undefined => {
return assets.find((asset) => isSupportedMimeType(asset.mimeType));
};
29 changes: 29 additions & 0 deletions dotcom-rendering/src/model/block-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1598,6 +1598,24 @@
"mimeType": {
"type": "string"
},
"dimensions": {
"type": "object",
"properties": {
"width": {
"type": "number"
},
"height": {
"type": "number"
}
},
"required": [
"height",
"width"
]
},
"aspectRatio": {
"type": "string"
},
"fields": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -2186,6 +2204,9 @@
},
"duration": {
"type": "number"
},
"videoPlayerFormat": {
"$ref": "#/definitions/VideoPlayerFormat"
}
},
"required": [
Expand All @@ -2195,6 +2216,14 @@
"id"
]
},
"VideoPlayerFormat": {
"enum": [
"Cinemagraph",
"Default",
"Loop"
],
"type": "string"
},
"MiniProfilesBlockElement": {
"type": "object",
"properties": {
Expand Down
11 changes: 9 additions & 2 deletions dotcom-rendering/src/types/content.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type CrosswordProps } from '@guardian/react-crossword';
import type { ArticleFormat } from '../lib/articleFormat';
import type { VideoPlayerFormat } from './mainMedia';

export type StarRating = 0 | 1 | 2 | 3 | 4 | 5;

Expand Down Expand Up @@ -423,7 +424,7 @@ export interface MapBlockElement extends ThirdPartyEmbeddedContent {
role?: RoleType;
}

interface MediaAtomBlockElement {
export interface MediaAtomBlockElement {
_type: 'model.dotcomrendering.pageElements.MediaAtomBlockElement';
elementId: string;
id: string;
Expand All @@ -434,6 +435,7 @@ interface MediaAtomBlockElement {
}[];
title?: string;
duration?: number;
videoPlayerFormat?: VideoPlayerFormat;
}

export interface MultiImageBlockElement {
Expand Down Expand Up @@ -939,9 +941,14 @@ export interface Image {
url: string;
}

interface VideoAssets {
export interface VideoAssets {
url: string;
mimeType?: string;
dimensions?: {
width: number;
height: number;
};
aspectRatio?: string;
fields?: {
source?: string;
embeddable?: string;
Expand Down
Loading