From fa10ef51169bbb6d4e1d63ba59907cfdc72df062 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 28 Mar 2026 01:41:26 +0800 Subject: [PATCH] fix(WebRTCManager, callStore): improve signaling state handling and media track management - Enhanced ontrack event handling to construct remote streams and avoid duplicate tracks during renegotiation. - Updated negotiation logic to ensure it only occurs when the signaling state is stable and the instance is the initiator. - Added diagnostic logging for offer and answer SDP to assist in debugging video track handling. - Improved rollback logic in callStore to handle signaling state checks more robustly after rollback attempts. --- src/services/webrtc/WebRTCManager.ts | 169 +++++++++++++++++++++------ src/stores/callStore.ts | 12 +- 2 files changed, 143 insertions(+), 38 deletions(-) diff --git a/src/services/webrtc/WebRTCManager.ts b/src/services/webrtc/WebRTCManager.ts index 4d5fa72..49e3ce6 100644 --- a/src/services/webrtc/WebRTCManager.ts +++ b/src/services/webrtc/WebRTCManager.ts @@ -87,11 +87,18 @@ class WebRTCManager { // @ts-ignore pc.ontrack = (event) => { - console.log('[WebRTC] ontrack event, kind:', event.track.kind, 'streams:', event.streams.length); - if (event.streams && event.streams[0]) { - this.remoteStream = event.streams[0]; - this.emit({ type: 'remotestream', stream: event.streams[0] }); + console.log('[WebRTC] ontrack event, kind:', event.track.kind, 'streams:', event.streams?.length ?? 0); + // react-native-webrtc often doesn't populate event.streams + // Manually construct the remote stream from the track + if (!this.remoteStream) { + this.remoteStream = new MediaStream(); } + // Avoid adding duplicate tracks (can happen during renegotiation) + const existingTrack = this.remoteStream.getTracks().find(t => t.id === event.track.id); + if (!existingTrack) { + this.remoteStream.addTrack(event.track); + } + this.emit({ type: 'remotestream', stream: this.remoteStream }); }; // @ts-ignore @@ -112,7 +119,8 @@ class WebRTCManager { // Only start negotiation if: // 1. We're in stable state // 2. Not already negotiating - if (pc.signalingState === 'stable' && !this.isNegotiating) { + // 3. We are the initiator (only initiator should auto-negotiate) + if (pc.signalingState === 'stable' && !this.isNegotiating && this.isInitiator) { try { this.isNegotiating = true; const offer = await this.createOffer(); @@ -129,7 +137,7 @@ class WebRTCManager { }, 500); } } else { - console.log('[WebRTC] Skipping negotiation: state=', pc.signalingState, 'isNegotiating=', this.isNegotiating); + console.log('[WebRTC] Skipping negotiation: state=', pc.signalingState, 'isNegotiating=', this.isNegotiating, 'isInitiator=', this.isInitiator); } }; @@ -238,8 +246,14 @@ class WebRTCManager { async createOffer(): Promise { if (!this.peerConnection) throw new Error('PeerConnection not initialized'); + // Check signaling state - must be stable to create offer + if (this.peerConnection.signalingState !== 'stable') { + console.warn('[WebRTC] Cannot create offer, signaling state is:', this.peerConnection.signalingState); + throw new Error(`Cannot create offer in signaling state: ${this.peerConnection.signalingState}`); + } + console.log('[WebRTC] Creating offer...'); - + // Check if we have video tracks const hasLocalVideo = (this.localStream?.getVideoTracks().length ?? 0) > 0; const offerOptions = { @@ -250,19 +264,29 @@ class WebRTCManager { console.log('[WebRTC] Offer options:', offerOptions, 'hasLocalVideo:', hasLocalVideo); const offer = await this.peerConnection.createOffer(offerOptions); - + // Check again after async operation if (!this.peerConnection || this.disposed) { throw new Error('PeerConnection was disposed during offer creation'); } - + + // Diagnostic: log video m-line from offer SDP + if (offer.sdp) { + const videoMLine = offer.sdp.split('\n').find((l: string) => l.startsWith('m=video')); + const videoIdx = offer.sdp.indexOf('m=video'); + const videoDir = videoIdx >= 0 + ? offer.sdp.split('\n').find((l: string) => l.startsWith('a=') && offer.sdp.indexOf(l) > videoIdx && (l.includes('sendrecv') || l.includes('recvonly') || l.includes('sendonly') || l.includes('inactive'))) + : null; + console.log('[WebRTC] Offer video m-line:', videoMLine, 'video direction:', videoDir); + } + await this.peerConnection.setLocalDescription(offer); return offer; } async createAnswer(): Promise { console.log('[WebRTC] createAnswer called, peerConnection exists:', !!this.peerConnection, 'disposed:', this.disposed); - + if (!this.peerConnection) { console.error('[WebRTC] createAnswer: PeerConnection is null'); throw new Error('PeerConnection not initialized'); @@ -279,18 +303,28 @@ class WebRTCManager { // @ts-ignore - react-native-webrtc types const answer = await this.peerConnection.createAnswer(answerOptions); - + console.log('[WebRTC] Answer created, setting local description...'); - + // Check again after async operation if (!this.peerConnection || this.disposed) { console.error('[WebRTC] PeerConnection was disposed after createAnswer'); throw new Error('PeerConnection was disposed during answer creation'); } - + + // Diagnostic: log video m-line from answer SDP + if (answer.sdp) { + const videoMLine = answer.sdp.split('\n').find((l: string) => l.startsWith('m=video')); + const videoIdx = answer.sdp.indexOf('m=video'); + const videoDir = videoIdx >= 0 + ? answer.sdp.split('\n').find((l: string) => l.startsWith('a=') && answer.sdp.indexOf(l) > videoIdx && (l.includes('sendrecv') || l.includes('recvonly') || l.includes('sendonly') || l.includes('inactive'))) + : null; + console.log('[WebRTC] Answer video m-line:', videoMLine, 'video direction:', videoDir); + } + await this.peerConnection.setLocalDescription(answer); console.log('[WebRTC] Local description set successfully'); - + // Process pending candidates after local description is set await this.processPendingCandidates(); return answer; @@ -310,23 +344,60 @@ class WebRTCManager { if (!description.sdp) { throw new Error('setRemoteDescription: sdp is required'); } - + console.log('[WebRTC] Setting remote description, type:', description.type); - + + // When receiving an offer, ensure video transceiver direction is compatible + // react-native-webrtc may not auto-negotiate transceiver direction from remote SDP + if (description.type === 'offer' && description.sdp) { + this.ensureVideoTransceiverRecvForOffer(description.sdp); + } + const desc = new RTCSessionDescription({ type: description.type, sdp: description.sdp, }); await this.peerConnection.setRemoteDescription(desc); - + console.log('[WebRTC] Remote description set successfully'); // Process pending candidates after remote description is set await this.processPendingCandidates(); - + console.log('[WebRTC] Pending candidates processed, connection state:', this.peerConnection?.signalingState); } + /** + * When receiving an offer where remote wants to send video, + * ensure our video transceiver direction allows receiving (recvonly). + * react-native-webrtc doesn't always auto-negotiate direction from remote SDP. + */ + private ensureVideoTransceiverRecvForOffer(remoteSdp: string): void { + if (!this.peerConnection) return; + + // Check if the SDP contains a video media line with send direction + // react-native-webrtc may use \n instead of \r\n + const hasVideoSend = + remoteSdp.includes('m=video') && + (remoteSdp.includes('a=sendrecv') || remoteSdp.includes('a=sendonly')); + + if (!hasVideoSend) { + console.log('[WebRTC] Remote offer does not send video'); + return; + } + + const transceivers = this.peerConnection.getTransceivers(); + const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video'); + + if (videoTransceiver) { + // If our direction is inactive, update to recvonly so we can receive video + if (videoTransceiver.direction === 'inactive') { + console.log('[WebRTC] Updating video transceiver from inactive to recvonly for remote video'); + videoTransceiver.direction = 'recvonly'; + } + } + } + /** * Rollback to stable state (for Glare handling) */ @@ -397,8 +468,8 @@ class WebRTCManager { } /** - * Enable video using transceiver direction - * This preserves m-line order and triggers renegotiation + * Enable video using addTrack for maximum compatibility + * Actively triggers renegotiation */ async enableVideo(): Promise { if (!this.peerConnection) throw new Error('PeerConnection not initialized'); @@ -414,43 +485,53 @@ class WebRTCManager { const videoTrack = videoStream.getVideoTracks()[0]; - // Find the video transceiver and update it + // Find the video transceiver and replace track on it const transceivers = this.peerConnection.getTransceivers(); const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video'); if (videoTransceiver) { - // Replace the track + // Replace the track on existing sender await videoTransceiver.sender.replaceTrack(videoTrack); - // Update direction to sendrecv + // Force direction to sendrecv videoTransceiver.direction = 'sendrecv'; console.log('[WebRTC] Video transceiver updated, direction:', videoTransceiver.direction); } else { - console.error('[WebRTC] No video transceiver found!'); + // No transceiver exists, add track directly (will create one) + this.peerConnection.addTrack(videoTrack); + console.log('[WebRTC] Video track added via addTrack (no existing transceiver)'); } // Update local stream const newStream = new MediaStream(); - + if (this.localStream) { - // Add existing audio tracks this.localStream.getAudioTracks().forEach(track => { newStream.addTrack(track); }); - // Stop old video tracks this.localStream.getVideoTracks().forEach(track => { track.stop(); }); } - - // Add new video track + newStream.addTrack(videoTrack); this.localStream = newStream; - console.log('[WebRTC] Video enabled successfully'); - - // onnegotiationneeded will be triggered automatically + console.log('[WebRTC] Video enabled, creating renegotiation offer...'); + + // Set lock to prevent onnegotiationneeded from firing duplicate + this.isNegotiating = true; + const offer = await this.createOffer(); + if (this.peerConnection && !this.disposed) { + this.emit({ type: 'negotiationneeded', offer }); + } + // Release lock after a delay + setTimeout(() => { + this.isNegotiating = false; + }, 500); + return newStream; } catch (error) { + this.isNegotiating = false; console.error('[WebRTC] Failed to enable video:', error); throw error; } @@ -458,6 +539,7 @@ class WebRTCManager { /** * Disable video using transceiver direction + * Actively triggers renegotiation instead of relying on onnegotiationneeded */ async disableVideo(): Promise { if (!this.peerConnection) { @@ -494,11 +576,25 @@ class WebRTCManager { this.localStream.getAudioTracks().forEach(track => { newStream.addTrack(track); }); - - this.localStream = newStream; - console.log('[WebRTC] Video disabled successfully'); - // onnegotiationneeded will be triggered automatically + this.localStream = newStream; + console.log('[WebRTC] Video disabled, creating renegotiation offer...'); + + // Set lock to prevent onnegotiationneeded from firing duplicate + this.isNegotiating = true; + try { + const offer = await this.createOffer(); + if (this.peerConnection && !this.disposed) { + this.emit({ type: 'negotiationneeded', offer }); + } + } catch (err) { + console.error('[WebRTC] Failed to create renegotiation offer for disableVideo:', err); + } finally { + setTimeout(() => { + this.isNegotiating = false; + }, 500); + } + return newStream; } @@ -559,7 +655,6 @@ class WebRTCManager { if (this.remoteStream) { this.remoteStream.getTracks().forEach((track) => track.stop()); - this.remoteStream.release(); this.remoteStream = null; } diff --git a/src/stores/callStore.ts b/src/stores/callStore.ts index 69acb25..9e45fd6 100644 --- a/src/stores/callStore.ts +++ b/src/stores/callStore.ts @@ -202,7 +202,7 @@ async function handleIncomingOffer(msg: WSCallSDPMessage, myUserId: string): Pro return; } - const signalingState = pc.signalingState; + let signalingState = pc.signalingState; console.log('[CallStore] Received offer, signalingState:', signalingState); // Glare handling: if we're not in stable state, we have a conflict @@ -223,6 +223,16 @@ async function handleIncomingOffer(msg: WSCallSDPMessage, myUserId: string): Pro await webrtcManager.rollback(); } catch (err) { console.error('[CallStore] Rollback failed:', err); + // If rollback fails because we're already stable, that's fine - just proceed + if (pc.signalingState !== 'stable') { + return; + } + } + + // Re-check state after rollback + signalingState = pc.signalingState; + if (signalingState !== 'stable') { + console.warn('[CallStore] Not stable after rollback, state:', signalingState); return; } }