diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart index ba99160..fb7d7e7 100644 --- a/lib/core/calls/call_session.dart +++ b/lib/core/calls/call_session.dart @@ -87,6 +87,8 @@ class CallSession { Future _tail = Future.value(); final Map _participants = {}; + final Map _participantStreams = {}; + final _participantStreamUpdates = StreamController.broadcast(); String? _topology; List _iceServers = const []; @@ -99,7 +101,9 @@ class CallSession { MediaStream? _screenStream; RTCRtpSender? _videoSender; RTCRtpSender? _screenSender; - bool _fastScreenShare = false; + + Completer? _gatherReady; + bool _gotConnection = false; bool _reconnecting = false; bool _iceRestarting = false; @@ -148,6 +152,13 @@ class CallSession { List get participants => _participants.values.toList(growable: false); + Map get participantStreams => + Map.unmodifiable(_participantStreams); + + Stream get participantStreamUpdates => _participantStreamUpdates.stream; + + MediaStream? streamOf(int participantId) => _participantStreams[participantId]; + int get participantCount => _participants.length; bool isSpeaking(int id) => _speaking.contains(id); @@ -217,6 +228,13 @@ class CallSession { signaling.done.then((_) => _onSignalingLost()); await signaling.connect(); logger.i('[call] signaling connected to ${ws2Config.uri.host}'); + Timer(const Duration(seconds: 10), () { + if (_ended || _gotConnection) return; + logger.w( + '[call] ws2 молчит 10 с: нотификация "connection" не пришла — ' + 'конференция закрыта или токен протух', + ); + }); } void _onSignalingLost() { @@ -299,6 +317,7 @@ class CallSession { _accepted = false; _mediaConnected = false; _sfuSessionId = null; + await _clearParticipantStreams(); for (final track in _localStream?.getTracks() ?? []) { try { @@ -370,6 +389,8 @@ class CallSession { } Future _onNotification(Map msg) async { + final name = msg['notification'] ?? msg['response'] ?? msg['type']; + logger.i('[call] ws2 <- $name'); if (msg['type'] == 'error') { _onWs2Error(msg); return; @@ -668,6 +689,7 @@ class CallSession { } Future _onConnection(Map msg) async { + _gotConnection = true; logger.i('[call] connection notification received'); final convParams = msg['conversationParams']; final conversation = msg['conversation']; @@ -896,8 +918,12 @@ class CallSession { logger.i( '[call][sfu] allocate-consumer camera=$_localVideo screen=$_localScreen', ); - await _signaling?.allocateConsumer(); - _fastScreenShare = true; + try { + final reply = await _signaling?.allocateConsumer(); + logger.i('[call][sfu] allocate-consumer reply: $reply'); + } catch (e) { + logger.w('[call][sfu] allocate-consumer failed: $e'); + } } Future _rebuildSfuPc() async { @@ -909,6 +935,7 @@ class CallSession { _screenSender = null; _remoteDescSet = false; _pendingCandidates.clear(); + await _clearParticipantStreams(); for (final track in _localStream?.getTracks() ?? []) { try { @@ -955,6 +982,10 @@ class CallSession { } Future _onProducerUpdated(Map msg) async { + logger.i( + '[call][sfu] producer-updated fields=${msg.keys.toList()} ' + 'sessionId=${msg['sessionId']}', + ); if (_pc == null) return; final session = msg['sessionId']; @@ -988,6 +1019,7 @@ class CallSession { 'ssrcs=${ssrcs.length}, candidates=${_countCandidates(sdp)} ' '(${_candidateTypes(sdp)}), ${_sdpSummary(sdp)}, ice=${_iceServerUrls()}', ); + logger.i('[call][sfu] producer m-lines: ${_mLineDetails(sdp)}'); await pc.setRemoteDescription(RTCSessionDescription(sdp, type)); _remoteDescSet = true; await _flushCandidates(); @@ -1001,6 +1033,12 @@ class CallSession { return; } + await _awaitReflexiveCandidates(pc); + if (_pc != pc) { + logger.w('[call][sfu] peer connection replaced while gathering'); + return; + } + RTCSessionDescription? local; try { local = await pc.getLocalDescription(); @@ -1015,11 +1053,15 @@ class CallSession { '(${_candidateTypes(answerSdp)}), ${_sdpSummary(answerSdp)}, ' 'gathering=${pc.iceGatheringState}', ); + logger.i('[call][sfu] answer m-lines: ${_mLineDetails(answerSdp)}'); + await _logSenders(); try { + final localSsrcs = _extractSsrcs(answerSdp); + logger.i('[call][sfu] accept-producer ssrcs=$localSsrcs'); final reply = await _signaling?.acceptProducer( - description: answerSdp, - ssrcs: ssrcs, + description: _labelLocalTracks(answerSdp), + ssrcs: localSsrcs, sessionId: _sfuSessionId, ); logger.i('[call][sfu] accept-producer reply: $reply'); @@ -1031,28 +1073,10 @@ class CallSession { if (_pc == pc && !_ended) unawaited(_dumpIceStats(pc)); }); - if (_wantVideo) await _publishCamera(); if (_accepted) await _sendMediaSettings(); unawaited(_collectReceivers()); } - Future _publishCamera() async { - try { - await _signaling?.changeSimulcast( - mediaSource: 'CAMERA', - layers: const [ - { - 'rid': 'h', - 'width': 1280, - 'height': 720, - 'fps': 30, - 'bitrateKbps': 2000, - }, - ], - ); - } catch (_) {} - } - int _countCandidates(String sdp) => RegExp(r'^a=candidate:', multiLine: true).allMatches(sdp).length; @@ -1160,6 +1184,46 @@ class CallSession { int _mLines(String sdp) => RegExp(r'^m=', multiLine: true).allMatches(sdp).length; + String _mLineDetails(String sdp) { + final rows = []; + String? kind; + String? port; + String? mid; + String? dir; + String? msid; + + void flush() { + if (kind == null) return; + rows.add( + '[$kind:$port mid=${mid ?? '?'} ${dir ?? '?'} msid=${msid ?? '-'}]', + ); + } + + for (var line in sdp.split('\n')) { + line = line.trim(); + if (line.startsWith('m=')) { + flush(); + final parts = line.substring(2).split(' '); + kind = parts.isEmpty ? '?' : parts.first; + port = parts.length > 1 ? parts[1] : '?'; + mid = null; + dir = null; + msid = null; + } else if (line.startsWith('a=mid:')) { + mid = line.substring(6); + } else if (line == 'a=sendrecv' || + line == 'a=recvonly' || + line == 'a=sendonly' || + line == 'a=inactive') { + dir = line.substring(2); + } else if (line.startsWith('a=msid:')) { + msid = line.substring(7); + } + } + flush(); + return rows.join(' '); + } + List _extractSsrcs(String sdp) { final set = {}; for (final m in RegExp(r'a=ssrc:(\d+)', multiLine: true).allMatches(sdp)) { @@ -1169,6 +1233,35 @@ class CallSession { return set.toList(); } + String _labelLocalTracks(String sdp) { + final self = 'u${ws2Config.userId}'; + final names = {}; + final camera = _videoSender?.track?.id; + final screen = _screenSender?.track?.id; + if (camera != null && camera.isNotEmpty) names[camera] = '$self:sCAMERA'; + if (screen != null && screen.isNotEmpty) names[screen] = '$self:sSCREEN'; + if (names.isEmpty) return sdp; + + var out = sdp; + for (final entry in names.entries) { + final id = RegExp.escape(entry.key); + final name = entry.value; + out = out.replaceAllMapped( + RegExp('^a=msid:(\\S+) $id\\s*\$', multiLine: true), + (m) => 'a=msid:${m[1]} $name', + ); + out = out.replaceAllMapped( + RegExp('^(a=ssrc:\\d+ msid:\\S+) $id\\s*\$', multiLine: true), + (m) => '${m[1]} $name', + ); + out = out.replaceAllMapped( + RegExp('^(a=ssrc:\\d+ label:)$id\\s*\$', multiLine: true), + (m) => '${m[1]}$name', + ); + } + return out; + } + String _videoDir(String sdp) { var inVideo = false; String? mline; @@ -1191,8 +1284,10 @@ class CallSession { Future _onRemoteTrack(RTCTrackEvent event) async { logger.t( - '[call] remote track: ${event.track.kind} streams=${event.streams.length}', + '[call] remote track: ${event.track.kind} id=${event.track.id} ' + 'streams=${event.streams.length}', ); + await _bindParticipantTrack(event.track); if (event.streams.isNotEmpty) { _remoteStreamRef = event.streams.first; _remoteStream.add(event.streams.first); @@ -1201,6 +1296,35 @@ class CallSession { } } + int? _participantFromTrackId(String? trackId) { + if (trackId == null) return null; + for (final prefix in const ['video-', 'audio-']) { + if (trackId.length > prefix.length && trackId.startsWith(prefix)) { + final parsed = _participantIdFrom(trackId.substring(prefix.length)); + if (parsed != null) return parsed; + } + } + return null; + } + + Future _bindParticipantTrack(MediaStreamTrack track) async { + final id = _participantFromTrackId(track.id); + if (id == null || id == ws2Config.userId) return; + var stream = _participantStreams[id]; + if (stream == null) { + stream = await createLocalMediaStream('komet_p$id'); + _participantStreams[id] = stream; + } + if (stream.getTracks().any((t) => t.id == track.id)) return; + try { + await stream.addTrack(track); + } catch (_) { + return; + } + logger.t('[call] track ${track.id} -> participant $id'); + if (!_participantStreamUpdates.isClosed) _participantStreamUpdates.add(id); + } + Future _pushRemoteTrack(MediaStreamTrack track) async { var stream = _remoteStreamRef; if (stream == null) { @@ -1216,6 +1340,26 @@ class CallSession { _remoteStream.add(stream); } + Future _logSenders() async { + final pc = _pc; + if (pc == null) return; + try { + final rows = []; + for (final tr in await pc.getTransceivers()) { + final sent = tr.sender.track; + final received = tr.receiver.track; + rows.add( + '[mid=${tr.mid} dir=${await tr.getCurrentDirection()} ' + 'send=${sent == null ? '-' : '${sent.kind}:${sent.id}'} ' + 'recv=${received == null ? '-' : '${received.kind}:${received.id}'}]', + ); + } + logger.i('[call][sfu] transceivers: ${rows.join(' ')}'); + } catch (e) { + logger.w('[call][sfu] transceiver dump failed: $e'); + } + } + Future _collectReceivers() async { final pc = _pc; if (pc == null) return; @@ -1223,7 +1367,8 @@ class CallSession { for (final tr in await pc.getTransceivers()) { final track = tr.receiver.track; if (track != null) { - logger.t('[call] receiver track: ${track.kind}'); + logger.t('[call] receiver track: ${track.kind} id=${track.id}'); + await _bindParticipantTrack(track); await _pushRemoteTrack(track); } } @@ -1244,7 +1389,7 @@ class CallSession { participantType: _peerType, deviceIdx: _peerDeviceIdx, type: offer.type!, - sdp: sdp, + sdp: _labelLocalTracks(sdp), ); } @@ -1319,7 +1464,7 @@ class CallSession { participantType: _peerType, deviceIdx: _peerDeviceIdx, type: answer.type!, - sdp: answer.sdp!, + sdp: _labelLocalTracks(answer.sdp!), ); } if (_current == CallSessionState.connecting) { @@ -1360,7 +1505,42 @@ class CallSession { } } + static bool _isReflexive(String? line) => + line != null && + (line.contains(' typ srflx') || line.contains(' typ relay')); + + Future _awaitReflexiveCandidates( + RTCPeerConnection pc, { + Duration timeout = const Duration(seconds: 4), + }) async { + if (pc.iceGatheringState == + RTCIceGatheringState.RTCIceGatheringStateComplete) { + return; + } + try { + final current = await pc.getLocalDescription(); + if (_isReflexive(current?.sdp)) return; + } catch (_) {} + + final ready = Completer(); + _gatherReady = ready; + try { + await ready.future.timeout(timeout); + logger.i('[call][sfu] reflexive candidate gathered'); + } catch (_) { + logger.w('[call][sfu] no reflexive candidate within $timeout'); + } finally { + _gatherReady = null; + } + } + void _onLocalCandidate(RTCIceCandidate candidate) { + final pending = _gatherReady; + if (pending != null && + !pending.isCompleted && + _isReflexive(candidate.candidate)) { + pending.complete(); + } if (_topology == 'SERVER') return; final peerId = _peerId; if (peerId == null || candidate.candidate == null) return; @@ -1409,7 +1589,6 @@ class CallSession { isAudioEnabled: !_muted, isVideoEnabled: _localVideo, isScreenSharingEnabled: _localScreen, - isFastScreenSharingEnabled: _fastScreenShare ? _localScreen : null, ); } @@ -1504,6 +1683,7 @@ class CallSession { } logger.i('[call] screen share published, topology=$_topology'); + await _renegotiate(); await _sendMediaSettings(); _notifyInfo(); } @@ -1551,6 +1731,16 @@ class CallSession { } } + Future _clearParticipantStreams() async { + final streams = _participantStreams.values.toList(growable: false); + _participantStreams.clear(); + for (final stream in streams) { + try { + await stream.dispose(); + } catch (_) {} + } + } + Future _disposeStream(MediaStream? stream) async { if (stream == null) return; for (final track in stream.getTracks()) { @@ -1607,7 +1797,11 @@ class CallSession { await _remoteStreamRef?.dispose(); } catch (_) {} } + await _clearParticipantStreams(); await _signaling?.close(); + if (!_participantStreamUpdates.isClosed) { + await _participantStreamUpdates.close(); + } if (!_state.isClosed) await _state.close(); if (!_remoteStream.isClosed) await _remoteStream.close(); if (!_info.isClosed) await _info.close(); diff --git a/lib/core/calls/ws2_signaling.dart b/lib/core/calls/ws2_signaling.dart index 308ec14..1916a86 100644 --- a/lib/core/calls/ws2_signaling.dart +++ b/lib/core/calls/ws2_signaling.dart @@ -20,7 +20,7 @@ class Ws2Config { const Ws2Config({required this.uri, required this.userId}); - static const _defaultCapabilities = '3c03f'; + static const defaultCapabilities = '3c03f'; static const _appVersion = 'sdk-0.1.16.4'; /// Входящий звонок: из распакованных параметров [ConversationParams]. @@ -28,7 +28,7 @@ class Ws2Config { factory Ws2Config.fromVcp( ConversationParams params, { required String conversationId, - String capabilities = _defaultCapabilities, + String capabilities = defaultCapabilities, String device = 'Komet', String osVersion = '36', }) { @@ -56,7 +56,7 @@ class Ws2Config { factory Ws2Config.fromEndpoint( String endpoint, { required int userId, - String capabilities = _defaultCapabilities, + String capabilities = defaultCapabilities, String device = 'Komet', }) { final base = Uri.parse(endpoint); @@ -170,7 +170,7 @@ class Ws2Signaling { required String sdp, String participantType = 'USER', int deviceIdx = 0, - String capabilities = '1', + String capabilities = Ws2Config.defaultCapabilities, }) { return sendCommand( 'transmit-data', @@ -270,7 +270,7 @@ class Ws2Signaling { Future hangup({String reason = 'HUNGUP'}) => sendCommand('hangup', extra: {'reason': reason}); - Future allocateConsumer() => sendCommand( + Future> allocateConsumer() => sendCommand( 'allocate-consumer', extra: const { 'capabilities': { @@ -313,14 +313,6 @@ class Ws2Signaling { }, ); - Future changeSimulcast({ - String mediaSource = 'CAMERA', - required List> layers, - }) => sendCommand( - 'change-simulcast', - extra: {'mediaSource': mediaSource, 'layers': layers}, - ); - Future close() async { await _notifSub?.cancel(); _notifSub = null; diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index c005998..dd06efd 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' show cos, pi; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' @@ -20,6 +21,7 @@ import '../../../core/calls/call_controller.dart'; import '../../../core/calls/call_info.dart'; import '../../../core/calls/call_session.dart'; import '../../../core/utils/format.dart'; +import '../../../core/utils/logger.dart'; import '../../../l10n/app_localizations.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; @@ -319,10 +321,19 @@ class _CallScreenState extends State with TickerProviderStateMixin { await _session?.setMuted(next); } + static bool get _hasSpeakerphone => + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + Future _toggleSpeaker() async { final next = !_isSpeaker; setState(() => _isSpeaker = next); - await Helper.setSpeakerphoneOn(next); + if (!_hasSpeakerphone) return; + try { + await Helper.setSpeakerphoneOn(next); + } catch (e) { + logger.w('[call] setSpeakerphoneOn недоступен: $e'); + } } bool _videoBusy = false;