From afbfa33b99f4568644ba8dda26472b264f266e44 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 12 Jun 2026 22:10:59 +0700 Subject: [PATCH] =?UTF-8?q?feat(calls):=20=D0=A2=D1=8F=D0=B6=D0=B5=D0=BB?= =?UTF-8?q?=D0=BE......=20=D0=97=D0=B2=D0=BE=D0=BD=D0=BA=D0=B8.=20=D0=92?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=D1=8E.=20=D0=9F=D0=BE=D1=87=D1=82=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/calls.dart | 90 ++- lib/core/calls/call_controller.dart | 14 + lib/core/calls/call_link.dart | 11 + lib/core/calls/call_session.dart | 633 +++++++++++++++++++- lib/core/calls/ws2_signaling.dart | 58 +- lib/core/protocol/opcode_map.dart | 2 + lib/core/utils/file_log_output.dart | 2 + lib/core/utils/file_log_output_io.dart | 61 ++ lib/core/utils/file_log_output_stub.dart | 14 + lib/core/utils/link_opener.dart | 4 + lib/core/utils/logger.dart | 7 + lib/frontend/screens/calls/call_screen.dart | 459 +++++++++++++- lib/frontend/widgets/call_link_handler.dart | 53 ++ lib/main.dart | 3 + 14 files changed, 1341 insertions(+), 70 deletions(-) create mode 100644 lib/core/calls/call_link.dart create mode 100644 lib/core/utils/file_log_output.dart create mode 100644 lib/core/utils/file_log_output_io.dart create mode 100644 lib/core/utils/file_log_output_stub.dart create mode 100644 lib/frontend/widgets/call_link_handler.dart diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index 5b14ccc..9f4fe5b 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -30,6 +30,20 @@ class OutgoingCallParams { }); } +class CallLinkPreview { + final String? conferenceId; + final String? callName; + final int participantsCount; + final bool isVideo; + + const CallLinkPreview({ + this.conferenceId, + this.callName, + this.participantsCount = 0, + this.isVideo = false, + }); +} + class CallLogEntry { final String id; final int accountId; @@ -63,22 +77,11 @@ class CallsModule { bool isVideo = false, }) async { final conversationId = _uuidV4(); - // Структура подтверждена дампом основного сокета (opcode 78). - final internalParams = jsonEncode({ - 'platform': 'ANDROID', - 'sdkVersion': '0.1.16.4', - 'clientAppKey': 'CGPGAGLGDIHBABABA', - 'deviceId': _api.deviceId ?? '', - 'protocolVersion': 5, - 'onlyAdminCanRecord': false, - 'waitForAdmin': false, - 'capabilities': '3c03f', - }); final response = await _api.sendRequest(Opcode.videoChatStartActive, { 'conversationId': conversationId, 'calleeIds': [calleeId], - 'internalParams': internalParams, + 'internalParams': _internalParams(), 'isVideo': isVideo, }); @@ -111,6 +114,69 @@ class CallsModule { ); } + String _internalParams() => jsonEncode({ + 'platform': 'ANDROID', + 'sdkVersion': '0.1.16.4', + 'clientAppKey': 'CGPGAGLGDIHBABABA', + 'deviceId': _api.deviceId ?? '', + 'protocolVersion': 5, + 'onlyAdminCanRecord': false, + 'waitForAdmin': false, + 'capabilities': '3c03f', + }); + + Future resolveCallLink(String url) async { + final response = await _api.sendRequest(Opcode.linkInfo, {'link': url}); + if (!response.isOk || response.payload is! Map) return null; + + final vc = (response.payload as Map)['videoConference']; + if (vc is! Map) return null; + + return CallLinkPreview( + conferenceId: vc['conferenceId']?.toString(), + callName: (vc['callName'] as String?)?.trim(), + participantsCount: (vc['participantsCount'] as int?) ?? 0, + isVideo: vc['callType'] == 'VIDEO', + ); + } + + Future joinByLink( + String token, { + bool isVideo = false, + }) async { + final response = await _api.sendRequest(Opcode.videoChatJoinByLink, { + 'joinLink': token, + 'internalParams': _internalParams(), + 'isVideo': isVideo, + }); + + if (!response.isOk || response.payload is! Map) { + throw Exception('joinByLink: bad response'); + } + final payload = response.payload as Map; + + final ipRaw = payload['internalParams']; + final ip = ipRaw is String + ? jsonDecode(ipRaw) as Map + : const {}; + + final endpoint = ip['endpoint'] as String?; + if (endpoint == null) { + throw Exception('joinByLink: no endpoint'); + } + + final id = ip['id']; + final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0; + + return OutgoingCallParams( + conversationId: (payload['conversationId'] as String?) ?? '', + endpoint: endpoint, + callsUserId: callsUserId, + peerExternalId: 0, + isVideo: isVideo, + ); + } + static String _uuidV4() { final r = Random(); final b = List.generate(16, (_) => r.nextInt(256)); diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart index 019b867..e67b529 100644 --- a/lib/core/calls/call_controller.dart +++ b/lib/core/calls/call_controller.dart @@ -103,6 +103,20 @@ class CallController { return session; } + Future previewCallLink(String url) => + _calls!.resolveCallLink(url); + + Future joinByLink(String token, {bool isVideo = false}) async { + if (_active != null) throw StateError('уже идёт звонок'); + final params = await _calls!.joinByLink(token, isVideo: isVideo); + final config = + Ws2Config.fromEndpoint(params.endpoint, userId: params.callsUserId); + final session = CallSession(ws2Config: config, role: CallRole.joiner); + _bind(session); + await session.start(); + return session; + } + /// Принять входящий звонок. Future acceptIncoming(IncomingCall call) async { _pending = null; diff --git a/lib/core/calls/call_link.dart b/lib/core/calls/call_link.dart new file mode 100644 index 0000000..4bccb5e --- /dev/null +++ b/lib/core/calls/call_link.dart @@ -0,0 +1,11 @@ +class CallLink { + static final RegExp _pattern = RegExp( + r'^https?://(?:[^/\s]+\.)?max\.ru/joincall/([A-Za-z0-9_-]+)', + caseSensitive: false, + ); + + static bool isCallLink(String url) => token(url) != null; + + static String? token(String url) => + _pattern.firstMatch(url.trim())?.group(1); +} diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart index a3f788d..f26edd3 100644 --- a/lib/core/calls/call_session.dart +++ b/lib/core/calls/call_session.dart @@ -9,10 +9,32 @@ import 'call_info.dart'; import 'conversation_params.dart'; import 'ws2_signaling.dart'; -enum CallRole { caller, callee } +enum CallRole { caller, callee, joiner } enum CallSessionState { connecting, ringing, active, ended } +class CallParticipant { + final int id; + final bool isSelf; + int? externalId; + String state; + bool audioEnabled; + bool videoEnabled; + bool screenSharing; + bool handRaised; + + CallParticipant({ + required this.id, + this.isSelf = false, + this.externalId, + this.state = '', + this.audioEnabled = true, + this.videoEnabled = false, + this.screenSharing = false, + this.handRaised = false, + }); +} + class CallSession { final Ws2Config ws2Config; @@ -44,6 +66,39 @@ class CallSession { final List _pendingCandidates = []; Future _tail = Future.value(); + final Map _participants = {}; + + String? _topology; + List _iceServers = const []; + Object? _sfuSessionId; + Set _speaking = const {}; + + bool _localVideo = false; + bool _localScreen = false; + MediaStream? _localVideoStream; + RTCRtpSender? _videoSender; + + Timer? _levelTimer; + final Map _speakHold = {}; + + static const double _speakLevelOn = 0.05; + static const int _speakHoldTicks = 3; + + bool get localVideo => _localVideo; + bool get localScreen => _localScreen; + MediaStream? get localVideoStream => _localVideoStream; + + List get participants => + _participants.values.toList(growable: false); + + int get participantCount => _participants.length; + + bool isSpeaking(int id) => _speaking.contains(id); + + String? get topology => _topology; + + bool get _wantVideo => params?.isVideo == true; + final CallInfo info = CallInfo(); final _state = StreamController.broadcast(); @@ -88,6 +143,49 @@ class CallSession { signaling.notifications.listen(_enqueue, onError: (_) => _end()); signaling.done.then((_) => _end()); await signaling.connect(); + _levelTimer = Timer.periodic( + const Duration(milliseconds: 300), (_) => unawaited(_sampleLevels())); + } + + Future _sampleLevels() async { + final pc = _pc; + if (pc == null || _ended) return; + + var local = 0.0; + var remote = 0.0; + try { + for (final r in await pc.getStats()) { + final lvl = r.values['audioLevel']; + if (lvl is! num) continue; + final kind = r.values['kind'] ?? r.values['mediaType']; + if (kind != 'audio') continue; + if (r.type == 'media-source') { + local = lvl.toDouble(); + } else if (r.type == 'inbound-rtp') { + final v = lvl.toDouble(); + if (v > remote) remote = v; + } + } + } catch (_) { + return; + } + + final loud = {}; + if (!_muted && local > _speakLevelOn) loud.add(ws2Config.userId); + final others = _participants.values.where((p) => !p.isSelf).toList(); + if (others.length == 1 && remote > _speakLevelOn) loud.add(others.first.id); + + for (final id in loud) { + _speakHold[id] = _speakHoldTicks; + } + _speakHold.updateAll((id, ticks) => loud.contains(id) ? ticks : ticks - 1); + _speakHold.removeWhere((_, ticks) => ticks <= 0); + + final next = _speakHold.keys.toSet(); + if (next.length != _speaking.length || !next.containsAll(_speaking)) { + _speaking = next; + _notifyInfo(); + } } void _enqueue(Map msg) { @@ -95,6 +193,10 @@ class CallSession { } Future _onNotification(Map msg) async { + if (msg['type'] == 'error') { + _onWs2Error(msg); + return; + } _applyPeerMedia(msg); switch (msg['notification']) { case 'connection': @@ -109,67 +211,453 @@ class CallSession { case 'registered-peer': _applyRegisteredPeer(msg); break; + case 'participant-joined': + case 'media-settings-changed': + _onParticipantMedia(msg); + break; + case 'participant-state-changed': + _onParticipantStateChanged(msg); + break; + case 'participants-state-changed': + _onParticipantsStateChanged(msg); + break; + case 'participant-left': + case 'participant-removed': + _onParticipantLeft(msg); + break; + case 'force-media-settings-change': + case 'switch-micro': + _onForcedMedia(msg); + break; + case 'mute-participant': + _onMuteParticipant(msg); + break; + case 'hungup': + _onHungup(msg); + break; + case 'topology-changed': + await _onTopologyChanged(msg); + break; + case 'producer-updated': + await _onProducerUpdated(msg); + break; + case 'session-state': + _onSessionState(msg); + break; case 'closed-conversation': _end(); break; } } + void _onWs2Error(Map msg) { + final err = msg['error']; + logger.t('[call] ws2 error: $err'); + if (err == 'conversation-ended') _end(); + } + + int? _participantIdFrom(Object? raw) { + if (raw is int) return raw; + if (raw is! String) return null; + for (final seg in raw.split(':')) { + if (seg.isEmpty) continue; + final c = seg[0]; + if (c == 'u' || c == 'g') { + final v = int.tryParse(seg.substring(1)); + if (v != null) return v; + } else if (c != 'd') { + final v = int.tryParse(seg); + if (v != null) return v; + } + } + return null; + } + + void _onForcedMedia(Map msg) { + bool? audioOn; + final ms = msg['mediaSettings']; + if (ms is Map && ms['isAudioEnabled'] is bool) { + audioOn = ms['isAudioEnabled'] as bool; + } + final muteStates = msg['muteStates']; + if (muteStates is Map && muteStates['AUDIO'] is String) { + audioOn = muteStates['AUDIO'] == 'UNMUTE'; + } + final mute = msg['mute']; + if (mute is bool) audioOn = !mute; + if (audioOn == null) return; + logger.t('[call] forced media audioEnabled=$audioOn raw=$msg'); + _applyMuted(!audioOn); + } + + void _onMuteParticipant(Map msg) { + final muteStates = msg['muteStates']; + if (muteStates is! Map || muteStates['AUDIO'] is! String) return; + final audioOn = muteStates['AUDIO'] == 'UNMUTE'; + final target = _participantIdFrom(msg['participantId']); + final muteAll = msg['muteAll'] == true; + + if (target != null) { + final p = _participants[target]; + if (p != null) { + p.audioEnabled = audioOn; + _notifyInfo(); + } + } + if (muteAll || target == null || target == ws2Config.userId) { + _applyMuted(!audioOn); + } + } + + void _onHungup(Map msg) { + final raw = msg['participantId'] ?? + (msg['participant'] is Map ? (msg['participant'] as Map)['id'] : null); + if (raw is! int) return; + if (raw == ws2Config.userId) { + _end(); + return; + } + if (_participants.remove(raw) != null) _notifyInfo(); + } + + void _onSessionState(Map msg) { + logger.t( + '[call][sfu] session-state id=${msg['participantId']} connected=${msg['connected']}'); + } + + void _resolveParticipants(Object? conversation) { + if (conversation is! Map) return; + final list = conversation['participants']; + if (list is! List) return; + final seen = {}; + for (final p in list.whereType()) { + final id = p['id']; + if (id is! int) continue; + seen.add(id); + _upsertParticipant( + id, + externalId: _externalId(p['externalId']), + state: p['state'] as String?, + mediaSettings: p['mediaSettings'], + muteStates: p['muteStates'], + ); + } + _participants.removeWhere((key, _) => !seen.contains(key)); + _notifyInfo(); + } + + CallParticipant _upsertParticipant( + int id, { + int? externalId, + String? state, + Object? mediaSettings, + Object? muteStates, + bool? handRaised, + }) { + final p = _participants.putIfAbsent( + id, + () => CallParticipant(id: id, isSelf: id == ws2Config.userId), + ); + if (externalId != null) p.externalId = externalId; + if (state != null) p.state = state; + if (mediaSettings is Map) { + final a = mediaSettings['isAudioEnabled']; + final v = mediaSettings['isVideoEnabled']; + final s = mediaSettings['isScreenSharingEnabled']; + if (a is bool) p.audioEnabled = a; + if (v is bool) p.videoEnabled = v; + if (s is bool) p.screenSharing = s; + } + if (muteStates is Map) { + final a = muteStates['AUDIO']; + final v = muteStates['VIDEO']; + final s = muteStates['SCREEN_SHARING']; + if (a is String) p.audioEnabled = a == 'UNMUTE'; + if (v is String) p.videoEnabled = v == 'UNMUTE'; + if (s is String) p.screenSharing = s == 'UNMUTE'; + } + if (handRaised != null) p.handRaised = handRaised; + return p; + } + + int? _externalId(Object? ext) { + if (ext is! Map) return null; + final v = ext['id']; + if (v is int) return v; + if (v is String) return int.tryParse(v); + return null; + } + + bool? _handFrom(Object? participantState) { + if (participantState is! Map) return null; + final state = participantState['state']; + if (state is! Map || !state.containsKey('hand')) return null; + return state['hand'] == '1' || state['hand'] == true; + } + + void _onParticipantMedia(Map msg) { + final id = _participantIdFrom(msg['participantId']); + if (id == null) return; + _upsertParticipant( + id, + externalId: _externalId(msg['externalId']), + mediaSettings: msg['mediaSettings'], + muteStates: msg['muteStates'], + ); + _maybeAdoptPeer(msg); + _notifyInfo(); + } + + void _maybeAdoptPeer(Map msg) { + if (role != CallRole.joiner || _peerId != null || _pc == null) return; + if (_topology == 'SERVER') return; + final id = msg['participantId']; + if (id is! int || id == ws2Config.userId) return; + _peerId = id; + final type = msg['participantType']; + if (type is String && type.isNotEmpty) _peerType = type; + final deviceIdx = msg['deviceIdx']; + if (deviceIdx is int) _peerDeviceIdx = deviceIdx; + logger.t('[call] adopting peer $_peerId on join'); + unawaited(_createAndSendOffer()); + } + + void _onParticipantStateChanged(Map msg) { + final id = msg['participantId']; + if (id is! int) return; + _upsertParticipant(id, handRaised: _handFrom(msg['participantState'])); + _notifyInfo(); + } + + void _onParticipantsStateChanged(Map msg) { + final list = msg['participants']; + if (list is! List) return; + for (final p in list.whereType()) { + final id = _participantIdFrom(p['participantId'] ?? p['id']); + if (id == null) continue; + _upsertParticipant( + id, + externalId: _externalId(p['externalId']), + state: p['state'] as String?, + mediaSettings: p['mediaSettings'], + muteStates: p['muteStates'], + handRaised: _handFrom(p['participantState']), + ); + } + _notifyInfo(); + } + + void _onParticipantLeft(Map msg) { + final id = msg['participantId']; + if (id is! int) return; + if (_participants.remove(id) != null) _notifyInfo(); + } + Future _onConnection(Map msg) async { final convParams = msg['conversationParams']; final conversation = msg['conversation']; - final iceServers = - _iceServersFrom(convParams) ?? params?.iceServers ?? const []; + final ice = _iceServersFrom(convParams) ?? params?.iceServers ?? const []; + _iceServers = ice; _resolvePeer(conversation); - _applyConnectionInfo(msg, iceServers); + _resolveParticipants(conversation); + _applyConnectionInfo(msg, ice); - logger.t('[call] connection — role=$role peer=$_peerId'); + _topology = + (conversation is Map ? conversation['topology']?.toString() : null) ?? + _topology; + logger.t('[call] connection role=$role peer=$_peerId topology=$_topology'); - final pc = await createPeerConnection({ - 'iceServers': iceServers, - 'sdpSemantics': 'unified-plan', - }); - _pc = pc; - - _localStream = await navigator.mediaDevices.getUserMedia({ - 'audio': true, - 'video': false, - }); - for (final track in _localStream!.getTracks()) { - await pc.addTrack(track, _localStream!); + if (_topology == 'SERVER') { + await _setupSfu(); + return; } + final pc = await _createPc(ice); + _pc = pc; + await _addLocalMedia(pc); + await pc.addTransceiver( kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), ); + if (role == CallRole.caller) { + _setState(CallSessionState.ringing); + await _createAndSendOffer(); + } else if (role == CallRole.joiner) { + await _createAndSendOffer(); + } + } + + Future _createPc(List ice) async { + final pc = await createPeerConnection({ + 'iceServers': ice, + 'sdpSemantics': 'unified-plan', + 'bundlePolicy': 'max-bundle', + 'rtcpMuxPolicy': 'require', + }); pc.onIceCandidate = _onLocalCandidate; pc.onTrack = (event) => unawaited(_onRemoteTrack(event)); + pc.onIceConnectionState = (s) => logger.t('[call] ice $s'); pc.onConnectionState = (s) { + logger.t('[call] pc $s'); final connected = s == RTCPeerConnectionState.RTCPeerConnectionStateConnected; if (connected != _mediaConnected) { _mediaConnected = connected; _notifyInfo(); if (connected) { + if (role == CallRole.joiner || _topology == 'SERVER') { + _setState(CallSessionState.active); + } unawaited(_resolvePath()); unawaited(_collectReceivers()); } } - if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed || - s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) { + if ((s == RTCPeerConnectionState.RTCPeerConnectionStateFailed || + s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) && + _topology != 'SERVER') { _end(); } }; + return pc; + } - if (role == CallRole.caller) { - _setState(CallSessionState.ringing); - await _createAndSendOffer(); + Future _addLocalMedia(RTCPeerConnection pc) async { + _localStream = await navigator.mediaDevices.getUserMedia({ + 'audio': true, + 'video': _wantVideo, + }); + for (final track in _localStream!.getTracks()) { + await pc.addTrack(track, _localStream!); } } + Future _setupSfu() async { + if (_pc != null) { + await _pc!.close(); + _pc = null; + _remoteDescSet = false; + _pendingCandidates.clear(); + for (final track in _localStream?.getTracks() ?? []) { + await track.stop(); + } + await _localStream?.dispose(); + _localStream = null; + _videoSender = null; + await _disposeLocalVideoStream(); + _localVideo = false; + _localScreen = false; + } + _setState(CallSessionState.connecting); + final pc = await _createPc(_iceServers); + _pc = pc; + await _addLocalMedia(pc); + logger.t('[call][sfu] allocate-consumer'); + await _signaling?.allocateConsumer(); + } + + Future _onTopologyChanged(Map msg) async { + final topo = msg['topology']?.toString(); + if (topo == null) return; + logger.t('[call] topology-changed -> $topo'); + info.topology = topo; + final switchingToSfu = topo == 'SERVER' && _topology != 'SERVER'; + _topology = topo; + _notifyInfo(); + if (switchingToSfu) await _setupSfu(); + } + + Future _onProducerUpdated(Map msg) async { + final pc = _pc; + if (pc == null) return; + + final session = msg['sessionId']; + if (session != null) _sfuSessionId = session; + + final description = msg['description']; + String? sdp; + var type = 'offer'; + if (description is Map) { + sdp = (description['sdp'] ?? description['description']) as String?; + type = (description['type'] as String?) ?? 'offer'; + } else if (description is String) { + sdp = description; + } + if (sdp == null) { + logger.t('[call][sfu] producer-updated without sdp: $msg'); + return; + } + + logger.t('[call][sfu] producer offer: ${_mLines(sdp)} m-lines'); + await pc.setRemoteDescription(RTCSessionDescription(sdp, type)); + _remoteDescSet = true; + await _flushCandidates(); + + final answer = await pc.createAnswer({}); + await pc.setLocalDescription(answer); + await _waitIceGathering(pc, const Duration(seconds: 3)); + + final local = await pc.getLocalDescription(); + final answerSdp = local?.sdp ?? answer.sdp ?? ''; + final ssrcs = _extractSsrcs(answerSdp); + logger.t('[call][sfu] answer: ${_mLines(answerSdp)} m-lines, ' + 'ssrcs=${ssrcs.length}'); + + await _signaling?.acceptProducer( + description: answerSdp, + ssrcs: ssrcs, + sessionId: _sfuSessionId, + ); + + if (_wantVideo) await _publishCamera(); + 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 _mLines(String sdp) => RegExp(r'^m=', multiLine: true).allMatches(sdp).length; + + List _extractSsrcs(String sdp) { + final set = {}; + for (final m in RegExp(r'^a=ssrc:(\d+)', multiLine: true).allMatches(sdp)) { + final v = int.tryParse(m.group(1) ?? ''); + if (v != null) set.add(v); + } + return set.toList(); + } + + Future _waitIceGathering( + RTCPeerConnection pc, Duration timeout) async { + if (pc.iceGatheringState == + RTCIceGatheringState.RTCIceGatheringStateComplete) { + return; + } + final completer = Completer(); + Timer? timer; + void finish() { + if (!completer.isCompleted) completer.complete(); + } + + pc.onIceGatheringState = (state) { + if (state == RTCIceGatheringState.RTCIceGatheringStateComplete) finish(); + }; + timer = Timer(timeout, finish); + await completer.future; + timer.cancel(); + pc.onIceGatheringState = null; + } + String _videoDir(String sdp) { var inVideo = false; String? mline; @@ -389,6 +877,7 @@ class CallSession { } void _onLocalCandidate(RTCIceCandidate candidate) { + if (_topology == 'SERVER') return; final peerId = _peerId; if (peerId == null || candidate.candidate == null) return; _signaling?.transmitCandidate( @@ -406,7 +895,7 @@ class CallSession { _accepted = true; logger.t('[call] accepted'); await _signaling?.acceptCall(); - await _signaling?.changeMediaSettings(isAudioEnabled: !_muted); + await _sendMediaSettings(); _setState(CallSessionState.active); } @@ -415,20 +904,110 @@ class CallSession { } Future setMuted(bool muted) async { + await _applyMuted(muted, announce: true); + } + + Future _applyMuted(bool muted, {bool announce = false}) async { _muted = muted; for (final track in _localStream?.getAudioTracks() ?? []) { track.enabled = !muted; } - await _signaling?.changeMediaSettings(isAudioEnabled: !muted); + _notifyInfo(); + if (announce) await _sendMediaSettings(); } - Future hangup({String reason = 'HUNGUP'}) async { + Future _sendMediaSettings() async { + await _signaling?.changeMediaSettings( + isAudioEnabled: !_muted, + isVideoEnabled: _localVideo, + isScreenSharingEnabled: _localScreen, + ); + } + + Future setVideoEnabled(bool on) => + on ? _startLocalVideo(screen: false) : _stopLocalVideo(); + + Future setScreenSharing(bool on) => + on ? _startLocalVideo(screen: true) : _stopLocalVideo(); + + Future _startLocalVideo({required bool screen}) async { + final pc = _pc; + if (pc == null) return; + + MediaStream stream; try { - await _signaling?.hangup(reason: reason); + stream = screen + ? await navigator.mediaDevices + .getDisplayMedia({'video': true, 'audio': false}) + : await navigator.mediaDevices + .getUserMedia({'video': true, 'audio': false}); + } catch (e) { + logger.t('[call] video capture failed: $e'); + return; + } + + await _disposeLocalVideoStream(); + _localVideoStream = stream; + + final tracks = stream.getVideoTracks(); + final track = tracks.isEmpty ? null : tracks.first; + if (track != null) { + if (_videoSender == null) { + _videoSender = await pc.addTrack(track, stream); + } else { + await _videoSender!.replaceTrack(track); + } + } + + _localVideo = !screen; + _localScreen = screen; + + if (_topology != 'SERVER') await _createAndSendOffer(); + await _sendMediaSettings(); + _notifyInfo(); + } + + Future _stopLocalVideo() async { + try { + await _videoSender?.replaceTrack(null); + } catch (_) {} + await _disposeLocalVideoStream(); + _localVideo = false; + _localScreen = false; + await _sendMediaSettings(); + _notifyInfo(); + } + + Future _disposeLocalVideoStream() async { + final stream = _localVideoStream; + _localVideoStream = null; + if (stream == null) return; + for (final track in stream.getTracks()) { + try { + await track.stop(); + } catch (_) {} + } + try { + await stream.dispose(); + } catch (_) {} + } + + Future hangup({String? reason}) async { + final r = reason ?? _autoHangupReason(); + try { + await _signaling?.hangup(reason: r); } catch (_) {} _end(); } + String _autoHangupReason() { + if (_current != CallSessionState.active) { + if (role == CallRole.caller) return 'CANCELED'; + if (role == CallRole.callee && !_accepted) return 'REJECTED'; + } + return 'HUNGUP'; + } + bool _ended = false; void _end() { if (_ended) return; @@ -438,10 +1017,12 @@ class CallSession { } Future _dispose() async { + _levelTimer?.cancel(); for (final track in _localStream?.getTracks() ?? []) { await track.stop(); } await _localStream?.dispose(); + await _disposeLocalVideoStream(); await _pc?.close(); if (_ownRemoteStream) { try { diff --git a/lib/core/calls/ws2_signaling.dart b/lib/core/calls/ws2_signaling.dart index e08a662..5f0f242 100644 --- a/lib/core/calls/ws2_signaling.dart +++ b/lib/core/calls/ws2_signaling.dart @@ -148,11 +148,14 @@ class Ws2Signaling { final label = decoded['notification'] ?? decoded['response'] ?? decoded['type']; + final dump = jsonEncode(decoded); logger.t('[ws2] ← $label'); - logger.t(decoded); + logger.t(dump.length > 1500 + ? '${dump.substring(0, 1500)}… (${dump.length}b)' + : dump); final type = decoded['type']; - if (type == 'response') { + if (type == 'response' || type == 'error') { final seq = decoded['sequence']; if (seq is int) { final completer = _pending.remove(seq); @@ -160,6 +163,7 @@ class Ws2Signaling { completer.complete(decoded); } } + if (type == 'error') _notifications.add(decoded); return; } @@ -256,7 +260,6 @@ class Ws2Signaling { bool isVideoEnabled = false, bool isScreenSharingEnabled = false, bool isAnimojiEnabled = false, - bool isAudioSharingEnabled = false, }) { return sendCommand( 'change-media-settings', @@ -266,7 +269,6 @@ class Ws2Signaling { 'isAudioEnabled': isAudioEnabled, 'isScreenSharingEnabled': isScreenSharingEnabled, 'isAnimojiEnabled': isAnimojiEnabled, - 'isAudioSharingEnabled': isAudioSharingEnabled, }, }, ); @@ -278,6 +280,54 @@ class Ws2Signaling { Future hangup({String reason = 'HUNGUP'}) => sendCommand('hangup', extra: {'reason': reason}); + Future allocateConsumer() => sendCommand( + 'allocate-consumer', + extra: const { + 'capabilities': { + 'maxH264Decoders': 10, + 'producerNotificationDataChannelVersion': 7, + 'producerCommandDataChannelVersion': 2, + 'audioMix': true, + 'consumerUpdate': true, + 'onDemandTracks': true, + 'singleSession': true, + 'unifiedPlan': true, + 'fastScreenShare': true, + 'producerScreenDataChannelVersion': 1, + 'consumerScreenDataChannelVersion': 1, + 'animojiDataChannelVersion': 2, + 'animojiBackendRender': true, + 'asrDataChannelVersion': 1, + 'consumerFastScreenShare': true, + 'consumerFastScreenShareQualityOnDemand': true, + 'audioShare': true, + 'simulcast': true, + 'simulcastNativeOrder': true, + 'red': true, + 'videoTracksCount': 10, + 'csrcAccessible': true, + }, + }, + ); + + Future acceptProducer({ + required String description, + required List ssrcs, + Object? sessionId, + }) => + sendCommand('accept-producer', extra: { + 'description': description, + 'ssrcs': ssrcs, + 'sessionId': ?sessionId, + }); + + Future changeSimulcast({ + String mediaSource = 'CAMERA', + required List> layers, + }) => + sendCommand('change-simulcast', + extra: {'mediaSource': mediaSource, 'layers': layers}); + Future close() async { await _socket?.close(); _socket = null; diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index 8f0f5c9..1ab5851 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -107,6 +107,7 @@ abstract class Opcode { static const int videoChatStartActive = 78; // Инициация активного звонка static const int videoChatHistory = 79; // История звонков static const int videoChatCreateJoinLink = 84; // Ссылка для входа в видеочат + static const int videoChatJoinByLink = 166; // Вход в звонок по ссылке static const int videoChatMembers = 195; // Участники видеочата static const int getInboundCalls = 103; // Входящие звонки @@ -288,6 +289,7 @@ abstract class Opcode { videoChatStartActive: 'VIDEO_CHAT_START_ACTIVE', videoChatHistory: 'VIDEO_CHAT_HISTORY', videoChatCreateJoinLink: 'VIDEO_CHAT_CREATE_JOIN_LINK', + videoChatJoinByLink: 'VIDEO_CHAT_JOIN_BY_LINK', videoChatMembers: 'VIDEO_CHAT_MEMBERS', getInboundCalls: 'GET_INBOUND_CALLS', photoUpload: 'PHOTO_UPLOAD', diff --git a/lib/core/utils/file_log_output.dart b/lib/core/utils/file_log_output.dart new file mode 100644 index 0000000..01ea152 --- /dev/null +++ b/lib/core/utils/file_log_output.dart @@ -0,0 +1,2 @@ +export 'file_log_output_stub.dart' + if (dart.library.io) 'file_log_output_io.dart'; diff --git a/lib/core/utils/file_log_output_io.dart b/lib/core/utils/file_log_output_io.dart new file mode 100644 index 0000000..dec9ee1 --- /dev/null +++ b/lib/core/utils/file_log_output_io.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:logger/logger.dart'; +import 'package:path_provider/path_provider.dart'; + +class FileLogOutput extends LogOutput { + FileLogOutput._(); + + static final FileLogOutput instance = FileLogOutput._(); + + RandomAccessFile? _raf; + String? _path; + final List _buffer = []; + + static final RegExp _ansi = RegExp('\x1B\\[[0-9;]*m'); + + String? get path => _path; + + Future start() async { + if (_raf != null) return; + try { + final dir = await getApplicationSupportDirectory(); + final file = File('${dir.path}/komet_calls.log'); + final raf = await file.open(mode: FileMode.write); + _path = file.path; + _raf = raf; + _write('=== komet log ${file.path} ==='); + for (final line in _buffer) { + _write(line); + } + _buffer.clear(); + } catch (_) {} + } + + void _write(String line) { + try { + _raf?.writeStringSync('$line\n'); + } catch (_) {} + } + + @override + void output(OutputEvent event) { + if (_raf == null) { + for (final line in event.lines) { + if (_buffer.length < 20000) _buffer.add(line.replaceAll(_ansi, '')); + } + return; + } + for (final line in event.lines) { + _write(line.replaceAll(_ansi, '')); + } + } + + @override + Future destroy() async { + try { + await _raf?.close(); + } catch (_) {} + _raf = null; + } +} diff --git a/lib/core/utils/file_log_output_stub.dart b/lib/core/utils/file_log_output_stub.dart new file mode 100644 index 0000000..76a0181 --- /dev/null +++ b/lib/core/utils/file_log_output_stub.dart @@ -0,0 +1,14 @@ +import 'package:logger/logger.dart'; + +class FileLogOutput extends LogOutput { + FileLogOutput._(); + + static final FileLogOutput instance = FileLogOutput._(); + + String? get path => null; + + Future start() async {} + + @override + void output(OutputEvent event) {} +} diff --git a/lib/core/utils/link_opener.dart b/lib/core/utils/link_opener.dart index b669cdc..9fb0cc3 100644 --- a/lib/core/utils/link_opener.dart +++ b/lib/core/utils/link_opener.dart @@ -1,9 +1,13 @@ import 'package:flutter/widgets.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../frontend/widgets/call_link_handler.dart'; import '../../frontend/widgets/custom_notification.dart'; Future openExternalUrl(BuildContext context, String url) async { + if (await tryHandleCallLink(context, url)) return; + if (!context.mounted) return; + final uri = Uri.tryParse(url); if (uri == null) { showCustomNotification(context, 'Некорректная ссылка'); diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index a57ef72..8080e82 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; +import 'file_log_output.dart'; + Level _minimumLogLevel() { const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: ''); switch (raw.toLowerCase()) { @@ -41,8 +43,13 @@ final logger = Logger( filter: _logFilter(), level: _minimumLogLevel(), printer: KometLogPrinter(), + output: MultiOutput([ConsoleOutput(), FileLogOutput.instance]), ); +Future startFileLogging() => FileLogOutput.instance.start(); + +String? get logFilePath => FileLogOutput.instance.path; + int _importanceSortKey(Level level) { final v = level.value; if (v >= 5999) { diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 2682d3a..58f1b27 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -30,6 +30,7 @@ class CallScreen extends StatefulWidget { final String? avatarUrl; final CallSession? session; final IncomingCall? incoming; + final bool isGroup; const CallScreen({ super.key, @@ -37,6 +38,7 @@ class CallScreen extends StatefulWidget { this.avatarUrl, this.session, this.incoming, + this.isGroup = false, }); @override @@ -58,7 +60,9 @@ class _CallScreenState extends State late final AnimationController _dotsController; late final AnimationController _videoController; final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer(); + final RTCVideoRenderer _localRenderer = RTCVideoRenderer(); bool _rendererReady = false; + bool _localRendererReady = false; bool _videoAttached = false; MediaStream? _pendingStream; @@ -68,6 +72,21 @@ class _CallScreenState extends State late String _name = widget.name; late String? _avatarUrl = widget.avatarUrl; + final Map _peerInfo = {}; + + bool get _isGroup => + widget.isGroup || (_session?.participantCount ?? 0) > 2; + + bool get _tileVideoReady { + if (_session?.topology == 'SERVER') return false; + final others = (_session?.participants ?? const []) + .where((x) => !x.isSelf) + .length; + if (others != 1) return false; + final src = _remoteRenderer.srcObject; + return src != null && src.getVideoTracks().isNotEmpty; + } + @override void initState() { super.initState(); @@ -130,12 +149,15 @@ class _CallScreenState extends State Future _initRenderer() async { await _remoteRenderer.initialize(); + await _localRenderer.initialize(); if (!mounted) return; _rendererReady = true; + _localRendererReady = true; if (_pendingStream != null) { _remoteRenderer.srcObject = _pendingStream; _pendingStream = null; } + _syncLocalPreview(); setState(() {}); } @@ -183,15 +205,46 @@ class _CallScreenState extends State _stateSub = session.stateStream.listen(_onState); _infoSub = session.infoUpdates.listen((_) { if (!mounted) return; + _isMuted = session.isMuted; + _resolveParticipants(); _syncVideo(); + _syncLocalPreview(); setState(() {}); }); _remoteStreamSub = session.remoteStreamStream.listen(_attachStream); final existing = session.remoteStream; if (existing != null) _attachStream(existing); + _resolveParticipants(); _syncVideo(); } + void _resolveParticipants() { + final session = _session; + if (session == null) return; + for (final p in session.participants) { + final ext = p.externalId; + if (ext == null || p.isSelf || _peerInfo.containsKey(ext)) continue; + _peerInfo[ext] = const _PeerInfo(resolving: true); + unawaited(_resolveParticipant(ext)); + } + } + + Future _resolveParticipant(int id) async { + var name = ContactCache.get(id); + var avatar = ContactCache.getAvatar(id); + if (name == null) { + final info = await ContactInfoFetch.get(id); + if (info != null) { + name = _contactName(info); + avatar ??= info['baseUrl'] as String?; + if (name != null) ContactCache.put(id, name); + ContactCache.putAvatar(id, avatar); + } + } + if (!mounted) return; + setState(() => _peerInfo[id] = _PeerInfo(name: name, avatar: avatar)); + } + void _onState(CallSessionState state) { if (!mounted) return; setState(() => _state = state); @@ -247,6 +300,39 @@ class _CallScreenState extends State await Helper.setSpeakerphoneOn(next); } + bool _videoBusy = false; + + Future _toggleVideo() async { + final session = _session; + if (session == null || _videoBusy) return; + setState(() => _videoBusy = true); + await WidgetsBinding.instance.endOfFrame; + try { + await session.setVideoEnabled(!session.localVideo); + } finally { + _syncLocalPreview(); + if (mounted) setState(() => _videoBusy = false); + } + } + + Future _toggleScreen() async { + final session = _session; + if (session == null || _videoBusy) return; + setState(() => _videoBusy = true); + await WidgetsBinding.instance.endOfFrame; + try { + await session.setScreenSharing(!session.localScreen); + } finally { + _syncLocalPreview(); + if (mounted) setState(() => _videoBusy = false); + } + } + + void _syncLocalPreview() { + if (!_localRendererReady) return; + _localRenderer.srcObject = _session?.localVideoStream; + } + @override void dispose() { _stateSub?.cancel(); @@ -256,6 +342,8 @@ class _CallScreenState extends State _videoController.dispose(); _remoteRenderer.srcObject = null; _remoteRenderer.dispose(); + _localRenderer.srcObject = null; + _localRenderer.dispose(); super.dispose(); } @@ -284,11 +372,21 @@ class _CallScreenState extends State @override Widget build(BuildContext context) { final cs = _darkScheme(context); - final avatar = _buildAvatar(cs); - final name = _buildName(cs); - final status = _buildStatus(cs); - final peerBar = _peerStateBar(cs); - final controls = _buildControls(cs); + final group = _isGroup && !_incomingPending; + + final Widget body = group + ? _buildGroupBody(cs) + : AnimatedBuilder( + animation: _videoController, + builder: (context, _) => _buildBody( + cs, + avatar: _buildAvatar(cs), + name: _buildName(cs), + status: _buildStatus(cs), + peerBar: _peerStateBar(cs), + controls: _buildControls(cs), + ), + ); return Theme( data: Theme.of(context).copyWith(colorScheme: cs), @@ -300,22 +398,290 @@ class _CallScreenState extends State ), child: Scaffold( backgroundColor: cs.surface, - body: AnimatedBuilder( - animation: _videoController, - builder: (context, _) => _buildBody( - cs, - avatar: avatar, - name: name, - status: status, - peerBar: peerBar, - controls: controls, - ), + body: Stack( + children: [ + body, + if (_session?.localVideo == true || _session?.localScreen == true) + _localPreview(cs), + ], ), ), ), ); } + Widget _localPreview(ColorScheme cs) { + return Positioned( + right: 16, + top: MediaQuery.of(context).padding.top + 56, + child: SafeArea( + child: Container( + width: 96, + height: 140, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: cs.surfaceContainerHighest, + border: Border.all(color: cs.outlineVariant, width: 1), + ), + child: _localRendererReady && _localRenderer.srcObject != null + ? RTCVideoView( + _localRenderer, + mirror: _session?.localScreen != true, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + ) + : Center( + child: Icon( + _session?.localScreen == true + ? Symbols.screen_share + : Symbols.videocam, + color: cs.onSurfaceVariant, + size: 28, + ), + ), + ), + ), + ); + } + + Widget _buildGroupBody(ColorScheme cs) { + final participants = _session?.participants ?? const []; + return SafeArea( + child: Column( + children: [ + _buildTopBar(cs, 0), + const SizedBox(height: 4), + _groupHeader(cs, participants.length), + const SizedBox(height: 8), + Expanded( + child: participants.isEmpty + ? Center(child: _statusWithDots(cs, 'Соединение')) + : _participantGrid(cs, participants), + ), + const SizedBox(height: 12), + _activeControls(cs), + const SizedBox(height: 24), + ], + ), + ); + } + + Widget _groupHeader(ColorScheme cs, int count) { + final String subtitle; + if (count == 0) { + subtitle = 'Соединение…'; + } else if (count <= 1) { + subtitle = 'Ожидание участников…'; + } else { + subtitle = _participantsLabel(count); + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + Text( + _displayName, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ], + ), + ); + } + + Widget _participantGrid(ColorScheme cs, List ps) { + final cols = ps.length <= 1 + ? 1 + : ps.length <= 4 + ? 2 + : 3; + return GridView.count( + crossAxisCount: cols, + padding: const EdgeInsets.fromLTRB(20, 4, 20, 4), + mainAxisSpacing: 14, + crossAxisSpacing: 14, + childAspectRatio: 0.84, + children: [for (final p in ps) _participantTile(cs, p)], + ); + } + + Widget _participantTile(ColorScheme cs, CallParticipant p) { + final ext = p.externalId; + final info = ext != null ? _peerInfo[ext] : null; + final name = p.isSelf + ? 'Вы' + : (info?.name?.isNotEmpty == true ? info!.name! : 'Участник'); + final url = p.isSelf ? _avatarUrl : info?.avatar; + final muted = p.isSelf ? _isMuted : !p.audioEnabled; + final speaking = !muted && _session?.isSpeaking(p.id) == true; + final showVideo = + !p.isSelf && (p.videoEnabled || p.screenSharing) && _tileVideoReady; + + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + borderSide: + speaking ? const BorderSide(color: _kAcceptGreen, width: 2.5) : null, + padding: EdgeInsets.all(showVideo ? 0 : 12), + child: showVideo + ? _videoTile(cs, name, muted, p.handRaised, p.screenSharing) + : _avatarTile(cs, name, url, muted, p.handRaised, p.screenSharing), + ); + } + + Widget _avatarTile(ColorScheme cs, String name, String? url, bool muted, + bool hand, bool screen) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest.shortestSide.clamp(48.0, 96.0); + return Center( + child: SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + _circleAvatar(size, cs, name: name, url: url), + if (hand) + Positioned( + top: -2, + right: -2, + child: _tileBadge(cs, Symbols.front_hand, + cs.tertiaryContainer, cs.onTertiaryContainer), + ), + if (screen) + Positioned( + top: -2, + left: -2, + child: _tileBadge(cs, Symbols.screen_share, + cs.primaryContainer, cs.onPrimaryContainer), + ), + if (muted) + Positioned( + bottom: -2, + right: -2, + child: _tileBadge(cs, Symbols.mic_off, + cs.surfaceContainerHighest, cs.onSurfaceVariant), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 8), + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } + + Widget _videoTile( + ColorScheme cs, String name, bool muted, bool hand, bool screen) { + return ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Stack( + fit: StackFit.expand, + children: [ + RTCVideoView( + _remoteRenderer, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + ), + Positioned( + left: 8, + right: 8, + bottom: 8, + child: Row( + children: [ + if (muted) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon(Symbols.mic_off, + size: 16, color: Colors.white, fill: 1), + ), + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + shadows: [Shadow(blurRadius: 4, color: Colors.black)], + ), + ), + ), + ], + ), + ), + if (hand) + Positioned( + top: 8, + right: 8, + child: _tileBadge(cs, Symbols.front_hand, cs.tertiaryContainer, + cs.onTertiaryContainer), + ), + if (screen) + Positioned( + top: 8, + left: 8, + child: _tileBadge(cs, Symbols.screen_share, cs.primaryContainer, + cs.onPrimaryContainer), + ), + ], + ), + ); + } + + Widget _tileBadge(ColorScheme cs, IconData icon, Color bg, Color fg) { + return Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: bg, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), + ), + child: Icon(icon, size: 14, color: fg, fill: 1), + ); + } + + String _participantsLabel(int n) { + final mod10 = n % 10; + final mod100 = n % 100; + if (mod10 == 1 && mod100 != 11) return '$n участник'; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) { + return '$n участника'; + } + return '$n участников'; + } + Widget _buildBody( ColorScheme cs, { required Widget avatar, @@ -529,8 +895,15 @@ class _CallScreenState extends State String get _displayName => _name.isEmpty ? 'Неизвестный' : _name; - Widget _avatarCircle(double size, ColorScheme cs) { - final url = _avatarUrl; + Widget _avatarCircle(double size, ColorScheme cs) => + _circleAvatar(size, cs, name: _displayName, url: _avatarUrl); + + Widget _circleAvatar( + double size, + ColorScheme cs, { + required String name, + String? url, + }) { return Container( width: size, height: size, @@ -556,14 +929,14 @@ class _CallScreenState extends State fit: BoxFit.cover, memCacheWidth: 420, memCacheHeight: 420, - errorWidget: (_, _, _) => _avatarFallback(size, cs), + errorWidget: (_, _, _) => _avatarFallback(size, cs, name), ) - : _avatarFallback(size, cs), + : _avatarFallback(size, cs, name), ); } - Widget _avatarFallback(double size, ColorScheme cs) { - final letter = _displayName[0].toUpperCase(); + Widget _avatarFallback(double size, ColorScheme cs, String name) { + final letter = (name.isEmpty ? '?' : name[0]).toUpperCase(); return Container( color: cs.primaryContainer, alignment: Alignment.center, @@ -683,8 +1056,10 @@ class _CallScreenState extends State } Widget _activeControls(ColorScheme cs) { + final video = _session?.localVideo == true; + final screen = _session?.localScreen == true; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -696,11 +1071,20 @@ class _CallScreenState extends State onTap: _toggleSpeaker, ), _CallButton( - icon: Symbols.videocam_off, + icon: video ? Symbols.videocam : Symbols.videocam_off, label: 'Видео', - background: cs.surfaceContainerHighest, - foreground: cs.onSurface, - onTap: () {}, + background: video ? cs.primary : cs.surfaceContainerHighest, + foreground: video ? cs.onPrimary : cs.onSurface, + busy: _videoBusy, + onTap: _toggleVideo, + ), + _CallButton( + icon: Symbols.screen_share, + label: 'Экран', + background: screen ? cs.primary : cs.surfaceContainerHighest, + foreground: screen ? cs.onPrimary : cs.onSurface, + busy: _videoBusy, + onTap: _toggleScreen, ), _CallButton( icon: _isMuted ? Symbols.mic_off : Symbols.mic, @@ -722,6 +1106,14 @@ class _CallScreenState extends State } } +class _PeerInfo { + final String? name; + final String? avatar; + final bool resolving; + + const _PeerInfo({this.name, this.avatar, this.resolving = false}); +} + class _CallingDots extends StatelessWidget { final Animation animation; final Color color; @@ -763,6 +1155,7 @@ class _CallButton extends StatelessWidget { final Color background; final Color foreground; final VoidCallback onTap; + final bool busy; const _CallButton({ required this.icon, @@ -770,6 +1163,7 @@ class _CallButton extends StatelessWidget { required this.background, required this.foreground, required this.onTap, + this.busy = false, }); @override @@ -784,10 +1178,19 @@ class _CallButton extends StatelessWidget { child: GlossyPill( color: background, borderRadius: BorderRadius.circular(31), - onTap: onTap, + onTap: busy ? null : onTap, depth: 9, child: Center( - child: Icon(icon, color: foreground, size: 26, fill: 1), + child: busy + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(foreground), + ), + ) + : Icon(icon, color: foreground, size: 26, fill: 1), ), ), ), diff --git a/lib/frontend/widgets/call_link_handler.dart b/lib/frontend/widgets/call_link_handler.dart new file mode 100644 index 0000000..263e928 --- /dev/null +++ b/lib/frontend/widgets/call_link_handler.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../../core/calls/call_controller.dart'; +import '../../core/calls/call_link.dart'; +import '../screens/calls/call_screen.dart'; +import 'confirm_dialog.dart'; +import 'custom_notification.dart'; + +Future tryHandleCallLink(BuildContext context, String url) async { + final token = CallLink.token(url); + if (token == null) return false; + + final controller = CallController.instance; + if (controller.isBusy) { + showCustomNotification(context, 'Звонок уже идёт'); + return true; + } + + final navigator = Navigator.of(context); + final preview = await controller.previewCallLink(url); + if (!context.mounted) return true; + + final name = (preview?.callName?.isNotEmpty ?? false) + ? preview!.callName! + : 'Звонок'; + final count = preview?.participantsCount ?? 0; + final message = count > 0 + ? 'Присоединиться к звонку «$name»? Сейчас в звонке: $count.' + : 'Присоединиться к звонку «$name»?'; + + final confirmed = await showConfirmDialog( + context, + title: 'Звонок', + message: message, + confirmLabel: 'Присоединиться', + ); + if (!confirmed || !context.mounted) return true; + + try { + final session = + await controller.joinByLink(token, isVideo: preview?.isVideo ?? false); + navigator.push( + MaterialPageRoute( + builder: (_) => CallScreen(name: name, session: session, isGroup: true), + ), + ); + } catch (_) { + if (context.mounted) { + showCustomNotification(context, 'Не удалось присоединиться к звонку'); + } + } + return true; +} diff --git a/lib/main.dart b/lib/main.dart index 343f7e5..901f43d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; import 'core/cache/info_cache.dart'; +import 'core/utils/logger.dart'; import 'core/config/app_accent.dart'; import 'core/config/app_amoled.dart'; import 'core/config/app_bubble_behavior.dart'; @@ -78,6 +79,8 @@ Future _loadInitialLocale() async { void main() async { WidgetsFlutterBinding.ensureInitialized(); + await startFileLogging(); + logger.i('[log] file: ${logFilePath ?? '—'}'); await AppDatabase.init(); final activeAccountId = await TokenStorage.getActiveAccountId(); if (activeAccountId != null) {