Commit 8f50ac4a by Moorthy G

refactor(shared/responsive-video): update the underlying component logic, implement short medias

parent b7294f21
import type { Meta, StoryObj } from '@storybook/nextjs';
import ResponsiveVideoComponent from './ResponsiveVideo';
import { ResponsiveVideo as ResponsiveVideoComponent } from './ResponsiveVideo';
const meta: Meta<typeof ResponsiveVideoComponent> = {
title: 'Shared/ResponsiveVideo',
......@@ -14,22 +14,32 @@ export default meta;
type Story = StoryObj<typeof meta>;
const autoPlayFallback = (
const fallback = (
<h3 className="font-bold text-lg text-light bg-primary px-4 py-2">
Auto play failed. I am a fallback element
</h3>
);
const noSrcFallback = (
<div className="font-bold text-lg text-light bg-primary px-4 py-2">
No video source available. I am a fallback element
</div>
);
export const ResponsiveVideo: Story = {
args: {
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4',
srcSet: [
{
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
media: '(min-width: 640px)'
media: 'md'
},
{
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4',
media: '(min-width: 900px)'
}
],
autoPlayFallback,
fallback,
autoPlay: true,
controls: true,
muted: true
......@@ -40,6 +50,12 @@ export const AutoPlayFailed: Story = {
args: {
src: 'broken.mp4',
autoPlay: true,
autoPlayFallback
fallback
}
};
export const NoSrcWithFallback: Story = {
args: {
fallback: noSrcFallback
}
};
'use client';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import breakpoints from './breakpoints';
export interface ResponsiveVideoProps
extends React.VideoHTMLAttributes<HTMLVideoElement> {
src: string;
/** first match in the array will be selected
* media should be a valid media query or
* a key from breakpoints object
import React, { useCallback, useEffect, useRef, useState } from 'react';
import breakpoints, { type BreakpointKey } from './breakpoints';
export type IResponsiveVideoProps = React.VideoHTMLAttributes<HTMLVideoElement> & {
src?: string;
/**
* First match in the array will be selected.
* media should be a valid media query or a key from breakpoints object.
*/
srcSet?: {
src: string;
......@@ -16,83 +15,154 @@ export interface ResponsiveVideoProps
}[];
/** auto plays video source */
autoPlay?: boolean;
/** If autoplay fails and if there is a fallback,
* display this fallback element */
autoPlayFallback?: React.ReactNode;
}
const ResponsiveVideo = forwardRef<HTMLVideoElement, ResponsiveVideoProps>(({
src,
srcSet,
autoPlay,
autoPlayFallback,
...rest
}, ref) => {
/**
* If autoplay fails or src is not available and if there is a fallback,
* display this fallback element
*/
fallback?: React.ReactNode;
};
/**
* Resolves media query string from breakpoint key or returns the media query as-is.
* If media is a breakpoint key (e.g., 'md'), it returns the corresponding media query.
* Otherwise, it assumes media is already a valid media query string.
*/
const getMediaQuery = (media: string) =>
(media in breakpoints ? breakpoints[media as BreakpointKey] : null) || media;
export const ResponsiveVideo = React.forwardRef<
HTMLVideoElement,
IResponsiveVideoProps
>(({ src, srcSet, autoPlay, fallback, onError, ...rest }, ref) => {
const [isAutoPlayFailed, setIsAutoPlayFailed] = useState(false);
const [currentSrc, setCurrentSrc] = useState(src);
const [hasError, setHasError] = useState(false);
const [currentSrc, setCurrentSrc] = useState<string | undefined>(src);
const videoRef = useRef<HTMLVideoElement>(null);
const setRefs = (node: HTMLVideoElement | null) => {
videoRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLVideoElement | null>).current = node;
}
};
/**
* Ref callback that handles both function refs and object refs.
* We maintain an internal videoRef to access the video element for autoplay logic,
* while also forwarding the ref to the parent component.
*/
const setRefs = useCallback(
(node: HTMLVideoElement | null) => {
videoRef.current = node;
if (!ref) return;
if (typeof ref === 'function') {
ref(node);
} else {
(ref as React.RefObject<HTMLVideoElement | null>).current = node;
}
},
[ref]
);
useEffect(() => {
const handleResize = () => {
/** find the set that matches current media query */
const matchedSet = srcSet?.find(
({ media }) => window.matchMedia(breakpoints[media] || media).matches
);
/* set the current src to the matched set or the default src */
// SSR safety: window is not available during server-side rendering
if (typeof window === 'undefined') {
setCurrentSrc(src);
return undefined;
}
// If no srcSet provided, use default src
if (!srcSet?.length) {
setCurrentSrc(src);
return undefined;
}
// Create MediaQueryList listeners for each media query in srcSet.
// Using MediaQueryList listeners instead of window resize events is more performant
// as it only fires when the specific media query state changes, not on every resize.
const mediaQueries = srcSet.map(({ media, src: source }) => {
const query = getMediaQuery(media);
const mediaQueryList = window.matchMedia(query);
const handleChange = (event: MediaQueryListEvent) => {
// Only update source when the media query becomes active (matches = true)
if (event.matches) {
setCurrentSrc(source);
}
};
mediaQueryList.addEventListener('change', handleChange);
return { mediaQueryList, handleChange };
});
// On mount, find the first matching media query to set initial source.
// This ensures the correct video source is selected immediately, not waiting for a resize event.
const selectSource = () => {
const matchedSet = srcSet.find(({ media }) => {
const query = getMediaQuery(media);
return window.matchMedia(query).matches;
});
setCurrentSrc(matchedSet?.src || src);
};
/* call handleResize on mount */
handleResize();
/* add resize event listener to window */
window.addEventListener('resize', handleResize);
/* remove event listener on unmount */
return () => window.removeEventListener('resize', handleResize);
selectSource();
// Cleanup: remove all event listeners to prevent memory leaks
return () =>
mediaQueries.forEach(({ mediaQueryList, handleChange }) =>
mediaQueryList.removeEventListener('change', handleChange)
);
}, [src, srcSet]);
useEffect(() => {
async function play() {
try {
/** If user prefers reduced motion,
* throw an error and stop autoplay
*/
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
if (prefersReducedMotion.matches) {
throw new Error('User prefers reduced motion');
} else {
/* if autoplay is true, play the video */
autoPlay && (await videoRef?.current?.play());
}
} catch (error: any) {
/* AbortError happens when source is changed in runtime,
if error is not AbortError, mark autoPlay as failed
*/
if (error.name !== 'AbortError') {
console.error(error);
/* if play failed, report that autoplay has failed */
setIsAutoPlayFailed(true);
}
if (!autoPlay) return;
const node = videoRef.current;
if (!node) return;
// Respect user's reduced motion preference for accessibility.
// If user prefers reduced motion, we skip autoplay and show fallback if available.
if (typeof window !== 'undefined') {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
if (prefersReducedMotion.matches) {
setIsAutoPlayFailed(true);
return;
}
}
play();
// Attempt to play the video. AbortError occurs when the video source changes
// while a play() promise is pending (e.g., during responsive source switching).
// We ignore AbortError as it's expected behavior, not a real failure.
node.play().catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
setIsAutoPlayFailed(true);
});
}, [autoPlay, currentSrc]);
return isAutoPlayFailed && autoPlayFallback ? (
autoPlayFallback
) : (
<video ref={setRefs} src={currentSrc} {...rest} />
const handleError = (event: React.SyntheticEvent<HTMLVideoElement, Event>) => {
setHasError(true);
// Forward the error event to parent component's onError handler if provided
onError?.(event);
};
// Show fallback if: autoplay failed, video load error occurred, or no source available.
// Fallback is only shown if it's provided as a prop.
const shouldShowFallback = (isAutoPlayFailed || hasError || !currentSrc) && fallback;
if (shouldShowFallback) {
return fallback;
}
if (!currentSrc) {
return null;
}
return (
<video
ref={setRefs}
src={currentSrc}
onError={handleError}
{...rest}
/>
);
});
export default ResponsiveVideo;
ResponsiveVideo.displayName = 'ResponsiveVideo';
type Breakpoints = {
[key: string]: string;
};
const breakpoints: Breakpoints = {
const breakpoints = {
sm: '(min-width: 640px)',
md: '(min-width: 768px)',
lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
xxl: '(min-width: 1536px)'
};
} as const;
export type BreakpointKey = keyof typeof breakpoints;
export default breakpoints;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment