feat: звонки 1:1 (аудио) — WebRTC + ws2-сигналинг
Реализованы голосовые звонки 1:1 поверх инфраструктуры VK/OK
(flutter_webrtc + сигналинг ws2 wss://videowebrtc.okcdn.ru). Протокол
восстановлен реверсом захваченных пакетов и декомпиляцией
ru.ok.android.externcalls.sdk.
Ядро (lib/core/calls/):
- conversation_params.dart — декодер vcp (LZ4-блок), с unit-тестом
- ws2_signaling.dart — клиент сигналинга ws2 (transmit-data, ICE,
accept-call, hangup, change-media-settings, ping/pong)
- call_session.dart — WebRTC-контроллер для звонящего и вызываемого
- call_controller.dart — глобальный оркестратор: пуш opcode 137,
исходящие, приём/отклонение, управление активной сессией
Бэкенд:
- CallsModule.initiateCall — инициация исходящего (opcode 78)
- общий LZ4-block декодер вынесен в core/protocol/lz4_block.dart
(используется и транспортом, и vcp)
UI / обвязка:
- CallScreen переписан под живой CallSession (+ кнопка «свернуть»
без завершения звонка, возврат повторным нажатием 📞)
- кнопка звонка в шапке диалога
- инициализация CallController на логине, показ входящего по пушу
- дев-тумблер: принудительный сигнал состояния микрофона без
изменения реального аудиотрека
Прочее:
- зависимость flutter_webrtc ^0.12.5
- Android: RECORD_AUDIO / MODIFY_AUDIO_SETTINGS / BLUETOOTH
This commit is contained in:
@@ -2,22 +2,31 @@ import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' show Helper;
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/calls/call_controller.dart';
|
||||
import '../../../core/calls/call_session.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
|
||||
enum CallScreenState { incoming, outgoing, active }
|
||||
|
||||
/// Экран звонка. Управляется живым [CallSession].
|
||||
///
|
||||
/// Открывается в одном из режимов:
|
||||
/// - исходящий/активный: передан [session] (уже запущен);
|
||||
/// - входящий: передан [incoming] — показываем «принять/отклонить», сессия
|
||||
/// создаётся при принятии.
|
||||
class CallScreen extends StatefulWidget {
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
final CallScreenState initialState;
|
||||
final CallSession? session;
|
||||
final IncomingCall? incoming;
|
||||
|
||||
const CallScreen({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.avatarUrl,
|
||||
this.initialState = CallScreenState.incoming,
|
||||
this.session,
|
||||
this.incoming,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -26,18 +35,21 @@ class CallScreen extends StatefulWidget {
|
||||
|
||||
class _CallScreenState extends State<CallScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late CallScreenState _state;
|
||||
CallSession? _session;
|
||||
StreamSubscription<CallSessionState>? _stateSub;
|
||||
CallSessionState _state = CallSessionState.connecting;
|
||||
bool _incomingPending = false;
|
||||
|
||||
Timer? _timer;
|
||||
int _seconds = 0;
|
||||
bool _isMuted = false;
|
||||
bool _isSpeaker = false;
|
||||
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_state = widget.initialState;
|
||||
_pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
@@ -45,46 +57,88 @@ class _CallScreenState extends State<CallScreen>
|
||||
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||
);
|
||||
if (_state == CallScreenState.outgoing) {
|
||||
_startOutgoingTimer();
|
||||
|
||||
_incomingPending = widget.session == null && widget.incoming != null;
|
||||
if (widget.session != null) _bind(widget.session!);
|
||||
}
|
||||
|
||||
void _bind(CallSession session) {
|
||||
_session = session;
|
||||
_state = session.currentState;
|
||||
_stateSub = session.stateStream.listen(_onState);
|
||||
if (_state == CallSessionState.active) _startActiveTimer();
|
||||
}
|
||||
|
||||
void _onState(CallSessionState state) {
|
||||
if (!mounted) return;
|
||||
setState(() => _state = state);
|
||||
if (state == CallSessionState.active) {
|
||||
_startActiveTimer();
|
||||
} else if (state == CallSessionState.ended) {
|
||||
_close();
|
||||
}
|
||||
}
|
||||
|
||||
void _startOutgoingTimer() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _seconds++);
|
||||
if (_seconds >= 3 && _state == CallScreenState.outgoing) {
|
||||
_timer?.cancel();
|
||||
setState(() => _state = CallScreenState.active);
|
||||
_startActiveTimer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startActiveTimer() {
|
||||
_seconds = 0;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
_timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _seconds++);
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
void _accept() {
|
||||
Future<void> _accept() async {
|
||||
final incoming = widget.incoming;
|
||||
if (incoming == null) return;
|
||||
setState(() {
|
||||
_state = CallScreenState.active;
|
||||
_seconds = 0;
|
||||
_incomingPending = false;
|
||||
_state = CallSessionState.connecting;
|
||||
});
|
||||
_startActiveTimer();
|
||||
try {
|
||||
final session = await CallController.instance.acceptIncoming(incoming);
|
||||
if (!mounted) return;
|
||||
_bind(session);
|
||||
} catch (_) {
|
||||
_close();
|
||||
}
|
||||
}
|
||||
|
||||
void _endCall() {
|
||||
Future<void> _decline() async {
|
||||
final incoming = widget.incoming;
|
||||
if (incoming != null) {
|
||||
await CallController.instance.rejectIncoming(incoming);
|
||||
}
|
||||
_close();
|
||||
}
|
||||
|
||||
Future<void> _hangup() async {
|
||||
final session = _session;
|
||||
if (session != null) {
|
||||
await session.hangup();
|
||||
}
|
||||
_close();
|
||||
}
|
||||
|
||||
void _close() {
|
||||
if (!mounted) return;
|
||||
_timer?.cancel();
|
||||
Navigator.pop(context);
|
||||
Navigator.of(context).maybePop();
|
||||
}
|
||||
|
||||
Future<void> _toggleMute() async {
|
||||
final next = !_isMuted;
|
||||
setState(() => _isMuted = next);
|
||||
await _session?.setMuted(next);
|
||||
}
|
||||
|
||||
Future<void> _toggleSpeaker() async {
|
||||
final next = !_isSpeaker;
|
||||
setState(() => _isSpeaker = next);
|
||||
await Helper.setSpeakerphoneOn(next);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stateSub?.cancel();
|
||||
_timer?.cancel();
|
||||
_pulseController.dispose();
|
||||
super.dispose();
|
||||
@@ -97,33 +151,55 @@ class _CallScreenState extends State<CallScreen>
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0E0E14),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
child: Stack(
|
||||
children: [
|
||||
const Spacer(flex: 3),
|
||||
_buildAvatar(screenH),
|
||||
const SizedBox(height: 24),
|
||||
_buildName(),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatus(),
|
||||
const Spacer(flex: 2),
|
||||
_buildActions(),
|
||||
const SizedBox(height: 48),
|
||||
Column(
|
||||
children: [
|
||||
const Spacer(flex: 3),
|
||||
_buildAvatar(screenH),
|
||||
const SizedBox(height: 24),
|
||||
_buildName(),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatus(),
|
||||
const Spacer(flex: 2),
|
||||
_buildActions(),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Symbols.arrow_back,
|
||||
color: Colors.white,
|
||||
weight: 400,
|
||||
),
|
||||
tooltip: 'Свернуть',
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _isRinging =>
|
||||
_incomingPending ||
|
||||
_state == CallSessionState.connecting ||
|
||||
_state == CallSessionState.ringing;
|
||||
|
||||
Widget _buildAvatar(double screenH) {
|
||||
final size = screenH * 0.18;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isRinging = _state == CallScreenState.incoming;
|
||||
final isOutgoing = _state == CallScreenState.outgoing;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
final scale = (isRinging || isOutgoing) ? _pulseAnimation.value : 1.0;
|
||||
final scale = _isRinging ? _pulseAnimation.value : 1.0;
|
||||
return Transform.scale(scale: scale, child: child);
|
||||
},
|
||||
child: Container(
|
||||
@@ -189,13 +265,22 @@ class _CallScreenState extends State<CallScreen>
|
||||
Widget _buildStatus() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
String text;
|
||||
switch (_state) {
|
||||
case CallScreenState.incoming:
|
||||
text = 'Входящий звонок';
|
||||
case CallScreenState.outgoing:
|
||||
text = 'Вызов...';
|
||||
case CallScreenState.active:
|
||||
text = formatSecondsMmSs(_seconds, padMinutes: true);
|
||||
if (_incomingPending) {
|
||||
text = 'Входящий звонок';
|
||||
} else {
|
||||
switch (_state) {
|
||||
case CallSessionState.connecting:
|
||||
text = 'Соединение…';
|
||||
case CallSessionState.ringing:
|
||||
text = 'Вызов…';
|
||||
case CallSessionState.active:
|
||||
text = formatSecondsMmSs(
|
||||
_session?.elapsedSeconds ?? 0,
|
||||
padMinutes: true,
|
||||
);
|
||||
case CallSessionState.ended:
|
||||
text = 'Звонок завершён';
|
||||
}
|
||||
}
|
||||
return Text(
|
||||
text,
|
||||
@@ -208,14 +293,9 @@ class _CallScreenState extends State<CallScreen>
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
switch (_state) {
|
||||
case CallScreenState.incoming:
|
||||
return _buildIncomingActions();
|
||||
case CallScreenState.outgoing:
|
||||
return _buildOutgoingActions();
|
||||
case CallScreenState.active:
|
||||
return _buildActiveActions();
|
||||
}
|
||||
if (_incomingPending) return _buildIncomingActions();
|
||||
if (_state == CallSessionState.active) return _buildActiveActions();
|
||||
return _buildOutgoingActions();
|
||||
}
|
||||
|
||||
Widget _buildIncomingActions() {
|
||||
@@ -226,7 +306,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
icon: Symbols.phone_disabled,
|
||||
label: 'Отклонить',
|
||||
color: const Color(0xFFBA1A1A),
|
||||
onTap: _endCall,
|
||||
onTap: _decline,
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
_ActionButton(
|
||||
@@ -247,7 +327,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
icon: Symbols.phone_disabled,
|
||||
label: 'Отмена',
|
||||
color: const Color(0xFFBA1A1A),
|
||||
onTap: _endCall,
|
||||
onTap: _hangup,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -262,13 +342,13 @@ class _CallScreenState extends State<CallScreen>
|
||||
_CircleActionButton(
|
||||
icon: _isMuted ? Symbols.mic_off : Symbols.mic,
|
||||
active: _isMuted,
|
||||
onTap: () => setState(() => _isMuted = !_isMuted),
|
||||
onTap: _toggleMute,
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
_CircleActionButton(
|
||||
icon: _isMuted ? Symbols.volume_off : Symbols.volume_up,
|
||||
icon: _isSpeaker ? Symbols.volume_up : Symbols.volume_down,
|
||||
active: _isSpeaker,
|
||||
onTap: () => setState(() => _isSpeaker = !_isSpeaker),
|
||||
onTap: _toggleSpeaker,
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
_CircleActionButton(
|
||||
@@ -283,7 +363,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
icon: Symbols.phone_disabled,
|
||||
label: 'Завершить',
|
||||
color: const Color(0xFFBA1A1A),
|
||||
onTap: _endCall,
|
||||
onTap: _hangup,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -19,6 +19,8 @@ import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../../backend/modules/messages.dart';
|
||||
import '../../../core/calls/call_controller.dart';
|
||||
import '../calls/call_screen.dart';
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
@@ -656,6 +658,46 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _startCall() async {
|
||||
if (widget.chatType != 'DIALOG') {
|
||||
showCustomNotification(context, 'Звонки доступны только в диалогах');
|
||||
return;
|
||||
}
|
||||
// Звонок уже идёт (возможно, свёрнут) — просто открываем его экран снова.
|
||||
final active = CallController.instance.activeSession;
|
||||
if (active != null) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
name: widget.name,
|
||||
avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null,
|
||||
session: active,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final peerId = widget.chatId ^ _myId;
|
||||
if (peerId <= 0) return;
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
final session = await CallController.instance.startOutgoing(peerId);
|
||||
if (!mounted) return;
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
name: widget.name,
|
||||
avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null,
|
||||
session: session,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Не удалось начать звонок');
|
||||
}
|
||||
}
|
||||
|
||||
void _recomputeHeaderStatus() {
|
||||
_headerStatusNotifier.value = _headerStatus();
|
||||
}
|
||||
@@ -1194,7 +1236,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.call, weight: 400),
|
||||
onPressed: () {},
|
||||
onPressed: _startCall,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.more_vert, weight: 400),
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/login_success_screen.dart';
|
||||
import '../calls/call_screen.dart';
|
||||
import '../../../core/calls/call_controller.dart';
|
||||
import '../digital_id/digital_id_web_screen.dart';
|
||||
|
||||
class DebugMenuScreen extends StatefulWidget {
|
||||
@@ -36,6 +37,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
final Map<String, String> _errors = {};
|
||||
int _cacheSize = 0;
|
||||
bool _clearingCache = false;
|
||||
bool _micSignalOn = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -43,6 +45,18 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
_loadCacheSize();
|
||||
}
|
||||
|
||||
Future<void> _sendMicSignal(bool enabled) async {
|
||||
setState(() => _micSignalOn = enabled);
|
||||
final sent = await CallController.instance.sendMicSignal(enabled);
|
||||
if (!mounted) return;
|
||||
showCustomNotification(
|
||||
context,
|
||||
sent
|
||||
? 'Сигнал микрофона: ${enabled ? 'ВКЛ' : 'ВЫКЛ'} отправлен'
|
||||
: 'Нет активного звонка',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadCacheSize() async {
|
||||
final size = await MediaCache.currentSize();
|
||||
if (mounted) setState(() => _cacheSize = size);
|
||||
@@ -956,54 +970,46 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DebugCallButton(
|
||||
label: 'Экран звонка (превью)',
|
||||
icon: Symbols.phone,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const CallScreen(name: 'Кирил Г.'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _DebugCallButton(
|
||||
label: 'Входящий',
|
||||
icon: Symbols.call_received,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const CallScreen(
|
||||
name: 'Кирил Г.',
|
||||
initialState: CallScreenState.incoming,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Сигнал микрофона (тест)',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Шлёт change-media-settings в активный звонок, '
|
||||
'не меняя реальный микрофон',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _DebugCallButton(
|
||||
label: 'Исходящий',
|
||||
icon: Symbols.call_made,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const CallScreen(
|
||||
name: 'Кирил Г.',
|
||||
initialState: CallScreenState.outgoing,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _DebugCallButton(
|
||||
label: 'Активный',
|
||||
icon: Symbols.phone_in_talk,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const CallScreen(
|
||||
name: 'Кирил Г.',
|
||||
initialState: CallScreenState.active,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _micSignalOn,
|
||||
onChanged: _sendMicSignal,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user