Last active
March 17, 2025 10:30
-
-
Save librz/30c3e99a2edcfb6d5ed8f34b1038eaf9 to your computer and use it in GitHub Desktop.
A react component to display partial video content
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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