feat(calls): Тяжело...... Звонки. Вроде полностью. Почти.

This commit is contained in:
Jganenok
2026-06-12 22:19:57 +07:00
parent f22d830dfe
commit afbfa33b99
14 changed files with 1341 additions and 70 deletions
+78 -12
View File
@@ -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<CallLinkPreview?> 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<OutgoingCallParams> 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<dynamic, dynamic>
: const <dynamic, dynamic>{};
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<int>.generate(16, (_) => r.nextInt(256));
+14
View File
@@ -103,6 +103,20 @@ class CallController {
return session;
}
Future<CallLinkPreview?> previewCallLink(String url) =>
_calls!.resolveCallLink(url);
Future<CallSession> 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<CallSession> acceptIncoming(IncomingCall call) async {
_pending = null;
+11
View File
@@ -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);
}
+607 -26
View File
@@ -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<RTCIceCandidate> _pendingCandidates = [];
Future<void> _tail = Future.value();
final Map<int, CallParticipant> _participants = {};
String? _topology;
List _iceServers = const [];
Object? _sfuSessionId;
Set<int> _speaking = const {};
bool _localVideo = false;
bool _localScreen = false;
MediaStream? _localVideoStream;
RTCRtpSender? _videoSender;
Timer? _levelTimer;
final Map<int, int> _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<CallParticipant> 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<CallSessionState>.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<void> _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 = <int>{};
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<String, dynamic> msg) {
@@ -95,6 +193,10 @@ class CallSession {
}
Future<void> _onNotification(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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 = <int>{};
for (final p in list.whereType<Map>()) {
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> msg) {
final id = msg['participantId'];
if (id is! int) return;
_upsertParticipant(id, handRaised: _handFrom(msg['participantState']));
_notifyInfo();
}
void _onParticipantsStateChanged(Map<String, dynamic> msg) {
final list = msg['participants'];
if (list is! List) return;
for (final p in list.whereType<Map>()) {
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<String, dynamic> msg) {
final id = msg['participantId'];
if (id is! int) return;
if (_participants.remove(id) != null) _notifyInfo();
}
Future<void> _onConnection(Map<String, dynamic> 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<RTCPeerConnection> _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<void> _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<void> _setupSfu() async {
if (_pc != null) {
await _pc!.close();
_pc = null;
_remoteDescSet = false;
_pendingCandidates.clear();
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
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<void> _onTopologyChanged(Map<String, dynamic> 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<void> _onProducerUpdated(Map<String, dynamic> 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<void> _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<int> _extractSsrcs(String sdp) {
final set = <int>{};
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<void> _waitIceGathering(
RTCPeerConnection pc, Duration timeout) async {
if (pc.iceGatheringState ==
RTCIceGatheringState.RTCIceGatheringStateComplete) {
return;
}
final completer = Completer<void>();
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<void> setMuted(bool muted) async {
await _applyMuted(muted, announce: true);
}
Future<void> _applyMuted(bool muted, {bool announce = false}) async {
_muted = muted;
for (final track in _localStream?.getAudioTracks() ?? <MediaStreamTrack>[]) {
track.enabled = !muted;
}
await _signaling?.changeMediaSettings(isAudioEnabled: !muted);
_notifyInfo();
if (announce) await _sendMediaSettings();
}
Future<void> hangup({String reason = 'HUNGUP'}) async {
Future<void> _sendMediaSettings() async {
await _signaling?.changeMediaSettings(
isAudioEnabled: !_muted,
isVideoEnabled: _localVideo,
isScreenSharingEnabled: _localScreen,
);
}
Future<void> setVideoEnabled(bool on) =>
on ? _startLocalVideo(screen: false) : _stopLocalVideo();
Future<void> setScreenSharing(bool on) =>
on ? _startLocalVideo(screen: true) : _stopLocalVideo();
Future<void> _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(<String, dynamic>{'video': true, 'audio': false})
: await navigator.mediaDevices
.getUserMedia(<String, dynamic>{'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<void> _stopLocalVideo() async {
try {
await _videoSender?.replaceTrack(null);
} catch (_) {}
await _disposeLocalVideoStream();
_localVideo = false;
_localScreen = false;
await _sendMediaSettings();
_notifyInfo();
}
Future<void> _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<void> 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<void> _dispose() async {
_levelTimer?.cancel();
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
await track.stop();
}
await _localStream?.dispose();
await _disposeLocalVideoStream();
await _pc?.close();
if (_ownRemoteStream) {
try {
+54 -4
View File
@@ -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<void> hangup({String reason = 'HUNGUP'}) =>
sendCommand('hangup', extra: {'reason': reason});
Future<void> 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<void> acceptProducer({
required String description,
required List<int> ssrcs,
Object? sessionId,
}) =>
sendCommand('accept-producer', extra: {
'description': description,
'ssrcs': ssrcs,
'sessionId': ?sessionId,
});
Future<void> changeSimulcast({
String mediaSource = 'CAMERA',
required List<Map<String, dynamic>> layers,
}) =>
sendCommand('change-simulcast',
extra: {'mediaSource': mediaSource, 'layers': layers});
Future<void> close() async {
await _socket?.close();
_socket = null;
+2
View File
@@ -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',
+2
View File
@@ -0,0 +1,2 @@
export 'file_log_output_stub.dart'
if (dart.library.io) 'file_log_output_io.dart';
+61
View File
@@ -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<String> _buffer = [];
static final RegExp _ansi = RegExp('\x1B\\[[0-9;]*m');
String? get path => _path;
Future<void> 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<void> destroy() async {
try {
await _raf?.close();
} catch (_) {}
_raf = null;
}
}
+14
View File
@@ -0,0 +1,14 @@
import 'package:logger/logger.dart';
class FileLogOutput extends LogOutput {
FileLogOutput._();
static final FileLogOutput instance = FileLogOutput._();
String? get path => null;
Future<void> start() async {}
@override
void output(OutputEvent event) {}
}
+4
View File
@@ -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<void> 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, 'Некорректная ссылка');
+7
View File
@@ -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<void> startFileLogging() => FileLogOutput.instance.start();
String? get logFilePath => FileLogOutput.instance.path;
int _importanceSortKey(Level level) {
final v = level.value;
if (v >= 5999) {
+431 -28
View File
@@ -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<CallScreen>
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<CallScreen>
late String _name = widget.name;
late String? _avatarUrl = widget.avatarUrl;
final Map<int, _PeerInfo> _peerInfo = {};
bool get _isGroup =>
widget.isGroup || (_session?.participantCount ?? 0) > 2;
bool get _tileVideoReady {
if (_session?.topology == 'SERVER') return false;
final others = (_session?.participants ?? const <CallParticipant>[])
.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<CallScreen>
Future<void> _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<CallScreen>
_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<void> _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<CallScreen>
await Helper.setSpeakerphoneOn(next);
}
bool _videoBusy = false;
Future<void> _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<void> _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<CallScreen>
_videoController.dispose();
_remoteRenderer.srcObject = null;
_remoteRenderer.dispose();
_localRenderer.srcObject = null;
_localRenderer.dispose();
super.dispose();
}
@@ -284,11 +372,21 @@ class _CallScreenState extends State<CallScreen>
@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<CallScreen>
),
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 <CallParticipant>[];
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<CallParticipant> 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<CallScreen>
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<CallScreen>
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<CallScreen>
}
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<CallScreen>
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<CallScreen>
}
}
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<double> 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),
),
),
),
@@ -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<bool> 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;
}
+3
View File
@@ -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<Locale> _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) {