This blog was written by Akshat Gupta an Agora Developer Evangelist Intern who had started his journey in Agora as a Superstar. The Agora Superstar program empowers developers around the world to share their passion and technical expertise, and create innovative real-time communications apps and projects using Agora’s customizable SDKs. Think you’ve got what it takes to be an Agora Superstar? Apply here.
Last week, as I was giving a project review, my teammate forgot to turn off his microphone when someone in his room started talking to him. Our project review was interrupted by the chatter from his end, so we had to call and ask him to turn off his microphone.
If the host had had the option to mute my teammate, our meeting would not have been disrupted.
In this tutorial, we will develop a web application that supports muting and unmuting the video and audio streams of remote users in a group video calling application using the Agora Web SDK and the Agora RTM SDK.

Prerequisites
- Basic working knowledge of JavaScript, JQuery, Bootstrap, and Font Awesome
- An Agora developer account (see How to Get Started with Agora)
- The Agora Web SDK
- The Agora RTM SDK
Project Setup
Let’s start by laying out our basic HTML structure. A few UI elements are necessary, such as the local audio stream uid, the remote audio streams’ uids, the list of remote users,and buttons for joining, leaving, and muting and unmuting. I’ve also imported the necessary CDNs and linked the custom CSS and JS files.
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<!-- Meta Tags --> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | |
<title>Agora Mute-Unmute Remote User Demo</title> | |
<link rel="icon" href="./favicon.png" type="image/png"> | |
<link rel="apple-touch-icon" href="./apple-touch-icon.png" type="image/png"> | |
<!-- CSS only --> | |
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" rel="stylesheet" | |
integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" crossorigin="anonymous"> | |
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.0/css/all.css" | |
integrity="sha384-lZN37f5QGtY3VHgisS14W3ExzMWZxybE1SJSEsQp9S+oqd12jhcu+A56Ebc1zFSJ" crossorigin="anonymous"> | |
<link rel="stylesheet" href="./index.css"> | |
</head> | |
<body> | |
<!-- Container --> | |
<div class="container-fluid banner"> | |
<p class="banner-text">Agora Mute-Unmute Remote User Demo</p> | |
</div> | |
<div class="container mt-5 mb-5"> | |
<!-- Form for joining and leaving --> | |
<form id="join-form"> | |
<div class="row join-info-group"> | |
<div class="col-sm"> | |
<p class="join-info-text">AppID</p> | |
<input id="appid" type="text" placeholder="Enter AppID" required class="form-control"> | |
</div> | |
<div class="col-sm"> | |
<p class="join-info-text">Channel</p> | |
<input id="channel" type="text" placeholder="Enter Channel Name" required class="form-control"> | |
</div> | |
<div class="col-sm"> | |
<p class="join-info-text">Name</p> | |
<input id="accountName" type="text" placeholder="Enter Your Name" required class="form-control"> | |
</div> | |
</div> | |
<div class="button-group mt-2"> | |
<button id="join" type="submit" class="btn btn-sm">Join</button> | |
<button id="mic-btn" type="button" class="btn btn-sm" disabled> | |
<i id="mic-icon" class="fa fa-microphone"></i> | |
</button> | |
<button id="video-btn" type="button" class="btn btn-sm" disabled> | |
<i id="video-icon" class="fa fa-video"></i> | |
</button> | |
<button id="leave" type="button" class="btn btn-sm" disabled>Leave</button> | |
</div> | |
</form> | |
<div class="row"> | |
<div class="col"> | |
<div class="row video-group"> | |
<!-- Local Video --> | |
<div class="col"> | |
<p id="local-player-name" class="player-name"></p> | |
<div id="local-player" class="player"></div> | |
</div> | |
<div class="w-100"></div> | |
<!-- Remote Players --> | |
<div class="col"> | |
<div id="remote-playerlist"></div> | |
</div> | |
</div> | |
</div> | |
<div class="col"> | |
<!-- All Users List with Mute Controls --> | |
<div class="all-users-text my-3"> | |
<h5 class="text-decoration-underline">All Users:</h5> | |
<div class="all-users" id="all-users"> | |
<ul id="insert-all-users"> | |
</ul> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Scripts --> | |
<script src="https://code.jquery.com/jquery-3.6.0.min.js" | |
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> | |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js" | |
integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf" crossorigin="anonymous"> | |
</script> | |
<script src="https://download.agora.io/sdk/release/AgoraRTC_N.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/agora-rtm-sdk@1.3.1/index.js"></script> | |
<script src="./index.js"></script> | |
</body> | |
</html> |

Adding Color
Now that our basic layout is ready, it’s time to add some CSS.
I’ve already added basic Bootstrap classes to the HTML to make the site presentable, but we’ll use custom CSS to match the site with a blue Agora-based theme.
.banner { | |
padding: 10px; | |
background-color: #1A0374; | |
color: white; | |
} | |
.banner-text { | |
padding: 8px 20px; | |
margin: 0; | |
font-size: 18px; | |
} | |
#join-form { | |
margin-top: 10px; | |
} | |
.join-info-text { | |
margin-bottom: 2px; | |
} | |
input { | |
width: 100%; | |
margin-bottom: 2px; | |
} | |
.player { | |
width: 480px; | |
height: 320px; | |
} | |
.player-name { | |
margin: 8px 0; | |
} | |
@media (max-width: 640px) { | |
.player { | |
width: 320px; | |
height: 240px; | |
} | |
} | |
#join, | |
#leave, | |
#mic-btn, | |
#video-btn { | |
background-color: #0C9DFD; | |
color: white; | |
border-radius: 5px; | |
} | |
#join:hover, | |
#leave:hover, | |
#mic-btn:hover, | |
#video-btn:hover { | |
background-color: rgba(2, 133, 221, 0.911); | |
} | |
.all-users-text { | |
background: rgba(12, 157, 253, 0.897); | |
padding: 30px; | |
color: white; | |
border-radius: 15px; | |
max-height: 500px; | |
overflow: scroll; | |
} | |
.btn-control { | |
outline: none; | |
border: 1px solid white; | |
box-shadow: none; | |
} | |
.btn-control:hover, | |
.btn-control:focus { | |
outline: none; | |
box-shadow: none; | |
border: 1px solid white; | |
background: rgb(12, 157, 253); | |
} | |
.remoteMicrophone, | |
.remoteCamera { | |
cursor: pointer; | |
} |

Core Functionality (JS)
Now that we have the HTML/DOM structure laid out, we can add the JS, which uses the Agora Web SDK. It may look intimidating at first, but if you follow Agora’s official docs and demos and put in a little practice, it’ll be a piece of cake.
In the following snippet, we first create a client and then create a microphone audio and camera video track (line 56 and line 57).
Tokens are used for incorporating additional security into our application. If you don’t use tokens, specify the tokens as null.
When a user joins a channel by clicking the button, you begin playing the tracks specified while creating the client. The user’s stream is then published (line 63) and subscribed (line 88), which can be toggled using the UI controls we wrote above.
When a user leaves (line 116), remove the uid from the user’s screen.
Finally, we give the user an option to end the call and leave (line 40) the channel.
// Create Agora client | |
var client = AgoraRTC.createClient({ | |
mode: "rtc", | |
codec: "vp8" | |
}); | |
// RTM Global Vars | |
var isLoggedIn = false; | |
var localTracks = { | |
videoTrack: null, | |
audioTrack: null | |
}; | |
var remoteUsers = {}; | |
// Agora client options | |
var options = { | |
appid: null, | |
channel: null, | |
uid: null, | |
token: null, | |
accountName: null | |
}; | |
$("#join-form").submit(async function (e) { | |
e.preventDefault(); | |
$("#join").attr("disabled", true); | |
try { | |
options.appid = $("#appid").val(); | |
options.token = $("#token").val(); | |
options.channel = $("#channel").val(); | |
options.accountName = $('#accountName').val(); | |
await join(); | |
} catch (error) { | |
console.error(error); | |
} finally { | |
$("#leave").attr("disabled", false); | |
} | |
}) | |
$("#leave").click(function (e) { | |
leave(); | |
}) | |
async function join() { | |
$("#mic-btn").prop("disabled", false); | |
$("#video-btn").prop("disabled", false); | |
RTMJoin(); | |
// add event listener to play remote tracks when remote user publishs. | |
client.on("user-published", handleUserPublished); | |
client.on("user-left", handleUserLeft); | |
// join a channel and create local tracks, we can use Promise.all to run them concurrently | |
[options.uid, localTracks.audioTrack, localTracks.videoTrack] = await Promise.all([ | |
// join the channel | |
client.join(options.appid, options.channel, options.token || null), | |
// create local tracks, using microphone and camera | |
AgoraRTC.createMicrophoneAudioTrack(), | |
AgoraRTC.createCameraVideoTrack() | |
]); | |
// play local video track | |
localTracks.videoTrack.play("local-player"); | |
$("#local-player-name").text(`localVideo(${options.uid})`); | |
// publish local tracks to channel | |
await client.publish(Object.values(localTracks)); | |
console.log("publish success"); | |
} | |
async function leave() { | |
for (trackName in localTracks) { | |
var track = localTracks[trackName]; | |
if (track) { | |
track.stop(); | |
track.close(); | |
$('#mic-btn').prop('disabled', true); | |
$('#video-btn').prop('disabled', true); | |
localTracks[trackName] = undefined; | |
} | |
} | |
// remove remote users and player views | |
remoteUsers = {}; | |
$("#remote-playerlist").html(""); | |
// leave the channel | |
await client.leave(); | |
$("#local-player-name").text(""); | |
$("#join").attr("disabled", false); | |
$("#leave").attr("disabled", true); | |
console.log("client leaves channel success"); | |
} | |
async function subscribe(user, mediaType) { | |
const uid = user.uid; | |
// subscribe to a remote user | |
await client.subscribe(user, mediaType); | |
console.log("subscribe success"); | |
if (mediaType === 'video') { | |
const player = $(` | |
<div id="player-wrapper-${uid}"> | |
<p class="player-name">remoteUser(${uid})</p> | |
<div id="player-${uid}" class="player"></div> | |
</div> | |
`); | |
$("#remote-playerlist").append(player); | |
user.videoTrack.play(`player-${uid}`); | |
} | |
if (mediaType === 'audio') { | |
user.audioTrack.play(); | |
} | |
} | |
// Handle user publish | |
function handleUserPublished(user, mediaType) { | |
const id = user.uid; | |
remoteUsers[id] = user; | |
subscribe(user, mediaType); | |
} | |
// Handle user left | |
function handleUserLeft(user) { | |
const id = user.uid; | |
delete remoteUsers[id]; | |
$(`#player-wrapper-${id}`).remove(); | |
} | |
// Initialise UI controls | |
enableUiControls(); | |
// Action buttons | |
function enableUiControls() { | |
$("#mic-btn").click(function () { | |
toggleMic(); | |
}); | |
$("#video-btn").click(function () { | |
toggleVideo(); | |
}); | |
} | |
// Toggle Mic | |
function toggleMic() { | |
if ($("#mic-icon").hasClass('fa-microphone')) { | |
localTracks.audioTrack.setEnabled(false); | |
console.log("Audio Muted."); | |
} else { | |
localTracks.audioTrack.setEnabled(true); | |
console.log("Audio Unmuted."); | |
} | |
$("#mic-icon").toggleClass('fa-microphone').toggleClass('fa-microphone-slash'); | |
} | |
// Toggle Video | |
function toggleVideo() { | |
if ($("#video-icon").hasClass('fa-video')) { | |
localTracks.videoTrack.setEnabled(false); | |
console.log("Video Muted."); | |
} else { | |
localTracks.videoTrack.setEnabled(true); | |
console.log("Video Unmuted."); | |
} | |
$("#video-icon").toggleClass('fa-video').toggleClass('fa-video-slash'); | |
} |
Now that our application supports group video calling, it’s time to incorporate the main functionality: to mute remote video and audio tracks using the Agora RTM SDK.
Muting and Unmuting a User Using RTM
We call the RTMJoin() function to create a RTM client (line 3). Following the RTM Docs, we log in to RTM (line 8) and join a channel (line 16) to get started with the RTM SDK.
We use channel.getMembers() (line 19) to get a list of all users in the RTM channel. We’ll use their usernames, which we received from the front end to each button using a custom id.
The getMembers function is called only once, so the list isn’t updated when a user leaves or joins the channel. To prevent this data inconsistency, we use the MemberLeft (line 126) and MemberJoined (line 104) callbacks provided by the RTM SDK.
We then check for clicks on the remote video and audio toggle buttons (line 39). When a user clicks the button, we send a peer-to-peer message using RTM to tell the remote user what kind of a mute/unmute it is (video or audio).
When the peer receives the message (line 75), the user’s localTracks are muted/unmuted, and thus the stream is changed for all users in the RTC channel.
Finally, we add a logout function (line 154) because,unlike the Web SDK, the RTM SDK requires the user to log out in addition to leaving the channel.
async function RTMJoin() { | |
// Create Agora RTM client | |
const clientRTM = AgoraRTM.createInstance($("#appid").val(), { | |
enableLogUpload: false | |
}); | |
var accountName = $('#accountName').val(); | |
// Login | |
clientRTM.login({ | |
uid: accountName | |
}).then(() => { | |
console.log('AgoraRTM client login success. Username: ' + accountName); | |
isLoggedIn = true; | |
// RTM Channel Join | |
var channelName = $('#channel').val(); | |
channel = clientRTM.createChannel(channelName); | |
channel.join().then(() => { | |
console.log('AgoraRTM client channel join success.'); | |
// Get all members in RTM Channel | |
channel.getMembers().then((memberNames) => { | |
console.log("------------------------------"); | |
console.log("All members in the channel are as follows: "); | |
console.log(memberNames); | |
var newHTML = $.map(memberNames, function (singleMember) { | |
if (singleMember != accountName) { | |
return (`<li class="mt-2"> | |
<div class="row"> | |
<p>${singleMember}</p> | |
</div> | |
<div class="mb-4"> | |
<button class="text-white btn btn-control mx-3 remoteMicrophone micOn" id="remoteAudio-${singleMember}">Toggle Mic</button> | |
<button class="text-white btn btn-control remoteCamera camOn" id="remoteVideo-${singleMember}">Toggle Video</button> | |
</div> | |
</li>`); | |
} | |
}); | |
$("#insert-all-users").html(newHTML.join("")); | |
}); | |
// Send peer-to-peer message for audio muting and unmuting | |
$(document).on('click', '.remoteMicrophone', function () { | |
fullDivId = $(this).attr('id'); | |
peerId = fullDivId.substring(fullDivId.indexOf("-") + 1); | |
console.log("Remote microphone button pressed."); | |
let peerMessage = "audio"; | |
clientRTM.sendMessageToPeer({ | |
text: peerMessage | |
}, | |
peerId, | |
).then(sendResult => { | |
if (sendResult.hasPeerReceived) { | |
console.log("Message has been received by: " + peerId + " Message: " + peerMessage); | |
} else { | |
console.log("Message sent to: " + peerId + " Message: " + peerMessage); | |
} | |
}) | |
}); | |
// Send peer-to-peer message for video muting and unmuting | |
$(document).on('click', '.remoteCamera', function () { | |
fullDivId = $(this).attr('id'); | |
peerId = fullDivId.substring(fullDivId.indexOf("-") + 1); | |
console.log("Remote video button pressed."); | |
let peerMessage = "video"; | |
clientRTM.sendMessageToPeer({ | |
text: peerMessage | |
}, | |
peerId, | |
).then(sendResult => { | |
if (sendResult.hasPeerReceived) { | |
console.log("Message has been received by: " + peerId + " Message: " + peerMessage); | |
} else { | |
console.log("Message sent to: " + peerId + " Message: " + peerMessage); | |
} | |
}) | |
}); | |
// Display messages from peer | |
clientRTM.on('MessageFromPeer', function ({ | |
text | |
}, peerId) { | |
console.log(peerId + " muted/unmuted your " + text); | |
if (text == "audio") { | |
console.log("Remote video toggle reached with " + peerId); | |
if ($("#remoteAudio-" + peerId).hasClass('micOn')) { | |
localTracks.audioTrack.setEnabled(false); | |
console.log("Remote Audio Muted for: " + peerId); | |
$("#remoteAudio-" + peerId).removeClass('micOn'); | |
} else { | |
localTracks.audioTrack.setEnabled(true); | |
console.log("Remote Audio Unmuted for: " + peerId); | |
$("#remoteAudio-" + peerId).addClass('micOn'); | |
} | |
} else if (text == "video") { | |
console.log("Remote video toggle reached with " + peerId); | |
if ($("#remoteVideo-" + peerId).hasClass('camOn')) { | |
localTracks.videoTrack.setEnabled(false); | |
console.log("Remote Video Muted for: " + peerId); | |
$("#remoteVideo-" + peerId).removeClass('camOn'); | |
} else { | |
localTracks.videoTrack.setEnabled(true); | |
console.log("Remote Video Unmuted for: " + peerId); | |
$("#remoteVideo-" + peerId).addClass('camOn'); | |
} | |
} | |
}) | |
// Display channel member joined updated users | |
channel.on('MemberJoined', function () { | |
// Get all members in RTM Channel | |
channel.getMembers().then((memberNames) => { | |
console.log("New member joined so updated list is: "); | |
console.log(memberNames); | |
var newHTML = $.map(memberNames, function (singleMember) { | |
if (singleMember != accountName) { | |
return (`<li class="mt-2"> | |
<div class="row"> | |
<p>${singleMember}</p> | |
</div> | |
<div class="mb-4"> | |
<button class="text-white btn btn-control mx-3 remoteMicrophone micOn" id="remoteAudio-${singleMember}">Toggle Mic</button> | |
<button class="text-white btn btn-control remoteCamera camOn" id="remoteVideo-${singleMember}">Toggle Video</button> | |
</div> | |
</li>`); | |
} | |
}); | |
$("#insert-all-users").html(newHTML.join("")); | |
}); | |
}) | |
// Display channel member left updated users | |
channel.on('MemberLeft', function () { | |
// Get all members in RTM Channel | |
channel.getMembers().then((memberNames) => { | |
console.log("A member left so updated list is: "); | |
console.log(memberNames); | |
var newHTML = $.map(memberNames, function (singleMember) { | |
if (singleMember != accountName) { | |
return (`<li class="mt-2"> | |
<div class="row"> | |
<p>${singleMember}</p> | |
</div> | |
<div class="mb-4"> | |
<button class="text-white btn btn-control mx-3 remoteMicrophone micOn" id="remoteAudio-${singleMember}">Toggle Mic</button> | |
<button class="text-white btn btn-control remoteCamera camOn" id="remoteVideo-${singleMember}">Toggle Video</button> | |
</div> | |
</li>`); | |
} | |
}); | |
$("#insert-all-users").html(newHTML.join("")); | |
}); | |
}); | |
}).catch(error => { | |
console.log('AgoraRTM client channel join failed: ', error); | |
}).catch(err => { | |
console.log('AgoraRTM client login failure: ', err); | |
}); | |
}); | |
// Logout | |
document.getElementById("leave").onclick = async function () { | |
console.log("Client logged out of RTM."); | |
await clientRTM.logout(); | |
} | |
} |
You can now run and test the application.
Note: For testing, you can use multiple browser tabs to simulate multiple users on the call.
Conclusion
You did it!
We have successfully made our own video calling application with a video and audio stream muting service. In case you weren’t coding along or want to see the finished product all together, I have uploaded all the code to GitHub:

If you would like to see the demo in action, check out the demo of the code in action:

Thanks for taking the time to read my tutorial. If you have questions, please let me know with a comment. If you see room for improvement, feel free to fork the repo and make a pull request!