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.
This commit is contained in:
@@ -87,11 +87,18 @@ class WebRTCManager {
|
|||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
pc.ontrack = (event) => {
|
pc.ontrack = (event) => {
|
||||||
console.log('[WebRTC] ontrack event, kind:', event.track.kind, 'streams:', event.streams.length);
|
console.log('[WebRTC] ontrack event, kind:', event.track.kind, 'streams:', event.streams?.length ?? 0);
|
||||||
if (event.streams && event.streams[0]) {
|
// react-native-webrtc often doesn't populate event.streams
|
||||||
this.remoteStream = event.streams[0];
|
// Manually construct the remote stream from the track
|
||||||
this.emit({ type: 'remotestream', stream: event.streams[0] });
|
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
|
// @ts-ignore
|
||||||
@@ -112,7 +119,8 @@ class WebRTCManager {
|
|||||||
// Only start negotiation if:
|
// Only start negotiation if:
|
||||||
// 1. We're in stable state
|
// 1. We're in stable state
|
||||||
// 2. Not already negotiating
|
// 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 {
|
try {
|
||||||
this.isNegotiating = true;
|
this.isNegotiating = true;
|
||||||
const offer = await this.createOffer();
|
const offer = await this.createOffer();
|
||||||
@@ -129,7 +137,7 @@ class WebRTCManager {
|
|||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
} else {
|
} 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<RTCSessionDescriptionInit> {
|
async createOffer(): Promise<RTCSessionDescriptionInit> {
|
||||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
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...');
|
console.log('[WebRTC] Creating offer...');
|
||||||
|
|
||||||
// Check if we have video tracks
|
// Check if we have video tracks
|
||||||
const hasLocalVideo = (this.localStream?.getVideoTracks().length ?? 0) > 0;
|
const hasLocalVideo = (this.localStream?.getVideoTracks().length ?? 0) > 0;
|
||||||
const offerOptions = {
|
const offerOptions = {
|
||||||
@@ -250,19 +264,29 @@ class WebRTCManager {
|
|||||||
console.log('[WebRTC] Offer options:', offerOptions, 'hasLocalVideo:', hasLocalVideo);
|
console.log('[WebRTC] Offer options:', offerOptions, 'hasLocalVideo:', hasLocalVideo);
|
||||||
|
|
||||||
const offer = await this.peerConnection.createOffer(offerOptions);
|
const offer = await this.peerConnection.createOffer(offerOptions);
|
||||||
|
|
||||||
// Check again after async operation
|
// Check again after async operation
|
||||||
if (!this.peerConnection || this.disposed) {
|
if (!this.peerConnection || this.disposed) {
|
||||||
throw new Error('PeerConnection was disposed during offer creation');
|
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);
|
await this.peerConnection.setLocalDescription(offer);
|
||||||
return offer;
|
return offer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||||
console.log('[WebRTC] createAnswer called, peerConnection exists:', !!this.peerConnection, 'disposed:', this.disposed);
|
console.log('[WebRTC] createAnswer called, peerConnection exists:', !!this.peerConnection, 'disposed:', this.disposed);
|
||||||
|
|
||||||
if (!this.peerConnection) {
|
if (!this.peerConnection) {
|
||||||
console.error('[WebRTC] createAnswer: PeerConnection is null');
|
console.error('[WebRTC] createAnswer: PeerConnection is null');
|
||||||
throw new Error('PeerConnection not initialized');
|
throw new Error('PeerConnection not initialized');
|
||||||
@@ -279,18 +303,28 @@ class WebRTCManager {
|
|||||||
|
|
||||||
// @ts-ignore - react-native-webrtc types
|
// @ts-ignore - react-native-webrtc types
|
||||||
const answer = await this.peerConnection.createAnswer(answerOptions);
|
const answer = await this.peerConnection.createAnswer(answerOptions);
|
||||||
|
|
||||||
console.log('[WebRTC] Answer created, setting local description...');
|
console.log('[WebRTC] Answer created, setting local description...');
|
||||||
|
|
||||||
// Check again after async operation
|
// Check again after async operation
|
||||||
if (!this.peerConnection || this.disposed) {
|
if (!this.peerConnection || this.disposed) {
|
||||||
console.error('[WebRTC] PeerConnection was disposed after createAnswer');
|
console.error('[WebRTC] PeerConnection was disposed after createAnswer');
|
||||||
throw new Error('PeerConnection was disposed during answer creation');
|
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);
|
await this.peerConnection.setLocalDescription(answer);
|
||||||
console.log('[WebRTC] Local description set successfully');
|
console.log('[WebRTC] Local description set successfully');
|
||||||
|
|
||||||
// Process pending candidates after local description is set
|
// Process pending candidates after local description is set
|
||||||
await this.processPendingCandidates();
|
await this.processPendingCandidates();
|
||||||
return answer;
|
return answer;
|
||||||
@@ -310,23 +344,60 @@ class WebRTCManager {
|
|||||||
if (!description.sdp) {
|
if (!description.sdp) {
|
||||||
throw new Error('setRemoteDescription: sdp is required');
|
throw new Error('setRemoteDescription: sdp is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[WebRTC] Setting remote description, type:', description.type);
|
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({
|
const desc = new RTCSessionDescription({
|
||||||
type: description.type,
|
type: description.type,
|
||||||
sdp: description.sdp,
|
sdp: description.sdp,
|
||||||
});
|
});
|
||||||
await this.peerConnection.setRemoteDescription(desc);
|
await this.peerConnection.setRemoteDescription(desc);
|
||||||
|
|
||||||
console.log('[WebRTC] Remote description set successfully');
|
console.log('[WebRTC] Remote description set successfully');
|
||||||
|
|
||||||
// Process pending candidates after remote description is set
|
// Process pending candidates after remote description is set
|
||||||
await this.processPendingCandidates();
|
await this.processPendingCandidates();
|
||||||
|
|
||||||
console.log('[WebRTC] Pending candidates processed, connection state:', this.peerConnection?.signalingState);
|
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)
|
* Rollback to stable state (for Glare handling)
|
||||||
*/
|
*/
|
||||||
@@ -397,8 +468,8 @@ class WebRTCManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable video using transceiver direction
|
* Enable video using addTrack for maximum compatibility
|
||||||
* This preserves m-line order and triggers renegotiation
|
* Actively triggers renegotiation
|
||||||
*/
|
*/
|
||||||
async enableVideo(): Promise<MediaStream> {
|
async enableVideo(): Promise<MediaStream> {
|
||||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||||
@@ -414,43 +485,53 @@ class WebRTCManager {
|
|||||||
|
|
||||||
const videoTrack = videoStream.getVideoTracks()[0];
|
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 transceivers = this.peerConnection.getTransceivers();
|
||||||
const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video');
|
const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video');
|
||||||
|
|
||||||
if (videoTransceiver) {
|
if (videoTransceiver) {
|
||||||
// Replace the track
|
// Replace the track on existing sender
|
||||||
await videoTransceiver.sender.replaceTrack(videoTrack);
|
await videoTransceiver.sender.replaceTrack(videoTrack);
|
||||||
// Update direction to sendrecv
|
// Force direction to sendrecv
|
||||||
videoTransceiver.direction = 'sendrecv';
|
videoTransceiver.direction = 'sendrecv';
|
||||||
console.log('[WebRTC] Video transceiver updated, direction:', videoTransceiver.direction);
|
console.log('[WebRTC] Video transceiver updated, direction:', videoTransceiver.direction);
|
||||||
} else {
|
} 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
|
// Update local stream
|
||||||
const newStream = new MediaStream();
|
const newStream = new MediaStream();
|
||||||
|
|
||||||
if (this.localStream) {
|
if (this.localStream) {
|
||||||
// Add existing audio tracks
|
|
||||||
this.localStream.getAudioTracks().forEach(track => {
|
this.localStream.getAudioTracks().forEach(track => {
|
||||||
newStream.addTrack(track);
|
newStream.addTrack(track);
|
||||||
});
|
});
|
||||||
// Stop old video tracks
|
|
||||||
this.localStream.getVideoTracks().forEach(track => {
|
this.localStream.getVideoTracks().forEach(track => {
|
||||||
track.stop();
|
track.stop();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new video track
|
|
||||||
newStream.addTrack(videoTrack);
|
newStream.addTrack(videoTrack);
|
||||||
this.localStream = newStream;
|
this.localStream = newStream;
|
||||||
|
|
||||||
console.log('[WebRTC] Video enabled successfully');
|
console.log('[WebRTC] Video enabled, creating renegotiation offer...');
|
||||||
|
|
||||||
// onnegotiationneeded will be triggered automatically
|
// 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;
|
return newStream;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.isNegotiating = false;
|
||||||
console.error('[WebRTC] Failed to enable video:', error);
|
console.error('[WebRTC] Failed to enable video:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -458,6 +539,7 @@ class WebRTCManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable video using transceiver direction
|
* Disable video using transceiver direction
|
||||||
|
* Actively triggers renegotiation instead of relying on onnegotiationneeded
|
||||||
*/
|
*/
|
||||||
async disableVideo(): Promise<MediaStream | null> {
|
async disableVideo(): Promise<MediaStream | null> {
|
||||||
if (!this.peerConnection) {
|
if (!this.peerConnection) {
|
||||||
@@ -494,11 +576,25 @@ class WebRTCManager {
|
|||||||
this.localStream.getAudioTracks().forEach(track => {
|
this.localStream.getAudioTracks().forEach(track => {
|
||||||
newStream.addTrack(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;
|
return newStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +655,6 @@ class WebRTCManager {
|
|||||||
|
|
||||||
if (this.remoteStream) {
|
if (this.remoteStream) {
|
||||||
this.remoteStream.getTracks().forEach((track) => track.stop());
|
this.remoteStream.getTracks().forEach((track) => track.stop());
|
||||||
this.remoteStream.release();
|
|
||||||
this.remoteStream = null;
|
this.remoteStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ async function handleIncomingOffer(msg: WSCallSDPMessage, myUserId: string): Pro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const signalingState = pc.signalingState;
|
let signalingState = pc.signalingState;
|
||||||
console.log('[CallStore] Received offer, signalingState:', signalingState);
|
console.log('[CallStore] Received offer, signalingState:', signalingState);
|
||||||
|
|
||||||
// Glare handling: if we're not in stable state, we have a conflict
|
// 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();
|
await webrtcManager.rollback();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CallStore] Rollback failed:', 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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user