import 'dart:async'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/device/device_hw_id_provider.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_snapshot.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_task_handler.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/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:livekit_client/livekit_client.dart' as lk; import 'package:permission_handler/permission_handler.dart'; typedef VideoCallManagerListener = void Function(VideoCallManagerSnapshot snapshot); class VideoCallManager { VideoCallManager({ required VideoCallRepository videoCallRepository, ProfileRepository? profileRepository, VideoCallForegroundCoordinator? foregroundCoordinator, VideoCallLifecycleBinding? lifecycleBinding, VideoCallAudioSessionCoordinator? audioSessionCoordinator, }) : _videoCallRepository = videoCallRepository, _profileRepository = profileRepository, _foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(), _lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(), _audioSession = audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() { FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData); } final VideoCallRepository _videoCallRepository; final ProfileRepository? _profileRepository; final VideoCallForegroundCoordinator _foreground; final VideoCallLifecycleBinding _lifecycleBinding; final VideoCallAudioSessionCoordinator _audioSession; 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; final List _listeners = []; lk.Room? _room; lk.EventsListener? _roomEventsListener; Timer? _mutedMicPollTimer; DateTime? _lastMutedMicOverlayAt; bool _recordingOverlayScheduled = false; bool _mutedMicStatsInFlight = false; num? _lastMutedMicTotalEnergy; bool _liveKitDisposed = true; bool _callEndedNotified = true; final Set _callAcceptNotifiedConversationUuids = {}; bool _callStageEntered = false; bool _joinInProgress = false; bool _counterpartJoinedNotified = false; Future? _roomConnectFuture; String? _preparedWsUrl; String? _preparedToken; bool _videoEnabledBeforeBackground = false; bool _videoPausedForBackground = false; bool _abortingLiveKitAutoReconnect = false; Future? _myProfileNameFuture; VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial; VideoCallManagerSnapshot get snapshot => _snapshot; lk.Room? get room => _room; bool get callStageEntered => _callStageEntered; String get conversationUuid => _snapshot.conversationUuid; String? get counterpartName => _snapshot.counterpartName; String? get localParticipantName => _snapshot.localParticipantName; bool get isActiveCall => _snapshot.connectPhase == VideoCallConnectPhase.connected && _callStageEntered && !_snapshot.linkIssue.needsManualReconnect && !_liveKitDisposed && _room != null; void addListener(VideoCallManagerListener listener) { if (!_listeners.contains(listener)) { _listeners.add(listener); } } void removeListener(VideoCallManagerListener listener) { _listeners.remove(listener); } @visibleForTesting void markActiveCallForTesting(lk.Room room) { _room = room; _liveKitDisposed = false; _callStageEntered = true; _ensureLifecycleAttached(); _emitSnapshot( _snapshot.copyWith( conversationUuid: 'test-uuid', counterpartName: 'Test User', connectPhase: VideoCallConnectPhase.connected, linkIssue: VideoCallLinkIssue.none, micEnabled: true, ), ); } @visibleForTesting bool get liveKitDisposedForTesting => _liveKitDisposed; @visibleForTesting bool get videoPausedForBackgroundForTesting => _videoPausedForBackground; @visibleForTesting void setCallStageEnteredForTesting(bool value) => _callStageEntered = value; void dispose() { FlutterForegroundTask.removeTaskDataCallback(_onForegroundTaskData); _lifecycleBinding.detach(); _mutedMicPollTimer?.cancel(); } void _notifyListeners() { final snap = _snapshot; for (final listener in List.from(_listeners)) { listener(snap); } } void _emitSnapshot(VideoCallManagerSnapshot snapshot) { _snapshot = snapshot; _notifyListeners(); } BuildContext? get _navigatorContext => AppRouter.navigatorKey.currentContext; bool _useFrontCamera = true; void setUseFrontCamera(bool value) => _useFrontCamera = value; lk.CameraCaptureOptions cameraCaptureOptions({ lk.CameraPosition? cameraPosition, }) { return lk.CameraCaptureOptions( cameraPosition: cameraPosition ?? (_useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back), params: lk.VideoParametersPresets.h1080_169, ); } Future startCall({ required String conversationUuid, String? counterpartName, String? localParticipantName, bool micEnabled = false, bool videoEnabled = false, }) async { if (_room != null && !_liveKitDisposed && _snapshot.conversationUuid == conversationUuid) { if (_callStageEntered && _room!.connectionState == lk.ConnectionState.connected) { _emitSnapshot( _snapshot.copyWith( counterpartName: counterpartName ?? _snapshot.counterpartName, localParticipantName: localParticipantName ?? _snapshot.localParticipantName, ), ); _ensureLifecycleAttached(); unawaited(_startCallAudioSession()); return; } if (!_callStageEntered && _snapshot.connectPhase == VideoCallConnectPhase.connected && _preparedWsUrl != null && _preparedToken != null) { _emitSnapshot( _snapshot.copyWith( counterpartName: counterpartName ?? _snapshot.counterpartName, localParticipantName: localParticipantName ?? _snapshot.localParticipantName, ), ); _ensureLifecycleAttached(); return; } } await endCall(); _resetSessionFlagsForNewRoom(); _callEndedNotified = false; _liveKitDisposed = false; _callStageEntered = false; _room = lk.Room( roomOptions: const lk.RoomOptions( adaptiveStream: true, defaultCameraCaptureOptions: lk.CameraCaptureOptions( params: lk.VideoParametersPresets.h1080_169, ), defaultAudioCaptureOptions: _micCaptureOptions, ), ); _attachRoomEventListeners(); _startMutedMicPollTimer(); _ensureLifecycleAttached(); final resolvedLocalName = await _resolveLocalParticipantName( localParticipantName, ); _emitSnapshot( VideoCallManagerSnapshot( conversationUuid: conversationUuid, counterpartName: counterpartName, localParticipantName: resolvedLocalName, micEnabled: micEnabled, videoEnabled: videoEnabled, connectError: null, connectPhase: VideoCallConnectPhase.connecting, linkIssue: VideoCallLinkIssue.none, ), ); await _prepareLiveKitConnection(); } Future onCallScreenVisible({ required String conversationUuid, String? counterpartName, String? localParticipantName, bool micEnabled = false, bool videoEnabled = false, }) async { if (_snapshot.conversationUuid != conversationUuid || _room == null || _liveKitDisposed) { await startCall( conversationUuid: conversationUuid, counterpartName: counterpartName, localParticipantName: localParticipantName, micEnabled: micEnabled, videoEnabled: videoEnabled, ); } else { _emitSnapshot( _snapshot.copyWith( counterpartName: counterpartName ?? _snapshot.counterpartName, localParticipantName: localParticipantName ?? _snapshot.localParticipantName, ), ); if (_room?.connectionState == lk.ConnectionState.connected) { unawaited(_ensureLocalParticipantNamePublished()); _syncRemoteParticipantNameFromRoom(); } } } Future onEnterCallStage() async { if (_callStageEntered) return true; if (_joinInProgress) return false; if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return false; _joinInProgress = true; try { await _ensureRoomConnected(); if (_liveKitDisposed) return false; _callStageEntered = true; await _publishLocalMedia(); if (_liveKitDisposed || !_callStageEntered) return false; await _startForegroundIfPossible(); await _startCallAudioSession(); return true; } catch (e) { _emitSnapshot( _snapshot.copyWith( connectError: e.toString(), linkIssue: VideoCallLinkIssue.lost, ), ); return false; } finally { _joinInProgress = false; } } /// Joins the LiveKit room without publishing local media so the outgoing /// caller can detect when the callee enters. Future ensureLobbyRoomJoined() async { if (_callStageEntered || _joinInProgress || _liveKitDisposed) return; if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; final room = _room; if (room != null && room.connectionState == lk.ConnectionState.connected) { _notifyCounterpartIfPresent(); return; } try { await _ensureRoomConnected(); if (!_liveKitDisposed) { _notifyCounterpartIfPresent(); } } catch (_) {} } Future _ensureRoomConnected() async { final room = _room; if (room != null && room.connectionState == lk.ConnectionState.connected) { return; } final pending = _roomConnectFuture; if (pending != null) { await pending; return; } final run = _joinLiveKitRoom(); _roomConnectFuture = run; try { await run; } finally { if (identical(_roomConnectFuture, run)) { _roomConnectFuture = null; } } } void _notifyCounterpartIfPresent() { if (_callStageEntered || _counterpartJoinedNotified || _liveKitDisposed) { return; } final room = _room; if (room == null || room.connectionState != lk.ConnectionState.connected) { return; } if (room.remoteParticipants.isEmpty) return; _counterpartJoinedNotified = true; _emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: true)); } Future retryAfterConnectionLoss() async { if (_snapshot.conversationUuid.isEmpty) return; _disposeRoomEphemerals(); await _disconnectLiveKitOnly(); _resetSessionFlagsForNewRoom(); _liveKitDisposed = false; _callStageEntered = false; _room = lk.Room( roomOptions: const lk.RoomOptions( adaptiveStream: true, defaultCameraCaptureOptions: lk.CameraCaptureOptions( params: lk.VideoParametersPresets.h1080_169, ), defaultAudioCaptureOptions: _micCaptureOptions, ), ); _attachRoomEventListeners(); _startMutedMicPollTimer(); _emitSnapshot( _snapshot.copyWith( connectPhase: VideoCallConnectPhase.connecting, linkIssue: VideoCallLinkIssue.none, connectError: null, ), ); await _prepareLiveKitConnection(); } Future endCall() async { final uuid = _snapshot.conversationUuid; _disposeRoomEphemerals(); await _disconnectLiveKitOnly(); if (!_callEndedNotified && uuid.isNotEmpty) { _callEndedNotified = true; try { await _videoCallRepository.notifyCallEnded(uuid); } catch (_) {} } await _foreground.stopOngoingCall(); await _stopCallAudioSession(); _lifecycleBinding.detach(); _callStageEntered = false; _videoPausedForBackground = false; _liveKitDisposed = true; _myProfileNameFuture = null; _emitSnapshot(VideoCallManagerSnapshot.initial); } /// Notifies backend that this device accepted an incoming call so ringing /// stops on the user's other devices. Future notifyIncomingCallAccepted(String conversationUuid) async { final uuid = conversationUuid.trim(); if (uuid.isEmpty) return; if (_callAcceptNotifiedConversationUuids.contains(uuid)) return; final hwId = (await DeviceHwIdProvider.current())?.trim(); if (hwId == null || hwId.length < 8) return; _callAcceptNotifiedConversationUuids.add(uuid); try { await _videoCallRepository.notifyCallAccepted( conversationUuid: uuid, hwId: hwId, ); } catch (_) { _callAcceptNotifiedConversationUuids.remove(uuid); } } /// Decline from incoming UI before joining the LiveKit room. Future declineIncomingCall(String conversationUuid) async { final uuid = conversationUuid.trim(); if (uuid.isEmpty) { await endCall(); return; } if (_snapshot.conversationUuid == uuid) { await endCall(); return; } await endCall(); try { await _videoCallRepository.notifyCallEnded(uuid); } catch (_) {} } /// [currentUiEnabled] reflects what the user sees on the mic control /// ([VideoCallSessionState.isMicActiveForUi]), not only stored intent. Future toggleMic(bool currentUiEnabled) async { final next = !currentUiEnabled; if (!_callStageEntered) { _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; return; } if (_liveKitDisposed || _room == null || _room!.connectionState != lk.ConnectionState.connected) { _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; return; } if (next) { final granted = await _ensureMicrophonePermission(); if (!granted) return; } _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; await _room!.localParticipant?.setMicrophoneEnabled( next, audioCaptureOptions: _micCaptureOptions, ); } Future toggleVideo(bool currentEnabled) async { final next = !currentEnabled; if (!_callStageEntered) { _emitSnapshot(_snapshot.copyWith(videoEnabled: next)); return; } if (_liveKitDisposed || _room == null || _room!.connectionState != lk.ConnectionState.connected) { _emitSnapshot(_snapshot.copyWith(videoEnabled: next)); return; } if (next) { final granted = await _ensureCameraPermission(); if (!granted) return; } _emitSnapshot(_snapshot.copyWith(videoEnabled: next)); final lp = _room!.localParticipant; if (lp == null) return; if (next && !_videoPausedForBackground) { await lp.setCameraEnabled( true, cameraCaptureOptions: cameraCaptureOptions(), ); } else if (!next) { await lp.setCameraEnabled(false); } } Future switchCameraFacing(bool currentUseFront) async { if (_liveKitDisposed || _room == null || _room!.connectionState != lk.ConnectionState.connected) { _useFrontCamera = !currentUseFront; return; } final currentPosition = currentUseFront ? lk.CameraPosition.front : lk.CameraPosition.back; final newPosition = currentPosition.switched(); _useFrontCamera = newPosition == lk.CameraPosition.front; final lp = _room!.localParticipant; if (lp == null) return; final pub = lp.getTrackPublicationBySource(lk.TrackSource.camera); final track = pub?.track; if (track is lk.LocalVideoTrack) { try { await track.setCameraPosition(newPosition); } catch (_) {} return; } try { await lp.setCameraEnabled( true, cameraCaptureOptions: cameraCaptureOptions( cameraPosition: newPosition, ), ); } catch (_) {} } void scheduleRecordingNoticeOnce() { if (_recordingOverlayScheduled) return; final ctx = _navigatorContext; if (ctx == null) return; final shown = VideoCallTopOverlay.insertBanner( ctx, message: S.of(ctx).video_call_recording_notice, backgroundColor: appTheme.videoCallNoticeBannerBackground, svgAsset: VideoCallTopOverlay.warningSvg, autoRemoveAfter: const Duration(seconds: 3), ).mounted; if (shown) { _recordingOverlayScheduled = true; } } String resolvedLocalDisplayName() { final fromSnapshot = _snapshot.localParticipantName?.trim(); if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; final fromRoom = (_room?.localParticipant?.name ?? '').trim(); if (fromRoom.isNotEmpty) return fromRoom; return ''; } String resolvedRemoteDisplayName([String fallback = '']) { final fromSnapshot = _snapshot.counterpartName?.trim(); if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; final room = _room; if (room != null) { for (final participant in room.remoteParticipants.values) { final name = participant.name.trim(); if (name.isNotEmpty) return name; } } return fallback; } Future _resolveLocalParticipantName(String? hint) async { final fromHint = hint?.trim(); if (fromHint != null && fromHint.isNotEmpty) return fromHint; final fromSnapshot = _snapshot.localParticipantName?.trim(); if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; return _fetchMyProfileName(); } Future _fetchMyProfileName() async { _myProfileNameFuture ??= _loadMyProfileName(); return _myProfileNameFuture; } Future _loadMyProfileName() async { final repository = _profileRepository; if (repository == null) return null; try { final name = (await repository.getMyProfile()).name.trim(); return name.isEmpty ? null : name; } catch (_) { return null; } } Future _ensureLocalParticipantNamePublished() async { var name = _snapshot.localParticipantName?.trim(); if (name == null || name.isEmpty) { name = await _resolveLocalParticipantName(null); if (name != null && name.isNotEmpty) { _emitSnapshot(_snapshot.copyWith(localParticipantName: name)); } } _applyLocalParticipantNameToLiveKit(); } void _applyLocalParticipantNameToLiveKit() { final name = _snapshot.localParticipantName?.trim(); if (name == null || name.isEmpty) return; try { _room?.localParticipant?.setName(name); } catch (_) {} } void _syncRemoteParticipantNameFromRoom() { if (_snapshot.counterpartName?.trim().isNotEmpty == true) return; final room = _room; if (room == null) return; for (final participant in room.remoteParticipants.values) { final name = participant.name.trim(); if (name.isEmpty) continue; _emitSnapshot(_snapshot.copyWith(counterpartName: name)); return; } } void _ensureLifecycleAttached() { _lifecycleBinding.attach(_onAppLifecycleStateChanged); } void _onAppLifecycleStateChanged(AppLifecycleState state) { switch (state) { case AppLifecycleState.paused: case AppLifecycleState.hidden: unawaited(_onAppBackgrounded()); case AppLifecycleState.resumed: unawaited(_onAppResumed()); case AppLifecycleState.detached: if (isActiveCall) { unawaited(_onAppBackgrounded()); } case AppLifecycleState.inactive: break; } } Future _onAppBackgrounded() async { if (!isActiveCall) return; await _startForegroundIfPossible(); await _pauseVideoForBackground(); } Future _onAppResumed() async { if (!isActiveCall) return; await _resumeVideoAfterForeground(); } Future _startForegroundIfPossible() async { if (!isActiveCall) return; final ctx = _navigatorContext; final s = ctx != null ? S.of(ctx) : null; final title = resolvedRemoteDisplayName( s?.video_call_counterpart_default ?? 'Call', ); final body = s?.video_call_notification_in_progress ?? 'Call in progress'; final endLabel = s?.video_call_notification_end ?? 'End call'; await _foreground.startOngoingCall( conversationUuid: _snapshot.conversationUuid, notificationTitle: title, notificationBody: body, endCallButtonLabel: endLabel, ); } Future _pauseVideoForBackground() async { if (_videoPausedForBackground) return; _videoEnabledBeforeBackground = _snapshot.videoEnabled; if (!_videoEnabledBeforeBackground) return; final room = _room; if (room == null || _liveKitDisposed) return; try { await room.localParticipant?.setCameraEnabled(false); _videoPausedForBackground = true; } catch (_) {} } Future _resumeVideoAfterForeground() async { if (!_videoPausedForBackground) return; _videoPausedForBackground = false; if (!_videoEnabledBeforeBackground) return; final room = _room; if (room == null || _liveKitDisposed) return; if (!_snapshot.videoEnabled) return; try { await room.localParticipant?.setCameraEnabled( true, cameraCaptureOptions: cameraCaptureOptions(), ); } catch (_) {} } void _onForegroundTaskData(Object data) { if (data is! Map) return; final action = data[videoCallForegroundTaskActionKey]; if (action == videoCallForegroundEndCallAction) { unawaited(endCall()); return; } if (action == videoCallForegroundOpenAction) { unawaited( _foreground.readActiveConversationUuid().then((uuid) { return VideoCallNotificationNavigator.openActiveCall( conversationUuid: uuid, ); }), ); } } void _resetSessionFlagsForNewRoom() { _lastMutedMicOverlayAt = null; _lastMutedMicTotalEnergy = null; _recordingOverlayScheduled = false; _joinInProgress = false; _counterpartJoinedNotified = false; _roomConnectFuture = null; _clearPreparedConnectionCredentials(); _callAcceptNotifiedConversationUuids.clear(); _emitSnapshot( _snapshot.copyWith( counterpartPresentInLobby: false, ), ); } void _clearPreparedConnectionCredentials() { _preparedWsUrl = null; _preparedToken = null; } void _disposeRoomEphemerals() { _mutedMicPollTimer?.cancel(); _mutedMicPollTimer = null; final listener = _roomEventsListener; _roomEventsListener = null; if (listener != null) { unawaited(listener.dispose()); } VideoCallTopOverlay.hidePoorConnectionIcon(); } void _startMutedMicPollTimer() { _mutedMicPollTimer?.cancel(); _mutedMicPollTimer = Timer.periodic(_mutedMicPollInterval, (_) { if (_liveKitDisposed || _mutedMicStatsInFlight) { return; } if (_snapshot.micEnabled) { return; } if (_room == null || _room!.connectionState != lk.ConnectionState.connected) { return; } _mutedMicStatsInFlight = true; unawaited( _pollMutedMicFromWebRtcStats().whenComplete(() { _mutedMicStatsInFlight = false; }), ); }); } /// Stops LiveKit's built-in auto-reconnect and surfaces [VideoCallLinkIssue.lost] /// so the user can retry manually from [VideoCallConnectionLostScreen]. Future _abortLiveKitAutoReconnect() async { if (_liveKitDisposed || _abortingLiveKitAutoReconnect) return; if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; if (_snapshot.linkIssue == VideoCallLinkIssue.lost) return; _abortingLiveKitAutoReconnect = true; try { _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost)); VideoCallTopOverlay.hidePoorConnectionIcon(); final room = _room; if (room != null && room.connectionState != lk.ConnectionState.disconnected) { try { await room.disconnect(); } catch (_) {} } } finally { _abortingLiveKitAutoReconnect = false; } } void _attachRoomEventListeners() { final room = _room; if (room == null) return; _roomEventsListener = room.createListener() ..on((_) { unawaited(_abortLiveKitAutoReconnect()); }) ..on((_) { if (_snapshot.linkIssue.needsManualReconnect) return; _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.none)); }) ..on((_) { if (_liveKitDisposed) return; if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost)); }) ..on((e) { if (e.participant != room.localParticipant) return; _syncPoorConnectionOverlay(e.connectionQuality); }) ..on((e) { if (_snapshot.micEnabled) return; final lp = room.localParticipant; if (lp == null) return; if (e.speakers.any((p) => p.sid == lp.sid)) { _maybeShowMutedMicOverlay(); } }) ..on((_) { _notifyCounterpartIfPresent(); _syncRemoteParticipantNameFromRoom(); }); } void _syncPoorConnectionOverlay(lk.ConnectionQuality q) { if (_liveKitDisposed || !_callStageEntered) { VideoCallTopOverlay.hidePoorConnectionIcon(); return; } final ctx = _navigatorContext; if (ctx == null) return; final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; if (bad) { VideoCallTopOverlay.showPoorConnectionIcon(ctx); } else { VideoCallTopOverlay.hidePoorConnectionIcon(); } } void _maybeShowMutedMicOverlay() { if (_snapshot.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 _pollMutedMicFromWebRtcStats() async { if (_liveKitDisposed || _snapshot.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 (_liveKitDisposed || _snapshot.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 (_) {} } Future _prepareLiveKitConnection() async { final room = _room; if (room == null) return; _emitSnapshot(_snapshot.copyWith(connectError: null)); try { final tokenResponse = await _videoCallRepository.getParticipantToken( _snapshot.conversationUuid, ); if (_liveKitDisposed) return; _preparedWsUrl = tokenResponse.wsUrl; _preparedToken = tokenResponse.token; await room.prepareConnection( tokenResponse.wsUrl, tokenResponse.token, ); if (_liveKitDisposed) return; _emitSnapshot( _snapshot.copyWith( connectPhase: VideoCallConnectPhase.connected, linkIssue: VideoCallLinkIssue.none, clearConnectError: true, ), ); } catch (e) { _clearPreparedConnectionCredentials(); _emitSnapshot( _snapshot.copyWith( connectError: e.toString(), connectPhase: VideoCallConnectPhase.idle, linkIssue: VideoCallLinkIssue.lost, ), ); } } Future _joinLiveKitRoom() async { final room = _room; if (room == null) { throw StateError('VideoCallManager: room is null'); } if (room.connectionState == lk.ConnectionState.connected) return; var wsUrl = _preparedWsUrl; var token = _preparedToken; if (wsUrl == null || token == null) { final tokenResponse = await _videoCallRepository.getParticipantToken( _snapshot.conversationUuid, ); if (_liveKitDisposed) return; wsUrl = tokenResponse.wsUrl; token = tokenResponse.token; _preparedWsUrl = wsUrl; _preparedToken = token; } await room.connect(wsUrl, token); if (_liveKitDisposed) return; await _ensureLocalParticipantNamePublished(); _syncRemoteParticipantNameFromRoom(); _syncPoorConnectionOverlay( room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown, ); } Future _disconnectLiveKitOnly() async { if (_room == null) return; await _stopCallAudioSession(); _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 _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 || !ctx2.mounted) return false; final s = S.of(ctx2); if (!ctx2.mounted) return false; 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 _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 || !ctx2.mounted) return false; final s = S.of(ctx2); if (!ctx2.mounted) return false; 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; } bool get _canHandleCallAudio => _callStageEntered && !_liveKitDisposed && _room != null && _snapshot.connectPhase == VideoCallConnectPhase.connected; Future _publishLocalMedia() async { if (_liveKitDisposed || !_callStageEntered) return; final room = _room; if (room == null || room.connectionState != lk.ConnectionState.connected) { return; } var micOn = _snapshot.micEnabled; var videoOn = _snapshot.videoEnabled; if (micOn) { final granted = await _ensureMicrophonePermission(); if (_liveKitDisposed || !_callStageEntered) return; if (!granted) micOn = false; } if (videoOn) { final granted = await _ensureCameraPermission(); if (_liveKitDisposed || !_callStageEntered) return; if (!granted) videoOn = false; } if (micOn != _snapshot.micEnabled || videoOn != _snapshot.videoEnabled) { _emitSnapshot( _snapshot.copyWith(micEnabled: micOn, videoEnabled: videoOn), ); } final lp = room.localParticipant; var effectiveMic = micOn; var effectiveVideo = videoOn; if (micOn) { try { await lp?.setMicrophoneEnabled( true, audioCaptureOptions: _micCaptureOptions, ); } catch (_) { effectiveMic = false; } } else { try { await lp?.setMicrophoneEnabled( false, audioCaptureOptions: _micCaptureOptions, ); } catch (_) {} } if (_liveKitDisposed || !_callStageEntered) return; if (videoOn && !_videoPausedForBackground) { try { await lp?.setCameraEnabled( true, cameraCaptureOptions: cameraCaptureOptions(), ); } catch (_) { effectiveVideo = false; } } else { try { await lp?.setCameraEnabled(false); } catch (_) {} } if (effectiveMic != _snapshot.micEnabled || effectiveVideo != _snapshot.videoEnabled) { _emitSnapshot( _snapshot.copyWith( micEnabled: effectiveMic, videoEnabled: effectiveVideo, ), ); } _lastMutedMicTotalEnergy = null; } Future _startCallAudioSession() async { if (!_canHandleCallAudio) return; try { await _audioSession.start(); } catch (_) {} } Future _stopCallAudioSession() async { try { await _audioSession.stop(); } catch (_) {} } }