@@ -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 < EventHandler > = 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 < void > {
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 . s treams [ 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 . isInitiato r ) ;
} catch ( err ) {
console . error ( '[WebRTC] Negotiation needed failed:' , er r ) ;
}
} ;
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 > {
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 < RTCSessionDescriptionInit | null > {
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 < RTCSessionDescriptionInit > {
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 < RTCSessionDescriptionInit > {
console . log ( '[WebRTC] createAnswer called, peerConnection exists:' , ! ! this . peerConnection , 'disposed:' , this . dispos ed) ;
if ( ! this . peerConnection ) {
console . error ( '[WebRTC] createAnswer: PeerConnection is null' ) ;
throw new Error ( 'PeerConnection not initialized' ) ;
}
if ( ! this . peerConnection) throw new Error ( 'PeerConnection not initializ ed' ) ;
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 < 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 > {
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 < 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 > {
@@ -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 < MediaStream > {
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 transceiv ers = this . peerConnection . getTransceiv ers ( ) ;
const videoTransceiver = transceiv ers . find ( t = > t . receiver . track ? . kind === 'video' ) ;
// Find the sender for video and replace the t rack
const send ers = this . peerConnection . getSend ers ( ) ;
const videoSender = send ers . find ( s = > s . track ? . kind === 'video' ) ;
if ( videoTransceiv er ) {
// 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 ( videoSend er ) {
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 < MediaStream | null > {
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 < 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 {
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 ;
}