Join a meeting
In the previous lesson we have learnt how to create a React component to generate a meeting. Now we will learn how to join that meeting.
You can download the starter code from Step.03-Exercise-Join-a-meeting.
StreamInfo interface
We will start by creating another file ./client/src/types/StreamInfo.ts
and
defining our new interface there.
The StreamInfo
interface will be used to store information about each
available stream. This is important because, unlike other solutions, we won't
receive any MediaStream
directly by default. We get a list of available remote
streams and we can subscribe (request_stream
) and unsubscribe (disconnect
)
on demand.
The advantage of this selective approach is that it can save bandwidth and processor power in the end user device. Also, we will be able to choose the quality that we want to receive (simulcast), but we will learn about this in a future lesson.
This is our interface for now, although we will extend it in subsequent lessons:
export interface StreamInfo {
streamId: string;
type: 'audio' | 'video';
participantId: string;
mid?: string;
}
We have three mandatory properties:
streamId
: Unique identifier for the stream.type
: Indicates if it is anaudio
orvideo
stream.participantId
: Unique identifier for the participant that emits the stream.
We have one property that is optional:
mid
: It stands for Media Identification and it's used to get the right transceiver for thestreamId
. We get this value once we request a stream.
Participant component
The Participant
component is located in the file
./client/src/Meeting/RemoteParticipants/Participant.tsx
and it will contain
the video
and audio
HTML elements to render the MediaStreams
for a remote
participant.
We will start by importing all the dependencies that we will use:
import {Cell, Icon, IconTypes, Video, Audio} from '@pexip/components';
import {type Cells} from '@pexip/components/dist/types/src/components/foundation/Grid/Grid.types';
Now we will define the input properties for our component:
interface ParticipantProps {
participantId: string;
md: Cells | undefined;
audioStream: MediaStream | null;
videoStream: MediaStream | null;
}
The properties for this component include:
participantId
: Unique identifier for the user. It will be used as a title for the video.md
: It is Bootstrap parameter that corresponds to a medium size device. It will be used to adapt the component width, since it specifies the number of columns that the component will take in a 12-column layout. If we have a lot of participants in the screen, we will want to see each participant in a smaller section.audioStream
: This is theMediaStream
used for the audio.videoStream
: This is theMediaStream
used for the video.
And now we define the component itself and pass the ParticipantProps
as input:
export const Participant = (props: ParticipantProps): JSX.Element => {
return (
<Cell className="Cell" xs={12} md={props.md}>
{props.videoStream != null && (
<Video
className="RemoteVideo"
title={props.participantId}
srcObject={props.videoStream}
style={{ objectFit: 'cover' }}
/>
)}
{props.videoStream == null && (
<div className="NoStreamContainer">
<Icon
className="ParticipantIcon"
source={IconTypes.IconParticipant}
/>
</div>
)}
{props.audioStream != null && (
<Audio srcObject={props.audioStream} autoPlay={true} />
)}
</Cell>
)
}
We can divide the component into three sections:
RemoteVideo
: Contains aVideo
tag and it's used to display theMediaStream
for video.NoStreamContainer
: This element will be displayed if the video is not active.Audio
: This element will be the container for the audioMediaStream
.
RemoteParticipants component
Next we will implement the RemoteParticipants
component. This component is
located in ./client/src/Meeting/RemoteParticipants/RemoteParticipants.tsx
and
it's responsible for displaying the view with all the participants.
In our implementation, each participant will have only one stream, but we could have chosen to have several streams per participant if we would have liked to have that.
import {type StreamInfo} from '../../types/StreamInfo';
import {type TransceiverConfig} from '@pexip/peer-connection';
import {Participant} from './Participant';
We will start by defining the properties for the component:
interface RemoteParticipantsProps {
remoteParticipantsIds: string[];
streamsInfo: StreamInfo[];
remoteTransceiversConfig: TransceiverConfig[];
}
In this component we receive three properties:
remoteParticipantsIds
: An array of strings with the IDs of all remote participants. This excludes the local participant ID.streamsInfo
: This object will help us to match the remote stream of thetransceiversConfig
with the participant ID.transceiversConfig
: Information about all the available transceivers. It contains theMediaStreams
foraudio
andvideo
for all the participants in the call.
We define the properties for the component:
export const RemoteParticipants = (props: RemoteParticipantsProps): JSX.Element => {
And inside the RemoteParticipant
we deconstruct the props:
const {remoteParticipantsIds, streamsInfo, remoteTransceiversConfig} = props;
Now we define the function getMediaStreams
in which, using the
participantId
, we get the audio and video streams for a specific participant:
const getMediaStreams = (
participantId: string,
): {
audioStream: MediaStream | null;
videoStream: MediaStream | null;
} => {
const audioMid = streamsInfo.find(streamInfo => {
return (
streamInfo.participantId === participantId && streamInfo.type === 'audio'
);
})?.mid;
const videoMid = streamsInfo.find(streamInfo => {
return (
streamInfo.participantId === participantId && streamInfo.type === 'video'
);
})?.mid;
const audioTransceiverConfig =
audioMid != null
? remoteTransceiversConfig.find(
transceiverConfig => transceiverConfig.transceiver?.mid === audioMid,
)
: null;
const videoTransceiverConfig =
videoMid != null
? remoteTransceiversConfig.find(
transceiverConfig => transceiverConfig.transceiver?.mid === videoMid,
)
: null;
const audioStream =
audioTransceiverConfig?.remoteStreams != null
? audioTransceiverConfig.remoteStreams[0]
: null;
const videoStream =
videoTransceiverConfig?.remoteStreams != null
? videoTransceiverConfig.remoteStreams[0]
: null;
return {
audioStream,
videoStream,
};
};
In our front end, we will display the participants in a matrix. This means that all the participants will have the same size. You could define a different layout if you wished; for instance, you could choose to display a main participant filling almost the whole screen and the rest of participants in smaller thumbnails.
For our simple matrix implementation, we will get total number of participants
and use that to calculate the width of each participant. To do this, we define
the value of md
(medium size device in Bootstrap) that refers to the number of
columns that it will take in a 12-column layout:
const totalStreams = remoteParticipantsIds.length;
const columns = Math.ceil(Math.sqrt(totalStreams));
const md = Math.max(Math.round(12 / columns), 1) as any;
Next we build the Participant
elements for each remote participant:
const participants = remoteParticipantsIds.map((participantId) => {
const { audioStream, videoStream } = getMediaStreams(participantId)
return (
<Participant
participantId={participantId}
md={md}
audioStream={audioStream}
videoStream={videoStream}
key={participantId}
/>
)
})
Finally, we put all together and render the component:
return (
<Grid
className="RemoteParticipants"
style={{
display: totalStreams === 1 ? 'block' : 'flex',
flexGrow: totalStreams < 3 ? '1' : 'initial'
}}
>
{participants}
</Grid>
)
Meeting component
The Meeting
component is the main component, it will fill the whole page and
will perform the following tasks:
- Initialize the SDK.
- Attach of the handlers to events.
- Subscribe to streams.
- Create a one-time participant for a meeting.
- Connects to a meeting.
The file for this changes is located in ./client/src/Meeting/Meeting.tsx
.
We start by importing all the dependencies that we will use:
import {useEffect, useState} from 'react';
import {useParams} from 'react-router-dom';
import {
createVpaas,
createVpaasSignals,
createRecvTransceivers,
type Vpaas,
type RosterEntry,
} from '@pexip/vpaas-sdk';
import type {MediaInit, TransceiverConfig} from '@pexip/peer-connection';
import {type Participant} from '@pexip/vpaas-api';
import {type StreamInfo} from '../types/StreamInfo';
import {config} from '../config';
import {RemoteParticipants} from './RemoteParticipants/RemoteParticipants';
import {Selfview} from '@pexip/media-components';
Define constants and variables
At the top of the file we will define some global constants and the vpaas
variable:
const RECV_AUDIO_COUNT = 9;
const RECV_VIDEO_COUNT = 9;
let vpaas: Vpaas;
The two previous constants define the maximum number of incoming streams that
our application will be able to manage. The vpaas
variable will be initialized
later and it will contain all the methods from the VPaaS SDK.
Now, inside the Meeting
function, we define some other constants that will
store the component state:
const {meetingId} = useParams();
const [participant, setParticipant] = useState<Participant>();
const [localStream, setLocalStream] = useState<MediaStream>();
const [remoteTransceiversConfig, setRemoteTransceiversConfig] = useState<
TransceiverConfig[]
>([]);
const [roster, setRoster] = useState<Record<string, RosterEntry>>();
const [streamsInfo, setStreamsInfo] = useState<StreamInfo[]>([]);
Here is a brief description of each of them, but we will explain them more deeply throughout this tutorial:
meetingId
: We useuseParams
to obtain themeetingId
from the URL. This is the id that we obtained after using theCreateMeeting
component.participant
: In this state we save the current participant info. We will need the participant's credentials to join the meeting.localStream
: Stores theMediaStream
with the feed obtained from thegetUserMedia
API. It contents the audio and video.remoteTransceiversConfig
: Array with information about all the remote transceivers.roster
: Object that contains all the participants and their streams properties.streamsInfo
: Array that we will build with information about all the available streams. We will update this array each time we subscribe to a stream.
Initialize the SDK
Now it's time to initialize the SDK. We will create the function initVpaas
that will return an object that we can use to interact with VPaaS:
const initVpaas = (): Vpaas => {
const vpaasSignals = createVpaasSignals();
vpaasSignals.onRosterUpdate.add(handleRosterUpdate);
vpaasSignals.onRemoteStreams.add(handleRemoteStreams);
return createVpaas({vpaasSignals, config: {}});
};
In the function we also attach two function to events:
onRosterUpdate
: This event contains the list ofparticipants
and thestreams
that each participant is emitting.onRemoteStreams
: This event contains information about thetransceiver
configuration.
We will define this handler for these events in the next section.
Handler for events
We have created the vpaas
object and we have attached handlers to the events.
In this section we implement the handler functions for onRosterUpdate
and
onRemoteStreams
.
Handler for Roster Update event
The first handler that we will implement is onRosterUpdate
. This event is
triggered each time a participant connects to the meeting, adds streams, removes
streams or leaves the meeting.
In our function we will do several things:
- Save the
roster
in the state. - Add new streams to the
streamsInfo
array. - Unsubscribe streams and remove the element from
streamsInfo
when a stream is not longer available.
Here is the code to perform these tasks:
const handleRosterUpdate = (roster: Record<string, RosterEntry>): void => {
setRoster(roster);
// Check if we have a new stream
const activeStreamsIds: string[] = [];
for (const [participantId, rosterEntry] of Object.entries(roster)) {
for (const [streamId, stream] of Object.entries(rosterEntry.streams)) {
activeStreamsIds.push(streamId);
const found = streamsInfo.some(
streamInfo => streamInfo.streamId === streamId,
);
if (!found) {
streamsInfo.push({
streamId,
type: stream.type,
participantId,
});
}
}
}
// Check if we should disconnect old streams
streamsInfo.forEach(streamInfo => {
const found = activeStreamsIds.includes(streamInfo.streamId);
if (!found) {
// Disconnect stream
unsubscribeStream(streamInfo).catch(e => {
console.error(e);
});
const index = streamsInfo.findIndex(
stream => stream.streamId === streamInfo.streamId,
);
streamsInfo.splice(index, 1);
}
return found;
});
setStreamsInfo([...streamsInfo]);
};
The above code is easier to understand given an example roster
message:
{
"64p36hxloui65drtbkg7qyzfgi": {
"streams": {
"7c2gsjhloui65hdi22bzhn6sre": {
"codec": "opus",
"semantic": "main",
"type": "audio"
},
"7c7fpqxloui65hdi22bzhn6sre": {
"codec": "vp8",
"semantic": "main",
"type": "video",
"layers": []
}
}
},
"6pyoukxloui65ke5mlrbm5vycy": {
"streams": {
"6xmbophloui65d5ttyzcb7sle4": {
"codec": "opus",
"semantic": "main",
"type": "audio"
},
"6xq6ejxloui65d5ttyzcb7sle4": {
"codec": "vp8",
"semantic": "main",
"type": "video",
"layers": []
}
}
}
}
Handler for Remote Streams event
Now we will jump to the second event, onRemoteStreams
. In this case the
handler will do the following tasks:
- Add the transceiver config to the array if it's need.
- Update the transceiver if it changed.
- Finally, we update the
remoteTransceiversConfig
state:
const handleRemoteStreams = (config: TransceiverConfig): void => {
const index = remoteTransceiversConfig.findIndex(transceiverConfig => {
return transceiverConfig.transceiver?.mid !== config.transceiver?.mid;
});
if (index > 0) {
remoteTransceiversConfig[index] = config;
} else {
remoteTransceiversConfig.push(config);
}
setRemoteTransceiversConfig([...remoteTransceiversConfig]);
};
The parameter that the handler will receive will be similar to the following:
{
"content": "main",
"dirty": false,
"kind": "video",
"allowAutoChangeOfDirection": true,
"relativeDirection": true,
"direction": "recvonly",
"streams": [],
"remoteStreams": [...],
"transceiver": {...},
...
}
Functions to subscribe/unsubscribe
By default we don't receive any MediaStream
from VPaaS, so we need to define a
couple of functions to subscribe and unsubscribe to the available streams.
Subscribe to a stream
In this section we learn how to subscribe to streams. You should take into account that a stream could be an audio or video, so we will need to subscribe to two streams per participant.
For this task, we will make use of the SDK function requestStream()
. This
function will send a message to VPaaS to request the stream and return the
receive_mid
. The unique Media ID (MID) is part of the SDP in WebRTC and helps
to distinguish between different streams. During this lesson we will learn how
to use the mid
to obtain the corresponding MediaStream
.
const subscribeStream = async (streamInfo: StreamInfo): Promise<void> => {
const response = await vpaas.requestStream({
producer_id: streamInfo.participantId,
stream_id: streamInfo.streamId,
rid: null,
receive_mid: null,
});
streamInfo.mid = response.receive_mid;
setStreamsInfo([...streamsInfo]);
};
Once we get the response from the request, we save it into the streamsInfo
state. Later, in the useEffect
hook, we will use this info to obtain the
requested MediaStream
.
Unsubscribe from a stream
Now we will do the opposite. We need to unsubscribe from a stream to which we had previously subscribed. This function will perform the following tasks:
- Disconnect the stream using the SDK function
disconnectStream()
and pass the value of themid
. - Set the
mid = undefined
in thestreamInfo
. This way we annotate that we aren't subscribed to the stream. - Finally, we save these changes in the
streamsInfo
array.
const unsubscribeStream = async (streamInfo: StreamInfo): Promise<void> => {
const mid = streamInfo.mid;
if (mid != null) {
await vpaas.disconnectStream({
receive_mid: mid,
});
}
streamInfo.mid = undefined;
setStreamsInfo([...streamsInfo]);
};
Get the VPaaS API address
We have defined the VPaaS address in the server configuration. The client is agnostic to this URL. Here we create a simple request to obtain this value:
const getApiAddress = async (): Promise<string> => {
const response = await fetch(`${config.server}/api-address`);
const url = await response.text();
return url;
};
Create a one-time participant
In the previous lesson we created a meeting. Now we need to create a participant with access to that meeting.
To achieve this, we will define a simple function that makes a request to the endpoint that we previously created:
const createParticipant = async (): Promise<Participant> => {
const response = await fetch(
`${config.server}/meetings/${meetingId}/participants`,
{
method: 'POST',
},
);
const participant = await response.json();
return participant;
};
Connect to a meeting
Finally, we arrived to the key function in this lesson. This function is the one that will connect us to a meeting. We first need to define some input parameters:
apiAddress
: This is the VPaaS URL that was retrieved from the server.participant
: This object contains the participant credentials to authenticate into the meeting.mediaStream
: This is aMediaStream
that contains the local audio and video.
The first thing that we need to do in the function is to define the media requirements. In this array we define the following:
Track
for audio from theMediaStream
.Track
for video from theMediaStream
.- Define the maximum audio streams that we can receive.
- Define the maximum video streams that we can receive.
::info
Take into account that if we reached the transceivers limit and we want to subscribe to another stream, we need to unsubscribe first from another stream.
::
Then we join the meeting using the meetingId
, participant credentials and the
MediaStream
with audio and video.
Finally, we connect to the meeting with the media requirements.
const connectMeeting = async (
apiAddress: string,
participant: Participant,
mediaStream: MediaStream,
): Promise<void> => {
if (meetingId == null) {
throw new Error('meetingId not defined');
}
const mediaInits: MediaInit[] = [
{
content: 'main',
direction: 'sendonly',
kindOrTrack: mediaStream.getAudioTracks()[0],
streams: [mediaStream],
},
{
content: 'main',
direction: 'sendonly',
kindOrTrack: mediaStream.getVideoTracks()[0],
streams: [mediaStream],
},
...createRecvTransceivers('audio', RECV_AUDIO_COUNT),
...createRecvTransceivers('video', RECV_VIDEO_COUNT),
];
await vpaas.joinMeeting({
meetingId,
participantId: participant.id,
participantSecret: participant.participant_secret,
apiAddress,
});
vpaas.connect({mediaInits});
};
useEffect hooks
Now we will make use of the useEffect
hook to perform some actions depending
on the state of our component. We will have two different hooks:
- Component mounted: We run this code when the component is mounted. This happens each time we try to join a meeting. In this case we get the access to the media devices and use the VPaaS API to join the meeting.
- Stream Info or Transceiver changed: In this case something happened. Maybe a new transceiver was received or a new stream was added. When this happens we will check if we should subscribe to a new stream.
Component mounted
This hook runs when the component is mounted and it performs the following tasks:
- Init the SDK if it wasn't done before.
- Create the one-time participant with access to that meeting.
- Get access the microphone and camera.
- Get the VPaaS URL.
- Connect to the meeting.
This process is repeated each time the Meeting
component is mounted.
useEffect(() => {
const bootstrap = async (): Promise<void> => {
if (vpaas == null) {
vpaas = initVpaas();
}
const participant = await createParticipant();
const localStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
setParticipant(participant);
setLocalStream(localStream);
const apiAddress = await getApiAddress();
await connectMeeting(apiAddress, participant, localStream);
};
bootstrap().catch(e => {
console.error(e);
});
return () => {
vpaas.disconnect();
};
}, []);
Stream Info or Transceiver changed
This hook is triggered when streamsInfo
or remoteTransceiversConfig
change.
The goal is to subscribe to a stream if the following conditions are fulfilled:
- We have received all the
remoteTransceiversConfig
. - We don't have a
mid
for that stream yet.
So, in our case, we will subscribe to all available streams.
useEffect(() => {
// Check if it's connected (we received all the transceivers)
if (remoteTransceiversConfig.length === RECV_AUDIO_COUNT + RECV_VIDEO_COUNT) {
streamsInfo.forEach(streamInfo => {
if (
streamInfo.mid == null &&
streamInfo.participantId !== participant?.id
) {
subscribeStream(streamInfo).catch(e => {
console.error(e);
});
}
});
}
}, [streamsInfo, remoteTransceiversConfig]);
Render the Meeting component
Now we move to the visual component part. We start by obtaining a list of all remote participants Ids:
let remoteParticipantsIds: string[] = [];
if (roster != null) {
remoteParticipantsIds = Object.keys(roster);
remoteParticipantsIds = remoteParticipantsIds.filter(
id => id !== participant?.id,
);
}
Inside the Meeting
element, we define the RemoteParticipants
. This element
will contain the audio and video for all the participants:
{remoteParticipantsIds.length > 0 &&
<RemoteParticipants
remoteParticipantsIds={remoteParticipantsIds}
streamsInfo={streamsInfo}
remoteTransceiversConfig={remoteTransceiversConfig}
/>
}
Next we define the component that will be displayed if there isn't any available participant:
{remoteParticipantsIds.length === 0 &&
<h2 className='NoParticipants'>Waiting for other participants...</h2>
}
Finally, we define the component that will contain our local video. Take into
account the isMirrored
property. This allows us to display the self-view video
flipped, which improves user experience.
<div className='PipContainer'>
<Selfview
className='SelfView'
isVideoInputMuted={false}
shouldShowUserAvatar={false}
username='User'
localMediaStream={localStream}
isMirrored={true}
/>
</div>
Run the app
If you want to run the application, you need to launch the server and the client at the same time. We start by launching the server:
$ cd server
$ npm start
And in another terminal we launch the client:
$ cd client
$ npm start
The browser will open automatically with the URL https://localhost:4000.
Once you have joined a meeting, you will see the following interface:
You can compare your code with the solution in Step.03-Solution-Join-a-meeting. You can also check the differences with the previous lesson in the git diff.