Peer-to-peer video call with Next.js, Socket.io and Native WebRTC APIs

Tauqueer Khan

Software Engineer

8 min

Article Banner

Note: The app we are building cannot be deployed on Vercel. The reason is that Vercel takes any code put into the api folder and deploys it as serverless functions, and websockets aren't supported. This app would have to be built and served traditionally to work. If you want to deploy something like this to Vercel, check out our WebRTC implementation with Pusher.

In this example, we'll set up a Next.js application with Socket.io, and build a simple video chat app!

Create new Next app:

1npx create-next-app next-webrtc-socket-io

Install dependencies socket.io and socket.io-client.

socket.io will be used to create a socket server and used server side and socket.io-client will be used on the client side.

1yarn add socket.io socket.io-client

Let's pause a second and see what we need before we keep going. If you read The Ultimate Beginner's Guide To WebRTC there were many times that Rick and Morty were talking to the signaling server. In this example, our socket server created by socket.io will be used as our signaling server.

To start, lets create a socket.js file inside of our pages/api folder:

1// pages/api/socket.js
2import { Server } from 'socket.io';
3
4const SocketHandler = (req, res) => {
5  if (res.socket.server.io) {
6    console.log('Socket is already attached');
7    return res.end();
8  }
9
10  const io = new Server(res.socket.server);
11  res.socket.server.io = io;
12
13  io.on("connection", (socket) => {
14    console.log(`User Connected : ${socket.id}`);
15  });
16  
17  return res.end();
18};
19
20export default SocketHandler;
21

When the user joins, we make a GET request to /api/socket endpoint.

Building off of our Beginner's Guide to WebRTC example, these are the socket events that we'll need:

  1. When Rick joins a room or when Morty joins the same room. Event: join
  2. Morty tells the socket he's ready. Event: ready
  3. Rick will eventually create an Offer and send it to the socket. Event: offer
  4. Morty will then create an Answer and send it to the socket. Event: answer
  5. Rick and Morty will then be sending ICE Candidates to the socket. Event: ice-candidate
  6. Rick or Morty might leave the room. Event: leave
1// pages/api/socket.js
2import { Server } from 'socket.io';
3
4const SocketHandler = (req, res) => {
5  if (res.socket.server.io) {
6    console.log('Socket is already attached');
7    return res.end();
8  }
9
10  const io = new Server(res.socket.server);
11  res.socket.server.io = io;
12
13  io.on("connection", (socket) => {
14    console.log(`User Connected :$socket.id}`);
15  
16    // Triggered when a peer hits the join room button.
17    socket.on("join", (roomName) => {
18    });
19  
20    // Triggered when the person who joined the room is ready to communicate.
21    socket.on("ready", (roomName) => {
22    });
23  
24    // Triggered when server gets an icecandidate from a peer in the room.
25    socket.on("ice-candidate", (candidate, roomName: string) => {
26    });
27  
28    // Triggered when server gets an offer from a peer in the room.
29    socket.on("offer", (offer, roomName) => {
30    });
31  
32    // Triggered when server gets an answer from a peer in the room
33    socket.on("answer", (answer, roomName) => {
34    });
35
36    socket.on("leave", (roomName) => {
37    });
38
39  });
40  
41  return res.end();
42};
43
44export default SocketHandler;
45

Our socket will also need to react to the events above, so let's set those up too:

  1. When the socket receives a join event, it can go one of three different ways
    1. If no one is in the room, we will emit a created event
    2. If someone is already in the room, it will emit a joined event
    3. Since we only support 2 people in this room, if a third person joins, the socket will emit a full event
  2. When the socket receives a ready event, it broadcasts ready to the room
  3. When the socket receives an offer event, it broadcasts offer to the room with the offer data
  4. When the socket receives an answer event, it broadcasts answer to the room with the answer data
  5. When it receives the ice-candidate event, it broadcasts ice-candidate to the room with the ice-candidates it receives
  6. When it receives the leave event, it broadcasts the leave event to the room
