Skip to content

WebRTC - Video Demo

In this chapter, we will construct a client application enabling two users on different devices to establish a video call using WebRTC. The application will feature a login page and a main page for initiating and managing video calls.

The original tutorial used an image to show the login page: a simple interface with a username input field and a ‘Sign in’ button. Another image depicted the calling page, which included a large area for remote video, a smaller inset for local video, an input field for the ‘username to call’, a ‘Call’ button, and a ‘Hang Up’ button. We will describe these elements textually when we define the HTML structure.

The application’s views (login and call pages) will be managed by toggling the visibility of div elements. User interactions are handled via JavaScript event listeners.

A signaling server is essential for WebRTC to allow peers to exchange connection metadata (SDP offers/answers, ICE candidates) before a direct peer-to-peer connection can be established. We will use the same WebSocket-based signaling server developed in the ‘WebRTC - Modern Voice Demo’ chapter. No modifications to that server.js are necessary for this video demo.

Ensure you have Node.js and the ws library installed (npm install ws). The server code can be found in the voice demo chapter. Start it by running node server.js in its directory.

Testing this video chat application typically involves opening it in two separate browser tabs or on two different devices and initiating a call between them.

First, create an index.html file. We’ll use Bootstrap via CDN for styling. Place this file and the upcoming client.js in a new folder (e.g., webrtc-video-app).

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC Video Demo</title>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f0f0f0; padding: 20px; }
.container { max-width: 800px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
#loginPage, #callPage { margin-top: 20px; }
video { background: black; border: 1px solid gray; width: 100%; }
#localVideoContainer { position: relative; margin-bottom: 10px; }
#localVideo {
width: 200px; /* Smaller size for local video */
height: 150px;
position: absolute;
top: 10px;
right: 10px;
border: 2px solid white;
z-index: 100; /* Ensure it's on top */
}
#remoteVideo { height: 480px; /* Larger size for remote video */ }
</style>
</head>
<body>
<div class="container">
<div id="loginPage">
<h2 class="text-center">WebRTC Video Demo</h2>
<p class="text-center">Please sign in</p>
<div class="form-group">
<input type="text" id="usernameInput" class="form-control" placeholder="Enter your username" autofocus required>
</div>
<button id="loginBtn" class="btn btn-primary btn-block">Sign In</button>
</div>
<div id="callPage" style="display: none;">
<h3 class="text-center">Hi, <span id="currentUser"></span>! You are in a call.</h3>
<div id="localVideoContainer">
<video id="remoteVideo" autoplay playsinline></video> <!-- Main remote video -->
<video id="localVideo" autoplay playsinline muted></video> <!-- Small local video overlay -->
</div>
<hr>
<div class="form-group">
<input type="text" id="callToUsernameInput" class="form-control" placeholder="Username to call">
</div>
<button id="callBtn" class="btn btn-success">Call</button>
<button id="hangUpBtn" class="btn btn-danger">Hang Up</button>
</div>
</div>
<script src="client.js"></script>
</body>
</html>

This HTML structure includes two <video> elements: localVideo for the user’s own camera feed (typically smaller and overlaid) and remoteVideo for the peer’s video stream. The playsinline attribute is important for mobile browsers, and muted on localVideo prevents audio echo.

Now, create client.js:

// client.js
let name;
let connectedUser;
let localStream;
let peerConnection;
const wsURL = 'ws://localhost:9090';
const connection = new WebSocket(wsURL);
// DOM Elements
const loginPage = document.querySelector('#loginPage');
const usernameInput = document.querySelector('#usernameInput');
const loginBtn = document.querySelector('#loginBtn');
const callPage = document.querySelector('#callPage');
const currentUserDisplay = document.querySelector('#currentUser');
const callToUsernameInput = document.querySelector('#callToUsernameInput');
const callBtn = document.querySelector('#callBtn');
const hangUpBtn = document.querySelector('#hangUpBtn');
const localVideo = document.querySelector('#localVideo');
const remoteVideo = document.querySelector('#remoteVideo');
// ICE servers configuration
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
// Add TURN servers here if needed for NAT traversal
]
};
connection.onopen = () => {
console.log('Connected to signaling server');
};
connection.onmessage = async (msg) => {
console.log('Got message', msg.data);
const data = JSON.parse(msg.data);
switch (data.type) {
case 'login':
await handleLogin(data.success, data.message); // Made async for getUserMedia
break;
case 'offer':
await handleOffer(data.offer, data.name);
break;
case 'answer':
await handleAnswer(data.answer);
break;
case 'candidate':
await handleCandidate(data.candidate);
break;
case 'leave':
handleLeave();
break;
case 'error':
alert(`Error: ${data.message}`);
break;
default:
break;
}
};
connection.onerror = (err) => {
console.error('WebSocket error:', err);
alert('Error connecting to signaling server.');
};
function send(message) {
if (connectedUser) {
message.name = connectedUser; // Target user for signaling messages
}
connection.send(JSON.stringify(message));
}
// UI Event Listeners
loginBtn.addEventListener('click', () => {
name = usernameInput.value;
if (name.length > 0) {
send({ type: 'login', name: name });
} else {
alert('Please enter a username.');
}
});
async function handleLogin(success, message) {
if (success === false) {
alert(`Login failed: ${message || 'Try a different username.'}`);
} else {
loginPage.style.display = 'none';
callPage.style.display = 'block';
currentUserDisplay.textContent = name;
try {
// Get local video and audio stream
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localVideo.srcObject = localStream;
// Initialize RTCPeerConnection after media is acquired
initializePeerConnection();
} catch (error) {
console.error('Error accessing media devices:', error);
alert('Could not access camera/microphone: ' + error.message);
// Revert UI or handle gracefully
loginPage.style.display = 'block';
callPage.style.display = 'none';
}
}
}
function initializePeerConnection() {
peerConnection = new RTCPeerConnection(configuration);
// Add local stream tracks to the peer connection
if (localStream) {
localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));
}
// Handle incoming remote tracks
peerConnection.ontrack = (event) => {
console.log('Remote track received:', event.track.kind);
if (event.streams && event.streams[0]) {
remoteVideo.srcObject = event.streams[0];
} else {
// Fallback for older browsers or specific track event scenarios
let inboundStream = new MediaStream();
inboundStream.addTrack(event.track);
remoteVideo.srcObject = inboundStream;
}
};
// Handle ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
send({ type: 'candidate', candidate: event.candidate });
}
};
// Log connection state changes (optional, for debugging)
peerConnection.oniceconnectionstatechange = () => {
console.log(`ICE connection state: ${peerConnection.iceConnectionState}`);
if (peerConnection.iceConnectionState === 'failed' ||
peerConnection.iceConnectionState === 'disconnected' ||
peerConnection.iceConnectionState === 'closed') {
// Handle connection failure here
// For this demo, we mostly rely on explicit 'leave' or hang-up
}
};
}
callBtn.addEventListener('click', async () => {
const callToUsername = callToUsernameInput.value;
if (callToUsername.length === 0) {
alert('Please enter a username to call.');
return;
}
if (callToUsername === name) {
alert('You cannot call yourself.');
return;
}
connectedUser = callToUsername;
console.log(`Initiating call to ${connectedUser}`);
if (!peerConnection) initializePeerConnection(); // Ensure peerConnection is initialized
// if (!localStream) { alert('Local media not ready'); return; } // Could add this check
try {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
send({ type: 'offer', offer: offer });
} catch (error) {
console.error('Error creating offer:', error);
alert('Error creating call offer: ' + error.message);
}
});
async function handleOffer(offer, fromName) {
if (!peerConnection) initializePeerConnection(); // Ensure PC is ready
// if (!localStream) { alert('Local media not ready for incoming call'); return; } // Could add this check
connectedUser = fromName;
console.log(`Received offer from ${connectedUser}`);
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
send({ type: 'answer', answer: answer });
} catch (error) {
console.error('Error handling offer:', error);
alert('Error handling incoming call: ' + error.message);
}
}
async function handleAnswer(answer) {
console.log('Received answer');
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
} catch (error) {
console.error('Error handling answer:', error);
}
}
async function handleCandidate(candidate) {
try {
if (candidate) {
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
}
} catch (error) {
console.error('Error adding ICE candidate:', error);
}
}
hangUpBtn.addEventListener('click', () => {
send({ type: 'leave' });
handleLeave();
});
function handleLeave() {
console.log('Call ended or other user left.');
connectedUser = null;
if (remoteVideo) remoteVideo.srcObject = null;
if (peerConnection) {
peerConnection.close();
peerConnection.onicecandidate = null;
peerConnection.ontrack = null;
peerConnection.oniceconnectionstatechange = null;
peerConnection = null; // Nullify to be re-created for the next call
}
callToUsernameInput.value = ''; // Clear the input field
// Re-initialize peerConnection for a new call if localStream is still active
// if (localStream) initializePeerConnection();
}
// Clean up on window close
window.addEventListener('beforeunload', () => {
if (name) { // If logged in
send({ type: 'leave' }); // Inform other user
}
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
}
if (peerConnection) {
peerConnection.close();
}
if (connection) {
connection.close();
}
});

Key updates and features in client.js for video:

  • Media Constraints: navigator.mediaDevices.getUserMedia({ video: true, audio: true }) requests both video and audio.
  • DOM Elements: Selectors for localVideo and remoteVideo elements are used.
  • srcObject: The local and remote media streams are attached to their respective <video> elements using the srcObject property.
  • Modern APIs: Uses navigator.mediaDevices.getUserMedia, RTCPeerConnection, addTrack, ontrack, and async/await for promise-based operations, consistent with the voice demo.
  • Initialization: initializePeerConnection() is called after successful login and media acquisition. It sets up the RTCPeerConnection and its crucial event handlers (ontrack, onicecandidate).
  • Track Handling: localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream)) adds all tracks from the local stream to the connection. peerConnection.ontrack handles incoming tracks and attaches them to the remoteVideo element.
  • Cleanup: handleLeave() and the beforeunload event listener ensure that the peerConnection is closed, and media streams are stopped and cleaned up.

To run this video demo:

  1. Save server.js (from voice demo), index.html, and client.js.
  2. Start the signaling server: node server.js.
  3. Open index.html in two different browser tabs (or on different devices if your network/STUN setup allows).
  4. Log in with unique usernames in each tab. The browser will request camera and microphone access.
  5. The original tutorial showed an image of this permission prompt and the local video stream appearing. This is replicated by the current setup.
  6. Enter the other tab’s username in the ‘Username to call’ field and click ‘Call’.

You should see the video from the other peer in the remoteVideo element and your own video in the localVideo element. The original tutorial also had an image illustrating a successful call between two users, ‘userA’ and ‘userB’, showing both local and remote video feeds, and then the state after hanging up.

This video demo builds upon the voice demo, primarily changing the media constraints to include video and updating the HTML to display video elements. The core WebRTC signaling and connection logic remains very similar. This example provides a solid foundation for building more complex video communication applications. For production, consider robust error handling, UI/UX enhancements, and a TURN server for reliable NAT traversal.