750 lines
27 KiB
Dart
750 lines
27 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
|
import 'package:get/get.dart' hide Response;
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
|
|
import '../../constant/box_name.dart';
|
|
import '../../constant/links.dart';
|
|
import '../../main.dart';
|
|
import '../../print.dart';
|
|
import '../../services/signaling_service.dart';
|
|
import '../../views/widgets/voice_call_bottom_sheet.dart';
|
|
import 'functions/crud.dart';
|
|
|
|
// EN: Enum representing the different states of a voice call.
|
|
// AR: تعداد يمثل الحالات المختلفة للمكالمة الصوتية.
|
|
enum VoiceCallState { idle, dialing, ringing, connecting, active, ended }
|
|
|
|
class VoiceCallController extends GetxController with WidgetsBindingObserver {
|
|
// EN: Instance of the signaling service to manage WebSocket communication.
|
|
// AR: مثيل لخدمة الإشارات لإدارة الاتصال عبر الـ WebSocket.
|
|
final SignalingService _signaling = SignalingService();
|
|
|
|
// --- Observable Variables (GetX) / المتغيرات التفاعلية ---
|
|
|
|
// EN: Current state of the call.
|
|
// AR: الحالة الحالية للمكالمة.
|
|
var state = VoiceCallState.idle.obs;
|
|
|
|
// EN: Unique identifier for the WebRTC session.
|
|
// AR: المعرف الفريد لجلسة الاتصال.
|
|
var sessionId = "".obs;
|
|
|
|
// EN: ID of the current active ride.
|
|
// AR: معرف الرحلة النشطة الحالية.
|
|
var rideId = "".obs;
|
|
|
|
// EN: Name of the other party (Driver/Passenger).
|
|
// AR: اسم الطرف الآخر في المكالمة (سائق/راكب).
|
|
var remoteName = "User".obs;
|
|
|
|
// EN: Microphone mute status.
|
|
// AR: حالة كتم الميكروفون.
|
|
var isMuted = false.obs;
|
|
|
|
// EN: Speakerphone status.
|
|
// AR: حالة مكبر الصوت الخارجي.
|
|
var isSpeakerOn = false.obs;
|
|
|
|
// EN: Timer countdown variable, starts from 60 seconds.
|
|
// AR: متغير العد التنازلي للمؤقت، يبدأ من 60 ثانية.
|
|
var elapsedSeconds = 60.obs;
|
|
|
|
// EN: Error message to display in UI when call setup fails.
|
|
// AR: رسالة الخطأ لعرضها في الواجهة عندما يفشل إعداد المكالمة.
|
|
var errorMessage = "".obs;
|
|
|
|
// --- Core State Variables / متغيرات الحالة الأساسية ---
|
|
|
|
// EN: Flag to determine if the current user initiated the call.
|
|
// AR: مؤشر لتحديد ما إذا كان المستخدم الحالي هو من بدأ المكالمة.
|
|
bool isCaller = false;
|
|
|
|
// EN: ID of the current user.
|
|
// AR: معرف المستخدم الحالي.
|
|
String currentUserId = "";
|
|
|
|
// --- WebRTC Internal Variables / متغيرات WebRTC الداخلية ---
|
|
|
|
// EN: The main connection object between peers.
|
|
// AR: كائن الاتصال الرئيسي بين الطرفين.
|
|
rtc.RTCPeerConnection? _peerConnection;
|
|
|
|
// EN: The local audio stream captured from the microphone.
|
|
// AR: دفق الصوت المحلي الملتقط من الميكروفون.
|
|
rtc.MediaStream? _localStream;
|
|
|
|
// EN: Timer to enforce the 60-second call limit.
|
|
// AR: مؤقت لفرض حد الـ 60 ثانية للمكالمة.
|
|
Timer? _countdownTimer;
|
|
|
|
// EN: Timer to hang up if the call is not answered within 30 seconds.
|
|
// AR: مؤقت لإنهاء المكالمة إذا لم يتم الرد خلال 30 ثانية.
|
|
Timer? _ringingTimeoutTimer;
|
|
|
|
// EN: Flag to indicate if the peer connection is currently attempting ICE reconnection.
|
|
// AR: مؤشر يوضح ما إذا كان الاتصال يحاول إعادة بناء مسارات الشبكة حالياً.
|
|
bool _isReconnecting = false;
|
|
Timer? _reconnectTimer;
|
|
List<dynamic> _dynamicIceServers = [];
|
|
|
|
AudioPlayer? _ringtonePlayer;
|
|
|
|
void _startRingtone() async {
|
|
try {
|
|
_ringtonePlayer ??= AudioPlayer();
|
|
await _ringtonePlayer!.setAsset('assets/order.mp3');
|
|
await _ringtonePlayer!.setLoopMode(LoopMode.one);
|
|
_ringtonePlayer!.play();
|
|
} catch (e) {
|
|
Log.print("Error playing ringtone: $e");
|
|
}
|
|
}
|
|
|
|
void _stopRingtone() {
|
|
try {
|
|
_ringtonePlayer?.stop();
|
|
} catch (e) {
|
|
Log.print("Error stopping ringtone: $e");
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
// EN: Add lifecycle observer.
|
|
// AR: إضافة مراقب لدورة حياة التطبيق.
|
|
WidgetsBinding.instance.addObserver(this);
|
|
|
|
// EN: Initialize WebSocket signaling listeners.
|
|
// AR: تهيئة مستمعي إشارات الـ WebSocket.
|
|
_initSignalingCallbacks();
|
|
}
|
|
|
|
// EN: Lifecycle hook: handle app switching background/foreground.
|
|
// AR: معالجة انتقال التطبيق إلى الخلفية أو العودة للواجهة.
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
Log.print("VoiceCall: didChangeAppLifecycleState -> $state");
|
|
if (state == AppLifecycleState.paused) {
|
|
Log.print(
|
|
"WARNING: App is in background. Microphone access might be suspended by the OS.");
|
|
} else if (state == AppLifecycleState.resumed) {
|
|
Log.print("App resumed. Verifying WebRTC connection health.");
|
|
if (this.state.value == VoiceCallState.active) {
|
|
_ensureMicrophoneActive();
|
|
_attemptIceRestart();
|
|
}
|
|
}
|
|
}
|
|
|
|
// EN: Registers all event listeners for the signaling server.
|
|
// AR: تسجيل جميع مستمعي الأحداث لخادم الإشارات.
|
|
void _initSignalingCallbacks() {
|
|
// EN: Triggered when successfully connected to the signaling server.
|
|
// AR: يُستدعى عند الاتصال بنجاح بخادم الإشارات.
|
|
_signaling.onConnected = (iceServers) {
|
|
Log.print("WebRTC Signaling Connected & Authenticated");
|
|
_dynamicIceServers = iceServers;
|
|
};
|
|
|
|
// EN: Triggered when the WebSocket connection drops.
|
|
// AR: يُستدعى عند انقطاع اتصال الـ WebSocket.
|
|
_signaling.onDisconnected = (reason) {
|
|
Log.print("WebRTC Signaling Disconnected: $reason");
|
|
if (state.value != VoiceCallState.idle) {
|
|
_endCallInternal("signaling_disconnected");
|
|
}
|
|
};
|
|
|
|
// EN: Triggered when the remote user joins the room.
|
|
// AR: يُستدعى عند انضمام الطرف الآخر إلى غرفة الاتصال.
|
|
_signaling.onParticipantJoined = () async {
|
|
Log.print("Remote participant joined signaling session");
|
|
// EN: If we are the caller, initiate the WebRTC handshake by creating an Offer.
|
|
// AR: إذا كنا نحن المتصل، نبدأ مصافحة WebRTC بإنشاء عرض (Offer).
|
|
if (isCaller && state.value == VoiceCallState.dialing) {
|
|
state.value = VoiceCallState.connecting;
|
|
await _createOffer();
|
|
}
|
|
};
|
|
|
|
// EN: Triggered when an SDP Offer is received from the remote peer.
|
|
// AR: يُستدعى عند استلام عرض اتصال (Offer) من الطرف الآخر.
|
|
_signaling.onOffer = (sdpMap) async {
|
|
Log.print("Received WebRTC SDP Offer");
|
|
if (!isCaller) {
|
|
state.value = VoiceCallState.connecting;
|
|
await _initializePeerConnection();
|
|
|
|
// EN: Set the remote peer's settings.
|
|
// AR: تعيين إعدادات الطرف الآخر.
|
|
final description =
|
|
rtc.RTCSessionDescription(sdpMap['sdp'], sdpMap['type']);
|
|
await _peerConnection!.setRemoteDescription(description);
|
|
|
|
// EN: Respond with an Answer.
|
|
// AR: الرد بإجابة (Answer).
|
|
await _createAnswer();
|
|
}
|
|
};
|
|
|
|
// EN: Triggered when an SDP Answer is received.
|
|
// AR: يُستدعى عند استلام إجابة (Answer) من الطرف الآخر.
|
|
_signaling.onAnswer = (sdpMap) async {
|
|
Log.print("Received WebRTC SDP Answer");
|
|
if (isCaller && _peerConnection != null) {
|
|
final description =
|
|
rtc.RTCSessionDescription(sdpMap['sdp'], sdpMap['type']);
|
|
await _peerConnection!.setRemoteDescription(description);
|
|
}
|
|
};
|
|
|
|
// EN: Triggered when ICE candidates (Network routing info) are exchanged.
|
|
// AR: يُستدعى عند تبادل مسارات الشبكة (ICE Candidates) لتأسيس الاتصال.
|
|
_signaling.onIceCandidate = (candidateMap) async {
|
|
Log.print("Received Remote ICE Candidate");
|
|
if (_peerConnection != null) {
|
|
final candidate = rtc.RTCIceCandidate(
|
|
candidateMap['candidate'],
|
|
candidateMap['sdpMid'],
|
|
candidateMap['sdpMLineIndex'],
|
|
);
|
|
await _peerConnection!.addCandidate(candidate);
|
|
}
|
|
};
|
|
|
|
// EN: Triggered when a hangup event is received from the server.
|
|
// AR: يُستدعى عند استلام حدث إنهاء المكالمة من السيرفر.
|
|
_signaling.onCallEnded = (reason) {
|
|
Log.print("WebRTC Call Ended: $reason");
|
|
_endCallInternal(reason);
|
|
};
|
|
}
|
|
|
|
// --- CALL LIFECYCLE / دورة حياة المكالمة ---
|
|
|
|
// EN: Initiates an outgoing call.
|
|
// AR: يبدأ مكالمة صادرة.
|
|
Future<void> startCall({
|
|
required String rideIdVal,
|
|
required String driverId,
|
|
required String passengerId,
|
|
required String remoteNameVal,
|
|
}) async {
|
|
if (state.value != VoiceCallState.idle) return;
|
|
|
|
// EN: Setup call variables.
|
|
// AR: إعداد متغيرات المكالمة.
|
|
state.value = VoiceCallState.dialing;
|
|
isCaller = true;
|
|
currentUserId = driverId;
|
|
rideId.value = rideIdVal;
|
|
remoteName.value = remoteNameVal;
|
|
isMuted.value = false;
|
|
isSpeakerOn.value = false;
|
|
elapsedSeconds.value = 60;
|
|
_isReconnecting = false;
|
|
errorMessage.value = "";
|
|
|
|
_showCallBottomSheet();
|
|
HapticFeedback.vibrate();
|
|
|
|
try {
|
|
// 1. EN: Request Microphone Permission / AR: طلب صلاحية الميكروفون
|
|
if (!GetPlatform.isIOS) {
|
|
final permissionStatus = await Permission.microphone.request();
|
|
if (!permissionStatus.isGranted) {
|
|
errorMessage.value =
|
|
"Microphone permission is required for voice calls".tr;
|
|
_endCallInternal("permission_denied");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 2. EN: Call PHP Backend to create Node.js session & notify Passenger via FCM.
|
|
// AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار الراكب عبر FCM.
|
|
final response = await CRUD().post(
|
|
link: "${AppLink.server}/ride/call/driver/create_call_session.php",
|
|
payload: {'ride_id': rideIdVal},
|
|
);
|
|
|
|
if (response == null ||
|
|
response == 'failure' ||
|
|
response['status'] != 'success') {
|
|
errorMessage.value =
|
|
"Failed to initiate call session. Please try again.".tr;
|
|
_endCallInternal("session_creation_failed");
|
|
return;
|
|
}
|
|
|
|
final data = response['data'];
|
|
sessionId.value = data['session_id'];
|
|
|
|
// 3. EN: Connect to WebRTC signaling server / AR: الاتصال بخادم الإشارات
|
|
await _signaling.connect(sessionId.value, currentUserId);
|
|
|
|
// 4. EN: Initialize Local WebRTC Audio Stream / AR: تهيئة دفق الصوت المحلي
|
|
await _initializeLocalStream();
|
|
|
|
// 5. EN: Start Ringing Timeout Timer (30s max wait for passenger to answer).
|
|
// AR: بدء مؤقت الرنين (أقصى انتظار 30 ثانية لرد الراكب).
|
|
_ringingTimeoutTimer = Timer(const Duration(seconds: 30), () {
|
|
if (state.value == VoiceCallState.dialing) {
|
|
_signaling.send("hangup", {"reason": "no_answer"});
|
|
_endCallInternal("no_answer");
|
|
}
|
|
});
|
|
} catch (e) {
|
|
Log.print("Error starting WebRTC call: $e");
|
|
final errStr = e.toString().toLowerCase();
|
|
if (errStr.contains("permission") || errStr.contains("denied")) {
|
|
errorMessage.value =
|
|
"Microphone permission is required for voice calls".tr;
|
|
} else {
|
|
errorMessage.value = "Error starting voice call".tr;
|
|
}
|
|
_endCallInternal("error");
|
|
}
|
|
}
|
|
|
|
// EN: Handles incoming call requests via FCM/Socket.
|
|
// AR: معالجة طلبات المكالمات الواردة.
|
|
Future<void> receiveCall({
|
|
required String sessionIdVal,
|
|
required String remoteNameVal,
|
|
required String rideIdVal,
|
|
}) async {
|
|
// EN: If already in a call, send busy signal.
|
|
// AR: إذا كان في مكالمة بالفعل، إرسال إشارة مشغول.
|
|
if (state.value != VoiceCallState.idle) {
|
|
_signaling.send("hangup", {"reason": "busy"});
|
|
return;
|
|
}
|
|
|
|
state.value = VoiceCallState.ringing;
|
|
isCaller = false;
|
|
currentUserId = box.read(BoxName.driverID).toString();
|
|
sessionId.value = sessionIdVal;
|
|
rideId.value = rideIdVal;
|
|
remoteName.value = remoteNameVal;
|
|
isMuted.value = false;
|
|
isSpeakerOn.value = false;
|
|
elapsedSeconds.value = 60;
|
|
_isReconnecting = false;
|
|
errorMessage.value = "";
|
|
|
|
_showCallBottomSheet();
|
|
_startRingtone();
|
|
HapticFeedback.vibrate();
|
|
|
|
// EN: Max 30s ringing timeout for receiver before auto-decline.
|
|
// AR: أقصى مدة للرنين 30 ثانية قبل الرفض التلقائي.
|
|
_ringingTimeoutTimer = Timer(const Duration(seconds: 30), () {
|
|
if (state.value == VoiceCallState.ringing) {
|
|
declineCall();
|
|
}
|
|
});
|
|
}
|
|
|
|
// EN: Accepts the incoming call.
|
|
// AR: قبول المكالمة الواردة.
|
|
Future<void> acceptCall() async {
|
|
if (state.value != VoiceCallState.ringing) return;
|
|
|
|
_ringingTimeoutTimer?.cancel();
|
|
_stopRingtone();
|
|
state.value = VoiceCallState.connecting;
|
|
errorMessage.value = "";
|
|
|
|
try {
|
|
// EN: Check Mic permissions / AR: التحقق من صلاحيات الميكروفون
|
|
if (!GetPlatform.isIOS) {
|
|
final permissionStatus = await Permission.microphone.request();
|
|
if (!permissionStatus.isGranted) {
|
|
errorMessage.value =
|
|
"Microphone permission is required for voice calls".tr;
|
|
declineCall();
|
|
return;
|
|
}
|
|
}
|
|
|
|
await _signaling.connect(sessionId.value, currentUserId);
|
|
await _initializeLocalStream();
|
|
|
|
// EN: Notify caller we accepted / AR: إشعار المتصل بأننا قبلنا المكالمة
|
|
_signaling.send("join", {});
|
|
} catch (e) {
|
|
Log.print("Error accepting call: $e");
|
|
final errStr = e.toString().toLowerCase();
|
|
if (errStr.contains("permission") || errStr.contains("denied")) {
|
|
errorMessage.value =
|
|
"Microphone permission is required for voice calls".tr;
|
|
} else {
|
|
errorMessage.value = "Error connecting call".tr;
|
|
}
|
|
declineCall();
|
|
}
|
|
}
|
|
|
|
// EN: Declines an incoming call.
|
|
// AR: رفض المكالمة الواردة.
|
|
void declineCall() {
|
|
_ringingTimeoutTimer?.cancel();
|
|
_stopRingtone();
|
|
_signaling.send("hangup", {"reason": "declined"});
|
|
_endCallInternal("declined");
|
|
}
|
|
|
|
// EN: Ends an active or dialing call.
|
|
// AR: إنهاء المكالمة النشطة أو الجاري الاتصال بها.
|
|
void hangup() {
|
|
_signaling.send("hangup", {"reason": "normal"});
|
|
_endCallInternal("hangup");
|
|
}
|
|
|
|
// --- WEBRTC CORE HELPERS / دوال WebRTC الأساسية ---
|
|
|
|
// EN: Captures the audio from the microphone with optimization constraints.
|
|
// AR: التقاط الصوت من الميكروفون مع قيود تحسين الجودة (إلغاء الصدى والضوضاء).
|
|
Future<void> _initializeLocalStream() async {
|
|
final Map<String, dynamic> mediaConstraints = {
|
|
'audio': {
|
|
'echoCancellation': true,
|
|
'noiseSuppression': true,
|
|
'autoGainControl': true,
|
|
},
|
|
'video': false, // EN: Audio only / AR: صوت فقط
|
|
};
|
|
|
|
_localStream =
|
|
await rtc.navigator.mediaDevices.getUserMedia(mediaConstraints);
|
|
rtc.Helper.setSpeakerphoneOn(isSpeakerOn.value);
|
|
}
|
|
|
|
// EN: Verifies local microphone stream health on app resume and recreates/replaces track if suspended.
|
|
// AR: التحقق من سلامة مسار الميكروفون المحلي عند استئناف التطبيق وإعادة إنشائه إذا تم تعليقه.
|
|
Future<void> _ensureMicrophoneActive() async {
|
|
if (_localStream == null || _peerConnection == null) return;
|
|
|
|
bool needsRecreation = false;
|
|
if (_localStream!.active == false) {
|
|
needsRecreation = true;
|
|
} else {
|
|
for (var track in _localStream!.getAudioTracks()) {
|
|
if (!track.enabled && !isMuted.value) {
|
|
needsRecreation = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (needsRecreation) {
|
|
Log.print(
|
|
"Local audio track ended or disabled. Recreating local stream...");
|
|
try {
|
|
_localStream?.getTracks().forEach((track) => track.stop());
|
|
_localStream?.dispose();
|
|
_localStream = null;
|
|
|
|
await _initializeLocalStream();
|
|
|
|
final senders = await _peerConnection!.getSenders();
|
|
for (var sender in senders) {
|
|
final track = sender.track;
|
|
if (track != null && track.kind == 'audio') {
|
|
final newTracks = _localStream?.getAudioTracks();
|
|
if (newTracks != null && newTracks.isNotEmpty) {
|
|
await sender.replaceTrack(newTracks.first);
|
|
Log.print(
|
|
"Replaced suspended/ended audio track with a new active one.");
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
Log.print("Error recreating local stream on resume: $e");
|
|
}
|
|
} else {
|
|
_localStream!.getAudioTracks().forEach((track) {
|
|
track.enabled = !isMuted.value;
|
|
});
|
|
}
|
|
}
|
|
|
|
// EN: Creates the peer connection object and sets up ICE servers (STUN/TURN).
|
|
// AR: إنشاء كائن الاتصال المباشر وإعداد خوادم STUN/TURN لاختراق الجدران النارية.
|
|
Future<void> _initializePeerConnection() async {
|
|
if (_peerConnection != null) return;
|
|
|
|
final List<Map<String, dynamic>> iceServers = [];
|
|
if (_dynamicIceServers.isNotEmpty) {
|
|
for (var server in _dynamicIceServers) {
|
|
if (server is Map) {
|
|
iceServers.add({
|
|
"urls": server["urls"] ?? server["url"],
|
|
if (server["username"] != null) "username": server["username"],
|
|
if (server["credential"] != null)
|
|
"credential": server["credential"],
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
// EN: Fallback STUN servers / AR: خوام STUN الاحتياطية
|
|
iceServers.addAll([
|
|
{"urls": "stun:stun.l.google.com:19302"},
|
|
{"urls": "stun:stun1.l.google.com:19302"},
|
|
]);
|
|
}
|
|
|
|
final Map<String, dynamic> configuration = {
|
|
"iceServers": iceServers,
|
|
};
|
|
|
|
_peerConnection = await rtc.createPeerConnection(configuration);
|
|
|
|
// EN: Gather local network routing info and send to remote peer.
|
|
// AR: جمع بيانات مسارات الشبكة المحلية وإرسالها للطرف الآخر.
|
|
_peerConnection!.onIceCandidate = (candidate) {
|
|
if (candidate.candidate != null) {
|
|
_signaling.send("ice_candidate", {
|
|
"candidate": {
|
|
"candidate": candidate.candidate,
|
|
"sdpMid": candidate.sdpMid,
|
|
"sdpMLineIndex": candidate.sdpMLineIndex,
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// EN: Monitor connection status changes and handle disconnections.
|
|
// AR: مراقبة تغيرات حالة الاتصال ومعالجة انقطاع الشبكة.
|
|
_peerConnection!.onConnectionState = (connState) {
|
|
Log.print("RTCPeerConnectionState: $connState");
|
|
if (connState ==
|
|
rtc.RTCPeerConnectionState.RTCPeerConnectionStateConnected) {
|
|
_onCallConnected();
|
|
} else if (connState ==
|
|
rtc.RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
|
connState ==
|
|
rtc.RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) {
|
|
_handleIceConnectionFailure();
|
|
}
|
|
};
|
|
|
|
// EN: Add local audio stream to the connection to send it to the other peer.
|
|
// AR: إضافة دفق الصوت المحلي للاتصال لإرساله للطرف الآخر.
|
|
if (_localStream != null) {
|
|
_localStream!.getTracks().forEach((track) {
|
|
_peerConnection!.addTrack(track, _localStream!);
|
|
});
|
|
}
|
|
}
|
|
|
|
// EN: Attempts an ICE restart to reconnect the WebRTC session when disconnections occur.
|
|
// AR: محاولة إعادة تأسيس الاتصال (ICE Restart) في حالة انقطاع الشبكة.
|
|
void _handleIceConnectionFailure() {
|
|
if (_isReconnecting) return;
|
|
_isReconnecting = true;
|
|
Log.print(
|
|
"ICE connection dropped. Attempting ICE Restart reconnection for 5s...");
|
|
|
|
if (isCaller) {
|
|
_attemptIceRestart();
|
|
}
|
|
|
|
_reconnectTimer?.cancel();
|
|
_reconnectTimer = Timer(const Duration(seconds: 5), () {
|
|
if (state.value == VoiceCallState.active &&
|
|
_peerConnection?.connectionState !=
|
|
rtc.RTCPeerConnectionState.RTCPeerConnectionStateConnected) {
|
|
Log.print("ICE reconnection timed out. Hanging up.");
|
|
_endCallInternal("connection_lost");
|
|
} else {
|
|
_isReconnecting = false;
|
|
Log.print("ICE Reconnection succeeded!");
|
|
}
|
|
});
|
|
}
|
|
|
|
// EN: Initiates ICE Restart SDP exchange.
|
|
// AR: بدء تبادل حزم SDP لإعادة بناء مسارات الاتصال.
|
|
Future<void> _attemptIceRestart() async {
|
|
if (_peerConnection == null || !isCaller) return;
|
|
try {
|
|
Log.print("Caller initiating WebRTC ICE Restart...");
|
|
final constraints = {
|
|
'mandatory': {
|
|
'OfferToReceiveAudio': true,
|
|
'OfferToReceiveVideo': false,
|
|
},
|
|
'optional': [
|
|
{'IceRestart': true}
|
|
],
|
|
};
|
|
final offer = await _peerConnection!.createOffer(constraints);
|
|
await _peerConnection!.setLocalDescription(offer);
|
|
_signaling.send("offer", {
|
|
"sdp": {
|
|
"sdp": offer.sdp,
|
|
"type": offer.type,
|
|
}
|
|
});
|
|
} catch (e) {
|
|
Log.print("Error initiating WebRTC ICE Restart: $e");
|
|
}
|
|
}
|
|
|
|
// EN: Generates an SDP Offer to initialize the connection.
|
|
// AR: إنشاء عرض (Offer) لبدء الاتصال وتحديد قدرات الجهاز.
|
|
Future<void> _createOffer() async {
|
|
await _initializePeerConnection();
|
|
|
|
final constraints = {
|
|
'mandatory': {
|
|
'OfferToReceiveAudio': true,
|
|
'OfferToReceiveVideo': false,
|
|
},
|
|
'optional': [],
|
|
};
|
|
|
|
final offer = await _peerConnection!.createOffer(constraints);
|
|
await _peerConnection!.setLocalDescription(offer);
|
|
|
|
_signaling.send("offer", {
|
|
"sdp": {
|
|
"sdp": offer.sdp,
|
|
"type": offer.type,
|
|
}
|
|
});
|
|
}
|
|
|
|
// EN: Generates an SDP Answer in response to an Offer.
|
|
// AR: الرد بإنشاء إجابة (Answer) بناءً على العرض المستلم.
|
|
Future<void> _createAnswer() async {
|
|
final constraints = {
|
|
'mandatory': {
|
|
'OfferToReceiveAudio': true,
|
|
'OfferToReceiveVideo': false,
|
|
},
|
|
'optional': [],
|
|
};
|
|
|
|
final answer = await _peerConnection!.createAnswer(constraints);
|
|
await _peerConnection!.setLocalDescription(answer);
|
|
|
|
_signaling.send("answer", {
|
|
"sdp": {
|
|
"sdp": answer.sdp,
|
|
"type": answer.type,
|
|
}
|
|
});
|
|
}
|
|
|
|
// EN: Triggered when connection is fully established. Starts the 60s timer.
|
|
// AR: يُستدعى عند تأسيس الاتصال بنجاح، ويقوم ببدء مؤقت الـ 60 ثانية.
|
|
void _onCallConnected() {
|
|
_ringingTimeoutTimer?.cancel();
|
|
_reconnectTimer?.cancel();
|
|
_isReconnecting = false;
|
|
|
|
if (state.value != VoiceCallState.active) {
|
|
state.value = VoiceCallState.active;
|
|
HapticFeedback.vibrate();
|
|
|
|
// EN: Start 120s countdown timer / AR: بدء العد التنازلي لمدة 120 ثانية
|
|
_countdownTimer?.cancel();
|
|
elapsedSeconds.value = 120;
|
|
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
if (elapsedSeconds.value > 1) {
|
|
elapsedSeconds.value--;
|
|
} else {
|
|
elapsedSeconds.value = 0;
|
|
_countdownTimer?.cancel();
|
|
// EN: Force hangup when timer reaches 0 / AR: إغلاق إجباري عند وصول المؤقت لصفر
|
|
hangup();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// EN: Internal cleanup function. Closes all connections and streams.
|
|
// AR: دالة التنظيف الداخلية. تقوم بإغلاق جميع الاتصالات وتفريغ الذاكرة.
|
|
void _endCallInternal(String reason) {
|
|
_countdownTimer?.cancel();
|
|
_ringingTimeoutTimer?.cancel();
|
|
_reconnectTimer?.cancel();
|
|
_stopRingtone();
|
|
|
|
state.value = VoiceCallState.ended;
|
|
|
|
// EN: Close WebRTC connection / AR: إغلاق اتصال WebRTC
|
|
_peerConnection?.close();
|
|
_peerConnection = null;
|
|
|
|
// EN: Stop mic capture / AR: إيقاف التقاط الميكروفون
|
|
_localStream?.getTracks().forEach((track) => track.stop());
|
|
_localStream?.dispose();
|
|
_localStream = null;
|
|
|
|
// EN: Disconnect WebSockets / AR: إغلاق اتصال الـ WebSockets
|
|
_signaling.disconnect();
|
|
|
|
// EN: Close UI BottomSheet after delay / AR: إغلاق واجهة المكالمة بعد فترة زمنية قصيرة
|
|
Future.delayed(const Duration(milliseconds: 1500), () {
|
|
if (state.value == VoiceCallState.ended) {
|
|
state.value = VoiceCallState.idle;
|
|
Get.back();
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- ACTIONS (UI Controls) / إجراءات الواجهة ---
|
|
|
|
// EN: Toggles microphone mute state.
|
|
// AR: تبديل حالة كتم الميكروفون.
|
|
void toggleMute() {
|
|
isMuted.value = !isMuted.value;
|
|
_localStream?.getAudioTracks().forEach((track) {
|
|
track.enabled = !isMuted.value;
|
|
});
|
|
}
|
|
|
|
// EN: Toggles loudspeaker mode.
|
|
// AR: تبديل حالة مكبر الصوت الخارجي.
|
|
void toggleSpeaker() {
|
|
isSpeakerOn.value = !isSpeakerOn.value;
|
|
rtc.Helper.setSpeakerphoneOn(isSpeakerOn.value);
|
|
}
|
|
|
|
// EN: Displays the call UI overlay.
|
|
// AR: إظهار نافذة المكالمة السفلية.
|
|
void _showCallBottomSheet() {
|
|
Get.bottomSheet(
|
|
const VoiceCallBottomSheet(),
|
|
isScrollControlled: true,
|
|
enableDrag: false,
|
|
isDismissible: false,
|
|
);
|
|
}
|
|
|
|
// EN: Lifecycle hook: clean up resources when controller is destroyed.
|
|
// AR: دورة الحياة: تفريغ الذاكرة وإغلاق الموارد عند تدمير المتحكم.
|
|
@override
|
|
void onClose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_countdownTimer?.cancel();
|
|
_ringingTimeoutTimer?.cancel();
|
|
_reconnectTimer?.cancel();
|
|
_stopRingtone();
|
|
_ringtonePlayer?.dispose();
|
|
_peerConnection?.close();
|
|
_localStream?.dispose();
|
|
_signaling.disconnect();
|
|
super.onClose();
|
|
}
|
|
}
|