Merge branch 'feature/FullStack' of https://github.com/KometTeam/Komet into feature/FullStack
This commit is contained in:
@@ -17,11 +17,16 @@ class IncomingCall {
|
|||||||
final bool isVideo;
|
final bool isVideo;
|
||||||
final ConversationParams params;
|
final ConversationParams params;
|
||||||
|
|
||||||
|
final String? country;
|
||||||
|
final bool? isContact;
|
||||||
|
|
||||||
const IncomingCall({
|
const IncomingCall({
|
||||||
required this.conversationId,
|
required this.conversationId,
|
||||||
required this.callerId,
|
required this.callerId,
|
||||||
required this.isVideo,
|
required this.isVideo,
|
||||||
required this.params,
|
required this.params,
|
||||||
|
this.country,
|
||||||
|
this.isContact,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +85,8 @@ class CallController {
|
|||||||
callerId: callerId,
|
callerId: callerId,
|
||||||
isVideo: payload['type'] == 'VIDEO',
|
isVideo: payload['type'] == 'VIDEO',
|
||||||
params: params,
|
params: params,
|
||||||
|
country: payload['country'] as String?,
|
||||||
|
isContact: payload['isContact'] as bool?,
|
||||||
);
|
);
|
||||||
_pending = incoming;
|
_pending = incoming;
|
||||||
_incoming.add(incoming);
|
_incoming.add(incoming);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class CallInfo {
|
||||||
|
String? conversationId;
|
||||||
|
String? topology;
|
||||||
|
|
||||||
|
String? peerPlatform;
|
||||||
|
String? peerEngine;
|
||||||
|
|
||||||
|
String? peerIp;
|
||||||
|
String? peerNetwork;
|
||||||
|
String? path;
|
||||||
|
|
||||||
|
String? audioCodec;
|
||||||
|
bool record = false;
|
||||||
|
bool denoise = false;
|
||||||
|
bool animoji = false;
|
||||||
|
|
||||||
|
String? region;
|
||||||
|
String? dtlsFingerprint;
|
||||||
|
final List<String> stun = [];
|
||||||
|
final List<String> turn = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class CallParse {
|
||||||
|
static Map<String, String> candidate(String c) {
|
||||||
|
final parts = c.trim().split(RegExp(r'\s+'));
|
||||||
|
String? at(int i) => (i >= 0 && i < parts.length) ? parts[i] : null;
|
||||||
|
int idx(String k) => parts.indexOf(k);
|
||||||
|
final r = <String, String>{};
|
||||||
|
final tr = at(2);
|
||||||
|
if (tr != null) r['transport'] = tr.toUpperCase();
|
||||||
|
final ip = at(4);
|
||||||
|
if (ip != null) r['ip'] = ip;
|
||||||
|
final port = at(5);
|
||||||
|
if (port != null) r['port'] = port;
|
||||||
|
final typ = idx('typ');
|
||||||
|
if (typ != -1) r['type'] = at(typ + 1) ?? '';
|
||||||
|
final nc = idx('network-cost');
|
||||||
|
if (nc != -1) r['cost'] = at(nc + 1) ?? '';
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String networkLabel(String? cost) {
|
||||||
|
switch (cost) {
|
||||||
|
case '0':
|
||||||
|
return 'VPN';
|
||||||
|
case '10':
|
||||||
|
return 'Wi-Fi / Ethernet';
|
||||||
|
case '50':
|
||||||
|
return 'неизвестно';
|
||||||
|
case '900':
|
||||||
|
case '999':
|
||||||
|
return 'сотовая';
|
||||||
|
default:
|
||||||
|
return (cost == null || cost.isEmpty) ? '—' : 'cost=$cost';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String engine(String sdp) {
|
||||||
|
for (final line in const LineSplitter().convert(sdp)) {
|
||||||
|
if (!line.startsWith('o=')) continue;
|
||||||
|
final l = line.toLowerCase();
|
||||||
|
if (l.contains('mozilla') || l.contains('sdparta')) return 'Firefox (web)';
|
||||||
|
if (l.contains('gstreamer')) return 'GStreamer';
|
||||||
|
return 'нативный libwebrtc';
|
||||||
|
}
|
||||||
|
return 'неизвестно';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? audioCodec(String sdp) {
|
||||||
|
for (final line in const LineSplitter().convert(sdp)) {
|
||||||
|
if (line.startsWith('a=rtpmap:') && line.toLowerCase().contains('opus')) {
|
||||||
|
final i = line.indexOf(' ');
|
||||||
|
if (i != -1) return line.substring(i + 1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? fingerprint(String sdp) {
|
||||||
|
for (final line in const LineSplitter().convert(sdp)) {
|
||||||
|
if (line.startsWith('a=fingerprint:')) {
|
||||||
|
return line.substring('a=fingerprint:'.length).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool hasAnimoji(String sdp) => sdp.contains('animoji');
|
||||||
|
|
||||||
|
static bool isServerIp(String ip) => ip.startsWith('155.212.');
|
||||||
|
|
||||||
|
static String pathLabel(String? localType, String? remoteType) {
|
||||||
|
final relay = localType == 'relay' || remoteType == 'relay';
|
||||||
|
final via = relay ? 'через сервер (TURN)' : 'прямое (P2P)';
|
||||||
|
String t(String? v) => v ?? '?';
|
||||||
|
return '$via · ${t(localType)} ↔ ${t(remoteType)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show TargetPlatform, defaultTargetPlatform;
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
|
||||||
|
import '../utils/logger.dart';
|
||||||
|
import 'call_info.dart';
|
||||||
import 'conversation_params.dart';
|
import 'conversation_params.dart';
|
||||||
import 'ws2_signaling.dart';
|
import 'ws2_signaling.dart';
|
||||||
|
|
||||||
@@ -9,21 +13,9 @@ enum CallRole { caller, callee }
|
|||||||
|
|
||||||
enum CallSessionState { connecting, ringing, active, ended }
|
enum CallSessionState { connecting, ringing, active, ended }
|
||||||
|
|
||||||
/// Один сеанс 1:1 аудиозвонка: связывает сигналинг [Ws2Signaling] с
|
|
||||||
/// `RTCPeerConnection`.
|
|
||||||
///
|
|
||||||
/// Поток (подтверждён захватом `docs/ws2_capture.log` для звонящего и
|
|
||||||
/// реконструирован из `ru.ok.android.externcalls.sdk` для вызываемого):
|
|
||||||
/// - сервер шлёт `connection` → берём ICE-сервера и id собеседника;
|
|
||||||
/// - звонящий: createOffer → `transmit-data`(offer); ждёт `transmitted-data`(answer);
|
|
||||||
/// - вызываемый: `transmitted-data`(offer) → createAnswer → `transmit-data`(answer);
|
|
||||||
/// - обе стороны: ICE-кандидаты через `transmit-data`, приём — через `transmitted-data`;
|
|
||||||
/// - вызываемый по тапу «принять» шлёт `accept-call`.
|
|
||||||
class CallSession {
|
class CallSession {
|
||||||
final Ws2Config ws2Config;
|
final Ws2Config ws2Config;
|
||||||
|
|
||||||
/// Параметры из `vcp` (входящий звонок) — резервный источник ICE-серверов,
|
|
||||||
/// если их нет в пуше `connection`. Для исходящего может быть `null`.
|
|
||||||
final ConversationParams? params;
|
final ConversationParams? params;
|
||||||
final CallRole role;
|
final CallRole role;
|
||||||
|
|
||||||
@@ -36,6 +28,7 @@ class CallSession {
|
|||||||
Ws2Signaling? _signaling;
|
Ws2Signaling? _signaling;
|
||||||
RTCPeerConnection? _pc;
|
RTCPeerConnection? _pc;
|
||||||
MediaStream? _localStream;
|
MediaStream? _localStream;
|
||||||
|
MediaStream? _remoteStreamRef;
|
||||||
|
|
||||||
int? _peerId;
|
int? _peerId;
|
||||||
String _peerType = 'USER';
|
String _peerType = 'USER';
|
||||||
@@ -43,22 +36,32 @@ class CallSession {
|
|||||||
|
|
||||||
bool _muted = false;
|
bool _muted = false;
|
||||||
bool _accepted = false;
|
bool _accepted = false;
|
||||||
|
bool _peerMuted = false;
|
||||||
|
bool _peerVideo = false;
|
||||||
|
bool _mediaConnected = false;
|
||||||
|
|
||||||
|
final CallInfo info = CallInfo();
|
||||||
|
|
||||||
final _state = StreamController<CallSessionState>.broadcast();
|
final _state = StreamController<CallSessionState>.broadcast();
|
||||||
final _remoteStream = StreamController<MediaStream>.broadcast();
|
final _remoteStream = StreamController<MediaStream>.broadcast();
|
||||||
|
final _info = StreamController<void>.broadcast();
|
||||||
|
|
||||||
Stream<CallSessionState> get stateStream => _state.stream;
|
Stream<CallSessionState> get stateStream => _state.stream;
|
||||||
Stream<MediaStream> get remoteStreamStream => _remoteStream.stream;
|
Stream<MediaStream> get remoteStreamStream => _remoteStream.stream;
|
||||||
|
MediaStream? get remoteStream => _remoteStreamRef;
|
||||||
|
|
||||||
|
Stream<void> get infoUpdates => _info.stream;
|
||||||
|
|
||||||
bool get isMuted => _muted;
|
bool get isMuted => _muted;
|
||||||
|
bool get peerMuted => _peerMuted;
|
||||||
|
bool get peerVideo => _peerVideo;
|
||||||
|
bool get mediaConnected => _mediaConnected;
|
||||||
|
|
||||||
CallSessionState _current = CallSessionState.connecting;
|
CallSessionState _current = CallSessionState.connecting;
|
||||||
DateTime? _activeSince;
|
DateTime? _activeSince;
|
||||||
|
|
||||||
/// Текущее состояние (для переоткрытия свёрнутого экрана —
|
|
||||||
/// broadcast-поток не отдаёт последнее значение новым слушателям).
|
|
||||||
CallSessionState get currentState => _current;
|
CallSessionState get currentState => _current;
|
||||||
|
|
||||||
/// Длительность разговора в секундах (0, пока не активен).
|
|
||||||
int get elapsedSeconds =>
|
int get elapsedSeconds =>
|
||||||
_activeSince == null ? 0 : DateTime.now().difference(_activeSince!).inSeconds;
|
_activeSince == null ? 0 : DateTime.now().difference(_activeSince!).inSeconds;
|
||||||
|
|
||||||
@@ -69,8 +72,13 @@ class CallSession {
|
|||||||
_state.add(s);
|
_state.add(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _notifyInfo() {
|
||||||
|
if (!_info.isClosed) _info.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
_setState(CallSessionState.connecting);
|
_setState(CallSessionState.connecting);
|
||||||
|
info.region = ws2Config.uri.host;
|
||||||
final signaling = Ws2Signaling(ws2Config);
|
final signaling = Ws2Signaling(ws2Config);
|
||||||
_signaling = signaling;
|
_signaling = signaling;
|
||||||
signaling.notifications.listen(_onNotification, onError: (_) => _end());
|
signaling.notifications.listen(_onNotification, onError: (_) => _end());
|
||||||
@@ -79,6 +87,7 @@ class CallSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onNotification(Map<String, dynamic> msg) async {
|
Future<void> _onNotification(Map<String, dynamic> msg) async {
|
||||||
|
_applyPeerMedia(msg);
|
||||||
switch (msg['notification']) {
|
switch (msg['notification']) {
|
||||||
case 'connection':
|
case 'connection':
|
||||||
await _onConnection(msg);
|
await _onConnection(msg);
|
||||||
@@ -89,6 +98,9 @@ class CallSession {
|
|||||||
case 'accepted-call':
|
case 'accepted-call':
|
||||||
_setState(CallSessionState.active);
|
_setState(CallSessionState.active);
|
||||||
break;
|
break;
|
||||||
|
case 'registered-peer':
|
||||||
|
_applyRegisteredPeer(msg);
|
||||||
|
break;
|
||||||
case 'closed-conversation':
|
case 'closed-conversation':
|
||||||
_end();
|
_end();
|
||||||
break;
|
break;
|
||||||
@@ -102,6 +114,9 @@ class CallSession {
|
|||||||
final iceServers =
|
final iceServers =
|
||||||
_iceServersFrom(convParams) ?? params?.iceServers ?? const [];
|
_iceServersFrom(convParams) ?? params?.iceServers ?? const [];
|
||||||
_resolvePeer(conversation);
|
_resolvePeer(conversation);
|
||||||
|
_applyConnectionInfo(msg, iceServers);
|
||||||
|
|
||||||
|
logger.t('[call] connection — role=$role peer=$_peerId');
|
||||||
|
|
||||||
final pc = await createPeerConnection({
|
final pc = await createPeerConnection({
|
||||||
'iceServers': iceServers,
|
'iceServers': iceServers,
|
||||||
@@ -117,11 +132,24 @@ class CallSession {
|
|||||||
await pc.addTrack(track, _localStream!);
|
await pc.addTrack(track, _localStream!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await pc.addTransceiver(
|
||||||
|
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
||||||
|
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
|
||||||
|
);
|
||||||
|
|
||||||
pc.onIceCandidate = _onLocalCandidate;
|
pc.onIceCandidate = _onLocalCandidate;
|
||||||
pc.onTrack = (event) {
|
pc.onTrack = (event) => unawaited(_onRemoteTrack(event));
|
||||||
if (event.streams.isNotEmpty) _remoteStream.add(event.streams.first);
|
|
||||||
};
|
|
||||||
pc.onConnectionState = (s) {
|
pc.onConnectionState = (s) {
|
||||||
|
final connected =
|
||||||
|
s == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
|
||||||
|
if (connected != _mediaConnected) {
|
||||||
|
_mediaConnected = connected;
|
||||||
|
_notifyInfo();
|
||||||
|
if (connected) {
|
||||||
|
unawaited(_resolvePath());
|
||||||
|
unawaited(_collectReceivers());
|
||||||
|
}
|
||||||
|
}
|
||||||
if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
||||||
s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
|
s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
|
||||||
_end();
|
_end();
|
||||||
@@ -134,22 +162,142 @@ class CallSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _videoDir(String sdp) {
|
||||||
|
var inVideo = false;
|
||||||
|
String? mline;
|
||||||
|
var dir = '?';
|
||||||
|
for (var line in sdp.split('\n')) {
|
||||||
|
line = line.trim();
|
||||||
|
if (line.startsWith('m=')) {
|
||||||
|
inVideo = line.startsWith('m=video');
|
||||||
|
if (inVideo) mline = line;
|
||||||
|
} else if (inVideo &&
|
||||||
|
(line == 'a=sendrecv' ||
|
||||||
|
line == 'a=recvonly' ||
|
||||||
|
line == 'a=sendonly' ||
|
||||||
|
line == 'a=inactive')) {
|
||||||
|
dir = line.substring(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mline == null ? 'НЕТ m=video' : '$mline -> $dir';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRemoteTrack(RTCTrackEvent event) async {
|
||||||
|
logger.t(
|
||||||
|
'[call] remote track: ${event.track.kind} streams=${event.streams.length}');
|
||||||
|
if (event.streams.isNotEmpty) {
|
||||||
|
_remoteStreamRef = event.streams.first;
|
||||||
|
_remoteStream.add(event.streams.first);
|
||||||
|
} else {
|
||||||
|
await _collectReceivers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushRemoteTrack(MediaStreamTrack track) async {
|
||||||
|
var stream = _remoteStreamRef;
|
||||||
|
stream ??= await createLocalMediaStream('komet_remote');
|
||||||
|
_remoteStreamRef = stream;
|
||||||
|
if (!stream.getTracks().any((t) => t.id == track.id)) {
|
||||||
|
try {
|
||||||
|
await stream.addTrack(track);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
_remoteStream.add(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _collectReceivers() async {
|
||||||
|
final pc = _pc;
|
||||||
|
if (pc == null) return;
|
||||||
|
try {
|
||||||
|
for (final tr in await pc.getTransceivers()) {
|
||||||
|
final track = tr.receiver.track;
|
||||||
|
if (track != null) {
|
||||||
|
logger.t('[call] receiver track: ${track.kind}');
|
||||||
|
await _pushRemoteTrack(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _createAndSendOffer() async {
|
Future<void> _createAndSendOffer() async {
|
||||||
final pc = _pc;
|
final pc = _pc;
|
||||||
final peerId = _peerId;
|
final peerId = _peerId;
|
||||||
if (pc == null || peerId == null) return;
|
if (pc == null || peerId == null) return;
|
||||||
|
|
||||||
final offer = await pc.createOffer({});
|
final offer = await pc.createOffer({});
|
||||||
await pc.setLocalDescription(offer);
|
final raw = offer.sdp ?? '';
|
||||||
|
final sdp = _isDesktop ? _forceVp8(raw) : raw;
|
||||||
|
await pc.setLocalDescription(RTCSessionDescription(sdp, offer.type));
|
||||||
|
logger.t('[call] our offer video: ${_videoDir(sdp)}');
|
||||||
await _signaling?.transmitSdp(
|
await _signaling?.transmitSdp(
|
||||||
participantId: peerId,
|
participantId: peerId,
|
||||||
participantType: _peerType,
|
participantType: _peerType,
|
||||||
deviceIdx: _peerDeviceIdx,
|
deviceIdx: _peerDeviceIdx,
|
||||||
type: offer.type!,
|
type: offer.type!,
|
||||||
sdp: offer.sdp!,
|
sdp: sdp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _isDesktop =>
|
||||||
|
defaultTargetPlatform == TargetPlatform.linux ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.windows ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.macOS;
|
||||||
|
|
||||||
|
String _forceVp8(String sdp) {
|
||||||
|
final lines = sdp.split('\r\n');
|
||||||
|
var mIdx = -1;
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].startsWith('m=video')) {
|
||||||
|
mIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mIdx == -1) return sdp;
|
||||||
|
|
||||||
|
String? vp8;
|
||||||
|
for (final l in lines) {
|
||||||
|
final m = RegExp(r'^a=rtpmap:(\d+) VP8/90000').firstMatch(l);
|
||||||
|
if (m != null) {
|
||||||
|
vp8 = m.group(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vp8 == null) return sdp;
|
||||||
|
|
||||||
|
String? rtx;
|
||||||
|
for (final l in lines) {
|
||||||
|
final m = RegExp('^a=fmtp:(\\d+) apt=$vp8\$').firstMatch(l);
|
||||||
|
if (m != null) {
|
||||||
|
rtx = m.group(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final keep = {vp8, ?rtx};
|
||||||
|
final parts = lines[mIdx].split(' ');
|
||||||
|
if (parts.length <= 3) return sdp;
|
||||||
|
lines[mIdx] = [...parts.sublist(0, 3), ...keep].join(' ');
|
||||||
|
|
||||||
|
var end = lines.length;
|
||||||
|
for (var i = mIdx + 1; i < lines.length; i++) {
|
||||||
|
if (lines[i].startsWith('m=')) {
|
||||||
|
end = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final ptLine = RegExp(r'^a=(?:rtpmap|fmtp|rtcp-fb):(\d+)');
|
||||||
|
final result = <String>[];
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
if (i > mIdx && i < end) {
|
||||||
|
final m = ptLine.firstMatch(lines[i]);
|
||||||
|
if (m != null && !keep.contains(m.group(1))) continue;
|
||||||
|
}
|
||||||
|
result.add(lines[i]);
|
||||||
|
}
|
||||||
|
return result.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onTransmittedData(Map<String, dynamic> msg) async {
|
Future<void> _onTransmittedData(Map<String, dynamic> msg) async {
|
||||||
final pc = _pc;
|
final pc = _pc;
|
||||||
if (pc == null) return;
|
if (pc == null) return;
|
||||||
@@ -163,12 +311,22 @@ class CallSession {
|
|||||||
final desc = sdp['sdp'] as String?;
|
final desc = sdp['sdp'] as String?;
|
||||||
if (type == null || desc == null) return;
|
if (type == null || desc == null) return;
|
||||||
|
|
||||||
|
_applyRemoteSdp(desc);
|
||||||
|
logger.t('[call] remote $type video: ${_videoDir(desc)}');
|
||||||
|
|
||||||
|
if (type == 'answer' &&
|
||||||
|
pc.signalingState !=
|
||||||
|
RTCSignalingState.RTCSignalingStateHaveLocalOffer) {
|
||||||
|
logger.t('[call] extra answer ignored (state=${pc.signalingState})');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await pc.setRemoteDescription(RTCSessionDescription(desc, type));
|
await pc.setRemoteDescription(RTCSessionDescription(desc, type));
|
||||||
|
|
||||||
if (type == 'offer') {
|
if (type == 'offer') {
|
||||||
// Сторона вызываемого: отвечаем answer.
|
|
||||||
final answer = await pc.createAnswer({});
|
final answer = await pc.createAnswer({});
|
||||||
await pc.setLocalDescription(answer);
|
await pc.setLocalDescription(answer);
|
||||||
|
logger.t('[call] our answer video: ${_videoDir(answer.sdp ?? '')}');
|
||||||
final peerId = _peerId;
|
final peerId = _peerId;
|
||||||
if (peerId != null) {
|
if (peerId != null) {
|
||||||
await _signaling?.transmitSdp(
|
await _signaling?.transmitSdp(
|
||||||
@@ -183,11 +341,13 @@ class CallSession {
|
|||||||
_setState(CallSessionState.ringing);
|
_setState(CallSessionState.ringing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
unawaited(_collectReceivers());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final candidate = data['candidate'];
|
final candidate = data['candidate'];
|
||||||
if (candidate is Map) {
|
if (candidate is Map) {
|
||||||
|
_applyRemoteCandidate(candidate['candidate']);
|
||||||
await pc.addCandidate(RTCIceCandidate(
|
await pc.addCandidate(RTCIceCandidate(
|
||||||
candidate['candidate'] as String?,
|
candidate['candidate'] as String?,
|
||||||
candidate['sdpMid'] as String?,
|
candidate['sdpMid'] as String?,
|
||||||
@@ -209,17 +369,15 @@ class CallSession {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Принять входящий звонок (сторона вызываемого).
|
|
||||||
Future<void> accept() async {
|
Future<void> accept() async {
|
||||||
if (_accepted) return;
|
if (_accepted) return;
|
||||||
_accepted = true;
|
_accepted = true;
|
||||||
|
logger.t('[call] accepted');
|
||||||
await _signaling?.acceptCall();
|
await _signaling?.acceptCall();
|
||||||
await _signaling?.changeMediaSettings(isAudioEnabled: !_muted);
|
await _signaling?.changeMediaSettings(isAudioEnabled: !_muted);
|
||||||
_setState(CallSessionState.active);
|
_setState(CallSessionState.active);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DEBUG: отправить серверу сигнал `change-media-settings` с заданным
|
|
||||||
/// состоянием микрофона, НЕ трогая реальный аудиотрек.
|
|
||||||
Future<void> sendAudioEnabledSignal(bool enabled) async {
|
Future<void> sendAudioEnabledSignal(bool enabled) async {
|
||||||
await _signaling?.changeMediaSettings(isAudioEnabled: enabled);
|
await _signaling?.changeMediaSettings(isAudioEnabled: enabled);
|
||||||
}
|
}
|
||||||
@@ -256,6 +414,124 @@ class CallSession {
|
|||||||
await _signaling?.close();
|
await _signaling?.close();
|
||||||
if (!_state.isClosed) await _state.close();
|
if (!_state.isClosed) await _state.close();
|
||||||
if (!_remoteStream.isClosed) await _remoteStream.close();
|
if (!_remoteStream.isClosed) await _remoteStream.close();
|
||||||
|
if (!_info.isClosed) await _info.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyConnectionInfo(Map<String, dynamic> msg, List iceServers) {
|
||||||
|
final conv = msg['conversation'];
|
||||||
|
if (conv is Map) {
|
||||||
|
info.conversationId = conv['id']?.toString();
|
||||||
|
info.topology = conv['topology']?.toString();
|
||||||
|
final features = conv['features'];
|
||||||
|
if (features is List) info.record = features.contains('RECORD');
|
||||||
|
final parts = conv['participants'];
|
||||||
|
if (parts is List) {
|
||||||
|
for (final p in parts.whereType<Map>()) {
|
||||||
|
if (p['id'] != ws2Config.userId) {
|
||||||
|
final ms = p['mediaSettings'];
|
||||||
|
if (ms is Map) {
|
||||||
|
_peerMuted = ms['isAudioEnabled'] != true;
|
||||||
|
_peerVideo = ms['isVideoEnabled'] == true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final mm = msg['mediaModifiers'];
|
||||||
|
if (mm is Map) {
|
||||||
|
info.denoise = mm['denoise'] == true || mm['denoiseAnn'] == true;
|
||||||
|
}
|
||||||
|
info.stun.clear();
|
||||||
|
info.turn.clear();
|
||||||
|
for (final s in iceServers.whereType<Map>()) {
|
||||||
|
final urls = s['urls'];
|
||||||
|
final list = urls is List ? urls : [urls];
|
||||||
|
for (final u in list) {
|
||||||
|
final str = u.toString();
|
||||||
|
if (str.startsWith('stun')) {
|
||||||
|
info.stun.add(str);
|
||||||
|
} else if (str.startsWith('turn')) {
|
||||||
|
info.turn.add(str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_notifyInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyPeerMedia(Map<String, dynamic> msg) {
|
||||||
|
final ms = msg['mediaSettings'];
|
||||||
|
if (ms is! Map) return;
|
||||||
|
final pid = msg['participantId'];
|
||||||
|
if (_peerId != null && pid != null && pid != _peerId) return;
|
||||||
|
|
||||||
|
final muted = ms['isAudioEnabled'] != true;
|
||||||
|
final video = ms['isVideoEnabled'] == true;
|
||||||
|
if (muted != _peerMuted || video != _peerVideo) {
|
||||||
|
_peerMuted = muted;
|
||||||
|
_peerVideo = video;
|
||||||
|
_notifyInfo();
|
||||||
|
if (video) unawaited(_collectReceivers());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyRegisteredPeer(Map<String, dynamic> msg) {
|
||||||
|
final peer = msg['peerId'];
|
||||||
|
if (peer is Map && peer['type'] == 'WEB_TRANSPORT') return;
|
||||||
|
final platform = msg['platform'];
|
||||||
|
if (platform is String && platform.isNotEmpty) {
|
||||||
|
info.peerPlatform = platform;
|
||||||
|
_notifyInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyRemoteSdp(String sdp) {
|
||||||
|
info.peerEngine = CallParse.engine(sdp);
|
||||||
|
info.audioCodec ??= CallParse.audioCodec(sdp);
|
||||||
|
info.dtlsFingerprint ??= CallParse.fingerprint(sdp);
|
||||||
|
if (CallParse.hasAnimoji(sdp)) info.animoji = true;
|
||||||
|
_notifyInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyRemoteCandidate(Object? raw) {
|
||||||
|
if (raw is! String || raw.isEmpty) return;
|
||||||
|
final c = CallParse.candidate(raw);
|
||||||
|
final type = c['type'];
|
||||||
|
final ip = c['ip'];
|
||||||
|
if (ip == null) return;
|
||||||
|
if ((type == 'srflx' || type == 'host') && !CallParse.isServerIp(ip)) {
|
||||||
|
info.peerIp = ip;
|
||||||
|
info.peerNetwork = CallParse.networkLabel(c['cost']);
|
||||||
|
_notifyInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resolvePath() async {
|
||||||
|
final pc = _pc;
|
||||||
|
if (pc == null) return;
|
||||||
|
try {
|
||||||
|
final stats = await pc.getStats();
|
||||||
|
final byId = {for (final r in stats) r.id: r};
|
||||||
|
StatsReport? pair;
|
||||||
|
StatsReport? anySucceeded;
|
||||||
|
for (final r in stats) {
|
||||||
|
if (r.type != 'candidate-pair') continue;
|
||||||
|
if (r.values['state'] != 'succeeded') continue;
|
||||||
|
anySucceeded ??= r;
|
||||||
|
if (r.values['nominated'] == true || r.values['selected'] == true) {
|
||||||
|
pair = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pair ??= anySucceeded;
|
||||||
|
if (pair == null) return;
|
||||||
|
final local = byId[pair.values['localCandidateId']];
|
||||||
|
final remote = byId[pair.values['remoteCandidateId']];
|
||||||
|
info.path = CallParse.pathLabel(
|
||||||
|
local?.values['candidateType']?.toString(),
|
||||||
|
remote?.values['candidateType']?.toString(),
|
||||||
|
);
|
||||||
|
_notifyInfo();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resolvePeer(Object? conversation) {
|
void _resolvePeer(Object? conversation) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../utils/logger.dart';
|
||||||
import 'conversation_params.dart';
|
import 'conversation_params.dart';
|
||||||
|
|
||||||
/// Параметры подключения к сигналинг-сокету ws2.
|
/// Параметры подключения к сигналинг-сокету ws2.
|
||||||
@@ -145,6 +146,11 @@ class Ws2Signaling {
|
|||||||
}
|
}
|
||||||
if (decoded is! Map<String, dynamic>) return;
|
if (decoded is! Map<String, dynamic>) return;
|
||||||
|
|
||||||
|
final label =
|
||||||
|
decoded['notification'] ?? decoded['response'] ?? decoded['type'];
|
||||||
|
logger.t('[ws2] ← $label');
|
||||||
|
logger.t(decoded);
|
||||||
|
|
||||||
final type = decoded['type'];
|
final type = decoded['type'];
|
||||||
if (type == 'response') {
|
if (type == 'response') {
|
||||||
final seq = decoded['sequence'];
|
final seq = decoded['sequence'];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+5
-19
@@ -303,29 +303,15 @@ class KometAppState extends State<KometApp>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onIncomingCall(IncomingCall call) async {
|
Future<void> _onIncomingCall(IncomingCall call) async {
|
||||||
String name = 'Входящий звонок';
|
|
||||||
String? avatar;
|
|
||||||
try {
|
|
||||||
final profile = await AppDatabase.loadActiveProfile();
|
|
||||||
if (profile != null) {
|
|
||||||
final contacts = await ContactsModule.getContacts(profile.id);
|
|
||||||
for (final c in contacts) {
|
|
||||||
if (c.id == call.callerId) {
|
|
||||||
final full = '${c.firstName} ${c.lastName ?? ''}'.trim();
|
|
||||||
if (full.isNotEmpty) name = full;
|
|
||||||
avatar = c.baseUrl;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
final navState = KometApp.navigatorKey.currentState;
|
final navState = KometApp.navigatorKey.currentState;
|
||||||
if (navState == null) return;
|
if (navState == null) return;
|
||||||
navState.push(
|
navState.push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) =>
|
builder: (_) => CallScreen(
|
||||||
CallScreen(name: name, avatarUrl: avatar, incoming: call),
|
name: ContactCache.get(call.callerId) ?? '',
|
||||||
|
avatarUrl: ContactCache.getAvatar(call.callerId),
|
||||||
|
incoming: call,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -785,10 +785,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1174,10 +1174,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.11"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user