Published at

Building a peer to peer video call app with PeerJS and React

Building a peer to peer video call app with PeerJS and React

Discover how to build a peer-to-peer video call app from scratch. Learn the latest in P2P technology and enhance your web development skills.

Authors
Table of Contents

Hey everyone! it’s been a while since my last post Today, we build our own peer to peer video call app wiht PeerJS & React! ✨ Dive into WebRTC’s world for real-time audio & video, privacy, and ultimate customization. Ready? Let’s code!

What is PeerJS and How Does It Help?

PeerJS wraps the browser’s WebRTC implementation to provide a complete, configurable, and easy-to-use peer-to-peer connection API. Equipped with nothing but an ID, a peer can create a P2P data or media stream connection to a remote peer.

Why PeerJS? WebRTC is awesome, but it can be tricky. Setting up connections and dealing with technical stuff

To build this app, we need a Peer server to facilitate connections between peers; however, data is not proxied through the server.

Peer server

You can either create a Node.js server or, if you prefer not to develop anything, simply run the following commands to install the PeerJS package globally and start the PeerJS server

npm install peer -g
peerjs --port 9000 --key peerjs --path /

If you navigate to http://localhost:9000/, you should see a JSON object containing ‘name’, description, and website’fields. Alternatively, if you have Docker installed, you can run the PeerJS server using the following Docker command

docker run -p 9000:9000 -d peerjs/peerjs-server

That concludes the server setup. Now, let’s move on to building the front-end part

Front end

Create a new React application using Vite with the following command

npm create vite@latest

Replace the app.tsx file with the following code, and I will attempt to explain what it does

import Peer from "peerjs";
import { Dispatch, SetStateAction, createContext, useContext } from "react";

export type User = {
  name: string,
  peerId: string | undefined,
};

export interface CallDetailContext {
  user?: User | null;
  setUser?: Dispatch<SetStateAction<User | null>>;
  roomId?: string | null;
  setRoomId?: Dispatch<SetStateAction<string>>;
  peer?: Peer | null;
  setPeer?: Dispatch<SetStateAction<Peer | null>>;
}
const callDetailContext =
  createContext<CallDetailContext>({
    roomId: null,
    user: null,
  });

export function useCallDetailContext() {
  return useContext(callDetailContext);
}

function App() {
  const [user, setUser] = useState<Peer | null>(null);
  const [peer, setPeer] = useState<Peer | null>(null);
  const [roomId, setRoomId] = React.useState<string | null>();
  return (
    <>
      {" "}
      <callDetailContext.Provider
        value={{ setUser, user, roomId, setRoomId, peer, setPeer }}>
        <Header />
        <main className="grid max-w-7xl mx-auto place-items-center h-[80dvh]">
          {user ? <Stream /> : <UserLoginForm />}
        </main>
      </callDetailContext.Provider>
    </>
  );
}

In the main element, we conditionally render either the Stream component, which contains the logic for connecting to remote peers, or the UserLoginForm component. Upon successful user login, we will render the Stream component

The UserLoginForm component contains one input element for accepting the user’s name and a button to update the user state, which is retrieved from the useCallDetailContext() custom hook

//UserLoginForm component
function UserLoginForm() {
  const { setUser } = useCallDetailContext();
  const userInputRef = useRef<HTMLInputElement>(null);
  return (
    <>
      <form>
        <div className="w-4/5 mx-auto my-2 text-center font-bold text-lg">
          Login to Continue
        </div>

        <input
          className="p-3 rounded-md text-black"
          ref={userInputRef}
          type="text"
          placeholder="What is your name"
        />
        <button
          className=" p-3 max-w-7xl mx-2 rounded-md bg-green-700 hover:bg-green-400"
          onClick={(e) => {
            e.preventDefault();
            const userName = userInputRef.current?.value;
            if (!setUser || !userName) return;
            setUser(
              (prv) => ({ name: userName, peerId: prv?.peerId } satisfies User)
            );
          }}
          type="submit">
          Login
        </button>
      </form>
    </>
  );
}

That concludes the login part. Now, let’s see how we implement the Stream component.

Copy and paste the following code into your stream.tsx file, and let’s discuss each piece of code to understand what it does.

// stream.tsx
function Stream() {
  const { user, setUser, setPeer, setRoomId } = useCallDetailContext();
  const [remoteStream, setRemoteStream] =
    (useState < MediaStream) | (null > null);

  const [connectionsStatus, setConnectionsStatus] =
    (useState < string) | (null > null);

  useEffect(() => {
    (async () => {
      if (!user?.name) return;
      connect(
        user.name,
        setConnectionsStatus,
        (id, peer: Peer) => {
          setUser?.({ name: user.name, peerId: id });
          setPeer?.(peer);
        },
        (remoteStream: MediaStream) => {
          setRemoteStream(remoteStream);
        },
        (id: string) => {
          console.log("connected to remote peer", id);
          setRoomId?.(id);
        }
      );
    })();
  }, [user?.name]);

  return (
    <div className="w-full h-full flex justify-center relative">
      <div>
        <div>
          <div className="font-bold text-center ">wellcome {user?.name}</div>
        </div>
        <div className="font-bold text-lg absolute right-0">
          <UserProfile
            connectionsStatus={connectionsStatus}
            peerId={user?.peerId}
          />
        </div>
        <div className="grid gap-10 place-items-center w-full h-full">
          <div>
            <div>
              <StartCalling
                setRemoteStream={setRemoteStream}
                remoteStream={remoteStream}
              />
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

The Central Hub: The Stream Component

The Stream component serves as the heart of our video call interface. It leverages the useCallDetailContext hook to gain access to the user’s details, the PeerJS instance, and the room ID. Additionally, it manages its own state for the remote media stream and the connection status.

Establishing Connections with useEffect

The useEffect hook plays a pivotal role in establishing a connection to the PeerJS server once a user is logged in. It performs a check to ensure that the user has a name and then invokes the connect function with five key arguments:

  1. Username: The current user’s name, which is used to identify the user within the system.
  2. Update Connection Status: A function designed to update the connection status, providing real-time feedback on the connection’s progress.
  3. On Successful Connection: A callback function that gets triggered upon a successful connection to the PeerJS server. It receives the peer ID and the PeerJS instance as parameters.
  4. On Receiving Remote Stream: A callback function that is invoked when a remote media stream is received, enabling the display of the remote video feed.
  5. On Remote Peer Connection Change: A callback function that is called when the remote peer connects with the peer and subsequently disconnects, allowing the application to respond accordingly.

const connect = (
  userName: string,
  setConnectionsStatus: Dispatch<SetStateAction<string | null>>,
  onConnect: (id: string, peer: Peer) => void,
  onRemoteStream: (stream: MediaStream) => void,
  onRemoteStreamConnect: (remotePeerId: string | null) => void
) => {
  setConnectionsStatus("connecting");
  const myId = `${userName}${Math.floor(Math.random() * 10000).toString()}`;
  const peer = new Peer(myId, {
    host: "localhost",
    path: "/",
    port: 9000,
    config,
  });

  peer.on("call", async (conn) => {
    conn.answer(window.localMediaStream ?? (await getLocalStream()));
    conn.on("stream", (stream) => {
      onRemoteStream(stream);
    });
  });
  peer.on("open", (id) => {
    setConnectionsStatus("connected");
    onConnect(id, peer);
  });

  peer.on("connection", (dataConn) => {
    setConnectionsStatus("connected");
    onRemoteStreamConnect(dataConn.peer);
    dataConn.on("iceStateChanged", (state) => {
      if (state == "disconnected") {
        alert(dataConn.peer + "is disconnected");
        onRemoteStreamConnect(null);
      }
    });
  });

Understanding the connect function and the Role of ICE Servers

The connect function is a critical part of our video call application. It’s responsible for setting up the connection to the PeerJS server and handling the various stages of the connection process. Let’s explore what this function does and why we need the configuration object with ICE servers.

Configuration Object with ICE Servers

The config object contains an array of ICE servers. ICE stands for Interactive Connectivity Establishment, and it’s a protocol used to find the best path for media over the internet. These servers act as relay points that help establish a direct connection between peers, especially when they are behind restrictive Network Address Translation (NAT) firewalls or using Virtual Private Networks (VPNs).

Here’s a more detailed explanation of the config object:

const config = {
  iceServers: [
    { url: "stun:stun.l.google.com:19302" },
    { url: "stun:stun4.l.google.com:19302" },
  ],
};

Why Do We Need ICE Servers?

When two devices want to communicate directly over the internet, they often face challenges due to NAT firewalls or VPNs. These security measures can prevent devices from being directly reachable from the outside world. ICE servers play a vital role in overcoming these obstacles.

Here’s how ICE servers help:

  • NAT Traversal: They allow devices to discover their public IP address and port number, even if they are behind a NAT firewall. This is essential for establishing a direct connection between peers.
  • Relaying: If a direct connection cannot be established, ICE servers can relay traffic between peers. This is particularly useful when peers are behind very restrictive NATs or when they are using VPNs.
  • Fallback: They provide a fallback mechanism in case a direct connection fails. This ensures that the video call can still be established, albeit with a slight delay or increased latency.

The connect Function in Detail

Now, let’s dive into the connect function itself:

  1. Initialization: The function starts by setting the connection status to “connecting”. It generates a unique ID for the current user by combining their username with a random number.

  2. Creating a Peer Instance: A new PeerJS instance is created with the generated ID, specifying the host, path, port, and configuration object.

  3. Event Listeners: Several event listeners are added to handle different stages of the connection process:

    • call: When a call is received, the function answers with the local media stream and sets up a listener for the remote stream.
    • open: When the connection is successfully opened, the function updates the connection status and calls the onConnect callback.
    • connection: When a data connection is established, the function updates the connection status, calls the onRemoteStreamConnect callback, and sets up a listener for ICE state changes.
    • error: If an error occurs, the function updates the connection status with the error message

The Stream component displays a welcome message to the user and includes two subcomponents:

  • UserProfile: which displays the connection status and the peer ID of the current user
  • StartCalling: A component that enables the user to initiate a call by entering a peer ID and clicking a call button.”

UserProfile component

The UserProfile component that takes peerId and connectionsStatus as props. It displays the current connection status and the user’s peer ID in a simple layout. This component is purely informational and does not interact with the rest of the application directly

//UserProfile
function UserProfile({
  peerId,
  connectionsStatus,
}: {
  peerId: string | undefined,
  connectionsStatus: string | null,
}) {
  return (
    <div className="">
      <div className="">
        <div>{connectionsStatus}</div>
        <div>your id is {peerId}</div>
      </div>
    </div>
  );
}

StartCalling Component

The StartCalling component is designed with interactivity in mind. It features an input field where users can manually enter a peer ID and a button to initiate the call. Upon clicking the button, the default form submission behavior is suppressed, and the entered peer ID is retrieved from the input field. If a valid peer ID is detected, the roomId state is updated, which in turn activates the useEffect hook to attempt a connection with the specified remote peer.

Within the StartCalling component, the useEffect hook is dedicated to establishing a connection with the remote peer. It verifies that both roomId and peer are properly defined. Should these conditions be met, the connectRemotePeer function is invoked with the roomId, the PeerJS instance, and a callback function that updates the remoteStream state with the incoming media stream.

Displaying Video Streams with RenderVideos

Upon the activation of the remoteStream state, signifying an active call, the RenderVideos component is rendered to present the video streams to the user. If the remoteStream is not yet configured, the StartCalling component continues to render a form prompting the user to input a peer ID and start a call.

function StartCalling({
  remoteStream,
  setRemoteStream,
}: {
  remoteStream: MediaStream | null,
  setRemoteStream: Dispatch<SetStateAction<MediaStream | null>>,
}) {
  const peerIdInputRef = useRef < HTMLInputElement > null;
  const { roomId, setRoomId, peer } = useCallDetailContext();

  useEffect(() => {
    if (!roomId || !peer) return;
    connectRemotePeer(roomId, peer, (remoteStream: MediaStream) => {
      setRemoteStream(remoteStream);
    });
  }, [roomId]);

  if (roomId) return <RenderVideos remoteStream={remoteStream} />;

  return (
    <div>
      <div className="font-bold my-10 text-center text-lg ">
        Enter peer id and click call{" "}
      </div>
      <form>
        <input
          className="p-3 rounded-md text-black"
          ref={peerIdInputRef}
          type="text"
          placeholder="Peer id"
        />
        <button
          onClick={(e) => {
            e.preventDefault();
            const roomId = peerIdInputRef.current?.value;
            if (!roomId || !setRoomId) return;

            setRoomId(roomId);
          }}
          className=" p-3 max-w-7xl mx-2 rounded-md bg-green-700 hover:bg-green-400"
          type="submit">
          Call
        </button>
      </form>
    </div>
  );
}

The connectRemotePeer Function

The connectRemotePeer function is a crucial part of our video call application. It’s an asynchronous function that helps us connect to a remote peer and start a video call. Here’s a step-by-step breakdown of what happens inside this function:

  1. Setting Up: First, we check if we already have a local media stream ready. If not, we get one using the getLocalStream function. This stream includes our video and audio.
    • The getLocalStream function is an asynchronous utility that simplifies the process of obtaining the user’s local media stream. By calling navigator.mediaDevices.getUserMedia with the appropriate constraints, it requests access to both the audio and video feeds from the user’s device. Once granted permission, the function returns a Promise that resolves to the MediaStream object containing the user’s audio and video tracks, ready for use in real-time applications such as video conferencing.```
async function getLocalStream() {
  return await navigator.mediaDevices.getUserMedia({
    audio: true,
    video: true,
  });
}
  1. Creating a Connection: Next, we tell our PeerJS instance to connect to the remote peer using their ID. We store this connection in a variable called dataConn.

  2. Listening for Errors: We add listeners to catch any errors that might happen during the connection process. If something goes wrong, we log the error message to the console.

  3. Opening the Connection: When the connection is successfully opened, we log a success message to the console. Then, we start a call to the remote peer and send our local media stream to them.

  4. Monitoring the Call: We keep an eye on the call’s state. If the remote peer disconnects, we alert the user.

  5. Receiving the Remote Stream: Finally, when the remote peer sends their video stream back to us, we log it to the console and pass it to a callback function. This function is usually used to display the remote video on our screen.

In short, the connectRemotePeer function is all about making sure we can talk to someone else over video. It handles the nitty-gritty details of starting a call, keeping track of the connection, and dealing with any issues that come up.

Conclusion

After completing the setup, you’ll witness the video call application in action. With a valid peer ID, the live video streams will display, confirming the app’s functionality. For those interested in exploring further, the source code is available on our GitHub repository.

Sharing is caring!