Skip to content

Instantly share code, notes, and snippets.

@librz
Last active March 17, 2025 10:30
Show Gist options
  • Save librz/30c3e99a2edcfb6d5ed8f34b1038eaf9 to your computer and use it in GitHub Desktop.
Save librz/30c3e99a2edcfb6d5ed8f34b1038eaf9 to your computer and use it in GitHub Desktop.
A react component to display partial video content
import { PauseIcon, PlayIcon } from "Todo";
import { Button } from "Todo";
import * as Slider from "@radix-ui/react-slider";
import classNames from "classnames";
import throttle from "lodash.throttle";
import {
useCallback,
useEffect,
useRef,
useState,
type HTMLProps,
type ReactNode,
} from "react";
interface PartialVideoProps {
videoUrl: string;
/* unit is second */
startTime: (duration: number) => number;
/* unit is second */
endTime: (duration: number) => number;
videoTitle?: ReactNode;
containerProps?: HTMLProps<HTMLDivElement>;
videoProps?: HTMLProps<HTMLVideoElement>;
}
/**
* Display part of the video (percision: second)
*/
export function PartialVideo({
videoUrl,
startTime: startTimeSpecifier,
endTime: endTimeSpecifier,
videoTitle,
containerProps,
videoProps,
}: PartialVideoProps) {
const { className: containerClassname, ...otherContainerProps } =
containerProps ?? {};
const { loop, ...otherVideoProps } = videoProps ?? {};
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState<boolean>(Boolean(videoProps?.autoPlay));
/** duration of the whole video */
const [duration, setDuration] = useState<number | null>(null);
/* start & end time (always integer, unit is second) */
const startTime: number = duration
? Math.max(0, Math.floor(startTimeSpecifier(duration)))
: 0;
const endTime: number = duration
? Math.min(duration, Math.ceil(endTimeSpecifier(duration)))
: 0;
/** current time in terms of the whole video (always integer, unit is second) */
const [currentTime, setCurrentTimeUnperformant] = useState(startTime);
/** throttle to avoid excessive re-renders */
const setCurrentTimeThrottled = useCallback(
throttle((ts: number) => {
const floatPointCurrentTime = duration ? Math.min(duration, ts) : ts;
setCurrentTimeUnperformant(Math.floor(floatPointCurrentTime));
}, 500),
[duration],
);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.src = videoUrl;
const abortController = new AbortController();
const handleLoadedMetadata = () => {
if (video.duration) {
setDuration(video.duration);
video.currentTime = Math.max(0, startTimeSpecifier(video.duration));
} else {
// eslint-disable-next-line no-console
console.warn("Fail to load video duration");
}
};
const handleTimeUpdate = () => {
setCurrentTimeThrottled(video.currentTime);
// manual loop/pause
if (!duration || !endTime) {
return;
}
const exceedsEndTime = video.currentTime >= endTime;
if (!exceedsEndTime) return;
if (loop) {
video.currentTime = startTime;
setCurrentTimeThrottled(0);
void video.play();
} else {
video.pause();
}
};
video.addEventListener("loadedmetadata", handleLoadedMetadata, {
signal: abortController.signal,
});
video.addEventListener("timeupdate", handleTimeUpdate, {
signal: abortController.signal,
});
video.addEventListener("play", () => setPlaying(true), {
signal: abortController.signal,
});
video.addEventListener("pause", () => setPlaying(false), {
signal: abortController.signal,
});
return () => {
abortController.abort();
};
}, [videoUrl, duration, endTime]);
if (startTime > endTime) {
// eslint-disable-next-line no-console
console.error("cannot render video: startTime exceeds endTime");
return null;
}
return (
<div
className={classNames("flex flex-col bg-[#38393C]", containerClassname)}
{...otherContainerProps}
>
<video
ref={videoRef}
muted
loop={false}
controls={false}
{...otherVideoProps}
/>
{videoTitle && (
<div className={"mt-4 flex flex-row justify-center text-white"}>
{videoTitle}
</div>
)}
<div className={"mb-2 flex flex-row items-center gap-1 px-3"}>
<Button
variant={"text"}
className={`outline-none p-0 dark:text-[#D9E2FF]`}
type={"button"}
onPress={() => {
const video = videoRef.current;
if (!video) {
return;
}
if (playing) {
video.pause();
} else {
if (video.ended) {
// if reached end of whole video
// need to start at startTime
video.currentTime = startTime;
}
void video.play();
}
}}
>
{playing ? <PauseIcon size={24} /> : <PlayIcon size={24} />}
</Button>
<Slider.Root
className={"flex-grow relative flex h-5 touch-none select-none items-center"}
min={0}
max={endTime - startTime}
step={1}
value={[currentTime - startTime]}
onValueChange={(value) => {
const newTimestamp = startTime + (value[0] ?? 0);
if (videoRef.current) {
videoRef.current.currentTime = newTimestamp;
}
setCurrentTimeThrottled(newTimestamp);
}}
>
<Slider.Track className={`relative h-[4px] grow bg-[#004299]`}>
<Slider.Range className={`absolute h-full bg-[#D9E2FF]`} />
</Slider.Track>
<Slider.Thumb
className={"block size-4 rounded-[10px] outline-none bg-[#D9E2FF]"}
/>
</Slider.Root>
</div>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment