one to one calling implemented
parent
e9df60bd48
commit
8aa40f7b8a
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Helper class to debug call issues
|
||||
class CallDebugHelper {
|
||||
static void logCallInitiation({
|
||||
required String callerEmployeeNumber,
|
||||
required String calleeEmployeeNumber,
|
||||
required bool isVideo,
|
||||
required String hubConnectionState,
|
||||
}) {
|
||||
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
|
||||
log('📞 INITIATING CALL', name: 'CALL_DEBUG');
|
||||
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
|
||||
log('Caller: $callerEmployeeNumber', name: 'CALL_DEBUG');
|
||||
log('Callee: $calleeEmployeeNumber', name: 'CALL_DEBUG');
|
||||
log('Type: ${isVideo ? "VIDEO" : "AUDIO"}', name: 'CALL_DEBUG');
|
||||
log('Hub State: $hubConnectionState', name: 'CALL_DEBUG');
|
||||
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
|
||||
}
|
||||
|
||||
static void logConnectionStatus({
|
||||
required String employeeNumber,
|
||||
required bool isConnected,
|
||||
required bool handlersRegistered,
|
||||
String? additionalInfo,
|
||||
}) {
|
||||
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
|
||||
log('📡 CONNECTION STATUS CHECK', name: 'CALL_DEBUG');
|
||||
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
|
||||
log('Employee: $employeeNumber', name: 'CALL_DEBUG');
|
||||
log('Connected: $isConnected', name: 'CALL_DEBUG');
|
||||
log('Handlers Registered: $handlersRegistered', name: 'CALL_DEBUG');
|
||||
if (additionalInfo != null) {
|
||||
log('Info: $additionalInfo', name: 'CALL_DEBUG');
|
||||
}
|
||||
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
|
||||
}
|
||||
|
||||
static void logEventReceived({
|
||||
required String eventName,
|
||||
required dynamic rawData,
|
||||
required String currentStatus,
|
||||
}) {
|
||||
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
|
||||
log('📨 SIGNALR EVENT RECEIVED', name: 'CALL_DEBUG');
|
||||
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
|
||||
log('Event: $eventName', name: 'CALL_DEBUG');
|
||||
log('Current Status: $currentStatus', name: 'CALL_DEBUG');
|
||||
log('Raw Data: $rawData', name: 'CALL_DEBUG');
|
||||
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
|
||||
}
|
||||
|
||||
static void printCallTroubleshooting() {
|
||||
if (kDebugMode) {
|
||||
print('\n');
|
||||
print('╔════════════════════════════════════════════════════════════╗');
|
||||
print('║ CALL NOT RECEIVED - TROUBLESHOOTING ║');
|
||||
print('╚════════════════════════════════════════════════════════════╝');
|
||||
print('');
|
||||
print('Check the following on BOTH devices:');
|
||||
print('');
|
||||
print('1. SignalR Connection:');
|
||||
print(' Look for: "🔌 SignalR Hub Connection: Started"');
|
||||
print(' Look for: "✅ All call handlers registered successfully"');
|
||||
print('');
|
||||
print('2. Employee Numbers:');
|
||||
print(' Verify sender and recipient employee numbers are correct');
|
||||
print(' Look for: "🔵 [CALL] Sender: XXX"');
|
||||
print(' Look for: " - target: XXX"');
|
||||
print('');
|
||||
print('3. Backend Response:');
|
||||
print(' On receiving device, look for:');
|
||||
print(' "📞 [CALL EVENT] OnIncomingCallAsync received"');
|
||||
print('');
|
||||
print('4. Common Issues:');
|
||||
print(' ❌ Receiving device not connected to SignalR');
|
||||
print(' ❌ Call handlers not registered (app not in chat screen)');
|
||||
print(' ❌ Wrong employee number in backend mapping');
|
||||
print(' ❌ Backend feature flag disabled for audio/video calls');
|
||||
print(' ❌ Network connectivity issues');
|
||||
print('');
|
||||
print('5. Backend Logs:');
|
||||
print(' Check backend logs for CallUserAsync invocation');
|
||||
print(' Verify backend is routing to correct user connection');
|
||||
print('');
|
||||
print('╚════════════════════════════════════════════════════════════╝');
|
||||
print('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,471 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:test_sa/new_views/swipe_module/dialoge/single_btn_dialog.dart';
|
||||
import 'package:test_sa/new_views/swipe_module/dialoge/info_dialog.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'dart:io';
|
||||
|
||||
/// Centralized error handler for call-related errors
|
||||
class CallErrorHandler {
|
||||
/// Show permission denied dialog
|
||||
static void showPermissionDenied(
|
||||
BuildContext context, {
|
||||
required String permissionName,
|
||||
VoidCallback? onSettingsTap,
|
||||
}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => InfoDialog(
|
||||
title: 'Permission Required',
|
||||
message: '$permissionName permission is required to make calls.',
|
||||
content: [
|
||||
InfoContent(
|
||||
title: 'Action needed',
|
||||
message: 'Please grant $permissionName permission in Settings to continue.',
|
||||
),
|
||||
],
|
||||
okTitle: 'Open Settings',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
openAppSettings();
|
||||
onSettingsTap?.call();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show microphone permission denied
|
||||
static void showMicrophonePermissionDenied(BuildContext context) {
|
||||
showPermissionDenied(
|
||||
context,
|
||||
permissionName: 'Microphone',
|
||||
);
|
||||
}
|
||||
|
||||
/// Show camera permission denied
|
||||
static void showCameraPermissionDenied(BuildContext context) {
|
||||
showPermissionDenied(
|
||||
context,
|
||||
permissionName: 'Camera',
|
||||
);
|
||||
}
|
||||
|
||||
/// Show user is busy dialog
|
||||
static void showUserBusy(BuildContext context, String userName) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => SingleBtnDialog(
|
||||
title: 'User Busy',
|
||||
message: '$userName is currently on another call. Please try again later.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show call declined dialog
|
||||
static void showCallDeclined(BuildContext context, String userName, String? reason) {
|
||||
String message;
|
||||
switch (reason) {
|
||||
case 'declined':
|
||||
message = '$userName declined your call.';
|
||||
break;
|
||||
case 'busy':
|
||||
message = '$userName is busy on another call.';
|
||||
break;
|
||||
case 'no answer':
|
||||
message = '$userName didn\'t answer the call.';
|
||||
break;
|
||||
default:
|
||||
message = 'Call was not answered.';
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => SingleBtnDialog(
|
||||
title: 'Call Ended',
|
||||
message: message,
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show connection failed dialog
|
||||
static void showConnectionFailed(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const SingleBtnDialog(
|
||||
title: 'Connection Failed',
|
||||
message: 'Unable to establish call connection. Please check your internet connection and try again.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show WebRTC initialization failed
|
||||
static void showWebRTCInitFailed(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const SingleBtnDialog(
|
||||
title: 'Call Setup Failed',
|
||||
message: 'Failed to initialize call. Please check your device settings and try again.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show SignalR not connected
|
||||
static void showSignalRNotConnected(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const SingleBtnDialog(
|
||||
title: 'Connection Error',
|
||||
message: 'Not connected to server. Please check your internet connection.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show ICE connection failed
|
||||
static void showICEConnectionFailed(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => InfoDialog(
|
||||
title: 'Connection Issue',
|
||||
message: 'Unable to establish peer-to-peer connection.',
|
||||
content: [
|
||||
InfoContent(
|
||||
title: 'Possible causes',
|
||||
message: 'Network firewall, poor internet connection, or restricted network.',
|
||||
),
|
||||
InfoContent(
|
||||
title: 'Suggestion',
|
||||
message: 'Try switching to a different network (WiFi/Mobile data).',
|
||||
),
|
||||
],
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show call timeout dialog
|
||||
static void showCallTimeout(BuildContext context, String userName) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => SingleBtnDialog(
|
||||
title: 'No Answer',
|
||||
message: '$userName didn\'t answer the call.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show network error during call
|
||||
static void showNetworkErrorDuringCall(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const SingleBtnDialog(
|
||||
title: 'Connection Lost',
|
||||
message: 'Network connection was lost during the call.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show generic error
|
||||
static void showGenericError(BuildContext context, String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => SingleBtnDialog(
|
||||
title: 'Error',
|
||||
message: message,
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show call already in progress
|
||||
static void showCallAlreadyInProgress(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const SingleBtnDialog(
|
||||
title: 'Call In Progress',
|
||||
message: 'You are already in a call. Please end the current call before starting a new one.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show user offline
|
||||
static void showUserOffline(BuildContext context, String userName) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => SingleBtnDialog(
|
||||
title: 'User Offline',
|
||||
message: '$userName is currently offline and cannot receive calls.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show reconnecting dialog
|
||||
static void showReconnecting(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const PopScope(
|
||||
canPop: false,
|
||||
child: Dialog(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(width: 24),
|
||||
Text('Reconnecting...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check internet connectivity
|
||||
static Future<bool> hasInternetConnection() async {
|
||||
try {
|
||||
final result = await InternetAddress.lookup('google.com');
|
||||
return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
|
||||
} on SocketException catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Show no internet connection dialog
|
||||
static void showNoInternetConnection(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => InfoDialog(
|
||||
title: 'No Internet Connection',
|
||||
message: 'Please check your internet connection and try again.',
|
||||
content: [
|
||||
InfoContent(
|
||||
title: 'Action needed',
|
||||
message: 'Make sure you are connected to WiFi or mobile data.',
|
||||
),
|
||||
],
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show poor network quality warning
|
||||
static void showPoorNetworkQuality(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => InfoDialog(
|
||||
title: 'Poor Network Quality',
|
||||
message: 'Your network connection is weak. Call quality may be affected.',
|
||||
content: [
|
||||
InfoContent(
|
||||
title: 'Recommendation',
|
||||
message: 'Try moving to an area with better signal or connect to WiFi.',
|
||||
),
|
||||
],
|
||||
okTitle: 'Continue Anyway',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show server error dialog
|
||||
static void showServerError(BuildContext context, {String? errorMessage}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => SingleBtnDialog(
|
||||
title: 'Server Error',
|
||||
message: errorMessage ?? 'Unable to reach the server. Please try again later.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show call initialization timeout
|
||||
static void showCallInitializationTimeout(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const SingleBtnDialog(
|
||||
title: 'Connection Timeout',
|
||||
message: 'Call setup is taking too long. Please check your connection and try again.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show media device error (camera/microphone)
|
||||
static void showMediaDeviceError(BuildContext context, String deviceName) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => InfoDialog(
|
||||
title: 'Device Error',
|
||||
message: 'Unable to access your $deviceName.',
|
||||
content: [
|
||||
InfoContent(
|
||||
title: 'Possible causes',
|
||||
message: 'Another app may be using the $deviceName, or the device may be unavailable.',
|
||||
),
|
||||
InfoContent(
|
||||
title: 'Solution',
|
||||
message: 'Close other apps using the $deviceName and try again.',
|
||||
),
|
||||
],
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show call ended unexpectedly
|
||||
static void showCallEndedUnexpectedly(BuildContext context, {String? reason}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => SingleBtnDialog(
|
||||
title: 'Call Ended',
|
||||
message: reason ?? 'The call ended unexpectedly. This may be due to network issues.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show peer connection lost
|
||||
static void showPeerConnectionLost(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const SingleBtnDialog(
|
||||
title: 'Connection Lost',
|
||||
message: 'Lost connection to the other participant. The call has ended.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check and handle internet connectivity before call
|
||||
static Future<bool> checkInternetConnectionForCall(BuildContext context) async {
|
||||
final hasInternet = await hasInternetConnection();
|
||||
if (!hasInternet) {
|
||||
showNoInternetConnection(context);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Show backend not reachable error
|
||||
static void showBackendNotReachable(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => InfoDialog(
|
||||
title: 'Server Unreachable',
|
||||
message: 'Unable to connect to the server.',
|
||||
content: [
|
||||
InfoContent(
|
||||
title: 'Please check',
|
||||
message: 'Your internet connection and try again. If the problem persists, the server may be down.',
|
||||
),
|
||||
],
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show SignalR disconnected during call
|
||||
static void showSignalRDisconnected(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const SingleBtnDialog(
|
||||
title: 'Connection Lost',
|
||||
message: 'Lost connection to the server. The call has ended.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show call setup timeout error
|
||||
static void showCallSetupTimeout(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const SingleBtnDialog(
|
||||
title: 'Setup Failed',
|
||||
message: 'Call setup timed out. Please check your connection and try again.',
|
||||
okTitle: 'OK',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle WebRTC media error with specific message
|
||||
static void handleMediaError(BuildContext context, dynamic error) {
|
||||
final errorString = error.toString().toLowerCase();
|
||||
|
||||
if (errorString.contains('permission') || errorString.contains('denied')) {
|
||||
if (errorString.contains('camera') || errorString.contains('video')) {
|
||||
showCameraPermissionDenied(context);
|
||||
} else {
|
||||
showMicrophonePermissionDenied(context);
|
||||
}
|
||||
} else if (errorString.contains('notfound') || errorString.contains('not found')) {
|
||||
showMediaDeviceError(context, 'device');
|
||||
} else if (errorString.contains('notreadable') || errorString.contains('in use')) {
|
||||
showMediaDeviceError(context, 'camera or microphone');
|
||||
} else {
|
||||
showWebRTCInitFailed(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ICE connection error
|
||||
static void handleICEConnectionError(BuildContext context) {
|
||||
showICEConnectionFailed(context);
|
||||
}
|
||||
|
||||
/// Handle unexpected error with fallback message
|
||||
static void handleUnexpectedError(BuildContext context, dynamic error, {String? customMessage}) {
|
||||
final message = customMessage ?? 'An unexpected error occurred. Please try again.';
|
||||
showGenericError(context, message);
|
||||
}
|
||||
|
||||
/// Validate and show appropriate error for call failure
|
||||
static Future<bool> validateCallPreconditions(
|
||||
BuildContext context, {
|
||||
required bool checkInternet,
|
||||
required bool checkPermissions,
|
||||
required bool isVideoCall,
|
||||
}) async {
|
||||
// Check internet connection
|
||||
if (checkInternet) {
|
||||
final hasInternet = await hasInternetConnection();
|
||||
if (!hasInternet) {
|
||||
showNoInternetConnection(context);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check microphone permission
|
||||
if (checkPermissions) {
|
||||
final micStatus = await Permission.microphone.status;
|
||||
if (!micStatus.isGranted) {
|
||||
showMicrophonePermissionDenied(context);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check camera permission for video calls
|
||||
if (isVideoCall) {
|
||||
final cameraStatus = await Permission.camera.status;
|
||||
if (!cameraStatus.isGranted) {
|
||||
showCameraPermissionDenied(context);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,216 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:test_sa/extensions/int_extensions.dart';
|
||||
import 'package:test_sa/extensions/string_extensions.dart';
|
||||
import 'package:test_sa/extensions/text_extensions.dart';
|
||||
import 'package:test_sa/extensions/widget_extensions.dart';
|
||||
import 'package:test_sa/new_views/app_style/app_color.dart';
|
||||
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
|
||||
import '../chat_provider.dart';
|
||||
import '../model/call_session.dart';
|
||||
import 'audio_call_page.dart';
|
||||
import 'video_call_page.dart';
|
||||
|
||||
/// Native-style full-screen incoming call dialog matching audio call page design
|
||||
class IncomingCallDialog extends StatefulWidget {
|
||||
final CallSession call;
|
||||
|
||||
const IncomingCallDialog({
|
||||
Key? key,
|
||||
required this.call,
|
||||
}) : super(key: key);
|
||||
|
||||
static void show(BuildContext context, CallSession call) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (context) => IncomingCallDialog(call: call),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<IncomingCallDialog> createState() => _IncomingCallDialogState();
|
||||
}
|
||||
|
||||
class _IncomingCallDialogState extends State<IncomingCallDialog> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Listen for call status changes
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
chatProvider.addListener(_checkCallStatus);
|
||||
});
|
||||
}
|
||||
|
||||
void _checkCallStatus() {
|
||||
if (!mounted) return; // Early exit if widget is unmounted
|
||||
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
// If call status is idle (call cancelled/ended), automatically dismiss dialog
|
||||
if (chatProvider.callStatus == CallStatus.idle) {
|
||||
// Remove listener before popping
|
||||
chatProvider.removeListener(_checkCallStatus);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Clean up listener
|
||||
try {
|
||||
context.read<ChatProvider>().removeListener(_checkCallStatus);
|
||||
} catch (e) {
|
||||
// Provider might already be disposed
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
|
||||
return PopScope(
|
||||
canPop: false, // Prevent dismissing by back button
|
||||
child: Scaffold(
|
||||
backgroundColor: AppColor.backgroundTabBarDark,
|
||||
body: Column(
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
_buildContactInfo(context, widget.call),
|
||||
8.height,
|
||||
_buildIncomingCallStatus(context),
|
||||
const Spacer(flex: 4),
|
||||
_buildActions(context, chatProvider, widget.call),
|
||||
32.height,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactInfo(BuildContext context, CallSession session) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColor.whiteF8d,
|
||||
border: Border.all(color: AppColor.white10.withOpacity(0.2), width: 1),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: session.peerAvatar != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: session.peerAvatar!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48),
|
||||
)
|
||||
: 'call_user_avatar'.toSvgAsset(height: 48, width: 48),
|
||||
),
|
||||
),
|
||||
24.height,
|
||||
Text(
|
||||
session.peerName,
|
||||
style: AppTextStyles.heading2.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIncomingCallStatus(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
widget.call.type == CallType.video ? 'Atoms Video Call' : 'Atoms Audio Call',
|
||||
style: AppTextStyles.heading5.copyWith(
|
||||
color: AppColor.neutral100,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
8.height,
|
||||
Text(
|
||||
'Incoming call...',
|
||||
style: AppTextStyles.heading5.copyWith(
|
||||
color: AppColor.neutral100,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions(BuildContext context, ChatProvider chatProvider, CallSession session) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// Decline button
|
||||
_buildActionButton(
|
||||
icon: 'end_call',
|
||||
label: 'Decline',
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await chatProvider.declineCall('declined');
|
||||
},
|
||||
),
|
||||
|
||||
// Accept button
|
||||
_buildActionButton(
|
||||
icon: 'calling_icon',
|
||||
label: 'Accept',
|
||||
onPressed: () async {
|
||||
// Just dismiss the dialog - acceptCall() will handle navigation
|
||||
Navigator.of(context).pop();
|
||||
await chatProvider.acceptCall();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required String icon,
|
||||
required String label,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: icon == 'end_call' ? AppColor.red30 : Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: icon.toSvgAsset(height: 32, width: 32, color: AppColor.white10),
|
||||
).onPress(onPressed),
|
||||
8.height,
|
||||
Text(
|
||||
label,
|
||||
style: AppTextStyles.heading5.copyWith(
|
||||
color: AppColor.neutral100,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,617 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue