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.
618 lines
18 KiB
Dart
618 lines
18 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;
|
|
|
|
// 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 {
|
|
_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<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) {
|
|
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<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);
|
|
|
|
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<RTCSessionDescription> 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<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');
|
|
}
|
|
}
|
|
}
|