diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 91bcaa52..daa5ec83 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -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" diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index 26bd8ed7..65903d96 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -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) + } + } } diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 157b1e56..4e6ff7ee 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -77,7 +77,10 @@ class CubitRegistrations { () => VideoCallManager(videoCallRepository: sl()), ); sl.registerLazySingleton( - () => VideoCallSessionCubit(videoCallManager: sl()), + () => VideoCallSessionCubit( + videoCallManager: sl(), + systemPipCoordinator: sl(), + ), ); } diff --git a/lib/core/di/service_registrations.dart b/lib/core/di/service_registrations.dart index a5ee09ee..d8f23ca2 100644 --- a/lib/core/di/service_registrations.dart +++ b/lib/core/di/service_registrations.dart @@ -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(firebaseApi: sl()), ); + sl.registerLazySingleton( + () => VideoCallSystemPipCoordinator(), + ); sl.registerLazySingleton(() => ResetPasswordService(sl())); sl.registerLazySingleton(() => BlockedUserService(sl())); sl.registerLazySingleton(() => CommentsService(sl())); diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 87bafb3b..92fade24 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -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 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 _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, diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 5eb112db..a7c73fc8 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -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 { - 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 { 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 { Future _onCallEndedExternally() async { if (isClosed) return; + await _systemPipCoordinator.disarm(); await _restoreSystemUiAfterCall(); emit(const VideoCallSessionState()); } @@ -102,6 +112,7 @@ class VideoCallSessionCubit extends BaseCubit { String? counterpartName, String? localParticipantName, }) async { + _callScreenRouteVisible = true; final isResumingActiveCall = state.conversationUuid == conversationUuid && state.callStageEntered && @@ -144,6 +155,7 @@ class VideoCallSessionCubit extends BaseCubit { } 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 { } } - Future 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 _applyPipUiState() async { emit(state.copyWith(uiMode: VideoCallUiMode.pip)); await _restoreSystemUiAfterCall(); } + /// Called when Android enters system PiP (e.g. Home gesture). + Future syncPipUiStateFromSystem() async { + if (!_canMinimizeToPip) return; + if (state.uiMode == VideoCallUiMode.pip) return; + await _applyPipUiState(); + } + + Future minimizeToPip() async { + if (!_canMinimizeToPip) return; + await _applyPipUiState(); + } + Future 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 { 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 { } Future endCall() async { + await _systemPipCoordinator.disarm(); await _videoCallManager.endCall(); } diff --git a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart new file mode 100644 index 00000000..5a972711 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart @@ -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(), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart index f07ae217..e9c9b281 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart @@ -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 { ); } - 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( @@ -155,7 +136,10 @@ class _VideoCallGlobalPipLayerState extends State { child: SizedBox( width: _pipW, height: _pipH, - child: _pipPreview(state, room), + child: VideoCallPipVideoPreview( + sessionState: state, + room: room, + ), ), ), ), diff --git a/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart new file mode 100644 index 00000000..0653f7e2 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart @@ -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); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart new file mode 100644 index 00000000..da20656f --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart @@ -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( + bloc: sl(), + 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(); + 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, + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart new file mode 100644 index 00000000..7760e27f --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart @@ -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 createState() => + _VideoCallSystemPipListenerState(); +} + +class _VideoCallSystemPipListenerState extends State { + static const MethodChannel _userLeaveHintChannel = MethodChannel( + 'com.eatvioapp/video_call_pip', + ); + + StreamSubscription? _subscription; + PiPStatus? _lastStatus; + + @override + void initState() { + super.initState(); + if (!Platform.isAndroid) return; + + _userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall); + final coordinator = sl(); + _subscription = coordinator.statusStream.listen(_onPipStatus); + } + + Future _onNativePipMethodCall(MethodCall call) async { + if (call.method != 'onUserLeaveHint') return; + await sl().syncPipUiStateFromSystem(); + } + + Future _onPipStatus(PiPStatus status) async { + final cubit = sl(); + 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: { + '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(); +} diff --git a/lib/features/video_calls/services/video_call_system_pip_coordinator.dart b/lib/features/video_calls/services/video_call_system_pip_coordinator.dart new file mode 100644 index 00000000..ca7c9c4d --- /dev/null +++ b/lib/features/video_calls/services/video_call_system_pip_coordinator.dart @@ -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 get isAvailable async { + if (!isPlatformSupported) return false; + return _floating.isPipAvailable; + } + + Stream 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 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 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 _setNativeUserLeaveHintPipEnabled(bool enabled) async { + if (!isPlatformSupported) return; + try { + await _nativePipChannel.invokeMethod('setPipEnabled', enabled); + } on PlatformException { + // Native channel may be unavailable during startup/teardown. + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 2f8373da..2813a4d0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { debugShowCheckedModeBanner: false, localizationsDelegates: sl().localizationDelegates, supportedLocales: sl().supportedLocales, - builder: (context, child) { - return Stack(clipBehavior: Clip.none, children: [(child ?? const SizedBox.shrink()), const VideoCallGlobalPipLayer()]); - }, + builder: (context, child) => VideoCallAppShell(child: child), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 3f87b816..b6a3d23c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index 552f5283..d9c67cd9 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -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); + }); }); }