forked from eatvio/eatvio_app
1139 lines
35 KiB
Dart
1139 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 _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<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;
|
|
}
|
|
_emitSnapshot(_snapshot.copyWith(micEnabled: next));
|
|
_lastMutedMicTotalEnergy = null;
|
|
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;
|
|
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 _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;
|
|
|
|
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;
|
|
|
|
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 {
|
|
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;
|
|
}
|
|
|
|
Future<void> _startCallAudioSession() async {
|
|
if (!_canHandleCallAudio) return;
|
|
try {
|
|
await _audioSession.start();
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _stopCallAudioSession() async {
|
|
try {
|
|
await _audioSession.stop();
|
|
} catch (_) {}
|
|
}
|
|
} |