feat(vide_calls): gms calls handling

This commit is contained in:
misha
2026-05-25 08:07:20 +03:00
parent 1acb158f36
commit 73cec1a01e
35 changed files with 2288 additions and 618 deletions

View File

@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
@@ -25,7 +24,6 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.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/pages/video_call_screen.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:file_picker/file_picker.dart';
@@ -241,13 +239,6 @@ class _ChatScreenState extends State<ChatScreen> {
padding: const EdgeInsetsDirectional.only(end: 8),
child: _ChatHeaderVideoCallButton(
onPressed: () {
unawaited(
sl<VideoCallSessionCubit>().startCall(
conversationUuid: widget.uuid,
counterpartName: state.chatRoom.users.receiver.name,
localParticipantName: state.chatRoom.users.sender.name,
),
);
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
class VideoCallManagerSnapshot {
const VideoCallManagerSnapshot({
required this.conversationUuid,
this.counterpartName,
this.localParticipantName,
required this.micEnabled,
required this.videoEnabled,
this.connectError,
required this.connectPhase,
required this.linkIssue,
this.systemAudioInterrupted = false,
});
final String conversationUuid;
final String? counterpartName;
final String? localParticipantName;
final bool micEnabled;
final bool videoEnabled;
final String? connectError;
final VideoCallConnectPhase connectPhase;
final VideoCallLinkIssue linkIssue;
final bool systemAudioInterrupted;
static const initial = VideoCallManagerSnapshot(
conversationUuid: '',
micEnabled: false,
videoEnabled: false,
connectPhase: VideoCallConnectPhase.idle,
linkIssue: VideoCallLinkIssue.none,
);
VideoCallManagerSnapshot copyWith({
String? conversationUuid,
String? counterpartName,
String? localParticipantName,
bool? micEnabled,
bool? videoEnabled,
String? connectError,
VideoCallConnectPhase? connectPhase,
VideoCallLinkIssue? linkIssue,
bool? systemAudioInterrupted,
bool clearConnectError = false,
}) {
return VideoCallManagerSnapshot(
conversationUuid: conversationUuid ?? this.conversationUuid,
counterpartName: counterpartName ?? this.counterpartName,
localParticipantName: localParticipantName ?? this.localParticipantName,
micEnabled: micEnabled ?? this.micEnabled,
videoEnabled: videoEnabled ?? this.videoEnabled,
connectError: clearConnectError ? null : (connectError ?? this.connectError),
connectPhase: connectPhase ?? this.connectPhase,
linkIssue: linkIssue ?? this.linkIssue,
systemAudioInterrupted:
systemAudioInterrupted ?? this.systemAudioInterrupted,
);
}
}

View File

@@ -1,120 +1,100 @@
import 'dart:async';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/cubit/base_cubit.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.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/presentation/widgets/video_call_top_overlay.dart';
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:permission_handler/permission_handler.dart';
// ignore_for_file: use_build_context_synchronously
class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
VideoCallSessionCubit({required VideoCallRepository videoCallRepository})
: _videoCallRepository = videoCallRepository,
super(const VideoCallSessionState());
VideoCallSessionCubit({required VideoCallManager videoCallManager})
: _videoCallManager = videoCallManager,
super(const VideoCallSessionState()) {
_videoCallManager.addListener(_onManagerSnapshot);
_syncFromManager(_videoCallManager.snapshot);
}
final VideoCallRepository _videoCallRepository;
final VideoCallManager _videoCallManager;
static const lk.AudioCaptureOptions _micCaptureOptions =
lk.AudioCaptureOptions(stopAudioCaptureOnMute: false);
static const Duration _mutedMicPollInterval = Duration(milliseconds: 320);
static const Duration _mutedMicOverlayMinInterval = Duration(
milliseconds: 2600,
);
static const double _mutedMicAudioLevelThreshold = 0.008;
static const double _mutedMicEnergyDelta = 1e-6;
lk.Room? get liveKitRoom => _videoCallManager.room;
lk.Room? _room;
lk.EventsListener<lk.RoomEvent>? _roomEventsListener;
OverlayEntry? _poorConnectionEntry;
Timer? _mutedMicPollTimer;
DateTime? _lastMutedMicOverlayAt;
bool _recordingOverlayScheduled = false;
bool _mutedMicStatsInFlight = false;
num? _lastMutedMicTotalEnergy;
bool _liveKitDisposed = true;
bool _callEndedNotified = true;
static bool _isCallSessionInactive(VideoCallManagerSnapshot snapshot) {
return snapshot.conversationUuid.isEmpty ||
snapshot.connectPhase == VideoCallConnectPhase.idle;
}
lk.Room? get liveKitRoom => _room;
VideoCallSessionState _sessionStateFromSnapshot(
VideoCallManagerSnapshot snapshot,
) {
final hideStaleConnectError =
snapshot.connectPhase == VideoCallConnectPhase.connected &&
snapshot.linkIssue != VideoCallLinkIssue.lost;
final resetCallStage =
_isCallSessionInactive(snapshot) ||
snapshot.connectPhase == VideoCallConnectPhase.connecting;
lk.CameraCaptureOptions _cameraCaptureOptions({
lk.CameraPosition? cameraPosition,
}) {
return lk.CameraCaptureOptions(
cameraPosition:
cameraPosition ??
(state.useFrontCamera
? lk.CameraPosition.front
: lk.CameraPosition.back),
params: lk.VideoParametersPresets.h1080_169,
return state.copyWith(
conversationUuid: snapshot.conversationUuid,
counterpartName: snapshot.counterpartName,
localParticipantName: snapshot.localParticipantName,
micEnabled: snapshot.micEnabled,
videoEnabled: snapshot.videoEnabled,
connectError: hideStaleConnectError ? null : snapshot.connectError,
connectPhase: snapshot.connectPhase,
linkIssue: snapshot.linkIssue,
systemAudioInterrupted: snapshot.systemAudioInterrupted,
callStageEntered: resetCallStage ? false : state.callStageEntered,
uiMode:
_isCallSessionInactive(snapshot)
? VideoCallUiMode.hidden
: state.uiMode,
);
}
BuildContext? get _navigatorContext => AppRouter.navigatorKey.currentContext;
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
if (isClosed) return;
if (_isCallSessionInactive(snapshot)) {
emit(const VideoCallSessionState());
unawaited(_restoreSystemUiAfterCall());
return;
}
emit(_sessionStateFromSnapshot(snapshot));
}
void _syncFromManager(VideoCallManagerSnapshot snapshot) {
if (_isCallSessionInactive(snapshot)) {
emit(const VideoCallSessionState());
return;
}
emit(_sessionStateFromSnapshot(snapshot));
}
/// Синхронизирует UI-состояние после [VideoCallManager.endCall] из notification /
/// foreground task (без повторного disconnect).
Future<void> onCallEndedExternally() async {
await _onCallEndedExternally();
}
Future<void> _onCallEndedExternally() async {
if (isClosed) return;
await _restoreSystemUiAfterCall();
emit(const VideoCallSessionState());
}
Future<void> startCall({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
}) async {
if (_room != null &&
!_liveKitDisposed &&
state.conversationUuid == conversationUuid &&
_room!.connectionState == lk.ConnectionState.connected) {
emit(
state.copyWith(
counterpartName: counterpartName ?? state.counterpartName,
localParticipantName:
localParticipantName ?? state.localParticipantName,
),
);
return;
}
await endCall();
_resetSessionFlagsForNewRoom();
_callEndedNotified = false;
_liveKitDisposed = false;
_room = lk.Room(
roomOptions: const lk.RoomOptions(
adaptiveStream: true,
defaultCameraCaptureOptions: lk.CameraCaptureOptions(
params: lk.VideoParametersPresets.h1080_169,
),
defaultAudioCaptureOptions: _micCaptureOptions,
),
await _videoCallManager.startCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: state.micEnabled,
videoEnabled: state.videoEnabled,
);
_attachRoomEventListeners();
_startMutedMicPollTimer();
emit(
state.copyWith(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
connectError: null,
connectPhase: VideoCallConnectPhase.connecting,
uiMode: VideoCallUiMode.hidden,
linkIssue: VideoCallLinkIssue.none,
callStageEntered: false,
),
);
await _connectLiveKit();
}
void _resetSessionFlagsForNewRoom() {
_lastMutedMicOverlayAt = null;
_lastMutedMicTotalEnergy = null;
_recordingOverlayScheduled = false;
}
Future<void> onCallScreenVisible({
@@ -122,22 +102,39 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
String? counterpartName,
String? localParticipantName,
}) async {
if (state.conversationUuid != conversationUuid ||
_room == null ||
_liveKitDisposed) {
await startCall(
final isResumingActiveCall =
state.conversationUuid == conversationUuid &&
state.callStageEntered &&
_videoCallManager.room != null;
if (isResumingActiveCall) {
await _videoCallManager.onCallScreenVisible(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: state.micEnabled,
videoEnabled: state.videoEnabled,
);
} else {
emit(
state.copyWith(
counterpartName: counterpartName ?? state.counterpartName,
localParticipantName:
localParticipantName ?? state.localParticipantName,
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: false,
videoEnabled: false,
callStageEntered: false,
connectError: null,
linkIssue: VideoCallLinkIssue.none,
),
);
await _videoCallManager.startCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: false,
videoEnabled: false,
);
}
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
@@ -170,6 +167,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
await _restoreSystemUiAfterCall();
}
Future<void> restoreFullscreenFromPip() async {
if (state.uiMode != VideoCallUiMode.pip) return;
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
}
Future<void> leaveCallRoute() async {
if (state.connectPhase == VideoCallConnectPhase.connected &&
state.linkIssue != VideoCallLinkIssue.lost) {
@@ -183,73 +188,25 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
}
}
void enterCallStage() {
Future<void> enterCallStage() async {
if (isClosed) return;
if (state.connectPhase != VideoCallConnectPhase.connected) return;
if (state.callStageEntered) return;
emit(state.copyWith(callStageEntered: true));
await _videoCallManager.onEnterCallStage();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isClosed) return;
_scheduleRecordingNoticeOnce();
_videoCallManager.scheduleRecordingNoticeOnce();
});
}
Future<void> retryAfterConnectionLoss() async {
if (state.conversationUuid.isEmpty) return;
_disposeRoomEphemerals();
await _disconnectLiveKitOnly();
_resetSessionFlagsForNewRoom();
_liveKitDisposed = false;
_room = lk.Room(
roomOptions: const lk.RoomOptions(
adaptiveStream: true,
defaultCameraCaptureOptions: lk.CameraCaptureOptions(
params: lk.VideoParametersPresets.h1080_169,
),
defaultAudioCaptureOptions: _micCaptureOptions,
),
);
_attachRoomEventListeners();
_startMutedMicPollTimer();
emit(
state.copyWith(
connectPhase: VideoCallConnectPhase.connecting,
linkIssue: VideoCallLinkIssue.none,
connectError: null,
callStageEntered: false,
),
);
await _connectLiveKit();
}
void _disposeRoomEphemerals() {
_mutedMicPollTimer?.cancel();
_mutedMicPollTimer = null;
final listener = _roomEventsListener;
_roomEventsListener = null;
if (listener != null) {
unawaited(listener.dispose());
}
final poor = _poorConnectionEntry;
_poorConnectionEntry = null;
if (poor != null && poor.mounted) {
poor.remove();
}
await _videoCallManager.retryAfterConnectionLoss();
emit(state.copyWith(callStageEntered: false));
}
Future<void> endCall() async {
final uuid = state.conversationUuid;
_disposeRoomEphemerals();
await _disconnectLiveKitOnly();
if (!_callEndedNotified && uuid.isNotEmpty) {
_callEndedNotified = true;
try {
await _videoCallRepository.notifyCallEnded(uuid);
} catch (_) {}
}
await _restoreSystemUiAfterCall();
_liveKitDisposed = true;
emit(const VideoCallSessionState());
await _videoCallManager.endCall();
}
void expandLocal() {
@@ -281,240 +238,16 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
}
Future<void> toggleMic() async {
if (_liveKitDisposed ||
_room == null ||
_room!.connectionState != lk.ConnectionState.connected) {
emit(state.copyWith(micEnabled: !state.micEnabled));
_lastMutedMicTotalEnergy = null;
return;
}
final next = !state.micEnabled;
if (next) {
final granted = await _ensureMicrophonePermission();
if (!granted) return;
}
emit(state.copyWith(micEnabled: next));
_lastMutedMicTotalEnergy = null;
await _room!.localParticipant?.setMicrophoneEnabled(
next,
audioCaptureOptions: _micCaptureOptions,
);
await _videoCallManager.toggleMic(state.isMicActiveForUi);
}
Future<void> toggleVideo() async {
if (_liveKitDisposed ||
_room == null ||
_room!.connectionState != lk.ConnectionState.connected) {
emit(state.copyWith(videoEnabled: !state.videoEnabled));
return;
}
final next = !state.videoEnabled;
if (next) {
final granted = await _ensureCameraPermission();
if (!granted) return;
}
emit(state.copyWith(videoEnabled: next));
final lp = _room!.localParticipant;
if (lp == null) return;
if (next) {
await lp.setCameraEnabled(
true,
cameraCaptureOptions: _cameraCaptureOptions(),
);
} else {
await lp.setCameraEnabled(false);
}
await _videoCallManager.toggleVideo(state.videoEnabled);
}
Future<void> switchCameraFacing() async {
if (_liveKitDisposed ||
_room == null ||
_room!.connectionState != lk.ConnectionState.connected) {
emit(state.copyWith(useFrontCamera: !state.useFrontCamera));
return;
}
if (!state.videoEnabled) return;
final lp = _room!.localParticipant;
if (lp == null) return;
final currentPosition = state.useFrontCamera
? lk.CameraPosition.front
: lk.CameraPosition.back;
final newPosition = currentPosition.switched();
final pub = lp.getTrackPublicationBySource(lk.TrackSource.camera);
final track = pub?.track;
if (track is lk.LocalVideoTrack) {
try {
await track.setCameraPosition(newPosition);
emit(
state.copyWith(
useFrontCamera: newPosition == lk.CameraPosition.front,
),
);
} catch (_) {}
return;
}
try {
await lp.setCameraEnabled(
true,
cameraCaptureOptions: _cameraCaptureOptions(
cameraPosition: newPosition,
),
);
emit(
state.copyWith(useFrontCamera: newPosition == lk.CameraPosition.front),
);
} catch (_) {}
}
void _startMutedMicPollTimer() {
_mutedMicPollTimer?.cancel();
_mutedMicPollTimer = Timer.periodic(_mutedMicPollInterval, (_) {
if (isClosed ||
_liveKitDisposed ||
state.micEnabled ||
_mutedMicStatsInFlight) {
return;
}
if (_room == null ||
_room!.connectionState != lk.ConnectionState.connected) {
return;
}
_mutedMicStatsInFlight = true;
unawaited(
_pollMutedMicFromWebRtcStats().whenComplete(() {
_mutedMicStatsInFlight = false;
}),
);
});
}
void _attachRoomEventListeners() {
final room = _room;
if (room == null) return;
_roomEventsListener = room.createListener()
..on<lk.RoomReconnectingEvent>((_) {
if (isClosed) return;
if (state.connectPhase != VideoCallConnectPhase.connected) return;
emit(state.copyWith(linkIssue: VideoCallLinkIssue.reconnecting));
})
..on<lk.RoomReconnectedEvent>((_) {
if (isClosed) return;
emit(state.copyWith(linkIssue: VideoCallLinkIssue.none));
})
..on<lk.RoomDisconnectedEvent>((_) {
if (isClosed) return;
if (_liveKitDisposed) return;
if (state.connectPhase != VideoCallConnectPhase.connected) return;
emit(state.copyWith(linkIssue: VideoCallLinkIssue.lost));
})
..on<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
if (e.participant != room.localParticipant) return;
if (isClosed) return;
_syncPoorConnectionOverlay(e.connectionQuality);
})
..on<lk.ActiveSpeakersChangedEvent>((e) {
if (isClosed || state.micEnabled) return;
final lp = room.localParticipant;
if (lp == null) return;
if (e.speakers.any((p) => p.sid == lp.sid)) {
_maybeShowMutedMicOverlay();
}
});
}
void _scheduleRecordingNoticeOnce() {
if (_recordingOverlayScheduled) return;
final ctx = _navigatorContext;
if (ctx == null) return;
_recordingOverlayScheduled = true;
VideoCallTopOverlay.insertBanner(
ctx,
message: S.of(ctx).video_call_recording_notice,
backgroundColor: appTheme.videoCallNoticeBannerBackground,
svgAsset: VideoCallTopOverlay.warningSvg,
autoRemoveAfter: const Duration(seconds: 3),
);
}
void _syncPoorConnectionOverlay(lk.ConnectionQuality q) {
final ctx = _navigatorContext;
if (ctx == null) return;
final bad =
q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost;
if (bad) {
if (_poorConnectionEntry != null) return;
_poorConnectionEntry = VideoCallTopOverlay.insertBanner(
ctx,
message: S.of(ctx).video_call_poor_connection,
backgroundColor: appTheme.strongRed,
svgAsset: VideoCallTopOverlay.poorConnectionSvg,
);
} else {
final e = _poorConnectionEntry;
if (e != null && e.mounted) {
e.remove();
}
_poorConnectionEntry = null;
}
}
void _maybeShowMutedMicOverlay() {
if (isClosed || state.micEnabled) return;
final ctx = _navigatorContext;
if (ctx == null) return;
final now = DateTime.now();
if (_lastMutedMicOverlayAt != null &&
now.difference(_lastMutedMicOverlayAt!) < _mutedMicOverlayMinInterval) {
return;
}
_lastMutedMicOverlayAt = now;
VideoCallTopOverlay.insertBanner(
ctx,
message: S.of(ctx).video_call_muted_mic_warning,
backgroundColor: appTheme.videoCallNoticeBannerBackground,
svgAsset: VideoCallTopOverlay.warningSvg,
autoRemoveAfter: const Duration(seconds: 2),
);
}
Future<void> _pollMutedMicFromWebRtcStats() async {
if (isClosed || _liveKitDisposed || state.micEnabled) return;
final room = _room;
if (room == null || room.connectionState != lk.ConnectionState.connected) {
return;
}
final lp = room.localParticipant;
if (lp == null) return;
final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone);
final track = pub?.track;
if (track is! lk.LocalAudioTrack) return;
if (!track.muted) return;
try {
final stats = await track.getSenderStats();
if (isClosed || _liveKitDisposed || state.micEnabled) return;
final src = stats?.audioSourceStats;
if (src == null) return;
final level = src.audioLevel;
if (level != null && level >= _mutedMicAudioLevelThreshold) {
_maybeShowMutedMicOverlay();
_lastMutedMicTotalEnergy = src.totalAudioEnergy;
return;
}
final energy = src.totalAudioEnergy;
if (energy != null) {
final prev = _lastMutedMicTotalEnergy;
if (prev != null && energy > prev + _mutedMicEnergyDelta) {
_maybeShowMutedMicOverlay();
}
_lastMutedMicTotalEnergy = energy;
}
} catch (_) {}
await _videoCallManager.switchCameraFacing(state.useFrontCamera);
emit(state.copyWith(useFrontCamera: !state.useFrontCamera));
}
Future<void> _applyVideoCallSystemUi() async {
@@ -541,185 +274,15 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
} on PlatformException catch (_) {}
}
Future<void> _connectLiveKit() async {
final room = _room;
if (room == null) return;
emit(state.copyWith(connectError: null));
try {
final tokenResponse = await _videoCallRepository.getParticipantToken(
state.conversationUuid,
);
if (isClosed || _liveKitDisposed) return;
String resolvedLocalDisplayName() =>
_videoCallManager.resolvedLocalDisplayName();
var micOn = state.micEnabled;
var videoOn = state.videoEnabled;
if (micOn) {
final granted = await _ensureMicrophonePermission();
if (isClosed || _liveKitDisposed) return;
if (!granted) micOn = false;
}
if (videoOn) {
final granted = await _ensureCameraPermission();
if (isClosed || _liveKitDisposed) return;
if (!granted) videoOn = false;
}
if (micOn != state.micEnabled || videoOn != state.videoEnabled) {
emit(state.copyWith(micEnabled: micOn, videoEnabled: videoOn));
}
await room.connect(tokenResponse.wsUrl, tokenResponse.token);
if (isClosed || _liveKitDisposed) {
return;
}
var effectiveMic = micOn;
var effectiveVideo = videoOn;
final lp = room.localParticipant;
if (micOn) {
try {
await lp?.setMicrophoneEnabled(
true,
audioCaptureOptions: _micCaptureOptions,
);
} catch (_) {
effectiveMic = false;
}
}
if (isClosed || _liveKitDisposed) return;
if (videoOn) {
try {
await lp?.setCameraEnabled(
true,
cameraCaptureOptions: _cameraCaptureOptions(),
);
} catch (_) {
effectiveVideo = false;
}
}
if (effectiveMic != state.micEnabled ||
effectiveVideo != state.videoEnabled) {
emit(
state.copyWith(
micEnabled: effectiveMic,
videoEnabled: effectiveVideo,
),
);
}
if (!isClosed) {
emit(
state.copyWith(
connectPhase: VideoCallConnectPhase.connected,
linkIssue: VideoCallLinkIssue.none,
),
);
_syncPoorConnectionOverlay(
room.localParticipant?.connectionQuality ??
lk.ConnectionQuality.unknown,
);
}
} catch (e) {
if (!isClosed) {
emit(
state.copyWith(
connectError: e.toString(),
connectPhase: VideoCallConnectPhase.idle,
linkIssue: VideoCallLinkIssue.lost,
),
);
}
}
}
Future<void> _disconnectLiveKitOnly() async {
if (_room == null) return;
_liveKitDisposed = true;
final room = _room!;
_room = null;
try {
if (room.connectionState != lk.ConnectionState.disconnected) {
await room.disconnect();
}
} catch (_) {}
try {
await room.dispose();
} catch (_) {}
}
bool _permissionNeedsSettings(PermissionStatus status) =>
status.isPermanentlyDenied || status.isRestricted;
Future<bool> _ensureCameraPermission() async {
final ctx = _navigatorContext;
if (ctx == null) return false;
var status = await Permission.camera.status;
if (status.isGranted) return true;
status = await Permission.camera.request();
if (status.isGranted) return true;
final ctx2 = _navigatorContext;
if (ctx2 == null) return false;
final s = S.of(ctx2);
await showErrorDialog(
ctx2,
title: s.video_call_permission_camera_title,
description: s.video_call_permission_camera_description,
buttonTitle: s.allowAccess,
needToOpenSettings: _permissionNeedsSettings(status),
);
return false;
}
Future<bool> _ensureMicrophonePermission() async {
final ctx = _navigatorContext;
if (ctx == null) return false;
var status = await Permission.microphone.status;
if (status.isGranted) return true;
status = await Permission.microphone.request();
if (status.isGranted) return true;
final ctx2 = _navigatorContext;
if (ctx2 == null) return false;
final s = S.of(ctx2);
await showErrorDialog(
ctx2,
title: s.video_call_permission_microphone_title,
description: s.video_call_permission_microphone_description,
buttonTitle: s.allowAccess,
needToOpenSettings: _permissionNeedsSettings(status),
);
return false;
}
String resolvedLocalDisplayName() {
final fromNav = state.localParticipantName?.trim();
if (fromNav != null && fromNav.isNotEmpty) return fromNav;
final n = (_room?.localParticipant?.name ?? '').trim();
if (n.isNotEmpty) return n;
final ctx = _navigatorContext;
if (ctx != null) {
return S.of(ctx).video_call_you;
}
return 'You';
}
String resolvedRemoteDisplayName(String fallback) {
final room = _room;
if (room != null) {
for (final p in room.remoteParticipants.values) {
final n = p.name.trim();
if (n.isNotEmpty) return n;
}
}
final c = state.counterpartName?.trim();
if (c != null && c.isNotEmpty) return c;
return fallback;
}
String resolvedRemoteDisplayName(String fallback) =>
_videoCallManager.resolvedRemoteDisplayName(fallback);
@override
Future<void> close() async {
_mutedMicPollTimer?.cancel();
_videoCallManager.removeListener(_onManagerSnapshot);
await endCall();
return super.close();
}

