Skip to content

Commit 9fccf71

Browse files
authored
Merge pull request #14133 from guardian/doml/loop-video-qa
Resume looping video when re-enters viewport
2 parents 6a4f5ee + 3b3ed9b commit 9fccf71

File tree

4 files changed

+130
-72
lines changed

4 files changed

+130
-72
lines changed

dotcom-rendering/src/components/Lazy.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const Lazy = ({ children, margin, disableFlexStyles }: Props) => {
3030
// being loaded as part of a Chromatic story or not so that
3131
// we can prevent lazy loading our storybook snapshots that we
3232
// use for visual regression
33-
const renderChildren = hasBeenSeen || Lazy.disabled;
33+
const renderChildren = !!hasBeenSeen || Lazy.disabled;
3434
return (
3535
<div ref={setRef} css={!disableFlexStyles && flexGrowStyles}>
3636
{renderChildren && <>{children}</>}

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

Lines changed: 77 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getZIndex } from '../lib/getZIndex';
66
import { useIsInView } from '../lib/useIsInView';
77
import { useShouldAdapt } from '../lib/useShouldAdapt';
88
import { useConfig } from './ConfigContext';
9+
import type { PLAYER_STATES } from './LoopVideoPlayer';
910
import { LoopVideoPlayer } from './LoopVideoPlayer';
1011

1112
const videoContainerStyles = css`
@@ -20,7 +21,6 @@ type Props = {
2021
height: number;
2122
thumbnailImage: string;
2223
fallbackImageComponent: JSX.Element;
23-
hasAudio?: boolean;
2424
};
2525

2626
export const LoopVideo = ({
@@ -30,16 +30,21 @@ export const LoopVideo = ({
3030
height,
3131
thumbnailImage,
3232
fallbackImageComponent,
33-
hasAudio = true,
3433
}: Props) => {
3534
const adapted = useShouldAdapt();
3635
const { renderingTarget } = useConfig();
3736
const vidRef = useRef<HTMLVideoElement>(null);
3837
const [isPlayable, setIsPlayable] = useState(false);
39-
const [isPlaying, setIsPlaying] = useState(false);
4038
const [isMuted, setIsMuted] = useState(true);
4139
const [currentTime, setCurrentTime] = useState(0);
42-
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
40+
const [playerState, setPlayerState] =
41+
useState<(typeof PLAYER_STATES)[number]>('NOT_STARTED');
42+
43+
// The user indicates a preference for reduced motion: https://web.dev/articles/prefers-reduced-motion
44+
const [prefersReducedMotion, setPrefersReducedMotion] = useState<
45+
boolean | null
46+
>(null);
47+
4348
/**
4449
* Keep a track of whether the video has been in view. We only want to
4550
* pause the video if it has been in view.
@@ -52,51 +57,83 @@ export const LoopVideo = ({
5257
});
5358

5459
/**
55-
* Pause the video when the user scrolls past it.
60+
* Register the users motion preferences.
5661
*/
5762
useEffect(() => {
58-
if (!vidRef.current) return;
63+
const userPrefersReducedMotion = window.matchMedia(
64+
'(prefers-reduced-motion: reduce)',
65+
).matches;
66+
setPrefersReducedMotion(userPrefersReducedMotion);
67+
}, []);
5968

60-
if (isInView) {
61-
// We only autoplay the first time the video comes into view.
62-
if (hasBeenInView) return;
69+
/**
70+
* Autoplays the video when it comes into view.
71+
*/
72+
useEffect(() => {
73+
if (!vidRef.current || playerState === 'PAUSED_BY_USER') return;
6374

64-
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
65-
setPrefersReducedMotion(true);
75+
if (isInView && isPlayable && playerState !== 'PLAYING') {
76+
if (prefersReducedMotion !== false) {
6677
return;
6778
}
6879

69-
setIsPlaying(true);
70-
void vidRef.current.play();
71-
80+
setPlayerState('PLAYING');
7281
setHasBeenInView(true);
82+
83+
void vidRef.current.play();
7384
}
85+
}, [isInView, isPlayable, playerState, prefersReducedMotion]);
7486

75-
if (!isInView && hasBeenInView && isPlayable && isPlaying) {
76-
setIsPlaying(false);
87+
/**
88+
* Stops playback when the video is scrolled out of view, resumes playbacks
89+
* when the video is back in the viewport.
90+
*/
91+
useEffect(() => {
92+
if (!vidRef.current || !hasBeenInView) return;
93+
94+
const isNoLongerInView = playerState === 'PLAYING' && !isInView;
95+
if (isNoLongerInView) {
96+
setPlayerState('PAUSED_BY_INTERSECTION_OBSERVER');
7797
void vidRef.current.pause();
98+
setIsMuted(true);
99+
}
100+
101+
// If a user action paused the video, they have indicated
102+
// that they don't want to watch the video. Therefore, don't
103+
// resume the video when it comes back in view
104+
const isBackInView =
105+
playerState === 'PAUSED_BY_INTERSECTION_OBSERVER' && isInView;
106+
if (isBackInView) {
107+
setPlayerState('PLAYING');
108+
109+
void vidRef.current.play();
78110
}
79-
}, [isInView, hasBeenInView, isPlayable, isPlaying]);
111+
}, [isInView, hasBeenInView, playerState]);
80112

81113
if (renderingTarget !== 'Web') return null;
82114

83115
if (adapted) return fallbackImageComponent;
84116

85117
const playVideo = () => {
86118
if (!vidRef.current) return;
87-
setIsPlaying(true);
119+
120+
setPlayerState('PLAYING');
121+
setHasBeenInView(true);
88122
void vidRef.current.play();
89123
};
90124

91125
const pauseVideo = () => {
92126
if (!vidRef.current) return;
93-
setIsPlaying(false);
127+
128+
setPlayerState('PAUSED_BY_USER');
94129
void vidRef.current.pause();
95130
};
96131

97132
const playPauseVideo = () => {
98-
if (isPlaying) {
99-
pauseVideo();
133+
if (playerState === 'PLAYING') {
134+
if (isInView) {
135+
pauseVideo();
136+
}
100137
} else {
101138
playVideo();
102139
}
@@ -166,6 +203,20 @@ export const LoopVideo = ({
166203

167204
const AudioIcon = isMuted ? SvgAudioMute : SvgAudio;
168205

206+
// We only show a poster image when the user has indicated that they do
207+
// not want videos to play automatically, e.g. prefers reduced motion. Otherwise,
208+
// we do not need to download the image as the video will be autoplayed.
209+
const posterImage =
210+
!!prefersReducedMotion || isInView === false
211+
? thumbnailImage
212+
: undefined;
213+
214+
const showPlayIcon =
215+
playerState === 'PAUSED_BY_USER' ||
216+
(!!prefersReducedMotion && playerState === 'NOT_STARTED');
217+
218+
const shouldPreloadData = !!isInView || prefersReducedMotion === false;
219+
169220
return (
170221
<div
171222
ref={setNode}
@@ -177,24 +228,23 @@ export const LoopVideo = ({
177228
videoId={videoId}
178229
width={width}
179230
height={height}
180-
hasAudio={hasAudio}
231+
posterImage={posterImage}
181232
fallbackImageComponent={fallbackImageComponent}
182233
currentTime={currentTime}
183234
setCurrentTime={setCurrentTime}
184235
ref={vidRef}
185236
isPlayable={isPlayable}
186237
setIsPlayable={setIsPlayable}
187-
isPlaying={isPlaying}
188-
setIsPlaying={setIsPlaying}
238+
playerState={playerState}
239+
setPlayerState={setPlayerState}
189240
isMuted={isMuted}
190241
setIsMuted={setIsMuted}
191242
handleClick={handleClick}
192243
handleKeyDown={handleKeyDown}
193244
onError={onError}
194245
AudioIcon={AudioIcon}
195-
thumbnailImage={
196-
prefersReducedMotion ? thumbnailImage : undefined
197-
}
246+
shouldPreload={shouldPreloadData}
247+
showPlayIcon={showPlayIcon}
198248
/>
199249
</div>
200250
);

dotcom-rendering/src/components/LoopVideoPlayer.tsx

Lines changed: 47 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ const videoStyles = (width: number, height: number) => css`
1515
cursor: pointer;
1616
/* Prevents CLS by letting the browser know the space the video will take up. */
1717
aspect-ratio: ${width} / ${height};
18+
object-fit: cover;
1819
`;
1920

2021
const playIconStyles = css`
2122
position: absolute;
23+
/* Center the icon */
2224
top: calc(50% - ${narrowPlayIconWidth / 2}px);
2325
left: calc(50% - ${narrowPlayIconWidth / 2}px);
2426
cursor: pointer;
@@ -49,17 +51,23 @@ const audioIconContainerStyles = css`
4951
border: 1px solid ${palette('--loop-video-audio-icon-border')};
5052
`;
5153

54+
export const PLAYER_STATES = [
55+
'NOT_STARTED',
56+
'PLAYING',
57+
'PAUSED_BY_USER',
58+
'PAUSED_BY_INTERSECTION_OBSERVER',
59+
] as const;
60+
5261
type Props = {
5362
src: string;
5463
videoId: string;
5564
width: number;
5665
height: number;
57-
hasAudio: boolean;
5866
fallbackImageComponent: JSX.Element;
5967
isPlayable: boolean;
6068
setIsPlayable: Dispatch<SetStateAction<boolean>>;
61-
isPlaying: boolean;
62-
setIsPlaying: Dispatch<SetStateAction<boolean>>;
69+
playerState: (typeof PLAYER_STATES)[number];
70+
setPlayerState: Dispatch<SetStateAction<(typeof PLAYER_STATES)[number]>>;
6371
currentTime: number;
6472
setCurrentTime: Dispatch<SetStateAction<number>>;
6573
isMuted: boolean;
@@ -68,12 +76,9 @@ type Props = {
6876
handleKeyDown: (event: React.KeyboardEvent<HTMLVideoElement>) => void;
6977
onError: (event: SyntheticEvent<HTMLVideoElement>) => void;
7078
AudioIcon: (iconProps: IconProps) => JSX.Element;
71-
/**
72-
* We ONLY show a thumbnail image when the user has indicated that they do
73-
* not want videos to play automatically, e.g. prefers reduced motion. Otherwise,
74-
* we do not bother downloading the image, as the video will be autoplayed.
75-
*/
76-
thumbnailImage?: string;
79+
posterImage?: string;
80+
shouldPreload: boolean;
81+
showPlayIcon: boolean;
7782
};
7883

7984
/**
@@ -87,13 +92,12 @@ export const LoopVideoPlayer = forwardRef(
8792
videoId,
8893
width,
8994
height,
90-
hasAudio,
9195
fallbackImageComponent,
92-
thumbnailImage,
96+
posterImage,
9397
isPlayable,
9498
setIsPlayable,
95-
isPlaying,
96-
setIsPlaying,
99+
playerState,
100+
setPlayerState,
97101
currentTime,
98102
setCurrentTime,
99103
isMuted,
@@ -102,6 +106,8 @@ export const LoopVideoPlayer = forwardRef(
102106
handleKeyDown,
103107
onError,
104108
AudioIcon,
109+
shouldPreload,
110+
showPlayIcon,
105111
}: Props,
106112
ref: React.ForwardedRef<HTMLVideoElement>,
107113
) => {
@@ -114,15 +120,15 @@ export const LoopVideoPlayer = forwardRef(
114120
<video
115121
id={loopVideoId}
116122
ref={ref}
117-
preload={thumbnailImage ? 'metadata' : 'none'}
123+
preload={shouldPreload ? 'metadata' : 'none'}
118124
loop={true}
119125
muted={isMuted}
120126
playsInline={true}
121127
height={height}
122128
width={width}
123-
poster={thumbnailImage ?? undefined}
129+
poster={posterImage}
124130
onPlaying={() => {
125-
setIsPlaying(true);
131+
setPlayerState('PLAYING');
126132
}}
127133
onCanPlay={() => {
128134
setIsPlayable(true);
@@ -132,7 +138,7 @@ export const LoopVideoPlayer = forwardRef(
132138
ref &&
133139
'current' in ref &&
134140
ref.current &&
135-
isPlaying
141+
playerState === 'PLAYING'
136142
) {
137143
setCurrentTime(ref.current.currentTime);
138144
}
@@ -144,15 +150,14 @@ export const LoopVideoPlayer = forwardRef(
144150
onError={onError}
145151
css={videoStyles(width, height)}
146152
>
147-
{/* Ensure webm source is provided. Encoding the video to a webm file will improve
148-
performance on supported browsers. https://web.dev/articles/video-and-source-tags */}
149-
{/* <source src={webmSrc} type="video/webm"> */}
153+
{/* Only mp4 is currently supported. Assumes the video file type is mp4. */}
150154
<source src={src} type="video/mp4" />
151155
{fallbackImageComponent}
152156
</video>
153157
{ref && 'current' in ref && ref.current && isPlayable && (
154158
<>
155-
{!isPlaying && (
159+
{/* Play icon */}
160+
{showPlayIcon && (
156161
<button
157162
type="button"
158163
onClick={handleClick}
@@ -161,32 +166,32 @@ export const LoopVideoPlayer = forwardRef(
161166
<PlayIcon iconWidth="narrow" />
162167
</button>
163168
)}
169+
{/* Progress bar */}
164170
<LoopVideoProgressBar
165171
videoId={loopVideoId}
166172
currentTime={currentTime}
167173
duration={ref.current.duration}
168174
/>
169-
{hasAudio && (
170-
<button
171-
type="button"
172-
onClick={(event) => {
173-
event.stopPropagation(); // Don't pause the video
174-
setIsMuted(!isMuted);
175-
}}
176-
css={audioButtonStyles}
177-
>
178-
<div css={audioIconContainerStyles}>
179-
<AudioIcon
180-
size="xsmall"
181-
theme={{
182-
fill: palette(
183-
'--loop-video-audio-icon',
184-
),
185-
}}
186-
/>
187-
</div>
188-
</button>
189-
)}
175+
{/* Audio icon */}
176+
<button
177+
type="button"
178+
onClick={(event) => {
179+
event.stopPropagation(); // Don't pause the video
180+
setIsMuted(!isMuted);
181+
}}
182+
css={audioButtonStyles}
183+
>
184+
<div css={audioIconContainerStyles}>
185+
<AudioIcon
186+
size="xsmall"
187+
theme={{
188+
fill: palette(
189+
'--loop-video-audio-icon',
190+
),
191+
}}
192+
/>
193+
</div>
194+
</button>
190195
</>
191196
)}
192197
</>

dotcom-rendering/src/lib/useIsInView.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@ type Options = {
4242
*/
4343
const useIsInView = (
4444
options: IntersectionObserverInit & Options,
45-
): [boolean, React.Dispatch<React.SetStateAction<HTMLElement | null>>] => {
46-
const [isInView, setIsInView] = useState<boolean>(false);
45+
): [
46+
boolean | null,
47+
React.Dispatch<React.SetStateAction<HTMLElement | null>>,
48+
] => {
49+
const [isInView, setIsInView] = useState<boolean | null>(null);
4750
const [node, setNode] = useState<HTMLElement | null>(options.node ?? null);
4851

4952
const observer = useRef<IntersectionObserver | null>(null);

0 commit comments

Comments
 (0)