// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-03 // Call controller — manages LiveKit room connection lifecycle. // Transitions through idle → connecting → active → ended states. import 'dart:async'; import 'package:flutter/foundation.dart'; 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: true) class CallController extends _$CallController { Timer? _durationTimer; Duration _elapsed = Duration.zero; EventsListener? _roomListener; @override CallState build() { ref.onDispose(() { _durationTimer?.cancel(); _durationTimer = null; _roomListener?.dispose(); _roomListener = null; ref.read(liveKitServiceProvider).disconnect(); }); return 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); } }); // Auto-end when the remote party leaves (1:1 call behaviour). // Wait 5 seconds after the last remote participant disconnects before // ending — gives time for brief network interruptions to recover. _roomListener?.dispose(); _roomListener = room.createListener(); _roomListener!.on((event) { debugPrint( '[Call] Participant left: ${event.participant.identity}, ' 'remaining: ${room.remoteParticipants.length}', ); if (room.remoteParticipants.isEmpty) { debugPrint('[Call] No remote participants — auto-ending in 5s'); Future.delayed(const Duration(seconds: 5), () { // Re-check in case someone rejoined during the delay. if (room.remoteParticipants.isEmpty && state is CallActive) { endCall(); } }); } }); } }