22-06-2026+19-07
This commit is contained in:
parent
3c93e4148e
commit
d116fbde43
|
|
@ -1,5 +1,10 @@
|
||||||
class AppConstants {
|
class AppConstants {
|
||||||
//static const baseUrl = '192.168.0.180:8000';
|
|
||||||
static const baseUrl = 'https://api.chepuhagram.ru';
|
static const baseUrl = 'https://api.chepuhagram.ru';
|
||||||
static const wsUrl = 'wss://api.chepuhagram.ru';
|
static const wsUrl = 'wss://api.chepuhagram.ru';
|
||||||
|
|
||||||
|
// TURN Server Configuration
|
||||||
|
static const turnUrl = 'turns:turn.chepuhagram.ru:5349';
|
||||||
|
static const stunUrl = 'stuns:turn.chepuhagram.ru:5349';
|
||||||
|
static const turnUsername = 'chepuha_user';
|
||||||
|
static const turnCredential = 'byybg3456bFYTfY8ytFb323yTfYBtF';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import 'package:web_socket_channel/status.dart' as status;
|
import 'package:web_socket_channel/status.dart' as status;
|
||||||
import 'package:web_socket_channel/io.dart';
|
import 'package:web_socket_channel/io.dart';
|
||||||
import 'package:chepuhagram/core/constants.dart';
|
import 'package:chepuhagram/core/constants.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:chepuhagram/domain/services/webrtc_service.dart';
|
import 'package:chepuhagram/domain/services/webrtc_service.dart';
|
||||||
|
import 'package:chepuhagram/main.dart';
|
||||||
|
import 'package:chepuhagram/presentation/screens/call_screen.dart';
|
||||||
|
|
||||||
class SocketService with WidgetsBindingObserver {
|
class SocketService with WidgetsBindingObserver {
|
||||||
static final SocketService _instance = SocketService._internal();
|
static final SocketService _instance = SocketService._internal();
|
||||||
|
|
@ -14,6 +17,7 @@ class SocketService with WidgetsBindingObserver {
|
||||||
|
|
||||||
SocketService._internal() {
|
SocketService._internal() {
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_initMessageListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
WebSocketChannel? _channel;
|
WebSocketChannel? _channel;
|
||||||
|
|
@ -36,8 +40,47 @@ class SocketService with WidgetsBindingObserver {
|
||||||
|
|
||||||
void _initMessageListener() {
|
void _initMessageListener() {
|
||||||
messages.listen((data) {
|
messages.listen((data) {
|
||||||
if (data['type'] == 'call_accepted') {
|
final type = data['type'];
|
||||||
WebRTCService().handleOffer(data['call_id'], data['sdp']);
|
print("SocketService: Получено сигнальное сообщение: $type");
|
||||||
|
if (type == 'call_init') {
|
||||||
|
final callId = data['call_id']?.toString() ?? '';
|
||||||
|
final callerId = int.tryParse(data['caller_id']?.toString() ?? '') ?? 0;
|
||||||
|
final callerUsername = data['caller_username']?.toString() ?? 'Пользователь';
|
||||||
|
|
||||||
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
|
showIncomingCallKit(
|
||||||
|
callId: callId,
|
||||||
|
callerName: callerUsername,
|
||||||
|
callerId: callerId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
if (context != null) {
|
||||||
|
if (CallScreen.currentActiveCallId != null) {
|
||||||
|
print("SocketService: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $callId");
|
||||||
|
} else {
|
||||||
|
CallScreen.currentActiveCallId = callId;
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => CallScreen(
|
||||||
|
callId: callId,
|
||||||
|
isIncoming: true,
|
||||||
|
callerName: callerUsername,
|
||||||
|
targetUserId: callerId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type == 'call_accepted' ||
|
||||||
|
type == 'offer' ||
|
||||||
|
type == 'answer' ||
|
||||||
|
type == 'ice_candidate' ||
|
||||||
|
type == 'hangup' ||
|
||||||
|
type == 'decline' ||
|
||||||
|
type == 'video_toggle') {
|
||||||
|
WebRTCService().handleSignalingMessage(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,122 @@
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
import 'package:chepuhagram/data/datasources/ws_client.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 {
|
class WebRTCService {
|
||||||
|
static final WebRTCService _instance = WebRTCService._internal();
|
||||||
|
factory WebRTCService() => _instance;
|
||||||
|
WebRTCService._internal();
|
||||||
|
|
||||||
RTCPeerConnection? _peerConnection;
|
RTCPeerConnection? _peerConnection;
|
||||||
MediaStream? _localStream;
|
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-серверов (Google STUN)
|
// Конфигурация STUN и TURN-серверов
|
||||||
final Map<String, dynamic> _config = {
|
final Map<String, dynamic> _config = {
|
||||||
"iceServers": [
|
"iceServers": [
|
||||||
{"urls": "stun:stun.l.google.com:19302"},
|
{"urls": "stun:stun.l.google.com:19302"},
|
||||||
{"urls": "stun:stun1.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
|
/// Инициализация PeerConnection
|
||||||
Future<void> initPeerConnection(String callId, Function(MediaStream) onRemoteStream) async {
|
Future<void> initPeerConnection(
|
||||||
_peerConnection = await createPeerConnection(_config);
|
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) {
|
_peerConnection!.onAddStream = (stream) {
|
||||||
|
|
@ -24,55 +125,129 @@ class WebRTCService {
|
||||||
|
|
||||||
// Отправляем ICE-кандидаты на сервер через SocketService
|
// Отправляем ICE-кандидаты на сервер через SocketService
|
||||||
_peerConnection!.onIceCandidate = (candidate) {
|
_peerConnection!.onIceCandidate = (candidate) {
|
||||||
|
if (_targetUserId != null) {
|
||||||
SocketService().sendMessage({
|
SocketService().sendMessage({
|
||||||
"type": "ice_candidate",
|
"type": "ice_candidate",
|
||||||
"call_id": callId,
|
"call_id": callId,
|
||||||
|
"receiver_id": _targetUserId,
|
||||||
|
"sender_id": _myUserId,
|
||||||
"candidate": candidate.toMap(),
|
"candidate": candidate.toMap(),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Получаем локальный поток (микрофон + камера)
|
|
||||||
_localStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
'audio': true,
|
|
||||||
'video': true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Добавляем треки в соединение
|
// Добавляем треки в соединение
|
||||||
|
if (_localStream != null && !_tracksAdded) {
|
||||||
_localStream!.getTracks().forEach((track) {
|
_localStream!.getTracks().forEach((track) {
|
||||||
_peerConnection!.addTrack(track, _localStream!);
|
_peerConnection!.addTrack(track, _localStream!);
|
||||||
});
|
});
|
||||||
|
_tracksAdded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> handleOffer(String callId, String remoteSdp) async {
|
// Дрейним очередь сигналов, так как PeerConnection теперь готов!
|
||||||
// 1. Инициализируем соединение, если оно еще не создано
|
_drainSignalingQueue();
|
||||||
if (_peerConnection == null) {
|
} catch (e) {
|
||||||
await initPeerConnection(callId, (stream) {
|
print("Ошибка инициализации PeerConnection: $e");
|
||||||
// Здесь можно добавить callback для отрисовки видео, если нужно
|
}
|
||||||
print("Remote stream received");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Создаем ответ (это вызывает ваш метод createAnswer)
|
/// Обработка сигнальных сообщений
|
||||||
await createAnswer(callId, remoteSdp);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Создание Offer (вызывает инициатор звонка)
|
final callId = data['call_id'];
|
||||||
Future<void> createOffer(String callId) async {
|
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();
|
RTCSessionDescription offer = await _peerConnection!.createOffer();
|
||||||
await _peerConnection!.setLocalDescription(offer);
|
await _peerConnection!.setLocalDescription(offer);
|
||||||
|
|
||||||
SocketService().sendMessage({
|
SocketService().sendMessage({
|
||||||
"type": "offer",
|
"type": "offer",
|
||||||
"call_id": callId,
|
"call_id": callId,
|
||||||
|
"receiver_id": tId,
|
||||||
|
"sender_id": _myUserId,
|
||||||
"sdp": offer.sdp,
|
"sdp": offer.sdp,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
print("Ошибка при создании offer: $e");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Создание Answer (вызывает получатель звонка)
|
/// Обработка Offer и отправка Answer
|
||||||
Future<void> createAnswer(String callId, String remoteSdp) async {
|
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(
|
await _peerConnection!.setRemoteDescription(
|
||||||
RTCSessionDescription(remoteSdp, 'offer'),
|
RTCSessionDescription(remoteSdp, 'offer'),
|
||||||
);
|
);
|
||||||
|
await _processRemoteIceCandidatesQueue();
|
||||||
|
|
||||||
RTCSessionDescription answer = await _peerConnection!.createAnswer();
|
RTCSessionDescription answer = await _peerConnection!.createAnswer();
|
||||||
await _peerConnection!.setLocalDescription(answer);
|
await _peerConnection!.setLocalDescription(answer);
|
||||||
|
|
@ -80,25 +255,125 @@ class WebRTCService {
|
||||||
SocketService().sendMessage({
|
SocketService().sendMessage({
|
||||||
"type": "answer",
|
"type": "answer",
|
||||||
"call_id": callId,
|
"call_id": callId,
|
||||||
|
"receiver_id": tId,
|
||||||
|
"sender_id": _myUserId,
|
||||||
"sdp": answer.sdp,
|
"sdp": answer.sdp,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
print("Ошибка при обработке offer и создании answer: $e");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка ICE кандидатов от удаленного собеседника
|
/// Обработка ICE кандидатов от удаленного собеседника
|
||||||
Future<void> addRemoteIceCandidate(Map<String, dynamic> candidateMap) async {
|
Future<void> addRemoteIceCandidate(Map<String, dynamic> candidateMap) async {
|
||||||
await _peerConnection!.addCandidate(
|
if (_peerConnection == null) return;
|
||||||
RTCIceCandidate(
|
final candidate = RTCIceCandidate(
|
||||||
candidateMap['candidate'],
|
candidateMap['candidate'],
|
||||||
candidateMap['sdpMid'],
|
candidateMap['sdpMid'],
|
||||||
candidateMap['sdpMLineIndex'],
|
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() {
|
void dispose() {
|
||||||
|
print("WebRTCService: Очистка ресурсов WebRTC");
|
||||||
|
_tracksAdded = false;
|
||||||
|
_remoteIceCandidatesQueue.clear();
|
||||||
|
_incomingSignalingQueue.clear();
|
||||||
|
_isReadyForSignaling = false;
|
||||||
|
isCallAcceptedLocally = false;
|
||||||
_localStream?.getTracks().forEach((track) => track.stop());
|
_localStream?.getTracks().forEach((track) => track.stop());
|
||||||
_localStream?.dispose();
|
_localStream?.dispose();
|
||||||
|
_localStream = null;
|
||||||
_peerConnection?.close();
|
_peerConnection?.close();
|
||||||
|
_peerConnection = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
130
lib/main.dart
130
lib/main.dart
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||||
import 'package:chepuhagram/logic/auth_provider.dart';
|
import 'package:chepuhagram/logic/auth_provider.dart';
|
||||||
|
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||||
import 'package:chepuhagram/logic/contact_provider.dart';
|
import 'package:chepuhagram/logic/contact_provider.dart';
|
||||||
import 'package:chepuhagram/core/theme_manager.dart';
|
import 'package:chepuhagram/core/theme_manager.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
|
@ -8,6 +9,7 @@ import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
||||||
|
|
@ -271,6 +273,56 @@ void main() async {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> showIncomingCallKit({
|
||||||
|
required String callId,
|
||||||
|
required String callerName,
|
||||||
|
required int callerId,
|
||||||
|
}) async {
|
||||||
|
final params = CallKitParams(
|
||||||
|
id: callId,
|
||||||
|
nameCaller: callerName,
|
||||||
|
appName: 'Chepuhagram',
|
||||||
|
avatar: '',
|
||||||
|
handle: callerName,
|
||||||
|
type: 0, // 0 - audio, 1 - video
|
||||||
|
duration: 30000,
|
||||||
|
textAccept: 'Принять',
|
||||||
|
textDecline: 'Отклонить',
|
||||||
|
missedCallNotification: const NotificationParams(
|
||||||
|
showNotification: true,
|
||||||
|
subtitle: 'Пропущенный звонок',
|
||||||
|
),
|
||||||
|
extra: <String, dynamic>{
|
||||||
|
'callerId': callerId,
|
||||||
|
},
|
||||||
|
android: const AndroidParams(
|
||||||
|
isCustomNotification: true,
|
||||||
|
isShowLogo: false,
|
||||||
|
ringtonePath: 'system_ringtone_default',
|
||||||
|
backgroundColor: '#0F0F12',
|
||||||
|
actionColor: '#4CAF50',
|
||||||
|
textColor: '#ffffff',
|
||||||
|
incomingCallNotificationChannelName: 'Входящие звонки',
|
||||||
|
),
|
||||||
|
ios: const IOSParams(
|
||||||
|
handleType: 'generic',
|
||||||
|
supportsVideo: false,
|
||||||
|
maximumCallGroups: 1,
|
||||||
|
maximumCallsPerCallGroup: 1,
|
||||||
|
audioSessionMode: 'default',
|
||||||
|
audioSessionActive: true,
|
||||||
|
audioSessionPreferredSampleRate: 44100.0,
|
||||||
|
audioSessionPreferredIOBufferDuration: 0.005,
|
||||||
|
supportsDTMF: false,
|
||||||
|
supportsHolding: false,
|
||||||
|
supportsGrouping: false,
|
||||||
|
supportsUngrouping: false,
|
||||||
|
ringtonePath: 'system_ringtone_default',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await FlutterCallkitIncoming.showCallkitIncoming(params);
|
||||||
|
}
|
||||||
|
|
||||||
void initCallkitListener() {
|
void initCallkitListener() {
|
||||||
if (!(Platform.isAndroid || Platform.isIOS)) {
|
if (!(Platform.isAndroid || Platform.isIOS)) {
|
||||||
print('Skipping CallKit event listener on non-mobile platform.');
|
print('Skipping CallKit event listener on non-mobile platform.');
|
||||||
|
|
@ -281,10 +333,12 @@ void initCallkitListener() {
|
||||||
FlutterCallkitIncoming.onEvent.listen((event) {
|
FlutterCallkitIncoming.onEvent.listen((event) {
|
||||||
if (event == null) return;
|
if (event == null) return;
|
||||||
|
|
||||||
|
final callerId = int.tryParse(event.body['extra']?['callerId']?.toString() ?? '') ??
|
||||||
|
int.tryParse(event.body['callerId']?.toString() ?? '') ?? 0;
|
||||||
|
|
||||||
switch (event.event) {
|
switch (event.event) {
|
||||||
case Event.actionCallIncoming:
|
case Event.actionCallIncoming:
|
||||||
// Звонок получен, но CallKit уже показал экран.
|
// Звонок получен, но CallKit уже показал экран.
|
||||||
// Здесь можно логировать или обновить статус в БД.
|
|
||||||
print("Incoming call: ${event.body['id']}");
|
print("Incoming call: ${event.body['id']}");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -294,31 +348,76 @@ void initCallkitListener() {
|
||||||
|
|
||||||
case Event.actionCallAccept:
|
case Event.actionCallAccept:
|
||||||
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ПРИНЯТЬ"
|
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ПРИНЯТЬ"
|
||||||
|
Future(() async {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
var myId = 0;
|
||||||
|
if (context != null) {
|
||||||
|
try {
|
||||||
|
myId = Provider.of<AuthProvider>(context, listen: false).currentUserId ?? 0;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (myId == 0) {
|
||||||
|
final storage = FlutterSecureStorage();
|
||||||
|
final userIdStr = await storage.read(key: 'user_id');
|
||||||
|
myId = int.tryParse(userIdStr ?? '') ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final socket = SocketService();
|
||||||
|
if (!socket.isConnected()) {
|
||||||
|
print("CallKit action: Socket is not connected, connecting...");
|
||||||
|
try {
|
||||||
|
await socket.connect(ApiService());
|
||||||
|
} catch (e) {
|
||||||
|
print("CallKit action error connecting socket: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка)
|
// 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка)
|
||||||
SocketService().sendMessage({
|
socket.sendMessage({
|
||||||
"type": "call_accept",
|
"type": "call_accepted",
|
||||||
"call_id": event.body['id'],
|
"call_id": event.body['id'],
|
||||||
|
"receiver_id": callerId,
|
||||||
|
"sender_id": myId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Переходим на экран звонка
|
// 2. Переходим на экран звонка
|
||||||
|
final callId = event.body['id']?.toString() ?? '';
|
||||||
|
if (CallScreen.currentActiveCallId != null) {
|
||||||
|
print("CallKit action: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $callId");
|
||||||
|
} else {
|
||||||
|
CallScreen.currentActiveCallId = callId;
|
||||||
navigatorKey.currentState?.push(
|
navigatorKey.currentState?.push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => CallScreen(
|
builder: (_) => CallScreen(
|
||||||
callId: event.body['id'],
|
callId: callId,
|
||||||
isIncoming: true,
|
isIncoming: true,
|
||||||
callerName: event.body['nameCaller'] ?? 'Unknown',
|
callerName: event.body['nameCaller'] ?? 'Unknown',
|
||||||
onAccept: () {},
|
targetUserId: callerId,
|
||||||
onHangup: () => _handleHangupGlobal(event.body['id']),
|
startAccepted: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Event.actionCallDecline:
|
case Event.actionCallDecline:
|
||||||
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ОТКЛОНИТЬ"
|
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ОТКЛОНИТЬ"
|
||||||
SocketService().sendMessage({
|
Future(() async {
|
||||||
|
final socket = SocketService();
|
||||||
|
if (!socket.isConnected()) {
|
||||||
|
print("CallKit action: Socket is not connected for decline, connecting...");
|
||||||
|
try {
|
||||||
|
await socket.connect(ApiService());
|
||||||
|
} catch (e) {
|
||||||
|
print("CallKit action error connecting socket: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.sendMessage({
|
||||||
"type": "decline",
|
"type": "decline",
|
||||||
"call_id": event.body['id'],
|
"call_id": event.body['id'],
|
||||||
|
"receiver_id": callerId,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -504,8 +603,23 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error processing background message: $e');
|
print('Error processing background message: $e');
|
||||||
}
|
}
|
||||||
|
} else if (message.data['type'] == 'call_init') {
|
||||||
|
try {
|
||||||
|
final callId = message.data['call_id']?.toString() ?? '';
|
||||||
|
final callerName = message.data['caller_username']?.toString() ?? 'Пользователь';
|
||||||
|
final callerId = int.tryParse(message.data['caller_id']?.toString() ?? '') ?? 0;
|
||||||
|
|
||||||
|
print("Фоновый звонок через FCM: callId=$callId, callerName=$callerName, callerId=$callerId");
|
||||||
|
await showIncomingCallKit(
|
||||||
|
callId: callId,
|
||||||
|
callerName: callerName,
|
||||||
|
callerId: callerId,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
print("Error showing incoming call kit in background: $e");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
print('Message type is not enc_message: ${message.data['type']}');
|
print('Message type is not recognized: ${message.data['type']}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,506 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
import 'package:chepuhagram/domain/services/webrtc_service.dart';
|
||||||
|
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:chepuhagram/logic/auth_provider.dart';
|
||||||
|
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||||
|
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
class CallScreen extends StatefulWidget {
|
class CallScreen extends StatefulWidget {
|
||||||
final String callId;
|
final String callId;
|
||||||
final bool isIncoming;
|
final bool isIncoming;
|
||||||
final String callerName;
|
final String callerName;
|
||||||
final VoidCallback onAccept;
|
final int targetUserId;
|
||||||
final VoidCallback onHangup;
|
final bool startAccepted;
|
||||||
|
|
||||||
|
static String? currentActiveCallId;
|
||||||
|
|
||||||
const CallScreen({
|
const CallScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.callId,
|
required this.callId,
|
||||||
required this.isIncoming,
|
required this.isIncoming,
|
||||||
required this.callerName,
|
required this.callerName,
|
||||||
required this.onAccept,
|
required this.targetUserId,
|
||||||
required this.onHangup,
|
this.startAccepted = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CallScreen> createState() => _CallScreenState();
|
State<CallScreen> createState() => _CallScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CallScreenState extends State<CallScreen> {
|
class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||||
// Рендереры для видеопотока
|
|
||||||
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
|
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
|
||||||
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
|
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
|
||||||
|
|
||||||
bool _isAccepted = false;
|
bool _isAccepted = false;
|
||||||
|
bool _isMuted = false;
|
||||||
|
bool _isVideoOff = true;
|
||||||
|
bool _isSpeakerOn = false;
|
||||||
|
bool _isRemoteVideoOn = false;
|
||||||
|
bool _renderersInitialized = false;
|
||||||
|
bool _isPopped = false;
|
||||||
|
|
||||||
|
Timer? _durationTimer;
|
||||||
|
int _durationSeconds = 0;
|
||||||
|
|
||||||
|
// Анимация пульсации для экрана вызова
|
||||||
|
late AnimationController _pulseController;
|
||||||
|
late Animation<double> _pulseAnimation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_localRenderer.initialize();
|
CallScreen.currentActiveCallId = widget.callId;
|
||||||
_remoteRenderer.initialize();
|
_isAccepted = widget.startAccepted;
|
||||||
|
_initRenderers();
|
||||||
|
_setupPulseAnimation();
|
||||||
|
_setupWebRTC();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initRenderers() async {
|
||||||
|
await _localRenderer.initialize();
|
||||||
|
await _remoteRenderer.initialize();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_renderersInitialized = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupPulseAnimation() {
|
||||||
|
_pulseController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
)..repeat();
|
||||||
|
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.6).animate(
|
||||||
|
CurvedAnimation(parent: _pulseController, curve: Curves.easeOut),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupWebRTC() async {
|
||||||
|
// 1. Устанавливаем обработчик завершения звонка
|
||||||
|
WebRTCService().onCallEnded = () {
|
||||||
|
if (mounted && !_isPopped) {
|
||||||
|
_isPopped = true;
|
||||||
|
if (CallScreen.currentActiveCallId == widget.callId) {
|
||||||
|
CallScreen.currentActiveCallId = null;
|
||||||
|
}
|
||||||
|
_stopTimer();
|
||||||
|
FlutterCallkitIncoming.endAllCalls();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Слушаем изменение состояния камеры собеседника
|
||||||
|
WebRTCService().onRemoteVideoToggled = (enabled) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isRemoteVideoOn = enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Если звонок исходящий или входящий и уже принят через CallKit,
|
||||||
|
// сразу же создаем/инициализируем PeerConnection, чтобы быть готовыми принимать входящие сигналы (offer и т.д.)
|
||||||
|
if (!widget.isIncoming || widget.startAccepted) {
|
||||||
|
if (widget.startAccepted || !widget.isIncoming) {
|
||||||
|
WebRTCService().isCallAcceptedLocally = true;
|
||||||
|
}
|
||||||
|
await _initConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Запускаем локальный стрим (камера открывается параллельно/следом)
|
||||||
|
await WebRTCService().startLocalStream((localStream) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_localRenderer.srcObject = localStream;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Помечаем WebRTCService как готовый для обмена сигналами
|
||||||
|
WebRTCService().setReadyForSignaling();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initConnection() async {
|
||||||
|
var myId = context.read<AuthProvider>().currentUserId ?? 0;
|
||||||
|
if (myId == 0) {
|
||||||
|
final storage = FlutterSecureStorage();
|
||||||
|
final userIdStr = await storage.read(key: 'user_id');
|
||||||
|
myId = int.tryParse(userIdStr ?? '') ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final socket = SocketService();
|
||||||
|
if (!socket.isConnected()) {
|
||||||
|
print("CallScreen: Socket not connected during init, connecting...");
|
||||||
|
try {
|
||||||
|
await socket.connect(ApiService());
|
||||||
|
} catch (e) {
|
||||||
|
print("CallScreen socket connection error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await WebRTCService().initPeerConnection(
|
||||||
|
widget.callId,
|
||||||
|
widget.targetUserId,
|
||||||
|
(remoteStream) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_remoteRenderer.srcObject = remoteStream;
|
||||||
|
_isAccepted = true;
|
||||||
|
});
|
||||||
|
_startTimer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
myId,
|
||||||
|
!widget.isIncoming,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startTimer() {
|
||||||
|
_durationTimer?.cancel();
|
||||||
|
_durationSeconds = 0;
|
||||||
|
_durationTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_durationSeconds++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopTimer() {
|
||||||
|
_durationTimer?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(int seconds) {
|
||||||
|
final int minutes = seconds ~/ 60;
|
||||||
|
final int remainingSeconds = seconds % 60;
|
||||||
|
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleAccept() async {
|
||||||
|
setState(() {
|
||||||
|
_isAccepted = true;
|
||||||
|
});
|
||||||
|
WebRTCService().isCallAcceptedLocally = true;
|
||||||
|
final myId = context.read<AuthProvider>().currentUserId ?? 0;
|
||||||
|
// Сообщаем вызывающему, что вызов принят
|
||||||
|
SocketService().sendMessage({
|
||||||
|
"type": "call_accepted",
|
||||||
|
"call_id": widget.callId,
|
||||||
|
"receiver_id": widget.targetUserId,
|
||||||
|
"sender_id": myId,
|
||||||
|
});
|
||||||
|
// Инициализируем WebRTC-соединение
|
||||||
|
await _initConnection();
|
||||||
|
_startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDecline() {
|
||||||
|
SocketService().sendMessage({
|
||||||
|
"type": "decline",
|
||||||
|
"call_id": widget.callId,
|
||||||
|
"receiver_id": widget.targetUserId,
|
||||||
|
});
|
||||||
|
_cleanupAndPop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleHangup() {
|
||||||
|
SocketService().sendMessage({
|
||||||
|
"type": "hangup",
|
||||||
|
"call_id": widget.callId,
|
||||||
|
"receiver_id": widget.targetUserId,
|
||||||
|
});
|
||||||
|
_cleanupAndPop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cleanupAndPop() {
|
||||||
|
if (_isPopped) return;
|
||||||
|
_isPopped = true;
|
||||||
|
if (CallScreen.currentActiveCallId == widget.callId) {
|
||||||
|
CallScreen.currentActiveCallId = null;
|
||||||
|
}
|
||||||
|
_stopTimer();
|
||||||
|
WebRTCService().dispose();
|
||||||
|
FlutterCallkitIncoming.endAllCalls();
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleMute() {
|
||||||
|
setState(() {
|
||||||
|
_isMuted = !_isMuted;
|
||||||
|
});
|
||||||
|
WebRTCService().toggleMute(_isMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleVideo() {
|
||||||
|
setState(() {
|
||||||
|
_isVideoOff = !_isVideoOff;
|
||||||
|
});
|
||||||
|
WebRTCService().toggleVideo(_isVideoOff);
|
||||||
|
|
||||||
|
// Уведомляем собеседника о переключении нашей камеры
|
||||||
|
final myId = context.read<AuthProvider>().currentUserId ?? 0;
|
||||||
|
SocketService().sendMessage({
|
||||||
|
"type": "video_toggle",
|
||||||
|
"call_id": widget.callId,
|
||||||
|
"receiver_id": widget.targetUserId,
|
||||||
|
"sender_id": myId,
|
||||||
|
"video_enabled": !_isVideoOff,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSpeaker() {
|
||||||
|
setState(() {
|
||||||
|
_isSpeakerOn = !_isSpeakerOn;
|
||||||
|
});
|
||||||
|
WebRTCService().toggleSpeakerphone(_isSpeakerOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _switchCamera() async {
|
||||||
|
await WebRTCService().switchCamera();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_stopTimer();
|
||||||
|
_pulseController.dispose();
|
||||||
|
_localRenderer.srcObject = null;
|
||||||
|
_remoteRenderer.srcObject = null;
|
||||||
_localRenderer.dispose();
|
_localRenderer.dispose();
|
||||||
_remoteRenderer.dispose();
|
_remoteRenderer.dispose();
|
||||||
|
WebRTCService().onCallEnded = null;
|
||||||
|
WebRTCService().onRemoteVideoToggled = null;
|
||||||
|
WebRTCService().dispose();
|
||||||
|
if (CallScreen.currentActiveCallId == widget.callId) {
|
||||||
|
CallScreen.currentActiveCallId = null;
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final initials = widget.callerName.isNotEmpty
|
||||||
|
? widget.callerName.split(RegExp(r'\s+')).take(2).map((p) => p[0].toUpperCase()).join()
|
||||||
|
: '?';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: const Color(0xFF0F0F12),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Основной контент (Видео или Аватар)
|
// 1. Задний фон / Удаленное видео
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: _isAccepted
|
child: _renderersInitialized && _isAccepted && _isRemoteVideoOn && _remoteRenderer.srcObject != null
|
||||||
? RTCVideoView(_remoteRenderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover)
|
? RTCVideoView(
|
||||||
: Center(child: Text(widget.callerName, style: const TextStyle(color: Colors.white, fontSize: 24))),
|
_remoteRenderer,
|
||||||
|
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||||
|
)
|
||||||
|
: _buildWaitingScreen(initials),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Панель управления
|
// 2. Плавающее локальное видео (картинка в картинке) после ответа
|
||||||
|
if (_renderersInitialized && _isAccepted && !_isVideoOff && _localRenderer.srcObject != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 50,
|
top: MediaQuery.of(context).padding.top + 16,
|
||||||
|
right: 16,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Container(
|
||||||
|
width: 110,
|
||||||
|
height: 160,
|
||||||
|
color: Colors.black26,
|
||||||
|
child: RTCVideoView(
|
||||||
|
_localRenderer,
|
||||||
|
mirror: true,
|
||||||
|
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 3. Статус звонка и таймер
|
||||||
|
Positioned(
|
||||||
|
top: MediaQuery.of(context).padding.top + 80,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Row(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
children: [
|
||||||
FloatingActionButton(
|
Text(
|
||||||
backgroundColor: Colors.red,
|
widget.callerName,
|
||||||
onPressed: () {
|
style: const TextStyle(
|
||||||
widget.onHangup();
|
color: Colors.white,
|
||||||
Navigator.of(context).pop();
|
fontSize: 28,
|
||||||
},
|
fontWeight: FontWeight.bold,
|
||||||
child: const Icon(Icons.call_end, color: Colors.white),
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_isAccepted
|
||||||
|
? _formatDuration(_durationSeconds)
|
||||||
|
: (widget.isIncoming ? 'Входящий звонок...' : 'Вызов...'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.7),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
if (widget.isIncoming && !_isAccepted)
|
|
||||||
FloatingActionButton(
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _isAccepted = true);
|
|
||||||
widget.onAccept();
|
|
||||||
},
|
|
||||||
child: const Icon(Icons.call, color: Colors.white),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 4. Панель управления звонком
|
||||||
|
Positioned(
|
||||||
|
bottom: 40,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
child: _buildControlsBar(colorScheme),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildWaitingScreen(String initials) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF1E1E24), Color(0xFF0F0F12)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
if (!_isAccepted)
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _pulseAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Container(
|
||||||
|
width: 140 * _pulseAnimation.value,
|
||||||
|
height: 140 * _pulseAnimation.value,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.blueAccent.withOpacity(1.0 - (_pulseAnimation.value - 1.0) / 0.6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 130,
|
||||||
|
height: 130,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Colors.blueAccent, Colors.indigoAccent],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildControlsBar(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.4),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.08)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
// Кнопка звука
|
||||||
|
_buildCircleButton(
|
||||||
|
icon: _isMuted ? Icons.mic_off : Icons.mic,
|
||||||
|
isActive: !_isMuted,
|
||||||
|
onPressed: _toggleMute,
|
||||||
|
),
|
||||||
|
// Кнопка динамика
|
||||||
|
_buildCircleButton(
|
||||||
|
icon: _isSpeakerOn ? Icons.volume_up : Icons.volume_down,
|
||||||
|
isActive: _isSpeakerOn,
|
||||||
|
onPressed: _toggleSpeaker,
|
||||||
|
),
|
||||||
|
// Кнопка камеры
|
||||||
|
_buildCircleButton(
|
||||||
|
icon: _isVideoOff ? Icons.videocam_off : Icons.videocam,
|
||||||
|
isActive: !_isVideoOff,
|
||||||
|
onPressed: _toggleVideo,
|
||||||
|
),
|
||||||
|
// Поворот камеры
|
||||||
|
_buildCircleButton(
|
||||||
|
icon: Icons.flip_camera_ios,
|
||||||
|
isActive: true,
|
||||||
|
onPressed: _switchCamera,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// Кнопка Отклонить/Принять или Завершить
|
||||||
|
if (widget.isIncoming && !_isAccepted) ...[
|
||||||
|
FloatingActionButton(
|
||||||
|
heroTag: 'decline_btn',
|
||||||
|
backgroundColor: Colors.redAccent,
|
||||||
|
onPressed: _handleDecline,
|
||||||
|
child: const Icon(Icons.call_end, color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
|
FloatingActionButton(
|
||||||
|
heroTag: 'accept_btn',
|
||||||
|
backgroundColor: Colors.greenAccent,
|
||||||
|
onPressed: _handleAccept,
|
||||||
|
child: const Icon(Icons.call, color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
FloatingActionButton(
|
||||||
|
heroTag: 'hangup_btn',
|
||||||
|
backgroundColor: Colors.redAccent,
|
||||||
|
onPressed: _handleHangup,
|
||||||
|
child: const Icon(Icons.call_end, color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCircleButton({
|
||||||
|
required IconData icon,
|
||||||
|
required bool isActive,
|
||||||
|
required VoidCallback onPressed,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: isActive ? Colors.white.withOpacity(0.12) : Colors.red.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(icon, color: isActive ? Colors.white : Colors.redAccent, size: 24),
|
||||||
|
onPressed: onPressed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import '/logic/contact_provider.dart';
|
import '/logic/contact_provider.dart';
|
||||||
|
import 'package:chepuhagram/logic/auth_provider.dart';
|
||||||
import '../../domain/services/api_service.dart';
|
import '../../domain/services/api_service.dart';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
||||||
|
|
@ -841,6 +842,15 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
if (_currentContact.id != 0)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.phone_rounded),
|
||||||
|
tooltip: 'Позвонить',
|
||||||
|
onPressed: () => _initiateCall(context),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -1315,10 +1325,12 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initiateCall(BuildContext context) {
|
void _initiateCall(BuildContext context) {
|
||||||
|
final myUsername = context.read<AuthProvider>().username ?? 'Пользователь';
|
||||||
// Отправляем сигнал на сервер
|
// Отправляем сигнал на сервер
|
||||||
SocketService().sendMessage({
|
SocketService().sendMessage({
|
||||||
"type": "call_init",
|
"type": "call_init",
|
||||||
"receiver_id": widget.contact.id,
|
"receiver_id": widget.contact.id,
|
||||||
|
"caller_username": myUsername,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2948,26 +2960,25 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
Duration offset = now.timeZoneOffset;
|
Duration offset = now.timeZoneOffset;
|
||||||
if (data['type'] == 'call_created') {
|
if (data['type'] == 'call_created') {
|
||||||
final String serverCallId = data['call_id'];
|
final String serverCallId = data['call_id'];
|
||||||
final String targetName = data['receiver_name'] ?? "Пользователь";
|
final String displayName = '${widget.contact.name} ${widget.contact.surname}'.trim();
|
||||||
// Переходим на экран звонка, используя ID от сервера
|
final String targetName = displayName.isNotEmpty ? displayName : widget.contact.username;
|
||||||
|
|
||||||
|
if (CallScreen.currentActiveCallId != null) {
|
||||||
|
print("ChatScreen: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $serverCallId");
|
||||||
|
} else {
|
||||||
|
CallScreen.currentActiveCallId = serverCallId;
|
||||||
navigatorKey.currentState?.push(
|
navigatorKey.currentState?.push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => CallScreen(
|
builder: (_) => CallScreen(
|
||||||
callId: serverCallId,
|
callId: serverCallId,
|
||||||
isIncoming: false, // Мы инициатор
|
isIncoming: false, // Мы инициатор
|
||||||
callerName: targetName,
|
callerName: targetName,
|
||||||
onAccept: () async {},
|
targetUserId: widget.contact.id,
|
||||||
onHangup: () {
|
|
||||||
// Отправляем сигнал отмены на сервер
|
|
||||||
SocketService().sendMessage({
|
|
||||||
"type": "hangup",
|
|
||||||
"call_id": serverCallId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data['type'] == 'all_chat_read') {
|
if (data['type'] == 'all_chat_read') {
|
||||||
final readerId = int.tryParse(data['reader_id']?.toString() ?? '');
|
final readerId = int.tryParse(data['reader_id']?.toString() ?? '');
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
||||||
import '../screens/settings_screen.dart';
|
import '../screens/settings_screen.dart';
|
||||||
import '../screens/new_chat_screen.dart';
|
import '../screens/new_chat_screen.dart';
|
||||||
import '../screens/chat_screen.dart';
|
import '../screens/chat_screen.dart';
|
||||||
|
import 'call_screen.dart';
|
||||||
import 'my_profile_screen.dart';
|
import 'my_profile_screen.dart';
|
||||||
import '/logic/contact_provider.dart';
|
import '/logic/contact_provider.dart';
|
||||||
import '/logic/auth_provider.dart';
|
import '/logic/auth_provider.dart';
|
||||||
|
|
@ -1693,6 +1694,43 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
||||||
|
|
||||||
Future<void> _handleIncomingMessage(dynamic data) async {
|
Future<void> _handleIncomingMessage(dynamic data) async {
|
||||||
if (data is RemoteMessage) {
|
if (data is RemoteMessage) {
|
||||||
|
if (data.data['type'] == 'call_init') {
|
||||||
|
try {
|
||||||
|
final callId = data.data['call_id']?.toString() ?? '';
|
||||||
|
final callerName = data.data['caller_username']?.toString() ?? 'Пользователь';
|
||||||
|
final callerId = int.tryParse(data.data['caller_id']?.toString() ?? '') ?? 0;
|
||||||
|
print("Входящий звонок через FCM в фокусе: callId=$callId");
|
||||||
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
|
await showIncomingCallKit(
|
||||||
|
callId: callId,
|
||||||
|
callerName: callerName,
|
||||||
|
callerId: callerId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
if (context != null) {
|
||||||
|
if (CallScreen.currentActiveCallId != null) {
|
||||||
|
print("ContactsScreen: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $callId");
|
||||||
|
} else {
|
||||||
|
CallScreen.currentActiveCallId = callId;
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => CallScreen(
|
||||||
|
callId: callId,
|
||||||
|
isIncoming: true,
|
||||||
|
callerName: callerName,
|
||||||
|
targetUserId: callerId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("Error showing incoming call kit: $e");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
await _handleFCMMessage(data);
|
await _handleFCMMessage(data);
|
||||||
} else if (data is Map<String, dynamic>) {
|
} else if (data is Map<String, dynamic>) {
|
||||||
print('WebSocket message received in ContactsScreen: $data');
|
print('WebSocket message received in ContactsScreen: $data');
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,14 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
||||||
static const _showAvatarKey = 'privacy_show_avatar';
|
static const _showAvatarKey = 'privacy_show_avatar';
|
||||||
static const _showAboutKey = 'privacy_show_about';
|
static const _showAboutKey = 'privacy_show_about';
|
||||||
static const _showLastOnlineKey = 'privacy_show_last_online';
|
static const _showLastOnlineKey = 'privacy_show_last_online';
|
||||||
|
static const _alwaysRelayCallsKey = 'privacy_always_relay_calls';
|
||||||
|
|
||||||
bool _showEmail = true;
|
bool _showEmail = true;
|
||||||
bool _showPhone = true;
|
bool _showPhone = true;
|
||||||
bool _showAvatar = true;
|
bool _showAvatar = true;
|
||||||
bool _showAbout = true;
|
bool _showAbout = true;
|
||||||
bool _showLastOnline = true;
|
bool _showLastOnline = true;
|
||||||
|
bool _alwaysRelayCalls = false;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -38,6 +40,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
||||||
_showAvatar = prefs.getBool(_showAvatarKey) ?? true;
|
_showAvatar = prefs.getBool(_showAvatarKey) ?? true;
|
||||||
_showAbout = prefs.getBool(_showAboutKey) ?? true;
|
_showAbout = prefs.getBool(_showAboutKey) ?? true;
|
||||||
_showLastOnline = prefs.getBool(_showLastOnlineKey) ?? true;
|
_showLastOnline = prefs.getBool(_showLastOnlineKey) ?? true;
|
||||||
|
_alwaysRelayCalls = prefs.getBool(_alwaysRelayCallsKey) ?? false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +90,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
||||||
await _savePreference(_showAvatarKey, _showAvatar);
|
await _savePreference(_showAvatarKey, _showAvatar);
|
||||||
await _savePreference(_showAboutKey, _showAbout);
|
await _savePreference(_showAboutKey, _showAbout);
|
||||||
await _savePreference(_showLastOnlineKey, _showLastOnline);
|
await _savePreference(_showLastOnlineKey, _showLastOnline);
|
||||||
|
await _savePreference(_alwaysRelayCallsKey, _alwaysRelayCalls);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -145,6 +149,30 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(left: 8.0, bottom: 12),
|
||||||
|
child: Text('Звонки:', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, letterSpacing: 0.5)),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||||||
|
),
|
||||||
|
child: _buildSwitchTile('Всегда направлять звонки через сервер', _alwaysRelayCalls, (v) {
|
||||||
|
setState(() => _alwaysRelayCalls = v);
|
||||||
|
_savePreference(_alwaysRelayCallsKey, v);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(
|
||||||
|
'При включении звонки будут направляться только через TURN сервер. Это скрывает ваш IP-адрес от собеседника, но может ухудшить качество связи.',
|
||||||
|
style: TextStyle(color: colorScheme.outline, fontSize: 12, height: 1.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
|
||||||
|
|
@ -449,20 +449,59 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
||||||
if message_data.get("type") == "call_init":
|
if message_data.get("type") == "call_init":
|
||||||
receiver_id = message_data.get("receiver_id")
|
receiver_id = message_data.get("receiver_id")
|
||||||
call_id = str(uuid.uuid4())
|
call_id = str(uuid.uuid4())
|
||||||
|
caller_username = message_data.get("caller_username")
|
||||||
|
|
||||||
call_data = {
|
call_data = {
|
||||||
"type": "call_init",
|
"type": "call_init",
|
||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
"caller_username": message_data.get("caller_username"),
|
"caller_username": caller_username,
|
||||||
"caller_id": str(user_id)
|
"caller_id": str(user_id)
|
||||||
}
|
}
|
||||||
sent = await manager.send_personal_message(call_data, str(receiver_id))
|
|
||||||
if sent:
|
# 1. Отправляем в WebSocket
|
||||||
|
await manager.send_personal_message(call_data, str(receiver_id))
|
||||||
|
|
||||||
|
# 2. Отправляем высокоприоритетный пуш через FCM
|
||||||
|
try:
|
||||||
|
res = await db.execute(
|
||||||
|
select(models.User.fcm_token)
|
||||||
|
.where(models.User.id == receiver_id)
|
||||||
|
)
|
||||||
|
receiver_fcm = res.scalar()
|
||||||
|
if receiver_fcm:
|
||||||
|
fcm_message = messaging.Message(
|
||||||
|
token=receiver_fcm,
|
||||||
|
data={
|
||||||
|
"type": "call_init",
|
||||||
|
"call_id": call_id,
|
||||||
|
"caller_username": str(caller_username),
|
||||||
|
"caller_id": str(user_id),
|
||||||
|
},
|
||||||
|
android=messaging.AndroidConfig(priority="high"),
|
||||||
|
apns=messaging.APNSConfig(
|
||||||
|
payload=messaging.APNSPayload(
|
||||||
|
aps=messaging.Aps(sound="default", content_available=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
await loop.run_in_executor(None, messaging.send, fcm_message)
|
||||||
|
print(f"DEBUG: FCM Call Push sent to user {receiver_id}")
|
||||||
|
except Exception as ex:
|
||||||
|
print(f"DEBUG: Error sending FCM Call Push: {ex}")
|
||||||
|
|
||||||
|
# 3. Подтверждаем инициатору
|
||||||
await manager.send_personal_message({"type": "call_created", "call_id": call_id}, str(user_id))
|
await manager.send_personal_message({"type": "call_created", "call_id": call_id}, str(user_id))
|
||||||
|
|
||||||
elif message_data.get("type") in ["offer", "answer", "ice_candidate", "hangup", "decline", "call_accepted"]:
|
elif message_data.get("type") in ["offer", "answer", "ice_candidate", "video_toggle"]:
|
||||||
receiver_id = message_data.get("receiver_id")
|
receiver_id = message_data.get("receiver_id")
|
||||||
await manager.send_personal_message(message_data, str(receiver_id))
|
await manager.send_personal_message(message_data, str(receiver_id))
|
||||||
|
|
||||||
|
elif message_data.get("type") in ["hangup", "decline", "call_accepted"]:
|
||||||
|
receiver_id = message_data.get("receiver_id")
|
||||||
|
await manager.send_personal_message(message_data, str(receiver_id))
|
||||||
|
await manager.send_personal_message(message_data, str(user_id))
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue