diff --git a/src/components/call/CallScreen.tsx b/src/components/call/CallScreen.tsx index 62b24c2..ce1b727 100644 --- a/src/components/call/CallScreen.tsx +++ b/src/components/call/CallScreen.tsx @@ -59,14 +59,20 @@ const CallScreen: React.FC = () => { if (!currentCall || isMinimized) return null; const getStatusText = (): string => { switch (currentCall.status) { - case 'ringing': + case 'calling': return '正在等待对方接听...'; + case 'ringing': + return '来电响铃中...'; case 'connecting': return '连接中...'; case 'connected': return formatDuration(callDuration); - case 'ending': + case 'reconnecting': + return '网络重连中...'; + case 'ended': return '通话已结束'; + case 'failed': + return '连接失败'; default: return ''; } @@ -88,7 +94,10 @@ const CallScreen: React.FC = () => { }; // Determine if we should show video UI const showRemoteVideo = hasPeerVideo; - const showLocalVideo = hasLocalVideo; + // Use currentCall.isVideoEnabled directly for local video + // This is more reliable than checking localStream.getVideoTracks() + // because the stream object reference may not trigger useEffect properly + const showLocalVideo = currentCall?.isVideoEnabled && localStream; const isVideoCallActive = showRemoteVideo || showLocalVideo; return ( diff --git a/src/components/call/FloatingCallWindow.tsx b/src/components/call/FloatingCallWindow.tsx index cf7f795..99e2709 100644 --- a/src/components/call/FloatingCallWindow.tsx +++ b/src/components/call/FloatingCallWindow.tsx @@ -27,14 +27,20 @@ const FloatingCallWindow: React.FC = () => { const getStatusText = (): string => { switch (currentCall.status) { - case 'ringing': + case 'calling': return '等待接听...'; + case 'ringing': + return '来电响铃...'; case 'connecting': return '连接中...'; case 'connected': return formatDuration(callDuration); - case 'ending': + case 'reconnecting': + return '重连中...'; + case 'ended': return '已结束'; + case 'failed': + return '连接失败'; default: return ''; } diff --git a/src/services/webrtc/WebRTCManager.ts b/src/services/webrtc/WebRTCManager.ts index 49e3ce6..00f4bc9 100644 --- a/src/services/webrtc/WebRTCManager.ts +++ b/src/services/webrtc/WebRTCManager.ts @@ -4,8 +4,6 @@ import { RTCIceCandidate, mediaDevices, MediaStream, - MediaStreamTrack, - RTCRtpTransceiver, } from 'react-native-webrtc'; export interface ICEServer { @@ -36,8 +34,12 @@ class WebRTCManager { private eventHandlers: Set = new Set(); private disposed = false; private isInitiator = false; - private callType: CallType = 'voice'; - private isNegotiating = false; + + // ICE restart 相关状态 + private reconnectAttempts = 0; + private readonly MAX_RECONNECT_ATTEMPTS = 3; + private reconnectTimer: NodeJS.Timeout | null = null; + private disconnectTimer: NodeJS.Timeout | null = null; async initialize(iceServers: ICEServer[] = []): Promise { if (this.peerConnection) { @@ -64,7 +66,7 @@ class WebRTCManager { const config = this.buildPeerConnectionConfig(); const pc = new RTCPeerConnection(config); - // @ts-ignore - react-native-webrtc uses on* handlers instead of addEventListener + // @ts-ignore pc.onicecandidate = (event) => { if (event.candidate) { this.emit({ type: 'icecandidate', candidate: event.candidate }); @@ -77,28 +79,39 @@ class WebRTCManager { pc.oniceconnectionstatechange = () => { const state = pc.iceConnectionState as ConnectionState; this.emit({ type: 'connectionstatechange', state }); + this.handleIceConnectionStateChange(state); }; // @ts-ignore pc.onconnectionstatechange = () => { const state = pc.connectionState as ConnectionState; this.emit({ type: 'connectionstatechange', state }); + this.handleConnectionStateChange(state); }; // @ts-ignore pc.ontrack = (event) => { - 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(); + console.log('[WebRTC] ontrack event, kind:', event.track?.kind, 'streams:', event.streams?.length ?? 0); + if (!event.track) { + console.log('[WebRTC] ontrack: no track in event'); + return; } - // 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); + // Official react-native-webrtc approach: use event.streams[0] if available + if (event.streams && event.streams[0]) { + this.remoteStream = event.streams[0]; + } else { + // Fallback: manually construct stream + if (!this.remoteStream) { + this.remoteStream = new MediaStream(); + } + // Check if track already exists to avoid duplicates + const existingTracks = this.remoteStream.getTracks(); + const trackExists = existingTracks.some(t => t.id === event.track.id); + if (!trackExists) { + this.remoteStream.addTrack(event.track); + } } - this.emit({ type: 'remotestream', stream: this.remoteStream }); + this.emit({ type: 'remotestream', stream: this.remoteStream! }); }; // @ts-ignore @@ -108,84 +121,35 @@ class WebRTCManager { // @ts-ignore pc.onnegotiationneeded = async () => { - console.log('[WebRTC] Negotiation needed, signalingState:', pc.signalingState, 'isNegotiating:', this.isNegotiating); - - // Check if peer connection is still valid + console.log('[WebRTC] Negotiation needed, signalingState:', pc.signalingState); if (!this.peerConnection || this.peerConnection !== pc) { - console.log('[WebRTC] PeerConnection changed or disposed, skipping negotiation'); + console.log('[WebRTC] PeerConnection changed or disposed, skipping'); return; } - - // Only start negotiation if: - // 1. We're in stable state - // 2. Not already negotiating - // 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(); - // Check again after async operation - if (this.peerConnection && !this.disposed) { - this.emit({ type: 'negotiationneeded', offer }); - } - } catch (err) { - console.error('[WebRTC] Failed to create offer for renegotiation:', err); - } finally { - // Reset after a short delay to allow the offer to be processed - setTimeout(() => { - this.isNegotiating = false; - }, 500); + if (pc.signalingState !== 'stable') { + console.log('[WebRTC] Skipping negotiation, not in stable state:', pc.signalingState); + return; + } + try { + const offer = await this.createOffer(); + if (this.peerConnection && !this.disposed) { + this.emit({ type: 'negotiationneeded', offer }); } - } else { - console.log('[WebRTC] Skipping negotiation: state=', pc.signalingState, 'isNegotiating=', this.isNegotiating, 'isInitiator=', this.isInitiator); + } catch (err) { + console.error('[WebRTC] Negotiation needed failed:', err); } }; return pc; } - /** - * Setup transceivers with predefined m-line order - * This ensures m-line order is always: audio -> video - * Even for voice calls, we pre-allocate video transceiver as 'inactive' - */ - private setupTransceivers(callType: CallType): void { - if (!this.peerConnection) return; - - console.log('[WebRTC] Setting up transceivers for callType:', callType); - - // Always add audio transceiver first - this.peerConnection.addTransceiver('audio', { direction: 'sendrecv' }); - - // Add video transceiver - for voice calls it's inactive, for video calls it's sendrecv - const videoDirection = callType === 'video' ? 'sendrecv' : 'inactive'; - this.peerConnection.addTransceiver('video', { direction: videoDirection }); - - console.log('[WebRTC] Transceivers setup complete, video direction:', videoDirection); - } - - /** - * Update transceiver directions based on current state - */ - private updateTransceiverDirections(videoDirection: 'sendrecv' | 'recvonly' | 'inactive'): void { - if (!this.peerConnection) return; - - const transceivers = this.peerConnection.getTransceivers(); - const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video'); - - if (videoTransceiver) { - console.log('[WebRTC] Updating video transceiver direction to:', videoDirection); - videoTransceiver.direction = videoDirection; - } - } - async createLocalStream(voiceOnly = true): Promise { const constraints = voiceOnly ? { audio: true, video: false } : { audio: true, video: { facingMode: 'user', frameRate: 30 } }; try { - // @ts-ignore - react-native-webrtc has different constraint types + // @ts-ignore this.localStream = await mediaDevices.getUserMedia(constraints); return this.localStream; } catch (error) { @@ -195,138 +159,73 @@ class WebRTCManager { } /** - * Start a call with transceiver-based m-line allocation - * This replaces the old addTrack approach + * Start a call using official addTrack approach + * Follows react-native-webrtc CallGuide.md */ async startCall(isInitiator: boolean, callType: CallType = 'voice'): Promise { if (this.disposed) throw new Error('WebRTCManager has been disposed'); if (!this.localStream) throw new Error('Local stream not initialized'); this.isInitiator = isInitiator; - this.callType = callType; - this.isNegotiating = true; // Prevent onnegotiationneeded from firing during setup this.peerConnection = this.createPeerConnection(); - // Setup transceivers FIRST - this ensures consistent m-line order - this.setupTransceivers(callType); - - // Now add local tracks to transceivers - const transceivers = this.peerConnection.getTransceivers(); - - // Add audio track to audio transceiver - const audioTrack = this.localStream.getAudioTracks()[0]; - const audioTransceiver = transceivers.find(t => t.receiver.track?.kind === 'audio'); - if (audioTransceiver && audioTrack) { - await audioTransceiver.sender.replaceTrack(audioTrack); - } - - // Add video track if this is a video call - if (callType === 'video') { - const videoTrack = this.localStream.getVideoTracks()[0]; - const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video'); - if (videoTransceiver && videoTrack) { - await videoTransceiver.sender.replaceTrack(videoTrack); - } - } + // Official approach: add all tracks using addTrack + // addTrack automatically creates senders with proper directions + this.localStream.getTracks().forEach((track) => { + console.log('[WebRTC] Adding track:', track.kind, track.enabled); + this.peerConnection!.addTrack(track, this.localStream!); + }); if (isInitiator) { - // For initiator, create offer directly here + // For initiator, create offer directly const offer = await this.createOffer(); - // Release negotiation lock after a delay to allow state to settle - setTimeout(() => { - this.isNegotiating = false; - }, 500); return offer; } - // For non-initiator, release lock immediately since no offer is created - this.isNegotiating = false; return null; } 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 = { offerToReceiveAudio: true, - offerToReceiveVideo: true, // Always offer to receive video (transceiver will handle direction) + offerToReceiveVideo: true, }; - 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'); - } + if (!this.peerConnection) throw new Error('PeerConnection not initialized'); console.log('[WebRTC] Creating answer...'); const answerOptions = { offerToReceiveAudio: true, - offerToReceiveVideo: true, // Always offer to receive video + offerToReceiveVideo: true, }; - console.log('[WebRTC] Answer options:', answerOptions); - - // @ts-ignore - react-native-webrtc types + // @ts-ignore 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 + // Process pending candidates await this.processPendingCandidates(); + return answer; } @@ -335,6 +234,52 @@ class WebRTCManager { return this.createAnswer(); } + /** + * Rollback to stable state (for glare handling) + * Used when both peers try to negotiate simultaneously + */ + async rollback(): Promise { + if (!this.peerConnection) { + throw new Error('PeerConnection not initialized'); + } + + const pc = this.peerConnection; + const signalingState = pc.signalingState; + + console.log('[WebRTC] Attempting rollback, current state:', signalingState); + + // Only rollback if we're not in stable state + if (signalingState === 'stable') { + console.log('[WebRTC] Already in stable state, no rollback needed'); + return; + } + + try { + // For react-native-webrtc, we may need to recreate the peer connection + // as rollback is not fully supported + if (signalingState === 'have-local-offer') { + // Rollback local offer by setting local description to null/undefined + // @ts-ignore - react-native-webrtc specific + if (pc.setLocalDescription) { + // @ts-ignore + await pc.setLocalDescription({ type: 'rollback' }); + } + } else if (signalingState === 'have-remote-offer') { + // Rollback remote offer + // @ts-ignore + if (pc.setRemoteDescription) { + // @ts-ignore + await pc.setRemoteDescription({ type: 'rollback' }); + } + } + + console.log('[WebRTC] Rollback successful'); + } catch (err) { + console.error('[WebRTC] Rollback failed:', err); + throw err; + } + } + async setRemoteDescription(description: RTCSessionDescriptionInit): Promise { if (!this.peerConnection) { console.error('[WebRTC] setRemoteDescription: PeerConnection is null'); @@ -347,12 +292,6 @@ class WebRTCManager { 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, @@ -363,50 +302,6 @@ class WebRTCManager { // 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) - */ - async rollback(): Promise { - if (!this.peerConnection) return; - - console.log('[WebRTC] Rolling back to stable state...'); - // @ts-ignore - react-native-webrtc supports rollback - await this.peerConnection.setLocalDescription({ type: 'rollback', sdp: '' }); } async addIceCandidate(candidate: RTCIceCandidateInit): Promise { @@ -425,7 +320,6 @@ class WebRTCManager { await this.peerConnection.addIceCandidate(iceCandidate); } catch (error) { console.error('[WebRTC] Failed to add ICE candidate:', error); - // Still push to pending in case order matters this.pendingCandidates.push(candidate); } } @@ -438,12 +332,8 @@ class WebRTCManager { this.pendingCandidates = []; for (const candidate of candidates) { - // Check connection state before each candidate - if (!this.peerConnection) { - console.log('[WebRTC] PeerConnection lost during processing pending candidates'); - return; - } - + if (!this.peerConnection) return; + try { const iceCandidate = new RTCIceCandidate(candidate); await this.peerConnection.addIceCandidate(iceCandidate); @@ -468,8 +358,7 @@ class WebRTCManager { } /** - * Enable video using addTrack for maximum compatibility - * Actively triggers renegotiation + * Enable video - official replaceTrack approach */ async enableVideo(): Promise { if (!this.peerConnection) throw new Error('PeerConnection not initialized'); @@ -485,30 +374,28 @@ class WebRTCManager { const videoTrack = videoStream.getVideoTracks()[0]; - // Find the video transceiver and replace track on it - const transceivers = this.peerConnection.getTransceivers(); - const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video'); + // Find the sender for video and replace the track + const senders = this.peerConnection.getSenders(); + const videoSender = senders.find(s => s.track?.kind === 'video'); - if (videoTransceiver) { - // Replace the track on existing sender - await videoTransceiver.sender.replaceTrack(videoTrack); - // Force direction to sendrecv - videoTransceiver.direction = 'sendrecv'; - console.log('[WebRTC] Video transceiver updated, direction:', videoTransceiver.direction); + if (videoSender) { + await videoSender.replaceTrack(videoTrack); + console.log('[WebRTC] Video track replaced on existing sender'); } else { - // No transceiver exists, add track directly (will create one) - this.peerConnection.addTrack(videoTrack); - console.log('[WebRTC] Video track added via addTrack (no existing transceiver)'); + // No existing video sender, add track + this.peerConnection.addTrack(videoTrack, this.localStream!); + console.log('[WebRTC] Video track added via addTrack'); } // Update local stream const newStream = new MediaStream(); if (this.localStream) { - this.localStream.getAudioTracks().forEach(track => { + this.localStream.getAudioTracks().forEach((track) => { newStream.addTrack(track); }); - this.localStream.getVideoTracks().forEach(track => { + // Stop old video tracks + this.localStream.getVideoTracks().forEach((track) => { track.stop(); }); } @@ -516,30 +403,17 @@ class WebRTCManager { newStream.addTrack(videoTrack); this.localStream = newStream; - 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); + console.log('[WebRTC] Video enabled successfully'); return newStream; } catch (error) { - this.isNegotiating = false; console.error('[WebRTC] Failed to enable video:', error); throw error; } } /** - * Disable video using transceiver direction - * Actively triggers renegotiation instead of relying on onnegotiationneeded + * Disable video - official replaceTrack approach */ async disableVideo(): Promise { if (!this.peerConnection) { @@ -553,47 +427,29 @@ class WebRTCManager { console.log('[WebRTC] Disabling video...'); + // Find the sender for video and replace with null + const senders = this.peerConnection.getSenders(); + const videoSender = senders.find(s => s.track?.kind === 'video'); + + if (videoSender) { + await videoSender.replaceTrack(null); + console.log('[WebRTC] Video track removed from sender'); + } + // Stop video tracks const videoTracks = this.localStream.getVideoTracks(); videoTracks.forEach((track) => { track.stop(); }); - // Find video transceiver and update direction - const transceivers = this.peerConnection.getTransceivers(); - const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video'); - - if (videoTransceiver) { - // Remove the track - await videoTransceiver.sender.replaceTrack(null); - // Set direction to inactive - videoTransceiver.direction = 'inactive'; - console.log('[WebRTC] Video transceiver direction set to inactive'); - } - // Create new stream with only audio const newStream = new MediaStream(); - this.localStream.getAudioTracks().forEach(track => { + this.localStream.getAudioTracks().forEach((track) => { newStream.addTrack(track); }); 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); - } + console.log('[WebRTC] Video disabled successfully'); return newStream; } @@ -641,15 +497,160 @@ class WebRTCManager { }); } + // ========== ICE Restart 支持 ========== + + /** + * 处理 ICE 连接状态变化 + * 根据 W3C 规范: disconnected 状态可能间歇性触发并自发解决 + * failed 状态表示需要 ICE restart + */ + private handleIceConnectionStateChange(state: ConnectionState): void { + console.log('[WebRTC] ICE connection state:', state); + + switch (state) { + case 'connected': // 连接成功,重置重连状态 + this.resetReconnectState(); + break; + + case 'disconnected': + // 临时断开,等待一段时间看是否自动恢复 + this.scheduleDisconnectCheck(); + break; + + case 'failed': + // ICE 失败,尝试 ICE restart + this.attemptIceRestart(); + break; + + case 'closed': + this.clearReconnectTimers(); + break; + } + } + + /** + * 处理 PeerConnection 连接状态变化 + */ + private handleConnectionStateChange(state: ConnectionState): void { + console.log('[WebRTC] PeerConnection state:', state); + + switch (state) { + case 'connected': + this.resetReconnectState(); + break; + + case 'disconnected': + // 等待短暂时间看是否自动恢复 + this.scheduleDisconnectCheck(); + break; + + case 'failed': + // 连接完全失败 + this.emit({ type: 'error', error: new Error('Connection failed') }); + break; + } + } + + /** + * 安排断开检查 + * 给 disconnected 状态一个恢复窗口(5秒) + */ + private scheduleDisconnectCheck(): void { + if (this.disconnectTimer) { + clearTimeout(this.disconnectTimer); + } + + this.disconnectTimer = setTimeout(() => { + const pc = this.peerConnection; + if (!pc || this.disposed) return; + + // 如果 5 秒后仍然是 disconnected,尝试 ICE restart + if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') { + console.log('[WebRTC] Connection still disconnected after 5s, attempting ICE restart'); + this.attemptIceRestart(); + } + }, 5000); + } + + /** + * 尝试 ICE restart + * 参考: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Session_lifetime#ice_restart + */ + private async attemptIceRestart(): Promise { + if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) { + console.error('[WebRTC] Max reconnection attempts reached'); + this.emit({ type: 'error', error: new Error('Max reconnection attempts reached') }); + return; + } + + const pc = this.peerConnection; + if (!pc || this.disposed) { + console.log('[WebRTC] Cannot restart ICE: PeerConnection not available'); + return; + } + + // 检查信令状态 + if (pc.signalingState !== 'stable') { + console.log('[WebRTC] Cannot restart ICE: signaling state not stable:', pc.signalingState); + return; + } + + this.reconnectAttempts++; + console.log(`[WebRTC] Attempting ICE restart (${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS})`); + + try { + // 尝试使用 restartIce() API (现代浏览器支持) + // @ts-ignore + if (pc.restartIce) { + // @ts-ignore + pc.restartIce(); + console.log('[WebRTC] restartIce() called'); + } + + // 创建新的 offer,触发 ICE restart + const offer = await pc.createOffer({ iceRestart: true }); + await pc.setLocalDescription(offer); + + console.log('[WebRTC] ICE restart offer created'); + + // 发送新的 offer 给对方 + this.emit({ type: 'negotiationneeded', offer }); + } catch (error) { + console.error('[WebRTC] ICE restart failed:', error); + this.emit({ type: 'error', error: error as Error }); + } + } + + /** + * 重置重连状态 + */ + private resetReconnectState(): void { + this.reconnectAttempts = 0; + this.clearReconnectTimers(); + } + + /** + * 清除重连定时器 + */ + private clearReconnectTimers(): void { + if (this.disconnectTimer) { + clearTimeout(this.disconnectTimer); + this.disconnectTimer = null; + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + dispose(): void { this.disposed = true; this.eventHandlers.clear(); this.pendingCandidates = []; - this.isNegotiating = false; + this.clearReconnectTimers(); if (this.localStream) { this.localStream.getTracks().forEach((track) => track.stop()); - this.localStream.release(); this.localStream = null; } diff --git a/src/services/wsService.ts b/src/services/wsService.ts index e19bcbf..13d9d4f 100644 --- a/src/services/wsService.ts +++ b/src/services/wsService.ts @@ -617,6 +617,10 @@ class WebSocketService { // Call signaling handlers private handleCallIncoming(payload: any): void { + console.log('[WSService] call_incoming payload:', JSON.stringify({ + call_type: payload.call_type, + media_type: payload.media_type, + })); const m: WSCallIncomingMessage = { type: 'call_incoming', call_id: payload.call_id, @@ -727,6 +731,21 @@ class WebSocketService { }); } + /** + * 查询当前活跃的通话 + * 用于 WebSocket 重连后恢复通话状态 + */ + async getActiveCall(): Promise { + try { + const { api } = await import('./api'); + const response = await api.get('/calls/active'); + return response.data || null; + } catch (error) { + console.log('[WSService] No active call found'); + return null; + } + } + sendCallAnswer(callId: string): void { this.sendFireAndForget('call_answer', { call_id: callId }); } diff --git a/src/stores/callStore.ts b/src/stores/callStore.ts index 9e45fd6..ad59b95 100644 --- a/src/stores/callStore.ts +++ b/src/stores/callStore.ts @@ -12,7 +12,15 @@ import { useAuthStore } from './authStore'; import { useUserStore } from './userStore'; import { userManager } from './userManager'; -export type CallStatus = 'idle' | 'ringing' | 'connecting' | 'connected' | 'renegotiating' | 'ending'; +export type CallStatus = + | 'idle' // 空闲状态 + | 'calling' // 正在呼出(已发送邀请,等待对方响应) + | 'ringing' // 来电响铃中 + | 'connecting' // 正在建立连接(WebRTC 协商中) + | 'connected' // 已接通 + | 'reconnecting' // 网络断开,正在重连 + | 'ended' // 已结束 + | 'failed'; // 连接失败 export type CallType = 'voice' | 'video'; @@ -160,35 +168,60 @@ function handleNegotiationNeeded(callId: string, offer: RTCSessionDescriptionIni wsService.sendCallSDP(callId, 'offer', offer.sdp || ''); } -/** - * Handle connection state change - */ -function handleConnectionStateChange(state: string): void { - console.log('[CallStore] Connection state changed:', state); + /** + * Handle connection state change with enhanced state machine + */ + function handleConnectionStateChange(state: string): void { + console.log('[CallStore] Connection state changed:', state); - if (state === 'connected') { - const now = Date.now(); - callStore.setState((s) => ({ - currentCall: s.currentCall - ? { ...s.currentCall, status: 'connected', startedAt: now } - : null, - })); - - if (durationTimer) clearInterval(durationTimer); - durationTimer = setInterval(() => { - callStore.setState((s) => ({ callDuration: s.callDuration + 1 })); - }, 1000); - } - - // Only end call if connection failed after being connected - // Don't end call during initial connection setup - if (state === 'failed') { const { currentCall } = callStore.getState(); - if (currentCall && currentCall.status === 'connected') { - callStore.getState().endCall('connection_lost'); + if (!currentCall) return; + + switch (state) { + case 'connected': + // 连接成功,开始计时 + const now = Date.now(); + callStore.setState((s) => ({ + currentCall: s.currentCall + ? { ...s.currentCall, status: 'connected', startedAt: now } + : null, + })); + + if (durationTimer) clearInterval(durationTimer); + durationTimer = setInterval(() => { + callStore.setState((s) => ({ callDuration: s.callDuration + 1 })); + }, 1000); + break; + + case 'disconnected': + // 临时断开,进入重连状态 + if (currentCall.status === 'connected') { + callStore.setState((s) => ({ + currentCall: s.currentCall + ? { ...s.currentCall, status: 'reconnecting' } + : null, + })); + } + break; + + case 'failed': + // 连接失败 + if (currentCall.status === 'connected' || currentCall.status === 'reconnecting') { + callStore.getState().endCall('connection_failed'); + } else if (currentCall.status === 'connecting') { + // 初始连接失败 + callStore.getState().endCall('connection_failed'); + } + break; + + case 'closed': + // 连接关闭 + if (currentCall.status !== 'ended' && currentCall.status !== 'failed') { + callStore.getState().endCall('connection_closed'); + } + break; } } -} /** * Handle incoming SDP offer with Glare handling @@ -600,7 +633,7 @@ export const callStore = create((set, get) => ({ peerName: calleeName, peerAvatar: callee?.avatar, callType, - status: 'ringing', + status: 'calling', // 改为 'calling' 表示正在呼出 duration: 0, isMuted: false, isSpeakerOn: false, @@ -626,7 +659,8 @@ export const callStore = create((set, get) => ({ if (callTimeoutTimer) clearTimeout(callTimeoutTimer); callTimeoutTimer = setTimeout(() => { const { currentCall: cc } = get(); - if (cc && cc.status === 'ringing') { + // 只有在 'calling' 状态(呼出中)才超时 + if (cc && cc.status === 'calling') { console.warn('[CallStore] Call timeout'); get().endCall('timeout'); } @@ -637,12 +671,15 @@ export const callStore = create((set, get) => ({ const { incomingCall } = get(); if (!incomingCall) return; + console.log('[CallStore] acceptCall, incomingCall.callType:', incomingCall.callType); + if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); callTimeoutTimer = null; } const isVideoCall = incomingCall.callType === 'video'; + console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall); const myUserId = useAuthStore.getState().currentUser?.id || ''; set({