/* * Famedly * Copyright (C) 2019, 2020, 2021 Famedly GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import 'dart:async' hide StreamView; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:just_audio/just_audio.dart'; import 'package:matrix/matrix.dart'; import 'package:universal_html/html.dart' as darthtml; import 'package:vibration/vibration.dart'; import 'package:wakelock/wakelock.dart'; import 'package:fluffychat/pages/voip/dialer/pip/pip_view.dart'; import 'package:fluffychat/pages/voip/group_call_view.dart'; import 'package:fluffychat/pages/voip/utils/call_session_state.dart'; import 'package:fluffychat/pages/voip/utils/call_state_proxy.dart'; import 'package:fluffychat/pages/voip/utils/group_call_session_state.dart'; import 'package:fluffychat/pages/voip/utils/stream_view.dart'; import 'package:fluffychat/pages/voip/utils/voip_plugin.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/avatar.dart'; class Calling extends StatefulWidget { final VoipPlugin voipPlugin; final VoidCallback onClear; const Calling({Key? key, required this.voipPlugin, required this.onClear}) : super(key: key); @override MyCallingPage createState() => MyCallingPage(); } class MyCallingPage extends State { late CallStateProxy? proxy; late Room room; String get displayName => proxy?.displayName ?? ''; MediaStream? get localStream { if (proxy != null && proxy!.localUserMediaStream != null) { return proxy!.localUserMediaStream!.stream!; } return null; } bool get isMicrophoneMuted => proxy?.isMicrophoneMuted ?? false; bool get isLocalVideoMuted => proxy?.isLocalVideoMuted ?? false; bool get isScreensharingEnabled => proxy?.isScreensharingEnabled ?? false; bool get isRemoteOnHold => proxy?.isRemoteOnHold ?? false; bool get voiceonly => proxy?.voiceonly ?? false; bool get connecting => proxy?.connecting ?? false; bool get connected => proxy?.connected ?? false; bool get ended => proxy?.ended ?? true; bool get callOnHold => proxy?.callOnHold ?? false; bool get isGroupCall => (proxy != null && proxy! is GroupCallSessionState); bool get showMicMuteButton => connected; bool get showScreenSharingButton => connected; bool get showHoldButton => connected && !isGroupCall; WrappedMediaStream get screenSharing => screenSharingStreams.elementAt(0); WrappedMediaStream? get primaryStream => proxy?.primaryStream; bool get showAnswerButton => (!connected && !connecting && !ended) && !(proxy?.isOutgoing ?? false) && !isGroupCall; bool get showVideoMuteButton => proxy != null && (proxy?.localUserMediaStream?.stream?.getVideoTracks().isNotEmpty ?? false) && connected; List get screenSharingStreams => (proxy?.screenSharingStreams ?? []); List get userMediaStreams { if (isGroupCall) { return (proxy?.userMediaStreams ?? []); } final streams = [ ...proxy?.screenSharingStreams ?? [], ...proxy?.userMediaStreams ?? [] ]; streams .removeWhere((s) => s.stream?.id == proxy?.primaryStream?.stream?.id); return streams; } String get title { if (isGroupCall) { return 'Group call'; } return '${voiceonly ? 'Voice Call' : 'Video Call'} (${proxy?.callState ?? 'Could not detect call state'})'; } String get heldTitle { var heldTitle = ''; if (proxy?.localHold ?? false) { heldTitle = '${proxy?.displayName ?? ''} held the call.'; } else if (proxy?.remoteOnHold ?? false) { heldTitle = 'You held the call.'; } return heldTitle; } // bool get speakerOn => call?.speakerOn ?? false; // bool get mirrored => call?.facingMode == 'user'; VoipPlugin get voipPlugin => widget.voipPlugin; double? _localVideoHeight; double? _localVideoWidth; EdgeInsetsGeometry? _localVideoMargin; void _playCallSound() async { const path = 'assets/sounds/call.wav'; if (kIsWeb) { darthtml.AudioElement() ..src = 'assets/$path' ..autoplay = true ..load(); } else if (PlatformInfos.isMobile) { final callSoundPlayer = AudioPlayer(); await callSoundPlayer.setAsset(path); callSoundPlayer.play(); } else { Logs().w('Playing sound not implemented for this platform!'); } } @override void initState() { super.initState(); initialize(); _playCallSound(); } void initialize() async { if (voipPlugin.currentGroupCall != null) { room = voipPlugin.currentGroupCall!.room; proxy = GroupCallSessionState(voipPlugin.currentGroupCall!); } else if (voipPlugin.currentCall != null) { room = voipPlugin.currentCall!.room; proxy = CallSessionState(voipPlugin.currentCall!); } else { throw Exception('No call or group call found'); } proxy!.onStateChanged(_handleCallState); if (!proxy!.voiceonly) { try { // Enable wakelock (keep screen on) unawaited(Wakelock.enable()); } catch (_) {} } } void cleanUp() { Timer( const Duration(seconds: 2), () => widget.onClear.call(), ); if (!proxy!.voiceonly) { try { unawaited(Wakelock.disable()); } catch (_) {} } } @override void dispose() { super.dispose(); cleanUp(); } void _resizeLocalVideo(Orientation orientation) { final shortSide = min( MediaQuery.of(context).size.width, MediaQuery.of(context).size.height); _localVideoMargin = userMediaStreams.isNotEmpty ? const EdgeInsets.only(top: 20.0, right: 20.0) : EdgeInsets.zero; _localVideoWidth = userMediaStreams.isNotEmpty ? shortSide / 3 : MediaQuery.of(context).size.width; _localVideoHeight = userMediaStreams.isNotEmpty ? shortSide / 4 : MediaQuery.of(context).size.height; } void _handleCallState() { Logs().v('CallingPage::handleCallState'); if ({'connected', 'ended'}.contains(proxy!.callState.toLowerCase())) { Vibration.vibrate(duration: 200); } if (mounted) { setState(() { if (proxy!.callState.toLowerCase() == 'ended') cleanUp(); }); } } void handleAnswerButtonClick() { if (mounted) { setState(() { proxy?.answer(); }); } } void handleHangupButtonClick() { _hangUp(); } void _hangUp() { setState(() { proxy!.hangup(); }); } void handleMicMuteButtonClick() { setState(() { proxy?.setMicrophoneMuted(!isMicrophoneMuted); }); } void handleScreenSharingButtonClick() async { if (!proxy!.isScreensharingEnabled) { await FlutterForegroundTask.init( androidNotificationOptions: AndroidNotificationOptions( channelId: 'notification_channel_id', channelName: 'Foreground Notification', channelDescription: 'This notification appears when the foreground service is running.', ), ); FlutterForegroundTask.startService( notificationTitle: 'Screen sharing', notificationText: 'You are sharing your screen in famedly', ); } else { FlutterForegroundTask.stopService(); } setState(() { proxy?.setScreensharingEnabled(!isScreensharingEnabled); }); } void handleHoldButtonClick() { setState(() { proxy?.setRemoteOnHold(!isRemoteOnHold); }); } void handleVideoMuteButtonClick() { setState(() { proxy?.setLocalVideoMuted(!isLocalVideoMuted); }); } // void _switchCamera() async { // if (call.localUserMediaStream != null) { // await Helper.switchCamera( // call.localUserMediaStream!.stream!.getVideoTracks()[0]); // if (kIsMobile) { // call.facingMode == 'user' // ? call.facingMode = 'environment' // : call.facingMode = 'user'; // } // } // setState(() {}); // } /* void _switchSpeaker() { setState(() { session.setSpeakerOn(); }); } */ List _buildActionButtons(bool isFloating) { if (isFloating || proxy == null) { return []; } /* var switchSpeakerButton = FloatingActionButton( heroTag: 'switchSpeaker', child: Icon(_speakerOn ? Icons.volume_up : Icons.volume_off), onPressed: _switchSpeaker, foregroundColor: Colors.black54, backgroundColor: Theme.of(context).backgroundColor, ); */ return [ FloatingActionButton( heroTag: 'hangup', onPressed: handleHangupButtonClick, tooltip: 'Hangup', backgroundColor: proxy!.callState.toLowerCase() == 'ended' ? Colors.black45 : Colors.red, child: const Icon(Icons.call_end), ), if (showAnswerButton) FloatingActionButton( heroTag: 'answer', onPressed: handleAnswerButtonClick, tooltip: 'Answer', backgroundColor: Colors.green, child: const Icon(Icons.phone), ), if (showMicMuteButton) FloatingActionButton( heroTag: 'muteMic', onPressed: handleMicMuteButtonClick, foregroundColor: isMicrophoneMuted ? Colors.black26 : Colors.white, backgroundColor: isMicrophoneMuted ? Colors.white : Colors.black45, child: Icon(isMicrophoneMuted ? Icons.mic_off : Icons.mic), ), if (showScreenSharingButton) FloatingActionButton( heroTag: 'screenSharing', onPressed: handleScreenSharingButtonClick, foregroundColor: isScreensharingEnabled ? Colors.black26 : Colors.white, backgroundColor: isScreensharingEnabled ? Colors.white : Colors.black45, child: const Icon(Icons.desktop_mac), ), if (showHoldButton) FloatingActionButton( heroTag: 'hold', onPressed: handleHoldButtonClick, foregroundColor: isRemoteOnHold ? Colors.black26 : Colors.white, backgroundColor: isRemoteOnHold ? Colors.white : Colors.black45, child: const Icon(Icons.pause), ), // FloatingActionButton( // heroTag: 'switchCamera', // onPressed: _switchCamera, // backgroundColor: Colors.black45, // child: const Icon(Icons.switch_camera), // ), if (showVideoMuteButton) FloatingActionButton( heroTag: 'muteCam', onPressed: handleVideoMuteButtonClick, foregroundColor: isLocalVideoMuted ? Colors.black26 : Colors.white, backgroundColor: isLocalVideoMuted ? Colors.white : Colors.black45, child: Icon(isLocalVideoMuted ? Icons.videocam_off : Icons.videocam), ), ]; } List _buildP2PView(Orientation orientation, bool isFloating) { final stackWidgets = []; if (proxy == null || proxy!.ended) { return stackWidgets; } if (proxy!.localHold || proxy!.remoteOnHold) { var title = ''; if (proxy!.localHold) { title = '${proxy!.displayName} held the call.'; } else if (proxy!.remoteOnHold) { title = 'You held the call.'; } stackWidgets.add(Center( child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.pause, size: 48.0, color: Colors.white, ), Text( title, style: const TextStyle( color: Colors.white, fontSize: 24.0, ), ) ]), )); return stackWidgets; } if (primaryStream != null) { stackWidgets.add( Center( child: StreamView( primaryStream!, mainView: true, matrixClient: voipPlugin.client, ), ), ); } if (isFloating || !connected) { return stackWidgets; } _resizeLocalVideo(orientation); if (userMediaStreams.isEmpty) { return stackWidgets; } final secondaryStreamViews = []; for (final stream in userMediaStreams) { secondaryStreamViews.add(SizedBox( width: _localVideoWidth, height: _localVideoHeight, child: StreamView( stream, matrixClient: voipPlugin.client, ), )); secondaryStreamViews.add(const SizedBox(height: 10)); } if (secondaryStreamViews.isNotEmpty) { stackWidgets.add(Container( padding: const EdgeInsets.fromLTRB(0, 20, 0, 120), alignment: Alignment.bottomRight, child: Container( width: _localVideoWidth, margin: _localVideoMargin, child: Column( children: secondaryStreamViews, ), ), )); } return stackWidgets; } @override Widget build(BuildContext context) { return PIPView(builder: (context, isFloating) { return Scaffold( resizeToAvoidBottomInset: !isFloating, floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButton: SizedBox( width: 320.0, height: 150.0, child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: _buildActionButtons(isFloating).reversed.toList(), ), ), body: OrientationBuilder( builder: (BuildContext context, Orientation orientation) { return Container( decoration: const BoxDecoration( color: Colors.black87, ), child: Stack(children: [ if (isGroupCall) GroupCallView( call: proxy as GroupCallSessionState, client: voipPlugin.client) else ..._buildP2PView(orientation, isFloating), if (!isFloating) Positioned( top: 24.0, left: 24.0, child: IconButton( color: Colors.red, icon: const Icon(Icons.arrow_back), onPressed: () { PIPView.of(context)?.setFloating(true); }, )) ])); })); }); } }