Skip to content

Commit 9081824

Browse files
committed
Refactor to use player state instead of multiple state variables.
1 parent fb6e4a5 commit 9081824

File tree

2 files changed

+122
-67
lines changed

2 files changed

+122
-67
lines changed

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

Lines changed: 76 additions & 25 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,48 +57,81 @@ 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+
}, []);
68+
69+
/**
70+
* Autoplays the video when it comes into view.
71+
*/
72+
useEffect(() => {
73+
if (!vidRef.current || playerState === 'PAUSED_BY_USER') return;
5974

60-
if (isInView) {
61-
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
62-
setPrefersReducedMotion(true);
75+
if (isInView && isPlayable && playerState !== 'PLAYING') {
76+
if (prefersReducedMotion !== false) {
6377
return;
6478
}
6579

66-
setIsPlaying(true);
67-
void vidRef.current.play();
68-
80+
setPlayerState('PLAYING');
6981
setHasBeenInView(true);
82+
83+
void vidRef.current.play();
7084
}
85+
}, [isInView, isPlayable, playerState, prefersReducedMotion]);
7186

72-
if (!isInView && hasBeenInView && isPlayable && isPlaying) {
73-
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');
7497
void vidRef.current.pause();
7598
}
76-
}, [isInView, hasBeenInView, isPlayable, isPlaying]);
99+
100+
// If a user action paused the video, they have indicated
101+
// that they don't want to watch the video. Therefore, don't
102+
// resume the video when it comes back in view
103+
const isBackInView =
104+
playerState === 'PAUSED_BY_INTERSECTION_OBSERVER' && isInView;
105+
if (isBackInView) {
106+
setPlayerState('PLAYING');
107+
void vidRef.current.play();
108+
}
109+
}, [isInView, hasBeenInView, playerState]);
77110

78111
if (renderingTarget !== 'Web') return null;
79112

80113
if (adapted) return fallbackImageComponent;
81114

82115
const playVideo = () => {
83116
if (!vidRef.current) return;
84-
setIsPlaying(true);
117+
118+
setPlayerState('PLAYING');
119+
setHasBeenInView(true);
85120
void vidRef.current.play();
86121
};
87122

88123
const pauseVideo = () => {
89124
if (!vidRef.current) return;
90-
setIsPlaying(false);
125+
126+
setPlayerState('PAUSED_BY_USER');
91127
void vidRef.current.pause();
92128
};
93129

