Peer receiving the answer cannot see offerer’s stream until reconnect

16 hours ago 1
ARTICLE AD BOX

I'm learning WebRTC by making video chat application, and stumbled across the issue I can't fix for days.

I had a somehow working version of app, but it was unstable and inconsistent.
To improve it, I've switched to transceivers, to maintain correct m-line order and avoid redundant renegotiations.

But for some reason the caller's stream is not visible for callee after initial negotiation.

Callee the one who sending offer, caller just sits and waits.

However, after the initial negotiation, the caller stream is not visible on the callee.

If caller will disconnect from the call and then enter back - app will work as intended, both streams visible, reconnection works, everything is fine.

Tried to swap roles, made caller sending offer instead of receiving, and now caller can't see callee's stream. So the one who receiving answer can't see offerer's stream and should reconnect in order for it to show up.

As far as I know, renegotiation is not needed if I'm just replacing track on transceiver that was included in SDP. I've tried adding negotiations after initial track swap, after initial connection, tried made both peers exchange both answers and offers, but nothing worked.

Renegotiations also throws m-line order mismatch error on reconnect (renegotiation introducing 2 more m-lines once)

I have logs everywhere to confirm that tracks are present in the stream and srcObject is set.
It shows that tracks are here, they're enabled, live, and not muted.

Tried as well to move remoteStream component in the main file, to eliminate all state/ref desync, but nope, nothing.

The offerer (callee) can't see answerer's (caller) stream

attaching some code snippets.

function to turn camera on/off:

const toggleCamera = async() => { const transceiver = getTransceiver("video") if(!transceiver) return; const sender = transceiver.sender; if (!sender) return; if (sender.track) { // sender.track.stop(); await sender.replaceTrack(null); const videoTracks = localStream.current.getVideoTracks(); videoTracks.forEach(track => localStream.current.removeTrack(track)); setLocalStreamActive(false); socket.emit("cameraState", {to: roomId, state: "disabled"}) } else { const stream = await navigator.mediaDevices.getUserMedia({ video: true }); const track = stream.getVideoTracks()[0]; console.log('Got video track:', track); await sender.replaceTrack(track); console.log('track replaced') localStream.current.addTrack(track); setLocalStreamActive(true); socket.emit("cameraState", {to: roomId, state: "enabled"}) } }

pc creation and listeners:

const createPC = async() => { pc.current = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }); pc.current.addTransceiver("audio", { direction: "sendrecv" }); pc.current.addTransceiver("video", { direction: "sendrecv" }); pc.current.onconnectionstatechange = async() => { const state = pc.current?.connectionState; console.log(state) setConnectionState(state) if(state === 'connected'){ startStatsMonitoring(); } if (state === 'disconnected' || state === 'failed' || state === 'closed') { setRemoteStreamActive(false) setRemoteMicActive(false) stopStatsMonitoring() } } pc.current.ontrack = (event) => { const track = event.track; console.log("ONTRACK FIRED", track.kind); if (!remoteStream.current.getTracks().includes(track)) { remoteStream.current.addTrack(track) console.log("track added") } track.onended = () => {track.kind === 'video' ? setRemoteStreamActive(false) : setRemoteMicActive(false)}; if (track.readyState === "ended") { track.kind === 'video' ? setRemoteStreamActive(false) : setRemoteMicActive(false) } console.log("muted", track.muted) }; pc.current.onicecandidate = event => { console.log('current peerId', peerId.current) if (event.candidate && peerId.current) { socket.emit("ice-candidate", { candidate: event.candidate, to: peerId.current }); } }; }

sdp handler:

socket.on("description", async({from, description}) => { console.log(description.type, 'received') console.warn(description.sdp) try { const readyForOffer = !creatingOffer.current && (pc.current?.signalingState === "stable" || isSettingRemoteAnswerPending.current); const offerCollision = description.type === "offer" && !readyForOffer; ignoreOffer.current = !isPolite.current && offerCollision; if (ignoreOffer.current) { return; } isSettingRemoteAnswerPending.current = description.type === "answer"; await pc.current?.setRemoteDescription(description); isSettingRemoteAnswerPending.current = false; if (description.type === "offer") { pc.current?.getTransceivers().forEach(transceiver => { if (transceiver.direction !== 'sendrecv') { console.log(`Forcing ${transceiver.receiver.track.kind} to sendrecv (was ${transceiver.direction})`); transceiver.direction = 'sendrecv'; } }); const answer = await pc.current?.createAnswer() console.log('Answer directions:', answer?.sdp?.match(/^a=(sendrecv|recvonly|sendonly|inactive)$/gm)); await pc.current?.setLocalDescription(answer); socket.emit("description", {description: answer, to: from}) } } catch (err) { console.error(err); console.warn(description); } })

and since it works fine after disconnect and return - leave call function:

const leaveCall = () => { localStream.current?.getTracks().forEach(track => track.stop()); localStream.current = new MediaStream(); remoteStream.current = new MediaStream(); pc.current?.close(); pc.current = null; socket.emit("leave-room", { roomId }); setLocalStreamActive(false); setLocalMicActive(false); setRemoteStreamActive(false); setRemoteMicActive(false); peerId.current = null; setConnectionState('closed') navigate('/') };

Server side (socket.io):

io.on("connection", (socket) => { socket.on("create-room", () => { const roomId = uuidv4() socket.join(roomId); console.log("create", roomId) socket.emit("room-created", roomId) }); socket.on("join-room", (roomId) => { const room = io.sockets.adapter.rooms.get(roomId); const peers = room ? [...room] : []; socket.join(roomId); console.log(peers) peers.forEach(peerId => { socket.emit("existing-peer", peerId); }); socket.to(roomId).emit("peer-joined", socket.id); }); socket.on("description", ({ description, to }) => { console.log(description?.type, " to: ", to) if(to) socket.to(to).emit("description", { from: socket.id, description }); }); socket.on("ice-candidate", ({ candidate, to }) => { socket.to(to).emit("ice-candidate", { candidate, from: socket.id }); }); socket.on("cameraState", ({state, to}: {state: 'enabled' | 'disabled', to: any}) => { socket.to(to).emit("cameraState", { state, from: socket.id }) }) socket.on("micState", ({state, to}: {state: 'enabled' | 'disabled', to: any}) => { socket.to(to).emit("micState", { state, from: socket.id }) }) socket.on("leave-room", ({ roomId }) => { socket.leave(roomId); socket.to(roomId).emit("peer-left", socket.id); }); });
Read Entire Article