Files
eatvio_app/lib/features/video_calls/managers/video_call_manager.dart

1219 lines
37 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:audio_session/audio_session.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/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,
VideoCallForegroundCoordinator? foregroundCoordinator,
VideoCallLifecycleBinding? lifecycleBinding,
VideoCallAudioSessionCoordinator? audioSessionCoordinator,
}) : _videoCallRepository = videoCallRepository,
_foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(),
_lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(),
_audioSession =
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() {
FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData);
}
final VideoCallRepository _videoCallRepository;
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 Duration _localMicActivationInterruptionGrace = Duration(
milliseconds: 1500,
);
static const double _mutedMicAudioLevelThreshold = 0.008;
static const double _mutedMicEnergyDelta = 1e-6;
final List<VideoCallManagerListener> _listeners = [];
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;
bool _callStageEntered = false;
bool _joinInProgress = false;
String? _preparedWsUrl;
String? _preparedToken;
bool _videoEnabledBeforeBackground = false;
bool _videoPausedForBackground = false;
bool? _micEnabledBeforeSystemInterruption;
int _audioInterruptionHoldCount = 0;
DateTime? _ignoreAudioInterruptionUntil;
OverlayEntry? _systemInterruptionEntry;
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 != VideoCallLinkIssue.lost &&
!_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;
@visibleForTesting
void handleAudioInterruptionForTesting(AudioInterruptionEvent event) {
unawaited(_onAudioInterruption(event));
}
void dispose() {
FlutterForegroundTask.removeTaskDataCallback(_onForegroundTaskData);
_lifecycleBinding.detach();
_mutedMicPollTimer?.cancel();
}
void _notifyListeners() {
final snap = _snapshot;
for (final listener in List<VideoCallManagerListener>.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<void> 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();
_emitSnapshot(
VideoCallManagerSnapshot(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: micEnabled,
videoEnabled: videoEnabled,
connectError: null,
connectPhase: VideoCallConnectPhase.connecting,
linkIssue: VideoCallLinkIssue.none,
),
);
await _prepareLiveKitConnection();
}
Future<void> 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,
),
);
}
}
Future<bool> onEnterCallStage() async {
if (_callStageEntered) return true;
if (_joinInProgress) return false;
if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return false;
_joinInProgress = true;
try {
await _joinLiveKitRoom();
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;
}
}
Future<void> 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<void> 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;
_micEnabledBeforeSystemInterruption = null;
_audioInterruptionHoldCount = 0;
_liveKitDisposed = true;
_emitSnapshot(VideoCallManagerSnapshot.initial);
}
/// Decline from incoming UI before joining the LiveKit room.
Future<void> 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<void> toggleMic(bool currentUiEnabled) async {
if (!currentUiEnabled &&
_snapshot.systemAudioInterrupted &&
_snapshot.micEnabled &&
_isLocalMicrophonePublishing()) {
_clearSystemInterruptionState();
_lastMutedMicTotalEnergy = null;
return;
}
final next = !currentUiEnabled;
if (_snapshot.systemAudioInterrupted || _audioInterruptionHoldCount > 0) {
if (next) {
_micEnabledBeforeSystemInterruption = true;
} else {
_micEnabledBeforeSystemInterruption = false;
_emitSnapshot(_snapshot.copyWith(micEnabled: false));
await _pauseLocalAudioForSystemInterruption();
}
_lastMutedMicTotalEnergy = null;
return;
}
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;
if (next) {
_suppressOwnMicActivationInterruptions();
}
await _room!.localParticipant?.setMicrophoneEnabled(
next,
audioCaptureOptions: _micCaptureOptions,
);
}
Future<void> 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<void> 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;
_recordingOverlayScheduled = true;
VideoCallTopOverlay.insertBanner(
ctx,
message: S.of(ctx).video_call_recording_notice,
backgroundColor: appTheme.videoCallNoticeBannerBackground,
svgAsset: VideoCallTopOverlay.warningSvg,
autoRemoveAfter: const Duration(seconds: 3),
);
}
String resolvedLocalDisplayName() {
final fromNav = _snapshot.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 c = _snapshot.counterpartName?.trim();
if (c != null && c.isNotEmpty) return c;
final room = _room;
if (room != null) {
for (final p in room.remoteParticipants.values) {
final n = p.name.trim();
if (n.isNotEmpty) return n;
}
}
return fallback;
}
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<void> _onAppBackgrounded() async {
if (!isActiveCall) return;
await _startForegroundIfPossible();
await _pauseVideoForBackground();
}
Future<void> _onAppResumed() async {
if (!isActiveCall) return;
await _resumeVideoAfterForeground();
}
Future<void> _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<void> _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<void> _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;
_micEnabledBeforeSystemInterruption = null;
_audioInterruptionHoldCount = 0;
_ignoreAudioInterruptionUntil = null;
_joinInProgress = false;
_clearPreparedConnectionCredentials();
_syncSystemInterruptionOverlay(show: false);
_emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false));
}
void _clearPreparedConnectionCredentials() {
_preparedWsUrl = null;
_preparedToken = null;
}
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();
}
}
void _startMutedMicPollTimer() {
_mutedMicPollTimer?.cancel();
_mutedMicPollTimer = Timer.periodic(_mutedMicPollInterval, (_) {
if (_liveKitDisposed || _mutedMicStatsInFlight) {
return;
}
if (_snapshot.systemAudioInterrupted && _snapshot.micEnabled) {
unawaited(_maybeClearStaleSystemInterruptionFromStats());
return;
}
if (_snapshot.micEnabled) {
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 (_snapshot.connectPhase != VideoCallConnectPhase.connected) return;
_emitSnapshot(
_snapshot.copyWith(linkIssue: VideoCallLinkIssue.reconnecting),
);
})
..on<lk.RoomReconnectedEvent>((_) {
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.none));
})
..on<lk.RoomDisconnectedEvent>((_) {
if (_liveKitDisposed) return;
if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return;
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost));
})
..on<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
if (e.participant != room.localParticipant) return;
_syncPoorConnectionOverlay(e.connectionQuality);
})
..on<lk.ActiveSpeakersChangedEvent>((e) {
if (_snapshot.micEnabled) return;
final lp = room.localParticipant;
if (lp == null) return;
if (e.speakers.any((p) => p.sid == lp.sid)) {
_maybeShowMutedMicOverlay();
}
});
}
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 (_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<void> _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<void> _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<void> _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;
_syncPoorConnectionOverlay(
room.localParticipant?.connectionQuality ??
lk.ConnectionQuality.unknown,
);
}
Future<void> _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<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 || !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<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 || !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<void> _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;
}
bool _isLocalMicrophonePublishing() {
final room = _room;
if (room == null || _liveKitDisposed) return false;
final lp = room.localParticipant;
if (lp == null) return false;
final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone);
if (pub == null || pub.muted) return false;
final track = pub.track;
if (track is lk.LocalAudioTrack) {
return !track.muted;
}
return track != null;
}
Future<void> _maybeClearStaleSystemInterruptionFromStats() async {
if (!_snapshot.systemAudioInterrupted) return;
if (!_snapshot.micEnabled || !_callStageEntered) return;
if (!_isLocalMicrophonePublishing()) return;
final room = _room;
if (room == null || _liveKitDisposed) 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;
try {
final stats = await track.getSenderStats();
if (_liveKitDisposed || !_snapshot.systemAudioInterrupted) return;
final src = stats?.audioSourceStats;
if (src == null) return;
final level = src.audioLevel;
if (level != null && level >= _mutedMicAudioLevelThreshold) {
_clearSystemInterruptionState();
return;
}
final energy = src.totalAudioEnergy;
if (energy == null || _lastMutedMicTotalEnergy == null) {
_lastMutedMicTotalEnergy = energy;
return;
}
if (energy - _lastMutedMicTotalEnergy! >= _mutedMicEnergyDelta) {
_clearSystemInterruptionState();
}
_lastMutedMicTotalEnergy = energy;
} catch (_) {}
}
void _clearSystemInterruptionState() {
if (!_snapshot.systemAudioInterrupted && _audioInterruptionHoldCount == 0) {
return;
}
_audioInterruptionHoldCount = 0;
_micEnabledBeforeSystemInterruption = null;
_emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false));
_syncSystemInterruptionOverlay(show: false);
}
Future<void> _startCallAudioSession() async {
if (!_canHandleCallAudio) return;
try {
await _audioSession.start(onInterruption: _onAudioInterruption);
} catch (_) {}
}
Future<void> _stopCallAudioSession() async {
_syncSystemInterruptionOverlay(show: false);
try {
await _audioSession.stop();
} catch (_) {}
}
Future<void> _onAudioInterruption(AudioInterruptionEvent event) async {
if (!_callStageEntered ||
_liveKitDisposed ||
_room == null ||
_snapshot.connectPhase != VideoCallConnectPhase.connected) {
return;
}
if (_shouldIgnoreAudioInterruption(event)) {
return;
}
if (event.begin) {
_audioInterruptionHoldCount++;
if (_audioInterruptionHoldCount == 1) {
await _handleAudioInterruptionBegin();
} else if (_snapshot.micEnabled) {
await _pauseLocalAudioForSystemInterruption();
}
return;
}
if (_audioInterruptionHoldCount <= 0) {
if (_snapshot.micEnabled) {
await _restoreLocalAudioAfterSystemInterruption();
}
return;
}
_audioInterruptionHoldCount--;
if (_audioInterruptionHoldCount == 0) {
await _handleAudioInterruptionEnd();
}
}
Future<void> _handleAudioInterruptionBegin() async {
if (_snapshot.systemAudioInterrupted) return;
_micEnabledBeforeSystemInterruption ??= _snapshot.micEnabled;
_emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: true));
_syncSystemInterruptionOverlay(show: true);
await _pauseLocalAudioForSystemInterruption();
}
Future<void> _pauseLocalAudioForSystemInterruption() async {
final room = _room;
if (room == null || _liveKitDisposed) return;
try {
await room.localParticipant?.setMicrophoneEnabled(
false,
audioCaptureOptions: _micCaptureOptions,
);
} catch (_) {}
}
Future<void> _handleAudioInterruptionEnd() async {
if (!_snapshot.systemAudioInterrupted) return;
await _audioSession.reactivate();
_emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false));
_syncSystemInterruptionOverlay(show: false);
final restoreMic =
_micEnabledBeforeSystemInterruption ?? _snapshot.micEnabled;
_micEnabledBeforeSystemInterruption = null;
if (restoreMic) {
await _restoreLocalAudioAfterSystemInterruption();
}
}
Future<void> _restoreLocalAudioAfterSystemInterruption() async {
if (!_canHandleCallAudio || !_snapshot.micEnabled) return;
final lp = _room!.localParticipant;
if (lp == null) return;
try {
_suppressOwnMicActivationInterruptions();
await lp.setMicrophoneEnabled(
true,
audioCaptureOptions: _micCaptureOptions,
);
} catch (_) {
await _restartLocalAudioTrack();
return;
}
final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone);
final track = pub?.track;
if (track is lk.LocalAudioTrack && track.muted) {
await _restartLocalAudioTrack();
}
}
void _suppressOwnMicActivationInterruptions() {
if (!Platform.isAndroid) return;
_ignoreAudioInterruptionUntil = DateTime.now().add(
_localMicActivationInterruptionGrace,
);
}
bool _shouldIgnoreAudioInterruption(AudioInterruptionEvent event) {
if (!Platform.isAndroid || !event.begin) return false;
final until = _ignoreAudioInterruptionUntil;
if (until == null) return false;
if (DateTime.now().isBefore(until)) {
return true;
}
_ignoreAudioInterruptionUntil = null;
return false;
}
Future<void> _restartLocalAudioTrack() async {
if (!_canHandleCallAudio || !_snapshot.micEnabled) return;
final lp = _room!.localParticipant;
if (lp == null) return;
final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone);
final track = pub?.track;
if (track is lk.LocalAudioTrack) {
try {
_suppressOwnMicActivationInterruptions();
await track.restartTrack();
return;
} catch (_) {}
}
try {
await lp.setMicrophoneEnabled(
false,
audioCaptureOptions: _micCaptureOptions,
);
_suppressOwnMicActivationInterruptions();
await lp.setMicrophoneEnabled(
true,
audioCaptureOptions: _micCaptureOptions,
);
} catch (_) {}
}
void _syncSystemInterruptionOverlay({required bool show}) {
final ctx = _navigatorContext;
if (ctx == null) return;
if (show) {
if (_systemInterruptionEntry != null) return;
_systemInterruptionEntry = VideoCallTopOverlay.insertBanner(
ctx,
message: S.of(ctx).video_call_system_interruption,
backgroundColor: appTheme.videoCallNoticeBannerBackground,
svgAsset: VideoCallTopOverlay.warningSvg,
);
return;
}
final entry = _systemInterruptionEntry;
_systemInterruptionEntry = null;
if (entry != null && entry.mounted) {
entry.remove();
}
}
}