94130
const playPauseVideo = () => {
95-
if (isPlaying) {
96-
pauseVideo();
131+
if (playerState === 'PLAYING') {
132+
if (isInView) {
133+
pauseVideo();
134+
}
97135
} else {
98136
playVideo();
99137
}
@@ -163,6 +201,20 @@ export const LoopVideo = ({
163201

164202
const AudioIcon = isMuted ? SvgAudioMute : SvgAudio;
165203

204+
// We only show a poster image when the user has indicated that they do
205+
// not want videos to play automatically, e.g. prefers reduced motion. Otherwise,
206+
// we do not need to download the image as the video will be autoplayed.
207+
const posterImage =
208+
!!prefersReducedMotion || isInView === false
209+
? thumbnailImage
210+
: undefined;
211+
212+
const showPlayIcon =
213+
playerState === 'PAUSED_BY_USER' ||
214+
(!!prefersReducedMotion && playerState === 'NOT_STARTED');
215+
216+
const shouldPreloadData = !!isInView || prefersReducedMotion === false;
217+
166218
return (
167219
<div
168220
ref={setNode}
@@ -174,24 +226,23 @@ export const LoopVideo = ({
174226
videoId={videoId}
175227
width={width}
176228
height={height}
177-
hasAudio={hasAudio}
229+
posterImage={posterImage}
178230
fallbackImageComponent={fallbackImageComponent}
179231
currentTime={currentTime}
180232
setCurrentTime={setCurrentTime}
181233
ref={vidRef}
182234
isPlayable={isPlayable}
183235
setIsPlayable={setIsPlayable}
184-
isPlaying={isPlaying}
185-
setIsPlaying={setIsPlaying}
236+
playerState={playerState}
237+
setPlayerState={setPlayerState}
186238
isMuted={isMuted}
187239
setIsMuted={setIsMuted}
188240
handleClick={handleClick}
189241
handleKeyDown={handleKeyDown}
190242
onError={onError}
191243
AudioIcon={AudioIcon}
192-
thumbnailImage={
193-
prefersReducedMotion ? thumbnailImage : undefined
194-
}
244+
shouldPreload={shouldPreloadData}
245+
showPlayIcon={showPlayIcon}
195246
/>
196247
</div>
197248
);

dotcom-rendering/src/components/LoopVideoPlayer.tsx

Lines changed: 46 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const videoStyles = (width: number, height: number) => css`
2020

2121
const playIconStyles = css`
2222
position: absolute;
23+
/* Center the icon */
2324
top: calc(50% - ${narrowPlayIconWidth / 2}px);
2425
left: calc(50% - ${narrowPlayIconWidth / 2}px);
2526
cursor: pointer;
@@ -50,17 +51,23 @@ const audioIconContainerStyles = css`
5051
border: 1px solid ${palette('--loop-video-audio-icon-border')};
5152
`;
5253

54+
export const PLAYER_STATES = [
55+
'NOT_STARTED',
56+
'PLAYING',
57+
'PAUSED_BY_USER',
58+
'PAUSED_BY_INTERSECTION_OBSERVER',
59+
] as const;
60+
5361
type Props = {
5462
src: string;
5563
videoId: string;
5664
width: number;
5765
height: number;
58-
hasAudio: boolean;
5966
fallbackImageComponent: JSX.Element;
6067
isPlayable: boolean;
6168
setIsPlayable: Dispatch<SetStateAction<boolean>>;
62-
isPlaying: boolean;
63-
setIsPlaying: Dispatch<SetStateAction<boolean>>;
69+
playerState: (typeof PLAYER_STATES)[number];
70+
setPlayerState: Dispatch<SetStateAction<(typeof PLAYER_STATES)[number]>>;
6471
currentTime: number;
6572
setCurrentTime: Dispatch<SetStateAction<number>>;
6673
isMuted: boolean;
@@ -69,12 +76,9 @@ type Props = {
6976
handleKeyDown: (event: React.KeyboardEvent<HTMLVideoElement>) => void;
7077
onError: (event: SyntheticEvent<HTMLVideoElement>) => void;
7178
AudioIcon: (iconProps: IconProps) => JSX.Element;
72-
/**
73-
* We ONLY show a thumbnail image when the user has indicated that they do
74-
* not want videos to play automatically, e.g. prefers reduced motion. Otherwise,
75-
* we do not bother downloading the image, as the video will be autoplayed.
76-
*/
77-
thumbnailImage?: string;
79+
posterImage?: string;
80+
shouldPreload: boolean;
81+
showPlayIcon: boolean;
7882
};
7983

8084
/**
@@ -88,13 +92,12 @@ export const LoopVideoPlayer = forwardRef(
8892
videoId,
8993
width,
9094
height,
91-
hasAudio,
9295
fallbackImageComponent,
93-
thumbnailImage,
96+
posterImage,
9497
isPlayable,
9598
setIsPlayable,
96-
isPlaying,
97-
setIsPlaying,
99+
playerState,
100+
setPlayerState,
98101
currentTime,
99102
setCurrentTime,
100103
isMuted,
@@ -103,6 +106,8 @@ export const LoopVideoPlayer = forwardRef(
103106
handleKeyDown,
104107
onError,
105108
AudioIcon,
109+
shouldPreload,
110+
showPlayIcon,
106111
}: Props,
107112
ref: React.ForwardedRef<HTMLVideoElement>,
108113
) => {
@@ -115,15 +120,15 @@ export const LoopVideoPlayer = forwardRef(
115120
<video
116121
id={loopVideoId}
117122
ref={ref}
118-
preload={thumbnailImage ? 'metadata' : 'none'}
123+
preload={shouldPreload ? 'metadata' : 'none'}
119124
loop={true}
120125
muted={isMuted}
121126
playsInline={true}
122127
height={height}
123128
width={width}
124-
poster={thumbnailImage ?? undefined}
129+
poster={posterImage}
125130
onPlaying={() => {
126-
setIsPlaying(true);
131+
setPlayerState('PLAYING');
127132
}}
128133
onCanPlay={() => {
129134
setIsPlayable(true);
@@ -133,7 +138,7 @@ export const LoopVideoPlayer = forwardRef(
133138
ref &&
134139
'current' in ref &&
135140
ref.current &&
136-
isPlaying
141+
playerState === 'PLAYING'
137142
) {
138143
setCurrentTime(ref.current.currentTime);
139144
}
@@ -145,15 +150,14 @@ export const LoopVideoPlayer = forwardRef(
145150
onError={onError}
146151
css={videoStyles(width, height)}
147152
>
148-
{/* Ensure webm source is provided. Encoding the video to a webm file will improve
149-
performance on supported browsers. https://web.dev/articles/video-and-source-tags */}
150-
{/* <source src={webmSrc} type="video/webm"> */}
153+
{/* Only mp4 is currently supported. Assumes the video file type is mp4. */}
151154
<source src={src} type="video/mp4" />
152155
{fallbackImageComponent}
153156
</video>
154157
{ref && 'current' in ref && ref.current && isPlayable && (
155158
<>
156-
{!isPlaying && (
159+
{/* Play icon */}
160+
{showPlayIcon && (
157161
<button
158162
type="button"
159163
onClick={handleClick}
@@ -162,32 +166,32 @@ export const LoopVideoPlayer = forwardRef(
162166
<PlayIcon iconWidth="narrow" />
163167
</button>
164168
)}
169+
{/* Progress bar */}
165170
<LoopVideoProgressBar
166171
videoId={loopVideoId}
167172
currentTime={currentTime}
168173
duration={ref.current.duration}
169174
/>
170-
{hasAudio && (
171-
<button
172-
type="button"
173-
onClick={(event) => {
174-
event.stopPropagation(); // Don't pause the video
175-
setIsMuted(!isMuted);
176-
}}
177-
css={audioButtonStyles}
178-
>
179-
<div css={audioIconContainerStyles}>
180-
<AudioIcon
181-
size="xsmall"
182-
theme={{
183-
fill: palette(
184-
'--loop-video-audio-icon',
185-
),
186-
}}
187-
/>
188-
</div>
189-
</button>
190-
)}
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>
191195
</>
192196
)}
193197
</>

0 commit comments

Comments
 (0)