Build a React Camera Component with Hooks

With the getUserMedia API, we can access the camera in web browsers, which makes it possible to do computer vision tasks like barcode scanning. In this article, we are going to build a camera component for React with hooks.

Build a React Camera Component with Hooks

We are going to write a React camera component with hooks using Typescript.

New project

  1. Init a new npm project.

    npm init
    
  2. Install dev dependencies.

    npm install --save-dev typescript @types/react react react-dom
    

    Since we are going to publish it as a library, we install the packages as devDependencies.

Create the entry file and the component

  1. Create a component named src/VisionCamera.tsx with the basic content:

    import React from 'react';
    
    const VisionCamera = (): React.ReactElement => {
      return (
        <div>
          Vision Camera
        </div>
      )
    }
    
    export default VisionCamera;
    
  2. Create an entry file named src/index.js with the following content:

    import VisionCamera from './VisionCamera'
    export * from "./VisionCamera"
    
    export {
      VisionCamera
    };
    

TypeScript configuration

  1. Create a tsconfig.json file with the following content:

    {
      "compilerOptions": {
        "target": "es2015",
        "lib": [
          "dom",
          "dom.iterable",
          "esnext"
        ],
        "allowJs": true,
        "baseUrl": ".",
        "esModuleInterop": true,
        "forceConsistentCasingInFileNames": true,
        "jsx": "react",
        "module": "commonjs",
        "moduleResolution": "node",
        "resolveJsonModule": true,
        "skipLibCheck": true,
        "strict": true,
        "declaration": true,
        "strictNullChecks": false,
        "outDir": "dist",
        "rootDir": "src"
      },
      "exclude": [
        "node_modules",
        "cypress"
      ],
      "include": [
        "src/**/*"
      ]
    }
    
  2. Update the package.json file to add a build script.

    "scripts": {
      "build": "tsc"
    }
    

Now, we can run npm run build to build the library.

Create an example project to test the component

We can create a new project to test the component.

  1. New project with Create React App in the component project’s folder.

    npx create-react-app example --template typescript
    
  2. Install the component.

    npm install ../
    
  3. Use npm link to use the React in the component project’s node_modules.

    npm link ../node_modules/react
    npm link ../node_modules/react-dom
    
  4. Update App.tsx to use the camera component.

    import React from 'react';
    import {VisionCamera} from 'react-vision-camera';
    
    function App() {
      return (
        <div>
          <VisionCamera></VisionCamera>
        </div>
      );
    }
    
    export default App;
    
  5. We can now run the example project to test the component.

    npm run start
    

Implement the camera component

Define props for the component

export interface CameraProps{
  isActive?:boolean; // whether the camera is opened
  isPause?:boolean; // whether the camera is paused
  desiredCamera?:string;
  desiredResolution?:Resolution;
  facingMode?:string;
  children?: ReactNode;
  onOpened?: (cam:HTMLVideoElement,camLabel:string) => void; // event triggered when the camera is opened
  onClosed?: () => void; // event triggered when the camera is closed
  onDeviceListLoaded?: (list:MediaDeviceInfo[]) => void; // event triggered when the list of camera devices is loaded
}
const VisionCamera = (props:CameraProps): React.ReactElement => {

}

Add a video element

  1. Update the JSX with the following:

    return (
      <div 
        style={{ position:"relative", width:"100%", height:"100%", left:0, top:0 }}>
        <video 
          style={{ position:"absolute", objectFit:"cover", width:"100%", height:"100%", left:0, top:0 }}
          ref={camera} muted autoPlay={true} playsInline={true} 
          onLoadedData={onCameraOpened}></video>
        {props.children}
      </div>
    )
    

    Because we do not use a bundler like webpack and rollup, here we use inline styles instead of a CSS file.

  2. Add ref for the video element and the handler for the onLoadedData event.

    const camera = React.useRef(null);
    const onCameraOpened = () => {
    console.log("onCameraOpened");
      if (props.onOpened) {
        props.onOpened(camera.current,getCurrentCameraLabel());
      }
    }
    

