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

Tauqueer Khan

Software Engineer

8 min

Article Banner

Note: The app we are building can be deloyed on Vercel. We are using a service called Pusher. If you would prefer using websockets and Socket.IO in particular, check out our article "Peer-to-peer video call with Next.js, Socket.io and Native WebRTC APIs". We will also be using Typescript in this project.

Before we write any code, we have to create an account on Pusher. It's Free. Pusher has two products, Channels and Beams. We will be using Channels, which is similar to websockets.

Click 'Create app'

Give your app a name, pick the cluster closest to you, choose React and Node.js, and click Create app.

Ignore the boilerplate code, and on the sidebar you'll see App Keys. Keep this page open.

Head to your terminal and let's create a new Next app.

1npx create-next-app next-webrtc-pusher --typescript

Let's pause a second and see what we need before we keep going. If you read the example in The Beginner's Guide to Understanding WebRTC, there are many times that Rick and Morty talk to the signaling server. Our Pusher API will be used as our signaling server. You can find the docs for Pusher Channels here.

To start, let's take care of some configuration. Create a .env.local file in the root directory and fill out any information from the App Keys page. We'll need this later. The NEXT_PUBLIC versions are for client-side access of environment variables.

1PUSHER_APP_ID = "" <--- app_id
2PUSHER_KEY = ""  <--- key
3NEXT_PUBLIC_PUSHER_KEY = ""  <--- key
4PUSHER_SECRET = ""  <--- secret
5NEXT_PUBLIC_PUSHER_CLUSTER = "us2" <--- cluster
6PUSHER_CLUSTER = "us2"  <--- cluster

Pusher provides a variety of channels that can be used, but we would like to know when a user joins and leaves our call. Pusher calls this "subscribing" to a channel. The idea is similar to sockets where on joining and leaving, events are fired and can be subscribed to. Unlike some of the other channels, Pusher requires authentication for this channel.

Let's start by creating a lib folder in the root directory of the project and create a pusher.ts file. Here we will be instantiating a new Pusher instance to use on the server/api side:

1// lib/pusher.ts
2import Pusher from 'pusher'
3
4export const pusher = new Pusher({
5  appId: process.env.PUSHER_APP_ID!,
6  key: process.env.PUSHER_KEY!,
7  secret: process.env.PUSHER_SECRET!,
8  cluster: process.env.PUSHER_CLUSTER!,
9  useTLS: true
10})

Next, let's create a pusher folder inside pages/api. Inside of the pusher folder, add another folder called auth and a file index.ts inside of it.

We will use this api route to authenticate a user, so whenever a pusher event is fired, Pusher will know who fired it but also who to send it to based on the room or channel_name they belong to. Import the pusher class from the lib/pusher as well as Pusher from the pusher package.

On the client side, we'll have a Pusher instance that will make this api call. This instance will provide the socket_id and channel_name We will provide the username. Creating presenceData requires a user_id and some information about the user. We then pass all this information to the authenticate method, which returns auth data. The idea here is to authenticate a user with Pusher when this endpoint is called, and respond with the auth data that we get from Pusher.

1// pages/api/pusher/auth/index.ts
2
3import { NextApiRequest, NextApiResponse } from "next";
4import Pusher from "pusher";
5import { pusher } from "../../../../lib/pusher";
6
7export default async function handler(
8  req: NextApiRequest,
9  res: NextApiResponse
10): Promise<Pusher.AuthResponse | void> {
11  const { socket_id, channel_name, username } = req.body;
12  const randomString = Math.random().toString(36).slice(2);
13
14  const presenceData = {
15    user_id: randomString,
16    user_info: {
17      username: "@" + username,
18    },
19  };
20
21  try {
22    const auth = pusher.authenticate(socket_id, channel_name, presenceData);
23    res.send(auth);
24  } catch (error) {
25    console.error(error);
26    res.send(500)
27  }
28}

This is all the server-side code we'll need. Since we are using Pusher as the signaling server, the users will send each other information directly via Pusher. There are two ways to go about this. Your server can relay messages to pusher, or your clients broadcast directly to Pusher.

Let's head back to the Pusher dashboard, and for the app you created, go to App Settings and activate Enable client events.

