// Version: 1.1.0 | Created: 2026-04-01 // Call controller — manages LiveKit room connection lifecycle. // Transitions through idle → connecting → active → ended states. import 'dart:async'; import 'package:livekit_client/livekit_client.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../data/livekit_service.dart'; import '../domain/call_state.dart'; part 'call_controller.g.dart'; @Riverpod(keepAlive: false) class CallController extends _$CallController { Timer? _durationTimer; Duration _elapsed = Duration.zero; @override CallState build() => const CallState.idle(); /// Join a LiveKit room via MatrixRTC JWT endpoint. /// /// On success, starts a timer to track call duration and transitions to /// [CallActive]. On failure, transitions to [CallEnded] with a reason. Future joinCall(String roomId, {bool withVideo = true}) async { state = CallState.connecting(roomId: roomId); final service = ref.read(liveKitServiceProvider); final result = await service.connect(roomId); switch (result) { case LiveKitConnected(:final room): // Enable camera and microphone on the local participant. final local = room.localParticipant; if (local != null) { if (withVideo) { await local.setCameraEnabled(true); } await local.setMicrophoneEnabled(true); } _startTimer(roomId, room, isVideo: withVideo); case LiveKitFailed(:final failure): state = CallEnded( reason: switch (failure) { LiveKitNotAuthenticated() => 'Not authenticated.', LiveKitJwtFetchFailed(:final message) => 'Could not connect: $message', LiveKitConnectFailed(:final message) => 'Call failed: $message', }, ); } } /// Toggle microphone on/off during an active call. Future toggleAudio() async { final current = state; if (current is! CallActive) return; final room = ref.read(liveKitServiceProvider).activeRoom; final local = room?.localParticipant; if (local == null) return; final newEnabled = !current.isAudioEnabled; await local.setMicrophoneEnabled(newEnabled); state = current.copyWith(isAudioEnabled: newEnabled); } /// Toggle camera on/off during an active call. Future toggleVideo() async { final current = state; if (current is! CallActive) return; final room = ref.read(liveKitServiceProvider).activeRoom; final local = room?.localParticipant; if (local == null) return; final newEnabled = !current.isVideoEnabled; await local.setCameraEnabled(newEnabled); state = current.copyWith(isVideoEnabled: newEnabled); } /// Hang up — disconnect from LiveKit and transition to [CallEnded]. Future endCall() async { _durationTimer?.cancel(); _durationTimer = null; await ref.read(liveKitServiceProvider).disconnect(); state = const CallState.ended(); } void _startTimer(String roomId, Room room, {required bool isVideo}) { _elapsed = Duration.zero; state = CallActive( roomId: roomId, duration: _elapsed, isVideoEnabled: isVideo, isAudioEnabled: true, ); _durationTimer = Timer.periodic(const Duration(seconds: 1), (_) { _elapsed += const Duration(seconds: 1); final current = state; if (current is CallActive) { state = current.copyWith(duration: _elapsed); } }); } }