Listens for ParticipantDisconnectedEvent on the LiveKit Room. When no remote participants remain (1:1 call ended by other side), waits 5 seconds then auto-hangs up. The delay allows for brief reconnections. Also ensures our app properly cleans up when Element X user hangs up on their end — our side won't sit connected to an empty LiveKit room. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
142 lines
4.6 KiB
Dart
142 lines
4.6 KiB
Dart
// 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<RoomEvent>? _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<void> 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<void> 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<void> 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<void> 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<ParticipantDisconnectedEvent>((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();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|