forked from eatvio/eatvio_app
feat(video_calls): changed accept flow, fixed ui bugs for PIP and video render sizes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -17,6 +17,16 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<service
|
||||
android:name=".EatvioFcmMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
|
||||
tools:node="remove" />
|
||||
<service
|
||||
android:name=".HmsPushService"
|
||||
android:exported="false">
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>): 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<String, String>) {
|
||||
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<String, Any?>()
|
||||
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<String, Any?>())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<String, String>): 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<String, String>) {
|
||||
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<String, String>): 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<String, Any?>()
|
||||
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<String, Any?>())
|
||||
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, String>): 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,10 @@ class CubitRegistrations {
|
||||
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
||||
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
||||
sl.registerLazySingleton<VideoCallManager>(
|
||||
() => VideoCallManager(videoCallRepository: sl()),
|
||||
() => VideoCallManager(
|
||||
videoCallRepository: sl(),
|
||||
profileRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerLazySingleton<VideoCallSessionCubit>(
|
||||
() => VideoCallSessionCubit(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void>? _roomConnectFuture;
|
||||
String? _preparedWsUrl;
|
||||
String? _preparedToken;
|
||||
bool _videoEnabledBeforeBackground = false;
|
||||
bool _videoPausedForBackground = false;
|
||||
bool? _micEnabledBeforeSystemInterruption;
|
||||
int _audioInterruptionHoldCount = 0;
|
||||
DateTime? _ignoreAudioInterruptionUntil;
|
||||
OverlayEntry? _systemInterruptionEntry;
|
||||
Future<String?>? _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<void> 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<void> _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<void> 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<void> 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<String?> _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<String?> _fetchMyProfileName() async {
|
||||
_myProfileNameFuture ??= _loadMyProfileName();
|
||||
return _myProfileNameFuture;
|
||||
}
|
||||
|
||||
Future<String?> _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<void> _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<lk.ParticipantConnectedEvent>((_) {
|
||||
_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<void> _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<void> _startCallAudioSession() async {
|
||||
if (!_canHandleCallAudio) return;
|
||||
try {
|
||||
await _audioSession.start(onInterruption: _onAudioInterruption);
|
||||
await _audioSession.start();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _stopCallAudioSession() async {
|
||||
_syncSystemInterruptionOverlay(show: false);
|
||||
try {
|
||||
await _audioSession.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _handleAudioInterruptionBegin() async {
|
||||
if (_snapshot.systemAudioInterrupted) return;
|
||||
_micEnabledBeforeSystemInterruption ??= _snapshot.micEnabled;
|
||||
_emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: true));
|
||||
_syncSystemInterruptionOverlay(show: true);
|
||||
await _pauseLocalAudioForSystemInterruption();
|
||||
}
|
||||
|
||||
Future<void> _pauseLocalAudioForSystemInterruption() async {
|
||||
final room = _room;
|
||||
if (room == null || _liveKitDisposed) return;
|
||||
try {
|
||||
await room.localParticipant?.setMicrophoneEnabled(
|
||||
false,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<VideoCallSessionState> {
|
||||
VideoCallSessionCubit({
|
||||
@@ -24,6 +26,9 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
final VideoCallManager _videoCallManager;
|
||||
final VideoCallSystemPipCoordinator _systemPipCoordinator;
|
||||
bool _callScreenRouteVisible = false;
|
||||
bool _autoEnterInFlight = false;
|
||||
Future<void>? _autoEnterFuture;
|
||||
Future<void>? _outgoingAutoEnterFuture;
|
||||
|
||||
lk.Room? get liveKitRoom => _videoCallManager.room;
|
||||
bool get isCallScreenRouteVisible => _callScreenRouteVisible;
|
||||
@@ -52,7 +57,6 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
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<VideoCallSessionState> {
|
||||
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<VideoCallSessionState> {
|
||||
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<VideoCallSessionState> {
|
||||
callStageEntered: false,
|
||||
connectError: null,
|
||||
linkIssue: VideoCallLinkIssue.none,
|
||||
connectPhase: VideoCallConnectPhase.connecting,
|
||||
entryMode: effectiveEntryMode,
|
||||
),
|
||||
);
|
||||
await _videoCallManager.startCall(
|
||||
@@ -146,17 +197,137 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
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<void> _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<void> _tryAutoEnterWhenCounterpartJoins() {
|
||||
final pending = _outgoingAutoEnterFuture;
|
||||
if (pending != null) return pending;
|
||||
|
||||
final run = _runOutgoingAutoEnterCallStage();
|
||||
_outgoingAutoEnterFuture = run;
|
||||
return run.whenComplete(() {
|
||||
if (identical(_outgoingAutoEnterFuture, run)) {
|
||||
_outgoingAutoEnterFuture = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<void> _tryAutoEnterCallStage() {
|
||||
final pending = _autoEnterFuture;
|
||||
if (pending != null) return pending;
|
||||
|
||||
final run = _runAutoEnterCallStage();
|
||||
_autoEnterFuture = run;
|
||||
return run.whenComplete(() {
|
||||
if (identical(_autoEnterFuture, run)) {
|
||||
_autoEnterFuture = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<bool> requestMicPermissionForCall() => _requestMicPermissionForCall();
|
||||
|
||||
Future<bool> _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<VideoCallSessionState> {
|
||||
Future<void> _applyPipUiState() async {
|
||||
emit(state.copyWith(uiMode: VideoCallUiMode.pip));
|
||||
await _restoreSystemUiAfterCall();
|
||||
await _ensureSystemPipArmedIfInCall();
|
||||
}
|
||||
|
||||
/// Called when Android enters system PiP (e.g. Home gesture).
|
||||
Future<void> 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<void> onSystemPipExitedWithCallScreenVisible() async {
|
||||
if (!_callScreenRouteVisible) return;
|
||||
if (state.uiMode != VideoCallUiMode.fullscreen) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_applyVideoCallSystemUi());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> minimizeToPip() async {
|
||||
if (!_canMinimizeToPip) return;
|
||||
await _applyPipUiState();
|
||||
@@ -206,6 +391,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
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<VideoCallSessionState> {
|
||||
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<VideoCallSessionState> {
|
||||
}
|
||||
|
||||
Future<void> retryAfterConnectionLoss() async {
|
||||
final entryMode = state.entryMode;
|
||||
await _videoCallManager.retryAfterConnectionLoss();
|
||||
emit(state.copyWith(callStageEntered: false));
|
||||
emit(state.copyWith(callStageEntered: false, entryMode: entryMode));
|
||||
}
|
||||
|
||||
Future<void> endCall() async {
|
||||
@@ -309,7 +502,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
String resolvedLocalDisplayName() =>
|
||||
_videoCallManager.resolvedLocalDisplayName();
|
||||
|
||||
String resolvedRemoteDisplayName(String fallback) =>
|
||||
String resolvedRemoteDisplayName([String fallback = '']) =>
|
||||
_videoCallManager.resolvedRemoteDisplayName(fallback);
|
||||
|
||||
@override
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<VideoCallScreen> {
|
||||
conversationUuid: widget.conversationUuid,
|
||||
counterpartName: widget.counterpartName,
|
||||
localParticipantName: widget.localParticipantName,
|
||||
entryMode: widget.entryMode,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -94,7 +98,6 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
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<VideoCallScreen> {
|
||||
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<VideoCallScreen> {
|
||||
}
|
||||
|
||||
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<SystemUiOverlayStyle>(
|
||||
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<SystemUiOverlayStyle>(
|
||||
@@ -228,18 +249,15 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
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<VideoCallScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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() ??
|
||||
'';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<VideoCallSessionCubit, VideoCallSessionState>(
|
||||
bloc: sl<VideoCallSessionCubit>(),
|
||||
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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<VideoCallGlobalPipLayer> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final cubit = context.read<VideoCallSessionCubit>();
|
||||
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<VideoCallGlobalPipLayer> {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListenableBuilder(
|
||||
listenable: room,
|
||||
listenable: videoCallRoomTracksListenable(room),
|
||||
builder: (context, _) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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!,
|
||||
]);
|
||||
}
|
||||
@@ -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<VideoCallStageLayout> {
|
||||
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<VideoCallStageLayout> {
|
||||
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<VideoCallStageLayout> {
|
||||
}
|
||||
|
||||
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<VideoCallStageLayout> {
|
||||
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<Widget> _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<VideoCallStageLayout> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,6 +55,12 @@ class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener>
|
||||
}
|
||||
|
||||
if (!wasEnabled || status != PiPStatus.disabled) return;
|
||||
|
||||
if (cubit.isCallScreenRouteVisible) {
|
||||
await cubit.onSystemPipExitedWithCallScreenVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cubit.state.uiMode != VideoCallUiMode.pip) return;
|
||||
|
||||
await cubit.restoreFullscreenFromPip();
|
||||
|
||||
@@ -32,15 +32,12 @@ class VideoCallAudioSessionCoordinator {
|
||||
androidWillPauseWhenDucked: true,
|
||||
);
|
||||
|
||||
StreamSubscription<AudioInterruptionEvent>? _interruptionSubscription;
|
||||
AudioSession? _session;
|
||||
bool _configured = false;
|
||||
|
||||
bool get isActive => _session != null;
|
||||
|
||||
Future<void> start({
|
||||
required void Function(AudioInterruptionEvent event) onInterruption,
|
||||
}) async {
|
||||
Future<void> 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<bool> reactivate() async {
|
||||
@@ -65,8 +58,6 @@ class VideoCallAudioSessionCoordinator {
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _interruptionSubscription?.cancel();
|
||||
_interruptionSubscription = null;
|
||||
final session = _session;
|
||||
_session = null;
|
||||
if (session == null) return;
|
||||
|
||||
@@ -496,6 +496,7 @@ class VideoCallCallKitCoordinator {
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
entryMode: VideoCallEntryMode.incomingAccepted,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<VideoCallSessionCubit>().onCallScreenVisible(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
entryMode: entryMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _pushCallScreen({
|
||||
static Future<bool> _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<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> start({required void Function(AudioInterruptionEvent event) onInterruption}) async {
|
||||
onStart(onInterruption);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> reactivate() async {
|
||||
reactivateCalled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
}
|
||||
|
||||
@GenerateNiceMocks([MockSpec<VideoCallRepository>(), MockSpec<lk.Room>(), MockSpec<lk.LocalParticipant>()])
|
||||
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<void>.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<void>.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<void>.delayed(Duration.zero);
|
||||
|
||||
manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(false, AudioInterruptionType.pause));
|
||||
await Future<void>.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<void>.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<void>.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'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<VideoCallManager>()])
|
||||
@GenerateNiceMocks([MockSpec<VideoCallManager>(), MockSpec<lk.Room>()])
|
||||
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<void>.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<void>.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<bool> requestMicPermissionForCall() async => grantMicOnRequest;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import 'video_call_livekit_track_utils_test.mocks.dart';
|
||||
MockSpec<lk.Room>(),
|
||||
MockSpec<lk.LocalParticipant>(),
|
||||
MockSpec<lk.RemoteParticipant>(),
|
||||
MockSpec<lk.RemoteTrackPublication>(),
|
||||
MockSpec<lk.RemoteVideoTrack>(),
|
||||
MockSpec<lk.TrackPublication>(),
|
||||
MockSpec<lk.VideoTrack>(),
|
||||
])
|
||||
@@ -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(<String, lk.RemoteParticipant>{}));
|
||||
|
||||
expect(VideoCallLiveKitTrackUtils.isSoloInRoom(room), isTrue);
|
||||
});
|
||||
|
||||
test('localCameraVideoTrack возвращает null если localParticipant отсутствует', () {
|
||||
final room = MockRoom();
|
||||
when(room.localParticipant).thenReturn(null);
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'video_call_stage_layout_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<lk.Room>()])
|
||||
@GenerateNiceMocks([MockSpec<lk.Room>(), MockSpec<lk.RemoteParticipant>()])
|
||||
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(<String, lk.RemoteParticipant>{'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(
|
||||
|
||||
Reference in New Issue
Block a user