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: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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,10 @@ class CubitRegistrations {
|
||||
() => VideoCallManager(videoCallRepository: sl()),
|
||||
);
|
||||
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/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()));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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_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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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/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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user