Skip to content

Commit 73a7410

Browse files
authored
Merge pull request #14826 from guardian/doml/cinemagraph
Cinemagraphs
2 parents 10ff756 + 3554a19 commit 73a7410

File tree

14 files changed

+144
-37
lines changed

14 files changed

+144
-37
lines changed

dotcom-rendering/src/components/Card/Card.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
DCRSnapType,
3333
DCRSupportingContent,
3434
} from '../../types/front';
35+
import type { CardMediaType } from '../../types/layout';
3536
import type { MainMedia } from '../../types/mainMedia';
3637
import type { OnwardsSource } from '../../types/onwards';
3738
import { Avatar } from '../Avatar';
@@ -280,8 +281,21 @@ const getMedia = ({
280281
isBetaContainer: boolean;
281282
}) => {
282283
if (mainMedia?.type === 'SelfHostedVideo' && canPlayInline) {
284+
let type: CardMediaType;
285+
switch (mainMedia.videoStyle) {
286+
case 'Default':
287+
type = 'default-video';
288+
break;
289+
case 'Loop':
290+
type = 'loop-video';
291+
break;
292+
case 'Cinemagraph':
293+
type = 'cinemagraph';
294+
break;
295+
}
296+
283297
return {
284-
type: 'loop-video',
298+
type,
285299
mainMedia,
286300
} as const;
287301
}
@@ -564,6 +578,12 @@ export const Card = ({
564578
isBetaContainer,
565579
});
566580

581+
const isSelfHostedVideo =
582+
media &&
583+
(media.type === 'default-video' ||
584+
media.type === 'loop-video' ||
585+
media.type === 'cinemagraph');
586+
567587
const resolvedDataLinkName =
568588
media && dataLinkName
569589
? appendLinkNameMedia(dataLinkName, media.type)
@@ -941,7 +961,7 @@ export const Card = ({
941961
/>
942962
</AvatarContainer>
943963
)}
944-
{media.type === 'loop-video' && (
964+
{isSelfHostedVideo && (
945965
<Island
946966
priority="critical"
947967
defer={{ until: 'visible' }}
@@ -952,6 +972,7 @@ export const Card = ({
952972
uniqueId={uniqueId}
953973
height={media.mainMedia.height}
954974
width={media.mainMedia.width}
975+
videoStyle={media.mainMedia.videoStyle}
955976
posterImage={media.mainMedia.image ?? ''}
956977
fallbackImage={media.mainMedia.image ?? ''}
957978
fallbackImageSize={mediaSize}

dotcom-rendering/src/components/Card/components/CardWrapper.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const hoverStyles = css`
5959
*/
6060
:has(
6161
ul.sublinks:hover,
62-
.video-container:hover,
62+
.video-container.loop:hover,
6363
.slideshow-carousel:hover,
6464
.branding-logo:hover
6565
) {

dotcom-rendering/src/components/Card/components/MediaWrapper.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { SerializedStyles } from '@emotion/react';
22
import { css } from '@emotion/react';
33
import { between, from, space, until } from '@guardian/source/foundations';
4+
import { getZIndex } from '../../../lib/getZIndex';
45
import type { CardMediaType } from '../../../types/layout';
56

67
const mediaFixedSize = {
@@ -48,6 +49,9 @@ const mediaOverlayContainerStyles = css`
4849
left: 0;
4950
width: 100%;
5051
height: 100%;
52+
z-index: ${getZIndex('mediaOverlay')};
53+
cursor: pointer;
54+
pointer-events: none;
5155
`;
5256

5357
/**
@@ -219,7 +223,9 @@ export const MediaWrapper = ({
219223
(mediaType === 'slideshow' ||
220224
mediaType === 'picture' ||
221225
mediaType === 'youtube-video' ||
226+
mediaType === 'default-video' ||
222227
mediaType === 'loop-video' ||
228+
mediaType === 'cinemagraph' ||
223229
mediaType === 'podcast') &&
224230
isHorizontalOnDesktop &&
225231
flexBasisStyles({
@@ -267,8 +273,10 @@ export const MediaWrapper = ({
267273
>
268274
<>
269275
{children}
270-
{/* This image overlay is styled when the CardLink is hovered */}
271-
{(mediaType === 'picture' || mediaType === 'slideshow') &&
276+
{/* This overlay is styled when the CardLink is hovered */}
277+
{(mediaType === 'picture' ||
278+
mediaType === 'slideshow' ||
279+
mediaType === 'cinemagraph') &&
272280
!hideMediaOverlay && (
273281
<div
274282
css={[

dotcom-rendering/src/components/SelfHostedVideo.importable.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
customSelfHostedVideoPlayAudioEventName,
1919
customYoutubePlayEventName,
2020
} from '../lib/video';
21+
import type { VideoPlayerFormat } from '../types/mainMedia';
2122
import { CardPicture, type Props as CardPictureProps } from './CardPicture';
2223
import { useConfig } from './ConfigContext';
2324
import type {
@@ -33,6 +34,10 @@ const videoContainerStyles = css`
3334
position: relative;
3435
`;
3536

37+
const cinemagraphContainerStyles = css`
38+
pointer-events: none;
39+
`;
40+
3641
/**
3742
* Dispatches a custom play audio event so that other videos listening
3843
* for this event will be muted.
@@ -116,6 +121,7 @@ type Props = {
116121
uniqueId: string;
117122
height: number;
118123
width: number;
124+
videoStyle: VideoPlayerFormat;
119125
posterImage: string;
120126
fallbackImage: CardPictureProps['mainImage'];
121127
fallbackImageSize: CardPictureProps['imageSize'];
@@ -133,6 +139,7 @@ export const SelfHostedVideo = ({
133139
uniqueId,
134140
height,
135141
width,
142+
videoStyle,
136143
posterImage,
137144
fallbackImage,
138145
fallbackImageSize,
@@ -170,6 +177,12 @@ export const SelfHostedVideo = ({
170177

171178
const VISIBILITY_THRESHOLD = 0.5;
172179

180+
/**
181+
* All controls on the video are hidden: the video looks like a GIF.
182+
* This includes but may not be limited to: audio icon, play/pause icon, subtitles, progress bar.
183+
*/
184+
const isCinemagraph = videoStyle === 'Cinemagraph';
185+
173186
const [isInView, setNode] = useIsInView({
174187
repeat: true,
175188
threshold: VISIBILITY_THRESHOLD,
@@ -534,11 +547,15 @@ export const SelfHostedVideo = ({
534547
};
535548

536549
const handlePlayPauseClick = (event: React.SyntheticEvent) => {
550+
if (isCinemagraph) return;
551+
537552
event.preventDefault();
538553
playPauseVideo();
539554
};
540555

541556
const handleAudioClick = (event: React.SyntheticEvent) => {
557+
if (isCinemagraph) return;
558+
542559
void submitClickComponentEvent(event.currentTarget, renderingTarget);
543560

544561
event.stopPropagation(); // Don't pause the video
@@ -558,6 +575,8 @@ export const SelfHostedVideo = ({
558575
* browser. Therefore we need to apply the pause state to the video.
559576
*/
560577
const handlePause = () => {
578+
if (isCinemagraph) return;
579+
561580
if (
562581
playerState === 'PAUSED_BY_USER' ||
563582
playerState === 'PAUSED_BY_INTERSECTION_OBSERVER'
@@ -581,6 +600,7 @@ export const SelfHostedVideo = ({
581600
new Error(message),
582601
'self-hosted-video',
583602
);
603+
584604
log('dotcom', message);
585605
};
586606

@@ -612,6 +632,8 @@ export const SelfHostedVideo = ({
612632
const handleKeyDown = (
613633
event: React.KeyboardEvent<HTMLVideoElement>,
614634
): void => {
635+
if (isCinemagraph) return;
636+
615637
switch (event.key) {
616638
case 'Enter':
617639
case ' ':
@@ -642,8 +664,11 @@ export const SelfHostedVideo = ({
642664
return (
643665
<figure
644666
ref={setNode}
645-
css={videoContainerStyles}
646-
className="video-container"
667+
css={[
668+
videoContainerStyles,
669+
isCinemagraph && cinemagraphContainerStyles,
670+
]}
671+
className={`video-container ${videoStyle.toLocaleLowerCase()}`}
647672
data-component="gu-video-loop"
648673
>
649674
<SelfHostedVideoPlayer
@@ -652,6 +677,7 @@ export const SelfHostedVideo = ({
652677
uniqueId={uniqueId}
653678
width={width}
654679
height={height}
680+
videoStyle={videoStyle}
655681
posterImage={optimisedPosterImage}
656682
FallbackImageComponent={FallbackImageComponent}
657683
currentTime={currentTime}
@@ -671,8 +697,8 @@ export const SelfHostedVideo = ({
671697
AudioIcon={hasAudio ? AudioIcon : null}
672698
preloadPartialData={preloadPartialData}
673699
showPlayIcon={showPlayIcon}
674-
subtitleSource={subtitleSource}
675700
subtitleSize={subtitleSize}
701+
subtitleSource={subtitleSource}
676702
activeCue={activeCue}
677703
/>
678704
</figure>

dotcom-rendering/src/components/SelfHostedVideo.stories.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ export const Loop16to9: Story = {
7171
},
7272
};
7373

74+
export const WithCinemagraph: Story = {
75+
args: {
76+
...Loop4to5.args,
77+
videoStyle: 'Cinemagraph',
78+
},
79+
};
80+
7481
export const PausePlay: Story = {
7582
...Loop4to5,
7683
name: 'Pause and play interaction',

dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,14 @@ import { forwardRef } from 'react';
1616
import type { ActiveCue } from '../lib/useSubtitles';
1717
import type { Source } from '../lib/video';
1818
import { palette } from '../palette';
19+
import type { VideoPlayerFormat } from '../types/mainMedia';
1920
import { narrowPlayIconWidth, PlayIcon } from './Card/components/PlayIcon';
2021
import { SubtitleOverlay } from './SubtitleOverlay';
2122
import { VideoProgressBar } from './VideoProgressBar';
2223

2324
export type SubtitleSize = 'small' | 'medium' | 'large';
2425

25-
const videoStyles = (
26-
width: number,
27-
height: number,
28-
subtitleSize: SubtitleSize,
29-
) => css`
26+
const videoStyles = (width: number, height: number) => css`
3027
position: relative;
3128
display: block;
3229
height: auto;
@@ -35,7 +32,9 @@ const videoStyles = (
3532
/* Prevents CLS by letting the browser know the space the video will take up. */
3633
aspect-ratio: ${width} / ${height};
3734
object-fit: cover;
35+
`;
3836

37+
const subtitleStyles = (subtitleSize: SubtitleSize | undefined) => css`
3938
::cue {
4039
/* Hide the cue by default as we prefer custom overlay */
4140
visibility: hidden;
@@ -103,6 +102,7 @@ type Props = {
103102
uniqueId: string;
104103
width: number;
105104
height: number;
105+
videoStyle: VideoPlayerFormat;
106106
FallbackImageComponent: ReactElement;
107107
isPlayable: boolean;
108108
playerState: PlayerStates;
@@ -122,7 +122,7 @@ type Props = {
122122
preloadPartialData: boolean;
123123
showPlayIcon: boolean;
124124
subtitleSource?: string;
125-
subtitleSize: SubtitleSize;
125+
subtitleSize?: SubtitleSize;
126126
/* used in custom subtitle overlays */
127127
activeCue?: ActiveCue | null;
128128
};
@@ -144,6 +144,7 @@ export const SelfHostedVideoPlayer = forwardRef(
144144
uniqueId,
145145
width,
146146
height,
147+
videoStyle,
147148
FallbackImageComponent,
148149
posterImage,
149150
isPlayable,
@@ -169,22 +170,36 @@ export const SelfHostedVideoPlayer = forwardRef(
169170
ref: React.ForwardedRef<HTMLVideoElement>,
170171
) => {
171172
const videoId = `video-${uniqueId}`;
173+
const showSubtitles =
174+
videoStyle !== 'Cinemagraph' && !!subtitleSource && !!subtitleSize;
175+
176+
const showControls =
177+
videoStyle !== 'Cinemagraph' &&
178+
ref &&
179+
'current' in ref &&
180+
ref.current &&
181+
isPlayable;
182+
183+
const dataLinkName = `gu-video-${videoStyle}-${
184+
showPlayIcon ? 'play' : 'pause'
185+
}-${atomId}`;
172186

173187
return (
174188
<>
175-
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- Captions will be considered later. */}
189+
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- Not all videos require captions. */}
176190
<video
177191
id={videoId}
178-
css={videoStyles(width, height, subtitleSize)}
192+
css={[
193+
videoStyles(width, height),
194+
showSubtitles && subtitleStyles(subtitleSize),
195+
]}
179196
crossOrigin="anonymous"
180197
ref={ref}
181198
tabIndex={0}
182199
data-testid="self-hosted-video-player"
183200
height={height}
184201
width={width}
185-
data-link-name={`gu-video-loop-${
186-
showPlayIcon ? 'play' : 'pause'
187-
}-${atomId}`}
202+
data-link-name={dataLinkName}
188203
data-chromatic="ignore"
189204
preload={preloadPartialData ? 'metadata' : 'none'}
190205
loop={true}
@@ -218,7 +233,7 @@ export const SelfHostedVideoPlayer = forwardRef(
218233
type={source.mimeType}
219234
/>
220235
))}
221-
{subtitleSource !== undefined && (
236+
{showSubtitles && (
222237
<track
223238
// Don't use default - it forces native rendering on iOS
224239
default={false}
@@ -229,13 +244,13 @@ export const SelfHostedVideoPlayer = forwardRef(
229244
)}
230245
{FallbackImageComponent}
231246
</video>
232-
{!!activeCue?.text && (
247+
{showSubtitles && !!activeCue?.text && (
233248
<SubtitleOverlay
234249
text={activeCue.text}
235250
subtitleSize={subtitleSize}
236251
/>
237252
)}
238-
{ref && 'current' in ref && ref.current && isPlayable && (
253+
{showControls && (
239254
<>
240255
{/* Play icon */}
241256
{showPlayIcon && (
@@ -253,7 +268,7 @@ export const SelfHostedVideoPlayer = forwardRef(
253268
<VideoProgressBar
254269
videoId={videoId}
255270
currentTime={currentTime}
256-
duration={ref.current.duration}
271+
duration={ref.current!.duration}
257272
/>
258273
{/* Audio icon */}
259274
{AudioIcon && (

dotcom-rendering/src/frontend/feFront.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export interface FEMediaAtom {
126126
trailImage?: { allImages: Image[] };
127127
expired?: boolean;
128128
activeVersion?: number;
129+
videoPlayerFormat?: 'Default' | 'Loop' | 'Cinemagraph';
129130
// channelId?: string; // currently unused
130131
}
131132

0 commit comments

Comments
 (0)