Skip to content

Commit e2b3ae4

Browse files
authored
Merge branch 'main' into jm/new-sign-in-gate
2 parents e70089f + deb2bfc commit e2b3ae4

File tree

6 files changed

+148
-67
lines changed

6 files changed

+148
-67
lines changed

dotcom-rendering/src/components/FeatureCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,8 @@ export type Props = {
327327
collectionId: number;
328328
isNewsletter?: boolean;
329329
/**
330-
* An immersive feature card variant. It dictates that the card has a full width background image on all breakpoints. It also dictates the the card change aspect ratio to 5:3 on desktop and 4:5 on mobile.
331-
*
330+
* An immersive feature card variant. It dictates that the card has a full width background image on
331+
* all breakpoints. It also dictates the the card change aspect ratio to 5:3 on desktop and 4:5 on mobile.
332332
*/
333333
isImmersive?: boolean;
334334
showVideo?: boolean;

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

Lines changed: 90 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { css } from '@emotion/react';
2-
import { log } from '@guardian/libs';
2+
import { log, storage } from '@guardian/libs';
33
import { SvgAudio, SvgAudioMute } from '@guardian/source/react-components';
4-
import { useEffect, useRef, useState } from 'react';
4+
import { useCallback, useEffect, useRef, useState } from 'react';
55
import { submitClickComponentEvent } from '../client/ophan/ophan';
66
import { getZIndex } from '../lib/getZIndex';
77
import { useIsInView } from '../lib/useIsInView';
@@ -12,7 +12,7 @@ import {
1212
customYoutubePlayEventName,
1313
} from '../lib/video';
1414
import { useConfig } from './ConfigContext';
15-
import type { PLAYER_STATES } from './LoopVideoPlayer';
15+
import type { PLAYER_STATES, PlayerStates } from './LoopVideoPlayer';
1616
import { LoopVideoPlayer } from './LoopVideoPlayer';
1717

1818
const videoContainerStyles = css`
@@ -80,11 +80,68 @@ export const LoopVideo = ({
8080
threshold: 0.5,
8181
});
8282

83+
const playVideo = useCallback(async () => {
84+
if (!vidRef.current) return;
85+
86+
/** https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Autoplay#example_handling_play_failures */
87+
const startPlayPromise = vidRef.current.play();
88+
89+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- In earlier versions of the HTML specification, play() didn't return a value
90+
if (startPlayPromise !== undefined) {
91+
await startPlayPromise
92+
.catch((error) => {
93+
// Autoplay failed
94+
const message = `Autoplay failure for loop video. Source: ${src} could not be played. Error: ${error}`;
95+
if (error instanceof Error) {
96+
window.guardian.modules.sentry.reportError(
97+
new Error(message),
98+
'loop-video',
99+
);
100+
}
101+
102+
log('dotcom', message);
103+
104+
setPosterImage(image);
105+
setShowPlayIcon(true);
106+
})
107+
.then(() => {
108+
// Autoplay succeeded
109+
setPlayerState('PLAYING');
110+
});
111+
}
112+
}, [src, image]);
113+
114+
const pauseVideo = (
115+
reason: Extract<
116+
PlayerStates,
117+
'PAUSED_BY_USER' | 'PAUSED_BY_INTERSECTION_OBSERVER'
118+
>,
119+
) => {
120+
if (!vidRef.current) return;
121+
122+
if (reason === 'PAUSED_BY_INTERSECTION_OBSERVER') {
123+
setIsMuted(true);
124+
}
125+
126+
setPlayerState(reason);
127+
void vidRef.current.pause();
128+
};
129+
130+
const playPauseVideo = () => {
131+
if (playerState === 'PLAYING') {
132+
if (isInView) {
133+
pauseVideo('PAUSED_BY_USER');
134+
}
135+
} else {
136+
void playVideo();
137+
}
138+
};
139+
83140
/**
84141
* Setup.
85142
*
86-
* Register the users motion preferences.
87-
* Creates event listeners to control playback when there are multiple videos.
143+
* 1. Register the user's motion preferences.
144+
* 2. Creates event listeners to control playback when there are multiple videos.
88145
*/
89146
useEffect(() => {
90147
/**
@@ -93,7 +150,19 @@ export const LoopVideo = ({
93150
const userPrefersReducedMotion = window.matchMedia(
94151
'(prefers-reduced-motion: reduce)',
95152
).matches;
96-
setIsAutoplayAllowed(!userPrefersReducedMotion);
153+
154+
/**
155+
* The user indicates a preference for no flashing elements.
156+
* `flashingPreference` is `null` if no preference exists and
157+
* explicitly `false` when the reader has said they don't want flashing.
158+
*/
159+
const flashingPreferences = storage.local.get(
160+
'gu.prefs.accessibility.flashing-elements',
161+
);
162+
163+
setIsAutoplayAllowed(
164+
!userPrefersReducedMotion && flashingPreferences !== false,
165+
);
97166

98167
/**
99168
* Mutes the current video when another video is unmuted
@@ -113,7 +182,7 @@ export const LoopVideo = ({
113182
};
114183

115184
/**
116-
* Mute the current video when a Youtube video is played
185+
* Mute the current video when a YouTube video is played
117186
* Triggered by the CustomEvent in YoutubeAtomPlayer.
118187
*/
119188
const handleCustomPlayYoutubeEvent = () => {
@@ -161,10 +230,9 @@ export const LoopVideo = ({
161230
(playerState === 'NOT_STARTED' ||
162231
playerState === 'PAUSED_BY_INTERSECTION_OBSERVER')
163232
) {
164-
setPlayerState('PLAYING');
165-
void vidRef.current.play();
233+
void playVideo();
166234
}
167-
}, [isInView, isPlayable, playerState, isAutoplayAllowed]);
235+
}, [isAutoplayAllowed, isInView, isPlayable, playerState, playVideo]);
168236

169237
/**
170238
* Stops playback when the video is scrolled out of view, resumes playbacks
@@ -176,9 +244,7 @@ export const LoopVideo = ({
176244
const isNoLongerInView =
177245
playerState === 'PLAYING' && isInView === false;
178246
if (isNoLongerInView) {
179-
setPlayerState('PAUSED_BY_INTERSECTION_OBSERVER');
180-
void vidRef.current.pause();
181-
setIsMuted(true);
247+
pauseVideo('PAUSED_BY_INTERSECTION_OBSERVER');
182248
}
183249

184250
/**
@@ -189,11 +255,9 @@ export const LoopVideo = ({
189255
const isBackInView =
190256
playerState === 'PAUSED_BY_INTERSECTION_OBSERVER' && isInView;
191257
if (isBackInView) {
192-
setPlayerState('PLAYING');
193-
194-
void vidRef.current.play();
258+
void playVideo();
195259
}
196-
}, [isInView, hasBeenInView, playerState]);
260+
}, [isInView, hasBeenInView, playerState, playVideo]);
197261

198262
/**
199263
* Show the play icon when the video is not playing, except for when it is scrolled
@@ -237,30 +301,6 @@ export const LoopVideo = ({
237301

238302
if (adapted) return fallbackImageComponent;
239303

240-
const playVideo = () => {
241-
if (!vidRef.current) return;
242-
243-
setPlayerState('PLAYING');
244-
void vidRef.current.play();
245-
};
246-
247-
const pauseVideo = () => {
248-
if (!vidRef.current) return;
249-
250-
setPlayerState('PAUSED_BY_USER');
251-
void vidRef.current.pause();
252-
};
253-
254-
const playPauseVideo = () => {
255-
if (playerState === 'PLAYING') {
256-
if (isInView) {
257-
pauseVideo();
258-
}
259-
} else {
260-
playVideo();
261-
}
262-
};
263-
264304
const handlePlayPauseClick = (event: React.SyntheticEvent) => {
265305
event.preventDefault();
266306
playPauseVideo();
@@ -280,12 +320,18 @@ export const LoopVideo = ({
280320
}
281321
};
282322

323+
/**
324+
* If the video could not be loaded due to an error, report to
325+
* Sentry and log in the console.
326+
*/
283327
const onError = () => {
328+
const message = `Loop video could not be played. source: ${src}`;
329+
284330
window.guardian.modules.sentry.reportError(
285-
new Error(`Loop video could not be played. source: ${src}`),
331+
new Error(message),
286332
'loop-video',
287333
);
288-
log('dotcom', `Loop video could not be played. source: ${src}`);
334+
log('dotcom', message);
289335
};
290336

291337
const seekForward = () => {
@@ -323,7 +369,7 @@ export const LoopVideo = ({
323369
playPauseVideo();
324370
break;
325371
case 'Escape':
326-
pauseVideo();
372+
pauseVideo('PAUSED_BY_USER');
327373
break;
328374
case 'ArrowRight':
329375
seekForward();

dotcom-rendering/src/components/LoopVideoPlayer.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export const PLAYER_STATES = [
5858
'PAUSED_BY_INTERSECTION_OBSERVER',
5959
] as const;
6060

61+
export type PlayerStates = (typeof PLAYER_STATES)[number];
62+
6163
type Props = {
6264
src: string;
6365
atomId: string;
@@ -67,7 +69,7 @@ type Props = {
6769
fallbackImageComponent: JSX.Element;
6870
isPlayable: boolean;
6971
setIsPlayable: Dispatch<SetStateAction<boolean>>;
70-
playerState: (typeof PLAYER_STATES)[number];
72+
playerState: PlayerStates;
7173
currentTime: number;
7274
setCurrentTime: Dispatch<SetStateAction<number>>;
7375
isMuted: boolean;

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { css } from '@emotion/react';
22
import type { Meta, StoryObj } from '@storybook/react';
3+
import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
34
import { VideoAtom } from './VideoAtom';
45

56
const meta = {
@@ -25,6 +26,13 @@ export const Default = {
2526
mimeType: 'video/mp4',
2627
},
2728
],
29+
isMainMedia: false,
30+
format: {
31+
theme: Pillar.News,
32+
display: ArticleDisplay.Standard,
33+
design: ArticleDesign.Standard,
34+
},
35+
caption: 'This is a video caption',
2836
},
2937
decorators: [
3038
(Story) => (
Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { ArticleFormat } from '../lib/articleFormat';
2+
import { Caption } from './Caption';
13
import { MaintainAspectRatio } from './MaintainAspectRatio';
24

35
type AssetType = {
@@ -6,41 +8,61 @@ type AssetType = {
68
};
79

810
interface Props {
11+
format: ArticleFormat;
912
assets: AssetType[];
13+
isMainMedia: boolean;
1014
poster?: string;
15+
caption?: string;
1116
height?: number;
1217
width?: number;
1318
}
1419

1520
export const VideoAtom = ({
21+
format,
1622
assets,
23+
isMainMedia,
1724
poster,
25+
caption,
1826
height = 259,
1927
width = 460,
2028
}: Props) => {
2129
if (assets.length === 0) return null; // Handle empty assets array
2230
return (
23-
<MaintainAspectRatio
24-
height={height}
25-
width={width}
26-
data-spacefinder-role="inline"
27-
>
28-
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- caption not available */}
29-
<video
30-
controls={true}
31-
preload="metadata"
32-
width={width}
31+
<>
32+
<MaintainAspectRatio
3333
height={height}
34-
poster={poster}
34+
width={width}
35+
data-spacefinder-role="inline"
3536
>
36-
{assets.map((asset, index) => (
37-
<source key={index} src={asset.url} type={asset.mimeType} />
38-
))}
39-
<p>
40-
{`Your browser doesn't support HTML5 video. Here is a `}
41-
<a href={assets[0]?.url}>link to the video</a> instead.
42-
</p>
43-
</video>
44-
</MaintainAspectRatio>
37+
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- caption not available */}
38+
<video
39+
controls={true}
40+
preload="metadata"
41+
width={width}
42+
height={height}
43+
poster={poster}
44+
>
45+
{assets.map((asset, index) => (
46+
<source
47+
key={index}
48+
src={asset.url}
49+
type={asset.mimeType}
50+
/>
51+
))}
52+
<p>
53+
{`Your browser doesn't support HTML5 video. Here is a `}
54+
<a href={assets[0]?.url}>link to the video</a> instead.
55+
</p>
56+
</video>
57+
</MaintainAspectRatio>
58+
{!!caption && (
59+
<Caption
60+
captionText={caption}
61+
format={format}
62+
mediaType="Video"
63+
isMainMedia={isMainMedia}
64+
/>
65+
)}
66+
</>
4567
);
4668
};

dotcom-rendering/src/lib/renderElement.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,8 +478,11 @@ export const renderElement = ({
478478
case 'model.dotcomrendering.pageElements.MediaAtomBlockElement':
479479
return (
480480
<VideoAtom
481+
format={format}
481482
assets={element.assets}
482483
poster={element.posterImage?.[0]?.url}
484+
caption={element.title}
485+
isMainMedia={isMainMedia}
483486
/>
484487
);
485488
case 'model.dotcomrendering.pageElements.MiniProfilesBlockElement':

0 commit comments

Comments
 (0)