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: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"

View File

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

View File

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

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/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()));

View File

@@ -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,

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

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_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,
),
), ),
), ),
), ),

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

View File

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

View File

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