diff --git a/dotcom-rendering/src/components/Lazy.tsx b/dotcom-rendering/src/components/Lazy.tsx
index ae94575141f..e5c347a2394 100644
--- a/dotcom-rendering/src/components/Lazy.tsx
+++ b/dotcom-rendering/src/components/Lazy.tsx
@@ -30,7 +30,7 @@ export const Lazy = ({ children, margin, disableFlexStyles }: Props) => {
// being loaded as part of a Chromatic story or not so that
// we can prevent lazy loading our storybook snapshots that we
// use for visual regression
- const renderChildren = hasBeenSeen || Lazy.disabled;
+ const renderChildren = !!hasBeenSeen || Lazy.disabled;
return (
{renderChildren && <>{children}>}
diff --git a/dotcom-rendering/src/components/LoopVideo.importable.tsx b/dotcom-rendering/src/components/LoopVideo.importable.tsx
index 7d9f966ac67..5969f41d969 100644
--- a/dotcom-rendering/src/components/LoopVideo.importable.tsx
+++ b/dotcom-rendering/src/components/LoopVideo.importable.tsx
@@ -6,6 +6,7 @@ import { getZIndex } from '../lib/getZIndex';
import { useIsInView } from '../lib/useIsInView';
import { useShouldAdapt } from '../lib/useShouldAdapt';
import { useConfig } from './ConfigContext';
+import type { PLAYER_STATES } from './LoopVideoPlayer';
import { LoopVideoPlayer } from './LoopVideoPlayer';
const videoContainerStyles = css`
@@ -20,7 +21,6 @@ type Props = {
height: number;
thumbnailImage: string;
fallbackImageComponent: JSX.Element;
- hasAudio?: boolean;
};
export const LoopVideo = ({
@@ -30,16 +30,21 @@ export const LoopVideo = ({
height,
thumbnailImage,
fallbackImageComponent,
- hasAudio = true,
}: Props) => {
const adapted = useShouldAdapt();
const { renderingTarget } = useConfig();
const vidRef = useRef(null);
const [isPlayable, setIsPlayable] = useState(false);
- const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(true);
const [currentTime, setCurrentTime] = useState(0);
- const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+ const [playerState, setPlayerState] =
+ useState<(typeof PLAYER_STATES)[number]>('NOT_STARTED');
+
+ // The user indicates a preference for reduced motion: https://web.dev/articles/prefers-reduced-motion
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState<
+ boolean | null
+ >(null);
+
/**
* Keep a track of whether the video has been in view. We only want to
* pause the video if it has been in view.
@@ -52,31 +57,58 @@ export const LoopVideo = ({
});
/**
- * Pause the video when the user scrolls past it.
+ * Register the users motion preferences.
*/
useEffect(() => {
- if (!vidRef.current) return;
+ const userPrefersReducedMotion = window.matchMedia(
+ '(prefers-reduced-motion: reduce)',
+ ).matches;
+ setPrefersReducedMotion(userPrefersReducedMotion);
+ }, []);
- if (isInView) {
- // We only autoplay the first time the video comes into view.
- if (hasBeenInView) return;
+ /**
+ * Autoplays the video when it comes into view.
+ */
+ useEffect(() => {
+ if (!vidRef.current || playerState === 'PAUSED_BY_USER') return;
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
- setPrefersReducedMotion(true);
+ if (isInView && isPlayable && playerState !== 'PLAYING') {
+ if (prefersReducedMotion !== false) {
return;
}
- setIsPlaying(true);
- void vidRef.current.play();
-
+ setPlayerState('PLAYING');
setHasBeenInView(true);
+
+ void vidRef.current.play();
}
+ }, [isInView, isPlayable, playerState, prefersReducedMotion]);
- if (!isInView && hasBeenInView && isPlayable && isPlaying) {
- setIsPlaying(false);
+ /**
+ * Stops playback when the video is scrolled out of view, resumes playbacks
+ * when the video is back in the viewport.
+ */
+ useEffect(() => {
+ if (!vidRef.current || !hasBeenInView) return;
+
+ const isNoLongerInView = playerState === 'PLAYING' && !isInView;
+ if (isNoLongerInView) {
+ setPlayerState('PAUSED_BY_INTERSECTION_OBSERVER');
void vidRef.current.pause();
+ setIsMuted(true);
+ }
+
+ // If a user action paused the video, they have indicated
+ // that they don't want to watch the video. Therefore, don't
+ // resume the video when it comes back in view
+ const isBackInView =
+ playerState === 'PAUSED_BY_INTERSECTION_OBSERVER' && isInView;
+ if (isBackInView) {
+ setPlayerState('PLAYING');
+
+ void vidRef.current.play();
}
- }, [isInView, hasBeenInView, isPlayable, isPlaying]);
+ }, [isInView, hasBeenInView, playerState]);
if (renderingTarget !== 'Web') return null;
@@ -84,19 +116,24 @@ export const LoopVideo = ({
const playVideo = () => {
if (!vidRef.current) return;
- setIsPlaying(true);
+
+ setPlayerState('PLAYING');
+ setHasBeenInView(true);
void vidRef.current.play();
};
const pauseVideo = () => {
if (!vidRef.current) return;
- setIsPlaying(false);
+
+ setPlayerState('PAUSED_BY_USER');
void vidRef.current.pause();
};
const playPauseVideo = () => {
- if (isPlaying) {
- pauseVideo();
+ if (playerState === 'PLAYING') {
+ if (isInView) {
+ pauseVideo();
+ }
} else {
playVideo();
}
@@ -166,6 +203,20 @@ export const LoopVideo = ({
const AudioIcon = isMuted ? SvgAudioMute : SvgAudio;
+ // We only show a poster image when the user has indicated that they do
+ // not want videos to play automatically, e.g. prefers reduced motion. Otherwise,
+ // we do not need to download the image as the video will be autoplayed.
+ const posterImage =
+ !!prefersReducedMotion || isInView === false
+ ? thumbnailImage
+ : undefined;
+
+ const showPlayIcon =
+ playerState === 'PAUSED_BY_USER' ||
+ (!!prefersReducedMotion && playerState === 'NOT_STARTED');
+
+ const shouldPreloadData = !!isInView || prefersReducedMotion === false;
+
return (
);
diff --git a/dotcom-rendering/src/components/LoopVideoPlayer.tsx b/dotcom-rendering/src/components/LoopVideoPlayer.tsx
index 6407eb148cf..043025aee3a 100644
--- a/dotcom-rendering/src/components/LoopVideoPlayer.tsx
+++ b/dotcom-rendering/src/components/LoopVideoPlayer.tsx
@@ -15,10 +15,12 @@ const videoStyles = (width: number, height: number) => css`
cursor: pointer;
/* Prevents CLS by letting the browser know the space the video will take up. */
aspect-ratio: ${width} / ${height};
+ object-fit: cover;
`;
const playIconStyles = css`
position: absolute;
+ /* Center the icon */
top: calc(50% - ${narrowPlayIconWidth / 2}px);
left: calc(50% - ${narrowPlayIconWidth / 2}px);
cursor: pointer;
@@ -49,17 +51,23 @@ const audioIconContainerStyles = css`
border: 1px solid ${palette('--loop-video-audio-icon-border')};
`;
+export const PLAYER_STATES = [
+ 'NOT_STARTED',
+ 'PLAYING',
+ 'PAUSED_BY_USER',
+ 'PAUSED_BY_INTERSECTION_OBSERVER',
+] as const;
+
type Props = {
src: string;
videoId: string;
width: number;
height: number;
- hasAudio: boolean;
fallbackImageComponent: JSX.Element;
isPlayable: boolean;
setIsPlayable: Dispatch>;
- isPlaying: boolean;
- setIsPlaying: Dispatch>;
+ playerState: (typeof PLAYER_STATES)[number];
+ setPlayerState: Dispatch>;
currentTime: number;
setCurrentTime: Dispatch>;
isMuted: boolean;
@@ -68,12 +76,9 @@ type Props = {
handleKeyDown: (event: React.KeyboardEvent) => void;
onError: (event: SyntheticEvent) => void;
AudioIcon: (iconProps: IconProps) => JSX.Element;
- /**
- * We ONLY show a thumbnail image when the user has indicated that they do
- * not want videos to play automatically, e.g. prefers reduced motion. Otherwise,
- * we do not bother downloading the image, as the video will be autoplayed.
- */
- thumbnailImage?: string;
+ posterImage?: string;
+ shouldPreload: boolean;
+ showPlayIcon: boolean;
};
/**
@@ -87,13 +92,12 @@ export const LoopVideoPlayer = forwardRef(
videoId,
width,
height,
- hasAudio,
fallbackImageComponent,
- thumbnailImage,
+ posterImage,
isPlayable,
setIsPlayable,
- isPlaying,
- setIsPlaying,
+ playerState,
+ setPlayerState,
currentTime,
setCurrentTime,
isMuted,
@@ -102,6 +106,8 @@ export const LoopVideoPlayer = forwardRef(
handleKeyDown,
onError,
AudioIcon,
+ shouldPreload,
+ showPlayIcon,
}: Props,
ref: React.ForwardedRef,
) => {
@@ -114,15 +120,15 @@ export const LoopVideoPlayer = forwardRef(
{ref && 'current' in ref && ref.current && isPlayable && (
<>
- {!isPlaying && (
+ {/* Play icon */}
+ {showPlayIcon && (
)}
+ {/* Progress bar */}
- {hasAudio && (
-
- )}
+ {/* Audio icon */}
+
>
)}
>
diff --git a/dotcom-rendering/src/lib/useIsInView.ts b/dotcom-rendering/src/lib/useIsInView.ts
index b5bc6c87b81..f9699834ff5 100644
--- a/dotcom-rendering/src/lib/useIsInView.ts
+++ b/dotcom-rendering/src/lib/useIsInView.ts
@@ -42,8 +42,11 @@ type Options = {
*/
const useIsInView = (
options: IntersectionObserverInit & Options,
-): [boolean, React.Dispatch>] => {
- const [isInView, setIsInView] = useState(false);
+): [
+ boolean | null,
+ React.Dispatch>,
+] => {
+ const [isInView, setIsInView] = useState(null);
const [node, setNode] = useState(options.node ?? null);
const observer = useRef(null);