22-06-2026+19-07

This commit is contained in:
Artur 2026-06-22 19:07:21 +05:00
parent 3c93e4148e
commit d116fbde43
9 changed files with 1118 additions and 149 deletions

View File

@ -1,5 +1,10 @@
class AppConstants {
//static const baseUrl = '192.168.0.180:8000';
static const baseUrl = 'https://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';
}

View File

@ -1,12 +1,15 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;
import 'package:web_socket_channel/io.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/main.dart';
import 'package:chepuhagram/presentation/screens/call_screen.dart';
class SocketService with WidgetsBindingObserver {
static final SocketService _instance = SocketService._internal();
@ -14,6 +17,7 @@ class SocketService with WidgetsBindingObserver {
SocketService._internal() {
WidgetsBinding.instance.addObserver(this);
_initMessageListener();
}
WebSocketChannel? _channel;
@ -36,8 +40,47 @@ class SocketService with WidgetsBindingObserver {
void _initMessageListener() {
messages.listen((data) {
if (data['type'] == 'call_accepted') {
WebRTCService().handleOffer(data['call_id'], data['sdp']);
final type = data['type'];
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);
}
});
}

View File

@ -1,104 +1,379 @@
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-серверов (Google STUN)
// Конфигурация 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,
},
],
};
/// Инициализация PeerConnection
Future<void> initPeerConnection(String callId, Function(MediaStream) onRemoteStream) async {
_peerConnection = await createPeerConnection(_config);
/// Локальный стрим (камера + микрофон)
Future<void> startLocalStream(
Function(MediaStream) onLocalStreamCallback, {
bool videoDefaultEnabled = false,
}) async {
try {
_localStream?.getTracks().forEach((track) => track.stop());
_localStream?.dispose();
// Слушаем удаленный поток
_peerConnection!.onAddStream = (stream) {
onRemoteStream(stream);
};
// Отправляем ICE-кандидаты на сервер через SocketService
_peerConnection!.onIceCandidate = (candidate) {
SocketService().sendMessage({
"type": "ice_candidate",
"call_id": callId,
"candidate": candidate.toMap(),
_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;
});
};
// Получаем локальный поток (микрофон + камера)
_localStream = await navigator.mediaDevices.getUserMedia({
'audio': true,
'video': true,
});
// Если PeerConnection уже инициализирован, сразу же добавляем локальные треки
if (_peerConnection != null && !_tracksAdded) {
_localStream!.getTracks().forEach((track) {
_peerConnection!.addTrack(track, _localStream!);
});
_tracksAdded = true;
}
// Добавляем треки в соединение
_localStream!.getTracks().forEach((track) {
_peerConnection!.addTrack(track, _localStream!);
});
onLocalStreamCallback(_localStream!);
} catch (e) {
print("Ошибка получения локального стрима: $e");
}
}
Future<void> handleOffer(String callId, String remoteSdp) async {
// 1. Инициализируем соединение, если оно еще не создано
if (_peerConnection == null) {
await initPeerConnection(callId, (stream) {
// Здесь можно добавить callback для отрисовки видео, если нужно
print("Remote stream received");
});
/// Инициализация 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");
}
}
// 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');
/// Создание Offer (вызывает инициатор звонка)
Future<void> createOffer(String callId) async {
RTCSessionDescription offer = await _peerConnection!.createOffer();
await _peerConnection!.setLocalDescription(offer);
if (!_isReadyForSignaling ||
(needsPeerConnection && _peerConnection == null)) {
_incomingSignalingQueue.add(data);
print(
"WebRTCService: Queueing message: $type (ready: $_isReadyForSignaling, pc: ${_peerConnection != null})",
);
return;
}
SocketService().sendMessage({
"type": "offer",
"call_id": callId,
"sdp": offer.sdp,
});
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();
}
}
}
/// Создание Answer (вызывает получатель звонка)
Future<void> createAnswer(String callId, String remoteSdp) async {
await _peerConnection!.setRemoteDescription(
RTCSessionDescription(remoteSdp, 'offer'),
);
/// Создание 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);
RTCSessionDescription answer = await _peerConnection!.createAnswer();
await _peerConnection!.setLocalDescription(answer);
SocketService().sendMessage({
"type": "offer",
"call_id": callId,
"receiver_id": tId,
"sender_id": _myUserId,
"sdp": offer.sdp,
});
} catch (e) {
print("Ошибка при создании offer: $e");
}
}
SocketService().sendMessage({
"type": "answer",
"call_id": callId,
"sdp": answer.sdp,
});
/// Обработка 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 {
await _peerConnection!.addCandidate(
RTCIceCandidate(
candidateMap['candidate'],
candidateMap['sdpMid'],
candidateMap['sdpMLineIndex'],
),
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;
}
}

