Skip to content

Commit 0939929

Browse files
authored
Merge pull request #14186 from guardian/doml/loop-video-safari
Fix looping video autoplay bug on Safari
2 parents 4e241d3 + 7014561 commit 0939929

File tree

2 files changed

+69
-33
lines changed

2 files changed

+69
-33
lines changed

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

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,22 @@ export const LoopVideo = ({
5353
const vidRef = useRef<HTMLVideoElement>(null);
5454
const [isPlayable, setIsPlayable] = useState(false);
5555
const [isMuted, setIsMuted] = useState(true);
56+
const [showPlayIcon, setShowPlayIcon] = useState(false);
57+
const [preloadPartialData, setPreloadPartialData] = useState(false);
58+
const [posterImage, setPosterImage] = useState<string | undefined>(
59+
undefined,
60+
);
5661
const [currentTime, setCurrentTime] = useState(0);
5762
const [playerState, setPlayerState] =
5863
useState<(typeof PLAYER_STATES)[number]>('NOT_STARTED');
5964

60-
// The user indicates a preference for reduced motion: https://web.dev/articles/prefers-reduced-motion
61-
const [prefersReducedMotion, setPrefersReducedMotion] = useState<
62-
boolean | null
63-
>(null);
65+
const [isAutoplayAllowed, setIsAutoplayAllowed] = useState<boolean | null>(
66+
null,
67+
);
6468

6569
/**
66-
* Keep a track of whether the video has been in view. We only want to
67-
* pause the video if it has been in view.
70+
* Keep a track of whether the video has been in view. We only
71+
* want to pause the video if it has been in view.
6872
*/
6973
const [hasBeenInView, setHasBeenInView] = useState(false);
7074

@@ -77,13 +81,16 @@ export const LoopVideo = ({
7781
* Setup.
7882
*
7983
* Register the users motion preferences.
80-
* Creates an event listener to ensure we don't play audio from multiple loops
84+
* Creates event listeners to control playback when there are multiple videos.
8185
*/
8286
useEffect(() => {
87+
/**
88+
* The user indicates a preference for reduced motion: https://web.dev/articles/prefers-reduced-motion
89+
*/
8390
const userPrefersReducedMotion = window.matchMedia(
8491
'(prefers-reduced-motion: reduce)',
8592
).matches;
86-
setPrefersReducedMotion(userPrefersReducedMotion);
93+
setIsAutoplayAllowed(!userPrefersReducedMotion);
8794

8895
/**
8996
* Mutes the current video when another video is unmuted
@@ -131,11 +138,17 @@ export const LoopVideo = ({
131138
};
132139
}, [uniqueId]);
133140

141+
useEffect(() => {
142+
if (isInView && !hasBeenInView) {
143+
setHasBeenInView(true);
144+
}
145+
}, [isInView, hasBeenInView]);
146+
134147
/**
135148
* Autoplay the video when it comes into view.
136149
*/
137150
useEffect(() => {
138-
if (!vidRef.current || prefersReducedMotion !== false) {
151+
if (!vidRef.current || isAutoplayAllowed === false) {
139152
return;
140153
}
141154

@@ -146,11 +159,9 @@ export const LoopVideo = ({
146159
playerState === 'PAUSED_BY_INTERSECTION_OBSERVER')
147160
) {
148161
setPlayerState('PLAYING');
149-
setHasBeenInView(true);
150-
151162
void vidRef.current.play();
152163
}
153-
}, [isInView, isPlayable, playerState, prefersReducedMotion]);
164+
}, [isInView, isPlayable, playerState, isAutoplayAllowed]);
154165

155166
/**
156167
* Stops playback when the video is scrolled out of view, resumes playbacks
@@ -159,7 +170,8 @@ export const LoopVideo = ({
159170
useEffect(() => {
160171
if (!vidRef.current || !hasBeenInView) return;
161172

162-
const isNoLongerInView = playerState === 'PLAYING' && !isInView;
173+
const isNoLongerInView =
174+
playerState === 'PLAYING' && isInView === false;
163175
if (isNoLongerInView) {
164176
setPlayerState('PAUSED_BY_INTERSECTION_OBSERVER');
165177
void vidRef.current.pause();
@@ -180,6 +192,44 @@ export const LoopVideo = ({
180192
}
181193
}, [isInView, hasBeenInView, playerState]);
182194

195+
/**
196+
* Show the play icon when the video is not playing, except for when it is scrolled
197+
* out of view. In this case, the intersection observer will resume playback and
198+
* having a play icon would falsely indicate a user action is required to resume playback.
199+
*/
200+
useEffect(() => {
201+
const shouldShowPlayIcon =
202+
playerState === 'PAUSED_BY_USER' ||
203+
(!isAutoplayAllowed && playerState === 'NOT_STARTED');
204+
205+
setShowPlayIcon(shouldShowPlayIcon);
206+
}, [playerState, isAutoplayAllowed]);
207+
208+
/**
209+
* Show a poster image if a video does NOT play automatically. Otherwise, we do not need
210+
* to download the image as the video will be autoplayed and the image will not be seen.
211+
*
212+
* If the video is partially in view (not enough to trigger autoplay) and hasn't yet been
213+
* seen, we want to show the poster image to avoid showing a blank space.
214+
*/
215+
useEffect(() => {
216+
if (
217+
isAutoplayAllowed === false ||
218+
(isInView === false && !hasBeenInView)
219+
) {
220+
setPosterImage(thumbnailImage);
221+
}
222+
}, [isAutoplayAllowed, isInView, hasBeenInView, thumbnailImage]);
223+
224+
/**
225+
* We almost always want to preload some of the video data. If a user has prefers-reduced-motion
226+
* enabled, then the video will only be partially preloaded (metadata + small amount of video)
227+
* when it comes into view.
228+
*/
229+
useEffect(() => {
230+
setPreloadPartialData(isAutoplayAllowed === false || !!isInView);
231+
}, [isAutoplayAllowed, isInView]);
232+
183233
if (renderingTarget !== 'Web') return null;
184234

185235
if (adapted) return fallbackImageComponent;
@@ -188,7 +238,6 @@ export const LoopVideo = ({
188238
if (!vidRef.current) return;
189239

190240
setPlayerState('PLAYING');
191-
setHasBeenInView(true);
192241
void vidRef.current.play();
193242
};
194243

@@ -285,20 +334,6 @@ export const LoopVideo = ({
285334

286335
const AudioIcon = isMuted ? SvgAudioMute : SvgAudio;
287336

288-
// We only show a poster image when the user has indicated that they do
289-
// not want videos to play automatically, e.g. prefers reduced motion. Otherwise,
290-
// we do not need to download the image as the video will be autoplayed.
291-
const posterImage =
292-
!!prefersReducedMotion || isInView === false
293-
? thumbnailImage
294-
: undefined;
295-
296-
const showPlayIcon =
297-
playerState === 'PAUSED_BY_USER' ||
298-
(!!prefersReducedMotion && playerState === 'NOT_STARTED');
299-
300-
const shouldPreloadData = !!isInView || prefersReducedMotion === false;
301-
302337
return (
303338
<div
304339
ref={setNode}
@@ -324,7 +359,7 @@ export const LoopVideo = ({
324359
handleKeyDown={handleKeyDown}
325360
onError={onError}
326361
AudioIcon={AudioIcon}
327-
shouldPreload={shouldPreloadData}
362+
preloadPartialData={preloadPartialData}
328363
showPlayIcon={showPlayIcon}
329364
/>
330365
</div>

dotcom-rendering/src/components/LoopVideoPlayer.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ type Props = {
7676
onError: (event: SyntheticEvent<HTMLVideoElement>) => void;
7777
AudioIcon: (iconProps: IconProps) => JSX.Element;
7878
posterImage?: string;
79-
shouldPreload: boolean;
79+
preloadPartialData: boolean;
8080
showPlayIcon: boolean;
8181
};
8282

@@ -104,7 +104,7 @@ export const LoopVideoPlayer = forwardRef(
104104
handleKeyDown,
105105
onError,
106106
AudioIcon,
107-
shouldPreload,
107+
preloadPartialData,
108108
showPlayIcon,
109109
}: Props,
110110
ref: React.ForwardedRef<HTMLVideoElement>,
@@ -117,7 +117,7 @@ export const LoopVideoPlayer = forwardRef(
117117
<video
118118
id={loopVideoId}
119119
ref={ref}
120-
preload={shouldPreload ? 'metadata' : 'none'}
120+
preload={preloadPartialData ? 'metadata' : 'none'}
121121
loop={true}
122122
muted={isMuted}
123123
playsInline={true}
@@ -145,7 +145,8 @@ export const LoopVideoPlayer = forwardRef(
145145
css={videoStyles(width, height)}
146146
>
147147
{/* Only mp4 is currently supported. Assumes the video file type is mp4. */}
148-
<source src={src} type="video/mp4" />
148+
{/* The start time is set to 1ms so that Safari will autoplay the video */}
149+
<source src={`${src}#t=0.001`} type="video/mp4" />
149150
{fallbackImageComponent}
150151
</video>
151152
{ref && 'current' in ref && ref.current && isPlayable && (

0 commit comments

Comments
 (0)