You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cloudsolutions-atoms/lib/modules/cx_module/chat/call/services/webrtc_service.dart

614 lines
17 KiB
Dart

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<RTCIceCandidate> _iceCandidateQueue = [];
bool _remoteDescriptionSet = false;
// CRITICAL FIX: Atomic initialization tracking
Completer<void>? _initializationCompleter;
bool _isFullyInitialized = 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;
/// Check if WebRTC is fully initialized
bool get isFullyInitialized => _isFullyInitialized;
/// Check if peer connection is ready
bool get isPeerConnectionInitialized => _peerConnection != null && _isFullyInitialized;
/// Wait for WebRTC initialization to complete
Future<void> waitForInitialization() async {
if (_isFullyInitialized) {
return;
}
if (_initializationCompleter != null) {
await _initializationCompleter!.future;
}
}
/// Enable test mode (use only Google STUN servers for debugging)
static void enableTestMode() {
_useTestMode = true;
}
/// Disable test mode (use backend TURN servers)
static void disableTestMode() {
_useTestMode = false;
}
// Configuration
static const Map<String, dynamic> _mediaConstraints = {
'audio': true,
'video': false,
};
// Video constraints for video calls
static const Map<String, dynamic> _videoConstraints = {
'audio': true,
'video': {
'facingMode': 'user',
'width': {'ideal': 1280},
'height': {'ideal': 720},
},
};
// ICE servers configuration for TEST MODE (Google STUN only)
static const Map<String, dynamic> _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<String, dynamic> _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<void> initializeForAudioCall() async {
_initializationCompleter = Completer<void>();
_isFullyInitialized = false;
try {
_isVideoCall = false;
try {
_localStream = await navigator.mediaDevices.getUserMedia(_mediaConstraints);
final audioTracks = _localStream!.getAudioTracks();
for (var i = 0; i < audioTracks.length; i++) {
final track = audioTracks[i];
if (!track.enabled) {
track.enabled = true;
}
}
if (audioTracks.isEmpty) {
throw Exception('No audio tracks captured from microphone');
}
} catch (e) {
throw Exception('Microphone access denied or unavailable');
}
_peerConnection = await createPeerConnection(_configuration);
if (_peerConnection == null) {
throw Exception('Failed to create peer connection');
}
final tracks = _localStream!.getTracks();
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
await _peerConnection!.addTrack(track, _localStream!);
}
_setupPeerConnectionListeners();
try {
await Helper.setSpeakerphoneOn(false);
} catch (e) {
// Ignore speakerphone errors
}
_isFullyInitialized = true;
if (!_initializationCompleter!.isCompleted) {
_initializationCompleter!.complete();
}
} catch (e, stackTrace) {
log('❌ [WebRTC] Initialization error: $e\nStack: $stackTrace', name: 'WebRTCService');
if (_initializationCompleter != null && !_initializationCompleter!.isCompleted) {
_initializationCompleter!.completeError(e, stackTrace);
}
_isFullyInitialized = false;
await dispose();
rethrow;
}
}
/// Initialize WebRTC for video call
/// Initialize WebRTC for video call
Future<void> initializeForVideoCall() async {
_initializationCompleter = Completer<void>();
_isFullyInitialized = false;
try {
_isVideoCall = true;
// Initialize video renderers
localRenderer = RTCVideoRenderer();
remoteRenderer = RTCVideoRenderer();
await localRenderer!.initialize();
await remoteRenderer!.initialize();
// Get camera + microphone stream first
try {
_localStream = await navigator.mediaDevices.getUserMedia(_videoConstraints);
} catch (e) {
log(
'❌ [WebRTC] Failed to get video stream: $e',
name: 'WebRTCService',
);
throw Exception('Camera or microphone access denied or unavailable');
}
if (_localStream == null) {
throw Exception('Local video stream is null');
}
// Attach local stream to renderer
localRenderer!.srcObject = _localStream;
// Create peer connection
final config = _useTestMode ? _testConfiguration : _configuration;
_peerConnection = await createPeerConnection(config);
if (_peerConnection == null) {
throw Exception('Failed to create peer connection');
}
// Setup listeners
_setupPeerConnectionListeners();
// Add local tracks
final tracks = _localStream!.getTracks();
if (tracks.isEmpty) {
throw Exception('No media tracks found');
}
for (final track in tracks) {
await _peerConnection!.addTrack(
track,
_localStream!,
);
}
_isFullyInitialized = true;
if (!_initializationCompleter!.isCompleted) {
_initializationCompleter!.complete();
}
log(
'✅ [WebRTC] Video initialization completed',
name: 'WebRTCService',
);
} catch (e, stackTrace) {
log(
'❌ [WebRTC] Video initialization error: $e\nStack: $stackTrace',
name: 'WebRTCService',
error: e,
stackTrace: stackTrace,
);
if (_initializationCompleter != null &&
!_initializationCompleter!.isCompleted) {
_initializationCompleter!.completeError(
e,
stackTrace,
);
}
_isFullyInitialized = false;
await dispose();
rethrow;
}
}
/// Setup peer connection event listeners
void _setupPeerConnectionListeners() {
_peerConnection!.onIceCandidate = (RTCIceCandidate candidate) {
onIceCandidate?.call(candidate);
};
_peerConnection!.onIceGatheringState = (RTCIceGatheringState state) {
// Ice gathering state changed
};
_peerConnection!.onIceConnectionState = (RTCIceConnectionState state) {
onIceConnectionStateChange?.call(state);
};
_peerConnection!.onTrack = (RTCTrackEvent event) {
if (event.streams.isNotEmpty) {
final stream = event.streams[0];
if (_remoteStream == null || _remoteStream!.id != stream.id) {
_remoteStream = stream;
if (remoteRenderer != null && _isVideoCall) {
try {
remoteRenderer!.srcObject = _remoteStream;
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) {
// Ignore errors
}
}
onRemoteStream?.call(_remoteStream!);
} else {
if (remoteRenderer != null && _isVideoCall && remoteRenderer!.srcObject == null && _remoteStream != null) {
try {
remoteRenderer!.srcObject = _remoteStream;
} catch (e) {
// Ignore errors
}
}
onRemoteStream?.call(_remoteStream!);
}
}
};
_peerConnection!.onConnectionState = (RTCPeerConnectionState state) {
if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed) {
log('🔌 [WebRTC] Connection state: ${state.toString()}', name: 'WebRTCService');
}
};
}
/// Create SDP offer (caller side)
Future<RTCSessionDescription> 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);
return offer;
} catch (e, stackTrace) {
log('❌ [WebRTC] Error creating offer: $e\nStack: $stackTrace', name: 'WebRTCService');
rethrow;
}
}
/// Create SDP answer (callee side)
Future<RTCSessionDescription> createAnswer(String offerSdp) async {
try {
if (_peerConnection == null) {
throw Exception('Peer connection not initialized');
}
final offer = RTCSessionDescription(offerSdp, 'offer');
await _peerConnection!.setRemoteDescription(offer);
_remoteDescriptionSet = true;
await _flushIceCandidateQueue();
final answer = await _peerConnection!.createAnswer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
});
await _peerConnection!.setLocalDescription(answer);
return answer;
} catch (e, stackTrace) {
log('❌ [WebRTC] Error creating answer: $e\nStack: $stackTrace', name: 'WebRTCService');
rethrow;
}
}
/// Set remote answer (caller side)
Future<void> 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;
await _flushIceCandidateQueue();
} catch (e, stackTrace) {
log('❌ [WebRTC] Error setting remote answer: $e\nStack: $stackTrace', name: 'WebRTCService');
rethrow;
}
}
/// Send SDP answer (convenience method for callee)
Future<RTCSessionDescription> sendSDPAnswer(String? offerSdp) async {
if (offerSdp == null) {
throw Exception('Offer SDP is null');
}
return await createAnswer(offerSdp);
}
/// Add remote ICE candidate
Future<void> 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<void> _flushIceCandidateQueue() async {
if (_iceCandidateQueue.isEmpty) {
return;
}
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<void> switchCamera() async {
if (_localStream != null) {
final videoTracks = _localStream!.getVideoTracks();
if (videoTracks.isNotEmpty) {
try {
await Helper.switchCamera(videoTracks.first);
} catch (e) {
log('❌ [WebRTC] Error switching camera: $e', name: 'WebRTCService');
}
}
}
}
/// Enable/disable speakerphone
Future<void> 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;
}
}
}
/// Restart ICE negotiation (used when connection fails)
Future<RTCSessionDescription?> restartIce() async {
try {
if (_peerConnection == null) {
return null;
}
final offer = await _peerConnection!.createOffer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
'iceRestart': true,
});
await _peerConnection!.setLocalDescription(offer);
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;
}
/// Dispose and cleanup
Future<void> dispose() async {
try {
if (_localStream != null) {
_localStream!.getTracks().forEach((track) {
track.stop();
});
await _localStream!.dispose();
_localStream = null;
}
if (_remoteStream != null) {
_remoteStream!.getTracks().forEach((track) {
track.stop();
});
await _remoteStream!.dispose();
_remoteStream = null;
}
if (localRenderer != null) {
await localRenderer!.dispose();
localRenderer = null;
}
if (remoteRenderer != null) {
await remoteRenderer!.dispose();
remoteRenderer = null;
}
if (_peerConnection != null) {
await _peerConnection!.close();
await _peerConnection!.dispose();
_peerConnection = null;
}
_iceCandidateQueue.clear();
_remoteDescriptionSet = false;
} catch (e) {
log('⚠️ [WebRTC] Error during dispose: $e', name: 'WebRTCService');
}
}
}