react-use-precision-timer
react-use-precision-timer copied to clipboard
Set `delay` on a stopped timer after its creation
I'm currently creating a Slideshow
component, which has access to an array of URLs and a currentIndex
in state, as well as a Slide
component with the props src
, durationSeconds
and onEnded
. The value of src
is essentially urls[currentIndex]
, and onEnded
is a callback function which increments that index, causing a new slide to render.
If src
is an image, I want to set a timer for durationSeconds * 1000
, then call onEnded
. Easily done.
If src
is a video, I want the following behavior:
- Play the video to completion at least once, regardless of its duration.
- Keep playing the video to completion as many times as is necessary to satisfy
durationSeconds
.
If durationSeconds
is 10 and the video's duration is 3 seconds, I want to play it to completion 4 times (12 seconds), then call onEnded
.
The Slideshow
has its own timer (total slideshow duration), and the Slide
component has its own timer (slide duration).
The Slideshow
component has isPaused
in its state, which can be toggled with either a button or hotkey. If the value is set to true
, I want to pause both the Slideshow
timer and Slide
timer, as well as the video in Slide
(if it is a video).
My issue is that my calculation for the slide duration for a video relies on its metadata, which is inherently not very reliable, so I need to wait for it to become available before I know how long to loop the video for. I set this value in Slide
's state as totalSlideDurationMilliseconds
as outlined above (essentially ((Math.ceil(durationSeconds / video.duration) * video.duration) - video.currentTime) * 1000
).
Currently, I'm creating a stopped timer with a delay of 0 (stopwatch), then waiting for totalSlideDurationMilliseconds
to have a value, then starting the timer and checking if timer.getElapsedRunningTime()
is greater than or equal to totalSlideDurationMilliseconds
in a 25ms interval (which necessitates a trade-off between precision and performance):
Messy WIP code
function getRequiredPlayingTime(
video: HTMLVideoElement,
durationSeconds: number
): number {
return (
(Math.ceil(durationSeconds / video.duration) * video.duration -
video.currentTime) *
1000
);
}
export default function Slide({ src, durationSeconds, onEnded }: SlideProps) {
const timer = useTimer({ delay: 0, startImmediately: false });
const { isPaused } = useSlideshowStore();
const [totalSlideDurationMilliseconds, setTotalSlideDurationMilliseconds] =
useState<number>(0);
const mimeType = mime.getType(src);
const imageRef = useRef<HTMLImageElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
if (src === '') {
return;
}
if (imageRef.current) {
setTotalSlideDurationMilliseconds(durationSeconds * 1000);
}
const video = videoRef.current;
if (video) {
if (!isNaN(video.duration)) {
setTotalSlideDurationMilliseconds(
getRequiredPlayingTime(video, durationSeconds)
);
} else {
video.addEventListener(
'loadedmetadata',
() => {
setTotalSlideDurationMilliseconds(
getRequiredPlayingTime(video, durationSeconds)
);
},
{ once: true }
);
return () => {
timer.stop();
setTotalSlideDurationMilliseconds(0);
};
}
}
return () => {
timer.stop();
setTotalSlideDurationMilliseconds(0);
};
}, [src]);
useEffect(() => {
if (totalSlideDurationMilliseconds === 0) {
return;
}
timer.start();
const checkElapsedTime = () => {
const currentTime = timer.getElapsedRunningTime();
console.log(`timer stopped: ${timer.isStopped()}`);
console.log(
`${currentTime / 1000}/${totalSlideDurationMilliseconds / 1000}sec`
);
if (currentTime >= totalSlideDurationMilliseconds) {
onEnded();
}
};
const interval = setInterval(checkElapsedTime, 10);
return () => {
clearInterval(interval);
timer.stop();
};
}, [src, totalSlideDurationMilliseconds]);
useEffect(() => {
const video = videoRef.current;
if (isPaused) {
video?.pause();
timer.pause();
} else {
video?.play().catch(() => {});
timer.resume();
}
}, [isPaused]);
return (
{/* ... */}
);
(if there is a better way of doing this then please do let me know)
I'd ideally like to be able to create a stopped timer (or delay) at the top level of Slide
, then update its delay
once I know the required duration, then start it. Something like:
"Ideal" code
// ...
export default function Slide({ src, durationSeconds, onEnded }: SlideProps) {
const timer = useTimer({ delay: 0, startImmediately: false }, onEnded); // <- Callback added
// ...
useEffect(() => {
// Set totalSlideDurationMilliseconds when src changes
}, [src]);
useEffect(() => {
if (totalSlideDurationMilliseconds === 0) {
return;
}
timer.setDelay(totalSlideDurationMilliseconds); // <- Set the delay after creating the timer
timer.start();
}, [src, totalSlideDurationMilliseconds]);
useEffect(() => {
// Pause timer/video if isPaused
}, [isPaused]);
return (
{/* ... */}
);
}
I've edited the code because it had a subtle bug where totalSlideDurationMilliseconds
for the previous and current slide being identical prevented the hook where I start the timer from firing, causing it to stay on the same slide indefinitely. I believe I've fixed that bug now.