reactjshtml5-videoreact-hooks

Correct handling of React Hooks for streaming video camera to HTML Video Element


I've been trying to write a React Hook for handling steaming video captured from a users camera to an HTML Video Element. I'm having trouble working out the best way to handle initialising and de-initialising the camera and HTML Video Element.

I have attempted to add a cleanup function at the end of my hook, but my attempts have ended up with the video re-initialising repeatedly or any number of other weird bugs.

Really I'm struggling to work out how and why the cleanup function is being called. It doesn't seem to relate to the component being unmounted.

Also, I'm unsure how best to destroy the video, though there are plenty of answers for that on here already, I'm not sure I need to remove it entirely. It wouldn't hurt if it hung around, there are only half a dozen pages. I suppose I just want to stop the camera streaming when a user navigates away from the page and start it up again when they return to the video page.

Camera Video Stream Hook

import { useEffect, useState } from 'react';

const initialiseCamera = async() => await
    navigator
        .mediaDevices
        .getUserMedia({audio: false, video: true});

export const useCamera = videoRef => {
    const [isCameraInitialised, setIsCameraInitialised] = useState(false);
    const [video, setVideo] = useState(null);
    const [error, setError] = useState('');
    const [playing, setPlaying] = useState(true);

    useEffect(() => {
        if(video || !videoRef.current) {
            return;
        }

        const videoElement = videoRef.current;
        if(videoElement instanceof HTMLVideoElement) {
            setVideo(videoRef.current);
        }
    }, [videoRef, video]);

    useEffect(() => {
        if(!video || isCameraInitialised || !playing) {
            return;
        }

        initialiseCamera()
            .then(stream => {
                video.srcObject = stream;
                setIsCameraInitialised(true);
            })
            .catch(e => {
                setError(e.message);
                setPlaying(false);
            });
    }, [video, isCameraInitialised, playing]);

    useEffect(() => {
        const videoElement = videoRef.current;

        if(playing) {
            videoElement.play();
        } else {
            videoElement.pause();
        }

    },[playing, videoRef]);



    return [video, isCameraInitialised, playing, setPlaying, error];
};


Video View

import React, {createRef} from 'react';
import { useCamera } from '../hooks/use-camera';
import { Button } from '@orderandchaos/react-components';

const VideoViewDemo = () => {
    const videoRef = createRef();
    const [video, isCameraInitialised, running, setPlaying, error] = useCamera(videoRef);

    return (
        <div>
            <video
                ref={videoRef}
                autoPlay={true}
                muted={true}
                controls
                width={480}
                height={270}
            />
            <Button
                onClick={() => setPlaying(!running)}
                ariaLabel='Start/Stop Audio'
            >{running ? 'Stop' : 'Start'}</Button>
        </div>
    );
};

export default VideoViewDemo;


Solution

  • If you add a cleanup function within any of the useEffect which has parameters specified as a dependency array, the cleanup function will be run whenever any of the parameters change.

    In order for the video cleanup to only run on unmount, you would have to pass an empty dependency array. Now since the variables inside the effect will belong to the closure at the initial run, you would need to have a ref that references those values.

    You can write a cleanup hook to take care of that

    const useCleanup = (val) => {
        const valRef = useRef(val);
        useEffect(() => {
           valRef.current = val;
        }, [val])
    
        useEffect(() => {
            return () => {
                  // cleanup based on valRef.current
            }
        }, [])
    }
    
    
    
    import { useEffect, useState } from 'react';
    
    const initialiseCamera = async() => await
        navigator
            .mediaDevices
            .getUserMedia({audio: false, video: true});
    
    export const useCamera = videoRef => {
        const [isCameraInitialised, setIsCameraInitialised] = useState(false);
        const [video, setVideo] = useState(null);
        const [error, setError] = useState('');
        const [playing, setPlaying] = useState(true);
    
        useEffect(() => {
            if(video || !videoRef.current) {
                return;
            }
    
            const videoElement = videoRef.current;
            if(videoElement instanceof HTMLVideoElement) {
                setVideo(videoRef.current);
            }
        }, [videoRef, video]);
    
    
        useCleanup(video)
    
        useEffect(() => {
            if(!video || isCameraInitialised || !playing) {
                return;
            }
    
            initialiseCamera()
                .then(stream => {
                    video.srcObject = stream;
                    setIsCameraInitialised(true);
                })
                .catch(e => {
                    setError(e.message);
                    setPlaying(false);
                });
        }, [video, isCameraInitialised, playing]);
    
        useEffect(() => {
            const videoElement = videoRef.current;
    
            if(playing) {
                videoElement.play();
            } else {
                videoElement.pause();
            }
    
        },[playing, videoRef]);
    
    
    
        return [video, isCameraInitialised, playing, setPlaying, error];
    };