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(