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

1145 lines
35 KiB
Dart

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<VideoCallManagerListener> _listeners = [];
lk.Room? _room;
lk.EventsListener<lk.RoomEvent>? _roomEventsListener;
Timer? _mutedMicPollTimer;
DateTime? _lastMutedMicOverlayAt;
bool _recordingOverlayScheduled = false;
bool _mutedMicStatsInFlight = false;
num? _lastMutedMicTotalEnergy;
bool _liveKitDisposed = true;
bool _callEndedNotified = true;
final Set<String> _callAcceptNotifiedConversationUuids = {};
bool _callStageEntered = false;
bool _joinInProgress = false;
bool _counterpartJoinedNotified = false;
Future<void>? _roomConnectFuture;
String? _preparedWsUrl;
String? _preparedToken;
bool _videoEnabledBeforeBackground = false;
bool _videoPausedForBackground = false;
bool _abortingLiveKitAutoReconnect = false;
Future<String?>? _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<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();
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<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,
),
);
if (_room?.connectionState == lk.ConnectionState.connected) {
unawaited(_ensureLocalParticipantNamePublished());
_syncRemoteParticipantNameFromRoom();
}
}
}
Future<bool> 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 _startCallAudioSession();
if (_liveKitDisposed || !_callStageEntered) return false;
await _publishLocalMedia();
if (_liveKitDisposed || !_callStageEntered) return false;
await _startForegroundIfPossible();
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<void> 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<void> _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<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;
_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<void> 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<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 {
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;
await _startCallAudioSession();
}
final participant = _room!.localParticipant;
if (participant == null) return;
try {
await participant.setMicrophoneEnabled(
next,
audioCaptureOptions: _micCaptureOptions,
);
} catch (_) {
return;
}
_emitSnapshot(_snapshot.copyWith(micEnabled: next));
_lastMutedMicTotalEnergy = null;
}
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;
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<String?> _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<String?> _fetchMyProfileName() async {
_myProfileNameFuture ??= _loadMyProfileName();
return _myProfileNameFuture;
}
Future<String?> _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<void> _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<void> _onAppBackgrounded() async {
if (!isActiveCall) return;
await _startForegroundIfPossible();
await _pauseVideoForBackground();
}
Future<void> _onAppResumed() async {
if (!isActiveCall) return;
await _startCallAudioSession();
if (_snapshot.micEnabled) {
try {
await _room?.localParticipant?.setMicrophoneEnabled(
true,
audioCaptureOptions: _micCaptureOptions,
);
} catch (_) {}
}
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;
_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<void> _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<lk.RoomReconnectingEvent>((_) {
unawaited(_abortLiveKitAutoReconnect());
})
..on<lk.RoomReconnectedEvent>((_) {
if (_snapshot.linkIssue.needsManualReconnect) return;
_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();
}
})
..on<lk.ParticipantConnectedEvent>((_) {
_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<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;
// Warmup must not keep the enter button disabled. Joining the room
// connects with the token even if prepareConnection hangs or fails.
try {
await room
.prepareConnection(tokenResponse.wsUrl, tokenResponse.token)
.timeout(const Duration(seconds: 5));
} catch (_) {}
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;
await _ensureLocalParticipantNamePublished();
_syncRemoteParticipantNameFromRoom();
_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 {
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 {
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;
}
Future<void> _startCallAudioSession() async {
if (!_canHandleCallAudio) return;
try {
await _audioSession.start();
} catch (_) {}
}
Future<void> _stopCallAudioSession() async {
try {
await _audioSession.stop();
} catch (_) {}
}
}