380 lines
13 KiB
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;
|
|
}
|
|
}
|