Skip to main content

Join a conference

In this lesson we will learn how to modify the starter_code and add changes to allow us to join a conference. During this journey we will perform the following tasks:

  • Define the callbacks for some Infinity events.
  • Update the connectionState and change the visible component depending on the current state (Preflight, Loading, Conference or Error).
  • Join a conference with the proper parameters.
  • Get the local stream (camera feed) and attach it to a HTML video element.
  • Get the remote stream (remote feed) and attach it to a HTML video element.
  • Disconnect from a conference gracefully and free the camera.
info

After finishing this lesson our app will be able to join a conference that is not protected by a PIN. If you want to use a PIN, you will have to make the changes that are explained in the next lesson.

You can download the starter code from Step.01-Exercise-Join-a-conference.

App component

We will start by modifying the file located in src/App.tsx. This file contains the main logic of our application and it's the first file that we need to change.

Create the Infinity Client

We will start by creating the Infinity Client. The Infinity Client is the object that will connect to the Pexip Infinity Conferencing Node and exchange the proper messages to manage a conference.

The first step is to define the signals that our application could receive from Pexip Infinity:

const infinityClientSignals = createInfinityClientSignals([]);
const callSignals = createCallSignals([]);

We will use these objects to get different types of signals:

  • infinityClientSignals: Thanks to this object, we will be able to detect when the call is connected, a participant joins the conference, the layout is changed or somebody raises their hand, but more signals are available.
  • callSignals: These signals contain the basic elements for the calls to work. An example of these signals are onRemoteStream and onRemotePresentationStream. These signals will contain a MediaStream with the video source that we can attach to a HTML video element.

Now we will define the variable that will contain the instance of the Infinity Client:

let infinityClient: InfinityClient;

And inside an useEffect hook, we will create an instance of the infinityClient. We also need to define the dependency (error), so in case of a problem, we will create a new instance:

useEffect(() => {
infinityClient = createInfinityClient(infinityClientSignals, callSignals);
}, [error]);

Define the possible app states

It's import to keep track of the app state. The app needs to know if we are in the middle of a call, trying to connect or if there was any kind of issue.

We will start by defining a constant that contains all the possible states:

enum ConnectionState {
Disconnected,
Connecting,
Connected,
Error,
}

Now we will use the React useState hook to define some state variables:

const [connectionState, setConnectionState] = useState<ConnectionState>(
ConnectionState.Disconnected,
);
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [error, setError] = useState('');

With only these four variables we can define the whole app state:

  • connectionState: This state variable will store the current state for the connection. It will have one of the values that we have defined previously: Disconnected, Connecting, Connected and Error.
  • localStream: This variable will contain a MediaStream with the local video obtained directly from the user's webcam. We will attach this stream to a HTML Video element and also send it to Pexip Infinity.
  • remoteStream: In a similar way, we will have a MediaStream that contains the video of all the remote participants mixed in a single stream. This stream is obtained from the Infinity Conferencing Node and, in the same way that with the localStream, we will attach it to a HTML Video element.
  • error: This variable is used to save a string with the error that happened when the user tries to join a conference. If the call was successful, it will be an empty string.

Set the event handlers

Now we need to define all the callback functions for each one of the events. We will start by defining a function that will be called each time the user tries to join a conference:

const handleStartConference = async (
nodeDomain: string,
conferenceAlias: string,
displayName: string,
) => {
setConnectionState(ConnectionState.Connecting);
const localStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
setLocalStream(localStream);
const response = await infinityClient.call({
callType: ClientCallType.AudioVideo,
node: nodeDomain,
conferenceAlias,
displayName,
bandwidth: 0,
mediaStream: localStream,
});
if (response != null) {
if (response.status !== 200) {
localStream.getTracks().forEach(track => track.stop());
setLocalStream(null);
}
switch (response.status) {
case 200:
setConnectionState(ConnectionState.Connected);
break;
case 403: {
setConnectionState(ConnectionState.Error);
setError('The conference is protected by PIN');
break;
}
case 404: {
setConnectionState(ConnectionState.Error);
setError("The conference doesn't exist");
break;
}
default: {
setConnectionState(ConnectionState.Error);
setError('Internal error');
break;
}
}
} else {
setConnectionState(ConnectionState.Error);
setError("The server isn't available");
}
};

This is a long function, but it makes almost everything that our application needs:

  • It changes the connection state to Connecting, so the interface can display the Loading component.
  • Call the getUserMedia() method. This is a method from the browser API to request access to the microphone and camera. Thanks to this method, we can get a MediaStream with audio and video.
  • Assign the previous MediaStream to the localStream state variable. This way the local camera feed can be displayed in our interface.
  • Call infinityClient.call() with all the conference info that we want to start. Two parameters need to be mentioned:
    • callType: This parameter defines how we want to join the meeting. In this case we define that we want audio and video, but we can define that we only want to send video, receive audio and so on.
    • node: Through this parameter we can indicate which Conferencing Node we want to use. This parameter could be a domain or an IP address.
    • displayName: This is the username that the other participants will see for this user.
    • bandwidth: We can limit the bandwidth to use. In this case we set the value to 0 indicating that we don't want to use the bandwidth limitation.
    • mediaStream: This is the stream with local audio and video that we will send to Pexip Infinity.
  • Change the connection state accordingly to the call response.

Now we define the disconnect handler:

const handleDisconnect = () => {
localStream?.getTracks().forEach(track => track.stop());
infinityClient.disconnect({reason: 'User initiated disconnect'});
setConnectionState(ConnectionState.Disconnected);
};

In this case, the fist thing that this handler will do is to stop the localStream. That means that the application will release the camera, so it will be available for other applications. You will also see that the camera led will turn off.

Later on, it will disconnect from the meeting indicating the reason and change the connection state to Disconnected.

Assign signals to their handlers

Now we will define which signal we want to listen and what should happen when one of these signals are received:

useEffect(() => {
callSignals.onRemoteStream.add(stream => setRemoteStream(stream));
infinityClientSignals.onError.add(error => {
setConnectionState(ConnectionState.Error);
setError(error.error);
});
infinityClientSignals.onDisconnected.add(() =>
setConnectionState(ConnectionState.Disconnected),
);
const disconnectBrowserClosed = () => {
infinityClient.disconnect({reason: 'Browser closed'});
};
window.addEventListener('beforeunload', disconnectBrowserClosed);
return () =>
window.removeEventListener('beforeunload', disconnectBrowserClosed);
}, []);

This piece of code run when the Conference component is created and it does the following:

  • Set the remote stream when the onRemoteStream signal is received.
  • Trigger the proper handler when the app receives an error in the onError signal. In this case, it changes the connection state to Error and define the string that contains the error itself in another variable.
  • Change the state to Disconnected if the app receives the onDisconnect signal. This happens if a host kick another user out of a conference.
  • Finally, assign a listener to the event beforeunload. This event is triggered when the user closes the window or tab that is running the app and, in this case, we will disconnect the user from the conference.

Return component based on state

Now we will center our focus in the application rendering. We will choose what component we should show to the user depending on the application state:

let component;
switch (connectionState) {
case ConnectionState.Connecting:
component = <Loading />;
break;
case ConnectionState.Connected:
component = (
<Conference
localStream={localStream}
remoteStream={remoteStream}
onDisconnect={handleDisconnect}
/>
);
break;
case ConnectionState.Error:
component = (
<Error
message={error}
onClose={() => setConnectionState(ConnectionState.Disconnected)}
/>
);
break;
default:
component = <Preflight onSubmit={handleStartConference} />;
break;
}

The last step is to return the component that was selected in the switch statement:

return (
<div className="App" data-testid="App">
{component}
</div>
);

Conference component

This component is located in src/components/Conference/Conference.tsx and it will be displayed once the user joins a conference.

The first thing that we will do is to create a new component called Video. This component will add an abstraction layer and simplify the creation of the conference component.

We define first all the possible props that it can accept:

interface VideoProps {
className: string;
mediaStream: MediaStream | null;
muted?: boolean;
onClick?: (event: React.MouseEvent<HTMLVideoElement>) => void;
}

And now the component itself:

const Video = React.memo((props: VideoProps) => {
return (
<video
className={props.className}
autoPlay
playsInline
muted={props.muted}
onClick={props.onClick}
ref={element => {
if (element) element.srcObject = props.mediaStream;
}}
/>
);
});

Some things that we need to consider regarding the Video component:

  • We need to use React.memo to prevent to re-render the component when we don't have changes in the props. If we didn't use this, we would notice flickering each time the video is re-rendered.
  • We need to use autoPlay to start the video automatically. If we didn't use it, we would have to call video.play() manually which is not a good practice.
  • If we want to support iOS, we need to include playsInline. If we forget to add this parameter, the video will be played in fullscreen and it will break the whole experience.
  • We need to define the muted prop for this component. This way we can define easily if the Video should have audio or not. For example, the local video shouldn't have audio, meanwhile the remote video with the rest of the participants should have it. If we forgot to add this parameter, we would experience a small issue as the user will hear themself when they were talking.
  • The last thing to mention is how the MediaStream is assigned to the HTML Video. In this case the MediaStream is assigned to the srcObject video property.
interface ConferenceProps {
localStream: MediaStream | null;
remoteStream: MediaStream | null;
onDisconnect: () => void;
}

Now we will define what to render for the conference component:

return (
<div className="Conference">
<Video className="remote-video" mediaStream={props.remoteStream} />
<Video
className="local-video"
mediaStream={props.localStream}
muted={true}
/>
<Toolbar className="toolbar" onDisconnect={props.onDisconnect} />
</div>
);

This component consists in three sub-components:

  • remote-video: The video with the rest of the participants and their audio.
  • local-video: The video with the webcam feed of the current user. In this case the audio is muted.
  • toolbar: This component is the one that contains all the buttons to make actions. In the next section we will discuss how it works.

Toolbar component

The last file that we will modify in this lesson is src/components/Conference/Toolbar/Toolbar.tsx. This is the toolbar that allows us to interact with the conference. This time we will implement a new button. This button will be used to disconnect the user from the conference on demand.

The first thing that we will do is to define the function that will be called when the button is pushed. In this case the function is quite simple, since it only calls to the props.onDisconnect() function that we have defined in a previous step.

First of all, we add onDisconnect to the props list:

interface ToolbarProps {
className: string;
onDisconnect: () => void;
}

Now we define the function that will be run when the user pushes the disconnect button:

const handleHangUp = () => {
props.onDisconnect();
};

The final step is to add the button itself to our interface:

return (
<div className={className}>
<Button onClick={handleHangUp} icon={<CallEndIcon />} />
</div>
);

Run the app

You have everything in place to launch your videoconferencing application. Run the command npm start and you will see the app in the browser.

You can compare your code with the solution in Step.01-Solution-Join-a-conference. You can also check the differences with the previous lesson in the git diff.

Join a conference