forked from eatvio/eatvio_app
feat(video_calls): added system android pip for calls
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user