Chepuhagram/lib/domain/services/webrtc_service.dart

380 lines
13 KiB
Dart

import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'package:chepuhagram/core/constants.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:shared_preferences/shared_preferences.dart';
class WebRTCService {
static final WebRTCService _instance = WebRTCService._internal();
factory WebRTCService() => _instance;
WebRTCService._internal();
RTCPeerConnection? _peerConnection;
MediaStream? _localStream;
Function()? onCallEnded;
Function(bool)? onRemoteVideoToggled;
int? _targetUserId;
int? _myUserId;
bool _isInitiator = false;
final List<RTCIceCandidate> _remoteIceCandidatesQueue = [];
bool _isReadyForSignaling = false;
final List<Map<String, dynamic>> _incomingSignalingQueue = [];
bool isCallAcceptedLocally = false;
bool _tracksAdded = false;
// Конфигурация STUN и TURN-серверов
final Map<String, dynamic> _config = {
"iceServers": [
{"urls": "stun:stun.l.google.com:19302"},
{"urls": "stun:stun1.l.google.com:19302"},
{"urls": "stun:stun2.l.google.com:19302"},
{
"urls": AppConstants.turnUrl,
"username": AppConstants.turnUsername,
"credential": AppConstants.turnCredential,
},
],
};
/// Локальный стрим (камера + микрофон)
Future<void> startLocalStream(
Function(MediaStream) onLocalStreamCallback, {
bool videoDefaultEnabled = false,
}) async {
try {
_localStream?.getTracks().forEach((track) => track.stop());
_localStream?.dispose();
_localStream = await navigator.mediaDevices.getUserMedia({
'audio': true,
'video': {
'mandatory': {
'minWidth': '1280',
'minHeight': '720',
'minFrameRate': '30',
},
'facingMode': 'user',
'optional': [],
},
});
// Выключаем видео по умолчанию
_localStream!.getVideoTracks().forEach((track) {
track.enabled = videoDefaultEnabled;
});
// Если PeerConnection уже инициализирован, сразу же добавляем локальные треки
if (_peerConnection != null && !_tracksAdded) {
_localStream!.getTracks().forEach((track) {
_peerConnection!.addTrack(track, _localStream!);
});
_tracksAdded = true;
}
onLocalStreamCallback(_localStream!);
} catch (e) {
print("Ошибка получения локального стрима: $e");
}
}
/// Инициализация PeerConnection
Future<void> initPeerConnection(
String callId,
int targetUserId,
Function(MediaStream) onRemoteStream, [
int? myUserId,
bool isInitiator = false,
]) async {
_targetUserId = targetUserId;
_isInitiator = isInitiator;
if (myUserId != null) {
_myUserId = myUserId;
}
try {
final prefs = await SharedPreferences.getInstance();
final alwaysRelay = prefs.getBool('privacy_always_relay_calls') ?? false;
final Map<String, dynamic> currentConfig = {
"iceServers": alwaysRelay
? [
{
"urls": AppConstants.turnUrl,
"username": AppConstants.turnUsername,
"credential": AppConstants.turnCredential,
},
]
: [
{"urls": "stun:stun.l.google.com:19302"},
{"urls": "stun:stun1.l.google.com:19302"},
{"urls": "stun:stun2.l.google.com:19302"},
{"urls": AppConstants.stunUrl},
{
"urls": AppConstants.turnUrl,
"username": AppConstants.turnUsername,
"credential": AppConstants.turnCredential,
},
],
if (alwaysRelay) "iceTransportPolicy": "relay",
};
_peerConnection = await createPeerConnection(currentConfig);
// Слушаем удаленный поток
_peerConnection!.onAddStream = (stream) {
onRemoteStream(stream);
};
// Отправляем ICE-кандидаты на сервер через SocketService
_peerConnection!.onIceCandidate = (candidate) {
if (_targetUserId != null) {
SocketService().sendMessage({
"type": "ice_candidate",
"call_id": callId,
"receiver_id": _targetUserId,
"sender_id": _myUserId,
"candidate": candidate.toMap(),
});
}
};
// Добавляем треки в соединение
if (_localStream != null && !_tracksAdded) {
_localStream!.getTracks().forEach((track) {
_peerConnection!.addTrack(track, _localStream!);
});
_tracksAdded = true;
}
// Дрейним очередь сигналов, так как PeerConnection теперь готов!
_drainSignalingQueue();
} catch (e) {
print("Ошибка инициализации PeerConnection: $e");
}
}
/// Обработка сигнальных сообщений
void handleSignalingMessage(Map<String, dynamic> data) async {
final type = data['type'];
final needsPeerConnection =
(type == 'offer' || type == 'answer' || type == 'ice_candidate');
if (!_isReadyForSignaling ||
(needsPeerConnection && _peerConnection == null)) {
_incomingSignalingQueue.add(data);
print(
"WebRTCService: Queueing message: $type (ready: $_isReadyForSignaling, pc: ${_peerConnection != null})",
);
return;
}
final callId = data['call_id'];
print("WebRTCService: Получено сигнальное сообщение: $type");
if (type == 'call_accepted') {
if (_isInitiator) {
await createOffer(callId);
} else {
// Мы не инициатор, значит звонок принят на другом нашем устройстве!
if (!isCallAcceptedLocally) {
print(
"WebRTCService: Call accepted on other device. Ending call locally.",
);
onCallEnded?.call();
FlutterCallkitIncoming.endAllCalls();
}
}
} else if (type == 'offer') {
final senderId = int.tryParse(data['sender_id']?.toString() ?? '') ?? 0;
if (senderId != 0) {
_targetUserId = senderId;
}
final sdp = data['sdp']?.toString() ?? '';
await handleOffer(callId, sdp);
} else if (type == 'answer') {
final sdp = data['sdp']?.toString() ?? '';
await _peerConnection?.setRemoteDescription(
RTCSessionDescription(sdp, 'answer'),
);
await _processRemoteIceCandidatesQueue();
} else if (type == 'ice_candidate') {
final candidate = data['candidate'];
if (candidate != null) {
await addRemoteIceCandidate(Map<String, dynamic>.from(candidate));
}
} else if (type == 'video_toggle') {
final enabled = data['video_enabled'] == true;
onRemoteVideoToggled?.call(enabled);
} else if (type == 'hangup' || type == 'decline') {
if (onCallEnded != null) {
onCallEnded?.call();
} else {
print(
"WebRTCService: Call declined or hung up on other device. Dismissing CallKit.",
);
FlutterCallkitIncoming.endAllCalls();
}
}
}
/// Создание Offer (вызывает инициатор звонка после call_accepted)
Future<void> createOffer(String callId, [int? targetUserId]) async {
final tId = targetUserId ?? _targetUserId;
if (_peerConnection == null || tId == null || tId == 0) return;
try {
RTCSessionDescription offer = await _peerConnection!.createOffer();
await _peerConnection!.setLocalDescription(offer);
SocketService().sendMessage({
"type": "offer",
"call_id": callId,
"receiver_id": tId,
"sender_id": _myUserId,
"sdp": offer.sdp,
});
} catch (e) {
print("Ошибка при создании offer: $e");
}
}
/// Обработка Offer и отправка Answer
Future<void> handleOffer(
String callId,
String remoteSdp, [
int? targetUserId,
]) async {
final tId = targetUserId ?? _targetUserId;
if (_peerConnection == null || tId == null || tId == 0) return;
try {
await _peerConnection!.setRemoteDescription(
RTCSessionDescription(remoteSdp, 'offer'),
);
await _processRemoteIceCandidatesQueue();
RTCSessionDescription answer = await _peerConnection!.createAnswer();
await _peerConnection!.setLocalDescription(answer);
SocketService().sendMessage({
"type": "answer",
"call_id": callId,
"receiver_id": tId,
"sender_id": _myUserId,
"sdp": answer.sdp,
});
} catch (e) {
print("Ошибка при обработке offer и создании answer: $e");
}
}
/// Обработка ICE кандидатов от удаленного собеседника
Future<void> addRemoteIceCandidate(Map<String, dynamic> candidateMap) async {
if (_peerConnection == null) return;
final candidate = RTCIceCandidate(
candidateMap['candidate'],
candidateMap['sdpMid'],
candidateMap['sdpMLineIndex'],
);
final remoteDesc = await _peerConnection!.getRemoteDescription();
if (remoteDesc == null) {
_remoteIceCandidatesQueue.add(candidate);
print(
"WebRTCService: Remote description is not set yet. Queued ICE candidate.",
);
return;
}
try {
await _peerConnection!.addCandidate(candidate);
} catch (e) {
print("Ошибка добавления удаленного ICE кандидата: $e");
}
}
/// Процесс добавления сохраненных в очереди ICE кандидатов
Future<void> _processRemoteIceCandidatesQueue() async {
if (_peerConnection == null) return;
print(
"WebRTCService: Processing queued remote ICE candidates (${_remoteIceCandidatesQueue.length})",
);
for (var candidate in _remoteIceCandidatesQueue) {
try {
await _peerConnection!.addCandidate(candidate);
} catch (e) {
print("Ошибка добавления удаленного ICE кандидата из очереди: $e");
}
}
_remoteIceCandidatesQueue.clear();
}
/// Включение/выключение звука
void toggleMute(bool isMuted) {
_localStream?.getAudioTracks().forEach((track) {
track.enabled = !isMuted;
});
}
/// Включение/выключение видео
void toggleVideo(bool isVideoOff) {
_localStream?.getVideoTracks().forEach((track) {
track.enabled = !isVideoOff;
});
}
/// Переключение вывода звука (динамик громкой связи / разговорный динамик)
void toggleSpeakerphone(bool isSpeakerOn) {
try {
Helper.setSpeakerphoneOn(isSpeakerOn);
print("WebRTCService: Громкая связь переключена в: $isSpeakerOn");
} catch (e) {
print("Ошибка при переключении динамика: $e");
}
}
/// Переключение камеры
Future<void> switchCamera() async {
if (_localStream != null) {
final videoTrack = _localStream!.getVideoTracks().firstOrNull;
if (videoTrack != null) {
await Helper.switchCamera(videoTrack);
}
}
}
/// Пометить WebRTCService как готовый для обмена сообщениями и обработать накопившуюся очередь
void setReadyForSignaling() {
_isReadyForSignaling = true;
print("WebRTCService: Ready for signaling.");
_drainSignalingQueue();
}
void _drainSignalingQueue() {
if (!_isReadyForSignaling) return;
print(
"WebRTCService: Draining signaling queue of size: ${_incomingSignalingQueue.length}",
);
final List<Map<String, dynamic>> queueCopy = List.from(
_incomingSignalingQueue,
);
_incomingSignalingQueue.clear();
for (var data in queueCopy) {
handleSignalingMessage(data);
}
}
/// Очистка ресурсов
void dispose() {
print("WebRTCService: Очистка ресурсов WebRTC");
_tracksAdded = false;
_remoteIceCandidatesQueue.clear();
_incomingSignalingQueue.clear();
_isReadyForSignaling = false;
isCallAcceptedLocally = false;
_localStream?.getTracks().forEach((track) => track.stop());
_localStream?.dispose();
_localStream = null;
_peerConnection?.close();
_peerConnection = null;
}
}