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);