Skip to content

Commit 88db1a5

Browse files
authored
Merge pull request #13811 from guardian/doml/loop-video
Loop video component
2 parents ecf3982 + 1da32c6 commit 88db1a5

File tree

9 files changed

+464
-11
lines changed

9 files changed

+464
-11
lines changed

dotcom-rendering/src/components/Card/components/PlayIcon.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,8 @@ const wideIconStyles = (
4343
}
4444
`;
4545

46-
const narrowPlayIconWidth = 56;
46+
export const narrowPlayIconWidth = 56;
4747
const narrowStyles = css`
48-
position: absolute;
49-
/**
50-
* Subject to change. We will wait to see how fronts editors use the
51-
* headlines and standfirsts before we decide on a final position.
52-
*/
53-
top: 35%;
54-
left: calc(50% - ${narrowPlayIconWidth / 2}px);
5548
width: ${narrowPlayIconWidth}px;
5649
height: ${narrowPlayIconWidth}px;
5750
display: flex;
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { css } from '@emotion/react';
2+
import { log } from '@guardian/libs';
3+
import { SvgAudio, SvgAudioMute } from '@guardian/source/react-components';
4+
import { useEffect, useRef, useState } from 'react';
5+
import { getZIndex } from '../lib/getZIndex';
6+
import { useIsInView } from '../lib/useIsInView';
7+
import { useShouldAdapt } from '../lib/useShouldAdapt';
8+
import { useConfig } from './ConfigContext';
9+
import { LoopVideoPlayer } from './LoopVideoPlayer';
10+
11+
const videoContainerStyles = (height: number, width: number) => css`
12+
z-index: ${getZIndex('loop-video-container')};
13+
position: relative;
14+
height: ${height}px;
15+
width: ${width}px;
16+
`;
17+
18+
type Props = {
19+
src: string;
20+
videoId: string;
21+
width?: number;
22+
height?: number;
23+
hasAudio?: boolean;
24+
fallbackImage: JSX.Element;
25+
};
26+
27+
export const LoopVideo = ({
28+
src,
29+
videoId,
30+
width = 600,
31+
height = 360,
32+
hasAudio = true,
33+
fallbackImage,
34+
}: Props) => {
35+
const adapted = useShouldAdapt();
36+
const { renderingTarget } = useConfig();
37+
const vidRef = useRef<HTMLVideoElement>(null);
38+
const [isPlayable, setIsPlayable] = useState(false);
39+
const [isPlaying, setIsPlaying] = useState(false);
40+
const [isMuted, setIsMuted] = useState(true);
41+
const [currentTime, setCurrentTime] = useState(0);
42+
/**
43+
* Keep a track of whether the video has been in view. We only want to
44+
* pause the video if it has been in view.
45+
*/
46+
const [hasBeenInView, setHasBeenInView] = useState(false);
47+
48+
const [isInView, setNode] = useIsInView({
49+
repeat: true,
50+
threshold: 0.5,
51+
});
52+
53+
/**
54+
* Pause the video when the user scrolls past it.
55+
*/
56+
useEffect(() => {
57+
if (!vidRef.current) return;
58+
59+
if (isInView) {
60+
if (!hasBeenInView) {
61+
// When the video first comes into view, it should autoplay
62+
setIsPlaying(true);
63+
void vidRef.current.play();
64+
}
65+
setHasBeenInView(true);
66+
}
67+
68+
if (!isInView && hasBeenInView && isPlayable && isPlaying) {
69+
setIsPlaying(false);
70+
void vidRef.current.pause();
71+
}
72+
}, [isInView, hasBeenInView, isPlayable, isPlaying]);
73+
74+
if (renderingTarget !== 'Web') return null;
75+
76+
if (adapted) return fallbackImage;
77+
78+
const handleClick = (event: React.SyntheticEvent) => {
79+
event.preventDefault();
80+
if (!vidRef.current) return;
81+
82+
if (isPlaying) {
83+
setIsPlaying(false);
84+
void vidRef.current.pause();
85+
} else {
86+
setIsPlaying(true);
87+
void vidRef.current.play();
88+
}
89+
};
90+
91+
const onError = () => {
92+
window.guardian.modules.sentry.reportError(
93+
new Error(`Loop video could not be played. source: ${src}`),
94+
'loop-video',
95+
);
96+
log('dotcom', `Loop video could not be played. source: ${src}`);
97+
};
98+
99+
const seekForward = () => {
100+
if (vidRef.current) {
101+
const newTime = Math.min(
102+
vidRef.current.currentTime + 1,
103+
vidRef.current.duration,
104+
);
105+
106+
vidRef.current.currentTime = newTime;
107+
setCurrentTime(newTime);
108+
}
109+
};
110+
111+
const seekBackward = () => {
112+
if (vidRef.current) {
113+
// Allow the user to cycle to the end of the video using the arrow keys
114+
const newTime =
115+
(((vidRef.current.currentTime - 1) % vidRef.current.duration) +
116+
vidRef.current.duration) %
117+
vidRef.current.duration;
118+
119+
vidRef.current.currentTime = newTime;
120+
setCurrentTime(newTime);
121+
}
122+
};
123+
124+
const handleKeyDown = (
125+
event: React.KeyboardEvent<HTMLVideoElement>,
126+
): void => {
127+
switch (event.key) {
128+
case 'Enter':
129+
case ' ':
130+
handleClick(event);
131+
break;
132+
case 'ArrowRight':
133+
seekForward();
134+
break;
135+
case 'ArrowLeft':
136+
seekBackward();
137+
break;
138+
}
139+
};
140+
141+
const AudioIcon = isMuted ? SvgAudioMute : SvgAudio;
142+
143+
return (
144+
<div
145+
className="loop-video-container"
146+
ref={setNode}
147+
css={videoContainerStyles(height, width)}
148+
>
149+
<LoopVideoPlayer
150+
src={src}
151+
videoId={videoId}
152+
width={width}
153+
height={height}
154+
hasAudio={hasAudio}
155+
fallbackImage={fallbackImage}
156+
currentTime={currentTime}
157+
setCurrentTime={setCurrentTime}
158+
ref={vidRef}
159+
isPlayable={isPlayable}
160+
setIsPlayable={setIsPlayable}
161+
isPlaying={isPlaying}
162+
setIsPlaying={setIsPlaying}
163+
isMuted={isMuted}
164+
setIsMuted={setIsMuted}
165+
handleClick={handleClick}
166+
handleKeyDown={handleKeyDown}
167+
onError={onError}
168+
AudioIcon={AudioIcon}
169+
/>
170+
</div>
171+
);
172+
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { breakpoints } from '@guardian/source/foundations';
2+
import type { Meta, StoryObj } from '@storybook/react';
3+
import { centreColumnDecorator } from '../../.storybook/decorators/gridDecorators';
4+
import { CardPicture } from './CardPicture';
5+
import { LoopVideo } from './LoopVideo.importable';
6+
7+
export default {
8+
component: LoopVideo,
9+
title: 'Components/LoopVideo',
10+
decorators: [centreColumnDecorator],
11+
render: (args) => <LoopVideo {...args} />,
12+
parameters: {
13+
chromatic: {
14+
viewports: [breakpoints.mobile, breakpoints.wide],
15+
},
16+
},
17+
} satisfies Meta<typeof LoopVideo>;
18+
19+
export const Default = {
20+
name: 'Default',
21+
args: {
22+
src: 'https://uploads.guim.co.uk/2024/10/01/241001HeleneLoop_2.mp4',
23+
videoId: 'test-video-1',
24+
height: 337.5,
25+
width: 600,
26+
fallbackImage: (
27+
<CardPicture
28+
mainImage="https://i.guim.co.uk/img/media/13dd7e5c4ca32a53cd22dfd90ac1845ef5e5d643/91_0_1800_1080/master/1800.jpg?width=465&dpr=1&s=none&crop=5%3A4"
29+
imageSize="large"
30+
loading="eager"
31+
/>
32+
),
33+
},
34+
} satisfies StoryObj<typeof LoopVideo>;
35+
36+
export const WithoutAudio = {
37+
name: 'Without Audio',
38+
args: {
39+
...Default.args,
40+
hasAudio: false,
41+
},
42+
} satisfies StoryObj<typeof LoopVideo>;

0 commit comments

Comments
 (0)