/** * WebRTC Manager * Handles peer connections for voice and video calls */ import { io, Socket } from 'socket.io-client'; export interface PeerConnectionConfig { iceServers?: RTCIceServer[]; audioConstraints?: MediaTrackConstraints; videoConstraints?: MediaTrackConstraints; } export interface CallOptions { audio: boolean; video: boolean; } export class WebRTCManager { private socket: Socket | null = null; private peerConnections: Map = new Map(); private localStream: MediaStream | null = null; private config: RTCConfiguration; private audioContext: AudioContext | null = null; private analyser: AnalyserNode | null = null; constructor(serverUrl: string, config?: PeerConnectionConfig) { this.config = { iceServers: config?.iceServers || [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, ], }; this.socket = io(serverUrl, { transports: ['websocket'], }); this.setupSocketListeners(); } /** * Setup Socket.IO event listeners for signaling */ private setupSocketListeners(): void { if (!this.socket) return; this.socket.on('call:offer', async ({ from, offer }) => { await this.handleOffer(from, offer); }); this.socket.on('call:answer', async ({ from, answer }) => { await this.handleAnswer(from, answer); }); this.socket.on('call:ice-candidate', ({ from, candidate }) => { this.handleIceCandidate(from, candidate); }); this.socket.on('call:end', ({ from }) => { this.closePeerConnection(from); }); } /** * Initialize local media stream */ async initializeLocalStream(options: CallOptions): Promise { try { const constraints: MediaStreamConstraints = { audio: options.audio ? { echoCancellation: true, noiseSuppression: true, autoGainControl: true, } : false, video: options.video ? { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 }, } : false, }; this.localStream = await navigator.mediaDevices.getUserMedia(constraints); // Setup voice activity detection if (options.audio) { this.setupVoiceActivityDetection(); } return this.localStream; } catch (error) { console.error('Failed to get user media:', error); throw error; } } /** * Setup voice activity detection using Web Audio API */ private setupVoiceActivityDetection(): void { if (!this.localStream) return; this.audioContext = new AudioContext(); this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 2048; const source = this.audioContext.createMediaStreamSource(this.localStream); source.connect(this.analyser); } /** * Check if user is speaking */ isSpeaking(threshold: number = 0.01): boolean { if (!this.analyser) return false; const dataArray = new Uint8Array(this.analyser.frequencyBinCount); this.analyser.getByteFrequencyData(dataArray); const average = dataArray.reduce((acc, val) => acc + val, 0) / dataArray.length; return average / 255 > threshold; } /** * Initiate a call to a peer */ async initiateCall(peerId: string, options: CallOptions): Promise { if (!this.socket) throw new Error('Socket not initialized'); // Get local stream if (!this.localStream) { await this.initializeLocalStream(options); } // Create peer connection const pc = this.createPeerConnection(peerId); // Add local tracks if (this.localStream) { this.localStream.getTracks().forEach(track => { pc.addTrack(track, this.localStream!); }); } // Create and send offer const offer = await pc.createOffer(); await pc.setLocalDescription(offer); this.socket.emit('call:offer', { to: peerId, offer: offer, }); } /** * Handle incoming call offer */ private async handleOffer(peerId: string, offer: RTCSessionDescriptionInit): Promise { if (!this.socket) return; const pc = this.createPeerConnection(peerId); await pc.setRemoteDescription(new RTCSessionDescription(offer)); // Add local tracks if available if (this.localStream) { this.localStream.getTracks().forEach(track => { pc.addTrack(track, this.localStream!); }); } // Create and send answer const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); this.socket.emit('call:answer', { to: peerId, answer: answer, }); } /** * Handle answer to our offer */ private async handleAnswer(peerId: string, answer: RTCSessionDescriptionInit): Promise { const pc = this.peerConnections.get(peerId); if (!pc) return; await pc.setRemoteDescription(new RTCSessionDescription(answer)); } /** * Handle ICE candidate */ private async handleIceCandidate(peerId: string, candidate: RTCIceCandidateInit): Promise { const pc = this.peerConnections.get(peerId); if (!pc) return; try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (error) { console.error('Error adding ICE candidate:', error); } } /** * Create a new peer connection */ private createPeerConnection(peerId: string): RTCPeerConnection { const pc = new RTCPeerConnection(this.config); // ICE candidate event pc.onicecandidate = (event) => { if (event.candidate && this.socket) { this.socket.emit('call:ice-candidate', { to: peerId, candidate: event.candidate, }); } }; // Track event (remote stream) pc.ontrack = (event) => { this.onRemoteTrack?.(peerId, event.streams[0]); }; // Connection state change pc.onconnectionstatechange = () => { console.log(`Peer connection state: ${pc.connectionState}`); if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') { this.closePeerConnection(peerId); } }; this.peerConnections.set(peerId, pc); return pc; } /** * Close peer connection */ private closePeerConnection(peerId: string): void { const pc = this.peerConnections.get(peerId); if (pc) { pc.close(); this.peerConnections.delete(peerId); this.onPeerDisconnected?.(peerId); } } /** * End call */ endCall(peerId: string): void { if (this.socket) { this.socket.emit('call:end', { to: peerId }); } this.closePeerConnection(peerId); } /** * Toggle audio */ toggleAudio(enabled: boolean): void { if (!this.localStream) return; this.localStream.getAudioTracks().forEach(track => { track.enabled = enabled; }); } /** * Toggle video */ toggleVideo(enabled: boolean): void { if (!this.localStream) return; this.localStream.getVideoTracks().forEach(track => { track.enabled = enabled; }); } /** * Get screen sharing stream */ async getScreenStream(): Promise { try { return await navigator.mediaDevices.getDisplayMedia({ video: { cursor: 'always', }, audio: false, }); } catch (error) { console.error('Failed to get screen stream:', error); throw error; } } /** * Replace video track with screen share */ async startScreenShare(peerId: string): Promise { const screenStream = await this.getScreenStream(); const screenTrack = screenStream.getVideoTracks()[0]; const pc = this.peerConnections.get(peerId); if (!pc) return; const sender = pc.getSenders().find(s => s.track?.kind === 'video'); if (sender) { await sender.replaceTrack(screenTrack); } // Stop screen share when track ends screenTrack.onended = () => { this.stopScreenShare(peerId); }; } /** * Stop screen sharing */ async stopScreenShare(peerId: string): Promise { if (!this.localStream) return; const videoTrack = this.localStream.getVideoTracks()[0]; const pc = this.peerConnections.get(peerId); if (!pc) return; const sender = pc.getSenders().find(s => s.track?.kind === 'video'); if (sender && videoTrack) { await sender.replaceTrack(videoTrack); } } /** * Cleanup */ destroy(): void { // Close all peer connections this.peerConnections.forEach((pc, peerId) => { this.closePeerConnection(peerId); }); // Stop local stream if (this.localStream) { this.localStream.getTracks().forEach(track => track.stop()); this.localStream = null; } // Close audio context if (this.audioContext) { this.audioContext.close(); this.audioContext = null; } // Disconnect socket if (this.socket) { this.socket.disconnect(); this.socket = null; } } // Event handlers (to be set by consumer) onRemoteTrack?: (peerId: string, stream: MediaStream) => void; onPeerDisconnected?: (peerId: string) => void; }