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