/** * WebRTC Manager * Handles all WebRTC peer connection logic for voice and video calls */ class WebRTCManager { constructor(socket) { this.socket = socket; this.peerConnections = new Map(); // Map of userId -> RTCPeerConnection this.localStream = null; this.screenStream = null; this.remoteStreams = new Map(); // Map of userId -> MediaStream this.currentCallId = null; this.isInitiator = false; // WebRTC configuration with STUN/TURN servers this.configuration = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] }; // Media constraints this.audioConstraints = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; this.videoConstraints = { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } }; // Event handlers this.onRemoteStream = null; this.onRemoteStreamRemoved = null; this.onConnectionStateChange = null; this.onIceConnectionStateChange = null; this.setupSocketListeners(); } /** * Setup Socket.io listeners for WebRTC signaling */ setupSocketListeners() { this.socket.on('call:offer', async (data) => { await this.handleOffer(data); }); this.socket.on('call:answer', async (data) => { await this.handleAnswer(data); }); this.socket.on('call:ice-candidate', async (data) => { await this.handleIceCandidate(data); }); this.socket.on('call:ended', () => { this.cleanup(); }); this.socket.on('call:participant-joined', async (data) => { console.log('Participant joined:', data); // For group calls, establish connection with new participant if (this.isInitiator) { await this.initiateCallToUser(data.userId); } }); this.socket.on('call:participant-left', (data) => { console.log('Participant left:', data); this.removePeerConnection(data.userId); }); this.socket.on('call:media-state-changed', (data) => { console.log('Media state changed:', data); // Update UI to reflect remote user's media state if (this.onMediaStateChanged) { this.onMediaStateChanged(data); } }); } /** * Set TURN server credentials */ async setTurnCredentials(turnCredentials) { if (turnCredentials && turnCredentials.urls) { const turnServer = { urls: turnCredentials.urls, username: turnCredentials.username, credential: turnCredentials.credential }; this.configuration.iceServers.push(turnServer); console.log('TURN server configured'); } } /** * Initialize local media stream (audio and/or video) */ async initializeLocalStream(audioEnabled = true, videoEnabled = true) { try { const constraints = { audio: audioEnabled ? this.audioConstraints : false, video: videoEnabled ? this.videoConstraints : false }; this.localStream = await navigator.mediaDevices.getUserMedia(constraints); console.log('Local stream initialized:', { audio: audioEnabled, video: videoEnabled, tracks: this.localStream.getTracks().length }); return this.localStream; } catch (error) { console.error('Error accessing media devices:', error); throw new Error(`Failed to access camera/microphone: ${error.message}`); } } /** * Create a peer connection for a user */ createPeerConnection(userId) { if (this.peerConnections.has(userId)) { return this.peerConnections.get(userId); } const peerConnection = new RTCPeerConnection(this.configuration); // Add local stream tracks to peer connection if (this.localStream) { this.localStream.getTracks().forEach(track => { peerConnection.addTrack(track, this.localStream); }); } // Handle incoming remote stream peerConnection.ontrack = (event) => { console.log('Received remote track from', userId, event.track.kind); const [remoteStream] = event.streams; this.remoteStreams.set(userId, remoteStream); if (this.onRemoteStream) { this.onRemoteStream(userId, remoteStream); } }; // Handle ICE candidates peerConnection.onicecandidate = (event) => { if (event.candidate) { console.log('Sending ICE candidate to', userId); this.socket.emit('call:ice-candidate', { callId: this.currentCallId, targetUserId: userId, candidate: event.candidate }); } }; // Handle connection state changes peerConnection.onconnectionstatechange = () => { console.log(`Connection state with ${userId}:`, peerConnection.connectionState); if (this.onConnectionStateChange) { this.onConnectionStateChange(userId, peerConnection.connectionState); } // Cleanup if connection fails or closes if (peerConnection.connectionState === 'failed' || peerConnection.connectionState === 'closed') { this.removePeerConnection(userId); } }; // Handle ICE connection state changes peerConnection.oniceconnectionstatechange = () => { console.log(`ICE connection state with ${userId}:`, peerConnection.iceConnectionState); if (this.onIceConnectionStateChange) { this.onIceConnectionStateChange(userId, peerConnection.iceConnectionState); } }; this.peerConnections.set(userId, peerConnection); return peerConnection; } /** * Initiate a call to a user (create offer) */ async initiateCallToUser(userId) { try { const peerConnection = this.createPeerConnection(userId); // Create offer const offer = await peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true }); await peerConnection.setLocalDescription(offer); // Send offer through signaling server this.socket.emit('call:offer', { callId: this.currentCallId, targetUserId: userId, offer: offer }); console.log('Call offer sent to', userId); } catch (error) { console.error('Error initiating call:', error); throw error; } } /** * Handle incoming call offer */ async handleOffer(data) { const { callId, fromUserId, offer } = data; try { console.log('Received call offer from', fromUserId); this.currentCallId = callId; const peerConnection = this.createPeerConnection(fromUserId); await peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); // Create answer const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); // Send answer back this.socket.emit('call:answer', { callId: callId, targetUserId: fromUserId, answer: answer }); console.log('Call answer sent to', fromUserId); } catch (error) { console.error('Error handling offer:', error); throw error; } } /** * Handle incoming call answer */ async handleAnswer(data) { const { fromUserId, answer } = data; try { console.log('Received call answer from', fromUserId); const peerConnection = this.peerConnections.get(fromUserId); if (!peerConnection) { throw new Error(`No peer connection found for user ${fromUserId}`); } await peerConnection.setRemoteDescription(new RTCSessionDescription(answer)); console.log('Remote description set for', fromUserId); } catch (error) { console.error('Error handling answer:', error); throw error; } } /** * Handle incoming ICE candidate */ async handleIceCandidate(data) { const { fromUserId, candidate } = data; try { const peerConnection = this.peerConnections.get(fromUserId); if (!peerConnection) { console.warn(`No peer connection found for user ${fromUserId}`); return; } await peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); console.log('ICE candidate added for', fromUserId); } catch (error) { console.error('Error adding ICE candidate:', error); } } /** * Remove peer connection for a user */ removePeerConnection(userId) { const peerConnection = this.peerConnections.get(userId); if (peerConnection) { peerConnection.close(); this.peerConnections.delete(userId); } const remoteStream = this.remoteStreams.get(userId); if (remoteStream) { remoteStream.getTracks().forEach(track => track.stop()); this.remoteStreams.delete(userId); if (this.onRemoteStreamRemoved) { this.onRemoteStreamRemoved(userId); } } console.log('Peer connection removed for', userId); } /** * Toggle audio track enabled/disabled */ toggleAudio(enabled) { if (this.localStream) { const audioTrack = this.localStream.getAudioTracks()[0]; if (audioTrack) { audioTrack.enabled = enabled; console.log('Audio', enabled ? 'enabled' : 'disabled'); return true; } } return false; } /** * Toggle video track enabled/disabled */ toggleVideo(enabled) { if (this.localStream) { const videoTrack = this.localStream.getVideoTracks()[0]; if (videoTrack) { videoTrack.enabled = enabled; console.log('Video', enabled ? 'enabled' : 'disabled'); return true; } } return false; } /** * Start screen sharing */ async startScreenShare() { try { this.screenStream = await navigator.mediaDevices.getDisplayMedia({ video: { cursor: 'always' }, audio: false }); const screenTrack = this.screenStream.getVideoTracks()[0]; // Replace video track in all peer connections this.peerConnections.forEach((peerConnection) => { const sender = peerConnection.getSenders().find(s => s.track?.kind === 'video'); if (sender) { sender.replaceTrack(screenTrack); } }); // Handle screen share stop screenTrack.onended = () => { this.stopScreenShare(); }; console.log('Screen sharing started'); return this.screenStream; } catch (error) { console.error('Error starting screen share:', error); throw error; } } /** * Stop screen sharing and restore camera */ stopScreenShare() { if (this.screenStream) { this.screenStream.getTracks().forEach(track => track.stop()); this.screenStream = null; // Restore camera track if (this.localStream) { const videoTrack = this.localStream.getVideoTracks()[0]; if (videoTrack) { this.peerConnections.forEach((peerConnection) => { const sender = peerConnection.getSenders().find(s => s.track?.kind === 'video'); if (sender) { sender.replaceTrack(videoTrack); } }); } } console.log('Screen sharing stopped'); } } /** * Get connection statistics */ async getConnectionStats(userId) { const peerConnection = this.peerConnections.get(userId); if (!peerConnection) { return null; } const stats = await peerConnection.getStats(); const result = { audio: {}, video: {}, connection: {} }; stats.forEach(report => { if (report.type === 'inbound-rtp') { if (report.kind === 'audio') { result.audio.bytesReceived = report.bytesReceived; result.audio.packetsLost = report.packetsLost; result.audio.jitter = report.jitter; } else if (report.kind === 'video') { result.video.bytesReceived = report.bytesReceived; result.video.packetsLost = report.packetsLost; result.video.framesDecoded = report.framesDecoded; result.video.frameWidth = report.frameWidth; result.video.frameHeight = report.frameHeight; } } else if (report.type === 'candidate-pair' && report.state === 'succeeded') { result.connection.roundTripTime = report.currentRoundTripTime; result.connection.availableOutgoingBitrate = report.availableOutgoingBitrate; } }); return result; } /** * Cleanup all connections and streams */ cleanup() { console.log('Cleaning up WebRTC resources'); // Stop screen share if active this.stopScreenShare(); // Close all peer connections this.peerConnections.forEach((peerConnection, userId) => { this.removePeerConnection(userId); }); // Stop local stream if (this.localStream) { this.localStream.getTracks().forEach(track => track.stop()); this.localStream = null; } // Clear remote streams this.remoteStreams.forEach((stream) => { stream.getTracks().forEach(track => track.stop()); }); this.remoteStreams.clear(); this.currentCallId = null; this.isInitiator = false; } /** * Get local stream */ getLocalStream() { return this.localStream; } /** * Get remote stream for a user */ getRemoteStream(userId) { return this.remoteStreams.get(userId); } /** * Get all remote streams */ getAllRemoteStreams() { return Array.from(this.remoteStreams.entries()); } /** * Check if audio is enabled */ isAudioEnabled() { if (this.localStream) { const audioTrack = this.localStream.getAudioTracks()[0]; return audioTrack ? audioTrack.enabled : false; } return false; } /** * Check if video is enabled */ isVideoEnabled() { if (this.localStream) { const videoTrack = this.localStream.getVideoTracks()[0]; return videoTrack ? videoTrack.enabled : false; } return false; } /** * Check if screen sharing is active */ isScreenSharing() { return this.screenStream !== null; } } export default WebRTCManager;