diff --git a/.github/workflows/integrate.yaml b/.github/workflows/integrate.yaml
index cacb3b53f..3138775f9 100644
--- a/.github/workflows/integrate.yaml
+++ b/.github/workflows/integrate.yaml
@@ -14,6 +14,7 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
+ - run: ./scripts/prepare-web.sh
- run: flutter pub get
- name: Check formatting
run: dart format lib/ test/ --set-exit-if-changed
@@ -41,7 +42,7 @@ jobs:
- run: flutter pub get
- run: flutter build apk --debug
- build_debug_web:
+ build_web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -49,11 +50,50 @@ jobs:
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- cache: true
+ cache: false
+ - name: Install dependencies
+ run: sudo apt-get update && sudo apt-get install nodejs -y
+ - name: Remove Emoji Font
+ run: |
+ rm -rf fonts/NotoEmoji
+ yq -i 'del( .flutter.fonts[] | select(.family == "NotoEmoji") )' pubspec.yaml
- run: flutter pub get
- name: Prepare web
run: ./scripts/prepare-web.sh
- - run: flutter build web
+ - name: Build Release Web
+ run: flutter build web --dart-define=FLUTTER_WEB_CANVASKIT_URL=canvaskit/ --release --source-maps
+ # - name: Create archive
+ # run: tar -czf fluffychat-web.tar.gz build/web/
+ - name: Upload Web Build
+ uses: actions/upload-artifact@v4
+ with:
+ name: web
+ path: build/web/
+
+
+ deploy_github_pages:
+ needs: build_web
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: web
+ path: .
+ - run: ls -la
+ - uses: actions/configure-pages@v4
+ - uses: actions/upload-pages-artifact@v2
+ with:
+ path: .
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v2
+
build_debug_linux:
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index 0ce56e79e..c6e290231 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,3 +60,12 @@ ios/Runner.ipa
/macos/out
.vs
olm
+
+web/*.dart.js*
+web/Imaging.js
+web/Imaging.wasm
+web/olm.js
+web/olm.wasm
+web/olm_legacy.js
+libolm.3.dylib
+libcrypto.1.1.dylib
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index d0e0fbc9b..0aeeedced 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -1 +1,5 @@
--keep class net.sqlcipher.** { *; }
\ No newline at end of file
+-keep class net.sqlcipher.** { *; }
+
+-keep class com.cloudwebrtc.webrtc.** { *; }
+-keep class org.webrtc.** { *; }
+-keep class com.hiennv.flutter_callkit_incoming.** { *; }
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c0595780a..6af50e425 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -13,23 +13,24 @@
+
+
+
+
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+ >
+
-
-
-
-
-
-
diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_logo.png b/android/app/src/main/res/drawable-xxxhdpi/ic_logo.png
new file mode 100644
index 000000000..fcd0f6e4a
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_logo.png differ
diff --git a/assets/js/package/.gitkeep b/assets/js/package/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb
index a03fb5c02..40dd1aa1d 100644
--- a/assets/l10n/intl_en.arb
+++ b/assets/l10n/intl_en.arb
@@ -1960,11 +1960,6 @@
"type": "text",
"placeholders": {}
},
- "videoCall": "Video call",
- "@videoCall": {
- "type": "text",
- "placeholders": {}
- },
"visibilityOfTheChatHistory": "Visibility of the chat history",
"@visibilityOfTheChatHistory": {
"type": "text",
@@ -2463,5 +2458,44 @@
"sender": {}
}
},
+ "videoCall": "Video call",
+ "audioCall": "Audio call",
+ "groupCall": "Group call",
+ "heldTheCall": "held the call",
+ "startScreenShare": "Start screen sharing",
+ "stopScreenShare": "Stop screen sharing",
+ "holdCall": "Hold call",
+ "unholdCall": "Unhold call",
+ "flipCamera": "Flip camera",
+ "audioOutput": "Audio output",
+ "audioInput": "Audio input",
+ "hangup": "Hangup",
+ "joinGroupCall": "Join group call",
+ "cameraTurnedOff": "Camera turned off",
+ "youHeldTheCall": "You held the call",
+ "userHeldTheCall": "{user} held the call",
+ "@userHeldTheCall": {
+ "placeholders": {
+ "user": {}
+ }
+ },
+ "unknownUser": "Unknown user",
+ "connecting": "Connecting...",
+ "youAreCalling": "You are calling...",
+ "ringing": "Ringing...",
+ "incomingCall": "Incoming call...",
+ "answering": "Answering...",
+ "ended": "Ended",
+ "calling": "Calling",
+ "startGroupCall": "Start group call",
+ "startVideoCall": "Start video call",
+ "startVoiceCall": "Start voice call",
+ "showInformation": "Show information",
+ "showMember": "Show member",
+ "showImages": "Show images",
+ "activeCall": "Active call",
+ "call": "Call",
+ "missedCall": "Missed call",
+ "callBack": "Call back",
"transparent": "Transparent"
}
\ No newline at end of file
diff --git a/ios/Podfile b/ios/Podfile
index a069cc794..3bc280d30 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
-platform :ios, '12.0'
+platform :ios, '12.1'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart
index 92cfb312d..4c988a7b5 100644
--- a/lib/config/app_config.dart
+++ b/lib/config/app_config.dart
@@ -50,6 +50,10 @@ abstract class AppConfig {
static bool sendPublicReadReceipts = true;
static bool? sendOnEnter;
static bool experimentalVoip = false;
+ static bool livekitEnabledCalls = true;
+ // static const livekitServiceUrl = 'https://famedly-livekit-server.teedee.dev';
+ static const livekitServiceUrl = 'https://livekit-jwt.call.element.dev';
+ static const enableLivekitE2EE = false;
static const bool hideTypingUsernames = false;
static const bool hideAllStateEvents = false;
static const String inviteLinkPrefix = 'https://matrix.to/#/';
diff --git a/lib/config/routes.dart b/lib/config/routes.dart
index d525b94e6..4bfa9c860 100644
--- a/lib/config/routes.dart
+++ b/lib/config/routes.dart
@@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
+import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/archive/archive.dart';
@@ -29,6 +31,9 @@ import 'package:fluffychat/pages/settings_notifications/settings_notifications.d
import 'package:fluffychat/pages/settings_password/settings_password.dart';
import 'package:fluffychat/pages/settings_security/settings_security.dart';
import 'package:fluffychat/pages/settings_style/settings_style.dart';
+import 'package:fluffychat/pages/voip/calling_page.dart';
+import 'package:fluffychat/pages/voip/group_call_onboarding/group_call_onboarding_view.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
import 'package:fluffychat/widgets/layouts/empty_page.dart';
import 'package:fluffychat/widgets/layouts/two_column_layout.dart';
import 'package:fluffychat/widgets/log_view.dart';
@@ -80,6 +85,38 @@ abstract class AppRoutes {
const LogViewer(),
),
),
+ GoRoute(
+ redirect: (context, state) {
+ if (VoipPlugin.currentCallProxy == null) {
+ final parts = state.uri.path.split('/');
+ final redirectPath = '/${parts[1]}/${parts[2]}';
+ Logs().w(
+ '[GoRouter] voip currentCallProxy was null, redirecting to $redirectPath',
+ );
+ return redirectPath;
+ }
+ return null;
+ },
+ path: '/rooms/:roomid/call',
+ pageBuilder: (context, state) => defaultPageBuilder(
+ context,
+ VoipPlugin.currentCallProxy == null
+ ? const Center(child: CircularProgressIndicator())
+ : Calling(
+ voipPlugin: Matrix.of(context).voipPlugin,
+ proxy: VoipPlugin.currentCallProxy!,
+ ),
+ ),
+ ),
+ GoRoute(
+ path: '/rooms/:roomid/group_call_onboarding',
+ pageBuilder: (context, state) => defaultPageBuilder(
+ context,
+ GroupCallOnboardingView(
+ roomId: state.pathParameters['roomid']!,
+ ),
+ ),
+ ),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
diff --git a/lib/main.dart b/lib/main.dart
index 877f80da3..e9ac41748 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/client_manager.dart';
import 'package:fluffychat/utils/platform_infos.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
import 'package:fluffychat/widgets/error_widget.dart';
import 'config/setting_keys.dart';
import 'utils/background_push.dart';
@@ -24,6 +25,7 @@ void main() async {
Logs().nativeColors = !PlatformInfos.isIOS;
final store = await SharedPreferences.getInstance();
final clients = await ClientManager.getClients(store: store);
+ final voipPlugins = clients.map((e) => VoipPlugin.clientOnly(e)).toList();
// If the app starts in detached mode, we assume that it is in
// background fetch mode for processing push notifications. This is
@@ -34,12 +36,12 @@ void main() async {
for (final client in clients) {
client.syncPresence = PresenceType.offline;
}
-
// In the background fetch mode we do not want to waste ressources with
// starting the Flutter engine but process incoming push notifications.
BackgroundPush.clientOnly(clients.first);
// To start the flutter engine afterwards we add an custom observer.
- WidgetsBinding.instance.addObserver(AppStarter(clients, store));
+ WidgetsBinding.instance
+ .addObserver(AppStarter(clients, voipPlugins, store));
Logs().i(
'${AppConfig.applicationName} started in background-fetch mode. No GUI will be created unless the app is no longer detached.',
);
@@ -50,11 +52,15 @@ void main() async {
Logs().i(
'${AppConfig.applicationName} started in foreground mode. Rendering GUI...',
);
- await startGui(clients, store);
+ await startGui(clients, voipPlugins, store);
}
/// Fetch the pincode for the applock and start the flutter engine.
-Future startGui(List clients, SharedPreferences store) async {
+Future startGui(
+ List clients,
+ List voipPlugins,
+ SharedPreferences store,
+) async {
// Fetch the pin for the applock if existing for mobile applications.
String? pin;
if (PlatformInfos.isMobile) {
@@ -72,17 +78,25 @@ Future startGui(List clients, SharedPreferences store) async {
await firstClient?.accountDataLoading;
ErrorWidget.builder = (details) => FluffyChatErrorWidget(details);
- runApp(FluffyChatApp(clients: clients, pincode: pin, store: store));
+ runApp(
+ FluffyChatApp(
+ clients: clients,
+ voipPlugins: voipPlugins,
+ pincode: pin,
+ store: store,
+ ),
+ );
}
/// Watches the lifecycle changes to start the application when it
/// is no longer detached.
class AppStarter with WidgetsBindingObserver {
final List clients;
+ List voipPlugins;
final SharedPreferences store;
bool guiStarted = false;
- AppStarter(this.clients, this.store);
+ AppStarter(this.clients, this.voipPlugins, this.store);
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
@@ -96,7 +110,7 @@ class AppStarter with WidgetsBindingObserver {
for (final client in clients) {
client.syncPresence = PresenceType.online;
}
- startGui(clients, store);
+ startGui(clients, voipPlugins, store);
// We must make sure that the GUI is only started once.
guiStarted = true;
}
diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart
index 1f7eb4242..60a227ad6 100644
--- a/lib/pages/chat/chat.dart
+++ b/lib/pages/chat/chat.dart
@@ -30,6 +30,7 @@ import 'package:fluffychat/utils/error_reporter.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
import 'package:fluffychat/widgets/app_lock.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../utils/account_bundles.dart';
@@ -1251,7 +1252,7 @@ class ChatController extends State
// VoIP required Android SDK 21
if (PlatformInfos.isAndroid) {
DeviceInfoPlugin().androidInfo.then((value) {
- if (value.version.sdkInt < 21) {
+ if (value.version.sdkInt < 23) {
Navigator.pop(context);
showOkAlertDialog(
context: context,
@@ -1262,45 +1263,41 @@ class ChatController extends State
}
});
}
- final callType = await showModalActionSheet(
+ final callType = await showModalActionSheet(
context: context,
title: L10n.of(context)!.warning,
message: L10n.of(context)!.videoCallsBetaWarning,
cancelLabel: L10n.of(context)!.cancel,
actions: [
- 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,
- ),
+ if (room.isDirectChat)
+ SheetAction(
+ label: L10n.of(context)!.voiceCall,
+ icon: Icons.phone_outlined,
+ key: VoipType.kVoice,
+ ),
+ if (room.isDirectChat)
+ SheetAction(
+ label: L10n.of(context)!.videoCall,
+ icon: Icons.video_call_outlined,
+ key: VoipType.kVideo,
+ ),
+ if (!room.isDirectChat)
+ SheetAction(
+ label: L10n.of(context)!.groupCall,
+ icon: Icons.people,
+ key: VoipType.kGroup,
+ ),
],
);
if (callType == null) return;
- final success = await showFutureLoadingDialog(
- context: context,
- future: () =>
- Matrix.of(context).voipPlugin!.voip.requestTurnServerCredentials(),
- );
- if (success.result != null) {
- final voipPlugin = Matrix.of(context).voipPlugin;
- try {
- await voipPlugin!.voip.inviteToCall(room.id, callType);
- } catch (e) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(e.toLocalizedString(context))),
- );
- }
- } else {
- await showOkAlertDialog(
- context: context,
- title: L10n.of(context)!.unavailable,
- okLabel: L10n.of(context)!.next,
+ final voipPlugin = Matrix.of(context).voipPlugin;
+ try {
+ voipPlugin.onPhoneButtonTap(context, room, callType);
+ } catch (e, s) {
+ Logs().e('[VOIP] onPhoneButtonTap fialed', e, s);
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(e.toLocalizedString(context))),
);
}
}
diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart
index eb9bdda5c..29ee19c09 100644
--- a/lib/pages/chat/chat_view.dart
+++ b/lib/pages/chat/chat_view.dart
@@ -113,13 +113,11 @@ class ChatView extends StatelessWidget {
];
} else if (!controller.room.isArchived) {
return [
- if (Matrix.of(context).voipPlugin != null &&
- controller.room.isDirectChat)
- IconButton(
- onPressed: controller.onPhoneButtonTap,
- icon: const Icon(Icons.call_outlined),
- tooltip: L10n.of(context)!.placeCall,
- ),
+ IconButton(
+ onPressed: controller.onPhoneButtonTap,
+ icon: const Icon(Icons.call_outlined),
+ tooltip: L10n.of(context)!.placeCall,
+ ),
EncryptionButton(controller.room),
ChatSettingsPopupMenu(controller.room, true),
];
diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart
index db0708ffb..b3869df7c 100644
--- a/lib/pages/chat_list/chat_list.dart
+++ b/lib/pages/chat_list/chat_list.dart
@@ -23,7 +23,6 @@ import 'package:fluffychat/utils/platform_infos.dart';
import '../../../utils/account_bundles.dart';
import '../../utils/matrix_sdk_extensions/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';
@@ -406,8 +405,6 @@ class ChatListController extends State
scrollController.addListener(_onScroll);
_waitForFirstSync();
- _hackyWebRTCFixForWeb();
- CallKeepManager().initialize();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
searchServer =
@@ -740,10 +737,6 @@ class ChatListController extends State
@override
Widget build(BuildContext context) => ChatListView(this);
- void _hackyWebRTCFixForWeb() {
- ChatList.contextForVoip = context;
- }
-
Future _checkTorBrowser() async {
if (!kIsWeb) return;
final isTor = await TorBrowserDetector.isTorBrowser;
diff --git a/lib/pages/dialer/dialer.dart b/lib/pages/dialer/dialer.dart
deleted file mode 100644
index a8e5a9202..000000000
--- a/lib/pages/dialer/dialer.dart
+++ /dev/null
@@ -1,651 +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_plus/wakelock_plus.dart';
-
-import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.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, {
- this.mainView = false,
- required this.matrixClient,
- });
-
- 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,
- super.key,
- });
-
- @override
- MyCallingPage createState() => MyCallingPage();
-}
-
-class MyCallingPage extends State {
- Room? get room => call.room;
-
- String get displayName => call.room.getLocalizedDisplayname(
- MatrixLocals(L10n.of(widget.context)!),
- );
-
- 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(WakelockPlus.enable());
- } catch (_) {}
- }
- }
-
- void cleanUp() {
- Timer(
- const Duration(seconds: 2),
- () => widget.onClear?.call(),
- );
- if (call.type == CallType.kVideo) {
- try {
- unawaited(WakelockPlus.disable());
- } catch (_) {}
- }
- }
-
- @override
- void dispose() {
- super.dispose();
- call.cleanUp.call();
- }
-
- void _resizeLocalVideo(Orientation orientation) {
- final shortSide = min(
- MediaQuery.of(widget.context).size.width,
- MediaQuery.of(widget.context).size.height,
- );
- _localVideoMargin = remoteStream != null
- ? const EdgeInsets.only(top: 20.0, right: 20.0)
- : EdgeInsets.zero;
- _localVideoWidth = remoteStream != null
- ? shortSide / 3
- : MediaQuery.of(widget.context).size.width;
- _localVideoHeight = remoteStream != null
- ? shortSide / 4
- : MediaQuery.of(widget.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) {
- FlutterForegroundTask.init(
- androidNotificationOptions: AndroidNotificationOptions(
- channelId: 'notification_channel_id',
- channelName: 'Foreground Notification',
- channelDescription:
- L10n.of(widget.context)!.foregroundServiceRunning,
- ),
- iosNotificationOptions: const IOSNotificationOptions(),
- foregroundTaskOptions: const ForegroundTaskOptions(),
- );
- FlutterForegroundTask.startService(
- notificationTitle: L10n.of(widget.context)!.screenSharingTitle,
- notificationText: L10n.of(widget.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(widget.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.room.getLocalizedDisplayname(
- MatrixLocals(L10n.of(widget.context)!),
- )} 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/dialer/pip/dismiss_keyboard.dart b/lib/pages/dialer/pip/dismiss_keyboard.dart
deleted file mode 100644
index c9ca3180f..000000000
--- a/lib/pages/dialer/pip/dismiss_keyboard.dart
+++ /dev/null
@@ -1,5 +0,0 @@
-import 'package:flutter/material.dart';
-
-void dismissKeyboard(BuildContext context) {
- FocusScope.of(context).requestFocus(FocusNode());
-}
diff --git a/lib/pages/dialer/pip/pip_view.dart b/lib/pages/dialer/pip/pip_view.dart
deleted file mode 100644
index 5396c9136..000000000
--- a/lib/pages/dialer/pip/pip_view.dart
+++ /dev/null
@@ -1,345 +0,0 @@
-import 'package:flutter/material.dart';
-
-import 'package:fluffychat/config/themes.dart';
-import 'dismiss_keyboard.dart';
-
-class PIPView extends StatefulWidget {
- final PIPViewCorner initialCorner;
- final double? floatingWidth;
- final double? floatingHeight;
- final bool avoidKeyboard;
-
- final Widget Function(
- BuildContext context,
- bool isFloating,
- ) builder;
-
- const PIPView({
- super.key,
- required this.builder,
- this.initialCorner = PIPViewCorner.topRight,
- this.floatingWidth,
- this.floatingHeight,
- this.avoidKeyboard = true,
- });
-
- @override
- PIPViewState createState() => PIPViewState();
-
- static PIPViewState? of(BuildContext context) {
- return context.findAncestorStateOfType();
- }
-}
-
-class PIPViewState extends State with TickerProviderStateMixin {
- late AnimationController _toggleFloatingAnimationController;
- late AnimationController _dragAnimationController;
- late PIPViewCorner _corner;
- Offset _dragOffset = Offset.zero;
- bool _isDragging = false;
- bool _floating = false;
- Map _offsets = {};
-
- @override
- void initState() {
- super.initState();
- _corner = widget.initialCorner;
- _toggleFloatingAnimationController = AnimationController(
- duration: FluffyThemes.animationDuration,
- vsync: this,
- );
- _dragAnimationController = AnimationController(
- duration: FluffyThemes.animationDuration,
- vsync: this,
- );
- }
-
- void _updateCornersOffsets({
- required Size spaceSize,
- required Size widgetSize,
- required EdgeInsets windowPadding,
- }) {
- _offsets = _calculateOffsets(
- spaceSize: spaceSize,
- widgetSize: widgetSize,
- windowPadding: windowPadding,
- );
- }
-
- bool _isAnimating() {
- return _toggleFloatingAnimationController.isAnimating ||
- _dragAnimationController.isAnimating;
- }
-
- void setFloating(bool floating) {
- if (_isAnimating()) return;
- dismissKeyboard(context);
- setState(() {
- _floating = floating;
- });
- _toggleFloatingAnimationController.forward();
- }
-
- void stopFloating() {
- if (_isAnimating()) return;
- dismissKeyboard(context);
- _toggleFloatingAnimationController.reverse().whenCompleteOrCancel(() {
- if (mounted) {
- setState(() {
- _floating = false;
- });
- }
- });
- }
-
- void _onPanUpdate(DragUpdateDetails details) {
- if (!_isDragging) return;
- setState(() {
- _dragOffset = _dragOffset.translate(
- details.delta.dx,
- details.delta.dy,
- );
- });
- }
-
- void _onPanCancel() {
- if (!_isDragging) return;
- setState(() {
- _dragAnimationController.value = 0;
- _dragOffset = Offset.zero;
- _isDragging = false;
- });
- }
-
- void _onPanEnd(_) {
- if (!_isDragging) return;
-
- final nearestCorner = _calculateNearestCorner(
- offset: _dragOffset,
- offsets: _offsets,
- );
- setState(() {
- _corner = nearestCorner;
- _isDragging = false;
- });
- _dragAnimationController.forward().whenCompleteOrCancel(() {
- _dragAnimationController.value = 0;
- _dragOffset = Offset.zero;
- });
- }
-
- void _onPanStart(_) {
- if (_isAnimating()) return;
- setState(() {
- _dragOffset = _offsets[_corner]!;
- _isDragging = true;
- });
- }
-
- @override
- Widget build(BuildContext context) {
- final mediaQuery = MediaQuery.of(context);
- var windowPadding = mediaQuery.padding;
- if (widget.avoidKeyboard) {
- windowPadding += mediaQuery.viewInsets;
- }
- final isFloating = _floating;
-
- return LayoutBuilder(
- builder: (context, constraints) {
- final width = constraints.maxWidth;
- final height = constraints.maxHeight;
- var floatingWidth = widget.floatingWidth;
- var floatingHeight = widget.floatingHeight;
- if (floatingWidth == null && floatingHeight != null) {
- floatingWidth = width / height * floatingHeight;
- }
- floatingWidth ??= 100.0;
- floatingHeight ??= height / width * floatingWidth;
-
- final floatingWidgetSize = Size(floatingWidth, floatingHeight);
- final fullWidgetSize = Size(width, height);
-
- _updateCornersOffsets(
- spaceSize: fullWidgetSize,
- widgetSize: floatingWidgetSize,
- windowPadding: windowPadding,
- );
-
- final calculatedOffset = _offsets[_corner];
-
- // BoxFit.cover
- final widthRatio = floatingWidth / width;
- final heightRatio = floatingHeight / height;
- final scaledDownScale = widthRatio > heightRatio
- ? floatingWidgetSize.width / fullWidgetSize.width
- : floatingWidgetSize.height / fullWidgetSize.height;
-
- return Stack(
- children: [
- AnimatedBuilder(
- animation: Listenable.merge([
- _toggleFloatingAnimationController,
- _dragAnimationController,
- ]),
- builder: (context, child) {
- final animationCurve = CurveTween(
- curve: Curves.easeInOutQuad,
- );
- final dragAnimationValue = animationCurve.transform(
- _dragAnimationController.value,
- );
- final toggleFloatingAnimationValue = animationCurve.transform(
- _toggleFloatingAnimationController.value,
- );
-
- final floatingOffset = _isDragging
- ? _dragOffset
- : Tween(
- begin: _dragOffset,
- end: calculatedOffset,
- ).transform(
- _dragAnimationController.isAnimating
- ? dragAnimationValue
- : toggleFloatingAnimationValue,
- );
- final borderRadius = Tween(
- begin: 0,
- end: 10,
- ).transform(toggleFloatingAnimationValue);
- final width = Tween(
- begin: fullWidgetSize.width,
- end: floatingWidgetSize.width,
- ).transform(toggleFloatingAnimationValue);
- final height = Tween(
- begin: fullWidgetSize.height,
- end: floatingWidgetSize.height,
- ).transform(toggleFloatingAnimationValue);
- final scale = Tween(
- begin: 1,
- end: scaledDownScale,
- ).transform(toggleFloatingAnimationValue);
- return Positioned(
- left: floatingOffset.dx,
- top: floatingOffset.dy,
- child: GestureDetector(
- onPanStart: isFloating ? _onPanStart : null,
- onPanUpdate: isFloating ? _onPanUpdate : null,
- onPanCancel: isFloating ? _onPanCancel : null,
- onPanEnd: isFloating ? _onPanEnd : null,
- onTap: isFloating ? stopFloating : null,
- child: Material(
- elevation: 10,
- borderRadius: BorderRadius.circular(borderRadius),
- child: Container(
- clipBehavior: Clip.antiAlias,
- decoration: BoxDecoration(
- color: Colors.transparent,
- borderRadius: BorderRadius.circular(borderRadius),
- ),
- width: width,
- height: height,
- child: Transform.scale(
- scale: scale,
- child: OverflowBox(
- maxHeight: fullWidgetSize.height,
- maxWidth: fullWidgetSize.width,
- child: IgnorePointer(
- ignoring: isFloating,
- child: child,
- ),
- ),
- ),
- ),
- ),
- ),
- );
- },
- child: Builder(
- builder: (context) => widget.builder(context, isFloating),
- ),
- ),
- ],
- );
- },
- );
- }
-}
-
-enum PIPViewCorner {
- topLeft,
- topRight,
- bottomLeft,
- bottomRight,
-}
-
-class _CornerDistance {
- final PIPViewCorner corner;
- final double distance;
-
- _CornerDistance({
- required this.corner,
- required this.distance,
- });
-}
-
-PIPViewCorner _calculateNearestCorner({
- required Offset offset,
- required Map offsets,
-}) {
- _CornerDistance calculateDistance(PIPViewCorner corner) {
- final distance = offsets[corner]!
- .translate(
- -offset.dx,
- -offset.dy,
- )
- .distanceSquared;
- return _CornerDistance(
- corner: corner,
- distance: distance,
- );
- }
-
- final distances = PIPViewCorner.values.map(calculateDistance).toList();
-
- distances.sort((cd0, cd1) => cd0.distance.compareTo(cd1.distance));
-
- return distances.first.corner;
-}
-
-Map _calculateOffsets({
- required Size spaceSize,
- required Size widgetSize,
- required EdgeInsets windowPadding,
-}) {
- Offset getOffsetForCorner(PIPViewCorner corner) {
- const spacing = 16;
- final left = spacing + windowPadding.left;
- final top = spacing + windowPadding.top;
- final right =
- spaceSize.width - widgetSize.width - windowPadding.right - spacing;
- final bottom =
- spaceSize.height - widgetSize.height - windowPadding.bottom - spacing;
-
- switch (corner) {
- case PIPViewCorner.topLeft:
- return Offset(left, top);
- case PIPViewCorner.topRight:
- return Offset(right, top);
- case PIPViewCorner.bottomLeft:
- return Offset(left, bottom);
- case PIPViewCorner.bottomRight:
- return Offset(right, bottom);
- default:
- throw Exception('Not implemented.');
- }
- }
-
- const corners = PIPViewCorner.values;
- final offsets = {};
- for (final corner in corners) {
- offsets[corner] = getOffsetForCorner(corner);
- }
-
- return offsets;
-}
diff --git a/lib/pages/global_banner_scaffold.dart b/lib/pages/global_banner_scaffold.dart
new file mode 100644
index 000000000..675316790
--- /dev/null
+++ b/lib/pages/global_banner_scaffold.dart
@@ -0,0 +1,74 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+
+import 'package:provider/provider.dart';
+
+import 'package:fluffychat/config/themes.dart';
+import 'package:fluffychat/utils/app_state.dart';
+import 'package:fluffychat/widgets/fluffy_chat_app.dart';
+import 'package:fluffychat/widgets/matrix.dart';
+
+class GlobalBannerScaffold extends StatefulWidget {
+ // the actual page below the banner
+ final Widget child;
+
+ const GlobalBannerScaffold({super.key, required this.child});
+
+ // add strings or whole routes you don't want banner to be in
+ static const ignoreBannerRoutes = ['call'];
+
+ @override
+ State createState() => _GlobalBannerScaffoldState();
+}
+
+class _GlobalBannerScaffoldState extends State {
+ StreamSubscription? _onSyncStatusSub;
+
+ @override
+ void dispose() {
+ _onSyncStatusSub?.cancel();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (FluffyThemes.isColumnMode(context)) return widget.child;
+ return Selector(
+ selector: (_, state) => state.globalBanner,
+ child: widget.child,
+ builder: (context, banner, child) {
+ final bool showBanner = banner != null &&
+ !GlobalBannerScaffold.ignoreBannerRoutes.any(
+ (route) => FluffyChatApp
+ .router.routerDelegate.currentConfiguration.uri
+ .toString()
+ .contains(route),
+ ) &&
+ Matrix.of(context).client.isLogged();
+
+ return showBanner
+ ? Scaffold(
+ appBar: AppBar(
+ titleSpacing: 0,
+ toolbarHeight: showBanner ? 70 : 0,
+ automaticallyImplyLeading: false,
+ title: Column(
+ children: [
+ if (showBanner)
+ Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8.0,
+ ),
+ child: banner,
+ ),
+ ],
+ ),
+ ),
+ body: child,
+ )
+ : child ?? widget.child; // only for null safety, never occurs
+ },
+ );
+ }
+}
diff --git a/lib/pages/settings_chat/settings_chat_view.dart b/lib/pages/settings_chat/settings_chat_view.dart
index b2d4a40ad..f32c427c1 100644
--- a/lib/pages/settings_chat/settings_chat_view.dart
+++ b/lib/pages/settings_chat/settings_chat_view.dart
@@ -6,9 +6,7 @@ import 'package:go_router/go_router.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.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';
import 'settings_chat.dart';
@@ -75,22 +73,12 @@ class SettingsChatView extends StatelessWidget {
title: L10n.of(context)!.experimentalVideoCalls,
onChanged: (b) {
AppConfig.experimentalVoip = b;
- Matrix.of(context).createVoipPlugin();
+ // Matrix.of(context).createVoipPlugin();
return;
},
storeKey: SettingKeys.experimentalVoip,
defaultValue: AppConfig.experimentalVoip,
),
- if (PlatformInfos.isMobile)
- ListTile(
- title: Text(L10n.of(context)!.callingPermissions),
- onTap: () =>
- CallKeepManager().checkoutPhoneAccountSetting(context),
- trailing: const Padding(
- padding: EdgeInsets.all(16.0),
- child: Icon(Icons.call),
- ),
- ),
SettingsSwitchListTile.adaptive(
title: L10n.of(context)!.separateChatTypes,
onChanged: (b) => AppConfig.separateChatTypes = b,
diff --git a/lib/pages/voip/calling_page.dart b/lib/pages/voip/calling_page.dart
new file mode 100644
index 000000000..60a2d936b
--- /dev/null
+++ b/lib/pages/voip/calling_page.dart
@@ -0,0 +1,810 @@
+/*
+ * 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:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.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:go_router/go_router.dart';
+import 'package:matrix/matrix.dart';
+import 'package:provider/provider.dart';
+
+import 'package:fluffychat/config/themes.dart';
+import 'package:fluffychat/utils/app_state.dart';
+import 'package:fluffychat/utils/platform_infos.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+import '../../utils/voip/call_session_state.dart';
+import '../../utils/voip/call_state_proxy.dart';
+import '../../utils/voip/group_call_session_state.dart';
+import '../../utils/voip/livekit_group_call_session_state.dart';
+import 'group_call_view/group_call_view.dart';
+import 'p2p_call_view/p2p_view.dart';
+import 'widgets/call_buttons.dart';
+import 'widgets/call_timer.dart';
+import 'widgets/more_options_listtile.dart';
+
+enum ChangeAudioMode { input, output }
+
+class Calling extends StatefulWidget {
+ final VoipPlugin voipPlugin;
+ final CallStateProxy proxy;
+
+ /// when a call is not connected we don't have the remote user, so pass this
+ /// to show the remote user
+ // final User? remoteUserInCall;
+ const Calling({
+ super.key,
+ required this.voipPlugin,
+ required this.proxy,
+ // this.remoteUserInCall,
+ });
+
+ @override
+ MyCallingPage createState() => MyCallingPage();
+}
+
+class MyCallingPage extends State {
+ CallStateProxy get proxy => widget.proxy;
+ Room get room => proxy.room;
+
+ String get displayName => proxy.displayName ?? '';
+
+ MediaStream? get localStream {
+ if (proxy.localUserMediaStream != null) {
+ return proxy.localUserMediaStream!.stream!;
+ }
+ return null;
+ }
+
+ bool get isMicrophoneMuted => proxy.isMicrophoneMuted;
+ bool get isLocalVideoMuted => proxy.isLocalVideoMuted;
+ bool get isScreensharingEnabled => proxy.isScreensharingEnabled;
+ bool get isRemoteOnHold => proxy.remoteOnHold;
+ bool get isLocalOnHold => proxy.localHold;
+ bool get voiceonly => proxy.voiceonly;
+ bool get answering => proxy.answering;
+ bool get connecting => proxy.connecting;
+ bool get connected => proxy.connected;
+ bool get ended => proxy.ended;
+ bool get callOnHold => proxy.callOnHold;
+ bool get isGroupCall =>
+ (proxy is GroupCallSessionState || proxy is LiveKitGroupCallSessionState);
+ bool get showMicMuteButton => connected;
+ bool get showScreenSharingButton => connected;
+ bool get showHoldButton => connected && !isGroupCall;
+ bool get showEstablishingConnection => connecting;
+ WrappedMediaStream get screenSharing => screenSharingStreams.elementAt(0);
+ WrappedMediaStream? get primaryStream => proxy.primaryStream;
+
+ // TODO(td): remove this when ios gets callkeepv3
+ bool get showAnswerButton =>
+ (!connected && !connecting && !ended) &&
+ !proxy.isOutgoing &&
+ !isGroupCall &&
+ (kIsWeb || !Platform.isAndroid);
+
+ bool get showVideoMuteButton => connected;
+ bool get showFlipCameraButton =>
+ !kIsWeb &&
+ PlatformInfos.isMobile &&
+ proxy.localUserMediaStream?.videoMuted == false;
+
+ 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 ? L10n.of(context)!.audioCall : L10n.of(context)!.videoCall} (${proxy.callState})';
+ }
+
+ String get heldTitle {
+ var heldTitle = '';
+ if (proxy.localHold) {
+ heldTitle = '${proxy.displayName ?? ''} ${L10n.of(context)!.heldTheCall}';
+ } else if (proxy.remoteOnHold) {
+ heldTitle = '${L10n.of(context)!.you} ${L10n.of(context)!.heldTheCall}';
+ }
+ return heldTitle;
+ }
+
+ VoipPlugin get voipPlugin => widget.voipPlugin;
+
+ @override
+ void initState() {
+ super.initState();
+ // Do not rely on this initState to be called only once, user can close this
+ // page and reopen from call banner anytime.
+ ServicesBinding.instance.addPostFrameCallback((timeStamp) {
+ Provider.of(context, listen: false)
+ .removeGlobalBanner(); // ideally was a call banner
+ });
+ SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
+ proxy.onUpdateViewCallback(_handleCallState);
+ }
+
+ StreamSubscription? proximitySubscription;
+
+ @override
+ void dispose() {
+ SystemChrome.setPreferredOrientations([]);
+ super.dispose();
+ }
+
+ void _handleCallState() {
+ if (mounted) {
+ setState(() {});
+ }
+ }
+
+ Future handleAnswerButtonClick() async {
+ try {
+ await proxy.answer();
+ } catch (e) {
+ Logs().e('answer failed?', e);
+ }
+ }
+
+ Future handleHangupButtonClick() async {
+ try {
+ await proxy.hangup();
+ } catch (e) {
+ Logs().e('hangup failed?', e);
+ }
+ }
+
+ Future handleMicMuteButtonClick() async {
+ await proxy.setMicrophoneMuted(!isMicrophoneMuted);
+ }
+
+ Future handleScreenSharingButtonClick() async {
+ if (!kIsWeb && Platform.isAndroid) {
+ if (!proxy.isScreensharingEnabled) {
+ FlutterForegroundTask.init(
+ androidNotificationOptions: AndroidNotificationOptions(
+ channelId: 'notification_channel_id',
+ channelName: 'Foreground Notification',
+ channelDescription:
+ 'This notification appears when the foreground service is running.',
+ ),
+ iosNotificationOptions: const IOSNotificationOptions(),
+ foregroundTaskOptions: const ForegroundTaskOptions(),
+ );
+ await FlutterForegroundTask.startService(
+ notificationTitle: 'Screen sharing',
+ notificationText: 'You are sharing your screen in famedly',
+ );
+ } else {
+ await FlutterForegroundTask.stopService();
+ }
+ }
+
+ await proxy.setScreensharingEnabled(!isScreensharingEnabled);
+ }
+
+ Future handleHoldButtonClick() async {
+ await proxy.setRemoteOnHold(!isRemoteOnHold);
+ }
+
+ Future handleVideoMuteButtonClick() async {
+ await proxy.setLocalVideoMuted(!isLocalVideoMuted);
+ }
+
+ Future handleFlipCameraButtonClick() async {
+ if (proxy.localUserMediaStream != null) {
+ await Helper.switchCamera(
+ proxy.localUserMediaStream!.stream!.getVideoTracks()[0],
+ );
+ }
+ setState(() {});
+ }
+
+ bool speakerPhoneTurnedOn = false;
+ void handleturnOnSpeakerPhoneClicked() async {
+ if (proxy.localUserMediaStream != null) {
+ await Helper.setSpeakerphoneOn(!speakerPhoneTurnedOn);
+ setState(() {
+ speakerPhoneTurnedOn = !speakerPhoneTurnedOn;
+ });
+ }
+ }
+
+ void changeAudioStuff(ChangeAudioMode mode) async {
+ if (proxy.localUserMediaStream != null) {
+ final devices = mode == ChangeAudioMode.input
+ ? await Helper.enumerateDevices('audioinput')
+ : await Helper.audiooutputs;
+ await showModalBottomSheet(
+ context: context,
+ backgroundColor: Colors.white,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.only(
+ topLeft: Radius.circular(8.0),
+ topRight: Radius.circular(8.0),
+ ),
+ ),
+ builder: (context) {
+ return SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: devices
+ .map(
+ (device) => ListTile(
+ title: Text(
+ device.label,
+ style: Theme.of(context).textTheme.bodyLarge!,
+ ),
+ onTap: () async {
+ Logs().d('setting audio $mode to ${device.label}');
+ if (mode == ChangeAudioMode.input) {
+ await Helper.selectAudioInput(device.deviceId);
+ } else {
+ await Helper.selectAudioOutput(device.deviceId);
+ }
+
+ GoRouter.of(context).pop();
+ },
+ ),
+ )
+ .toList(),
+ ),
+ );
+ },
+ );
+ }
+ setState(() {});
+ }
+
+ void handleMoreButtonClicked() async {
+ await showModalBottomSheet(
+ context: context,
+ backgroundColor: Colors.white,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.only(
+ topLeft: Radius.circular(8.0),
+ topRight: Radius.circular(8.0),
+ ),
+ ),
+ builder: (context) {
+ return SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (showScreenSharingButton &&
+ !FluffyThemes.isColumnMode(context))
+ MoreOptionsListTile(
+ icon: Icons.screen_share,
+ title: isScreensharingEnabled
+ ? L10n.of(context)!.stopScreenShare
+ : L10n.of(context)!.startScreenShare,
+ onPressed: handleScreenSharingButtonClick,
+ shouldPopOnPress: true,
+ ),
+ if (proxy is CallSessionState)
+ MoreOptionsListTile(
+ icon: Icons.pause,
+ title: isRemoteOnHold
+ ? L10n.of(context)!.unholdCall
+ : L10n.of(context)!.holdCall,
+ onPressed: handleHoldButtonClick,
+ shouldPopOnPress: true,
+ ),
+ if (showFlipCameraButton &&
+ MediaQuery.of(context).size.width < 400)
+ MoreOptionsListTile(
+ icon: Icons.flip_camera_android,
+ title: L10n.of(context)!.flipCamera,
+ onPressed: handleFlipCameraButtonClick,
+ ),
+ if (!kIsWeb)
+ MoreOptionsListTile(
+ icon: Icons.speaker,
+ title: L10n.of(context)!.audioOutput,
+ onPressed: () async =>
+ changeAudioStuff(ChangeAudioMode.output),
+ ),
+ if (!kIsWeb)
+ MoreOptionsListTile(
+ icon: Icons.mic,
+ title: L10n.of(context)!.audioInput,
+ onPressed: () async =>
+ changeAudioStuff(ChangeAudioMode.input),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+
+ bool miniHangupButtonLoading = false;
+
+ /// PIP buttons
+ /// New buttons will also need to go to more list manually for now if no space available.
+ List _buildActionButtons() {
+ return [
+ if (connected &&
+ voiceonly &&
+ !kIsWeb &&
+ PlatformInfos.isMobile &&
+ !FluffyThemes.isColumnMode(context))
+ CallButton(
+ onPressed: handleturnOnSpeakerPhoneClicked,
+ selected: speakerPhoneTurnedOn,
+ selectedIcon: Icons.speaker,
+ unSelectedIcon: Icons.speaker,
+ extendedView: expandedMainView,
+ ),
+ if (showMicMuteButton)
+ CallButton(
+ onPressed: handleMicMuteButtonClick,
+ selected: isMicrophoneMuted,
+ selectedIcon: Icons.mic_off,
+ unSelectedIcon: Icons.mic,
+ extendedView: expandedMainView,
+ ),
+ if (showFlipCameraButton && MediaQuery.of(context).size.width > 400)
+ CallButton(
+ onPressed: handleFlipCameraButtonClick,
+ selected: false,
+ selectedIcon: Icons.flip_camera_android,
+ unSelectedIcon: Icons.flip_camera_android,
+ extendedView: expandedMainView,
+ ),
+ if (showVideoMuteButton)
+ CallButton(
+ onPressed: handleVideoMuteButtonClick,
+ selected: isLocalVideoMuted,
+ selectedIcon: Icons.videocam_off,
+ unSelectedIcon: Icons.videocam,
+ extendedView: expandedMainView,
+ ),
+ if (showScreenSharingButton && FluffyThemes.isColumnMode(context))
+ CallButton(
+ onPressed: handleScreenSharingButtonClick,
+ selected: isScreensharingEnabled,
+ selectedIcon: Icons.stop_screen_share,
+ unSelectedIcon: Icons.screen_share,
+ extendedView: expandedMainView,
+ ),
+ if (connected &&
+ (!FluffyThemes.isColumnMode(context) ||
+ !isGroupCall)) // show for p2p calls because hold, audio change buttons
+ CallButton(
+ onPressed: handleMoreButtonClicked,
+ selected: false,
+ selectedIcon: Icons.more_vert,
+ unSelectedIcon: Icons.more_vert,
+ extendedView: expandedMainView,
+ doLoadingAnimation: false,
+ ),
+ if (expandedMainView)
+ InkWell(
+ onTap: () async {
+ if (miniHangupButtonLoading) return;
+ setState(() {
+ miniHangupButtonLoading = true;
+ });
+ await handleHangupButtonClick();
+ if (mounted) {
+ setState(() {
+ miniHangupButtonLoading = false;
+ });
+ }
+ },
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(
+ 8.0,
+ ),
+ color: Colors.red,
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16.0,
+ vertical: 12,
+ ),
+ child: miniHangupButtonLoading
+ ? const Center(
+ child: SizedBox(
+ height: 20,
+ width: 20,
+ child: CircularProgressIndicator(
+ color: Colors.white,
+ ),
+ ),
+ )
+ : isGroupCall
+ ? Center(
+ child: Text(
+ L10n.of(context)!.leave,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 15,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ )
+ : const Center(
+ child: Icon(
+ Icons.call_end,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ];
+ }
+
+ bool get expandedMainView =>
+ FluffyThemes.isColumnMode(context) ||
+ isGroupCall ||
+ primaryStream != null && !voiceonly && connected;
+ bool membersEnabled = true;
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: Colors.black,
+ appBar: !FluffyThemes.isColumnMode(context)
+ ? AppBar(
+ automaticallyImplyLeading: false,
+ title: expandedMainView
+ ? CallerId(
+ proxy: proxy,
+ voipPlugin: voipPlugin,
+ )
+ : Container(),
+ toolbarHeight: 70,
+ backgroundColor: Colors.black.withOpacity(0.9),
+ actions: [
+ CallActionButtons(
+ proxy: proxy,
+ voipPlugin: voipPlugin,
+ toggleMembers: () {
+ setState(() {
+ membersEnabled = !membersEnabled;
+ });
+ },
+ ),
+ const SizedBox(width: 12),
+ ],
+ )
+ : const PreferredSize(
+ preferredSize: Size.fromHeight(24.0),
+ child: SizedBox(height: 24.0),
+ ),
+ body: AnnotatedRegion(
+ value: const SystemUiOverlayStyle(
+ systemNavigationBarColor: Colors.black,
+ statusBarColor: Colors.black,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.max,
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: [
+ if (isGroupCall)
+ Expanded(
+ child: Center(
+ child: GroupCallView(
+ call: proxy,
+ client: voipPlugin.client,
+ ),
+ ),
+ )
+ else
+ Expanded(
+ child: Center(
+ child: P2PCallView(
+ call: proxy as CallSessionState,
+ voipPlugin: voipPlugin,
+ ),
+ ),
+ ),
+ Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SizedBox(
+ height: expandedMainView ? 12.0 : 40,
+ ),
+ if (connected)
+ SizedBox(
+ height: 56.0,
+ width: MediaQuery.of(context).size.width,
+ child: Center(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16.0,
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (FluffyThemes.isColumnMode(context))
+ Flexible(
+ flex: 1,
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: CallerId(
+ proxy: proxy,
+ voipPlugin: voipPlugin,
+ ),
+ ),
+ ),
+ Flexible(
+ flex: 3,
+ child: Align(
+ alignment: Alignment.center,
+ child: ListView.separated(
+ shrinkWrap: true,
+ itemBuilder: (context, index) =>
+ _buildActionButtons().toList()[index],
+ separatorBuilder: (context, index) =>
+ const SizedBox(width: 12),
+ itemCount: _buildActionButtons().length,
+ scrollDirection: Axis.horizontal,
+ ),
+ ),
+ ),
+ if (FluffyThemes.isColumnMode(context))
+ Flexible(
+ flex: 1,
+ child: Align(
+ alignment: Alignment.centerRight,
+ child: CallActionButtons(
+ proxy: proxy,
+ voipPlugin: voipPlugin,
+ toggleMembers: () {
+ setState(() {
+ membersEnabled = !membersEnabled;
+ });
+ },
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ SizedBox(
+ height: expandedMainView ? 0 : 16.0,
+ ),
+ if (!expandedMainView || (!connected && !isGroupCall))
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ CallBigButton(
+ onPressed: handleHangupButtonClick,
+ iconData: Icons.call_end,
+ proxy: proxy,
+ text: proxy.isOutgoing || proxy.connected
+ ? L10n.of(context)!.hangup
+ : L10n.of(context)!.reject,
+ backgroundColor: Colors.red,
+ ),
+ const SizedBox(width: 12),
+ if (showAnswerButton)
+ CallBigButton(
+ onPressed: handleAnswerButtonClick,
+ iconData: Icons.call,
+ proxy: proxy,
+ text: L10n.of(context)!.accept,
+ backgroundColor: Colors.green,
+ ),
+ ],
+ ),
+ SizedBox(
+ height: expandedMainView
+ ? 12.0
+ : connected
+ ? MediaQuery.of(context).size.height * 0.05
+ : MediaQuery.of(context).size.height * 0.1,
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class CallBigButton extends StatefulWidget {
+ final Function onPressed;
+ final CallStateProxy proxy;
+ final Color backgroundColor;
+ final IconData iconData;
+ final String text;
+ const CallBigButton({
+ super.key,
+ required this.onPressed,
+ required this.proxy,
+ required this.backgroundColor,
+ required this.iconData,
+ required this.text,
+ });
+
+ @override
+ State createState() => _CallBigButtonState();
+}
+
+class _CallBigButtonState extends State {
+ bool loading = false;
+
+ @override
+ Widget build(BuildContext context) {
+ return InkWell(
+ onTap: () async {
+ if (loading) return;
+ setState(() {
+ loading = true;
+ });
+ await widget.onPressed();
+ if (mounted) {
+ setState(() {
+ loading = false;
+ });
+ }
+ },
+ child: Container(
+ decoration: BoxDecoration(
+ color: widget.backgroundColor,
+ borderRadius: BorderRadius.circular(8.0),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ vertical: 16.0,
+ horizontal: 16.0 + 4,
+ ),
+ child: loading
+ ? const Center(
+ child: SizedBox(
+ height: 24,
+ width: 24,
+ child: CircularProgressIndicator(
+ color: Colors.white,
+ ),
+ ),
+ )
+ : Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(widget.iconData, color: Colors.white),
+ const SizedBox(width: 12),
+ Text(
+ widget.text,
+ style: const TextStyle(color: Colors.white),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class CallerId extends StatelessWidget {
+ final CallStateProxy proxy;
+ final VoipPlugin voipPlugin;
+ const CallerId({super.key, required this.proxy, required this.voipPlugin});
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding:
+ EdgeInsets.only(left: FluffyThemes.isColumnMode(context) ? 0 : 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ proxy.room.getLocalizedDisplayname(),
+ style: Theme.of(context)
+ .textTheme
+ .titleMedium!
+ .copyWith(color: Colors.white),
+ maxLines: 1,
+ ),
+ CallTimer(
+ voipPlugin: voipPlugin,
+ appBar: true,
+ proxy: proxy,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class CallActionButtons extends StatelessWidget {
+ final CallStateProxy proxy;
+ final VoipPlugin voipPlugin;
+ final Function toggleMembers;
+ const CallActionButtons({
+ super.key,
+ required this.proxy,
+ required this.voipPlugin,
+ required this.toggleMembers,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final toRoomPath = '/rooms/${proxy.room.id}';
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ IconButton(
+ onPressed: () {
+ GoRouter.of(context).go(toRoomPath);
+ voipPlugin.createMinimizer(proxy);
+ },
+ icon: const Icon(
+ Icons.chat,
+ color: Colors.white,
+ ),
+ ),
+ if ((proxy.screenSharingStreams.isNotEmpty &&
+ proxy is GroupCallSessionState) ||
+ proxy.userMediaStreams.length > 4)
+ IconButton(
+ onPressed: () => toggleMembers(),
+ icon: const Icon(
+ Icons.people,
+ color: Colors.white,
+ ),
+ ),
+ IconButton(
+ onPressed: () {
+ GoRouter.of(context).go(
+ Provider.of(context, listen: false)
+ .bannerClickedOnPath ??
+ toRoomPath,
+ );
+ voipPlugin.createMinimizer(proxy);
+ },
+ icon: const Icon(
+ Icons.minimize,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/pages/voip/group_call_onboarding/group_call_onboarding_view.dart b/lib/pages/voip/group_call_onboarding/group_call_onboarding_view.dart
new file mode 100644
index 000000000..c47a9af55
--- /dev/null
+++ b/lib/pages/voip/group_call_onboarding/group_call_onboarding_view.dart
@@ -0,0 +1,367 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import 'package:flutter_gen/gen_l10n/l10n.dart';
+import 'package:go_router/go_router.dart';
+import 'package:matrix/matrix.dart';
+import 'package:webrtc_interface/webrtc_interface.dart' hide Navigator;
+
+import 'package:fluffychat/config/app_config.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+import 'package:fluffychat/widgets/matrix.dart';
+import '../../../utils/voip/group_call_session_state.dart';
+import '../../../utils/voip/livekit_group_call_session_state.dart';
+import '../widgets/stream_view.dart';
+
+class GroupCallOnboardingView extends StatefulWidget {
+ const GroupCallOnboardingView({super.key, required this.roomId});
+ final String roomId;
+
+ @override
+ State createState() =>
+ _GroupCallOnboardingViewState();
+}
+
+class _GroupCallOnboardingViewState extends State {
+ MediaStream? _localMediaStream;
+ WrappedMediaStream? _localStream;
+ Client get client => Matrix.of(context).client;
+
+ late Room room;
+
+ Future setup() async {
+ room = client.getRoomById(widget.roomId)!;
+
+ final voipPlugin = Matrix.of(context).voipPlugin;
+
+ _localMediaStream = await voipPlugin.mediaDevices.getUserMedia(
+ {
+ 'audio': true,
+ 'video': {
+ 'mandatory': {
+ 'minWidth': '640',
+ 'minHeight': '480',
+ 'minFrameRate': '30',
+ },
+ 'facingMode': 'user',
+ 'optional': [],
+ },
+ },
+ );
+
+ _localStream = WrappedMediaStream(
+ renderer: voipPlugin.createRenderer(),
+ stream: _localMediaStream,
+ participant:
+ Participant(deviceId: client.deviceID!, userId: client.userID!),
+ room: room,
+ client: voipPlugin.client,
+ purpose: SDPStreamMetadataPurpose.Usermedia,
+ audioMuted: _localMediaStream!.getAudioTracks().isEmpty,
+ videoMuted: _localMediaStream!.getVideoTracks().isEmpty,
+ isWeb: voipPlugin.isWeb,
+ isGroupCall: true,
+ );
+
+ await _localStream!.initialize();
+
+ setState(() {});
+ Logs().i(
+ 'setting up group calls onboarding page with localStream = $_localStream',
+ );
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ setup();
+ SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
+ }
+
+ @override
+ void dispose() {
+ SystemChrome.setPreferredOrientations([]);
+ super.dispose();
+ }
+
+ bool cancelButtonLoading = false;
+ bool joinGroupCallButtonLoading = false;
+ GroupCallSession? groupCallSession;
+
+ @override
+ Widget build(BuildContext context) {
+ if (_localStream == null) {
+ return const Scaffold(
+ body: Center(
+ child: CircularProgressIndicator(),
+ ),
+ );
+ }
+ return Scaffold(
+ backgroundColor: Colors.black,
+ appBar: AppBar(
+ backgroundColor: Colors.black,
+ leadingWidth: 120,
+ leading: InkWell(
+ onTap: _localStream == null
+ ? null
+ : () async {
+ if (cancelButtonLoading) return;
+ setState(() {
+ cancelButtonLoading = true;
+ });
+
+ await _localStream?.dispose();
+ await _localMediaStream?.dispose();
+ await groupCallSession?.leave();
+
+ if (mounted) {
+ setState(() {
+ cancelButtonLoading = false;
+ });
+ }
+ final path = '/rooms/${room.id}';
+ GoRouter.of(context).go(path);
+ },
+ child: Row(
+ children: [
+ const SizedBox(
+ width: 16.0,
+ ),
+ if (cancelButtonLoading)
+ const SizedBox(
+ height: 24,
+ width: 24,
+ child: CircularProgressIndicator(
+ color: Colors.white,
+ ),
+ ),
+ if (!cancelButtonLoading) ...[
+ const Icon(Icons.cancel, color: Colors.white),
+ const SizedBox(
+ width: 12.0,
+ ),
+ Text(
+ L10n.of(context)!.cancel,
+ style: const TextStyle(color: Colors.white),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ body: _localStream == null
+ ? const Center(
+ child: CircularProgressIndicator(),
+ )
+ : Center(
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.max,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ L10n.of(context)!.joinGroupCall,
+ style: Theme.of(context)
+ .textTheme
+ .bodyLarge!
+ .copyWith(color: Colors.white70),
+ ),
+ const SizedBox(
+ height: 4.0,
+ ),
+ Text(
+ room.getLocalizedDisplayname(),
+ style: const TextStyle(fontSize: 28, color: Colors.white),
+ ),
+ const SizedBox(
+ height: 32.0,
+ ),
+ Center(
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(
+ 8.0,
+ ),
+ color: Colors.white.withOpacity(0.08),
+ ),
+ height: 200,
+ width:
+ min(343, MediaQuery.of(context).size.width * 0.9),
+ child: _localStream!.videoMuted
+ ? Center(
+ child: Text(
+ L10n.of(context)!.cameraTurnedOff,
+ style: Theme.of(context)
+ .textTheme
+ .bodyLarge!
+ .copyWith(
+ color: Colors.white.withOpacity(0.8),
+ ),
+ ),
+ )
+ : StreamView(wrappedStream: _localStream!),
+ ),
+ ),
+ const SizedBox(height: 60),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.max,
+ children: [
+ InkWell(
+ onTap: () {
+ setState(() {
+ _localStream!
+ .setAudioMuted(!_localStream!.audioMuted);
+ });
+ },
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(
+ 8.0,
+ ),
+ color: _localStream!.audioMuted
+ ? Colors.white
+ : Colors.white10,
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(
+ 16.0,
+ ),
+ child: Icon(
+ _localStream!.audioMuted
+ ? Icons.mic_off
+ : Icons.mic,
+ color: !_localStream!.audioMuted
+ ? Colors.white
+ : Colors.black,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(
+ width: 16.0,
+ ),
+ InkWell(
+ onTap: () {
+ setState(() {
+ _localStream!
+ .setVideoMuted(!_localStream!.videoMuted);
+ });
+ },
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(
+ 8.0,
+ ),
+ color: _localStream!.videoMuted
+ ? Colors.white
+ : Colors.white10,
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(
+ 16.0,
+ ),
+ child: Icon(
+ _localStream!.videoMuted
+ ? Icons.videocam_off
+ : Icons.videocam,
+ color: !_localStream!.videoMuted
+ ? Colors.white
+ : Colors.black,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 24),
+ InkWell(
+ onTap: () async {
+ if (joinGroupCallButtonLoading) return;
+ setState(() {
+ joinGroupCallButtonLoading = true;
+ });
+ groupCallSession = await Matrix.of(context)
+ .voipPlugin
+ .voip
+ .fetchOrCreateGroupCall(
+ '', //call for whole room
+ room,
+ [
+ AppConfig.livekitEnabledCalls
+ ? LiveKitBackend(
+ livekitServiceUrl:
+ AppConfig.livekitServiceUrl,
+ livekitAlias: widget.roomId,
+ )
+ : MeshBackend(),
+ ],
+ 'm.call',
+ 'm.room',
+ e2ee: AppConfig.enableLivekitE2EE,
+ );
+ final voipPlugin = Matrix.of(context).voipPlugin;
+ final groupCallProxy = groupCallSession!.isLivekitCall
+ ? LiveKitGroupCallSessionState(
+ groupCallSession!,
+ voipPlugin,
+ )
+ : GroupCallSessionState(
+ groupCallSession!,
+ voipPlugin,
+ );
+ VoipPlugin.currentCallProxy = groupCallProxy;
+
+ voipPlugin.connectedTsSinceEpoch = 0;
+ voipPlugin.onHoldMs = 0;
+
+ await groupCallProxy.enter(_localStream!);
+
+ voipPlugin.setupCallAndOpenCallPage(groupCallProxy);
+
+ if (mounted) {
+ setState(() {
+ joinGroupCallButtonLoading = false;
+ });
+ }
+ },
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(
+ 8.0,
+ ),
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ vertical: 16.0,
+ horizontal: 32.0,
+ ),
+ child: joinGroupCallButtonLoading
+ ? const SizedBox(
+ height: 24,
+ width: 24,
+ child: CircularProgressIndicator(
+ color: Colors.white,
+ ),
+ )
+ : Text(
+ L10n.of(context)!.joinGroupCall,
+ style: Theme.of(context)
+ .textTheme
+ .bodyLarge!
+ .copyWith(color: Colors.white),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/voip/group_call_view/group_call_view.dart b/lib/pages/voip/group_call_view/group_call_view.dart
new file mode 100644
index 000000000..e7cb0f704
--- /dev/null
+++ b/lib/pages/voip/group_call_view/group_call_view.dart
@@ -0,0 +1,375 @@
+import 'package:flutter/material.dart';
+
+import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
+import 'package:matrix/matrix.dart';
+import 'package:uuid/uuid.dart';
+
+import 'package:fluffychat/config/themes.dart';
+import 'package:fluffychat/widgets/fluffy_chat_app.dart';
+import '../../../utils/voip/call_state_proxy.dart';
+import '../widgets/stream_view.dart';
+import 'widgets/grid_definitions.dart';
+import 'widgets/no_tracks_published_tile.dart';
+
+enum TileType { userMedia, screenShare }
+
+class GroupCallView extends StatefulWidget {
+ final CallStateProxy call;
+ final Client client;
+
+ const GroupCallView({
+ super.key,
+ required this.call,
+ required this.client,
+ });
+
+ @override
+ State createState() => GroupCallViewState();
+}
+
+class GroupCallViewState extends State {
+ GroupCallSession get groupCall => widget.call.groupCall!;
+
+ List get participants => groupCall.participants;
+
+ List? userMediaStreams;
+ List? screenSharingStreams;
+
+ List getStreamOfTileType(TileType type) {
+ return (type == TileType.userMedia
+ ? userMediaStreams
+ : screenSharingStreams) ??
+ [];
+ }
+
+ List selectedLayouts = [];
+ int currentPage = 0;
+ final pageViewController = PageController();
+
+ /// called every build
+ void createLayoutList(int pcount) {
+ final layout = selectGridLayout(
+ GRID_LAYOUTS,
+ pcount,
+ MediaQuery.of(context).size.width,
+ MediaQuery.of(context).size.height,
+ );
+ int pSet;
+ final pLeft = pcount - layout.maxTiles;
+ pSet = pLeft > 0 ? layout.maxTiles : pcount;
+ selectedLayouts.add(SelectedLayout(layout: layout, tilesOnLayout: pSet));
+ if (pLeft > 0) {
+ createLayoutList(pLeft);
+ }
+ }
+
+ /// keep in sync with getTiles
+ int getTilesCount(TileType tileType) {
+ int tiles = 0;
+ for (final p in participants) {
+ final tracksLength = getStreamOfTileType(tileType)
+ .where((element) => element.participant == p)
+ .length;
+ if (tileType == TileType.userMedia) {
+ tiles += tracksLength > 0 ? tracksLength : 1;
+ }
+ }
+ return tiles;
+ }
+
+ /// keep in sync with getTilesCount
+ List getTiles({
+ required TileType tileType,
+ required double height,
+ double? width,
+ }) {
+ final List tiles = [];
+ for (final p in participants) {
+ final user = widget.call.room.unsafeGetUserFromMemoryOrFallback(p.userId);
+
+ final List ptiles = [];
+ for (final stream in getStreamOfTileType(tileType)
+ .where((element) => element.participant == p)) {
+ final streamUuid = const Uuid().v4().toString();
+ final tile = InkWell(
+ key: ValueKey(stream.id + streamUuid),
+ onTap: () => togglePinned(stream),
+ child: IgnorePointer(
+ child: SizedBox(
+ height: height,
+ width: width,
+ child: StreamView(
+ highLight: false,
+ wrappedStream: stream,
+ showName: true,
+ avatarSize: 80,
+ avatarTextSize: 48,
+ avatarBorderRadius: 16,
+ showExtendedName: true,
+ ),
+ ),
+ ),
+ );
+ ptiles.add(tile);
+ }
+
+ tiles.addAll(ptiles);
+ Logs().d(
+ 'Found ${ptiles.length} tiles for ${user.displayName} of $tileType',
+ );
+ // add users who haven't published any tracks and hide missing screen share blanks
+ if (tileType == TileType.userMedia && ptiles.isEmpty) {
+ tiles.add(
+ NoTracksPublishedTile(
+ client: widget.client,
+ user: user,
+ height: height,
+ ),
+ );
+ Logs().d(
+ 'Added blank tile for for ${user.displayName} of $tileType',
+ );
+ }
+ }
+ Logs().d(
+ 'Rendering ${tiles.length} tiles of $tileType',
+ );
+ return tiles;
+ }
+
+ WrappedMediaStream? pinnedStream;
+
+ void togglePinned(WrappedMediaStream stream) {
+ // ignore pinning when only one tile
+
+ final totalTiles =
+ getTilesCount(TileType.userMedia) + getTilesCount(TileType.screenShare);
+
+ if (totalTiles <= 1) return;
+
+ setState(() {
+ pinnedStream = pinnedStream == stream ? null : stream;
+ });
+ Logs()
+ .e('pinnsed stream set to: ${(pinnedStream?.displayName).toString()}');
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final focusedStreamUuid = const Uuid().v4().toString();
+ Logs().d('[GroupCallView] rebuilding callgrid children');
+
+ // groupCall.encryptionKeysMap.forEach((key, value) {
+ // Logs().i(key.userId.toString());
+ // Logs().i(value.values.map((e) => base64Encode(e).toString()).toString());
+ // });
+
+ userMediaStreams = List.from(widget.call.userMediaStreams);
+ screenSharingStreams = List.from(widget.call.screenSharingStreams);
+
+ // reset the pinned stream
+ if (!userMediaStreams!.contains(pinnedStream) &&
+ !screenSharingStreams!.contains(pinnedStream)) pinnedStream = null;
+
+ selectedLayouts.clear();
+ createLayoutList(getTilesCount(TileType.userMedia));
+
+ if (currentPage > selectedLayouts.length - 1) {
+ currentPage = selectedLayouts.length - 1;
+ Logs().d('[GroupCallView] currentPage set to $currentPage');
+ }
+
+ // use global context so no other widget in the tree can affect getting this
+ final mediaQuery =
+ MediaQuery.of(FluffyChatApp.appGlobalKey.currentContext!);
+ final columnMode =
+ FluffyThemes.isColumnMode(FluffyChatApp.appGlobalKey.currentContext!);
+
+ final availableHeight = mediaQuery.size.height -
+ (70 +
+ mediaQuery.padding.top +
+ mediaQuery.padding.bottom +
+ 56.0 +
+ 24 + // bottom and top padding of action buttons bar
+ 8.0 + // seperator padding
+ // pagination buttons
+ (columnMode ? 0 : 40));
+ final availableWidth = mediaQuery.size.width -
+ 24; // acts as left right padding because centered
+
+ if ((screenSharingStreams ?? []).isEmpty && pinnedStream == null) {
+ return Column(
+ children: [
+ SizedBox(
+ height: availableHeight,
+ child: PageView.builder(
+ physics: const NeverScrollableScrollPhysics(),
+ itemCount: selectedLayouts.length,
+ controller: pageViewController,
+ itemBuilder: (BuildContext context, int pageIndex) {
+ // Logs().d('pageIndex: $pageIndex');
+ // Logs().d('users: ${selectedLayouts[pageIndex].tilesOnLayout}');
+ // Logs()
+ // .d('grid name: ${selectedLayouts[pageIndex].layout.name}');
+ // Logs()
+ // .d('columns: ${selectedLayouts[pageIndex].layout.columns}');
+ // Logs().d('rows: ${selectedLayouts[pageIndex].layout.rows}');
+ // Logs().d(
+ // 'maxTiles: ${selectedLayouts[pageIndex].layout.maxTiles}',
+ // );
+
+ int startIndex = 0;
+ for (final page in selectedLayouts.sublist(0, pageIndex)) {
+ startIndex += page.tilesOnLayout;
+ }
+ Logs().d('startIndex: $startIndex');
+
+ final tiles = getTiles(
+ tileType: TileType.userMedia,
+ height:
+ availableHeight / selectedLayouts[pageIndex].layout.rows,
+ );
+
+ final tilesForPage = tiles.sublist(
+ startIndex,
+ startIndex + selectedLayouts[pageIndex].tilesOnLayout,
+ );
+
+ Logs().d('tilesForPage length: ${tilesForPage.toString()}');
+
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ child: AlignedGridView.count(
+ mainAxisSpacing: 3.0,
+ crossAxisSpacing: 4.0,
+ crossAxisCount: selectedLayouts[pageIndex].layout.columns,
+ itemCount: selectedLayouts[pageIndex].tilesOnLayout,
+ itemBuilder: (context, index) {
+ return tilesForPage[index];
+ },
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ IconButton(
+ icon: const Icon(Icons.arrow_back),
+ onPressed: currentPage > 0
+ ? () {
+ setState(() {
+ currentPage--;
+ pageViewController.jumpToPage(currentPage);
+ });
+ }
+ : null,
+ ),
+ Text('${currentPage + 1}/${selectedLayouts.length}'),
+ IconButton(
+ icon: const Icon(Icons.arrow_forward),
+ onPressed: currentPage < selectedLayouts.length - 1
+ ? () {
+ setState(() {
+ currentPage++;
+ pageViewController.jumpToPage(currentPage);
+ });
+ }
+ : null,
+ ),
+ ],
+ ),
+ ],
+ );
+ } else {
+ final focusedStream = pinnedStream ??
+ screenSharingStreams!.firstWhere((element) => element.stream != null);
+
+ final List miniTiles = [
+ ...getTiles(
+ tileType: TileType.screenShare,
+ height: 120.0,
+ width: columnMode ? 220.0 - 8.0 : 120.0,
+ ),
+ ...getTiles(
+ tileType: TileType.userMedia,
+ height: 120.0,
+ width: columnMode ? 220.0 - 8.0 : 120.0,
+ ),
+ ]
+ .where(
+ (element) =>
+ element.key != ValueKey(focusedStream.id + focusedStreamUuid),
+ )
+ .toList();
+
+ final List tilesWhileScreenSharing = [
+ InkWell(
+ key: ValueKey(focusedStream.id + focusedStreamUuid),
+ onTap: () => togglePinned(focusedStream),
+ child: IgnorePointer(
+ child: SizedBox(
+ height: columnMode ? availableHeight : availableHeight - 120.0,
+ width: columnMode ? availableWidth - 220.0 : availableWidth,
+ child: StreamView(
+ highLight: false,
+ showName: true,
+ avatarSize: 80,
+ avatarTextSize: 48,
+ avatarBorderRadius: 16,
+ showExtendedName: true,
+ wrappedStream: focusedStream,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(
+ height: 2.0,
+ width: 2.0,
+ ),
+ SizedBox(
+ height: columnMode
+ ? null
+ : 120.0, // allow complete height to scroll in columnMode
+ width: columnMode
+ ? 220.0 - 8.0
+ : null, // allow complete width to scroll in mobileMode
+ child: ListView.separated(
+ scrollDirection: columnMode ? Axis.vertical : Axis.horizontal,
+ itemCount: miniTiles.length,
+ separatorBuilder: (context, index) => const SizedBox(
+ height: 2.0,
+ width: 2.0,
+ ),
+ itemBuilder: (context, index) => miniTiles[index],
+ ),
+ ),
+ ];
+ return SizedBox(
+ // height: availableHeight,
+ width: availableWidth,
+ child: columnMode
+ ? Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: tilesWhileScreenSharing,
+ )
+ : Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: tilesWhileScreenSharing.reversed.toList(),
+ ),
+ );
+ }
+ }
+}
+
+class SelectedLayout {
+ GridLayoutDefinition layout;
+ int tilesOnLayout;
+
+ SelectedLayout({required this.layout, required this.tilesOnLayout});
+}
diff --git a/lib/pages/voip/group_call_view/widgets/grid_definitions.dart b/lib/pages/voip/group_call_view/widgets/grid_definitions.dart
new file mode 100644
index 000000000..d4e01cf7f
--- /dev/null
+++ b/lib/pages/voip/group_call_view/widgets/grid_definitions.dart
@@ -0,0 +1,131 @@
+import 'package:matrix/matrix.dart';
+
+class GridLayoutDefinition {
+ late String name;
+ late int columns;
+ late int rows;
+ late int minTiles;
+ late int maxTiles;
+ late int minWidth;
+ late int minHeight;
+
+ GridLayoutDefinition({
+ required this.name,
+ required this.columns,
+ required this.rows,
+ required this.minTiles,
+ required this.maxTiles,
+ required this.minWidth,
+ required this.minHeight,
+ });
+}
+
+// ignore: non_constant_identifier_names
+List GRID_LAYOUTS = [
+ GridLayoutDefinition(
+ name: '1x1',
+ columns: 1,
+ rows: 1,
+ minTiles: 1,
+ maxTiles: 1,
+ minWidth: 0,
+ minHeight: 0,
+ ),
+ GridLayoutDefinition(
+ name: '1x2',
+ columns: 1,
+ rows: 2,
+ minTiles: 2,
+ maxTiles: 2,
+ minWidth: 0,
+ minHeight: 0,
+ ),
+ GridLayoutDefinition(
+ name: '2x1',
+ columns: 2,
+ rows: 1,
+ minTiles: 2,
+ maxTiles: 2,
+ minWidth: 560,
+ minHeight: 0,
+ ),
+ GridLayoutDefinition(
+ name: '2x2',
+ columns: 2,
+ rows: 2,
+ minTiles: 3,
+ maxTiles: 4,
+ minWidth: 560,
+ minHeight: 0,
+ ),
+ GridLayoutDefinition(
+ name: '3x3',
+ columns: 3,
+ rows: 3,
+ minTiles: 5,
+ maxTiles: 9,
+ minWidth: 700,
+ minHeight: 0,
+ ),
+ GridLayoutDefinition(
+ name: '4x4',
+ columns: 4,
+ rows: 4,
+ minTiles: 10,
+ maxTiles: 16,
+ minWidth: 960,
+ minHeight: 0,
+ ),
+ GridLayoutDefinition(
+ name: '5x5',
+ columns: 5,
+ rows: 5,
+ minTiles: 17,
+ maxTiles: 25,
+ minWidth: 1100,
+ minHeight: 0,
+ ),
+];
+
+GridLayoutDefinition selectGridLayout(
+ List layouts,
+ int participantCount,
+ double width,
+ double height,
+) {
+ int currentLayoutIndex = 0;
+ var layout = layouts.firstWhere(
+ (layout_) {
+ currentLayoutIndex = layouts.indexOf(layout_);
+ final isBiggerLayoutAvailable = layouts.indexWhere(
+ (l) =>
+ layouts.indexOf(l) > currentLayoutIndex &&
+ l.maxTiles == layout_.maxTiles,
+ ) !=
+ -1;
+ return layout_.maxTiles >= participantCount && !isBiggerLayoutAvailable;
+ },
+ orElse: () {
+ final lastLayout = layouts.last;
+ // ignore: avoid_print
+ Logs().d(
+ 'No layout found for: participantCount: $participantCount, width/height: $width/$height fallback to biggest available layout (${lastLayout.name}).',
+ );
+ return lastLayout;
+ },
+ );
+
+ if (width < layout.minWidth || height < layout.minHeight) {
+ if (currentLayoutIndex > 0) {
+ final smallerLayout = layouts[currentLayoutIndex - 1];
+ layout = selectGridLayout(
+ layouts.sublist(0, currentLayoutIndex),
+ smallerLayout.maxTiles,
+ width,
+ height,
+ );
+ }
+ }
+
+ return layout;
+}
diff --git a/lib/pages/voip/group_call_view/widgets/lk_participant_stats.dart b/lib/pages/voip/group_call_view/widgets/lk_participant_stats.dart
new file mode 100644
index 000000000..d43389cfd
--- /dev/null
+++ b/lib/pages/voip/group_call_view/widgets/lk_participant_stats.dart
@@ -0,0 +1,137 @@
+import 'package:flutter/material.dart';
+
+import 'package:livekit_client/livekit_client.dart';
+
+enum StatsType {
+ kUnknown,
+ kLocalAudioSender,
+ kLocalVideoSender,
+ kRemoteAudioReceiver,
+ kRemoteVideoReceiver,
+}
+
+class ParticipantStatsWidget extends StatefulWidget {
+ const ParticipantStatsWidget({super.key, required this.participant});
+ final Participant participant;
+ @override
+ State createState() => _ParticipantStatsWidgetState();
+}
+
+class _ParticipantStatsWidgetState extends State {
+ List> listeners = [];
+ StatsType statsType = StatsType.kUnknown;
+ Map stats = {};
+
+ void _setUpListener(Track track) {
+ final listener = track.createListener();
+ listeners.add(listener);
+ if (track is LocalVideoTrack) {
+ statsType = StatsType.kLocalVideoSender;
+ listener.on((event) {
+ setState(() {
+ stats['video tx'] = 'total sent ${event.currentBitrate.toInt()} kpbs';
+ event.stats.forEach((key, value) {
+ stats['layer-$key'] =
+ '${value.frameWidth ?? 0}x${value.frameHeight ?? 0} ${value.framesPerSecond?.toDouble() ?? 0} fps, ${event.bitrateForLayers[key] ?? 0} kbps';
+ });
+ final firstStats =
+ event.stats['f'] ?? event.stats['h'] ?? event.stats['q'];
+ if (firstStats != null) {
+ stats['encoder'] = firstStats.encoderImplementation ?? '';
+ stats['video codec'] =
+ '${firstStats.mimeType}, ${firstStats.clockRate}hz, pt: ${firstStats.payloadType}';
+ stats['qualityLimitationReason'] =
+ firstStats.qualityLimitationReason ?? '';
+ }
+ });
+ });
+ } else if (track is RemoteVideoTrack) {
+ statsType = StatsType.kRemoteVideoReceiver;
+ listener.on((event) {
+ setState(() {
+ stats['video rx'] = '${event.currentBitrate.toInt()} kpbs';
+ stats['video codec'] =
+ '${event.stats.mimeType}, ${event.stats.clockRate}hz, pt: ${event.stats.payloadType}';
+ stats['video size'] =
+ '${event.stats.frameWidth}x${event.stats.frameHeight} ${event.stats.framesPerSecond?.toDouble()}fps';
+ stats['video jitter'] = '${event.stats.jitter} s';
+ stats['video decoder'] = '${event.stats.decoderImplementation}';
+ //stats['video packets lost'] = '${event.stats.packetsLost}';
+ //stats['video packets received'] = '${event.stats.packetsReceived}';
+ stats['video frames received'] = '${event.stats.framesReceived}';
+ stats['video frames decoded'] = '${event.stats.framesDecoded}';
+ stats['video frames dropped'] = '${event.stats.framesDropped}';
+ });
+ });
+ } else if (track is LocalAudioTrack) {
+ statsType = StatsType.kLocalAudioSender;
+ listener.on((event) {
+ setState(() {
+ stats['audio tx'] = '${event.currentBitrate.toInt()} kpbs';
+ stats['audio codec'] =
+ '${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
+ });
+ });
+ } else if (track is RemoteAudioTrack) {
+ statsType = StatsType.kRemoteAudioReceiver;
+ listener.on((event) {
+ setState(() {
+ stats['audio rx'] = '${event.currentBitrate.toInt()} kpbs';
+ stats['audio codec'] =
+ '${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
+ stats['audio jitter'] = '${event.stats.jitter} s';
+ //stats['audio concealed samples'] =
+ // '${event.stats.concealedSamples} / ${event.stats.concealmentEvents}';
+ stats['audio packets lost'] = '${event.stats.packetsLost}';
+ stats['audio packets received'] = '${event.stats.packetsReceived}';
+ });
+ });
+ }
+ }
+
+ _onParticipantChanged() {
+ for (final element in listeners) {
+ element.dispose();
+ }
+ listeners.clear();
+ for (final track in widget.participant.trackPublications.values) {
+ if (track.track != null) {
+ _setUpListener(track.track!);
+ }
+ }
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ widget.participant.addListener(_onParticipantChanged);
+ // trigger initial change
+ _onParticipantChanged();
+ }
+
+ @override
+ void deactivate() {
+ for (final element in listeners) {
+ element.dispose();
+ }
+ widget.participant.removeListener(_onParticipantChanged);
+ super.deactivate();
+ }
+
+ num sendBitrate = 0;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ color: Colors.black.withOpacity(0.3),
+ padding: const EdgeInsets.symmetric(
+ vertical: 8,
+ horizontal: 8,
+ ),
+ child: Column(
+ children:
+ stats.entries.map((e) => Text('${e.key}: ${e.value}')).toList(),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/voip/group_call_view/widgets/no_tracks_published_tile.dart b/lib/pages/voip/group_call_view/widgets/no_tracks_published_tile.dart
new file mode 100644
index 000000000..8866c5197
--- /dev/null
+++ b/lib/pages/voip/group_call_view/widgets/no_tracks_published_tile.dart
@@ -0,0 +1,67 @@
+import 'package:flutter/material.dart';
+
+import 'package:matrix/matrix.dart';
+
+import '../../../../widgets/avatar.dart';
+
+class NoTracksPublishedTile extends StatelessWidget {
+ final double? height;
+ final User user;
+ final Client client;
+
+ const NoTracksPublishedTile({
+ super.key,
+ this.height,
+ required this.user,
+ required this.client,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ height: height,
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.18),
+ borderRadius: BorderRadius.circular(
+ 4.0,
+ ),
+ ),
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ Avatar(
+ mxContent: user.avatarUrl,
+ name: user.displayName ?? '',
+ size: 80,
+ fontSize: 48,
+ client: client,
+ ),
+ Positioned(
+ left: 4.0,
+ bottom: 4.0,
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Row(
+ children: [
+ const Icon(
+ Icons.mic_off,
+ size: 15,
+ color: Colors.white,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ user.displayName.toString(),
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 12,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages/voip/p2p_call_view/p2p_view.dart b/lib/pages/voip/p2p_call_view/p2p_view.dart
new file mode 100644
index 000000000..b072695ab
--- /dev/null
+++ b/lib/pages/voip/p2p_call_view/p2p_view.dart
@@ -0,0 +1,241 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+
+import 'package:flutter_gen/gen_l10n/l10n.dart';
+import 'package:matrix/matrix.dart';
+
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+import 'package:fluffychat/widgets/fluffy_chat_app.dart';
+import '../../../utils/voip/call_session_state.dart';
+import '../../../widgets/avatar.dart';
+import '../widgets/call_timer.dart';
+import '../widgets/stream_view.dart';
+
+/// stack of remote view and user view
+class P2PCallView extends StatefulWidget {
+ final CallSessionState call;
+ final VoipPlugin voipPlugin;
+ const P2PCallView({
+ super.key,
+ required this.call,
+ required this.voipPlugin,
+ });
+
+ @override
+ State createState() => _P2PCallViewState();
+}
+
+class _P2PCallViewState extends State {
+ CallSessionState get call => widget.call;
+ VoipPlugin get voipPlugin => widget.voipPlugin;
+
+ WrappedMediaStream? get primaryStream => call.primaryStream;
+ List get remoteStreams =>
+ call.userMediaStreams.where((element) => !element.isLocal()).toList();
+ List get screenSharingStreams =>
+ call.screenSharingStreams;
+
+ List secondaryViews() {
+ final views = [];
+
+ final List userMediaStreamsCopy =
+ List.from(call.userMediaStreams);
+
+ if (call.connected && call.voiceonly) {
+ userMediaStreamsCopy.removeWhere(
+ (element) => element.participant == voipPlugin.voip.localParticipant,
+ );
+ }
+ userMediaStreamsCopy.removeWhere(
+ (element) => element.stream!.id == primaryStream!.stream!.id,
+ );
+
+ for (final stream in userMediaStreamsCopy) {
+ views.add(
+ Material(
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8.0),
+ ),
+ elevation: 20,
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(
+ 8.0,
+ ),
+ color: Colors.black,
+ ),
+ child: SizedBox(
+ width: 96,
+ height: 128,
+ child: stream.participant == voipPlugin.voip.localParticipant &&
+ stream.videoMuted
+ ? Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.videocam_off,
+ color: Colors.white.withOpacity(0.3),
+ ),
+ const SizedBox(height: 11),
+ Text(
+ 'Camera turned off',
+ style:
+ Theme.of(context).textTheme.bodySmall!.copyWith(
+ color: Colors.white.withOpacity(0.8),
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ )
+ : StreamView(
+ wrappedStream: stream,
+ isVoiceOnly: call.voiceonly,
+ ),
+ ),
+ ),
+ ),
+ );
+ views.add(const SizedBox(height: 8));
+ }
+
+ return views;
+ }
+
+ bool get expandedMainView =>
+ primaryStream != null && !call.voiceonly && call.connected;
+
+ @override
+ Widget build(BuildContext context) {
+ // use global context so no other widget in the tree can affect getting this
+ final mediaQuery =
+ MediaQuery.of(FluffyChatApp.appGlobalKey.currentContext!);
+ final availableHeight = mediaQuery.size.height -
+ (70 +
+ mediaQuery.padding.top +
+ mediaQuery.padding.bottom +
+ 56.0 +
+ 24 // bottom and top padding of action buttons bar
+ );
+
+ return call.callOnHold
+ ? Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(
+ Icons.pause,
+ size: 48.0,
+ color: Colors.white,
+ ),
+ Text(
+ call.localHold
+ ? L10n.of(context)!.userHeldTheCall(
+ call.displayName ?? L10n.of(context)!.unknownUser,
+ )
+ : L10n.of(context)!.youHeldTheCall,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 24.0,
+ ),
+ ),
+ ],
+ )
+ : Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ if (!call.connected)
+ Text(
+ !call.isOutgoing && !call.connecting
+ ? call.type == VoipType.kVoice
+ ? L10n.of(context)!.audioCall
+ : L10n.of(context)!.videoCall
+ : call.isOutgoing && !call.connecting
+ ? L10n.of(context)!.youAreCalling
+ : L10n.of(context)!.connecting,
+ style: Theme.of(context)
+ .textTheme
+ .bodyLarge!
+ .copyWith(color: Colors.white.withOpacity(0.8)),
+ ),
+ if (call.connected && call.voiceonly)
+ CallTimer(
+ voipPlugin: voipPlugin,
+ appBar: false,
+ proxy: call,
+ ),
+ SizedBox(height: !expandedMainView ? 32 : 0),
+ if (!call.connected)
+ // outgoing calls initial page
+ Avatar(
+ mxContent: call.call.remoteUser!.avatarUrl,
+ name: call.call.remoteUser!.displayName.toString(),
+ size: min(MediaQuery.of(context).size.height * 0.2, 160),
+ fontSize: 64,
+ client: call.room.client,
+ )
+ else
+ Stack(
+ children: [
+ Center(
+ child: SizedBox(
+ height: expandedMainView ? availableHeight : null,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 4),
+ child: Center(
+ child: StreamView(
+ wrappedStream: primaryStream!,
+ avatarSize: min(
+ MediaQuery.of(context).size.height * 0.2,
+ 160,
+ ),
+ avatarTextSize: 64,
+ avatarBorderRadius: 24,
+ isVoiceOnly: call.voiceonly,
+ ),
+ ),
+ ),
+ ),
+ ),
+ call.userMediaStreams.isEmpty ||
+ (call.voiceonly && primaryStream!.videoMuted)
+ ? Container()
+ : Positioned(
+ right: 8,
+ bottom: 0,
+ child: Column(children: secondaryViews()),
+ ),
+ ],
+ ),
+ SizedBox(height: expandedMainView ? 0 : 32),
+ if (screenSharingStreams.isEmpty &&
+ (call.voiceonly || !call.connected)) ...[
+ // show name and org only if voice only and no screen sharing
+ Text(
+ !call.connected
+ ? call.call.remoteUser!.displayName.toString()
+ : primaryStream!.displayName.toString(),
+ style: Theme.of(context)
+ .textTheme
+ .headlineSmall!
+ .copyWith(fontSize: 24, color: Colors.white),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ !call.connected
+ ? call.call.remoteUser!.id.domain.toString()
+ : primaryStream!.participant.userId.domain.toString(),
+ style: Theme.of(context)
+ .textTheme
+ .bodyLarge!
+ .copyWith(color: Colors.white),
+ ),
+ ],
+ ],
+ );
+ }
+}
diff --git a/lib/pages/voip/widgets/call_banner.dart b/lib/pages/voip/widgets/call_banner.dart
new file mode 100644
index 000000000..70380f472
--- /dev/null
+++ b/lib/pages/voip/widgets/call_banner.dart
@@ -0,0 +1,78 @@
+import 'package:flutter/material.dart';
+
+import 'package:flutter_gen/gen_l10n/l10n.dart';
+import 'package:provider/provider.dart';
+
+import 'package:fluffychat/utils/app_state.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+import 'package:fluffychat/widgets/fluffy_chat_app.dart';
+import 'package:fluffychat/widgets/matrix.dart';
+import '../../../utils/voip/call_state_proxy.dart';
+import '../../../utils/voip/group_call_session_state.dart';
+import '../../../widgets/avatar.dart';
+import 'call_timer.dart';
+
+class CallBanner extends StatelessWidget {
+ final CallStateProxy proxy;
+ final VoipPlugin? voipPlugin;
+ const CallBanner({super.key, required this.proxy, this.voipPlugin});
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isBright = theme.brightness == Brightness.light;
+ return ListTile(
+ onTap: () {
+ Provider.of(context, listen: false).bannerClickedOnPath =
+ FluffyChatApp.router.routerDelegate.currentConfiguration.uri
+ .toString();
+
+ FluffyChatApp.router.go('/rooms/${proxy.room.id}/call');
+ },
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.all(
+ Radius.circular(12.0),
+ ),
+ ),
+ tileColor: Colors.green,
+ contentPadding: const EdgeInsets.all(0),
+ minVerticalPadding: 0,
+ horizontalTitleGap: 0,
+ leading: Padding(
+ padding: const EdgeInsets.only(
+ left: 8.0,
+ right: 12.0,
+ ),
+ child: Avatar(
+ mxContent: proxy.room.avatar,
+ size: 52,
+ name: proxy.room.getLocalizedDisplayname(),
+ client: proxy.room.client,
+ ),
+ ),
+ title: Text(
+ proxy.room.getLocalizedDisplayname(),
+ style: Theme.of(context).textTheme.labelLarge!.copyWith(fontSize: 16),
+ ),
+ subtitle: CallTimer(
+ appendText: proxy is GroupCallSessionState
+ ? L10n.of(context)!.groupCall
+ : proxy.type == VoipType.kVoice
+ ? L10n.of(context)!.audioCall
+ : L10n.of(context)!.videoCall,
+ proxy: proxy,
+ appBar: true,
+ voipPlugin: voipPlugin ?? Matrix.of(context).voipPlugin,
+ overrideTextStyle: Theme.of(context).textTheme.bodySmall!.copyWith(
+ color: isBright
+ ? Colors.white.withOpacity(0.8)
+ : Colors.black.withOpacity(0.72),
+ ),
+ ),
+ trailing: const Padding(
+ padding: EdgeInsets.only(right: 16.0),
+ child: Icon(Icons.arrow_forward_ios, size: 16),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/voip/widgets/call_buttons.dart b/lib/pages/voip/widgets/call_buttons.dart
new file mode 100644
index 000000000..68369722e
--- /dev/null
+++ b/lib/pages/voip/widgets/call_buttons.dart
@@ -0,0 +1,70 @@
+import 'package:flutter/material.dart';
+
+class CallButton extends StatefulWidget {
+ final IconData selectedIcon;
+ final IconData unSelectedIcon;
+ final bool selected;
+ final Function onPressed;
+ final bool extendedView;
+ final bool doLoadingAnimation;
+ const CallButton({
+ super.key,
+ required this.onPressed,
+ required this.selectedIcon,
+ required this.selected,
+ required this.unSelectedIcon,
+ this.extendedView = false,
+ this.doLoadingAnimation = true,
+ });
+
+ @override
+ State createState() => _CallButtonState();
+}
+
+class _CallButtonState extends State {
+ bool loading = false;
+ @override
+ Widget build(BuildContext context) {
+ return InkWell(
+ onTap: () async {
+ if (loading) return;
+ setState(() {
+ loading = true;
+ });
+ await widget.onPressed();
+ setState(() {
+ loading = false;
+ });
+ },
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8.0),
+ color: widget.selected
+ ? Colors.white
+ : widget.extendedView
+ ? null
+ : Colors.white10,
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Center(
+ child: loading && widget.doLoadingAnimation
+ ? SizedBox(
+ height: 24,
+ width: 24,
+ child: CircularProgressIndicator(
+ color: widget.selected ? Colors.black : Colors.white,
+ ),
+ )
+ : Icon(
+ widget.selected
+ ? widget.selectedIcon
+ : widget.unSelectedIcon,
+ color: widget.selected ? Colors.black : Colors.white,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/voip/widgets/call_overlay.dart b/lib/pages/voip/widgets/call_overlay.dart
new file mode 100644
index 000000000..00df2ddc7
--- /dev/null
+++ b/lib/pages/voip/widgets/call_overlay.dart
@@ -0,0 +1,380 @@
+import 'package:flutter/material.dart';
+
+import 'package:flutter_gen/gen_l10n/l10n.dart';
+import 'package:matrix/matrix.dart';
+import 'package:provider/provider.dart';
+
+import 'package:fluffychat/utils/app_state.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+import 'package:fluffychat/widgets/fluffy_chat_app.dart';
+import '../../../utils/voip/call_state_proxy.dart';
+import '../../../utils/voip/group_call_session_state.dart';
+import '../../../utils/voip/livekit_group_call_session_state.dart';
+import '../../../widgets/avatar.dart';
+import 'call_timer.dart';
+import 'stream_view.dart';
+
+class CallOverlay extends StatefulWidget {
+ final CallStateProxy callStateProxy;
+ final VoipPlugin voipPlugin;
+ const CallOverlay({
+ super.key,
+ required this.callStateProxy,
+ required this.voipPlugin,
+ });
+
+ @override
+ State createState() => _CallOverlayState();
+}
+
+class _CallOverlayState extends State {
+ List screenSharingStreams = [];
+ List userMediaStreams = [];
+ WrappedMediaStream? get primaryScreenShare => screenSharingStreams.first;
+
+ late bool isGroupCall;
+
+ BuildContext get globalContext => FluffyChatApp.appGlobalKey.currentContext!;
+ void setupCall() {
+ isGroupCall = widget.callStateProxy is GroupCallSessionState ||
+ widget.callStateProxy is LiveKitGroupCallSessionState;
+
+ p2pCallConnecting = !widget.callStateProxy.connected &&
+ !widget.callStateProxy.ended &&
+ !isGroupCall;
+
+ userMediaStreams = List.from(widget.callStateProxy.userMediaStreams);
+
+ screenSharingStreams =
+ List.from(widget.callStateProxy.screenSharingStreams);
+
+ widget.callStateProxy.onUpdateViewCallback(() {
+ if (mounted) setState(() {});
+ });
+ }
+
+ bool? p2pCallConnecting;
+ bool hovering = false;
+
+ void toCallAndRemovePopup() {
+ Provider.of(globalContext, listen: false).bannerClickedOnPath =
+ FluffyChatApp.router.routerDelegate.currentConfiguration.uri.toString();
+
+ FluffyChatApp.router.go('/rooms/${widget.callStateProxy.room.id}/call');
+
+ widget.voipPlugin.removeCallPopupOverlay();
+ }
+
+ Future handleAnswerButtonClick() async {
+ try {
+ await widget.callStateProxy.answer();
+ } catch (e) {
+ Logs().e('answer failed?', e);
+ }
+ toCallAndRemovePopup();
+ }
+
+ Future handleHangupButtonClick() async {
+ try {
+ await widget.callStateProxy.hangup();
+ } catch (e) {
+ Logs().e('hangup failed?', e);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ /// why call this per build and not initState? widget can close/open multiple
+ /// times so initState is called multiple times, having it in the build to avoid confusion
+ /// state is stored in a global variable
+ setupCall();
+
+ return SafeArea(
+ child: p2pCallConnecting ?? true
+ ? Align(
+ heightFactor: 1.0,
+ alignment: Alignment.center,
+ child: SizedBox(
+ height: 360,
+ width: 360,
+ child: Material(
+ color: Colors.black,
+ borderRadius: const BorderRadius.all(Radius.circular(20)),
+ child: Container(
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.16),
+ border: Border.all(),
+ borderRadius: const BorderRadius.all(Radius.circular(20)),
+ ),
+ child: widget.callStateProxy.call!.remoteUser == null
+ ? const Center(child: CircularProgressIndicator())
+ : Column(
+ mainAxisAlignment: MainAxisAlignment.start,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ const SizedBox(
+ height: 16.0,
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16.0,
+ ),
+ child: Row(
+ children: [
+ if (widget.callStateProxy.type ==
+ VoipType.kVoice)
+ Icon(
+ Icons.call,
+ color: Colors.white.withOpacity(0.6),
+ size: 21,
+ )
+ else
+ Icon(
+ Icons.videocam,
+ color: Colors.white.withOpacity(0.6),
+ size: 21,
+ ),
+ const SizedBox(
+ width: 12.0,
+ ),
+ Text(
+ widget.voipPlugin.getCallStateSuffix(
+ widget.callStateProxy,
+ context,
+ ),
+ style: TextStyle(
+ color: Colors.white.withOpacity(0.6),
+ ),
+ ),
+ const Spacer(),
+ IconButton(
+ onPressed: toCallAndRemovePopup,
+ icon: const Icon(
+ Icons.maximize,
+ color: Colors.white,
+ ),
+ ),
+ const SizedBox(
+ width: 12.0,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(
+ height: 32.0,
+ ),
+ Avatar(
+ mxContent: widget
+ .callStateProxy.call!.remoteUser!.avatarUrl,
+ name: widget.callStateProxy.call!.remoteUser!
+ .displayName
+ .toString(),
+ size: 96,
+ fontSize: 16,
+ client: widget.callStateProxy.client,
+ ),
+ const SizedBox(
+ height: 24.0,
+ ),
+ Text(
+ widget.callStateProxy.call!.remoteUser!
+ .displayName
+ .toString(),
+ style: Theme.of(context)
+ .textTheme
+ .bodyLarge!
+ .copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(
+ height: 4.0,
+ ),
+ Text(
+ widget
+ .callStateProxy.call!.remoteUser!.id.domain
+ .toString(),
+ ),
+ const SizedBox(
+ height: 24.0,
+ ),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.max,
+ children: [
+ InkWell(
+ onTap: handleHangupButtonClick,
+ child: Container(
+ height: 56,
+ width: 64,
+ decoration: BoxDecoration(
+ color:
+ Theme.of(context).colorScheme.error,
+ border: Border.all(),
+ borderRadius: const BorderRadius.all(
+ Radius.circular(8),
+ ),
+ ),
+ child: const Icon(
+ Icons.call_end,
+ color: Colors.red,
+ ),
+ ),
+ ),
+ const SizedBox(
+ width: 16.0,
+ ),
+ InkWell(
+ onTap: handleAnswerButtonClick,
+ child: Container(
+ height: 56,
+ width: 64,
+ decoration: BoxDecoration(
+ color: Colors.green,
+ border: Border.all(),
+ borderRadius: const BorderRadius.all(
+ Radius.circular(8),
+ ),
+ ),
+ child: Icon(
+ widget.callStateProxy.type ==
+ VoipType.kVoice
+ ? Icons.call
+ : Icons.videocam,
+ color: Colors.green,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ )
+ : Align(
+ heightFactor: 1.0,
+ alignment: Alignment.bottomRight,
+ child: Container(
+ margin: const EdgeInsets.only(
+ bottom: 74.0,
+ right: 8.0,
+ ),
+ height: 300,
+ width: 300,
+ child: Material(
+ color: Colors.black,
+ borderRadius: const BorderRadius.all(Radius.circular(12)),
+ child: Container(
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.16),
+ border: Border.all(),
+ borderRadius: const BorderRadius.all(Radius.circular(12)),
+ ),
+ child: InkWell(
+ enableFeedback: false,
+ onTap: toCallAndRemovePopup,
+ onHover: (value) {
+ setState(() {
+ hovering = value;
+ });
+ },
+ child: screenSharingStreams.isEmpty &&
+ userMediaStreams.isEmpty
+ ? const Center(child: CircularProgressIndicator())
+ : Stack(
+ children: [
+ StreamView(
+ highLight: true,
+ wrappedStream: screenSharingStreams.isNotEmpty
+ ? screenSharingStreams.first
+ : userMediaStreams.first,
+ avatarSize: 80,
+ avatarTextSize: 48,
+ avatarBorderRadius: 16,
+ ),
+ if (hovering)
+ Align(
+ alignment: Alignment.bottomCenter,
+ child: Container(
+ height: 64,
+ width: 300,
+ margin: const EdgeInsets.only(
+ left: 8.0,
+ right: 8.0,
+ bottom: 8.0,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.black,
+ border: Border.all(),
+ borderRadius: const BorderRadius.all(
+ Radius.circular(12),
+ ),
+ ),
+ child: Row(
+ mainAxisAlignment:
+ MainAxisAlignment.start,
+ crossAxisAlignment:
+ CrossAxisAlignment.center,
+ children: [
+ const SizedBox(
+ width: 12.0,
+ ),
+ Column(
+ mainAxisAlignment:
+ MainAxisAlignment.center,
+ crossAxisAlignment:
+ CrossAxisAlignment.start,
+ children: [
+ Text(
+ widget.callStateProxy
+ .displayName ??
+ L10n.of(context)!.call,
+ style: Theme.of(context)
+ .textTheme
+ .titleMedium!
+ .copyWith(
+ fontWeight:
+ FontWeight.bold,
+ ),
+ ),
+ const SizedBox(
+ height: 4.0,
+ ),
+ CallTimer(
+ appBar: false,
+ proxy: widget.callStateProxy,
+ voipPlugin: widget.voipPlugin,
+ overrideTextStyle:
+ Theme.of(context)
+ .textTheme
+ .bodySmall!,
+ ),
+ ],
+ ),
+ const Spacer(),
+ IconButton(
+ onPressed: toCallAndRemovePopup,
+ icon: const Icon(
+ Icons.maximize,
+ color: Colors.white,
+ ),
+ ),
+ const SizedBox(
+ width: 12.0,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/voip/widgets/call_timer.dart b/lib/pages/voip/widgets/call_timer.dart
new file mode 100644
index 000000000..e42f422cf
--- /dev/null
+++ b/lib/pages/voip/widgets/call_timer.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/material.dart';
+
+import 'package:fluffychat/utils/format_time_helper.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+import '../../../utils/voip/call_state_proxy.dart';
+
+class CallTimer extends StatelessWidget {
+ final CallStateProxy proxy;
+ final VoipPlugin voipPlugin;
+ final bool appBar;
+ final TextStyle? overrideTextStyle;
+ final String? appendText;
+ const CallTimer({
+ super.key,
+ required this.voipPlugin,
+ required this.appBar,
+ this.overrideTextStyle,
+ this.appendText,
+ required this.proxy,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return StreamBuilder(
+ stream: Stream.periodic(const Duration(seconds: 1)),
+ builder: (context, snapshot) {
+ final ts = FormatTimeHelper.formatHHMMSS(
+ ((DateTime.now().millisecondsSinceEpoch -
+ voipPlugin.connectedTsSinceEpoch -
+ voipPlugin.onHoldMs) /
+ 1000)
+ .floor(),
+ );
+ return Text(
+ '${appendText?.isEmpty ?? true ? '' : '$appendText '}${proxy.connected ? ts : voipPlugin.getCallStateSuffix(proxy, context)}',
+ style: overrideTextStyle ??
+ Theme.of(context).textTheme.bodyLarge!.copyWith(
+ color: Colors.white.withOpacity(0.8),
+ fontSize: appBar ? 12 : 16,
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/pages/voip/widgets/more_options_listtile.dart b/lib/pages/voip/widgets/more_options_listtile.dart
new file mode 100644
index 000000000..24b5767d3
--- /dev/null
+++ b/lib/pages/voip/widgets/more_options_listtile.dart
@@ -0,0 +1,44 @@
+import 'package:flutter/material.dart';
+
+import 'package:go_router/go_router.dart';
+
+class MoreOptionsListTile extends StatelessWidget {
+ final IconData icon;
+ final Function onPressed;
+ final String title;
+ final bool shouldPopOnPress;
+ const MoreOptionsListTile({
+ super.key,
+ required this.icon,
+ required this.onPressed,
+ required this.title,
+ this.shouldPopOnPress = false,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return ListTile(
+ onTap: () async {
+ await onPressed();
+ if (shouldPopOnPress) GoRouter.of(context).pop();
+ },
+ minLeadingWidth: 1,
+ leading: Icon(
+ icon,
+ color: Colors.black,
+ ),
+ title: Text(
+ title,
+ style: Theme.of(context)
+ .textTheme
+ .bodyLarge!
+ .copyWith(color: Colors.black),
+ ),
+ trailing: const Icon(
+ Icons.arrow_forward_ios,
+ color: Colors.black,
+ size: 16,
+ ),
+ );
+ }
+}
diff --git a/lib/pages/voip/widgets/stream_view.dart b/lib/pages/voip/widgets/stream_view.dart
new file mode 100644
index 000000000..959610f80
--- /dev/null
+++ b/lib/pages/voip/widgets/stream_view.dart
@@ -0,0 +1,164 @@
+import 'package:flutter/material.dart';
+
+import 'package:flutter_webrtc/flutter_webrtc.dart';
+import 'package:livekit_client/livekit_client.dart';
+import 'package:matrix/matrix.dart';
+
+import '../../../utils/voip/livekit_stream.dart';
+import '../../../widgets/avatar.dart';
+
+class StreamView extends StatefulWidget {
+ final WrappedMediaStream wrappedStream;
+ final double avatarSize;
+ final double avatarTextSize;
+ final double avatarBorderRadius;
+ final bool showExtendedName;
+ final bool showName;
+ final bool isVoiceOnly;
+ final bool highLight;
+ const StreamView({
+ super.key,
+ required this.wrappedStream,
+ this.isVoiceOnly = false,
+ this.showName = false,
+ this.highLight = false,
+ this.avatarSize = 44,
+ this.avatarTextSize = 18,
+ this.avatarBorderRadius = 8.0,
+ this.showExtendedName = false,
+ });
+
+ @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 isLivekit => widget.wrappedStream is LivekitParticipantStream;
+
+ bool get isEncrypted =>
+ isLivekit &&
+ (widget.wrappedStream as LivekitParticipantStream).isEncrypted;
+
+ VideoTrack? get videoTrack => isLivekit
+ ? (widget.wrappedStream as LivekitParticipantStream).lkVideoTrack()
+ : null;
+
+ bool get mirrored =>
+ widget.wrappedStream.isLocal() &&
+ widget.wrappedStream.purpose == SDPStreamMetadataPurpose.Usermedia;
+
+ bool get isScreenSharing =>
+ widget.wrappedStream.purpose == SDPStreamMetadataPurpose.Screenshare;
+
+ @override
+ void initState() {
+ widget.wrappedStream.onMuteStateChanged.stream.listen((stream) {
+ if (mounted) {
+ setState(() {});
+ }
+ });
+ super.initState();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ decoration: BoxDecoration(
+ border: Border.all(
+ width: widget.highLight && !widget.wrappedStream.audioMuted ? 2 : 0,
+ color: Theme.of(context).colorScheme.primary,
+ strokeAlign: BorderSide.strokeAlignInside,
+ style: widget.highLight && !widget.wrappedStream.audioMuted
+ ? BorderStyle.solid
+ : BorderStyle.none,
+ ),
+ borderRadius: BorderRadius.circular(8.0),
+ color: widget.isVoiceOnly
+ ? Colors.black.withOpacity(0.9)
+ : Colors.white.withOpacity(0.18),
+ ),
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ if (widget.wrappedStream.videoMuted)
+ Container(
+ color: Colors.transparent,
+ ),
+ if (!widget.wrappedStream.videoMuted)
+ isLivekit && videoTrack != null
+ ? VideoTrackRenderer(videoTrack!)
+ : RTCVideoView(
+ widget.wrappedStream.renderer as RTCVideoRenderer,
+ mirror: mirrored,
+ ),
+ if (widget.wrappedStream.videoMuted)
+ Builder(
+ builder: (context) {
+ return Avatar(
+ mxContent: avatarUrl,
+ name: displayName ?? '',
+ size: widget.avatarSize,
+ fontSize: widget.avatarTextSize,
+ client: widget.wrappedStream.client,
+ );
+ },
+ ),
+ if (widget.showName)
+ Positioned(
+ left: 4.0,
+ bottom: 4.0,
+ child: !widget.showExtendedName
+ ? Text(displayName.toString())
+ : Container(
+ decoration: BoxDecoration(
+ color:
+ widget.highLight && !widget.wrappedStream.audioMuted
+ ? Theme.of(context).colorScheme.primary
+ : Colors.black.withOpacity(0.4),
+ borderRadius: BorderRadius.circular(
+ 4.0,
+ ),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Row(
+ children: [
+ Icon(
+ widget.wrappedStream.audioMuted
+ ? Icons.mic_off
+ : Icons.mic,
+ size: 15,
+ color: Colors.white,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ displayName.toString(),
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 12,
+ ),
+ ),
+ const SizedBox(width: 4),
+ Icon(
+ isEncrypted ? Icons.lock : Icons.lock_open,
+ size: 15,
+ color: isEncrypted ? Colors.green : Colors.red,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/utils/app_state.dart b/lib/utils/app_state.dart
new file mode 100644
index 000000000..4223fe5b0
--- /dev/null
+++ b/lib/utils/app_state.dart
@@ -0,0 +1,35 @@
+import 'package:flutter/material.dart';
+
+import 'package:matrix/matrix.dart';
+
+class AppState extends ChangeNotifier {
+ /// used by the call screen minimize button to navigate back to the last
+ /// screen where banner was tapped
+ String? bannerClickedOnPath;
+
+ /// a banner widget which can be set using the `setGlobalBanner` and removed
+ /// using the `removeGlobalBanner` methods. Is shown on all screens
+ Widget? globalBanner;
+
+ /// stores a list of the room ids where user clicked to hide the join group call
+ /// banner
+ List hideGroupCallBanner = [];
+ void addRoomToHideBanner(Room room) {
+ hideGroupCallBanner.add(room.id);
+ notifyListeners();
+ }
+
+ /// Use these to set banner anytime in the app
+ void setGlobalBanner(Widget banner, {bool isSecondary = false}) {
+ globalBanner = banner;
+
+ notifyListeners();
+ }
+
+ /// Use these to remove the global banner
+ void removeGlobalBanner({bool isSecondary = false}) {
+ globalBanner = null;
+
+ notifyListeners();
+ }
+}
diff --git a/lib/utils/format_time_helper.dart b/lib/utils/format_time_helper.dart
new file mode 100644
index 000000000..5a8cc25d5
--- /dev/null
+++ b/lib/utils/format_time_helper.dart
@@ -0,0 +1,35 @@
+import 'package:duration/duration.dart';
+import 'package:duration/locale.dart';
+import 'package:intl/intl.dart';
+
+class FormatTimeHelper {
+ static String formatHHMMSS(int seconds) {
+ if (seconds != 0) {
+ final int hours = (seconds / 3600).truncate();
+ seconds = (seconds % 3600).truncate();
+ final int minutes = (seconds / 60).truncate();
+
+ final String hoursStr = (hours).toString().padLeft(2, '0');
+ final String minutesStr = (minutes).toString().padLeft(2, '0');
+ final String secondsStr = (seconds % 60).toString().padLeft(2, '0');
+ if (hours == 0) {
+ return "$minutesStr:$secondsStr";
+ }
+ return "$hoursStr:$minutesStr:$secondsStr";
+ } else {
+ return " ";
+ }
+ }
+
+ /// returns seconds in a prettier format supporting localizations
+ /// Use l10n.localeName to pass localeName
+ static String prettierTime(int seconds, String localeName) {
+ return prettyDuration(
+ Duration(seconds: seconds),
+ locale: DurationLocale.fromLanguageCode(
+ Intl.getCurrentLocale(),
+ ) ??
+ DurationLocale.fromLanguageCode(localeName)!,
+ );
+ }
+}
diff --git a/lib/utils/localization_for_locale_extension.dart b/lib/utils/localization_for_locale_extension.dart
new file mode 100644
index 000000000..fd4a9de31
--- /dev/null
+++ b/lib/utils/localization_for_locale_extension.dart
@@ -0,0 +1,32 @@
+/*
+ * 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:ui';
+
+import 'package:flutter/material.dart';
+
+import 'package:flutter_gen/gen_l10n/l10n.dart';
+
+extension LocalizationForLocaleExtension on PlatformDispatcher {
+ /// Loads the right L10n delegate for the current locale or falls back to a default otherwise. This reuses the Flutter locale selection algorithm. Usually you should not call this. Only do that if you have no access to a build context.
+ Future loadL10n() {
+ final locale = basicLocaleListResolution(locales, L10n.supportedLocales);
+
+ return L10n.delegate.load(locale);
+ }
+}
diff --git a/lib/utils/push_helper.dart b/lib/utils/push_helper.dart
index 2b4b561bf..6ed1311fb 100644
--- a/lib/utils/push_helper.dart
+++ b/lib/utils/push_helper.dart
@@ -15,7 +15,6 @@ import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/utils/client_manager.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart';
-import 'package:fluffychat/utils/voip/callkeep_manager.dart';
Future pushHelper(
PushNotification notification, {
@@ -132,12 +131,6 @@ Future _tryPushHelper(
client.backgroundSync = true;
}
- if (event.type == EventTypes.CallInvite) {
- CallKeepManager().initialize();
- } else if (event.type == EventTypes.CallHangup) {
- client.backgroundSync = false;
- }
-
if (event.type.startsWith('m.call') && event.type != EventTypes.CallInvite) {
Logs().v('Push message is a m.call but not invite. Do not display.');
return;
diff --git a/lib/utils/voip/call_session_state.dart b/lib/utils/voip/call_session_state.dart
new file mode 100644
index 000000000..ea8c3a7ab
--- /dev/null
+++ b/lib/utils/voip/call_session_state.dart
@@ -0,0 +1,309 @@
+import 'dart:async';
+
+import 'package:all_sensors/all_sensors.dart';
+import 'package:just_audio/just_audio.dart';
+import 'package:matrix/matrix.dart';
+import 'package:vibration/vibration.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
+
+import 'package:fluffychat/utils/platform_infos.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+import 'call_state_proxy.dart';
+
+// maybe make it a singleton?
+
+class CallSessionState implements CallStateProxy {
+ final CallSession _call;
+ Function()? callback;
+ final VoipPlugin voipPlugin;
+ AudioPlayer? _outgoingCallAudioPlayer;
+ CallSessionState(this._call, this.voipPlugin) {
+ StreamSubscription? proximitySubscription;
+ int onHold = 0;
+ int onUnhold = 0;
+ _call.onCallEventChanged.stream.listen((CallEvent event) async {
+ Logs().d('[CallSessionState] onCallEventChanged ${event.toString()}');
+ // if (event == CallEvent.kError) {
+ // await ErrorReporter(
+ // call.lastError,
+ // StackTrace.current,
+ // level: SentryLevel.error,
+ // ).onErrorCallback(error);
+ // }
+
+ if (event == CallEvent.kState ||
+ event == CallEvent.kFeedsChanged ||
+ event == CallEvent.kLocalHoldUnhold ||
+ event == CallEvent.kRemoteHoldUnhold) {
+ if (event == CallEvent.kFeedsChanged) {
+ await _call.tryRemoveStopedStreams();
+ } else if ({CallEvent.kLocalHoldUnhold, CallEvent.kRemoteHoldUnhold}
+ .contains(event)) {
+ if (callOnHold) {
+ onHold = DateTime.now().millisecondsSinceEpoch;
+ } else {
+ onUnhold = DateTime.now().millisecondsSinceEpoch;
+ voipPlugin.onHoldMs = voipPlugin.onHoldMs + (onUnhold - onHold);
+ }
+ Logs().w(voipPlugin.onHoldMs.toString());
+ }
+ callback?.call();
+ }
+ });
+
+ _call.onCallStateChanged.stream.listen((state) async {
+ Logs().d('[CallSessionState] onCallStateChanged ${state.toString()}');
+
+ if (_call.isOutgoing) {
+ if (state == CallState.kInviteSent) {
+ final player = _outgoingCallAudioPlayer = AudioPlayer();
+ await player.setLoopMode(LoopMode.all);
+ await player.setAudioSource(
+ AudioSource.asset('assets/sounds/call.ogg'),
+ initialIndex: 0,
+ initialPosition: Duration.zero,
+ );
+ await player.play();
+ } else if ({
+ CallState.kConnecting,
+ CallState.kConnected,
+ CallState.kEnded,
+ }.contains(state)) {
+ await _outgoingCallAudioPlayer?.stop();
+ _outgoingCallAudioPlayer = null;
+ }
+ }
+
+ if (state == CallState.kConnected) {
+ voipPlugin.connectedTsSinceEpoch =
+ DateTime.now().millisecondsSinceEpoch;
+ if (PlatformInfos.isMobile) {
+ if (voiceonly) {
+ // once you start listening to proximity stream remember to cancel it or
+ // proximity sensor will keep turning off screen
+ proximitySubscription =
+ proximityEvents!.listen((ProximityEvent event) {});
+ } else {
+ await WakelockPlus.enable();
+ }
+ await vibrate();
+ }
+ } else if (state == CallState.kEnded) {
+ voipPlugin.connectedTsSinceEpoch = 0;
+ if (PlatformInfos.isMobile) {
+ if (voiceonly) {
+ await proximitySubscription?.cancel();
+ } else {
+ await WakelockPlus.disable();
+ }
+ await vibrate();
+ }
+ }
+ callback?.call();
+ });
+ }
+
+ @override
+ GroupCallSession? get groupCall => null;
+
+ @override
+ CallSession get call => _call;
+
+ Future vibrate() async {
+ try {
+ await Vibration.vibrate(duration: 100);
+ } catch (e) {
+ Logs().e('[Dialer] could not vibrate for call updates');
+ }
+ }
+
+ @override
+ Stream get callEventStream => _call.onCallEventChanged.stream;
+ @override
+ Stream get callStateStream => _call.onCallStateChanged.stream;
+ @override
+ bool get voiceonly =>
+ userMediaStreams.every((stream) => stream.videoMuted) &&
+ screenSharingStreams.isEmpty;
+
+ @override
+ bool get connecting => _call.state == CallState.kConnecting;
+
+ @override
+ bool get answering => _call.state == CallState.kCreateAnswer;
+
+ @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
+ Future answer() async {
+ await _call.answer();
+ callback?.call();
+ }
+
+ @override
+ Future enter(WrappedMediaStream stream) async {
+ // TODO: implement enter
+ }
+
+ @override
+ Future hangup() async {
+ if ({CallState.kRinging, CallState.kFledgling}.contains(_call.state)) {
+ await _call.reject();
+ } else {
+ await _call.hangup();
+ }
+ callback?.call();
+ }
+
+ @override
+ bool get isLocalVideoMuted => _call.isLocalVideoMuted;
+
+ @override
+ bool get isMicrophoneMuted => _call.isMicrophoneMuted;
+
+ @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
+ Future setLocalVideoMuted(bool muted) async {
+ await _call.setLocalVideoMuted(muted);
+ callback?.call();
+ }
+
+ @override
+ Future setMicrophoneMuted(bool muted) async {
+ await _call.setMicrophoneMuted(muted);
+ // TODO(Nico): Refactor this to be more granular
+ callback?.call();
+ }
+
+ @override
+ Future setRemoteOnHold(bool onHold) async {
+ await _call.setRemoteOnHold(onHold);
+ callback?.call();
+ }
+
+ @override
+ Future setScreensharingEnabled(bool enabled) async {
+ await _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.type == CallType.kVoice && !_call.isOutgoing
+ ? _call.remoteUserMediaStream // show remote avatar on incoming call
+ : _call.localUserMediaStream;
+ }
+
+ return _call.localScreenSharingStream ?? _call.localUserMediaStream;
+ }
+
+ @override
+ String? get displayName => _call.room.getLocalizedDisplayname();
+
+ @override
+ void onUpdateViewCallback(Function() handler) {
+ callback = handler;
+ }
+
+ @override
+ Room get room => _call.room;
+
+ @override
+ Client get client => _call.client;
+
+ @override
+ VoipType get type =>
+ _call.type == CallType.kVideo ? VoipType.kVideo : VoipType.kVoice;
+}
diff --git a/lib/utils/voip/call_state_proxy.dart b/lib/utils/voip/call_state_proxy.dart
new file mode 100644
index 000000000..6a521a374
--- /dev/null
+++ b/lib/utils/voip/call_state_proxy.dart
@@ -0,0 +1,54 @@
+import 'package:matrix/matrix.dart';
+
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+
+abstract class CallStateProxy {
+ String? get displayName;
+ bool get isMicrophoneMuted;
+ bool get isLocalVideoMuted;
+ bool get isScreensharingEnabled;
+ bool get localHold;
+ bool get remoteOnHold;
+ bool get answering;
+ bool get voiceonly;
+ bool get connecting;
+ bool get connected;
+ bool get ended;
+ bool get callOnHold;
+ bool get isOutgoing;
+ bool get ringingPlay;
+ String get callState;
+ Room get room;
+ Stream get callEventStream;
+ Stream get callStateStream;
+ Client get client;
+ VoipType get type;
+ GroupCallSession? get groupCall;
+ CallSession? get call;
+
+ Future answer();
+
+ Future hangup();
+
+ Future enter(WrappedMediaStream stream);
+
+ Future setMicrophoneMuted(bool muted);
+
+ Future setLocalVideoMuted(bool muted);
+
+ Future setScreensharingEnabled(bool enabled);
+
+ Future setRemoteOnHold(bool onHold);
+
+ WrappedMediaStream? get localUserMediaStream;
+
+ WrappedMediaStream? get localScreenSharingStream;
+
+ WrappedMediaStream? get primaryStream;
+
+ List get screenSharingStreams;
+
+ List get userMediaStreams;
+
+ void onUpdateViewCallback(Function() callback);
+}
diff --git a/lib/utils/voip/callkeep_manager.dart b/lib/utils/voip/callkeep_manager.dart
deleted file mode 100644
index 6de7cb6e3..000000000
--- a/lib/utils/voip/callkeep_manager.dart
+++ /dev/null
@@ -1,367 +0,0 @@
-import 'dart:async';
-
-import 'package:flutter/material.dart';
-
-import 'package:callkeep/callkeep.dart';
-import 'package:flutter_foreground_task/flutter_foreground_task.dart';
-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';
-
-class CallKeeper {
- CallKeeper(this.callKeepManager, this.call) {
- call.onCallStateChanged.stream.listen(_handleCallState);
- }
-
- CallKeepManager callKeepManager;
- bool? held = false;
- bool? muted = false;
- bool connected = false;
- CallSession call;
-
- // update native caller to show what remote user has done.
- void _handleCallState(CallState state) {
- Logs().i('CallKeepManager::handleCallState: ${state.toString()}');
- switch (state) {
- case CallState.kConnecting:
- Logs().v('callkeep connecting');
- break;
- case CallState.kConnected:
- Logs().v('callkeep connected');
- if (!connected) {
- callKeepManager.answer(call.callId);
- } else {
- callKeepManager.setMutedCall(call.callId, false);
- callKeepManager.setOnHold(call.callId, false);
- }
- break;
- case CallState.kEnded:
- callKeepManager.hangup(call.callId);
- break;
- /* TODO:
- case CallState.kMuted:
- callKeepManager.setMutedCall(uuid, true);
- break;
- case CallState.kHeld:
- callKeepManager.setOnHold(uuid, true);
- break;
- */
- case CallState.kFledgling:
- // TODO: Handle this case.
- break;
- case CallState.kInviteSent:
- // TODO: Handle this case.
- break;
- case CallState.kWaitLocalMedia:
- // TODO: Handle this case.
- break;
- case CallState.kCreateOffer:
- // TODO: Handle this case.
- break;
- case CallState.kCreateAnswer:
- // TODO: Handle this case.
- break;
- case CallState.kRinging:
- // TODO: Handle this case.
- break;
- }
- }
-}
-
-Map calls = {};
-
-class CallKeepManager {
- factory CallKeepManager() {
- return _instance;
- }
-
- CallKeepManager._internal() {
- _callKeep = FlutterCallkeep();
- }
-
- static final CallKeepManager _instance = CallKeepManager._internal();
-
- late FlutterCallkeep _callKeep;
- VoipPlugin? _voipPlugin;
-
- String get appName => 'FluffyChat';
-
- Future get hasPhoneAccountEnabled async =>
- await _callKeep.hasPhoneAccount();
-
- Map get alertOptions => {
- 'alertTitle': 'Permissions required',
- 'alertDescription':
- 'Allow $appName to register as a calling account? This will allow calls to be handled by the native android dialer.',
- 'cancelButton': 'Cancel',
- 'okButton': 'ok',
- // Required to get audio in background when using Android 11
- 'foregroundService': {
- 'channelId': 'com.fluffy.fluffychat',
- 'channelName': 'Foreground service for my app',
- 'notificationTitle': '$appName is running on background',
- 'notificationIcon': 'mipmap/ic_notification_launcher',
- },
- 'additionalPermissions': [''],
- };
- bool setupDone = false;
-
- Future showCallkitIncoming(CallSession call) async {
- if (!setupDone) {
- await _callKeep.setup(
- null,
- {
- 'ios': {
- 'appName': appName,
- },
- 'android': alertOptions,
- },
- backgroundMode: true,
- );
- }
- setupDone = true;
- await displayIncomingCall(call);
- call.onCallStateChanged.stream.listen((state) {
- if (state == CallState.kEnded) {
- _callKeep.endAllCalls();
- }
- });
- call.onCallEventChanged.stream.listen(
- (event) {
- if (event == CallEvent.kLocalHoldUnhold) {
- Logs().i(
- 'Call hold event: local ${call.localHold}, remote ${call.remoteOnHold}',
- );
- }
- },
- );
- }
-
- void removeCall(String? callUUID) {
- calls.remove(callUUID);
- }
-
- void addCall(String? callUUID, CallKeeper callKeeper) {
- if (calls.containsKey(callUUID)) return;
- calls[callUUID] = callKeeper;
- }
-
- void setCallHeld(String? callUUID, bool? held) {
- calls[callUUID]!.held = held;
- }
-
- void setCallMuted(String? callUUID, bool? muted) {
- calls[callUUID]!.muted = muted;
- }
-
- void didDisplayIncomingCall(CallKeepDidDisplayIncomingCall event) {
- final callUUID = event.callUUID;
- final number = event.handle;
- Logs().v('[displayIncomingCall] $callUUID number: $number');
- // addCall(callUUID, CallKeeper(this null));
- }
-
- void onPushKitToken(CallKeepPushKitToken event) {
- Logs().v('[onPushKitToken] token => ${event.token}');
- }
-
- Future initialize() async {
- _callKeep.on(CallKeepPerformAnswerCallAction(), answerCall);
- _callKeep.on(CallKeepDidPerformDTMFAction(), didPerformDTMFAction);
- _callKeep.on(
- CallKeepDidReceiveStartCallAction(),
- didReceiveStartCallAction,
- );
- _callKeep.on(CallKeepDidToggleHoldAction(), didToggleHoldCallAction);
- _callKeep.on(
- CallKeepDidPerformSetMutedCallAction(),
- didPerformSetMutedCallAction,
- );
- _callKeep.on(CallKeepPerformEndCallAction(), endCall);
- _callKeep.on(CallKeepPushKitToken(), onPushKitToken);
- _callKeep.on(CallKeepDidDisplayIncomingCall(), didDisplayIncomingCall);
- Logs().i('[VOIP] Initialized');
- }
-
- Future hangup(String callUUID) async {
- await _callKeep.endCall(callUUID);
- removeCall(callUUID);
- }
-
- Future reject(String callUUID) async {
- await _callKeep.rejectCall(callUUID);
- }
-
- Future answer(String? callUUID) async {
- final keeper = calls[callUUID]!;
- if (!keeper.connected) {
- await _callKeep.answerIncomingCall(callUUID!);
- keeper.connected = true;
- }
- }
-
- Future setOnHold(String callUUID, bool held) async {
- await _callKeep.setOnHold(callUUID, held);
- setCallHeld(callUUID, held);
- }
-
- Future setMutedCall(String callUUID, bool muted) async {
- await _callKeep.setMutedCall(callUUID, muted);
- setCallMuted(callUUID, muted);
- }
-
- Future updateDisplay(String callUUID) async {
- // Workaround because Android doesn't display well displayName, se we have to switch ...
- if (isIOS) {
- await _callKeep.updateDisplay(
- callUUID,
- displayName: 'New Name',
- handle: callUUID,
- );
- } else {
- await _callKeep.updateDisplay(
- callUUID,
- displayName: callUUID,
- handle: 'New Name',
- );
- }
- }
-
- Future displayIncomingCall(CallSession call) async {
- final callKeeper = CallKeeper(this, call);
- addCall(call.callId, callKeeper);
- await _callKeep.displayIncomingCall(
- call.callId,
- '${call.room.getLocalizedDisplayname()} (FluffyChat)',
- localizedCallerName:
- '${call.room.getLocalizedDisplayname()} (FluffyChat)',
- handleType: 'number',
- hasVideo: call.type == CallType.kVideo,
- );
- return callKeeper;
- }
-
- Future checkoutPhoneAccountSetting(BuildContext context) async {
- showDialog(
- context: context,
- barrierDismissible: true,
- useRootNavigator: false,
- builder: (_) => AlertDialog(
- title: Text(L10n.of(context)!.callingPermissions),
- content: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- ListTile(
- onTap: () => openCallingAccountsPage(context),
- title: Text(L10n.of(context)!.callingAccount),
- subtitle: Text(L10n.of(context)!.callingAccountDetails),
- trailing: const Icon(Icons.phone),
- ),
- const Divider(),
- ListTile(
- onTap: () => FlutterForegroundTask.openSystemAlertWindowSettings(
- forceOpen: true,
- ),
- title: Text(L10n.of(context)!.appearOnTop),
- subtitle: Text(L10n.of(context)!.appearOnTopDetails),
- trailing: const Icon(Icons.file_upload_rounded),
- ),
- const Divider(),
- ListTile(
- onTap: () => openAppSettings(),
- title: Text(L10n.of(context)!.otherCallingPermissions),
- trailing: const Icon(Icons.mic),
- ),
- ],
- ),
- ),
- );
- }
-
- void openCallingAccountsPage(BuildContext context) async {
- await _callKeep.setup(context, {
- 'ios': {
- 'appName': appName,
- },
- 'android': alertOptions,
- });
- final hasPhoneAccount = await _callKeep.hasPhoneAccount();
- Logs().e(hasPhoneAccount.toString());
- if (!hasPhoneAccount) {
- await _callKeep.hasDefaultPhoneAccount(context, alertOptions);
- } else {
- await _callKeep.openPhoneAccounts();
- }
- }
-
- /// CallActions.
- Future answerCall(CallKeepPerformAnswerCallAction event) async {
- final callUUID = event.callUUID;
- final keeper = calls[event.callUUID]!;
- if (!keeper.connected) {
- Logs().e('answered');
- // Answer Call
- keeper.call.answer();
- keeper.connected = true;
- }
- Timer(const Duration(seconds: 1), () {
- _callKeep.setCurrentCallActive(callUUID!);
- });
- }
-
- Future endCall(CallKeepPerformEndCallAction event) async {
- final keeper = calls[event.callUUID];
- keeper?.call.hangup();
- removeCall(event.callUUID);
- }
-
- Future didPerformDTMFAction(CallKeepDidPerformDTMFAction event) async {
- final keeper = calls[event.callUUID]!;
- keeper.call.sendDTMF(event.digits!);
- }
-
- Future didReceiveStartCallAction(
- CallKeepDidReceiveStartCallAction event,
- ) async {
- if (event.handle == null) {
- // @TODO: sometime we receive `didReceiveStartCallAction` with handle` undefined`
- return;
- }
- final callUUID = event.callUUID!;
- if (event.callUUID == null) {
- final call =
- await _voipPlugin!.voip.inviteToCall(event.handle!, CallType.kVideo);
- addCall(callUUID, CallKeeper(this, call));
- }
- await _callKeep.startCall(callUUID, event.handle!, event.handle!);
- Timer(const Duration(seconds: 1), () {
- _callKeep.setCurrentCallActive(callUUID);
- });
- }
-
- Future didPerformSetMutedCallAction(
- CallKeepDidPerformSetMutedCallAction event,
- ) async {
- final keeper = calls[event.callUUID];
- if (event.muted!) {
- keeper!.call.setMicrophoneMuted(true);
- } else {
- keeper!.call.setMicrophoneMuted(false);
- }
- setCallMuted(event.callUUID, event.muted);
- }
-
- Future didToggleHoldCallAction(
- CallKeepDidToggleHoldAction event,
- ) async {
- final keeper = calls[event.callUUID];
- if (event.hold!) {
- keeper!.call.setRemoteOnHold(true);
- } else {
- keeper!.call.setRemoteOnHold(false);
- }
- setCallHeld(event.callUUID, event.hold);
- }
-}
diff --git a/lib/utils/voip/famedly_key_provider_impl.dart b/lib/utils/voip/famedly_key_provider_impl.dart
new file mode 100644
index 000000000..750ba74d0
--- /dev/null
+++ b/lib/utils/voip/famedly_key_provider_impl.dart
@@ -0,0 +1,65 @@
+import 'dart:typed_data';
+
+import 'package:livekit_client/livekit_client.dart' as lk;
+import 'package:matrix/matrix.dart';
+
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+
+class FamedlyAppEncryptionKeyProviderImpl implements EncryptionKeyProvider {
+ final Client client;
+ final VoipPlugin voip;
+
+ FamedlyAppEncryptionKeyProviderImpl(this.client, this.voip) {
+ _initFuture = init();
+ }
+ late Future _initFuture;
+
+ late lk.BaseKeyProvider _keyProvider;
+
+ lk.BaseKeyProvider get keyProvider => _keyProvider;
+
+ Future init() async {
+ _keyProvider = await lk.BaseKeyProvider.create(
+ sharedKey: false,
+ ratchetWindowSize: 16,
+ failureTolerance: -1,
+ );
+ }
+
+ @override
+ Future onSetEncryptionKey(
+ Participant participant,
+ String key,
+ int index,
+ ) async {
+ await _initFuture;
+ await _keyProvider.setKey(
+ key,
+ keyIndex: index,
+ participantId: participant.id,
+ );
+ Logs().i(
+ 'onSetEncryptionKey Set key for ${participant.id}, key = $key, index = $index,',
+ );
+ }
+
+ @override
+ Future onExportKey(Participant participant, int index) async {
+ await _initFuture;
+ final key = await _keyProvider.exportKey(participant.id, index);
+ Logs().i(
+ 'onExportKey Got key for ${participant.id}, key = $key, index = $index,',
+ );
+ return key;
+ }
+
+ @override
+ Future onRatchetKey(Participant participant, int index) async {
+ await _initFuture;
+ final key = await _keyProvider.ratchetKey(participant.id, index);
+ Logs().i(
+ 'onRatchetKey Ratched key for ${participant.id}, new key = $key, index = $index,',
+ );
+ return key;
+ }
+}
diff --git a/lib/utils/voip/group_call_session_state.dart b/lib/utils/voip/group_call_session_state.dart
new file mode 100644
index 000000000..1d8390e63
--- /dev/null
+++ b/lib/utils/voip/group_call_session_state.dart
@@ -0,0 +1,194 @@
+import 'dart:async';
+
+import 'package:matrix/matrix.dart';
+import 'package:vibration/vibration.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
+
+import 'package:fluffychat/utils/platform_infos.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+import 'call_state_proxy.dart';
+
+class GroupCallSessionState implements CallStateProxy {
+ Function()? callback;
+ final GroupCallSession _groupCall;
+ final VoipPlugin _voipPlugin;
+ GroupCallSessionState(this._groupCall, this._voipPlugin) {
+ _groupCall.onGroupCallEvent.stream.listen((event) {
+ Logs().d('[GroupCallSessionState] onGroupCallEvent ${event.toString()}');
+ callback?.call();
+ });
+
+ _groupCall.onGroupCallState.stream.listen((state) async {
+ Logs().d('[GroupCallSessionState] onGroupCallState ${state.toString()}');
+ if (state == GroupCallState.Entered) {
+ _voipPlugin.connectedTsSinceEpoch =
+ DateTime.now().millisecondsSinceEpoch;
+ if (PlatformInfos.isMobile) {
+ await WakelockPlus.enable();
+ await vibrate();
+ }
+ } else if ({
+ GroupCallState.LocalCallFeedUninitialized,
+ GroupCallState.Ended,
+ }.contains(state)) {
+ // uninititalized when call not terminated, still has participant
+ _voipPlugin.connectedTsSinceEpoch = 0;
+ if (PlatformInfos.isMobile) {
+ await WakelockPlus.disable();
+ await vibrate();
+ }
+ }
+ callback?.call();
+ });
+
+ _groupCall.onStreamAdd.stream.listen((event) {
+ Logs().d('[GroupCallSessionState] onStreamAdd ${event.toString()}');
+ callback?.call();
+ });
+ _groupCall.onStreamRemoved.stream.listen((event) {
+ Logs().d('[GroupCallSessionState] onStreamRemoved ${event.toString()}');
+ callback?.call();
+ });
+ }
+
+ Future vibrate() async {
+ try {
+ await Vibration.vibrate(duration: 100);
+ } catch (e) {
+ Logs().e('[Dialer] could not vibrate for call updates');
+ }
+ }
+
+ @override
+ Future answer() async {
+ // TODO: implement answer
+ }
+
+ @override
+ Stream get callEventStream => _groupCall.onGroupCallEvent.stream;
+
+ @override
+ Stream get callStateStream => _groupCall.onGroupCallState.stream;
+
+ @override
+ bool get callOnHold => false;
+
+ @override
+ String get callState => _groupCall.state;
+
+ @override
+ bool get connected => _groupCall.state == GroupCallState.Entered;
+
+ @override
+ bool get connecting => _groupCall.state == GroupCallState.Entering;
+
+ @override
+ bool get answering => _groupCall.state == GroupCallState.Entering;
+
+ @override
+ GroupCallSession get groupCall => _groupCall;
+
+ @override
+ CallSession? get call => null;
+
+ @override
+ String get displayName => _groupCall.room.getLocalizedDisplayname();
+
+ @override
+ bool get ended =>
+ _groupCall.state == GroupCallState.Ended ||
+ _groupCall.state == GroupCallState.LocalCallFeedUninitialized;
+
+ @override
+ Future enter(WrappedMediaStream stream) async {
+ await _groupCall.enter(
+ stream: stream,
+ );
+ callback?.call();
+ }
+
+ @override
+ Future hangup() async {
+ await _groupCall.leave();
+ callback?.call();
+ }
+
+ @override
+ bool get isLocalVideoMuted => _groupCall.isLocalVideoMuted;
+
+ @override
+ bool get isMicrophoneMuted => _groupCall.isMicrophoneMuted;
+
+ @override
+ bool get isOutgoing => 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 onUpdateViewCallback(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
+ Future setLocalVideoMuted(bool muted) async {
+ await _groupCall.setLocalVideoMuted(muted);
+ callback?.call();
+ }
+
+ @override
+ Future setMicrophoneMuted(bool muted) async {
+ await _groupCall.setMicrophoneMuted(muted);
+ callback?.call();
+ }
+
+ @override
+ Future setRemoteOnHold(bool onHold) async {
+ // TODO: implement setRemoteOnHold
+ }
+
+ @override
+ Future setScreensharingEnabled(bool enabled) async {
+ await _groupCall.setScreensharingEnabled(enabled, '');
+ callback?.call();
+ }
+
+ @override
+ bool get voiceonly => false;
+
+ @override
+ Room get room => _groupCall.room;
+
+ @override
+ Client get client => _groupCall.client;
+
+ @override
+ VoipType get type => VoipType.kGroup;
+}
diff --git a/lib/utils/voip/incoming_call.dart b/lib/utils/voip/incoming_call.dart
new file mode 100644
index 000000000..23fd7affe
--- /dev/null
+++ b/lib/utils/voip/incoming_call.dart
@@ -0,0 +1,202 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+
+import 'package:flutter_callkit_incoming/entities/entities.dart';
+import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
+import 'package:flutter_gen/gen_l10n/l10n.dart';
+import 'package:matrix/matrix.dart' hide CallEvent, Event;
+import 'package:provider/provider.dart';
+import 'package:uuid/uuid.dart';
+
+import 'package:fluffychat/utils/app_state.dart';
+import 'package:fluffychat/utils/localization_for_locale_extension.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+import 'package:fluffychat/widgets/fluffy_chat_app.dart';
+import '../../pages/voip/widgets/call_banner.dart';
+
+Map calls = {};
+
+/// Add uuid extension field for CallSession
+extension CallSessionUuidExt on CallSession {
+ static Map uuids_ = {};
+ String get callUUID {
+ if (uuids_.containsKey(callId)) {
+ return uuids_[callId]!;
+ }
+ final uuid = const Uuid().v4().toString();
+ uuids_[callId] = uuid;
+ return uuid;
+ }
+
+ void removeUUID() {
+ uuids_.remove(callId);
+ }
+}
+
+class CallKeeper {
+ CallKeeper(this.incomingCallManager, this.call) {
+ call.onCallStateChanged.stream.listen(_handleCallState);
+ }
+
+ IncomingCallManager incomingCallManager;
+ bool? held = false;
+ bool? muted = false;
+ bool connected = false;
+ CallSession call;
+
+ // update native caller to show what remote user has done.
+ Future _handleCallState(CallState state) async {
+ Logs().d('CallKeepManager::handleCallState: ${state.toString()}');
+ if (state case CallState.kConnected) {
+ await incomingCallManager.endIncomingCall(call.callUUID);
+ } else if (state case CallState.kEnded) {
+ await incomingCallManager.endIncomingCall(call.callUUID);
+ }
+ }
+}
+
+class IncomingCallManager {
+ late VoipPlugin voipPlugin;
+ factory IncomingCallManager(VoipPlugin voipPlugin) {
+ _instance.voipPlugin = voipPlugin;
+ return _instance;
+ }
+
+ static final IncomingCallManager _instance = IncomingCallManager._internal();
+ IncomingCallManager._internal();
+
+ void removeCall(String? callUUID) {
+ final callkeep = calls.remove(callUUID);
+ callkeep?.call.removeUUID();
+ }
+
+ void addCall(String? callUUID, CallKeeper callKeeper) {
+ if (calls.containsKey(callUUID)) return;
+ calls[callUUID] = callKeeper;
+ }
+
+ Future initialize() async {
+ FlutterCallkitIncoming.onEvent.listen((event) async {
+ Logs().w(event!.event.name.toString());
+ if (event.event case Event.actionCallAccept) {
+ await answerCall(event.body['id']);
+ } else if (event.event case Event.actionCallDecline) {
+ await reject(event.body['id']);
+ }
+ });
+ Logs().d('callkeepv3 init done');
+ }
+
+ /// CallActions.
+ Future answerCall(String callUUID) async {
+ final keeper = calls[callUUID]!;
+
+ final provider = Provider.of(
+ FluffyChatApp.appGlobalKey.currentContext!,
+ listen: false,
+ );
+
+ provider.setGlobalBanner(
+ CallBanner(proxy: VoipPlugin.currentCallProxy!),
+ );
+ FluffyChatApp.router
+ .go('/rooms/${VoipPlugin.currentCallProxy!.room.id}/call');
+ if (!keeper.connected) {
+ Logs().d('[VOIP] answering call');
+ // Answer Call, don't await because call page is not up yet no loading
+ // inndicator to show
+ unawaited(keeper.call.answer());
+ keeper.connected = true;
+ }
+ }
+
+ /// recieved decline from other end, passed on by callkeepmanager
+ Future hangup(String callUUID) async {
+ await endIncomingCall(callUUID);
+ removeCall(callUUID);
+ }
+
+ /// user clicked the decline button on incoming call UI
+ Future reject(String callUUID) async {
+ await endIncomingCall(callUUID);
+ final keeper = calls[callUUID];
+ // unawaited because we don't have a screen to show any progress indicator
+ unawaited(keeper!.call.reject());
+ removeCall(callUUID);
+ }
+
+ Future showIncomingCall(CallSession call) async {
+ final callKeeper = CallKeeper(this, call);
+ final l10n = await WidgetsBinding.instance.platformDispatcher.loadL10n();
+ addCall(call.callUUID, callKeeper);
+ final remoteUser = await call.room.requestUser(call.remoteUserId!);
+ final avatarMxc = await call.client.getAvatarUrl(remoteUser?.id ?? '');
+ final avatarUrl = avatarMxc?.getThumbnail(
+ call.client,
+ width: 500,
+ height: 500,
+ method: ThumbnailMethod.scale,
+ );
+
+ final params = getParams(
+ call,
+ avatarUrl.toString(),
+ l10n,
+ );
+
+ Logs().e('[VOIP] showing incoming call with params: ${params.toJson()}');
+
+ await FlutterCallkitIncoming.showCallkitIncoming(params);
+ }
+
+ /// only hides incoming call notification
+ Future endIncomingCall(String callUUID) async {
+ try {
+ await FlutterCallkitIncoming.endCall(callUUID);
+ } catch (e) {
+ Logs().e('[VOIP] removing callkit incoming failed on endIncomingCall');
+ }
+ }
+
+ Future showMissedCallNotification(CallSession call) async {
+ final l10n = await WidgetsBinding.instance.platformDispatcher.loadL10n();
+ final remoteUser = await call.room.requestUser(call.remoteUserId!);
+ final avatarMxc = await call.client.getAvatarUrl(remoteUser?.id ?? '');
+ final avatarUrl = avatarMxc?.getThumbnail(
+ call.client,
+ width: 500,
+ height: 500,
+ method: ThumbnailMethod.scale,
+ );
+ await FlutterCallkitIncoming.showMissCallNotification(
+ getParams(
+ call,
+ avatarUrl.toString(),
+ l10n,
+ ),
+ );
+ }
+
+ CallKitParams getParams(CallSession call, String avatarUrl, L10n l10n) {
+ return CallKitParams(
+ id: call.callUUID,
+ appName: call.client.clientName,
+ nameCaller: call.room.getLocalizedDisplayname(),
+ handle: call.type == CallType.kVideo ? l10n.videoCall : l10n.audioCall,
+ avatar: avatarUrl,
+ textAccept: l10n.accept,
+ textDecline: l10n.reject,
+ type: call.type == CallType.kVideo ? 1 : 0,
+ missedCallNotification: NotificationParams(
+ isShowCallback: false,
+ subtitle: 'Missed call from ${call.room.getLocalizedDisplayname()}',
+ ),
+ android: const AndroidParams(
+ isShowLogo: true,
+ isShowCallID: true,
+ ),
+ // TODO: add ios once you can run decryption stuff in background
+ );
+ }
+}
diff --git a/lib/utils/voip/livekit_group_call_session_state.dart b/lib/utils/voip/livekit_group_call_session_state.dart
new file mode 100644
index 000000000..a3d089fc7
--- /dev/null
+++ b/lib/utils/voip/livekit_group_call_session_state.dart
@@ -0,0 +1,616 @@
+import 'dart:convert';
+
+import 'package:collection/collection.dart';
+import 'package:flutter_webrtc/flutter_webrtc.dart';
+import 'package:http/http.dart' as http;
+import 'package:livekit_client/livekit_client.dart' as livekit;
+import 'package:matrix/matrix.dart';
+import 'package:vibration/vibration.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
+import 'package:webrtc_interface/webrtc_interface.dart';
+
+import 'package:fluffychat/config/app_config.dart';
+import 'package:fluffychat/utils/platform_infos.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
+import 'call_state_proxy.dart';
+import 'livekit_stream.dart';
+
+class LiveKitGroupCallSessionState implements CallStateProxy {
+ Function()? callback;
+ final GroupCallSession _groupCall;
+ final VoipPlugin voipPlugin;
+ livekit.Room? lkRoom;
+
+ /// livekitstreams
+ final List _userMediaStreams = [];
+
+ /// livekitstreams
+ final List _screenSharingStreams = [];
+
+ LiveKitGroupCallSessionState(this._groupCall, this.voipPlugin) {
+ _groupCall.onGroupCallEvent.stream.listen((event) {
+ Logs().d(
+ '[LiveKitGroupCallSessionState] onGroupCallEvent ${event.toString()}',
+ );
+ callback?.call();
+ });
+
+ _groupCall.onGroupCallState.stream.listen((state) async {
+ Logs().d(
+ '[LiveKitGroupCallSessionState] onGroupCallState ${state.toString()}',
+ );
+ if (state == GroupCallState.Entered) {
+ voipPlugin.connectedTsSinceEpoch =
+ DateTime.now().millisecondsSinceEpoch;
+ if (PlatformInfos.isMobile) {
+ await WakelockPlus.enable();
+ await vibrate();
+ }
+ } else if ({
+ GroupCallState.LocalCallFeedUninitialized,
+ GroupCallState.Ended,
+ }.contains(state)) {
+ // uninititalized when call not terminated, still has participant
+ voipPlugin.connectedTsSinceEpoch = 0;
+ if (PlatformInfos.isMobile) {
+ await WakelockPlus.disable();
+ await vibrate();
+ }
+ }
+ callback?.call();
+ });
+ }
+
+ Future vibrate() async {
+ try {
+ await Vibration.vibrate(duration: 100);
+ } catch (e) {
+ Logs().e('[Dialer] could not vibrate for call updates');
+ }
+ }
+
+ @override
+ Future answer() async {}
+
+ @override
+ Stream get callEventStream => _groupCall.onGroupCallEvent.stream;
+
+ @override
+ Stream get callStateStream => _groupCall.onGroupCallState.stream;
+
+ @override
+ bool get callOnHold => false;
+
+ @override
+ String get callState => _groupCall.state;
+
+ @override
+ bool get connected => _groupCall.state == GroupCallState.Entered;
+
+ @override
+ bool get connecting => _groupCall.state == GroupCallState.Entering;
+
+ @override
+ bool get answering => _groupCall.state == GroupCallState.Entering;
+
+ @override
+ GroupCallSession get groupCall => _groupCall;
+
+ @override
+ CallSession? get call => null;
+
+ @override
+ String get displayName => _groupCall.room.getLocalizedDisplayname();
+
+ @override
+ bool get ended =>
+ _groupCall.state == GroupCallState.Ended ||
+ _groupCall.state == GroupCallState.LocalCallFeedUninitialized;
+
+ @override
+ Future enter(WrappedMediaStream stream) async {
+ await _groupCall.enter();
+
+ if (_groupCall.isLivekitCall && _groupCall.state != GroupCallState.Ended) {
+ try {
+ final sfuConfig = await getSFUConfigWithOpenID(
+ client: client,
+ roomName: _groupCall.room.id,
+ groupCall: _groupCall,
+ );
+ if (sfuConfig == null) {
+ Logs().w('Failed to get SFU config for group call');
+ return;
+ }
+ await join(
+ groupCall: _groupCall,
+ enableE2EE: AppConfig.enableLivekitE2EE,
+ sfuConfig: sfuConfig,
+ stream: stream,
+ );
+ } catch (e) {
+ Logs().e('Failed to join SFU for group call', e);
+ return;
+ }
+ }
+ callback?.call();
+ }
+
+ @override
+ Future hangup() async {
+ await _groupCall.leave();
+ if (_groupCall.isLivekitCall) {
+ if (lkRoom != null) {
+ await lkRoom?.disconnect();
+ await _groupCall.localUserMediaStream?.dispose();
+ _groupCall.localUserMediaStream = null;
+ }
+ }
+ callback?.call();
+ }
+
+ @override
+ bool get isLocalVideoMuted =>
+ !(lkRoom?.localParticipant?.isCameraEnabled() ?? false);
+
+ @override
+ bool get isMicrophoneMuted =>
+ !(lkRoom?.localParticipant?.isMicrophoneEnabled() ?? false);
+
+ @override
+ bool get isScreensharingEnabled =>
+ lkRoom?.localParticipant?.isScreenShareEnabled() ?? false;
+
+ @override
+ bool get isOutgoing => false;
+
+ @override
+ bool get localHold => false;
+
+ @override
+ WrappedMediaStream? get localScreenSharingStream =>
+ screenSharingStreams.firstWhereOrNull((element) => element.isLocal());
+
+ @override
+ WrappedMediaStream? get localUserMediaStream =>
+ userMediaStreams.firstWhereOrNull((element) => element.isLocal());
+
+ @override
+ List get userMediaStreams => _userMediaStreams.toList();
+
+ @override
+ WrappedMediaStream? get primaryStream => localUserMediaStream;
+
+ @override
+ List get screenSharingStreams =>
+ _screenSharingStreams.toList();
+
+ @override
+ void onUpdateViewCallback(Function() handler) {
+ callback = handler;
+ }
+
+ @override
+ bool get remoteOnHold => false;
+
+ @override
+ bool get ringingPlay => false;
+
+ @override
+ Room get room => _groupCall.room;
+
+ @override
+ Future setLocalVideoMuted(bool muted) async {
+ await lkRoom?.localParticipant?.setCameraEnabled(!muted);
+ localUserMediaStream?.setVideoMuted(muted);
+ callback?.call();
+ }
+
+ @override
+ Future setMicrophoneMuted(bool muted) async {
+ await lkRoom?.localParticipant?.setMicrophoneEnabled(!muted);
+ localUserMediaStream?.setAudioMuted(muted);
+ callback?.call();
+ }
+
+ @override
+ Future setRemoteOnHold(bool onHold) async {}
+
+ @override
+ Future setScreensharingEnabled(bool enabled) async {
+ enabled ? await _enableScreenShare() : await _disableScreenShare();
+ }
+
+ Future _enableScreenShare() async {
+ if (livekit.lkPlatformIs(livekit.PlatformType.iOS)) {
+ final track = await livekit.LocalVideoTrack.createScreenShareTrack(
+ const livekit.ScreenShareCaptureOptions(
+ useiOSBroadcastExtension: true,
+ maxFrameRate: 15.0,
+ ),
+ );
+ await lkRoom?.localParticipant?.publishVideoTrack(track);
+ return;
+ }
+ await lkRoom?.localParticipant?.setScreenShareEnabled(true);
+ }
+
+ Future _disableScreenShare() async {
+ await lkRoom?.localParticipant?.setScreenShareEnabled(false);
+ }
+
+ @override
+ VoipType get type => VoipType.kGroup;
+
+ @override
+ bool get voiceonly => false;
+
+ @override
+ Client get client => _groupCall.client;
+
+ Future join({
+ required GroupCallSession groupCall,
+ required SFUConfig sfuConfig,
+ bool? enableE2EE,
+ required WrappedMediaStream stream,
+ }) async {
+ livekit.E2EEOptions? e2eeOptions;
+ if (enableE2EE == true) {
+ e2eeOptions = livekit.E2EEOptions(
+ keyProvider: voipPlugin.encryptionKeyProvider.keyProvider,
+ );
+ }
+
+ livekit.FastConnectOptions? fastConnectOptions;
+
+ fastConnectOptions = livekit.FastConnectOptions(
+ microphone: livekit.TrackOption(enabled: !stream.audioMuted),
+ camera: livekit.TrackOption(enabled: !stream.videoMuted),
+ );
+
+ // create new room
+ lkRoom = livekit.Room(
+ roomOptions: livekit.RoomOptions(
+ adaptiveStream: true,
+ dynacast: true,
+ defaultAudioPublishOptions: const livekit.AudioPublishOptions(),
+ defaultVideoPublishOptions: const livekit.VideoPublishOptions(),
+ defaultScreenShareCaptureOptions: livekit.ScreenShareCaptureOptions(
+ useiOSBroadcastExtension: true,
+ params: livekit.VideoParametersPresets.screenShareH1080FPS30,
+ ),
+ e2eeOptions: e2eeOptions,
+ defaultCameraCaptureOptions: livekit.CameraCaptureOptions(
+ maxFrameRate: 30,
+ params: livekit.VideoParametersPresets.h720_169,
+ ),
+ ),
+ );
+
+ // Create a Listener before connecting
+ final livekit.EventsListener listener =
+ lkRoom!.createListener();
+
+ await setUpListeners(lkRoom, listener, groupCall);
+
+ await lkRoom!.connect(
+ sfuConfig.url,
+ sfuConfig.jwt,
+ fastConnectOptions: fastConnectOptions,
+ );
+
+ Logs().i(
+ 'Connected to room ${lkRoom?.name}, local participant => ${lkRoom?.localParticipant!.identity}',
+ );
+
+ // we don't need the preview stream anymore? I think
+ await stream.disposeRenderer();
+ await stopMediaStream(stream.stream!);
+ }
+
+ Future _sortParticipants(GroupCallSession groupCall) async {
+ _userMediaStreams.clear();
+ _screenSharingStreams.clear();
+
+ final lkps = List.from(
+ lkRoom?.participants.values.toList() ?? [],
+ );
+ for (final lkp in lkps) {
+ // skip livekit participant updates that don't have a valid matrix event set
+ if (!groupCall.participants.contains(Participant.fromId(lkp.identity))) {
+ continue;
+ }
+
+ final remoteTrackPublications = List.from(
+ lkp.trackPublications.values.toList(),
+ );
+ for (final t in remoteTrackPublications) {
+ if (t.kind == livekit.TrackType.AUDIO) continue;
+
+ final lkpStream = LivekitParticipantStream(
+ lkParticipant: lkp,
+ client: client,
+ room: groupCall.room,
+ participant: Participant.fromId(lkp.identity),
+ audioMuted: !lkp.isMicrophoneEnabled(),
+ videoMuted: t.isScreenShare
+ ? !lkp.isScreenShareEnabled()
+ : !lkp.isCameraEnabled(),
+ stream: t.track?.mediaStream,
+ renderer: voipPlugin.createRenderer(),
+ isWeb: false,
+ isGroupCall: true,
+ purpose: t.isScreenShare
+ ? SDPStreamMetadataPurpose.Screenshare
+ : SDPStreamMetadataPurpose.Usermedia,
+ publication: [t],
+ );
+ await lkpStream.initialize();
+ if (t.isScreenShare) {
+ if (_screenSharingStreams.contains(lkpStream)) continue;
+ _screenSharingStreams.add(lkpStream);
+ } else {
+ if (_userMediaStreams.contains(lkpStream)) continue;
+ _userMediaStreams.add(lkpStream);
+ }
+ }
+ }
+ // sort speakers for the grid
+ _userMediaStreams.sort((a, b) {
+ // loudest speaker first
+ if (a.lkParticipant.isSpeaking && b.lkParticipant.isSpeaking) {
+ if (a.lkParticipant.audioLevel > b.lkParticipant.audioLevel) {
+ return -1;
+ } else {
+ return 1;
+ }
+ }
+
+ // last spoken at
+ final aSpokeAt = a.lkParticipant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
+ final bSpokeAt = b.lkParticipant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
+
+ if (aSpokeAt != bSpokeAt) {
+ return aSpokeAt > bSpokeAt ? -1 : 1;
+ }
+
+ // video on
+ if (a.lkParticipant.hasVideo != b.lkParticipant.hasVideo) {
+ return a.lkParticipant.hasVideo ? -1 : 1;
+ }
+
+ // joinedAt
+ return a.lkParticipant.joinedAt.millisecondsSinceEpoch -
+ b.lkParticipant.joinedAt.millisecondsSinceEpoch;
+ });
+
+ final localParticipantTracks = List.from(
+ lkRoom?.localParticipant?.trackPublications.values.toList() ?? [],
+ );
+
+ for (final t in localParticipantTracks) {
+ if (t.kind == livekit.TrackType.AUDIO) continue;
+ final lkp = lkRoom!.localParticipant!;
+
+ final lkpStream = LivekitParticipantStream(
+ lkParticipant: lkp,
+ client: client,
+ room: groupCall.room,
+ participant: Participant.fromId(lkp.identity),
+ audioMuted: !lkp.isMicrophoneEnabled(),
+ videoMuted: t.isScreenShare
+ ? !lkp.isScreenShareEnabled()
+ : !lkp.isCameraEnabled(),
+ stream: t.track?.mediaStream,
+ renderer: voipPlugin.createRenderer(),
+ isWeb: false,
+ isGroupCall: true,
+ purpose: t.isScreenShare
+ ? SDPStreamMetadataPurpose.Screenshare
+ : SDPStreamMetadataPurpose.Usermedia,
+ publication: [t],
+ );
+ await lkpStream.initialize();
+ if (t.isScreenShare) {
+ if (_screenSharingStreams.contains(lkpStream)) continue;
+ _screenSharingStreams.add(lkpStream);
+ } else {
+ if (_userMediaStreams.contains(lkpStream)) continue;
+ _userMediaStreams.add(lkpStream);
+ }
+ }
+
+ callback?.call();
+ }
+
+ Future setUpListeners(
+ room,
+ livekit.EventsListener listener,
+ GroupCallSession groupCall,
+ ) async {
+ listener.on((livekit.RoomDisconnectedEvent event) async {
+ Logs().i('RoomDisconnectedEvent');
+ if (event.reason != null) {
+ Logs().i('Room disconnected: reason => ${event.reason}');
+ }
+ await groupCall.leave();
+ });
+ listener.on((livekit.LocalTrackPublishedEvent event) async {
+ Logs().i(
+ 'LocalTrackPublishedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}',
+ );
+ await _sortParticipants(groupCall);
+ });
+
+ listener.on((livekit.LocalTrackUnpublishedEvent event) async {
+ Logs().i(
+ 'LocalTrackUnpublishedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}',
+ );
+ await _sortParticipants(groupCall);
+ });
+
+ listener.on((livekit.TrackSubscribedEvent event) async {
+ Logs().i(
+ 'TrackSubscribedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}',
+ );
+ await _sortParticipants(groupCall);
+ });
+
+ listener.on((livekit.TrackUnsubscribedEvent event) async {
+ Logs().i(
+ 'TrackUnsubscribedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}',
+ );
+ await _sortParticipants(groupCall);
+ });
+
+ listener.on((livekit.ParticipantNameUpdatedEvent event) async {
+ Logs().i('ParticipantNameUpdatedEvent');
+ callback?.call();
+ });
+
+ listener.on((livekit.DataReceivedEvent event) async {
+ String decoded = 'Failed to decode';
+ try {
+ decoded = utf8.decode(event.data);
+ } catch (_) {
+ Logs().e('Failed to decode: $_');
+ }
+ Logs()
+ .i('Data received: ${event.participant?.identity}, data => $decoded');
+ });
+
+ listener.on((livekit.TrackMutedEvent event) async {
+ Logs().i(
+ 'TrackMutedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}',
+ );
+ await _sortParticipants(groupCall);
+ });
+
+ listener.on((livekit.TrackUnmutedEvent event) async {
+ Logs().i(
+ 'TrackUnmutedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}',
+ );
+ await _sortParticipants(groupCall);
+ });
+
+ listener.on((livekit.TrackE2EEStateEvent event) async {
+ Logs().i(
+ 'TrackE2EEStateEvent: ${event.participant.identity}, ${event.publication.sid} state: ${event.state}',
+ );
+ final participant = Participant.fromId(event.participant.identity);
+ if (event.state == livekit.E2EEState.kMissingKey &&
+ participant != voipPlugin.voip.localParticipant) {
+ Logs().i('TrackE2EEStateEvent: requesting keys from ${participant.id}');
+ await groupCall.requestEncrytionKey(
+ [participant],
+ );
+ }
+ await _sortParticipants(groupCall);
+ });
+ }
+
+ Future stopMediaStream(MediaStream? stream) async {
+ if (stream != null) {
+ for (final track in stream.getTracks()) {
+ try {
+ await track.stop();
+ } catch (e, s) {
+ Logs().e('[VOIP] stopping track ${track.id} failed', e, s);
+ }
+ }
+ try {
+ await stream.dispose();
+ } catch (e, s) {
+ Logs().e('[VOIP] disposing stream ${stream.id} failed', e, s);
+ }
+ }
+ }
+}
+
+/// lk token processing
+/// The livekitServiceURL is usually set by the participant who created the
+/// meeting, and everyone who joins later needs to use the same livekitServiceURL
+/// 1, request a openID token from our matrix server
+/// 2, use the { openIDToken, roomId,deviceId } to request a SFUConfig from the lk jwt service,
+/// `lk-jwt-service` will use openIdToken to go to our matrix server to verify the validity of the client.
+/// and then we will get a jwt and a url from the livekit server
+/// 3, put jwt/url to the livekit-flutter-sdk to connect to the livekit server
+Future getSFUConfigWithOpenID({
+ required Client client,
+ required String roomName,
+ required GroupCallSession groupCall,
+}) async {
+ final openIdToken = await client.requestOpenIdToken(client.userID!, {});
+ Logs().d('Got openID token of type ${openIdToken.tokenType}');
+ if (groupCall.isLivekitCall) {
+ final backend = (groupCall.backends.first as LiveKitBackend);
+ try {
+ Logs().i(
+ 'Trying to get JWT from call\'s configured URL of ${backend.livekitServiceUrl}...',
+ );
+ final sfuConfig = await getLiveKitJWT(
+ client,
+ backend.livekitServiceUrl,
+ roomName,
+ openIdToken,
+ );
+ Logs().i('Got JWT from call state event URL.');
+
+ return sfuConfig;
+ } catch (e) {
+ Logs().w(
+ 'Failed to get JWT from group call\'s configured URL of ${backend.livekitServiceUrl}. $e',
+ );
+ }
+ }
+ const urlFromConf = AppConfig.livekitServiceUrl;
+ Logs().i('Trying livekit service URL from our config: $urlFromConf...');
+ try {
+ final sfuConfig =
+ await getLiveKitJWT(client, urlFromConf, roomName, openIdToken);
+
+ Logs()
+ .i('Got JWT, updating call livekit service URL with: $urlFromConf...');
+
+ return sfuConfig;
+ } catch (e) {
+ Logs().e('Failed to get JWT from URL defined in Config.', e);
+ rethrow;
+ }
+}
+
+/// identity for the user is set in livekit-jwt-service (currently userId:deviceId)
+Future getLiveKitJWT(
+ Client client,
+ String livekitServiceURL,
+ String roomName,
+ OpenIdCredentials openIdCredentials,
+) async {
+ try {
+ final res = await http.post(
+ Uri.parse('$livekitServiceURL/sfu/get'), // element compantibilty
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: jsonEncode({
+ 'room': roomName,
+ 'openid_token': {
+ 'access_token': openIdCredentials.accessToken,
+ 'token_type': openIdCredentials.tokenType,
+ 'matrix_server_name': openIdCredentials.matrixServerName,
+ },
+ 'device_id': client.deviceID!,
+ }),
+ );
+ if (res.statusCode != 200) {
+ throw Exception(
+ 'SFU Config fetch failed with status code #${res.statusCode}',
+ );
+ }
+ return SFUConfig.fromJson(jsonDecode(res.body));
+ } catch (e) {
+ throw Exception(
+ 'SFU Config fetch failed with exception $e',
+ );
+ }
+}
diff --git a/lib/utils/voip/livekit_stream.dart b/lib/utils/voip/livekit_stream.dart
new file mode 100644
index 000000000..e0bcd7291
--- /dev/null
+++ b/lib/utils/voip/livekit_stream.dart
@@ -0,0 +1,58 @@
+import 'package:livekit_client/livekit_client.dart' as livekit;
+import 'package:matrix/matrix.dart';
+
+class SFUConfig {
+ final String url;
+ final String jwt;
+ SFUConfig({required this.url, required this.jwt});
+ factory SFUConfig.fromJson(Map json) {
+ return SFUConfig(url: json['url'], jwt: json['jwt']);
+ }
+}
+
+class LivekitParticipantStream extends WrappedMediaStream {
+ final livekit.Participant lkParticipant;
+ List publication = [];
+ LivekitParticipantStream({
+ required super.participant,
+ required super.renderer,
+ required super.room,
+ required super.purpose,
+ required super.client,
+ required super.audioMuted,
+ required super.videoMuted,
+ required super.isWeb,
+ required super.isGroupCall,
+ required super.stream,
+ required this.lkParticipant,
+ this.publication = const [],
+ });
+
+ bool publicationExists(livekit.TrackPublication pub) {
+ return publication.contains(pub);
+ }
+
+ void addPublication(livekit.TrackPublication pub) {
+ publication.add(pub);
+ super.onMuteStateChanged.add(this);
+ }
+
+ void removePublication(livekit.TrackPublication pub) {
+ publication.remove(pub);
+ super.onMuteStateChanged.add(this);
+ }
+
+ livekit.VideoTrack? lkVideoTrack() {
+ if (lkParticipant is livekit.LocalParticipant) {
+ return publication.firstOrNull?.track as livekit.VideoTrack?;
+ }
+
+ if (lkParticipant is livekit.RemoteParticipant) {
+ return publication.firstOrNull?.track as livekit.VideoTrack?;
+ }
+
+ return null;
+ }
+
+ bool get isEncrypted => lkParticipant.isEncrypted;
+}
diff --git a/lib/utils/voip/user_media_manager.dart b/lib/utils/voip/user_media_manager.dart
deleted file mode 100644
index 03ecd6030..000000000
--- a/lib/utils/voip/user_media_manager.dart
+++ /dev/null
@@ -1,42 +0,0 @@
-import 'package:flutter/foundation.dart';
-
-import 'package:flutter_ringtone_player/flutter_ringtone_player.dart';
-import 'package:just_audio/just_audio.dart';
-
-import 'package:fluffychat/utils/platform_infos.dart';
-
-class UserMediaManager {
- factory UserMediaManager() {
- return _instance;
- }
-
- UserMediaManager._internal();
-
- static final UserMediaManager _instance = UserMediaManager._internal();
-
- AudioPlayer? _assetsAudioPlayer;
-
- final FlutterRingtonePlayer _flutterRingtonePlayer = FlutterRingtonePlayer();
-
- Future startRingingTone() async {
- if (PlatformInfos.isMobile) {
- await _flutterRingtonePlayer.playRingtone(volume: 80);
- } else if ((kIsWeb || PlatformInfos.isMacOS) &&
- _assetsAudioPlayer != null) {
- const path = 'assets/sounds/phone.ogg';
- final player = _assetsAudioPlayer = AudioPlayer();
- player.setAsset(path);
- player.play();
- }
- return;
- }
-
- Future stopRingingTone() async {
- if (PlatformInfos.isMobile) {
- await _flutterRingtonePlayer.stop();
- }
- await _assetsAudioPlayer?.stop();
- _assetsAudioPlayer = null;
- return;
- }
-}
diff --git a/lib/utils/voip/voip_plugin.dart b/lib/utils/voip/voip_plugin.dart
new file mode 100644
index 000000000..0e6dbfcdb
--- /dev/null
+++ b/lib/utils/voip/voip_plugin.dart
@@ -0,0 +1,447 @@
+import 'dart:async';
+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_gen/gen_l10n/l10n.dart';
+import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc_impl;
+import 'package:just_audio/just_audio.dart';
+import 'package:matrix/matrix.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:provider/provider.dart';
+import 'package:webrtc_interface/webrtc_interface.dart' hide Navigator;
+
+import 'package:fluffychat/config/themes.dart';
+import 'package:fluffychat/utils/app_state.dart';
+import 'package:fluffychat/utils/platform_infos.dart';
+import 'package:fluffychat/utils/voip/livekit_group_call_session_state.dart';
+import 'package:fluffychat/widgets/fluffy_chat_app.dart';
+import 'package:fluffychat/widgets/matrix.dart';
+import '../../pages/voip/widgets/call_banner.dart';
+import '../../pages/voip/widgets/call_overlay.dart';
+import '../../utils/voip/call_session_state.dart';
+import '../../utils/voip/call_state_proxy.dart';
+import '../../utils/voip/famedly_key_provider_impl.dart';
+import '../../utils/voip/incoming_call.dart';
+
+enum VoipType { kVoice, kVideo, kGroup }
+
+class MediaDevicesWrapper extends MediaDevices {
+ MediaDevicesWrapper() {
+ AppLifecycleListener(
+ onResume: () {
+ _appLifecycleStateResumeStream.sink.add(true);
+ },
+ );
+ }
+ final StreamController _appLifecycleStateResumeStream =
+ StreamController.broadcast();
+
+ // We only request mic/cam permissions during the first call (this is handled in
+ // getUserMedia from flutter_webrtc). But if the first call happens when the
+ // app is in background (in android), getUserMedia throws an exception and is
+ // unable to request permissions.
+ //
+ // So, awaiting for _waitBeforeRequestingMediaPermission method will make sure
+ // you wait till the app comes back to foreground (or timeout, whichever comes first).
+ // If the respective media permissions for the call (mic for audio call,
+ // mic+cam for video call) have already been granted, the method just returns.
+ //
+ // The expected behaviour is, only the push notification that the call has started
+ // will be shown to users and no callkit ringing, if the respective media
+ // permissions for the call haven't been granted. The user will click on the push
+ // notification and the app will come to foreground, then getUserMedia will run.
+ Future _waitBeforeRequestingMediaPermission({
+ required bool isVideoCall,
+ }) async {
+ // if android and not on web,
+ if (kIsWeb || !Platform.isAndroid) return;
+ // if mic permission not granted and if video call, mic+cam permission not granted,
+ if (await Permission.microphone.isGranted &&
+ (!isVideoCall || await Permission.camera.isGranted)) return;
+ // if app currently isn't in foreground,
+ if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
+ return;
+ }
+ // then, wait till it comes to foreground.
+ await Future.any([
+ _appLifecycleStateResumeStream.stream.first,
+ Future.delayed(CallTimeouts.callInviteLifetime),
+ ]);
+ }
+
+ @override
+ Future> enumerateDevices() =>
+ webrtc_impl.navigator.mediaDevices.enumerateDevices();
+
+ @override
+ Future getDisplayMedia(
+ Map mediaConstraints,
+ ) async {
+ final mediaConstraintsCopy = Map.from(mediaConstraints);
+ if (!kIsWeb && Platform.isIOS) {
+ mediaConstraintsCopy['video'] = {'deviceId': 'broadcast'};
+ }
+ return webrtc_impl.navigator.mediaDevices
+ .getDisplayMedia(mediaConstraintsCopy);
+ }
+
+ @override
+ Future getSources() =>
+ webrtc_impl.navigator.mediaDevices.enumerateDevices();
+
+ @override
+ Future getUserMedia(
+ Map mediaConstraints,
+ ) async {
+ await _waitBeforeRequestingMediaPermission(
+ isVideoCall: mediaConstraints.containsKey('video') &&
+ mediaConstraints['video'] != false,
+ );
+ return webrtc_impl.navigator.mediaDevices.getUserMedia(mediaConstraints);
+ }
+}
+
+class VoipPlugin implements WebRTCDelegate {
+ static VoipPlugin? _instance;
+ static CallStateProxy? currentCallProxy;
+
+ final Client client;
+
+ factory VoipPlugin.clientOnly(Client client) {
+ _instance ??= VoipPlugin._(client);
+ return _instance!;
+ }
+
+ /// dw it'll be there
+ /// do not use this randomly everywhere, rare cases where you are either stuck
+ /// with a context hack or this
+
+ VoipPlugin._(this.client) {
+ voip = VoIP(client, this);
+ encryptionKeyProvider = FamedlyAppEncryptionKeyProviderImpl(client, this);
+ if (PlatformInfos.isMobile) {
+ IncomingCallManager(this).initialize();
+ }
+ Connectivity()
+ .onConnectivityChanged
+ .listen(_handleNetworkChanged)
+ .onError((e) => _currentConnectivity = ConnectivityResult.none);
+ Connectivity()
+ .checkConnectivity()
+ .then((result) => _currentConnectivity = result)
+ .catchError((e) => _currentConnectivity = ConnectivityResult.none);
+ }
+
+ final MediaDevicesWrapper _mediaDevices = MediaDevicesWrapper();
+
+ late FamedlyAppEncryptionKeyProviderImpl encryptionKeyProvider;
+
+ late VoIP voip;
+ ConnectivityResult? _currentConnectivity;
+
+ /// the only time this is null is when `FamedlyApp` is not in the tree (eg: call when the app is not open)
+ BuildContext get globalContext => FluffyChatApp.appGlobalKey.currentContext!;
+
+ void setupCallAndOpenCallPage(CallStateProxy proxy) {
+ final provider = Provider.of(globalContext, listen: false);
+
+ if (!FluffyThemes.isColumnMode(globalContext)) {
+ provider.setGlobalBanner(CallBanner(proxy: proxy, voipPlugin: this));
+ }
+
+ if (FluffyThemes.isColumnMode(globalContext) &&
+ currentCall != null &&
+ currentGroupCall == null &&
+ !currentCall!.isOutgoing) {
+ createOverlay(proxy);
+ } else {
+ FluffyChatApp.router.go('/rooms/${proxy.room.id}/call');
+ }
+ }
+
+ void onPhoneButtonTap(
+ BuildContext context,
+ Room room,
+ VoipType callType,
+ ) async {
+ await startCall(context, room, callType);
+ }
+
+ void _handleNetworkChanged(ConnectivityResult result) async {
+ /// Got a new connectivity status!
+ if (_currentConnectivity != result) {
+ voip.calls.forEach((_, sess) {
+ sess.restartIce();
+ });
+ }
+ _currentConnectivity = result;
+ }
+
+ CallSession? get currentCall =>
+ voip.currentCID == null ? null : voip.calls[voip.currentCID];
+
+ GroupCallSession? get currentGroupCall => voip.currentGroupCID == null
+ ? null
+ : voip.groupCalls[voip.currentGroupCID];
+
+ bool getInMeetingState() {
+ return currentCall != null || currentGroupCall != null;
+ }
+
+ @override
+ MediaDevices get mediaDevices => _mediaDevices;
+
+ @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();
+ }
+
+ int connectedTsSinceEpoch = 0;
+ int onHoldMs = 0;
+
+ @override
+ Future handleNewCall(CallSession call) async {
+ Logs().d('[VoipPlugin] Handle new call');
+ connectedTsSinceEpoch = 0;
+ onHoldMs = 0;
+ final callProxy = CallSessionState(call, this);
+ currentCallProxy = callProxy;
+ if (!call.isOutgoing && !kIsWeb && Platform.isAndroid) {
+ await IncomingCallManager(this).showIncomingCall(call);
+ } else {
+ setupCallAndOpenCallPage(callProxy);
+ }
+ client.backgroundSync = true;
+ }
+
+ @override
+ Future handleCallEnded(CallSession call) async {
+ try {
+ Logs().d('[VoipPlugin] handleCallEnded');
+ if (currentCallProxy != null &&
+ currentCallProxy is CallSessionState &&
+ currentCallProxy!.call?.callId == call.callId) {
+ if (!kIsWeb && Platform.isAndroid) {
+ await IncomingCallManager(this).endIncomingCall(call.callId);
+ await FlutterForegroundTask.stopService();
+ }
+ Logs().d('[VoipPlugin] Handle 1:1 call ended');
+
+ connectedTsSinceEpoch = 0;
+ onHoldMs = 0;
+ final provider = Provider.of(globalContext, listen: false);
+ provider.removeGlobalBanner();
+ removeCallPopupOverlay();
+ currentCallProxy = null;
+
+ final path = '/rooms/${call.room.id}';
+
+ if (FluffyChatApp.router.routerDelegate.currentConfiguration.uri
+ .toString() ==
+ '/rooms/${call.room.id}/call') {
+ FluffyChatApp.router.go(path);
+ }
+ }
+ } catch (e) {
+ Logs().e('[VoipPlugin] handleCallEnded failed', e);
+ }
+ }
+
+ @override
+ Future handleNewGroupCall(GroupCallSession groupCall) async {
+ Logs().d('[VoipPlugin] new group call found');
+ }
+
+ @override
+ Future handleGroupCallEnded(GroupCallSession groupCall) async {
+ try {
+ if ((currentCallProxy is GroupCallSession ||
+ currentCallProxy is LiveKitGroupCallSessionState) &&
+ currentCallProxy != null &&
+ currentCallProxy?.groupCall?.groupCallId == groupCall.groupCallId) {
+ if (!kIsWeb && Platform.isAndroid) {
+ await FlutterForegroundTask.stopService();
+ }
+ final provider = Provider.of(globalContext, listen: false);
+ provider.removeGlobalBanner();
+ removeCallPopupOverlay();
+ currentCallProxy = null;
+
+ final roomPath = '/rooms/${groupCall.room.id}';
+ if (FluffyChatApp.router.routerDelegate.currentConfiguration.uri
+ .toString() ==
+ '/rooms/${groupCall.room.id}/call') {
+ FluffyChatApp.router.go(roomPath);
+ }
+ }
+ } catch (e, s) {
+ Logs().e('[VoipPlugin] handleGroupCallEnded failed', e, s);
+ }
+ }
+
+ @override
+ bool get canHandleNewCall =>
+ voip.currentCID == null && voip.currentGroupCID == null;
+
+ @override
+ Future handleMissedCall(CallSession call) async {
+ try {
+ if (currentGroupCall == null) {
+ await IncomingCallManager(this).showMissedCallNotification(call);
+ }
+ } catch (e) {
+ Logs().w('[VoipPlugin] unable to show missed call notification');
+ }
+ }
+
+ final player = AudioPlayer();
+
+ @override
+ Future playRingtone() async {
+ try {
+ if (kIsWeb || Platform.isIOS) {
+ await player.setLoopMode(LoopMode.all);
+ await player.setAudioSource(
+ // https://pixabay.com/sound-effects/ringtone-126505
+ AudioSource.asset('assets/sounds/ringtone.mp3'),
+ initialIndex: 0,
+ initialPosition: Duration.zero,
+ );
+ // don't want to block the UI
+ unawaited(player.play());
+ }
+ Logs().d('[VoipPlugin] ringtone playing');
+ } catch (e) {
+ Logs().e('[VoipPlugin] unable to start ringtone', e);
+ }
+ }
+
+ @override
+ Future stopRingtone() async {
+ try {
+ // TODO: remove ios once callkeep is implemented
+ if (kIsWeb || Platform.isIOS) {
+ if (player.playerState.playing) {
+ // don't want to block the UI
+ unawaited(player.stop());
+ }
+ }
+ } catch (e) {
+ Logs().e('[VoipPlugin] unable to stop ringtone', e);
+ }
+ }
+
+ OverlayEntry? callPopupOverlayEntry;
+ void createOverlay(CallStateProxy proxy) {
+ callPopupOverlayEntry = OverlayEntry(
+ maintainState: true,
+ builder: (context) {
+ return CallOverlay(
+ callStateProxy: proxy,
+ voipPlugin: this,
+ );
+ },
+ );
+
+ Overlay.of(globalContext).insert(callPopupOverlayEntry!);
+ }
+
+ void createMinimizer(CallStateProxy proxy) async {
+ if (FluffyThemes.isColumnMode(globalContext)) {
+ Provider.of(globalContext, listen: false).removeGlobalBanner();
+ createOverlay(proxy);
+ } else {
+ removeCallPopupOverlay();
+ Provider.of(globalContext, listen: false)
+ .setGlobalBanner(CallBanner(proxy: proxy, voipPlugin: this));
+ }
+ }
+
+ // Remove the OverlayEntry.
+ void removeCallPopupOverlay() {
+ callPopupOverlayEntry?.remove();
+ callPopupOverlayEntry = null;
+ }
+
+ Future startCall(
+ BuildContext context,
+ Room room,
+ VoipType callType,
+ ) async {
+ FocusManager.instance.primaryFocus?.unfocus();
+
+ final voipPlugin = Matrix.of(context).voipPlugin;
+ if ({VoipType.kVideo, VoipType.kVoice}.contains(callType) &&
+ room.isDirectChat) {
+ if (currentCallProxy != null) {
+ setupCallAndOpenCallPage(currentCallProxy!);
+ return;
+ }
+
+ try {
+ await voipPlugin.voip.inviteToCall(
+ room.id,
+ callType == VoipType.kVoice ? CallType.kVoice : CallType.kVideo,
+ room.directChatMatrixID!,
+ );
+
+ // force null check here because handleNewCall is triggered in the above line anyway
+ setupCallAndOpenCallPage(currentCallProxy!);
+ } catch (e, s) {
+ Logs().e('startCall', e, s);
+ }
+ } else if (callType == VoipType.kGroup) {
+ if (voipPlugin.currentGroupCall != null &&
+ (currentCallProxy is GroupCallSession ||
+ currentCallProxy is LiveKitGroupCallSessionState) &&
+ currentCallProxy != null) {
+ setupCallAndOpenCallPage(currentCallProxy!);
+ } else if (currentCallProxy == null) {
+ FluffyChatApp.router.go('/rooms/${room.id}/group_call_onboarding');
+ }
+ }
+ }
+
+ String getCallStateSuffix(CallStateProxy proxy, BuildContext context) {
+ if (proxy.connecting) {
+ return L10n.of(context)!.connecting;
+ }
+ if (proxy.answering) {
+ return L10n.of(context)!.answering;
+ }
+ if (proxy.ended) {
+ return L10n.of(context)!.ended;
+ }
+ if (proxy is CallSessionState) {
+ if (proxy.isOutgoing) {
+ return L10n.of(context)!.calling;
+ }
+ if (!proxy.isOutgoing) {
+ return L10n.of(context)!.incomingCall;
+ }
+ if (proxy.ringingPlay) {
+ return L10n.of(context)!.ringing;
+ }
+ }
+
+ return 'Unknown state';
+ }
+
+ @override
+ EncryptionKeyProvider? get keyProvider => encryptionKeyProvider;
+}
diff --git a/lib/utils/voip_plugin.dart b/lib/utils/voip_plugin.dart
deleted file mode 100644
index bf36385a5..000000000
--- a/lib/utils/voip_plugin.dart
+++ /dev/null
@@ -1,200 +0,0 @@
-import 'dart:core';
-
-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' 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 '../../utils/voip/callkeep_manager.dart';
-import '../../utils/voip/user_media_manager.dart';
-import '../widgets/matrix.dart';
-
-class VoipPlugin with WidgetsBindingObserver implements WebRTCDelegate {
- final MatrixState matrix;
- Client get client => matrix.client;
- VoipPlugin(this.matrix) {
- voip = VoIP(client, this);
- if (!kIsWeb) {
- final wb = WidgetsBinding.instance;
- wb.addObserver(this);
- didChangeAppLifecycleState(wb.lifecycleState);
- }
- }
- bool background = false;
- bool speakerOn = false;
- late VoIP voip;
- OverlayEntry? overlayEntry;
- BuildContext get context => matrix.context;
-
- @override
- void didChangeAppLifecycleState(AppLifecycleState? state) {
- background = (state == AppLifecycleState.detached ||
- state == AppLifecycleState.paused);
- Logs().w('Set background mode in VOIP plugin', background);
- }
-
- void addCallingOverlay(String callId, CallSession call) {
- final context =
- kIsWeb ? ChatList.contextForVoip! : this.context; // 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
- 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
- Future playRingtone() async {
- if (!background && !await hasCallingAccount) {
- try {
- await UserMediaManager().startRingingTone();
- } catch (_) {}
- }
- }
-
- @override
- Future stopRingtone() async {
- if (!background && !await hasCallingAccount) {
- try {
- await UserMediaManager().stopRingingTone();
- } catch (_) {}
- }
- }
-
- @override
- Future 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 matrix.store.setString(
- '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(context).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
- Future handleCallEnded(CallSession session) async {
- if (overlayEntry != null) {
- overlayEntry!.remove();
- overlayEntry = null;
- if (PlatformInfos.isAndroid) {
- FlutterForegroundTask.setOnLockScreenVisibility(false);
- FlutterForegroundTask.stopService();
- final wasForeground = matrix.store.getString('wasForeground');
- wasForeground == 'false' ? FlutterForegroundTask.minimizeApp() : null;
- }
- }
- }
-
- @override
- Future handleGroupCallEnded(GroupCall groupCall) async {
- // TODO: implement handleGroupCallEnded
- }
-
- @override
- Future handleNewGroupCall(GroupCall groupCall) async {
- // TODO: implement handleNewGroupCall
- }
-
- @override
- // TODO: implement canHandleNewCall
- bool get canHandleNewCall =>
- voip.currentCID == null && voip.currentGroupCID == null;
-
- @override
- Future handleMissedCall(CallSession session) async {
- // TODO: implement handleMissedCall
- }
-}
diff --git a/lib/widgets/fluffy_chat_app.dart b/lib/widgets/fluffy_chat_app.dart
index d9d2f042a..22130a586 100644
--- a/lib/widgets/fluffy_chat_app.dart
+++ b/lib/widgets/fluffy_chat_app.dart
@@ -3,10 +3,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
+import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:fluffychat/config/routes.dart';
import 'package:fluffychat/config/themes.dart';
+import 'package:fluffychat/utils/app_state.dart';
+import 'package:fluffychat/utils/voip/voip_plugin.dart';
import 'package:fluffychat/widgets/app_lock.dart';
import 'package:fluffychat/widgets/theme_builder.dart';
import '../config/app_config.dart';
@@ -16,6 +19,7 @@ import 'matrix.dart';
class FluffyChatApp extends StatelessWidget {
final Widget? testWidget;
final List clients;
+ final List voipPlugins;
final String? pincode;
final SharedPreferences store;
@@ -23,6 +27,7 @@ class FluffyChatApp extends StatelessWidget {
super.key,
this.testWidget,
required this.clients,
+ required this.voipPlugins,
required this.store,
this.pincode,
});
@@ -36,6 +41,10 @@ class FluffyChatApp extends StatelessWidget {
// the current path.
static final GoRouter router = GoRouter(routes: AppRoutes.routes);
+ static final appGlobalKey = GlobalKey();
+
+ static final appState = AppState();
+
@override
Widget build(BuildContext context) {
return ThemeBuilder(
@@ -49,21 +58,29 @@ class FluffyChatApp extends StatelessWidget {
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
routerConfig: router,
- builder: (context, child) => AppLockWidget(
- pincode: pincode,
- clients: clients,
- // Need a navigator above the Matrix widget for
- // displaying dialogs
- child: Navigator(
- onGenerateRoute: (_) => MaterialPageRoute(
- builder: (_) => Matrix(
- clients: clients,
- store: store,
- child: testWidget ?? child,
+ builder: (context, child) {
+ return ChangeNotifierProvider.value(
+ key: ValueKey(themeMode),
+ value: appState,
+ child: AppLockWidget(
+ pincode: pincode,
+ clients: clients,
+ // Need a navigator above the Matrix widget for
+ // displaying dialogs
+ child: Navigator(
+ onGenerateRoute: (_) => MaterialPageRoute(
+ builder: (_) => Matrix(
+ key: appGlobalKey,
+ clients: clients,
+ voipPlugins: voipPlugins,
+ store: store,
+ child: testWidget ?? child,
+ ),
+ ),
),
),
- ),
- ),
+ );
+ },
),
);
}
diff --git a/lib/widgets/layouts/two_column_layout.dart b/lib/widgets/layouts/two_column_layout.dart
index a6f4c8bdf..13355e545 100644
--- a/lib/widgets/layouts/two_column_layout.dart
+++ b/lib/widgets/layouts/two_column_layout.dart
@@ -1,5 +1,12 @@
import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:provider/provider.dart';
+
+import 'package:fluffychat/pages/global_banner_scaffold.dart';
+import 'package:fluffychat/utils/app_state.dart';
+import 'package:fluffychat/widgets/matrix.dart';
+
class TwoColumnLayout extends StatelessWidget {
final Widget mainView;
final Widget sideView;
@@ -15,21 +22,56 @@ class TwoColumnLayout extends StatelessWidget {
Widget build(BuildContext context) {
return ScaffoldMessenger(
child: Scaffold(
- body: Row(
+ body: Column(
children: [
- Container(
- clipBehavior: Clip.antiAlias,
- decoration: const BoxDecoration(),
- width: 360.0 + (displayNavigationRail ? 64 : 0),
- child: mainView,
- ),
- Container(
- width: 1.0,
- color: Theme.of(context).dividerColor,
+ Selector(
+ selector: (_, state) => state.globalBanner,
+ builder: (context, banner, _) {
+ final showBanner = !GlobalBannerScaffold.ignoreBannerRoutes.any(
+ (route) => GoRouter.of(context)
+ .routerDelegate
+ .currentConfiguration
+ .uri
+ .toString()
+ .contains(route),
+ ) &&
+ Matrix.of(context).client.isLogged();
+ return AnimatedSize(
+ duration: const Duration(milliseconds: 200),
+ curve: Curves.easeInOut,
+ child: Column(
+ children: [
+ if (showBanner && banner != null)
+ Padding(
+ padding: const EdgeInsets.only(
+ top: 8.0,
+ ),
+ child: banner,
+ ),
+ ],
+ ),
+ );
+ },
),
Expanded(
- child: ClipRRect(
- child: sideView,
+ child: Row(
+ children: [
+ Container(
+ clipBehavior: Clip.antiAlias,
+ decoration: const BoxDecoration(),
+ width: 360.0 + (displayNavigationRail ? 64 : 0),
+ child: mainView,
+ ),
+ Container(
+ width: 1.0,
+ color: Theme.of(context).dividerColor,
+ ),
+ Expanded(
+ child: ClipRRect(
+ child: sideView,
+ ),
+ ),
+ ],
),
),
],
diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart
index 9f98931b3..546b70140 100644
--- a/lib/widgets/matrix.dart
+++ b/lib/widgets/matrix.dart
@@ -18,12 +18,13 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:universal_html/html.dart' as html;
import 'package:url_launcher/url_launcher_string.dart';
+import 'package:fluffychat/pages/global_banner_scaffold.dart';
import 'package:fluffychat/utils/client_manager.dart';
import 'package:fluffychat/utils/init_with_restore.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 'package:fluffychat/utils/voip/voip_plugin.dart';
import 'package:fluffychat/widgets/fluffy_chat_app.dart';
import '../config/app_config.dart';
import '../config/setting_keys.dart';
@@ -32,12 +33,11 @@ import '../utils/account_bundles.dart';
import '../utils/background_push.dart';
import 'local_notifications_extension.dart';
-// import 'package:flutter_secure_storage/flutter_secure_storage.dart';
-
class Matrix extends StatefulWidget {
final Widget? child;
final List clients;
+ final List voipPlugins;
final Map? queryParameters;
@@ -46,6 +46,7 @@ class Matrix extends StatefulWidget {
const Matrix({
this.child,
required this.clients,
+ required this.voipPlugins,
required this.store,
this.queryParameters,
super.key,
@@ -71,6 +72,9 @@ class MatrixState extends State