Peer-to-peer video call with Next.js, Pusher and Native WebRTC APIs
July 11, 2022
8 min
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.
Pusher Channels
Click 'Create app'
Pusher 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.
Pusher App Keys
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.
Pusher 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 thehandlePeerLeaving
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 theinitiateCall
method. This method will create anRTCPeerConnection
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 theclient-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 thehandleReceivedOffer
method and pass it the Offer object. The method is similar toinitiateCall
in that it will create anRTCPeerConnection
, 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 theclient-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 thehandleAnswerReceived
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 eventhandleICECandidateEvent
. When we create theRTCPeerConnection
, it provides us with two (among others) events to subscribe to. Theontrack
andonicecandidate
events. Theonicecandidate
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 theaddIceCandidate
method. Theontrack
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