Skip to content

Commit 4a836f0

Browse files
committed
Add Loop Video component
1 parent a1db210 commit 4a836f0

File tree

6 files changed

+385
-1
lines changed

6 files changed

+385
-1
lines changed
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import { css } from '@emotion/react';
2+
import { log } from '@guardian/libs';
3+
import { space } from '@guardian/source/foundations';
4+
import { SvgAudio, SvgAudioMute } from '@guardian/source/react-components';
5+
import { useEffect, useRef, useState } from 'react';
6+
import type { FEAspectRatio } from '../frontend/feFront';
7+
import { getZIndex } from '../lib/getZIndex';
8+
import { useIsInView } from '../lib/useIsInView';
9+
import { useShouldAdapt } from '../lib/useShouldAdapt';
10+
import { palette } from '../palette';
11+
import type { ImageSizeType } from './Card/components/ImageWrapper';
12+
import { narrowPlayIconWidth, PlayIcon } from './Card/components/PlayIcon';
13+
import type { Loading } from './CardPicture';
14+
import { CardPicture } from './CardPicture';
15+
import { useConfig } from './ConfigContext';
16+
import { LoopVideoProgressBar } from './LoopVideoProgressBar';
17+
18+
const videoContainerStyles = css`
19+
z-index: ${getZIndex('loop-video-container')};
20+
cursor: pointer;
21+
position: relative;
22+
`;
23+
24+
const videoStyles = css`
25+
position: relative;
26+
width: 100%;
27+
height: auto;
28+
/* Find out why this is needed to align the video with its container. */
29+
margin-bottom: -3px;
30+
`;
31+
32+
const playIconStyles = css`
33+
position: absolute;
34+
top: calc(50% - ${narrowPlayIconWidth / 2}px);
35+
left: calc(50% - ${narrowPlayIconWidth / 2}px);
36+
cursor: pointer;
37+
border: none;
38+
background: none;
39+
padding: 0;
40+
`;
41+
42+
const audioButtonStyles = css`
43+
border: none;
44+
background: none;
45+
padding: 0;
46+
position: absolute;
47+
bottom: ${space[8]}px;
48+
right: ${space[8]}px;
49+
cursor: pointer;
50+
`;
51+
52+
type ImageProps = {
53+
posterImage: string;
54+
imageSize: ImageSizeType;
55+
imageLoading: Loading;
56+
altText?: string;
57+
aspectRatio?: FEAspectRatio;
58+
};
59+
60+
type Props = ImageProps & {
61+
src: string;
62+
videoId?: string;
63+
width?: number;
64+
height?: number;
65+
hasAudio?: boolean;
66+
};
67+
68+
export const LoopVideo = ({
69+
src,
70+
videoId,
71+
posterImage,
72+
imageSize,
73+
imageLoading,
74+
altText,
75+
aspectRatio,
76+
width = 600,
77+
height = 360,
78+
hasAudio = true,
79+
}: Props) => {
80+
const adapted = useShouldAdapt();
81+
const { renderingTarget } = useConfig();
82+
const vidRef = useRef<HTMLVideoElement>(null);
83+
const [isPlayable, setIsPlayable] = useState(false);
84+
const [isPlaying, setIsPlaying] = useState(false);
85+
const [isMuted, setIsMuted] = useState(true);
86+
const [elapsedTime, setElapsedTime] = useState(0);
87+
/**
88+
* Keep a track of whether the video has been in view. We only want to
89+
* pause the video if it has been in view.
90+
*/
91+
const [hasBeenInView, setHasBeenInView] = useState(false);
92+
93+
const [isInView, setNode] = useIsInView({
94+
repeat: true,
95+
threshold: 0.5,
96+
node: vidRef.current ?? undefined,
97+
debounce: true,
98+
});
99+
100+
/**
101+
* Pause the video when the user scrolls past it.
102+
*/
103+
useEffect(() => {
104+
if (!vidRef.current) return;
105+
106+
if (isInView) {
107+
if (!hasBeenInView) {
108+
// When the video first comes into view, it should autoplay
109+
setIsPlaying(true);
110+
void vidRef.current.play();
111+
}
112+
setHasBeenInView(true);
113+
}
114+
115+
if (!isInView && hasBeenInView && isPlayable && isPlaying) {
116+
setIsPlaying(false);
117+
void vidRef.current.pause();
118+
}
119+
}, [isInView, hasBeenInView, isPlayable, isPlaying]);
120+
121+
/**
122+
* Progress bar updates
123+
*/
124+
useEffect(() => {
125+
const interval = setInterval(() => {
126+
setElapsedTime(vidRef.current?.currentTime ?? 0);
127+
}, 40);
128+
129+
return () => clearInterval(interval);
130+
}, []);
131+
132+
if (renderingTarget !== 'Web') return null;
133+
134+
if (adapted) {
135+
return (
136+
<CardPicture
137+
mainImage={posterImage}
138+
imageSize={imageSize}
139+
alt={altText}
140+
loading={imageLoading}
141+
aspectRatio={aspectRatio}
142+
/>
143+
);
144+
}
145+
146+
const handleClick = (event: React.SyntheticEvent) => {
147+
event.preventDefault();
148+
if (!vidRef.current) return;
149+
150+
if (isPlaying) {
151+
setIsPlaying(false);
152+
void vidRef.current.pause();
153+
} else {
154+
setIsPlaying(true);
155+
void vidRef.current.play();
156+
}
157+
};
158+
159+
const onError = () => {
160+
window.guardian.modules.sentry.reportError(
161+
new Error(`Loop video could not be played. source: ${src}`),
162+
'loop-video',
163+
);
164+
log('dotcom', `Loop video could not be played. source: ${src}`);
165+
};
166+
167+
const seekForward = () => {
168+
if (vidRef.current) {
169+
const newTime = Math.min(
170+
vidRef.current.currentTime + 1,
171+
vidRef.current.duration,
172+
);
173+
174+
vidRef.current.currentTime = newTime;
175+
setElapsedTime(newTime);
176+
}
177+
};
178+
179+
const seekBackward = () => {
180+
if (vidRef.current) {
181+
// Allow the user to cycle to the end of the video using the arrow keys
182+
const newTime =
183+
(((vidRef.current.currentTime - 1) % vidRef.current.duration) +
184+
vidRef.current.duration) %
185+
vidRef.current.duration;
186+
187+
vidRef.current.currentTime = newTime;
188+
setElapsedTime(newTime);
189+
}
190+
};
191+
192+
const handleKeyDown = (
193+
event: React.KeyboardEvent<HTMLDivElement>,
194+
): void => {
195+
switch (event.key) {
196+
case 'Enter':
197+
case ' ':
198+
handleClick(event);
199+
break;
200+
case 'ArrowRight':
201+
seekForward();
202+
break;
203+
case 'ArrowLeft':
204+
seekBackward();
205+
break;
206+
}
207+
};
208+
209+
const AudioIcon = isMuted ? SvgAudioMute : SvgAudio;
210+
211+
return (
212+
<div
213+
className="loop-video-container"
214+
ref={setNode}
215+
onClick={handleClick}
216+
onKeyDown={handleKeyDown}
217+
role="button"
218+
tabIndex={0}
219+
css={videoContainerStyles}
220+
>
221+
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- Captions will be considered later. */}
222+
<video
223+
id={`loop-video-${videoId}`}
224+
ref={vidRef}
225+
preload="none"
226+
loop={true}
227+
muted={isMuted}
228+
playsInline={true}
229+
poster={posterImage}
230+
height={height}
231+
width={width}
232+
onPlaying={() => {
233+
setIsPlaying(true);
234+
}}
235+
onCanPlay={() => {
236+
setIsPlayable(true);
237+
}}
238+
onError={onError}
239+
css={videoStyles}
240+
>
241+
{/* Ensure webm source is provided */}
242+
{/* <source src={webmSrc} type="video/webm"> */}
243+
<source src={src} type="video/mp4" />
244+
<CardPicture
245+
mainImage={posterImage}
246+
imageSize={imageSize}
247+
alt={altText}
248+
loading={imageLoading}
249+
aspectRatio={aspectRatio}
250+
/>
251+
</video>
252+
{vidRef.current && (
253+
<>
254+
{isPlayable && !isPlaying && (
255+
<div css={playIconStyles}>
256+
<PlayIcon iconWidth="narrow" />
257+
</div>
258+
)}
259+
<LoopVideoProgressBar
260+
currentTime={elapsedTime}
261+
duration={vidRef.current.duration}
262+
/>
263+
{hasAudio && (
264+
<button
265+
type="button"
266+
onClick={(event) => {
267+
event.stopPropagation(); // Don't pause the video
268+
setIsMuted(!isMuted);
269+
}}
270+
css={audioButtonStyles}
271+
>
272+
<AudioIcon
273+
size="small"
274+
theme={{
275+
fill: palette('--loop-video-audio-icon'),
276+
}}
277+
/>
278+
</button>
279+
)}
280+
</>
281+
)}
282+
</div>
283+
);
284+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { centreColumnDecorator } from '../../.storybook/decorators/gridDecorators';
3+
import { LoopVideo } from './LoopVideo.importable';
4+
5+
export default {
6+
component: LoopVideo,
7+
title: 'Components/LoopVideo',
8+
decorators: [centreColumnDecorator],
9+
render: (args) => <LoopVideo {...args} />,
10+
} satisfies Meta<typeof LoopVideo>;
11+
12+
export const Default = {
13+
name: 'Default',
14+
args: {
15+
src: 'https://uploads.guim.co.uk/2024/10/01/241001HeleneLoop_2.mp4',
16+
posterImage:
17+
'https://i.guim.co.uk/img/media/13dd7e5c4ca32a53cd22dfd90ac1845ef5e5d643/91_0_1800_1080/master/1800.jpg?width=465&dpr=1&s=none&crop=5%3A4',
18+
imageSize: 'large',
19+
imageLoading: 'eager',
20+
},
21+
} satisfies StoryObj<typeof LoopVideo>;
22+
23+
export const WithouAudio = {
24+
name: 'Without Audio',
25+
args: {
26+
...Default.args,
27+
hasAudio: false,
28+
},
29+
} satisfies StoryObj<typeof LoopVideo>;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { css } from '@emotion/react';
2+
import { palette } from '../palette';
3+
4+
const styles = css`
5+
position: absolute;
6+
bottom: 0;
7+
left: 0;
8+
height: 7px;
9+
width: 100%;
10+
11+
progress {
12+
display: block;
13+
width: 100%;
14+
height: 100%;
15+
border: none;
16+
-moz-border-radius: 0;
17+
-webkit-border-radius: 0;
18+
border-radius: 0;
19+
}
20+
21+
/* background: */
22+
progress::-webkit-progress-bar {
23+
background-color: transparent;
24+
}
25+
progress {
26+
background-color: transparent;
27+
}
28+
29+
/* value: */
30+
progress::-webkit-progress-value {
31+
background-color: ${palette('--loop-video-progress-bar-value')};
32+
}
33+
progress::-moz-progress-bar {
34+
background-color: ${palette('--loop-video-progress-bar-value')};
35+
}
36+
progress {
37+
color: ${palette('--loop-video-progress-bar-value')};
38+
}
39+
`;
40+
41+
type Props = {
42+
currentTime: number;
43+
duration: number;
44+
};
45+
46+
export const LoopVideoProgressBar = ({ currentTime, duration }: Props) => {
47+
if (duration <= 0) return null;
48+
49+
const progressPercentage =
50+
duration > 0 ? (currentTime * 100) / duration : 0;
51+
52+
return (
53+
<div css={styles} className="progress-bar">
54+
<progress value={progressPercentage / 100} />
55+
</div>
56+
);
57+
};

dotcom-rendering/src/lib/getZIndex.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ const indices = [
8585
'bodyArea',
8686
'rightColumnArea',
8787

88+
// Loop video container
89+
'loop-video-container',
90+
8891
// Main media
8992
'mainMedia',
9093

0 commit comments

Comments
 (0)