feat(calls): Тяжело...... Звонки. Вроде полностью. Почти.
This commit is contained in:
@@ -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 {
|
class CallLogEntry {
|
||||||
final String id;
|
final String id;
|
||||||
final int accountId;
|
final int accountId;
|
||||||
@@ -63,22 +77,11 @@ class CallsModule {
|
|||||||
bool isVideo = false,
|
bool isVideo = false,
|
||||||
}) async {
|
}) async {
|
||||||
final conversationId = _uuidV4();
|
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, {
|
final response = await _api.sendRequest(Opcode.videoChatStartActive, {
|
||||||
'conversationId': conversationId,
|
'conversationId': conversationId,
|
||||||
'calleeIds': [calleeId],
|
'calleeIds': [calleeId],
|
||||||
'internalParams': internalParams,
|
'internalParams': _internalParams(),
|
||||||
'isVideo': isVideo,
|
'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() {
|
static String _uuidV4() {
|
||||||
final r = Random();
|
final r = Random();
|
||||||
final b = List<int>.generate(16, (_) => r.nextInt(256));
|
final b = List<int>.generate(16, (_) => r.nextInt(256));
|
||||||
|
|||||||
@@ -103,6 +103,20 @@ class CallController {
|
|||||||
return session;
|
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 {
|
Future<CallSession> acceptIncoming(IncomingCall call) async {
|
||||||
_pending = null;
|
_pending = null;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -9,10 +9,32 @@ import 'call_info.dart';
|
|||||||
import 'conversation_params.dart';
|
import 'conversation_params.dart';
|
||||||
import 'ws2_signaling.dart';
|
import 'ws2_signaling.dart';
|
||||||
|
|
||||||
enum CallRole { caller, callee }
|
enum CallRole { caller, callee, joiner }
|
||||||
|
|
||||||
enum CallSessionState { connecting, ringing, active, ended }
|
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 {
|
class CallSession {
|
||||||
final Ws2Config ws2Config;
|
final Ws2Config ws2Config;
|
||||||
|
|
||||||
@@ -44,6 +66,39 @@ class CallSession {
|
|||||||
final List<RTCIceCandidate> _pendingCandidates = [];
|
final List<RTCIceCandidate> _pendingCandidates = [];
|
||||||
Future<void> _tail = Future.value();
|
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 CallInfo info = CallInfo();
|
||||||
|
|
||||||
final _state = StreamController<CallSessionState>.broadcast();
|
final _state = StreamController<CallSessionState>.broadcast();
|
||||||
@@ -88,6 +143,49 @@ class CallSession {
|
|||||||
signaling.notifications.listen(_enqueue, onError: (_) => _end());
|
signaling.notifications.listen(_enqueue, onError: (_) => _end());
|
||||||
signaling.done.then((_) => _end());
|
signaling.done.then((_) => _end());
|
||||||
await signaling.connect();
|
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) {
|
void _enqueue(Map<String, dynamic> msg) {
|
||||||
@@ -95,6 +193,10 @@ class CallSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onNotification(Map<String, dynamic> msg) async {
|
Future<void> _onNotification(Map<String, dynamic> msg) async {
|
||||||
|
if (msg['type'] == 'error') {
|
||||||
|
_onWs2Error(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
_applyPeerMedia(msg);
|
_applyPeerMedia(msg);
|
||||||
switch (msg['notification']) {
|
switch (msg['notification']) {
|
||||||
case 'connection':
|
case 'connection':
|
||||||
@@ -109,67 +211,453 @@ class CallSession {
|
|||||||
case 'registered-peer':
|
case 'registered-peer':
|
||||||
_applyRegisteredPeer(msg);
|
_applyRegisteredPeer(msg);
|
||||||
break;
|
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':
|
case 'closed-conversation':
|
||||||
_end();
|
_end();
|
||||||
break;
|
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 {
|
Future<void> _onConnection(Map<String, dynamic> msg) async {
|
||||||
final convParams = msg['conversationParams'];
|
final convParams = msg['conversationParams'];
|
||||||
final conversation = msg['conversation'];
|
final conversation = msg['conversation'];
|
||||||
|
|
||||||
final iceServers =
|
final ice = _iceServersFrom(convParams) ?? params?.iceServers ?? const [];
|
||||||
_iceServersFrom(convParams) ?? params?.iceServers ?? const [];
|
_iceServers = ice;
|
||||||
_resolvePeer(conversation);
|
_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({
|
if (_topology == 'SERVER') {
|
||||||
'iceServers': iceServers,
|
await _setupSfu();
|
||||||
'sdpSemantics': 'unified-plan',
|
return;
|
||||||
});
|
|
||||||
_pc = pc;
|
|
||||||
|
|
||||||
_localStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
'audio': true,
|
|
||||||
'video': false,
|
|
||||||
});
|
|
||||||
for (final track in _localStream!.getTracks()) {
|
|
||||||
await pc.addTrack(track, _localStream!);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final pc = await _createPc(ice);
|
||||||
|
_pc = pc;
|
||||||
|
await _addLocalMedia(pc);
|
||||||
|
|
||||||
await pc.addTransceiver(
|
await pc.addTransceiver(
|
||||||
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
||||||
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
|
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.onIceCandidate = _onLocalCandidate;
|
||||||
pc.onTrack = (event) => unawaited(_onRemoteTrack(event));
|
pc.onTrack = (event) => unawaited(_onRemoteTrack(event));
|
||||||
|
pc.onIceConnectionState = (s) => logger.t('[call] ice $s');
|
||||||
pc.onConnectionState = (s) {
|
pc.onConnectionState = (s) {
|
||||||
|
logger.t('[call] pc $s');
|
||||||
final connected =
|
final connected =
|
||||||
s == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
|
s == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
|
||||||
if (connected != _mediaConnected) {
|
if (connected != _mediaConnected) {
|
||||||
_mediaConnected = connected;
|
_mediaConnected = connected;
|
||||||
_notifyInfo();
|
_notifyInfo();
|
||||||
if (connected) {
|
if (connected) {
|
||||||
|
if (role == CallRole.joiner || _topology == 'SERVER') {
|
||||||
|
_setState(CallSessionState.active);
|
||||||
|
}
|
||||||
unawaited(_resolvePath());
|
unawaited(_resolvePath());
|
||||||
unawaited(_collectReceivers());
|
unawaited(_collectReceivers());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
if ((s == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
||||||
s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
|
s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) &&
|
||||||
|
_topology != 'SERVER') {
|
||||||
_end();
|
_end();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
return pc;
|
||||||
|
}
|
||||||
|
|
||||||
if (role == CallRole.caller) {
|
Future<void> _addLocalMedia(RTCPeerConnection pc) async {
|
||||||
_setState(CallSessionState.ringing);
|
_localStream = await navigator.mediaDevices.getUserMedia({
|
||||||
await _createAndSendOffer();
|
'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) {
|
String _videoDir(String sdp) {
|
||||||
var inVideo = false;
|
var inVideo = false;
|
||||||
String? mline;
|
String? mline;
|
||||||
@@ -389,6 +877,7 @@ class CallSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onLocalCandidate(RTCIceCandidate candidate) {
|
void _onLocalCandidate(RTCIceCandidate candidate) {
|
||||||
|
if (_topology == 'SERVER') return;
|
||||||
final peerId = _peerId;
|
final peerId = _peerId;
|
||||||
if (peerId == null || candidate.candidate == null) return;
|
if (peerId == null || candidate.candidate == null) return;
|
||||||
_signaling?.transmitCandidate(
|
_signaling?.transmitCandidate(
|
||||||
@@ -406,7 +895,7 @@ class CallSession {
|
|||||||
_accepted = true;
|
_accepted = true;
|
||||||
logger.t('[call] accepted');
|
logger.t('[call] accepted');
|
||||||
await _signaling?.acceptCall();
|
await _signaling?.acceptCall();
|
||||||
await _signaling?.changeMediaSettings(isAudioEnabled: !_muted);
|
await _sendMediaSettings();
|
||||||
_setState(CallSessionState.active);
|
_setState(CallSessionState.active);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,20 +904,110 @@ class CallSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setMuted(bool muted) async {
|
Future<void> setMuted(bool muted) async {
|
||||||
|
await _applyMuted(muted, announce: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyMuted(bool muted, {bool announce = false}) async {
|
||||||
_muted = muted;
|
_muted = muted;
|
||||||
for (final track in _localStream?.getAudioTracks() ?? <MediaStreamTrack>[]) {
|
for (final track in _localStream?.getAudioTracks() ?? <MediaStreamTrack>[]) {
|
||||||
track.enabled = !muted;
|
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 {
|
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 (_) {}
|
} catch (_) {}
|
||||||
_end();
|
_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;
|
bool _ended = false;
|
||||||
void _end() {
|
void _end() {
|
||||||
if (_ended) return;
|
if (_ended) return;
|
||||||
@@ -438,10 +1017,12 @@ class CallSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _dispose() async {
|
Future<void> _dispose() async {
|
||||||
|
_levelTimer?.cancel();
|
||||||
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
|
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
|
||||||
await track.stop();
|
await track.stop();
|
||||||
}
|
}
|
||||||
await _localStream?.dispose();
|
await _localStream?.dispose();
|
||||||
|
await _disposeLocalVideoStream();
|
||||||
await _pc?.close();
|
await _pc?.close();
|
||||||
if (_ownRemoteStream) {
|
if (_ownRemoteStream) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -148,11 +148,14 @@ class Ws2Signaling {
|
|||||||
|
|
||||||
final label =
|
final label =
|
||||||
decoded['notification'] ?? decoded['response'] ?? decoded['type'];
|
decoded['notification'] ?? decoded['response'] ?? decoded['type'];
|
||||||
|
final dump = jsonEncode(decoded);
|
||||||
logger.t('[ws2] ← $label');
|
logger.t('[ws2] ← $label');
|
||||||
logger.t(decoded);
|
logger.t(dump.length > 1500
|
||||||
|
? '${dump.substring(0, 1500)}… (${dump.length}b)'
|
||||||
|
: dump);
|
||||||
|
|
||||||
final type = decoded['type'];
|
final type = decoded['type'];
|
||||||
if (type == 'response') {
|
if (type == 'response' || type == 'error') {
|
||||||
final seq = decoded['sequence'];
|
final seq = decoded['sequence'];
|
||||||
if (seq is int) {
|
if (seq is int) {
|
||||||
final completer = _pending.remove(seq);
|
final completer = _pending.remove(seq);
|
||||||
@@ -160,6 +163,7 @@ class Ws2Signaling {
|
|||||||
completer.complete(decoded);
|
completer.complete(decoded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (type == 'error') _notifications.add(decoded);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +260,6 @@ class Ws2Signaling {
|
|||||||
bool isVideoEnabled = false,
|
bool isVideoEnabled = false,
|
||||||
bool isScreenSharingEnabled = false,
|
bool isScreenSharingEnabled = false,
|
||||||
bool isAnimojiEnabled = false,
|
bool isAnimojiEnabled = false,
|
||||||
bool isAudioSharingEnabled = false,
|
|
||||||
}) {
|
}) {
|
||||||
return sendCommand(
|
return sendCommand(
|
||||||
'change-media-settings',
|
'change-media-settings',
|
||||||
@@ -266,7 +269,6 @@ class Ws2Signaling {
|
|||||||
'isAudioEnabled': isAudioEnabled,
|
'isAudioEnabled': isAudioEnabled,
|
||||||
'isScreenSharingEnabled': isScreenSharingEnabled,
|
'isScreenSharingEnabled': isScreenSharingEnabled,
|
||||||
'isAnimojiEnabled': isAnimojiEnabled,
|
'isAnimojiEnabled': isAnimojiEnabled,
|
||||||
'isAudioSharingEnabled': isAudioSharingEnabled,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -278,6 +280,54 @@ class Ws2Signaling {
|
|||||||
Future<void> hangup({String reason = 'HUNGUP'}) =>
|
Future<void> hangup({String reason = 'HUNGUP'}) =>
|
||||||
sendCommand('hangup', extra: {'reason': reason});
|
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 {
|
Future<void> close() async {
|
||||||
await _socket?.close();
|
await _socket?.close();
|
||||||
_socket = null;
|
_socket = null;
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ abstract class Opcode {
|
|||||||
static const int videoChatStartActive = 78; // Инициация активного звонка
|
static const int videoChatStartActive = 78; // Инициация активного звонка
|
||||||
static const int videoChatHistory = 79; // История звонков
|
static const int videoChatHistory = 79; // История звонков
|
||||||
static const int videoChatCreateJoinLink = 84; // Ссылка для входа в видеочат
|
static const int videoChatCreateJoinLink = 84; // Ссылка для входа в видеочат
|
||||||
|
static const int videoChatJoinByLink = 166; // Вход в звонок по ссылке
|
||||||
static const int videoChatMembers = 195; // Участники видеочата
|
static const int videoChatMembers = 195; // Участники видеочата
|
||||||
static const int getInboundCalls = 103; // Входящие звонки
|
static const int getInboundCalls = 103; // Входящие звонки
|
||||||
|
|
||||||
@@ -288,6 +289,7 @@ abstract class Opcode {
|
|||||||
videoChatStartActive: 'VIDEO_CHAT_START_ACTIVE',
|
videoChatStartActive: 'VIDEO_CHAT_START_ACTIVE',
|
||||||
videoChatHistory: 'VIDEO_CHAT_HISTORY',
|
videoChatHistory: 'VIDEO_CHAT_HISTORY',
|
||||||
videoChatCreateJoinLink: 'VIDEO_CHAT_CREATE_JOIN_LINK',
|
videoChatCreateJoinLink: 'VIDEO_CHAT_CREATE_JOIN_LINK',
|
||||||
|
videoChatJoinByLink: 'VIDEO_CHAT_JOIN_BY_LINK',
|
||||||
videoChatMembers: 'VIDEO_CHAT_MEMBERS',
|
videoChatMembers: 'VIDEO_CHAT_MEMBERS',
|
||||||
getInboundCalls: 'GET_INBOUND_CALLS',
|
getInboundCalls: 'GET_INBOUND_CALLS',
|
||||||
photoUpload: 'PHOTO_UPLOAD',
|
photoUpload: 'PHOTO_UPLOAD',
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export 'file_log_output_stub.dart'
|
||||||
|
if (dart.library.io) '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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../frontend/widgets/call_link_handler.dart';
|
||||||
import '../../frontend/widgets/custom_notification.dart';
|
import '../../frontend/widgets/custom_notification.dart';
|
||||||
|
|
||||||
Future<void> openExternalUrl(BuildContext context, String url) async {
|
Future<void> openExternalUrl(BuildContext context, String url) async {
|
||||||
|
if (await tryHandleCallLink(context, url)) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final uri = Uri.tryParse(url);
|
final uri = Uri.tryParse(url);
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
showCustomNotification(context, 'Некорректная ссылка');
|
showCustomNotification(context, 'Некорректная ссылка');
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
|
|
||||||
|
import 'file_log_output.dart';
|
||||||
|
|
||||||
Level _minimumLogLevel() {
|
Level _minimumLogLevel() {
|
||||||
const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: '');
|
const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: '');
|
||||||
switch (raw.toLowerCase()) {
|
switch (raw.toLowerCase()) {
|
||||||
@@ -41,8 +43,13 @@ final logger = Logger(
|
|||||||
filter: _logFilter(),
|
filter: _logFilter(),
|
||||||
level: _minimumLogLevel(),
|
level: _minimumLogLevel(),
|
||||||
printer: KometLogPrinter(),
|
printer: KometLogPrinter(),
|
||||||
|
output: MultiOutput([ConsoleOutput(), FileLogOutput.instance]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<void> startFileLogging() => FileLogOutput.instance.start();
|
||||||
|
|
||||||
|
String? get logFilePath => FileLogOutput.instance.path;
|
||||||
|
|
||||||
int _importanceSortKey(Level level) {
|
int _importanceSortKey(Level level) {
|
||||||
final v = level.value;
|
final v = level.value;
|
||||||
if (v >= 5999) {
|
if (v >= 5999) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class CallScreen extends StatefulWidget {
|
|||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
final CallSession? session;
|
final CallSession? session;
|
||||||
final IncomingCall? incoming;
|
final IncomingCall? incoming;
|
||||||
|
final bool isGroup;
|
||||||
|
|
||||||
const CallScreen({
|
const CallScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -37,6 +38,7 @@ class CallScreen extends StatefulWidget {
|
|||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.session,
|
this.session,
|
||||||
this.incoming,
|
this.incoming,
|
||||||
|
this.isGroup = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -58,7 +60,9 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
late final AnimationController _dotsController;
|
late final AnimationController _dotsController;
|
||||||
late final AnimationController _videoController;
|
late final AnimationController _videoController;
|
||||||
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
|
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
|
||||||
|
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
|
||||||
bool _rendererReady = false;
|
bool _rendererReady = false;
|
||||||
|
bool _localRendererReady = false;
|
||||||
bool _videoAttached = false;
|
bool _videoAttached = false;
|
||||||
MediaStream? _pendingStream;
|
MediaStream? _pendingStream;
|
||||||
|
|
||||||
@@ -68,6 +72,21 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
late String _name = widget.name;
|
late String _name = widget.name;
|
||||||
late String? _avatarUrl = widget.avatarUrl;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -130,12 +149,15 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
|
|
||||||
Future<void> _initRenderer() async {
|
Future<void> _initRenderer() async {
|
||||||
await _remoteRenderer.initialize();
|
await _remoteRenderer.initialize();
|
||||||
|
await _localRenderer.initialize();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_rendererReady = true;
|
_rendererReady = true;
|
||||||
|
_localRendererReady = true;
|
||||||
if (_pendingStream != null) {
|
if (_pendingStream != null) {
|
||||||
_remoteRenderer.srcObject = _pendingStream;
|
_remoteRenderer.srcObject = _pendingStream;
|
||||||
_pendingStream = null;
|
_pendingStream = null;
|
||||||
}
|
}
|
||||||
|
_syncLocalPreview();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,15 +205,46 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
_stateSub = session.stateStream.listen(_onState);
|
_stateSub = session.stateStream.listen(_onState);
|
||||||
_infoSub = session.infoUpdates.listen((_) {
|
_infoSub = session.infoUpdates.listen((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
_isMuted = session.isMuted;
|
||||||
|
_resolveParticipants();
|
||||||
_syncVideo();
|
_syncVideo();
|
||||||
|
_syncLocalPreview();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
});
|
});
|
||||||
_remoteStreamSub = session.remoteStreamStream.listen(_attachStream);
|
_remoteStreamSub = session.remoteStreamStream.listen(_attachStream);
|
||||||
final existing = session.remoteStream;
|
final existing = session.remoteStream;
|
||||||
if (existing != null) _attachStream(existing);
|
if (existing != null) _attachStream(existing);
|
||||||
|
_resolveParticipants();
|
||||||
_syncVideo();
|
_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) {
|
void _onState(CallSessionState state) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _state = state);
|
setState(() => _state = state);
|
||||||
@@ -247,6 +300,39 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
await Helper.setSpeakerphoneOn(next);
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_stateSub?.cancel();
|
_stateSub?.cancel();
|
||||||
@@ -256,6 +342,8 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
_videoController.dispose();
|
_videoController.dispose();
|
||||||
_remoteRenderer.srcObject = null;
|
_remoteRenderer.srcObject = null;
|
||||||
_remoteRenderer.dispose();
|
_remoteRenderer.dispose();
|
||||||
|
_localRenderer.srcObject = null;
|
||||||
|
_localRenderer.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,11 +372,21 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = _darkScheme(context);
|
final cs = _darkScheme(context);
|
||||||
final avatar = _buildAvatar(cs);
|
final group = _isGroup && !_incomingPending;
|
||||||
final name = _buildName(cs);
|
|
||||||
final status = _buildStatus(cs);
|
final Widget body = group
|
||||||
final peerBar = _peerStateBar(cs);
|
? _buildGroupBody(cs)
|
||||||
final controls = _buildControls(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(
|
return Theme(
|
||||||
data: Theme.of(context).copyWith(colorScheme: cs),
|
data: Theme.of(context).copyWith(colorScheme: cs),
|
||||||
@@ -300,22 +398,290 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
),
|
),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: cs.surface,
|
backgroundColor: cs.surface,
|
||||||
body: AnimatedBuilder(
|
body: Stack(
|
||||||
animation: _videoController,
|
children: [
|
||||||
builder: (context, _) => _buildBody(
|
body,
|
||||||
cs,
|
if (_session?.localVideo == true || _session?.localScreen == true)
|
||||||
avatar: avatar,
|
_localPreview(cs),
|
||||||
name: name,
|
],
|
||||||
status: status,
|
|
||||||
peerBar: peerBar,
|
|
||||||
controls: controls,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
Widget _buildBody(
|
||||||
ColorScheme cs, {
|
ColorScheme cs, {
|
||||||
required Widget avatar,
|
required Widget avatar,
|
||||||
@@ -529,8 +895,15 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
|
|
||||||
String get _displayName => _name.isEmpty ? 'Неизвестный' : _name;
|
String get _displayName => _name.isEmpty ? 'Неизвестный' : _name;
|
||||||
|
|
||||||
Widget _avatarCircle(double size, ColorScheme cs) {
|
Widget _avatarCircle(double size, ColorScheme cs) =>
|
||||||
final url = _avatarUrl;
|
_circleAvatar(size, cs, name: _displayName, url: _avatarUrl);
|
||||||
|
|
||||||
|
Widget _circleAvatar(
|
||||||
|
double size,
|
||||||
|
ColorScheme cs, {
|
||||||
|
required String name,
|
||||||
|
String? url,
|
||||||
|
}) {
|
||||||
return Container(
|
return Container(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@@ -556,14 +929,14 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 420,
|
memCacheWidth: 420,
|
||||||
memCacheHeight: 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) {
|
Widget _avatarFallback(double size, ColorScheme cs, String name) {
|
||||||
final letter = _displayName[0].toUpperCase();
|
final letter = (name.isEmpty ? '?' : name[0]).toUpperCase();
|
||||||
return Container(
|
return Container(
|
||||||
color: cs.primaryContainer,
|
color: cs.primaryContainer,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
@@ -683,8 +1056,10 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _activeControls(ColorScheme cs) {
|
Widget _activeControls(ColorScheme cs) {
|
||||||
|
final video = _session?.localVideo == true;
|
||||||
|
final screen = _session?.localScreen == true;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -696,11 +1071,20 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
onTap: _toggleSpeaker,
|
onTap: _toggleSpeaker,
|
||||||
),
|
),
|
||||||
_CallButton(
|
_CallButton(
|
||||||
icon: Symbols.videocam_off,
|
icon: video ? Symbols.videocam : Symbols.videocam_off,
|
||||||
label: 'Видео',
|
label: 'Видео',
|
||||||
background: cs.surfaceContainerHighest,
|
background: video ? cs.primary : cs.surfaceContainerHighest,
|
||||||
foreground: cs.onSurface,
|
foreground: video ? cs.onPrimary : cs.onSurface,
|
||||||
onTap: () {},
|
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(
|
_CallButton(
|
||||||
icon: _isMuted ? Symbols.mic_off : Symbols.mic,
|
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 {
|
class _CallingDots extends StatelessWidget {
|
||||||
final Animation<double> animation;
|
final Animation<double> animation;
|
||||||
final Color color;
|
final Color color;
|
||||||
@@ -763,6 +1155,7 @@ class _CallButton extends StatelessWidget {
|
|||||||
final Color background;
|
final Color background;
|
||||||
final Color foreground;
|
final Color foreground;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
const _CallButton({
|
const _CallButton({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -770,6 +1163,7 @@ class _CallButton extends StatelessWidget {
|
|||||||
required this.background,
|
required this.background,
|
||||||
required this.foreground,
|
required this.foreground,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.busy = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -784,10 +1178,19 @@ class _CallButton extends StatelessWidget {
|
|||||||
child: GlossyPill(
|
child: GlossyPill(
|
||||||
color: background,
|
color: background,
|
||||||
borderRadius: BorderRadius.circular(31),
|
borderRadius: BorderRadius.circular(31),
|
||||||
onTap: onTap,
|
onTap: busy ? null : onTap,
|
||||||
depth: 9,
|
depth: 9,
|
||||||
child: Center(
|
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;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:package_info_plus/package_info_plus.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'backend/api.dart';
|
import 'backend/api.dart';
|
||||||
import 'core/cache/info_cache.dart';
|
import 'core/cache/info_cache.dart';
|
||||||
|
import 'core/utils/logger.dart';
|
||||||
import 'core/config/app_accent.dart';
|
import 'core/config/app_accent.dart';
|
||||||
import 'core/config/app_amoled.dart';
|
import 'core/config/app_amoled.dart';
|
||||||
import 'core/config/app_bubble_behavior.dart';
|
import 'core/config/app_bubble_behavior.dart';
|
||||||
@@ -78,6 +79,8 @@ Future<Locale> _loadInitialLocale() async {
|
|||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await startFileLogging();
|
||||||
|
logger.i('[log] file: ${logFilePath ?? '—'}');
|
||||||
await AppDatabase.init();
|
await AppDatabase.init();
|
||||||
final activeAccountId = await TokenStorage.getActiveAccountId();
|
final activeAccountId = await TokenStorage.getActiveAccountId();
|
||||||
if (activeAccountId != null) {
|
if (activeAccountId != null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user