1// pages/api/socket.js
2
3import { Server } from 'socket.io';
4
5const SocketHandler = (req, res) => {
6  if (res.socket.server.io) {
7    console.log('Socket is already attached');
8    return res.end();
9  }
10
11  const io = new Server(res.socket.server);
12  res.socket.server.io = io;
13
14  io.on("connection", (socket) => {
15    console.log(`User Connected :${  socket.id}`);
16  
17    // Triggered when a peer hits the join room button.
18    socket.on("join", (roomName) => {
19      const {rooms} = io.sockets.adapter;
20      const room = rooms.get(roomName);
21  
22      // room == undefined when no such room exists.
23      if (room === undefined) {
24        socket.join(roomName);
25        socket.emit("created");
26      } else if (room.size === 1) {
27        // room.size == 1 when one person is inside the room.
28        socket.join(roomName);
29        socket.emit("joined");
30      } else {
31        // when there are already two people inside the room.
32        socket.emit("full");
33      }
34      console.log(rooms);
35    });
36  
37    // Triggered when the person who joined the room is ready to communicate.
38    socket.on("ready", (roomName) => {
39      socket.broadcast.to(roomName).emit("ready"); // Informs the other peer in the room.
40    });
41  
42    // Triggered when server gets an icecandidate from a peer in the room.
43    socket.on("ice-candidate", (candidate: RTCIceCandidate, roomName: string) => {
44      console.log(candidate);
45      socket.broadcast.to(roomName).emit("ice-candidate", candidate); // Sends Candidate to the other peer in the room.
46    });
47  
48    // Triggered when server gets an offer from a peer in the room.
49    socket.on("offer", (offer, roomName) => {
50      socket.broadcast.to(roomName).emit("offer", offer); // Sends Offer to the other peer in the room.
51    });
52  
53    // Triggered when server gets an answer from a peer in the room.
54    socket.on("answer", (answer, roomName) => {
55      socket.broadcast.to(roomName).emit("answer", answer); // Sends Answer to the other peer in the room.
56    });
57
58    socket.on("leave", (roomName) => {
59      socket.leave(roomName);
60      socket.broadcast.to(roomName).emit("leave");
61    });
62
63  });
64  return res.end();
65};
66
67export default SocketHandler;
68

That's all we need to do from our signaling server!

Now let's create a useful hook for our front end. This hook will initialize the socket on any component that calls it. Create a hooks folder, and create a file called useSocket.js

1// hooks/useSocket.js
2import { useEffect, useRef } from "react";
3
4const useSocket = () => {
5  const socketCreated = useRef(false)
6  useEffect(() =>{
7    if (!socketCreated.current) {
8      const socketInitializer = async () => {
9        await fetch ('/api/socket')
10      }
11      try {
12        socketInitializer()
13        socketCreated.current = true
14      } catch (error) {
15        console.log(error)
16      }
17    }
18  }, []);
19};
20
21export default useSocket

Let's update the pages/index.js with some boilerplate code:

1import Head from 'next/head'
2import { useRouter } from 'next/router'
3import { useState } from 'react'
4import styles from '../styles/Home.module.css'
5
6export default function Home() {
7  const router = useRouter()
8  const [roomName, setRoomName] = useState('')
9
10  const joinRoom = () => {
11    router.push(`/room/${roomName || Math.random().toString(36).slice(2)}`)
12  }
13
14  return (
15    <div className={styles.container}>
16      <Head>
17        <title>Native WebRTC API with NextJS</title>
18        <meta name="description" content="Use Native WebRTC API for video conferencing" />
19        <link rel="icon" href="/favicon.ico" />
20      </Head>
21
22      <main className={styles.main}>
23       <h1>Lets join a room!</h1>
24       <input onChange={(e) => setRoomName(e.target.value)} value={roomName} className={styles['room-name']} />
25       <button onClick={joinRoom} type="button" className={styles['join-room']}>Join Room</button>
26      </main>
27    </div>
28  )
29}
30

Here we are storing the room name in state, and we also will send the user to /room/{roomName} using Next's Router. If the user doesn't type a room name, the joinRoom function will simply pick a random string.

You can now start up Next:

1yarn run dev

Let's create a room folder and create a file called [id].js. This is where the bulk of our application code will go.

First, let's import a few things that we will need later. We are going to instantiate our socket via useSocket. We will also create a bunch of refs.

Understanding the refs:

  • userVideREf, peerVideoRef - both are refs for video elements
  • rtcConnectionRef - this will store the ref to the WebRTC connection that will be created later
  • socketRef - will store the ref of the socket
  • userStreamRef - will keep a reference to the user media streams that we get from the camera and mic
1import { useRouter } from 'next/router';
2import { useEffect, useRef, useState } from 'react';
3import { io } from 'socket.io-client';
4import useSocket from '../../hooks/useSocket';
5
6const Room = () => {
7  useSocket();
8  
9  const router = useRouter();
10  const userVideoRef = useRef();
11  const peerVideoRef = useRef();
12  const rtcConnectionRef = useRef(null);
13  const socketRef = useRef();
14  const userStreamRef = useRef();
15  const hostRef = useRef(false);
16
17  return (
18    <div>
19      <video autoPlay ref={userVideoRef} />
20      <video autoPlay ref={peerVideoRef} />
21    </div>
22  );
23};
24
25export default Room;
26

Let's start by working on our socketRef. The first thing we want to know is the roomName that the user has joined. This is done with the help of next/router:

const{id:roomName}=router.query;

Inside the useEffect, we first store a reference to the socket. This works because we called our useSocket hook to create the socket.

socketRef.current = io();

When a user joins a room, the client would emit a join event with the roomName. When joining, the server will check if they are the first to join and emit created if they were or emit joined if they were second user. It would emit full if they were the third person to join. We have to handle those 3 events via callbacks.

We also want to clean up or disconnect from the socket when we leave the component with a return statement in the useEffect

return()=>socketRef.current.disconnect();

We will receive a few other events from the server: first when a user leaves a room (leave event). The other events are webRTC related, which include offer, answer and ice-candidate. We'll listen for each of these in a related callback as well:

1import { useRouter } from 'next/router';
2import { useEffect, useRef, useState } from 'react';
3import { io } from 'socket.io-client';
4import useSocket from '../../hooks/useSocket';
5
6const Room = () => {
7  useSocket();
8
9  const router = useRouter();
10  const userVideoRef = useRef();
11  const peerVideoRef = useRef();
12  const rtcConnectionRef = useRef(null);
13  const socketRef = useRef();
14  const userStreamRef = useRef();
15  const hostRef = useRef(false);
16
17  const { id: roomName } = router.query;
18  useEffect(() => {
19    socketRef.current = io();
20    // First we join a room
21    socketRef.current.emit('join', roomName);
22    
23    socketRef.current.on('created', handleRoomCreated);
24
25    socketRef.current.on('joined', handleRoomJoined);
26    // If the room didn't exist, the server would emit the room was 'created'
27
28    // Whenever the next person joins, the server emits 'ready'
29    socketRef.current.on('ready', initiateCall);
30
31    // Emitted when a peer leaves the room
32    socketRef.current.on('leave', onPeerLeave);
33
34    // If the room is full, we show an alert
35    socketRef.current.on('full', () => {
36      window.location.href = '/';
37    });
38
39    // Events that are webRTC speccific
40    socketRef.current.on('offer', handleReceivedOffer);
41    socketRef.current.on('answer', handleAnswer);
42    socketRef.current.on('ice-candidate', handlerNewIceCandidateMsg);
43
44    // clear up after
45    return () => socketRef.current.disconnect();
46  }, [roomName]);
47
48  return (
49    <div>
50      <video autoPlay ref={userVideoRef} />
51      <video autoPlay ref={peerVideoRef} />
52    </div>
53  );
54};
55
56export default Room;
57

With our useEffect complete, let's handle each of the event callbacks.

The first person to join will call the handleRoomCreated method, and the second person will call handleRoomJoined. They are similar methods with two differences:

  1. When handleRoomCreated is called, it will set the caller as the host (i.e. the first user to join the room)
  2. When handleRoomJoined is called, it will emit the ready event to the server to tell the first user that they are ready to start the call

Without the ready event, none of the downstream events will fire and the call won't start. These two callbacks (handleRoomCreated and handleRoomJoined) both request and capture the user camera and mic and store it to the userStreamRef, and then assign it for playback to the userVideoRef.current.srcObject.

1  const handleRoomCreated = () => {
2    hostRef.current = true;
3    navigator.mediaDevices
4      .getUserMedia({
5        audio: true,
6        video: { width: 500, height: 500 },
7      })
8      .then((stream) => {
9        /* use the stream */
10        userStreamRef.current = stream;
11        userVideoRef.current.srcObject = stream;
12        userVideoRef.current.onloadedmetadata = () => {
13          userVideoRef.current.play();
14        };
15      })
16      .catch((err) => {
17        /* handle the error */
18        console.log(err);
19      });
20  };
21  
22  const handleRoomJoined = () => {
23    navigator.mediaDevices
24      .getUserMedia({
25        audio: true,
26        video: { width: 500, height: 500 },
27      })
28      .then((stream) => {
29        /* use the stream */
30        userStreamRef.current = stream;
31        userVideoRef.current.srcObject = stream;
32        userVideoRef.current.onloadedmetadata = () => {
33          userVideoRef.current.play();
34        };
35        socketRef.current.emit('ready', roomName);
36      })
37      .catch((err) => {
38        /* handle the error */
39        console.log('error', err);
40      });
41  };

Now once hanldeRoomJoined emits ready, the first user or host will call the initiateCall function. The initiateCall function will do three things:

  1. Call createPeerConnection, which returns a RTCPeerConnection and set it to the rtcConnectionRef
  2. The stream in userStreamsRef that was captured during handleCreateRoom has an audio and video track. We take these tracks and add them to the connection so that the peer can have access to it
  3. Create an offer and emit the offer event along with the roomName, and then set the offer to the local description of the RTCPeerConnection that we just created

The RTCPeerConnection subscribes to a bunch of events, but we only care about two: onicecandidate and ontrack. For now, let's create two functions (handleICECandidateEvent and handleTrackEvent) to handle these events.

1  const initiateCall = () => {
2    if (hostRef.current) {
3      rtcConnectionRef.current = createPeerConnection();
4      rtcConnectionRef.current.addTrack(
5        userStreamRef.current.getTracks()[0],
6        userStreamRef.current,
7      );
8      rtcConnectionRef.current.addTrack(
9        userStreamRef.current.getTracks()[1],
10        userStreamRef.current,
11      );
12      rtcConnectionRef.current
13        .createOffer()
14        .then((offer) => {
15          rtcConnectionRef.current.setLocalDescription(offer);
16          socketRef.current.emit('offer', offer, roomName);
17        })
18        .catch((error) => {
19          console.log(error);
20        });
21    }
22  };
23  
24  const ICE_SERVERS = {
25    iceServers: [
26      {
27        urls: 'stun:openrelay.metered.ca:80',
28      }
29    ],
30  };
31  
32  const createPeerConnection = () => {
33    // We create a RTC Peer Connection
34    const connection = new RTCPeerConnection(ICE_SERVERS);
35
36    // We implement our onicecandidate method for when we received a ICE candidate from the STUN server
37    connection.onicecandidate = handleICECandidateEvent;
38
39    // We implement our onTrack method for when we receive tracks
40    connection.ontrack = handleTrackEvent;
41    return connection;
42    
43  };

Once an offer is emitted by the host, the server receives and emits it to the other peer who is listening for it. The other peer will call handleReceivedOffer. This function is similar to initiateCall. It calls createPeerConnection and stores the returning RTCPeerConnection to the rtcConnectionRef and it adds the audio and video tracks to this RTCPeerConnection or ref. The offer is also set as the RemoteDescription of the peer (as opposed to this same offer were stored as the LocalDescription of the host, since it is local to the host). Then an answer is created, which is emitted to the server with the roomName.

1  const handleReceivedOffer = (offer) => {
2    if (!hostRef.current) {
3      rtcConnectionRef.current = createPeerConnection();
4      rtcConnectionRef.current.addTrack(
5        userStreamRef.current.getTracks()[0],
6        userStreamRef.current,
7      );
8      rtcConnectionRef.current.addTrack(
9        userStreamRef.current.getTracks()[1],
10        userStreamRef.current,
11      );
12      rtcConnectionRef.current.setRemoteDescription(offer);
13
14      rtcConnectionRef.current
15        .createAnswer()
16        .then((answer) => {
17          rtcConnectionRef.current.setLocalDescription(answer);
18          socketRef.current.emit('answer', answer, roomName);
19        })
20        .catch((error) => {
21          console.log(error);
22        });
23    }
24  };

The host will now receive the answer from the server and has to do something with it. In this case, the RTCPeerConnection that was created during the initiateCall function will set the answer as its RemoteDescription.

