feat(video_calls): added system android pip for calls

This commit is contained in:
misha
2026-06-10 09:32:43 +03:00
parent 27fe4afd81
commit de795c5274
15 changed files with 436 additions and 33 deletions

View File

@@ -37,6 +37,7 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:supportsPictureInPicture="true"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"

View File

@@ -1,8 +1,11 @@
package com.eatvioapp.app
import android.app.PictureInPictureParams
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.os.Build
import android.util.Rational
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -21,7 +24,10 @@ class MainActivity: FlutterActivity() {
}
private val channelName = "com.eatvioapp/hms_push"
private val videoCallPipChannelName = "com.eatvioapp/video_call_pip"
private var methodChannel: MethodChannel? = null
private var videoCallPipChannel: MethodChannel? = null
private var videoCallPipEnabled: Boolean = false
override fun onResume() {
super.onResume()
@@ -37,6 +43,16 @@ class MainActivity: FlutterActivity() {
super.configureFlutterEngine(flutterEngine)
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 ->
when (call.method) {
"getHuaweiToken" -> {
@@ -94,6 +110,12 @@ class MainActivity: FlutterActivity() {
handleIntentForActionUrl(intent)
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
enterVideoCallPictureInPictureIfNeeded()
videoCallPipChannel?.invokeMethod("onUserLeaveHint", null)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
@@ -122,4 +144,19 @@ class MainActivity: FlutterActivity() {
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
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)
}
}
}

View File

@@ -77,7 +77,10 @@ class CubitRegistrations {
() => VideoCallManager(videoCallRepository: sl()),
);
sl.registerLazySingleton<VideoCallSessionCubit>(
() => VideoCallSessionCubit(videoCallManager: sl()),
() => VideoCallSessionCubit(
videoCallManager: sl(),
systemPipCoordinator: sl(),
),
);
}

View File

@@ -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/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_system_pip_coordinator.dart';
import 'package:get_it/get_it.dart';
/// Регистрация всех Service в системе внедрения зависимостей
@@ -79,6 +80,9 @@ class ServiceRegistrations {
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
);
sl.registerLazySingleton<VideoCallSystemPipCoordinator>(
() => VideoCallSystemPipCoordinator(),
);
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -46,6 +47,9 @@ 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;
@@ -69,6 +73,7 @@ class VideoCallManager {
bool _videoPausedForBackground = false;
bool? _micEnabledBeforeSystemInterruption;
int _audioInterruptionHoldCount = 0;
DateTime? _ignoreAudioInterruptionUntil;
OverlayEntry? _systemInterruptionEntry;
VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial;
@@ -367,6 +372,14 @@ 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) {
@@ -397,6 +410,9 @@ class VideoCallManager {
}
_emitSnapshot(_snapshot.copyWith(micEnabled: next));
_lastMutedMicTotalEnergy = null;
if (next) {
_suppressOwnMicActivationInterruptions();
}
await _room!.localParticipant?.setMicrophoneEnabled(
next,
audioCaptureOptions: _micCaptureOptions,
@@ -608,6 +624,7 @@ class VideoCallManager {
_recordingOverlayScheduled = false;
_micEnabledBeforeSystemInterruption = null;
_audioInterruptionHoldCount = 0;
_ignoreAudioInterruptionUntil = null;
_joinInProgress = false;
_clearPreparedConnectionCredentials();
_syncSystemInterruptionOverlay(show: false);
@@ -1056,6 +1073,9 @@ class VideoCallManager {
_snapshot.connectPhase != VideoCallConnectPhase.connected) {
return;
}
if (_shouldIgnoreAudioInterruption(event)) {
return;
}
if (event.begin) {
_audioInterruptionHoldCount++;
if (_audioInterruptionHoldCount == 1) {
@@ -1115,6 +1135,7 @@ class VideoCallManager {
if (lp == null) return;
try {
_suppressOwnMicActivationInterruptions();
await lp.setMicrophoneEnabled(
true,
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 {
if (!_canHandleCallAudio || !_snapshot.micEnabled) return;
final lp = _room!.localParticipant;
@@ -1139,6 +1178,7 @@ class VideoCallManager {
final track = pub?.track;
if (track is lk.LocalAudioTrack) {
try {
_suppressOwnMicActivationInterruptions();
await track.restartTrack();
return;
} catch (_) {}
@@ -1148,6 +1188,7 @@ class VideoCallManager {
false,
audioCaptureOptions: _micCaptureOptions,
);
_suppressOwnMicActivationInterruptions();
await lp.setMicrophoneEnabled(
true,
audioCaptureOptions: _micCaptureOptions,

View File

@@ -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_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/services/video_call_system_pip_coordinator.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
VideoCallSessionCubit({required VideoCallManager videoCallManager})
: _videoCallManager = videoCallManager,
super(const VideoCallSessionState()) {
VideoCallSessionCubit({
required VideoCallManager videoCallManager,
VideoCallSystemPipCoordinator? systemPipCoordinator,
}) : _videoCallManager = videoCallManager,
_systemPipCoordinator =
systemPipCoordinator ?? VideoCallSystemPipCoordinator(),
super(const VideoCallSessionState()) {
_videoCallManager.addListener(_onManagerSnapshot);
_syncFromManager(_videoCallManager.snapshot);
}
final VideoCallManager _videoCallManager;
final VideoCallSystemPipCoordinator _systemPipCoordinator;
bool _callScreenRouteVisible = false;
lk.Room? get liveKitRoom => _videoCallManager.room;
bool get isCallScreenRouteVisible => _callScreenRouteVisible;
static bool _isCallSessionInactive(VideoCallManagerSnapshot snapshot) {
return snapshot.conversationUuid.isEmpty ||
@@ -56,6 +64,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
if (isClosed) return;
if (_isCallSessionInactive(snapshot)) {
unawaited(_systemPipCoordinator.disarm());
emit(const VideoCallSessionState());
unawaited(_restoreSystemUiAfterCall());
return;
@@ -79,6 +88,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
Future<void> _onCallEndedExternally() async {
if (isClosed) return;
await _systemPipCoordinator.disarm();
await _restoreSystemUiAfterCall();
emit(const VideoCallSessionState());
}
@@ -102,6 +112,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
String? counterpartName,
String? localParticipantName,
}) async {
_callScreenRouteVisible = true;
final isResumingActiveCall =
state.conversationUuid == conversationUuid &&
state.callStageEntered &&
@@ -144,6 +155,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
}
void onCallScreenDisposed() {
_callScreenRouteVisible = false;
if (state.uiMode != VideoCallUiMode.fullscreen) return;
if (state.connectPhase == VideoCallConnectPhase.connected &&
state.linkIssue != VideoCallLinkIssue.lost) {
@@ -159,17 +171,32 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
}
}
Future<void> minimizeToPip() async {
if (state.connectPhase != VideoCallConnectPhase.connected) return;
if (state.linkIssue == VideoCallLinkIssue.lost) return;
if (!state.callStageEntered) return;
bool get _canMinimizeToPip =>
state.connectPhase == VideoCallConnectPhase.connected &&
state.linkIssue != VideoCallLinkIssue.lost &&
state.callStageEntered;
Future<void> _applyPipUiState() async {
emit(state.copyWith(uiMode: VideoCallUiMode.pip));
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 {
if (state.uiMode != VideoCallUiMode.pip) return;
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
unawaited(_systemPipCoordinator.armAutoPipOnLeave());
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
@@ -197,6 +224,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
if (!entered || isClosed) return;
emit(state.copyWith(callStageEntered: true));
unawaited(_systemPipCoordinator.armAutoPipOnLeave());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isClosed) return;
_videoCallManager.scheduleRecordingNoticeOnce();
@@ -209,6 +237,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
}
Future<void> endCall() async {
await _systemPipCoordinator.disarm();
await _videoCallManager.endCall();
}

View File

@@ -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(),
);
}
}

View File

@@ -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_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_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_pip_video_preview.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
class VideoCallGlobalPipLayer extends StatefulWidget {
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
Widget build(BuildContext context) {
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
@@ -155,7 +136,10 @@ class _VideoCallGlobalPipLayerState extends State<VideoCallGlobalPipLayer> {
child: SizedBox(
width: _pipW,
height: _pipH,
child: _pipPreview(state, room),
child: VideoCallPipVideoPreview(
sessionState: state,
room: room,
),
),
),
),

View File

@@ -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);
}
}

View File

@@ -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,
),
),
);
},
);
}
}

View File

@@ -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();
}

View File

@@ -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.
}
}
}

View File

@@ -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/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/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 'package:flutter/material.dart';
@@ -51,9 +51,7 @@ class _MyAppState extends State<MyApp> {
debugShowCheckedModeBanner: false,
localizationsDelegates: sl<AppConfig>().localizationDelegates,
supportedLocales: sl<AppConfig>().supportedLocales,
builder: (context, child) {
return Stack(clipBehavior: Clip.none, children: [(child ?? const SizedBox.shrink()), const VideoCallGlobalPipLayer()]);
},
builder: (context, child) => VideoCallAppShell(child: child),
),
),
),

View File

@@ -88,6 +88,7 @@ dependencies:
audio_session: ^0.1.25
flutter_foreground_task: ^9.2.1
flutter_callkit_incoming: ^3.1.1
floating: ^6.0.0
dev_dependencies:
build_runner: ^2.10.4

View File

@@ -142,5 +142,32 @@ void main() {
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);
});
});
}