WebRTC with NodeJS: Building a Video Chat App
WebRTC with NodeJS: Building a Video Chat App
In the guide we will go through building a Group Video Calling application, the application would allow the users to have a video conference and it would have features like active speaker detecting, waiting room and the ability to do screen sharing.
We will build the application using HTML+JavaScript with Node.JS + Express in the backend, the backend logic would be very simple, it will call the Metered REST API to create meeting rooms and to validate the meeting ids.
Our final application would run on all modern browsers on Windows/Mac/Linux as well as on mobile devices, like iOS and Android and would look like this:
Metered video Calling Application
You can download the complete source code from Github: https://github.com/metered-ca/video-javascript-quickstart
Prerequisite
To build the video calling application to follow this guide you need to have basic knowledge of HTML and JavaScript and some familiarity with Node.JS.
We will use the Metered API and JavaScript SDK, for that you will need to have a Metered account, if you don't have it then you can easily create a free account by visiting https://dashboard.metered.ca/signup
After you have created an account, come back here for the next steps.
Application Structure
Our application would have a Node.JS + Express backend and HTML+JavaScript font-end, the backend would provide APIs to the front-end to create a meeting room and generate a meeting id and also to validate an existing meeting id.
Our folder structure would look something like this:
Metered Group Video Calling Application Folder Structure
.env
The .env file contains the environment variables here we will specify the METERED_DOMAIN
and METERED_SECRET_KEY
more on this later in the document.
src The src folder contains all the source code for the project.
src/server.js The server.js file contains the backend code and API routes and also contains the code to serve the front-end files.
src/config.js The config.js contains the config variables for the project and also loads the values from the .env file or from the environment variables.
src/public/index.html The index.html file contains all the front-end user interface built with HTML
src/public/script.js The script.js file contains all the front-end login related to our video calling application, it will use the Metered JavaScript SDK and is the brains of our application.
Building the Backend
Let's start with building the backend of our application first. In a non-scalable WebRTC application you would have to get a purchase a TURN Server, but when using the video SDK purchasing a TURN Server separately is not required.
1. Initializing our project
We will initialize our project and create a package.json
, run the command below in your project root directory.
npm init -y
Next, we will install a few dependencies that would be needed in build our backend service, we would require the following dependencies:
- dotenv: To load the environment variables from the .env file.
- axios: To call the Metered REST APIs to create and validate Meeting IDs.
- express: To create REST routes for our server.
npm install dotenv --save
npm install axios --save
npm install express --save
2. Creating config.js and .env file
We will create a config.js file and here we will add the variables that we would need in our application, like the port the application will run on and the Metered Domain and Metered Secret Key
require("dotenv").config();
module.exports = {
METERED_DOMAIN: process.env.METERED_DOMAIN || "",
METERED_SECRET_KEY: process.env.METERED_SECRET_KEY || "",
port: process.env.PORT || 4000,
};
To obtain the Metered Domain and Secret Key, open your Metered Dashboard by going to https://dashboard.metered.ca
The name of your app + metered.live is your Metered Domain, for e.g name of your app is simpleapp then your Metered domain would be simpleapp.metered.live
Then go to Developers tab there you will find the secret key:
Metered Secret Key
Now create a .env file in the project's root with the following contents, and replace <METERED_DOMAIN>
and <METERED_SECRET>
key with the actual domain and secret key that we have obtained from the previous step.
(Be sure to paste the key without the < > angle brackets)
METERED_DOMAIN = "<METERED_DOMAIN>";
METERED_SECRET_KEY = "<METERED_SECRET_KEY>";
3. Writing the code for backend service in server.js
Our server.js file would contain the APIs that would be used by our front-end application, and in the server.js file, we will call the Metered REST APIs.
There are 3 tasks we need to accomplish:
- Serving the front-end application
- API to create a meeting room and obtain a meeting id
- API to validate an existing meeting Id
To accomplish that we will create 3 endpoints:
/
- Going to this route will serve our index.html/validate-meeting
- This route will validate the meeting ID, we will call the Metered REST API to validate the Meeting ID here./create-meeting-room
- This route will create a new meeting room, thus generating a new meeting ID, we will call the Metered REST API to create a room here and send the Room ID as the response./metered-domain
- This is a very simple route we have created, it will send the metered domain that we have specified in our .env / config.js to the front-end
Create server.js boilerplate code
We will require the dependencies and create the route handlers.
//Requiring dependencies
const path = require("path");
const express = require("express");
var axios = require("axios").default;
// Creating express app
const app = express();
// Requiring the config
const config = require("./config");
const port = config.port;
// Printing the config for debugging
console.log(config);
// Checking if METERED_DOMAIN is specified, otherwise throwing an error.
if (!config.METERED_DOMAIN) {
throw new Error(
"Please specify the METERED_DOMAIN.\nAdd as an environment variable or in the .env file or directly specify in the src/config.js\nIf you are unsure where to get METERED_DOMAIN please read the Advanced SDK Guide here: https://metered.ca/docs/Video%20Calls/JavaScript/Building%20a%20Group%20Video%20Calling%20Application"
);
}
// Check if METERED_SECRET_KEY is specified, otherwise throwing an error.
if (!config.METERED_SECRET_KEY) {
throw new Error(
"Please specify the METERED_SECRET_KEY.\nAdd as an environment variable or in the .env file or directly specify in the src/config.js\nIf you are unsure where to get METERED_SECRET_KEY please read the Advanced SDK Guide here: https://metered.ca/docs/Video%20Calls/JavaScript/Building%20a%20Group%20Video%20Calling%20Application"
);
}
// Serving static files in the public folder
app.use("/", express.static(path.join(__dirname, "/public")));
app.get("/validate-meeting", function (req, res) {});
app.post("/create-meeting-room", function (req, res) {});
app.get("/metered-domain", function (req, res) {});
app.listen(port, () => {
console.log(`app listening at http://localhost:${port}`);
});
Serving Static Files
To serve the static files in the public folder, that contains our front-end code, like index.html
and script.js
we are using the express static middleware.
app.use("/", express.static(path.join(__dirname, '/public')))
Creating /validate-meeting route
In the /validate-meeting
route we are going to call the Metered REST API, we will call the Get Room API and pass it is Meeting ID
sent to us by the client to validate if a such a room exists, if it does then we will send a success response and if it doesn't then we will return an error.
We will use axios to make the HTTP request to the Metered Server.
app.get("/validate-meeting", function (req, res) {
/**
* Using the Metered Get Room API to check if the
* Specified Meeting ID is valid.
* https://www.metered.ca/docs/rest-api/get-room-api
*/
var options = {
method: "GET",
url:
"https://" +
config.METERED_DOMAIN +
"/api/v1/room/" +
req.query.meetingId,
params: {
secretKey: config.METERED_SECRET_KEY,
},
headers: {
Accept: "application/json",
},
};
axios
.request(options)
.then(function (response) {
console.log(response.data);
res.send({
success: true,
});
})
.catch(function (error) {
console.error(error);
res.send({
success: false,
});
});
});
Creating /create-meeting-room route
In the Create Meeting Room route we will again call the Metered REST API, and this time we will call the Create Room API.
app.post("/create-meeting-room", function (req, res) {
/**
* Using the Metered Create Room API to create a new
* Meeting Room.
* https://www.metered.ca/docs/rest-api/create-room-api
*/
var options = {
method: "POST",
url: "https://" + config.METERED_DOMAIN + "/api/v1/room/",
params: {
secretKey: config.METERED_SECRET_KEY,
},
headers: {
Accept: "application/json",
},
};
axios
.request(options)
.then(function (response) {
console.log(response.data);
res.send({
success: true,
...response.data,
});
})
.catch(function (error) {
console.error(error);
res.send({
success: false,
});
});
});
Create /metered-domain route
The Metered Domain route is very simple, here we will just return the Metered Domain value that we have specified in the .env / config.js file.
We are creating this route so that we can fetch the metered domain in our front-end application to initialize the Metered SDK and to keep the config centralized.
app.get("/metered-domain", function (req, res) {
res.send({
domain: config.METERED_DOMAIN,
});
});
Putting it all together
Here is our final server.js code with all the code put together:
//Requiring dependencies
const path = require("path");
const express = require("express");
var axios = require("axios").default;
// Creating express app
const app = express();
// Requiring the config
const config = require("./config");
const port = config.port;
// Priting the config for debugging
console.log(config);
// Checking if METERED_DOMAIN is specified, otherwise throwing an error.
if (!config.METERED_DOMAIN) {
throw new Error(
"Please specify the METERED_DOMAIN.\nAdd as an environment variable or in the .env file or directly specify in the src/config.js\nIf you are unsure where to get METERED_DOMAIN please read the Advanced SDK Guide here: https://metered.ca/docs/Video%20Calls/JavaScript/Building%20a%20Group%20Video%20Calling%20Application"
);
}
// Check if METERED_SECRET_KEY is specified, otherwise throwing an error.
if (!config.METERED_SECRET_KEY) {
throw new Error(
"Please specify the METERED_SECRET_KEY.\nAdd as an environment variable or in the .env file or directly specify in the src/config.js\nIf you are unsure where to get METERED_SECRET_KEY please read the Advanced SDK Guide here: https://metered.ca/docs/Video%20Calls/JavaScript/Building%20a%20Group%20Video%20Calling%20Application"
);
}
// Serving static files in the public folder
app.use("/", express.static(path.join(__dirname, "/public")));
app.get("/validate-meeting", function (req, res) {
/**
* Using the Metered Get Room API to check if the
* Specified Meeting ID is valid.
* https://www.metered.ca/docs/rest-api/get-room-api
*/
var options = {
method: "GET",
url:
"https://" +
config.METERED_DOMAIN +
"/api/v1/room/" +
req.query.meetingId,
params: {
secretKey: config.METERED_SECRET_KEY,
},
headers: {
Accept: "application/json",
},
};
axios
.request(options)
.then(function (response) {
console.log(response.data);
res.send({
success: true,
});
})
.catch(function (error) {
console.error(error);
res.send({
success: false,
});
});
});
app.post("/create-meeting-room", function (req, res) {
/**
* Using the Metered Create Room API to create a new
* Meeting Room.
* https://www.metered.ca/docs/rest-api/create-room-api
*/
var options = {
method: "POST",
url: "https://" + config.METERED_DOMAIN + "/api/v1/room/",
params: {
secretKey: config.METERED_SECRET_KEY,
},
headers: {
Accept: "application/json",
},
};
axios
.request(options)
.then(function (response) {
console.log(response.data);
res.send({
success: true,
...response.data,
});
})
.catch(function (error) {
console.error(error);
res.send({
success: false,
});
});
});
app.get("/metered-domain", function (req, res) {
res.send({
domain: config.METERED_DOMAIN,
});
});
app.listen(port, () => {
console.log(`app listening at http://localhost:${port}`);
});
Front End
Let's start building the front-end of our application, we will first create our index.html
file and script.js
files and add some boilerplate code.
In the front-end we have to build 4 main areas:
- Join Meeting Area - Allow the user to enter an existing meeting id or create a new meeting
- Waiting Area - Allow user to set a username, and select camera and microphone, see the preview of the camera and join the meeting
- Meeting Area - Main meeting interface
- Meeting Ended Area - A screen to show when the meeting end or the user decides to leave the meeting.
1. Boilerplate code
We will create index.html
file and include the front-end dependencies like:
- Metered Javascript SDK
- Daily UI a CSS Component library and Tailwind CSS for Styling
- jQuery
We will also create 4 main containers to hold our 4 views, the Join Meeting Area, Waiting Area, Meeting Area, and Meeting Ended Area and we will show/hide them as the user moves from one view to another.
Initially the Join Meeting Area would be visible and rest of the views will be hidden:
<!DOCTYPE html>
<html lang="en" class="bg-white">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Demo App</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"
integrity="sha512-bZS47S7sPOxkjU/4Bt0zrhEtWx0y0CRkhEp8IckzK+ltifIIE9EMIMTuT/mEzoIMewUINruDBIR/jJnbguonqQ=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<!-- Import the webpage's stylesheet -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.1/dist/tailwind.min.css" rel="stylesheet" type="text/css" />
<link href="https://cdn.jsdelivr.net/npm/daisyui@1.11.1/dist/full.css" rel="stylesheet" type="text/css" />
<script src="//cdn.metered.ca/sdk/video/1.4.6/sdk.min.js"></script>
</head>
<body>
<!-- Header Nav Bar -->
<div class="navbar mb-2 shadow-lg bg-neutral text-neutral-content">
<div class="flex-none px-2 mx-2">
<span class="text-lg font-bold">
Metered
</span>
</div>
<div class="flex-1 px-2 mx-2">
<div class="items-stretch hidden lg:flex">
<a href="https://metered.ca/docs/Video%20Calls/JavaScript/Building%20a%20Group%20Video%20Calling%20Application" target="_blank"
class="btn btn-ghost btn-sm rounded-btn">
Advanced SDK Guide
</a>
<a href="https://metered.ca/docs/Video%20Calls/JavaScript/Tips%20and%20Best%20Practices" target="_blank"
class="btn btn-ghost btn-sm rounded-btn">
Tips and Best practices
</a>
<a href="https://metered.ca/docs/SDK-Reference/JavaScript/Methods/Methods%20Introduction" target="_blank" class="btn btn-ghost btn-sm rounded-btn">
SDK Reference
</a>
</div>
</div>
</div>
<!-- Header Nav Bar End -->
<div id="meetingIdContainer" class="w-full bg-base-300 hidden font-bold text-center py-2">
Meeting ID: <span id="displayMeetingId"></span>
</div>
<!-- Join view -->
<div id="joinView" class="w-full items-center justify-center flex">
</div>
<!-- Waiting area -->
<div id="waitingArea" class="w-full items-center justify-center flex hidden">
</div>
<!-- Meeting view -->
<div id="meetingView" class="hidden ">
</div>
<!-- Leave View -->
<div id="leaveView" class="flex items-center justify-center hidden">
</div>
<!-- Import the webpage's javascript file -->
<script src="/script.js" defer></script>
</body>
</html>
2. Building the Join Meeting Area
Metered Group Video Calling Application Join Area.
In the #joinView
div we will create the Join Meeting Area, the Join Meeting Area would contain an input to enter the Meeting ID and buttons to join the existing meeting or create a new meeting.
<div id="joinView" class="w-full items-center justify-center flex">
<div class="bg-base-300 w-11/12 max-w-screen-md rounded mt-48 p-10">
<div class="form-control">
<label class="label">
<span class="label-text">Meeting ID</span>
</label>
<div class="relative">
<input
id="meetingId"
type="text"
placeholder="Meeting ID"
class="w-full pr-16 input input-primary input-bordered"
/>
<button
id="joinExistingMeeting"
class="absolute top-0 right-0 rounded-l-none btn btn-primary text-xs"
>
<span class="hidden sm:block">Join Existing Meeting</span>
<span class="sm:hidden">Join</span>
</button>
</div>
</div>
<div class="divider">OR</div>
<div class="flex justify-center">
<button id="createANewMeeting" class="btn btn-primary">
Create a new meeting
</button>
</div>
</div>
</div>
#meetingId
- Input will hold the value for the an existing meeting id that the user wish to join.
#joinExistingMeeting
- Button will call our /validate-meeting
endpoint which will in turn call our Metered REST API to validate the meeting id, if the meeting id is valid then we will call the Metered SDK method to join the meeting.
#createANewMeeting
- Button will call our /create-meeting-room
endpoint to create a new room, and then will cal the Metered SDK method to join the newly created room.
Here is our script.js code to handle the click events on the buttons #joinExistingMeeting
and #createANewMeeting
// Creating instance of Metered Javascript SDK
const meeting = new Metered.Meeting();
// Creating a Global variable to store the Meeting ID
let meetingId = "";
$("#joinExistingMeeting").on("click", async function (e) {
if (e) e.preventDefault();
meetingId = $("#meetingId").val();
if (!meetingId) {
return alert("Please enter meeting id");
}
// Sending request to validate meeting id
try {
const response = await axios.get(
"/validate-meeting?meetingId=" + meetingId
);
if (response.data.success) {
// Meeting id is valid, taking the user to the waiting area.
$("#joinView").addClass("hidden");
$("#waitingArea").removeClass("hidden");
$("#displayMeetingId").text(meetingId);
$("#meetingIdContainer").removeClass("hidden");
initializeWaitingArea();
} else {
alert("meeting id is invalid");
}
} catch (ex) {
alert("meeting Id is invalid");
}
});
$("#createANewMeeting").on("click", async function (e) {
if (e) e.preventDefault();
// Sending request to create a new meeting room
try {
const response = await axios.post("/create-meeting-room");
if (response.data.success) {
$("#joinView").addClass("hidden");
$("#waitingArea").removeClass("hidden");
$("#displayMeetingId").text(response.data.roomName);
$("#meetingIdContainer").removeClass("hidden");
meetingId = response.data.roomName;
initializeWaitingArea();
}
} catch (ex) {
alert("Error occurred when creating a new meeting");
}
});
Here if the existing meeting id is valid or after creating a new meeting id, we are calling the initializeWaitingArea()
method which we will discuss in the next step.
3. Building the Waiting Area
Metered Group Video Calling Application Waiting Area.
In the #waitingArea
div
we will build the waiting area of the application, in the waiting area, we would want to perform the following operations:
- Allow the user to select the camera by listing the available cameras on the device
- Allow the user to select the microphone by listing the available microphones on the device.
- Allow the user to select the speaker by listing the available audio output devices.
- Allow the user to join the meeting with microphone muted/unmuted
- Allow the user to join the meeting with camera muted/unmuted
- Show the preview of the selected camera
Metered SDK provides us with various helper methods that would allow to easily accomplish these tasks.
<div id="waitingArea" class="w-full items-center justify-center flex hidden">
<div class="bg-base-300 w-11/12 rounded mt-48 p-10">
<video
id="waitingAreaVideoTag"
class="w-full"
muted
autoplay
playsinline
></video>
<div class="flex items-center justify-center mt-4 space-x-4">
<button id="waitingAreaMicrophoneButton" class="btn">
<svg
class="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z"
clip-rule="evenodd"
></path>
</svg>
</button>
<button id="waitingAreaCameraButton" class="btn">
<svg
class="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"
></path>
</svg>
</button>
</div>
<div class="divider"></div>
<div class="grid grid-cols-3 space-x-4">
<div class="form-control">
<label class="label">
<span class="label-text">Camera</span>
</label>
<select id="cameras" class="select select-bordered w-full"></select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Microphone</span>
</label>
<select id="microphones" class="select select-bordered w-full"></select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Speaker</span>
</label>
<select id="speakers" class="select select-bordered w-full"></select>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Enter a username</span>
</label>
<div class="relative">
<input
id="username"
type="text"
placeholder="username"
class="w-full pr-16 input input-primary input-bordered"
/>
<button
id="joinMeetingButton"
class="absolute top-0 right-0 rounded-l-none btn btn-primary"
>
<span class="hidden sm:block">Join Existing Meeting</span>
<span class="sm:hidden">Join</span>
</button>
</div>
</div>
</div>
</div>
#waitingAreaVideoTag
- Video Tag: Used to show the preview of the camera.
#waitingAreaMicrophoneButton
- Button: Used to mute/unmute the microphone when the user joins the meeting.
#waitingAreaCameraButton
- Button: Used to enable/disable the camera when the user joins the meeting.
#cameras
- Select Input: Show the list of available cameras on the system.
#microphones
- Select Input: Show the list of available microphones on the system.
#speakers
- Select Input: Show the list of available audio outputs on the device.
#username
- Text Input: Allow the user to enter a username to join the meeting.
#joinMeetingButton
- Button: When pressed the user will join the meeting, we will hide the waiting area and show the meeting area.
/**
* Method to initialize the waiting area:
* This methods calls the SDK methods to request the
* user for microphone and camera permissions.
*/
var videoUnavailable = true;
var audioUnavailable = true;
async function initializeWaitingArea() {
let audioOutputDevices = [];
try {
audioOutputDevices = await meeting.listAudioOutputDevices();
} catch (ex) {
console.log("option not available - it is unsupported in firefox", ex);
}
let audioInputDevices = [];
try {
audioInputDevices = await meeting.listAudioInputDevices();
} catch (ex) {
console.log("camera not available or have disabled camera access", ex);
audioUnavailable = true;
// Disabling the camera button
$("#waitingAreaMicrophoneButton").attr("disabled", true);
}
let videoInputDevices = [];
try {
videoInputDevices = await meeting.listVideoInputDevices();
} catch (ex) {
console.log("camera not available or have disabled camera access", ex);
videoUnavailable = true;
// Disabling the camera button
$("#waitingAreaCameraButton").attr("disabled", true);
}
let cameraOptions = [];
for (let device of videoInputDevices) {
cameraOptions.push(
`<option value="${device.deviceId}">${device.label}</option>`
);
}
let microphoneOptions = [];
for (let device of audioInputDevices) {
microphoneOptions.push(
`<option value="${device.deviceId}">${device.label}</option>`
);
}
let speakerOptions = [];
for (let device of audioOutputDevices) {
speakerOptions.push(
`<option value="${device.deviceId}">${device.label}</option>`
);
}
$("#cameras").html(cameraOptions.join(""));
$("#microphones").html(microphoneOptions.join(""));
$("#speakers").html(speakerOptions.join(""));
// Selecting different camera
$("#cameras").on("change", async function (value) {
const deviceId = $("#cameras").val();
console.log(deviceId);
await meeting.chooseVideoInputDevice(deviceId);
});
// Setting different microphone
$("#microphones").on("change", async function (value) {
const deviceId = $("#microphones").val();
await meeting.chooseAudioInputDevice(deviceId);
});
// Setting different speaker
$("#speakers").on("change", async function (value) {
const deviceId = $("#speakers").val();
await meeting.chooseAudioOutputDevice(deviceId);
});
}
/**
* Adding click events to buttons in waiting area
*/
let microphoneOn = false;
$("#waitingAreaMicrophoneButton").on("click", function () {
if (microphoneOn) {
$("#waitingAreaMicrophoneButton").removeClass("bg-accent");
microphoneOn = false;
} else {
microphoneOn = true;
$("#waitingAreaMicrophoneButton").addClass("bg-accent");
}
});
let cameraOn = false;
let localVideoStream = null;
$("#waitingAreaCameraButton").on("click", async function () {
if (cameraOn) {
cameraOn = false;
$("#waitingAreaCameraButton").removeClass("bg-accent");
const tracks = localVideoStream.getTracks();
tracks.forEach(function (track) {
track.stop();
});
localVideoStream = null;
$("#waitingAreaVideoTag")[0].srcObject = null;
} else {
try {
$("#waitingAreaCameraButton").addClass("bg-accent");
localVideoStream = await meeting.getLocalVideoStream();
$("#waitingAreaVideoTag")[0].srcObject = localVideoStream;
cameraOn = true;
} catch (ex) {
$("#waitingAreaCameraButton").removeClass("bg-accent");
console.log("Error occurred when trying to acquire video stream", ex);
$("#waitingAreaCameraButton").attr("disabled", true);
}
}
});
let meetingInfo = {};
$("#joinMeetingButton").on("click", async function () {
var username = $("#username").val();
if (!username) {
return alert("Please enter a username");
}
try {
console.log(meetingId);
const { data } = await axios.get("/metered-domain");
console.log(data.domain);
meetingInfo = await meeting.join({
roomURL: `${data.domain}/${meetingId}`,
name: username,
});
console.log("Meeting joined", meetingInfo);
$("#waitingArea").addClass("hidden");
$("#meetingView").removeClass("hidden");
$("#meetingAreaUsername").text(username);
if (cameraOn) {
$("#meetingViewCamera").addClass("bg-accent");
if (localVideoStream) {
const tracks = localVideoStream.getTracks();
tracks.forEach(function (track) {
track.stop();
});
localVideoStream = null;
}
await meeting.startVideo();
}
if (microphoneOn) {
$("#meetingViewMicrophone").addClass("bg-accent");
await meeting.startAudio();
}
} catch (ex) {
console.log("Error occurred when joining the meeting", ex);
}
});
Let's see how we have accomplished 6 of our above tasks with Metered SDK:
Loading the available cameras in the Select Box
Metered SDK Provides a method called listVideoInputDevices
that returns a list of cameras connected to the device, in case of a mobile device it will list the front and back cameras and for a computer is multiple cameras are connected it would list all of them, allowing the user to select which camera they wish to share.
You can read more about the method here listVideoInputDevices().
let videoInputDevices = [];
try {
videoInputDevices = await meeting.listVideoInputDevices();
} catch (ex) {
console.log("camera not available or have disabled camera access", ex);
videoUnavailable = true;
// Disabling the camera button
$("#waitingAreaCameraButton").attr("disabled", true);
}
let cameraOptions = [];
for (let device of videoInputDevices) {
cameraOptions.push(
`<option value="${device.deviceId}">${device.label}</option>`
);
}
$("#cameras").html(cameraOptions.join(""));
In the above code snippet we are loading the list of cameras, and then populating the select box.
Handling Camera Selection
Metered SDK provides a method called chooseVideoInputDevice()
which accepts the a deviceId
which is returned by the listVideoInputDevices()
method.
You can read more about the chooseVideoInputDevice() method here.
// Selecting different camera
$("#cameras").on("change", async function (value) {
const deviceId = $("#cameras").val();
console.log(deviceId);
await meeting.chooseVideoInputDevice(deviceId);
});
In the above code we had attached an onchange
listener on the select box and then calling the chooseVideoInputDevice()
method of the Metered SDK and passing the deviceId
of the selected camera.
Loading List of Available Microphones in the Select Box
Metered SDK Provides a method called listAudioInputDevices()
that returns a list of microphones connected to the device.
You can read more about the method here listAudioInputDevices().
let audioInputDevices = [];
try {
audioInputDevices = await meeting.listAudioInputDevices();
} catch (ex) {
console.log("camera not available or have disabled camera access", ex);
audioUnavailable = true;
// Disabling the camera button
$("#waitingAreaMicrophoneButton").attr("disabled", true);
}
let microphoneOptions = [];
for (let device of audioInputDevices) {
microphoneOptions.push(
`<option value="${device.deviceId}">${device.label}</option>`
);
}
$("#microphones").html(microphoneOptions.join(""));
In the above code snippet we are fetching the list of microphones and then adding them to a select box.
Handling Microphone Selection
Metered SDK provides a method called chooseAudioInputDevice()
which accepts the a deviceId
which is returned by the listAudioInputDevices()
method.
You can read more about the chooseAudioInputDevice() method here.
// Setting different microphone
$("#microphones").on("change", async function (value) {
const deviceId = $("#microphones").val();
await meeting.chooseAudioInputDevice(deviceId);
});
In the above code we had attached an onchange
listener on the select box and then calling the chooseAudioInputDevice()
method of the Metered SDK and passing the deviceId
of the selected camera.
Loading List of Available Audio Outputs (Speakers) in the Select Box
Metered SDK Provides a method called listAudioOutputDevices()
that returns a list of audio output devices, like speakers or headphones connected to the device.
You can read more about the method here listAudioOutputDevices().
It works in Google Chrome, but not all browsers currently support this method.
let audioOutputDevices = [];
try {
audioOutputDevices = await meeting.listAudioOutputDevices();
} catch (ex) {
console.log("option not available - it is unsupported in firefox", ex);
}
let speakerOptions = [];
for (let device of audioOutputDevices) {
speakerOptions.push(
`<option value="${device.deviceId}">${device.label}</option>`
);
}
$("#speakers").html(speakerOptions.join(""));
In the above code snippet we are calling listAudioOutputDevices
method of the Metered SDK and then populating a select box with the returned values.
Handling Speaker Selection
To select the speaker, there is method called a chooseAudioOutputDevice()
which accepts the deviceId
of the audio output device returned by the listAudioOutputDevices()
method.
You can read more about chooseAudioOutputDevice() method here.
// Setting different speaker
$("#speakers").on("change", async function (value) {
const deviceId = $("#speakers").val();
await meeting.chooseAudioOutputDevice(deviceId);
});
In the above code snippet, we are attaching an onchange
listener to the select box where we have populated the audio output devices, and then when an option is selected we are passing the selected deviceId
to the chooseAudioOutputDevice
method.
Allow the user to join the meeting with microphone muted/unmuted
We will create a variable called microphoneOn
and add a click listener to the microphone button in the waiting area microphone button and then toggle the value of this variable.
let microphoneOn = false;
$("#waitingAreaMicrophoneButton").on("click", function () {
if (microphoneOn) {
$("#waitingAreaMicrophoneButton").removeClass("bg-accent");
microphoneOn = false;
} else {
microphoneOn = true;
$("#waitingAreaMicrophoneButton").addClass("bg-accent");
}
});
And when the user presses the join meeting button, and after joining the meeting we will check the value of the microphoneOn
variable, if it is set to true then we will call the startAudio()
method of the Metered SDK, we will describe in the implementation of this later in the article.
Allow the user to join the meeting with camera muted/unmuted and showing the preview of the camera in the waiting area
Similar to microphoneOn
we will create a variable called cameraOn
and attach a click listener to the camera button in the waiting area, and toggle the value of the cameraOn
variable, and when the user presses the join meeting button we will call the startVideo()
method of the Metered SDK.
let cameraOn = false;
let localVideoStream = null;
$("#waitingAreaCameraButton").on("click", async function () {
if (cameraOn) {
cameraOn = false;
$("#waitingAreaCameraButton").removeClass("bg-accent");
const tracks = localVideoStream.getTracks();
tracks.forEach(function (track) {
track.stop();
});
localVideoStream = null;
$("#waitingAreaVideoTag")[0].srcObject = null;
} else {
try {
$("#waitingAreaCameraButton").addClass("bg-accent");
localVideoStream = await meeting.getLocalVideoStream();
$("#waitingAreaVideoTag")[0].srcObject = localVideoStream;
cameraOn = true;
} catch (ex) {
$("#waitingAreaCameraButton").removeClass("bg-accent");
console.log("Error occurred when trying to acquire video stream", ex);
$("#waitingAreaCameraButton").attr("disabled", true);
}
}
});
In the above code snippet, there is an if condition
, which is checking whether the cameraOn
variable is set to true or not.
If the cameraOn
is set to true then we are turning off the camera, and if it is false then we are turning on the camera, let start with the "else" part first.
In the else block we are calling a Metered SDK method getLocalVideoStream()
this method returns the video steam of the device video device or of the device selected using the chooseVideoInputDevice()
method, you read more about the getLocalVideoStream()
method here.
localVideoStream = await meeting.getLocalVideoStream();
$("#waitingAreaVideoTag")[0].srcObject = localVideoStream;
cameraOn = true;
And we have created a video tag in our HTML file to show the local video, so we will be setting the srcObject
attribute of the video tag to our localVideoStream
, this will show the local video stream in the video tag and we will set the cameraOn
variable to true.
Now if the user presses the camera button again, our method will be executed, and this time the cameraOn
variable will be set to true.
const tracks = localVideoStream.getTracks();
tracks.forEach(function (track) {
track.stop();
});
localVideoStream = null;
$("#waitingAreaVideoTag")[0].srcObject = null;
So we will stop the localVideoStream
, so that the camera light turns off, to do that we would need to fetch the tracks of the localVideoStream
and stop all the tracks, this will turn off the camera light, and we will set the cameraOn
variable to false.
Joining the Meeting
We will attach an onclick
listener to the #joinMeetingButton
and in the event handler, we will call the join()
method of the Metered SDK.
After the user successfully joins the meeting, we will check if the value of cameraOn
is set to true, if yes then we will stop the localVideoStream
which was used to show the preview of the camera in the waiting area and call the startVideo()
method to share the camera with the meeting participants.
We will check if microphoneOn
variable is set to true, if yes then we will call the startAudio()
method to share the microphone with the meeting participants.
let meetingInfo = {};
$("#joinMeetingButton").on("click", async function () {
var username = $("#username").val();
if (!username) {
return alert("Please enter a username");
}
try {
console.log(meetingId);
// Fetching our Metered Domain e.g: videoapp.metered.live
// that we have added in the .env/config.js file in backend
const { data } = await axios.get("/metered-domain");
console.log(data.domain);
// Calling the Join Method of the Metered SDK
meetingInfo = await meeting.join({
roomURL: `${data.domain}/${meetingId}`,
name: username,
});
console.log("Meeting joined", meetingInfo);
$("#waitingArea").addClass("hidden");
$("#meetingView").removeClass("hidden");
$("#meetingAreaUsername").text(username);
if (cameraOn) {
$("#meetingViewCamera").addClass("bg-accent");
if (localVideoStream) {
const tracks = localVideoStream.getTracks();
tracks.forEach(function (track) {
track.stop();
});
localVideoStream = null;
}
await meeting.startVideo();
}
if (microphoneOn) {
$("#meetingViewMicrophone").addClass("bg-accent");
await meeting.startAudio();
}
} catch (ex) {
console.log("Error occurred when joining the meeting", ex);
}
});
4. Building the Meeting Area
In the meeting area the actual meeting takes place, here we have to implement the following functionality:
- If the user has shared their camera/screen show the preview of the camera or screen
- When a remote user joins the meeting show the user in the online users list
- When a remote user leaves the meeting remove the user
- When remote user share their camera / screen show the video stream
- When remote user share microphone handle the audio stream
- Allow the user to share microphone
- Allow user to share camera
- Allow user to share screen
- Enable active speaker detection and show the user who is speaking in the center.
Let start with building the UI for the meeting area:
<!-- Meeting view -->
<div id="meetingView">
<!-- remote video containers -->
<div id="remoteParticipantContainer" style="display: flex;">
</div>
<!-- Active Speaker -->
<div class="mt-4">
<div style=" border-radius: 5px;" class="bg-base-300">
<video id="activeSpeakerVideo" muted autoplay playsinline
style="padding: 0; margin: 0; width: 100%; height: 400px;"></video>
<div id="activeSpeakerUsername" class="bg-base-300 " style=" text-align: center;">
</div>
</div>
</div>
<div class="flex flex-col bg-base-300" style="width: 150px">
<video id="meetingAreaLocalVideo" muted autoplay playsinline
style="padding: 0; margin: 0; width: 150px; height: 100px;"></video>
<div id="meetingAreaUsername" class="bg-base-300 " style=" text-align: center;">
</div>
</div>
<!-- Controls -->
<div style="display: flex; justify-content: center; margin-top: 20px;" class="space-x-4">
<button id="meetingViewMicrophone" class="btn">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z">
</path>
</svg>
</button>
<button id="meetingViewCamera" class="btn">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z">
</path>
</svg>
</button>
<button id="meetingViewScreen" class="btn">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z">
</path>
</svg>
</button>
<button id="meetingViewLeave" class="btn">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clip-rule="evenodd"></path></svg>
</button>
</div>
</div>
#remoteParticipantContainer
div - Here we will add the remote participants as they join the meeting.
#activeSpeakerVideo
video tag - In this video tag we will show the video stream of the active speaker. this video tag is at the center of the page.
#activeSpeakerUsername
div - Here we will show the username of the active speaker
#meetingAreaLocalVideo
video tag - The video preview of the local camera stream of the user if the user has shared his/her camera or screen.
#meetingAreaUsername
div - This will contain the show the username of the current user.
#meetingViewMicrophone
button - This button when pressed will share the microphone with other participants in the meeting.
#meetingViewCamera
button - This button will share the camera with other participants in the meeting
#meetingViewScreen
button - This button will share the screen with other participants in the meeting
#meetingViewLeave
button - This exits the user from the meeting.
Let's see how we can achieve the goals that we have listed above:
Showing/Hiding the preview of screen or camera shared by the user
We have created a video tag with id #meetingAreaLocalVideo
, in this video tag we will show the preview of the local camera or screen shared by our current user
To achieve this, Metered SDK emits some events:
- localTrackStarted - Read more about it here
- localTrackUpdated
- localTrackStopped - Read more about it here
Whenever the local media is shared, wether audio or video this event is emitted, we will not do anything when audio is emitted (because if we add the audio tag and add the stream then user will hear his/her own voice through the speakers), but when a video stream is shared we will add it to our #meetingAreaLocalVideo video tag.
meeting.on("localTrackStarted", function (trackItem) {
if (trackItem.type === "video") {
let track = trackItem.track;
let mediaStream = new MediaStream([track]);
$("#meetingAreaLocalVideo")[0].srcObject = mediaStream;
$("#meetingAreaLocalVideo")[0].play();
}
});
When the user selects a different camera or switches from camera sharing to screen sharing, the localTrackUpdated
event is emitted, when this event is emitted we have to update our video tag so that it shows the currently shared video stream. (If we do not handle this event and the user selects a different camera or select screen sharing, then the video tag will show blank video).
meeting.on("localTrackUpdated", function (trackItem) {
if (trackItem.type === "video") {
let track = trackItem.track;
let mediaStream = new MediaStream([track]);
$("#meetingAreaLocalVideo")[0].srcObject = mediaStream;
}
});
Finally, when the user wants to stop sharing his/her camera or screen, we have to remove the video from the video tag.
meeting.on("localTrackStopped", function (localTrackItem) {
if (localTrackItem.type === "video") {
$("#meetingAreaLocalVideo")[0].srcObject = null;
}
});
Handling remote participants
We haven't talked about how we are going to show the video or handle the microphone shared by the remote participants in the meeting, so here we will go through how that is handled.
Handling participant left and participant joined
(1) When a remote participant joins the meeting we want to indicate that someone has joined the meeting, and we will show their username somewhere and create the video and audio tags to show the video if they share their camera or screen and listen to their audio if they share their microphone.
(2) Similarly, when the participant leaves the meeting we want to remove the block where the username, audio, and video tag for the participant is present.
(3) Also, when the user joins a meeting where there are already participants present, we need to handle get the list of all the existing users in the meeting and display their username and create audio and video tags to show the audio or video shared by the existing participants.
To handle the above 3 scenarios we have events provided by the Metered JavaScript SDK,
- participantJoined - Read more about it here
- participantLeft - Read more about it here
- onlineParticipants. - Read more about it here
When a new participant joins the meeting the participantJoined
event is emitted, when a participants leaves the meeting participantLeft
event is emitted and when the user joins a meeting where there are existing participants then onlineParticipants
event is emitted with a list of existing participants.
Let write the code to handle the participantJoined
event:
meeting.on("participantJoined", function (participantInfo) {
// This event is emitted for all the users, even for the current user,
// so we want ignore if it is the current user.
if (participantInfo._id === meeting.participantSessionId) return;
// Creating a div with video, audio and a div tag to show username
// Giving the div tag id of the participant so that it is easy for us to remove the tag
// when the participant leaves the meeting.
var participant = `<div id="participant-${participantInfo._id}" class="bg-base-300">
<video id="participant-${participantInfo._id}-video" muted autoplay playsinline
style="padding: 0; margin: 0; width: 150px; height: 100px;"></video>
<audio id="participant-${participantInfo._id}-audio" autoplay playsinline
style="padding: 0; margin: 0;"></audio>
<div id="participant-${participantInfo._id}-username" class="bg-base-300 " style=" text-align: center;">
${participantInfo.name}
</div>
</div>`;
// Adding the HTML to our remoteParticipantContainer
$("#remoteParticipantContainer").append(participant);
});
Code to handle the participantLeft
event:
meeting.on("participantLeft", function (participantInfo) {
console.log("participant has left the room", participantInfo);
$(`#participant-${participantInfo._id}`).remove();
});
In the above code we are removing the div for the participant, that contains the participant's username, video and audio tags.
Code to handle onlineParticipants
event:
meeting.on("onlineParticipants", function (onlineParticipants) {
$("#remoteParticipantContainer").html("");
for (let participantInfo of onlineParticipants) {
if (participantInfo._id !== meeting.participantSessionId) {
var participant = `<div id="participant-${participantInfo._id}" class="bg-base-300">
<video id="participant-${participantInfo._id}-video" muted autoplay playsinline
style="padding: 0; margin: 0; width: 150px; height: 100px;"></video>
<audio id="participant-${participantInfo._id}-audio" autoplay playsinline
style="padding: 0; margin: 0;"></audio>
<div id="participant-${participantInfo._id}-username" class="bg-base-300 " style=" text-align: center;">
${participantInfo.name}
</div>
</div>`;
$("#remoteParticipantContainer").append(participant);
}
}
});
The online participant's code is very similar to participantJoined
event code, the only difference here is that we get an array of participants instead of one single participant and we loop through the array add them to the UI.
Handling when remote participants share their camera, screen or microphone
In the previous step, we have created the audio and video tag for the remote participants, now we need to add the video stream or audio stream to the audio or video tag and remove the audio and video stream when they share their video (screen or camera) and audio respectively.
For each remote participant we have created an audio tag with id participant-${participantInfo._id}-audio
and video tag with id participant-${participantInfo._id}-video
where ${participantInfo._id} will replace with the id of the participant, by creating id's like this it becomes easier for us to find the appropriate video/audio tag for the participant to attach the video or audio stream.
When the remote participant share's their video or microphone remoteTrackStarted
event is emitted to all the participants in the meeting, and when the remote participant stop's sharing the camera or microphone, remoteTrackStopped
event is emitted to all the participants.
- remoteTrackStarted - Read more about it here
- remoteTrackStopped - Read more about it here
meeting.on("remoteTrackStarted", function (trackItem) {
if (trackItem.participantSessionId === meeting.participantSessionId) return;
var track = trackItem.track;
var mediaStream = new MediaStream([track]);
$(
`#participant-${trackItem.participantSessionId}-${trackItem.type}`
)[0].srcObject = mediaStream;
$(
`#participant-${trackItem.participantSessionId}-${trackItem.type}`
)[0].play();
});
meeting.on("remoteTrackStopped", function (trackItem) {
if (trackItem.participantSessionId === meeting.participantSessionId) return;
$(
`#participant-${trackItem.participantSessionId}-${trackItem.type}`
)[0].srcObject = null;
});
Handling active speaker
We have created a large video #activeSpeakerVideo
in the center of the page, and here we will show the user who is currently speaking, to implement this Metered SDK provides and event called as activeSpeaker
, this event contains the info of the user who is actively speaking.
- activeSpeaker - Read more about it here
var currentActiveSpeaker = "";
meeting.on("activeSpeaker", function (activeSpeaker) {
if (currentActiveSpeaker === activeSpeaker.participantSessionId) return;
$("#activeSpeakerUsername").text(activeSpeaker.name);
currentActiveSpeaker = activeSpeaker.participantSessionId;
if ($(`#participant-${activeSpeaker.participantSessionId}-video`)[0]) {
let stream = $(
`#participant-${activeSpeaker.participantSessionId}-video`
)[0].srcObject;
$("#activeSpeakerVideo")[0].srcObject = stream.clone();
}
if (activeSpeaker.participantSessionId === meeting.participantSessionId) {
let stream = $(`#meetingAreaLocalVideo`)[0].srcObject;
if (stream) {
$("#activeSpeakerVideo")[0].srcObject = stream.clone();
}
}
});
Here we will clone the video stream of the active speaking user from its video tag and show it in the #activeSpeakerVideo
video tag, and also show the username of user in the #activeSpeakerUsername
div tag.
Handling Leave Meeting
When the participant closes the window, the participant automatically leaves the meeting, we can also call the leaveMeeting()
, if we want to leave the meeting.
$("#meetingViewLeave").on("click", async function () {
await meeting.leaveMeeting();
$("#meetingView").addClass("hidden");
$("#leaveView").removeClass("hidden");
});
Complete Front-End Code
Here is our complete front-end code:
const meeting = new Metered.Meeting();
let meetingId = "";
$("#joinExistingMeeting").on("click", async function (e) {
if (e) e.preventDefault();
meetingId = $("#meetingId").val();
if (!meetingId) {
return alert("Please enter meeting id");
}
// Sending request to validate meeting id
try {
const response = await axios.get(
"/validate-meeting?meetingId=" + meetingId
);
if (response.data.success) {
// Meeting id is valid, taking the user to the waiting area.
$("#joinView").addClass("hidden");
$("#waitingArea").removeClass("hidden");
$("#displayMeetingId").text(meetingId);
$("#meetingIdContainer").removeClass("hidden");
initializeWaitingArea();
} else {
alert("meeting id is invalid");
}
} catch (ex) {
alert("meeting Id is invalid");
}
});
$("#createANewMeeting").on("click", async function (e) {
if (e) e.preventDefault();
// Sending request to create a new meeting room
try {
const response = await axios.post("/create-meeting-room");
if (response.data.success) {
$("#joinView").addClass("hidden");
$("#waitingArea").removeClass("hidden");
$("#displayMeetingId").text(response.data.roomName);
$("#meetingIdContainer").removeClass("hidden");
meetingId = response.data.roomName;
initializeWaitingArea();
}
} catch (ex) {
alert("Error occurred when creating a new meeting");
}
});
/**
* Method to initialize the waiting area:
* This methods calls the SDK methods to request the
* user for microphone and camera permissions.
*/
var videoUnavailable = true;
var audioUnavailable = true;
async function initializeWaitingArea() {
let audioOutputDevices = [];
try {
audioOutputDevices = await meeting.listAudioOutputDevices();
} catch (ex) {
console.log("option not available - it is unsupported in firefox", ex);
}
let audioInputDevices = [];
try {
audioInputDevices = await meeting.listAudioInputDevices();
} catch (ex) {
console.log("camera not available or have disabled camera access", ex);
audioUnavailable = true;
// Disabling the camera button
$("#waitingAreaMicrophoneButton").attr("disabled", true);
}
let videoInputDevices = [];
try {
videoInputDevices = await meeting.listVideoInputDevices();
} catch (ex) {
console.log("camera not available or have disabled camera access", ex);
videoUnavailable = true;
// Disabling the camera button
$("#waitingAreaCameraButton").attr("disabled", true);
}
let cameraOptions = [];
for (let device of videoInputDevices) {
cameraOptions.push(
`<option value="${device.deviceId}">${device.label}</option>`
);
}
let microphoneOptions = [];
for (let device of audioInputDevices) {
microphoneOptions.push(
`<option value="${device.deviceId}">${device.label}</option>`
);
}
let speakerOptions = [];
for (let device of audioOutputDevices) {
speakerOptions.push(
`<option value="${device.deviceId}">${device.label}</option>`
);
}
$("#cameras").html(cameraOptions.join(""));
$("#microphones").html(microphoneOptions.join(""));
$("#speakers").html(speakerOptions.join(""));
// Selecting different camera
$("#cameras").on("change", async function (value) {
const deviceId = $("#cameras").val();
console.log(deviceId);
await meeting.chooseVideoInputDevice(deviceId);
});
// Setting different microphone
$("#microphones").on("change", async function (value) {
const deviceId = $("#microphones").val();
await meeting.chooseAudioInputDevice(deviceId);
});
// Setting different speaker
$("#speakers").on("change", async function (value) {
const deviceId = $("#speakers").val();
await meeting.chooseAudioOutputDevice(deviceId);
});
}
/**
* Adding click events to buttons in waiting area
*/
let microphoneOn = false;
$("#waitingAreaMicrophoneButton").on("click", function () {
if (microphoneOn) {
$("#waitingAreaMicrophoneButton").removeClass("bg-accent");
microphoneOn = false;
} else {
microphoneOn = true;
$("#waitingAreaMicrophoneButton").addClass("bg-accent");
}
});
let cameraOn = false;
let localVideoStream = null;
$("#waitingAreaCameraButton").on("click", async function () {
if (cameraOn) {
cameraOn = false;
$("#waitingAreaCameraButton").removeClass("bg-accent");
const tracks = localVideoStream.getTracks();
tracks.forEach(function (track) {
track.stop();
});
localVideoStream = null;
$("#waitingAreaVideoTag")[0].srcObject = null;
} else {
try {
$("#waitingAreaCameraButton").addClass("bg-accent");
localVideoStream = await meeting.getLocalVideoStream();
$("#waitingAreaVideoTag")[0].srcObject = localVideoStream;
cameraOn = true;
} catch (ex) {
$("#waitingAreaCameraButton").removeClass("bg-accent");
console.log("Error occurred when trying to acquire video stream", ex);
$("#waitingAreaCameraButton").attr("disabled", true);
}
}
});
let meetingInfo = {};
$("#joinMeetingButton").on("click", async function () {
var username = $("#username").val();
if (!username) {
return alert("Please enter a username");
}
try {
console.log(meetingId);
const { data } = await axios.get("/metered-domain");
console.log(data.domain);
meetingInfo = await meeting.join({
roomURL: `${data.domain}/${meetingId}`,
name: username,
});
console.log("Meeting joined", meetingInfo);
$("#waitingArea").addClass("hidden");
$("#meetingView").removeClass("hidden");
$("#meetingAreaUsername").text(username);
if (cameraOn) {
$("#meetingViewCamera").addClass("bg-accent");
if (localVideoStream) {
const tracks = localVideoStream.getTracks();
tracks.forEach(function (track) {
track.stop();
});
localVideoStream = null;
}
await meeting.startVideo();
}
if (microphoneOn) {
$("#meetingViewMicrophone").addClass("bg-accent");
await meeting.startAudio();
}
} catch (ex) {
console.log("Error occurred when joining the meeting", ex);
}
});
/**
* Adding click events to buttons in Meeting Area
*/
$("#meetingViewMicrophone").on("click", async function () {
if (microphoneOn) {
microphoneOn = false;
$("#meetingViewMicrophone").removeClass("bg-accent");
await meeting.stopAudio();
} else {
microphoneOn = true;
$("#meetingViewMicrophone").addClass("bg-accent");
await meeting.startAudio();
}
});
$("#meetingViewCamera").on("click", async function () {
if (cameraOn) {
cameraOn = false;
$("#meetingViewCamera").removeClass("bg-accent");
await meeting.stopVideo();
} else {
cameraOn = true;
$("#meetingViewCamera").addClass("bg-accent");
await meeting.startVideo();
}
});
let screenSharing = false;
$("#meetingViewScreen").on("click", async function () {
if (screenSharing) {
$("#meetingViewScreen").removeClass("bg-accent");
await meeting.stopVideo();
return;
} else {
try {
await meeting.startScreenShare();
screenSharing = true;
cameraOn = false;
$("#meetingViewCamera").removeClass("bg-accent");
$("#meetingViewScreen").addClass("bg-accent");
} catch (ex) {
console.log("Error occurred when trying to share screen", ex);
}
}
});
/**
* Listening to events
*/
meeting.on("localTrackStarted", function (trackItem) {
if (trackItem.type === "video") {
let track = trackItem.track;
let mediaStream = new MediaStream([track]);
$("#meetingAreaLocalVideo")[0].srcObject = mediaStream;
$("#meetingAreaLocalVideo")[0].play();
}
});
meeting.on("localTrackUpdated", function (trackItem) {
if (trackItem.type === "video") {
let track = trackItem.track;
let mediaStream = new MediaStream([track]);
$("#meetingAreaLocalVideo")[0].srcObject = mediaStream;
}
});
meeting.on("localTrackStopped", function (localTrackItem) {
if (localTrackItem.type === "video") {
$("#meetingAreaLocalVideo")[0].srcObject = null;
}
});
meeting.on("remoteTrackStarted", function (trackItem) {
if (trackItem.participantSessionId === meeting.participantSessionId) return;
var track = trackItem.track;
var mediaStream = new MediaStream([track]);
$(
`#participant-${trackItem.participantSessionId}-${trackItem.type}`
)[0].srcObject = mediaStream;
$(
`#participant-${trackItem.participantSessionId}-${trackItem.type}`
)[0].play();
});
meeting.on("remoteTrackStopped", function (trackItem) {
if (trackItem.participantSessionId === meeting.participantSessionId) return;
$(
`#participant-${trackItem.participantSessionId}-${trackItem.type}`
)[0].srcObject = null;
});
meeting.on("participantJoined", function (participantInfo) {
if (participantInfo._id === meeting.participantSessionId) return;
var participant = `<div id="participant-${participantInfo._id}" class="bg-base-300">
<video id="participant-${participantInfo._id}-video" muted autoplay playsinline
style="padding: 0; margin: 0; width: 150px; height: 100px;"></video>
<audio id="participant-${participantInfo._id}-audio" autoplay playsinline
style="padding: 0; margin: 0;"></audio>
<div id="participant-${participantInfo._id}-username" class="bg-base-300 " style=" text-align: center;">
${participantInfo.name}
</div>
</div>`;
$("#remoteParticipantContainer").append(participant);
});
meeting.on("participantLeft", function (participantInfo) {
console.log("participant has left the room", participantInfo);
$(`#participant-${participantInfo._id}`).remove();
});
meeting.on("onlineParticipants", function (onlineParticipants) {
$("#remoteParticipantContainer").html("");
for (let participantInfo of onlineParticipants) {
if (participantInfo._id !== meeting.participantSessionId) {
var participant = `<div id="participant-${participantInfo._id}" class="bg-base-300">
<video id="participant-${participantInfo._id}-video" muted autoplay playsinline
style="padding: 0; margin: 0; width: 150px; height: 100px;"></video>
<audio id="participant-${participantInfo._id}-audio" autoplay playsinline
style="padding: 0; margin: 0;"></audio>
<div id="participant-${participantInfo._id}-username" class="bg-base-300 " style=" text-align: center;">
${participantInfo.name}
</div>
</div>`;
$("#remoteParticipantContainer").append(participant);
}
}
});
var currentActiveSpeaker = "";
meeting.on("activeSpeaker", function (activeSpeaker) {
if (currentActiveSpeaker === activeSpeaker.participantSessionId) return;
$("#activeSpeakerUsername").text(activeSpeaker.name);
currentActiveSpeaker = activeSpeaker.participantSessionId;
if ($(`#participant-${activeSpeaker.participantSessionId}-video`)[0]) {
let stream = $(
`#participant-${activeSpeaker.participantSessionId}-video`
)[0].srcObject;
$("#activeSpeakerVideo")[0].srcObject = stream.clone();
}
if (activeSpeaker.participantSessionId === meeting.participantSessionId) {
let stream = $(`#meetingAreaLocalVideo`)[0].srcObject;
if (stream) {
$("#activeSpeakerVideo")[0].srcObject = stream.clone();
}
}
});
$("#meetingViewLeave").on("click", async function () {
await meeting.leaveMeeting();
$("#meetingView").addClass("hidden");
$("#leaveView").removeClass("hidden");
});
HTML code:
<!DOCTYPE html>
<html lang="en" class="bg-white">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Demo App</title>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"
integrity="sha512-bZS47S7sPOxkjU/4Bt0zrhEtWx0y0CRkhEp8IckzK+ltifIIE9EMIMTuT/mEzoIMewUINruDBIR/jJnbguonqQ=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<script
src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
crossorigin="anonymous"
></script>
<!-- Import the webpage's stylesheet -->
<link
href="https://cdn.jsdelivr.net/npm/tailwindcss@2.1/dist/tailwind.min.css"
rel="stylesheet"
type="text/css"
/>
<link
href="https://cdn.jsdelivr.net/npm/daisyui@1.11.1/dist/full.css"
rel="stylesheet"
type="text/css"
/>
<script src="//cdn.metered.ca/sdk/video/1.4.6/sdk.min.js"></script>
</head>
<body>
<div class="navbar mb-2 shadow-lg bg-neutral text-neutral-content">
<div class="flex-none px-2 mx-2">
<span class="text-lg font-bold"> Metered </span>
</div>
<div class="flex-1 px-2 mx-2">
<div class="items-stretch hidden lg:flex">
<a
href="https://metered.ca/docs/Video-Calls/JavaScript/Advanced-SDK-Guide"
target="_blank"
class="btn btn-ghost btn-sm rounded-btn"
>
Advanced SDK Guide
</a>
<a
href="https://metered.ca/docs/Video-Calls/JavaScript/Tips-and-Best-Practices"
target="_blank"
class="btn btn-ghost btn-sm rounded-btn"
>
Tips and Best practices
</a>
<a
href="https://metered.ca/docs/SDK-Reference/JavaScript/Methods/Methods%20Introduction"
target="_blank"
class="btn btn-ghost btn-sm rounded-btn"
>
SDK Reference
</a>
</div>
</div>
</div>
<div
id="meetingIdContainer"
class="w-full bg-base-300 hidden font-bold text-center py-2"
>
Meeting ID: <span id="displayMeetingId"></span>
</div>
<!-- Join view -->
<div id="joinView" class="w-full items-center justify-center flex">
<div class="bg-base-300 w-11/12 max-w-screen-md rounded mt-48 p-10">
<div class="form-control">
<label class="label">
<span class="label-text">Meeting ID</span>
</label>
<div class="relative">
<input
id="meetingId"
type="text"
placeholder="Meeting ID"
class="w-full pr-16 input input-primary input-bordered"
/>
<button
id="joinExistingMeeting"
class="absolute top-0 right-0 rounded-l-none btn btn-primary text-xs"
>
<span class="hidden sm:block">Join Existing Meeting</span>
<span class="sm:hidden">Join</span>
</button>
</div>
</div>
<div class="divider">OR</div>
<div class="flex justify-center">
<button id="createANewMeeting" class="btn btn-primary">
Create a new meeting
</button>
</div>
</div>
</div>
<!-- Waiting area -->
<div
id="waitingArea"
class="w-full items-center justify-center flex hidden"
>
<div class="bg-base-300 w-11/12 rounded mt-48 p-10">
<video
id="waitingAreaVideoTag"
class="w-full"
muted
autoplay
playsinline
></video>
<div class="flex items-center justify-center mt-4 space-x-4">
<button id="waitingAreaMicrophoneButton" class="btn">
<svg
class="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z"
clip-rule="evenodd"
></path>
</svg>
</button>
<button id="waitingAreaCameraButton" class="btn">
<svg
class="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"
></path>
</svg>
</button>
</div>
<div class="divider"></div>
<div class="grid grid-cols-3 space-x-4">
<div class="form-control">
<label class="label">
<span class="label-text">Camera</span>
</label>
<select id="cameras" class="select select-bordered w-full"></select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Microphone</span>
</label>
<select
id="microphones"
class="select select-bordered w-full"
></select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Speaker</span>
</label>
<select
id="speakers"
class="select select-bordered w-full"
></select>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Enter a username</span>
</label>
<div class="relative">
<input
id="username"
type="text"
placeholder="username"
class="w-full pr-16 input input-primary input-bordered"
/>
<button
id="joinMeetingButton"
class="absolute top-0 right-0 rounded-l-none btn btn-primary"
>
<span class="hidden sm:block">Join Existing Meeting</span>
<span class="sm:hidden">Join</span>
</button>
</div>
</div>
</div>
</div>
<!-- Meeting view -->
<div id="meetingView" class="hidden ">
<!-- remote video containers -->
<div id="remoteParticipantContainer" style="display: flex;"></div>
<!-- Active Speaker -->
<div class="mt-4">
<div style=" border-radius: 5px;" class="bg-base-300">
<video
id="activeSpeakerVideo"
muted
autoplay
playsinline
style="padding: 0; margin: 0; width: 100%; height: 400px;"
></video>
<div
id="activeSpeakerUsername"
class="bg-base-300 "
style=" text-align: center;"
></div>
</div>
</div>
<div class="flex flex-col bg-base-300" style="width: 150px">
<video
id="meetingAreaLocalVideo"
muted
autoplay
playsinline
style="padding: 0; margin: 0; width: 150px; height: 100px;"
></video>
<div
id="meetingAreaUsername"
class="bg-base-300 "
style=" text-align: center;"
></div>
</div>
<!-- Controls -->
<div
style="display: flex; justify-content: center; margin-top: 20px;"
class="space-x-4"
>
<button id="meetingViewMicrophone" class="btn">
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
></path>
</svg>
</button>
<button id="meetingViewCamera" class="btn">
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>
</button>
<button id="meetingViewScreen" class="btn">
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
></path>
</svg>
</button>
<button id="meetingViewLeave" class="btn">
<svg
class="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z"
clip-rule="evenodd"
></path>
</svg>
</button>
</div>
</div>
<div id="leaveView" class="flex items-center justify-center hidden">
<div class="bg-base-300 w-11/12 rounded-lg mt-20 p-4">
<h1 class="text-2xl font-bold">You have Left the Meeting</h1>
<div class="divider"></div>
<p>
<button class="btn btn-primary" onclick="window.location.reload()">
Join Another Meeting
</button>
</p>
</div>
</div>
<!-- Import the webpage's javascript file -->
<script src="/script.js" defer></script>
</body>
</html>
Running the Application
To run the application, will run the command:
node src/server.js
This will start the application on localhost:4000
Testing on multiple devices
To test the application on multiple devices you can use tsocket, you can download and install it from here: https://tsocket.org
After installing run the application then run the command:
tsocket http 4000
tsocket will give you a URL that you can open on multiple devices to test out video conferencing.
Github
You can download the complete application from Github: https://github.com/metered-ca/video-javascript-quickstart