Let's now work with App.tsx.

Here we are setting userName and roomName, in the top-level App component, and we will pass it to every route. (Note: This is not a good way to manage state, but we will keep it simple for this example). We also have a handleCredChange method which updates the userName and roomName as they change, and also a handleLogin method which sends the user to the room.

1// pages/app.tsx
2import '../styles/globals.css';
3import { useState } from 'react';
4import { useRouter } from 'next/router';
5import { AppProps } from 'next/app';
6
7function MyApp({ Component, pageProps }: AppProps) {
8  const [userName, setUserName] = useState('');
9  const [roomName, setRoomName] = useState('');
10  const router = useRouter();
11
12  const handleLogin = (event: Event) => {
13    event.preventDefault();
14    router.push(`/room/${roomName}`);
15  };
16  return (
17    <Component
18      handleCredChange={(userName: string, roomName: string) => {
19        setUserName(userName);
20        setRoomName(roomName);
21      }}
22      userName={userName}
23      roomName={roomName}
24      handleLogin={handleLogin}
25      {...pageProps}
26    />
27  );
28}
29
30export default MyApp;

Open the index.tsx file in the pages directory. We want to create a form that allows users to choose a username and room name that they want to join. We will also import the handleLogin and handleCredChange props.

1// pages/index.tsx
2import Head from 'next/head'
3import Image from 'next/image'
4import { useEffect, useState } from 'react'
5import styles from '../styles/Home.module.css'
6
7interface Props {
8  handleCredChange: (userName: string, roomName: string) => void;
9  handleLogin: () => void;
10}
11
12export default function Home({ handleCredChange, handleLogin }: Props) {
13  const [roomName, setRoomName] = useState('')
14  const [userName, setUserName] = useState('')
15
16  useEffect(() => {
17    handleCredChange(userName, roomName)
18  }, [roomName, userName, handleCredChange])
19
20  return (
21    <div className={styles.container}>
22      <Head>
23        <title>Native WebRTC API with NextJS and Pusher as the Signalling Server</title>
24        <meta name="description" content="Use Native WebRTC API for video conferencing" />
25        <link rel="icon" href="/favicon.ico" />
26      </Head>
27
28      <form className={styles.main} onSubmit={handleLogin}>
29       <h1>Lets join a room!</h1>
30       <input onChange={(e) => setUserName(e.target.value)} value={userName} className={styles['room-name']} placeholder="Enter Username" />
31       <input onChange={(e) => setRoomName(e.target.value)} value={roomName} className={styles['room-name']} placeholder="Enter Room Name"/>
32       <button type="submit" className={styles['join-room']}>Join Room</button>
33      </form>
34    </div>
35  )
36}
37

Next, let's create a room folder and [id].tsx file, where the rest of the code will go. Let's import a few dependencies that we'll need later. We'll be importing Pusher, Members and PresenceChannel. We'll go through these in depth later.

We also have defined a few refs to keep track during rerenders.

1import { useRouter } from "next/router";
2import Pusher, { Members, PresenceChannel } from "pusher-js";
3import { useEffect, useRef, useState } from "react";
4import styles from "../../styles/Room.module.css";
5
6interface Props {
7  userName: string;
8  roomName: string;
9}
10
11export default function Room({ userName, roomName }: Props) {
12  const host = useRef(false);
13  // Pusher specific refs
14  const pusherRef = useRef<Pusher>();
15  const channelRef = useRef<PresenceChannel>();
16
17  // Webrtc refs
18  const rtcConnection = useRef<RTCPeerConnection | null>();
19  const userStream = useRef<MediaStream>();
20
21  const userVideo = useRef<HTMLVideoElement>(null);
22  const partnerVideo = useRef<HTMLVideoElement>(null);
23
24return (
25    <div className={styles["videos-container"]}>
26      <div className={styles["video-container"]}>
27        <video autoPlay ref={userVideo} />
28      </div>
29      <div className={styles["video-container"]}>
30        <video autoPlay ref={partnerVideo} />
31      </div>
32    </div>
33  );
34}
35

Connecting to Pusher and joining channel

The user landing on this room should first get authenticated with Pusher. We do this by instantiating a new Pusher instance and assigning it to the pusherRef. The new Pusher instance constructor requires the Pusher public key (NEXT_PUBLIC_PUSHER_KEY) and some options, which include authEndpoint, auth data and the cluster(NEXT_PUBLIC_PUSHER_CLUSTER) we'll be connecting to. This call will return the authentication data necessary to make future calls to Pusher.

Now we want to subscribe to a channel. The way channel names work in Pusher is that they have to be prefixed by the channel type and a dash, followed by the channel name. In our case, that is presense-[roomname].

1import { useRouter } from "next/router";
2import Pusher, { Members, PresenceChannel } from "pusher-js";
3import { useEffect, useRef, useState } from "react";
4import styles from "../../styles/Room.module.css";
5
6interface Props {
7  userName: string;
8  roomName: string;
9}
10
11export default function Room({ userName, roomName }: Props) {
12  const host = useRef(false);
13  // Pusher specific refs
14  const pusherRef = useRef<Pusher>();
15  const channelRef = useRef<PresenceChannel>();
16
17  // Webrtc refs
18  const rtcConnection = useRef<RTCPeerConnection | null>();
19  const userStream = useRef<MediaStream>();
20
21  const userVideo = useRef<HTMLVideoElement>(null);
22  const partnerVideo = useRef<HTMLVideoElement>(null);
23  useEffect(() => {
24    pusherRef.current = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
25      authEndpoint: "/api/pusher/auth",
26      auth: {
27        params: { username: userName },
28      },
29      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER,
30    });
31    
32    channelRef.current = pusherRef.current.subscribe(
33      `presence-${roomName}`
34    ) as PresenceChannel;
35  }, [userName, roomName])
36  
37return (
38    <div className={styles["videos-container"]}>
39      <div className={styles["video-container"]}>
40        <video autoPlay ref={userVideo} />
41      </div>
42      <div className={styles["video-container"]}>
43        <video autoPlay ref={partnerVideo} />
44      </div>
45    </div>
46  );
47}
48

Pusher Events

Once we have subscribed to a channel, we need to subscribe to events in this channel. Let's first explore events that come out of the box from Pusher that we'll need, and then we will explore custom events.

All events that are provided by Pusher come prefixed with "pusher:" followed by the event type. These events are only available in the Presence channel. The events are as follows:

  • pusher:subscription_succeeded: this will be the first event to be triggered when the user joins a channel (or room, we will be using this term interchangeably). A callback provided to this event is called. The callback looks for the following,
    • if no one is in the room, make the first user the host.
    • if there are two people in the room and third joins, we will then redirect the third user to the home screen.
    • Both the first and second users that join will call the handlRoomJoined method.
  • pusher:member_removed: this event is fired when either one of the members leave. This event is handled with the handlePeerLeaving method. We'll define this method close to the end when we have things to clean up.

All these events will go inside the same useEffect:

1import { useRouter } from "next/router";
2import Pusher, { Members, PresenceChannel } from "pusher-js";
3import { useEffect, useRef, useState } from "react";
4import styles from "../../styles/Room.module.css";
5
6interface Props {
7  userName: string;
8  roomName: string;
9}
10
11export default function Room({ userName, roomName }: Props) {
12  const host = useRef(false);
13  // Pusher specific refs
14  const pusherRef = useRef<Pusher>();
15  const channelRef = useRef<PresenceChannel>();
16
17  // Webrtc refs
18  const rtcConnection = useRef<RTCPeerConnection | null>();
19  const userStream = useRef<MediaStream>();
20
21  const userVideo = useRef<HTMLVideoElement>(null);
22  const partnerVideo = useRef<HTMLVideoElement>(null);
23  useEffect(() => {
24    pusherRef.current = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
25      authEndpoint: "/api/pusher/auth",
26      auth: {
27        params: { username: userName },
28      },
29      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER,
30    });
31    channelRef.current.bind(
32      'pusher:subscription_succeeded',
33      (members: Members) => {
34        if (members.count === 1) {
35          // when subscribing, if you are the first member, you are the host
36          host.current = true
37        }
38        
39        // example only supports 2 users per call
40        if (members.count > 2) {
41          // 3+ person joining will get sent back home
42          // Can handle however you'd like
43          router.push('/')
44        }
45        handleRoomJoined()
46      }
47    )
48    // when a member leaves the chat
49    channelRef.current.bind("pusher:member_removed", handlePeerLeaving);
50  }, [userName, roomName])
51  
52return (
53    <div className={styles["videos-container"]}>
54      <div className={styles["video-container"]}>
55        <video autoPlay ref={userVideo} />
56      </div>
57      <div className={styles["video-container"]}>
58        <video autoPlay ref={partnerVideo} />
59      </div>
60    </div>
61  );
62}
63