View File

@ -1,5 +1,6 @@
import 'package:chepuhagram/data/datasources/ws_client.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/core/theme_manager.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:path_provider/path_provider.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 'dart:convert';
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() {
if (!(Platform.isAndroid || Platform.isIOS)) {
print('Skipping CallKit event listener on non-mobile platform.');
@ -281,10 +333,12 @@ void initCallkitListener() {
FlutterCallkitIncoming.onEvent.listen((event) {
if (event == null) return;
final callerId = int.tryParse(event.body['extra']?['callerId']?.toString() ?? '') ??
int.tryParse(event.body['callerId']?.toString() ?? '') ?? 0;
switch (event.event) {
case Event.actionCallIncoming:
// Звонок получен, но CallKit уже показал экран.
// Здесь можно логировать или обновить статус в БД.
print("Incoming call: ${event.body['id']}");
break;
@ -294,31 +348,76 @@ void initCallkitListener() {
case Event.actionCallAccept:
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ПРИНЯТЬ"
// 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка)
SocketService().sendMessage({
"type": "call_accept",
"call_id": event.body['id'],
});
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;
}
// 2. Переходим на экран звонка
navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (_) => CallScreen(
callId: event.body['id'],
isIncoming: true,
callerName: event.body['nameCaller'] ?? 'Unknown',
onAccept: () {},
onHangup: () => _handleHangupGlobal(event.body['id']),
),
),
);
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. Уведомляем сервер (чтобы другая сторона узнала о начале звонка)
socket.sendMessage({
"type": "call_accepted",
"call_id": event.body['id'],
"receiver_id": callerId,
"sender_id": myId,
});
// 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(
MaterialPageRoute(
builder: (_) => CallScreen(
callId: callId,
isIncoming: true,
callerName: event.body['nameCaller'] ?? 'Unknown',
targetUserId: callerId,
startAccepted: true,
),
),
);
}
});
break;
case Event.actionCallDecline:
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ОТКЛОНИТЬ"
SocketService().sendMessage({
"type": "decline",
"call_id": event.body['id'],
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",
"call_id": event.body['id'],
"receiver_id": callerId,
});
});
break;
@ -504,8 +603,23 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
} catch (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 {
print('Message type is not enc_message: ${message.data['type']}');
print('Message type is not recognized: ${message.data['type']}');
}
}

View File

@ -1,90 +1,506 @@
import 'dart:async';
import 'package:flutter/material.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 {
final String callId;
final bool isIncoming;
final String callerName;
final VoidCallback onAccept;
final VoidCallback onHangup;
final int targetUserId;
final bool startAccepted;
static String? currentActiveCallId;
const CallScreen({
super.key,
required this.callId,
required this.isIncoming,
required this.callerName,
required this.onAccept,
required this.onHangup,
required this.targetUserId,
this.startAccepted = false,
});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
// Рендереры для видеопотока
class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
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
void initState() {
super.initState();
_localRenderer.initialize();
_remoteRenderer.initialize();
CallScreen.currentActiveCallId = widget.callId;
_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
void dispose() {
_stopTimer();
_pulseController.dispose();
_localRenderer.srcObject = null;
_remoteRenderer.srcObject = null;
_localRenderer.dispose();
_remoteRenderer.dispose();
WebRTCService().onCallEnded = null;
WebRTCService().onRemoteVideoToggled = null;
WebRTCService().dispose();
if (CallScreen.currentActiveCallId == widget.callId) {
CallScreen.currentActiveCallId = null;
}
super.dispose();
}
@override
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(
backgroundColor: Colors.black,
backgroundColor: const Color(0xFF0F0F12),
body: Stack(
children: [
// Основной контент (Видео или Аватар)
// 1. Задний фон / Удаленное видео
Positioned.fill(
child: _isAccepted
? RTCVideoView(_remoteRenderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover)
: Center(child: Text(widget.callerName, style: const TextStyle(color: Colors.white, fontSize: 24))),
child: _renderersInitialized && _isAccepted && _isRemoteVideoOn && _remoteRenderer.srcObject != null
? RTCVideoView(
_remoteRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
)
: _buildWaitingScreen(initials),
),
// Панель управления
// 2. Плавающее локальное видео (картинка в картинке) после ответа
if (_renderersInitialized && _isAccepted && !_isVideoOff && _localRenderer.srcObject != null)
Positioned(
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(
bottom: 50,
top: MediaQuery.of(context).padding.top + 80,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
child: Column(
children: [
FloatingActionButton(
backgroundColor: Colors.red,
onPressed: () {
widget.onHangup();
Navigator.of(context).pop();
},
child: const Icon(Icons.call_end, color: Colors.white),
),
if (widget.isIncoming && !_isAccepted)
FloatingActionButton(
backgroundColor: Colors.green,
onPressed: () {
setState(() => _isAccepted = true);
widget.onAccept();
},
child: const Icon(Icons.call, color: Colors.white),
Text(
widget.callerName,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
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,
),
),
],
),
),
// 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,
),
);
}
}

View File

@ -14,6 +14,7 @@ import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'package:provider/provider.dart';
import 'package:flutter/rendering.dart';
import '/logic/contact_provider.dart';
import 'package:chepuhagram/logic/auth_provider.dart';
import '../../domain/services/api_service.dart';
import 'dart:math';
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(
children: [
@ -1315,10 +1325,12 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
}
void _initiateCall(BuildContext context) {
final myUsername = context.read<AuthProvider>().username ?? 'Пользователь';
// Отправляем сигнал на сервер
SocketService().sendMessage({
"type": "call_init",
"receiver_id": widget.contact.id,
"caller_username": myUsername,
});
}
@ -2948,25 +2960,24 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
Duration offset = now.timeZoneOffset;
if (data['type'] == 'call_created') {
final String serverCallId = data['call_id'];
final String targetName = data['receiver_name'] ?? "Пользователь";
// Переходим на экран звонка, используя ID от сервера
navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (_) => CallScreen(
callId: serverCallId,
isIncoming: false, // Мы инициатор
callerName: targetName,
onAccept: () async {},
onHangup: () {
// Отправляем сигнал отмены на сервер
SocketService().sendMessage({
"type": "hangup",
"call_id": serverCallId,
});
},
final String displayName = '${widget.contact.name} ${widget.contact.surname}'.trim();
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(
MaterialPageRoute(
builder: (_) => CallScreen(
callId: serverCallId,
isIncoming: false, // Мы инициатор
callerName: targetName,
targetUserId: widget.contact.id,
),
),
),
);
);
}
}
if (data['type'] == 'all_chat_read') {

View File

@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../screens/settings_screen.dart';
import '../screens/new_chat_screen.dart';
import '../screens/chat_screen.dart';
import 'call_screen.dart';
import 'my_profile_screen.dart';
import '/logic/contact_provider.dart';
import '/logic/auth_provider.dart';
@ -1693,6 +1694,43 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
Future<void> _handleIncomingMessage(dynamic data) async {
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);
} else if (data is Map<String, dynamic>) {
print('WebSocket message received in ContactsScreen: $data');

View File

@ -15,12 +15,14 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
static const _showAvatarKey = 'privacy_show_avatar';
static const _showAboutKey = 'privacy_show_about';
static const _showLastOnlineKey = 'privacy_show_last_online';
static const _alwaysRelayCallsKey = 'privacy_always_relay_calls';
bool _showEmail = true;
bool _showPhone = true;
bool _showAvatar = true;
bool _showAbout = true;
bool _showLastOnline = true;
bool _alwaysRelayCalls = false;
bool _isSaving = false;
@override
@ -38,6 +40,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
_showAvatar = prefs.getBool(_showAvatarKey) ?? true;
_showAbout = prefs.getBool(_showAboutKey) ?? 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(_showAboutKey, _showAbout);
await _savePreference(_showLastOnlineKey, _showLastOnline);
await _savePreference(_alwaysRelayCallsKey, _alwaysRelayCalls);
}
} catch (e) {
if (mounted) {
@ -145,6 +149,30 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
),
),
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(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(

View File

@ -449,20 +449,59 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
if message_data.get("type") == "call_init":
receiver_id = message_data.get("receiver_id")
call_id = str(uuid.uuid4())
caller_username = message_data.get("caller_username")
call_data = {
"type": "call_init",
"call_id": call_id,
"caller_username": message_data.get("caller_username"),
"caller_username": caller_username,
"caller_id": str(user_id)
}
sent = await manager.send_personal_message(call_data, str(receiver_id))
if sent:
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"]:
# 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))
elif message_data.get("type") in ["offer", "answer", "ice_candidate", "video_toggle"]:
receiver_id = message_data.get("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:
pass
finally: