feat(CallScreen, FloatingCallWindow, callStore, wsService): enhance call status handling and UI updates
- Updated call status messages to include 'calling', 'reconnecting', and 'failed' states for better user feedback. - Improved video handling logic in CallScreen to use currentCall.isVideoEnabled for local video display. - Enhanced callStore to manage new call statuses and ensure proper timeout handling during 'calling' state. - Added a method in wsService to retrieve the active call state after WebSocket reconnections. - Refactored FloatingCallWindow to reflect updated call status messages for consistency across components.
This commit is contained in:
@@ -59,14 +59,20 @@ const CallScreen: React.FC = () => {
|
|||||||
if (!currentCall || isMinimized) return null;
|
if (!currentCall || isMinimized) return null;
|
||||||
const getStatusText = (): string => {
|
const getStatusText = (): string => {
|
||||||
switch (currentCall.status) {
|
switch (currentCall.status) {
|
||||||
case 'ringing':
|
case 'calling':
|
||||||
return '正在等待对方接听...';
|
return '正在等待对方接听...';
|
||||||
|
case 'ringing':
|
||||||
|
return '来电响铃中...';
|
||||||
case 'connecting':
|
case 'connecting':
|
||||||
return '连接中...';
|
return '连接中...';
|
||||||
case 'connected':
|
case 'connected':
|
||||||
return formatDuration(callDuration);
|
return formatDuration(callDuration);
|
||||||
case 'ending':
|
case 'reconnecting':
|
||||||
|
return '网络重连中...';
|
||||||
|
case 'ended':
|
||||||
return '通话已结束';
|
return '通话已结束';
|
||||||
|
case 'failed':
|
||||||
|
return '连接失败';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -88,7 +94,10 @@ const CallScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
// Determine if we should show video UI
|
// Determine if we should show video UI
|
||||||
const showRemoteVideo = hasPeerVideo;
|
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;
|
const isVideoCallActive = showRemoteVideo || showLocalVideo;
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
|||||||
@@ -27,14 +27,20 @@ const FloatingCallWindow: React.FC = () => {
|
|||||||
|
|
||||||
const getStatusText = (): string => {
|
const getStatusText = (): string => {
|
||||||
switch (currentCall.status) {
|
switch (currentCall.status) {
|
||||||
case 'ringing':
|
case 'calling':
|
||||||
return '等待接听...';
|
return '等待接听...';
|
||||||
|
case 'ringing':
|
||||||
|
return '来电响铃...';
|
||||||
case 'connecting':
|
case 'connecting':
|
||||||
return '连接中...';
|
return '连接中...';
|
||||||
case 'connected':
|
case 'connected':
|
||||||
return formatDuration(callDuration);
|
return formatDuration(callDuration);
|
||||||
case 'ending':
|
case 'reconnecting':
|
||||||
|
return '重连中...';
|
||||||
|
case 'ended':
|
||||||
return '已结束';
|
return '已结束';
|
||||||
|
case 'failed':
|
||||||
|
return '连接失败';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
RTCIceCandidate,
|
RTCIceCandidate,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
MediaStream,
|
MediaStream,
|
||||||
MediaStreamTrack,
|
|
||||||
RTCRtpTransceiver,
|
|
||||||
} from 'react-native-webrtc';
|
} from 'react-native-webrtc';
|
||||||
|
|
||||||
export interface ICEServer {
|
export interface ICEServer {
|
||||||
@@ -36,8 +34,12 @@ class WebRTCManager {
|
|||||||
private eventHandlers: Set<EventHandler> = new Set();
|
private eventHandlers: Set<EventHandler> = new Set();
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
private isInitiator = 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<void> {
|
async initialize(iceServers: ICEServer[] = []): Promise<void> {
|
||||||
if (this.peerConnection) {
|
if (this.peerConnection) {
|
||||||
@@ -64,7 +66,7 @@ class WebRTCManager {
|
|||||||
const config = this.buildPeerConnectionConfig();
|
const config = this.buildPeerConnectionConfig();
|
||||||
const pc = new RTCPeerConnection(config);
|
const pc = new RTCPeerConnection(config);
|
||||||
|
|
||||||
// @ts-ignore - react-native-webrtc uses on* handlers instead of addEventListener
|
// @ts-ignore
|
||||||
pc.onicecandidate = (event) => {
|
pc.onicecandidate = (event) => {
|
||||||
if (event.candidate) {
|
if (event.candidate) {
|
||||||
this.emit({ type: 'icecandidate', candidate: event.candidate });
|
this.emit({ type: 'icecandidate', candidate: event.candidate });
|
||||||
@@ -77,28 +79,39 @@ class WebRTCManager {
|
|||||||
pc.oniceconnectionstatechange = () => {
|
pc.oniceconnectionstatechange = () => {
|
||||||
const state = pc.iceConnectionState as ConnectionState;
|
const state = pc.iceConnectionState as ConnectionState;
|
||||||
this.emit({ type: 'connectionstatechange', state });
|
this.emit({ type: 'connectionstatechange', state });
|
||||||
|
this.handleIceConnectionStateChange(state);
|
||||||
};
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
const state = pc.connectionState as ConnectionState;
|
const state = pc.connectionState as ConnectionState;
|
||||||
this.emit({ type: 'connectionstatechange', state });
|
this.emit({ type: 'connectionstatechange', state });
|
||||||
|
this.handleConnectionStateChange(state);
|
||||||
};
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
pc.ontrack = (event) => {
|
pc.ontrack = (event) => {
|
||||||
console.log('[WebRTC] ontrack event, kind:', event.track.kind, 'streams:', event.streams?.length ?? 0);
|
console.log('[WebRTC] ontrack event, kind:', event.track?.kind, 'streams:', event.streams?.length ?? 0);
|
||||||
// react-native-webrtc often doesn't populate event.streams
|
if (!event.track) {
|
||||||
// Manually construct the remote stream from the track
|
console.log('[WebRTC] ontrack: no track in event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 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) {
|
if (!this.remoteStream) {
|
||||||
this.remoteStream = new MediaStream();
|
this.remoteStream = new MediaStream();
|
||||||
}
|
}
|
||||||
// Avoid adding duplicate tracks (can happen during renegotiation)
|
// Check if track already exists to avoid duplicates
|
||||||
const existingTrack = this.remoteStream.getTracks().find(t => t.id === event.track.id);
|
const existingTracks = this.remoteStream.getTracks();
|
||||||
if (!existingTrack) {
|
const trackExists = existingTracks.some(t => t.id === event.track.id);
|
||||||
|
if (!trackExists) {
|
||||||
this.remoteStream.addTrack(event.track);
|
this.remoteStream.addTrack(event.track);
|
||||||
}
|
}
|
||||||
this.emit({ type: 'remotestream', stream: this.remoteStream });
|
}
|
||||||
|
this.emit({ type: 'remotestream', stream: this.remoteStream! });
|
||||||
};
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -108,84 +121,35 @@ class WebRTCManager {
|
|||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
pc.onnegotiationneeded = async () => {
|
pc.onnegotiationneeded = async () => {
|
||||||
console.log('[WebRTC] Negotiation needed, signalingState:', pc.signalingState, 'isNegotiating:', this.isNegotiating);
|
console.log('[WebRTC] Negotiation needed, signalingState:', pc.signalingState);
|
||||||
|
|
||||||
// Check if peer connection is still valid
|
|
||||||
if (!this.peerConnection || this.peerConnection !== pc) {
|
if (!this.peerConnection || this.peerConnection !== pc) {
|
||||||
console.log('[WebRTC] PeerConnection changed or disposed, skipping negotiation');
|
console.log('[WebRTC] PeerConnection changed or disposed, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pc.signalingState !== 'stable') {
|
||||||
|
console.log('[WebRTC] Skipping negotiation, not in stable state:', pc.signalingState);
|
||||||
return;
|
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 {
|
try {
|
||||||
this.isNegotiating = true;
|
|
||||||
const offer = await this.createOffer();
|
const offer = await this.createOffer();
|
||||||
// Check again after async operation
|
|
||||||
if (this.peerConnection && !this.disposed) {
|
if (this.peerConnection && !this.disposed) {
|
||||||
this.emit({ type: 'negotiationneeded', offer });
|
this.emit({ type: 'negotiationneeded', offer });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[WebRTC] Failed to create offer for renegotiation:', err);
|
console.error('[WebRTC] Negotiation needed failed:', err);
|
||||||
} finally {
|
|
||||||
// Reset after a short delay to allow the offer to be processed
|
|
||||||
setTimeout(() => {
|
|
||||||
this.isNegotiating = false;
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('[WebRTC] Skipping negotiation: state=', pc.signalingState, 'isNegotiating=', this.isNegotiating, 'isInitiator=', this.isInitiator);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return pc;
|
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<MediaStream> {
|
async createLocalStream(voiceOnly = true): Promise<MediaStream> {
|
||||||
const constraints = voiceOnly
|
const constraints = voiceOnly
|
||||||
? { audio: true, video: false }
|
? { audio: true, video: false }
|
||||||
: { audio: true, video: { facingMode: 'user', frameRate: 30 } };
|
: { audio: true, video: { facingMode: 'user', frameRate: 30 } };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// @ts-ignore - react-native-webrtc has different constraint types
|
// @ts-ignore
|
||||||
this.localStream = await mediaDevices.getUserMedia(constraints);
|
this.localStream = await mediaDevices.getUserMedia(constraints);
|
||||||
return this.localStream;
|
return this.localStream;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -195,138 +159,73 @@ class WebRTCManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a call with transceiver-based m-line allocation
|
* Start a call using official addTrack approach
|
||||||
* This replaces the old addTrack approach
|
* Follows react-native-webrtc CallGuide.md
|
||||||
*/
|
*/
|
||||||
async startCall(isInitiator: boolean, callType: CallType = 'voice'): Promise<RTCSessionDescriptionInit | null> {
|
async startCall(isInitiator: boolean, callType: CallType = 'voice'): Promise<RTCSessionDescriptionInit | null> {
|
||||||
if (this.disposed) throw new Error('WebRTCManager has been disposed');
|
if (this.disposed) throw new Error('WebRTCManager has been disposed');
|
||||||
if (!this.localStream) throw new Error('Local stream not initialized');
|
if (!this.localStream) throw new Error('Local stream not initialized');
|
||||||
|
|
||||||
this.isInitiator = isInitiator;
|
this.isInitiator = isInitiator;
|
||||||
this.callType = callType;
|
|
||||||
this.isNegotiating = true; // Prevent onnegotiationneeded from firing during setup
|
|
||||||
this.peerConnection = this.createPeerConnection();
|
this.peerConnection = this.createPeerConnection();
|
||||||
|
|
||||||
// Setup transceivers FIRST - this ensures consistent m-line order
|
// Official approach: add all tracks using addTrack
|
||||||
this.setupTransceivers(callType);
|
// addTrack automatically creates senders with proper directions
|
||||||
|
this.localStream.getTracks().forEach((track) => {
|
||||||
// Now add local tracks to transceivers
|
console.log('[WebRTC] Adding track:', track.kind, track.enabled);
|
||||||
const transceivers = this.peerConnection.getTransceivers();
|
this.peerConnection!.addTrack(track, this.localStream!);
|
||||||
|
});
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInitiator) {
|
if (isInitiator) {
|
||||||
// For initiator, create offer directly here
|
// For initiator, create offer directly
|
||||||
const offer = await this.createOffer();
|
const offer = await this.createOffer();
|
||||||
// Release negotiation lock after a delay to allow state to settle
|
|
||||||
setTimeout(() => {
|
|
||||||
this.isNegotiating = false;
|
|
||||||
}, 500);
|
|
||||||
return offer;
|
return offer;
|
||||||
}
|
}
|
||||||
// For non-initiator, release lock immediately since no offer is created
|
|
||||||
this.isNegotiating = false;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
const hasLocalVideo = (this.localStream?.getVideoTracks().length ?? 0) > 0;
|
|
||||||
const offerOptions = {
|
const offerOptions = {
|
||||||
offerToReceiveAudio: true,
|
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);
|
const offer = await this.peerConnection.createOffer(offerOptions);
|
||||||
|
|
||||||
// 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);
|
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||||
|
|
||||||
if (!this.peerConnection) {
|
|
||||||
console.error('[WebRTC] createAnswer: PeerConnection is null');
|
|
||||||
throw new Error('PeerConnection not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[WebRTC] Creating answer...');
|
console.log('[WebRTC] Creating answer...');
|
||||||
|
|
||||||
const answerOptions = {
|
const answerOptions = {
|
||||||
offerToReceiveAudio: true,
|
offerToReceiveAudio: true,
|
||||||
offerToReceiveVideo: true, // Always offer to receive video
|
offerToReceiveVideo: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[WebRTC] Answer options:', answerOptions);
|
// @ts-ignore
|
||||||
|
|
||||||
// @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...');
|
|
||||||
|
|
||||||
// Check again after async operation
|
|
||||||
if (!this.peerConnection || this.disposed) {
|
if (!this.peerConnection || this.disposed) {
|
||||||
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');
|
|
||||||
|
|
||||||
// Process pending candidates after local description is set
|
// Process pending candidates
|
||||||
await this.processPendingCandidates();
|
await this.processPendingCandidates();
|
||||||
|
|
||||||
return answer;
|
return answer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +234,52 @@ class WebRTCManager {
|
|||||||
return this.createAnswer();
|
return this.createAnswer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback to stable state (for glare handling)
|
||||||
|
* Used when both peers try to negotiate simultaneously
|
||||||
|
*/
|
||||||
|
async rollback(): Promise<void> {
|
||||||
|
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<void> {
|
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||||
if (!this.peerConnection) {
|
if (!this.peerConnection) {
|
||||||
console.error('[WebRTC] setRemoteDescription: PeerConnection is null');
|
console.error('[WebRTC] setRemoteDescription: PeerConnection is null');
|
||||||
@@ -347,12 +292,6 @@ class WebRTCManager {
|
|||||||
|
|
||||||
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,
|
||||||
@@ -363,50 +302,6 @@ class WebRTCManager {
|
|||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<void> {
|
|
||||||
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<void> {
|
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||||
@@ -425,7 +320,6 @@ class WebRTCManager {
|
|||||||
await this.peerConnection.addIceCandidate(iceCandidate);
|
await this.peerConnection.addIceCandidate(iceCandidate);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WebRTC] Failed to add ICE candidate:', error);
|
console.error('[WebRTC] Failed to add ICE candidate:', error);
|
||||||
// Still push to pending in case order matters
|
|
||||||
this.pendingCandidates.push(candidate);
|
this.pendingCandidates.push(candidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -438,11 +332,7 @@ class WebRTCManager {
|
|||||||
this.pendingCandidates = [];
|
this.pendingCandidates = [];
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
// Check connection state before each candidate
|
if (!this.peerConnection) return;
|
||||||
if (!this.peerConnection) {
|
|
||||||
console.log('[WebRTC] PeerConnection lost during processing pending candidates');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const iceCandidate = new RTCIceCandidate(candidate);
|
const iceCandidate = new RTCIceCandidate(candidate);
|
||||||
@@ -468,8 +358,7 @@ class WebRTCManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable video using addTrack for maximum compatibility
|
* Enable video - official replaceTrack approach
|
||||||
* 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');
|
||||||
@@ -485,30 +374,28 @@ class WebRTCManager {
|
|||||||
|
|
||||||
const videoTrack = videoStream.getVideoTracks()[0];
|
const videoTrack = videoStream.getVideoTracks()[0];
|
||||||
|
|
||||||
// Find the video transceiver and replace track on it
|
// Find the sender for video and replace the track
|
||||||
const transceivers = this.peerConnection.getTransceivers();
|
const senders = this.peerConnection.getSenders();
|
||||||
const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video');
|
const videoSender = senders.find(s => s.track?.kind === 'video');
|
||||||
|
|
||||||
if (videoTransceiver) {
|
if (videoSender) {
|
||||||
// Replace the track on existing sender
|
await videoSender.replaceTrack(videoTrack);
|
||||||
await videoTransceiver.sender.replaceTrack(videoTrack);
|
console.log('[WebRTC] Video track replaced on existing sender');
|
||||||
// Force direction to sendrecv
|
|
||||||
videoTransceiver.direction = 'sendrecv';
|
|
||||||
console.log('[WebRTC] Video transceiver updated, direction:', videoTransceiver.direction);
|
|
||||||
} else {
|
} else {
|
||||||
// No transceiver exists, add track directly (will create one)
|
// No existing video sender, add track
|
||||||
this.peerConnection.addTrack(videoTrack);
|
this.peerConnection.addTrack(videoTrack, this.localStream!);
|
||||||
console.log('[WebRTC] Video track added via addTrack (no existing transceiver)');
|
console.log('[WebRTC] Video track added via addTrack');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local stream
|
// Update local stream
|
||||||
const newStream = new MediaStream();
|
const newStream = new MediaStream();
|
||||||
|
|
||||||
if (this.localStream) {
|
if (this.localStream) {
|
||||||
this.localStream.getAudioTracks().forEach(track => {
|
this.localStream.getAudioTracks().forEach((track) => {
|
||||||
newStream.addTrack(track);
|
newStream.addTrack(track);
|
||||||
});
|
});
|
||||||
this.localStream.getVideoTracks().forEach(track => {
|
// Stop old video tracks
|
||||||
|
this.localStream.getVideoTracks().forEach((track) => {
|
||||||
track.stop();
|
track.stop();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -516,30 +403,17 @@ class WebRTCManager {
|
|||||||
newStream.addTrack(videoTrack);
|
newStream.addTrack(videoTrack);
|
||||||
this.localStream = newStream;
|
this.localStream = newStream;
|
||||||
|
|
||||||
console.log('[WebRTC] Video enabled, creating renegotiation offer...');
|
console.log('[WebRTC] Video enabled successfully');
|
||||||
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable video using transceiver direction
|
* Disable video - official replaceTrack approach
|
||||||
* Actively triggers renegotiation instead of relying on onnegotiationneeded
|
|
||||||
*/
|
*/
|
||||||
async disableVideo(): Promise<MediaStream | null> {
|
async disableVideo(): Promise<MediaStream | null> {
|
||||||
if (!this.peerConnection) {
|
if (!this.peerConnection) {
|
||||||
@@ -553,47 +427,29 @@ class WebRTCManager {
|
|||||||
|
|
||||||
console.log('[WebRTC] Disabling video...');
|
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
|
// Stop video tracks
|
||||||
const videoTracks = this.localStream.getVideoTracks();
|
const videoTracks = this.localStream.getVideoTracks();
|
||||||
videoTracks.forEach((track) => {
|
videoTracks.forEach((track) => {
|
||||||
track.stop();
|
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
|
// Create new stream with only audio
|
||||||
const newStream = new MediaStream();
|
const newStream = new MediaStream();
|
||||||
this.localStream.getAudioTracks().forEach(track => {
|
this.localStream.getAudioTracks().forEach((track) => {
|
||||||
newStream.addTrack(track);
|
newStream.addTrack(track);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.localStream = newStream;
|
this.localStream = newStream;
|
||||||
console.log('[WebRTC] Video disabled, creating renegotiation offer...');
|
console.log('[WebRTC] Video disabled successfully');
|
||||||
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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 {
|
dispose(): void {
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
this.eventHandlers.clear();
|
this.eventHandlers.clear();
|
||||||
this.pendingCandidates = [];
|
this.pendingCandidates = [];
|
||||||
this.isNegotiating = false;
|
this.clearReconnectTimers();
|
||||||
|
|
||||||
if (this.localStream) {
|
if (this.localStream) {
|
||||||
this.localStream.getTracks().forEach((track) => track.stop());
|
this.localStream.getTracks().forEach((track) => track.stop());
|
||||||
this.localStream.release();
|
|
||||||
this.localStream = null;
|
this.localStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -617,6 +617,10 @@ class WebSocketService {
|
|||||||
|
|
||||||
// Call signaling handlers
|
// Call signaling handlers
|
||||||
private handleCallIncoming(payload: any): void {
|
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 = {
|
const m: WSCallIncomingMessage = {
|
||||||
type: 'call_incoming',
|
type: 'call_incoming',
|
||||||
call_id: payload.call_id,
|
call_id: payload.call_id,
|
||||||
@@ -727,6 +731,21 @@ class WebSocketService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前活跃的通话
|
||||||
|
* 用于 WebSocket 重连后恢复通话状态
|
||||||
|
*/
|
||||||
|
async getActiveCall(): Promise<any | null> {
|
||||||
|
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 {
|
sendCallAnswer(callId: string): void {
|
||||||
this.sendFireAndForget('call_answer', { call_id: callId });
|
this.sendFireAndForget('call_answer', { call_id: callId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ import { useAuthStore } from './authStore';
|
|||||||
import { useUserStore } from './userStore';
|
import { useUserStore } from './userStore';
|
||||||
import { userManager } from './userManager';
|
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';
|
export type CallType = 'voice' | 'video';
|
||||||
|
|
||||||
@@ -161,12 +169,17 @@ function handleNegotiationNeeded(callId: string, offer: RTCSessionDescriptionIni
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle connection state change
|
* Handle connection state change with enhanced state machine
|
||||||
*/
|
*/
|
||||||
function handleConnectionStateChange(state: string): void {
|
function handleConnectionStateChange(state: string): void {
|
||||||
console.log('[CallStore] Connection state changed:', state);
|
console.log('[CallStore] Connection state changed:', state);
|
||||||
|
|
||||||
if (state === 'connected') {
|
const { currentCall } = callStore.getState();
|
||||||
|
if (!currentCall) return;
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case 'connected':
|
||||||
|
// 连接成功,开始计时
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
callStore.setState((s) => ({
|
callStore.setState((s) => ({
|
||||||
currentCall: s.currentCall
|
currentCall: s.currentCall
|
||||||
@@ -178,15 +191,35 @@ function handleConnectionStateChange(state: string): void {
|
|||||||
durationTimer = setInterval(() => {
|
durationTimer = setInterval(() => {
|
||||||
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
|
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
break;
|
||||||
|
|
||||||
// Only end call if connection failed after being connected
|
case 'disconnected':
|
||||||
// Don't end call during initial connection setup
|
// 临时断开,进入重连状态
|
||||||
if (state === 'failed') {
|
if (currentCall.status === 'connected') {
|
||||||
const { currentCall } = callStore.getState();
|
callStore.setState((s) => ({
|
||||||
if (currentCall && currentCall.status === 'connected') {
|
currentCall: s.currentCall
|
||||||
callStore.getState().endCall('connection_lost');
|
? { ...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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +633,7 @@ export const callStore = create<CallState>((set, get) => ({
|
|||||||
peerName: calleeName,
|
peerName: calleeName,
|
||||||
peerAvatar: callee?.avatar,
|
peerAvatar: callee?.avatar,
|
||||||
callType,
|
callType,
|
||||||
status: 'ringing',
|
status: 'calling', // 改为 'calling' 表示正在呼出
|
||||||
duration: 0,
|
duration: 0,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
isSpeakerOn: false,
|
isSpeakerOn: false,
|
||||||
@@ -626,7 +659,8 @@ export const callStore = create<CallState>((set, get) => ({
|
|||||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||||
callTimeoutTimer = setTimeout(() => {
|
callTimeoutTimer = setTimeout(() => {
|
||||||
const { currentCall: cc } = get();
|
const { currentCall: cc } = get();
|
||||||
if (cc && cc.status === 'ringing') {
|
// 只有在 'calling' 状态(呼出中)才超时
|
||||||
|
if (cc && cc.status === 'calling') {
|
||||||
console.warn('[CallStore] Call timeout');
|
console.warn('[CallStore] Call timeout');
|
||||||
get().endCall('timeout');
|
get().endCall('timeout');
|
||||||
}
|
}
|
||||||
@@ -637,12 +671,15 @@ export const callStore = create<CallState>((set, get) => ({
|
|||||||
const { incomingCall } = get();
|
const { incomingCall } = get();
|
||||||
if (!incomingCall) return;
|
if (!incomingCall) return;
|
||||||
|
|
||||||
|
console.log('[CallStore] acceptCall, incomingCall.callType:', incomingCall.callType);
|
||||||
|
|
||||||
if (callTimeoutTimer) {
|
if (callTimeoutTimer) {
|
||||||
clearTimeout(callTimeoutTimer);
|
clearTimeout(callTimeoutTimer);
|
||||||
callTimeoutTimer = null;
|
callTimeoutTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isVideoCall = incomingCall.callType === 'video';
|
const isVideoCall = incomingCall.callType === 'video';
|
||||||
|
console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall);
|
||||||
const myUserId = useAuthStore.getState().currentUser?.id || '';
|
const myUserId = useAuthStore.getState().currentUser?.id || '';
|
||||||
|
|
||||||
set({
|
set({
|
||||||
|
|||||||
Reference in New Issue
Block a user