forked from eatvio/eatvio_app
feat(video_calls): added system android pip for calls
This commit is contained in:
@@ -37,6 +37,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
|
android:supportsPictureInPicture="true"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.eatvioapp.app
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.app.PictureInPictureParams
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Rational
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
@@ -21,7 +24,10 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val channelName = "com.eatvioapp/hms_push"
|
private val channelName = "com.eatvioapp/hms_push"
|
||||||
|
private val videoCallPipChannelName = "com.eatvioapp/video_call_pip"
|
||||||
private var methodChannel: MethodChannel? = null
|
private var methodChannel: MethodChannel? = null
|
||||||
|
private var videoCallPipChannel: MethodChannel? = null
|
||||||
|
private var videoCallPipEnabled: Boolean = false
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
@@ -37,6 +43,16 @@ class MainActivity: FlutterActivity() {
|
|||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||||
|
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
||||||
|
videoCallPipChannel?.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"setPipEnabled" -> {
|
||||||
|
videoCallPipEnabled = call.arguments as? Boolean ?: false
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
methodChannel?.setMethodCallHandler { call, result ->
|
methodChannel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getHuaweiToken" -> {
|
"getHuaweiToken" -> {
|
||||||
@@ -94,6 +110,12 @@ class MainActivity: FlutterActivity() {
|
|||||||
handleIntentForActionUrl(intent)
|
handleIntentForActionUrl(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onUserLeaveHint() {
|
||||||
|
super.onUserLeaveHint()
|
||||||
|
enterVideoCallPictureInPictureIfNeeded()
|
||||||
|
videoCallPipChannel?.invokeMethod("onUserLeaveHint", null)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
|
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
|
||||||
@@ -122,4 +144,19 @@ class MainActivity: FlutterActivity() {
|
|||||||
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
|
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
|
||||||
methodChannel?.invokeMethod("openActionUrl", actionUrl)
|
methodChannel?.invokeMethod("openActionUrl", actionUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun enterVideoCallPictureInPictureIfNeeded() {
|
||||||
|
if (!videoCallPipEnabled) return
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
if (isInPictureInPictureMode) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val params = PictureInPictureParams.Builder()
|
||||||
|
.setAspectRatio(Rational(2, 3))
|
||||||
|
.build()
|
||||||
|
enterPictureInPictureMode(params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("VideoCallPiP", "Unable to enter PiP from onUserLeaveHint", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ class CubitRegistrations {
|
|||||||
() => VideoCallManager(videoCallRepository: sl()),
|
() => VideoCallManager(videoCallRepository: sl()),
|
||||||
);
|
);
|
||||||
sl.registerLazySingleton<VideoCallSessionCubit>(
|
sl.registerLazySingleton<VideoCallSessionCubit>(
|
||||||
() => VideoCallSessionCubit(videoCallManager: sl()),
|
() => VideoCallSessionCubit(
|
||||||
|
videoCallManager: sl(),
|
||||||
|
systemPipCoordinator: sl(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import 'package:eatvio_app/features/registration_v2/services/registration_servic
|
|||||||
import 'package:eatvio_app/features/settings/services/settings_service.dart';
|
import 'package:eatvio_app/features/settings/services/settings_service.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
/// Регистрация всех Service в системе внедрения зависимостей
|
/// Регистрация всех Service в системе внедрения зависимостей
|
||||||
@@ -79,6 +80,9 @@ class ServiceRegistrations {
|
|||||||
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
|
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
|
||||||
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
|
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
|
||||||
);
|
);
|
||||||
|
sl.registerLazySingleton<VideoCallSystemPipCoordinator>(
|
||||||
|
() => VideoCallSystemPipCoordinator(),
|
||||||
|
);
|
||||||
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
|
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
|
||||||
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
|
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
|
||||||
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
|
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -46,6 +47,9 @@ class VideoCallManager {
|
|||||||
static const Duration _mutedMicOverlayMinInterval = Duration(
|
static const Duration _mutedMicOverlayMinInterval = Duration(
|
||||||
milliseconds: 2600,
|
milliseconds: 2600,
|
||||||
);
|
);
|
||||||
|
static const Duration _localMicActivationInterruptionGrace = Duration(
|
||||||
|
milliseconds: 1500,
|
||||||
|
);
|
||||||
static const double _mutedMicAudioLevelThreshold = 0.008;
|
static const double _mutedMicAudioLevelThreshold = 0.008;
|
||||||
static const double _mutedMicEnergyDelta = 1e-6;
|
static const double _mutedMicEnergyDelta = 1e-6;
|
||||||
|
|
||||||
@@ -69,6 +73,7 @@ class VideoCallManager {
|
|||||||
bool _videoPausedForBackground = false;
|
bool _videoPausedForBackground = false;
|
||||||
bool? _micEnabledBeforeSystemInterruption;
|
bool? _micEnabledBeforeSystemInterruption;
|
||||||
int _audioInterruptionHoldCount = 0;
|
int _audioInterruptionHoldCount = 0;
|
||||||
|
DateTime? _ignoreAudioInterruptionUntil;
|
||||||
OverlayEntry? _systemInterruptionEntry;
|
OverlayEntry? _systemInterruptionEntry;
|
||||||
|
|
||||||
VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial;
|
VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial;
|
||||||
@@ -367,6 +372,14 @@ class VideoCallManager {
|
|||||||
/// [currentUiEnabled] reflects what the user sees on the mic control
|
/// [currentUiEnabled] reflects what the user sees on the mic control
|
||||||
/// ([VideoCallSessionState.isMicActiveForUi]), not only stored intent.
|
/// ([VideoCallSessionState.isMicActiveForUi]), not only stored intent.
|
||||||
Future<void> toggleMic(bool currentUiEnabled) async {
|
Future<void> toggleMic(bool currentUiEnabled) async {
|
||||||
|
if (!currentUiEnabled &&
|
||||||
|
_snapshot.systemAudioInterrupted &&
|
||||||
|
_snapshot.micEnabled &&
|
||||||
|
_isLocalMicrophonePublishing()) {
|
||||||
|
_clearSystemInterruptionState();
|
||||||
|
_lastMutedMicTotalEnergy = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
final next = !currentUiEnabled;
|
final next = !currentUiEnabled;
|
||||||
if (_snapshot.systemAudioInterrupted || _audioInterruptionHoldCount > 0) {
|
if (_snapshot.systemAudioInterrupted || _audioInterruptionHoldCount > 0) {
|
||||||
if (next) {
|
if (next) {
|
||||||
@@ -397,6 +410,9 @@ class VideoCallManager {
|
|||||||
}
|
}
|
||||||
_emitSnapshot(_snapshot.copyWith(micEnabled: next));
|
_emitSnapshot(_snapshot.copyWith(micEnabled: next));
|
||||||
_lastMutedMicTotalEnergy = null;
|
_lastMutedMicTotalEnergy = null;
|
||||||
|
if (next) {
|
||||||
|
_suppressOwnMicActivationInterruptions();
|
||||||
|
}
|
||||||
await _room!.localParticipant?.setMicrophoneEnabled(
|
await _room!.localParticipant?.setMicrophoneEnabled(
|
||||||
next,
|
next,
|
||||||
audioCaptureOptions: _micCaptureOptions,
|
audioCaptureOptions: _micCaptureOptions,
|
||||||
@@ -608,6 +624,7 @@ class VideoCallManager {
|
|||||||
_recordingOverlayScheduled = false;
|
_recordingOverlayScheduled = false;
|
||||||
_micEnabledBeforeSystemInterruption = null;
|
_micEnabledBeforeSystemInterruption = null;
|
||||||
_audioInterruptionHoldCount = 0;
|
_audioInterruptionHoldCount = 0;
|
||||||
|
_ignoreAudioInterruptionUntil = null;
|
||||||
_joinInProgress = false;
|
_joinInProgress = false;
|
||||||
_clearPreparedConnectionCredentials();
|
_clearPreparedConnectionCredentials();
|
||||||
_syncSystemInterruptionOverlay(show: false);
|
_syncSystemInterruptionOverlay(show: false);
|
||||||
@@ -1056,6 +1073,9 @@ class VideoCallManager {
|
|||||||
_snapshot.connectPhase != VideoCallConnectPhase.connected) {
|
_snapshot.connectPhase != VideoCallConnectPhase.connected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_shouldIgnoreAudioInterruption(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.begin) {
|
if (event.begin) {
|
||||||
_audioInterruptionHoldCount++;
|
_audioInterruptionHoldCount++;
|
||||||
if (_audioInterruptionHoldCount == 1) {
|
if (_audioInterruptionHoldCount == 1) {
|
||||||
@@ -1115,6 +1135,7 @@ class VideoCallManager {
|
|||||||
if (lp == null) return;
|
if (lp == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_suppressOwnMicActivationInterruptions();
|
||||||
await lp.setMicrophoneEnabled(
|
await lp.setMicrophoneEnabled(
|
||||||
true,
|
true,
|
||||||
audioCaptureOptions: _micCaptureOptions,
|
audioCaptureOptions: _micCaptureOptions,
|
||||||
@@ -1131,6 +1152,24 @@ class VideoCallManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
Future<void> _restartLocalAudioTrack() async {
|
||||||
if (!_canHandleCallAudio || !_snapshot.micEnabled) return;
|
if (!_canHandleCallAudio || !_snapshot.micEnabled) return;
|
||||||
final lp = _room!.localParticipant;
|
final lp = _room!.localParticipant;
|
||||||
@@ -1139,6 +1178,7 @@ class VideoCallManager {
|
|||||||
final track = pub?.track;
|
final track = pub?.track;
|
||||||
if (track is lk.LocalAudioTrack) {
|
if (track is lk.LocalAudioTrack) {
|
||||||
try {
|
try {
|
||||||
|
_suppressOwnMicActivationInterruptions();
|
||||||
await track.restartTrack();
|
await track.restartTrack();
|
||||||
return;
|
return;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -1148,6 +1188,7 @@ class VideoCallManager {
|
|||||||
false,
|
false,
|
||||||
audioCaptureOptions: _micCaptureOptions,
|
audioCaptureOptions: _micCaptureOptions,
|
||||||
);
|
);
|
||||||
|
_suppressOwnMicActivationInterruptions();
|
||||||
await lp.setMicrophoneEnabled(
|
await lp.setMicrophoneEnabled(
|
||||||
true,
|
true,
|
||||||
audioCaptureOptions: _micCaptureOptions,
|
audioCaptureOptions: _micCaptureOptions,
|
||||||
|
|||||||
@@ -4,21 +4,29 @@ import 'package:eatvio_app/core/cubit/base_cubit.dart';
|
|||||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart';
|
import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.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/bloc/video_call_session/video_call_session_state.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||||
|
|
||||||
class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||||
VideoCallSessionCubit({required VideoCallManager videoCallManager})
|
VideoCallSessionCubit({
|
||||||
: _videoCallManager = videoCallManager,
|
required VideoCallManager videoCallManager,
|
||||||
super(const VideoCallSessionState()) {
|
VideoCallSystemPipCoordinator? systemPipCoordinator,
|
||||||
|
}) : _videoCallManager = videoCallManager,
|
||||||
|
_systemPipCoordinator =
|
||||||
|
systemPipCoordinator ?? VideoCallSystemPipCoordinator(),
|
||||||
|
super(const VideoCallSessionState()) {
|
||||||
_videoCallManager.addListener(_onManagerSnapshot);
|
_videoCallManager.addListener(_onManagerSnapshot);
|
||||||
_syncFromManager(_videoCallManager.snapshot);
|
_syncFromManager(_videoCallManager.snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
final VideoCallManager _videoCallManager;
|
final VideoCallManager _videoCallManager;
|
||||||
|
final VideoCallSystemPipCoordinator _systemPipCoordinator;
|
||||||
|
bool _callScreenRouteVisible = false;
|
||||||
|
|
||||||
lk.Room? get liveKitRoom => _videoCallManager.room;
|
lk.Room? get liveKitRoom => _videoCallManager.room;
|
||||||
|
bool get isCallScreenRouteVisible => _callScreenRouteVisible;
|
||||||
|
|
||||||
static bool _isCallSessionInactive(VideoCallManagerSnapshot snapshot) {
|
static bool _isCallSessionInactive(VideoCallManagerSnapshot snapshot) {
|
||||||
return snapshot.conversationUuid.isEmpty ||
|
return snapshot.conversationUuid.isEmpty ||
|
||||||
@@ -56,6 +64,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
|
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
if (_isCallSessionInactive(snapshot)) {
|
if (_isCallSessionInactive(snapshot)) {
|
||||||
|
unawaited(_systemPipCoordinator.disarm());
|
||||||
emit(const VideoCallSessionState());
|
emit(const VideoCallSessionState());
|
||||||
unawaited(_restoreSystemUiAfterCall());
|
unawaited(_restoreSystemUiAfterCall());
|
||||||
return;
|
return;
|
||||||
@@ -79,6 +88,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
|
|
||||||
Future<void> _onCallEndedExternally() async {
|
Future<void> _onCallEndedExternally() async {
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
|
await _systemPipCoordinator.disarm();
|
||||||
await _restoreSystemUiAfterCall();
|
await _restoreSystemUiAfterCall();
|
||||||
emit(const VideoCallSessionState());
|
emit(const VideoCallSessionState());
|
||||||
}
|
}
|
||||||
@@ -102,6 +112,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
String? counterpartName,
|
String? counterpartName,
|
||||||
String? localParticipantName,
|
String? localParticipantName,
|
||||||
}) async {
|
}) async {
|
||||||
|
_callScreenRouteVisible = true;
|
||||||
final isResumingActiveCall =
|
final isResumingActiveCall =
|
||||||
state.conversationUuid == conversationUuid &&
|
state.conversationUuid == conversationUuid &&
|
||||||
state.callStageEntered &&
|
state.callStageEntered &&
|
||||||
@@ -144,6 +155,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void onCallScreenDisposed() {
|
void onCallScreenDisposed() {
|
||||||
|
_callScreenRouteVisible = false;
|
||||||
if (state.uiMode != VideoCallUiMode.fullscreen) return;
|
if (state.uiMode != VideoCallUiMode.fullscreen) return;
|
||||||
if (state.connectPhase == VideoCallConnectPhase.connected &&
|
if (state.connectPhase == VideoCallConnectPhase.connected &&
|
||||||
state.linkIssue != VideoCallLinkIssue.lost) {
|
state.linkIssue != VideoCallLinkIssue.lost) {
|
||||||
@@ -159,17 +171,32 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> minimizeToPip() async {
|
bool get _canMinimizeToPip =>
|
||||||
if (state.connectPhase != VideoCallConnectPhase.connected) return;
|
state.connectPhase == VideoCallConnectPhase.connected &&
|
||||||
if (state.linkIssue == VideoCallLinkIssue.lost) return;
|
state.linkIssue != VideoCallLinkIssue.lost &&
|
||||||
if (!state.callStageEntered) return;
|
state.callStageEntered;
|
||||||
|
|
||||||
|
Future<void> _applyPipUiState() async {
|
||||||
emit(state.copyWith(uiMode: VideoCallUiMode.pip));
|
emit(state.copyWith(uiMode: VideoCallUiMode.pip));
|
||||||
await _restoreSystemUiAfterCall();
|
await _restoreSystemUiAfterCall();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Called when Android enters system PiP (e.g. Home gesture).
|
||||||
|
Future<void> syncPipUiStateFromSystem() async {
|
||||||
|
if (!_canMinimizeToPip) return;
|
||||||
|
if (state.uiMode == VideoCallUiMode.pip) return;
|
||||||
|
await _applyPipUiState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> minimizeToPip() async {
|
||||||
|
if (!_canMinimizeToPip) return;
|
||||||
|
await _applyPipUiState();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> restoreFullscreenFromPip() async {
|
Future<void> restoreFullscreenFromPip() async {
|
||||||
if (state.uiMode != VideoCallUiMode.pip) return;
|
if (state.uiMode != VideoCallUiMode.pip) return;
|
||||||
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
|
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
|
||||||
|
unawaited(_systemPipCoordinator.armAutoPipOnLeave());
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
unawaited(_applyVideoCallSystemUi());
|
unawaited(_applyVideoCallSystemUi());
|
||||||
});
|
});
|
||||||
@@ -197,6 +224,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
if (!entered || isClosed) return;
|
if (!entered || isClosed) return;
|
||||||
|
|
||||||
emit(state.copyWith(callStageEntered: true));
|
emit(state.copyWith(callStageEntered: true));
|
||||||
|
unawaited(_systemPipCoordinator.armAutoPipOnLeave());
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
_videoCallManager.scheduleRecordingNoticeOnce();
|
_videoCallManager.scheduleRecordingNoticeOnce();
|
||||||
@@ -209,6 +237,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> endCall() async {
|
Future<void> endCall() async {
|
||||||
|
await _systemPipCoordinator.disarm();
|
||||||
await _videoCallManager.endCall();
|
await _videoCallManager.endCall();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
/// Root overlay shell: system PiP on Android, in-app PiP fallback on iOS.
|
||||||
|
class VideoCallAppShell extends StatelessWidget {
|
||||||
|
const VideoCallAppShell({super.key, required this.child});
|
||||||
|
|
||||||
|
final Widget? child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final shell = Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
child ?? const SizedBox.shrink(),
|
||||||
|
const VideoCallGlobalPipLayer(),
|
||||||
|
const VideoCallSystemPipListener(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!Platform.isAndroid) return shell;
|
||||||
|
|
||||||
|
return PiPSwitcher(
|
||||||
|
childWhenDisabled: shell,
|
||||||
|
childWhenEnabled: const VideoCallSystemPipContent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,10 @@ 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_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/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/pages/video_call_screen.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_pip_video_preview.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
|
||||||
|
|
||||||
class VideoCallGlobalPipLayer extends StatefulWidget {
|
class VideoCallGlobalPipLayer extends StatefulWidget {
|
||||||
const VideoCallGlobalPipLayer({super.key});
|
const VideoCallGlobalPipLayer({super.key});
|
||||||
@@ -54,23 +52,6 @@ class _VideoCallGlobalPipLayerState extends State<VideoCallGlobalPipLayer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _pipPreview(VideoCallSessionState sessionState, lk.Room room) {
|
|
||||||
if (!sessionState.videoEnabled) {
|
|
||||||
return const VideoCallNoVideoBackground(logoSize: 56);
|
|
||||||
}
|
|
||||||
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
|
|
||||||
if (local != null) {
|
|
||||||
return IgnorePointer(
|
|
||||||
child: lk.VideoTrackRenderer(
|
|
||||||
local,
|
|
||||||
fit: lk.VideoViewFit.cover,
|
|
||||||
mirrorMode: lk.VideoViewMirrorMode.auto,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const VideoCallNoVideoBackground(logoSize: 56);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
|
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
|
||||||
@@ -155,7 +136,10 @@ class _VideoCallGlobalPipLayerState extends State<VideoCallGlobalPipLayer> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: _pipW,
|
width: _pipW,
|
||||||
height: _pipH,
|
height: _pipH,
|
||||||
child: _pipPreview(state, room),
|
child: VideoCallPipVideoPreview(
|
||||||
|
sessionState: state,
|
||||||
|
room: room,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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:flutter/material.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||||
|
|
||||||
|
/// Shared compact preview for both in-app and Android system PiP.
|
||||||
|
class VideoCallPipVideoPreview extends StatelessWidget {
|
||||||
|
const VideoCallPipVideoPreview({
|
||||||
|
super.key,
|
||||||
|
required this.sessionState,
|
||||||
|
required this.room,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VideoCallSessionState sessionState;
|
||||||
|
final lk.Room room;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room);
|
||||||
|
if (remote != null) {
|
||||||
|
return IgnorePointer(
|
||||||
|
child: lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return const VideoCallNoVideoBackground(logoSize: 56);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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_pip_video_preview.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
/// Compact call UI rendered by [PiPSwitcher] while Android is in system PiP mode.
|
||||||
|
class VideoCallSystemPipContent extends StatelessWidget {
|
||||||
|
const VideoCallSystemPipContent({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
|
||||||
|
bloc: sl<VideoCallSessionCubit>(),
|
||||||
|
buildWhen: (a, b) =>
|
||||||
|
a.connectPhase != b.connectPhase ||
|
||||||
|
a.linkIssue != b.linkIssue ||
|
||||||
|
a.callStageEntered != b.callStageEntered ||
|
||||||
|
a.videoEnabled != b.videoEnabled,
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.connectPhase != VideoCallConnectPhase.connected ||
|
||||||
|
state.linkIssue == VideoCallLinkIssue.lost ||
|
||||||
|
!state.callStageEntered) {
|
||||||
|
return const ColoredBox(color: Colors.black);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cubit = sl<VideoCallSessionCubit>();
|
||||||
|
final room = cubit.liveKitRoom;
|
||||||
|
if (room == null) {
|
||||||
|
return const ColoredBox(color: Colors.black);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.black,
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: room,
|
||||||
|
builder: (context, _) => VideoCallPipVideoPreview(
|
||||||
|
sessionState: state,
|
||||||
|
room: room,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
|
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/pages/video_call_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||||
|
import 'package:floating/floating.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// Syncs [VideoCallSessionCubit] with Android system PiP lifecycle events.
|
||||||
|
class VideoCallSystemPipListener extends StatefulWidget {
|
||||||
|
const VideoCallSystemPipListener({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VideoCallSystemPipListener> createState() =>
|
||||||
|
_VideoCallSystemPipListenerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener> {
|
||||||
|
static const MethodChannel _userLeaveHintChannel = MethodChannel(
|
||||||
|
'com.eatvioapp/video_call_pip',
|
||||||
|
);
|
||||||
|
|
||||||
|
StreamSubscription<PiPStatus>? _subscription;
|
||||||
|
PiPStatus? _lastStatus;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (!Platform.isAndroid) return;
|
||||||
|
|
||||||
|
_userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall);
|
||||||
|
final coordinator = sl<VideoCallSystemPipCoordinator>();
|
||||||
|
_subscription = coordinator.statusStream.listen(_onPipStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onNativePipMethodCall(MethodCall call) async {
|
||||||
|
if (call.method != 'onUserLeaveHint') return;
|
||||||
|
await sl<VideoCallSessionCubit>().syncPipUiStateFromSystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onPipStatus(PiPStatus status) async {
|
||||||
|
final cubit = sl<VideoCallSessionCubit>();
|
||||||
|
final wasEnabled = _lastStatus == PiPStatus.enabled;
|
||||||
|
_lastStatus = status;
|
||||||
|
|
||||||
|
if (status == PiPStatus.enabled) {
|
||||||
|
await cubit.syncPipUiStateFromSystem();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wasEnabled || status != PiPStatus.disabled) return;
|
||||||
|
if (cubit.state.uiMode != VideoCallUiMode.pip) return;
|
||||||
|
|
||||||
|
await cubit.restoreFullscreenFromPip();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (cubit.isCallScreenRouteVisible) return;
|
||||||
|
_openCallScreen(cubit.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openCallScreen(VideoCallSessionState state) {
|
||||||
|
if (state.conversationUuid.isEmpty) return;
|
||||||
|
|
||||||
|
final ctx = AppRouter.navigatorKey.currentContext;
|
||||||
|
if (ctx == null) return;
|
||||||
|
|
||||||
|
GoRouter.of(ctx).pushNamed(
|
||||||
|
VideoCallScreen.name,
|
||||||
|
extra: <String, dynamic>{
|
||||||
|
'conversationUuid': state.conversationUuid,
|
||||||
|
'counterpartName': state.counterpartName,
|
||||||
|
'localParticipantName': state.localParticipantName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
_userLeaveHintChannel.setMethodCallHandler(null);
|
||||||
|
}
|
||||||
|
unawaited(_subscription?.cancel());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:floating/floating.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Android system Picture-in-Picture via the [floating] package.
|
||||||
|
class VideoCallSystemPipCoordinator {
|
||||||
|
VideoCallSystemPipCoordinator({Floating? floating})
|
||||||
|
: _floating = floating ?? Floating();
|
||||||
|
|
||||||
|
static const MethodChannel _nativePipChannel = MethodChannel(
|
||||||
|
'com.eatvioapp/video_call_pip',
|
||||||
|
);
|
||||||
|
|
||||||
|
final Floating _floating;
|
||||||
|
|
||||||
|
/// Portrait ratio close to the in-call mini preview (142×201).
|
||||||
|
static const Rational callAspectRatio = Rational(2, 3);
|
||||||
|
|
||||||
|
bool get isPlatformSupported => Platform.isAndroid;
|
||||||
|
|
||||||
|
Future<bool> get isAvailable async {
|
||||||
|
if (!isPlatformSupported) return false;
|
||||||
|
return _floating.isPipAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<PiPStatus> get statusStream => _floating.pipStatusStream;
|
||||||
|
|
||||||
|
/// Enter PiP when the user leaves the app (Home / recent apps).
|
||||||
|
///
|
||||||
|
/// Requires Android 12+ (API 31); on older versions this is a no-op.
|
||||||
|
Future<void> armAutoPipOnLeave() async {
|
||||||
|
if (!await isAvailable) return;
|
||||||
|
await _setNativeUserLeaveHintPipEnabled(true);
|
||||||
|
try {
|
||||||
|
await _floating.enable(
|
||||||
|
const OnLeavePiP(aspectRatio: callAspectRatio),
|
||||||
|
);
|
||||||
|
} on PlatformException {
|
||||||
|
// OnLeavePiP is unavailable below Android 12; native onUserLeaveHint
|
||||||
|
// still handles Home / recent apps on Android 8+.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disarm() async {
|
||||||
|
if (!isPlatformSupported) return;
|
||||||
|
await _setNativeUserLeaveHintPipEnabled(false);
|
||||||
|
try {
|
||||||
|
await _floating.cancelOnLeavePiP();
|
||||||
|
} on PlatformException {
|
||||||
|
// The activity may already be detached while the call is being torn down.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setNativeUserLeaveHintPipEnabled(bool enabled) async {
|
||||||
|
if (!isPlatformSupported) return;
|
||||||
|
try {
|
||||||
|
await _nativePipChannel.invokeMethod<void>('setPipEnabled', enabled);
|
||||||
|
} on PlatformException {
|
||||||
|
// Native channel may be unavailable during startup/teardown.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import 'package:eatvio_app/core/notifications/push_navigation_handler.dart';
|
|||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_bootstrap.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_bootstrap.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_app_shell.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -51,9 +51,7 @@ class _MyAppState extends State<MyApp> {
|
|||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
localizationsDelegates: sl<AppConfig>().localizationDelegates,
|
localizationsDelegates: sl<AppConfig>().localizationDelegates,
|
||||||
supportedLocales: sl<AppConfig>().supportedLocales,
|
supportedLocales: sl<AppConfig>().supportedLocales,
|
||||||
builder: (context, child) {
|
builder: (context, child) => VideoCallAppShell(child: child),
|
||||||
return Stack(clipBehavior: Clip.none, children: [(child ?? const SizedBox.shrink()), const VideoCallGlobalPipLayer()]);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ dependencies:
|
|||||||
audio_session: ^0.1.25
|
audio_session: ^0.1.25
|
||||||
flutter_foreground_task: ^9.2.1
|
flutter_foreground_task: ^9.2.1
|
||||||
flutter_callkit_incoming: ^3.1.1
|
flutter_callkit_incoming: ^3.1.1
|
||||||
|
floating: ^6.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.10.4
|
build_runner: ^2.10.4
|
||||||
|
|||||||
@@ -142,5 +142,32 @@ void main() {
|
|||||||
|
|
||||||
expect(cubit.state, const VideoCallSessionState());
|
expect(cubit.state, const VideoCallSessionState());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tracks whether call screen route 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');
|
||||||
|
expect(cubit.isCallScreenRouteVisible, isTrue);
|
||||||
|
|
||||||
|
cubit.emit(
|
||||||
|
const VideoCallSessionState(
|
||||||
|
conversationUuid: 'uuid',
|
||||||
|
connectPhase: VideoCallConnectPhase.connected,
|
||||||
|
callStageEntered: true,
|
||||||
|
uiMode: VideoCallUiMode.pip,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
cubit.onCallScreenDisposed();
|
||||||
|
expect(cubit.isCallScreenRouteVisible, isFalse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user