1  const handleAnswer = (answer) => {
2    rtcConnectionRef.current
3      .setRemoteDescription(answer)
4      .catch((err) => console.log(err));
5  };

So far, we've handled 2 out of the 3 WebRTC related events, offer and answer. Before we tackle the socket event for ice-candidate, we have to first receive these ice candidates from the handleIceCandidateEvent event. It receives candidates from the STUN server as soon as we set the offer (if host) and answer (if peer) is set to LocalDescription. As soon as the ice candidate events come in, it is emitted to the server for the other peers to receive.

1  const handleICECandidateEvent = (event) => {
2    if (event.candidate) {
3      socketRef.current.emit('ice-candidate', event.candidate, roomName);
4    }
5  };

We can finally handle the ice-candidate event that is received from the socket. Here the incoming ice candidate is casted into the right format via RTCIceCandidate and added to the RTCPeerConnection. This event will get called many times and multiple ice candidates will get stored in the RTCPeerConnection.

1  const handlerNewIceCandidateMsg = (incoming) => {
2    // We cast the incoming candidate to RTCIceCandidate
3    const candidate = new RTCIceCandidate(incoming);
4    rtcConnectionRef.current
5      .addIceCandidate(candidate)
6      .catch((e) => console.log(e));
7  };

At this point, the two peers know how to reach each other, via the offer and answer and now they know what are the supported ways to communicate with their peer via the ice candidates. A negotiation will happen to find the best way to communicate based on codec support, bandwidth, etc.

Once the negotiation is successful, the audio and video tracks are sent. Here we are storing the stream coming into the peerVideoRef's srcObject:

1  const handleTrackEvent = (event) => {
2    peerVideoRef.current.srcObject = event.streams[0];
3  };

And with this, we have successfully created a video call between two users!

There is one more socket event to handle, which is when one of the users decides to leave the room.

For this workflow, let's first create a button in the JSX that we are returning that a user can click which calls a leaveRoom function.

1  return (
2    <div>
3      <video autoPlay ref={userVideoRef} />
4      <video autoPlay ref={peerVideoRef} />
5      <button onClick={leaveRoom} type="button">
6        Leave
7      </button>
8    </div>
9  );

This function will emit the leave event and also do a bunch of cleanup such as stoping all tracks its receiving and closing our the RTCPeerConnection, and then it finally sends the user back to the main view.

1  const leaveRoom = () => {
2    socketRef.current.emit('leave', roomName); // Let's the server know that user has left the room.
3
4    if (userVideoRef.current.srcObject) {
5      userVideoRef.current.srcObject.getTracks().forEach((track) => track.stop()); // Stops receiving all track of User.
6    }
7    if (peerVideoRef.current.srcObject) {
8      peerVideoRef.current.srcObject
9        .getTracks()
10        .forEach((track) => track.stop()); // Stops receiving audio track of Peer.
11    }
12
13    // Checks if there is peer on the other side and safely closes the existing connection established with the peer.
14    if (rtcConnectionRef.current) {
15      rtcConnectionRef.current.ontrack = null;
16      rtcConnectionRef.current.onicecandidate = null;
17      rtcConnectionRef.current.close();
18      rtcConnectionRef.current = null;
19    }
20    router.push('/')
21  };

So now, once one peer leaves, the peer remaining will receive the leave event from the server and call the onPeerLeave method. Let's do some cleanup for the remaining user on the call as well:

1  const onPeerLeave = () => {
2    // This person is now the creator because they are the only person in the room.
3    hostRef.current = true;
4    if (peerVideoRef.current.srcObject) {
5      peerVideoRef.current.srcObject
6        .getTracks()
7        .forEach((track) => track.stop()); // Stops receiving all track of Peer.
8    }
9
10    // Safely closes the existing connection established with the peer who left.
11    if (rtcConnectionRef.current) {
12      rtcConnectionRef.current.ontrack = null;
13      rtcConnectionRef.current.onicecandidate = null;
14      rtcConnectionRef.current.close();
15      rtcConnectionRef.current = null;
16    }
17  }

And that's it! Below is the complete [id].js page.