Add camera control functions

  1. Load camera devices into a list.

    const loadDevices = async () => {
      const constraints = {video: true, audio: false};
      const stream = await navigator.mediaDevices.getUserMedia(constraints) // ask for permission
      const mediaDevices = await navigator.mediaDevices.enumerateDevices();
      let cameraDevices = [];
      for (let i=0;i<mediaDevices.length;i++){
        let device = mediaDevices[i];
        if (device.kind == 'videoinput'){ // filter out audio devices
          cameraDevices.push(device);
        }
      }
      devices.current = cameraDevices;
      const tracks = stream.getTracks();
      for (let i=0;i<tracks.length;i++) {
        const track = tracks[i];
        track.stop();  // stop the opened camera
      }
      if (props.onDeviceListLoaded) {
        props.onDeviceListLoaded(cameraDevices);
      }
    }
    
  2. Stop the opened camera.

    const stop = () => {
       try{
         if (localStream.current){
           const stream = localStream.current as MediaStream;
           const tracks = stream.getTracks();
           for (let index = 0; index < tracks.length; index++) {
             const track = tracks[index];
             track.stop();
           }
           if (props.onClosed) {
             props.onClosed();
           }
         }
       } catch (e){
         console.log(e);
       }
     };
    
  3. Start the camera with desired conditions.

    const play = (options:PlayOptions) => {
       stop(); // close before play
       var constraints:any = {};
        
       if (options.deviceId){
         constraints = {
           video: {deviceId: options.deviceId},
           audio: false
         }
       }else{
         constraints = {
           video: {width:1280, height:720},
           audio: false
         }
       }
        
       if (options.facingMode) {
         delete constraints["video"]["deviceId"];
         constraints["video"]["facingMode"] = { exact: options.facingMode };
       }
    
       if (options.desiredResolution) {
         constraints["video"]["width"] = options.desiredResolution.width;
         constraints["video"]["height"] = options.desiredResolution.height;
       }
       navigator.mediaDevices.getUserMedia(constraints).then(function(stream) {
         localStream.current = stream;
         // Attach local stream to video element
         camera.current.srcObject = stream;
       }).catch(function(err) {
         if (options.facingMode) { // facing mode not supported on desktop Chrome
           delete options["facingMode"];
           play(options);
         }else{
           console.error('getUserMediaError', err, err.stack);
         }
       });
     }
        
    interface PlayOptions {
      deviceId?:string;
      desiredResolution?:Resolution;
      facingMode?:string;
    }
    
  4. Helper functions:

    const playWithDesired = async () => {
      if (!devices.current) {
        await loadDevices(); // load the camera devices list if it hasn't been loaded
      }
         
      let desiredDevice = getDesiredDevice(devices.current)
            
      if (desiredDevice) {
        let options:PlayOptions = {};
        options.deviceId=desiredDevice;
        if (props.desiredResolution) {
          options.desiredResolution=props.desiredResolution;
        }
        if (props.facingMode) {
          options.facingMode=props.facingMode;
        }
        play(options);
      }else{
        throw new Error("No camera detected");
      }
    }
         
    const getDesiredDevice = (devices:MediaDeviceInfo[]) => {
      var count = 0;
      var desiredIndex = 0;
      for (var i=0;i<devices.length;i++){
        var device = devices[i];
        var label = device.label || `Camera ${count++}`;
        if (props.desiredCamera) {
          if (label.toLowerCase().indexOf(props.desiredCamera.toLowerCase()) != -1) {
            desiredIndex = i;
            break;
          } 
        }
      }
    
      if (devices.length>0) {
        return devices[desiredIndex].deviceId; // return the device id
      }else{
        return null;
      }
    }
    

Add hooks to use the camera functions

  1. When the component is mounted, load the camera devices list and start the camera if the isActive prop is true.

    const init = async () => {
      if (!devices.current) {
        await loadDevices(); // load the camera devices list when the component is mounted
      }
      if (props.isActive === true) {
        playWithDesired();
      }
      mounted.current = true;
    }
    init();
    
  2. Monitor the change of the isActive prop. If it is set to true, start the camera, otherwise, stop the camera.

    React.useEffect(() => {
      if (mounted.current === true) {
        if (props.isActive === true) {
          playWithDesired();
        }else{
          stop();
        }
      }
    }, [props.isActive]);
    
  3. Monitor the change of the isPause prop. If it is set to true, pause the video, otherwise, resume the video.

    React.useEffect(() => {
      if (mounted.current === true) {
        if (camera.current && props.isActive === true) {
          if (props.isPause === true) {
            camera.current.pause();
          }else{
            camera.current.play();
          }
        }
      }
    }, [props.isPause]);
    
  4. Monitor the changes of the desired camera configurations. If there are changes, restart the camera.

    React.useEffect(() => {
      if (props.isActive === true && localStream.current && mounted.current === true) {
        playWithDesired();
      }
    }, [props.desiredCamera,props.desiredResolution,props.facingMode]);
    

Update the example to use the camera component

Now, we can use the component in the example project to open the camera.

<div className="vision-camera">
  <VisionCamera 
    isActive={true}
    desiredCamera="back"
    desiredResolution={{width:1280,height:720}}
  >
  </VisionCamera>
</div>

You can check out the online demo to have a try.

In the next article, we are going to build a barcode scanner component based on this camera component.

Source Code

https://github.com/xulihang/react-vision-camera