Before moving on to the other events, let's define handleRoomJoined. When both users join the channel, the handleRoomJoined method grabs the media streams from the camera and audio via navigator.mediaDevice.getUserMedia and stores the stream's reference in the userStream ref. It also assigns a stream to the userVideo. The idea here is that we want access to the streams BEFORE we initiate webRTC connection process, since grabbing streams is asyncronous. This isn't as much of an issue for the first/host user, but the second user should let the host know when they are ready (via client-ready event, more below) and have grabbed their stream before starting the webRTC connection process. You can read more about the overall webRTC process in our Beginner's Guide to Understanding WebRTC.

1import { useRouter } from "next/router";
2import Pusher, { Members, PresenceChannel } from "pusher-js";
3import { useEffect, useRef, useState } from "react";
4import styles from "../../styles/Room.module.css";
5
6interface Props {
7  userName: string;
8  roomName: string;
9}
10
11export default function Room({ userName, roomName }: Props) {
12  const host = useRef(false);
13  // Pusher specific refs
14  const pusherRef = useRef<Pusher>();
15  const channelRef = useRef<PresenceChannel>();
16
17  // Webrtc refs
18  const rtcConnection = useRef<RTCPeerConnection | null>();
19  const userStream = useRef<MediaStream>();
20
21  const userVideo = useRef<HTMLVideoElement>(null);
22  const partnerVideo = useRef<HTMLVideoElement>(null);
23  useEffect(() => {
24    pusherRef.current = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
25      authEndpoint: "/api/pusher/auth",
26      auth: {
27        params: { username: userName },
28      },
29      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER,
30    });
31    channelRef.current.bind(
32      'pusher:subscription_succeeded',
33      (members: Members) => {
34        if (members.count === 1) {
35          // when subscribing, if you are the first member, you are the host
36          host.current = true
37        }
38        
39        // example only supports 2 users per call
40        if (members.count > 2) {
41          // 3+ person joining will get sent back home
42          // Can handle however you'd like
43          router.push('/')
44        }
45        handleRoomJoined()
46      }
47    )
48    // when a member leaves the chat
49    channelRef.current.bind("pusher:member_removed", handlePeerLeaving);
50  }, [userName, roomName])
51  
52  const handleRoomJoined = () => {
53    navigator.mediaDevices
54      .getUserMedia({
55        audio: true,
56        video: { width: 1280, height: 720 },
57      })
58      .then((stream) => {
59        /* use the stream */
60        userStream.current = stream
61        userVideo.current!.srcObject = stream
62        userVideo.current!.onloadedmetadata = () => {
63          userVideo.current!.play()
64        }
65        if (!host.current) {
66          // the 2nd peer joining will tell to host they are ready
67          console.log('triggering client ready')
68          channelRef.current!.trigger('client-ready', {})
69        }
70      })
71      .catch((err) => {
72        /* handle the error */
73        console.log(err)
74      })
75  }
76return (
77    <div className={styles["videos-container"]}>
78      <div className={styles["video-container"]}>
79        <video autoPlay ref={userVideo} />
80      </div>
81      <div className={styles["video-container"]}>
82        <video autoPlay ref={partnerVideo} />
83      </div>
84    </div>
85  );
86}
87

Custom Client Events

The next kind of events we need to bind (or subscribe) are custom client-side events. These are events that will be received from the other peer. As with the Pusher specific events being prefixed with "pusher:", client events are prefixed with "client-" to denote that these events are being sent from one client to the other.

The custom client events that we will be firing and listening for are as follows:

  • client-ready: This event is called when the non-host user has grabbed their media and tells the host that they are ready to initiate the WebRTC connection. When the host receives this event, they will call the initiateCall method. This method will create an RTCPeerConnection and add a couple of event listeners to it (more on that later). We then take the connection and add the audio and video tracks, and lastly create an Offer to send to the non-host user by triggering the client-offer event.
1// inside useEffect
2    channelRef.current.bind('client-ready', () => {
3      initiateCall()
4    })
5
6// outside useEffect but inside [id].tsx component
7 const initiateCall = () => {
8    if (host.current) {
9      rtcConnection.current = createPeerConnection()
10      // Host creates offer
11      userStream.current?.getTracks().forEach((track) => {
12        rtcConnection.current?.addTrack(track, userStream.current!);
13      });
14      rtcConnection
15        .current!.createOffer()
16        .then((offer) => {
17          rtcConnection.current!.setLocalDescription(offer)
18          // 4. Send offer to other peer via pusher
19          // Note: 'client-' prefix means this event is not being sent directly from the client
20          // This options needs to be turned on in Pusher app settings
21          channelRef.current?.trigger('client-offer', offer)
22        })
23        .catch((error) => {
24          console.log(error)
25        })
26    }
27  }
28  
29  const createPeerConnection = () => {
30    // We create a RTC Peer Connection
31    const connection = new RTCPeerConnection(ICE_SERVERS)
32  
33    // We implement our onicecandidate method for when we received a ICE candidate from the STUN server
34    connection.onicecandidate = handleICECandidateEvent
35  
36    // We implement our onTrack method for when we receive tracks
37    connection.ontrack = handleTrackEvent
38    connection.onicecandidateerror = (e) => console.log(e)
39    return connection
40  }
41  
42  const ICE_SERVERS = {
43    // you can add TURN servers here too
44    iceServers: [
45      {
46        urls: 'stun:openrelay.metered.ca:80'
47      },
48      {
49        urls: 'stun:stun.l.google.com:19302',
50      },
51      {
52        urls: 'stun:stun2.l.google.com:19302',
53      },
54    ],
55  }
  • client-offer: When receiving this event, the non-host user will call the handleReceivedOffer method and pass it the Offer object. The method is similar to initiateCall in that it will create an RTCPeerConnection, and adds media to the connection, but instead of creating an Offer, the non-host user will create an Answer for the host and send that to the host by triggering the client-answer event.
1// inside useEffect
2channelRef.current.bind(
3  'client-offer',
4  (offer: RTCSessionDescriptionInit) => {
5    // offer is sent by the host, so only non-host should handle it
6    if (!host.current) {
7      handleReceivedOffer(offer)
8    }
9  }
10)
11
12// inside component but outside useEffect
13const handleReceivedOffer = (offer: RTCSessionDescriptionInit) => {
14  rtcConnection.current = createPeerConnection()
15  userStream.current?.getTracks().forEach((track) => {
16    // Adding tracks to the RTCPeerConnection to give peer access to it
17    rtcConnection.current?.addTrack(track, userStream.current!)
18  })
19
20  rtcConnection.current.setRemoteDescription(offer)
21  rtcConnection
22    .current.createAnswer()
23    .then((answer) => {
24      rtcConnection.current!.setLocalDescription(answer)
25      channelRef.current?.trigger('client-answer', answer)
26    })
27    .catch((error) => {
28      console.log(error)
29    })
30
31}

A quick note about Offer and Answer. The Offer is created by the host and provides the necessary information needed for the second peer to connect with the host. The host will store the Offer in the connection's localDescription. The Answer will be stored in the connection's remoteDescription. Lastly, the non-host does the opposite (Answer -> localDescription, Offer -> remoteDescription).

  • client-answer: Once the non-host sends the answer, the host is listening for the client-answer event and calls the handleAnswerReceived method
1// inside useEffect
2channelRef.current.bind(
3  'client-answer',
4  (answer: RTCSessionDescriptionInit) => {
5    // answer is sent by non-host, so only host should handle it
6    if (host.current) {
7      handleAnswerReceived(answer as RTCSessionDescriptionInit)
8    }
9  }
10)
11
12// inside component outside useEfect
13const handleAnswerReceived = (answer: RTCSessionDescriptionInit) => {
14  rtcConnection
15    .current!.setRemoteDescription(answer)
16    .catch((error) => console.log(error))
17}
  • client-ice-candidate: This event is interesting because it is triggered by another event handleICECandidateEvent. When we create the RTCPeerConnection, it provides us with two (among others) events to subscribe to. The ontrack and onicecandidate events. The onicecandidate function is fired every time we receive an ice-candidate, and then the ice-candidate is sent to the other peer by triggering the client-ice-candidate. When the other peer receives the ice-candidate, they add it to their rtcConnection via the addIceCandidate method. The ontrack event is fired when a track is added to a connection and sent to the peer. This event gives the peer access to the other peers media streams.
1// inside the useEffect
2channelRef.current.bind(
3  'client-ice-candidate',
4  (iceCandidate: RTCIceCandidate) => {
5    // answer is sent by non-host, so only host should handle it
6    handlerNewIceCandidateMsg(iceCandidate)
7  }
8)
9
10const handleICECandidateEvent = async (event: RTCPeerConnectionIceEvent) => {
11  if (event.candidate) {
12    // return sentToPusher('ice-candidate', event.candidate)
13    channelRef.current?.trigger('client-ice-candidate', event.candidate)
14  }
15}
16
17const handlerNewIceCandidateMsg = (incoming: RTCIceCandidate) => {
18  // We cast the incoming candidate to RTCIceCandidate
19  const candidate = new RTCIceCandidate(incoming)
20  rtcConnection
21    .current!.addIceCandidate(candidate)
22    .catch((error) => console.log(error))
23}
24
25const handleTrackEvent = (event: RTCTrackEvent) => {
26  partnerVideo.current!.srcObject = event.streams[0]
27}

Lastly, once one of the peers leaves the call, Pusher fires the pusher:member_removed event, and we handle it in the handlePeerLeaving method. The remaining peer will become the host and all the other refs will be cleared out.

1const handlePeerLeaving = () => {
2  host.current = true
3  if (partnerVideo.current?.srcObject) {
4    ;(partnerVideo.current.srcObject as MediaStream)
5      .getTracks()
6      .forEach((track) => track.stop()) // Stops receiving all track of Peer.
7  }
8
9  // Safely closes the existing connection established with the peer who left.
10  if (rtcConnection.current) {
11    rtcConnection.current.ontrack = null
12    rtcConnection.current.onicecandidate = null
13    rtcConnection.current.close()
14    rtcConnection.current = null
15  }
16}

That's it! There are a few other things that can be added, such as a mute button and turning the camera on/off. That is included in the complete code below:

1import { useRouter } from 'next/router'
2import Pusher, { Members, PresenceChannel } from 'pusher-js'
3import { useEffect, useRef, useState } from 'react'
4import styles from '../../styles/Room.module.css'
5
6interface Props {
7  userName: string
8  roomName: string
9}
10
11const ICE_SERVERS = {
12  // you can add TURN servers here too
13  iceServers: [
14    {
15      urls: 'stun:openrelay.metered.ca:80'
16    },
17    {
18      urls: 'stun:stun.l.google.com:19302',
19    },
20    {
21      urls: 'stun:stun2.l.google.com:19302',
22    },
23  ],
24}
25
26export default function Room({ userName, roomName }: Props) {
27  const [micActive, setMicActive] = useState(true)
28  const [cameraActive, setCameraActive] = useState(true)
29  const router = useRouter()
30
31  const host = useRef(false)
32  // Pusher specific refs
33  const pusherRef = useRef<Pusher>()
34  const channelRef = useRef<PresenceChannel>()
35
36  // Webrtc refs
37  const rtcConnection = useRef<RTCPeerConnection | null>()
38  const userStream = useRef<MediaStream>()
39
40  const userVideo = useRef<HTMLVideoElement>(null)
41  const partnerVideo = useRef<HTMLVideoElement>(null)
42
43  useEffect(() => {
44    pusherRef.current = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
45      authEndpoint: '/api/pusher/auth',
46      auth: {
47        params: { username: userName },
48      },
49      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER,
50    })
51    channelRef.current = pusherRef.current.subscribe(
52      `presence-${roomName}`
53    ) as PresenceChannel
54    // when a users subscribe
55    channelRef.current.bind(
56      'pusher:subscription_succeeded',
57      (members: Members) => {
58        if (members.count === 1) {
59          // when subscribing, if you are the first member, you are the host
60          host.current = true
61        }
62        
63        // example only supports 2 users per call
64        if (members.count > 2) {
65          // 3+ person joining will get sent back home
66          // Can handle this however you'd like
67          router.push('/')
68        }
69        handleRoomJoined()
70      }
71    )
72
73    // when a member leaves the chat
74    channelRef.current.bind('pusher:member_removed', () => {
75      handlePeerLeaving()
76    })
77
78    channelRef.current.bind(
79      'client-offer',
80      (offer: RTCSessionDescriptionInit) => {
81        // offer is sent by the host, so only non-host should handle it
82        if (!host.current) {
83          handleReceivedOffer(offer)
84        }
85      }
86    )
87
88    // When the second peer tells host they are ready to start the call
89    // This happens after the second peer has grabbed their media
90    channelRef.current.bind('client-ready', () => {
91      initiateCall()
92    })
93
94    channelRef.current.bind(
95      'client-answer',
96      (answer: RTCSessionDescriptionInit) => {
97        // answer is sent by non-host, so only host should handle it
98        if (host.current) {
99          handleAnswerReceived(answer as RTCSessionDescriptionInit)
100        }
101      }
102    )
103
104    channelRef.current.bind(
105      'client-ice-candidate',
106      (iceCandidate: RTCIceCandidate) => {
107        // answer is sent by non-host, so only host should handle it
108        handlerNewIceCandidateMsg(iceCandidate)
109      }
110    )
111
112    return () => {
113      if (pusherRef.current)
114        pusherRef.current.unsubscribe(`presence-${roomName}`)
115    }
116  }, [userName, roomName])
117
118  const handleRoomJoined = () => {
119    navigator.mediaDevices
120      .getUserMedia({
121        audio: true,
122        video: { width: 1280, height: 720 },
123      })
124      .then((stream) => {
125        /* store reference to the stream and provide it to the video element */
126        userStream.current = stream
127        userVideo.current!.srcObject = stream
128        userVideo.current!.onloadedmetadata = () => {
129          userVideo.current!.play()
130        }
131        if (!host.current) {
132          // the 2nd peer joining will tell to host they are ready
133          channelRef.current!.trigger('client-ready', {})
134        }
135      })
136      .catch((err) => {
137        /* handle the error */
138        console.log(err)
139      })
140  }
141
142  const createPeerConnection = () => {
143    // We create a RTC Peer Connection
144    const connection = new RTCPeerConnection(ICE_SERVERS)
145
146    // We implement our onicecandidate method for when we received a ICE candidate from the STUN server
147    connection.onicecandidate = handleICECandidateEvent
148
149    // We implement our onTrack method for when we receive tracks
150    connection.ontrack = handleTrackEvent
151    connection.onicecandidateerror = (e) => console.log(e)
152    return connection
153  }
154
155
156  const initiateCall = () => {
157    if (host.current) {
158      rtcConnection.current = createPeerConnection()
159      // Host creates offer
160      userStream.current?.getTracks().forEach((track) => {
161        rtcConnection.current?.addTrack(track, userStream.current!);
162      });
163      rtcConnection
164        .current!.createOffer()
165        .then((offer) => {
166          rtcConnection.current!.setLocalDescription(offer)
167          // 4. Send offer to other peer via pusher
168          // Note: 'client-' prefix means this event is not being sent directly from the client
169          // This options needs to be turned on in Pusher app settings
170          channelRef.current?.trigger('client-offer', offer)
171        })
172        .catch((error) => {
173          console.log(error)
174        })
175    }
176  }
177
178  const handleReceivedOffer = (offer: RTCSessionDescriptionInit) => {
179    rtcConnection.current = createPeerConnection()
180    userStream.current?.getTracks().forEach((track) => {
181      // Adding tracks to the RTCPeerConnection to give peer access to it
182      rtcConnection.current?.addTrack(track, userStream.current!)
183    })
184
185    rtcConnection.current.setRemoteDescription(offer)
186    rtcConnection
187      .current.createAnswer()
188      .then((answer) => {
189        rtcConnection.current!.setLocalDescription(answer)
190        channelRef.current?.trigger('client-answer', answer)
191      })
192      .catch((error) => {
193        console.log(error)
194      })
195
196  }
197  const handleAnswerReceived = (answer: RTCSessionDescriptionInit) => {
198    rtcConnection
199      .current!.setRemoteDescription(answer)
200      .catch((error) => console.log(error))
201  }
202
203  const handleICECandidateEvent = async (event: RTCPeerConnectionIceEvent) => {
204    if (event.candidate) {
205      // return sentToPusher('ice-candidate', event.candidate)
206      channelRef.current?.trigger('client-ice-candidate', event.candidate)
207    }
208  }
209
210  const handlerNewIceCandidateMsg = (incoming: RTCIceCandidate) => {
211    // We cast the incoming candidate to RTCIceCandidate
212    const candidate = new RTCIceCandidate(incoming)
213    rtcConnection
214      .current!.addIceCandidate(candidate)
215      .catch((error) => console.log(error))
216  }
217
218  const handleTrackEvent = (event: RTCTrackEvent) => {
219    partnerVideo.current!.srcObject = event.streams[0]
220  }
221
222  const toggleMediaStream = (type: 'video' | 'audio', state: boolean) => {
223    userStream.current!.getTracks().forEach((track) => {
224      if (track.kind === type) {
225        track.enabled = !state
226      }
227    })
228  }
229
230  const handlePeerLeaving = () => {
231    host.current = true
232    if (partnerVideo.current?.srcObject) {
233      ;(partnerVideo.current.srcObject as MediaStream)
234        .getTracks()
235        .forEach((track) => track.stop()) // Stops receiving all track of Peer.
236    }
237
238    // Safely closes the existing connection established with the peer who left.
239    if (rtcConnection.current) {
240      rtcConnection.current.ontrack = null
241      rtcConnection.current.onicecandidate = null
242      rtcConnection.current.close()
243      rtcConnection.current = null
244    }
245  }
246
247  const leaveRoom = () => {
248    // socketRef.current.emit('leave', roomName); // Let's the server know that user has left the room.
249
250    if (userVideo.current!.srcObject) {
251      ;(userVideo.current!.srcObject as MediaStream)
252        .getTracks()
253        .forEach((track) => track.stop()) // Stops sending all tracks of User.
254    }
255    if (partnerVideo.current!.srcObject) {
256      ;(partnerVideo.current!.srcObject as MediaStream)
257        .getTracks()
258        .forEach((track) => track.stop()) // Stops receiving all tracks from Peer.
259    }
260
261    // Checks if there is peer on the other side and safely closes the existing connection established with the peer.
262    if (rtcConnection.current) {
263      rtcConnection.current.ontrack = null
264      rtcConnection.current.onicecandidate = null
265      rtcConnection.current.close()
266      rtcConnection.current = null
267    }
268
269    router.push('/')
270  }
271
272  const toggleMic = () => {
273    toggleMediaStream('audio', micActive)
274    setMicActive((prev) => !prev)
275  }
276
277  const toggleCamera = () => {
278    toggleMediaStream('video', cameraActive)
279    setCameraActive((prev) => !prev)
280  }
281
282  return (
283    <div>
284      <div className={styles['videos-container']}>
285        <div className={styles['video-container']}>
286          <video autoPlay ref={userVideo} muted />
287          <div>
288            <button onClick={toggleMic} type="button">
289              {micActive ? 'Mute Mic' : 'UnMute Mic'}
290            </button>
291            <button onClick={leaveRoom} type="button">
292              Leave
293            </button>
294            <button onClick={toggleCamera} type="button">
295              {cameraActive ? 'Stop Camera' : 'Start Camera'}
296            </button>
297          </div>
298        </div>
299        <div className={styles['video-container']}>
300          <video autoPlay ref={partnerVideo} />
301        </div>
302      </div>
303    </div>
304  )
305}
306