1import { useRouter } from 'next/router';
2import { useEffect, useRef, useState } from 'react';
3import { io } from 'socket.io-client';
4import useSocket from '../../hooks/useSocket';
5
6const ICE_SERVERS = {
7  iceServers: [
8    {
9      urls: 'stun:openrelay.metered.ca:80',
10    }
11  ],
12};
13
14const Room = () => {
15  useSocket();
16  const [micActive, setMicActive] = useState(true);
17  const [cameraActive, setCameraActive] = useState(true);
18
19  const router = useRouter();
20  const userVideoRef = useRef();
21  const peerVideoRef = useRef();
22  const rtcConnectionRef = useRef(null);
23  const socketRef = useRef();
24  const userStreamRef = useRef();
25  const hostRef = useRef(false);
26
27  const { id: roomName } = router.query;
28  useEffect(() => {
29    socketRef.current = io();
30    // First we join a room
31    socketRef.current.emit('join', roomName);
32
33    socketRef.current.on('joined', handleRoomJoined);
34    // If the room didn't exist, the server would emit the room was 'created'
35    socketRef.current.on('created', handleRoomCreated);
36    // Whenever the next person joins, the server emits 'ready'
37    socketRef.current.on('ready', initiateCall);
38
39    // Emitted when a peer leaves the room
40    socketRef.current.on('leave', onPeerLeave);
41
42    // If the room is full, we show an alert
43    socketRef.current.on('full', () => {
44      window.location.href = '/';
45    });
46
47    // Event called when a remote user initiating the connection and
48    socketRef.current.on('offer', handleReceivedOffer);
49    socketRef.current.on('answer', handleAnswer);
50    socketRef.current.on('ice-candidate', handlerNewIceCandidateMsg);
51
52    // clear up after
53    return () => socketRef.current.disconnect();
54  }, [roomName]);
55
56  const handleRoomJoined = () => {
57    navigator.mediaDevices
58      .getUserMedia({
59        audio: true,
60        video: { width: 500, height: 500 },
61      })
62      .then((stream) => {
63        /* use the stream */
64        userStreamRef.current = stream;
65        userVideoRef.current.srcObject = stream;
66        userVideoRef.current.onloadedmetadata = () => {
67          userVideoRef.current.play();
68        };
69        socketRef.current.emit('ready', roomName);
70      })
71      .catch((err) => {
72        /* handle the error */
73        console.log('error', err);
74      });
75  };
76
77  
78
79  const handleRoomCreated = () => {
80    hostRef.current = true;
81    navigator.mediaDevices
82      .getUserMedia({
83        audio: true,
84        video: { width: 500, height: 500 },
85      })
86      .then((stream) => {
87        /* use the stream */
88        userStreamRef.current = stream;
89        userVideoRef.current.srcObject = stream;
90        userVideoRef.current.onloadedmetadata = () => {
91          userVideoRef.current.play();
92        };
93      })
94      .catch((err) => {
95        /* handle the error */
96        console.log(err);
97      });
98  };
99
100  const initiateCall = () => {
101    if (hostRef.current) {
102      rtcConnectionRef.current = createPeerConnection();
103      rtcConnectionRef.current.addTrack(
104        userStreamRef.current.getTracks()[0],
105        userStreamRef.current,
106      );
107      rtcConnectionRef.current.addTrack(
108        userStreamRef.current.getTracks()[1],
109        userStreamRef.current,
110      );
111      rtcConnectionRef.current
112        .createOffer()
113        .then((offer) => {
114          rtcConnectionRef.current.setLocalDescription(offer);
115          socketRef.current.emit('offer', offer, roomName);
116        })
117        .catch((error) => {
118          console.log(error);
119        });
120    }
121  };
122
123  const onPeerLeave = () => {
124    // This person is now the creator because they are the only person in the room.
125    hostRef.current = true;
126    if (peerVideoRef.current.srcObject) {
127      peerVideoRef.current.srcObject
128        .getTracks()
129        .forEach((track) => track.stop()); // Stops receiving all track of Peer.
130    }
131
132    // Safely closes the existing connection established with the peer who left.
133    if (rtcConnectionRef.current) {
134      rtcConnectionRef.current.ontrack = null;
135      rtcConnectionRef.current.onicecandidate = null;
136      rtcConnectionRef.current.close();
137      rtcConnectionRef.current = null;
138    }
139  }
140
141  /**
142   * Takes a userid which is also the socketid and returns a WebRTC Peer
143   *
144   * @param  {string} userId Represents who will receive the offer
145   * @returns {RTCPeerConnection} peer
146   */
147
148  const createPeerConnection = () => {
149    // We create a RTC Peer Connection
150    const connection = new RTCPeerConnection(ICE_SERVERS);
151
152    // We implement our onicecandidate method for when we received a ICE candidate from the STUN server
153    connection.onicecandidate = handleICECandidateEvent;
154
155    // We implement our onTrack method for when we receive tracks
156    connection.ontrack = handleTrackEvent;
157    return connection;
158
159  };
160
161  const handleReceivedOffer = (offer) => {
162    if (!hostRef.current) {
163      rtcConnectionRef.current = createPeerConnection();
164      rtcConnectionRef.current.addTrack(
165        userStreamRef.current.getTracks()[0],
166        userStreamRef.current,
167      );
168      rtcConnectionRef.current.addTrack(
169        userStreamRef.current.getTracks()[1],
170        userStreamRef.current,
171      );
172      rtcConnectionRef.current.setRemoteDescription(offer);
173
174      rtcConnectionRef.current
175        .createAnswer()
176        .then((answer) => {
177          rtcConnectionRef.current.setLocalDescription(answer);
178          socketRef.current.emit('answer', answer, roomName);
179        })
180        .catch((error) => {
181          console.log(error);
182        });
183    }
184  };
185
186  const handleAnswer = (answer) => {
187    rtcConnectionRef.current
188      .setRemoteDescription(answer)
189      .catch((err) => console.log(err));
190  };
191
192  const handleICECandidateEvent = (event) => {
193    if (event.candidate) {
194      socketRef.current.emit('ice-candidate', event.candidate, roomName);
195    }
196  };
197
198  const handlerNewIceCandidateMsg = (incoming) => {
199    // We cast the incoming candidate to RTCIceCandidate
200    const candidate = new RTCIceCandidate(incoming);
201    rtcConnectionRef.current
202      .addIceCandidate(candidate)
203      .catch((e) => console.log(e));
204  };
205
206  const handleTrackEvent = (event) => {
207    // eslint-disable-next-line prefer-destructuring
208    peerVideoRef.current.srcObject = event.streams[0];
209  };
210
211  const toggleMediaStream = (type, state) => {
212    userStreamRef.current.getTracks().forEach((track) => {
213      if (track.kind === type) {
214        // eslint-disable-next-line no-param-reassign
215        track.enabled = !state;
216      }
217    });
218  };
219
220  const toggleMic = () => {
221    toggleMediaStream('audio', micActive);
222    setMicActive((prev) => !prev);
223  };
224
225  const toggleCamera = () => {
226    toggleMediaStream('video', cameraActive);
227    setCameraActive((prev) => !prev);
228  };
229
230  const leaveRoom = () => {
231    socketRef.current.emit('leave', roomName); // Let's the server know that user has left the room.
232
233    if (userVideoRef.current.srcObject) {
234      userVideoRef.current.srcObject.getTracks().forEach((track) => track.stop()); // Stops receiving all track of User.
235    }
236    if (peerVideoRef.current.srcObject) {
237      peerVideoRef.current.srcObject
238        .getTracks()
239        .forEach((track) => track.stop()); // Stops receiving audio track of Peer.
240    }
241
242    // Checks if there is peer on the other side and safely closes the existing connection established with the peer.
243    if (rtcConnectionRef.current) {
244      rtcConnectionRef.current.ontrack = null;
245      rtcConnectionRef.current.onicecandidate = null;
246      rtcConnectionRef.current.close();
247      rtcConnectionRef.current = null;
248    }
249    router.push('/')
250  };
251
252  return (
253    <div>
254      <video autoPlay ref={userVideoRef} />
255      <video autoPlay ref={peerVideoRef} />
256      <button onClick={toggleMic} type="button">
257        {micActive ? 'Mute Mic' : 'UnMute Mic'}
258      </button>
259      <button onClick={leaveRoom} type="button">
260        Leave
261      </button>
262      <button onClick={toggleCamera} type="button">
263        {cameraActive ? 'Stop Camera' : 'Start Camera'}
264      </button>
265    </div>
266  );
267};
268
269export default Room;
270

You should be able to open two tabs in your browser and have a video call with yourself to test the functioning app end-to-end!