Fixes & Updates - 2026-06-01: Integrate Back-End v3 updates, fix call/connection issues across apps
This commit is contained in:
722
lib/controller/voice_call_controller.dart
Normal file
722
lib/controller/voice_call_controller.dart
Normal file
@@ -0,0 +1,722 @@
|
||||
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;
|
||||
|
||||
// --- 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/start.wav');
|
||||
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 = passengerId;
|
||||
rideId.value = rideIdVal;
|
||||
remoteName.value = remoteNameVal;
|
||||
isMuted.value = false;
|
||||
isSpeakerOn.value = false;
|
||||
elapsedSeconds.value = 60;
|
||||
_isReconnecting = false;
|
||||
|
||||
_showCallBottomSheet();
|
||||
HapticFeedback.vibrate();
|
||||
|
||||
try {
|
||||
// 1. EN: Request Microphone Permission / AR: طلب صلاحية الميكروفون
|
||||
final permissionStatus = await Permission.microphone.request();
|
||||
if (!permissionStatus.isGranted) {
|
||||
_endCallInternal("permission_denied");
|
||||
Get.snackbar(
|
||||
"Error",
|
||||
"Microphone permission is required for voice calls".tr,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. EN: Call PHP Backend to create Node.js session & notify Driver via FCM.
|
||||
// AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار السائق عبر FCM.
|
||||
final response = await CRUD().post(
|
||||
link: "${AppLink.server}/ride/call/passenger/create_call_session.php",
|
||||
payload: {'ride_id': rideIdVal},
|
||||
);
|
||||
|
||||
if (response == null ||
|
||||
response == 'failure' ||
|
||||
response['status'] != 'success') {
|
||||
_endCallInternal("session_creation_failed");
|
||||
Get.snackbar(
|
||||
"Error",
|
||||
"Failed to initiate call session. Please try again.".tr,
|
||||
);
|
||||
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 driver 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");
|
||||
_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.passengerID).toString();
|
||||
sessionId.value = sessionIdVal;
|
||||
rideId.value = rideIdVal;
|
||||
remoteName.value = remoteNameVal;
|
||||
isMuted.value = false;
|
||||
isSpeakerOn.value = false;
|
||||
elapsedSeconds.value = 60;
|
||||
_isReconnecting = false;
|
||||
|
||||
_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;
|
||||
|
||||
try {
|
||||
// EN: Check Mic permissions / AR: التحقق من صلاحيات الميكروفون
|
||||
final permissionStatus = await Permission.microphone.request();
|
||||
if (!permissionStatus.isGranted) {
|
||||
declineCall();
|
||||
Get.snackbar(
|
||||
"Error",
|
||||
"Microphone permission is required for voice calls".tr,
|
||||
);
|
||||
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");
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user