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. */} {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; };