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

784 lines
26 KiB
Dart

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;
// 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<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 {
try {
if (kDebugMode) {
log('🔧 [WebRTC] Initializing audio call', name: 'WebRTCService');
}
_isVideoCall = false;
// Get local audio stream
try {
log('🎤 [WebRTC] Requesting microphone access...', name: 'WebRTCService');
_localStream = await navigator.mediaDevices.getUserMedia(_mediaConstraints);
// CRITICAL: Verify audio tracks were captured
final audioTracks = _localStream!.getAudioTracks();
log('✅ [WebRTC] Local audio stream captured', name: 'WebRTCService');
log(' Audio tracks count: ${audioTracks.length}', name: 'WebRTCService');
for (var i = 0; i < audioTracks.length; i++) {
final track = audioTracks[i];
log(' Track $i: ${track.kind} - ID: ${track.id}', name: 'WebRTCService');
log(' Track $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
// CRITICAL FIX: Ensure track is enabled
if (!track.enabled) {
log('⚠️ [WebRTC] Track $i was disabled, enabling it', name: 'WebRTCService');
track.enabled = true;
}
}
if (audioTracks.isEmpty) {
throw Exception('No audio tracks captured from microphone');
}
} catch (e) {
log('❌ [WebRTC] Failed to get audio stream: $e', name: 'WebRTCService', error: e);
throw Exception('Microphone access denied or unavailable');
}
// Create peer connection
log('🔧 [WebRTC] Creating peer connection...', name: 'WebRTCService');
_peerConnection = await createPeerConnection(_configuration);
if (_peerConnection == null) {
throw Exception('Failed to create peer connection');
}
log('✅ [WebRTC] Peer connection created', name: 'WebRTCService');
// Add local stream tracks to peer connection
log('📤 [WebRTC] Adding local tracks to peer connection...', name: 'WebRTCService');
final tracks = _localStream!.getTracks();
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
log(' Adding track $i: ${track.kind} (ID: ${track.id})', name: 'WebRTCService');
await _peerConnection!.addTrack(track, _localStream!);
log(' ✅ Track $i added successfully', name: 'WebRTCService');
}
log('✅ [WebRTC] All local tracks added to peer connection', name: 'WebRTCService');
// Setup peer connection event handlers
_setupPeerConnectionListeners();
// CRITICAL FIX: Keep speakerphone OFF by default for audio calls
// User can manually enable it if needed via the speaker button
log('🔇 [WebRTC] Keeping speakerphone OFF by default for audio call...', name: 'WebRTCService');
try {
await Helper.setSpeakerphoneOn(false);
log('✅ [WebRTC] Speakerphone disabled (earpiece mode)', name: 'WebRTCService');
} catch (e) {
log('⚠️ [WebRTC] Failed to disable speakerphone: $e', name: 'WebRTCService');
// Continue anyway - not critical
}
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<void> 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) {
log('═══════════════════════════════════════════', name: 'WebRTCService');
log('📡 [WebRTC] onTrack event received!', name: 'WebRTCService');
log(' Track kind: ${event.track?.kind}', name: 'WebRTCService');
log(' Track ID: ${event.track?.id}', name: 'WebRTCService');
log(' Track enabled: ${event.track?.enabled}', name: 'WebRTCService');
log(' Track muted: ${event.track?.muted}', name: 'WebRTCService');
// log(' Track readyState: ${event.track?.readyState}', name: 'WebRTCService');
log(' Streams count: ${event.streams.length}', name: 'WebRTCService');
if (event.streams.isNotEmpty) {
final stream = event.streams[0];
log(' Stream ID: ${stream.id}', name: 'WebRTCService');
log(' Audio tracks: ${stream.getAudioTracks().length}', name: 'WebRTCService');
log(' Video tracks: ${stream.getVideoTracks().length}', name: 'WebRTCService');
// Log all tracks in the stream
final allTracks = stream.getTracks();
for (var i = 0; i < allTracks.length; i++) {
final track = allTracks[i];
log(' Track $i: ${track.kind} - enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
}
// If this is the first remote stream or a different stream, update it
if (_remoteStream == null || _remoteStream!.id != stream.id) {
log('✅ [WebRTC] Setting new remote stream', name: 'WebRTCService');
_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!);
}
log('✅ [WebRTC] Remote stream set and callback notified', name: 'WebRTCService');
} else {
log(' [WebRTC] Additional track on existing stream', name: 'WebRTCService');
// 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!);
}
}
} else {
log('⚠️ [WebRTC] No streams in onTrack event!', name: 'WebRTCService');
}
log('═══════════════════════════════════════════', name: 'WebRTCService');
};
// 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<RTCSessionDescription> createOffer() async {
try {
if (_peerConnection == null) {
throw Exception('Peer connection not initialized');
}
log('🔧 [WebRTC] Creating SDP offer...', name: 'WebRTCService');
// CRITICAL: Verify local tracks before creating offer
final senders = await _peerConnection!.getSenders();
log('📊 [WebRTC] Peer connection has ${senders.length} sender(s)', name: 'WebRTCService');
for (var i = 0; i < senders.length; i++) {
final sender = senders[i];
final track = sender.track;
if (track != null) {
log(' Sender $i: ${track.kind} track (ID: ${track.id})', name: 'WebRTCService');
log(' Sender $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
} else {
log(' Sender $i: NO TRACK!', name: 'WebRTCService');
}
}
final offer = await _peerConnection!.createOffer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
'iceRestart': false,
});
await _peerConnection!.setLocalDescription(offer);
if (kDebugMode) {
log('✅ [WebRTC] SDP offer created', name: 'WebRTCService');
log(' Offer type: ${offer.type}', name: 'WebRTCService');
log(' Offer SDP length: ${offer.sdp?.length ?? 0}', name: 'WebRTCService');
// CRITICAL: Log SDP to verify audio track is included
if (offer.sdp != null) {
final sdpLines = offer.sdp!.split('\n');
final audioLines = sdpLines.where((line) =>
line.contains('m=audio') ||
line.contains('a=sendrecv') ||
line.contains('a=sendonly') ||
line.contains('a=recvonly')
).toList();
log('📋 [WebRTC] SDP Audio Configuration:', name: 'WebRTCService');
for (final line in audioLines) {
log(' $line', name: 'WebRTCService');
}
if (!offer.sdp!.contains('m=audio')) {
log('❌ [WebRTC] WARNING: No audio media line in SDP offer!', 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<RTCSessionDescription> createAnswer(String offerSdp) async {
try {
if (_peerConnection == null) {
throw Exception('Peer connection not initialized');
}
log('🔧 [WebRTC] Creating SDP answer...', name: 'WebRTCService');
log(' Received offer SDP length: ${offerSdp.length}', name: 'WebRTCService');
// CRITICAL: Log received offer to verify it has audio
if (kDebugMode) {
final offerLines = offerSdp.split('\n');
final audioLines = offerLines.where((line) =>
line.contains('m=audio') ||
line.contains('a=sendrecv') ||
line.contains('a=sendonly') ||
line.contains('a=recvonly')
).toList();
log('📋 [WebRTC] Received Offer Audio Configuration:', name: 'WebRTCService');
for (final line in audioLines) {
log(' $line', name: 'WebRTCService');
}
if (!offerSdp.contains('m=audio')) {
log('❌ [WebRTC] WARNING: No audio media line in received offer!', name: 'WebRTCService');
}
}
// CRITICAL: Verify local tracks before creating answer
final senders = await _peerConnection!.getSenders();
log('📊 [WebRTC] Peer connection has ${senders.length} sender(s)', name: 'WebRTCService');
for (var i = 0; i < senders.length; i++) {
final sender = senders[i];
final track = sender.track;
if (track != null) {
log(' Sender $i: ${track.kind} track (ID: ${track.id})', name: 'WebRTCService');
log(' Sender $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
} else {
log(' Sender $i: NO TRACK!', name: 'WebRTCService');
}
}
// Set remote description (offer from caller)
final offer = RTCSessionDescription(offerSdp, 'offer');
log('🔧 [WebRTC] Setting remote description (offer)...', name: 'WebRTCService');
await _peerConnection!.setRemoteDescription(offer);
_remoteDescriptionSet = true;
log('✅ [WebRTC] Remote description set', name: 'WebRTCService');
// Flush queued ICE candidates
await _flushIceCandidateQueue();
// Create answer
log('🔧 [WebRTC] Creating answer...', name: 'WebRTCService');
final answer = await _peerConnection!.createAnswer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
});
await _peerConnection!.setLocalDescription(answer);
if (kDebugMode) {
log('✅ [WebRTC] SDP answer created', name: 'WebRTCService');
log(' Answer type: ${answer.type}', name: 'WebRTCService');
log(' Answer SDP length: ${answer.sdp?.length ?? 0}', name: 'WebRTCService');
// CRITICAL: Log SDP to verify audio track is included
if (answer.sdp != null) {
final sdpLines = answer.sdp!.split('\n');
final audioLines = sdpLines.where((line) =>
line.contains('m=audio') ||
line.contains('a=sendrecv') ||
line.contains('a=sendonly') ||
line.contains('a=recvonly')
).toList();
log('📋 [WebRTC] SDP Answer Audio Configuration:', name: 'WebRTCService');
for (final line in audioLines) {
log(' $line', name: 'WebRTCService');
}
if (!answer.sdp!.contains('m=audio')) {
log('❌ [WebRTC] WARNING: No audio media line in SDP answer!', 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<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;
// 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<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;
}
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<void> 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<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;
if (kDebugMode) {
log('✅ [WebRTC] Remote renderer refreshed', name: 'WebRTCService');
}
}
}
}
/// 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);
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<void> 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');
}
}
}