import 'dart:async'; import 'dart:math'; import 'package:comunic/helpers/calls_helper.dart'; import 'package:comunic/helpers/conversations_helper.dart'; import 'package:comunic/helpers/events_helper.dart'; import 'package:comunic/helpers/users_helper.dart'; import 'package:comunic/lists/call_members_list.dart'; import 'package:comunic/lists/users_list.dart'; import 'package:comunic/models/call_config.dart'; import 'package:comunic/models/call_member.dart'; import 'package:comunic/models/conversation.dart'; import 'package:comunic/plugins_interface/wake_lock.dart'; import 'package:comunic/ui/routes/main_route/main_route.dart'; import 'package:comunic/ui/widgets/safe_state.dart'; import 'package:comunic/utils/account_utils.dart'; import 'package:comunic/utils/intl_utils.dart'; import 'package:comunic/utils/ui_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; /// Call screen /// /// @author Pierre Hubert enum _PopupMenuOption { STOP_STREAMING, SWITCH_CAMERA } class CallScreen extends StatefulWidget { final int convID; /// This settings should be always true except for the small call window... final bool floatingButtons; /// Use custom application bar. This function takes as parameter a nullable /// String which is the title of the conversation final PreferredSizeWidget Function(String?)? buildCustomAppBar; /// Execute custom action when the call is closed /// /// The default behavior is to pop the page final void Function()? onClose; const CallScreen({ Key? key, required this.convID, this.floatingButtons = true, this.buildCustomAppBar, this.onClose, }) : assert(convID != null), assert(convID > 0), assert(floatingButtons != null), super(key: key); @override _CallScreenState createState() => _CallScreenState(); } class _CallScreenState extends SafeState { // Widget properties int get convID => widget.convID; // State properties late Conversation _conversation; String? _convName; late CallConfig _conf; var _error = false; CallMembersList? _membersList; late UsersList _usersList; final _peersConnections = Map(); final _renderers = Map(); MediaStream? _localStream; var _isLocalStreamVisible = true; var _hideMenuBars = false; bool get _canHideMenuBar => _hideMenuBars && _canMakeVideoCall && _renderers.keys.where((f) => f != userID()).length > 0; bool get _canMakeVideoCall => _conversation.callCapabilities == CallCapabilities.VIDEO; bool get isStreamingAudio => _localStream != null && _localStream!.getAudioTracks().length > 0; bool get isStreamingVideo => _localStream != null && _localStream!.getVideoTracks().length > 0; bool get isAudioMuted => isStreamingAudio && !_localStream!.getAudioTracks()[0].enabled; bool get isVideoMuted => isStreamingVideo && !_localStream!.getVideoTracks()[0].enabled; @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 _conversation = await ConversationsHelper().getSingle(convID, force: true); _convName = await ConversationsHelper.getConversationNameAsync(_conversation); assert(_convName != null); setState(() {}); // Join the call await CallsHelper.join(convID); // Get call configuration _conf = await CallsHelper.getConfig(); // Get current members of the call final membersList = await CallsHelper.getMembers(convID); membersList.removeUser(userID()); _usersList = await UsersHelper().getListWithThrow(membersList.usersID); _membersList = membersList; setState(() {}); // Register to events this.listen((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"); } }); this.listen((e) { if (e.callID == convID) _removeMember(e.userID); }); this.listen((e) { if (e.callID == convID) _newSignal(e); }); this.listen((e) { if (e.callID == convID) _memberReady(e.peerID); }); this.listen((e) { if (e.callID == convID) _removeRemotePeerConnection(e.peerID); }); this.listen((e) { if (e.callID == convID) _leaveCall(needConfirm: false); }); // Connect to ready peers for (final peer in _membersList!.readyPeers) await this._memberReady(peer.userID); setState(() {}); // Lock device await await setWakeLock(true); } catch (e, stack) { print("Could not initialize call! $e\n$stack"); setState(() => _error = true); } } /// Do clean up operations when call screen is destroyed void _endCall() async { try { await setWakeLock(false); // Close all ready members for (final member in _membersList!.readyPeers) await _removeMember(member.userID); // Close local stream await _stopStreaming(); // Leave the call await CallsHelper.leave(convID); } catch (e, stack) { print("Could not end call properly! $e\n$stack"); } } /// Make us leave the call void _leaveCall({bool needConfirm = true}) async { if (needConfirm && !await showConfirmDialog( context: context, message: tr("Do you really want to leave this call ?"))) return; if (widget.onClose == null) MainController.of(context)!.popPage(); else widget.onClose!(); } /// Start streaming on our end Future _startStreaming(bool includeVideo) async { try { await _stopStreaming(); // Request user media _localStream = await navigator.mediaDevices.getUserMedia({ "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(() {}); // Open stream final peerConnection = await createPeerConnection(_conf.pluginConfig, { "mandatory": {}, "optional": [], }); _peersConnections[userID()] = peerConnection; for (final track in _localStream!.getTracks()) { await peerConnection.addTrack(track, _localStream!); } 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"); if (c == RTCIceConnectionState.RTCIceConnectionStateConnected) { // Add a delay of two seconds to avoid racing Timer(Duration(seconds: 2), () => CallsHelper.markPeerReady(convID)); } }; peerConnection.onSignalingState = (c) => print("New signaling state: $c"); // Create & send offer final offer = await peerConnection.createOffer({ "mandatory": { "OfferToReceiveAudio": true, "OfferToReceiveVideo": includeVideo, }, "optional": [], }); await peerConnection.setLocalDescription(offer); await CallsHelper.sendSessionDescription(convID, userID(), offer); } catch (e, stack) { print("Could not start streaming! $e\n$stack"); showSimpleSnack(context, tr("Could not start streaming!")!); } } /// Stop local streaming Future _stopStreaming() async { // Close peer connection if (_peersConnections.containsKey(userID())) { _peersConnections[userID()]!.close(); _peersConnections.remove(userID()); await CallsHelper.notifyStoppedStreaming(convID); } // Stop local stream if (_localStream != null) { await _localStream!.dispose(); _localStream = null; } // Close renderer if (_renderers.containsKey(userID())) { await _renderers[userID()]!.dispose(); _renderers.remove(userID()); } } /// Toggle menubar visibility void _toggleMenuBarsVisibility() { setState(() { _hideMenuBars = !_hideMenuBars; }); } /// Toggle local video streaming Future _toggleStreaming(bool isVideo) async { if (isVideo && !_canMakeVideoCall) { 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(() {}); } /// Call this when a user started to stream media Future _memberReady(int? memberID) async { try { _membersList!.getUser(memberID).status = MemberStatus.READY; setState(() {}); // Create peer connection final peerConnection = await createPeerConnection(_conf.pluginConfig, { "mandatory": { "OfferToReceiveAudio": true, "OfferToReceiveVideo": _canMakeVideoCall, }, "optional": [], }); _peersConnections[memberID] = peerConnection; // Create a renderer _renderers[memberID] = RTCVideoRenderer(); await _renderers[memberID]!.initialize(); // Register callbacks peerConnection.onIceCandidate = (c) => CallsHelper.sendIceCandidate(convID, memberID, c); peerConnection.onAddStream = (s) { setState(() { _membersList!.getUser(memberID).stream = s; _renderers[memberID]!.srcObject = s; }); }; // Request an offer to establish a peer connection await CallsHelper.requestOffer(convID, memberID); setState(() {}); } catch (e, stack) { print("Could not connect to remote peer $e\n$stack!"); showSimpleSnack(context, tr("Could not connect to a remote peer!")!); } } /// Call this method each time we get a new signal 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({}); await _peersConnections[ev.peerID]!.setLocalDescription(answer); await CallsHelper.sendSessionDescription(convID, ev.peerID, answer); } } // 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!")!); } } /// Call this when a user has interrupted streaming Future _removeRemotePeerConnection(int? memberID) async { final member = _membersList!.getUser(memberID); member.status = MemberStatus.JOINED; setState(() {}); if (_peersConnections.containsKey(memberID)) { await _peersConnections[memberID]!.close(); _peersConnections.remove(memberID); } if (_renderers.containsKey(memberID)) { await _renderers[memberID]!.dispose(); _renderers.remove(memberID); } if (member.stream != null) { member.stream!.dispose(); member.stream = null; } } /// Call this when a member has completely left the call Future _removeMember(int? memberID) async { await _removeRemotePeerConnection(memberID); _membersList!.removeUser(memberID); setState(() {}); } /// Toggle local stream visibility void _toggleLocalStreamVisibility() { setState(() { _isLocalStreamVisible = !_isLocalStreamVisible; }); } /// Handles menu selection void _handleSelectedMenuOption(_PopupMenuOption option) async { switch (option) { // Switch camera case _PopupMenuOption.SWITCH_CAMERA: await Helper.switchCamera(_localStream!.getVideoTracks()[0]); break; // Stop streaming case _PopupMenuOption.STOP_STREAMING: await _stopStreaming(); setState(() {}); break; } } @override Widget build(BuildContext context) { return GestureDetector( onDoubleTap: () => _toggleMenuBarsVisibility(), child: Scaffold( appBar: _canHideMenuBar ? null : _buildAppBar(), body: _buildBody(), ), ); } /// 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!), ); } /// Build widget body Widget _buildBody() { // Handle errors if (_error) return buildErrorCard(tr("Could not initialize call!"), actions: [ MaterialButton( onPressed: () => _initCall(), child: Text(tr("Try again")!.toUpperCase()), ) ]); // Check if are not ready to show call UI if (_membersList == null) return buildCenteredProgressBar(); return Column( children: [ // Members list _canHideMenuBar ? Container() : _buildMembersArea(), // Build videos area (+ buttons if floating) Expanded(child: LayoutBuilder(builder: _buildVideosArea)), // Buttons bar (if buttons are not floating) !_canHideMenuBar && !widget.floatingButtons ? _buildFooterArea() : Container(), ], ); } /// Build members area Widget _buildMembersArea() { return Center( child: Padding( padding: const EdgeInsets.all(8.0), child: RichText( text: TextSpan( children: _membersList! .map((f) => TextSpan( text: _usersList.getUser(f.userID).displayName + " ", style: TextStyle( color: f.status == MemberStatus.JOINED ? (darkTheme() ? Colors.white : Colors.black) : Colors.green))) .toList())), ), ); } /// Videos area Widget _buildVideosArea(BuildContext context, BoxConstraints constraints) { final List availableVideos = _membersList!.readyPeers .where((f) => f.hasVideoStream && _renderers.containsKey(f.userID)) .toList(); final rows = []; 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: [ // Remote peers videos Column( children: rows, ), // Local peer video isStreamingVideo && _isLocalStreamVisible ? _buildLocalVideo() : Container(), // Buttons bar (if floating) !_canHideMenuBar && widget.floatingButtons ? Positioned( bottom: 10, right: 0, left: 0, child: _buildFooterArea()) : Container(), ], ); } Widget _buildMemberVideo(int peerID) { return RTCVideoView(_renderers[peerID]!); } Widget _buildLocalVideo() { return Positioned( child: RTCVideoView(_renderers[userID()]!), height: 50, width: 50, right: 10, bottom: (_canHideMenuBar || !widget.floatingButtons ? 10 : 80), ); } /// Footer area Widget _buildFooterArea() { return Container( color: !widget.floatingButtons ? Colors.black : null, height: !widget.floatingButtons ? 40 : null, child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ // Show / hide user video button _FooterButton( visible: _canMakeVideoCall, icon: Icon(_isLocalStreamVisible ? Icons.visibility : Icons.visibility_off), roundedButtons: widget.floatingButtons, onPressed: () => _toggleLocalStreamVisibility(), ), // Toggle audio button _FooterButton( onPressed: () => _toggleStreaming(false), icon: Icon( isStreamingAudio && !isAudioMuted ? Icons.mic : Icons.mic_off), roundedButtons: widget.floatingButtons, ), // Hang up call _FooterButton( width: 60, icon: Icon(Icons.phone), roundedButtons: widget.floatingButtons, bgColor: Colors.red.shade900, onPressed: () => _leaveCall(), ), // Toggle video button _FooterButton( visible: _canMakeVideoCall, icon: Icon(isStreamingVideo && !isVideoMuted ? Icons.videocam : Icons.videocam_off), roundedButtons: widget.floatingButtons, onPressed: () => _toggleStreaming(true), ), // Interrupt local streaming 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) ], child: _FooterButton( icon: Icon(Icons.menu, color: Colors.white), onPressed: null, roundedButtons: widget.floatingButtons, ), onSelected: (d) => _handleSelectedMenuOption(d), ) ], ), ); } } class _FooterButton extends StatelessWidget { final Function()? onPressed; final Widget icon; final bool visible; final double width; final Color bgColor; 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), assert(visible != null), assert(roundedButtons != null), super(key: key); @override Widget build(BuildContext context) { if (!visible) return Container(); return roundedButtons ? _buildFAB() : _buildRectangleButton(); } Widget _buildFAB() => Padding( padding: const EdgeInsets.only(left: 6, right: 6), child: Container( width: width, child: FloatingActionButton( child: icon, foregroundColor: Colors.white, onPressed: onPressed == null ? null : () => onPressed!(), backgroundColor: bgColor, ), )); Widget _buildRectangleButton() => MaterialButton( onPressed: onPressed, child: icon, color: bgColor, textColor: Colors.white, ); }