2020-04-23 16:15:24 +00:00
|
|
|
import 'dart:async';
|
2020-05-09 14:06:24 +00:00
|
|
|
import 'dart:math';
|
2020-04-23 16:15:24 +00:00
|
|
|
|
2020-04-20 11:24:40 +00:00
|
|
|
import 'package:comunic/helpers/calls_helper.dart';
|
2020-04-20 11:19:49 +00:00
|
|
|
import 'package:comunic/helpers/conversations_helper.dart';
|
2020-04-20 12:24:35 +00:00
|
|
|
import 'package:comunic/helpers/events_helper.dart';
|
2020-04-20 12:13:03 +00:00
|
|
|
import 'package:comunic/helpers/users_helper.dart';
|
2020-04-20 12:02:32 +00:00
|
|
|
import 'package:comunic/lists/call_members_list.dart';
|
2020-04-20 12:13:03 +00:00
|
|
|
import 'package:comunic/lists/users_list.dart';
|
2020-04-20 11:43:17 +00:00
|
|
|
import 'package:comunic/models/call_config.dart';
|
2020-04-20 12:13:03 +00:00
|
|
|
import 'package:comunic/models/call_member.dart';
|
2020-04-20 11:19:49 +00:00
|
|
|
import 'package:comunic/models/conversation.dart';
|
2020-04-24 10:21:09 +00:00
|
|
|
import 'package:comunic/plugins_interface/wake_lock.dart';
|
2020-05-05 11:21:37 +00:00
|
|
|
import 'package:comunic/ui/routes/main_route/main_route.dart';
|
2020-04-20 11:19:49 +00:00
|
|
|
import 'package:comunic/ui/widgets/safe_state.dart';
|
2020-04-20 12:13:03 +00:00
|
|
|
import 'package:comunic/utils/account_utils.dart';
|
2020-04-20 11:19:49 +00:00
|
|
|
import 'package:comunic/utils/intl_utils.dart';
|
|
|
|
import 'package:comunic/utils/ui_utils.dart';
|
2020-04-20 08:53:25 +00:00
|
|
|
import 'package:flutter/material.dart';
|
2021-02-07 16:09:08 +00:00
|
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
2020-04-20 08:53:25 +00:00
|
|
|
|
|
|
|
/// Call screen
|
|
|
|
///
|
|
|
|
/// @author Pierre Hubert
|
|
|
|
|
2020-04-22 16:45:01 +00:00
|
|
|
enum _PopupMenuOption { STOP_STREAMING, SWITCH_CAMERA }
|
2020-04-21 16:20:24 +00:00
|
|
|
|
2020-04-20 08:53:25 +00:00
|
|
|
class CallScreen extends StatefulWidget {
|
|
|
|
final int convID;
|
2020-05-10 12:36:33 +00:00
|
|
|
|
|
|
|
/// This settings should be always true except for the small call window...
|
2020-05-10 12:32:44 +00:00
|
|
|
final bool floatingButtons;
|
2020-04-20 08:53:25 +00:00
|
|
|
|
2020-05-10 13:00:26 +00:00
|
|
|
/// Use custom application bar. This function takes as parameter a nullable
|
|
|
|
/// String which is the title of the conversation
|
|
|
|
final PreferredSizeWidget Function(String) buildCustomAppBar;
|
|
|
|
|
2020-05-10 13:07:20 +00:00
|
|
|
/// Execute custom action when the call is closed
|
|
|
|
///
|
|
|
|
/// The default behavior is to pop the page
|
|
|
|
final void Function() onClose;
|
|
|
|
|
2020-05-10 12:32:44 +00:00
|
|
|
const CallScreen({
|
|
|
|
Key key,
|
|
|
|
@required this.convID,
|
|
|
|
this.floatingButtons = true,
|
2020-05-10 13:00:26 +00:00
|
|
|
this.buildCustomAppBar,
|
2020-05-10 13:07:20 +00:00
|
|
|
this.onClose,
|
2020-05-10 12:32:44 +00:00
|
|
|
}) : assert(convID != null),
|
2020-04-20 08:53:25 +00:00
|
|
|
assert(convID > 0),
|
2020-05-10 12:32:44 +00:00
|
|
|
assert(floatingButtons != null),
|
2020-04-20 08:53:25 +00:00
|
|
|
super(key: key);
|
|
|
|
|
|
|
|
@override
|
|
|
|
_CallScreenState createState() => _CallScreenState();
|
|
|
|
}
|
|
|
|
|
2020-04-20 11:19:49 +00:00
|
|
|
class _CallScreenState extends SafeState<CallScreen> {
|
|
|
|
// Widget properties
|
|
|
|
int get convID => widget.convID;
|
|
|
|
|
|
|
|
// State properties
|
|
|
|
Conversation _conversation;
|
|
|
|
String _convName;
|
2020-04-20 11:43:17 +00:00
|
|
|
CallConfig _conf;
|
2020-04-20 11:19:49 +00:00
|
|
|
var _error = false;
|
2020-04-20 12:02:32 +00:00
|
|
|
CallMembersList _membersList;
|
2020-04-20 12:13:03 +00:00
|
|
|
UsersList _usersList;
|
2020-04-20 15:24:42 +00:00
|
|
|
final _peersConnections = Map<int, RTCPeerConnection>();
|
2020-04-20 16:13:28 +00:00
|
|
|
final _renderers = Map<int, RTCVideoRenderer>();
|
2020-04-21 11:26:58 +00:00
|
|
|
MediaStream _localStream;
|
2020-04-21 16:14:10 +00:00
|
|
|
var _isLocalStreamVisible = true;
|
2020-04-22 16:55:29 +00:00
|
|
|
var _hideMenuBars = false;
|
|
|
|
|
|
|
|
bool get _canHideMenuBar =>
|
|
|
|
_hideMenuBars &&
|
|
|
|
_canMakeVideoCall &&
|
|
|
|
_renderers.keys.where((f) => f != userID()).length > 0;
|
2020-04-21 11:26:58 +00:00
|
|
|
|
2020-04-21 16:04:01 +00:00
|
|
|
bool get _canMakeVideoCall =>
|
|
|
|
_conversation.callCapabilities == CallCapabilities.VIDEO;
|
|
|
|
|
2020-04-21 11:46:26 +00:00
|
|
|
bool get isStreamingAudio =>
|
|
|
|
_localStream != null && _localStream.getAudioTracks().length > 0;
|
|
|
|
|
2020-04-21 11:26:58 +00:00
|
|
|
bool get isStreamingVideo =>
|
|
|
|
_localStream != null && _localStream.getVideoTracks().length > 0;
|
2020-04-20 11:19:49 +00:00
|
|
|
|
2020-04-21 11:46:26 +00:00
|
|
|
bool get isAudioMuted =>
|
|
|
|
isStreamingAudio && !_localStream.getAudioTracks()[0].enabled;
|
|
|
|
|
|
|
|
bool get isVideoMuted =>
|
|
|
|
isStreamingVideo && !_localStream.getVideoTracks()[0].enabled;
|
|
|
|
|
2020-04-20 11:19:49 +00:00
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
_initCall();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
super.dispose();
|
|
|
|
_endCall();
|
|
|
|
}
|
|
|
|
|
|
|
|
void _initCall() async {
|
|
|
|
try {
|
|
|
|
setState(() => _error = false);
|
|
|
|
|
|
|
|
// First, load information about the conversation
|
2020-04-21 11:46:26 +00:00
|
|
|
_conversation =
|
2021-03-10 16:54:41 +00:00
|
|
|
await ConversationsHelper().getSingle(convID, force: true);
|
2020-04-20 11:19:49 +00:00
|
|
|
_convName =
|
|
|
|
await ConversationsHelper.getConversationNameAsync(_conversation);
|
|
|
|
assert(_convName != null);
|
|
|
|
|
|
|
|
setState(() {});
|
2020-04-20 11:24:40 +00:00
|
|
|
|
|
|
|
// Join the call
|
|
|
|
await CallsHelper.join(convID);
|
2020-04-20 11:43:17 +00:00
|
|
|
|
|
|
|
// Get call configuration
|
|
|
|
_conf = await CallsHelper.getConfig();
|
2020-04-20 12:02:32 +00:00
|
|
|
|
|
|
|
// Get current members of the call
|
2020-04-20 12:13:03 +00:00
|
|
|
final membersList = await CallsHelper.getMembers(convID);
|
|
|
|
membersList.removeUser(userID());
|
|
|
|
_usersList = await UsersHelper().getListWithThrow(membersList.usersID);
|
|
|
|
_membersList = membersList;
|
|
|
|
|
|
|
|
setState(() {});
|
2020-04-20 12:24:35 +00:00
|
|
|
|
|
|
|
// Register to events
|
2020-04-20 15:34:31 +00:00
|
|
|
this.listen<UserJoinedCallEvent>((e) async {
|
|
|
|
if (e.callID != convID) return;
|
|
|
|
try {
|
|
|
|
if (!_usersList.hasUser(e.userID))
|
|
|
|
_usersList.add(await UsersHelper().getSingleWithThrow(e.userID));
|
|
|
|
setState(() => _membersList.add(CallMember(userID: e.userID)));
|
|
|
|
} catch (e, stack) {
|
|
|
|
print("$e\n$stack");
|
|
|
|
}
|
2020-04-20 12:24:35 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
this.listen<UserLeftCallEvent>((e) {
|
|
|
|
if (e.callID == convID) _removeMember(e.userID);
|
|
|
|
});
|
2020-04-20 12:32:57 +00:00
|
|
|
|
2020-04-20 14:23:33 +00:00
|
|
|
this.listen<NewCallSignalEvent>((e) {
|
|
|
|
if (e.callID == convID) _newSignal(e);
|
|
|
|
});
|
|
|
|
|
2020-04-20 12:58:23 +00:00
|
|
|
this.listen<CallPeerReadyEvent>((e) {
|
|
|
|
if (e.callID == convID) _memberReady(e.peerID);
|
|
|
|
});
|
|
|
|
|
|
|
|
this.listen<CallPeerInterruptedStreamingEvent>((e) {
|
|
|
|
if (e.callID == convID) _removeRemotePeerConnection(e.peerID);
|
|
|
|
});
|
|
|
|
|
2020-04-20 12:32:57 +00:00
|
|
|
this.listen<CallClosedEvent>((e) {
|
2020-04-20 12:41:09 +00:00
|
|
|
if (e.callID == convID) _leaveCall(needConfirm: false);
|
2020-04-20 12:32:57 +00:00
|
|
|
});
|
2020-04-20 13:50:01 +00:00
|
|
|
|
|
|
|
// Connect to ready peers
|
|
|
|
for (final peer in _membersList.readyPeers)
|
|
|
|
await this._memberReady(peer.userID);
|
2020-04-20 15:47:51 +00:00
|
|
|
|
|
|
|
setState(() {});
|
2020-04-23 15:37:12 +00:00
|
|
|
|
|
|
|
// Lock device await
|
|
|
|
await setWakeLock(true);
|
2020-04-20 11:19:49 +00:00
|
|
|
} catch (e, stack) {
|
|
|
|
print("Could not initialize call! $e\n$stack");
|
|
|
|
setState(() => _error = true);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-20 12:58:23 +00:00
|
|
|
/// Do clean up operations when call screen is destroyed
|
2020-04-20 11:24:40 +00:00
|
|
|
void _endCall() async {
|
|
|
|
try {
|
2020-04-23 15:37:12 +00:00
|
|
|
await setWakeLock(false);
|
|
|
|
|
2020-04-20 15:47:51 +00:00
|
|
|
// Close all ready members
|
|
|
|
for (final member in _membersList.readyPeers)
|
|
|
|
await _removeMember(member.userID);
|
2020-04-21 11:26:58 +00:00
|
|
|
|
|
|
|
// Close local stream
|
|
|
|
await _stopStreaming();
|
|
|
|
|
|
|
|
// Leave the call
|
|
|
|
await CallsHelper.leave(convID);
|
2020-04-20 11:24:40 +00:00
|
|
|
} catch (e, stack) {
|
|
|
|
print("Could not end call properly! $e\n$stack");
|
|
|
|
}
|
|
|
|
}
|
2020-04-20 11:19:49 +00:00
|
|
|
|
2020-04-20 12:58:23 +00:00
|
|
|
/// Make us leave the call
|
2020-04-20 12:41:09 +00:00
|
|
|
void _leaveCall({bool needConfirm = true}) async {
|
|
|
|
if (needConfirm &&
|
|
|
|
!await showConfirmDialog(
|
|
|
|
context: context,
|
|
|
|
message: tr("Do you really want to leave this call ?"))) return;
|
|
|
|
|
2020-05-10 13:07:20 +00:00
|
|
|
if (widget.onClose == null)
|
|
|
|
MainController.of(context).popPage();
|
|
|
|
else
|
|
|
|
widget.onClose();
|
2020-04-20 12:32:57 +00:00
|
|
|
}
|
|
|
|
|
2020-04-21 11:26:58 +00:00
|
|
|
/// Start streaming on our end
|
|
|
|
Future<void> _startStreaming(bool includeVideo) async {
|
|
|
|
try {
|
|
|
|
await _stopStreaming();
|
|
|
|
|
|
|
|
// Request user media
|
2021-02-07 16:09:08 +00:00
|
|
|
_localStream = await navigator.mediaDevices.getUserMedia({
|
2020-04-21 11:26:58 +00:00
|
|
|
"audio": true,
|
|
|
|
"video": !includeVideo
|
|
|
|
? false
|
|
|
|
: {
|
|
|
|
"mandatory": {
|
|
|
|
"maxWidth": '320',
|
|
|
|
"maxHeight": '240',
|
|
|
|
"minFrameRate": '24',
|
|
|
|
},
|
|
|
|
"facingMode": "user",
|
|
|
|
"optional": [],
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Start renderer
|
|
|
|
_renderers[userID()] = RTCVideoRenderer();
|
|
|
|
await _renderers[userID()].initialize();
|
|
|
|
_renderers[userID()].srcObject = _localStream;
|
|
|
|
setState(() {});
|
2020-04-22 16:29:00 +00:00
|
|
|
|
|
|
|
// Open stream
|
2021-02-12 17:20:28 +00:00
|
|
|
final peerConnection = await createPeerConnection(_conf.pluginConfig, {
|
2020-04-22 16:29:00 +00:00
|
|
|
"mandatory": {},
|
|
|
|
"optional": [],
|
|
|
|
});
|
|
|
|
|
|
|
|
_peersConnections[userID()] = peerConnection;
|
2021-02-07 17:44:57 +00:00
|
|
|
|
|
|
|
for (final track in _localStream.getTracks()) {
|
|
|
|
await peerConnection.addTrack(track, _localStream);
|
|
|
|
}
|
2020-04-22 16:29:00 +00:00
|
|
|
|
|
|
|
peerConnection.onAddStream =
|
|
|
|
(s) => throw Exception("Got a new stream on main peer connection!");
|
|
|
|
peerConnection.onIceCandidate =
|
|
|
|
(c) => CallsHelper.sendIceCandidate(convID, userID(), c);
|
|
|
|
peerConnection.onIceConnectionState = (c) {
|
|
|
|
print("New connection state: $c");
|
|
|
|
|
2020-04-23 16:15:24 +00:00
|
|
|
if (c == RTCIceConnectionState.RTCIceConnectionStateConnected) {
|
|
|
|
// Add a delay of two seconds to avoid racing
|
|
|
|
Timer(Duration(seconds: 2), () => CallsHelper.markPeerReady(convID));
|
|
|
|
}
|
2020-04-22 16:29:00 +00:00
|
|
|
};
|
|
|
|
peerConnection.onSignalingState = (c) => print("New signaling state: $c");
|
|
|
|
|
|
|
|
// Create & send offer
|
|
|
|
final offer = await peerConnection.createOffer({
|
|
|
|
"mandatory": {
|
|
|
|
"OfferToReceiveAudio": true,
|
2020-04-22 16:35:19 +00:00
|
|
|
"OfferToReceiveVideo": includeVideo,
|
2020-04-22 16:29:00 +00:00
|
|
|
},
|
|
|
|
"optional": [],
|
|
|
|
});
|
|
|
|
await peerConnection.setLocalDescription(offer);
|
|
|
|
|
|
|
|
await CallsHelper.sendSessionDescription(convID, userID(), offer);
|
2020-04-21 11:26:58 +00:00
|
|
|
} catch (e, stack) {
|
|
|
|
print("Could not start streaming! $e\n$stack");
|
|
|
|
showSimpleSnack(context, tr("Could not start streaming!"));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Stop local streaming
|
|
|
|
Future<void> _stopStreaming() async {
|
2020-04-22 16:29:00 +00:00
|
|
|
// Close peer connection
|
|
|
|
if (_peersConnections.containsKey(userID())) {
|
|
|
|
_peersConnections[userID()].close();
|
|
|
|
_peersConnections.remove(userID());
|
2020-04-22 16:35:19 +00:00
|
|
|
|
|
|
|
await CallsHelper.notifyStoppedStreaming(convID);
|
2020-04-22 16:29:00 +00:00
|
|
|
}
|
|
|
|
|
2020-04-21 11:26:58 +00:00
|
|
|
// Stop local stream
|
|
|
|
if (_localStream != null) {
|
|
|
|
await _localStream.dispose();
|
|
|
|
_localStream = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Close renderer
|
|
|
|
if (_renderers.containsKey(userID())) {
|
|
|
|
await _renderers[userID()].dispose();
|
|
|
|
_renderers.remove(userID());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-22 16:55:29 +00:00
|
|
|
/// Toggle menubar visibility
|
|
|
|
void _toggleMenuBarsVisibility() {
|
|
|
|
setState(() {
|
|
|
|
_hideMenuBars = !_hideMenuBars;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-04-21 11:46:26 +00:00
|
|
|
/// Toggle local video streaming
|
|
|
|
Future<void> _toggleStreaming(bool isVideo) async {
|
2020-04-21 16:04:01 +00:00
|
|
|
if (isVideo && !_canMakeVideoCall) {
|
2020-04-21 11:46:26 +00:00
|
|
|
print("Attempting to switch video call on a non-capable video stream!");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Start streaming if required
|
|
|
|
if (!isStreamingAudio || (!isStreamingVideo && isVideo)) {
|
|
|
|
await _startStreaming(isVideo);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Toggle appropriate mute
|
|
|
|
else {
|
|
|
|
if (!isVideo)
|
|
|
|
_localStream.getAudioTracks()[0].enabled =
|
|
|
|
!_localStream.getAudioTracks()[0].enabled;
|
|
|
|
else
|
|
|
|
_localStream.getVideoTracks()[0].enabled =
|
|
|
|
!_localStream.getVideoTracks()[0].enabled;
|
|
|
|
}
|
|
|
|
|
|
|
|
setState(() {});
|
|
|
|
}
|
|
|
|
|
2020-04-20 12:58:23 +00:00
|
|
|
/// Call this when a user started to stream media
|
2020-04-20 13:50:01 +00:00
|
|
|
Future<void> _memberReady(int memberID) async {
|
|
|
|
try {
|
|
|
|
_membersList.getUser(memberID).status = MemberStatus.READY;
|
|
|
|
setState(() {});
|
2020-04-20 12:58:23 +00:00
|
|
|
|
2020-04-20 15:24:42 +00:00
|
|
|
// Create peer connection
|
|
|
|
final peerConnection = await createPeerConnection(_conf.pluginConfig, {
|
|
|
|
"mandatory": {
|
|
|
|
"OfferToReceiveAudio": true,
|
2020-04-21 16:04:01 +00:00
|
|
|
"OfferToReceiveVideo": _canMakeVideoCall,
|
2020-04-20 15:24:42 +00:00
|
|
|
},
|
|
|
|
"optional": [],
|
|
|
|
});
|
|
|
|
|
|
|
|
_peersConnections[memberID] = peerConnection;
|
|
|
|
|
2020-04-20 16:13:28 +00:00
|
|
|
// Create a renderer
|
2020-04-21 11:29:09 +00:00
|
|
|
_renderers[memberID] = RTCVideoRenderer();
|
|
|
|
await _renderers[memberID].initialize();
|
2020-04-20 16:13:28 +00:00
|
|
|
|
2020-04-20 15:53:31 +00:00
|
|
|
// Register callbacks
|
|
|
|
peerConnection.onIceCandidate =
|
|
|
|
(c) => CallsHelper.sendIceCandidate(convID, memberID, c);
|
2020-04-23 11:12:40 +00:00
|
|
|
peerConnection.onAddStream = (s) {
|
|
|
|
setState(() {
|
|
|
|
_membersList.getUser(memberID).stream = s;
|
|
|
|
_renderers[memberID].srcObject = s;
|
|
|
|
});
|
|
|
|
};
|
2020-04-20 15:53:31 +00:00
|
|
|
|
2020-04-20 13:50:01 +00:00
|
|
|
// Request an offer to establish a peer connection
|
|
|
|
await CallsHelper.requestOffer(convID, memberID);
|
2020-04-22 16:35:19 +00:00
|
|
|
|
|
|
|
setState(() {});
|
2020-04-20 13:50:01 +00:00
|
|
|
} catch (e, stack) {
|
|
|
|
print("Could not connect to remote peer $e\n$stack!");
|
|
|
|
showSimpleSnack(context, tr("Could not connect to a remote peer!"));
|
|
|
|
}
|
2020-04-20 12:58:23 +00:00
|
|
|
}
|
|
|
|
|
2020-04-20 14:23:33 +00:00
|
|
|
/// Call this method each time we get a new signal
|
2020-04-20 15:24:42 +00:00
|
|
|
void _newSignal(NewCallSignalEvent ev) async {
|
|
|
|
try {
|
|
|
|
// Check if we can process this message
|
|
|
|
if (!_peersConnections.containsKey(ev.peerID)) {
|
|
|
|
print(
|
|
|
|
"Could not process a signal for the connection with peer ${ev.peerID}!");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check the kind of signal
|
|
|
|
// SessionDescription
|
|
|
|
if (ev.sessionDescription != null) {
|
|
|
|
await _peersConnections[ev.peerID]
|
|
|
|
.setRemoteDescription(ev.sessionDescription);
|
|
|
|
|
|
|
|
// Send answer if required
|
|
|
|
if (ev.sessionDescription.type == "offer") {
|
|
|
|
final answer = await _peersConnections[ev.peerID].createAnswer({});
|
2020-04-20 16:13:28 +00:00
|
|
|
await _peersConnections[ev.peerID].setLocalDescription(answer);
|
2020-04-20 15:24:42 +00:00
|
|
|
|
2020-04-20 15:29:36 +00:00
|
|
|
await CallsHelper.sendSessionDescription(convID, ev.peerID, answer);
|
2020-04-20 15:24:42 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ice Candidate
|
|
|
|
else {
|
|
|
|
await _peersConnections[ev.peerID].addCandidate(ev.candidate);
|
|
|
|
}
|
|
|
|
} catch (e, stack) {
|
|
|
|
print("Error while handling new signal ! $e\n$stack");
|
|
|
|
showSimpleSnack(context, tr("Error while processing new signal!"));
|
|
|
|
}
|
2020-04-20 14:23:33 +00:00
|
|
|
}
|
|
|
|
|
2020-04-20 12:58:23 +00:00
|
|
|
/// Call this when a user has interrupted streaming
|
2020-04-20 15:47:51 +00:00
|
|
|
Future<void> _removeRemotePeerConnection(int memberID) async {
|
2020-04-23 11:12:40 +00:00
|
|
|
final member = _membersList.getUser(memberID);
|
|
|
|
member.status = MemberStatus.JOINED;
|
2020-04-20 13:20:01 +00:00
|
|
|
setState(() {});
|
2020-04-20 15:47:51 +00:00
|
|
|
|
|
|
|
if (_peersConnections.containsKey(memberID)) {
|
|
|
|
await _peersConnections[memberID].close();
|
|
|
|
_peersConnections.remove(memberID);
|
|
|
|
}
|
2020-04-20 16:13:28 +00:00
|
|
|
|
|
|
|
if (_renderers.containsKey(memberID)) {
|
|
|
|
await _renderers[memberID].dispose();
|
|
|
|
_renderers.remove(memberID);
|
|
|
|
}
|
2020-04-23 11:12:40 +00:00
|
|
|
|
|
|
|
if (member.stream != null) {
|
|
|
|
member.stream.dispose();
|
|
|
|
member.stream = null;
|
|
|
|
}
|
2020-04-20 12:58:23 +00:00
|
|
|
}
|
|
|
|
|
2020-04-20 15:47:51 +00:00
|
|
|
/// Call this when a member has completely left the call
|
|
|
|
Future<void> _removeMember(int memberID) async {
|
|
|
|
await _removeRemotePeerConnection(memberID);
|
2020-04-20 12:58:23 +00:00
|
|
|
|
2020-04-20 12:24:35 +00:00
|
|
|
_membersList.removeUser(memberID);
|
2020-04-20 15:47:51 +00:00
|
|
|
|
2020-04-20 12:24:35 +00:00
|
|
|
setState(() {});
|
|
|
|
}
|
|
|
|
|
2020-04-21 16:14:10 +00:00
|
|
|
/// Toggle local stream visibility
|
|
|
|
void _toggleLocalStreamVisibility() {
|
|
|
|
setState(() {
|
|
|
|
_isLocalStreamVisible = !_isLocalStreamVisible;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-04-21 16:20:24 +00:00
|
|
|
/// Handles menu selection
|
2020-04-21 16:23:34 +00:00
|
|
|
void _handleSelectedMenuOption(_PopupMenuOption option) async {
|
2020-04-21 16:20:24 +00:00
|
|
|
switch (option) {
|
2020-04-22 16:45:01 +00:00
|
|
|
// Switch camera
|
|
|
|
case _PopupMenuOption.SWITCH_CAMERA:
|
2021-02-07 16:09:08 +00:00
|
|
|
await Helper.switchCamera(_localStream.getVideoTracks()[0]);
|
2020-04-22 16:45:01 +00:00
|
|
|
break;
|
|
|
|
|
2020-04-21 16:23:34 +00:00
|
|
|
// Stop streaming
|
2020-04-21 16:20:24 +00:00
|
|
|
case _PopupMenuOption.STOP_STREAMING:
|
2020-04-21 16:23:34 +00:00
|
|
|
await _stopStreaming();
|
|
|
|
setState(() {});
|
2020-04-21 16:20:24 +00:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-20 08:53:25 +00:00
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
2020-04-22 16:55:29 +00:00
|
|
|
return GestureDetector(
|
|
|
|
onDoubleTap: () => _toggleMenuBarsVisibility(),
|
|
|
|
child: Scaffold(
|
2020-05-10 13:00:26 +00:00
|
|
|
appBar: _canHideMenuBar ? null : _buildAppBar(),
|
2020-04-22 16:55:29 +00:00
|
|
|
body: _buildBody(),
|
2020-04-20 11:19:49 +00:00
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-05-10 13:00:26 +00:00
|
|
|
/// Build application bar
|
|
|
|
PreferredSizeWidget _buildAppBar() {
|
|
|
|
if (widget.buildCustomAppBar != null)
|
|
|
|
return widget.buildCustomAppBar(_convName);
|
|
|
|
|
|
|
|
return AppBar(
|
|
|
|
leading: IconButton(
|
|
|
|
icon: Icon(Icons.arrow_back),
|
|
|
|
onPressed: () => _leaveCall(),
|
|
|
|
),
|
|
|
|
title: _convName == null ? CircularProgressIndicator() : Text(_convName),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-04-20 11:19:49 +00:00
|
|
|
/// Build widget body
|
|
|
|
Widget _buildBody() {
|
2020-04-20 12:02:32 +00:00
|
|
|
// Handle errors
|
2020-04-20 11:19:49 +00:00
|
|
|
if (_error)
|
|
|
|
return buildErrorCard(tr("Could not initialize call!"), actions: [
|
|
|
|
MaterialButton(
|
|
|
|
onPressed: () => _initCall(),
|
|
|
|
child: Text(tr("Try again").toUpperCase()),
|
|
|
|
)
|
|
|
|
]);
|
2020-04-20 12:13:03 +00:00
|
|
|
|
|
|
|
// Check if are not ready to show call UI
|
|
|
|
if (_membersList == null) return buildCenteredProgressBar();
|
|
|
|
|
|
|
|
return Column(
|
2020-04-20 16:13:28 +00:00
|
|
|
children: <Widget>[
|
2020-05-10 12:36:33 +00:00
|
|
|
// Members list
|
2020-04-22 16:55:29 +00:00
|
|
|
_canHideMenuBar ? Container() : _buildMembersArea(),
|
2020-05-10 12:36:33 +00:00
|
|
|
|
|
|
|
// Build videos area (+ buttons if floating)
|
2020-05-10 12:32:44 +00:00
|
|
|
Expanded(child: LayoutBuilder(builder: _buildVideosArea)),
|
2020-05-10 12:36:33 +00:00
|
|
|
|
|
|
|
// Buttons bar (if buttons are not floating)
|
|
|
|
!_canHideMenuBar && !widget.floatingButtons
|
|
|
|
? _buildFooterArea()
|
|
|
|
: Container(),
|
2020-04-20 16:13:28 +00:00
|
|
|
],
|
2020-04-20 12:13:03 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Build members area
|
|
|
|
Widget _buildMembersArea() {
|
|
|
|
return Center(
|
|
|
|
child: Padding(
|
|
|
|
padding: const EdgeInsets.all(8.0),
|
|
|
|
child: RichText(
|
|
|
|
text: TextSpan(
|
|
|
|
children: _membersList
|
|
|
|
.map((f) => TextSpan(
|
2020-04-20 13:02:49 +00:00
|
|
|
text: _usersList.getUser(f.userID).displayName + " ",
|
2020-04-20 12:13:03 +00:00
|
|
|
style: TextStyle(
|
|
|
|
color: f.status == MemberStatus.JOINED
|
2020-05-03 19:07:02 +00:00
|
|
|
? (darkTheme() ? Colors.white : Colors.black)
|
2020-04-20 12:13:03 +00:00
|
|
|
: Colors.green)))
|
|
|
|
.toList())),
|
|
|
|
),
|
|
|
|
);
|
2020-04-20 08:53:25 +00:00
|
|
|
}
|
2020-04-20 13:20:01 +00:00
|
|
|
|
2020-04-20 16:13:28 +00:00
|
|
|
/// Videos area
|
2020-05-09 14:06:24 +00:00
|
|
|
Widget _buildVideosArea(BuildContext context, BoxConstraints constraints) {
|
|
|
|
final availableVideos = _membersList.readyPeers
|
|
|
|
.where((f) => f.hasVideoStream && _renderers.containsKey(f.userID))
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
final rows = List<Row>();
|
|
|
|
|
|
|
|
var numberRows = sqrt(availableVideos.length).ceil();
|
|
|
|
var numberCols = numberRows;
|
|
|
|
|
|
|
|
if (availableVideos.length == 2) numberRows = 1;
|
|
|
|
|
|
|
|
final videoWidth = constraints.maxWidth / numberCols;
|
|
|
|
final videoHeight = constraints.maxHeight / numberRows;
|
|
|
|
|
|
|
|
for (int i = 0; i < numberRows; i++) {
|
|
|
|
rows.add(Row(
|
|
|
|
children: availableVideos
|
|
|
|
.getRange(i * numberRows,
|
|
|
|
min(availableVideos.length, i * numberRows + numberCols))
|
|
|
|
.map((f) => Container(
|
|
|
|
width: videoWidth,
|
|
|
|
height: videoHeight,
|
|
|
|
child: _buildMemberVideo(f.userID),
|
|
|
|
))
|
|
|
|
.toList(),
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
|
|
|
return Stack(
|
|
|
|
fit: StackFit.expand,
|
|
|
|
children: [
|
2020-05-10 12:32:44 +00:00
|
|
|
// Remote peers videos
|
2020-05-09 14:06:24 +00:00
|
|
|
Column(
|
|
|
|
children: rows,
|
|
|
|
),
|
|
|
|
|
|
|
|
// Local peer video
|
|
|
|
isStreamingVideo && _isLocalStreamVisible
|
|
|
|
? _buildLocalVideo()
|
|
|
|
: Container(),
|
|
|
|
|
2020-05-10 12:36:33 +00:00
|
|
|
// Buttons bar (if floating)
|
|
|
|
!_canHideMenuBar && widget.floatingButtons
|
|
|
|
? Positioned(
|
2020-05-10 12:32:44 +00:00
|
|
|
bottom: 10, right: 0, left: 0, child: _buildFooterArea())
|
2020-05-10 12:36:33 +00:00
|
|
|
: Container(),
|
2020-05-09 14:06:24 +00:00
|
|
|
],
|
2020-04-22 16:55:29 +00:00
|
|
|
);
|
2020-04-20 16:13:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildMemberVideo(int peerID) {
|
2020-05-09 14:06:24 +00:00
|
|
|
return RTCVideoView(_renderers[peerID]);
|
2020-04-20 16:13:28 +00:00
|
|
|
}
|
|
|
|
|
2020-04-21 11:26:58 +00:00
|
|
|
Widget _buildLocalVideo() {
|
|
|
|
return Positioned(
|
|
|
|
child: RTCVideoView(_renderers[userID()]),
|
|
|
|
height: 50,
|
|
|
|
width: 50,
|
|
|
|
right: 10,
|
2020-05-10 12:32:44 +00:00
|
|
|
bottom: (_canHideMenuBar || !widget.floatingButtons ? 10 : 80),
|
2020-04-21 11:26:58 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-04-20 13:20:01 +00:00
|
|
|
/// Footer area
|
|
|
|
Widget _buildFooterArea() {
|
2020-05-10 12:32:44 +00:00
|
|
|
return Container(
|
|
|
|
color: !widget.floatingButtons ? Colors.black : null,
|
|
|
|
height: !widget.floatingButtons ? 40 : null,
|
2020-04-20 13:20:01 +00:00
|
|
|
child: Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
2020-04-23 11:36:30 +00:00
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
2020-04-20 13:20:01 +00:00
|
|
|
children: <Widget>[
|
2020-04-21 16:14:10 +00:00
|
|
|
// Show / hide user video button
|
|
|
|
_FooterButton(
|
2020-04-23 11:12:40 +00:00
|
|
|
visible: _canMakeVideoCall,
|
2020-04-21 16:14:10 +00:00
|
|
|
icon: Icon(_isLocalStreamVisible
|
|
|
|
? Icons.visibility
|
|
|
|
: Icons.visibility_off),
|
2020-05-10 12:32:44 +00:00
|
|
|
roundedButtons: widget.floatingButtons,
|
2020-04-21 16:14:10 +00:00
|
|
|
onPressed: () => _toggleLocalStreamVisibility(),
|
|
|
|
),
|
|
|
|
|
2020-04-21 11:26:58 +00:00
|
|
|
// Toggle audio button
|
2020-04-21 16:08:01 +00:00
|
|
|
_FooterButton(
|
|
|
|
onPressed: () => _toggleStreaming(false),
|
|
|
|
icon: Icon(
|
|
|
|
isStreamingAudio && !isAudioMuted ? Icons.mic : Icons.mic_off),
|
2020-05-10 12:32:44 +00:00
|
|
|
roundedButtons: widget.floatingButtons,
|
2020-04-21 11:26:58 +00:00
|
|
|
),
|
|
|
|
|
2020-04-20 13:20:01 +00:00
|
|
|
// Hang up call
|
2020-04-21 16:08:01 +00:00
|
|
|
_FooterButton(
|
2020-04-23 11:36:30 +00:00
|
|
|
width: 60,
|
2020-04-23 11:39:33 +00:00
|
|
|
icon: Icon(Icons.phone),
|
2020-05-10 12:32:44 +00:00
|
|
|
roundedButtons: widget.floatingButtons,
|
2020-04-23 11:39:33 +00:00
|
|
|
bgColor: Colors.red.shade900,
|
2020-04-21 16:08:01 +00:00
|
|
|
onPressed: () => _leaveCall(),
|
2020-04-21 11:26:58 +00:00
|
|
|
),
|
|
|
|
|
|
|
|
// Toggle video button
|
2020-04-21 16:08:01 +00:00
|
|
|
_FooterButton(
|
2020-04-23 11:12:40 +00:00
|
|
|
visible: _canMakeVideoCall,
|
2020-04-21 16:08:01 +00:00
|
|
|
icon: Icon(isStreamingVideo && !isVideoMuted
|
|
|
|
? Icons.videocam
|
|
|
|
: Icons.videocam_off),
|
2020-05-10 12:32:44 +00:00
|
|
|
roundedButtons: widget.floatingButtons,
|
2020-04-21 16:08:01 +00:00
|
|
|
onPressed: () => _toggleStreaming(true),
|
2020-04-20 13:20:01 +00:00
|
|
|
),
|
2020-04-21 16:20:24 +00:00
|
|
|
|
|
|
|
// Interrupt local streaming
|
2020-04-23 11:36:30 +00:00
|
|
|
PopupMenuButton<_PopupMenuOption>(
|
|
|
|
offset: Offset(0, -110),
|
|
|
|
itemBuilder: (c) => [
|
|
|
|
// Switch camera
|
|
|
|
PopupMenuItem(
|
|
|
|
enabled: isStreamingVideo,
|
|
|
|
child: Text(tr("Switch camera")),
|
|
|
|
value: _PopupMenuOption.SWITCH_CAMERA),
|
|
|
|
|
|
|
|
// Interrupt streaming
|
|
|
|
PopupMenuItem(
|
|
|
|
child: Text(tr("Stop streaming")),
|
|
|
|
value: _PopupMenuOption.STOP_STREAMING)
|
|
|
|
],
|
2020-05-10 12:32:44 +00:00
|
|
|
child: _FooterButton(
|
|
|
|
icon: Icon(Icons.menu, color: Colors.white),
|
|
|
|
onPressed: null,
|
|
|
|
roundedButtons: widget.floatingButtons,
|
|
|
|
),
|
2020-04-23 11:36:30 +00:00
|
|
|
onSelected: (d) => _handleSelectedMenuOption(d),
|
2020-04-21 16:20:24 +00:00
|
|
|
)
|
2020-04-20 13:20:01 +00:00
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
2020-04-20 08:53:25 +00:00
|
|
|
}
|
2020-04-21 16:08:01 +00:00
|
|
|
|
|
|
|
class _FooterButton extends StatelessWidget {
|
|
|
|
final Function() onPressed;
|
|
|
|
final Widget icon;
|
2020-04-23 11:12:40 +00:00
|
|
|
final bool visible;
|
2020-04-23 11:36:30 +00:00
|
|
|
final double width;
|
2020-04-23 11:39:33 +00:00
|
|
|
final Color bgColor;
|
2020-05-10 12:32:44 +00:00
|
|
|
final bool roundedButtons;
|
|
|
|
|
|
|
|
const _FooterButton({
|
|
|
|
Key key,
|
|
|
|
@required this.icon,
|
|
|
|
@required this.onPressed,
|
|
|
|
this.visible = true,
|
|
|
|
this.width = 45,
|
|
|
|
this.bgColor = Colors.black,
|
|
|
|
@required this.roundedButtons,
|
|
|
|
}) : assert(icon != null),
|
2020-04-23 11:12:40 +00:00
|
|
|
assert(visible != null),
|
2020-05-10 12:32:44 +00:00
|
|
|
assert(roundedButtons != null),
|
2020-04-21 16:08:01 +00:00
|
|
|
super(key: key);
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
2020-04-23 11:12:40 +00:00
|
|
|
if (!visible) return Container();
|
2020-05-10 12:32:44 +00:00
|
|
|
return roundedButtons ? _buildFAB() : _buildRectangleButton();
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildFAB() => Padding(
|
2020-04-23 11:39:33 +00:00
|
|
|
padding: const EdgeInsets.only(left: 6, right: 6),
|
2020-04-23 11:36:30 +00:00
|
|
|
child: Container(
|
|
|
|
width: width,
|
|
|
|
child: FloatingActionButton(
|
|
|
|
child: icon,
|
|
|
|
foregroundColor: Colors.white,
|
|
|
|
onPressed: onPressed == null ? null : () => onPressed(),
|
2020-04-23 11:39:33 +00:00
|
|
|
backgroundColor: bgColor,
|
2020-04-23 11:36:30 +00:00
|
|
|
),
|
2020-05-10 12:32:44 +00:00
|
|
|
));
|
|
|
|
|
|
|
|
Widget _buildRectangleButton() => MaterialButton(
|
|
|
|
onPressed: onPressed,
|
|
|
|
child: icon,
|
|
|
|
color: bgColor,
|
|
|
|
textColor: Colors.white,
|
|
|
|
);
|
2020-04-21 16:08:01 +00:00
|
|
|
}
|