View File

@@ -14,12 +14,14 @@ enum VideoCallLinkIssue { none, reconnecting, lost }
@freezed
abstract class VideoCallSessionState with _$VideoCallSessionState {
const VideoCallSessionState._();
const factory VideoCallSessionState({
@Default('') String conversationUuid,
String? counterpartName,
String? localParticipantName,
@Default(true) bool micEnabled,
@Default(true) bool videoEnabled,
@Default(false) bool micEnabled,
@Default(false) bool videoEnabled,
@Default(true) bool splitParticipantView,
@Default(false) bool mainStageShowsLocalVideo,
@Default(true) bool useFrontCamera,
@@ -28,5 +30,9 @@ abstract class VideoCallSessionState with _$VideoCallSessionState {
@Default(VideoCallConnectPhase.idle) VideoCallConnectPhase connectPhase,
@Default(VideoCallLinkIssue.none) VideoCallLinkIssue linkIssue,
@Default(false) bool callStageEntered,
@Default(false) bool systemAudioInterrupted,
}) = _VideoCallSessionState;
/// UI for mic control: off while GSM / system audio holds the session.
bool get isMicActiveForUi => micEnabled && !systemAudioInterrupted;
}

View File

@@ -117,6 +117,7 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
a.counterpartName != b.counterpartName ||
a.callStageEntered != b.callStageEntered ||
a.micEnabled != b.micEnabled ||
a.systemAudioInterrupted != b.systemAudioInterrupted ||
a.videoEnabled != b.videoEnabled ||
a.splitParticipantView != b.splitParticipantView ||
a.mainStageShowsLocalVideo != b.mainStageShowsLocalVideo ||
@@ -125,7 +126,7 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
final room = _cubit.liveKitRoom;
final showLost =
sessionState.linkIssue == VideoCallLinkIssue.lost ||
sessionState.linkIssue == VideoCallLinkIssue.lost ||
(sessionState.connectPhase == VideoCallConnectPhase.idle &&
sessionState.connectError != null);
@@ -160,8 +161,7 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
final showEntranceLobby =
!sessionState.callStageEntered &&
sessionState.linkIssue == VideoCallLinkIssue.none &&
(sessionState.connectPhase == VideoCallConnectPhase.connecting ||
sessionState.connectPhase == VideoCallConnectPhase.connected);
sessionState.connectPhase != VideoCallConnectPhase.idle;
if (showEntranceLobby) {
final remoteName = _cubit.resolvedRemoteDisplayName(
@@ -176,7 +176,7 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
body: VideoCallConnectingEntranceScreen(
counterpartDisplayName: remoteName,
counterpartAvatarUrl: widget.counterpartAvatarUrl,
micEnabled: sessionState.micEnabled,
micEnabled: sessionState.isMicActiveForUi,
videoEnabled: sessionState.videoEnabled,
canEnterCall: canEnterCall,
onEnterCall: _cubit.enterCallStage,
@@ -237,8 +237,9 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
remoteDisplayName: _cubit.resolvedRemoteDisplayName(
counterpartFallback,
),
micEnabled: sessionState.micEnabled,
micEnabled: sessionState.isMicActiveForUi,
videoEnabled: sessionState.videoEnabled,
systemAudioInterrupted: sessionState.systemAudioInterrupted,
splitParticipantView: sessionState.splitParticipantView,
mainStageShowsLocalVideo:
sessionState.mainStageShowsLocalVideo,

View File

@@ -140,26 +140,34 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget {
const SizedBox(height: _topContentSpacing),
_avatar(context, counterpartDisplayName, counterpartAvatarUrl),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: appTheme.greenA700,
shape: BoxShape.circle,
if (canEnterCall)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: appTheme.greenA700,
shape: BoxShape.circle,
),
),
),
const SizedBox(width: 8),
Text(
s.video_call_status_online,
style: CustomTextStyles.bodySmallMulishBlack900
.copyWith(height: 14 / 12),
),
],
),
const SizedBox(height: 20),
const SizedBox(width: 8),
Text(
s.video_call_status_online,
style: CustomTextStyles.bodySmallMulishBlack900
.copyWith(height: 14 / 12),
),
],
)
else
Text(
s.video_call_screen_connecting_subtitle,
textAlign: TextAlign.center,
style: CustomTextStyles.bodySmallMulishBlack900
.copyWith(height: 14 / 12),
),
SizedBox(height: canEnterCall ? 20 : 16),
Text(
s.video_call_screen_connecting_title,
textAlign: TextAlign.center,

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:math' as math;
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';
@@ -41,6 +43,7 @@ class _VideoCallGlobalPipLayerState extends State<VideoCallGlobalPipLayer> {
void _openFullscreenRoute(VideoCallSessionState s) {
final ctx = AppRouter.navigatorKey.currentContext;
if (ctx == null) return;
unawaited(sl<VideoCallSessionCubit>().restoreFullscreenFromPip());
GoRouter.of(ctx).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{
@@ -71,6 +74,7 @@ class _VideoCallGlobalPipLayerState extends State<VideoCallGlobalPipLayer> {
@override
Widget build(BuildContext context) {
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
bloc: sl<VideoCallSessionCubit>(),
buildWhen: (a, b) =>
a.uiMode != b.uiMode ||
a.connectPhase != b.connectPhase ||

View File

@@ -8,6 +8,11 @@ abstract final class VideoCallLiveKitTrackUtils {
static lk.VideoTrack? visibleVideoFromPublication(lk.TrackPublication? pub) {
if (pub == null || pub.muted) return null;
if (pub is lk.RemoteTrackPublication) {
if (!pub.subscribed || pub.streamState != lk.StreamState.active) {
return null;
}
}
final v = asVideoTrack(pub.track);
if (v == null || v.muted) return null;
return v;

View File

@@ -24,11 +24,14 @@ class VideoCallRemoteVideoStage extends StatelessWidget {
if (remote != null) {
return lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover);
}
final showConnectError =
connectError != null &&
room.connectionState != lk.ConnectionState.connected;
return Stack(
fit: StackFit.expand,
children: [
const VideoCallNoVideoBackground(),
if (connectError != null)
if (showConnectError)
Center(
child: Padding(
padding: const EdgeInsets.all(24),

View File

@@ -1,6 +1,9 @@
import 'dart:math' as math;
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart';
import 'package:eatvio_app/generated/l10n.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_name_chips.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart';
@@ -25,6 +28,7 @@ class VideoCallStageLayout extends StatefulWidget {
required this.localDisplayName,
required this.remoteDisplayName,
required this.micEnabled,
this.systemAudioInterrupted = false,
required this.videoEnabled,
required this.splitParticipantView,
required this.mainStageShowsLocalVideo,
@@ -49,6 +53,7 @@ class VideoCallStageLayout extends StatefulWidget {
final String localDisplayName;
final String remoteDisplayName;
final bool micEnabled;
final bool systemAudioInterrupted;
final bool videoEnabled;
final bool splitParticipantView;
final bool mainStageShowsLocalVideo;
@@ -161,10 +166,23 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
),
..._bottomBarGradient(),
..._bottomControlsRow(),
..._systemInterruptionBanner(),
],
);
}
List<Widget> _systemInterruptionBanner() {
if (!widget.systemAudioInterrupted) return const [];
return [
Positioned(
left: 16,
right: 16,
top: widget.topInset + 8,
child: const _VideoCallSystemInterruptionBanner(),
),
];
}
Widget _buildFullscreenLayout(BoxConstraints constraints) {
final maxW = constraints.maxWidth;
final maxH = constraints.maxHeight;
@@ -203,6 +221,7 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
),
..._bottomBarGradient(),
..._bottomControlsRow(),
..._systemInterruptionBanner(),
Positioned(
top: widget.topInset + _edgeInset + _shrinkExpandExtraTop,
left: _controlsHorizontalInset,
@@ -487,3 +506,57 @@ class _VideoCallPipLocalContent extends StatelessWidget {
);
}
}
class _VideoCallSystemInterruptionBanner extends StatelessWidget {
const _VideoCallSystemInterruptionBanner();
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Material(
color: appTheme.videoCallNoticeBannerBackground,
borderRadius: BorderRadius.circular(12),
elevation: 6,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SvgPicture.asset(
VideoCallTopOverlay.warningSvg,
width: 22,
height: 22,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
s.video_call_mic_muted_phone_call,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.25,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
s.video_call_system_interruption,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
height: 1.25,
fontWeight: FontWeight.w400,
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'dart:async';
import 'package:audio_session/audio_session.dart';
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
/// Configures and owns the platform audio session for an active VoIP call.
class VideoCallAudioSessionCoordinator {
VideoCallAudioSessionCoordinator({
VideoCallAudioSessionFactory? audioSessionFactory,
}) : _audioSessionFactory =
audioSessionFactory ?? (() => AudioSession.instance);
final VideoCallAudioSessionFactory _audioSessionFactory;
static final AudioSessionConfiguration callConfiguration =
AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.defaultToSpeaker,
avAudioSessionMode: AVAudioSessionMode.voiceChat,
avAudioSessionRouteSharingPolicy:
AVAudioSessionRouteSharingPolicy.defaultPolicy,
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
flags: AndroidAudioFlags.none,
usage: AndroidAudioUsage.voiceCommunication,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
androidWillPauseWhenDucked: true,
);
StreamSubscription<AudioInterruptionEvent>? _interruptionSubscription;
AudioSession? _session;
bool _configured = false;
bool get isActive => _session != null;
Future<void> start({
required void Function(AudioInterruptionEvent event) onInterruption,
}) async {
final session = await _audioSessionFactory();
_session = session;
if (!_configured) {
await session.configure(callConfiguration);
_configured = true;
}
await session.setActive(true);
await _interruptionSubscription?.cancel();
_interruptionSubscription = session.interruptionEventStream.listen(
onInterruption,
);
}
Future<bool> reactivate() async {
final session = _session;
if (session == null) return false;
try {
return await session.setActive(true);
} catch (_) {
return false;
}
}
Future<void> stop() async {
await _interruptionSubscription?.cancel();
_interruptionSubscription = null;
final session = _session;
_session = null;
if (session == null) return;
try {
await session.setActive(false);
} catch (_) {}
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
abstract final class VideoCallForegroundBootstrap {
static const int _serviceId = 1001;
static bool _initialized = false;
static void ensureInitialized() {
if (_initialized) return;
FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: 'video_call_ongoing',
channelName: 'Video call',
channelDescription: 'Ongoing video call notifications',
channelImportance: NotificationChannelImportance.LOW,
priority: NotificationPriority.LOW,
onlyAlertOnce: true,
),
iosNotificationOptions: const IOSNotificationOptions(
showNotification: true,
playSound: false,
),
foregroundTaskOptions: ForegroundTaskOptions(
eventAction: ForegroundTaskEventAction.nothing(),
autoRunOnBoot: false,
autoRunOnMyPackageReplaced: false,
allowWakeLock: true,
allowWifiLock: true,
),
);
_initialized = true;
}
static int get serviceId => _serviceId;
}

View File

@@ -0,0 +1,79 @@
import 'dart:io';
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_bootstrap.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_task_handler.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:permission_handler/permission_handler.dart';
class VideoCallForegroundCoordinator {
Future<void> ensureNotificationPermission() async {
final permission = await FlutterForegroundTask.checkNotificationPermission();
if (permission == NotificationPermission.granted) return;
await FlutterForegroundTask.requestNotificationPermission();
}
Future<void> startOngoingCall({
required String conversationUuid,
required String notificationTitle,
required String notificationBody,
required String endCallButtonLabel,
}) async {
VideoCallForegroundBootstrap.ensureInitialized();
await ensureNotificationPermission();
await FlutterForegroundTask.saveData(
key: videoCallActiveConversationStorageKey,
value: conversationUuid,
);
if (await FlutterForegroundTask.isRunningService) {
await FlutterForegroundTask.updateService(
notificationTitle: notificationTitle,
notificationText: notificationBody,
notificationButtons: [
NotificationButton(
id: videoCallForegroundEndCallButtonId,
text: endCallButtonLabel,
),
],
);
return;
}
if (Platform.isAndroid) {
final micStatus = await Permission.microphone.status;
if (!micStatus.isGranted) {
await Permission.microphone.request();
}
}
await FlutterForegroundTask.startService(
serviceId: VideoCallForegroundBootstrap.serviceId,
serviceTypes: const [ForegroundServiceTypes.microphone],
notificationTitle: notificationTitle,
notificationText: notificationBody,
notificationInitialRoute: '/video-call-screen',
notificationButtons: [
NotificationButton(
id: videoCallForegroundEndCallButtonId,
text: endCallButtonLabel,
),
],
callback: videoCallForegroundTaskStartCallback,
);
}
Future<void> stopOngoingCall() async {
if (!await FlutterForegroundTask.isRunningService) return;
await FlutterForegroundTask.stopService();
await FlutterForegroundTask.removeData(
key: videoCallActiveConversationStorageKey,
);
}
Future<String?> readActiveConversationUuid() {
return FlutterForegroundTask.getData<String>(
key: videoCallActiveConversationStorageKey,
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/widgets.dart';
typedef VideoCallLifecycleCallback = void Function(AppLifecycleState state);
class VideoCallLifecycleBinding {
AppLifecycleListener? _listener;
VideoCallLifecycleCallback? _callback;
@visibleForTesting
void simulateLifecycle(AppLifecycleState state) => _callback?.call(state);
void attach(VideoCallLifecycleCallback onStateChanged) {
_callback = onStateChanged;
_listener?.dispose();
_listener = AppLifecycleListener(
onResume: () => onStateChanged(AppLifecycleState.resumed),
onInactive: () => onStateChanged(AppLifecycleState.inactive),
onHide: () => onStateChanged(AppLifecycleState.hidden),
onPause: () => onStateChanged(AppLifecycleState.paused),
onDetach: () => onStateChanged(AppLifecycleState.detached),
);
}
void detach() {
_listener?.dispose();
_listener = null;
_callback = null;
}
}

View File

@@ -0,0 +1,43 @@
import 'dart:async';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:go_router/go_router.dart';
class VideoCallNotificationNavigator {
static Future<void> openActiveCall({String? conversationUuid}) async {
final manager = sl<VideoCallManager>();
final uuid = conversationUuid ?? manager.conversationUuid;
if (uuid.isEmpty) return;
for (var attempt = 0; attempt < 40; attempt++) {
final ctx = AppRouter.navigatorKey.currentContext;
if (ctx != null && ctx.mounted) {
final router = GoRouter.of(ctx);
final matches = router.routerDelegate.currentConfiguration.matches;
final onCallRoute = matches.any(
(m) => m.matchedLocation.contains(VideoCallScreen.path),
);
if (!onCallRoute) {
await router.pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{
'conversationUuid': uuid,
'counterpartName': manager.counterpartName,
'localParticipantName': manager.localParticipantName,
},
);
}
await manager.onCallScreenVisible(
conversationUuid: uuid,
counterpartName: manager.counterpartName,
localParticipantName: manager.localParticipantName,
);
return;
}
await Future<void>.delayed(const Duration(milliseconds: 50));
}
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
const String videoCallForegroundTaskActionKey = 'action';
const String videoCallForegroundOpenAction = 'open';
const String videoCallForegroundEndCallAction = 'end_call';
const String videoCallForegroundEndCallButtonId = 'end_call';
const String videoCallActiveConversationStorageKey = 'video_call_active_conversation_uuid';
@pragma('vm:entry-point')
void videoCallForegroundTaskStartCallback() {
FlutterForegroundTask.setTaskHandler(VideoCallTaskHandler());
}
class VideoCallTaskHandler extends TaskHandler {
@override
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {}
@override
void onRepeatEvent(DateTime timestamp) {}
@override
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
@override
void onReceiveData(Object data) {}
@override
void onNotificationButtonPressed(String id) {
if (id == videoCallForegroundEndCallButtonId) {
FlutterForegroundTask.sendDataToMain(<String, String>{
videoCallForegroundTaskActionKey: videoCallForegroundEndCallAction,
});
}
}
@override
void onNotificationPressed() {
FlutterForegroundTask.sendDataToMain(<String, String>{
videoCallForegroundTaskActionKey: videoCallForegroundOpenAction,
});
}
@override
void onNotificationDismissed() {}
}