UNPKG

@telnyx/react-native-voice-sdk

Version:

Telnyx React Native Voice SDK - A complete WebRTC voice calling solution

426 lines (367 loc) 14.3 kB
import log from 'loglevel'; import { mediaDevices, RTCPeerConnection, RTCSessionDescription, MediaStream, } from 'react-native-webrtc'; import type { MediaTrackConstraints } from 'react-native-webrtc/lib/typescript/Constraints'; import type RTCIceCandidateEvent from 'react-native-webrtc/lib/typescript/RTCIceCandidateEvent'; import type { RTCSessionDescriptionInit } from 'react-native-webrtc/lib/typescript/RTCSessionDescription'; import type RTCTrackEvent from 'react-native-webrtc/lib/typescript/RTCTrackEvent'; import type { CallOptions } from './call-options'; import type { DeferredPromise } from './promise'; import { createDeferredPromise } from './promise'; import type { WebRTCReporter } from './webrtc-reporter'; type MediaConstraints = { audio?: boolean | MediaTrackConstraints; video?: boolean | MediaTrackConstraints; }; export type TrickleIceCandidate = { candidate: string; sdpMid?: string | null; sdpMLineIndex?: number | null; }; export class Peer { public static createOffer = async (callOptions: CallOptions) => { const peer = await new Peer(callOptions) .createPeerConnection() .attachLocalStream({ audio: true, video: false }) .then((peer) => peer.createOffer()); if (!peer.useTrickleIce) { await peer.waitForIceGatheringComplete(); } return peer; }; public mediaConstraints: MediaConstraints; private instance: RTCPeerConnection | null; private options: CallOptions; private iceGatheringComplete: DeferredPromise<boolean> | null; private reporter: WebRTCReporter | null = null; private pendingRemoteCandidates: TrickleIceCandidate[] = []; private pendingLocalCandidates: TrickleIceCandidate[] = []; private pendingLocalEndOfCandidates = false; private localCandidateDispatchEnabled = false; /** Callback for local Trickle ICE candidates. Call owns signaling transport. */ public onLocalCandidate: ((candidate: TrickleIceCandidate) => void) | null = null; /** Callback for local ICE completion when Trickle ICE is enabled. */ public onLocalEndOfCandidates: (() => void) | null = null; /** Callback for logging peer events to call report collector */ public onPeerEventLog: ((event: string, context: Record<string, unknown>) => void) | null = null; constructor(options: CallOptions) { this.instance = null; this.iceGatheringComplete = null; this.mediaConstraints = { audio: true, video: false }; this.options = options; } /** * Set the WebRTC reporter for debug stats collection */ public setReporter(reporter: WebRTCReporter | null): void { this.reporter = reporter; } /** * Get the underlying RTCPeerConnection instance */ public getPeerConnection(): RTCPeerConnection | null { return this.instance; } /** * Get the ICE servers configuration */ public getIceServers(): RTCIceServer[] { return this.getIceServersInternal(); } public close = () => { log.debug('[Peer] Closing peer connection'); if (this.instance) { this.instance.close(); this.instance = null; } if (this.iceGatheringComplete) { // Inbound pre-accept flows may never await ICE completion. Attach a handler // before rejecting so close() is marked handled while awaited callers still reject. this.iceGatheringComplete.promise.catch(() => {}); this.iceGatheringComplete.reject(new Error('Peer connection closed')); this.iceGatheringComplete = null; } this.options.localStream?.getTracks().forEach((track) => { track.stop(); }); this.options.localStream = undefined; this.options.remoteStream?.getTracks().forEach((track) => { track.stop(); }); this.options.remoteStream = undefined; this.pendingRemoteCandidates = []; this.pendingLocalCandidates = []; this.pendingLocalEndOfCandidates = false; this.localCandidateDispatchEnabled = false; return this; }; public setRemoteDescription = async (session: RTCSessionDescriptionInit) => { if (!this.instance) { throw new Error('[Peer] Peer connection not created'); } log.debug('[Peer] Setting remote description', session); await this.instance.setRemoteDescription(new RTCSessionDescription(session)).catch((error) => { log.error('[Peer] Error setting remote description', error); }); await this.flushPendingRemoteCandidates(); return this; }; public get remoteDescription() { if (!this.instance) { throw new Error('[Peer] Peer connection not created'); } return this.instance.remoteDescription; } public get localDescription() { if (!this.instance) { throw new Error('[Peer] Peer connection not created'); } return this.instance.localDescription; } public createPeerConnection = () => { log.debug('[Peer] Creating peer connection'); this.iceGatheringComplete = createDeferredPromise(1000); const instance = new RTCPeerConnection({ bundlePolicy: this.options.peerConnectionOptions?.bundlePolicy || 'max-compat', iceServers: this.getIceServers(), iceTransportPolicy: this.options.peerConnectionOptions?.iceTransportPolicy || 'all', rtcpMuxPolicy: this.options.peerConnectionOptions?.rtcpMuxPolicy || 'require', iceCandidatePoolSize: this.options.peerConnectionOptions?.prefetchIceCandidates ? 10 : 0, }); this.instance = instance; this.instance.addEventListener('icecandidate', this.onIceCandidate); this.instance.addEventListener('icecandidateerror', this.onIceCandidateError); this.instance.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange); this.instance.addEventListener('connectionstatechange', this.onConnectionStateChange); this.instance.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange); this.instance.addEventListener('signalingstatechange', this.onSignalingStateChange); this.instance.addEventListener('track', this.onTrackEvent); return this; }; public createAnswer = async () => { if (!this.instance) { throw new Error('[Peer] Cannot create answer before peer connection'); } const answer = await this.instance.createAnswer(); await this.instance.setLocalDescription(answer).catch((error) => { log.error('[Peer] Error setting local description', error); }); return this; }; public createOffer = async () => { if (!this.instance) { throw new Error('[Peer] Cannot create offer before peer connection'); } const offer = await this.instance.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: false, }); await this.instance.setLocalDescription(offer).catch((error) => { log.error('[Peer] Error setting local description', error); }); return this; }; public attachLocalStream = async (constraints: MediaConstraints) => { log.debug('[Peer] Attaching local stream', constraints); if (!this.instance) { throw new Error('[Peer] Peer connection not created'); } this.mediaConstraints = constraints; if (this.options.localStream == null) { this.options.localStream = await mediaDevices.getUserMedia(constraints); } this.options.localStream.getTracks().forEach((track) => { log.debug('[Peer] Adding track', track); this.instance?.addTrack(track, this.options.localStream!); }); return this; }; public waitForIceGatheringComplete = async () => { if (!this.iceGatheringComplete) { throw new Error('Ice gathering not started'); } await this.iceGatheringComplete.promise; log.debug('[Peer] ICE gathering complete'); return this; }; public get useTrickleIce(): boolean { return Boolean(this.options.peerConnectionOptions?.useTrickleIce); } public flushPendingLocalCandidates = () => { this.localCandidateDispatchEnabled = true; while (this.pendingLocalCandidates.length > 0) { const candidate = this.pendingLocalCandidates.shift()!; this.onLocalCandidate?.(candidate); } if (this.pendingLocalEndOfCandidates) { this.pendingLocalEndOfCandidates = false; this.onLocalEndOfCandidates?.(); } return this; }; public clearPendingLocalCandidates = () => { this.pendingLocalCandidates = []; this.pendingLocalEndOfCandidates = false; return this; }; public addRemoteCandidate = async (candidate: TrickleIceCandidate) => { if (!this.instance) { throw new Error('[Peer] Peer connection not created'); } if (!this.instance.remoteDescription) { log.debug('[Peer] Queueing remote ICE candidate until remote description is set'); this.pendingRemoteCandidates.push(candidate); return this; } // react-native-webrtc accepts RTCIceCandidateInit here, but its TS surface // can lag the native implementation. await (this.instance as any).addIceCandidate(candidate).catch((error: unknown) => { log.error('[Peer] Error adding remote ICE candidate', error); }); return this; }; public addRemoteEndOfCandidates = async () => { if (!this.instance) { throw new Error('[Peer] Peer connection not created'); } if (!this.instance.remoteDescription) { return this; } if (typeof (this.instance as any).addIceCandidate === 'function') { // Some RN WebRTC versions ignore or reject null end-of-candidates. // Treat that as non-fatal because candidates have already been delivered. await (this.instance as any).addIceCandidate(null).catch((error: unknown) => { log.debug('[Peer] Remote end-of-candidates ignored by peer connection', error); }); } return this; }; private flushPendingRemoteCandidates = async () => { while (this.pendingRemoteCandidates.length > 0) { const candidate = this.pendingRemoteCandidates.shift()!; await this.addRemoteCandidate(candidate); } }; private emitOrQueueLocalCandidate(candidate: TrickleIceCandidate) { if (this.localCandidateDispatchEnabled) { this.onLocalCandidate?.(candidate); return; } this.pendingLocalCandidates.push(candidate); } private emitOrQueueLocalEndOfCandidates() { if (this.localCandidateDispatchEnabled) { this.onLocalEndOfCandidates?.(); return; } this.pendingLocalEndOfCandidates = true; } public setMediaStreamState = (stream: MediaStream, enabled: boolean) => { log.debug('[Peer] Setting media stream state', { enabled, stream }); if (!stream) { throw new Error('[Peer] No media stream to set state'); } stream.getTracks().forEach((track) => { log.debug('[Peer] Track state set', { track, enabled }); track.enabled = enabled; }); return this; }; private onIceGatheringStateChange = () => { const iceGatheringState = this.instance?.iceGatheringState; log.debug('[Peer] ICE gathering state change', iceGatheringState); // Report to WebRTCReporter if available if (this.reporter && iceGatheringState) { this.reporter.onIceGatheringStateChange(iceGatheringState); } // Log to call report collector if (iceGatheringState) { this.onPeerEventLog?.('ICE gathering state changed', { state: iceGatheringState }); } if (iceGatheringState === 'complete') { log.debug('[Peer] ICE gathering complete'); this.iceGatheringComplete?.resolve(true); this.iceGatheringComplete = null; } }; private onIceCandidate = (ev: RTCIceCandidateEvent<'icecandidate'>) => { log.debug('[Peer] ICE candidate', ev); // Report to WebRTCReporter if available if (this.reporter && ev.candidate) { this.reporter.onIceCandidate(ev.candidate as unknown as RTCIceCandidate); } if (this.useTrickleIce) { if (ev.candidate) { const candidate = ev.candidate as unknown as TrickleIceCandidate; this.emitOrQueueLocalCandidate({ candidate: candidate.candidate, sdpMid: candidate.sdpMid, sdpMLineIndex: candidate.sdpMLineIndex, }); } else { this.emitOrQueueLocalEndOfCandidates(); this.iceGatheringComplete?.resolve(true); } return; } this.iceGatheringComplete?.resolve(true); return; }; private onIceCandidateError = (event: RTCIceCandidateEvent<'icecandidateerror'>) => { log.error('[Peer] ICE candidate error', event); }; private getIceServersInternal() { if ( this.options.peerConnectionOptions?.iceServers && this.options.peerConnectionOptions?.iceServers.length > 0 ) { return this.options.peerConnectionOptions.iceServers; } return [ { urls: 'stun:stun.l.google.com:19302', }, ]; } private onTrackEvent = (event: RTCTrackEvent<'track'>) => { log.debug('[Peer] Remote track event', event); if (event.streams && event.streams.length > 0) { this.options.remoteStream = event.streams[0]; } // Report to WebRTCReporter if available if (this.reporter && event.track) { this.reporter.onTrack(event.track as unknown as MediaStreamTrack); } }; private onSignalingStateChange = () => { const signalingState = this.instance?.signalingState; log.debug('[Peer] Signaling state change', signalingState); // Report to WebRTCReporter if available if (this.reporter && signalingState) { this.reporter.onSignalingStateChange(signalingState); } // Log to call report collector if (signalingState) { this.onPeerEventLog?.('Signaling state changed', { state: signalingState }); } }; private onIceConnectionStateChange = () => { const iceConnectionState = this.instance?.iceConnectionState; log.debug('[Peer] ICE connection state change', iceConnectionState); // Report to WebRTCReporter if available if (this.reporter && iceConnectionState) { this.reporter.onIceConnectionStateChange(iceConnectionState); } // Log to call report collector if (iceConnectionState) { this.onPeerEventLog?.('ICE connection state changed', { state: iceConnectionState }); } }; private onConnectionStateChange = () => { log.debug('[Peer] Connection state change', this.instance?.connectionState); }; }