Après le dernier blog, nous avons eu l'occasion de voir comment créer un serveur de signal avec TypeScript, Express et Socket.io. Aujourd'hui, nous plongerons du côté client, alors préparez-vous !
Contexte
Pour les besoins de ce blog, nous développerons un client WebRTC qui nous permettra de faire des appels un-à-un.
Il nous permettra d’indiquer notre nom à notre arrivée sur la page, puis de voir la liste de tous les utilisateurs connectés.
Chacun d’eux pourra être appelé. Lorsqu'un appel est reçu, une boîte de dialogue nous permettra de l'accepter ou de le refuser. S'il est accepté, les vidéos des deux utilisateurs seront affichées.
Tout comme pour le côté serveur, j’ai choisi d’utiliser TypeScript afin de mieux comprendre grâce aux types. Cependant, j’ai également décidé de ne pas utiliser de « framework » comme React ou Angular, pour ne citer qu’eux. La raison est simple : mon blog est avant tout axé sur WebRTC et son intégration. Utiliser l’une de ces technologies ajouterait une couche de complexité non nécessaire pour le sujet abordé. Malgré tout, vous en apprendrez davantage sur l’intégration de WebRTC, et celle-ci devrait être sensiblement la même dans d'autres « frameworks ».
Configuration initiale
Commençons par ce à quoi vous avez accès. Dans le dépôt du projet sur GitHub, vous retrouverez, dans la branche client
, le canevas sur lequel nous allons travailler dans le blog d'aujourd'hui. Celui-ci contient les styles de base de la page, le fichier HTML, le script JavaScript incomplet ainsi que le projet avec le serveur de signal.
Même s'il est vrai que ce que nous verrons pourrait être utilisé avec n'importe quel projet client, je sentais que, pour que nous restions concentrés sur WebRTC, certaines choses comme le CSS n'avaient pas à être détaillées dans cet article. C'est pourquoi je vous ai préparé un projet incomplet.
Vous pouvez le cloner dans votre espace et accéder à la branche avec les commandes suivantes :
git clone https://github.com/Jxddiss/WebRTC-blog.git
cd webrtc-blog
git switch client
Maintenant que nous avons les prérequis, nous pouvons installer les dépendances dont nous avons besoin.
La première est socket.io-client
, qui nous permet de communiquer avec le serveur WebSocket utilisant Socket.io.¹
cd WebApp/simple-webrtc-client
npm i socket.io-client
Ensuite, nous devons installer la librairie webrtc-adapter
. Celle-ci permet d'améliorer la compatibilité entre les différents navigateurs.¹
npm i webrtc-adapter
Maintenant, passons au code. Nous travaillerons dans le fichier main.ts
.
Types
Commençons par définir une interface pour window
. Il s'agit de quelque chose qui va nous permettre d'ajouter la fonction call
, qui sera définie plus tard, à l'objet window
. Ceci est nécessaire puisque nous utilisons TypeScript.
declare global {
interface Window {
call: (id: string) => void;
}
}
Éléments du DOM
Maintenant, allons chercher les éléments du DOM qui nous seront utiles.
const localVideo = document.getElementById("local-video") as HTMLVideoElement;
const remoteVideo = document.getElementById("remote-video") as HTMLVideoElement;
const callBox = document.getElementById("call-box") as HTMLDivElement;
const listeUtilisateursContainer = document.getElementById(
"liste-utilisateurs-container"
) as HTMLDivElement;
const endCallButton = document.getElementById("end-call") as HTMLButtonElement;
const listeUtilisateurs = document.querySelector(".online-users");
const registerDialog = document.getElementById(
"register-dialog"
) as HTMLDialogElement;
const registerForm = document.getElementById("register-form");
const registerInput = document.getElementById("username") as HTMLInputElement;
const callDialog = document.getElementById("call-dialog") as HTMLDialogElement;
const callDialogName = document.getElementById("caller-name");
const callDialogAccept = document.getElementById("accept-call");
const callDialogReject = document.getElementById("reject-call");
Laissez-moi vous expliquer à quoi correspond chacun de ces éléments :
-
localVideo
: L'élément qui contiendra la vidéo de l'utilisateur local -
remoteVideo
: L'élément qui contiendra la vidéo de l'autre pair -
callBox
: Le conteneur contenant les deux vidéos -
listeUtilisateursContainer
: Le conteneur contenant l'élément avec la liste des utilisateurs connectés -
endCallButton
: Le bouton pour arrêter un appel -
listeUtilisateurs
: L'élément avec la liste des utilisateurs connectés -
registerDialog
: Le dialogue pour l'inscription -
registerForm
: Le formulaire d'inscription -
registerInput
: Le champ avec le nom -
callDialog
: Le dialogue affiché lorsqu'un appel est reçu -
callDialogName
: Le nom de l'utilisateur qui appelle -
callDialogAccept
: Le bouton pour accepter un appel -
callDialogReject
: Le bouton pour rejeter un appel
États
Pour implémenter la logique, nous aurons besoin de plusieurs états.
let peerConnection = new RTCPeerConnection();
let currentCallId: string | null = null;
let localStream: MediaStream | null = null;
const socket = io("http://localhost:3000");
Ici, peerConnection
est l'objet qui référence la connexion WebRTC. On utilise let
puisqu'il sera changé entre chaque appel.
Ensuite, currentCallId
nous permet de garder en mémoire l'ID de connexion de celui avec qui nous sommes en appel. Grâce à celui-ci, nous pourrons directement lui envoyer des informations.
localStream
représente le flux vidéo venant de la caméra de l'utilisateur.
Finalement, socket
est l'objet représentant la connexion au serveur de signal.
Fonctions WebRTC
Cette prochaine section portera son attention sur les fonctions utilitaires liées à WebRTC dont nous aurons besoin.
La première est celle qui nous permet de créer et d'envoyer une offre SDP à l'autre utilisateur.
async function createOffer(socketId: string) {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
socket.emit("offer", { sdp: offer, to: socketId });
}
Dans le cas où une offre est reçue, il faut renvoyer une réponse SDP. C'est donc là qu'entre en jeu handleOffer
.
async function handleOffer(sdp: RTCSessionDescription, socketId: string) {
await peerConnection.setRemoteDescription(sdp);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
socket.emit("answer", { sdp: answer, to: socketId });
}
Comme mentionné dans un blog précédent, un échange SDP (offre ou réponse) est une description de la connexion WebRTC³
La fonction suivante nous permet de gérer le flux vidéo de l'utilisateur local. Lorsqu'il est reçu, l'état avec le flux vidéo est mis à jour, la liste des utilisateurs connectés est cachée, le conteneur avec les éléments vidéo est montré, le flux est mis en source dans l'élément contenant la vidéo locale, puis toutes les pistes (audio et vidéo) sont ajoutées à la connexion WebRTC avec l'état peerConnection
.
function handleStream(stream: MediaStream) {
localStream = stream;
listeUtilisateursContainer.style.display = "none";
callBox.style.display = "block";
localVideo.srcObject = stream;
stream.getTracks().forEach((track) => peerConnection.addTrack(track, stream));
}
Finalement, resetStates
retire le conteneur de vidéo, réaffiche les utilisateurs connectés, arrête les pistes et réinitialise tous les états.
function resetData() {
listeUtilisateursContainer.style.display = "flex";
callBox.style.display = "none";
localVideo.srcObject = null;
remoteVideo.srcObject = null;
peerConnection.close();
localStream?.getTracks().forEach((track) => track.stop());
peerConnection = new RTCPeerConnection();
setupPeerConnectionListeners();
}
Évènements WebRTC
Pour faire fonctionner la connexion WebRTC, nous devons définir ce qu'il se passe lors de certains événements. Ceux qui vont nous intéresser aujourd'hui sont onicecandidate
et ontrack
. Par contre, il en existe d'autres.
Puisque peerConnection
est réinitialisé après chaque appel, nous devons placer la définition des comportements dans la fonction setupPeerConnectionListeners
.
function setupPeerConnectionListeners() {
...
}
onicecandidate
est lancé lorsqu'un « ICE candidate »⁴ est détecté. Ce que nous voulons faire, c'est les envoyer à l'autre pair.
function setupPeerConnectionListeners() {
peerConnection.onicecandidate = (event) => {
if (event.candidate && currentCallId) {
socket.emit("candidate", { candidate: event.candidate, to: currentCallId });
}
};
}
ontrack
lui est lancé lorsqu'un flux de média est ajouté à la connexion par l'autre pair. Nous voulons le prendre et l'ajouter dans l'élément correspondant à la vidéo de l'autre utilisateur.
function setupPeerConnectionListeners() {
...
peerConnection.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
};
}
Configuration Socket
Nous savons comment gérer la connexion WebRTC, mais maintenant la question c'est de savoir comment gérer la connexion au serveur de signal ?
Avec la librairie socket.io-client
, les événements sont gérés un peu de la même manière que du côté serveur, donc ce que nous ferons sera similaire au précédent blog.
Pour définir ce qui sera fait lors d'un événement, on utilise la méthode on
de l'objet socket
, donc cela donne socket.on("<nom_event>", fonction à faire)
.
La configuration de tous les événements pour cette application sera faite à l'intérieur d'une fonction setupSocketListeners
.
Commençons par ce qui sera fait lors de la connexion initiale.
function setupSocketListeners() {
socket.on("connect", () => console.log("connected"));
}
Ensuite, nous avons l'événement userConnected
, lancé après que l'utilisateur s'est inscrit en envoyant son nom au serveur.
function setupSocketListeners() {
...
socket.on("userConnected", () => {
socket.emit("getOnlineUsers");
});
}
socket.emit
permet d'appeler un événement sur le serveur, dans ce cas-ci, getOnlineUsers
. Le serveur répondra en appelant un autre événement du côté client.
Cet autre est onlineUsers
. On voit dans la fonction à côté un paramètre, il s'agit de ce qui est retourné par le serveur. Dans ce cas-ci, un dictionnaire avec l'ID de connexion ainsi que le nom des utilisateurs connectés.
function setupSocketListeners() {
...
socket.on("onlineUsers", (users: Record<string, string>) => {
renderOnlineUsers(users);
});
}
renderOnlineUsers
est une fonction liée à l'interface qui sera expliquée plus tard.
userDisconnected
est lancé à la déconnexion d'un utilisateur. Ici, on relance getOnlineUsers
sur le serveur.
function setupSocketListeners() {
...
socket.on("userDisconnected", () => {
socket.emit("getOnlineUsers");
});
}
call
est ce qui est lancé lorsqu'un appel est reçu. Il appelle une méthode d'interface qui prend en paramètre ce qui doit être fait si l'appel est accepté ou refusé.
function setupSocketListeners() {
...
socket.on("call", (data: { from: string; name: string }) => {
showCallDialog(
data.name,
() => answerCall(data.from),
() => socket.emit("endCall", { to: data.from })
);
});
}
endCall
c'est lorsque l'autre utilisateur met fin à l'appel. Les états sont réinitialisés.
function setupSocketListeners() {
... précédants évènements
socket.on("endCall", resetStates);
}
Finalement, nous avons les événements liés à WebRTC directement : offer
lorsqu'une offre SDP est reçue, answer
lors de la réception d'une réponse SDP, et candidate
pour gérer les candidats reçus.
function setupSocketListeners() {
... précédants évènements
socket.on("offer", (data: { sdp: RTCSessionDescription; socketId: string }) =>
handleOffer(data.sdp, data.socketId)
);
socket.on("answer", (data: { sdp: RTCSessionDescription }) =>
peerConnection.setRemoteDescription(new RTCSessionDescription(data.sdp))
);
socket.on("candidate", (data: { candidate: RTCIceCandidate }) =>
peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate))
);
}
Logique des appels
C'est ici que nous allons définir les fonctions associées à la logique des appels.
La première est liée à ce que nous avons ajouté plus tôt à window
. En effet, c'est ici que nous définirons window.call
, qui est appelé lorsqu'on veut appeler un autre utilisateur.
window.call = async (id: string) => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
handleStream(stream);
socket.emit("call", { to: id });
currentCallId = id;
} catch (error) {
console.error("Error starting call:", error);
}
};
await navigator.mediaDevices.getUserMedia
nous permet d'aller chercher les flux de média à partir de la webcam de l'utilisateur. C'est ce qui retourne l'objet stream
passé à handleStream
, qui deviendra l'état localStream
.
Ensuite, nous avons answerCall
qui a une logique légèrement différente puisqu'il s'agit de ce qui est fait lorsqu'un appel est accepté.
async function answerCall(socketId: string) {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
handleStream(stream);
currentCallId = socketId;
await createOffer(socketId);
} catch (error) {
console.error("Error answering call:", error);
}
}
Puis finalement, nous avons hangUp
pour terminer l'appel et le communiquer à l'autre utilisateur.
function hangUp() {
socket.emit("endCall", { to: currentCallId });
resetStates();
}
Interface utilisateur
C'est maintenant ici que les fonctions liées à l'interface utilisateur seront définies.
La première est ce qui permet d'afficher la liste des utilisateurs connectés reçus du serveur.
function renderOnlineUsers(users: Record<string, string>) {
if (!listeUtilisateurs) return;
listeUtilisateurs.innerHTML = Object.entries(users)
.filter(([id]) => id !== socket.id)
.map(
([id, nom]) => `
<li class="user">
${userSVG}
<div class="name">${nom}</div>
<button onclick="call('${id}')">Appeler</button>
</li>
`
)
.join("");
}
La façon dont les utilisateurs sont rendus est la raison pour laquelle la fonction call
fait partie de window
. En effet, je passe directement le onclick
aux boutons avec l'ID de l'utilisateur.
Ensuite, nous avons la fonction showCallDialog
qui sera appelée lorsqu'un appel est reçu. Elle reçoit en paramètre le nom de l'appelant ainsi que les fonctions à lancer si l'appel est accepté ou refusé.
function showCallDialog(
callerName: string,
acceptCall: () => void,
rejectCall: () => void
) {
if (!callDialogName || !callDialogAccept || !callDialogReject) return;
callDialogName.textContent = callerName;
callDialogAccept.onclick = () => {
acceptCall();
callDialog.close();
};
callDialogReject.onclick = () => {
rejectCall();
callDialog.close();
};
callDialog.showModal();
}
Événements interface utilisateur
En plus des fonctions liées à l'interface, nous avons des écouteurs à définir.
Le premier est pour permettre au bouton endCallButton
de mettre fin à l'appel.
endCallButton.onclick = hangUp;
Finalement, nous en ajoutons un lorsque le formulaire d'inscription est envoyé, pour envoyer le nom avec le socket.
registerForm?.addEventListener("submit", (e) => {
e.preventDefault();
if (registerInput.value) {
socket.emit("register", registerInput.value);
socket.emit("getOnlineUsers");
registerDialog.close();
}
});
Initialisation
Tout ce qu'il nous reste à faire maintenant, c'est appeler les fonctions de mise en place, connecter le socket et montrer le dialog avec le formulaire d'inscription.
setupPeerConnectionListeners();
setupSocketListeners();
socket.connect();
registerDialog.showModal();
Démonstration
Voyons maintenant ce que nous avons en photos.
Formulaire d'inscription
Liste des utilisateurs connectés
Dialog d'appel
Zone d'appel
Ici, j'ai utilisé deux fenêtres du même navigateur, dont une en navigation privée, puisque deux applications ne peuvent pas avoir accès à ma webcam en même temps, mais il s'agit de deux utilisateurs différents et d'une connexion WebRTC entre deux pairs distinctes pour chaques utilisateurs.
Conclusion
Dans cet énorme article, nous avons vu comment créer une application cliente utilisant WebRTC pour faire des appels un-à-un. Pour la suite, je ne sais pas encore ce que nous verrons, mais nous risquons de construire sur cette application afin d'explorer un peu plus ce que nous pouvons faire, comme par exemple intégrer le partage d'écran. Alors sur ce, à la prochaine !
Lien
Si vous voulez explorer la totalité du code que nous avons jusqu'à présent, voici le lien : https://github.com/Jxddiss/WebRTC-blog
Sources
-
videosdk, « How to Build Real-time Apps using Socket.IO and WebRTC? », dans videosdk, 2024, https://www.videosdk.live/developer-hub/socketio/socketio-webrtc
-
codezup, « Building a Real-Time Collaboration Platform with WebRTC and Socket.io », dans codezup, 14 Décembre 2024, https://codezup.com/building-a-real-time-collaboration-platform-with-webrtc-and-socket-io/
-
Digital Samba, « Deciphering SDP: An In-Depth Exploration of WebRTC’s Session Description Protocol », dans medium, 2 Novembre 2023, https://medium.com/@digital_samba/deciphering-sdp-an-in-depth-exploration-of-webrtcs-session-description-protocol-b5dc0fca71a9
-
A. KERANEN, et al, « RFC 8445: Interactive Connectivity Establishment (ICE) : A Protocol for Network Address Translator (NAT Traversal ) », dans rfc-editor, Juillet 2018, ISSN: 2070-1721, https://www.rfc-editor.org/rfc/rfc8445#section-1
Commentaires