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

@@ -0,0 +1,33 @@
import 'dart:io';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart';
import 'package:floating/floating.dart';
import 'package:flutter/material.dart';
/// Root overlay shell: system PiP on Android, in-app PiP fallback on iOS.
class VideoCallAppShell extends StatelessWidget {
const VideoCallAppShell({super.key, required this.child});
final Widget? child;
@override
Widget build(BuildContext context) {
final shell = Stack(
clipBehavior: Clip.none,
children: [
child ?? const SizedBox.shrink(),
const VideoCallGlobalPipLayer(),
const VideoCallSystemPipListener(),
],
);
if (!Platform.isAndroid) return shell;
return PiPSwitcher(
childWhenDisabled: shell,
childWhenEnabled: const VideoCallSystemPipContent(),
);
}
}

View File

@@ -6,12 +6,10 @@ import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
class VideoCallGlobalPipLayer extends StatefulWidget {
const VideoCallGlobalPipLayer({super.key});
@@ -54,23 +52,6 @@ class _VideoCallGlobalPipLayerState extends State<VideoCallGlobalPipLayer> {
);
}
Widget _pipPreview(VideoCallSessionState sessionState, lk.Room room) {
if (!sessionState.videoEnabled) {
return const VideoCallNoVideoBackground(logoSize: 56);
}
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
if (local != null) {
return IgnorePointer(
child: lk.VideoTrackRenderer(
local,
fit: lk.VideoViewFit.cover,
mirrorMode: lk.VideoViewMirrorMode.auto,
),
);
}
return const VideoCallNoVideoBackground(logoSize: 56);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
@@ -155,7 +136,10 @@ class _VideoCallGlobalPipLayerState extends State<VideoCallGlobalPipLayer> {
child: SizedBox(
width: _pipW,
height: _pipH,
child: _pipPreview(state, room),
child: VideoCallPipVideoPreview(
sessionState: state,
room: room,
),
),
),
),

View File

@@ -0,0 +1,42 @@
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
/// Shared compact preview for both in-app and Android system PiP.
class VideoCallPipVideoPreview extends StatelessWidget {
const VideoCallPipVideoPreview({
super.key,
required this.sessionState,
required this.room,
});
final VideoCallSessionState sessionState;
final lk.Room room;
@override
Widget build(BuildContext context) {
final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room);
if (remote != null) {
return IgnorePointer(
child: lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover),
);
}
if (sessionState.videoEnabled) {
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
if (local != null) {
return IgnorePointer(
child: lk.VideoTrackRenderer(
local,
fit: lk.VideoViewFit.cover,
mirrorMode: lk.VideoViewMirrorMode.auto,
),
);
}
}
return const VideoCallNoVideoBackground(logoSize: 56);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// Compact call UI rendered by [PiPSwitcher] while Android is in system PiP mode.
class VideoCallSystemPipContent extends StatelessWidget {
const VideoCallSystemPipContent({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
bloc: sl<VideoCallSessionCubit>(),
buildWhen: (a, b) =>
a.connectPhase != b.connectPhase ||
a.linkIssue != b.linkIssue ||
a.callStageEntered != b.callStageEntered ||
a.videoEnabled != b.videoEnabled,
builder: (context, state) {
if (state.connectPhase != VideoCallConnectPhase.connected ||
state.linkIssue == VideoCallLinkIssue.lost ||
!state.callStageEntered) {
return const ColoredBox(color: Colors.black);
}
final cubit = sl<VideoCallSessionCubit>();
final room = cubit.liveKitRoom;
if (room == null) {
return const ColoredBox(color: Colors.black);
}
return Material(
color: Colors.black,
child: ListenableBuilder(
listenable: room,
builder: (context, _) => VideoCallPipVideoPreview(
sessionState: state,
room: room,
),
),
);
},
);
}
}

View File

@@ -0,0 +1,93 @@
import 'dart:async';
import 'dart:io';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
import 'package:floating/floating.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
/// Syncs [VideoCallSessionCubit] with Android system PiP lifecycle events.
class VideoCallSystemPipListener extends StatefulWidget {
const VideoCallSystemPipListener({super.key});
@override
State<VideoCallSystemPipListener> createState() =>
_VideoCallSystemPipListenerState();
}
class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener> {
static const MethodChannel _userLeaveHintChannel = MethodChannel(
'com.eatvioapp/video_call_pip',
);
StreamSubscription<PiPStatus>? _subscription;
PiPStatus? _lastStatus;
@override
void initState() {
super.initState();
if (!Platform.isAndroid) return;
_userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall);
final coordinator = sl<VideoCallSystemPipCoordinator>();
_subscription = coordinator.statusStream.listen(_onPipStatus);
}
Future<void> _onNativePipMethodCall(MethodCall call) async {
if (call.method != 'onUserLeaveHint') return;
await sl<VideoCallSessionCubit>().syncPipUiStateFromSystem();
}
Future<void> _onPipStatus(PiPStatus status) async {
final cubit = sl<VideoCallSessionCubit>();
final wasEnabled = _lastStatus == PiPStatus.enabled;
_lastStatus = status;
if (status == PiPStatus.enabled) {
await cubit.syncPipUiStateFromSystem();
return;
}
if (!wasEnabled || status != PiPStatus.disabled) return;
if (cubit.state.uiMode != VideoCallUiMode.pip) return;
await cubit.restoreFullscreenFromPip();
if (!mounted) return;
if (cubit.isCallScreenRouteVisible) return;
_openCallScreen(cubit.state);
}
void _openCallScreen(VideoCallSessionState state) {
if (state.conversationUuid.isEmpty) return;
final ctx = AppRouter.navigatorKey.currentContext;
if (ctx == null) return;
GoRouter.of(ctx).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{
'conversationUuid': state.conversationUuid,
'counterpartName': state.counterpartName,
'localParticipantName': state.localParticipantName,
},
);
}
@override
void dispose() {
if (Platform.isAndroid) {
_userLeaveHintChannel.setMethodCallHandler(null);
}
unawaited(_subscription?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}