From ed72ed9799007f0c53b175c965a4b9273f9db2f9 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 23 Jun 2026 08:42:55 +0300 Subject: [PATCH] feat(video_calls): changed accept flow, fixed ui bugs for PIP and video render sizes --- android/app/build.gradle | 2 + android/app/src/main/AndroidManifest.xml | 10 + .../app/EatvioFcmMessagingService.kt | 33 ++ .../com/eatvioapp/app/HmsPushService.kt | 92 +--- .../com/eatvioapp/app/VideoCallPushBridge.kt | 163 +++++++ lib/core/di/cubit_registrations.dart | 5 +- .../push_navigation_handler.dart | 2 + lib/core/routing/other_routes.dart | 10 + .../managers/video_call_manager.dart | 430 +++++++----------- .../managers/video_call_manager_snapshot.dart | 10 +- .../video_call_session_cubit.dart | 205 ++++++++- .../video_call_session_state.dart | 15 +- .../presentation/pages/video_call_screen.dart | 60 ++- .../widgets/video_call_app_shell.dart | 26 +- .../widgets/video_call_global_pip_layer.dart | 7 +- .../video_call_livekit_track_utils.dart | 8 +- .../widgets/video_call_pip_video_preview.dart | 63 ++- .../video_call_remote_video_stage.dart | 13 +- .../video_call_room_tracks_listenable.dart | 11 + .../widgets/video_call_stage_layout.dart | 418 ++++++++--------- .../video_call_system_pip_content.dart | 21 +- .../video_call_system_pip_listener.dart | 6 + .../video_call_audio_session_coordinator.dart | 11 +- .../video_call_callkit_coordinator.dart | 1 + .../services/video_call_fcm_handler.dart | 8 + .../video_call_incoming_push_presenter.dart | 1 + .../video_call_notification_navigator.dart | 23 +- ..._call_manager_audio_interruption_test.dart | 173 ------- .../video_call_session_cubit_test.dart | 232 +++++++++- .../video_call_livekit_track_utils_test.dart | 29 ++ .../widgets/video_call_stage_layout_test.dart | 148 +++++- 31 files changed, 1399 insertions(+), 837 deletions(-) create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt create mode 100644 lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart delete mode 100644 test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 5fb4bf83..ff3634f9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -69,6 +69,8 @@ android { } dependencies { + implementation platform('com.google.firebase:firebase-bom:34.4.0') + implementation 'com.google.firebase:firebase-messaging' implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300' implementation 'com.huawei.hms:push:6.13.0.301' // Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index daa5ec83..4ce279c3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,16 @@ android:usesCleartextTraffic="true" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + + + + + + diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt new file mode 100644 index 00000000..7e9e7377 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt @@ -0,0 +1,33 @@ +package com.eatvioapp.app + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import io.flutter.plugins.firebase.messaging.FlutterFirebaseTokenLiveData + +class EatvioFcmMessagingService : FirebaseMessagingService() { + override fun onNewToken(token: String) { + super.onNewToken(token) + FlutterFirebaseTokenLiveData.getInstance().postToken(token) + Log.i(TAG, "onNewToken") + } + + override fun onMessageReceived(message: RemoteMessage) { + Log.i(TAG, "onMessageReceived: ${message.data}") + + if (!VideoCallPushBridge.isIncomingVideoCall(message.data)) { + return + } + + if (MainActivity.isInForeground) { + Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler") + return + } + + VideoCallPushBridge.showIncomingCall(this, message.data) + } + + companion object { + private const val TAG = "EatvioFCM" + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index f8db072b..b7b986a6 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -2,14 +2,11 @@ package com.eatvioapp.app import android.app.PendingIntent import android.content.Intent -import android.os.Bundle import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import com.hiennv.flutter_callkit_incoming.CallkitConstants import com.huawei.hms.push.HmsMessageService import com.huawei.hms.push.RemoteMessage -import java.util.HashMap class HmsPushService : HmsMessageService() { override fun onNewToken(token: String?) { @@ -23,22 +20,18 @@ class HmsPushService : HmsMessageService() { if (message == null) return - if (isIncomingVideoCall(message.dataOfMap)) { - showIncomingVideoCallNotification(message.dataOfMap) + if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) { + VideoCallPushBridge.showIncomingCall(this, message.dataOfMap) return } - // Берём action_url из data-поля, если оно есть. val actionUrl = message.dataOfMap["action_url"] - - // Строим системное уведомление, по тапу по которому откроется MainActivity с action_url. showNotification(message, actionUrl) } private fun showNotification(message: RemoteMessage, actionUrl: String?) { val context = applicationContext - // Интент для запуска Flutter-активити. val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP if (!actionUrl.isNullOrBlank()) { @@ -56,7 +49,7 @@ class HmsPushService : HmsMessageService() { val channelId = "hms_push_channel" val notification = NotificationCompat.Builder(context, channelId) - .setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения + .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(message.notification?.title ?: context.getString(R.string.app_name)) .setContentText(message.notification?.body ?: "") .setAutoCancel(true) @@ -65,83 +58,4 @@ class HmsPushService : HmsMessageService() { NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification) } - - private fun isIncomingVideoCall(data: Map): Boolean { - val rawType = data["type"] - ?: data["notification_type"] - ?: data["event"] - ?: data["action"] - ?: data["push_type"] - ?: data["call_type"] - val type = rawType?.lowercase()?.replace("-", "_") - return type?.contains("video_call") == true && - !type.contains("end") && - !type.contains("ended") - } - - private fun showIncomingVideoCallNotification(data: Map) { - val conversationUuid = data["conversationUuid"] - ?: data["conversation_uuid"] - ?: data["conversation_id"] - ?: data["chat_uuid"] - ?: data["uuid"] - ?: return - val callerName = data["counterpartName"] - ?: data["counterpart_name"] - ?: data["caller_name"] - ?: data["callerName"] - ?: data["nameCaller"] - ?: data["caller"] - ?: "Итвио" - val avatarUrl = data["counterpartAvatarUrl"] - ?: data["counterpart_avatar_url"] - ?: data["avatar"] - ?: data["avatarUrl"] - ?: data["avatar_url"] - ?: data["caller_avatar"] - ?: "" - val localParticipantName = data["localParticipantName"] - ?: data["local_participant_name"] - - val extra = HashMap() - extra["conversationUuid"] = conversationUuid - extra["counterpartName"] = callerName - if (avatarUrl.isNotBlank()) { - extra["counterpartAvatarUrl"] = avatarUrl - } - if (!localParticipantName.isNullOrBlank()) { - extra["localParticipantName"] = localParticipantName - } - - val callData = Bundle().apply { - putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) - putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) - putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") - putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) - putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl) - putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) - putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L) - putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer") - putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline") - putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra) - putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap()) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false) - putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default") - putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, "#2787F5") - putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, "#2787F5") - putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff") - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false) - } - - val intent = com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver - .getIntent(this, CallkitConstants.ACTION_CALL_INCOMING, callData) - .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) - sendBroadcast(intent) - } } - - diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt new file mode 100644 index 00000000..b4272684 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -0,0 +1,163 @@ +package com.eatvioapp.app + +import android.content.Context +import android.os.Bundle +import android.util.Log +import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager +import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager +import com.hiennv.flutter_callkit_incoming.Data +import com.hiennv.flutter_callkit_incoming.addCall +import java.util.HashMap + +object VideoCallPushBridge { + private const val TAG = "VideoCallPushBridge" + private const val ANDROID_ACCENT_COLOR = "#2787F5" + + fun isIncomingVideoCall(data: Map): Boolean { + val rawType = data["type"] + ?: data["notification_type"] + ?: data["event"] + ?: data["action"] + ?: data["push_type"] + ?: data["call_type"] + + if (rawType != null) { + val normalized = rawType.lowercase().replace("-", "_") + if (normalized.contains("video_call")) { + return !normalized.contains("end") && !normalized.contains("ended") + } + if (normalized == "incoming_call" || + normalized == "video_call_incoming" || + normalized == "incoming_video_call" + ) { + return true + } + } + + if (isTruthy(data["is_video_call"]) || + isTruthy(data["incoming_call"]) || + isTruthy(data["is_incoming_call"]) + ) { + return conversationUuid(data) != null + } + + val actionUrl = data["action_url"] + if (!actionUrl.isNullOrBlank() && + (actionUrl.contains("video-call") || actionUrl.contains("videoCall")) + ) { + return true + } + + return false + } + + fun showIncomingCall(context: Context, data: Map) { + val appContext = context.applicationContext + val callData = buildCallDataBundle(data) + if (callData == null) { + Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data") + return + } + + Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}") + + val soundManager = CallkitSoundPlayerManager(appContext) + val notificationManager = CallkitNotificationManager(appContext, soundManager) + notificationManager.createNotificationChanel(callData) + notificationManager.showIncomingNotification(callData) + + try { + addCall(appContext, Data.fromBundle(callData)) + } catch (error: Exception) { + Log.w(TAG, "showIncomingCall: failed to persist active call", error) + } + } + + private fun buildCallDataBundle(data: Map): Bundle? { + val conversationUuid = conversationUuid(data) ?: return null + val callerName = data["counterpartName"] + ?: data["counterpart_name"] + ?: data["caller_name"] + ?: data["callerName"] + ?: data["nameCaller"] + ?: data["caller"] + ?: "Итвио" + val avatarUrl = data["counterpartAvatarUrl"] + ?: data["counterpart_avatar_url"] + ?: data["avatar"] + ?: data["avatarUrl"] + ?: data["avatar_url"] + ?: data["caller_avatar"] + ?: "" + val localParticipantName = data["localParticipantName"] + ?: data["local_participant_name"] + + val extra = HashMap() + extra["conversationUuid"] = conversationUuid + extra["counterpartName"] = callerName + if (avatarUrl.isNotBlank()) { + extra["counterpartAvatarUrl"] = avatarUrl + } + if (!localParticipantName.isNullOrBlank()) { + extra["localParticipantName"] = localParticipantName + } + + return Bundle().apply { + putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) + putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") + putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl) + putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) + putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L) + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer") + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline") + putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra) + putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap()) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false) + putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default") + putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, ANDROID_ACCENT_COLOR) + putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, ANDROID_ACCENT_COLOR) + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff") + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_CALLING_SHOW, false) + } + } + + private fun conversationUuid(data: Map): String? { + val fromData = data["conversationUuid"] + ?: data["conversation_uuid"] + ?: data["conversation_id"] + ?: data["chat_uuid"] + ?: data["uuid"] + if (!fromData.isNullOrBlank()) { + return fromData + } + + val actionUrl = data["action_url"] ?: return null + val uuidFromQuery = Regex("""[?&](?:uuid|conversationUuid|conversation_uuid)=([^&]+)""") + .find(actionUrl) + ?.groupValues + ?.getOrNull(1) + ?.trim() + if (!uuidFromQuery.isNullOrEmpty()) { + return uuidFromQuery + } + + val pathMatch = Regex("""/video-call/([^/?#]+)""").find(actionUrl) + return pathMatch?.groupValues?.getOrNull(1)?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun isTruthy(value: String?): Boolean { + if (value.isNullOrBlank()) return false + return when (value.trim().lowercase()) { + "1", "true", "yes" -> true + else -> false + } + } +} diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 4e6ff7ee..499165e1 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -74,7 +74,10 @@ class CubitRegistrations { sl.registerLazySingleton(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl())); sl.registerLazySingleton(() => InternetConnectionCubit()); sl.registerLazySingleton( - () => VideoCallManager(videoCallRepository: sl()), + () => VideoCallManager( + videoCallRepository: sl(), + profileRepository: sl(), + ), ); sl.registerLazySingleton( () => VideoCallSessionCubit( diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index 1c3b98a8..ad0286a1 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'dart:async'; import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -68,6 +69,7 @@ class PushNavigationHandler { counterpartName: videoCallPayload.counterpartName, counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl, localParticipantName: videoCallPayload.localParticipantName, + entryMode: VideoCallEntryMode.incomingAccepted, ), ); return; diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 42e29f10..cd139b77 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -15,6 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.da import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart'; @@ -236,6 +237,7 @@ class OtherRoutes { counterpartName: extra['counterpartName'] as String?, counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?, localParticipantName: extra['localParticipantName'] as String?, + entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']), onBackToChat: extra['onBackToChat'] as void Function()?, onHangUp: extra['onHangUp'] as void Function()?, ); @@ -330,3 +332,11 @@ class OtherRoutes { ]; } } + +VideoCallEntryMode _videoCallEntryModeFromRouteExtra(Object? raw) { + if (raw is VideoCallEntryMode) return raw; + if (raw == VideoCallEntryMode.incomingAccepted.name) { + return VideoCallEntryMode.incomingAccepted; + } + return VideoCallEntryMode.outgoing; +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 92fade24..6c967381 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1,10 +1,8 @@ import 'dart:async'; -import 'dart:io'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; -import 'package:audio_session/audio_session.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart'; @@ -14,6 +12,7 @@ import 'package:eatvio_app/features/video_calls/services/video_call_task_handler import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; +import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; @@ -25,10 +24,12 @@ typedef VideoCallManagerListener = void Function(VideoCallManagerSnapshot snapsh class VideoCallManager { VideoCallManager({ required VideoCallRepository videoCallRepository, + ProfileRepository? profileRepository, VideoCallForegroundCoordinator? foregroundCoordinator, VideoCallLifecycleBinding? lifecycleBinding, VideoCallAudioSessionCoordinator? audioSessionCoordinator, }) : _videoCallRepository = videoCallRepository, + _profileRepository = profileRepository, _foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(), _lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(), _audioSession = @@ -37,6 +38,7 @@ class VideoCallManager { } final VideoCallRepository _videoCallRepository; + final ProfileRepository? _profileRepository; final VideoCallForegroundCoordinator _foreground; final VideoCallLifecycleBinding _lifecycleBinding; final VideoCallAudioSessionCoordinator _audioSession; @@ -47,9 +49,6 @@ class VideoCallManager { static const Duration _mutedMicOverlayMinInterval = Duration( milliseconds: 2600, ); - static const Duration _localMicActivationInterruptionGrace = Duration( - milliseconds: 1500, - ); static const double _mutedMicAudioLevelThreshold = 0.008; static const double _mutedMicEnergyDelta = 1e-6; @@ -67,14 +66,13 @@ class VideoCallManager { bool _callEndedNotified = true; bool _callStageEntered = false; bool _joinInProgress = false; + bool _counterpartJoinedNotified = false; + Future? _roomConnectFuture; String? _preparedWsUrl; String? _preparedToken; bool _videoEnabledBeforeBackground = false; bool _videoPausedForBackground = false; - bool? _micEnabledBeforeSystemInterruption; - int _audioInterruptionHoldCount = 0; - DateTime? _ignoreAudioInterruptionUntil; - OverlayEntry? _systemInterruptionEntry; + Future? _myProfileNameFuture; VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial; @@ -128,11 +126,6 @@ class VideoCallManager { @visibleForTesting void setCallStageEnteredForTesting(bool value) => _callStageEntered = value; - @visibleForTesting - void handleAudioInterruptionForTesting(AudioInterruptionEvent event) { - unawaited(_onAudioInterruption(event)); - } - void dispose() { FlutterForegroundTask.removeTaskDataCallback(_onForegroundTaskData); _lifecycleBinding.detach(); @@ -228,11 +221,15 @@ class VideoCallManager { _startMutedMicPollTimer(); _ensureLifecycleAttached(); + final resolvedLocalName = await _resolveLocalParticipantName( + localParticipantName, + ); + _emitSnapshot( VideoCallManagerSnapshot( conversationUuid: conversationUuid, counterpartName: counterpartName, - localParticipantName: localParticipantName, + localParticipantName: resolvedLocalName, micEnabled: micEnabled, videoEnabled: videoEnabled, connectError: null, @@ -269,6 +266,10 @@ class VideoCallManager { localParticipantName ?? _snapshot.localParticipantName, ), ); + if (_room?.connectionState == lk.ConnectionState.connected) { + unawaited(_ensureLocalParticipantNamePublished()); + _syncRemoteParticipantNameFromRoom(); + } } } @@ -279,7 +280,7 @@ class VideoCallManager { _joinInProgress = true; try { - await _joinLiveKitRoom(); + await _ensureRoomConnected(); if (_liveKitDisposed) return false; _callStageEntered = true; @@ -301,6 +302,64 @@ class VideoCallManager { } } + /// Joins the LiveKit room without publishing local media so the outgoing + /// caller can detect when the callee enters. + Future ensureLobbyRoomJoined() async { + if (_callStageEntered || _joinInProgress || _liveKitDisposed) return; + if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; + + final room = _room; + if (room != null && room.connectionState == lk.ConnectionState.connected) { + _notifyCounterpartIfPresent(); + return; + } + + try { + await _ensureRoomConnected(); + if (!_liveKitDisposed) { + _notifyCounterpartIfPresent(); + } + } catch (_) {} + } + + Future _ensureRoomConnected() async { + final room = _room; + if (room != null && room.connectionState == lk.ConnectionState.connected) { + return; + } + + final pending = _roomConnectFuture; + if (pending != null) { + await pending; + return; + } + + final run = _joinLiveKitRoom(); + _roomConnectFuture = run; + try { + await run; + } finally { + if (identical(_roomConnectFuture, run)) { + _roomConnectFuture = null; + } + } + } + + void _notifyCounterpartIfPresent() { + if (_callStageEntered || _counterpartJoinedNotified || _liveKitDisposed) { + return; + } + final room = _room; + if (room == null || + room.connectionState != lk.ConnectionState.connected) { + return; + } + if (room.remoteParticipants.isEmpty) return; + + _counterpartJoinedNotified = true; + _emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: true)); + } + Future retryAfterConnectionLoss() async { if (_snapshot.conversationUuid.isEmpty) return; _disposeRoomEphemerals(); @@ -344,9 +403,8 @@ class VideoCallManager { _lifecycleBinding.detach(); _callStageEntered = false; _videoPausedForBackground = false; - _micEnabledBeforeSystemInterruption = null; - _audioInterruptionHoldCount = 0; _liveKitDisposed = true; + _myProfileNameFuture = null; _emitSnapshot(VideoCallManagerSnapshot.initial); } @@ -372,26 +430,7 @@ class VideoCallManager { /// [currentUiEnabled] reflects what the user sees on the mic control /// ([VideoCallSessionState.isMicActiveForUi]), not only stored intent. Future toggleMic(bool currentUiEnabled) async { - if (!currentUiEnabled && - _snapshot.systemAudioInterrupted && - _snapshot.micEnabled && - _isLocalMicrophonePublishing()) { - _clearSystemInterruptionState(); - _lastMutedMicTotalEnergy = null; - return; - } final next = !currentUiEnabled; - if (_snapshot.systemAudioInterrupted || _audioInterruptionHoldCount > 0) { - if (next) { - _micEnabledBeforeSystemInterruption = true; - } else { - _micEnabledBeforeSystemInterruption = false; - _emitSnapshot(_snapshot.copyWith(micEnabled: false)); - await _pauseLocalAudioForSystemInterruption(); - } - _lastMutedMicTotalEnergy = null; - return; - } if (!_callStageEntered) { _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; @@ -410,9 +449,6 @@ class VideoCallManager { } _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; - if (next) { - _suppressOwnMicActivationInterruptions(); - } await _room!.localParticipant?.setMicrophoneEnabled( next, audioCaptureOptions: _micCaptureOptions, @@ -499,30 +535,81 @@ class VideoCallManager { } String resolvedLocalDisplayName() { - final fromNav = _snapshot.localParticipantName?.trim(); - if (fromNav != null && fromNav.isNotEmpty) return fromNav; - final n = (_room?.localParticipant?.name ?? '').trim(); - if (n.isNotEmpty) return n; - final ctx = _navigatorContext; - if (ctx != null) { - return S.of(ctx).video_call_you; - } - return 'You'; + final fromSnapshot = _snapshot.localParticipantName?.trim(); + if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; + final fromRoom = (_room?.localParticipant?.name ?? '').trim(); + if (fromRoom.isNotEmpty) return fromRoom; + return ''; } - String resolvedRemoteDisplayName(String fallback) { - final c = _snapshot.counterpartName?.trim(); - if (c != null && c.isNotEmpty) return c; + String resolvedRemoteDisplayName([String fallback = '']) { + final fromSnapshot = _snapshot.counterpartName?.trim(); + if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; final room = _room; if (room != null) { - for (final p in room.remoteParticipants.values) { - final n = p.name.trim(); - if (n.isNotEmpty) return n; + for (final participant in room.remoteParticipants.values) { + final name = participant.name.trim(); + if (name.isNotEmpty) return name; } } return fallback; } + Future _resolveLocalParticipantName(String? hint) async { + final fromHint = hint?.trim(); + if (fromHint != null && fromHint.isNotEmpty) return fromHint; + final fromSnapshot = _snapshot.localParticipantName?.trim(); + if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; + return _fetchMyProfileName(); + } + + Future _fetchMyProfileName() async { + _myProfileNameFuture ??= _loadMyProfileName(); + return _myProfileNameFuture; + } + + Future _loadMyProfileName() async { + final repository = _profileRepository; + if (repository == null) return null; + try { + final name = (await repository.getMyProfile()).name.trim(); + return name.isEmpty ? null : name; + } catch (_) { + return null; + } + } + + Future _ensureLocalParticipantNamePublished() async { + var name = _snapshot.localParticipantName?.trim(); + if (name == null || name.isEmpty) { + name = await _resolveLocalParticipantName(null); + if (name != null && name.isNotEmpty) { + _emitSnapshot(_snapshot.copyWith(localParticipantName: name)); + } + } + _applyLocalParticipantNameToLiveKit(); + } + + void _applyLocalParticipantNameToLiveKit() { + final name = _snapshot.localParticipantName?.trim(); + if (name == null || name.isEmpty) return; + try { + _room?.localParticipant?.setName(name); + } catch (_) {} + } + + void _syncRemoteParticipantNameFromRoom() { + if (_snapshot.counterpartName?.trim().isNotEmpty == true) return; + final room = _room; + if (room == null) return; + for (final participant in room.remoteParticipants.values) { + final name = participant.name.trim(); + if (name.isEmpty) continue; + _emitSnapshot(_snapshot.copyWith(counterpartName: name)); + return; + } + } + void _ensureLifecycleAttached() { _lifecycleBinding.attach(_onAppLifecycleStateChanged); } @@ -622,13 +709,15 @@ class VideoCallManager { _lastMutedMicOverlayAt = null; _lastMutedMicTotalEnergy = null; _recordingOverlayScheduled = false; - _micEnabledBeforeSystemInterruption = null; - _audioInterruptionHoldCount = 0; - _ignoreAudioInterruptionUntil = null; _joinInProgress = false; + _counterpartJoinedNotified = false; + _roomConnectFuture = null; _clearPreparedConnectionCredentials(); - _syncSystemInterruptionOverlay(show: false); - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); + _emitSnapshot( + _snapshot.copyWith( + counterpartPresentInLobby: false, + ), + ); } void _clearPreparedConnectionCredentials() { @@ -657,10 +746,6 @@ class VideoCallManager { if (_liveKitDisposed || _mutedMicStatsInFlight) { return; } - if (_snapshot.systemAudioInterrupted && _snapshot.micEnabled) { - unawaited(_maybeClearStaleSystemInterruptionFromStats()); - return; - } if (_snapshot.micEnabled) { return; } @@ -706,6 +791,10 @@ class VideoCallManager { if (e.speakers.any((p) => p.sid == lp.sid)) { _maybeShowMutedMicOverlay(); } + }) + ..on((_) { + _notifyCounterpartIfPresent(); + _syncRemoteParticipantNameFromRoom(); }); } @@ -847,6 +936,9 @@ class VideoCallManager { await room.connect(wsUrl, token); if (_liveKitDisposed) return; + await _ensureLocalParticipantNamePublished(); + _syncRemoteParticipantNameFromRoom(); + _syncPoorConnectionOverlay( room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown, @@ -995,224 +1087,16 @@ class VideoCallManager { _lastMutedMicTotalEnergy = null; } - bool _isLocalMicrophonePublishing() { - final room = _room; - if (room == null || _liveKitDisposed) return false; - final lp = room.localParticipant; - if (lp == null) return false; - final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); - if (pub == null || pub.muted) return false; - final track = pub.track; - if (track is lk.LocalAudioTrack) { - return !track.muted; - } - return track != null; - } - - Future _maybeClearStaleSystemInterruptionFromStats() async { - if (!_snapshot.systemAudioInterrupted) return; - if (!_snapshot.micEnabled || !_callStageEntered) return; - if (!_isLocalMicrophonePublishing()) return; - final room = _room; - if (room == null || _liveKitDisposed) return; - final lp = room.localParticipant; - if (lp == null) return; - final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); - final track = pub?.track; - if (track is! lk.LocalAudioTrack) return; - try { - final stats = await track.getSenderStats(); - if (_liveKitDisposed || !_snapshot.systemAudioInterrupted) return; - final src = stats?.audioSourceStats; - if (src == null) return; - final level = src.audioLevel; - if (level != null && level >= _mutedMicAudioLevelThreshold) { - _clearSystemInterruptionState(); - return; - } - final energy = src.totalAudioEnergy; - if (energy == null || _lastMutedMicTotalEnergy == null) { - _lastMutedMicTotalEnergy = energy; - return; - } - if (energy - _lastMutedMicTotalEnergy! >= _mutedMicEnergyDelta) { - _clearSystemInterruptionState(); - } - _lastMutedMicTotalEnergy = energy; - } catch (_) {} - } - - void _clearSystemInterruptionState() { - if (!_snapshot.systemAudioInterrupted && _audioInterruptionHoldCount == 0) { - return; - } - _audioInterruptionHoldCount = 0; - _micEnabledBeforeSystemInterruption = null; - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); - _syncSystemInterruptionOverlay(show: false); - } - Future _startCallAudioSession() async { if (!_canHandleCallAudio) return; try { - await _audioSession.start(onInterruption: _onAudioInterruption); + await _audioSession.start(); } catch (_) {} } Future _stopCallAudioSession() async { - _syncSystemInterruptionOverlay(show: false); try { await _audioSession.stop(); } catch (_) {} } - - Future _onAudioInterruption(AudioInterruptionEvent event) async { - if (!_callStageEntered || - _liveKitDisposed || - _room == null || - _snapshot.connectPhase != VideoCallConnectPhase.connected) { - return; - } - if (_shouldIgnoreAudioInterruption(event)) { - return; - } - if (event.begin) { - _audioInterruptionHoldCount++; - if (_audioInterruptionHoldCount == 1) { - await _handleAudioInterruptionBegin(); - } else if (_snapshot.micEnabled) { - await _pauseLocalAudioForSystemInterruption(); - } - return; - } - if (_audioInterruptionHoldCount <= 0) { - if (_snapshot.micEnabled) { - await _restoreLocalAudioAfterSystemInterruption(); - } - return; - } - _audioInterruptionHoldCount--; - if (_audioInterruptionHoldCount == 0) { - await _handleAudioInterruptionEnd(); - } - } - - Future _handleAudioInterruptionBegin() async { - if (_snapshot.systemAudioInterrupted) return; - _micEnabledBeforeSystemInterruption ??= _snapshot.micEnabled; - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: true)); - _syncSystemInterruptionOverlay(show: true); - await _pauseLocalAudioForSystemInterruption(); - } - - Future _pauseLocalAudioForSystemInterruption() async { - final room = _room; - if (room == null || _liveKitDisposed) return; - try { - await room.localParticipant?.setMicrophoneEnabled( - false, - audioCaptureOptions: _micCaptureOptions, - ); - } catch (_) {} - } - - Future _handleAudioInterruptionEnd() async { - if (!_snapshot.systemAudioInterrupted) return; - await _audioSession.reactivate(); - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); - _syncSystemInterruptionOverlay(show: false); - final restoreMic = - _micEnabledBeforeSystemInterruption ?? _snapshot.micEnabled; - _micEnabledBeforeSystemInterruption = null; - if (restoreMic) { - await _restoreLocalAudioAfterSystemInterruption(); - } - } - - Future _restoreLocalAudioAfterSystemInterruption() async { - if (!_canHandleCallAudio || !_snapshot.micEnabled) return; - final lp = _room!.localParticipant; - if (lp == null) return; - - try { - _suppressOwnMicActivationInterruptions(); - await lp.setMicrophoneEnabled( - true, - audioCaptureOptions: _micCaptureOptions, - ); - } catch (_) { - await _restartLocalAudioTrack(); - return; - } - - final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); - final track = pub?.track; - if (track is lk.LocalAudioTrack && track.muted) { - await _restartLocalAudioTrack(); - } - } - - void _suppressOwnMicActivationInterruptions() { - if (!Platform.isAndroid) return; - _ignoreAudioInterruptionUntil = DateTime.now().add( - _localMicActivationInterruptionGrace, - ); - } - - bool _shouldIgnoreAudioInterruption(AudioInterruptionEvent event) { - if (!Platform.isAndroid || !event.begin) return false; - final until = _ignoreAudioInterruptionUntil; - if (until == null) return false; - if (DateTime.now().isBefore(until)) { - return true; - } - _ignoreAudioInterruptionUntil = null; - return false; - } - - Future _restartLocalAudioTrack() async { - if (!_canHandleCallAudio || !_snapshot.micEnabled) return; - final lp = _room!.localParticipant; - if (lp == null) return; - final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); - final track = pub?.track; - if (track is lk.LocalAudioTrack) { - try { - _suppressOwnMicActivationInterruptions(); - await track.restartTrack(); - return; - } catch (_) {} - } - try { - await lp.setMicrophoneEnabled( - false, - audioCaptureOptions: _micCaptureOptions, - ); - _suppressOwnMicActivationInterruptions(); - await lp.setMicrophoneEnabled( - true, - audioCaptureOptions: _micCaptureOptions, - ); - } catch (_) {} - } - - void _syncSystemInterruptionOverlay({required bool show}) { - final ctx = _navigatorContext; - if (ctx == null) return; - if (show) { - if (_systemInterruptionEntry != null) return; - _systemInterruptionEntry = VideoCallTopOverlay.insertBanner( - ctx, - message: S.of(ctx).video_call_system_interruption, - backgroundColor: appTheme.videoCallNoticeBannerBackground, - svgAsset: VideoCallTopOverlay.warningSvg, - ); - return; - } - final entry = _systemInterruptionEntry; - _systemInterruptionEntry = null; - if (entry != null && entry.mounted) { - entry.remove(); - } - } } diff --git a/lib/features/video_calls/managers/video_call_manager_snapshot.dart b/lib/features/video_calls/managers/video_call_manager_snapshot.dart index c798c4f5..86a16070 100644 --- a/lib/features/video_calls/managers/video_call_manager_snapshot.dart +++ b/lib/features/video_calls/managers/video_call_manager_snapshot.dart @@ -10,7 +10,7 @@ class VideoCallManagerSnapshot { this.connectError, required this.connectPhase, required this.linkIssue, - this.systemAudioInterrupted = false, + this.counterpartPresentInLobby = false, }); final String conversationUuid; @@ -21,7 +21,7 @@ class VideoCallManagerSnapshot { final String? connectError; final VideoCallConnectPhase connectPhase; final VideoCallLinkIssue linkIssue; - final bool systemAudioInterrupted; + final bool counterpartPresentInLobby; static const initial = VideoCallManagerSnapshot( conversationUuid: '', @@ -40,7 +40,7 @@ class VideoCallManagerSnapshot { String? connectError, VideoCallConnectPhase? connectPhase, VideoCallLinkIssue? linkIssue, - bool? systemAudioInterrupted, + bool? counterpartPresentInLobby, bool clearConnectError = false, }) { return VideoCallManagerSnapshot( @@ -52,8 +52,8 @@ class VideoCallManagerSnapshot { connectError: clearConnectError ? null : (connectError ?? this.connectError), connectPhase: connectPhase ?? this.connectPhase, linkIssue: linkIssue ?? this.linkIssue, - systemAudioInterrupted: - systemAudioInterrupted ?? this.systemAudioInterrupted, + counterpartPresentInLobby: + counterpartPresentInLobby ?? this.counterpartPresentInLobby, ); } } diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index a7c73fc8..332d4fcf 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -8,6 +8,8 @@ import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_c import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:meta/meta.dart'; +import 'package:permission_handler/permission_handler.dart'; class VideoCallSessionCubit extends BaseCubit { VideoCallSessionCubit({ @@ -24,6 +26,9 @@ class VideoCallSessionCubit extends BaseCubit { final VideoCallManager _videoCallManager; final VideoCallSystemPipCoordinator _systemPipCoordinator; bool _callScreenRouteVisible = false; + bool _autoEnterInFlight = false; + Future? _autoEnterFuture; + Future? _outgoingAutoEnterFuture; lk.Room? get liveKitRoom => _videoCallManager.room; bool get isCallScreenRouteVisible => _callScreenRouteVisible; @@ -52,7 +57,6 @@ class VideoCallSessionCubit extends BaseCubit { connectError: hideStaleConnectError ? null : snapshot.connectError, connectPhase: snapshot.connectPhase, linkIssue: snapshot.linkIssue, - systemAudioInterrupted: snapshot.systemAudioInterrupted, callStageEntered: resetCallStage ? false : state.callStageEntered, uiMode: _isCallSessionInactive(snapshot) @@ -64,16 +68,24 @@ class VideoCallSessionCubit extends BaseCubit { void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) { if (isClosed) return; if (_isCallSessionInactive(snapshot)) { + if (_callScreenRouteVisible) { + // [VideoCallManager.startCall] briefly emits idle while replacing the room. + return; + } unawaited(_systemPipCoordinator.disarm()); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); return; } - emit(_sessionStateFromSnapshot(snapshot)); + final next = _sessionStateFromSnapshot(snapshot); + emit(next); + _scheduleAutoEnterIfNeeded(next); + _scheduleOutgoingAutoEnterIfNeeded(next, snapshot); } void _syncFromManager(VideoCallManagerSnapshot snapshot) { if (_isCallSessionInactive(snapshot)) { + if (_callScreenRouteVisible) return; emit(const VideoCallSessionState()); return; } @@ -111,12 +123,49 @@ class VideoCallSessionCubit extends BaseCubit { required String conversationUuid, String? counterpartName, String? localParticipantName, + VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { _callScreenRouteVisible = true; + + final effectiveEntryMode = + state.entryMode == VideoCallEntryMode.incomingAccepted || + entryMode == VideoCallEntryMode.incomingAccepted + ? VideoCallEntryMode.incomingAccepted + : entryMode; + final isIncomingAccepted = + effectiveEntryMode == VideoCallEntryMode.incomingAccepted; final isResumingActiveCall = state.conversationUuid == conversationUuid && state.callStageEntered && _videoCallManager.room != null; + final isSameCallConnecting = + state.conversationUuid == conversationUuid && + !state.callStageEntered && + state.connectPhase != VideoCallConnectPhase.idle; + + if (!isResumingActiveCall && !isSameCallConnecting) { + await _systemPipCoordinator.disarm(); + } + + if (isSameCallConnecting) { + emit( + state.copyWith( + counterpartName: counterpartName ?? state.counterpartName, + localParticipantName: + localParticipantName ?? state.localParticipantName, + entryMode: effectiveEntryMode, + ), + ); + if (isIncomingAccepted) { + await _tryAutoEnterCallStage(); + } + emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); + await _ensureSystemPipArmedIfInCall(); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_applyVideoCallSystemUi()); + }); + return; + } if (isResumingActiveCall) { await _videoCallManager.onCallScreenVisible( @@ -137,6 +186,8 @@ class VideoCallSessionCubit extends BaseCubit { callStageEntered: false, connectError: null, linkIssue: VideoCallLinkIssue.none, + connectPhase: VideoCallConnectPhase.connecting, + entryMode: effectiveEntryMode, ), ); await _videoCallManager.startCall( @@ -146,17 +197,137 @@ class VideoCallSessionCubit extends BaseCubit { micEnabled: false, videoEnabled: false, ); + emit(state.copyWith(entryMode: effectiveEntryMode)); + if (isIncomingAccepted) { + await _tryAutoEnterCallStage(); + } } emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); + await _ensureSystemPipArmedIfInCall(); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_applyVideoCallSystemUi()); }); } + Future _ensureSystemPipArmedIfInCall() async { + if (!state.callStageEntered) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + if (state.linkIssue == VideoCallLinkIssue.lost) return; + await _systemPipCoordinator.armAutoPipOnLeave(); + } + + void _scheduleAutoEnterIfNeeded(VideoCallSessionState sessionState) { + if (sessionState.entryMode != VideoCallEntryMode.incomingAccepted) return; + if (sessionState.callStageEntered) return; + if (sessionState.connectPhase != VideoCallConnectPhase.connected) return; + unawaited(_tryAutoEnterCallStage()); + } + + void _scheduleOutgoingAutoEnterIfNeeded( + VideoCallSessionState sessionState, + VideoCallManagerSnapshot snapshot, + ) { + if (sessionState.entryMode != VideoCallEntryMode.outgoing) return; + if (sessionState.callStageEntered) return; + if (sessionState.connectPhase != VideoCallConnectPhase.connected) return; + + if (!snapshot.counterpartPresentInLobby) { + unawaited(_videoCallManager.ensureLobbyRoomJoined()); + return; + } + unawaited(_tryAutoEnterWhenCounterpartJoins()); + } + + Future _tryAutoEnterWhenCounterpartJoins() { + final pending = _outgoingAutoEnterFuture; + if (pending != null) return pending; + + final run = _runOutgoingAutoEnterCallStage(); + _outgoingAutoEnterFuture = run; + return run.whenComplete(() { + if (identical(_outgoingAutoEnterFuture, run)) { + _outgoingAutoEnterFuture = null; + } + }); + } + + Future _runOutgoingAutoEnterCallStage() async { + if (isClosed || _autoEnterInFlight) return; + if (state.entryMode != VideoCallEntryMode.outgoing) return; + if (state.callStageEntered) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + + _autoEnterInFlight = true; + try { + await enterCallStage(); + } finally { + _autoEnterInFlight = false; + } + } + + Future _tryAutoEnterCallStage() { + final pending = _autoEnterFuture; + if (pending != null) return pending; + + final run = _runAutoEnterCallStage(); + _autoEnterFuture = run; + return run.whenComplete(() { + if (identical(_autoEnterFuture, run)) { + _autoEnterFuture = null; + } + }); + } + + Future _runAutoEnterCallStage() async { + if (isClosed || _autoEnterInFlight) return; + if (state.entryMode != VideoCallEntryMode.incomingAccepted) return; + if (state.callStageEntered) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + + _autoEnterInFlight = true; + try { + await _requestMicBeforeIncomingEnter(); + await enterCallStage(); + } finally { + _autoEnterInFlight = false; + } + } + + /// After the loading/connect phase, request mic before joining the room. + Future _requestMicBeforeIncomingEnter() async { + if (state.entryMode != VideoCallEntryMode.incomingAccepted) return; + + final granted = await _requestMicPermissionForCall(); + if (!granted || isClosed) return; + + await _videoCallManager.toggleMic(state.isMicActiveForUi); + if (isClosed) return; + emit(state.copyWith(micEnabled: true)); + } + + @visibleForTesting + Future requestMicPermissionForCall() => _requestMicPermissionForCall(); + + Future _requestMicPermissionForCall() async { + try { + final status = await Permission.microphone.request(); + return status.isGranted; + } catch (_) { + return false; + } + } + void onCallScreenDisposed() { _callScreenRouteVisible = false; - if (state.uiMode != VideoCallUiMode.fullscreen) return; + if (state.uiMode != VideoCallUiMode.fullscreen) { + if (state.uiMode == VideoCallUiMode.pip && state.callStageEntered) { + // [isCallScreenRouteVisible] is not in bloc state; refresh the global + // PiP overlay after the call route is removed. + emit(state.copyWith()); + } + return; + } if (state.connectPhase == VideoCallConnectPhase.connected && state.linkIssue != VideoCallLinkIssue.lost) { if (state.callStageEntered) { @@ -179,15 +350,29 @@ class VideoCallSessionCubit extends BaseCubit { Future _applyPipUiState() async { emit(state.copyWith(uiMode: VideoCallUiMode.pip)); await _restoreSystemUiAfterCall(); + await _ensureSystemPipArmedIfInCall(); } /// Called when Android enters system PiP (e.g. Home gesture). Future syncPipUiStateFromSystem() async { if (!_canMinimizeToPip) return; if (state.uiMode == VideoCallUiMode.pip) return; + // Call screen already renders the in-call mini preview; enabling the global + // overlay here would duplicate PiP after returning from system PiP. + if (_callScreenRouteVisible) return; await _applyPipUiState(); } + /// Re-applies immersive UI after Android system PiP while the call screen + /// route is still on the stack. + Future onSystemPipExitedWithCallScreenVisible() async { + if (!_callScreenRouteVisible) return; + if (state.uiMode != VideoCallUiMode.fullscreen) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_applyVideoCallSystemUi()); + }); + } + Future minimizeToPip() async { if (!_canMinimizeToPip) return; await _applyPipUiState(); @@ -206,6 +391,7 @@ class VideoCallSessionCubit extends BaseCubit { if (state.connectPhase == VideoCallConnectPhase.connected && state.linkIssue != VideoCallLinkIssue.lost) { if (state.callStageEntered) { + _callScreenRouteVisible = false; await minimizeToPip(); } else { await endCall(); @@ -223,7 +409,13 @@ class VideoCallSessionCubit extends BaseCubit { final entered = await _videoCallManager.onEnterCallStage(); if (!entered || isClosed) return; - emit(state.copyWith(callStageEntered: true)); + emit( + state.copyWith( + callStageEntered: true, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + ), + ); unawaited(_systemPipCoordinator.armAutoPipOnLeave()); WidgetsBinding.instance.addPostFrameCallback((_) { if (isClosed) return; @@ -232,8 +424,9 @@ class VideoCallSessionCubit extends BaseCubit { } Future retryAfterConnectionLoss() async { + final entryMode = state.entryMode; await _videoCallManager.retryAfterConnectionLoss(); - emit(state.copyWith(callStageEntered: false)); + emit(state.copyWith(callStageEntered: false, entryMode: entryMode)); } Future endCall() async { @@ -309,7 +502,7 @@ class VideoCallSessionCubit extends BaseCubit { String resolvedLocalDisplayName() => _videoCallManager.resolvedLocalDisplayName(); - String resolvedRemoteDisplayName(String fallback) => + String resolvedRemoteDisplayName([String fallback = '']) => _videoCallManager.resolvedRemoteDisplayName(fallback); @override diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart index a7c38545..588a01ff 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart @@ -12,6 +12,15 @@ enum VideoCallConnectPhase { idle, connecting, connected } enum VideoCallLinkIssue { none, reconnecting, lost } +/// How the user reached [VideoCallScreen]. +enum VideoCallEntryMode { + /// Outgoing call or push tap — lobby with manual enter. + outgoing, + + /// Accepted incoming CallKit / VoIP / notification — loader then auto-enter. + incomingAccepted, +} + @freezed abstract class VideoCallSessionState with _$VideoCallSessionState { const VideoCallSessionState._(); @@ -22,7 +31,7 @@ abstract class VideoCallSessionState with _$VideoCallSessionState { String? localParticipantName, @Default(false) bool micEnabled, @Default(false) bool videoEnabled, - @Default(true) bool splitParticipantView, + @Default(false) bool splitParticipantView, @Default(false) bool mainStageShowsLocalVideo, @Default(true) bool useFrontCamera, String? connectError, @@ -30,8 +39,8 @@ abstract class VideoCallSessionState with _$VideoCallSessionState { @Default(VideoCallConnectPhase.idle) VideoCallConnectPhase connectPhase, @Default(VideoCallLinkIssue.none) VideoCallLinkIssue linkIssue, @Default(false) bool callStageEntered, - @Default(false) bool systemAudioInterrupted, + @Default(VideoCallEntryMode.outgoing) VideoCallEntryMode entryMode, }) = _VideoCallSessionState; - bool get isMicActiveForUi => micEnabled && !systemAudioInterrupted; + bool get isMicActiveForUi => micEnabled; } diff --git a/lib/features/video_calls/presentation/pages/video_call_screen.dart b/lib/features/video_calls/presentation/pages/video_call_screen.dart index e225cf87..b8aefe95 100644 --- a/lib/features/video_calls/presentation/pages/video_call_screen.dart +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -8,6 +8,7 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -21,6 +22,7 @@ class VideoCallScreen extends StatefulWidget { this.counterpartName, this.counterpartAvatarUrl, this.localParticipantName, + this.entryMode = VideoCallEntryMode.outgoing, this.onBackToChat, this.onHangUp, }); @@ -32,6 +34,7 @@ class VideoCallScreen extends StatefulWidget { final String? counterpartName; final String? counterpartAvatarUrl; final String? localParticipantName; + final VideoCallEntryMode entryMode; final VoidCallback? onBackToChat; final VoidCallback? onHangUp; @@ -62,6 +65,7 @@ class _VideoCallScreenState extends State { conversationUuid: widget.conversationUuid, counterpartName: widget.counterpartName, localParticipantName: widget.localParticipantName, + entryMode: widget.entryMode, ), ); }); @@ -94,7 +98,6 @@ class _VideoCallScreenState extends State { final topInset = viewPadding.top > 0 ? viewPadding.top : 16.0; final bottomInset = viewPadding.bottom; - final counterpartFallback = s.video_call_counterpart_default; final controlLabelStyle = TextStyles.textSMedium.copyWith( color: appTheme.whiteA700, height: 20 / 14, @@ -115,13 +118,14 @@ class _VideoCallScreenState extends State { a.connectError != b.connectError || a.linkIssue != b.linkIssue || a.counterpartName != b.counterpartName || + a.localParticipantName != b.localParticipantName || a.callStageEntered != b.callStageEntered || a.micEnabled != b.micEnabled || - a.systemAudioInterrupted != b.systemAudioInterrupted || a.videoEnabled != b.videoEnabled || a.splitParticipantView != b.splitParticipantView || a.mainStageShowsLocalVideo != b.mainStageShowsLocalVideo || - a.useFrontCamera != b.useFrontCamera, + a.useFrontCamera != b.useFrontCamera || + a.entryMode != b.entryMode, builder: (context, sessionState) { final room = _cubit.liveKitRoom; @@ -159,14 +163,31 @@ class _VideoCallScreenState extends State { } final showEntranceLobby = + sessionState.entryMode == VideoCallEntryMode.outgoing && !sessionState.callStageEntered && sessionState.linkIssue == VideoCallLinkIssue.none && sessionState.connectPhase != VideoCallConnectPhase.idle; - if (showEntranceLobby) { - final remoteName = _cubit.resolvedRemoteDisplayName( - counterpartFallback, + final showAutoEnterLoading = + sessionState.entryMode == VideoCallEntryMode.incomingAccepted && + !sessionState.callStageEntered && + sessionState.linkIssue == VideoCallLinkIssue.none && + sessionState.connectPhase != VideoCallConnectPhase.idle; + + if (showAutoEnterLoading) { + return AnnotatedRegion( + value: _videoCallOverlayStyle, + child: Scaffold( + backgroundColor: appTheme.whiteA700, + body: VideoCallLoadingScreen( + onClose: () => unawaited(_handleHangUp()), + ), + ), ); + } + + if (showEntranceLobby) { + final remoteName = _resolveRemoteDisplayName(sessionState); final canEnterCall = sessionState.connectPhase == VideoCallConnectPhase.connected; return AnnotatedRegion( @@ -228,18 +249,15 @@ class _VideoCallScreenState extends State { child: Scaffold( backgroundColor: appTheme.whiteA700, body: ListenableBuilder( - listenable: activeRoom, + listenable: videoCallRoomTracksListenable(activeRoom), builder: (context, _) { return VideoCallStageLayout( room: activeRoom, connectError: sessionState.connectError, - localDisplayName: _cubit.resolvedLocalDisplayName(), - remoteDisplayName: _cubit.resolvedRemoteDisplayName( - counterpartFallback, - ), - micEnabled: sessionState.isMicActiveForUi, + localDisplayName: _resolveLocalDisplayName(sessionState), + remoteDisplayName: _resolveRemoteDisplayName(sessionState), + micEnabled: sessionState.micEnabled, videoEnabled: sessionState.videoEnabled, - systemAudioInterrupted: sessionState.systemAudioInterrupted, splitParticipantView: sessionState.splitParticipantView, mainStageShowsLocalVideo: sessionState.mainStageShowsLocalVideo, @@ -272,4 +290,20 @@ class _VideoCallScreenState extends State { ), ); } + + String _resolveLocalDisplayName(VideoCallSessionState sessionState) { + final resolved = _cubit.resolvedLocalDisplayName().trim(); + if (resolved.isNotEmpty) return resolved; + return sessionState.localParticipantName?.trim() ?? + widget.localParticipantName?.trim() ?? + ''; + } + + String _resolveRemoteDisplayName(VideoCallSessionState sessionState) { + final resolved = _cubit.resolvedRemoteDisplayName().trim(); + if (resolved.isNotEmpty) return resolved; + return sessionState.counterpartName?.trim() ?? + widget.counterpartName?.trim() ?? + ''; + } } diff --git a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart index 5a972711..e7fdd6fd 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart @@ -1,10 +1,14 @@ import 'dart:io'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart'; import 'package:floating/floating.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; /// Root overlay shell: system PiP on Android, in-app PiP fallback on iOS. class VideoCallAppShell extends StatelessWidget { @@ -25,9 +29,25 @@ class VideoCallAppShell extends StatelessWidget { if (!Platform.isAndroid) return shell; - return PiPSwitcher( - childWhenDisabled: shell, - childWhenEnabled: const VideoCallSystemPipContent(), + return BlocBuilder( + bloc: sl(), + buildWhen: (previous, current) => + previous.callStageEntered != current.callStageEntered || + previous.connectPhase != current.connectPhase || + previous.linkIssue != current.linkIssue, + builder: (context, state) { + final allowSystemPipShell = + state.callStageEntered && + state.connectPhase == VideoCallConnectPhase.connected && + state.linkIssue != VideoCallLinkIssue.lost; + + if (!allowSystemPipShell) return shell; + + return PiPSwitcher( + childWhenDisabled: shell, + childWhenEnabled: const VideoCallSystemPipContent(), + ); + }, ); } } diff --git a/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart index e9c9b281..9a1e4e55 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; import 'package:eatvio_app/core/app_router.dart'; @@ -7,6 +8,7 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -68,6 +70,9 @@ class _VideoCallGlobalPipLayerState extends State { return const SizedBox.shrink(); } final cubit = context.read(); + if (Platform.isAndroid && cubit.isCallScreenRouteVisible) { + return const SizedBox.shrink(); + } final room = cubit.liveKitRoom; if (room == null) return const SizedBox.shrink(); @@ -94,7 +99,7 @@ class _VideoCallGlobalPipLayerState extends State { return Material( type: MaterialType.transparency, child: ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { return Stack( clipBehavior: Clip.none, diff --git a/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart b/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart index 547a9320..18b1acba 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart @@ -8,10 +8,8 @@ abstract final class VideoCallLiveKitTrackUtils { static lk.VideoTrack? visibleVideoFromPublication(lk.TrackPublication? pub) { if (pub == null || pub.muted) return null; - if (pub is lk.RemoteTrackPublication) { - if (!pub.subscribed || pub.streamState != lk.StreamState.active) { - return null; - } + if (pub is lk.RemoteTrackPublication && !pub.subscribed) { + return null; } final v = asVideoTrack(pub.track); if (v == null || v.muted) return null; @@ -42,4 +40,6 @@ abstract final class VideoCallLiveKitTrackUtils { room.localParticipant?.getTrackPublicationBySource(lk.TrackSource.camera), ); } + + static bool isSoloInRoom(lk.Room room) => room.remoteParticipants.isEmpty; } diff --git a/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart index 0653f7e2..c3baad4c 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart @@ -1,6 +1,8 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:livekit_client/livekit_client.dart' as lk; @@ -15,26 +17,55 @@ class VideoCallPipVideoPreview extends StatelessWidget { final VideoCallSessionState sessionState; final lk.Room room; + static const double _pipNoVideoLogoSize = 56; + @override Widget build(BuildContext context) { - final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room); - if (remote != null) { - return IgnorePointer( - child: lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover), - ); + return ListenableBuilder( + listenable: videoCallRoomTracksListenable(room), + builder: (context, _) { + if (VideoCallLiveKitTrackUtils.isSoloInRoom(room)) { + return _VideoCallPipLocalPreview( + room: room, + videoEnabled: sessionState.videoEnabled, + ); + } + + return VideoCallRemoteVideoStage( + room: room, + connectError: null, + noVideoLogoSize: _pipNoVideoLogoSize, + ); + }, + ); + } +} + +class _VideoCallPipLocalPreview extends StatelessWidget { + const _VideoCallPipLocalPreview({ + required this.room, + required this.videoEnabled, + }); + + final lk.Room room; + final bool videoEnabled; + + @override + Widget build(BuildContext context) { + if (!videoEnabled) { + return const VideoCallNoVideoBackground(logoSize: 56); } - if (sessionState.videoEnabled) { - final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); - if (local != null) { - return IgnorePointer( - child: lk.VideoTrackRenderer( - local, - fit: lk.VideoViewFit.cover, - mirrorMode: lk.VideoViewMirrorMode.auto, - ), - ); - } + final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); + if (local != null) { + return IgnorePointer( + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), + ); } return const VideoCallNoVideoBackground(logoSize: 56); diff --git a/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart b/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart index 86c6333f..3f35cd61 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart @@ -2,6 +2,7 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:livekit_client/livekit_client.dart' as lk; @@ -10,19 +11,25 @@ class VideoCallRemoteVideoStage extends StatelessWidget { super.key, required this.room, required this.connectError, + this.noVideoLogoSize = 96, }); final lk.Room room; final String? connectError; + final double noVideoLogoSize; @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room); if (remote != null) { - return lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover); + return lk.VideoTrackRenderer( + remote, + key: ValueKey(remote.sid), + fit: lk.VideoViewFit.cover, + ); } final showConnectError = connectError != null && @@ -30,7 +37,7 @@ class VideoCallRemoteVideoStage extends StatelessWidget { return Stack( fit: StackFit.expand, children: [ - const VideoCallNoVideoBackground(), + VideoCallNoVideoBackground(logoSize: noVideoLogoSize), if (showConnectError) Center( child: Padding( diff --git a/lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart b/lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart new file mode 100644 index 00000000..c36801d7 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; + + +Listenable videoCallRoomTracksListenable(lk.Room room) { + return Listenable.merge([ + room, + ...room.remoteParticipants.values, + if (room.localParticipant != null) room.localParticipant!, + ]); +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart index c70f3407..feac9376 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart @@ -1,13 +1,11 @@ import 'dart:math' as math; -import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart'; -import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; -import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:livekit_client/livekit_client.dart' as lk; @@ -28,7 +26,6 @@ class VideoCallStageLayout extends StatefulWidget { required this.localDisplayName, required this.remoteDisplayName, required this.micEnabled, - this.systemAudioInterrupted = false, required this.videoEnabled, required this.splitParticipantView, required this.mainStageShowsLocalVideo, @@ -53,7 +50,6 @@ class VideoCallStageLayout extends StatefulWidget { final String localDisplayName; final String remoteDisplayName; final bool micEnabled; - final bool systemAudioInterrupted; final bool videoEnabled; final bool splitParticipantView; final bool mainStageShowsLocalVideo; @@ -88,6 +84,8 @@ class _VideoCallStageLayoutState extends State { static const double _pipInitialExtraDown = 16; static const double _bottomGradientHeight = 140; static const double _splitExpandButtonTop = 8 + _shrinkExpandExtraTop; + static const int _splitRemoteFlex = 7; + static const int _splitLocalFlex = 3; double? _pipLeft; double? _pipTop; @@ -95,13 +93,6 @@ class _VideoCallStageLayoutState extends State { double? _pipPanOriginLeft; double? _pipPanOriginTop; - bool _pipHasVisibleVideo() { - if (widget.mainStageShowsLocalVideo) { - return VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(widget.room) != null; - } - return widget.videoEnabled && VideoCallLiveKitTrackUtils.localCameraVideoTrack(widget.room) != null; - } - @override Widget build(BuildContext context) { return LayoutBuilder( @@ -115,15 +106,17 @@ class _VideoCallStageLayoutState extends State { } Widget _buildSplitLayout(BoxConstraints constraints) { - return Stack( - fit: StackFit.expand, - children: [ - Positioned.fill( - child: Padding( - padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), - child: Column( - children: [ - Expanded( + return ListenableBuilder( + listenable: videoCallRoomTracksListenable(widget.room), + builder: (context, _) { + final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room); + if (soloInRoom) { + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), child: _SplitParticipantPane( room: widget.room, displayName: widget.localDisplayName, @@ -133,54 +126,73 @@ class _VideoCallStageLayoutState extends State { useFrontCamera: widget.useFrontCamera, onExpand: widget.onExpandLocal, onSwitchCamera: widget.onSwitchCamera, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(_mainStageCornerRadius), - topRight: Radius.circular(_mainStageCornerRadius), - bottomLeft: Radius.zero, - bottomRight: Radius.zero, - ), + borderRadius: BorderRadius.circular(_mainStageCornerRadius), ), ), - const SizedBox(height: _edgeInset), - Expanded( - child: _SplitParticipantPane( - room: widget.room, - displayName: widget.remoteDisplayName, - isLocal: false, - connectError: widget.connectError, - videoEnabled: widget.videoEnabled, - useFrontCamera: widget.useFrontCamera, - onExpand: widget.onExpandRemote, - onSwitchCamera: widget.onSwitchCamera, - borderRadius: const BorderRadius.only( - topLeft: Radius.zero, - topRight: Radius.zero, - bottomLeft: Radius.circular(_mainStageCornerRadius), - bottomRight: Radius.circular(_mainStageCornerRadius), - ), - ), - ), - ], - ), - ), - ), - ..._bottomBarGradient(), - ..._bottomControlsRow(), - ..._systemInterruptionBanner(), - ], - ); - } + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + ], + ); + } - List _systemInterruptionBanner() { - if (!widget.systemAudioInterrupted) return const []; - return [ - Positioned( - left: 16, - right: 16, - top: widget.topInset + 8, - child: const _VideoCallSystemInterruptionBanner(), - ), - ]; + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), + child: Column( + children: [ + Expanded( + flex: _splitRemoteFlex, + child: _SplitParticipantPane( + room: widget.room, + displayName: widget.remoteDisplayName, + isLocal: false, + connectError: widget.connectError, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onExpand: widget.onExpandRemote, + onSwitchCamera: widget.onSwitchCamera, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(_mainStageCornerRadius), + topRight: Radius.circular(_mainStageCornerRadius), + bottomLeft: Radius.zero, + bottomRight: Radius.zero, + ), + ), + ), + const SizedBox(height: _edgeInset), + Expanded( + flex: _splitLocalFlex, + child: _SplitParticipantPane( + room: widget.room, + displayName: widget.localDisplayName, + isLocal: true, + connectError: null, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onExpand: widget.onExpandLocal, + onSwitchCamera: widget.onSwitchCamera, + borderRadius: const BorderRadius.only( + topLeft: Radius.zero, + topRight: Radius.zero, + bottomLeft: Radius.circular(_mainStageCornerRadius), + bottomRight: Radius.circular(_mainStageCornerRadius), + ), + ), + ), + ], + ), + ), + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + ], + ); + }, + ); } Widget _buildFullscreenLayout(BoxConstraints constraints) { @@ -194,100 +206,105 @@ class _VideoCallStageLayoutState extends State { final pipLeft = ((_pipLeft ?? defaultPipLeft).clamp(_edgeInset, maxPipLeft) as num).toDouble(); final pipTop = ((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num).toDouble(); - return Stack( - fit: StackFit.expand, - children: [ - Positioned.fill( - child: Padding( - padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), - child: Material( - type: MaterialType.transparency, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)), - child: Stack( - fit: StackFit.expand, - children: [ - _VideoCallMainStage( - room: widget.room, - connectError: widget.connectError, - mainShowsLocal: widget.mainStageShowsLocalVideo, - videoEnabled: widget.videoEnabled, - ), - VideoCallParticipantNameLabel(name: widget.mainStageShowsLocalVideo ? widget.localDisplayName : widget.remoteDisplayName), - ], - ), - ), - ), - ), - ..._bottomBarGradient(), - ..._bottomControlsRow(), - ..._systemInterruptionBanner(), - Positioned( - top: widget.topInset + _edgeInset + _shrinkExpandExtraTop, - left: _controlsHorizontalInset, - child: Padding( - padding: _kExpandShrinkOuterPadding, - child: VideoCallPipStyleIconButton( - onPressed: widget.onShrinkFromFullscreen, - child: SvgPicture.asset(_VideoCallStageAssets.shrink, width: 20, height: 20, fit: BoxFit.contain), - ), - ), - ), - ListenableBuilder( - listenable: widget.room, - builder: (context, _) { - if (!_pipHasVisibleVideo()) { - return const SizedBox.shrink(); - } - return Positioned( - left: pipLeft, - top: pipTop, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onToggleStageSwap, - onPanStart: (details) { - _pipPanPointerGlobal = details.globalPosition; - _pipPanOriginLeft = pipLeft; - _pipPanOriginTop = pipTop; - }, - onPanUpdate: (details) { - if (_pipPanPointerGlobal == null || _pipPanOriginLeft == null || _pipPanOriginTop == null) { - return; - } - final delta = details.globalPosition - _pipPanPointerGlobal!; - setState(() { - final nx = (_pipPanOriginLeft! + delta.dx).clamp(_edgeInset, maxPipLeft); - final ny = (_pipPanOriginTop! + delta.dy).clamp(minPipTop, maxPipTop); - _pipLeft = (nx as num).toDouble(); - _pipTop = (ny as num).toDouble(); - }); - }, - onPanEnd: (_) { - _resetPipPanState(); - }, - onPanCancel: _resetPipPanState, - child: AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - width: _pipW, - height: _pipH, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: widget.mainStageShowsLocalVideo - ? _VideoCallPipRemoteContent(room: widget.room) - : _VideoCallPipLocalContent( - room: widget.room, - videoEnabled: widget.videoEnabled, - useFrontCamera: widget.useFrontCamera, - onSwitchCamera: widget.onSwitchCamera, - ), + return ListenableBuilder( + listenable: videoCallRoomTracksListenable(widget.room), + builder: (context, _) { + final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room); + final mainShowsLocal = + soloInRoom || widget.mainStageShowsLocalVideo; + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), + child: Material( + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)), + child: Stack( + fit: StackFit.expand, + children: [ + _VideoCallMainStage( + room: widget.room, + connectError: widget.connectError, + mainShowsLocal: mainShowsLocal, + videoEnabled: widget.videoEnabled, + ), + VideoCallParticipantNameLabel( + name: mainShowsLocal + ? widget.localDisplayName + : widget.remoteDisplayName, + ), + ], ), ), ), - ); - }, - ), - ], + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + Positioned( + top: widget.topInset + _edgeInset + _shrinkExpandExtraTop, + left: _controlsHorizontalInset, + child: Padding( + padding: _kExpandShrinkOuterPadding, + child: VideoCallPipStyleIconButton( + onPressed: widget.onShrinkFromFullscreen, + child: SvgPicture.asset(_VideoCallStageAssets.shrink, width: 20, height: 20, fit: BoxFit.contain), + ), + ), + ), + if (!soloInRoom) + Positioned( + left: pipLeft, + top: pipTop, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onToggleStageSwap, + onPanStart: (details) { + _pipPanPointerGlobal = details.globalPosition; + _pipPanOriginLeft = pipLeft; + _pipPanOriginTop = pipTop; + }, + onPanUpdate: (details) { + if (_pipPanPointerGlobal == null || _pipPanOriginLeft == null || _pipPanOriginTop == null) { + return; + } + final delta = details.globalPosition - _pipPanPointerGlobal!; + setState(() { + final nx = (_pipPanOriginLeft! + delta.dx).clamp(_edgeInset, maxPipLeft); + final ny = (_pipPanOriginTop! + delta.dy).clamp(minPipTop, maxPipTop); + _pipLeft = (nx as num).toDouble(); + _pipTop = (ny as num).toDouble(); + }); + }, + onPanEnd: (_) { + _resetPipPanState(); + }, + onPanCancel: _resetPipPanState, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: _pipW, + height: _pipH, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: mainShowsLocal + ? _VideoCallPipRemoteContent(room: widget.room) + : _VideoCallPipLocalContent( + room: widget.room, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onSwitchCamera: widget.onSwitchCamera, + ), + ), + ), + ), + ), + ], + ); + }, ); } @@ -381,7 +398,7 @@ class _SplitParticipantPane extends StatelessWidget { children: [ Positioned.fill( child: ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { if (isLocal) { if (!videoEnabled) { @@ -390,7 +407,12 @@ class _SplitParticipantPane extends StatelessWidget { final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); if (local != null) { return IgnorePointer( - child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), ); } return const VideoCallNoVideoBackground(); @@ -440,7 +462,7 @@ class _VideoCallMainStage extends StatelessWidget { @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { if (mainShowsLocal) { if (!videoEnabled) { @@ -449,7 +471,12 @@ class _VideoCallMainStage extends StatelessWidget { final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); if (local != null) { return IgnorePointer( - child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), ); } return const VideoCallNoVideoBackground(); @@ -467,7 +494,13 @@ class _VideoCallPipRemoteContent extends StatelessWidget { @override Widget build(BuildContext context) { - return IgnorePointer(child: VideoCallRemoteVideoStage(room: room, connectError: null)); + return IgnorePointer( + child: VideoCallRemoteVideoStage( + room: room, + connectError: null, + noVideoLogoSize: 56, + ), + ); } } @@ -489,7 +522,12 @@ class _VideoCallPipLocalContent extends StatelessWidget { const VideoCallNoVideoBackground(logoSize: 56) else if (local != null) IgnorePointer( - child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), ) else const VideoCallNoVideoBackground(logoSize: 56), @@ -506,57 +544,3 @@ class _VideoCallPipLocalContent extends StatelessWidget { ); } } - -class _VideoCallSystemInterruptionBanner extends StatelessWidget { - const _VideoCallSystemInterruptionBanner(); - - @override - Widget build(BuildContext context) { - final s = S.of(context); - return Material( - color: appTheme.videoCallNoticeBannerBackground, - borderRadius: BorderRadius.circular(12), - elevation: 6, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SvgPicture.asset( - VideoCallTopOverlay.warningSvg, - width: 22, - height: 22, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - s.video_call_mic_muted_phone_call, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - height: 1.25, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - s.video_call_system_interruption, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - height: 1.25, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart index da20656f..fbcc16fd 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart @@ -1,7 +1,9 @@ import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -22,6 +24,23 @@ class VideoCallSystemPipContent extends StatelessWidget { if (state.connectPhase != VideoCallConnectPhase.connected || state.linkIssue == VideoCallLinkIssue.lost || !state.callStageEntered) { + if (state.conversationUuid.isNotEmpty && + state.connectPhase != VideoCallConnectPhase.idle && + state.linkIssue != VideoCallLinkIssue.lost) { + return ColoredBox( + color: appTheme.gray100, + child: Center( + child: SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + strokeWidth: 3, + color: appTheme.blueA400, + ), + ), + ), + ); + } return const ColoredBox(color: Colors.black); } @@ -34,7 +53,7 @@ class VideoCallSystemPipContent extends StatelessWidget { return Material( color: Colors.black, child: ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) => VideoCallPipVideoPreview( sessionState: state, room: room, diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart index 7760e27f..4731d98f 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart @@ -55,6 +55,12 @@ class _VideoCallSystemPipListenerState extends State } if (!wasEnabled || status != PiPStatus.disabled) return; + + if (cubit.isCallScreenRouteVisible) { + await cubit.onSystemPipExitedWithCallScreenVisible(); + return; + } + if (cubit.state.uiMode != VideoCallUiMode.pip) return; await cubit.restoreFullscreenFromPip(); diff --git a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart index fd976f08..55c2477d 100644 --- a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart +++ b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart @@ -32,15 +32,12 @@ class VideoCallAudioSessionCoordinator { androidWillPauseWhenDucked: true, ); - StreamSubscription? _interruptionSubscription; AudioSession? _session; bool _configured = false; bool get isActive => _session != null; - Future start({ - required void Function(AudioInterruptionEvent event) onInterruption, - }) async { + Future start() async { final session = await _audioSessionFactory(); _session = session; if (!_configured) { @@ -48,10 +45,6 @@ class VideoCallAudioSessionCoordinator { _configured = true; } await session.setActive(true); - await _interruptionSubscription?.cancel(); - _interruptionSubscription = session.interruptionEventStream.listen( - onInterruption, - ); } Future reactivate() async { @@ -65,8 +58,6 @@ class VideoCallAudioSessionCoordinator { } Future stop() async { - await _interruptionSubscription?.cancel(); - _interruptionSubscription = null; final session = _session; _session = null; if (session == null) return; diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index b25e0468..036103ba 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -496,6 +496,7 @@ class VideoCallCallKitCoordinator { counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + entryMode: VideoCallEntryMode.incomingAccepted, ); } diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart index 6f058c5b..9d582d66 100644 --- a/lib/features/video_calls/services/video_call_fcm_handler.dart +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -57,6 +57,14 @@ abstract final class VideoCallFcmHandler { return; } + if (source == 'background') { + log( + 'VideoCallFcmHandler: skip background video call, handled by native FCM service, conversation=${payload.conversationUuid}', + name: 'VideoCallFcm', + ); + return; + } + log( 'VideoCallFcmHandler: show incoming call UI, source=$source, conversation=${payload.conversationUuid}', name: 'VideoCallFcm', diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart index c3325be9..4fb6bf2e 100644 --- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -28,6 +28,7 @@ abstract final class VideoCallIncomingPushPresenter { if (payload.localParticipantName != null) 'localParticipantName': payload.localParticipantName, }, + callingNotification: const NotificationParams(showNotification: false), android: const AndroidParams( isCustomNotification: true, isShowLogo: false, diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 5ffbd983..612e032f 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:go_router/go_router.dart'; @@ -30,22 +32,34 @@ class VideoCallNotificationNavigator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { if (conversationUuid.isEmpty) return; - await _pushCallScreen( + final pushed = await _pushCallScreen( conversationUuid: conversationUuid, counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + entryMode: entryMode, ); + + if (!pushed) { + await sl().onCallScreenVisible( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + localParticipantName: localParticipantName, + entryMode: entryMode, + ); + } } - static Future _pushCallScreen({ + static Future _pushCallScreen({ required String conversationUuid, String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { for (var attempt = 0; attempt < 100; attempt++) { final ctx = AppRouter.navigatorKey.currentContext; @@ -63,12 +77,15 @@ class VideoCallNotificationNavigator { 'counterpartName': counterpartName, 'counterpartAvatarUrl': counterpartAvatarUrl, 'localParticipantName': localParticipantName, + 'entryMode': entryMode.name, }, ); + return true; } - return; + return false; } await Future.delayed(const Duration(milliseconds: 100)); } + return false; } } diff --git a/test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart b/test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart deleted file mode 100644 index 73ef657d..00000000 --- a/test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:audio_session/audio_session.dart'; -import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; -import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart'; -import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart'; -import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart'; -import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:livekit_client/livekit_client.dart' as lk; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -import 'video_call_manager_audio_interruption_test.mocks.dart'; - -class _FakeAudioSessionCoordinator extends VideoCallAudioSessionCoordinator { - _FakeAudioSessionCoordinator(this.onStart); - - final void Function(void Function(AudioInterruptionEvent event) onInterruption) onStart; - bool reactivateCalled = false; - - @override - Future start({required void Function(AudioInterruptionEvent event) onInterruption}) async { - onStart(onInterruption); - } - - @override - Future reactivate() async { - reactivateCalled = true; - return true; - } - - @override - Future stop() async {} -} - -@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('VideoCallManager audio interruption', () { - late MockVideoCallRepository mockRepository; - late MockRoom mockRoom; - late MockLocalParticipant mockLocalParticipant; - late _FakeAudioSessionCoordinator audioCoordinator; - late VideoCallManager manager; - - setUp(() { - mockRepository = MockVideoCallRepository(); - mockRoom = MockRoom(); - mockLocalParticipant = MockLocalParticipant(); - audioCoordinator = _FakeAudioSessionCoordinator((_) {}); - manager = VideoCallManager( - videoCallRepository: mockRepository, - foregroundCoordinator: VideoCallForegroundCoordinator(), - lifecycleBinding: VideoCallLifecycleBinding(), - audioSessionCoordinator: audioCoordinator, - ); - when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected); - when(mockRoom.localParticipant).thenReturn(mockLocalParticipant); - when(mockLocalParticipant.setMicrophoneEnabled(any, audioCaptureOptions: anyNamed('audioCaptureOptions'))).thenAnswer((_) async { - return null; - }); - when(mockLocalParticipant.getTrackPublicationBySource(lk.TrackSource.microphone)).thenReturn(null); - }); - - tearDown(() { - manager.dispose(); - }); - - test('duck interruption mutes mic and sets interrupted UI state', () async { - manager.markActiveCallForTesting(mockRoom); - - manager.handleAudioInterruptionForTesting( - AudioInterruptionEvent(true, AudioInterruptionType.duck), - ); - await Future.delayed(Duration.zero); - - expect(manager.snapshot.systemAudioInterrupted, isTrue); - verify( - mockLocalParticipant.setMicrophoneEnabled( - false, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ).called(1); - }); - - test('interruption begin mutes mic and sets interrupted state', () async { - manager.markActiveCallForTesting(mockRoom); - - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - - expect(manager.snapshot.systemAudioInterrupted, isTrue); - expect(manager.snapshot.micEnabled, isTrue); - verify(mockLocalParticipant.setMicrophoneEnabled(false, audioCaptureOptions: anyNamed('audioCaptureOptions'))).called(1); - verifyNever(mockRepository.notifyCallEnded(any)); - }); - - test('interruption end reactivates session and restores mic', () async { - manager.markActiveCallForTesting(mockRoom); - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(false, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - - expect(manager.snapshot.systemAudioInterrupted, isFalse); - expect(audioCoordinator.reactivateCalled, isTrue); - verify(mockLocalParticipant.setMicrophoneEnabled(true, audioCaptureOptions: anyNamed('audioCaptureOptions'))).called(1); - }); - - test('toggleMic before call stage updates intent without livekit', () async { - manager.markActiveCallForTesting(mockRoom); - manager.setCallStageEnteredForTesting(false); - - await manager.toggleMic(false); - - expect(manager.snapshot.micEnabled, isTrue); - verifyNever( - mockLocalParticipant.setMicrophoneEnabled( - any, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ); - }); - - test( - 'toggleMic from UI off while interrupted keeps interrupted and does not unmute', - () async { - manager.markActiveCallForTesting(mockRoom); - manager.handleAudioInterruptionForTesting( - AudioInterruptionEvent(true, AudioInterruptionType.pause), - ); - await Future.delayed(Duration.zero); - clearInteractions(mockLocalParticipant); - - await manager.toggleMic(false); - - expect(manager.snapshot.systemAudioInterrupted, isTrue); - expect(manager.snapshot.micEnabled, isTrue); - verifyNever( - mockLocalParticipant.setMicrophoneEnabled( - true, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ); - }, - ); - - test('toggleMic during interruption disables intent and keeps mic paused', () async { - manager.markActiveCallForTesting(mockRoom); - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - clearInteractions(mockLocalParticipant); - - await manager.toggleMic(true); - - expect(manager.snapshot.micEnabled, isFalse); - expect(manager.snapshot.systemAudioInterrupted, isTrue); - verify( - mockLocalParticipant.setMicrophoneEnabled( - false, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ).called(1); - verifyNever( - mockLocalParticipant.setMicrophoneEnabled( - true, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ); - }); - }); -} diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index d9c67cd9..b18404ff 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -3,18 +3,19 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snap import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'video_call_session_cubit_test.mocks.dart'; -@GenerateNiceMocks([MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec()]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('VideoCallSessionCubit', () { late MockVideoCallManager mockManager; - late VideoCallSessionCubit cubit; + late _TestVideoCallSessionCubit cubit; late VideoCallManagerListener managerListener; @@ -26,7 +27,7 @@ void main() { managerListener = invocation.positionalArguments[0] as VideoCallManagerListener; }); - cubit = VideoCallSessionCubit(videoCallManager: mockManager); + cubit = _TestVideoCallSessionCubit(videoCallManager: mockManager); }); tearDown(() async { @@ -120,12 +121,9 @@ void main() { }, ); - test('isMicActiveForUi is false when system audio interrupted', () { - const state = VideoCallSessionState( - micEnabled: true, - systemAudioInterrupted: true, - ); - expect(state.isMicActiveForUi, isFalse); + test('isMicActiveForUi mirrors micEnabled', () { + const state = VideoCallSessionState(micEnabled: true); + expect(state.isMicActiveForUi, isTrue); }); test('onCallEndedExternally resets session state', () async { @@ -143,6 +141,213 @@ void main() { expect(cubit.state, const VideoCallSessionState()); }); + test('syncPipUiStateFromSystem skips overlay when call screen is visible', () async { + cubit.emit( + const VideoCallSessionState( + conversationUuid: 'uuid', + connectPhase: VideoCallConnectPhase.connected, + callStageEntered: true, + uiMode: VideoCallUiMode.fullscreen, + ), + ); + + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + await cubit.syncPipUiStateFromSystem(); + + expect(cubit.state.uiMode, VideoCallUiMode.fullscreen); + }); + + test('manager idle snapshot ignored while call screen is visible', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.incomingAccepted, + ); + + managerListener(VideoCallManagerSnapshot.initial); + + expect(cubit.state.entryMode, VideoCallEntryMode.incomingAccepted); + expect(cubit.state.conversationUuid, 'uuid'); + }); + + test('incoming accept auto-enters call stage after connect', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async { + managerListener( + const VideoCallManagerSnapshot( + conversationUuid: 'uuid', + micEnabled: false, + videoEnabled: false, + connectPhase: VideoCallConnectPhase.connected, + linkIssue: VideoCallLinkIssue.none, + ), + ); + }); + when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); + + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.incomingAccepted, + ); + + verify( + mockManager.startCall( + conversationUuid: 'uuid', + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: false, + videoEnabled: false, + ), + ).called(1); + verify(mockManager.onEnterCallStage()).called(1); + expect(cubit.state.entryMode, VideoCallEntryMode.incomingAccepted); + expect(cubit.state.callStageEntered, isTrue); + }); + + test('outgoing call keeps lobby until callee joins', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {}); + when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); + + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + + verifyNever(mockManager.onEnterCallStage()); + expect(cubit.state.entryMode, VideoCallEntryMode.outgoing); + expect(cubit.state.callStageEntered, isFalse); + expect(cubit.state.micEnabled, isFalse); + + managerListener( + const VideoCallManagerSnapshot( + conversationUuid: 'uuid', + micEnabled: false, + videoEnabled: false, + connectPhase: VideoCallConnectPhase.connected, + linkIssue: VideoCallLinkIssue.none, + ), + ); + await Future.delayed(Duration.zero); + + verify(mockManager.ensureLobbyRoomJoined()).called(1); + verifyNever(mockManager.onEnterCallStage()); + }); + + test('outgoing call auto-enters when callee joins lobby room', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {}); + when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); + + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + + managerListener( + const VideoCallManagerSnapshot( + conversationUuid: 'uuid', + micEnabled: false, + videoEnabled: false, + connectPhase: VideoCallConnectPhase.connected, + linkIssue: VideoCallLinkIssue.none, + counterpartPresentInLobby: true, + ), + ); + await Future.delayed(Duration.zero); + + verify(mockManager.onEnterCallStage()).called(1); + expect(cubit.state.callStageEntered, isTrue); + }); + + test('duplicate onCallScreenVisible does not restart connecting call', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.incomingAccepted, + ); + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.outgoing, + ); + + verify( + mockManager.startCall( + conversationUuid: 'uuid', + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: false, + videoEnabled: false, + ), + ).called(1); + expect(cubit.state.entryMode, VideoCallEntryMode.incomingAccepted); + }); + + test('leaveCallRoute minimizes to pip and marks call screen not visible', () async { + final mockRoom = MockRoom(); + when(mockManager.room).thenReturn(mockRoom); + when( + mockManager.onCallScreenVisible( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + + cubit.emit( + const VideoCallSessionState( + conversationUuid: 'uuid', + connectPhase: VideoCallConnectPhase.connected, + callStageEntered: true, + uiMode: VideoCallUiMode.fullscreen, + ), + ); + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + expect(cubit.isCallScreenRouteVisible, isTrue); + + await cubit.leaveCallRoute(); + + expect(cubit.state.uiMode, VideoCallUiMode.pip); + expect(cubit.isCallScreenRouteVisible, isFalse); + }); + test('tracks whether call screen route is visible', () async { when( mockManager.startCall( @@ -171,3 +376,12 @@ void main() { }); }); } + +class _TestVideoCallSessionCubit extends VideoCallSessionCubit { + _TestVideoCallSessionCubit({required super.videoCallManager}); + + bool grantMicOnRequest = false; + + @override + Future requestMicPermissionForCall() async => grantMicOnRequest; +} diff --git a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart index 2749415a..d8aff74a 100644 --- a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart +++ b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart @@ -12,6 +12,8 @@ import 'video_call_livekit_track_utils_test.mocks.dart'; MockSpec(), MockSpec(), MockSpec(), + MockSpec(), + MockSpec(), MockSpec(), MockSpec(), ]) @@ -67,6 +69,33 @@ void main() { expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); }); + test('visibleVideoFromPublication возвращает remote track при paused stream', () { + final pub = MockRemoteTrackPublication(); + final track = MockRemoteVideoTrack(); + when(pub.muted).thenReturn(false); + when(pub.subscribed).thenReturn(true); + when(pub.streamState).thenReturn(lk.StreamState.paused); + when(pub.track).thenReturn(track); + when(track.muted).thenReturn(false); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), equals(track)); + }); + + test('visibleVideoFromPublication возвращает null для unsubscribed remote', () { + final pub = MockRemoteTrackPublication(); + when(pub.muted).thenReturn(false); + when(pub.subscribed).thenReturn(false); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('isSoloInRoom is true when remote participants are empty', () { + final room = MockRoom(); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + + expect(VideoCallLiveKitTrackUtils.isSoloInRoom(room), isTrue); + }); + test('localCameraVideoTrack возвращает null если localParticipant отсутствует', () { final room = MockRoom(); when(room.localParticipant).thenReturn(null); diff --git a/test/features/video_calls/widgets/video_call_stage_layout_test.dart b/test/features/video_calls/widgets/video_call_stage_layout_test.dart index 078f43c6..ba3afd89 100644 --- a/test/features/video_calls/widgets/video_call_stage_layout_test.dart +++ b/test/features/video_calls/widgets/video_call_stage_layout_test.dart @@ -9,7 +9,7 @@ import 'package:mockito/mockito.dart'; import 'video_call_stage_layout_test.mocks.dart'; -@GenerateNiceMocks([MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec()]) void main() { Widget wrap(Widget child) { return MaterialApp( @@ -27,8 +27,19 @@ void main() { return room; } + MockRoom buildRoomWithRemote() { + final room = MockRoom(); + final remote = MockRemoteParticipant(); + when(room.remoteParticipants).thenReturn( + UnmodifiableMapView({'remote': remote}), + ); + when(room.localParticipant).thenReturn(null); + when(room.connectionState).thenReturn(lk.ConnectionState.connected); + return room; + } + testWidgets('renders split mode participant names and controls', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); var localExpanded = 0; var remoteExpanded = 0; @@ -114,7 +125,7 @@ void main() { }); testWidgets('split layout hides connectError on remote pane when connected', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); await tester.pumpWidget( wrap( @@ -150,7 +161,7 @@ void main() { }); testWidgets('fullscreen layout shows remote name when main stage is remote', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); await tester.pumpWidget( wrap( @@ -186,7 +197,7 @@ void main() { }); testWidgets('split layout with video on shows camera switch on local pane', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); await tester.pumpWidget( wrap( @@ -222,7 +233,7 @@ void main() { }); testWidgets('split layout with rear camera and remote main stage variant', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); var stageSwapped = 0; await tester.pumpWidget( @@ -299,8 +310,131 @@ void main() { expect(find.text('Leave'), findsOneWidget); }); - testWidgets('fullscreen layout with connect error shows it on stage', (tester) async { + testWidgets('fullscreen layout shows local PIP when camera is off', (tester) async { + final room = buildRoomWithRemote(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + await tester.pump(); + expect(find.byType(AnimatedContainer), findsOneWidget); + }); + + testWidgets('fullscreen layout shows only local participant when solo in room', (tester) async { final room = buildRoom(); + var stageSwapped = 0; + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () => stageSwapped++, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 8, + bottomInset: 32, + ), + ), + ); + + await tester.pump(); + expect(find.text('You'), findsOneWidget); + expect(find.text('Doctor'), findsNothing); + expect(find.byType(AnimatedContainer), findsNothing); + expect(stageSwapped, 0); + }); + + testWidgets('split layout places remote above local with 70/30 height ratio', (tester) async { + final room = buildRoomWithRemote(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: true, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + await tester.pump(); + + final remoteTop = tester.getTopLeft(find.text('Doctor')).dy; + final localTop = tester.getTopLeft(find.text('You')).dy; + expect(remoteTop, lessThan(localTop)); + + final remotePane = tester.getSize( + find.ancestor(of: find.text('Doctor'), matching: find.byType(Material)).first, + ); + final localPane = tester.getSize( + find.ancestor(of: find.text('You'), matching: find.byType(Material)).first, + ); + expect(remotePane.height / localPane.height, closeTo(7 / 3, 0.05)); + }); + + testWidgets('split layout with connect error shows it on stage', (tester) async { + final room = buildRoomWithRemote(); when(room.connectionState).thenReturn(lk.ConnectionState.disconnected); await tester.pumpWidget(