Skip to content

Resume looping video when re-enters viewport #14133

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dotcom-rendering/src/components/Lazy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div ref={setRef} css={!disableFlexStyles && flexGrowStyles}>
{renderChildren && <>{children}</>}
Expand Down
104 changes: 77 additions & 27 deletions dotcom-rendering/src/components/LoopVideo.importable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -20,7 +21,6 @@ type Props = {
height: number;
thumbnailImage: string;
fallbackImageComponent: JSX.Element;
hasAudio?: boolean;
};

export const LoopVideo = ({
Expand All @@ -30,16 +30,21 @@ export const LoopVideo = ({
height,
thumbnailImage,
fallbackImageComponent,
hasAudio = true,
}: Props) => {
const adapted = useShouldAdapt();
const { renderingTarget } = useConfig();
const vidRef = useRef<HTMLVideoElement>(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.
Expand All @@ -52,51 +57,83 @@ 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;

if (adapted) return fallbackImageComponent;

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();
}
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to grab the image if the video is not in view?

Copy link
Contributor Author

@domlander domlander Jun 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this situation, where the second card has a blank space in the place of the video:
image

A little confusing, as isInView means is at least 50% in view. I wonder if there's a better name for this variable

? thumbnailImage
: undefined;

const showPlayIcon =
playerState === 'PAUSED_BY_USER' ||
(!!prefersReducedMotion && playerState === 'NOT_STARTED');

const shouldPreloadData = !!isInView || prefersReducedMotion === false;

return (
<div
ref={setNode}
Expand All @@ -177,24 +228,23 @@ export const LoopVideo = ({
videoId={videoId}
width={width}
height={height}
hasAudio={hasAudio}
posterImage={posterImage}
fallbackImageComponent={fallbackImageComponent}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
ref={vidRef}
isPlayable={isPlayable}
setIsPlayable={setIsPlayable}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
playerState={playerState}
setPlayerState={setPlayerState}
isMuted={isMuted}
setIsMuted={setIsMuted}
handleClick={handleClick}
handleKeyDown={handleKeyDown}
onError={onError}
AudioIcon={AudioIcon}
thumbnailImage={
prefersReducedMotion ? thumbnailImage : undefined
}
shouldPreload={shouldPreloadData}
showPlayIcon={showPlayIcon}
/>
</div>
);
Expand Down
89 changes: 47 additions & 42 deletions dotcom-rendering/src/components/LoopVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like this type, it makes the intent of the loop video very easy to follow.

type Props = {
src: string;
videoId: string;
width: number;
height: number;
hasAudio: boolean;
fallbackImageComponent: JSX.Element;
isPlayable: boolean;
setIsPlayable: Dispatch<SetStateAction<boolean>>;
isPlaying: boolean;
setIsPlaying: Dispatch<SetStateAction<boolean>>;
playerState: (typeof PLAYER_STATES)[number];
setPlayerState: Dispatch<SetStateAction<(typeof PLAYER_STATES)[number]>>;
currentTime: number;
setCurrentTime: Dispatch<SetStateAction<number>>;
isMuted: boolean;
Expand All @@ -68,12 +76,9 @@ type Props = {
handleKeyDown: (event: React.KeyboardEvent<HTMLVideoElement>) => void;
onError: (event: SyntheticEvent<HTMLVideoElement>) => 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;
};

/**
Expand All @@ -87,13 +92,12 @@ export const LoopVideoPlayer = forwardRef(
videoId,
width,
height,
hasAudio,
fallbackImageComponent,
thumbnailImage,
posterImage,
isPlayable,
setIsPlayable,
isPlaying,
setIsPlaying,
playerState,
setPlayerState,
currentTime,
setCurrentTime,
isMuted,
Expand All @@ -102,6 +106,8 @@ export const LoopVideoPlayer = forwardRef(
handleKeyDown,
onError,
AudioIcon,
shouldPreload,
showPlayIcon,
}: Props,
ref: React.ForwardedRef<HTMLVideoElement>,
) => {
Expand All @@ -114,15 +120,15 @@ export const LoopVideoPlayer = forwardRef(
<video
id={loopVideoId}
ref={ref}
preload={thumbnailImage ? 'metadata' : 'none'}
preload={shouldPreload ? 'metadata' : 'none'}
loop={true}
muted={isMuted}
playsInline={true}
height={height}
width={width}
poster={thumbnailImage ?? undefined}
poster={posterImage}
onPlaying={() => {
setIsPlaying(true);
setPlayerState('PLAYING');
}}
onCanPlay={() => {
setIsPlayable(true);
Expand All @@ -132,7 +138,7 @@ export const LoopVideoPlayer = forwardRef(
ref &&
'current' in ref &&
ref.current &&
isPlaying
playerState === 'PLAYING'
) {
setCurrentTime(ref.current.currentTime);
}
Expand All @@ -144,15 +150,14 @@ export const LoopVideoPlayer = forwardRef(
onError={onError}
css={videoStyles(width, height)}
>
{/* Ensure webm source is provided. Encoding the video to a webm file will improve
performance on supported browsers. https://web.dev/articles/video-and-source-tags */}
{/* <source src={webmSrc} type="video/webm"> */}
{/* Only mp4 is currently supported. Assumes the video file type is mp4. */}
<source src={src} type="video/mp4" />
{fallbackImageComponent}
</video>
{ref && 'current' in ref && ref.current && isPlayable && (
<>
{!isPlaying && (
{/* Play icon */}
{showPlayIcon && (
<button
type="button"
onClick={handleClick}
Expand All @@ -161,32 +166,32 @@ export const LoopVideoPlayer = forwardRef(
<PlayIcon iconWidth="narrow" />
</button>
)}
{/* Progress bar */}
<LoopVideoProgressBar
videoId={loopVideoId}
currentTime={currentTime}
duration={ref.current.duration}
/>
{hasAudio && (
<button
type="button"
onClick={(event) => {
event.stopPropagation(); // Don't pause the video
setIsMuted(!isMuted);
}}
css={audioButtonStyles}
>
<div css={audioIconContainerStyles}>
<AudioIcon
size="xsmall"
theme={{
fill: palette(
'--loop-video-audio-icon',
),
}}
/>
</div>
</button>
)}
{/* Audio icon */}
<button
type="button"
onClick={(event) => {
event.stopPropagation(); // Don't pause the video
setIsMuted(!isMuted);
}}
css={audioButtonStyles}
>
<div css={audioIconContainerStyles}>
<AudioIcon
size="xsmall"
theme={{
fill: palette(
'--loop-video-audio-icon',
),
}}
/>
</div>
</button>
</>
)}
</>
Expand Down
7 changes: 5 additions & 2 deletions dotcom-rendering/src/lib/useIsInView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ type Options = {
*/
const useIsInView = (
options: IntersectionObserverInit & Options,
): [boolean, React.Dispatch<React.SetStateAction<HTMLElement | null>>] => {
const [isInView, setIsInView] = useState<boolean>(false);
): [
boolean | null,
React.Dispatch<React.SetStateAction<HTMLElement | null>>,
] => {
const [isInView, setIsInView] = useState<boolean | null>(null);
const [node, setNode] = useState<HTMLElement | null>(options.node ?? null);

const observer = useRef<IntersectionObserver | null>(null);
Expand Down