import 'dart:async'; import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; /// WebRTC service for handling peer-to-peer audio/video connections class WebRTCService { // Peer connection RTCPeerConnection? _peerConnection; // Media streams MediaStream? _localStream; MediaStream? _remoteStream; // Video renderers (for future video support) RTCVideoRenderer? localRenderer; RTCVideoRenderer? remoteRenderer; // Callbacks Function(MediaStream stream)? onLocalStream; Function(MediaStream stream)? onRemoteStream; Function(RTCIceCandidate candidate)? onIceCandidate; Function(RTCIceConnectionState state)? onIceConnectionStateChange; Function()? onCallEnded; // ICE candidate queue (for candidates received before remote description) final List _iceCandidateQueue = []; bool _remoteDescriptionSet = false; // Track if current call is video call bool _isVideoCall = false; // Track if we're using test mode (Google STUN only) static bool _useTestMode = false; /// Enable test mode (use only Google STUN servers for debugging) static void enableTestMode() { _useTestMode = true; if (kDebugMode) { log('⚙️ [WebRTC] Test mode enabled', name: 'WebRTCService'); } } /// Disable test mode (use backend TURN servers) static void disableTestMode() { _useTestMode = false; if (kDebugMode) { log('⚙️ [WebRTC] Test mode disabled', name: 'WebRTCService'); } } // Configuration static const Map _mediaConstraints = { 'audio': true, 'video': false, }; // Video constraints for video calls static const Map _videoConstraints = { 'audio': true, 'video': { 'facingMode': 'user', 'width': {'ideal': 1280}, 'height': {'ideal': 720}, }, }; // ICE servers configuration for TEST MODE (Google STUN only) static const Map _testConfiguration = { 'iceServers': [ { 'urls': [ 'stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302', 'stun:stun2.l.google.com:19302', ] }, ], 'sdpSemantics': 'unified-plan', 'iceTransportPolicy': 'all', 'iceCandidatePoolSize': 10, }; // ICE servers configuration (from backend) final Map _configuration = { 'iceServers': [ // Primary STUN servers (Google's public STUN - highly reliable) { 'urls': [ 'stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302', ] }, // Backend TURN server (PRIMARY) { 'urls': [ 'turn:15.185.116.59:3479', 'turn:15.185.116.59:3479?transport=tcp', ], 'username': 'admin', 'credential': 'admin' }, // FALLBACK: Public TURN servers { 'urls': 'turn:a.relay.metered.ca:80', 'username': 'e14d93f87f9ff517f1bee797', 'credential': 'PGfCGmR6CR2aM9OY', }, { 'urls': 'turn:a.relay.metered.ca:443', 'username': 'e14d93f87f9ff517f1bee797', 'credential': 'PGfCGmR6CR2aM9OY', }, ], 'sdpSemantics': 'unified-plan', 'iceTransportPolicy': 'all', 'iceCandidatePoolSize': 10, 'bundlePolicy': 'max-bundle', 'rtcpMuxPolicy': 'require', }; /// Initialize WebRTC for audio call Future initializeForAudioCall() async { try { if (kDebugMode) { log('🔧 [WebRTC] Initializing audio call', name: 'WebRTCService'); } _isVideoCall = false; // Get local audio stream try { _localStream = await navigator.mediaDevices.getUserMedia(_mediaConstraints); } catch (e) { log('❌ [WebRTC] Failed to get audio stream: $e', name: 'WebRTCService', error: e); throw Exception('Microphone access denied or unavailable'); } // Create peer connection _peerConnection = await createPeerConnection(_configuration); if (_peerConnection == null) { throw Exception('Failed to create peer connection'); } // Add local stream tracks to peer connection _localStream!.getTracks().forEach((track) { _peerConnection!.addTrack(track, _localStream!); }); // Setup peer connection event handlers _setupPeerConnectionListeners(); if (kDebugMode) { log('✅ [WebRTC] Audio call initialized', name: 'WebRTCService'); } } catch (e, stackTrace) { log('❌ [WebRTC] Initialization error: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace); // Clean up any partial initialization await dispose(); rethrow; } } /// Initialize WebRTC for video call Future initializeForVideoCall() async { try { if (kDebugMode) { log('🔧 [WebRTC] Initializing video call', name: 'WebRTCService'); } _isVideoCall = true; // Initialize video renderers localRenderer = RTCVideoRenderer(); remoteRenderer = RTCVideoRenderer(); try { await localRenderer!.initialize(); await remoteRenderer!.initialize(); } catch (e) { log('❌ [WebRTC] Failed to initialize renderers: $e', name: 'WebRTCService', error: e); throw Exception('Failed to initialize video renderers'); } // Create peer connection FIRST (before getting media) final config = _useTestMode ? _testConfiguration : _configuration; _peerConnection = await createPeerConnection(config); if (_peerConnection == null) { throw Exception('Failed to create peer connection'); } // Setup peer connection listeners immediately _setupPeerConnectionListeners(); // Get local audio + video stream try { _localStream = await navigator.mediaDevices.getUserMedia(_videoConstraints); } catch (e) { log('❌ [WebRTC] Failed to get video stream: $e', name: 'WebRTCService', error: e); throw Exception('Camera or microphone access denied or unavailable'); } // Set local stream to renderer if (localRenderer != null) { localRenderer!.srcObject = _localStream; } // Add local stream tracks to peer connection _localStream!.getTracks().forEach((track) { _peerConnection!.addTrack(track, _localStream!); }); if (kDebugMode) { log('✅ [WebRTC] Video call initialized', name: 'WebRTCService'); } } catch (e, stackTrace) { log('❌ [WebRTC] Video initialization error: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace); // Clean up any partial initialization await dispose(); rethrow; } } /// Setup peer connection event listeners void _setupPeerConnectionListeners() { // Handle ICE candidates _peerConnection!.onIceCandidate = (RTCIceCandidate candidate) { if (onIceCandidate != null) { onIceCandidate!(candidate); } }; // Handle ICE gathering state changes _peerConnection!.onIceGatheringState = (RTCIceGatheringState state) { if (kDebugMode && state == RTCIceGatheringState.RTCIceGatheringStateComplete) { log('✅ [WebRTC] ICE gathering completed', name: 'WebRTCService'); } }; // Handle ICE connection state changes _peerConnection!.onIceConnectionState = (RTCIceConnectionState state) { if (kDebugMode) { log('🔗 [WebRTC] ICE state: ${state.toString()}', name: 'WebRTCService'); } // Log critical failures if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) { log('❌ [WebRTC] ICE connection failed', name: 'WebRTCService'); } if (onIceConnectionStateChange != null) { onIceConnectionStateChange!(state); } }; // Handle remote stream _peerConnection!.onTrack = (RTCTrackEvent event) { if (event.streams.isNotEmpty) { final stream = event.streams[0]; // If this is the first remote stream or a different stream, update it if (_remoteStream == null || _remoteStream!.id != stream.id) { _remoteStream = stream; // For video calls, assign stream to renderer if (remoteRenderer != null && _isVideoCall) { try { remoteRenderer!.srcObject = _remoteStream; // Retry assignment after delays to ensure it sticks Future.delayed(const Duration(milliseconds: 100), () { if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) { remoteRenderer!.srcObject = _remoteStream; } }); Future.delayed(const Duration(milliseconds: 300), () { if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) { remoteRenderer!.srcObject = _remoteStream; } }); } catch (e) { log('⚠️ [WebRTC] Failed to assign remote stream: $e', name: 'WebRTCService'); } } // Notify callback if (onRemoteStream != null) { onRemoteStream!(_remoteStream!); } if (kDebugMode) { log('📡 [WebRTC] Remote stream received', name: 'WebRTCService'); } } else { // Additional track on existing stream if (remoteRenderer != null && _isVideoCall && remoteRenderer!.srcObject == null && _remoteStream != null) { try { remoteRenderer!.srcObject = _remoteStream; } catch (e) { log('⚠️ [WebRTC] Failed to assign stream (additional track): $e', name: 'WebRTCService'); } } if (onRemoteStream != null) { onRemoteStream!(_remoteStream!); } } } }; // Handle connection state changes _peerConnection!.onConnectionState = (RTCPeerConnectionState state) { if (kDebugMode || state == RTCPeerConnectionState.RTCPeerConnectionStateFailed) { log('🔌 [WebRTC] Connection state: ${state.toString()}', name: 'WebRTCService'); } }; } /// Create SDP offer (caller side) Future createOffer() async { try { if (_peerConnection == null) { throw Exception('Peer connection not initialized'); } final offer = await _peerConnection!.createOffer({ 'offerToReceiveAudio': true, 'offerToReceiveVideo': _isVideoCall, 'iceRestart': false, }); await _peerConnection!.setLocalDescription(offer); if (kDebugMode) { log('✅ [WebRTC] SDP offer created', name: 'WebRTCService'); } return offer; } catch (e, stackTrace) { log('❌ [WebRTC] Error creating offer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace); rethrow; } } /// Create SDP answer (callee side) Future createAnswer(String offerSdp) async { try { if (_peerConnection == null) { throw Exception('Peer connection not initialized'); } // Set remote description (offer from caller) final offer = RTCSessionDescription(offerSdp, 'offer'); await _peerConnection!.setRemoteDescription(offer); _remoteDescriptionSet = true; // Flush queued ICE candidates await _flushIceCandidateQueue(); // Create answer final answer = await _peerConnection!.createAnswer({ 'offerToReceiveAudio': true, 'offerToReceiveVideo': _isVideoCall, }); await _peerConnection!.setLocalDescription(answer); if (kDebugMode) { log('✅ [WebRTC] SDP answer created', name: 'WebRTCService'); } return answer; } catch (e, stackTrace) { log('❌ [WebRTC] Error creating answer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace); rethrow; } } /// Set remote answer (caller side) Future setRemoteAnswer(String answerSdp) async { try { if (_peerConnection == null) { throw Exception('Peer connection not initialized'); } final answer = RTCSessionDescription(answerSdp, 'answer'); await _peerConnection!.setRemoteDescription(answer); _remoteDescriptionSet = true; // Flush queued ICE candidates await _flushIceCandidateQueue(); if (kDebugMode) { log('✅ [WebRTC] Remote answer set', name: 'WebRTCService'); } } catch (e, stackTrace) { log('❌ [WebRTC] Error setting remote answer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace); rethrow; } } /// Send SDP answer (convenience method for callee) Future sendSDPAnswer(String? offerSdp) async { if (offerSdp == null) { throw Exception('Offer SDP is null'); } return await createAnswer(offerSdp); } /// Add remote ICE candidate Future addIceCandidate(RTCIceCandidate candidate) async { try { // If peer connection not created yet, queue the candidate if (_peerConnection == null) { _iceCandidateQueue.add(candidate); return; } // If remote description not set yet, queue the candidate if (!_remoteDescriptionSet) { _iceCandidateQueue.add(candidate); return; } await _peerConnection!.addCandidate(candidate); } catch (e) { log('⚠️ [WebRTC] Error adding ICE candidate: $e', name: 'WebRTCService'); // Don't rethrow - ICE candidate errors shouldn't break the call } } /// Flush queued ICE candidates Future _flushIceCandidateQueue() async { if (_iceCandidateQueue.isEmpty) { return; } if (kDebugMode) { log('🔄 [WebRTC] Flushing ${_iceCandidateQueue.length} ICE candidates', name: 'WebRTCService'); } for (final candidate in _iceCandidateQueue) { try { await _peerConnection!.addCandidate(candidate); } catch (e) { log('⚠️ [WebRTC] Error adding queued candidate: $e', name: 'WebRTCService'); } } _iceCandidateQueue.clear(); } /// Toggle microphone mute void setMicrophoneMuted(bool muted) { if (_localStream != null) { final audioTracks = _localStream!.getAudioTracks(); for (final track in audioTracks) { track.enabled = !muted; } } } /// Toggle camera on/off for video calls void setCameraEnabled(bool enabled) { if (_localStream != null) { final videoTracks = _localStream!.getVideoTracks(); for (final track in videoTracks) { track.enabled = enabled; } } } /// Switch between front and rear camera Future switchCamera() async { if (_localStream != null) { final videoTracks = _localStream!.getVideoTracks(); if (videoTracks.isNotEmpty) { try { await Helper.switchCamera(videoTracks.first); if (kDebugMode) { log('✅ [WebRTC] Camera switched', name: 'WebRTCService'); } } catch (e) { log('❌ [WebRTC] Error switching camera: $e', name: 'WebRTCService'); } } } } /// Enable/disable speakerphone Future setSpeakerphoneEnabled(bool enabled) async { try { await Helper.setSpeakerphoneOn(enabled); } catch (e) { log('❌ [WebRTC] Error setting speakerphone: $e', name: 'WebRTCService'); } } /// Get local stream MediaStream? get localStream => _localStream; /// Get remote stream MediaStream? get remoteStream => _remoteStream; /// Check if remote stream has video tracks bool get hasRemoteVideo { if (_remoteStream == null) return false; return _remoteStream!.getVideoTracks().isNotEmpty; } /// Force refresh remote renderer void refreshRemoteRenderer() { if (_remoteStream != null && remoteRenderer != null && _isVideoCall) { if (remoteRenderer!.srcObject == null) { remoteRenderer!.srcObject = _remoteStream; if (kDebugMode) { log('✅ [WebRTC] Remote renderer refreshed', name: 'WebRTCService'); } } } } /// Restart ICE negotiation (used when connection fails) Future restartIce() async { try { if (_peerConnection == null) { return null; } final offer = await _peerConnection!.createOffer({ 'offerToReceiveAudio': true, 'offerToReceiveVideo': _isVideoCall, 'iceRestart': true, }); await _peerConnection!.setLocalDescription(offer); if (kDebugMode) { log('✅ [WebRTC] ICE restart initiated', name: 'WebRTCService'); } return offer; } catch (e) { log('❌ [WebRTC] ICE restart failed: $e', name: 'WebRTCService'); return null; } } /// Get peer connection state RTCPeerConnectionState? getPeerConnectionState() { return _peerConnection?.connectionState; } /// Get ICE connection state RTCIceConnectionState? getIceConnectionState() { return _peerConnection?.iceConnectionState; } /// Check if peer connection is initialized bool get isPeerConnectionInitialized => _peerConnection != null; /// Dispose and cleanup Future dispose() async { try { // Stop local stream tracks if (_localStream != null) { _localStream!.getTracks().forEach((track) { track.stop(); }); await _localStream!.dispose(); _localStream = null; } // Stop remote stream tracks if (_remoteStream != null) { _remoteStream!.getTracks().forEach((track) { track.stop(); }); await _remoteStream!.dispose(); _remoteStream = null; } // Dispose renderers if (localRenderer != null) { await localRenderer!.dispose(); localRenderer = null; } if (remoteRenderer != null) { await remoteRenderer!.dispose(); remoteRenderer = null; } // Close peer connection if (_peerConnection != null) { await _peerConnection!.close(); await _peerConnection!.dispose(); _peerConnection = null; } // Clear ICE candidate queue _iceCandidateQueue.clear(); _remoteDescriptionSet = false; if (kDebugMode) { log('✅ [WebRTC] Service disposed', name: 'WebRTCService'); } } catch (e) { log('⚠️ [WebRTC] Error during dispose: $e', name: 'WebRTCService'); } } }