DEV Community

Mykhailo Toporkov 🇺🇦
Mykhailo Toporkov 🇺🇦

Posted on

useVideo - Custom hook for interacting with video

Right now, hooks are an essential part of React, and I bet you’ve used some custom hooks at least once to handle logic. Recently, I needed to create a custom video player, and to handle the video I came up with this custom useVideo hook:

import type { RefObject } from "react";
import { useRef, useState, useEffect, useCallback } from "react";

type VideoState = {
  volume: number;
  duration: number;
  isMuted: boolean;
  isEnded: boolean;
  isLoading: boolean;
  isStarted: boolean;
  isPlaying: boolean;
  isSeeking: boolean;
  currentTime: number;
  isBuffering: boolean;
  error: MediaError | null;
};

type UseVideoReturn = VideoState & {
  ref: RefObject<HTMLVideoElement | null>;
  mute: () => void;
  play: () => void;
  pause: () => void;
  unmute: () => void;
  restart: () => void;
  toggleMute: () => void;
  togglePlay: () => void;
  seek: (time: number) => void;
  skip: (seconds: number) => void;
  setVolume: (volume: number) => void;
  setPlaybackRate: (rate: number) => void;
};

const initialState: VideoState = {
  volume: 1,
  error: null,
  duration: 0,
  currentTime: 0,
  isMuted: false,
  isEnded: false,
  isLoading: false,
  isStarted: false,
  isPlaying: false,
  isSeeking: false,
  isBuffering: false,
};

export function useVideo(): UseVideoReturn {
  const ref = useRef<HTMLVideoElement>(null);
  const [state, setState] = useState<VideoState>(initialState);

  const updateState = useCallback((update: Partial<VideoState>) => setState((prev) => ({ ...prev, ...update })), []);

  useEffect(() => {
    const video = ref.current;
    if (!video) return;

    const controller = new AbortController();
    const { signal } = controller;

    const eventMap: Partial<Record<keyof HTMLMediaElementEventMap, () => void>> = {
      pause: () => updateState({ isPlaying: false }),
      abort: () => updateState({ isLoading: false }),
      seeking: () => updateState({ isSeeking: true }),
      canplay: () => updateState({ isLoading: false }),
      emptied: () => updateState({ isLoading: false }),
      stalled: () => updateState({ isLoading: false }),
      suspend: () => updateState({ isLoading: false }),
      waiting: () => updateState({ isBuffering: true }),
      loadstart: () => updateState({ isLoading: true }),
      loadeddata: () => updateState({ isLoading: false }),
      canplaythrough: () => updateState({ isLoading: false }),
      ended: () => updateState({ isPlaying: false, isEnded: true }),
      play: () => updateState({ isStarted: true, isPlaying: true }),
      loadedmetadata: () => updateState({ duration: video.duration }),
      durationchange: () => updateState({ duration: video.duration }),
      timeupdate: () => updateState({ currentTime: video.currentTime }),
      error: () => updateState({ isLoading: false, error: video.error }),
      playing: () => updateState({ isPlaying: true, isBuffering: false }),
      volumechange: () => updateState({ volume: video.volume, isMuted: video.muted }),
      seeked: () => updateState({ isSeeking: false, currentTime: video.currentTime }),
    };

    Object.entries(eventMap).forEach(([event, handler]) => video.addEventListener(event, handler, { signal }));

    updateState({
      volume: video.volume,
      isMuted: video.muted,
      isEnded: video.ended,
      duration: video.duration || 0,
      currentTime: video.currentTime || 0,
      isPlaying: !video.paused && !video.ended,
    });

    return () => controller.abort();
  }, [ref, updateState]);

  const play = useCallback(() => {
    if (!ref.current) return;

    ref.current.play();
  }, [ref]);

  const pause = useCallback(() => {
    if (!ref.current) return;

    ref.current.pause();
  }, [ref]);

  const togglePlay = useCallback(() => {
    if (!ref.current) return;

    ref.current.paused ? ref.current.play() : ref.current.pause();
  }, [ref]);

  const seek = useCallback(
    (seconds: number) => {
      if (!ref.current) return;

      const video = ref.current;

      const newTime = Math.max(0, Math.min((video.currentTime || 0) + seconds, video.duration || 0));

      ref.current.currentTime = newTime;
    },
    [ref],
  );

  const skip = useCallback(
    (seconds: number) => {
      if (!ref.current) return;

      seek((ref.current.currentTime || 0) + seconds);
    },
    [seek, ref],
  );

  const mute = useCallback(() => {
    if (!ref.current) return;

    ref.current.muted = true;
  }, [ref]);

  const unmute = useCallback(() => {
    if (!ref.current) return;

    ref.current.muted = false;
  }, [ref]);

  const toggleMute = useCallback(() => {
    if (!ref.current) return;

    ref.current.muted = !ref.current.muted;
  }, [ref]);

  const setVolume = useCallback(
    (volume: number) => {
      if (!ref.current) return;

      ref.current.volume = Math.max(0, Math.min(volume, 1));
    },
    [ref],
  );

  const setPlaybackRate = useCallback(
    (rate: number) => {
      if (!ref.current) return;

      ref.current.playbackRate = rate;
    },
    [ref],
  );

  const restart = useCallback(() => {
    if (!ref.current) return;

    ref.current.currentTime = 0;

    ref.current.play();
  }, [ref]);

  return {
    ref,
    mute,
    play,
    seek,
    skip,
    pause,
    unmute,
    restart,
    setVolume,
    togglePlay,
    toggleMute,
    setPlaybackRate,
    ...state,
  };
}
Enter fullscreen mode Exit fullscreen mode

Maybe this will save someone some development time, so feel free to Ctrl+C / Ctrl+V ))) Of course, if you have any suggestions for improvements, I’d be glad to read them in the comments.

P.S.
Today, russians terrorists launched the largest air attack on Ukraine and struck a government building for the first time. It seems some world leaders have decided to swallow their guts and suck up to evil instead of fighting it. Guess who can be called the new Chamberlain?

Top comments (0)