godcrm/god_frame/lib/features/chat/providers/call_provider.dart
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

296 lines
8.4 KiB
Dart

import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:livekit_client/livekit_client.dart';
import '../../../shared/utils/api_client.dart';
enum CallState { idle, connecting, connected, error }
class CallInfo {
final CallState state;
final String? roomName;
final int? conversationId;
final String? errorMessage;
final List<RemoteParticipant> remoteParticipants;
final Duration elapsed;
final bool isMuted;
final bool isSpeakerOn;
final bool isRecording;
final String? egressId;
final bool isTranscribing;
final String? transcriptionText;
final List<String> liveTranscriptLines;
const CallInfo({
this.state = CallState.idle,
this.roomName,
this.conversationId,
this.errorMessage,
this.remoteParticipants = const [],
this.elapsed = Duration.zero,
this.isMuted = false,
this.isSpeakerOn = true,
this.isRecording = false,
this.egressId,
this.isTranscribing = false,
this.transcriptionText,
this.liveTranscriptLines = const [],
});
CallInfo copyWith({
CallState? state,
String? roomName,
int? conversationId,
String? errorMessage,
List<RemoteParticipant>? remoteParticipants,
Duration? elapsed,
bool? isMuted,
bool? isSpeakerOn,
bool? isRecording,
String? egressId,
bool? isTranscribing,
String? transcriptionText,
List<String>? liveTranscriptLines,
}) {
return CallInfo(
state: state ?? this.state,
roomName: roomName ?? this.roomName,
conversationId: conversationId ?? this.conversationId,
errorMessage: errorMessage ?? this.errorMessage,
remoteParticipants: remoteParticipants ?? this.remoteParticipants,
elapsed: elapsed ?? this.elapsed,
isMuted: isMuted ?? this.isMuted,
isSpeakerOn: isSpeakerOn ?? this.isSpeakerOn,
isRecording: isRecording ?? this.isRecording,
egressId: egressId ?? this.egressId,
isTranscribing: isTranscribing ?? this.isTranscribing,
transcriptionText: transcriptionText ?? this.transcriptionText,
liveTranscriptLines: liveTranscriptLines ?? this.liveTranscriptLines,
);
}
}
class CallNotifier extends StateNotifier<CallInfo> {
final Dio _dio;
Room? _room;
EventsListener<RoomEvent>? _listener;
DateTime? _callStartTime;
CallNotifier(this._dio) : super(const CallInfo());
Room? get room => _room;
/// Start a call. [withRecording] = true starts server-side recording.
Future<void> startCall(int conversationId, {bool withRecording = true}) async {
if (state.state == CallState.connecting || state.state == CallState.connected) {
return;
}
state = state.copyWith(
state: CallState.connecting,
conversationId: conversationId,
errorMessage: null,
isRecording: false,
egressId: null,
transcriptionText: null,
liveTranscriptLines: [],
);
try {
final response = await _dio.post(
'/chat/conversations/$conversationId/call/token',
);
if (response.statusCode != 200 || response.data == null) {
throw Exception('Failed to get call token');
}
final data = response.data is Map && response.data.containsKey('data')
? response.data['data']
: response.data;
final token = data['token'] as String;
final url = data['url'] as String;
_room = Room(
roomOptions: const RoomOptions(
adaptiveStream: false,
dynacast: false,
defaultAudioPublishOptions: AudioPublishOptions(
dtx: true,
),
),
);
_listener = _room!.createListener();
_listener!
..on<ParticipantConnectedEvent>((event) {
_updateParticipants();
})
..on<ParticipantDisconnectedEvent>((event) {
_updateParticipants();
})
..on<RoomDisconnectedEvent>((event) {
endCall();
})
..on<TrackSubscribedEvent>((event) {
_updateParticipants();
});
await _room!.connect(url, token);
await _room!.localParticipant?.setMicrophoneEnabled(true);
_callStartTime = DateTime.now();
state = state.copyWith(
state: CallState.connected,
roomName: data['room'] as String?,
);
_updateParticipants();
// Auto-start recording if requested
if (withRecording) {
_startRecording(conversationId);
}
} catch (e) {
state = state.copyWith(
state: CallState.error,
errorMessage: e.toString(),
);
}
}
void _updateParticipants() {
if (_room == null) return;
state = state.copyWith(
remoteParticipants: _room!.remoteParticipants.values.toList(),
);
}
/// Start server-side recording via LiveKit Egress.
Future<void> _startRecording(int conversationId) async {
try {
final resp = await _dio.post(
'/chat/conversations/$conversationId/call/recording/start',
);
if (resp.statusCode == 200) {
final data = resp.data is Map && resp.data.containsKey('data')
? resp.data['data']
: resp.data;
state = state.copyWith(
isRecording: true,
egressId: data['egress_id'] as String?,
);
}
} catch (_) {
// Recording failed to start — call continues without recording
}
}
/// Stop server-side recording.
Future<void> stopRecording() async {
if (!state.isRecording || state.egressId == null || state.conversationId == null) return;
try {
await _dio.post(
'/chat/conversations/${state.conversationId}/call/recording/stop',
data: {'egress_id': state.egressId},
);
} catch (_) {}
state = state.copyWith(isRecording: false);
}
/// Toggle recording on/off during a call.
Future<void> toggleRecording() async {
if (state.isRecording) {
await stopRecording();
} else if (state.conversationId != null) {
await _startRecording(state.conversationId!);
}
}
Future<void> toggleMute() async {
if (_room == null) return;
final newMuted = !state.isMuted;
await _room!.localParticipant?.setMicrophoneEnabled(!newMuted);
state = state.copyWith(isMuted: newMuted);
}
Future<void> toggleSpeaker() async {
if (_room == null) return;
final newSpeaker = !state.isSpeakerOn;
await Hardware.instance.setSpeakerphoneOn(newSpeaker);
state = state.copyWith(isSpeakerOn: newSpeaker);
}
Future<void> endCall() async {
// Stop recording if active, then transcribe with diarization
final wasRecording = state.isRecording;
final convId = state.conversationId;
final egressId = state.egressId;
// Calculate call duration
final callDuration = _callStartTime != null
? DateTime.now().difference(_callStartTime!).inSeconds
: 0;
// Collect participant names (local + remote)
final participantsList = <Map<String, String>>[];
if (_room?.localParticipant != null) {
participantsList.add({
'identity': _room!.localParticipant!.identity ?? 'local',
'name': _room!.localParticipant!.name ?? 'Вы',
});
}
for (final p in state.remoteParticipants) {
participantsList.add({
'identity': p.identity ?? 'unknown',
'name': p.name ?? p.identity ?? 'Unknown',
});
}
if (wasRecording && egressId != null && convId != null) {
try {
final stopResp = await _dio.post(
'/chat/conversations/$convId/call/recording/stop',
data: {'egress_id': egressId},
);
// Trigger async transcription with diarization data
final stopData = stopResp.data is Map && stopResp.data.containsKey('data')
? stopResp.data['data']
: stopResp.data;
final filePath = stopData?['file'] as String?;
if (filePath != null) {
_dio.post(
'/chat/conversations/$convId/call/transcribe',
data: {
'file_path': filePath,
'duration': callDuration,
'participants': participantsList,
},
).catchError((_) => null);
}
} catch (_) {}
}
_callStartTime = null;
_listener?.dispose();
_listener = null;
await _room?.disconnect();
await _room?.dispose();
_room = null;
state = const CallInfo();
}
@override
void dispose() {
endCall();
super.dispose();
}
}
final callProvider = StateNotifierProvider<CallNotifier, CallInfo>((ref) {
final dio = ref.watch(apiClientProvider);
return CallNotifier(dio);
});