Skip to content

Commit 642828e

Browse files
Merge pull request #14843 from guardian/rjr-investigate-looping-video-in-articles
Add looping videos to articles
2 parents a5c760b + 7ecde49 commit 642828e

File tree

7 files changed

+197
-20
lines changed

7 files changed

+197
-20
lines changed

dotcom-rendering/src/components/Caption.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ export const Caption = ({
315315
]}
316316
data-spacefinder-role="inline"
317317
>
318-
{mediaType === 'YoutubeVideo' ? (
318+
{mediaType === 'YoutubeVideo' || mediaType === 'SelfHostedVideo' ? (
319319
<VideoIcon format={format} />
320320
) : (
321321
<CameraIcon format={format} />
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { FEAspectRatio } from '../frontend/feFront';
2+
import type { ArticleFormat } from '../lib/articleFormat';
3+
import {
4+
convertAssetsToVideoSources,
5+
getFirstVideoAsset,
6+
getSubtitleAsset,
7+
} from '../lib/video';
8+
import type { MediaAtomBlockElement } from '../types/content';
9+
import { Caption } from './Caption';
10+
import { Island } from './Island';
11+
import { SelfHostedVideo } from './SelfHostedVideo.importable';
12+
13+
type LoopVideoInArticleProps = {
14+
element: MediaAtomBlockElement;
15+
format: ArticleFormat;
16+
isMainMedia: boolean;
17+
};
18+
19+
export const LoopVideoInArticle = ({
20+
element,
21+
format,
22+
isMainMedia,
23+
}: LoopVideoInArticleProps) => {
24+
const posterImageUrl = element.posterImage?.[0]?.url;
25+
const caption = element.title;
26+
const firstVideoAsset = getFirstVideoAsset(element.assets);
27+
28+
if (!posterImageUrl) {
29+
return null;
30+
}
31+
32+
return (
33+
<>
34+
<Island priority="critical" defer={{ until: 'visible' }}>
35+
<SelfHostedVideo
36+
atomId={element.id}
37+
fallbackImage={posterImageUrl}
38+
fallbackImageAlt={caption}
39+
fallbackImageAspectRatio={
40+
(firstVideoAsset?.aspectRatio ?? '5:4') as FEAspectRatio
41+
}
42+
fallbackImageLoading="lazy"
43+
fallbackImageSize="small"
44+
height={firstVideoAsset?.dimensions?.height ?? 400}
45+
linkTo="Article-embed-MediaAtomBlockElement"
46+
posterImage={posterImageUrl}
47+
sources={convertAssetsToVideoSources(element.assets)}
48+
subtitleSize="medium"
49+
subtitleSource={getSubtitleAsset(element.assets)}
50+
videoStyle="Loop"
51+
uniqueId={element.id}
52+
width={firstVideoAsset?.dimensions?.width ?? 500}
53+
enableHls={false}
54+
/>
55+
</Island>
56+
{!!caption && (
57+
<Caption
58+
captionText={caption}
59+
format={format}
60+
isMainMedia={isMainMedia}
61+
mediaType="SelfHostedVideo"
62+
/>
63+
)}
64+
</>
65+
);
66+
};

dotcom-rendering/src/frontend/schemas/feArticle.json

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2110,6 +2110,24 @@
21102110
"mimeType": {
21112111
"type": "string"
21122112
},
2113+
"dimensions": {
2114+
"type": "object",
2115+
"properties": {
2116+
"width": {
2117+
"type": "number"
2118+
},
2119+
"height": {
2120+
"type": "number"
2121+
}
2122+
},
2123+
"required": [
2124+
"height",
2125+
"width"
2126+
]
2127+
},
2128+
"aspectRatio": {
2129+
"type": "string"
2130+
},
21132131
"fields": {
21142132
"type": "object",
21152133
"properties": {
@@ -2698,6 +2716,9 @@
26982716
},
26992717
"duration": {
27002718
"type": "number"
2719+
},
2720+
"videoPlayerFormat": {
2721+
"$ref": "#/definitions/VideoPlayerFormat"
27012722
}
27022723
},
27032724
"required": [
@@ -2707,6 +2728,14 @@
27072728
"id"
27082729
]
27092730
},
2731+
"VideoPlayerFormat": {
2732+
"enum": [
2733+
"Cinemagraph",
2734+
"Default",
2735+
"Loop"
2736+
],
2737+
"type": "string"
2738+
},
27102739
"MiniProfilesBlockElement": {
27112740
"type": "object",
27122741
"properties": {
@@ -5509,14 +5538,6 @@
55095538
}
55105539
]
55115540
},
5512-
"VideoPlayerFormat": {
5513-
"enum": [
5514-
"Cinemagraph",
5515-
"Default",
5516-
"Loop"
5517-
],
5518-
"type": "string"
5519-
},
55205541
"SupportedVideoFileType": {
55215542
"enum": [
55225543
"application/vnd.apple.mpegurl",

dotcom-rendering/src/lib/renderElement.tsx

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { Island } from '../components/Island';
2929
import { ItemLinkBlockElement } from '../components/ItemLinkBlockElement';
3030
import { KeyTakeaways } from '../components/KeyTakeaways';
3131
import { KnowledgeQuizAtom } from '../components/KnowledgeQuizAtom.importable';
32+
import { LoopVideoInArticle } from '../components/LoopVideoInArticle';
3233
import { MainMediaEmbedBlockComponent } from '../components/MainMediaEmbedBlockComponent';
3334
import { MapEmbedBlockComponent } from '../components/MapEmbedBlockComponent.importable';
3435
import { MiniProfiles } from '../components/MiniProfiles';
@@ -490,15 +491,35 @@ export const renderElement = ({
490491
</Island>
491492
);
492493
case 'model.dotcomrendering.pageElements.MediaAtomBlockElement':
493-
return (
494-
<VideoAtom
495-
format={format}
496-
assets={element.assets}
497-
poster={element.posterImage?.[0]?.url}
498-
caption={element.title}
499-
isMainMedia={isMainMedia}
500-
/>
501-
);
494+
/*
495+
- MediaAtomBlockElement is used for self-hosted videos
496+
- Historically, these videos have been self-hosted for legal or sensitive reasons
497+
- These videos play in the `VideoAtom` component
498+
- Looping videos, introduced in July 2025, are also self-hosted
499+
- Thus they are delivered as a MediaAtomBlockElement
500+
- However they need to display in a different video player
501+
- We need to differentiate between the two forms of video
502+
- We can do this by interrogating the atom's metadata, which includes the new attribute `videoPlayerFormat`
503+
*/
504+
if (element.videoPlayerFormat === 'Loop') {
505+
return (
506+
<LoopVideoInArticle
507+
element={element}
508+
format={format}
509+
isMainMedia={isMainMedia}
510+
/>
511+
);
512+
} else {
513+
return (
514+
<VideoAtom
515+
format={format}
516+
assets={element.assets}
517+
poster={element.posterImage?.[0]?.url}
518+
caption={element.title}
519+
isMainMedia={isMainMedia}
520+
/>
521+
);
522+
}
502523
case 'model.dotcomrendering.pageElements.MiniProfilesBlockElement':
503524
return (
504525
<MiniProfiles

dotcom-rendering/src/lib/video.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { VideoAssets } from '../types/content';
2+
13
export type CustomPlayEventDetail = { uniqueId: string };
24

35
export const customSelfHostedVideoPlayAudioEventName =
@@ -26,3 +28,34 @@ export const filterOutHlsSources = (sources: Source[]): Source[] =>
2628
);
2729

2830
export type SupportedVideoFileType = (typeof supportedVideoFileTypes)[number];
31+
32+
const isSupportedMimeType = (
33+
mime: string | undefined,
34+
): mime is SupportedVideoFileType => {
35+
if (!mime) return false;
36+
37+
return (supportedVideoFileTypes as readonly string[]).includes(mime);
38+
};
39+
40+
/**
41+
* The looping video player types its `sources` attribute as `Sources`.
42+
* However, looping videos in articles are delivered as media atoms, which type
43+
* their `assets` as `VideoAssets`. Which means that we need to alter the shape
44+
* of the incoming `assets` to match the requirements of the outgoing `sources`.
45+
*/
46+
export const convertAssetsToVideoSources = (assets: VideoAssets[]): Source[] =>
47+
assets
48+
.filter((asset) => isSupportedMimeType(asset.mimeType))
49+
.map((asset) => ({
50+
src: asset.url,
51+
mimeType: asset.mimeType as Source['mimeType'],
52+
}));
53+
54+
export const getSubtitleAsset = (assets: VideoAssets[]): string | undefined =>
55+
assets.find((asset) => asset.mimeType === 'text/vtt')?.url;
56+
57+
export const getFirstVideoAsset = (
58+
assets: VideoAssets[],
59+
): VideoAssets | undefined => {
60+
return assets.find((asset) => isSupportedMimeType(asset.mimeType));
61+
};

dotcom-rendering/src/model/block-schema.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,24 @@
15981598
"mimeType": {
15991599
"type": "string"
16001600
},
1601+
"dimensions": {
1602+
"type": "object",
1603+
"properties": {
1604+
"width": {
1605+
"type": "number"
1606+
},
1607+
"height": {
1608+
"type": "number"
1609+
}
1610+
},
1611+
"required": [
1612+
"height",
1613+
"width"
1614+
]
1615+
},
1616+
"aspectRatio": {
1617+
"type": "string"
1618+
},
16011619
"fields": {
16021620
"type": "object",
16031621
"properties": {
@@ -2186,6 +2204,9 @@
21862204
},
21872205
"duration": {
21882206
"type": "number"
2207+
},
2208+
"videoPlayerFormat": {
2209+
"$ref": "#/definitions/VideoPlayerFormat"
21892210
}
21902211
},
21912212
"required": [
@@ -2195,6 +2216,14 @@
21952216
"id"
21962217
]
21972218
},
2219+
"VideoPlayerFormat": {
2220+
"enum": [
2221+
"Cinemagraph",
2222+
"Default",
2223+
"Loop"
2224+
],
2225+
"type": "string"
2226+
},
21982227
"MiniProfilesBlockElement": {
21992228
"type": "object",
22002229
"properties": {

dotcom-rendering/src/types/content.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type CrosswordProps } from '@guardian/react-crossword';
22
import type { ArticleFormat } from '../lib/articleFormat';
3+
import type { VideoPlayerFormat } from './mainMedia';
34

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

@@ -423,7 +424,7 @@ export interface MapBlockElement extends ThirdPartyEmbeddedContent {
423424
role?: RoleType;
424425
}
425426

426-
interface MediaAtomBlockElement {
427+
export interface MediaAtomBlockElement {
427428
_type: 'model.dotcomrendering.pageElements.MediaAtomBlockElement';
428429
elementId: string;
429430
id: string;
@@ -434,6 +435,7 @@ interface MediaAtomBlockElement {
434435
}[];
435436
title?: string;
436437
duration?: number;
438+
videoPlayerFormat?: VideoPlayerFormat;
437439
}
438440

439441
export interface MultiImageBlockElement {
@@ -939,9 +941,14 @@ export interface Image {
939941
url: string;
940942
}
941943

942-
interface VideoAssets {
944+
export interface VideoAssets {
943945
url: string;
944946
mimeType?: string;
947+
dimensions?: {
948+
width: number;
949+
height: number;
950+
};
951+
aspectRatio?: string;
945952
fields?: {
946953
source?: string;
947954
embeddable?: string;

0 commit comments

Comments
 (0)