react-use-precision-timer icon indicating copy to clipboard operation
react-use-precision-timer copied to clipboard

Set `delay` on a stopped timer after its creation

Open bradenhilton opened this issue 1 year ago • 0 comments

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.

bradenhilton avatar Jul 11 '23 10:07 bradenhilton