diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx
index c2f8c888737..47777106346 100644
--- a/dotcom-rendering/src/components/Card/Card.tsx
+++ b/dotcom-rendering/src/components/Card/Card.tsx
@@ -42,6 +42,7 @@ import { CardPicture } from '../CardPicture';
import { Island } from '../Island';
import { LatestLinks } from '../LatestLinks.importable';
import { LoopVideo } from '../LoopVideo.importable';
+import type { SubtitleSize } from '../LoopVideoPlayer';
import { Pill } from '../Pill';
import { SlideshowCarousel } from '../SlideshowCarousel.importable';
import { Snap } from '../Snap';
@@ -156,6 +157,7 @@ export type Props = {
trailTextSize?: TrailTextSize;
/** A kicker image is seperate to the main media and renders as part of the kicker */
showKickerImage?: boolean;
+ subtitleSize?: SubtitleSize;
/** Determines if the headline should be positioned within the content or outside the content */
headlinePosition?: 'inner' | 'outer';
/** Feature flag for the labs redesign work */
@@ -403,6 +405,7 @@ export const Card = ({
showKickerImage = false,
headlinePosition = 'inner',
showLabsRedesign = false,
+ subtitleSize = 'small',
}: Props) => {
const hasSublinks = supportingContent && supportingContent.length > 0;
const sublinkPosition = decideSublinkPosition(
@@ -950,6 +953,10 @@ export const Card = ({
fallbackImageAlt={media.imageAltText}
fallbackImageAspectRatio="5:4"
linkTo={linkTo}
+ subtitleSource={
+ media.mainMedia.subtitleSource
+ }
+ subtitleSize={subtitleSize}
/>
)}
diff --git a/dotcom-rendering/src/components/FlexibleGeneral.stories.tsx b/dotcom-rendering/src/components/FlexibleGeneral.stories.tsx
index db99f3a5ab5..8772a61b1c8 100644
--- a/dotcom-rendering/src/components/FlexibleGeneral.stories.tsx
+++ b/dotcom-rendering/src/components/FlexibleGeneral.stories.tsx
@@ -323,6 +323,7 @@ export const SplashBoostLevels: Story = {
return (
<>
+
diff --git a/dotcom-rendering/src/components/FlexibleGeneral.tsx b/dotcom-rendering/src/components/FlexibleGeneral.tsx
index 57b6530d3bb..99e08f0df88 100644
--- a/dotcom-rendering/src/components/FlexibleGeneral.tsx
+++ b/dotcom-rendering/src/components/FlexibleGeneral.tsx
@@ -21,6 +21,7 @@ import type { ResponsiveFontSize } from './CardHeadline';
import type { Loading } from './CardPicture';
import { FeatureCard } from './FeatureCard';
import { FrontCard } from './FrontCard';
+import type { SubtitleSize } from './LoopVideoPlayer';
import type { Alignment } from './SupportingContent';
type Props = {
@@ -155,6 +156,7 @@ type BoostedSplashProperties = {
supportingContentAlignment: Alignment;
liveUpdatesAlignment: Alignment;
trailTextSize: TrailTextSize;
+ subtitleSize: SubtitleSize;
avatarUrl?: string;
};
@@ -185,6 +187,7 @@ const decideSplashCardProperties = (
supportingContentLength >= 4 ? 'horizontal' : 'vertical',
liveUpdatesAlignment: 'vertical',
trailTextSize: 'regular',
+ subtitleSize: 'medium',
};
case 'boost':
return {
@@ -200,6 +203,7 @@ const decideSplashCardProperties = (
supportingContentLength >= 4 ? 'horizontal' : 'vertical',
liveUpdatesAlignment: 'vertical',
trailTextSize: 'regular',
+ subtitleSize: 'medium',
};
case 'megaboost':
return {
@@ -216,6 +220,7 @@ const decideSplashCardProperties = (
supportingContentAlignment: 'horizontal',
liveUpdatesAlignment: 'horizontal',
trailTextSize: 'large',
+ subtitleSize: 'large',
};
case 'gigaboost':
return {
@@ -232,6 +237,7 @@ const decideSplashCardProperties = (
supportingContentAlignment: 'horizontal',
liveUpdatesAlignment: 'horizontal',
trailTextSize: 'large',
+ subtitleSize: 'large',
};
}
};
@@ -293,6 +299,7 @@ const SplashCardLayout = ({
supportingContentAlignment,
liveUpdatesAlignment,
trailTextSize,
+ subtitleSize,
} = decideSplashCardProperties(
card.boostLevel ?? 'default',
card.supportingContent?.length ?? 0,
@@ -342,6 +349,7 @@ const SplashCardLayout = ({
trailTextSize={trailTextSize}
canPlayInline={true}
showKickerImage={card.format.design === ArticleDesign.Audio}
+ subtitleSize={subtitleSize}
headlinePosition={card.showLivePlayable ? 'outer' : 'inner'}
showLabsRedesign={showLabsRedesign}
/>
@@ -355,6 +363,7 @@ type BoostedCardProperties = {
mediaSize: MediaSizeType;
liveUpdatesPosition: Position;
supportingContentAlignment: Alignment;
+ subtitleSize: SubtitleSize;
};
/**
@@ -378,6 +387,7 @@ const decideCardProperties = (
liveUpdatesPosition: 'outer',
supportingContentAlignment:
supportingContentLength >= 2 ? 'horizontal' : 'vertical',
+ subtitleSize: 'medium',
};
case 'boost':
default:
@@ -391,6 +401,7 @@ const decideCardProperties = (
liveUpdatesPosition: 'inner',
supportingContentAlignment:
supportingContentLength >= 2 ? 'horizontal' : 'vertical',
+ subtitleSize: 'small',
};
}
};
@@ -431,6 +442,7 @@ const FullWidthCardLayout = ({
mediaSize,
supportingContentAlignment,
liveUpdatesPosition,
+ subtitleSize,
} = decideCardProperties(
card.supportingContent?.length ?? 0,
card.boostLevel,
@@ -496,6 +508,7 @@ const FullWidthCardLayout = ({
canPlayInline={true}
showKickerImage={card.format.design === ArticleDesign.Audio}
showLabsRedesign={showLabsRedesign}
+ subtitleSize={subtitleSize}
/>
diff --git a/dotcom-rendering/src/components/FlexibleSpecial.tsx b/dotcom-rendering/src/components/FlexibleSpecial.tsx
index ace3263e10b..f161d4e4362 100644
--- a/dotcom-rendering/src/components/FlexibleSpecial.tsx
+++ b/dotcom-rendering/src/components/FlexibleSpecial.tsx
@@ -19,6 +19,7 @@ import { UL } from './Card/components/UL';
import type { ResponsiveFontSize } from './CardHeadline';
import type { Loading } from './CardPicture';
import { FrontCard } from './FrontCard';
+import type { SubtitleSize } from './LoopVideoPlayer';
import type { Alignment } from './SupportingContent';
type Props = {
@@ -41,6 +42,7 @@ type BoostProperties = {
supportingContentAlignment: Alignment;
liveUpdatesAlignment: Alignment;
trailTextSize: TrailTextSize;
+ subtitleSize: SubtitleSize;
};
/**
@@ -69,6 +71,7 @@ const determineCardProperties = (
supportingContentLength >= 3 ? 'horizontal' : 'vertical',
liveUpdatesAlignment: 'vertical',
trailTextSize: 'regular',
+ subtitleSize: 'medium',
};
case 'boost':
return {
@@ -84,6 +87,7 @@ const determineCardProperties = (
supportingContentLength >= 3 ? 'horizontal' : 'vertical',
liveUpdatesAlignment: 'vertical',
trailTextSize: 'regular',
+ subtitleSize: 'medium',
};
case 'megaboost':
return {
@@ -98,6 +102,7 @@ const determineCardProperties = (
supportingContentAlignment: 'horizontal',
liveUpdatesAlignment: 'horizontal',
trailTextSize: 'large',
+ subtitleSize: 'large',
};
case 'gigaboost':
return {
@@ -112,6 +117,7 @@ const determineCardProperties = (
supportingContentAlignment: 'horizontal',
liveUpdatesAlignment: 'horizontal',
trailTextSize: 'large',
+ subtitleSize: 'large',
};
}
};
@@ -154,6 +160,7 @@ export const OneCardLayout = ({
supportingContentAlignment,
liveUpdatesAlignment,
trailTextSize,
+ subtitleSize,
} = determineCardProperties(
card.boostLevel ?? 'default',
card.supportingContent?.length ?? 0,
@@ -194,6 +201,7 @@ export const OneCardLayout = ({
showKickerImage={card.format.design === ArticleDesign.Audio}
headlinePosition={isSplashCard ? 'outer' : 'inner'}
showLabsRedesign={showLabsRedesign}
+ subtitleSize={subtitleSize}
/>
diff --git a/dotcom-rendering/src/components/LoopVideo.importable.tsx b/dotcom-rendering/src/components/LoopVideo.importable.tsx
index ea0178c94e3..61fc1ae17fc 100644
--- a/dotcom-rendering/src/components/LoopVideo.importable.tsx
+++ b/dotcom-rendering/src/components/LoopVideo.importable.tsx
@@ -1,5 +1,6 @@
import { css } from '@emotion/react';
import { log, storage } from '@guardian/libs';
+import { space } from '@guardian/source/foundations';
import { SvgAudio, SvgAudioMute } from '@guardian/source/react-components';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
@@ -18,8 +19,12 @@ import {
} from '../lib/video';
import { CardPicture, type Props as CardPictureProps } from './CardPicture';
import { useConfig } from './ConfigContext';
+import type {
+ PLAYER_STATES,
+ PlayerStates,
+ SubtitleSize,
+} from './LoopVideoPlayer';
import { LoopVideoPlayer } from './LoopVideoPlayer';
-import type { PLAYER_STATES, PlayerStates } from './LoopVideoPlayer';
import { ophanTrackerWeb } from './YoutubeAtom/eventEmitters';
const videoContainerStyles = css`
@@ -117,6 +122,8 @@ type Props = {
fallbackImageAlt: CardPictureProps['alt'];
fallbackImageAspectRatio: CardPictureProps['aspectRatio'];
linkTo: string;
+ subtitleSource?: string;
+ subtitleSize: SubtitleSize;
};
export const LoopVideo = ({
@@ -132,6 +139,8 @@ export const LoopVideo = ({
fallbackImageAlt,
fallbackImageAspectRatio,
linkTo,
+ subtitleSource,
+ subtitleSize,
}: Props) => {
const adapted = useShouldAdapt();
const { renderingTarget } = useConfig();
@@ -478,6 +487,25 @@ export const LoopVideo = ({
return FallbackImageComponent;
}
+ const handleLoadedMetadata = () => {
+ const video = vidRef.current;
+ if (!video) return;
+
+ const track = video.textTracks[0];
+ if (!track?.cues) return;
+ const pxFromBottom = space[3];
+ const videoHeight = video.getBoundingClientRect().height;
+ const percentFromTop =
+ ((videoHeight - pxFromBottom) / videoHeight) * 100;
+
+ for (const cue of Array.from(track.cues)) {
+ if (cue instanceof VTTCue) {
+ cue.snapToLines = false;
+ cue.line = percentFromTop;
+ }
+ }
+ };
+
const handleLoadedData = () => {
if (vidRef.current) {
setHasAudio(doesVideoHaveAudio(vidRef.current));
@@ -617,6 +645,7 @@ export const LoopVideo = ({
isPlayable={isPlayable}
playerState={playerState}
isMuted={isMuted}
+ handleLoadedMetadata={handleLoadedMetadata}
handleLoadedData={handleLoadedData}
handleCanPlay={handleCanPlay}
handlePlayPauseClick={handlePlayPauseClick}
@@ -627,6 +656,8 @@ export const LoopVideo = ({
AudioIcon={hasAudio ? AudioIcon : null}
preloadPartialData={preloadPartialData}
showPlayIcon={showPlayIcon}
+ subtitleSource={subtitleSource}
+ subtitleSize={subtitleSize}
/>
);
diff --git a/dotcom-rendering/src/components/LoopVideo.stories.tsx b/dotcom-rendering/src/components/LoopVideo.stories.tsx
index 4a925494822..3fd1366b33f 100644
--- a/dotcom-rendering/src/components/LoopVideo.stories.tsx
+++ b/dotcom-rendering/src/components/LoopVideo.stories.tsx
@@ -12,6 +12,7 @@ const meta = {
parameters: {
chromatic: {
viewports: [breakpoints.mobile, breakpoints.wide],
+ disableSnapshot: true,
},
},
} satisfies Meta;
diff --git a/dotcom-rendering/src/components/LoopVideoPlayer.tsx b/dotcom-rendering/src/components/LoopVideoPlayer.tsx
index da77d99bdee..7abd5b7cb38 100644
--- a/dotcom-rendering/src/components/LoopVideoPlayer.tsx
+++ b/dotcom-rendering/src/components/LoopVideoPlayer.tsx
@@ -1,5 +1,10 @@
import { css } from '@emotion/react';
-import { space } from '@guardian/source/foundations';
+import {
+ space,
+ textSans15,
+ textSans17,
+ textSans20,
+} from '@guardian/source/foundations';
import type { IconProps } from '@guardian/source/react-components';
import type {
Dispatch,
@@ -13,7 +18,13 @@ import { palette } from '../palette';
import { narrowPlayIconWidth, PlayIcon } from './Card/components/PlayIcon';
import { LoopVideoProgressBar } from './LoopVideoProgressBar';
-const videoStyles = (width: number, height: number) => css`
+export type SubtitleSize = 'small' | 'medium' | 'large';
+
+const videoStyles = (
+ width: number,
+ height: number,
+ subtitleSize: SubtitleSize,
+) => css`
position: relative;
display: block;
height: auto;
@@ -22,6 +33,14 @@ const videoStyles = (width: number, height: number) => css`
/* Prevents CLS by letting the browser know the space the video will take up. */
aspect-ratio: ${width} / ${height};
object-fit: cover;
+
+ ::cue {
+ color: ${palette('--loop-video-subtitle-text')};
+
+ ${subtitleSize === 'small' && textSans15};
+ ${subtitleSize === 'medium' && textSans17};
+ ${subtitleSize === 'large' && textSans20};
+ }
`;
const playIconStyles = css`
@@ -86,6 +105,7 @@ type Props = {
currentTime: number;
setCurrentTime: Dispatch>;
isMuted: boolean;
+ handleLoadedMetadata: (event: SyntheticEvent) => void;
handleLoadedData: (event: SyntheticEvent) => void;
handleCanPlay: (event: SyntheticEvent) => void;
handlePlayPauseClick: (event: SyntheticEvent) => void;
@@ -97,12 +117,19 @@ type Props = {
posterImage?: string;
preloadPartialData: boolean;
showPlayIcon: boolean;
+ subtitleSource?: string;
+ subtitleSize: SubtitleSize;
};
/**
* Note that in React 19, forwardRef is no longer necessary:
* https://react.dev/reference/react/forwardRef
*/
+/**
+ * NB: To develop the loop video player locally, use `https://r.thegulocal.com/` instead of `localhost`.
+ * This is required because CORS restrictions prevent accessing the subtitles and video file from localhost.
+ */
+
export const LoopVideoPlayer = forwardRef(
(
{
@@ -118,6 +145,7 @@ export const LoopVideoPlayer = forwardRef(
currentTime,
setCurrentTime,
isMuted,
+ handleLoadedMetadata,
handleLoadedData,
handleCanPlay,
handlePlayPauseClick,
@@ -128,6 +156,8 @@ export const LoopVideoPlayer = forwardRef(
AudioIcon,
preloadPartialData,
showPlayIcon,
+ subtitleSource,
+ subtitleSize,
}: Props,
ref: React.ForwardedRef,
) => {
@@ -138,8 +168,9 @@ export const LoopVideoPlayer = forwardRef(
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- Captions will be considered later. */}
))}
+ {subtitleSource !== undefined && (
+
+ )}
{FallbackImageComponent}
{ref && 'current' in ref && ref.current && isPlayable && (
diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json
index d9030c9936b..d78571aa9a7 100644
--- a/dotcom-rendering/src/frontend/schemas/feArticle.json
+++ b/dotcom-rendering/src/frontend/schemas/feArticle.json
@@ -5327,6 +5327,9 @@
"duration": {
"type": "number"
},
+ "subtitleSource": {
+ "type": "string"
+ },
"image": {
"type": "string"
}
diff --git a/dotcom-rendering/src/frontend/schemas/feFront.json b/dotcom-rendering/src/frontend/schemas/feFront.json
index 4732c0aa721..b6d7ffd80e5 100644
--- a/dotcom-rendering/src/frontend/schemas/feFront.json
+++ b/dotcom-rendering/src/frontend/schemas/feFront.json
@@ -3959,6 +3959,9 @@
"duration": {
"type": "number"
},
+ "subtitleSource": {
+ "type": "string"
+ },
"image": {
"type": "string"
}
diff --git a/dotcom-rendering/src/model/enhanceCards.test.ts b/dotcom-rendering/src/model/enhanceCards.test.ts
index 07ff42cee39..fa8d13dd44e 100644
--- a/dotcom-rendering/src/model/enhanceCards.test.ts
+++ b/dotcom-rendering/src/model/enhanceCards.test.ts
@@ -100,6 +100,7 @@ describe('Enhance Cards', () => {
duration: 15,
height: 400,
image: '',
+ subtitleSource: 'https://guim-example.co.uk/atomID-1.vtt',
type: 'LoopVideo',
sources: [
{
diff --git a/dotcom-rendering/src/model/enhanceCards.ts b/dotcom-rendering/src/model/enhanceCards.ts
index dd6afa8583f..cad66e3fb86 100644
--- a/dotcom-rendering/src/model/enhanceCards.ts
+++ b/dotcom-rendering/src/model/enhanceCards.ts
@@ -200,10 +200,14 @@ export const getActiveMediaAtom = (
cardTrailImage?: string,
): MainMedia | undefined => {
if (mediaAtom) {
- const assets = mediaAtom.assets
- .filter((_) => _.assetType === 'Video')
- .filter(({ version }) => version === mediaAtom.activeVersion);
- if (!assets.length) return undefined;
+ const assets = mediaAtom.assets.filter(
+ ({ version }) => version === mediaAtom.activeVersion,
+ );
+
+ const videoAssets = assets.filter(
+ ({ assetType }) => assetType === 'Video',
+ );
+ if (!videoAssets.length) return undefined;
const image = decideMediaAtomImage(
videoReplace,
@@ -231,6 +235,10 @@ export const getActiveMediaAtom = (
);
if (!sources.length) return undefined;
+ const subtitleAsset = assets.find(
+ ({ assetType }) => assetType === 'Subtitles',
+ );
+
return {
type: 'LoopVideo',
atomId: mediaAtom.id,
@@ -238,6 +246,7 @@ export const getActiveMediaAtom = (
src: source.id,
mimeType: source.mimeType as SupportedVideoFileType,
})),
+ subtitleSource: subtitleAsset?.id,
duration: mediaAtom.duration ?? 0,
// Size fixed to a 5:4 ratio
width: 500,
diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts
index 06e928ad420..3f0e8f1ce1d 100644
--- a/dotcom-rendering/src/paletteDeclarations.ts
+++ b/dotcom-rendering/src/paletteDeclarations.ts
@@ -7310,6 +7310,10 @@ const paletteColours = {
light: () => sourcePalette.neutral[86],
dark: () => sourcePalette.neutral[86],
},
+ '--loop-video-subtitle-text': {
+ light: () => sourcePalette.neutral[100],
+ dark: () => sourcePalette.neutral[100],
+ },
'--masthead-nav-background': {
light: mastheadNavBackground,
dark: mastheadNavBackground,
diff --git a/dotcom-rendering/src/types/mainMedia.ts b/dotcom-rendering/src/types/mainMedia.ts
index b97ed925622..f389c4f42c3 100644
--- a/dotcom-rendering/src/types/mainMedia.ts
+++ b/dotcom-rendering/src/types/mainMedia.ts
@@ -27,6 +27,7 @@ type LoopVideo = Media & {
height: number;
width: number;
duration: number;
+ subtitleSource?: string;
image?: string;
};