Webrtc shows extra stream with only one peer connection
I am trying to allow my webrtc video call meeting app have multiple peer connections and have the stream appear on the remote video call dynamically using javascript. I have only two tabs in two windows open on the video test room url. One is incognito and one is regular google chrome browser but both are google chrome browsers. They are both logged in to superusers. The problem is, the remote videos are shown twice of the same user of the remote user.
This is my video call html file.
<!DOCTYPE html>
<html>
<head>
<style>
button {
border: 0;
background-color: orange;
padding: 10px;
color: white;
border-radius: 7px;
}
video {
border-radius: 15px;
}
.videoContainer {
display: flex;
margin: 20px;
width: 640px;
}
.videoContainer h2 {
color: white;
position: relative;
bottom: -380px;
left: -350px;
width: max-content;
}
#meet {
display: flex;
}
#recordButton.recording {
background-color: red;
}
#downloadButton {
background-color: #4caf50;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
</style>
<title>A Free Bird Video Call</title>
<script src="https://meet.jit.si/external_api.js"></script>
</head>
<body>
<div id="meet">
<div id="remote-videos">
<div class="videoContainer">
<video id="localVideo" autoplay playsinline></video>
<h2>{{ request.user.full_name }}</h2>
</div>
</div>
<!-- <div class="videoContainer">
<video id="remoteVideo" autoplay playsinline></video>
<h2></h2>
</div> -->
</div>
<div>
<button onclick="startCall()">Start Call</button>
<button id="recordButton" onclick="toggleRecording()" disabled>
Start Recording
</button>
<button id="downloadButton" onclick="downloadRecording()" disabled>
Download Recording
</button>
</div>
<script>
// Global variables
let peerConnection = null;
let localStream = null;
let isInitiator = false;
let iceCandidatesQueue = [];
let mediaRecorder = null;
let recordedChunks = [];
let isRecording = false;
const roomName = "{{ room_name }}";
const signalingChannel = new WebSocket(
`wss://${window.location.host}/ws/webrtc/${roomName}/`
);
setupMediaStream();
signalingChannel.onopen = async () => {
console.log("WebSocket connected!");
signalingChannel.send(
JSON.stringify({
type: "join",
room: roomName,
username: "{{ request.user.full_name }}",
})
);
};
async function setupMediaStream() {
try {
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
document.getElementById("localVideo").srcObject = localStream;
console.log("Local stream setup successful");
globalThis.localStream = localStream;
} catch (err) {
console.error("Error accessing media devices:", err);
throw err;
}
}
async function initializePeerConnection(localStream) {
const servers = {'iceServers': [{'urls': 'stun:stun.l.google.com:19302'}]}
peerConnection = new RTCPeerConnection(servers);
console.log("Created peer connection");
peerConnection.addStream(globalThis.localStream);
console.log("✅ Track added to peer connection");
peerConnection.ontrack = (event) => {
const userId = "{{ request.user.id }}"
console.log("Received remote track");
const remoteVideo = document.getElementById("remote-videos");
const stream = event.streams[0];
const container = document.createElement("div");
container.classList.add("videoContainer");
container.id = `remote-video-${userId}`;
const videoElement = document.createElement('video');
videoElement.srcObject = stream;
videoElement.play();
remoteVideo.appendChild(container);
container.appendChild(videoElement);
container.appendChild(document.createElement("h2"));
setupRecording();
};
// ICE candidate handling
peerConnection.onsignalingstatechange = (event) => {
console.log("Signaling state change:", peerConnection.signalingState);
};
peerConnection.onconnectionstatechange = (event) => {
console.log("Connection state change:", peerConnection.connectionState);
};
return peerConnection;
}
async function setupAndStart() {
try {
if (!peerConnection) {
await initializePeerConnection();
const remoteStreams = peerConnection.getRemoteStreams();
console.log(remoteStreams);
}
} catch (err) {
console.error("Error in setupAndStart:", err);
}
}
async function handleCandidate(message) {
try {
if (!peerConnection || !peerConnection.remoteDescription) {
console.log("Queueing ICE candidate");
iceCandidatesQueue.push(message.candidate);
}
if (message.candidate) {
await peerConnection.addIceCandidate(
new RTCIceCandidate(message.candidate)
);
console.log("Added ICE candidate");
}
} catch (err) {
console.error("Error adding ICE candidate:", err);
}
}
async function handleOffer(message) {
try {
if (!peerConnection) {
await setupAndStart();
}
await peerConnection.setRemoteDescription(
new RTCSessionDescription({
type: "offer",
sdp: message.sdp,
})
);
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
console.log("Sending ICE candidate");
signalingChannel.send(
JSON.stringify({
type: "ice-candidate",
candidate: event.candidate,
username: "{{ request.user.full_name }}",
})
);
console.log("ICE candidate sent");
}
};
// Process queued candidates
while (iceCandidatesQueue.length > 0) {
const candidate = iceCandidatesQueue.shift();
await peerConnection.addIceCandidate(
new RTCIceCandidate(candidate)
);
console.log("Added queued ICE candidate");
}
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
signalingChannel.send(
JSON.stringify({
type: "answer",
sdp: answer.sdp,
username: "{{ request.user.full_name }}",
})
);
} catch (err) {
console.error("Error handling offer:", err);
}
}
async function handleAnswer(message) {
try {
await peerConnection.setRemoteDescription(
new RTCSessionDescription({
type: "answer",
sdp: message.sdp,
})
);
// Process queued candidates
while (iceCandidatesQueue.length > 0) {
const candidate = iceCandidatesQueue.shift();
await peerConnection.addIceCandidate(
new RTCIceCandidate(candidate)
);
console.log("Added queued ICE candidate");
}
} catch (err) {
console.error("Error handling answer:", err);
}
}
async function startCall() {
try {
iceCandidatesQueue = [];
await setupAndStart();
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
signalingChannel.send(
JSON.stringify({
type: "offer",
sdp: offer.sdp,
username: "{{ request.user.full_name }}",
})
);
} catch (err) {
console.error("Error starting call:", err);
}
}
function updateRemoteUsername(message) {
const userId = "{{ request.user.id }}"
const remoteVideoContainer = document.getElementById(`remote-video-${userId}`);
if (remoteVideoContainer) {
const remoteNameElement = remoteVideoContainer.querySelector("h2");
if (remoteNameElement) {
remoteNameElement.textContent = message;
}
}
}
signalingChannel.onmessage = async (event) => {
const message = JSON.parse(event.data);
console.log("Received message:", message);
try {
switch (message.type) {
case "join":
if (!message.self) {
isInitiator = true;
updateRemoteUsername(message.username);
await setupAndStart();
}
break;
case "offer":
updateRemoteUsername(message.username);
await handleOffer(message);
break;
case "answer":
await handleAnswer(message);
break;
case "ice-candidate":
await handleCandidate(message);
break;
case "candidate":
await handleCandidate(message);
break;
default:
console.log("Unknown message type:", message.type);
}
} catch (err) {
console.error("Error handling message:", err);
}
};
async function setupRecording(stream) {
try {
// Create a canvas element to combine the videos
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
// Get video elements
const localVideo = document.getElementById("localVideo");
const remoteVideo = document.getElementById("remoteVideo");
// Wait for both streams to be available
if (!localVideo.srcObject || !remoteVideo.srcObject) {
console.log(
"Waiting for both streams before setting up recording..."
);
return;
}
// Set canvas size to match the video container width and height
const videoContainer = document.querySelector(".videoContainer");
const containerStyle = window.getComputedStyle(videoContainer);
canvas.width = videoContainer.offsetWidth * 2; // Width of two videos
canvas.height = videoContainer.offsetHeight;
// Create a stream from the canvas
const canvasStream = canvas.captureStream(30); // 30 FPS
// Add audio tracks from both streams
const localAudioTrack = localVideo.srcObject.getAudioTracks()[0];
const remoteAudioTrack = remoteVideo.srcObject.getAudioTracks()[0];
if (localAudioTrack) canvasStream.addTrack(localAudioTrack);
if (remoteAudioTrack) canvasStream.addTrack(remoteAudioTrack);
// Create MediaRecorder
mediaRecorder = new MediaRecorder(canvasStream, {
mimeType: "video/webm;codecs=vp8,opus",
});
// Handle data available event
mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
recordedChunks.push(event.data);
}
};
// Handle recording stop
mediaRecorder.onstop = () => {
console.log("Recording stopped");
document.getElementById("downloadButton").disabled = false;
};
// Enable record button
document.getElementById("recordButton").disabled = false;
// Draw frames to canvas
function drawFrame() {
// Draw local video
ctx.drawImage(localVideo, 0, 0, canvas.width / 2, canvas.height);
// Draw remote video
ctx.drawImage(
remoteVideo,
canvas.width / 2,
0,
canvas.width / 2,
canvas.height
);
// Add names under videos
ctx.fillStyle = "white";
ctx.font = "20px Arial";
const localName = "{{ request.user.full_name }}";
const remoteName =
remoteVideo.parentElement.querySelector("h2").textContent;
// Add local name
ctx.fillText(localName, 10, canvas.height - 20);
// Add remote name
ctx.fillText(remoteName, canvas.width / 2 + 10, canvas.height - 20);
if (isRecording) {
requestAnimationFrame(drawFrame);
}
}
// Start the recording loop when recording starts
mediaRecorder.onstart = () => {
drawFrame();
};
console.log("Recording setup complete");
} catch (err) {
console.error("Error setting up recording:", err);
}
}
async function toggleRecording() {
const recordButton = document.getElementById('recordButton');
if (!isRecording) {
// Start recording
recordedChunks = [];
try {
if (!mediaRecorder) {
await setupRecording();
}
if (mediaRecorder && mediaRecorder.state === 'inactive') {
mediaRecorder.start(1000); // Record in 1-second chunks
isRecording = true;
recordButton.textContent = 'Stop Recording';
recordButton.classList.add('recording');
console.log("Recording started");
}
} catch (err) {
console.error("Error starting recording:", err);
}
} else {
// Stop recording
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
isRecording = false;
recordButton.textContent = 'Start Recording';
recordButton.classList.remove('recording');
console.log("Recording stopped");
}
}
}
function downloadRecording() {
try {
if (!recordedChunks.length) {
console.error("No recording data available");
return;
}
const blob = new Blob(recordedChunks, {
type: 'video/webm'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
document.body.appendChild(a);
a.style.display = 'none';
a.href = url;
a.download = `call-recording-${new Date().toISOString()}.webm`;
a.click();
// Cleanup
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
recordedChunks = [];
document.getElementById('downloadButton').disabled = true;
} catch (err) {
console.error("Error downloading recording:", err);
}
}
// Cleanup when the call ends
window.onbeforeunload = () => {
if (localStream) {
localStream.getTracks().forEach((track) => track.stop());
}
if (peerConnection) {
peerConnection.close();
}
if (signalingChannel) {
signalingChannel.close();
}
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
isRecording = false;
};
window.onunload = () => {
if (peerConnection) {
peerConnection.close();
}
};
</script>
</body>
</html>
Any clues as to why there is an extra remote video? By the way, I am using the same webcam for both peer connections.
So I would end up seeing 3 webcams. One for local. 2 for remote peers. There should only be 2 webcam streams. 1 for local. 1 for remote peer. This is because I only had two tabs open for the video call meeting room.
If you have a stream with two tracks for each peer, your ontrack handler is called twice, once for the audio track of the stream and the second time for the video track for the stream. That means you are creating and showing the remote video element twice.