diff --git a/.gitignore b/.gitignore index 66aa9a5d..fea47780 100644 --- a/.gitignore +++ b/.gitignore @@ -10,11 +10,10 @@ .buildlog/ .history .svn/ -lib/generated_plugin_registrant.dart prime # libolm package -/assets/js/package/* +assets/assets/js/package/* # IntelliJ related *.iml @@ -38,7 +37,6 @@ prime /build/ # Web related -lib/generated_plugin_registrant.dart docs/build/ docs/.jekyll-cache/ docs/_site/ diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index ed9fc6b8..977fad33 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2949,5 +2949,6 @@ "placeholders": { "number": {} } - } + }, + "groupCall": "Group call" } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 577d2aea..32b2e704 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -997,15 +997,22 @@ class ChatController extends State { message: L10n.of(context)!.videoCallsBetaWarning, cancelLabel: L10n.of(context)!.cancel, actions: [ + if (room!.isDirectChat) + SheetAction( + label: L10n.of(context)!.voiceCall, + icon: Icons.phone_outlined, + key: CallType.kVoice, + ), + if (room!.isDirectChat) + SheetAction( + label: L10n.of(context)!.videoCall, + icon: Icons.video_call_outlined, + key: CallType.kVideo, + ), SheetAction( - label: L10n.of(context)!.voiceCall, - icon: Icons.phone_outlined, - key: CallType.kVoice, - ), - SheetAction( - label: L10n.of(context)!.videoCall, - icon: Icons.video_call_outlined, - key: CallType.kVideo, + label: L10n.of(context)!.groupCall, + icon: Icons.people, + key: CallType.kGroup, ), ], ); @@ -1017,11 +1024,20 @@ class ChatController extends State { Matrix.of(context).voipPlugin!.voip.requestTurnServerCredentials()); if (success.result != null) { final voipPlugin = Matrix.of(context).voipPlugin; - await voipPlugin!.voip.inviteToCall(room!.id, callType).catchError((e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text((e as Object).toLocalizedString(context))), - ); - }); + if ({CallType.kVideo, CallType.kVoice}.contains(callType)) { + await voipPlugin!.voip.inviteToCall(room!.id, callType).catchError((e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e), + ), + ); + }); + } else { + final groupCall = + await voipPlugin!.voip.fetchOrCreateGroupCall(room!.id); + groupCall?.enter(); + Logs().d('Group call should be enter now'); + } } else { await showOkAlertDialog( context: context, diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index e5c7bfd2..2deb9f48 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -110,8 +110,7 @@ class ChatView extends StatelessWidget { ]; } else { return [ - if (Matrix.of(context).voipPlugin != null && - controller.room!.isDirectChat) + if (Matrix.of(context).voipPlugin != null) IconButton( onPressed: controller.onPhoneButtonTap, icon: const Icon(Icons.call_outlined), diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 9037c9cf..b61bda52 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -22,11 +22,11 @@ import 'package:fluffychat/utils/platform_infos.dart'; import '../../../utils/account_bundles.dart'; import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart'; import '../../utils/url_launcher.dart'; -import '../../utils/voip/callkeep_manager.dart'; import '../../widgets/fluffy_chat_app.dart'; import '../../widgets/matrix.dart'; import '../bootstrap/bootstrap_dialog.dart'; import '../settings_account/settings_account.dart'; +import '../voip/utils/callkeep_manager.dart'; import 'package:fluffychat/utils/tor_stub.dart' if (dart.library.html) 'package:tor_detector_web/tor_detector_web.dart'; diff --git a/lib/pages/dialer/dialer.dart b/lib/pages/dialer/dialer.dart deleted file mode 100644 index 20e35fd8..00000000 --- a/lib/pages/dialer/dialer.dart +++ /dev/null @@ -1,600 +0,0 @@ -/* - * 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'; -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_gen/gen_l10n/l10n.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:matrix/matrix.dart'; -import 'package:vibration/vibration.dart'; -import 'package:wakelock/wakelock.dart'; - -import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'pip/pip_view.dart'; - -class _StreamView extends StatelessWidget { - const _StreamView(this.wrappedStream, - {Key? key, this.mainView = false, required this.matrixClient}) - : super(key: key); - - final WrappedMediaStream wrappedStream; - final Client matrixClient; - - final bool mainView; - - Uri? get avatarUrl => wrappedStream.getUser().avatarUrl; - - String? get displayName => wrappedStream.displayName; - - String get avatarName => wrappedStream.avatarName; - - bool get isLocal => wrappedStream.isLocal(); - - bool get mirrored => - wrappedStream.isLocal() && - wrappedStream.purpose == SDPStreamMetadataPurpose.Usermedia; - - bool get audioMuted => wrappedStream.audioMuted; - - bool get videoMuted => wrappedStream.videoMuted; - - bool get isScreenSharing => - wrappedStream.purpose == SDPStreamMetadataPurpose.Screenshare; - - @override - Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - color: Colors.black54, - ), - child: Stack( - alignment: Alignment.center, - children: [ - if (videoMuted) - Container( - color: Colors.transparent, - ), - if (!videoMuted) - RTCVideoView( - // yes, it must explicitly be casted even though I do not feel - // comfortable with it... - wrappedStream.renderer as RTCVideoRenderer, - mirror: mirrored, - objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, - ), - if (videoMuted) - Positioned( - child: Avatar( - mxContent: avatarUrl, - name: displayName, - size: mainView ? 96 : 48, - client: matrixClient, - // textSize: mainView ? 36 : 24, - // matrixClient: matrixClient, - )), - if (!isScreenSharing) - Positioned( - left: 4.0, - bottom: 4.0, - child: Icon(audioMuted ? Icons.mic_off : Icons.mic, - color: Colors.white, size: 18.0), - ) - ], - )); - } -} - -class Calling extends StatefulWidget { - final VoidCallback? onClear; - final BuildContext context; - final String callId; - final CallSession call; - final Client client; - - const Calling( - {required this.context, - required this.call, - required this.client, - required this.callId, - this.onClear, - Key? key}) - : super(key: key); - - @override - MyCallingPage createState() => MyCallingPage(); -} - -class MyCallingPage extends State { - Room? get room => call.room; - - String get displayName => call.displayName ?? ''; - - String get callId => widget.callId; - - CallSession get call => widget.call; - - MediaStream? get localStream { - if (call.localUserMediaStream != null) { - return call.localUserMediaStream!.stream!; - } - return null; - } - - MediaStream? get remoteStream { - if (call.getRemoteStreams.isNotEmpty) { - return call.getRemoteStreams[0].stream!; - } - return null; - } - - bool get speakerOn => call.speakerOn; - - bool get isMicrophoneMuted => call.isMicrophoneMuted; - - bool get isLocalVideoMuted => call.isLocalVideoMuted; - - bool get isScreensharingEnabled => call.screensharingEnabled; - - bool get isRemoteOnHold => call.remoteOnHold; - - bool get voiceonly => call.type == CallType.kVoice; - - bool get connecting => call.state == CallState.kConnecting; - - bool get connected => call.state == CallState.kConnected; - - bool get mirrored => call.facingMode == 'user'; - - List get streams => call.streams; - double? _localVideoHeight; - double? _localVideoWidth; - EdgeInsetsGeometry? _localVideoMargin; - CallState? _state; - - void _playCallSound() async { - const path = 'assets/sounds/call.ogg'; - if (kIsWeb || PlatformInfos.isMobile || PlatformInfos.isMacOS) { - final player = AudioPlayer(); - await player.setAsset(path); - player.play(); - } else { - Logs().w('Playing sound not implemented for this platform!'); - } - } - - @override - void initState() { - super.initState(); - initialize(); - _playCallSound(); - } - - void initialize() async { - final call = this.call; - call.onCallStateChanged.stream.listen(_handleCallState); - call.onCallEventChanged.stream.listen((event) { - if (event == CallEvent.kFeedsChanged) { - setState(() { - call.tryRemoveStopedStreams(); - }); - } else if (event == CallEvent.kLocalHoldUnhold || - event == CallEvent.kRemoteHoldUnhold) { - setState(() {}); - Logs().i( - 'Call hold event: local ${call.localHold}, remote ${call.remoteOnHold}'); - } - }); - _state = call.state; - - if (call.type == CallType.kVideo) { - try { - // Enable wakelock (keep screen on) - unawaited(Wakelock.enable()); - } catch (_) {} - } - } - - void cleanUp() { - Timer( - const Duration(seconds: 2), - () => widget.onClear?.call(), - ); - if (call.type == CallType.kVideo) { - try { - unawaited(Wakelock.disable()); - } catch (_) {} - } - } - - @override - void dispose() { - super.dispose(); - call.cleanUp.call(); - } - - void _resizeLocalVideo(Orientation orientation) { - final shortSide = min( - MediaQuery.of(context).size.width, MediaQuery.of(context).size.height); - _localVideoMargin = remoteStream != null - ? const EdgeInsets.only(top: 20.0, right: 20.0) - : EdgeInsets.zero; - _localVideoWidth = remoteStream != null - ? shortSide / 3 - : MediaQuery.of(context).size.width; - _localVideoHeight = remoteStream != null - ? shortSide / 4 - : MediaQuery.of(context).size.height; - } - - void _handleCallState(CallState state) { - Logs().v('CallingPage::handleCallState: ${state.toString()}'); - if ({CallState.kConnected, CallState.kEnded}.contains(state)) { - try { - Vibration.vibrate(duration: 200); - } catch (e) { - Logs().e('[Dialer] could not vibrate for call updates'); - } - } - - if (mounted) { - setState(() { - _state = state; - if (_state == CallState.kEnded) cleanUp(); - }); - } - } - - void _answerCall() { - setState(() { - call.answer(); - }); - } - - void _hangUp() { - setState(() { - if (call.isRinging) { - call.reject(); - } else { - call.hangup(); - } - }); - } - - void _muteMic() { - setState(() { - call.setMicrophoneMuted(!call.isMicrophoneMuted); - }); - } - - void _screenSharing() async { - if (PlatformInfos.isAndroid) { - if (!call.screensharingEnabled) { - await FlutterForegroundTask.init( - androidNotificationOptions: AndroidNotificationOptions( - channelId: 'notification_channel_id', - channelName: 'Foreground Notification', - channelDescription: L10n.of(context)!.foregroundServiceRunning, - ), - ); - FlutterForegroundTask.startService( - notificationTitle: L10n.of(context)!.screenSharingTitle, - notificationText: L10n.of(context)!.screenSharingDetail); - } else { - FlutterForegroundTask.stopService(); - } - } - - setState(() { - call.setScreensharingEnabled(!call.screensharingEnabled); - }); - } - - void _remoteOnHold() { - setState(() { - call.setRemoteOnHold(!call.remoteOnHold); - }); - } - - void _muteCamera() { - setState(() { - call.setLocalVideoMuted(!call.isLocalVideoMuted); - }); - } - - void _switchCamera() async { - if (call.localUserMediaStream != null) { - await Helper.switchCamera( - call.localUserMediaStream!.stream!.getVideoTracks()[0]); - if (PlatformInfos.isMobile) { - call.facingMode == 'user' - ? call.facingMode = 'environment' - : call.facingMode = 'user'; - } - } - setState(() {}); - } - - /* - void _switchSpeaker() { - setState(() { - session.setSpeakerOn(); - }); - } - */ - - List _buildActionButtons(bool isFloating) { - if (isFloating) { - return []; - } - - final switchCameraButton = FloatingActionButton( - heroTag: 'switchCamera', - onPressed: _switchCamera, - backgroundColor: Colors.black45, - child: const Icon(Icons.switch_camera), - ); - /* - var switchSpeakerButton = FloatingActionButton( - heroTag: 'switchSpeaker', - child: Icon(_speakerOn ? Icons.volume_up : Icons.volume_off), - onPressed: _switchSpeaker, - foregroundColor: Colors.black54, - backgroundColor: Theme.of(context).backgroundColor, - ); - */ - final hangupButton = FloatingActionButton( - heroTag: 'hangup', - onPressed: _hangUp, - tooltip: 'Hangup', - backgroundColor: _state == CallState.kEnded ? Colors.black45 : Colors.red, - child: const Icon(Icons.call_end), - ); - - final answerButton = FloatingActionButton( - heroTag: 'answer', - onPressed: _answerCall, - tooltip: 'Answer', - backgroundColor: Colors.green, - child: const Icon(Icons.phone), - ); - - final muteMicButton = FloatingActionButton( - heroTag: 'muteMic', - onPressed: _muteMic, - foregroundColor: isMicrophoneMuted ? Colors.black26 : Colors.white, - backgroundColor: isMicrophoneMuted ? Colors.white : Colors.black45, - child: Icon(isMicrophoneMuted ? Icons.mic_off : Icons.mic), - ); - - final screenSharingButton = FloatingActionButton( - heroTag: 'screenSharing', - onPressed: _screenSharing, - foregroundColor: isScreensharingEnabled ? Colors.black26 : Colors.white, - backgroundColor: isScreensharingEnabled ? Colors.white : Colors.black45, - child: const Icon(Icons.desktop_mac), - ); - - final holdButton = FloatingActionButton( - heroTag: 'hold', - onPressed: _remoteOnHold, - foregroundColor: isRemoteOnHold ? Colors.black26 : Colors.white, - backgroundColor: isRemoteOnHold ? Colors.white : Colors.black45, - child: const Icon(Icons.pause), - ); - - final muteCameraButton = FloatingActionButton( - heroTag: 'muteCam', - onPressed: _muteCamera, - foregroundColor: isLocalVideoMuted ? Colors.black26 : Colors.white, - backgroundColor: isLocalVideoMuted ? Colors.white : Colors.black45, - child: Icon(isLocalVideoMuted ? Icons.videocam_off : Icons.videocam), - ); - - switch (_state) { - case CallState.kRinging: - case CallState.kInviteSent: - case CallState.kCreateAnswer: - case CallState.kConnecting: - return call.isOutgoing - ? [hangupButton] - : [answerButton, hangupButton]; - case CallState.kConnected: - return [ - muteMicButton, - //switchSpeakerButton, - if (!voiceonly && !kIsWeb) switchCameraButton, - if (!voiceonly) muteCameraButton, - if (PlatformInfos.isMobile || PlatformInfos.isWeb) - screenSharingButton, - holdButton, - hangupButton, - ]; - case CallState.kEnded: - return [ - hangupButton, - ]; - case CallState.kFledgling: - // TODO: Handle this case. - break; - case CallState.kWaitLocalMedia: - // TODO: Handle this case. - break; - case CallState.kCreateOffer: - // TODO: Handle this case. - break; - case null: - // TODO: Handle this case. - break; - } - return []; - } - - List _buildContent(Orientation orientation, bool isFloating) { - final stackWidgets = []; - - final call = this.call; - if (call.callHasEnded) { - return stackWidgets; - } - - if (call.localHold || call.remoteOnHold) { - var title = ''; - if (call.localHold) { - title = '${call.displayName} held the call.'; - } else if (call.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; - } - - var primaryStream = call.remoteScreenSharingStream ?? - call.localScreenSharingStream ?? - call.remoteUserMediaStream ?? - call.localUserMediaStream; - - if (!connected) { - primaryStream = call.localUserMediaStream; - } - - if (primaryStream != null) { - stackWidgets.add(Center( - child: _StreamView(primaryStream, - mainView: true, matrixClient: widget.client), - )); - } - - if (isFloating || !connected) { - return stackWidgets; - } - - _resizeLocalVideo(orientation); - - if (call.getRemoteStreams.isEmpty) { - return stackWidgets; - } - - final secondaryStreamViews = []; - - if (call.remoteScreenSharingStream != null) { - final remoteUserMediaStream = call.remoteUserMediaStream; - secondaryStreamViews.add(SizedBox( - width: _localVideoWidth, - height: _localVideoHeight, - child: _StreamView(remoteUserMediaStream!, matrixClient: widget.client), - )); - secondaryStreamViews.add(const SizedBox(height: 10)); - } - - final localStream = - call.localUserMediaStream ?? call.localScreenSharingStream; - if (localStream != null && !isFloating) { - secondaryStreamViews.add(SizedBox( - width: _localVideoWidth, - height: _localVideoHeight, - child: _StreamView(localStream, matrixClient: widget.client), - )); - secondaryStreamViews.add(const SizedBox(height: 10)); - } - - if (call.localScreenSharingStream != null && !isFloating) { - secondaryStreamViews.add(SizedBox( - width: _localVideoWidth, - height: _localVideoHeight, - child: _StreamView(call.remoteUserMediaStream!, - matrixClient: widget.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))), - body: OrientationBuilder( - builder: (BuildContext context, Orientation orientation) { - return Container( - decoration: const BoxDecoration( - color: Colors.black87, - ), - child: Stack(children: [ - ..._buildContent(orientation, isFloating), - if (!isFloating) - Positioned( - top: 24.0, - left: 24.0, - child: IconButton( - color: Colors.black45, - icon: const Icon(Icons.arrow_back), - onPressed: () { - PIPView.of(context)?.setFloating(true); - }, - )) - ])); - })); - }); - } -} diff --git a/lib/pages/settings_chat/settings_chat_view.dart b/lib/pages/settings_chat/settings_chat_view.dart index e62a9411..4195273e 100644 --- a/lib/pages/settings_chat/settings_chat_view.dart +++ b/lib/pages/settings_chat/settings_chat_view.dart @@ -6,8 +6,8 @@ import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; +import 'package:fluffychat/pages/voip/utils/callkeep_manager.dart'; import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/utils/voip/callkeep_manager.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/settings_switch_list_tile.dart'; diff --git a/lib/pages/voip/dialer/dialer.dart b/lib/pages/voip/dialer/dialer.dart new file mode 100644 index 00000000..da3713b4 --- /dev/null +++ b/lib/pages/voip/dialer/dialer.dart @@ -0,0 +1,508 @@ +/* + * 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); + }, + )) + ])); + })); + }); + } +} diff --git a/lib/pages/dialer/pip/constants.dart b/lib/pages/voip/dialer/pip/constants.dart similarity index 100% rename from lib/pages/dialer/pip/constants.dart rename to lib/pages/voip/dialer/pip/constants.dart diff --git a/lib/pages/dialer/pip/dismiss_keyboard.dart b/lib/pages/voip/dialer/pip/dismiss_keyboard.dart similarity index 100% rename from lib/pages/dialer/pip/dismiss_keyboard.dart rename to lib/pages/voip/dialer/pip/dismiss_keyboard.dart diff --git a/lib/pages/dialer/pip/pip_view.dart b/lib/pages/voip/dialer/pip/pip_view.dart similarity index 100% rename from lib/pages/dialer/pip/pip_view.dart rename to lib/pages/voip/dialer/pip/pip_view.dart diff --git a/lib/pages/voip/group_call_view.dart b/lib/pages/voip/group_call_view.dart new file mode 100644 index 00000000..981f0892 --- /dev/null +++ b/lib/pages/voip/group_call_view.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:flutter_reorderable_grid_view/entities/order_update_entity.dart'; +import 'package:flutter_reorderable_grid_view/widgets/widgets.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/voip/utils/group_call_session_state.dart'; +import 'package:fluffychat/pages/voip/utils/stream_view.dart'; +import 'dialer/dialer.dart'; + +class GroupCallView extends StatefulWidget { + final GroupCallSessionState call; + final Client client; + const GroupCallView({ + Key? key, + required this.call, + required this.client, + }) : super(key: key); + + @override + State createState() => _GroupCallViewState(); +} + +class _GroupCallViewState extends State { + WrappedMediaStream? get primaryStream => widget.call.primaryStream; + + List get screenSharingStreams => + widget.call.screenSharingStreams; + List get userMediaStreams => widget.call.userMediaStreams; + + WrappedMediaStream? get primaryScreenShare => + widget.call.screenSharingStreams.first; + + void updateStreams() { + Logs().i('Group calls, updating streams'); + widget.call.groupCall.onStreamAdd.stream.listen((event) { + if (event.purpose == SDPStreamMetadataPurpose.Usermedia) { + if (userMediaStreams.indexWhere((element) => element == event) == -1) { + if (mounted) { + setState(() { + userMediaStreams.add(event); + }); + } + } + } else if (event.purpose == SDPStreamMetadataPurpose.Screenshare) { + if (screenSharingStreams.indexWhere((element) => element == event) == + -1) { + if (mounted) { + setState(() { + screenSharingStreams.add(event); + }); + } + } + } + }); + widget.call.groupCall.onStreamRemoved.stream.listen((event) { + if (event.purpose == SDPStreamMetadataPurpose.Usermedia) { + userMediaStreams + .removeWhere((element) => element.stream!.id == event.stream!.id); + if (mounted) { + setState(() { + userMediaStreams.remove(event); + }); + } + } else if (event.purpose == SDPStreamMetadataPurpose.Screenshare) { + screenSharingStreams + .removeWhere((element) => element.stream!.id == event.stream!.id); + if (mounted) { + setState(() { + screenSharingStreams.remove(event); + }); + } + } + }); + } + + @override + void initState() { + updateStreams(); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + if (screenSharingStreams.isNotEmpty) { + return Center( + child: Stack( + children: [ + StreamView( + screenSharingStreams.first, + matrixClient: widget.client, + ), + Positioned( + bottom: 150, + child: SizedBox( + // height: 400, + width: MediaQuery.of(context).size.width, + child: CallGrid( + call: widget.call, + screenSharing: true, + userMediaStreams: userMediaStreams, + client: widget.client, + )), + ), + ], + ), + ); + } else { + // No one is screen sharing, show avatars and user streams here + return Center( + child: CallGrid( + call: widget.call, + screenSharing: false, + userMediaStreams: userMediaStreams, + client: widget.client, + ), + ); + } + } +} + +class CallGrid extends StatefulWidget { + final GroupCallSessionState call; + final Client client; + final bool screenSharing; + final List userMediaStreams; + const CallGrid( + {Key? key, + required this.call, + required this.client, + required this.screenSharing, + required this.userMediaStreams}) + : super(key: key); + + @override + State createState() => _CallGridState(); +} + +class _CallGridState extends State { + int axisCount() { + var orientation = MediaQuery.of(context).orientation; + if (MediaQuery.of(context).size.width >= 600) { + orientation = Orientation.landscape; + } + if (widget.screenSharing) { + return orientation == Orientation.portrait ? 4 : 6; + } + if (widget.call.groupCall.participants.length > 2 || + MediaQuery.of(context).size.width >= 600) { + return orientation == Orientation.portrait ? 2 : 4; + } else { + return 1; + } + } + + final _scrollController = ScrollController(); + final _gridViewKey = GlobalKey(); + @override + Widget build(BuildContext context) { + final participants = widget.call.groupCall.participants; + + Logs().w(participants + .map((e) => widget.userMediaStreams + .firstWhereOrNull((element) => element.userId == e.id)) + .toString()); + + // adding a user to a call sometimes results in multiple userMediaStreams + // for the saem user, Just pick the latest one then. + final generatedChildren = participants + .map( + (participant) => widget.userMediaStreams + .lastWhereOrNull((stream) => stream.userId == participant.id), + ) + .where((element) => element != null) + .map( + (userMediaStream) => StreamView( + userMediaStream!, + key: Key(userMediaStream.userId), + matrixClient: widget.client, + ), + ) + .toList(); + + if (widget.screenSharing && + MediaQuery.of(context).orientation == Orientation.landscape) { + return Container(); + } + return ReorderableBuilder( + scrollController: _scrollController, + onReorder: (List orderUpdateEntities) { + for (final orderUpdateEntity in orderUpdateEntities) { + final reorderParticipant = + participants.removeAt(orderUpdateEntity.oldIndex); + participants.insert(orderUpdateEntity.newIndex, reorderParticipant); + } + }, + builder: (children) { + return GridView( + shrinkWrap: true, + key: _gridViewKey, + controller: _scrollController, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: axisCount(), + ), + children: children, + ); + }, + children: generatedChildren, + ); + } +} diff --git a/lib/pages/voip/utils/call_session_state.dart b/lib/pages/voip/utils/call_session_state.dart new file mode 100644 index 00000000..84b035e0 --- /dev/null +++ b/lib/pages/voip/utils/call_session_state.dart @@ -0,0 +1,192 @@ +import 'package:matrix/matrix.dart'; + +import 'call_state_proxy.dart'; + +class CallSessionState implements CallStateProxy { + final CallSession call; + Function()? callback; + CallSessionState(this.call) { + call.onCallEventChanged.stream.listen((CallEvent event) { + if (event == CallEvent.kState || + event == CallEvent.kFeedsChanged || + event == CallEvent.kLocalHoldUnhold || + event == CallEvent.kRemoteHoldUnhold) { + if (event == CallEvent.kFeedsChanged) { + call.tryRemoveStopedStreams(); + } + callback?.call(); + } + }); + call.onCallStateChanged.stream.listen((state) { + callback?.call(); + }); + } + + @override + bool get voiceonly => call.type == CallType.kVoice; + + @override + bool get connecting => call.state == CallState.kConnecting; + + @override + bool get connected => call.state == CallState.kConnected; + + @override + bool get ended => call.state == CallState.kEnded; + + @override + bool get isOutgoing => call.isOutgoing; + + @override + bool get ringingPlay => call.state == CallState.kInviteSent; + + @override + void answer() => call.answer(); + + @override + void enter() { + // TODO: implement enter + } + + @override + void hangup() { + if (call.isRinging) { + call.reject(); + } else { + call.hangup(); + } + } + + @override + bool get isLocalVideoMuted => call.isLocalVideoMuted; + + @override + bool get isMicrophoneMuted => call.isMicrophoneMuted; + + @override + bool get isRemoteOnHold => call.remoteOnHold; + + @override + bool get localHold => call.localHold; + + @override + bool get remoteOnHold => call.remoteOnHold; + + @override + bool get isScreensharingEnabled => call.screensharingEnabled; + + @override + bool get callOnHold => call.localHold || call.remoteOnHold; + + @override + void setLocalVideoMuted(bool muted) { + call.setLocalVideoMuted(muted); + callback?.call(); + } + + @override + void setMicrophoneMuted(bool muted) { + call.setMicrophoneMuted(muted); + // TODO(Nico): Refactor this to be more granular + callback?.call(); + } + + @override + void setRemoteOnHold(bool onHold) { + call.setRemoteOnHold(onHold); + callback?.call(); + } + + @override + void setScreensharingEnabled(bool enabled) { + call.setScreensharingEnabled(enabled); + callback?.call(); + } + + @override + String get callState { + switch (call.state) { + case CallState.kCreateAnswer: + case CallState.kFledgling: + case CallState.kWaitLocalMedia: + case CallState.kCreateOffer: + break; + case CallState.kRinging: + state = 'Ringing'; + break; + case CallState.kInviteSent: + state = 'Invite Sent'; + break; + case CallState.kConnecting: + state = 'Connecting'; + break; + case CallState.kConnected: + state = 'Connected'; + break; + case CallState.kEnded: + state = 'Ended'; + break; + } + return state; + } + + String state = 'New Call'; + @override + WrappedMediaStream? get localUserMediaStream => call.localUserMediaStream; + @override + WrappedMediaStream? get localScreenSharingStream => + call.localScreenSharingStream; + + @override + List get screenSharingStreams { + final streams = []; + if (connected) { + if (call.remoteScreenSharingStream != null) { + streams.add(call.remoteScreenSharingStream!); + } + if (call.localScreenSharingStream != null) { + streams.add(call.localScreenSharingStream!); + } + } + return streams; + } + + @override + List get userMediaStreams { + final streams = []; + if (connected) { + if (call.remoteUserMediaStream != null) { + streams.add(call.remoteUserMediaStream!); + } + if (call.localUserMediaStream != null) { + streams.add(call.localUserMediaStream!); + } + } + return streams; + } + + @override + WrappedMediaStream? get primaryStream { + if (screenSharingStreams.isNotEmpty) { + return screenSharingStreams.first; + } + + if (userMediaStreams.isNotEmpty) { + return userMediaStreams.first; + } + + if (!connected) { + return call.localUserMediaStream; + } + + return call.localScreenSharingStream ?? call.localUserMediaStream; + } + + @override + String? get displayName => call.displayName; + + @override + void onStateChanged(Function() handler) { + callback = handler; + } +} diff --git a/lib/pages/voip/utils/call_state_proxy.dart b/lib/pages/voip/utils/call_state_proxy.dart new file mode 100644 index 00000000..7b690abf --- /dev/null +++ b/lib/pages/voip/utils/call_state_proxy.dart @@ -0,0 +1,45 @@ +import 'package:matrix/matrix.dart'; + +abstract class CallStateProxy { + String? get displayName; + bool get isMicrophoneMuted; + bool get isLocalVideoMuted; + bool get isScreensharingEnabled; + bool get isRemoteOnHold; + bool get localHold; + bool get remoteOnHold; + bool get voiceonly; + bool get connecting; + bool get connected; + bool get ended; + bool get callOnHold; + bool get isOutgoing; + bool get ringingPlay; + String get callState; + + void answer(); + + void hangup(); + + void enter(); + + void setMicrophoneMuted(bool muted); + + void setLocalVideoMuted(bool muted); + + void setScreensharingEnabled(bool enabled); + + void setRemoteOnHold(bool onHold); + + WrappedMediaStream? get localUserMediaStream; + + WrappedMediaStream? get localScreenSharingStream; + + WrappedMediaStream? get primaryStream; + + List get screenSharingStreams; + + List get userMediaStreams; + + void onStateChanged(Function() callback); +} diff --git a/lib/utils/voip/callkeep_manager.dart b/lib/pages/voip/utils/callkeep_manager.dart similarity index 99% rename from lib/utils/voip/callkeep_manager.dart rename to lib/pages/voip/utils/callkeep_manager.dart index eaf25630..a84f0792 100644 --- a/lib/utils/voip/callkeep_manager.dart +++ b/lib/pages/voip/utils/callkeep_manager.dart @@ -8,7 +8,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:fluffychat/utils/voip_plugin.dart'; +import 'package:fluffychat/pages/voip/utils/voip_plugin.dart'; class CallKeeper { CallKeeper(this.callKeepManager, this.call) { diff --git a/lib/pages/voip/utils/group_call_session_state.dart b/lib/pages/voip/utils/group_call_session_state.dart new file mode 100644 index 00000000..0cc782f0 --- /dev/null +++ b/lib/pages/voip/utils/group_call_session_state.dart @@ -0,0 +1,124 @@ +import 'package:matrix/matrix.dart'; + +import 'call_state_proxy.dart'; + +class GroupCallSessionState implements CallStateProxy { + Function()? callback; + final GroupCall groupCall; + GroupCallSessionState(this.groupCall) { + groupCall.onGroupCallEvent.stream.listen((event) { + Logs().i("onGroupCallEvent ${event.toString()}"); + callback?.call(); + }); + groupCall.onGroupCallState.stream.listen((event) { + Logs().i("onGroupCallState ${event.toString()}"); + callback?.call(); + }); + } + + @override + void answer() { + // TODO: implement answer + } + + @override + bool get callOnHold => false; + + @override + String get callState => 'New Group Call...'; + + @override + bool get connected => groupCall.state == GroupCallState.Entered; + + @override + bool get connecting => groupCall.state == GroupCallState.Entering; + + @override + String? get displayName => groupCall.displayName; + + @override + bool get ended => + groupCall.state == GroupCallState.Ended || + groupCall.state == GroupCallState.LocalCallFeedUninitialized; + + @override + void enter() async { + await groupCall.initLocalStream(); + groupCall.enter(); + } + + @override + void hangup() async { + groupCall.leave(); + } + + @override + bool get isLocalVideoMuted => groupCall.isLocalVideoMuted; + + @override + bool get isMicrophoneMuted => groupCall.isMicrophoneMuted; + + @override + bool get isOutgoing => false; + + @override + bool get isRemoteOnHold => false; + + @override + bool get isScreensharingEnabled => groupCall.isScreensharing(); + + @override + bool get localHold => false; + + @override + WrappedMediaStream? get localScreenSharingStream => + groupCall.localScreenshareStream; + + @override + WrappedMediaStream? get localUserMediaStream => + groupCall.localUserMediaStream; + + @override + void onStateChanged(Function() handler) { + callback = handler; + } + + @override + WrappedMediaStream? get primaryStream => groupCall.localUserMediaStream; + + @override + bool get remoteOnHold => false; + + @override + bool get ringingPlay => false; + + @override + List get screenSharingStreams => + groupCall.screenshareStreams; + + @override + List get userMediaStreams => groupCall.userMediaStreams; + + @override + void setLocalVideoMuted(bool muted) { + groupCall.setLocalVideoMuted(muted); + } + + @override + void setMicrophoneMuted(bool muted) { + groupCall.setMicrophoneMuted(muted); + } + + @override + void setRemoteOnHold(bool onHold) { + // TODO: implement setRemoteOnHold + } + + @override + void setScreensharingEnabled(bool enabled) { + groupCall.setScreensharingEnabled(enabled, ''); + } + + @override + bool get voiceonly => false; +} diff --git a/lib/pages/voip/utils/stream_view.dart b/lib/pages/voip/utils/stream_view.dart new file mode 100644 index 00000000..05afde05 --- /dev/null +++ b/lib/pages/voip/utils/stream_view.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/widgets/avatar.dart'; + +class StreamView extends StatefulWidget { + const StreamView(this.wrappedStream, + {Key? key, this.mainView = false, required this.matrixClient}) + : super(key: key); + + final WrappedMediaStream wrappedStream; + final Client matrixClient; + + final bool mainView; + + @override + State createState() => _StreamViewState(); +} + +class _StreamViewState extends State { + Uri? get avatarUrl => widget.wrappedStream.getUser().avatarUrl; + + String? get displayName => widget.wrappedStream.displayName; + + String get avatarName => widget.wrappedStream.avatarName; + + bool get isLocal => widget.wrappedStream.isLocal(); + + bool get mirrored => + widget.wrappedStream.isLocal() && + widget.wrappedStream.purpose == SDPStreamMetadataPurpose.Usermedia; + + late bool audioMuted; + late bool videoMuted; + + bool get isScreenSharing => + widget.wrappedStream.purpose == SDPStreamMetadataPurpose.Screenshare; + + @override + void initState() { + audioMuted = widget.wrappedStream.audioMuted; + videoMuted = widget.wrappedStream.videoMuted; + widget.wrappedStream.onMuteStateChanged.stream.listen((stream) { + if (stream.audioMuted != audioMuted) { + setState(() { + audioMuted = stream.audioMuted; + }); + } + if (stream.videoMuted != videoMuted) { + setState(() { + videoMuted = stream.videoMuted; + }); + } + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.black54, + ), + child: Stack( + alignment: Alignment.center, + children: [ + if (videoMuted) + Container( + color: Colors.transparent, + ), + if (!videoMuted) + RTCVideoView(widget.wrappedStream.renderer as RTCVideoRenderer, + mirror: mirrored, + objectFit: + RTCVideoViewObjectFit.RTCVideoViewObjectFitContain), + if (videoMuted) + Positioned( + child: Avatar( + mxContent: avatarUrl, + name: displayName ?? '', + size: widget.mainView ? 96 : 48, + fontSize: widget.mainView ? 36 : 24, + client: widget.matrixClient, + ), + ), + if (!isScreenSharing) + Positioned( + left: 4.0, + bottom: 4.0, + child: Icon(audioMuted ? Icons.mic_off : Icons.mic, + color: Colors.white, size: 18.0), + ), + Positioned( + right: 4.0, + bottom: 4.0, + child: Text( + widget.wrappedStream.displayName.toString(), + style: TextStyle(color: Colors.white), + ), + ), + ], + )); + } +} diff --git a/lib/utils/voip/user_media_manager.dart b/lib/pages/voip/utils/user_media_manager.dart similarity index 100% rename from lib/utils/voip/user_media_manager.dart rename to lib/pages/voip/utils/user_media_manager.dart diff --git a/lib/pages/voip/utils/voip_plugin.dart b/lib/pages/voip/utils/voip_plugin.dart new file mode 100644 index 00000000..95b9500a --- /dev/null +++ b/lib/pages/voip/utils/voip_plugin.dart @@ -0,0 +1,341 @@ +import 'dart:core'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc_impl; +import 'package:matrix/matrix.dart'; +import 'package:webrtc_interface/webrtc_interface.dart' hide Navigator; + +import 'package:fluffychat/pages/chat_list/chat_list.dart'; +import 'package:fluffychat/pages/voip/dialer/dialer.dart'; +import 'package:fluffychat/pages/voip/utils/callkeep_manager.dart'; +import 'package:fluffychat/pages/voip/utils/user_media_manager.dart'; +import 'package:fluffychat/utils/famedlysdk_store.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/fluffy_chat_app.dart'; + +enum VoipType { kVoice, kVideo, kGroup } + +class VoipPlugin with WidgetsBindingObserver implements WebRTCDelegate { + final Client client; + VoipPlugin(this.client) { + voip = VoIP(client, this); + try { + Connectivity() + .onConnectivityChanged + .listen(_handleNetworkChanged) + .onError((e) => _currentConnectivity = ConnectivityResult.none); + Connectivity() + .checkConnectivity() + .then((result) => _currentConnectivity = result) + .catchError((e) => _currentConnectivity = ConnectivityResult.none); + } catch (e) { + // currently broken on web + Logs().e('Listening to connectivity failed. $e'); + } + + if (!kIsWeb) { + final wb = WidgetsBinding.instance; + wb.addObserver(this); + didChangeAppLifecycleState(wb.lifecycleState); + } + } + + bool background = false; + bool speakerOn = false; + late VoIP voip; + ConnectivityResult? _currentConnectivity; + OverlayEntry? overlayEntry; + // void onPhoneButtonTap(BuildContext context, Room room) async { + // final callType = await showDialog( + // context: context, + // builder: (BuildContext context) { + // return SimpleDialog( + // titlePadding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 24.0), + // title: Row( + // mainAxisAlignment: MainAxisAlignment.spaceEvenly, + // crossAxisAlignment: CrossAxisAlignment.center, + // children: [ + // ProfileScaffoldButton( + // key: const Key('CallProfileScaffoldVoiceCall'), + // iconData: FamedlyIconsV2.phone, + // onTap: () { + // Navigator.pop(context, VoipType.kVoice); + // }, + // ), + // ProfileScaffoldButton( + // key: const Key('CallProfileScaffoldVideoCall'), + // iconData: Icons.videocam, + // onTap: () { + // Navigator.pop(context, VoipType.kVideo); + // }, + // ), + // ProfileScaffoldButton( + // key: const Key('CallProfileScaffoldGroupCall'), + // iconData: Icons.people, + // onTap: () { + // Navigator.pop(context, VoipType.kGroup); + // }, + // ), + // ], + // ), + // ); + // }); + + // if (callType == null) { + // return; + // } + // final success = await showFutureLoadingDialog( + // context: context, + // future: () => + // Famedly.of(context).voipPlugin.voip.requestTurnServerCredentials()); + // if (success.result != null) { + // final voipPlugin = Famedly.of(context).voipPlugin; + // if ({VoipType.kVideo, VoipType.kVoice}.contains(callType)) { + // await voipPlugin.voip + // .inviteToCall(room.id, + // callType == VoipType.kVoice ? CallType.kVoice : CallType.kVideo) + // .catchError((e) { + // ScaffoldMessenger.of(context).showSnackBar( + // SnackBar( + // content: Text( + // LocalizedExceptionExtension.toLocalizedString(context, e)), + // ), + // ); + // }); + // } else { + // final groupCall = await voipPlugin.voip.fetchOrCreateGroupCall(room.id); + // groupCall?.enter(); + // Logs().e('Group call should be enter now'); + // } + // } else { + // await showOkAlertDialog( + // context: context, + // title: L10n.of(context)!.thisFeatureHasNotBeenImplementedYet, + // okLabel: L10n.of(context)!.next, + // useRootNavigator: false, + // ); + // } + // } + + void _handleNetworkChanged(ConnectivityResult result) async { + /// Got a new connectivity status! + if (_currentConnectivity != result) { + voip.calls.forEach((_, sess) { + sess.restartIce(); + }); + } + _currentConnectivity = result; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState? state) { + Logs().v('AppLifecycleState = $state'); + background = !(state != AppLifecycleState.detached && + state != AppLifecycleState.paused); + } + + void addCallingOverlay() { + final context = kIsWeb + ? ChatList.contextForVoip! + : FluffyChatApp.routerKey.currentContext ?? ChatList.contextForVoip!; + if (overlayEntry != null) { + Logs().w('[VOIP] addCallingOverlay: The call session already exists?'); + overlayEntry!.remove(); + } + final overlay = Overlay.of(context)!; + overlayEntry = OverlayEntry( + builder: (_) => Calling( + voipPlugin: this, + onClear: () { + overlayEntry?.remove(); + overlayEntry = null; + }), + ); + overlay.insert(overlayEntry!); + } + + CallSession? get currentCall => + voip.currentCID == null ? null : voip.calls[voip.currentCID]; + + GroupCall? get currentGroupCall => voip.currentGroupCID == null + ? null + : voip.groupCalls[voip.currentGroupCID]; + + bool inMeeting = false; + + void start() { + voip.startGroupCalls(); + } + + bool getInMeetingState() { + return currentCall != null || currentGroupCall != null; + } + + String? get currentMeetingRoomId { + return currentCall?.room.id ?? currentGroupCall?.room.id; + } + + CallSession? get call { + if (voip.currentCID != null) { + final call = voip.calls[voip.currentCID]; + if (call != null && call.groupCallId != null) { + return call; + } + } + return null; + } + + GroupCall? get groupCall { + if (voip.currentCID != null) { + return voip.groupCalls[voip.currentGroupCID]; + } + return null; + } + + @override + MediaDevices get mediaDevices => webrtc_impl.navigator.mediaDevices; + + @override + // remove this from sdk once callkeep is stable + bool get isBackgroud => false; + + @override + bool get isWeb => kIsWeb; + + @override + Future createPeerConnection( + Map configuration, + [Map constraints = const {}]) => + webrtc_impl.createPeerConnection(configuration, constraints); + + @override + VideoRenderer createRenderer() { + return webrtc_impl.RTCVideoRenderer(); + } + + @override + void playRingtone() async { + if (!background && !await hasCallingAccount) { + try { + await UserMediaManager().startRingingTone(); + } catch (_) {} + } + } + + @override + void stopRingtone() async { + if (!background && !await hasCallingAccount) { + try { + await UserMediaManager().stopRingingTone(); + } catch (_) {} + } + } + + Future get hasCallingAccount async => PlatformInfos.isAndroid + ? await CallKeepManager().hasPhoneAccountEnabled + : false; + + @override + void handleNewCall(CallSession call) async { + Logs().d('[VoipPlugin] detected new call'); + if (!getInMeetingState()) { + return; + } + if (call.direction == CallDirection.kIncoming && + await hasCallingAccount && + call.type == CallType.kVoice && + Platform.isAndroid) { + ///Popup native telecom manager call UI for incoming call. + final callKeeper = CallKeeper(CallKeepManager(), call); + CallKeepManager().addCall(call.callId, callKeeper); + await CallKeepManager().showCallkitIncoming(call); + } else { + if (!await hasCallingAccount || !Platform.isAndroid) { + if (Platform.isAndroid) { + try { + ScaffoldMessenger.of(FluffyChatApp.routerKey.currentContext!) + .showSnackBar(const SnackBar( + content: Text( + 'No calling accounts found (used for native calls UI)', + ))); + } catch (e) { + Logs().e('[VoipPlugin] Failed to show snackbar', e); + } + } + try { + final wasForeground = await FlutterForegroundTask.isAppOnForeground; + await Store().setItem( + 'wasForeground', wasForeground == true ? 'true' : 'false'); + FlutterForegroundTask.setOnLockScreenVisibility(true); + FlutterForegroundTask.wakeUpScreen(); + FlutterForegroundTask.launchApp(); + } catch (e) { + Logs().e('VOIP foreground failed $e'); + } + } + Logs().d('[VoipPlugin] pushing flutter call overlay'); + // use fallback flutter call pages for outgoing and video calls. + addCallingOverlay(); + } + } + + @override + void handleCallEnded(CallSession session) async { + if (getInMeetingState()) { + return; + } + if (overlayEntry != null) { + overlayEntry!.remove(); + overlayEntry = null; + if (Platform.isAndroid) { + FlutterForegroundTask.setOnLockScreenVisibility(false); + FlutterForegroundTask.stopService(); + final wasForeground = await Store().getItem('wasForeground'); + wasForeground == 'false' ? FlutterForegroundTask.minimizeApp() : null; + } + } + } + + @override + void handleNewGroupCall(GroupCall groupCall) { + Logs().d('[VOIP] new call found'); + if (!getInMeetingState()) { + return; + } + + /// Popup CallingPage for incoming call. + addCallingOverlay(); + if (PlatformInfos.isAndroid) { + FlutterForegroundTask.setOnLockScreenVisibility(true); + } + + Logs().d('[VOIP] overlay stuff should be there up there'); + } + + @override + void handleGroupCallEnded(GroupCall groupCall) { + if (overlayEntry != null) { + overlayEntry!.remove(); + overlayEntry = null; + } + if (Platform.isAndroid) { + FlutterForegroundTask.setOnLockScreenVisibility(false); + FlutterForegroundTask.stopService(); + } + } + + @override + Future cloneStream( + webrtc_impl.MediaStream stream) async { + final cloneStream = await webrtc_impl.createLocalMediaStream(stream.id); + stream.getTracks().forEach((track) { + cloneStream.addTrack(track, addToNative: true); + }); + return cloneStream; + } +} diff --git a/lib/utils/push_helper.dart b/lib/utils/push_helper.dart index d3db5473..fcc59d8a 100644 --- a/lib/utils/push_helper.dart +++ b/lib/utils/push_helper.dart @@ -10,10 +10,10 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; +import 'package:fluffychat/pages/voip/utils/callkeep_manager.dart'; import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/utils/voip/callkeep_manager.dart'; Future pushHelper( PushNotification notification, { diff --git a/lib/utils/voip_plugin.dart b/lib/utils/voip_plugin.dart deleted file mode 100644 index c5d71bb9..00000000 --- a/lib/utils/voip_plugin.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'dart:core'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:flutter_foreground_task/flutter_foreground_task.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc_impl; -import 'package:matrix/matrix.dart'; -import 'package:webrtc_interface/webrtc_interface.dart' hide Navigator; - -import 'package:fluffychat/pages/chat_list/chat_list.dart'; -import 'package:fluffychat/pages/dialer/dialer.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/widgets/fluffy_chat_app.dart'; -import '../../utils/famedlysdk_store.dart'; -import '../../utils/voip/callkeep_manager.dart'; -import '../../utils/voip/user_media_manager.dart'; - -class VoipPlugin with WidgetsBindingObserver implements WebRTCDelegate { - final Client client; - VoipPlugin(this.client) { - voip = VoIP(client, this); - Connectivity() - .onConnectivityChanged - .listen(_handleNetworkChanged) - .onError((e) => _currentConnectivity = ConnectivityResult.none); - Connectivity() - .checkConnectivity() - .then((result) => _currentConnectivity = result) - .catchError((e) => _currentConnectivity = ConnectivityResult.none); - if (!kIsWeb) { - final wb = WidgetsBinding.instance; - wb.addObserver(this); - didChangeAppLifecycleState(wb.lifecycleState); - } - } - bool background = false; - bool speakerOn = false; - late VoIP voip; - ConnectivityResult? _currentConnectivity; - OverlayEntry? overlayEntry; - - void _handleNetworkChanged(ConnectivityResult result) async { - /// Got a new connectivity status! - if (_currentConnectivity != result) { - voip.calls.forEach((_, sess) { - sess.restartIce(); - }); - } - _currentConnectivity = result; - } - - @override - void didChangeAppLifecycleState(AppLifecycleState? state) { - Logs().v('AppLifecycleState = $state'); - background = (state == AppLifecycleState.detached || - state == AppLifecycleState.paused); - } - - void addCallingOverlay(String callId, CallSession call) { - final context = kIsWeb - ? ChatList.contextForVoip! - : FluffyChatApp.routerKey.currentContext!; // web is weird - if (overlayEntry != null) { - Logs().e('[VOIP] addCallingOverlay: The call session already exists?'); - overlayEntry!.remove(); - } - // Overlay.of(context) is broken on web - // falling back on a dialog - if (kIsWeb) { - showDialog( - context: context, - builder: (context) => Calling( - context: context, - client: client, - callId: callId, - call: call, - onClear: () => Navigator.of(context).pop(), - ), - ); - } else { - overlayEntry = OverlayEntry( - builder: (_) => Calling( - context: context, - client: client, - callId: callId, - call: call, - onClear: () { - overlayEntry?.remove(); - overlayEntry = null; - }), - ); - Overlay.of(context)!.insert(overlayEntry!); - } - } - - @override - MediaDevices get mediaDevices => webrtc_impl.navigator.mediaDevices; - - @override - // remove this from sdk once callkeep is stable - bool get isBackgroud => false; - - @override - bool get isWeb => kIsWeb; - - @override - Future createPeerConnection( - Map configuration, - [Map constraints = const {}]) => - webrtc_impl.createPeerConnection(configuration, constraints); - - @override - VideoRenderer createRenderer() { - return webrtc_impl.RTCVideoRenderer(); - } - - Future get hasCallingAccount async => - kIsWeb ? false : await CallKeepManager().hasPhoneAccountEnabled; - - @override - void playRingtone() async { - if (!background && !await hasCallingAccount) { - try { - await UserMediaManager().startRingingTone(); - } catch (_) {} - } - } - - @override - void stopRingtone() async { - if (!background && !await hasCallingAccount) { - try { - await UserMediaManager().stopRingingTone(); - } catch (_) {} - } - } - - @override - void handleNewCall(CallSession call) async { - if (PlatformInfos.isAndroid) { - // probably works on ios too - final hasCallingAccount = await CallKeepManager().hasPhoneAccountEnabled; - if (call.direction == CallDirection.kIncoming && - hasCallingAccount && - call.type == CallType.kVoice) { - ///Popup native telecom manager call UI for incoming call. - final callKeeper = CallKeeper(CallKeepManager(), call); - CallKeepManager().addCall(call.callId, callKeeper); - await CallKeepManager().showCallkitIncoming(call); - return; - } else { - try { - final wasForeground = await FlutterForegroundTask.isAppOnForeground; - await Store().setItem( - 'wasForeground', wasForeground == true ? 'true' : 'false'); - FlutterForegroundTask.setOnLockScreenVisibility(true); - FlutterForegroundTask.wakeUpScreen(); - FlutterForegroundTask.launchApp(); - } catch (e) { - Logs().e('VOIP foreground failed $e'); - } - // use fallback flutter call pages for outgoing and video calls. - addCallingOverlay(call.callId, call); - try { - if (!hasCallingAccount) { - ScaffoldMessenger.of(FluffyChatApp.routerKey.currentContext!) - .showSnackBar(const SnackBar( - content: Text( - 'No calling accounts found (used for native calls UI)', - ))); - } - } catch (e) { - Logs().e('failed to show snackbar'); - } - } - } else { - addCallingOverlay(call.callId, call); - } - } - - @override - void handleCallEnded(CallSession session) async { - if (overlayEntry != null) { - overlayEntry!.remove(); - overlayEntry = null; - if (PlatformInfos.isAndroid) { - FlutterForegroundTask.setOnLockScreenVisibility(false); - FlutterForegroundTask.stopService(); - final wasForeground = await Store().getItem('wasForeground'); - wasForeground == 'false' ? FlutterForegroundTask.minimizeApp() : null; - } - } - } - - @override - void handleGroupCallEnded(GroupCall groupCall) { - // TODO: implement handleGroupCallEnded - } - - @override - void handleNewGroupCall(GroupCall groupCall) { - // TODO: implement handleNewGroupCall - } - - @override - Future cloneStream(webrtc_impl.MediaStream stream) { - // TODO: implement cloneStream - throw UnimplementedError(); - } -} diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index bb4ddbde..29e6066f 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -23,11 +23,11 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/voip/utils/voip_plugin.dart'; import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/uia_request_manager.dart'; -import 'package:fluffychat/utils/voip_plugin.dart'; import '../config/app_config.dart'; import '../config/setting_keys.dart'; import '../pages/key_verification/key_verification_dialog.dart'; @@ -424,9 +424,13 @@ class MatrixState extends State with WidgetsBindingObserver { void createVoipPlugin() async { if (await store.getItemBool(SettingKeys.experimentalVoip) == false) { + Logs().e('webrtc disabled'); voipPlugin = null; return; } + if (!webrtcIsSupported) { + Logs().e('webrtc not supported, not creating voipplugin'); + } voipPlugin = webrtcIsSupported ? VoipPlugin(client) : null; } diff --git a/pubspec.lock b/pubspec.lock index 1cffb526..8391918b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -430,6 +430,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.2" + equatable: + dependency: transitive + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -664,6 +671,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.5" + flutter_reorderable_grid_view: + dependency: "direct main" + description: + name: flutter_reorderable_grid_view + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.3" flutter_ringtone_player: dependency: "direct main" description: @@ -759,7 +773,7 @@ packages: name: flutter_webrtc url: "https://pub.dartlang.org" source: hosted - version: "0.9.5" + version: "0.9.6" fuchsia_remote_debug_protocol: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 1f039de6..3e455e4a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,13 +44,14 @@ dependencies: flutter_matrix_html: ^1.1.0 flutter_olm: ^1.2.0 flutter_openssl_crypto: ^0.1.0 + flutter_reorderable_grid_view: ^3.1.3 flutter_ringtone_player: ^3.1.1 flutter_secure_storage: ^6.0.0 flutter_slidable: ^2.0.0 flutter_svg: ^0.22.0 flutter_typeahead: ^4.0.0 flutter_web_auth: ^0.4.0 - flutter_webrtc: ^0.9.5 + flutter_webrtc: ^0.9.6 future_loading_dialog: ^0.2.3 geolocator: ^7.6.2 handy_window: ^0.1.6 @@ -119,8 +120,8 @@ flutter: assets: - assets/ - assets/sounds/ - - assets/js/ - - assets/js/package/ + - assets/assets/js/ + - assets/assets/js/package/ fonts: - family: Roboto