Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add group call support #101

Merged
merged 1 commit into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/core/constants/keys.dart
Original file line number Diff line number Diff line change
Expand Up @@ -187,5 +187,6 @@ class Keys {

// General
static const popupMenuButton = ValueKey('popup_menu_button');
static const popupMenuItemPrefix = ValueKey('popup_menu_item_');
Keys._();
}
6 changes: 5 additions & 1 deletion lib/core/routes/routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,11 @@ class Routes {
path: Routes.callScreen,
builder: (context, state) {
final Map<String, dynamic>? extra = state.extra as Map<String, dynamic>?;
return CallScreen(callee: extra?['user'] as UserModel?, voiceCallId: extra?['voiceCallId'] as String?);
return CallScreen(
chatId: extra?['chatId'] as String?,
callee: extra?['user'] as UserModel?,
voiceCallId: extra?['voiceCallId'] as String?
);
},
),
GoRoute(
Expand Down
6 changes: 5 additions & 1 deletion lib/core/view/widget/popup_menu_widget.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:telware_cross_platform/core/constants/keys.dart';
import 'package:telware_cross_platform/core/view/widget/popup_menu_item_widget.dart';
import 'package:telware_cross_platform/core/theme/palette.dart';
import 'package:telware_cross_platform/features/chat/view/screens/pinned_messages_screen.dart';

class PopupMenuWidget extends StatelessWidget {
final List<PopupMenuEntry> items;
Expand All @@ -26,10 +29,11 @@ class PopupMenuWidget extends StatelessWidget {
color: Palette.secondary,
position: RelativeRect.fromLTRB(position.dx, position.dy - renderBox.size.height, position.dx + 100, position.dy),
items: <PopupMenuEntry<dynamic>>[
...items.map((item) {
...items.mapIndexed((index, item) {
return PopupMenuItem<dynamic>(
value: item['value'],
child: PopupMenuItemWidget(
key: ValueKey(Keys.popupMenuItemPrefix.value + index.toString()),
icon: item['icon'],
text: item['text'],
trailing: item['trailing'],
Expand Down
1 change: 1 addition & 0 deletions lib/features/chat/enum/chatting_enums.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ enum EventType {
receiveLeftCall(event: 'CLIENT-LEFT'),
receiveCallSignal(event: 'SIGNAL-CLIENT'),
receiveCallStarted(event: 'CALL-STARTED'),
receiveCallEnded(event: 'CALL-ENDED'),
;

final String event;
Expand Down
6 changes: 5 additions & 1 deletion lib/features/chat/enum/message_enums.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ enum MessageContentType {
@HiveField(7)
file(content: 'file'),
@HiveField(8)
link(content: 'link');
link(content: 'link'),
@HiveField(9)
call(content: 'call');

static MessageContentType getType(String type) {
switch (type) {
Expand All @@ -91,6 +93,8 @@ enum MessageContentType {
return MessageContentType.file;
case 'link':
return MessageContentType.link;
case 'call':
return MessageContentType.call;
default:
return MessageContentType.text;
}
Expand Down
92 changes: 53 additions & 39 deletions lib/features/chat/services/signaling_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import 'package:telware_cross_platform/features/chat/view_model/event_handler.da

typedef StreamStateCallback = void Function(MediaStream stream, String senderId);
typedef DescriptionCallback = void Function(RTCSessionDescription description, String senderId);
typedef CandidateCallback = void Function(RTCIceCandidate candidate);
typedef CandidateCallback = void Function(RTCIceCandidate candidate, String senderId);
typedef ResponseCallback = void Function(dynamic response);

final TURN_SERVER_USERNAME = dotenv.env['TURN_SERVER_USERNAME'] ?? '';
Expand Down Expand Up @@ -46,7 +46,7 @@ class Signaling {
]
};

RTCPeerConnection? peerConnection;
Map<String, RTCPeerConnection> peerConnections = {};
MediaStream? localStream;
Map<String, MediaStream> remoteStreams = {};
StreamStateCallback? onAddRemoteStream;
Expand All @@ -56,6 +56,7 @@ class Signaling {
ResponseCallback? onCallStarted;
ResponseCallback? onReceiveJoinedCall;
ResponseCallback? onReceiveLeftCall;
ResponseCallback? onReceiveEndCall;

String? roomId;

Expand All @@ -75,59 +76,59 @@ class Signaling {
data['data']['sdpMid'],
data['data']['sdpMLineIndex'],
);
onICECandidate?.call(candidate);
onICECandidate?.call(candidate, data['senderId']);
}
}

Future<RTCSessionDescription> createOffer(calleeId) async {
await registerPeerConnection(calleeId);
Future<RTCSessionDescription> createOffer(clientId) async {
await registerPeerConnection(clientId);

localStream?.getTracks().forEach((track) {
peerConnection!.addTrack(track, localStream!);
peerConnections[clientId]!.addTrack(track, localStream!);
});

var offer = await peerConnection!.createOffer();
await setLocalDescription(offer);
var offer = await peerConnections[clientId]!.createOffer();
await setLocalDescription(offer, clientId);

return offer;
}

Future<RTCSessionDescription> createAnswer(String callerId) async {
Future<RTCSessionDescription> createAnswer(String clientId) async {
localStream?.getTracks().forEach((track) {
peerConnection!.addTrack(track, localStream!);
peerConnections[clientId]!.addTrack(track, localStream!);
});

peerConnection!.onTrack = (RTCTrackEvent event) async {
peerConnections[clientId]!.onTrack = (RTCTrackEvent event) async {
debugPrint('Remote stream added');
event.streams[0].getTracks().forEach((track) {
remoteStreams[callerId]!.addTrack(track);
remoteStreams[clientId]!.addTrack(track);
});
};

var answer = await peerConnection!.createAnswer();
await setLocalDescription(answer);
var answer = await peerConnections[clientId]!.createAnswer();
await setLocalDescription(answer, clientId);

return answer;
}

Future<void> addCandidate(RTCIceCandidate candidate) async {
await peerConnection!.addCandidate(candidate);
Future<void> addCandidate(RTCIceCandidate candidate, String clientId) async {
await peerConnections[clientId]!.addCandidate(candidate);
}

Future<void> registerPeerConnection(String callerId) async {
peerConnection ??= await createPeerConnection(configuration);
registerPeerConnectionListeners(callerId);
Future<void> registerPeerConnection(String clientId) async {
peerConnections[clientId] ??= await createPeerConnection(configuration);
registerPeerConnectionListeners(clientId);
}

Future<void> setRemoteDescription(RTCSessionDescription description) async {
Future<void> setRemoteDescription(RTCSessionDescription description, String clientId) async {
debugPrint('Setting remote description to: ${description.sdp}');
debugPrint('Remote SDP: ${description.sdp}');
await peerConnection!.setRemoteDescription(description);
await peerConnections[clientId]!.setRemoteDescription(description);
}

Future<void> setLocalDescription(RTCSessionDescription description) async {
Future<void> setLocalDescription(RTCSessionDescription description, String clientId) async {
debugPrint('Local SDP: ${description.sdp}');
await peerConnection!.setLocalDescription(description);
await peerConnections[clientId]!.setLocalDescription(description);
}

Future<void> hangUp(RTCVideoRenderer localRenderer) async {
Expand All @@ -138,9 +139,13 @@ class Signaling {
track.stop();
});
});
remoteStreams.clear();

peerConnections.forEach((key, value) {
value.close();
});
peerConnections.clear();

if (peerConnection != null) peerConnection!.close();
peerConnection = null;
_eventHandler.addEvent(
LeaveCallEvent(
{
Expand All @@ -150,11 +155,20 @@ class Signaling {
);

localStream!.dispose();
remoteStreams.clear();
}

void registerPeerConnectionListeners(String calleeId) {
peerConnection!.onIceCandidate = (RTCIceCandidate? candidate) {
Future<void> removeRemoteClient(String clientId) async {
peerConnections[clientId]?.close();
peerConnections.remove(clientId);

remoteStreams[clientId]?.getTracks().forEach((track) {
track.stop();
});
remoteStreams.remove(clientId);
}

void registerPeerConnectionListeners(String clientId) {
peerConnections[clientId]!.onIceCandidate = (RTCIceCandidate? candidate) {
if (candidate != null) {
debugPrint('Generated ICE Candidate');
_eventHandler.addEvent(
Expand All @@ -166,43 +180,43 @@ class Signaling {
'sdpMid': candidate.sdpMid,
'sdpMLineIndex': candidate.sdpMLineIndex,
},
'targetId': calleeId,
'targetId': clientId,
'voiceCallId': roomId,
},
)
);
}
};

peerConnection!.onConnectionState = (RTCPeerConnectionState state) {
peerConnections[clientId]!.onConnectionState = (RTCPeerConnectionState state) {
debugPrint('Connection state changed: $state');
};

peerConnection!.onSignalingState = (RTCSignalingState state) {
peerConnections[clientId]!.onSignalingState = (RTCSignalingState state) {
debugPrint('Signaling state changed: $state');
};

peerConnection!.onIceConnectionState = (RTCIceConnectionState state) {
peerConnections[clientId]!.onIceConnectionState = (RTCIceConnectionState state) {
debugPrint('ICE connection state changed: $state');
};

peerConnection!.onTrack = (RTCTrackEvent event) async {
peerConnections[clientId]!.onTrack = (RTCTrackEvent event) async {
if (event.streams.isNotEmpty) {
for (var track in event.streams[0].getTracks()) {
remoteStreams[calleeId]?.addTrack(track);
remoteStreams[clientId]?.addTrack(track);
}
onAddRemoteStream?.call(event.streams[0], calleeId);
onAddRemoteStream?.call(event.streams[0], clientId);
}
};

peerConnection!.onIceGatheringState = (RTCIceGatheringState state) {
peerConnections[clientId]!.onIceGatheringState = (RTCIceGatheringState state) {
debugPrint('ICE gathering state changed: $state');
};

peerConnection!.onAddStream = (MediaStream stream) {
peerConnections[clientId]!.onAddStream = (MediaStream stream) {
debugPrint('Remote stream added');
onAddRemoteStream?.call(stream, calleeId);
remoteStreams[calleeId] = stream;
onAddRemoteStream?.call(stream, clientId);
remoteStreams[clientId] = stream;
};
}

Expand Down
26 changes: 19 additions & 7 deletions lib/features/chat/view/screens/call_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import 'package:telware_cross_platform/features/chat/view_model/chats_view_model

class CallScreen extends ConsumerStatefulWidget {
static const String route = '/call';
final String? chatId;
final UserModel? callee;
final String? voiceCallId;

const CallScreen({
super.key,
this.chatId,
this.callee,
this.voiceCallId,
});
Expand All @@ -47,6 +49,7 @@ class _CallScreenState extends ConsumerState<CallScreen> {
isCaller = ref.read(callStateProvider).isCaller;
callee = widget.callee ?? ref.read(callStateProvider).callee;
voiceCallId = widget.voiceCallId ?? ref.read(callStateProvider).voiceCallId;
String? chatId = widget.chatId;

isReceivingCall = !isCaller && voiceCallId != null;

Expand All @@ -71,9 +74,12 @@ class _CallScreenState extends ConsumerState<CallScreen> {
_initializeSignaling();
if (voiceCallId == null && isCaller) {
debugPrint("Call: Creating voice call");
ChatModel chat = ref.read(chatsViewModelProvider.notifier)
.getChat(ref.read(userProvider)!, callee!, ChatType.private);
_signaling.createVoiceCall(chat.id, callee!.id);
if (chatId == null) {
ChatModel chat = ref.read(chatsViewModelProvider.notifier)
.getChat(ref.read(userProvider)!, callee!, ChatType.private);
chatId = chat.id;
}
_signaling.createVoiceCall(chatId, callee?.id);
}
});
}
Expand All @@ -88,19 +94,19 @@ class _CallScreenState extends ConsumerState<CallScreen> {
_signaling.onOffer = (description, senderId) async {
// If receiving a call, set remote description and create an answer
await _signaling.registerPeerConnection(senderId);
await _signaling.setRemoteDescription(description);
await _signaling.setRemoteDescription(description, senderId);
final answer = await _signaling.createAnswer(senderId);
_signaling.sendAnswer(answer, senderId);
startCall();
};

_signaling.onAnswer = (description, senderId) async {
await _signaling.setRemoteDescription(description);
await _signaling.setRemoteDescription(description, senderId);
startCall();
};

_signaling.onICECandidate = (candidate) {
_signaling.addCandidate(candidate);
_signaling.onICECandidate = (candidate, senderId) {
_signaling.addCandidate(candidate, senderId);
};

_signaling.onCallStarted = (response) {
Expand All @@ -126,6 +132,12 @@ class _CallScreenState extends ConsumerState<CallScreen> {
};

_signaling.onReceiveLeftCall = (response) {
// Terminate connection with the specific user
final clientId = response['clientId'];
_signaling.removeRemoteClient(clientId);
};

_signaling.onReceiveEndCall = (response) {
// Terminate connection with user
endCall();
context.pop();
Expand Down
15 changes: 15 additions & 0 deletions lib/features/chat/view/screens/chat_info_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:telware_cross_platform/core/view/widget/tab_bar_widget.dart';
import 'package:telware_cross_platform/core/view/widget/lottie_viewer.dart';
import 'package:telware_cross_platform/core/view/widget/popup_menu_widget.dart';
import 'package:telware_cross_platform/features/auth/view/widget/title_element.dart';
import 'package:telware_cross_platform/features/chat/providers/call_provider.dart';
import 'package:telware_cross_platform/features/chat/view/widget/member_tile_widget.dart';
import 'package:telware_cross_platform/features/chat/view_model/chats_view_model.dart';
import 'package:telware_cross_platform/features/chat/view_model/chatting_controller.dart';
Expand Down Expand Up @@ -337,11 +338,25 @@ class _ChatInfoScreen extends ConsumerState<ChatInfoScreen>
case 'delete-group':
_confirmDelete(context);
break;
case 'video-call':
_createGroupCall();
break;
default:
showToastMessage('Coming soon');
}
}

void _createGroupCall() {
if (ref.read(callStateProvider).voiceCallId == null) {
ref.read(callStateProvider.notifier).setCaller(true);
context.push(Routes.callScreen, extra: {
'chatId': chat.id,
});
} else {
showToastMessage('You are already in a call');
}
}

void _addMembers() {
Navigator.push(
context,
Expand Down
9 changes: 9 additions & 0 deletions lib/features/chat/view_model/event_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,15 @@ class EventHandler {
debugPrint('!!! Error in receiving a call started:\n${e.toString()}');
}
});
// get a call ended
_socket.on(EventType.receiveCallEnded.event, (response) async {
try {
debugPrint('### got a call ended: $response');
signaling.onReceiveEndCall?.call(response);
} on Exception catch (e) {
debugPrint('!!! Error in receiving a call ended:\n${e.toString()}');
}
});
}

// Private constructor
Expand Down
Loading
Loading