diff --git a/conf_call_sample/README.md b/conf_call_sample/README.md index 2806ab9..8e6b7f4 100644 --- a/conf_call_sample/README.md +++ b/conf_call_sample/README.md @@ -14,7 +14,10 @@ Project contains the following features implemented: - Screen sharing - Opponents' mic level monitoring - Opponents' video bitrate monitoring -- Speaker/grid modes (the Simulcast feature is applied) +- Speaker/grid/private modes (the Simulcast feature is applied) +- CallKit +- Pre-join screen for video calls +- Switching from audio call to video without reconnection ## Documentation @@ -25,6 +28,7 @@ ConnectyCube Conference Calls API documentation - [https://developers.connectycu ## Screenshots Flutter Conference Calls code sample, select users +Flutter Conference Calls code sample, video chat private Flutter Conference Calls code sample, video chat Flutter Conference Calls code sample, video chat (macOS) @@ -57,6 +61,21 @@ The app will automatically run on the selected iOS device or simulator. ### Run on Linux - Run command from the Terminal `flutter run -d linux`; +## Config for the CallKit feature +The CallKit feature is enabled by default in current version. + +The push notification feature is used for implementation the participants notification about the +new call event. Do the next for configuration: + +1. Create your own app in the ConnectyCube admin panel (if not created yet); +2. Create a project in the Firebase developer console (if not created yet); +3. Add the Server API key from the Firebase developer console to the ConnectyCube admin panel for the Android platform ([short guide](https://developers.connectycube.com/flutter/push-notifications?id=android)); +4. Add Apple certificate for the iOS platform ([short guide, how to generate and set it to the admin panel](https://developers.connectycube.com/ios/push-notifications?id=create-apns-certificate)). But instead of an APNS certificate, you should choose a VoIP certificate; +5. Add `google-services.json` file from the Firebase developer console to the Android app by path `conf_call_sample/android/app/` +6. Configure file `conf_call_sample/lib/src/utils/configs.dart` with your endpoints from the 1st. point of this guide; +7. Create users in the ConnectyCube admin panel and add them to the configure file `conf_call_sample/lib/src/utils/configs.dart` +8. Build and run the app as usual; + ## Can't build yourself? Got troubles with building Flutter code sample? Just create an issue at [Issues page](https://github.com/ConnectyCube/connectycube-flutter-samples/issues) - we will create the sample for you. For FREE! diff --git a/conf_call_sample/android/app/build.gradle b/conf_call_sample/android/app/build.gradle index e3c81bc..226be53 100644 --- a/conf_call_sample/android/app/build.gradle +++ b/conf_call_sample/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 33 + compileSdkVersion 34 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -39,7 +39,7 @@ android { defaultConfig { applicationId "com.connectycube.flutter.conference_call_sample" minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -69,3 +69,5 @@ dependencies { androidTestImplementation 'androidx.test:runner:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' } + +apply plugin: 'com.google.gms.google-services' diff --git a/conf_call_sample/android/app/src/main/AndroidManifest.xml b/conf_call_sample/android/app/src/main/AndroidManifest.xml index 8ffc909..3568691 100644 --- a/conf_call_sample/android/app/src/main/AndroidManifest.xml +++ b/conf_call_sample/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,8 @@ + + @@ -47,5 +49,15 @@ + + + + diff --git a/conf_call_sample/android/app/src/main/res/drawable/default_avatar.png b/conf_call_sample/android/app/src/main/res/drawable/default_avatar.png new file mode 100644 index 0000000..3b19374 Binary files /dev/null and b/conf_call_sample/android/app/src/main/res/drawable/default_avatar.png differ diff --git a/conf_call_sample/android/app/src/main/res/drawable/ic_notification.xml b/conf_call_sample/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..75a088e --- /dev/null +++ b/conf_call_sample/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/conf_call_sample/android/app/src/main/res/drawable/ic_notification_audio_call.xml b/conf_call_sample/android/app/src/main/res/drawable/ic_notification_audio_call.xml new file mode 100644 index 0000000..12002fa --- /dev/null +++ b/conf_call_sample/android/app/src/main/res/drawable/ic_notification_audio_call.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/conf_call_sample/android/app/src/main/res/drawable/ic_notification_video_call.xml b/conf_call_sample/android/app/src/main/res/drawable/ic_notification_video_call.xml new file mode 100644 index 0000000..028642d --- /dev/null +++ b/conf_call_sample/android/app/src/main/res/drawable/ic_notification_video_call.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/conf_call_sample/android/app/src/main/res/raw/custom_ringtone.mp3 b/conf_call_sample/android/app/src/main/res/raw/custom_ringtone.mp3 new file mode 100644 index 0000000..c4e0bbe Binary files /dev/null and b/conf_call_sample/android/app/src/main/res/raw/custom_ringtone.mp3 differ diff --git a/conf_call_sample/android/build.gradle b/conf_call_sample/android/build.gradle index 6fd9850..f6bcc8c 100644 --- a/conf_call_sample/android/build.gradle +++ b/conf_call_sample/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.6.10' + ext.kotlin_version = '1.9.20' repositories { google() jcenter() @@ -8,6 +8,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'com.google.gms:google-services:4.3.15' } } @@ -26,6 +27,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/conf_call_sample/android/gradle.properties b/conf_call_sample/android/gradle.properties index 38c8d45..94adc3a 100644 --- a/conf_call_sample/android/gradle.properties +++ b/conf_call_sample/android/gradle.properties @@ -1,4 +1,3 @@ org.gradle.jvmargs=-Xmx1536M -android.enableR8=true android.useAndroidX=true android.enableJetifier=true diff --git a/conf_call_sample/assets/audio/calling.mp3 b/conf_call_sample/assets/audio/calling.mp3 new file mode 100644 index 0000000..abfd3c6 Binary files /dev/null and b/conf_call_sample/assets/audio/calling.mp3 differ diff --git a/conf_call_sample/assets/audio/dialing.mp3 b/conf_call_sample/assets/audio/dialing.mp3 new file mode 100644 index 0000000..b02c987 Binary files /dev/null and b/conf_call_sample/assets/audio/dialing.mp3 differ diff --git a/conf_call_sample/assets/audio/end_call.mp3 b/conf_call_sample/assets/audio/end_call.mp3 new file mode 100644 index 0000000..f648cca Binary files /dev/null and b/conf_call_sample/assets/audio/end_call.mp3 differ diff --git a/conf_call_sample/ios/Conference Calls Sample (Screen Sharing)/Conference Calls Sample (Screen Sharing).entitlements b/conf_call_sample/ios/Conference Calls Sample (Screen Sharing)/Conference Calls Sample (Screen Sharing).entitlements index 8410518..8c04c8d 100644 --- a/conf_call_sample/ios/Conference Calls Sample (Screen Sharing)/Conference Calls Sample (Screen Sharing).entitlements +++ b/conf_call_sample/ios/Conference Calls Sample (Screen Sharing)/Conference Calls Sample (Screen Sharing).entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.com.connectycube.flutter + group.com.connectycube.flutter.val diff --git a/conf_call_sample/ios/Conference Calls Sample (Screen Sharing)/SampleHandler.swift b/conf_call_sample/ios/Conference Calls Sample (Screen Sharing)/SampleHandler.swift index ba4eaae..d56d6e2 100644 --- a/conf_call_sample/ios/Conference Calls Sample (Screen Sharing)/SampleHandler.swift +++ b/conf_call_sample/ios/Conference Calls Sample (Screen Sharing)/SampleHandler.swift @@ -9,7 +9,7 @@ import ReplayKit private enum Constants { // the App Group ID value that the app and the broadcast extension targets are setup with. It differs for each app. - static let appGroupIdentifier = "group.com.connectycube.flutter" + static let appGroupIdentifier = "group.com.connectycube.flutter.val" } class SampleHandler: RPBroadcastSampleHandler { diff --git a/conf_call_sample/ios/Flutter/AppFrameworkInfo.plist b/conf_call_sample/ios/Flutter/AppFrameworkInfo.plist index f2872cf..4f8d4d2 100644 --- a/conf_call_sample/ios/Flutter/AppFrameworkInfo.plist +++ b/conf_call_sample/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 11.0 diff --git a/conf_call_sample/ios/Podfile b/conf_call_sample/ios/Podfile index 313ea4a..2c068c4 100644 --- a/conf_call_sample/ios/Podfile +++ b/conf_call_sample/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '11.0' +platform :ios, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/conf_call_sample/ios/Podfile.lock b/conf_call_sample/ios/Podfile.lock index d857e51..a6823bf 100644 --- a/conf_call_sample/ios/Podfile.lock +++ b/conf_call_sample/ios/Podfile.lock @@ -1,42 +1,79 @@ PODS: + - assets_audio_player (0.0.1): + - Flutter + - assets_audio_player_web (0.0.1): + - Flutter + - connectycube_flutter_call_kit (2.5.0): + - Flutter - device_info_plus (0.0.1): - Flutter - Flutter (1.0.0) - - flutter_webrtc (0.9.20): + - flutter_webrtc (0.9.36): + - Flutter + - WebRTC-SDK (= 114.5735.08) + - package_info_plus (0.4.5): - Flutter - - WebRTC-SDK (= 104.5112.09) - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - WebRTC-SDK (104.5112.09) + - permission_handler_apple (9.1.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - WebRTC-SDK (114.5735.08) DEPENDENCIES: + - assets_audio_player (from `.symlinks/plugins/assets_audio_player/ios`) + - assets_audio_player_web (from `.symlinks/plugins/assets_audio_player_web/ios`) + - connectycube_flutter_call_kit (from `.symlinks/plugins/connectycube_flutter_call_kit/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) SPEC REPOS: trunk: - WebRTC-SDK EXTERNAL SOURCES: + assets_audio_player: + :path: ".symlinks/plugins/assets_audio_player/ios" + assets_audio_player_web: + :path: ".symlinks/plugins/assets_audio_player_web/ios" + connectycube_flutter_call_kit: + :path: ".symlinks/plugins/connectycube_flutter_call_kit/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter flutter_webrtc: :path: ".symlinks/plugins/flutter_webrtc/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/ios" + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: - device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed + assets_audio_player: edee322b9cb625571b830b35872ead1a295fd917 + assets_audio_player_web: 19826380c44375761aa0b9053665c1e3fbc3b86b + connectycube_flutter_call_kit: cc0bbb77ddefdc484ae7608eeda046a9bb16f0c7 + device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 - flutter_webrtc: 15c5fb4ea324f3178ff97c0f02b9467b85977e42 - path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9 - WebRTC-SDK: 3fa5c6fa717314fade68bffed85737484a28ad0b + flutter_webrtc: 55df3aaa802114dad390191a46c2c8d535751268 + package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 + path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8 + permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + WebRTC-SDK: c24d2a6c9f571f2ed42297cb8ffba9557093142b -PODFILE CHECKSUM: 7368163408c647b7eb699d0d788ba6718e18fb8d +PODFILE CHECKSUM: 4e8f8b2be68aeea4c0d5beb6ff1e79fface1d048 COCOAPODS: 1.11.3 diff --git a/conf_call_sample/ios/Runner.xcodeproj/project.pbxproj b/conf_call_sample/ios/Runner.xcodeproj/project.pbxproj index 51d2cbd..3196569 100644 --- a/conf_call_sample/ios/Runner.xcodeproj/project.pbxproj +++ b/conf_call_sample/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -21,6 +21,7 @@ A534AF5D28912D7F003E435B /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = A534AF5828912D7F003E435B /* Atomic.swift */; }; A534AF5E28912D7F003E435B /* SocketConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A534AF5928912D7F003E435B /* SocketConnection.swift */; }; A534AF5F28912D7F003E435B /* SampleUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A534AF5B28912D7F003E435B /* SampleUploader.swift */; }; + A5BCD48E2AD9459200C37B21 /* custom_ringtone.caf in Resources */ = {isa = PBXBuildFile; fileRef = A5BCD48D2AD9459200C37B21 /* custom_ringtone.caf */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -85,6 +86,7 @@ A534AF5B28912D7F003E435B /* SampleUploader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleUploader.swift; sourceTree = ""; }; A534AF6028912E3C003E435B /* Conference Calls Sample (Screen Sharing).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Conference Calls Sample (Screen Sharing).entitlements"; sourceTree = ""; }; A534AF6128912E78003E435B /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + A5BCD48D2AD9459200C37B21 /* custom_ringtone.caf */ = {isa = PBXFileReference; lastKnownFileType = file; name = custom_ringtone.caf; path = Resources/ringtones/custom_ringtone.caf; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -161,6 +163,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + A5BCD48D2AD9459200C37B21 /* custom_ringtone.caf */, A534AF6128912E78003E435B /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, @@ -204,14 +207,14 @@ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( 10D3B4B6B08C2EC508498864 /* [CP] Check Pods Manifest.lock */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 78A877BA7019A0897ABF39AE /* [CP] Embed Pods Frameworks */, + A534AF5228912C96003E435B /* Embed App Extensions */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 78A877BA7019A0897ABF39AE /* [CP] Embed Pods Frameworks */, - A534AF5228912C96003E435B /* Embed App Extensions */, ); buildRules = ( ); @@ -247,17 +250,18 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1340; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = "The Chromium Authors"; TargetAttributes = { 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; - DevelopmentTeam = 92G479T7LY; + DevelopmentTeam = 6B6Z335W9Z; LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; }; A534AF4728912C96003E435B = { CreatedOnToolsVersion = 13.4.1; - DevelopmentTeam = 92G479T7LY; + DevelopmentTeam = 6B6Z335W9Z; ProvisioningStyle = Automatic; }; }; @@ -288,6 +292,7 @@ files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + A5BCD48E2AD9459200C37B21 /* custom_ringtone.caf in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); @@ -327,10 +332,12 @@ }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -346,14 +353,24 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/assets_audio_player/assets_audio_player.framework", + "${BUILT_PRODUCTS_DIR}/assets_audio_player_web/assets_audio_player_web.framework", + "${BUILT_PRODUCTS_DIR}/connectycube_flutter_call_kit/connectycube_flutter_call_kit.framework", "${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework", + "${BUILT_PRODUCTS_DIR}/package_info_plus/package_info_plus.framework", "${BUILT_PRODUCTS_DIR}/path_provider_foundation/path_provider_foundation.framework", + "${BUILT_PRODUCTS_DIR}/shared_preferences_foundation/shared_preferences_foundation.framework", "${PODS_XCFRAMEWORKS_BUILD_DIR}/WebRTC-SDK/WebRTC.framework/WebRTC", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/assets_audio_player.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/assets_audio_player_web.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/connectycube_flutter_call_kit.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_foundation.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences_foundation.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WebRTC.framework", ); runOnlyForDeploymentPostprocessing = 0; @@ -363,6 +380,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -470,7 +488,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -487,15 +505,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 92G479T7LY; + DEVELOPMENT_TEAM = 6B6Z335W9Z; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -504,8 +524,9 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = com.connectycube.flutter.conferenceCallSample; + PRODUCT_BUNDLE_IDENTIFIER = com.connectycube.flutter.conferenceCallSample.val; PRODUCT_NAME = ConferenceCallsSample; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -559,7 +580,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -608,7 +629,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -626,15 +647,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 92G479T7LY; + DEVELOPMENT_TEAM = 6B6Z335W9Z; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -643,8 +666,9 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = com.connectycube.flutter.conferenceCallSample; + PRODUCT_BUNDLE_IDENTIFIER = com.connectycube.flutter.conferenceCallSample.val; PRODUCT_NAME = ConferenceCallsSample; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -660,15 +684,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 92G479T7LY; + DEVELOPMENT_TEAM = 6B6Z335W9Z; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -677,8 +703,9 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = com.connectycube.flutter.conferenceCallSample; + PRODUCT_BUNDLE_IDENTIFIER = com.connectycube.flutter.conferenceCallSample.val; PRODUCT_NAME = ConferenceCallsSample; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -697,7 +724,7 @@ CODE_SIGN_ENTITLEMENTS = "Conference Calls Sample (Screen Sharing)/Conference Calls Sample (Screen Sharing).entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 0.3.1; - DEVELOPMENT_TEAM = 92G479T7LY; + DEVELOPMENT_TEAM = 6B6Z335W9Z; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Conference Calls Sample (Screen Sharing)/Info.plist"; @@ -712,7 +739,7 @@ MARKETING_VERSION = 0.3.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "com.connectycube.flutter.conferenceCallSample.Conference-Calls-Sample-Screen-Sharing"; + PRODUCT_BUNDLE_IDENTIFIER = "com.connectycube.flutter.conferenceCallSample.val.Conference-Calls-Sample-Screen-Sharing.val"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -735,7 +762,7 @@ CODE_SIGN_ENTITLEMENTS = "Conference Calls Sample (Screen Sharing)/Conference Calls Sample (Screen Sharing).entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 0.3.1; - DEVELOPMENT_TEAM = 92G479T7LY; + DEVELOPMENT_TEAM = 6B6Z335W9Z; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Conference Calls Sample (Screen Sharing)/Info.plist"; @@ -749,7 +776,7 @@ ); MARKETING_VERSION = 0.3.1; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "com.connectycube.flutter.conferenceCallSample.Conference-Calls-Sample-Screen-Sharing"; + PRODUCT_BUNDLE_IDENTIFIER = "com.connectycube.flutter.conferenceCallSample.val.Conference-Calls-Sample-Screen-Sharing.val"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -770,7 +797,7 @@ CODE_SIGN_ENTITLEMENTS = "Conference Calls Sample (Screen Sharing)/Conference Calls Sample (Screen Sharing).entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 0.3.1; - DEVELOPMENT_TEAM = 92G479T7LY; + DEVELOPMENT_TEAM = 6B6Z335W9Z; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Conference Calls Sample (Screen Sharing)/Info.plist"; @@ -784,7 +811,7 @@ ); MARKETING_VERSION = 0.3.1; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "com.connectycube.flutter.conferenceCallSample.Conference-Calls-Sample-Screen-Sharing"; + PRODUCT_BUNDLE_IDENTIFIER = "com.connectycube.flutter.conferenceCallSample.val.Conference-Calls-Sample-Screen-Sharing.val"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/conf_call_sample/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/conf_call_sample/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index d477454..57081d2 100644 --- a/conf_call_sample/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/conf_call_sample/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/conf_call_sample/ios/Runner/Info.plist b/conf_call_sample/ios/Runner/Info.plist index ed99a79..00220ca 100644 --- a/conf_call_sample/ios/Runner/Info.plist +++ b/conf_call_sample/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -22,6 +24,22 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + $(PRODUCT_NAME) Camera Usage! + NSMicrophoneUsageDescription + $(PRODUCT_NAME) Microphone Usage! + RTCAppGroupIdentifier + group.com.connectycube.flutter.val + RTCScreenSharingExtension + com.connectycube.flutter.conferenceCallSample.val.Conference-Calls-Sample-Screen-Sharing.val + UIBackgroundModes + + audio + fetch + processing + remote-notification + voip + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,20 +59,7 @@ UIViewControllerBasedStatusBarAppearance - NSCameraUsageDescription - $(PRODUCT_NAME) Camera Usage! - NSMicrophoneUsageDescription - $(PRODUCT_NAME) Microphone Usage! - UIBackgroundModes - - voip - audio - - RTCScreenSharingExtension - com.connectycube.flutter.conferenceCallSample.Conference-Calls-Sample-Screen-Sharing - RTCAppGroupIdentifier - group.com.connectycube.flutter - CADisableMinimumFrameDurationOnPhone + UIApplicationSupportsIndirectInputEvents diff --git a/conf_call_sample/ios/Runner/Resources/ringtones/custom_ringtone.caf b/conf_call_sample/ios/Runner/Resources/ringtones/custom_ringtone.caf new file mode 100644 index 0000000..0c7456d Binary files /dev/null and b/conf_call_sample/ios/Runner/Resources/ringtones/custom_ringtone.caf differ diff --git a/conf_call_sample/ios/Runner/Runner.entitlements b/conf_call_sample/ios/Runner/Runner.entitlements index 8410518..a6b77e4 100644 --- a/conf_call_sample/ios/Runner/Runner.entitlements +++ b/conf_call_sample/ios/Runner/Runner.entitlements @@ -2,9 +2,11 @@ + aps-environment + development com.apple.security.application-groups - group.com.connectycube.flutter + group.com.connectycube.flutter.val diff --git a/conf_call_sample/lib/main.dart b/conf_call_sample/lib/main.dart index 4191736..52426c7 100644 --- a/conf_call_sample/lib/main.dart +++ b/conf_call_sample/lib/main.dart @@ -1,9 +1,11 @@ +import 'package:conf_call_sample/src/managers/call_manager.dart'; import 'package:flutter/material.dart'; import 'package:connectycube_sdk/connectycube_sdk.dart'; -import 'src//utils/configs.dart' as config; -import 'src/login_screen.dart'; +import 'src/utils/configs.dart' as config; +import 'src/utils/pref_util.dart'; +import 'src/screens/login_screen.dart'; void main() => runApp(App()); @@ -17,6 +19,8 @@ class App extends StatefulWidget { class _AppState extends State { @override Widget build(BuildContext context) { + initCallManager(context); + return MaterialApp( theme: ThemeData( primarySwatch: Colors.green, @@ -28,12 +32,48 @@ class _AppState extends State { @override void initState() { super.initState(); - init( - config.APP_ID, - config.AUTH_KEY, - config.AUTH_SECRET, - ); - // setEndpoints('https://', ''); + initConnectycube(); + } + + void initCallManager(BuildContext context) { + SharedPrefs.getUser().then((savedUser) { + if(savedUser != null){ + CallManager.instance.init(context); + } + }); } } + +initConnectycube() { + init( + config.APP_ID, + config.AUTH_KEY, + config.AUTH_SECRET, + onSessionRestore: () { + return SharedPrefs.getUser().then((savedUser) { + return createSession(savedUser); + }); + }, + ); + + setEndpoints(config.API_ENDPOINT, config.CHAT_ENDPOINT); + + ConferenceConfig.instance.url = config.CONF_SERVER_ENDPOINT; +} + +initConnectycubeContextLess() async { + CubeSettings.instance.applicationId = config.APP_ID; + CubeSettings.instance.authorizationKey = config.AUTH_KEY; + CubeSettings.instance.authorizationSecret = config.AUTH_SECRET; + CubeSettings.instance.onSessionRestore = () { + return SharedPrefs.getUser().then((savedUser) { + return createSession(savedUser); + }); + }; + + CubeSettings.instance.apiEndpoint = config.API_ENDPOINT; + CubeSettings.instance.chatEndpoint = config.CHAT_ENDPOINT; + + ConferenceConfig.instance.url = config.CONF_SERVER_ENDPOINT; +} diff --git a/conf_call_sample/lib/src/call_screen.dart b/conf_call_sample/lib/src/call_screen.dart deleted file mode 100644 index 9b6b237..0000000 --- a/conf_call_sample/lib/src/call_screen.dart +++ /dev/null @@ -1,1174 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:connectycube_sdk/connectycube_sdk.dart'; -import 'package:flutter_speed_dial/flutter_speed_dial.dart'; -import 'package:web_browser_detect/web_browser_detect.dart'; - -import 'utils/call_manager.dart'; -import 'utils/configs.dart'; -import 'utils/platform_utils.dart'; -import 'utils/speakers_manager.dart'; -import 'utils/video_config.dart'; - -class IncomingCallScreen extends StatelessWidget { - static const String TAG = "IncomingCallScreen"; - final String _meetingId; - final List _participantIds; - - IncomingCallScreen(this._meetingId, this._participantIds); - - @override - Widget build(BuildContext context) { - CallManager.instance.onCloseCall = () { - log("onCloseCall", TAG); - Navigator.pop(context); - }; - - return WillPopScope( - onWillPop: () => _onBackPressed(context), - child: Scaffold( - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.all(36), - child: Text(_getCallTitle(), style: TextStyle(fontSize: 28)), - ), - Padding( - padding: EdgeInsets.only(top: 36, bottom: 8), - child: Text("Members:", style: TextStyle(fontSize: 20)), - ), - Padding( - padding: EdgeInsets.only(bottom: 86), - child: Text(_participantIds.join(", "), - style: TextStyle(fontSize: 18)), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.only(right: 36), - child: FloatingActionButton( - heroTag: "RejectCall", - child: Icon( - Icons.call_end, - color: Colors.white, - ), - backgroundColor: Colors.red, - onPressed: () => _rejectCall(context), - ), - ), - Padding( - padding: EdgeInsets.only(left: 36), - child: FloatingActionButton( - heroTag: "AcceptCall", - child: Icon( - Icons.call, - color: Colors.white, - ), - backgroundColor: Colors.green, - onPressed: () => _acceptCall(context), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - - _getCallTitle() { - String callType = "Video"; - return "Incoming $callType call"; - } - - void _acceptCall(BuildContext context) async { - ConferenceSession callSession = await ConferenceClient.instance - .createCallSession(CubeChatConnection.instance.currentUser!.id!); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => ConversationCallScreen( - callSession, _meetingId, _participantIds, true), - ), - ); - } - - void _rejectCall(BuildContext context) { - CallManager.instance.reject(_meetingId, false); - Navigator.pop(context); - } - - Future _onBackPressed(BuildContext context) { - return Future.value(false); - } -} - -class ConversationCallScreen extends StatefulWidget { - final ConferenceSession _callSession; - final String _meetingId; - final List opponents; - final bool _isIncoming; - - @override - State createState() { - return _ConversationCallScreenState( - _callSession, _meetingId, opponents, _isIncoming); - } - - ConversationCallScreen( - this._callSession, this._meetingId, this.opponents, this._isIncoming); -} - -class _ConversationCallScreenState extends State { - static const String TAG = "_ConversationCallScreenState"; - final ConferenceSession _callSession; - CallManager _callManager = CallManager.instance; - final bool _isIncoming; - final String _meetingId; - final List _opponents; - final CubeStatsReportsManager _statsReportsManager = - CubeStatsReportsManager(); - final SpeakersManager _speakersManager = SpeakersManager(); - - LayoutMode layoutMode = LayoutMode.speaker; - bool _isCameraEnabled = true; - bool _isSpeakerEnabled = true; - bool _isMicMute = false; - bool _enableScreenSharing; - bool _isFrontCameraUsed = true; - RTCVideoViewObjectFit primaryVideoFit = - RTCVideoViewObjectFit.RTCVideoViewObjectFitCover; - final int currentUserId = CubeChatConnection.instance.currentUser!.id!; - - MapEntry? primaryRenderer; - Map minorRenderers = {}; - - _ConversationCallScreenState( - this._callSession, this._meetingId, this._opponents, this._isIncoming) - : _enableScreenSharing = !_callSession.startScreenSharing; - - @override - void initState() { - super.initState(); - _initCustomMediaConfigs(); - _statsReportsManager.init(_callSession); - _speakersManager.init(_statsReportsManager, _onSpeakerChanged); - _callManager.onReceiveRejectCall = _onReceiveRejectCall; - _callManager.onCloseCall = _onCloseCall; - - _callSession.onLocalStreamReceived = _addLocalMediaStream; - _callSession.onRemoteStreamTrackReceived = _addRemoteMediaStream; - _callSession.onSessionClosed = _onSessionClosed; - _callSession.onPublishersReceived = onPublishersReceived; - _callSession.onPublisherLeft = onPublisherLeft; - _callSession.onError = onError; - _callSession.onSubStreamChanged = onSubStreamChanged; - _callSession.onLayerChanged = onLayerChanged; - - _callSession.joinDialog(_meetingId, ((publishers) { - log("join session= $publishers", TAG); - - if (!_isIncoming) { - _callManager.startCall( - _meetingId, _opponents, _callSession.currentUserId); - } - }), conferenceRole: ConferenceRole.PUBLISHER); - // }), conferenceRole: ConferenceRole.LISTENER); - } - - @override - void dispose() { - super.dispose(); - - _statsReportsManager.dispose(); - _speakersManager.dispose(); - - stopBackgroundExecution(); - - primaryRenderer?.value.srcObject = null; - primaryRenderer?.value.dispose(); - - minorRenderers.forEach((opponentId, renderer) { - log("[dispose] dispose renderer for $opponentId", TAG); - try { - renderer.srcObject = null; - renderer.dispose(); - } catch (e) { - log('Error $e'); - } - }); - } - - void _onCloseCall() { - log("_onCloseCall", TAG); - _callSession.leave(); - } - - void _onReceiveRejectCall(String meetingId, int participantId, bool isBusy) { - log("_onReceiveRejectCall got reject from user $participantId", TAG); - } - - Future _addLocalMediaStream(MediaStream stream) async { - log("_addLocalMediaStream", TAG); - _callSession.setMaxBandwidth(0); - - _addMediaStream(currentUserId, true, stream); - } - - void _addRemoteMediaStream(session, int userId, MediaStream stream, - {String? trackId}) { - log("_addRemoteMediaStream for user $userId", TAG); - - _addMediaStream(userId, false, stream, trackId: trackId); - } - - void _removeMediaStream(callSession, int userId) { - log("_removeMediaStream for user $userId", TAG); - RTCVideoRenderer? videoRenderer = minorRenderers[userId]; - if (videoRenderer != null) { - videoRenderer.srcObject = null; - videoRenderer.dispose(); - - setState(() { - minorRenderers.remove(userId); - }); - } else if (primaryRenderer?.key == userId) { - var rendererToRemove = primaryRenderer?.value; - - if (rendererToRemove != null) { - rendererToRemove.srcObject = null; - rendererToRemove.dispose(); - } - - if (minorRenderers.isNotEmpty) { - setState(() { - var userIdToRemoveRenderer = minorRenderers.keys.firstWhere( - (key) => key != currentUserId, - orElse: () => minorRenderers.keys.first); - - primaryRenderer = MapEntry(userIdToRemoveRenderer, - minorRenderers.remove(userIdToRemoveRenderer)!); - _chooseOpponentsStreamsQuality( - {userIdToRemoveRenderer: StreamType.high}); - }); - } - } - } - - void _closeSessionIfLast() { - if (_callSession.allActivePublishers.length < 1) { - _callManager.stopCall(); - _callSession.leave(); - } - } - - void _onSessionClosed(session) { - log("_onSessionClosed", TAG); - _statsReportsManager.dispose(); - Navigator.pop(context); - } - - void onPublishersReceived(publishers) { - log("onPublishersReceived", TAG); - handlePublisherReceived(publishers); - } - - void onPublisherLeft(publisher) { - log("onPublisherLeft $publisher", TAG); - _removeMediaStream(_callSession, publisher); - _closeSessionIfLast(); - } - - void onError(ex) { - log("onError $ex", TAG); - } - - void onSubStreamChanged(int userId, StreamType streamType) { - log("onSubStreamChanged userId: $userId, streamType: $streamType", TAG); - } - - void onLayerChanged(int userId, int layer) { - log("onLayerChanged userId: $userId, layer: $layer", TAG); - } - - void _addMediaStream(int userId, bool isLocalStream, MediaStream stream, - {String? trackId}) async { - log("_addMediaStream for user $userId", TAG); - if (primaryRenderer == null || primaryRenderer!.key == currentUserId) { - if (primaryRenderer == null) { - primaryRenderer = MapEntry(userId, RTCVideoRenderer()); - await primaryRenderer!.value.initialize(); - - setState(() { - _setSourceForRenderer(primaryRenderer!.value, stream, isLocalStream, - trackId: trackId); - }); - } else { - var newRender = RTCVideoRenderer(); - await newRender.initialize(); - - _setSourceForRenderer(newRender, stream, isLocalStream, - trackId: trackId); - - setState(() { - minorRenderers.addEntries([primaryRenderer!]); - - primaryRenderer = MapEntry(userId, newRender); - }); - } - - _chooseOpponentsStreamsQuality({primaryRenderer!.key: StreamType.high}); - } else { - var newRender = primaryRenderer?.value; - - if (newRender != null && userId == primaryRenderer?.key) { - _setSourceForRenderer(newRender, stream, isLocalStream, - trackId: trackId); - - return; - } - - newRender = minorRenderers[userId]; - - if (newRender == null) { - newRender = RTCVideoRenderer(); - await newRender.initialize(); - } - - _setSourceForRenderer(newRender, stream, isLocalStream, trackId: trackId); - - if (!minorRenderers.containsKey(userId)) { - _chooseOpponentsStreamsQuality({userId: StreamType.low}); - - setState(() { - minorRenderers[userId] = newRender!; - }); - } - } - } - - _setSourceForRenderer( - RTCVideoRenderer renderer, MediaStream stream, bool isLocalStream, - {String? trackId}) { - isLocalStream - ? renderer.srcObject = stream - : renderer.setSrcObject(stream: stream, trackId: trackId); - } - - void handlePublisherReceived(List publishers) { - if (!_isIncoming) { - publishers.forEach((id) { - if (id != null) { - _callManager.handleAcceptCall(id); - } - }); - } - } - - List renderSpeakerModeViews(Orientation orientation) { - List streamsExpanded = []; - - if (primaryRenderer != null) { - streamsExpanded.add( - Expanded( - flex: 3, - child: Stack( - children: [ - RTCVideoView( - primaryRenderer!.value, - objectFit: primaryVideoFit, - mirror: primaryRenderer!.key == currentUserId && - _isFrontCameraUsed && - _enableScreenSharing, - ), - Align( - alignment: Alignment.topRight, - child: Padding( - padding: orientation == Orientation.portrait - ? EdgeInsets.only(top: 40, right: 20) - : EdgeInsets.only(right: 20, top: 20), - child: FloatingActionButton( - elevation: 0, - heroTag: "ToggleScreenFit", - child: Icon( - primaryVideoFit == - RTCVideoViewObjectFit.RTCVideoViewObjectFitCover - ? Icons.zoom_in_map - : Icons.zoom_out_map, - color: Colors.white, - ), - onPressed: () => _switchPrimaryVideoFit(), - backgroundColor: Colors.black38, - ), - ), - ), - if (primaryRenderer!.key != currentUserId) - Align( - alignment: Alignment.topCenter, - child: Container( - margin: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 8), - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: Container( - padding: EdgeInsets.all(6), - color: Colors.black26, - child: StreamBuilder( - stream: _statsReportsManager.videoBitrateStream.where( - (event) => event.userId == primaryRenderer!.key), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Text( - '0 kbits/sec', - style: TextStyle(color: Colors.white), - ); - } else { - var videoBitrateForUser = snapshot.data!; - return Text( - '${videoBitrateForUser.bitRate} kbits/sec', - style: TextStyle(color: Colors.white), - ); - } - }, - ), - ), - ), - ), - ), - ], - ), - ), - ); - } - - var itemHeight; - var itemWidth; - - if (orientation == Orientation.portrait) { - itemHeight = MediaQuery.of(context).size.height / 3 * 0.8; - itemWidth = itemHeight / 3 * 4; - } else { - itemWidth = MediaQuery.of(context).size.width / 3 * 0.8; - itemHeight = itemWidth / 4 * 3; - } - - var minorItems = buildItems(minorRenderers, itemWidth, itemHeight); - - if (minorRenderers.isNotEmpty) { - var membersList = Expanded( - flex: 1, - child: ListView( - scrollDirection: orientation == Orientation.landscape - ? Axis.vertical - : Axis.horizontal, - children: minorItems, - ), - ); - - streamsExpanded.add(membersList); - } - - return streamsExpanded; - } - - void _updatePrimaryUser(int userId, bool force) { - if (userId == primaryRenderer!.key || - (userId == currentUserId && !force) || - layoutMode == LayoutMode.grid) return; - - _chooseOpponentsStreamsQuality({ - userId: StreamType.high, - primaryRenderer!.key: StreamType.low, - }); - - setState(() { - minorRenderers.addEntries([primaryRenderer!]); - primaryRenderer = - minorRenderers.entries.where((entry) => entry.key == userId).first; - minorRenderers.remove(userId); - }); - } - - void _onSpeakerChanged(int userId) { - if (userId == currentUserId) return; - - _updatePrimaryUser(userId, false); - } - - @override - Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () => _onBackPressed(context), - child: Stack( - children: [ - Scaffold( - backgroundColor: Colors.grey, - body: _isVideoCall() - ? OrientationBuilder( - builder: (context, orientation) { - return layoutMode == LayoutMode.speaker - ? _buildSpeakerModLayout(orientation) - : _buildGridModLayout(orientation); - }, - ) - : Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.only(bottom: 24), - child: Text( - "Audio call", - style: TextStyle(fontSize: 28), - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 12), - child: Text( - "Members:", - style: TextStyle( - fontSize: 20, fontStyle: FontStyle.italic), - ), - ), - Text( - _callSession.allActivePublishers.join(", "), - style: TextStyle(fontSize: 20), - ), - ], - ), - ), - ), - Align( - alignment: Alignment.bottomCenter, - child: _getActionsPanel(), - ), - OrientationBuilder( - builder: (context, orientation) { - return Align( - alignment: Alignment.topLeft, - child: Container( - margin: orientation == Orientation.portrait - ? EdgeInsets.only(top: 40, left: 20) - : EdgeInsets.only(left: 40, top: 20), - child: FloatingActionButton( - elevation: 0, - heroTag: "ToggleScreenMode", - child: Icon( - layoutMode == LayoutMode.speaker - ? Icons.grid_view_rounded - : Icons.view_sidebar_rounded, - color: Colors.white, - ), - onPressed: () => _switchLayoutMode(), - backgroundColor: Colors.black38, - ), - ), - ); - }, - ), - ], - ), - ); - } - - _switchLayoutMode() { - setState(() { - layoutMode = layoutMode == LayoutMode.speaker - ? LayoutMode.grid - : LayoutMode.speaker; - - var config; - if (layoutMode == LayoutMode.grid) { - config = Map.fromEntries(minorRenderers - .map((key, value) => MapEntry(key, StreamType.medium)) - .entries); - config.addEntries([MapEntry(primaryRenderer!.key, StreamType.medium)]); - } else { - config = Map.fromEntries(minorRenderers - .map((key, value) => MapEntry(key, StreamType.low)) - .entries); - config.addEntries([MapEntry(primaryRenderer!.key, StreamType.high)]); - } - - _chooseOpponentsStreamsQuality(config); - }); - } - - Widget _buildSpeakerModLayout(Orientation orientation) { - return Center( - child: Container( - child: orientation == Orientation.portrait - ? Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: renderSpeakerModeViews(orientation)) - : Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: renderSpeakerModeViews(orientation)), - ), - ); - } - - Widget _buildGridModLayout(Orientation orientation) { - return Container( - margin: MediaQuery.of(context).padding, - child: GridView( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: orientation == Orientation.portrait ? 2 : 4, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - childAspectRatio: 4 / 3, - ), - padding: EdgeInsets.all(8), - scrollDirection: Axis.vertical, - children: _buildGridItems(orientation), - ), - ); - } - - List _buildGridItems(Orientation orientation) { - Map allRenderers = - Map.fromEntries([...minorRenderers.entries]); - if (primaryRenderer != null) { - allRenderers.addEntries([primaryRenderer!]); - } - var itemHeight; - var itemWidth; - - if (orientation == Orientation.portrait) { - itemWidth = MediaQuery.of(context).size.width * 0.95 / 2; - itemHeight = itemWidth / 4 * 3; - } else { - itemWidth = MediaQuery.of(context).size.width * 0.95 / 2; - itemHeight = itemWidth / 4 * 3; - } - - return buildItems(allRenderers, itemWidth, itemHeight); - } - - List buildItems(Map renderers, - double itemWidth, double itemHeight) { - return renderers.entries - .map( - (entry) => GestureDetector( - onDoubleTap: () => _updatePrimaryUser(entry.key, true), - child: SizedBox( - width: itemWidth, - height: itemHeight, - child: Padding( - padding: EdgeInsets.all(4), - child: Stack( - children: [ - Container( - margin: EdgeInsets.all(4), - decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(2.0), - side: BorderSide( - width: 4, - color: Colors.white70, - ), - ), - ), - child: Stack( - children: [ - StreamBuilder( - stream: _statsReportsManager.micLevelStream - .where((event) => event.userId == entry.key), - builder: (context, snapshot) { - var width = !snapshot.hasData - ? 0 - : snapshot.data!.micLevel * 4; - - return Container( - decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(2.0), - side: BorderSide( - width: width.toDouble(), - color: Colors.green, - strokeAlign: 1.0), - ), - ), - ); - }, - ), - RTCVideoView( - entry.value, - objectFit: RTCVideoViewObjectFit - .RTCVideoViewObjectFitCover, - mirror: entry.key == currentUserId && - _isFrontCameraUsed && - _enableScreenSharing, - ), - ], - ), - ), - if (entry.key != currentUserId) - Align( - alignment: Alignment.topCenter, - child: Container( - margin: EdgeInsets.only(top: 8), - child: ClipRRect( - borderRadius: - BorderRadius.all(Radius.circular(12)), - child: Container( - padding: EdgeInsets.all(6), - color: Colors.black26, - child: StreamBuilder( - stream: _statsReportsManager - .videoBitrateStream - .where( - (event) => event.userId == entry.key), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Text( - '0 kbits/sec', - style: TextStyle(color: Colors.white), - ); - } else { - var videoBitrateForUser = snapshot.data!; - return Text( - '${videoBitrateForUser.bitRate} kbits/sec', - style: TextStyle(color: Colors.white), - ); - } - }, - ), - ), - ), - )), - Align( - alignment: Alignment.bottomCenter, - child: Container( - margin: EdgeInsets.only(bottom: 8), - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: Container( - padding: EdgeInsets.all(6), - color: Colors.black26, - child: Text( - entry.key == - CubeChatConnection - .instance.currentUser?.id - ? 'Me' - : users - .where((user) => user.id == entry.key) - .first - .fullName ?? - 'Unknown', - style: TextStyle(color: Colors.white), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ) - .toList(); - } - - Widget _getActionsPanel() { - return Container( - margin: EdgeInsets.only(bottom: 16, left: 8, right: 8), - child: ClipRRect( - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(32), - bottomRight: Radius.circular(32), - topLeft: Radius.circular(32), - topRight: Radius.circular(32)), - child: Container( - padding: EdgeInsets.all(4), - color: Colors.black26, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.only(right: 4), - child: FloatingActionButton( - elevation: 0, - heroTag: "Mute", - child: Icon( - _isMicMute ? Icons.mic_off : Icons.mic, - color: _isMicMute ? Colors.grey : Colors.white, - ), - onPressed: () => _muteMic(), - backgroundColor: Colors.black38, - ), - ), - Visibility( - visible: _enableScreenSharing, - child: Padding( - padding: EdgeInsets.only(right: 4), - child: FloatingActionButton( - elevation: 0, - heroTag: "ToggleCamera", - child: Icon( - _isVideoEnabled() ? Icons.videocam : Icons.videocam_off, - color: _isVideoEnabled() ? Colors.white : Colors.grey, - ), - onPressed: () => _toggleCamera(), - backgroundColor: Colors.black38, - ), - ), - ), - SpeedDial( - heroTag: "Options", - icon: Icons.more_vert, - activeIcon: Icons.close, - backgroundColor: Colors.black38, - switchLabelPosition: true, - overlayColor: Colors.black, - elevation: 0, - overlayOpacity: 0.5, - children: [ - SpeedDialChild( - child: Icon( - _enableScreenSharing - ? Icons.screen_share - : Icons.stop_screen_share, - color: Colors.white, - ), - backgroundColor: Colors.black38, - foregroundColor: Colors.white, - label: - '${_enableScreenSharing ? 'Start' : 'Stop'} Screen Sharing', - onTap: () => _toggleScreenSharing(), - ), - SpeedDialChild( - visible: !(kIsWeb && - (Browser().browserAgent == BrowserAgent.Safari || - Browser().browserAgent == BrowserAgent.Firefox)), - child: Icon( - kIsWeb || WebRTC.platformIsDesktop - ? Icons.surround_sound - : _isSpeakerEnabled - ? Icons.volume_up - : Icons.volume_off, - color: _isSpeakerEnabled ? Colors.white : Colors.grey, - ), - backgroundColor: Colors.black38, - foregroundColor: Colors.white, - label: - 'Switch ${kIsWeb || WebRTC.platformIsDesktop ? 'Audio output' : 'Speakerphone'}', - onTap: () => _switchSpeaker(), - ), - SpeedDialChild( - visible: kIsWeb || WebRTC.platformIsDesktop, - child: Icon( - Icons.record_voice_over, - color: Colors.white, - ), - backgroundColor: Colors.black38, - foregroundColor: Colors.white, - label: 'Switch Audio Input device', - onTap: () => _switchAudioInput(), - ), - SpeedDialChild( - visible: _enableScreenSharing, - child: Icon( - Icons.cameraswitch, - color: _isVideoEnabled() ? Colors.white : Colors.grey, - ), - backgroundColor: Colors.black38, - foregroundColor: Colors.white, - label: 'Switch Camera', - onTap: () => _switchCamera(), - ), - ], - ), - Expanded( - child: SizedBox(), - flex: 1, - ), - Padding( - padding: EdgeInsets.only(left: 0), - child: FloatingActionButton( - child: Icon( - Icons.call_end, - color: Colors.white, - ), - backgroundColor: Colors.red, - onPressed: () => _endCall(), - ), - ), - ], - ), - ), - ), - ); - } - - _endCall() { - _callManager.stopCall(); - _callSession.leave(); - } - - Future _onBackPressed(BuildContext context) { - return Future.value(false); - } - - _chooseOpponentsStreamsQuality(Map config) { - config.remove(currentUserId); - - if (config.isEmpty) return; - - _callSession.requestPreferredStreamsForOpponents(config); - } - - _muteMic() { - setState(() { - _isMicMute = !_isMicMute; - _callSession.setMicrophoneMute(_isMicMute); - }); - } - - _switchCamera() { - if (!_isVideoEnabled()) return; - - if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { - _callSession.switchCamera().then((isFrontCameraUsed) { - setState(() { - _isFrontCameraUsed = isFrontCameraUsed; - }); - }); - } else { - showDialog( - context: context, - builder: (BuildContext context) { - return FutureBuilder>( - future: _callSession.getCameras(), - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data!.isEmpty) { - return AlertDialog( - content: const Text('No cameras found'), - actions: [ - TextButton( - style: TextButton.styleFrom( - textStyle: Theme.of(context).textTheme.labelLarge, - ), - child: const Text('Ok'), - onPressed: () { - Navigator.of(context).pop(); - }, - ) - ], - ); - } else { - return SimpleDialog( - title: const Text('Select camera'), - children: snapshot.data?.map( - (mediaDeviceInfo) { - return SimpleDialogOption( - onPressed: () { - Navigator.pop(context, mediaDeviceInfo.deviceId); - }, - child: Text(mediaDeviceInfo.label), - ); - }, - ).toList(), - ); - } - }, - ); - }, - ).then((deviceId) { - log("onCameraSelected deviceId: $deviceId", TAG); - if (deviceId != null) _callSession.switchCamera(deviceId: deviceId); - }); - } - } - - _toggleCamera() { - if (!_isVideoCall()) return; - - setState(() { - _isCameraEnabled = !_isCameraEnabled; - _callSession.setVideoEnabled(_isCameraEnabled); - }); - } - - _toggleScreenSharing() async { - var foregroundServiceFuture = _enableScreenSharing - ? startBackgroundExecution() - : stopBackgroundExecution(); - - var hasPermissions = await hasBackgroundExecutionPermissions(); - - if (!hasPermissions) { - await initForegroundService(); - } - - var desktopCapturerSource = _enableScreenSharing && isDesktop - ? await showDialog( - context: context, - builder: (context) => ScreenSelectDialog(), - ) - : null; - - foregroundServiceFuture.then((_) { - _callSession - .enableScreenSharing(_enableScreenSharing, - desktopCapturerSource: desktopCapturerSource, - useIOSBroadcasting: true, - requestAudioForScreenSharing: true) - .then((voidResult) { - setState(() { - _enableScreenSharing = !_enableScreenSharing; - _isFrontCameraUsed = _enableScreenSharing; - }); - }); - }); - } - - _switchPrimaryVideoFit() async { - setState(() { - primaryVideoFit = - primaryVideoFit == RTCVideoViewObjectFit.RTCVideoViewObjectFitCover - ? RTCVideoViewObjectFit.RTCVideoViewObjectFitContain - : RTCVideoViewObjectFit.RTCVideoViewObjectFitCover; - }); - } - - bool _isVideoEnabled() { - return _isVideoCall() && _isCameraEnabled; - } - - bool _isVideoCall() { - return CallType.VIDEO_CALL == _callSession.callType; - } - - _switchSpeaker() { - if (kIsWeb || WebRTC.platformIsDesktop) { - showDialog( - context: context, - builder: (BuildContext context) { - return FutureBuilder>( - future: _callSession.getAudioOutputs(), - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data!.isEmpty) { - return AlertDialog( - content: const Text('No Audio output devices found'), - actions: [ - TextButton( - style: TextButton.styleFrom( - textStyle: Theme.of(context).textTheme.labelLarge, - ), - child: const Text('Ok'), - onPressed: () { - Navigator.of(context).pop(); - }, - ) - ], - ); - } else { - return SimpleDialog( - title: const Text('Select Audio output device'), - children: snapshot.data?.map( - (mediaDeviceInfo) { - return SimpleDialogOption( - onPressed: () { - Navigator.pop(context, mediaDeviceInfo.deviceId); - }, - child: Text(mediaDeviceInfo.label), - ); - }, - ).toList(), - ); - } - }, - ); - }, - ).then((deviceId) { - log("onAudioOutputSelected deviceId: $deviceId", TAG); - if (deviceId != null) { - setState(() { - if (kIsWeb) { - primaryRenderer?.value.audioOutput(deviceId); - minorRenderers.forEach((userId, renderer) { - renderer.audioOutput(deviceId); - }); - } else { - _callSession.selectAudioOutput(deviceId); - } - }); - } - }); - } else { - setState(() { - _isSpeakerEnabled = !_isSpeakerEnabled; - _callSession.enableSpeakerphone(_isSpeakerEnabled); - }); - } - } - - _switchAudioInput() { - if (kIsWeb || WebRTC.platformIsDesktop) { - showDialog( - context: context, - builder: (BuildContext context) { - return FutureBuilder>( - future: _callSession.getAudioInputs(), - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data!.isEmpty) { - return AlertDialog( - content: const Text('No Audio input devices found'), - actions: [ - TextButton( - style: TextButton.styleFrom( - textStyle: Theme.of(context).textTheme.labelLarge, - ), - child: const Text('Ok'), - onPressed: () { - Navigator.of(context).pop(); - }, - ) - ], - ); - } else { - return SimpleDialog( - title: const Text('Select Audio input device'), - children: snapshot.data?.map( - (mediaDeviceInfo) { - return SimpleDialogOption( - onPressed: () { - Navigator.pop(context, mediaDeviceInfo.deviceId); - }, - child: Text(mediaDeviceInfo.label), - ); - }, - ).toList(), - ); - } - }, - ); - }, - ).then((deviceId) { - log("onAudioOutputSelected deviceId: $deviceId", TAG); - if (deviceId != null) { - setState(() { - _callSession.selectAudioInput(deviceId); - }); - } - }); - } - } - - void _initCustomMediaConfigs() { - RTCMediaConfig mediaConfig = RTCMediaConfig.instance; - mediaConfig.minHeight = HD_VIDEO.height; - mediaConfig.minWidth = HD_VIDEO.width; - } -} - -enum LayoutMode { speaker, grid } diff --git a/conf_call_sample/lib/src/managers/call_manager.dart b/conf_call_sample/lib/src/managers/call_manager.dart new file mode 100644 index 0000000..e35a647 --- /dev/null +++ b/conf_call_sample/lib/src/managers/call_manager.dart @@ -0,0 +1,572 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:universal_io/io.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:connectycube_flutter_call_kit/connectycube_flutter_call_kit.dart'; +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +import '../screens/conversation_call_screen.dart'; +import '../utils/consts.dart'; +import '../utils/pref_util.dart'; +import 'callkit_manager.dart'; +import 'push_notifications_manager.dart'; + +const NO_ANSWER_TIMER_INTERVAL = 60; + +class CallManager { + static final String TAG = 'CallManager'; + bool isInitialized = false; + SystemMessagesManager? _systemMessagesManager; + NewCallCallback? onReceiveNewCall; + CloseCall? onCloseCall; + RejectCallCallback? onReceiveRejectCall; + AcceptCallCallback? onReceiveAcceptCall; + CallActionCallback? onCallAccepted; + CallActionCallback? onCallRejected; + MuteCallCallback? onCallMuted; + UserNotAnswerCallback? onUserNotAnswerCallback; + String? _meetingId; + List? _participantIds; + int? _initiatorId; + Map _meetingsCalls = {}; + InternalCallState? currentCallState; + Map Function()? getMediaState; + MediaStateUpdatedCallback? onParticipantMediaUpdated; + + var _answerUserTimers = Map(); + + late BuildContext context; + + CallManager._privateConstructor() { + RTCConfig.instance.statsReportsInterval = 200; + } + + static final CallManager _instance = CallManager._privateConstructor(); + + static CallManager get instance => _instance; + + init(BuildContext context) { + log('[init]', TAG); + + if (isInitialized) return; + + if (Platform.isAndroid || Platform.isIOS) { + PushNotificationsManager.instance.init(); + CallKitManager.instance.init( + onCallAccepted: _onCallAccepted, + onCallEnded: _onCallEnded, + onMuteCall: _onMuteCall); + } + + _initSignalingListener(); + + isInitialized = true; + } + + parseCallMessage(CubeMessage cubeMessage) { + log("parseCallMessage cubeMessage= $cubeMessage"); + + if (cubeMessage.senderId == CubeChatConnection.instance.currentUser?.id) + return; + + final properties = cubeMessage.properties; + var meetingId = properties[PARAM_MEETING_ID]; + var callId = properties[PARAM_SESSION_ID]!; + + if (properties.containsKey(SIGNAL_TYPE_START_CALL)) { + var participantIds = properties[PARAM_CALL_OPPONENTS]! + .split(',') + .map((id) => int.parse(id)) + .toList(); + var callType = + int.tryParse(properties[PARAM_CALL_TYPE]?.toString() ?? '') ?? + CallType.VIDEO_CALL; + var callName = properties[PARAM_CALLER_NAME] ?? + cubeMessage.senderId?.toString() ?? + 'Unknown Caller'; + if (_meetingId == null) { + currentCallState = InternalCallState.NEW; + onReceiveNewCall?.call(callId, meetingId!, cubeMessage.senderId!, + participantIds, callType, callName); + } + } else if (properties.containsKey(SIGNAL_TYPE_ACCEPT_CALL)) { + if (_meetingId == meetingId) { + handleAcceptCall(cubeMessage.senderId!); + } + } else if (properties.containsKey(SIGNAL_TYPE_REJECT_CALL)) { + bool isBusy = properties[PARAM_BUSY] == 'true'; + if (_meetingId == meetingId) { + handleRejectCall(meetingId!, cubeMessage.senderId!, isBusy); + } + } else if (properties.containsKey(SIGNAL_TYPE_END_CALL)) { + processCallFinishedByParticipant( + cubeMessage.senderId!, callId, meetingId!); + } else if (properties.containsKey(SIGNAL_TYPE_UPDATE_MEDIA_STATE)) { + if (_meetingId == meetingId && + properties.containsKey(PARAM_MEDIA_CONFIG)) { + var mediaConfig = + Map.from(jsonDecode(properties[PARAM_MEDIA_CONFIG]!)); + onParticipantMediaUpdated?.call(cubeMessage.senderId!, mediaConfig); + } + } else if (properties.containsKey(SIGNAL_TYPE_REQUEST_MEDIA_STATE)) { + if (_meetingId == meetingId) { + var mediaConfig = getMediaState?.call(); + + if (mediaConfig != null) { + sendMediaUpdatedMessage( + callId, meetingId!, [cubeMessage.senderId!], mediaConfig); + } + } + } + } + + startNewOutgoingCall(String meetingId, List participantIds, + int currentUserId, int callType, String callName, String? callPhoto) { + _initiatorId = currentUserId; + _participantIds = participantIds; + _meetingId = meetingId; + _meetingsCalls[_meetingId!] = Uuid().v4(); + currentCallState = InternalCallState.NEW; + sendCallMessage(_meetingsCalls[_meetingId!]!, meetingId, participantIds, + callType, callName); + startNoAnswerTimers(participantIds); + _sendStartCallSignalForOffliners(_meetingsCalls[_meetingId!]!, meetingId, + callType, callName, callPhoto, currentUserId, participantIds.toSet()); + } + + reject(String callId, String meetingId, bool isBusy, int initiatorId, + bool fromCallKit) { + currentCallState = InternalCallState.REJECTED; + sendRejectMessage(callId, meetingId, isBusy, initiatorId); + + if (!fromCallKit) { + CallKitManager.instance.processCallFinished(callId); + } + + _clearCallData(); + } + + stopCall(CubeUser currentUser) { + currentCallState = InternalCallState.FINISHED; + + _clearNoAnswerTimers(); + + if (_meetingId == null) return; + + sendEndCallMessage( + _meetingsCalls[_meetingId!]!, _meetingId!, _participantIds!); + if (_initiatorId == currentUser.id) { + CallKitManager.instance.sendEndCallPushNotification( + _meetingsCalls[_meetingId!]!, _participantIds!); + } + CallKitManager.instance.processCallFinished(_meetingsCalls[_meetingId!]!); + _clearCallData(); + } + + processCallFinishedByParticipant( + int userId, String callId, String meetingId) { + if (_meetingId == null) { + currentCallState = InternalCallState.FINISHED; + + onCloseCall?.call(); + CallKitManager.instance.processCallFinished(callId); + } else if (_meetingId == meetingId) { + _clearCall(userId); + } + } + + sendCallMessage(String callId, String meetingId, List participantIds, + int callType, String callName) { + List callMsgList = + buildCallMessages(callId, meetingId, participantIds); + callMsgList.forEach((callMsg) { + callMsg.properties[SIGNAL_TYPE_START_CALL] = '1'; + callMsg.properties[PARAM_CALL_OPPONENTS] = participantIds.join(','); + callMsg.properties[PARAM_CALL_TYPE] = callType.toString(); + callMsg.properties[PARAM_CALLER_NAME] = callName; + }); + callMsgList + .forEach((msg) => sendSystemMessage(msg.recipientId!, msg.properties)); + } + + sendAcceptMessage(String callId, String meetingId, int participantId) { + List callMsgList = + buildCallMessages(callId, meetingId, [participantId]); + callMsgList.forEach((callMsg) { + callMsg.properties[SIGNAL_TYPE_ACCEPT_CALL] = '1'; + }); + callMsgList + .forEach((msg) => sendSystemMessage(msg.recipientId!, msg.properties)); + } + + sendRejectMessage( + String callId, String meetingId, bool isBusy, int participantId) { + List callMsgList = + buildCallMessages(callId, meetingId, [participantId]); + callMsgList.forEach((callMsg) { + callMsg.properties[SIGNAL_TYPE_REJECT_CALL] = '1'; + callMsg.properties[PARAM_BUSY] = isBusy.toString(); + }); + callMsgList + .forEach((msg) => sendSystemMessage(msg.recipientId!, msg.properties)); + } + + sendEndCallMessage( + String callId, String meetingId, List participantIds) { + List callMsgList = + buildCallMessages(callId, meetingId, participantIds); + callMsgList.forEach((callMsg) { + callMsg.properties[SIGNAL_TYPE_END_CALL] = '1'; + }); + callMsgList + .forEach((msg) => sendSystemMessage(msg.recipientId!, msg.properties)); + } + + sendMediaUpdatedMessage(String callId, String meetingId, + List participantIds, Map mediaConfig) { + List callMsgList = + buildCallMessages(callId, meetingId, participantIds); + callMsgList.forEach((callMsg) { + callMsg.properties[SIGNAL_TYPE_UPDATE_MEDIA_STATE] = '1'; + callMsg.properties[PARAM_MEDIA_CONFIG] = jsonEncode(mediaConfig); + }); + callMsgList + .forEach((msg) => sendSystemMessage(msg.recipientId!, msg.properties)); + } + + sendRequestMediaConfigMessage( + String callId, String meetingId, List participantIds) { + List callMsgList = + buildCallMessages(callId, meetingId, participantIds); + callMsgList.forEach((callMsg) { + callMsg.properties[SIGNAL_TYPE_REQUEST_MEDIA_STATE] = '1'; + }); + callMsgList + .forEach((msg) => sendSystemMessage(msg.recipientId!, msg.properties)); + } + + muteMic(String meetingId, bool mute) { + CallKitManager.instance.muteMic(_meetingsCalls[meetingId]!, mute); + } + + handleAcceptCall(int participantId) { + _clearNoAnswerTimers(id: participantId); + onReceiveAcceptCall?.call(participantId); + } + + handleRejectCall(String meetingId, int participantId, isBusy) { + onReceiveRejectCall?.call(meetingId, participantId, isBusy); + _clearNoAnswerTimers(id: participantId); + _clearCall(participantId); + } + + startNoAnswerTimers(participantIds) { + participantIds.forEach((userId) => { + _answerUserTimers[userId] = Timer( + Duration(seconds: NO_ANSWER_TIMER_INTERVAL), + () => noUserAnswer(userId)) + }); + } + + noUserAnswer(int participantId) { + onUserNotAnswerCallback?.call(participantId); + _clearNoAnswerTimers(id: participantId); + sendEndCallMessage( + _meetingsCalls[_meetingId!]!, _meetingId!, [participantId]); + _clearCall(participantId); + } + + _clearNoAnswerTimers({int id = 0}) { + if (id != 0) { + _answerUserTimers[id]?.cancel(); + _answerUserTimers.remove(id); + } else { + _answerUserTimers.forEach((participantId, timer) => timer.cancel()); + _answerUserTimers.clear(); + } + } + + _clearCallData() { + log('[_clearProperties]', TAG); + + _meetingId = null; + _initiatorId = null; + _participantIds = null; + _meetingsCalls.clear(); + } + + _clearCall(int participantId) { + _participantIds?.remove(participantId); + if ((_participantIds?.isEmpty ?? false) || participantId == _initiatorId) { + if (_meetingId != null) { + CallKitManager.instance + .processCallFinished(_meetingsCalls[_meetingId!]!); + } + + _clearCallData(); + currentCallState = InternalCallState.FINISHED; + + onCloseCall?.call(); + } + } + + _onCallAccepted(CallEvent callEvent) async { + log('[_onCallAccepted] _currentCallState: $currentCallState', TAG); + + if (currentCallState == InternalCallState.ACCEPTED) return; + + var savedUser = await SharedPrefs.getUser(); + if (savedUser == null) return; + + var meetingId = callEvent.userInfo?[PARAM_MEETING_ID]; + if (meetingId == null) return; + + CallManager.instance.startNewIncomingCall( + context, + savedUser, + callEvent.sessionId, + meetingId, + callEvent.callType, + callEvent.callerName, + callEvent.callerId, + callEvent.opponentsIds.toList(), + true, + cleanNavigation: false, + ); + } + + _onCallEnded(CallEvent callEvent) async { + log('[_onCallEnded] _currentCallState: $currentCallState', TAG); + + if (currentCallState == InternalCallState.FINISHED || + currentCallState == InternalCallState.REJECTED) return; + + var savedUser = await SharedPrefs.getUser(); + if (savedUser == null) return; + + var meetingId = callEvent.userInfo?[PARAM_MEETING_ID]; + if (meetingId == null) return; + + if (currentCallState == InternalCallState.ACCEPTED) { + stopCall(savedUser); + } else { + reject(callEvent.sessionId, meetingId, false, callEvent.callerId, true); + onCallRejected?.call(meetingId); + } + } + + _onMuteCall(bool mute, String callId) { + if (!_meetingsCalls.containsValue(callId)) return; + + _meetingsCalls.forEach((key, value) { + if (value == callId) { + onCallMuted?.call(key, mute); + } + }); + } + + void _sendStartCallSignalForOffliners( + String sessionId, + String meetingId, + int callType, + String callName, + String? callPhoto, + int callerId, + Set opponentsIds, + ) { + CreateEventParams params = _getCallEventParameters(sessionId, meetingId, + callType, callName, callPhoto, callerId, opponentsIds); + params.parameters[PARAM_SIGNAL_TYPE] = SIGNAL_TYPE_START_CALL; + params.parameters[PARAM_IOS_VOIP] = 1; + params.parameters[PARAM_EXPIRATION] = 0; + + createEvent(params.getEventForRequest()).then((cubeEvent) { + log("Event for offliners created: $cubeEvent"); + }).catchError((error) { + log("ERROR occurs during create event"); + }); + } + + CreateEventParams _getCallEventParameters( + String sessionId, + String meetingId, + int callType, + String callName, + String? callPhoto, + int callerId, + Set opponentsIds, + ) { + CreateEventParams params = CreateEventParams(); + params.parameters = { + 'message': + "Incoming ${callType == CallType.VIDEO_CALL ? "Video" : "Audio"} call", + PARAM_CALL_TYPE: callType, + PARAM_SESSION_ID: sessionId, + PARAM_CALLER_ID: callerId, + PARAM_CALLER_NAME: callName, + PARAM_CALL_OPPONENTS: opponentsIds.join(','), + PARAM_PHOTO_URL: callPhoto, + PARAM_USER_INFO: jsonEncode({ + PARAM_MEETING_ID: meetingId, + }), + }; + + params.notificationType = NotificationType.PUSH; + params.environment = CubeEnvironment + .DEVELOPMENT; // TODO use `DEVELOPMENT` for testing purposes + // params.environment = kReleaseMode ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT; // TODO use real in your app + params.usersIds = opponentsIds.toList(); + + return params; + } + + void _initSignalingListener() { + initSignalingListener() { + _systemMessagesManager = + CubeChatConnection.instance.systemMessagesManager; + _systemMessagesManager?.systemMessagesStream.listen(parseCallMessage); + } + + if (CubeChatConnection.instance.currentUser != null && + CubeChatConnection.instance.chatConnectionState == + CubeChatConnectionState.Ready) { + initSignalingListener(); + } else { + CubeChatConnection.instance.connectionStateStream.listen((state) { + if (state == CubeChatConnectionState.Ready) { + initSignalingListener(); + } + }); + } + } + + startNewIncomingCall( + BuildContext context, + CubeUser currentUser, + String callId, + String meetingId, + int callType, + String callName, + int callerId, + List opponentsIds, + bool fromCallKit, { + bool cleanNavigation = true, + MediaStream? initialLocalMediaStream, + bool isFrontCameraUsed = true, + }) async { + currentCallState = InternalCallState.ACCEPTED; + + var participants = Set.from([...opponentsIds, callerId]); + participants.removeWhere((userId) => userId == currentUser.id!); + + setActiveCall(callId, meetingId, callerId, participants.toList()); + + sendAcceptMessage(callId, meetingId, callerId); + + if (fromCallKit) { + onCallAccepted?.call(meetingId); + } else { + CallKitManager.instance.processCallStarted(callId); + } + + ConferenceSession callSession = await ConferenceClient.instance + .createCallSession(currentUser.id!, callType: callType); + + var route = MaterialPageRoute( + builder: (context) => ConversationCallScreen( + currentUser, + callSession, + meetingId, + opponentsIds, + true, + callName, + initialLocalMediaStream: initialLocalMediaStream, + isFrontCameraUsed: isFrontCameraUsed, + ), + ); + + if (cleanNavigation) { + Navigator.of(context).pushReplacement(route); + } else { + Navigator.of(context).push(route); + } + } + + static Future startCallIfNeed(BuildContext context) async { + var savedUser = await SharedPrefs.getUser(); + if (savedUser == null) return; + + CallKitManager.instance.getCallToStart().then((callToStart) async { + if (callToStart != null && callToStart.userInfo != null) { + var meetingId = callToStart.userInfo![PARAM_MEETING_ID]!; + + CallManager.instance.startNewIncomingCall( + context, + savedUser, + callToStart.sessionId, + meetingId, + callToStart.callType, + callToStart.callerName, + callToStart.callerId, + callToStart.opponentsIds.toList(), + true); + } + }); + } + + bool hasActiveCall() { + return _meetingId != null; + } + + void setActiveCall(String callId, String meetingId, int initiatorId, + List participantIds) { + _meetingId = meetingId; + _meetingsCalls[meetingId] = callId; + _initiatorId = initiatorId; + _participantIds = participantIds; + } + + void notifyParticipantsMediaUpdated(Map mediaConfig) { + if (_meetingId == null) return; + + sendMediaUpdatedMessage(_meetingsCalls[_meetingId]!, _meetingId!, + _participantIds!, mediaConfig); + } + + void requestParticipantsMediaConfig(List participants) { + if (_meetingId == null) return; + participants.removeWhere((userId) => userId == null); + + if (participants.isEmpty) return; + + sendRequestMediaConfigMessage( + _meetingsCalls[_meetingId]!, _meetingId!, List.from(participants)); + } +} + +List buildCallMessages( + String callId, String meetingId, List participantIds) { + return participantIds.map((userId) { + var msg = CubeMessage(); + msg.recipientId = userId; + msg.properties = {PARAM_MEETING_ID: meetingId, PARAM_SESSION_ID: callId}; + return msg; + }).toList(); +} + +enum InternalCallState { NEW, REJECTED, ACCEPTED, FINISHED } + +typedef void NewCallCallback(String callId, String meetingId, int initiatorId, + List participantIds, int callType, String callName); +typedef void CloseCall(); +typedef void RejectCallCallback( + String meetingId, int participantId, bool isBusy); +typedef void AcceptCallCallback(int participantId); +typedef void CallActionCallback(String meetingId); +typedef void UserNotAnswerCallback(int participantId); +typedef void MuteCallCallback(String meetingId, bool isMuted); +typedef void MediaStateUpdatedCallback( + int userId, Map mediaConfig); diff --git a/conf_call_sample/lib/src/managers/callkit_manager.dart b/conf_call_sample/lib/src/managers/callkit_manager.dart new file mode 100644 index 0000000..a3686be --- /dev/null +++ b/conf_call_sample/lib/src/managers/callkit_manager.dart @@ -0,0 +1,200 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:universal_io/io.dart'; + +import 'package:connectycube_flutter_call_kit/connectycube_flutter_call_kit.dart'; +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +import '../../main.dart'; +import '../utils/consts.dart'; +import 'call_manager.dart'; + +class CallKitManager { + static CallKitManager get instance => _getInstance(); + static CallKitManager? _instance; + static String TAG = "CallKitManager"; + + static CallKitManager _getInstance() { + return _instance ??= CallKitManager._internal(); + } + + factory CallKitManager() => _getInstance(); + + CallKitManager._internal(); + + late Function(CallEvent callEvent) onCallAccepted; + late Function(CallEvent CallEvent) onCallEnded; + late Function(bool mute, String uuid) onMuteCall; + + init({ + required onCallAccepted(CallEvent callEvent), + required onCallEnded(CallEvent callEvent), + required onMuteCall(bool mute, String uuid), + }) { + this.onCallAccepted = onCallAccepted; + this.onCallEnded = onCallEnded; + this.onMuteCall = onMuteCall; + + ConnectycubeFlutterCallKit.instance.init( + onCallAccepted: _onCallAccepted, + onCallRejected: _onCallRejected, + icon: Platform.isAndroid ? 'default_avatar' : 'CallKitIcon', + color: '#07711e', + // ringtone: + // Platform.isAndroid ? 'custom_ringtone' : 'Resources/ringtones/custom_ringtone.caf' + ); + ConnectycubeFlutterCallKit.onCallRejectedWhenTerminated = + onCallRejectedWhenTerminated; + + if (Platform.isIOS) { + ConnectycubeFlutterCallKit.onCallMuted = _onCallMuted; + } + } + + Future processCallFinished(String uuid) async { + if (Platform.isAndroid || Platform.isIOS) { + ConnectycubeFlutterCallKit.reportCallEnded(sessionId: uuid); + ConnectycubeFlutterCallKit.setOnLockScreenVisibility(isVisible: false); + ConnectycubeFlutterCallKit.clearCallData(sessionId: uuid); + } + } + + Future processCallStarted(String uuid) async { + if (Platform.isAndroid || Platform.isIOS) { + ConnectycubeFlutterCallKit.reportCallAccepted(sessionId: uuid); + ConnectycubeFlutterCallKit.setOnLockScreenVisibility(isVisible: true); + } + } + + Future sendEndCallPushNotification( + String callId, List participants) async { + if (Platform.isAndroid || Platform.isIOS) { + sendPushAboutEndingCall(callId, participants); + } + } + + Future _onCallMuted(bool mute, String callId) async { + onMuteCall.call(mute, callId); + } + + Future _onCallAccepted(CallEvent callEvent) async { + onCallAccepted.call(callEvent); + } + + Future _onCallRejected(CallEvent callEvent) async { + onCallEnded.call(callEvent); + } + + void muteMic(String callId, bool mute) { + ConnectycubeFlutterCallKit.reportCallMuted(sessionId: callId, muted: mute); + } + + Future getCallToStart() { + return ConnectycubeFlutterCallKit.getLastCallId().then((lastCallId) { + if (lastCallId == null) { + return null; + } + + return ConnectycubeFlutterCallKit.getCallState(sessionId: lastCallId) + .then((state) { + if (state == CallState.ACCEPTED) { + return ConnectycubeFlutterCallKit.getCallData(sessionId: lastCallId) + .then((callData) { + if (callData == null) return null; + + return CallEvent( + sessionId: callData[PARAM_SESSION_ID].toString(), + callType: int.parse(callData[PARAM_CALL_TYPE].toString()), + callerId: int.parse(callData[PARAM_CALLER_ID].toString()), + callerName: callData[PARAM_CALLER_NAME] as String, + opponentsIds: (callData[PARAM_CALL_OPPONENTS] as String) + .split(',') + .map(int.parse) + .toSet(), + userInfo: callData[PARAM_USER_INFO] != null + ? Map.from( + jsonDecode(callData[PARAM_USER_INFO])) + : null, + ); + ; + }); + } + return null; + }); + }); + } +} + +@pragma('vm:entry-point') +Future onCallRejectedWhenTerminated(CallEvent callEvent) async { + print( + '[PushNotificationsManager][onCallRejectedWhenTerminated] callEvent: $callEvent'); + + var meetingId = callEvent.userInfo?[PARAM_MEETING_ID]; + if (meetingId == null) return; + + initConnectycubeContextLess(); + + var callMsgList = + buildCallMessages(callEvent.sessionId, meetingId, [callEvent.callerId]); + callMsgList.forEach((callMsg) { + callMsg.properties[SIGNAL_TYPE_REJECT_CALL] = '1'; + callMsg.properties[PARAM_BUSY] = 'false'; + }); + + callMsgList + .forEach((msg) => sendSystemMessage(msg.recipientId!, msg.properties)); + + var sendRejectCallMessage = callMsgList.map((msg) { + return sendSystemMessage(msg.recipientId!, msg.properties); + }).toList(); + + var sendPushAboutReject = sendPushAboutRejectFromKilledState({ + PARAM_CALL_TYPE: callEvent.callType, + PARAM_SESSION_ID: callEvent.sessionId, + PARAM_CALLER_ID: callEvent.callerId, + PARAM_CALLER_NAME: callEvent.callerName, + PARAM_CALL_OPPONENTS: callEvent.opponentsIds.join(','), + PARAM_USER_INFO: {PARAM_MEETING_ID: meetingId}, + }, callEvent.callerId); + + return Future.wait([...sendRejectCallMessage, sendPushAboutReject]) + .then((result) { + return Future.value(); + }); +} + +Future sendPushAboutRejectFromKilledState( + Map parameters, + int callerId, +) { + CreateEventParams params = CreateEventParams(); + params.parameters = parameters; + params.parameters[PARAM_MESSAGE] = "Reject call"; + params.parameters[PARAM_SIGNAL_TYPE] = SIGNAL_TYPE_REJECT_CALL; + + params.notificationType = NotificationType.PUSH; + params.environment = + kReleaseMode ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT; + params.usersIds = [callerId]; + + return createEvent(params.getEventForRequest()); +} + +Future sendPushAboutEndingCall( + String callId, + List participants, +) { + CreateEventParams params = CreateEventParams(); + params.parameters[PARAM_SESSION_ID] = callId; + params.parameters[PARAM_MESSAGE] = 'End call'; + params.parameters[PARAM_SIGNAL_TYPE] = SIGNAL_TYPE_END_CALL; + + params.notificationType = NotificationType.PUSH; + params.environment = + kReleaseMode ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT; + params.usersIds = participants; + + return createEvent(params.getEventForRequest()); +} diff --git a/conf_call_sample/lib/src/managers/push_notifications_manager.dart b/conf_call_sample/lib/src/managers/push_notifications_manager.dart new file mode 100644 index 0000000..53b6b2a --- /dev/null +++ b/conf_call_sample/lib/src/managers/push_notifications_manager.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:universal_io/io.dart'; + +import 'package:connectycube_flutter_call_kit/connectycube_flutter_call_kit.dart'; +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +import '../utils/pref_util.dart'; + +class PushNotificationsManager { + static const TAG = "PushNotificationsManager"; + + static PushNotificationsManager? _instance; + + PushNotificationsManager._internal(); + + static PushNotificationsManager _getInstance() { + return _instance ??= PushNotificationsManager._internal(); + } + + factory PushNotificationsManager() => _getInstance(); + + BuildContext? applicationContext; + + static PushNotificationsManager get instance => _getInstance(); + + init() async { + ConnectycubeFlutterCallKit.initEventsHandler(); + + ConnectycubeFlutterCallKit.onTokenRefreshed = (token) { + log('[onTokenRefresh] VoIP token: $token', TAG); + subscribe(token); + }; + + ConnectycubeFlutterCallKit.getToken().then((token) { + log('[getToken] VoIP token: $token', TAG); + if (token != null) { + subscribe(token); + } + }); + } + + subscribe(String token) async { + log('[subscribe] token: $token', PushNotificationsManager.TAG); + + var savedToken = await SharedPrefs.getSubscriptionToken(); + if (token == savedToken) { + log('[subscribe] skip subscription for same token', + PushNotificationsManager.TAG); + return; + } + + CreateSubscriptionParameters parameters = CreateSubscriptionParameters(); + parameters.pushToken = token; + + parameters.environment = CubeEnvironment + .DEVELOPMENT; // TODO used `DEVELOPMENT` environment for testing purposes + // parameters.environment = kReleaseMode ? CubeEnvironment.PRODUCTION : CubeEnvironment.DEVELOPMENT; // TODO use actual environment in the real app + + if (Platform.isAndroid) { + parameters.channel = NotificationsChannels.GCM; + parameters.platform = CubePlatform.ANDROID; + } else if (Platform.isIOS) { + parameters.channel = NotificationsChannels.APNS_VOIP; + parameters.platform = CubePlatform.IOS; + } + + var deviceInfoPlugin = DeviceInfoPlugin(); + + var deviceId; + + if (kIsWeb) { + var webBrowserInfo = await deviceInfoPlugin.webBrowserInfo; + deviceId = base64Encode(utf8.encode(webBrowserInfo.userAgent ?? '')); + } else if (Platform.isAndroid) { + var androidInfo = await deviceInfoPlugin.androidInfo; + deviceId = androidInfo.id; + } else if (Platform.isIOS) { + var iosInfo = await deviceInfoPlugin.iosInfo; + deviceId = iosInfo.identifierForVendor; + } else if (Platform.isMacOS) { + var macOsInfo = await deviceInfoPlugin.macOsInfo; + deviceId = macOsInfo.computerName; + } + + parameters.udid = deviceId; + + var packageInfo = await PackageInfo.fromPlatform(); + parameters.bundleIdentifier = packageInfo.packageName; + + createSubscription(parameters.getRequestParameters()) + .then((cubeSubscriptions) { + log('[subscribe] subscription SUCCESS', PushNotificationsManager.TAG); + SharedPrefs.saveSubscriptionToken(token); + cubeSubscriptions.forEach((subscription) { + if (subscription.device!.clientIdentificationSequence == token) { + SharedPrefs.saveSubscriptionId(subscription.id!); + } + }); + }).catchError((error) { + log('[subscribe] subscription ERROR: $error', + PushNotificationsManager.TAG); + }); + } + + Future unsubscribe() { + return SharedPrefs.getSubscriptionId().then((subscriptionId) async { + if (subscriptionId != 0) { + return deleteSubscription(subscriptionId).then((voidResult) { + SharedPrefs.saveSubscriptionId(0); + }); + } else { + return Future.value(); + } + }).catchError((onError) { + log('[unsubscribe] ERROR: $onError', PushNotificationsManager.TAG); + }); + } +} diff --git a/conf_call_sample/lib/src/utils/speakers_manager.dart b/conf_call_sample/lib/src/managers/speakers_manager.dart similarity index 100% rename from conf_call_sample/lib/src/utils/speakers_manager.dart rename to conf_call_sample/lib/src/managers/speakers_manager.dart diff --git a/conf_call_sample/lib/src/screens/conversation_call_screen.dart b/conf_call_sample/lib/src/screens/conversation_call_screen.dart new file mode 100644 index 0000000..bd3b05f --- /dev/null +++ b/conf_call_sample/lib/src/screens/conversation_call_screen.dart @@ -0,0 +1,967 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:assets_audio_player/assets_audio_player.dart'; +import 'package:conf_call_sample/src/utils/duration_timer.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +import '../managers/call_manager.dart'; +import '../utils/configs.dart'; +import '../utils/consts.dart'; +import '../utils/media_utils.dart'; +import '../utils/platform_utils.dart'; +import '../widgets/call_controls_widget.dart'; +import '../widgets/call_info_widget.dart'; +import '../widgets/grid_view_call_widget.dart'; +import '../widgets/private_call_widget.dart'; +import '../widgets/speaker_view_call_widget.dart'; +import 'select_opponents_screen.dart'; + +class ConversationCallScreen extends StatefulWidget { + final CubeUser _currentUser; + final ConferenceSession _callSession; + final String _meetingId; + final List opponents; + final bool _isIncoming; + final String _callName; + final MediaStream? initialLocalMediaStream; + final bool isFrontCameraUsed; + + @override + State createState() { + return _ConversationCallScreenState( + _currentUser, + _callSession, + _meetingId, + opponents, + _isIncoming, + _callName, + initialLocalMediaStream: initialLocalMediaStream, + isFrontCameraUsed: isFrontCameraUsed, + ); + } + + ConversationCallScreen(this._currentUser, this._callSession, this._meetingId, + this.opponents, this._isIncoming, this._callName, + {this.initialLocalMediaStream, this.isFrontCameraUsed = true}); +} + +class _ConversationCallScreenState extends State { + static const String TAG = "_ConversationCallScreenState"; + static final LayoutMode DEFAULT_LAYOUT_MODE = LayoutMode.speaker; + final CubeUser _currentUser; + final ConferenceSession _callSession; + CallManager _callManager = CallManager.instance; + final String _callName; + final bool _isIncoming; + final String _meetingId; + final List _opponents; + final CubeStatsReportsManager _statsReportsManager = + CubeStatsReportsManager(); + MediaStream? initialLocalMediaStream; + + LayoutMode layoutMode = DEFAULT_LAYOUT_MODE; + String _callStatus = 'Waiting...'; + bool _isCameraEnabled = true; + bool _isSpeakerEnabled = true; + bool _isMicMute = false; + bool _enableScreenSharing; + bool _isFrontCameraUsed = true; + RTCVideoViewObjectFit primaryVideoFit = + RTCVideoViewObjectFit.RTCVideoViewObjectFitCover; + final int currentUserId; + DurationTimer _callTimer = DurationTimer(); + MapEntry? primaryRenderer; + Map minorRenderers = {}; + Map> participantsMediaConfigs = {}; + WidgetPosition _minorWidgetPosition = WidgetPosition.topRight; + AssetsAudioPlayer _ringtonePlayer = AssetsAudioPlayer.newPlayer(); + + _ConversationCallScreenState(this._currentUser, this._callSession, + this._meetingId, this._opponents, this._isIncoming, this._callName, + {this.initialLocalMediaStream, bool isFrontCameraUsed = true}) + : _enableScreenSharing = !_callSession.startScreenSharing, + _isCameraEnabled = _callSession.callType == CallType.VIDEO_CALL, + currentUserId = _currentUser.id!, + _isFrontCameraUsed = isFrontCameraUsed { + if (_opponents.length == 1) { + layoutMode = LayoutMode.private; + } + + if (initialLocalMediaStream != null) { + _isMicMute = + !(initialLocalMediaStream?.getAudioTracks().firstOrNull?.enabled ?? + false); + _isCameraEnabled = + initialLocalMediaStream?.getVideoTracks().firstOrNull?.enabled ?? + false; + } + + participantsMediaConfigs[currentUserId] = { + PARAM_IS_MIC_ENABLED: !_isMicMute, + PARAM_IS_CAMERA_ENABLED: _isCameraEnabled + }; + } + + @override + void initState() { + super.initState(); + _statsReportsManager.init(_callSession); + + _callManager.onReceiveRejectCall = _onReceiveRejectCall; + _callManager.onReceiveAcceptCall = _onReceiveAcceptCall; + _callManager.onCloseCall = _onCloseCall; + _callManager.onCallMuted = _onCallMuted; + _callManager.getMediaState = _getMediaState; + _callManager.onParticipantMediaUpdated = _onParticipantMediaUpdated; + + _callSession.onLocalStreamReceived = _addLocalMediaStream; + _callSession.onRemoteStreamTrackReceived = _addRemoteMediaStream; + _callSession.onSessionClosed = _onSessionClosed; + _callSession.onPublishersReceived = onPublishersReceived; + _callSession.onPublisherLeft = onPublisherLeft; + _callSession.onError = onError; + _callSession.onSubStreamChanged = onSubStreamChanged; + _callSession.onLayerChanged = onLayerChanged; + + if (initialLocalMediaStream != null) { + _callSession.localStream = initialLocalMediaStream; + _addLocalMediaStream(initialLocalMediaStream!); + } + + _callSession.joinDialog(_meetingId, ((publishers) { + log("join session= $publishers", TAG); + + _callManager.requestParticipantsMediaConfig(publishers); + + _callSession.setMaxBandwidth(0); + + if (!_isIncoming) { + _callManager.startNewOutgoingCall( + _meetingId, + _opponents, + _callSession.currentUserId, + _callSession.callType, + _callName, + _currentUser.avatar); + _playDialing(); + } else { + setState(() { + _callStatus = 'Connected'; + _startCallTimer(); + }); + } + }), conferenceRole: ConferenceRole.PUBLISHER); + } + + @override + void dispose() { + _statsReportsManager.dispose(); + + stopBackgroundExecution(); + + primaryRenderer?.value.srcObject = null; + primaryRenderer?.value.dispose(); + + minorRenderers.forEach((opponentId, renderer) { + log("[dispose] dispose renderer for $opponentId", TAG); + try { + renderer.srcObject = null; + renderer.dispose(); + } catch (e) { + log('Error $e'); + } + }); + + _playStoppingCall(); + + super.dispose(); + } + + void _onCloseCall() { + log("_onCloseCall", TAG); + _callSession.leave(); + } + + void _onReceiveRejectCall(String meetingId, int participantId, bool isBusy) { + log("_onReceiveRejectCall got reject from user $participantId", TAG); + } + + void _onReceiveAcceptCall(int participantId) { + log('[_onReceiveAcceptCall] from user $participantId', TAG); + + setState(() { + _callStatus = 'Connected'; + _startCallTimer(); + _stopDialing(); + }); + } + + Future _addLocalMediaStream(MediaStream stream) async { + log("_addLocalMediaStream", TAG); + + _addMediaStream(currentUserId, true, stream); + } + + void _addRemoteMediaStream(session, int userId, MediaStream stream, + {String? trackId}) { + log("_addRemoteMediaStream for user $userId", TAG); + + _addMediaStream(userId, false, stream, trackId: trackId); + } + + void _removeMediaStream(callSession, int userId) { + log("_removeMediaStream for user $userId", TAG); + RTCVideoRenderer? videoRenderer = minorRenderers[userId]; + if (videoRenderer != null) { + videoRenderer.srcObject = null; + videoRenderer.dispose(); + + setState(() { + minorRenderers.remove(userId); + }); + } else if (primaryRenderer?.key == userId) { + var rendererToRemove = primaryRenderer?.value; + + if (rendererToRemove != null) { + rendererToRemove.srcObject = null; + rendererToRemove.dispose(); + } + + if (minorRenderers.isNotEmpty) { + setState(() { + var userIdToRemoveRenderer = minorRenderers.keys.firstWhere( + (key) => key != currentUserId, + orElse: () => minorRenderers.keys.first); + + primaryRenderer = MapEntry(userIdToRemoveRenderer, + minorRenderers.remove(userIdToRemoveRenderer)!); + chooseOpponentsStreamsQuality(_callSession, currentUserId, + {userIdToRemoveRenderer: StreamType.high}); + }); + } + } + } + + void _closeSessionIfLast() { + log("[_closeSessionIfLast]", TAG); + if (_callSession.allActivePublishers.length < 1) { + log("[_closeSessionIfLast] 1", TAG); + _callSession.leave(); + } + } + + void _onSessionClosed(session) { + log("[_onSessionClosed]", TAG); + _statsReportsManager.dispose(); + _callManager.stopCall(_currentUser); + _stopCallTimer(); + + log("[_onSessionClosed] 2", TAG); + var navigator = Navigator.of(context); + if (navigator.canPop()) { + log("[_onSessionClosed] navigator.canPop()", TAG); + Navigator.of(context).popUntil((route) { + return route.isFirst; + }); + } else { + log("[_onSessionClosed] !navigator.canPop()", TAG); + navigator.pushReplacement( + MaterialPageRoute( + builder: (context) => SelectOpponentsScreen(_currentUser), + ), + ); + } + } + + void onPublishersReceived(publishers) { + log("onPublishersReceived", TAG); + handlePublisherReceived(publishers); + } + + void onPublisherLeft(publisher) { + log("onPublisherLeft $publisher", TAG); + _removeMediaStream(_callSession, publisher); + _closeSessionIfLast(); + } + + void onError(ex) { + log("onError $ex", TAG); + } + + void onSubStreamChanged(int userId, StreamType streamType) { + log("onSubStreamChanged userId: $userId, streamType: $streamType", TAG); + } + + void onLayerChanged(int userId, int layer) { + log("onLayerChanged userId: $userId, layer: $layer", TAG); + } + + Future _addMediaStream( + int userId, bool isLocalStream, MediaStream stream, + {String? trackId}) async { + log('[_addMediaStream] userId: $userId, isLocalStream: $isLocalStream', + TAG); + + if (primaryRenderer == null) { + primaryRenderer = MapEntry(userId, RTCVideoRenderer()); + await primaryRenderer!.value.initialize(); + + setState(() { + _setSourceForRenderer(primaryRenderer!.value, stream, isLocalStream, + trackId: trackId); + + chooseOpponentsStreamsQuality(_callSession, currentUserId, { + userId: StreamType.high, + }); + }); + + return; + } + + if (primaryRenderer?.key == userId) { + _setSourceForRenderer(primaryRenderer!.value, stream, isLocalStream, + trackId: trackId); + + chooseOpponentsStreamsQuality(_callSession, currentUserId, { + userId: StreamType.high, + }); + + return; + } + + if (minorRenderers[userId] == null) { + minorRenderers[userId] = RTCVideoRenderer(); + await minorRenderers[userId]?.initialize(); + } + + setState(() { + _setSourceForRenderer(minorRenderers[userId]!, stream, isLocalStream, + trackId: trackId); + + if (primaryRenderer?.key == currentUserId || + primaryRenderer?.key == userId || + ((primaryRenderer?.value.srcObject?.getVideoTracks().isEmpty ?? + false) && + stream.getVideoTracks().isNotEmpty)) { + _updatePrimaryUser(userId, true); + } + }); + } + + _setSourceForRenderer( + RTCVideoRenderer renderer, MediaStream stream, bool isLocalStream, + {String? trackId}) { + isLocalStream || kIsWeb + ? renderer.srcObject = stream + : renderer.setSrcObject(stream: stream, trackId: trackId); + } + + void handlePublisherReceived(List publishers) { + if (!_isIncoming) { + publishers.forEach((id) { + if (id != null) { + _callManager.handleAcceptCall(id); + } + }); + } + + if (publishers.isNotEmpty) { + _callManager.requestParticipantsMediaConfig(publishers); + } + } + + void _updatePrimaryUser(int userId, bool force) { + log("[_updatePrimaryUser] userId: $userId, force: $force", TAG); + + if (layoutMode == LayoutMode.grid) return; + + log("[_updatePrimaryUser] 2", TAG); + updatePrimaryUser( + userId, + force, + currentUserId, + primaryRenderer, + minorRenderers, + participantsMediaConfigs, + onRenderersUpdated: _updateRenderers, + ); + log("[_updatePrimaryUser] 3", TAG); + } + + _updateRenderers(MapEntry? updatedPrimaryRenderer, + Map updatedMinorRenderers) { + if (updatedPrimaryRenderer?.key != primaryRenderer?.key) { + chooseOpponentsStreamsQuality(_callSession, currentUserId, { + if (updatedPrimaryRenderer?.key != null) + updatedPrimaryRenderer!.key: StreamType.high, + if (primaryRenderer?.key != null) primaryRenderer!.key: StreamType.low, + }); + } + + primaryRenderer = updatedPrimaryRenderer; + minorRenderers = updatedMinorRenderers; + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () => _onBackPressed(context), + child: Stack( + children: [ + Scaffold( + backgroundColor: Colors.green.shade100, + body: _isVideoTracksPresent() + ? OrientationBuilder( + builder: (context, orientation) { + return layoutMode == LayoutMode.private + ? _buildPrivateCallLayout(orientation) + : layoutMode == LayoutMode.speaker + ? _buildSpeakerModLayout(orientation) + : _buildGridModLayout(orientation); + }, + ) + : _buildAudioCallLayout(), + ), + Align( + alignment: Alignment.bottomCenter, + child: _getActionsPanel(), + ), + Visibility( + visible: + layoutMode != LayoutMode.private && _isVideoTracksPresent(), + child: OrientationBuilder( + builder: (context, orientation) { + return Align( + alignment: Alignment.topLeft, + child: Container( + margin: orientation == Orientation.portrait + ? EdgeInsets.only(top: 40, left: 20) + : EdgeInsets.only(left: 40, top: 20), + child: FloatingActionButton( + elevation: 0, + heroTag: "ToggleScreenMode", + child: Icon( + layoutMode == LayoutMode.speaker + ? Icons.grid_view_rounded + : Icons.view_sidebar_rounded, + color: Colors.white, + ), + onPressed: () => _switchLayoutMode(), + backgroundColor: Colors.black38, + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + _switchLayoutMode() { + setState(() { + layoutMode = layoutMode == LayoutMode.speaker + ? LayoutMode.grid + : LayoutMode.speaker; + + var config; + if (layoutMode == LayoutMode.grid) { + config = Map.fromEntries(minorRenderers + .map((key, value) => MapEntry(key, StreamType.medium)) + .entries); + config.addEntries([MapEntry(primaryRenderer!.key, StreamType.medium)]); + } else { + config = Map.fromEntries(minorRenderers + .map((key, value) => MapEntry(key, StreamType.low)) + .entries); + config.addEntries([MapEntry(primaryRenderer!.key, StreamType.high)]); + } + + chooseOpponentsStreamsQuality(_callSession, currentUserId, config); + }); + } + + Widget _buildPrivateCallLayout(Orientation orientation) { + return PrivateCallLayout( + currentUserId: currentUserId, + primaryRenderer: primaryRenderer, + primaryVideoFit: primaryVideoFit, + minorRenderers: minorRenderers, + callName: _getCallName(), + callStatus: _callStatus, + callTimer: _callTimer, + minorWidgetInitialPosition: _minorWidgetPosition, + isFrontCameraUsed: _isFrontCameraUsed, + isScreenSharingEnabled: !_enableScreenSharing, + participantsMediaConfigs: participantsMediaConfigs, + onMinorVideoPositionChanged: (newPosition) { + _minorWidgetPosition = newPosition; + }, + onPrimaryVideoFitChanged: (newObjectFit) { + primaryVideoFit = newObjectFit; + }, + onRenderersChanged: _updateRenderers, + ); + } + + Widget _buildSpeakerModLayout(Orientation orientation) { + return SpeakerViewLayout( + currentUserId: currentUserId, + participants: users, + primaryRenderer: primaryRenderer, + primaryVideoFit: primaryVideoFit, + minorRenderers: minorRenderers, + callName: _getCallName(), + callStatus: _callStatus, + callTimer: _callTimer, + isFrontCameraUsed: _isFrontCameraUsed, + isScreenSharingEnabled: !_enableScreenSharing, + participantsMediaConfigs: participantsMediaConfigs, + onPrimaryVideoFitChanged: (newObjectFit) { + primaryVideoFit = newObjectFit; + }, + onRenderersChanged: _updateRenderers, + statsReportsManager: _statsReportsManager, + ); + } + + Widget _buildGridModLayout(Orientation orientation) { + return GridViewLayout( + currentUserId: currentUserId, + participants: users, + primaryRenderer: primaryRenderer, + minorRenderers: minorRenderers, + isFrontCameraUsed: _isFrontCameraUsed, + isScreenSharingEnabled: !_enableScreenSharing, + participantsMediaConfigs: participantsMediaConfigs, + onRenderersChanged: _updateRenderers, + statsReportsManager: _statsReportsManager, + ); + } + + Widget _getActionsPanel() { + return CallControls( + isMicMuted: _isMicMute, + onMute: _muteMic, + isCameraButtonVisible: _enableScreenSharing, + isCameraEnabled: _isVideoEnabled(), + onToggleCamera: _toggleCamera, + isScreenSharingButtonVisible: _isLocalVideoPresented(), + isScreenSharingEnabled: !_enableScreenSharing, + onToggleScreenSharing: _toggleScreenSharing, + isSpeakerEnabled: _isSpeakerEnabled, + onSwitchSpeaker: _switchSpeaker, + onSwitchAudioInput: _switchAudioInput, + isSwitchCameraButtonVisible: + _isLocalVideoPresented() && _enableScreenSharing, + onSwitchCamera: _switchCamera, + onEndCall: _endCall, + ); + } + + _endCall() { + _callManager.stopCall(_currentUser); + _callSession.leave(); + _stopCallTimer(); + } + + Future _onBackPressed(BuildContext context) { + return Future.value(false); + } + + _muteMic() { + setState(() { + _isMicMute = !_isMicMute; + _callSession.setMicrophoneMute(_isMicMute); + _callManager.muteMic(_meetingId, _isMicMute); + notifyParticipantsMediaUpdated(); + }); + } + + _switchCamera() { + if (!_isVideoEnabled()) return; + + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { + _callSession.switchCamera().then((isFrontCameraUsed) { + setState(() { + _isFrontCameraUsed = isFrontCameraUsed; + }); + }); + } else { + showDialog( + context: context, + builder: (BuildContext context) { + return FutureBuilder>( + future: _callSession.getCameras(), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return AlertDialog( + content: const Text('No cameras found'), + actions: [ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Ok'), + onPressed: () { + Navigator.of(context).pop(); + }, + ) + ], + ); + } else { + return SimpleDialog( + title: const Text('Select camera'), + children: snapshot.data?.map( + (mediaDeviceInfo) { + return SimpleDialogOption( + onPressed: () { + Navigator.pop(context, mediaDeviceInfo.deviceId); + }, + child: Text(mediaDeviceInfo.label), + ); + }, + ).toList(), + ); + } + }, + ); + }, + ).then((deviceId) { + log("onCameraSelected deviceId: $deviceId", TAG); + if (deviceId != null) _callSession.switchCamera(deviceId: deviceId); + }); + } + } + + _toggleCamera() { + if (!_isVideoCall()) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + content: const Text( + 'Are you sure you want to start the sharing of your video?'), + actions: [ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('No'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Yes'), + onPressed: () { + _addVideoTrack(); + Navigator.of(context).pop(); + }, + ), + ], + ); + }); + } else { + setState(() { + _isCameraEnabled = !_isCameraEnabled; + _callSession.setVideoEnabled(_isCameraEnabled); + notifyParticipantsMediaUpdated(); + }); + } + } + + _addVideoTrack() { + navigator.mediaDevices + .getUserMedia({'video': getVideoConfig()}).then((newMediaStream) { + if (newMediaStream.getVideoTracks().isNotEmpty) { + _callSession + .addMediaTrack(newMediaStream.getVideoTracks().first) + .whenComplete(() { + log('The track added successfully', TAG); + setState(() { + _isCameraEnabled = true; + _callSession.callType = CallType.VIDEO_CALL; + notifyParticipantsMediaUpdated(); + }); + }); + } + }); + } + + _toggleScreenSharing() async { + var foregroundServiceFuture = _enableScreenSharing + ? startBackgroundExecution() + : stopBackgroundExecution(); + + var hasPermissions = await hasBackgroundExecutionPermissions(); + + if (!hasPermissions) { + await initForegroundService(); + } + + var desktopCapturerSource = _enableScreenSharing && isDesktop + ? await showDialog( + context: context, + builder: (context) => ScreenSelectDialog(), + ) + : null; + + foregroundServiceFuture.then((_) { + _callSession + .enableScreenSharing(_enableScreenSharing, + desktopCapturerSource: desktopCapturerSource, + useIOSBroadcasting: true, + requestAudioForScreenSharing: true) + .then((voidResult) { + setState(() { + _enableScreenSharing = !_enableScreenSharing; + _isFrontCameraUsed = _enableScreenSharing; + }); + }); + }); + } + + bool _isVideoEnabled() { + return _isVideoCall() && _isCameraEnabled; + } + + bool _isVideoCall() { + return CallType.VIDEO_CALL == _callSession.callType; + } + + _switchSpeaker() { + if (kIsWeb || WebRTC.platformIsDesktop) { + showDialog( + context: context, + builder: (BuildContext context) { + return FutureBuilder>( + future: _callSession.getAudioOutputs(), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return AlertDialog( + content: const Text('No Audio output devices found'), + actions: [ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Ok'), + onPressed: () { + Navigator.of(context).pop(); + }, + ) + ], + ); + } else { + return SimpleDialog( + title: const Text('Select Audio output device'), + children: snapshot.data?.map( + (mediaDeviceInfo) { + return SimpleDialogOption( + onPressed: () { + Navigator.pop(context, mediaDeviceInfo.deviceId); + }, + child: Text(mediaDeviceInfo.label), + ); + }, + ).toList(), + ); + } + }, + ); + }, + ).then((deviceId) { + log("onAudioOutputSelected deviceId: $deviceId", TAG); + if (deviceId != null) { + setState(() { + if (kIsWeb) { + primaryRenderer?.value.audioOutput(deviceId); + minorRenderers.forEach((userId, renderer) { + renderer.audioOutput(deviceId); + }); + } else { + _callSession.selectAudioOutput(deviceId); + } + }); + } + }); + } else { + setState(() { + _isSpeakerEnabled = !_isSpeakerEnabled; + _callSession.enableSpeakerphone(_isSpeakerEnabled); + }); + } + } + + _switchAudioInput() { + if (kIsWeb || WebRTC.platformIsDesktop) { + showDialog( + context: context, + builder: (BuildContext context) { + return FutureBuilder>( + future: _callSession.getAudioInputs(), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return AlertDialog( + content: const Text('No Audio input devices found'), + actions: [ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Ok'), + onPressed: () { + Navigator.of(context).pop(); + }, + ) + ], + ); + } else { + return SimpleDialog( + title: const Text('Select Audio input device'), + children: snapshot.data?.map( + (mediaDeviceInfo) { + return SimpleDialogOption( + onPressed: () { + Navigator.pop(context, mediaDeviceInfo.deviceId); + }, + child: Text(mediaDeviceInfo.label), + ); + }, + ).toList(), + ); + } + }, + ); + }, + ).then((deviceId) { + log("onAudioOutputSelected deviceId: $deviceId", TAG); + if (deviceId != null) { + setState(() { + _callSession.selectAudioInput(deviceId); + }); + } + }); + } + } + + bool _isVideoTracksPresent() { + var hasMinorVideo = false; + minorRenderers.forEach((key, value) { + if (canShowVideo(key, value.srcObject, participantsMediaConfigs)) { + hasMinorVideo = true; + } + }); + + return (primaryRenderer != null && + canShowVideo(primaryRenderer?.key, primaryRenderer?.value.srcObject, + participantsMediaConfigs)) || + hasMinorVideo; + } + + bool _isLocalVideoPresented() { + if (primaryRenderer?.key == currentUserId) { + return primaryRenderer?.value.srcObject?.getVideoTracks().isNotEmpty ?? + false; + } + + var isLocalVideoPresented = false; + + minorRenderers.forEach((userId, renderer) { + if (userId == currentUserId && + (renderer.srcObject?.getVideoTracks().isNotEmpty ?? false)) { + isLocalVideoPresented = true; + } + }); + + return isLocalVideoPresented; + } + + Widget _buildAudioCallLayout() { + return Align( + alignment: Alignment.topCenter, + child: Container( + margin: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 48), + child: CallInfo(_getCallName(), _callStatus, _callTimer)), + ); + } + + String _getCallName() { + if (_isIncoming) return _callName; + + if (_opponents.length > 1) return 'Group call'; + + var opponent = users.firstWhere( + (savedUser) => savedUser.id == _opponents.first, + orElse: () => CubeUser(fullName: 'Unknown user')); + + return opponent.fullName ?? 'Unknown user'; + } + + _startCallTimer() { + _callTimer.start(); + } + + _stopCallTimer() { + _callTimer.stop(); + } + + void _onCallMuted(String meetingId, bool isMuted) { + if (meetingId == _meetingId) { + setState(() { + _isMicMute = isMuted; + _callSession.setMicrophoneMute(isMuted); + }); + } + } + + Map _getMediaState() { + return { + PARAM_IS_MIC_ENABLED: !_isMicMute, + PARAM_IS_CAMERA_ENABLED: _isCameraEnabled + }; + } + + void _onParticipantMediaUpdated(int userId, Map mediaConfig) { + setState(() { + participantsMediaConfigs[userId] = mediaConfig; + }); + } + + void notifyParticipantsMediaUpdated() { + participantsMediaConfigs[currentUserId] = { + PARAM_IS_MIC_ENABLED: !_isMicMute, + PARAM_IS_CAMERA_ENABLED: _isCameraEnabled + }; + + _callManager.notifyParticipantsMediaUpdated({ + PARAM_IS_MIC_ENABLED: !_isMicMute, + PARAM_IS_CAMERA_ENABLED: _isCameraEnabled + }); + } + + void _playDialing() { + _ringtonePlayer.open(Audio("assets/audio/dialing.mp3"), + loopMode: LoopMode.single); + } + + void _stopDialing() { + _ringtonePlayer.stop(); + } + + void _playStoppingCall() { + _ringtonePlayer.open(Audio("assets/audio/end_call.mp3"), + loopMode: LoopMode.none); + } +} + +enum LayoutMode { speaker, grid, private } diff --git a/conf_call_sample/lib/src/screens/incoming_call_screen.dart b/conf_call_sample/lib/src/screens/incoming_call_screen.dart new file mode 100644 index 0000000..7e6a4aa --- /dev/null +++ b/conf_call_sample/lib/src/screens/incoming_call_screen.dart @@ -0,0 +1,500 @@ +import 'package:assets_audio_player/assets_audio_player.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_io/io.dart'; + +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +import '../managers/call_manager.dart'; + +class IncomingCallScreen extends StatefulWidget { + static const String TAG = "IncomingCallScreen"; + final CubeUser _currentUser; + final String _callId; + final String _meetingId; + final int _initiatorId; + final List _participantIds; + final int _callType; + final String _callName; + + IncomingCallScreen(this._currentUser, this._callId, this._meetingId, + this._initiatorId, this._participantIds, this._callType, this._callName); + + @override + State createState() { + return _IncomingCallScreenState(_currentUser, _callId, _meetingId, + _initiatorId, _participantIds, _callType, _callName); + } +} + +class _IncomingCallScreenState extends State { + static const String TAG = "_IncomingCallScreenState"; + final CallManager _callManager = CallManager.instance; + final CubeUser _currentUser; + final String _callId; + final String _meetingId; + final int _initiatorId; + final List _participantIds; + final int _callType; + final String _callName; + bool _isFrontCameraSelected = true; + bool _isMicMute = false; + MediaStream? _localMediaStream; + RTCVideoRenderer? _localVideoRenderer; + AssetsAudioPlayer _ringtonePlayer = AssetsAudioPlayer.newPlayer(); + + _IncomingCallScreenState(this._currentUser, this._callId, this._meetingId, + this._initiatorId, this._participantIds, this._callType, this._callName); + + @override + void initState() { + super.initState(); + log('[initState]', TAG); + + _callManager.onCloseCall = _onCallClosed; + _callManager.onCallAccepted = _onCallAccepted; + _callManager.onCallRejected = _onCallRejected; + } + + @override + Widget build(BuildContext context) { + log('[build]', TAG); + if (_callManager.currentCallState != InternalCallState.NEW) { + closeScreen(); + return SizedBox.shrink(); + } + + _playRingtone(); + + return WillPopScope( + onWillPop: () => _onBackPressed(context), + child: Scaffold( + backgroundColor: Colors.green.shade100, + body: FutureBuilder( + future: _getLocalMediaStream(), + builder: (context, snapshot) { + return Stack( + children: [ + if (snapshot.hasData) + FutureBuilder( + future: getVideoRenderer(snapshot.data), + builder: (BuildContext context, + AsyncSnapshot snapshot2) { + if (!snapshot2.hasData) { + return SizedBox.shrink(); + } + + return RTCVideoView( + snapshot2.data, + objectFit: + RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + mirror: _isFrontCameraSelected, + ); + }, + ), + Container( + margin: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 80), + child: Align( + alignment: Alignment.topCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.all(8), + child: Text(_callName, + style: TextStyle( + color: Colors.white, + fontSize: 24, + shadows: [ + Shadow( + color: Colors.grey.shade900, + offset: Offset(2, 1), + blurRadius: 12, + ), + ], + )), + ), + Padding( + padding: EdgeInsets.all(8), + child: Text(_getCallTitle(), + style: TextStyle( + fontSize: 18, + color: Colors.white, + shadows: [ + Shadow( + color: Colors.grey.shade900, + offset: Offset(2, 1), + blurRadius: 12, + ), + ], + )), + ), + Expanded( + child: SizedBox(), + flex: 1, + ), + Visibility( + visible: _callType == CallType.VIDEO_CALL, + child: Padding( + padding: EdgeInsets.only( + bottom: + MediaQuery.of(context).padding.bottom + + 120), + child: Row( + // crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: + EdgeInsets.symmetric(horizontal: 16), + child: FloatingActionButton( + elevation: 0, + heroTag: "ToggleCamera", + child: Icon( + isVideoEnabledInStream( + _localMediaStream) + ? Icons.videocam + : Icons.videocam_off, + color: isVideoEnabledInStream( + _localMediaStream) + ? Colors.grey + : Colors.white, + ), + onPressed: () => _toggleCamera(), + backgroundColor: isVideoEnabledInStream( + _localMediaStream) + ? Colors.white + : Colors.grey, + ), + ), + Padding( + padding: + EdgeInsets.symmetric(horizontal: 16), + child: FloatingActionButton( + elevation: 0, + heroTag: "Mute", + child: Icon( + _isMicMute ? Icons.mic_off : Icons.mic, + color: _isMicMute + ? Colors.white + : Colors.grey, + ), + onPressed: () => _muteMic(), + backgroundColor: _isMicMute + ? Colors.grey + : Colors.white, + ), + ), + Padding( + padding: + EdgeInsets.symmetric(horizontal: 16), + child: FloatingActionButton( + elevation: 0, + heroTag: "SwitchCamera", + child: Icon( + Icons.cameraswitch, + color: isVideoEnabledInStream( + _localMediaStream) + ? Colors.white + : Colors.grey, + ), + onPressed: () => _switchCamera(), + backgroundColor: Colors.black38, + ), + ), + ], + ), + ), + ), + Padding( + padding: EdgeInsets.only( + bottom: + MediaQuery.of(context).padding.bottom + 80), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.only(right: 36), + child: FloatingActionButton( + heroTag: "RejectCall", + child: Icon( + Icons.call_end, + color: Colors.white, + ), + backgroundColor: Colors.red, + onPressed: () => _rejectCall(), + ), + ), + Padding( + padding: EdgeInsets.only(left: 36), + child: FloatingActionButton( + heroTag: "AcceptCall", + child: Icon( + _callType == CallType.VIDEO_CALL + ? Icons.videocam + : Icons.call, + color: Colors.white, + ), + backgroundColor: Colors.green, + onPressed: () => _acceptCall(_callType), + ), + ), + ], + ), + ) + ], + ), + ), + ), + ], + ); + }, + )), + ); + } + + _getCallTitle() { + log('[_getCallTitle]', TAG); + String callType = _callType == CallType.VIDEO_CALL ? "Video" : 'Audio'; + return "Incoming $callType call"; + } + + void _acceptCall(int callType) async { + log('[_acceptCall]', TAG); + _callManager.startNewIncomingCall(context, _currentUser, _callId, + _meetingId, callType, _callName, _initiatorId, _participantIds, false, + initialLocalMediaStream: _localMediaStream, + isFrontCameraUsed: _isFrontCameraSelected); + } + + void _rejectCall() { + log('[_rejectCall]', TAG); + _localMediaStream?.getTracks().forEach((track) async { + await track.stop(); + }); + _localMediaStream?.dispose(); + + _callManager.reject(_callId, _meetingId, false, _initiatorId, false); + closeScreen(); + } + + Future _onBackPressed(BuildContext context) { + return Future.value(false); + } + + @override + void dispose() { + log('[dispose]', TAG); + _localVideoRenderer?.srcObject = null; + _localVideoRenderer?.dispose(); + + _callManager.onCloseCall = null; + _callManager.onCallAccepted = null; + _callManager.onCallRejected = null; + + _stopRingtone(); + + super.dispose(); + } + + void _onCallClosed() { + log('[_onCallClosed]', TAG); + _localMediaStream?.getTracks().forEach((track) async { + await track.stop(); + }); + _localMediaStream?.dispose(); + + closeScreen(); + } + + void _onCallAccepted(String meetingId) { + log('[_onCallAccepted]', TAG); + } + + void _onCallRejected(String meetingId) { + log('[_onCallRejected]', TAG); + if (meetingId == _meetingId) { + _localMediaStream?.getTracks().forEach((track) async { + await track.stop(); + }); + _localMediaStream?.dispose(); + + closeScreen(); + } + } + + Future _getLocalMediaStream() { + log('[_getLocalMediaStream]', TAG); + if (_callType == CallType.AUDIO_CALL) return Future.value(null); + if (_localMediaStream != null) return Future.value(_localMediaStream); + + return navigator.mediaDevices + .getUserMedia(getMediaConstraints()) + .then((localMediaStream) { + _localMediaStream = localMediaStream; + + return localMediaStream; + }); + } + + Map getMediaConstraints() { + log('[getMediaConstraints]', TAG); + final Map mediaConstraints = { + 'audio': getAudioConfig(), + }; + + if (CallType.VIDEO_CALL == _callType) { + mediaConstraints['video'] = getVideoConfig(); + } + + return mediaConstraints; + } + + Future getVideoRenderer(MediaStream? mediaStream) { + log('[getVideoRenderer]', TAG); + if (_localVideoRenderer != null) return Future.value(_localVideoRenderer); + + var videoRenderer = RTCVideoRenderer(); + + return videoRenderer.initialize().then((value) { + videoRenderer.srcObject = mediaStream; + _localVideoRenderer = videoRenderer; + return videoRenderer; + }); + } + + _muteMic() { + if (_localMediaStream == null) return; + + setState(() { + _isMicMute = !_isMicMute; + + var audioTrack = _localMediaStream?.getAudioTracks().firstOrNull; + + if (audioTrack != null) { + Helper.setMicrophoneMute(_isMicMute, audioTrack); + } + }); + } + + _toggleCamera() { + if (_localMediaStream == null) return; + + setState(() { + _localMediaStream?.getVideoTracks().firstOrNull?.enabled = + !isVideoEnabledInStream(_localMediaStream); + }); + } + + _switchCamera() { + if (_localMediaStream == null) return; + + if (!isVideoEnabledInStream(_localMediaStream)) return; + + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { + switchCamera().then((isFrontCameraUsed) { + setState(() { + _isFrontCameraSelected = isFrontCameraUsed; + }); + }); + } else { + showDialog( + context: context, + builder: (BuildContext context) { + return FutureBuilder>( + future: Helper.cameras, + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return AlertDialog( + content: const Text('No cameras found'), + actions: [ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Ok'), + onPressed: () { + Navigator.of(context).pop(); + }, + ) + ], + ); + } else { + return SimpleDialog( + title: const Text('Select camera'), + children: snapshot.data?.map( + (mediaDeviceInfo) { + return SimpleDialogOption( + onPressed: () { + Navigator.pop(context, mediaDeviceInfo.deviceId); + }, + child: Text(mediaDeviceInfo.label), + ); + }, + ).toList(), + ); + } + }, + ); + }, + ).then((deviceId) { + log("onCameraSelected deviceId: $deviceId", TAG); + if (deviceId != null) switchCamera(deviceId: deviceId); + }); + } + } + + bool isVideoEnabledInStream(MediaStream? mediaStream) { + if (mediaStream == null) return false; + + if (mediaStream.getVideoTracks().isEmpty) return false; + + return mediaStream.getVideoTracks().first.enabled; + } + + Future switchCamera({String? deviceId}) async { + try { + if (_localMediaStream == null) { + return Future.error(IllegalStateException( + "Can't perform operation [switchCamera], cause 'localStream' not initialised")); + } else { + if (deviceId != null) { + var newMediaStream = await navigator.mediaDevices.getUserMedia({ + 'audio': false, + 'video': kIsWeb + ? {'deviceId': deviceId} + : getVideoConfig(deviceId: deviceId), + }); + + var oldVideoTrack = _localMediaStream!.getVideoTracks()[0]; + + await _localMediaStream?.removeTrack(oldVideoTrack); + oldVideoTrack.stop(); + + await _localMediaStream?.addTrack(newMediaStream.getVideoTracks()[0]); + + return Future.value(true); + } else { + final videoTrack = _localMediaStream!.getVideoTracks()[0]; + return Helper.switchCamera(videoTrack, null, _localMediaStream); + } + } + } catch (error) { + return Future.error(error); + } + } + + closeScreen() { + Navigator.of(context).pop(); + } + + void _playRingtone() { + _ringtonePlayer.open(Audio("assets/audio/calling.mp3"), + loopMode: LoopMode.single); + } + + void _stopRingtone() { + _ringtonePlayer.stop(); + } +} diff --git a/conf_call_sample/lib/src/login_screen.dart b/conf_call_sample/lib/src/screens/login_screen.dart similarity index 67% rename from conf_call_sample/lib/src/login_screen.dart rename to conf_call_sample/lib/src/screens/login_screen.dart index 3d2fce7..77c383e 100644 --- a/conf_call_sample/lib/src/login_screen.dart +++ b/conf_call_sample/lib/src/screens/login_screen.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:connectycube_sdk/connectycube_sdk.dart'; +import '../managers/call_manager.dart'; +import '../utils/configs.dart' as utils; +import '../utils/pref_util.dart'; import 'select_opponents_screen.dart'; -import 'utils/configs.dart' as utils; class LoginScreen extends StatelessWidget { static const String TAG = "LoginScreen"; @@ -31,8 +33,28 @@ class BodyState extends State { bool _isLoginContinues = false; int? _selectedUserId; + @override + void initState() { + super.initState(); + log("initState", TAG); + + _loginWithSavedUserIfExist(); + + CallManager.startCallIfNeed(context); + } + + void _loginWithSavedUserIfExist() { + SharedPrefs.getUser().then((savedUser) { + if (savedUser != null) { + _loginToCC(context, savedUser, savedUser: true); + } + }); + } + @override Widget build(BuildContext context) { + log("build", TAG); + return Padding( padding: EdgeInsets.all(48), child: Column( @@ -53,6 +75,7 @@ class BodyState extends State { } Widget _getUsersList(BuildContext context) { + log("[_getUsersList]", TAG); final users = utils.users; return ListView.builder( @@ -97,9 +120,18 @@ class BodyState extends State { ); } - _loginToCC(BuildContext context, CubeUser user) { + _loginToCC(BuildContext context, CubeUser user, {bool savedUser = false}) { + log('[_loginToCC]', TAG); if (_isLoginContinues) return; + if (CubeSessionManager.instance.isActiveSessionValid() && + CubeChatConnection.instance.chatConnectionState == + CubeChatConnectionState.Ready && + CubeChatConnection.instance.currentUser?.id == user.id) { + _goSelectOpponentsScreen(context, user); + return; + } + setState(() { _isLoginContinues = true; _selectedUserId = user.id; @@ -111,6 +143,10 @@ class BodyState extends State { _loginToCubeChat(context, user); } else { createSession(user).then((cubeSession) { + if (!savedUser) { + SharedPrefs.saveNewUser(user); + CallManager.instance.init(context); + } _loginToCubeChat(context, user); }).catchError((onError) { _processLoginError(onError); @@ -119,12 +155,15 @@ class BodyState extends State { } void _loginToCubeChat(BuildContext context, CubeUser user) { + log('[_loginToCubeChat]', TAG); CubeChatConnection.instance.login(user).then((cubeUser) { - setState(() { - _isLoginContinues = false; - _selectedUserId = 0; - }); - _goSelectOpponentsScreen(context, cubeUser); + if (mounted) { + setState(() { + _isLoginContinues = false; + _selectedUserId = 0; + }); + _goSelectOpponentsScreen(context, cubeUser); + } }).catchError((onError) { _processLoginError(onError); }); @@ -132,6 +171,7 @@ class BodyState extends State { void _processLoginError(exception) { log("Login error $exception", TAG); + if (!mounted) return; setState(() { _isLoginContinues = false; @@ -155,11 +195,37 @@ class BodyState extends State { } void _goSelectOpponentsScreen(BuildContext context, CubeUser cubeUser) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SelectOpponentsScreen(cubeUser), - ), - ); + if (!CallManager.instance.hasActiveCall()) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => SelectOpponentsScreen(cubeUser), + ), + ); + } + } + + @override + void dispose() { + log("[dispose]", TAG); + + super.dispose(); + } + + @override + void deactivate() { + super.deactivate(); + log("[deactivate]", TAG); + } + + @override + void activate() { + super.activate(); + log("[activate]", TAG); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + log("[didChangeDependencies]", TAG); } } diff --git a/conf_call_sample/lib/src/select_opponents_screen.dart b/conf_call_sample/lib/src/screens/select_opponents_screen.dart similarity index 64% rename from conf_call_sample/lib/src/select_opponents_screen.dart rename to conf_call_sample/lib/src/screens/select_opponents_screen.dart index ca66c94..e7c0502 100644 --- a/conf_call_sample/lib/src/select_opponents_screen.dart +++ b/conf_call_sample/lib/src/screens/select_opponents_screen.dart @@ -2,10 +2,13 @@ import 'package:flutter/material.dart'; import 'package:connectycube_sdk/connectycube_sdk.dart'; -import 'call_screen.dart'; -import 'utils/configs.dart' as utils; -import 'utils/call_manager.dart'; -import 'utils/platform_utils.dart'; +import '../managers/call_manager.dart'; +import '../utils/configs.dart' as utils; +import '../utils/platform_utils.dart'; +import '../utils/pref_util.dart'; +import 'conversation_call_screen.dart'; +import 'incoming_call_screen.dart'; +import 'login_screen.dart'; class SelectOpponentsScreen extends StatelessWidget { final CubeUser currentUser; @@ -38,7 +41,7 @@ class SelectOpponentsScreen extends StatelessWidget { } Future _onBackPressed() { - return Future.value(false); + return Future.value(true); } _logOut(BuildContext context) { @@ -61,6 +64,7 @@ class SelectOpponentsScreen extends StatelessWidget { signOut().then( (voidValue) { CubeChatConnection.instance.destroy(); + SharedPrefs.deleteUserData(); Navigator.pop(context); // cancel current Dialog _navigateToLoginScreen(context); }, @@ -79,7 +83,11 @@ class SelectOpponentsScreen extends StatelessWidget { } _navigateToLoginScreen(BuildContext context) { - Navigator.pop(context); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => LoginScreen(), + ), + ); } } @@ -95,16 +103,16 @@ class BodyLayout extends StatefulWidget { } class _BodyLayoutState extends State { + static final String TAG = 'SelectOpponentsScreen'; + Set _selectedUsers = {}; final CubeUser _currentUser; - late CallManager _callManager; - late ConferenceClient _callClient; - ConferenceSession? _currentCall; _BodyLayoutState(this._currentUser); @override Widget build(BuildContext context) { + log('[build]', TAG); return Container( padding: EdgeInsets.all(48), child: Column( @@ -113,15 +121,10 @@ class _BodyLayoutState extends State { "Select users to start call:", style: TextStyle(fontSize: 22), ), - Expanded( - child: _getOpponentsList(), - ), + _getOpponentsList(), Row( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 24, - ), FloatingActionButton( heroTag: "VideoCall", child: Icon( @@ -129,7 +132,21 @@ class _BodyLayoutState extends State { color: Colors.white, ), backgroundColor: Colors.blue, - onPressed: () => _startCall(_selectedUsers), + onPressed: () => + _startCall(_selectedUsers, CallType.VIDEO_CALL), + ), + Container( + width: 32, + ), + FloatingActionButton( + heroTag: "AudioCall", + child: Icon( + Icons.call, + color: Colors.white, + ), + backgroundColor: Colors.green, + onPressed: () => + _startCall(_selectedUsers, CallType.AUDIO_CALL), ), ], ), @@ -138,12 +155,15 @@ class _BodyLayoutState extends State { } Widget _getOpponentsList() { + log('[_getOpponentsList]', TAG); CubeUser? currentUser = _currentUser; final users = utils.users.where((user) => user.id != currentUser.id).toList(); return ListView.builder( + shrinkWrap: true, itemCount: users.length, itemBuilder: (context, index) { + log('[itemBuilder] index $index', TAG); return Card( child: CheckboxListTile( title: Center( @@ -153,6 +173,7 @@ class _BodyLayoutState extends State { ), value: _selectedUsers.contains(users[index].id), onChanged: ((checked) { + log('[CheckboxListTile][onChanged]', TAG); setState(() { if (checked!) { _selectedUsers.add(users[index].id!); @@ -170,30 +191,28 @@ class _BodyLayoutState extends State { @override void initState() { super.initState(); + log('[initState]', TAG); initForegroundService(); + checkSystemAlertWindowPermission(context); + requestNotificationsPermission(); + CallManager.instance.context = context; + requestFullScreenIntentsPermission(context); - CubeSettings.instance.onSessionRestore = () { - return createSession(_currentUser); - }; - - _initConferenceConfig(); _initCalls(); } void _initCalls() { - _callClient = ConferenceClient.instance; - _callManager = CallManager.instance; - _callManager.onReceiveNewCall = (meetingId, participantIds) { - _showIncomingCallScreen(meetingId, participantIds); - }; - - _callManager.onCloseCall = () { - _currentCall = null; + log('[_initCalls]', TAG); + CallManager.instance.onReceiveNewCall = + (callId, meetingId, initiatorId, participantIds, callType, callName) { + _showIncomingCallScreen( + callId, meetingId, initiatorId, participantIds, callType, callName); }; } - void _startCall(Set opponents) async { + void _startCall(Set opponents, int callType) async { + log('[_startCall] call type $callType', TAG); if (opponents.isEmpty) return; var attendees = opponents.map((entry) { @@ -210,31 +229,34 @@ class _BodyLayoutState extends State { attendees: attendees, ); createMeeting(meeting).then((createdMeeting) async { - _currentCall = await _callClient.createCallSession( + var callSession = await ConferenceClient.instance.createCallSession( createdMeeting.hostId!, - callType: CallType.VIDEO_CALL, + callType: callType, ); - Navigator.push( - context, + Navigator.of(context).push( MaterialPageRoute( - builder: (context) => ConversationCallScreen(_currentCall!, - createdMeeting.meetingId!, opponents.toList(), false), + builder: (context) => ConversationCallScreen( + _currentUser, + callSession, + createdMeeting.meetingId!, + opponents.toList(), + false, + '${_currentUser.fullName ?? 'Unknown User'}${opponents.length > 1 ? ' (in Group call)' : ''}'), ), ); }); } - void _showIncomingCallScreen(String meetingId, List participantIds) { - Navigator.push( - context, + void _showIncomingCallScreen(String callId, String meetingId, int initiatorId, + List participantIds, int callType, String callName) { + log('[_showIncomingCallScreen]', TAG); + + Navigator.of(context).push( MaterialPageRoute( - builder: (context) => IncomingCallScreen(meetingId, participantIds), + builder: (context) => IncomingCallScreen(_currentUser, callId, + meetingId, initiatorId, participantIds, callType, callName), ), ); } - - void _initConferenceConfig() { - ConferenceConfig.instance.url = utils.SERVER_ENDPOINT; - } } diff --git a/conf_call_sample/lib/src/utils/call_manager.dart b/conf_call_sample/lib/src/utils/call_manager.dart deleted file mode 100644 index e2955b1..0000000 --- a/conf_call_sample/lib/src/utils/call_manager.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'dart:async'; - -import 'package:connectycube_sdk/connectycube_sdk.dart'; - -const NO_ANSWER_TIMER_INTERVAL = 30; - -class CallManager { - SystemMessagesManager? _systemMessagesManager; - NewCallCallback? onReceiveNewCall; - CloseCall? onCloseCall; - RejectCallCallback? onReceiveRejectCall; - UserNotAnswerCallback? onUserNotAnswerCallback; - String? _meetingId; - List? _participantIds; - int? _initiatorId; - - var _answerUserTimers = Map(); - - CallManager._privateConstructor() { - _systemMessagesManager = CubeChatConnection.instance.systemMessagesManager; - _systemMessagesManager!.systemMessagesStream - .listen((cubeMessage) => parseCallMessage(cubeMessage)); - - RTCConfig.instance.statsReportsInterval = 200; - } - - static final CallManager _instance = CallManager._privateConstructor(); - - static CallManager get instance => _instance; - - parseCallMessage(CubeMessage cubeMessage) { - log("parseCallMessage cubeMessage= $cubeMessage"); - final properties = cubeMessage.properties; - var meetingId = properties["meetingId"]; - - if (properties.containsKey("callStart")) { - var participantIds = properties["participantIds"]! - .split(',') - .map((id) => int.parse(id)) - .toList(); - if (_meetingId == null) { - _meetingId = meetingId; - _initiatorId = cubeMessage.senderId; - _participantIds = participantIds; - if (onReceiveNewCall != null) { - onReceiveNewCall!(meetingId!, participantIds); - } - } - } else if (properties.containsKey("callAccepted")) { - if (_meetingId == meetingId) { - _clearNoAnswerTimers(id: cubeMessage.senderId!); - } - } else if (properties.containsKey("callRejected")) { - bool isBusy = properties["busy"] == 'true'; - if (_meetingId == meetingId) { - if (onReceiveRejectCall != null) { - onReceiveRejectCall!(meetingId!, cubeMessage.senderId!, isBusy); - } - - handleRejectCall(cubeMessage.senderId!, isBusy); - } - } else if (properties.containsKey("callEnd")) { - if (_meetingId == meetingId) { - _clearCall(cubeMessage.senderId!); - } - } - } - - startCall(String meetingId, List participantIds, int currentUserId) { - _initiatorId = currentUserId; - _participantIds = participantIds; - _meetingId = meetingId; - sendCallMessage(meetingId, participantIds); - startNoAnswerTimers(participantIds); - } - - acceptCall(String meetingId, int participantId) { - sendAcceptMessage(meetingId, participantId); - } - - reject(String meetingId, bool isBusy) { - sendRejectMessage(meetingId, isBusy, _initiatorId!); - _clearProperties(); - } - - stopCall() { - _clearNoAnswerTimers(); - sendEndCallMessage(_meetingId!, _participantIds!); - _clearProperties(); - } - - sendCallMessage(String meetingId, List participantIds) { - List callMsgList = - _buildCallMessages(meetingId, participantIds); - callMsgList.forEach((callMsg) { - callMsg.properties['callStart'] = '1'; - callMsg.properties['participantIds'] = participantIds.join(','); - }); - callMsgList - .forEach((msg) => _systemMessagesManager!.sendSystemMessage(msg)); - } - - sendAcceptMessage(String meetingId, int participantId) { - List callMsgList = - _buildCallMessages(meetingId, [participantId]); - callMsgList.forEach((callMsg) { - callMsg.properties['callAccepted'] = '1'; - }); - callMsgList - .forEach((msg) => _systemMessagesManager!.sendSystemMessage(msg)); - } - - sendRejectMessage(String meetingId, bool isBusy, int participantId) { - List callMsgList = - _buildCallMessages(meetingId, [participantId]); - callMsgList.forEach((callMsg) { - callMsg.properties['callRejected'] = '1'; - callMsg.properties['busy'] = isBusy.toString(); - }); - callMsgList - .forEach((msg) => _systemMessagesManager!.sendSystemMessage(msg)); - } - - sendEndCallMessage(String meetingId, List participantIds) { - List callMsgList = - _buildCallMessages(meetingId, participantIds); - callMsgList.forEach((callMsg) { - callMsg.properties['callEnd'] = '1'; - }); - callMsgList - .forEach((msg) => _systemMessagesManager!.sendSystemMessage(msg)); - } - - List _buildCallMessages( - String meetingId, List participantIds) { - return participantIds.map((userId) { - var msg = CubeMessage(); - msg.recipientId = userId; - msg.properties = {'meetingId': meetingId}; - return msg; - }).toList(); - } - - handleAcceptCall(int participantId) { - _clearNoAnswerTimers(id: participantId); - } - - handleRejectCall(int participantId, isBusy) { - _clearNoAnswerTimers(id: participantId); - _clearCall(participantId); - } - - startNoAnswerTimers(participantIds) { - participantIds.forEach((userId) => { - _answerUserTimers[userId] = Timer( - Duration(seconds: NO_ANSWER_TIMER_INTERVAL), - () => noUserAnswer(userId)) - }); - } - - noUserAnswer(int participantId) { - if (onUserNotAnswerCallback != null) - onUserNotAnswerCallback!(participantId); - _clearNoAnswerTimers(id: participantId); - sendEndCallMessage(_meetingId!, [participantId]); - _clearCall(participantId); - } - - _clearNoAnswerTimers({int id = 0}) { - if (id != 0) { - _answerUserTimers[id]!.cancel(); - _answerUserTimers.remove(id); - } else { - _answerUserTimers.forEach((participantId, timer) => timer.cancel()); - _answerUserTimers.clear(); - } - } - - _clearProperties() { - _meetingId = null; - _initiatorId = null; - _participantIds = null; - } - - _clearCall(int participantId) { - _participantIds!.remove(participantId); - if (_participantIds!.isEmpty || participantId == _initiatorId) { - _clearProperties(); - if (onCloseCall != null) onCloseCall!(); - } - } -} - -typedef void NewCallCallback(String meetingId, List participantIds); -typedef void CloseCall(); -typedef void RejectCallCallback( - String meetingId, int participantId, bool isBusy); -typedef void UserNotAnswerCallback(int participantId); diff --git a/conf_call_sample/lib/src/utils/configs.dart b/conf_call_sample/lib/src/utils/configs.dart index 993a383..554fac8 100644 --- a/conf_call_sample/lib/src/utils/configs.dart +++ b/conf_call_sample/lib/src/utils/configs.dart @@ -1,11 +1,12 @@ import 'package:connectycube_sdk/connectycube_sdk.dart'; -const String APP_ID = "476"; -const String AUTH_KEY = "PDZjPBzAO8WPfCp"; -const String AUTH_SECRET = "6247kjxXCLRaua6"; -const String ACCOUNT_ID = "TpuBZox_HPxofh7PVZdP"; -const String DEFAULT_PASS = "xxasBUM3gQs36bhj"; -const String SERVER_ENDPOINT = "wss://janus.connectycube.com:8989"; +const String APP_ID = '476'; +const String AUTH_KEY = 'PDZjPBzAO8WPfCp'; +const String AUTH_SECRET = '6247kjxXCLRaua6'; +const String DEFAULT_PASS = 'xxasBUM3gQs36bhj'; +const String API_ENDPOINT = 'https://api.connectycube.com'; +const String CHAT_ENDPOINT = 'chat.connectycube.com'; +const String CONF_SERVER_ENDPOINT = 'wss://janus.connectycube.com:8989'; List users = [ CubeUser( diff --git a/conf_call_sample/lib/src/utils/consts.dart b/conf_call_sample/lib/src/utils/consts.dart new file mode 100644 index 0000000..dd75268 --- /dev/null +++ b/conf_call_sample/lib/src/utils/consts.dart @@ -0,0 +1,24 @@ +final String PARAM_SESSION_ID = 'session_id'; +final String PARAM_CALL_TYPE = 'call_type'; +final String PARAM_CALLER_ID = 'caller_id'; +final String PARAM_CALLER_NAME = 'caller_name'; +final String PARAM_CALL_OPPONENTS = 'call_opponents'; +final String PARAM_IOS_VOIP = 'ios_voip'; +final String PARAM_SIGNAL_TYPE = 'signal_type'; +final String PARAM_EXPIRATION = 'expiration'; +final String PARAM_USER_INFO = 'user_info'; +final String PARAM_MEETING_ID = 'meeting_id'; +final String PARAM_MESSAGE = 'message'; +final String PARAM_BUSY = 'busy'; +final String PARAM_PHOTO_URL = 'photo_url'; + +final String SIGNAL_TYPE_START_CALL = 'startCall'; +final String SIGNAL_TYPE_END_CALL = 'endCall'; +final String SIGNAL_TYPE_ACCEPT_CALL = 'acceptCall'; +final String SIGNAL_TYPE_REJECT_CALL = 'rejectCall'; +final String SIGNAL_TYPE_REQUEST_MEDIA_STATE = 'requestMediaState'; +final String SIGNAL_TYPE_UPDATE_MEDIA_STATE = 'updateMediaState'; + +final String PARAM_MEDIA_CONFIG = 'media_config'; +final String PARAM_IS_MIC_ENABLED = 'is_mic_enabled'; +final String PARAM_IS_CAMERA_ENABLED = 'is_camera_enabled'; diff --git a/conf_call_sample/lib/src/utils/duration_timer.dart b/conf_call_sample/lib/src/utils/duration_timer.dart new file mode 100644 index 0000000..3f02073 --- /dev/null +++ b/conf_call_sample/lib/src/utils/duration_timer.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +class DurationTimer { + int _durationSec = 0; + + Timer? _durationTimer; + StreamController _durationStreamController = + StreamController.broadcast(); + + Stream get durationStream => _durationStreamController.stream; + + start() { + if (_durationTimer == null) { + _durationTimer = Timer.periodic(Duration(seconds: 1), (timer) async { + _durationSec++; + _durationStreamController.add(_durationSec); + }); + } + } + + stop() { + _durationTimer?.cancel(); + _durationTimer = null; + _durationSec = 0; + } +} diff --git a/conf_call_sample/lib/src/utils/media_utils.dart b/conf_call_sample/lib/src/utils/media_utils.dart new file mode 100644 index 0000000..9e437d8 --- /dev/null +++ b/conf_call_sample/lib/src/utils/media_utils.dart @@ -0,0 +1,75 @@ +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +import 'consts.dart'; + +bool canShowVideo( + int? userId, MediaStream? mediaStream, Map> config) { + if (userId == null || mediaStream == null) return false; + + if (mediaStream.getVideoTracks().isEmpty) return false; + + var hasEnabledVideo = false; + + mediaStream.getVideoTracks().forEach((videoTrack) { + if (!hasEnabledVideo && videoTrack.enabled) { + hasEnabledVideo = true; + } + }); + + return hasEnabledVideo && isUserCameraEnabled(userId, config); +} + +bool isUserCameraEnabled(int userId, Map> config, + {bool defaultValue = false}) { + return config[userId]?[PARAM_IS_CAMERA_ENABLED] ?? defaultValue; +} + +int? getUserWithEnabledVideo(Map renderers, + int currentUserId, Map> config) { + var resultUserId = -1; + + renderers.forEach((userId, renderer) { + if ((resultUserId == -1 || resultUserId == currentUserId) && + canShowVideo(userId, renderer.srcObject, config)) { + resultUserId = userId; + } + }); + + return resultUserId == -1 ? null : resultUserId; +} + +void chooseOpponentsStreamsQuality(ConferenceSession callSession, + int currentUserId, Map config) { + config.remove(currentUserId); + + if (config.isEmpty) return; + + callSession.requestPreferredStreamsForOpponents(config); +} + +void updatePrimaryUser( + int userId, + bool force, + int currentUserId, + MapEntry? primaryRenderer, + Map minorRenderers, + Map> participantsMediaConfigs, { + required Function(MapEntry? primaryRenderer, + Map minorRenderers)? + onRenderersUpdated, +}) { + if (!minorRenderers.containsKey(userId) || + userId == primaryRenderer?.key || + (userId == currentUserId && !force) || + getUserWithEnabledVideo( + minorRenderers, currentUserId, participantsMediaConfigs) == + null) return; + + if (primaryRenderer?.key != userId) { + minorRenderers.addEntries([primaryRenderer!]); + } + + primaryRenderer = MapEntry(userId, minorRenderers.remove(userId)!); + + onRenderersUpdated?.call(primaryRenderer, minorRenderers); +} diff --git a/conf_call_sample/lib/src/utils/platform_utils.dart b/conf_call_sample/lib/src/utils/platform_utils.dart index 4beb8cd..c1e776a 100644 --- a/conf_call_sample/lib/src/utils/platform_utils.dart +++ b/conf_call_sample/lib/src/utils/platform_utils.dart @@ -1,11 +1,18 @@ +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_background/flutter_background.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:universal_io/io.dart'; +import 'package:connectycube_flutter_call_kit/connectycube_flutter_call_kit.dart'; +import 'package:connectycube_sdk/connectycube_calls.dart'; + Future initForegroundService() async { if (Platform.isAndroid) { final androidConfig = FlutterBackgroundAndroidConfig( notificationTitle: 'Conference Calls sample', - notificationText: 'Screen sharing in in progress', + notificationText: 'Screen sharing is in progress', notificationImportance: AndroidNotificationImportance.Default, notificationIcon: AndroidResource(name: 'ic_launcher_foreground', defType: 'drawable'), @@ -41,3 +48,105 @@ Future hasBackgroundExecutionPermissions() async { return Future.value(true); } } + +Future checkSystemAlertWindowPermission(BuildContext context) async { + if (Platform.isAndroid) { + var androidInfo = await DeviceInfoPlugin().androidInfo; + var sdkInt = androidInfo.version.sdkInt; + + if (sdkInt >= 31) { + if (await Permission.systemAlertWindow.isDenied) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('Permission required'), + content: Text( + 'For accepting the calls in the background you should provide access to show System Alerts from the background. Would you like to do it now?'), + actions: [ + TextButton( + onPressed: () { + Permission.systemAlertWindow.request().then((status) { + if (status.isGranted) { + Navigator.of(context).pop(); + } + }); + }, + child: Text( + 'Allow', + ), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + 'Later', + ), + ), + ], + ); + }, + ); + } + } + } +} + +requestNotificationsPermission() async { + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isWindows)) { + var isPermissionGranted = await Permission.notification.isGranted; + if (!isPermissionGranted) { + await Permission.notification.request(); + } + } +} + +requestFullScreenIntentsPermission(BuildContext context) async { + if (!Platform.isAndroid) return; + + var androidInfo = await DeviceInfoPlugin().androidInfo; + var sdkInt = androidInfo.version.sdkInt; + + if (sdkInt < 34) return; + + ConnectycubeFlutterCallKit.canUseFullScreenIntent() + .then((canUseFullScreenIntent) { + log('[requestFullScreenIntentsPermission] canUseFullScreenIntent: $canUseFullScreenIntent', + 'platform_utils'); + + if (!canUseFullScreenIntent) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('Full Screen notifications Permission required'), + content: Text( + 'To display an Incoming call on the Lock screen, you must grant access to the Lock screen. Would you like to do it now?'), + actions: [ + TextButton( + onPressed: () { + ConnectycubeFlutterCallKit.provideFullScreenIntentAccess() + .then((_) { + Navigator.of(context).pop(); + }); + }, + child: Text( + 'Grant', + ), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + 'Later', + ), + ), + ], + ); + }, + ); + } + }); +} diff --git a/conf_call_sample/lib/src/utils/pref_util.dart b/conf_call_sample/lib/src/utils/pref_util.dart new file mode 100644 index 0000000..4367ea8 --- /dev/null +++ b/conf_call_sample/lib/src/utils/pref_util.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +const String prefUserLogin = "pref_user_login"; +const String prefUserPsw = "pref_user_psw"; +const String prefUserName = "pref_user_name"; +const String prefUserId = "pref_user_id"; +const String prefUserAvatar = "pref_user_avatar"; +const String prefSubscriptionToken = "pref_subscription_token"; +const String prefSubscriptionId = "pref_subscription_id"; + +class SharedPrefs { + static SharedPreferences? _prefs; + + static Future getPrefs() async { + Completer completer = Completer(); + if (_prefs != null) { + completer.complete(_prefs); + } else { + _prefs = await SharedPreferences.getInstance(); + completer.complete(_prefs); + } + return completer.future; + } + + static Future saveNewUser(CubeUser cubeUser) { + return getPrefs().then((prefs) { + prefs.clear(); + prefs.setString(prefUserLogin, cubeUser.login!); + prefs.setString(prefUserPsw, cubeUser.password!); + prefs.setString(prefUserName, cubeUser.fullName!); + prefs.setInt(prefUserId, cubeUser.id!); + if (cubeUser.avatar != null) + prefs.setString(prefUserAvatar, cubeUser.avatar!); + + return Future.value(true); + }); + } + + static Future updateUser(CubeUser cubeUser) { + return getPrefs().then((prefs) { + if (cubeUser.password != null) + prefs.setString(prefUserPsw, cubeUser.password!); + if (cubeUser.login != null) + prefs.setString(prefUserLogin, cubeUser.login!); + if (cubeUser.fullName != null) + prefs.setString(prefUserName, cubeUser.fullName!); + if (cubeUser.avatar != null) + prefs.setString(prefUserAvatar, cubeUser.avatar!); + + return Future.value(true); + }); + } + + static Future getUser() { + return getPrefs().then((prefs) { + if (prefs.getString(prefUserLogin) == null) return Future.value(); + var user = CubeUser(); + user.login = prefs.getString(prefUserLogin); + user.password = prefs.getString(prefUserPsw); + user.fullName = prefs.getString(prefUserName); + user.id = prefs.getInt(prefUserId); + user.avatar = prefs.getString(prefUserAvatar); + return Future.value(user); + }); + } + + static Future deleteUserData() { + return getPrefs().then((prefs) { + return prefs.clear(); + }); + } + + static Future saveSubscriptionToken(String token) { + return getPrefs().then((prefs) { + return prefs.setString(prefSubscriptionToken, token); + }); + } + + static Future getSubscriptionToken() { + return getPrefs().then((prefs) { + return Future.value(prefs.getString(prefSubscriptionToken) ?? ""); + }); + } + + static Future saveSubscriptionId(int id) { + return getPrefs().then((prefs) { + return prefs.setInt(prefSubscriptionId, id); + }); + } + + static Future getSubscriptionId() { + return getPrefs().then((prefs) { + return Future.value(prefs.getInt(prefSubscriptionId) ?? 0); + }); + } +} diff --git a/conf_call_sample/lib/src/utils/string_utils.dart b/conf_call_sample/lib/src/utils/string_utils.dart new file mode 100644 index 0000000..e5d2c2f --- /dev/null +++ b/conf_call_sample/lib/src/utils/string_utils.dart @@ -0,0 +1,15 @@ +String formatHHMMSS(int seconds) { + int hours = (seconds / 3600).truncate(); + seconds = (seconds % 3600).truncate(); + int minutes = (seconds / 60).truncate(); + + String hoursStr = (hours).toString().padLeft(2, '0'); + String minutesStr = (minutes).toString().padLeft(2, '0'); + String secondsStr = (seconds % 60).toString().padLeft(2, '0'); + + if (hours == 0) { + return '$minutesStr:$secondsStr'; + } + + return '$hoursStr:$minutesStr:$secondsStr'; +} diff --git a/conf_call_sample/lib/src/utils/video_config.dart b/conf_call_sample/lib/src/utils/video_config.dart index d3f41d8..e6be5b7 100644 --- a/conf_call_sample/lib/src/utils/video_config.dart +++ b/conf_call_sample/lib/src/utils/video_config.dart @@ -5,6 +5,6 @@ class VideoQuality { const VideoQuality(this.width, this.height); } -const HD_VIDEO = const VideoQuality (1280, 720); -const VGA_VIDEO = const VideoQuality (640, 480); -const QVGA_VIDEO = const VideoQuality (320, 240); \ No newline at end of file +const HD_VIDEO = const VideoQuality(1280, 720); +const VGA_VIDEO = const VideoQuality(640, 480); +const QVGA_VIDEO = const VideoQuality(320, 240); diff --git a/conf_call_sample/lib/src/widgets/call_controls_widget.dart b/conf_call_sample/lib/src/widgets/call_controls_widget.dart new file mode 100644 index 0000000..e53c957 --- /dev/null +++ b/conf_call_sample/lib/src/widgets/call_controls_widget.dart @@ -0,0 +1,180 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_speed_dial/flutter_speed_dial.dart'; +import 'package:web_browser_detect/web_browser_detect.dart'; + +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +class CallControls extends StatelessWidget { + final bool isMicMuted; + final Function() onMute; + + final bool isCameraButtonVisible; + final bool isCameraEnabled; + final Function() onToggleCamera; + + final bool isScreenSharingButtonVisible; + final bool isScreenSharingEnabled; + final Function() onToggleScreenSharing; + + final bool isSpeakerEnabled; + final Function() onSwitchSpeaker; + + final Function() onSwitchAudioInput; + + final bool isSwitchCameraButtonVisible; + final Function() onSwitchCamera; + + final Function() onEndCall; + + CallControls({ + super.key, + required this.isMicMuted, + required this.onMute, + required this.isCameraButtonVisible, + required this.isCameraEnabled, + required this.onToggleCamera, + required this.isScreenSharingButtonVisible, + required this.isScreenSharingEnabled, + required this.onToggleScreenSharing, + required this.isSpeakerEnabled, + required this.onSwitchSpeaker, + required this.onSwitchAudioInput, + required this.isSwitchCameraButtonVisible, + required this.onSwitchCamera, + required this.onEndCall, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.only(bottom: 16, left: 8, right: 8), + child: ClipRRect( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(32), + bottomRight: Radius.circular(32), + topLeft: Radius.circular(32), + topRight: Radius.circular(32)), + child: Container( + padding: EdgeInsets.all(4), + color: Colors.black26, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.only(right: 4), + child: FloatingActionButton( + elevation: 0, + heroTag: "Mute", + child: Icon( + isMicMuted ? Icons.mic_off : Icons.mic, + color: isMicMuted ? Colors.grey : Colors.white, + ), + onPressed: onMute, + backgroundColor: Colors.black38, + ), + ), + Visibility( + visible: isCameraButtonVisible, + child: Padding( + padding: EdgeInsets.only(right: 4), + child: FloatingActionButton( + elevation: 0, + heroTag: "ToggleCamera", + child: Icon( + isCameraEnabled ? Icons.videocam : Icons.videocam_off, + color: isCameraEnabled ? Colors.white : Colors.grey, + ), + onPressed: onToggleCamera, + backgroundColor: Colors.black38, + ), + ), + ), + SpeedDial( + heroTag: "Options", + icon: Icons.more_vert, + activeIcon: Icons.close, + backgroundColor: Colors.black38, + switchLabelPosition: true, + overlayColor: Colors.black, + elevation: 0, + overlayOpacity: 0.5, + children: [ + SpeedDialChild( + visible: isScreenSharingButtonVisible, + child: Icon( + isScreenSharingEnabled + ? Icons.stop_screen_share + : Icons.screen_share, + color: Colors.white, + ), + backgroundColor: Colors.black38, + foregroundColor: Colors.white, + label: + '${isScreenSharingEnabled ? 'Stop' : 'Start'} Screen Sharing', + onTap: onToggleScreenSharing, + ), + SpeedDialChild( + visible: !(kIsWeb && + (Browser().browserAgent == BrowserAgent.Safari || + Browser().browserAgent == BrowserAgent.Firefox)), + child: Icon( + kIsWeb || WebRTC.platformIsDesktop + ? Icons.surround_sound + : isSpeakerEnabled + ? Icons.volume_up + : Icons.volume_off, + color: isSpeakerEnabled ? Colors.white : Colors.grey, + ), + backgroundColor: Colors.black38, + foregroundColor: Colors.white, + label: + 'Switch ${kIsWeb || WebRTC.platformIsDesktop ? 'Audio output' : 'Speakerphone'}', + onTap: onSwitchSpeaker, + ), + SpeedDialChild( + visible: kIsWeb || WebRTC.platformIsDesktop, + child: Icon( + Icons.record_voice_over, + color: Colors.white, + ), + backgroundColor: Colors.black38, + foregroundColor: Colors.white, + label: 'Switch Audio Input device', + onTap: onSwitchAudioInput, + ), + SpeedDialChild( + visible: isSwitchCameraButtonVisible, + child: Icon( + Icons.cameraswitch, + color: isCameraEnabled ? Colors.white : Colors.grey, + ), + backgroundColor: Colors.black38, + foregroundColor: Colors.white, + label: 'Switch Camera', + onTap: onSwitchCamera, + ), + ], + ), + Expanded( + child: SizedBox(), + flex: 1, + ), + Padding( + padding: EdgeInsets.only(left: 0), + child: FloatingActionButton( + child: Icon( + Icons.call_end, + color: Colors.white, + ), + backgroundColor: Colors.red, + onPressed: onEndCall, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/conf_call_sample/lib/src/widgets/call_info_widget.dart b/conf_call_sample/lib/src/widgets/call_info_widget.dart new file mode 100644 index 0000000..21d2686 --- /dev/null +++ b/conf_call_sample/lib/src/widgets/call_info_widget.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import '../utils/duration_timer.dart'; +import '../utils/string_utils.dart'; + +class CallInfo extends StatelessWidget { + final String callName; + final String callStatus; + final DurationTimer callTimer; + + CallInfo(this.callName, this.callStatus, this.callTimer); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + callName, + style: TextStyle( + fontSize: 24, + color: Colors.white, + decoration: TextDecoration.none, + shadows: [ + Shadow( + color: Colors.grey.shade900, + offset: Offset(2, 1), + blurRadius: 12, + ), + ], + ), + ), + Text( + callStatus, + style: TextStyle( + fontSize: 14, + color: Colors.white, + decoration: TextDecoration.none, + shadows: [ + Shadow( + color: Colors.grey.shade900, + offset: Offset(2, 1), + blurRadius: 12, + ), + ], + ), + ), + StreamBuilder( + stream: callTimer.durationStream, + builder: (context, snapshot) { + return Text( + snapshot.hasData ? formatHHMMSS(snapshot.data!) : '00:00', + style: TextStyle( + fontSize: 14, + color: Colors.white, + decoration: TextDecoration.none, + shadows: [ + Shadow( + color: Colors.grey.shade900, + offset: Offset(2, 1), + blurRadius: 12, + ), + ], + ), + ); + }) + ], + ); + } +} diff --git a/conf_call_sample/lib/src/widgets/grid_view_call_widget.dart b/conf_call_sample/lib/src/widgets/grid_view_call_widget.dart new file mode 100644 index 0000000..5a11636 --- /dev/null +++ b/conf_call_sample/lib/src/widgets/grid_view_call_widget.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; + +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +import '../utils/media_utils.dart'; +import 'minor_video_widget.dart'; + +class GridViewLayout extends StatefulWidget { + final MapEntry? primaryRenderer; + final Map minorRenderers; + final int currentUserId; + final List participants; + final bool isFrontCameraUsed; + final bool isScreenSharingEnabled; + final Map> participantsMediaConfigs; + final Function(MapEntry? primaryRenderer, + Map minorRenderers) onRenderersChanged; + final CubeStatsReportsManager statsReportsManager; + + GridViewLayout({ + super.key, + required this.currentUserId, + required this.participants, + required this.primaryRenderer, + required this.minorRenderers, + required this.isFrontCameraUsed, + required this.isScreenSharingEnabled, + required this.participantsMediaConfigs, + required this.onRenderersChanged, + required this.statsReportsManager, + }); + + @override + State createState() { + return _GridViewLayoutState( + primaryRenderer: primaryRenderer, + minorRenderers: minorRenderers, + ); + } +} + +class _GridViewLayoutState extends State { + static final String TAG = 'GridViewLayout'; + + MapEntry? _primaryRenderer; + Map _minorRenderers; + + _GridViewLayoutState({ + required MapEntry? primaryRenderer, + required Map minorRenderers, + }) : _primaryRenderer = primaryRenderer, + _minorRenderers = minorRenderers; + + @override + void didUpdateWidget(GridViewLayout oldWidget) { + super.didUpdateWidget(oldWidget); + log("[didUpdateWidget]", TAG); + _primaryRenderer = widget.primaryRenderer; + _minorRenderers = widget.minorRenderers; + } + + @override + Widget build(BuildContext context) { + var orientation = MediaQuery.of(context).orientation; + return Container( + margin: MediaQuery.of(context).padding, + child: GridView( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: orientation == Orientation.portrait ? 2 : 4, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + childAspectRatio: 4 / 3, + ), + padding: EdgeInsets.all(4), + scrollDirection: Axis.vertical, + children: _buildGridItems(orientation), + ), + ); + } + + List _buildGridItems(Orientation orientation) { + Map allRenderers = + Map.fromEntries([..._minorRenderers.entries]); + if (_primaryRenderer != null) { + allRenderers.addEntries([_primaryRenderer!]); + } + var itemHeight; + var itemWidth; + + if (orientation == Orientation.portrait) { + itemWidth = MediaQuery.of(context).size.width * 0.95 / 2; + itemHeight = itemWidth / 4 * 3; + } else { + itemWidth = MediaQuery.of(context).size.width * 0.95 / 2; + itemHeight = itemWidth / 4 * 3; + } + + return buildItems(allRenderers, itemWidth, itemHeight); + } + + List buildItems(Map renderers, + double itemWidth, double itemHeight) { + var videoItems = []; + + renderers.forEach( + (key, value) { + if ((value.srcObject?.getVideoTracks().isNotEmpty ?? false) && + isUserCameraEnabled(key, widget.participantsMediaConfigs, + defaultValue: true)) { + videoItems.add( + StreamBuilder( + stream: widget.statsReportsManager.micLevelStream + .where((event) => event.userId == key), + builder: (context, snapshot) { + var defaultBorderWidth = 4.0; + var width = !snapshot.hasData + ? 0 + : snapshot.data!.micLevel * defaultBorderWidth; + + return Container( + margin: EdgeInsets.all(defaultBorderWidth), + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + side: BorderSide( + width: width.toDouble(), + color: Colors.green, + strokeAlign: 1.0), + ), + ), + child: MinorVideo( + width: itemWidth, + height: itemHeight, + renderer: value, + mirror: key == widget.currentUserId && + widget.isFrontCameraUsed && + !widget.isScreenSharingEnabled, + name: key == widget.currentUserId + ? 'Me' + : widget.participants + .where((user) => user.id == key) + .first + .fullName ?? + 'Unknown', + ), + ); + }, + ), + ); + } + }, + ); + + return videoItems; + } +} diff --git a/conf_call_sample/lib/src/widgets/minor_video_widget.dart b/conf_call_sample/lib/src/widgets/minor_video_widget.dart new file mode 100644 index 0000000..a232388 --- /dev/null +++ b/conf_call_sample/lib/src/widgets/minor_video_widget.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +class MinorVideo extends StatelessWidget { + final double width; + final double height; + final RTCVideoRenderer renderer; + final RTCVideoViewObjectFit objectFit; + final bool mirror; + final String? name; + final Function()? onTap; + final Function(DragUpdateDetails details)? onPanUpdate; + final Function(DragEndDetails details)? onPanEnd; + + MinorVideo({ + super.key, + required this.width, + required this.height, + required this.renderer, + required this.mirror, + this.name, + this.objectFit = RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + this.onTap, + this.onPanUpdate, + this.onPanEnd, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onPanUpdate: onPanUpdate, + onPanEnd: onPanEnd, + onTap: onTap, + child: AbsorbPointer( + child: SizedBox( + width: width, + height: height, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: RTCVideoView( + renderer, + objectFit: objectFit, + mirror: mirror, + ), + ), + Visibility( + visible: name != null && name!.isNotEmpty, + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + margin: EdgeInsets.only(bottom: 8), + child: Text( + name ?? 'Unknown', + style: TextStyle( + color: Colors.white, + shadows: [ + Shadow( + color: Colors.black, + offset: Offset(2, 1), + blurRadius: 8, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/conf_call_sample/lib/src/widgets/primary_video_widget.dart b/conf_call_sample/lib/src/widgets/primary_video_widget.dart new file mode 100644 index 0000000..897fcde --- /dev/null +++ b/conf_call_sample/lib/src/widgets/primary_video_widget.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +class PrimaryVideo extends StatelessWidget { + final RTCVideoRenderer renderer; + final RTCVideoViewObjectFit objectFit; + final bool mirror; + final Function()? onDoubleTap; + + PrimaryVideo({ + super.key, + required this.renderer, + required this.objectFit, + required this.mirror, + this.onDoubleTap, + }); + + @override + Widget build(BuildContext context) { + return Stack(children: [ + GestureDetector( + onDoubleTap: onDoubleTap, + child: RTCVideoView( + renderer, + objectFit: objectFit, + mirror: mirror, + ), + ), + ]); + } +} diff --git a/conf_call_sample/lib/src/widgets/private_call_widget.dart b/conf_call_sample/lib/src/widgets/private_call_widget.dart new file mode 100644 index 0000000..03ea745 --- /dev/null +++ b/conf_call_sample/lib/src/widgets/private_call_widget.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; + +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +import '../utils/duration_timer.dart'; +import '../utils/media_utils.dart'; +import 'call_info_widget.dart'; +import 'minor_video_widget.dart'; +import 'primary_video_widget.dart'; + +class PrivateCallLayout extends StatefulWidget { + final MapEntry? primaryRenderer; + final Map minorRenderers; + final RTCVideoViewObjectFit primaryVideoFit; + final Function(RTCVideoViewObjectFit newObjectFit)? onPrimaryVideoFitChanged; + final int currentUserId; + final String callName; + final String callStatus; + final DurationTimer callTimer; + final bool isFrontCameraUsed; + final bool isScreenSharingEnabled; + final Map> participantsMediaConfigs; + final WidgetPosition minorWidgetInitialPosition; + final Function(WidgetPosition newPosition)? onMinorVideoPositionChanged; + final Function(MapEntry? primaryRenderer, + Map minorRenderers) onRenderersChanged; + + PrivateCallLayout({ + super.key, + required this.currentUserId, + required this.primaryRenderer, + required this.primaryVideoFit, + this.onPrimaryVideoFitChanged, + required this.minorRenderers, + required this.callName, + required this.callStatus, + required this.callTimer, + required this.minorWidgetInitialPosition, + required this.isFrontCameraUsed, + required this.isScreenSharingEnabled, + required this.participantsMediaConfigs, + this.onMinorVideoPositionChanged, + required this.onRenderersChanged, + }); + + @override + State createState() { + return _PrivateCallLayoutState( + primaryRenderer: primaryRenderer, + minorRenderers: minorRenderers, + primaryVideoFit: primaryVideoFit, + minorWidgetInitialPosition: minorWidgetInitialPosition, + ); + } +} + +class _PrivateCallLayoutState extends State { + static final String TAG = 'PrivateCallLayout'; + + MapEntry? _primaryRenderer; + Map _minorRenderers; + RTCVideoViewObjectFit _primaryVideoFit; + bool _isPrimaryUserForciblySelected = false; + WidgetPosition _minorWidgetInitialPosition; + Offset _minorWidgetOffset = Offset(0, 0); + bool _isWidgetMoving = false; + + _PrivateCallLayoutState({ + required MapEntry? primaryRenderer, + required Map minorRenderers, + required RTCVideoViewObjectFit primaryVideoFit, + required WidgetPosition minorWidgetInitialPosition, + }) : _primaryRenderer = primaryRenderer, + _minorRenderers = minorRenderers, + _primaryVideoFit = primaryVideoFit, + _minorWidgetInitialPosition = minorWidgetInitialPosition; + + @override + void didUpdateWidget(PrivateCallLayout oldWidget) { + super.didUpdateWidget(oldWidget); + log("[didUpdateWidget]", TAG); + _primaryRenderer = widget.primaryRenderer; + _minorRenderers = widget.minorRenderers; + _primaryVideoFit = widget.primaryVideoFit; + _minorWidgetInitialPosition = widget.minorWidgetInitialPosition; + } + + @override + Widget build(BuildContext context) { + var orientation = MediaQuery.of(context).orientation; + log("[build]", TAG); + + List children = []; + + var primaryVideo = buildPrimaryVideoWidget(); + if (primaryVideo != null) { + children.add(primaryVideo); + } + + children.add(buildCallInfoWidget()); + + var minorVideo = buildMinorVideoWidget(orientation); + if (minorVideo != null) { + children.add(minorVideo); + } + + return Stack(children: children); + } + + Widget? buildPrimaryVideoWidget() { + Widget? createPrimaryVideoWidget() { + if (canShowVideo(_primaryRenderer?.key, _primaryRenderer?.value.srcObject, + widget.participantsMediaConfigs)) { + return PrimaryVideo( + renderer: _primaryRenderer!.value, + objectFit: _primaryVideoFit, + mirror: _primaryRenderer?.key == widget.currentUserId && + widget.isFrontCameraUsed && + !widget.isScreenSharingEnabled, + onDoubleTap: () { + setState(() { + _primaryVideoFit = _primaryVideoFit == + RTCVideoViewObjectFit.RTCVideoViewObjectFitCover + ? RTCVideoViewObjectFit.RTCVideoViewObjectFitContain + : RTCVideoViewObjectFit.RTCVideoViewObjectFitCover; + widget.onPrimaryVideoFitChanged?.call(_primaryVideoFit); + }); + }, + ); + } + + return null; + } + + var primaryVideoWidget; + + var minorUserWithEnabledVideo = getUserWithEnabledVideo( + _minorRenderers, widget.currentUserId, widget.participantsMediaConfigs); + + if ((_primaryRenderer?.key != widget.currentUserId || + (_primaryRenderer?.key == widget.currentUserId && + (_isPrimaryUserForciblySelected || + minorUserWithEnabledVideo == null))) && + canShowVideo(_primaryRenderer?.key, _primaryRenderer?.value.srcObject, + widget.participantsMediaConfigs)) { + primaryVideoWidget = createPrimaryVideoWidget(); + } else if (minorUserWithEnabledVideo != null) { + updatePrimaryUser( + minorUserWithEnabledVideo, + true, + widget.currentUserId, + _primaryRenderer, + _minorRenderers, + widget.participantsMediaConfigs, + onRenderersUpdated: (newPrimaryRenderer, newMinorRenderers) { + widget.onRenderersChanged.call(newPrimaryRenderer, newMinorRenderers); + _primaryRenderer = newPrimaryRenderer; + _minorRenderers = newMinorRenderers; + + _isPrimaryUserForciblySelected = false; + primaryVideoWidget = createPrimaryVideoWidget(); + }, + ); + } + + return primaryVideoWidget; + } + + Widget buildCallInfoWidget() { + return Align( + alignment: Alignment.topCenter, + child: Container( + margin: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 48, + ), + child: CallInfo( + widget.callName, + widget.callStatus, + widget.callTimer, + ), + ), + ); + } + + Widget? buildMinorVideoWidget(Orientation orientation) { + var width = calculateMinorVideoViewWidth(context, orientation); + var height = calculateMinorVideoViewHeight(context, orientation); + + var videoItems = []; + + _minorRenderers.forEach( + (key, value) { + if ((value.srcObject?.getVideoTracks().isNotEmpty ?? false) && + isUserCameraEnabled(key, widget.participantsMediaConfigs, + defaultValue: true)) { + videoItems.add( + MinorVideo( + width: width, + height: height, + renderer: value, + mirror: key == widget.currentUserId && + widget.isFrontCameraUsed && + !widget.isScreenSharingEnabled, + onPanUpdate: (details) => + _onPanUpdate(context, details, _minorWidgetOffset), + onPanEnd: (details) => _onPanEnd(context, details), + onTap: () => setState( + () { + log("[onTap] userId: $key", TAG); + updatePrimaryUser( + key, + true, + widget.currentUserId, + _primaryRenderer, + _minorRenderers, + widget.participantsMediaConfigs, + onRenderersUpdated: + (newPrimaryRenderer, newMinorRenderers) { + widget.onRenderersChanged + .call(newPrimaryRenderer, newMinorRenderers); + _primaryRenderer = newPrimaryRenderer; + _minorRenderers = newMinorRenderers; + }, + ); + _isPrimaryUserForciblySelected = true; + }, + ), + ), + ); + } + }, + ); + + if (videoItems.isEmpty) return null; + + var minorItem = videoItems.firstOrNull; + + if (minorItem != null) { + var widgetOffset = + getOffsetForPosition(context, _minorWidgetInitialPosition); + + if (_isWidgetMoving) { + widgetOffset = _minorWidgetOffset; + } + + return Positioned( + top: widgetOffset.dy - height / 2, + left: widgetOffset.dx - width / 2, + child: minorItem, + ); + } + + return minorItem; + } + + void _onPanUpdate( + BuildContext context, DragUpdateDetails details, Offset offset) { + log('_onPanUpdate', TAG); + + setState(() { + _isWidgetMoving = true; + _minorWidgetOffset = details.globalPosition; + }); + } + + void _onPanEnd(BuildContext context, DragEndDetails details) { + log('_onPanEnd', TAG); + + setState(() { + _isWidgetMoving = false; + _minorWidgetInitialPosition = + calculateMinorVideoViewPosition(context, _minorWidgetOffset); + widget.onMinorVideoPositionChanged?.call(_minorWidgetInitialPosition); + }); + } +} + +double calculateMinorVideoViewWidth( + BuildContext context, Orientation orientation) { + return orientation == Orientation.portrait + ? MediaQuery.of(context).size.width / 3 + : MediaQuery.of(context).size.width / 4; +} + +double calculateMinorVideoViewHeight( + BuildContext context, Orientation orientation) { + return orientation == Orientation.portrait + ? MediaQuery.of(context).size.height / 4 + : MediaQuery.of(context).size.height / 2.5; +} + +WidgetPosition calculateMinorVideoViewPosition( + BuildContext context, Offset initialPosition) { + var isRight = false; + if (initialPosition.dx > MediaQuery.of(context).size.width / 2) { + isRight = true; + } + + var isBottom = false; + if (initialPosition.dy > MediaQuery.of(context).size.height / 2) { + isBottom = true; + } + + var position = WidgetPosition.topRight; + + if (isRight && isBottom) { + position = WidgetPosition.bottomRight; + } else if (!isRight && isBottom) { + position = WidgetPosition.bottomLeft; + } else if (!isRight && !isBottom) { + position = WidgetPosition.topLeft; + } + + return position; +} + +Offset getOffsetForPosition(BuildContext context, WidgetPosition position) { + var orientation = MediaQuery.of(context).orientation; + + var width = calculateMinorVideoViewWidth(context, orientation); + var height = calculateMinorVideoViewHeight(context, orientation); + + var dxPosition = 0.0; + if (position == WidgetPosition.topRight || + position == WidgetPosition.bottomRight) { + dxPosition = MediaQuery.of(context).size.width - + (width / 2 + MediaQuery.of(context).padding.right + 10); + } else { + dxPosition = width / 2 + MediaQuery.of(context).padding.left + 10; + } + + var dyPosition = 0.0; + if (position == WidgetPosition.bottomRight || + position == WidgetPosition.bottomLeft) { + dyPosition = MediaQuery.of(context).size.height - + (height / 2 + MediaQuery.of(context).padding.bottom + 10); + } else { + dyPosition = height / 2 + MediaQuery.of(context).padding.top + 10; + } + + return Offset(dxPosition, dyPosition); +} + +enum WidgetPosition { topLeft, topRight, bottomLeft, bottomRight } diff --git a/conf_call_sample/lib/src/widgets/speaker_view_call_widget.dart b/conf_call_sample/lib/src/widgets/speaker_view_call_widget.dart new file mode 100644 index 0000000..c056ae4 --- /dev/null +++ b/conf_call_sample/lib/src/widgets/speaker_view_call_widget.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; + +import 'package:connectycube_sdk/connectycube_sdk.dart'; + +import '../utils/duration_timer.dart'; +import '../utils/media_utils.dart'; +import '../managers/speakers_manager.dart'; +import 'call_info_widget.dart'; +import 'minor_video_widget.dart'; +import 'primary_video_widget.dart'; + +class SpeakerViewLayout extends StatefulWidget { + final MapEntry? primaryRenderer; + final Map minorRenderers; + final RTCVideoViewObjectFit primaryVideoFit; + final Function(RTCVideoViewObjectFit newObjectFit)? onPrimaryVideoFitChanged; + final int currentUserId; + final List participants; + final String callName; + final String callStatus; + final DurationTimer callTimer; + final bool isFrontCameraUsed; + final bool isScreenSharingEnabled; + final Map> participantsMediaConfigs; + final Function(MapEntry? primaryRenderer, + Map minorRenderers) onRenderersChanged; + final CubeStatsReportsManager statsReportsManager; + + SpeakerViewLayout({ + super.key, + required this.currentUserId, + required this.participants, + required this.primaryRenderer, + required this.primaryVideoFit, + this.onPrimaryVideoFitChanged, + required this.minorRenderers, + required this.callName, + required this.callStatus, + required this.callTimer, + required this.isFrontCameraUsed, + required this.isScreenSharingEnabled, + required this.participantsMediaConfigs, + required this.onRenderersChanged, + required this.statsReportsManager, + }); + + @override + State createState() { + return _SpeakerViewLayoutState( + primaryRenderer: primaryRenderer, + minorRenderers: minorRenderers, + primaryVideoFit: primaryVideoFit, + ); + } +} + +class _SpeakerViewLayoutState extends State { + static final String TAG = 'SpeakerViewLayout'; + final SpeakersManager _speakersManager = SpeakersManager(); + + MapEntry? _primaryRenderer; + Map _minorRenderers; + RTCVideoViewObjectFit _primaryVideoFit; + bool _isPrimaryUserForciblySelected = false; + + _SpeakerViewLayoutState({ + required MapEntry? primaryRenderer, + required Map minorRenderers, + required RTCVideoViewObjectFit primaryVideoFit, + }) : _primaryRenderer = primaryRenderer, + _minorRenderers = minorRenderers, + _primaryVideoFit = primaryVideoFit; + + @override + void initState() { + _speakersManager.init(widget.statsReportsManager, _onSpeakerChanged); + } + + @override + void didUpdateWidget(SpeakerViewLayout oldWidget) { + super.didUpdateWidget(oldWidget); + log("[didUpdateWidget]", TAG); + _primaryRenderer = widget.primaryRenderer; + _minorRenderers = widget.minorRenderers; + _primaryVideoFit = widget.primaryVideoFit; + } + + @override + Widget build(BuildContext context) { + var orientation = MediaQuery.of(context).orientation; + return Center( + child: Container( + child: Stack(children: [ + orientation == Orientation.portrait + ? Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: renderSpeakerModeViews(orientation)) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: renderSpeakerModeViews(orientation)), + buildCallInfoWidget(), + ]), + )); + } + + void _onSpeakerChanged(int userId) { + log('[_onSpeakerChanged] userId: $userId, currentUserId: ${widget.currentUserId}', + TAG); + if (userId == widget.currentUserId) return; + + if (canShowVideo(userId, _minorRenderers[userId]?.srcObject, + widget.participantsMediaConfigs)) { + setState(() { + updatePrimaryUser( + userId, + false, + widget.currentUserId, + _primaryRenderer, + _minorRenderers, + widget.participantsMediaConfigs, + onRenderersUpdated: (newPrimaryRenderer, newMinorRenderers) { + widget.onRenderersChanged + .call(newPrimaryRenderer, newMinorRenderers); + _primaryRenderer = newPrimaryRenderer; + _minorRenderers = newMinorRenderers; + _isPrimaryUserForciblySelected = false; + }, + ); + }); + } + } + + List renderSpeakerModeViews(Orientation orientation) { + log("[renderSpeakerModeViews]", TAG); + List streamsExpanded = []; + + var primaryVideo = buildPrimaryVideoWidget(); + if (primaryVideo != null) { + streamsExpanded.add(primaryVideo); + } + + var minorItems = buildMinorVideoItems(orientation); + if (minorItems != null) { + streamsExpanded.add(minorItems); + } + + return streamsExpanded; + } + + buildPrimaryVideoWidget() { + Widget? createPrimaryVideoView() { + var primaryVideo; + if (canShowVideo(_primaryRenderer?.key, _primaryRenderer?.value.srcObject, + widget.participantsMediaConfigs)) { + primaryVideo = Expanded( + flex: 3, + child: PrimaryVideo( + renderer: _primaryRenderer!.value, + objectFit: widget.primaryVideoFit, + mirror: _primaryRenderer!.key == widget.currentUserId && + widget.isFrontCameraUsed && + !widget.isScreenSharingEnabled, + onDoubleTap: () { + setState(() { + _primaryVideoFit = _primaryVideoFit == + RTCVideoViewObjectFit.RTCVideoViewObjectFitCover + ? RTCVideoViewObjectFit.RTCVideoViewObjectFitContain + : RTCVideoViewObjectFit.RTCVideoViewObjectFitCover; + widget.onPrimaryVideoFitChanged?.call(_primaryVideoFit); + }); + }, + ), + ); + } + + return primaryVideo; + } + + var primaryVideoWidget; + + var minorUserWithEnabledVideo = getUserWithEnabledVideo( + _minorRenderers, widget.currentUserId, widget.participantsMediaConfigs); + + if ((_primaryRenderer?.key != widget.currentUserId || + (_primaryRenderer?.key == widget.currentUserId && + (_isPrimaryUserForciblySelected || + minorUserWithEnabledVideo == null))) && + canShowVideo(_primaryRenderer?.key, _primaryRenderer?.value.srcObject, + widget.participantsMediaConfigs)) { + primaryVideoWidget = createPrimaryVideoView(); + } else if (minorUserWithEnabledVideo != null) { + updatePrimaryUser( + minorUserWithEnabledVideo, + true, + widget.currentUserId, + _primaryRenderer, + _minorRenderers, + widget.participantsMediaConfigs, + onRenderersUpdated: (newPrimaryRenderer, newMinorRenderers) { + widget.onRenderersChanged.call(newPrimaryRenderer, newMinorRenderers); + _primaryRenderer = newPrimaryRenderer; + _minorRenderers = newMinorRenderers; + }, + ); + _isPrimaryUserForciblySelected = false; + primaryVideoWidget = createPrimaryVideoView(); + } + + return primaryVideoWidget; + } + + Widget? buildMinorVideoItems(Orientation orientation) { + var itemHeight; + var itemWidth; + + if (orientation == Orientation.portrait) { + itemHeight = MediaQuery.of(context).size.height / 3 * 0.8; + itemWidth = itemHeight / 3 * 4; + } else { + itemWidth = MediaQuery.of(context).size.width / 3 * 0.8; + itemHeight = itemWidth / 4 * 3; + } + + var videoItems = []; + + _minorRenderers.forEach( + (key, value) { + if ((value.srcObject?.getVideoTracks().isNotEmpty ?? false) && + isUserCameraEnabled(key, widget.participantsMediaConfigs, + defaultValue: true)) { + videoItems.add( + StreamBuilder( + stream: widget.statsReportsManager.micLevelStream + .where((event) => event.userId == key), + builder: (context, snapshot) { + var defaultBorderWidth = 4.0; + var width = !snapshot.hasData + ? 0 + : snapshot.data!.micLevel * defaultBorderWidth; + + return Padding( + padding: EdgeInsets.all(2), + child: Container( + margin: EdgeInsets.all(defaultBorderWidth), + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + side: BorderSide( + width: width.toDouble(), + color: Colors.green, + strokeAlign: 1.0), + ), + ), + child: MinorVideo( + width: itemWidth, + height: itemHeight, + renderer: value, + mirror: key == widget.currentUserId && + widget.isFrontCameraUsed && + !widget.isScreenSharingEnabled, + name: key == widget.currentUserId + ? 'Me' + : widget.participants + .where((user) => user.id == key) + .first + .fullName ?? + 'Unknown', + onTap: () => setState( + () { + log("[onTap] userId: $key", TAG); + updatePrimaryUser( + key, + true, + widget.currentUserId, + _primaryRenderer, + _minorRenderers, + widget.participantsMediaConfigs, + onRenderersUpdated: + (newPrimaryRenderer, newMinorRenderers) { + widget.onRenderersChanged.call( + newPrimaryRenderer, newMinorRenderers); + _primaryRenderer = newPrimaryRenderer; + _minorRenderers = newMinorRenderers; + }, + ); + _isPrimaryUserForciblySelected = true; + }, + ), + ), + )); + }, + ), + ); + } + }, + ); + + var minorVideoItems; + + if (videoItems.isNotEmpty) { + var membersList = Expanded( + flex: 1, + child: ListView( + scrollDirection: orientation == Orientation.landscape + ? Axis.vertical + : Axis.horizontal, + children: videoItems, + ), + ); + + minorVideoItems = membersList; + } + + return minorVideoItems; + } + + Widget buildCallInfoWidget() { + return Align( + alignment: Alignment.topCenter, + child: Container( + margin: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 48, + ), + child: CallInfo(widget.callName, widget.callStatus, widget.callTimer), + ), + ); + } + + @override + void dispose() { + _speakersManager.dispose(); + super.dispose(); + } +} diff --git a/conf_call_sample/macos/Flutter/GeneratedPluginRegistrant.swift b/conf_call_sample/macos/Flutter/GeneratedPluginRegistrant.swift index e559391..e399ce3 100644 --- a/conf_call_sample/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/conf_call_sample/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,20 @@ import FlutterMacOS import Foundation +import assets_audio_player +import assets_audio_player_web import device_info_plus import flutter_webrtc +import package_info_plus import path_provider_foundation +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AssetsAudioPlayerPlugin.register(with: registry.registrar(forPlugin: "AssetsAudioPlayerPlugin")) + AssetsAudioPlayerWebPlugin.register(with: registry.registrar(forPlugin: "AssetsAudioPlayerWebPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/conf_call_sample/macos/Podfile.lock b/conf_call_sample/macos/Podfile.lock index 2875101..46eda99 100644 --- a/conf_call_sample/macos/Podfile.lock +++ b/conf_call_sample/macos/Podfile.lock @@ -1,20 +1,20 @@ PODS: - device_info_plus (0.0.1): - FlutterMacOS - - flutter_webrtc (0.9.22): + - flutter_webrtc (0.9.31): - FlutterMacOS - - WebRTC-SDK (= 104.5112.12) + - WebRTC-SDK (= 104.5112.17) - FlutterMacOS (1.0.0) - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - WebRTC-SDK (104.5112.12) + - WebRTC-SDK (104.5112.17) DEPENDENCIES: - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) SPEC REPOS: trunk: @@ -28,14 +28,14 @@ EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin SPEC CHECKSUMS: device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f - flutter_webrtc: 0e2af3876ed28d4599c06734c771c2526309356b + flutter_webrtc: 9903b25f3648e335a40952058b14225e7b0df88d FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9 - WebRTC-SDK: 6469c304549f7187e7e67119c8b3184f9ec6b7f6 + path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8 + WebRTC-SDK: 082ae4893212534a779ca233f19a9df8efd5f3bd PODFILE CHECKSUM: 0095aabf5a2ba4b7b30669b8afaa68ef378ace64 diff --git a/conf_call_sample/pubspec.yaml b/conf_call_sample/pubspec.yaml index b574f98..023f8e1 100644 --- a/conf_call_sample/pubspec.yaml +++ b/conf_call_sample/pubspec.yaml @@ -1,21 +1,31 @@ name: conf_call_sample description: Conf Calls Sample -version: 0.3.2 +version: 0.3.6 environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: flutter: sdk: flutter - connectycube_sdk: ^2.5.0 + assets_audio_player: ^3.1.1 + connectycube_sdk: ^2.11.0 # connectycube_sdk: # path: ../../flutter-sdk + connectycube_flutter_call_kit: ^2.5.0 +# connectycube_flutter_call_kit: +# path: ../../connectycube-flutter-call-kit/connectycube_flutter_call_kit + device_info_plus: ^9.0.0 flutter_background: ^1.2.0 + flutter_speed_dial: ^7.0.0 + package_info_plus: ^5.0.0 + permission_handler: ^11.0.1 + shared_preferences: ^2.2.1 + universal_io: ^2.2.2 + uuid: ^3.0.7 web_browser_detect: ^2.0.3 - flutter_speed_dial: ^6.2.0 dev_dependencies: flutter_test: @@ -23,4 +33,6 @@ dev_dependencies: flutter: - uses-material-design: true \ No newline at end of file + uses-material-design: true + assets: + - assets/audio/ \ No newline at end of file diff --git a/conf_call_sample/windows/flutter/generated_plugin_registrant.cc b/conf_call_sample/windows/flutter/generated_plugin_registrant.cc index e8559e4..d5acadb 100644 --- a/conf_call_sample/windows/flutter/generated_plugin_registrant.cc +++ b/conf_call_sample/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterWebRTCPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); } diff --git a/conf_call_sample/windows/flutter/generated_plugins.cmake b/conf_call_sample/windows/flutter/generated_plugins.cmake index 3024cac..cb004cd 100644 --- a/conf_call_sample/windows/flutter/generated_plugins.cmake +++ b/conf_call_sample/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_webrtc + permission_handler_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST