506 lines
15 KiB
Dart
506 lines
15 KiB
Dart
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 int targetUserId;
|
||
final bool startAccepted;
|
||
|
||
static String? currentActiveCallId;
|
||
|
||
const CallScreen({
|
||
super.key,
|
||
required this.callId,
|
||
required this.isIncoming,
|
||
required this.callerName,
|
||
required this.targetUserId,
|
||
this.startAccepted = false,
|
||
});
|
||
|
||
@override
|
||
State<CallScreen> createState() => _CallScreenState();
|
||
}
|
||
|
||
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();
|
||
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: const Color(0xFF0F0F12),
|
||
body: Stack(
|
||
children: [
|
||
// 1. Задний фон / Удаленное видео
|
||
Positioned.fill(
|
||
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(
|
||
top: MediaQuery.of(context).padding.top + 80,
|
||
left: 0,
|
||
right: 0,
|
||
child: Column(
|
||
children: [
|
||
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,
|
||
),
|
||
);
|
||
}
|
||
} |