Skip to content

Commit db38c0d

Browse files
committed
Render looping video in appropriate player
1 parent 628437b commit db38c0d

File tree

6 files changed

+148
-19
lines changed

6 files changed

+148
-19
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { ArticleFormat } from '../lib/articleFormat';
2+
import { convertAssetsToVideoSources, getSubtitleAsset } from '../lib/video';
3+
import type { MediaAtomBlockElement } from '../types/content';
4+
import { Caption } from './Caption';
5+
import { SelfHostedVideo } from './SelfHostedVideo.importable';
6+
7+
type LoopVideoInArticleProps = {
8+
element: MediaAtomBlockElement;
9+
format: ArticleFormat;
10+
isMainMedia: boolean;
11+
};
12+
13+
export const LoopVideoInArticle = ({
14+
element,
15+
format,
16+
isMainMedia,
17+
}: LoopVideoInArticleProps) => {
18+
const posterImageUrl = element.posterImage?.[0]?.url;
19+
const caption = element.title;
20+
21+
if (!posterImageUrl) {
22+
return null;
23+
}
24+
25+
return (
26+
<>
27+
<SelfHostedVideo
28+
atomId={element.id}
29+
fallbackImage={posterImageUrl}
30+
fallbackImageAlt={caption}
31+
fallbackImageAspectRatio="5:4"
32+
fallbackImageLoading="lazy"
33+
fallbackImageSize="small"
34+
height={400}
35+
linkTo="Article-embed-MediaAtomBlockElement"
36+
posterImage={posterImageUrl}
37+
sources={convertAssetsToVideoSources(element.assets)}
38+
subtitleSize="medium"
39+
subtitleSource={getSubtitleAsset(element.assets)}
40+
videoStyle="Loop"
41+
uniqueId={element.id}
42+
width={500}
43+
/>
44+
{!!caption && (
45+
<Caption
46+
captionText={caption}
47+
format={format}
48+
isMainMedia={isMainMedia}
49+
mediaType="SelfHostedVideo"
50+
/>
51+
)}
52+
</>
53+
);
54+
};

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2698,6 +2698,9 @@
26982698
},
26992699
"duration": {
27002700
"type": "number"
2701+
},
2702+
"videoPlayerFormat": {
2703+
"$ref": "#/definitions/VideoPlayerFormat"
27012704
}
27022705
},
27032706
"required": [
@@ -2707,6 +2710,14 @@
27072710
"id"
27082711
]
27092712
},
2713+
"VideoPlayerFormat": {
2714+
"enum": [
2715+
"Cinemagraph",
2716+
"Default",
2717+
"Loop"
2718+
],
2719+
"type": "string"
2720+
},
27102721
"MiniProfilesBlockElement": {
27112722
"type": "object",
27122723
"properties": {
@@ -5510,14 +5521,6 @@
55105521
}
55115522
]
55125523
},
5513-
"VideoPlayerFormat": {
5514-
"enum": [
5515-
"Cinemagraph",
5516-
"Default",
5517-
"Loop"
5518-
],
5519-
"type": "string"
5520-
},
55215524
"Audio": {
55225525
"allOf": [
55235526
{

dotcom-rendering/src/lib/renderElement.tsx

Lines changed: 41 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.importable';
3233
import { MainMediaEmbedBlockComponent } from '../components/MainMediaEmbedBlockComponent';
3334
import { MapEmbedBlockComponent } from '../components/MapEmbedBlockComponent.importable';
3435
import { MiniProfiles } from '../components/MiniProfiles';
@@ -490,15 +491,46 @@ 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+
- Note: we'll probably extend this functionality to handle new 'Cinemagraph' videos
505+
- These may use the looping video, or yet another new, video player
506+
- But they will still be Media Atoms
507+
*/
508+
if (element.videoPlayerFormat === 'Loop') {
509+
return (
510+
<>
511+
<Island
512+
priority="critical"
513+
defer={{ until: 'visible' }}
514+
>
515+
<LoopVideoInArticle
516+
element={element}
517+
format={format}
518+
isMainMedia={isMainMedia}
519+
/>
520+
</Island>
521+
</>
522+
);
523+
} else {
524+
return (
525+
<VideoAtom
526+
format={format}
527+
assets={element.assets}
528+
poster={element.posterImage?.[0]?.url}
529+
caption={element.title}
530+
isMainMedia={isMainMedia}
531+
/>
532+
);
533+
}
502534
case 'model.dotcomrendering.pageElements.MiniProfilesBlockElement':
503535
return (
504536
<MiniProfiles

dotcom-rendering/src/lib/video.ts

Lines changed: 27 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 =
@@ -22,3 +24,28 @@ export const supportedVideoFileTypes = [
2224
] as const;
2325

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

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2186,6 +2186,9 @@
21862186
},
21872187
"duration": {
21882188
"type": "number"
2189+
},
2190+
"videoPlayerFormat": {
2191+
"$ref": "#/definitions/VideoPlayerFormat"
21892192
}
21902193
},
21912194
"required": [
@@ -2195,6 +2198,14 @@
21952198
"id"
21962199
]
21972200
},
2201+
"VideoPlayerFormat": {
2202+
"enum": [
2203+
"Cinemagraph",
2204+
"Default",
2205+
"Loop"
2206+
],
2207+
"type": "string"
2208+
},
21982209
"MiniProfilesBlockElement": {
21992210
"type": "object",
22002211
"properties": {

dotcom-rendering/src/types/content.ts

Lines changed: 4 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,7 +941,7 @@ export interface Image {
939941
url: string;
940942
}
941943

942-
interface VideoAssets {
944+
export interface VideoAssets {
943945
url: string;
944946
mimeType?: string;
945947
fields?: {

0 commit comments

Comments
 (0)