feat: серверная топология е!!!!!!!!! убейте меня

This commit is contained in:
Jganenokk
2026-08-06 10:03:08 +07:00
parent 47fe58b852
commit a46ee7234c
9 changed files with 847 additions and 72 deletions
+84
View File
@@ -51,9 +51,13 @@ class Api {
int? _callsSeed; int? _callsSeed;
String? _deviceId; String? _deviceId;
String? _callsDevice;
String? _callsOsVersion;
int? get callsSeed => _callsSeed; int? get callsSeed => _callsSeed;
String? get deviceId => _deviceId; String? get deviceId => _deviceId;
String? get callsDevice => _callsDevice;
String? get callsOsVersion => _callsOsVersion;
/// Сырой доступ к сессии ядра — для медиа-загрузок (data-plane). /// Сырой доступ к сессии ядра — для медиа-загрузок (data-plane).
KolibriSession? get session => _session; KolibriSession? get session => _session;
@@ -387,6 +391,10 @@ class Api {
String instanceId = await DeviceIdentity.instanceId(); String instanceId = await DeviceIdentity.instanceId();
int clientSessionId = DeviceIdentity.clientSessionId; int clientSessionId = DeviceIdentity.clientSessionId;
String? androidManufacturer;
String? androidModel;
int? androidSdkInt;
if (Platform.isLinux) { if (Platform.isLinux) {
final linuxInfo = await deviceInfo.linuxInfo; final linuxInfo = await deviceInfo.linuxInfo;
osVersion = linuxInfo.name; osVersion = linuxInfo.name;
@@ -398,15 +406,20 @@ class Api {
final androidInfo = await deviceInfo.androidInfo; final androidInfo = await deviceInfo.androidInfo;
osVersion = 'Android ${androidInfo.version.release}'; osVersion = 'Android ${androidInfo.version.release}';
deviceName = '${androidInfo.manufacturer} ${androidInfo.model}'; deviceName = '${androidInfo.manufacturer} ${androidInfo.model}';
androidManufacturer = androidInfo.manufacturer;
androidModel = androidInfo.model;
androidSdkInt = androidInfo.version.sdkInt;
} else if (Platform.isWindows) { } else if (Platform.isWindows) {
final windowsInfo = await deviceInfo.windowsInfo; final windowsInfo = await deviceInfo.windowsInfo;
osVersion = windowsInfo.productName; osVersion = windowsInfo.productName;
} }
String? spoofUserAgent;
final spoofed = await SpoofingService.getSpoofedSessionData( final spoofed = await SpoofingService.getSpoofedSessionData(
scope: spoofScope, scope: spoofScope,
); );
if (spoofed != null) { if (spoofed != null) {
spoofUserAgent = spoofed['user_agent'] as String?;
final sDeviceType = spoofed['device_type'] as String?; final sDeviceType = spoofed['device_type'] as String?;
if (sDeviceType != null && sDeviceType != 'IOS') deviceType = sDeviceType; if (sDeviceType != null && sDeviceType != 'IOS') deviceType = sDeviceType;
final sDeviceName = spoofed['device_name'] as String?; final sDeviceName = spoofed['device_name'] as String?;
@@ -448,6 +461,19 @@ class Api {
if (sClientSession is int) clientSessionId = sClientSession; if (sClientSession is int) clientSessionId = sClientSession;
} }
_callsDevice = _resolveCallsDevice(
spoofed: spoofed != null,
deviceName: deviceName,
spoofUserAgent: spoofUserAgent,
manufacturer: androidManufacturer,
model: androidModel,
);
_callsOsVersion = _resolveCallsOsVersion(
spoofed: spoofed != null,
osVersion: osVersion,
sdkInt: androidSdkInt,
);
_userAgent = { _userAgent = {
'deviceType': deviceType, 'deviceType': deviceType,
'appVersion': appVersion, 'appVersion': appVersion,
@@ -491,6 +517,64 @@ class Api {
); );
} }
static String? _resolveCallsDevice({
required bool spoofed,
required String deviceName,
String? spoofUserAgent,
String? manufacturer,
String? model,
}) {
if (!spoofed &&
manufacturer != null &&
manufacturer.isNotEmpty &&
model != null &&
model.isNotEmpty) {
return '$manufacturer/$model';
}
final parts = deviceName.trim().split(RegExp(r'\s+'))
..removeWhere((p) => p.isEmpty);
if (parts.isEmpty) return null;
final fallbackModel = parts.length > 1
? parts.sublist(1).join(' ')
: parts.first;
return '${parts.first}/'
'${_modelFromUserAgent(spoofUserAgent) ?? fallbackModel}';
}
static String? _modelFromUserAgent(String? userAgent) {
if (userAgent == null || userAgent.isEmpty) return null;
final match = RegExp(r'Android\s+[\d.]+;\s*([^;)]+)').firstMatch(userAgent);
final model = match
?.group(1)
?.replaceFirst(RegExp(r'\s+Build/.*$'), '')
.trim();
return model == null || model.isEmpty ? null : model;
}
static String _resolveCallsOsVersion({
required bool spoofed,
required String osVersion,
int? sdkInt,
}) {
if (!spoofed && sdkInt != null && sdkInt > 0) return '$sdkInt';
final release = RegExp(
r'^Android\s+(\d+)',
).firstMatch(osVersion.trim())?.group(1);
return '${_androidSdkForRelease(int.tryParse(release ?? ''))}';
}
static int _androidSdkForRelease(int? release) => switch (release) {
null => 34,
<= 9 => 28,
10 => 29,
11 => 30,
12 => 31,
13 => 33,
14 => 34,
15 => 35,
_ => 36,
};
static Future<String?> _buildProxyUrl() async { static Future<String?> _buildProxyUrl() async {
final p = await ProxyConfig.load(); final p = await ProxyConfig.load();
if (!p.isEnabled) return null; if (!p.isEnabled) return null;
+3 -2
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'contacts.dart'; import 'contacts.dart';
import '../api.dart'; import '../api.dart';
import '../../core/calls/ws2_signaling.dart';
import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/opcode_map.dart';
import '../../core/utils/ids.dart'; import '../../core/utils/ids.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
@@ -178,13 +179,13 @@ class CallsModule {
String _internalParams() => jsonEncode({ String _internalParams() => jsonEncode({
'platform': 'ANDROID', 'platform': 'ANDROID',
'sdkVersion': '0.1.16.4', 'sdkVersion': '0.2.1.3',
'clientAppKey': 'CGPGAGLGDIHBABABA', 'clientAppKey': 'CGPGAGLGDIHBABABA',
'deviceId': _api.deviceId ?? '', 'deviceId': _api.deviceId ?? '',
'protocolVersion': 5, 'protocolVersion': 5,
'onlyAdminCanRecord': false, 'onlyAdminCanRecord': false,
'isWaitForAdminEnabled': false, 'isWaitForAdminEnabled': false,
'hexCapability': '3c03f', 'hexCapability': Ws2Config.defaultCapabilities,
}); });
Future<CallLinkPreview?> resolveCallLink(String url) async { Future<CallLinkPreview?> resolveCallLink(String url) async {
+10
View File
@@ -150,6 +150,8 @@ class CallController {
final config = Ws2Config.fromEndpoint( final config = Ws2Config.fromEndpoint(
out.endpoint, out.endpoint,
userId: out.callsUserId, userId: out.callsUserId,
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
); );
final session = CallSession(ws2Config: config, role: CallRole.caller); final session = CallSession(ws2Config: config, role: CallRole.caller);
_bind(session); _bind(session);
@@ -167,6 +169,8 @@ class CallController {
final config = Ws2Config.fromEndpoint( final config = Ws2Config.fromEndpoint(
out.endpoint, out.endpoint,
userId: out.callsUserId, userId: out.callsUserId,
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
); );
final session = CallSession( final session = CallSession(
ws2Config: config, ws2Config: config,
@@ -188,6 +192,8 @@ class CallController {
final config = Ws2Config.fromEndpoint( final config = Ws2Config.fromEndpoint(
params.endpoint, params.endpoint,
userId: params.callsUserId, userId: params.callsUserId,
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
); );
final session = CallSession( final session = CallSession(
ws2Config: config, ws2Config: config,
@@ -206,6 +212,8 @@ class CallController {
final config = Ws2Config.fromVcp( final config = Ws2Config.fromVcp(
call.params, call.params,
conversationId: call.conversationId, conversationId: call.conversationId,
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
); );
final session = CallSession( final session = CallSession(
ws2Config: config, ws2Config: config,
@@ -225,6 +233,8 @@ class CallController {
final config = Ws2Config.fromVcp( final config = Ws2Config.fromVcp(
call.params, call.params,
conversationId: call.conversationId, conversationId: call.conversationId,
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
); );
final signaling = Ws2Signaling(config); final signaling = Ws2Signaling(config);
try { try {
+337 -44
View File
@@ -11,6 +11,7 @@ import 'call_admin.dart';
import 'call_bridge.dart'; import 'call_bridge.dart';
import 'call_info.dart'; import 'call_info.dart';
import 'conversation_params.dart'; import 'conversation_params.dart';
import 'sfu_data_channel.dart';
import 'ws2_signaling.dart'; import 'ws2_signaling.dart';
enum CallRole { caller, callee, joiner } enum CallRole { caller, callee, joiner }
@@ -102,7 +103,7 @@ class CallSession {
RTCRtpSender? _videoSender; RTCRtpSender? _videoSender;
RTCRtpSender? _screenSender; RTCRtpSender? _screenSender;
Completer<void>? _gatherReady; Completer<void>? _gatherDone;
bool _gotConnection = false; bool _gotConnection = false;
bool _reconnecting = false; bool _reconnecting = false;
@@ -123,6 +124,27 @@ class CallSession {
RTCDataChannel? _probeChannel; RTCDataChannel? _probeChannel;
bool _peerIsKomet = false; bool _peerIsKomet = false;
final List<RTCDataChannel> _sfuChannels = [];
SfuCommandChannel? _sfuCommands;
StreamSubscription<Map<String, int>>? _sfuSlotSub;
StreamSubscription<Map<String, int>>? _sfuLevelSub;
final Map<int, int> _slotParticipant = {};
Timer? _layoutDebounce;
Timer? _videoStatsTimer;
List<String> _lastLayout = const [];
bool _layoutSent = false;
static const int _maxVideoSlots = 10;
static const int _sfuSpeakLevel = 50;
static const Duration _levelTtl = Duration(seconds: 6);
final Map<int, ({int level, DateTime at})> _levelState = {};
static const List<String> _sfuChannelLabels = [
'producerCommand',
'producerNotification',
];
static const bool _kometProbeEnabled = false;
static const String _probeQuestion = 'AreYouKomet?'; static const String _probeQuestion = 'AreYouKomet?';
static const String _probeAnswer = 'YesImKomet😎'; static const String _probeAnswer = 'YesImKomet😎';
@@ -206,6 +228,7 @@ class CallSession {
void _notifyInfo() { void _notifyInfo() {
if (!_info.isClosed) _info.add(null); if (!_info.isClosed) _info.add(null);
if (_topology == 'SERVER') _scheduleDisplayLayout();
} }
Future<void> start() async { Future<void> start() async {
@@ -304,6 +327,7 @@ class CallSession {
await _probeChannel?.close(); await _probeChannel?.close();
} catch (_) {} } catch (_) {}
_probeChannel = null; _probeChannel = null;
await _closeSfuChannels();
try { try {
await _pc?.close(); await _pc?.close();
@@ -339,7 +363,7 @@ class CallSession {
Future<void> _sampleLevels() async { Future<void> _sampleLevels() async {
final pc = _pc; final pc = _pc;
if (pc == null || _ended) return; if (pc == null || _ended || _topology == 'SERVER') return;
if (!_mediaConnected || _current != CallSessionState.active) return; if (!_mediaConnected || _current != CallSessionState.active) return;
var local = 0.0; var local = 0.0;
@@ -456,7 +480,7 @@ class CallSession {
void _onWs2Error(Map<String, dynamic> msg) { void _onWs2Error(Map<String, dynamic> msg) {
final err = msg['error']; final err = msg['error'];
logger.w('[call] ws2 error: $err'); logger.w('[call] ws2 error: $err raw=$msg');
if (err == 'conversation-ended') _end(); if (err == 'conversation-ended') _end();
} }
@@ -569,20 +593,17 @@ class CallSession {
if (externalId != null) p.externalId = externalId; if (externalId != null) p.externalId = externalId;
if (state != null) p.state = state; if (state != null) p.state = state;
if (mediaSettings is Map) { if (mediaSettings is Map) {
final a = mediaSettings['isAudioEnabled']; p.audioEnabled = mediaSettings['isAudioEnabled'] == true;
final v = mediaSettings['isVideoEnabled']; p.videoEnabled = mediaSettings['isVideoEnabled'] == true;
final s = mediaSettings['isScreenSharingEnabled']; p.screenSharing = mediaSettings['isScreenSharingEnabled'] == true;
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) { if (muteStates is Map) {
final a = muteStates['AUDIO']; final a = muteStates['AUDIO'];
final v = muteStates['VIDEO']; final v = muteStates['VIDEO'];
final s = muteStates['SCREEN_SHARING']; final s = muteStates['SCREEN_SHARING'];
if (a is String) p.audioEnabled = a == 'UNMUTE'; if (a is String && a != 'UNMUTE') p.audioEnabled = false;
if (v is String) p.videoEnabled = v == 'UNMUTE'; if (v is String && v != 'UNMUTE') p.videoEnabled = false;
if (s is String) p.screenSharing = s == 'UNMUTE'; if (s is String && s != 'UNMUTE') p.screenSharing = false;
} }
if (handRaised != null) p.handRaised = handRaised; if (handRaised != null) p.handRaised = handRaised;
if (roles is List) { if (roles is List) {
@@ -606,12 +627,16 @@ class CallSession {
void _onParticipantMedia(Map<String, dynamic> msg) { void _onParticipantMedia(Map<String, dynamic> msg) {
final id = _participantIdFrom(msg['participantId']); final id = _participantIdFrom(msg['participantId']);
if (id == null) return; if (id == null) return;
_upsertParticipant( final p = _upsertParticipant(
id, id,
externalId: _externalId(msg['externalId']), externalId: _externalId(msg['externalId']),
mediaSettings: msg['mediaSettings'], mediaSettings: msg['mediaSettings'],
muteStates: msg['muteStates'], muteStates: msg['muteStates'],
); );
logger.i(
'[call] media $id video=${p.videoEnabled} audio=${p.audioEnabled} '
'screen=${p.screenSharing} raw=${msg['mediaSettings']}',
);
_maybeAdoptPeer(id, msg); _maybeAdoptPeer(id, msg);
_notifyInfo(); _notifyInfo();
} }
@@ -706,8 +731,8 @@ class CallSession {
logger.i('[call] connection role=$role peer=$_peerId topology=$_topology'); logger.i('[call] connection role=$role peer=$_peerId topology=$_topology');
if (_topology == 'SERVER') { if (_topology == 'SERVER') {
await _setupSfu();
await accept(activate: role != CallRole.caller); await accept(activate: role != CallRole.caller);
await _setupSfu();
return; return;
} }
@@ -744,8 +769,17 @@ class CallSession {
'audioJitterBufferMaxPackets': 200, 'audioJitterBufferMaxPackets': 200,
}); });
pc.onIceCandidate = _onLocalCandidate; pc.onIceCandidate = _onLocalCandidate;
pc.onIceGatheringState = (s) {
logger.i('[call] ice gathering $s');
if (s != RTCIceGatheringState.RTCIceGatheringStateComplete) return;
final done = _gatherDone;
if (done != null && !done.isCompleted) done.complete();
};
pc.onTrack = (event) => unawaited(_onRemoteTrack(event)); pc.onTrack = (event) => unawaited(_onRemoteTrack(event));
pc.onDataChannel = (channel) => _bindProbeChannel(channel, ask: false); pc.onDataChannel = (channel) {
if (!_kometProbeEnabled) return;
_bindProbeChannel(channel, ask: false);
};
pc.onIceConnectionState = (s) { pc.onIceConnectionState = (s) {
logger.i('[call] ice $s'); logger.i('[call] ice $s');
if (s != RTCIceConnectionState.RTCIceConnectionStateFailed) return; if (s != RTCIceConnectionState.RTCIceConnectionStateFailed) return;
@@ -797,8 +831,180 @@ class CallSession {
} }
} }
Future<void> _openSfuChannels(RTCPeerConnection pc) async {
await _closeSfuChannels();
final commands = SfuCommandChannel();
_sfuCommands = commands;
_sfuSlotSub = commands.slotUpdates.listen(_onSfuSlots);
_sfuLevelSub = commands.audioLevels.listen(_onSfuLevels);
for (final label in _sfuChannelLabels) {
try {
final channel = await pc.createDataChannel(
label,
RTCDataChannelInit()
..ordered = true
..maxRetransmitTime = 10000000,
);
channel.onDataChannelState = (state) {
logger.i('[call][sfu] data channel $label $state');
if (state == RTCDataChannelState.RTCDataChannelOpen) {
_scheduleDisplayLayout();
}
};
commands.bind(channel);
_sfuChannels.add(channel);
} catch (e) {
logger.w('[call][sfu] data channel $label failed: $e');
}
}
}
void _onSfuLevels(Map<String, int> levels) {
final now = DateTime.now();
levels.forEach((key, level) {
final id = _participantIdFrom(key.split(':').first);
if (id != null) _levelState[id] = (level: level, at: now);
});
_levelState.removeWhere((_, v) => now.difference(v.at) > _levelTtl);
final loud = _levelState.entries
.where((e) => e.value.level >= _sfuSpeakLevel)
.map((e) => e.key)
.toSet();
logger.i('[call][sfu] levels: $levels speaking=$loud');
if (loud.length == _speaking.length && loud.containsAll(_speaking)) return;
_speaking = loud;
_notifyInfo();
}
void _onSfuSlots(Map<String, int> slots) {
if (slots.isEmpty) return;
_slotParticipant.clear();
slots.forEach((key, slot) {
if (slot < 0) return;
final id = _participantIdFrom(key.split(':').first);
if (id != null) _slotParticipant[slot] = id;
});
unawaited(_rebindSlotTracks());
}
Future<void> _rebindSlotTracks() async {
await _clearParticipantStreams();
await _collectReceivers();
_notifyInfo();
}
void _scheduleDisplayLayout() {
_layoutDebounce?.cancel();
_layoutDebounce = Timer(
const Duration(milliseconds: 300),
() => unawaited(_publishDisplayLayout()),
);
}
Future<void> _publishDisplayLayout({bool force = false}) async {
final commands = _sfuCommands;
if (commands == null || _topology != 'SERVER' || _ended) return;
final items = <SfuLayoutItem>[];
for (final p in _participants.values) {
if (p.isSelf || items.length >= _maxVideoSlots) continue;
if (!p.videoEnabled && !p.screenSharing) continue;
items.add(
SfuLayoutItem(
trackKey: 'u${p.id}:${p.screenSharing ? 'sSCREEN' : 'sCAMERA'}',
),
);
}
final keys = items.map((i) => i.trackKey).toList(growable: false);
if (!force &&
_layoutSent &&
keys.length == _lastLayout.length &&
keys.every(_lastLayout.contains)) {
return;
}
if (!await commands.sendDisplayLayout(items)) return;
_lastLayout = keys;
_layoutSent = true;
}
Set<String> _videoSlotMids(String sdp) {
final mids = <String>{};
String? kind;
String? mid;
var recvOnly = false;
void flush() {
final id = mid;
if (kind == 'video' && recvOnly && id != null) mids.add(id);
}
for (var line in sdp.split('\n')) {
line = line.trim();
if (line.startsWith('m=')) {
flush();
kind = line.substring(2).split(' ').first;
mid = null;
recvOnly = false;
} else if (line.startsWith('a=mid:')) {
mid = line.substring(6);
} else if (line == 'a=recvonly') {
recvOnly = true;
}
}
flush();
return mids;
}
Future<void> _prepareVideoSlot(RTCPeerConnection pc, String offerSdp) async {
final mids = _videoSlotMids(offerSdp);
if (mids.isEmpty) return;
for (final transceiver in await pc.getTransceivers()) {
final mid = transceiver.mid;
if (!mids.contains(mid)) continue;
final tracks =
_cameraStream?.getVideoTracks() ?? const <MediaStreamTrack>[];
if (tracks.isNotEmpty) {
try {
await transceiver.sender.replaceTrack(tracks.first);
} catch (e) {
logger.w('[call][sfu] video slot $mid replaceTrack failed: $e');
}
}
try {
await transceiver.setDirection(TransceiverDirection.SendOnly);
} catch (e) {
logger.w('[call][sfu] video slot $mid setDirection failed: $e');
continue;
}
_videoSender = transceiver.sender;
logger.i('[call][sfu] video slot mid=$mid -> sendonly');
return;
}
}
Future<void> _closeSfuChannels() async {
_layoutDebounce?.cancel();
_layoutDebounce = null;
await _sfuSlotSub?.cancel();
_sfuSlotSub = null;
await _sfuLevelSub?.cancel();
_sfuLevelSub = null;
await _sfuCommands?.dispose();
_sfuCommands = null;
_slotParticipant.clear();
_lastLayout = const [];
_layoutSent = false;
final channels = List<RTCDataChannel>.from(_sfuChannels);
_sfuChannels.clear();
for (final channel in channels) {
try {
await channel.close();
} catch (_) {}
}
}
Future<void> _setupKometProbe(RTCPeerConnection pc) async { Future<void> _setupKometProbe(RTCPeerConnection pc) async {
if (_topology == 'SERVER') return; if (!_kometProbeEnabled || _topology == 'SERVER') return;
try { try {
final channel = await pc.createDataChannel( final channel = await pc.createDataChannel(
'komet', 'komet',
@@ -897,6 +1103,7 @@ class CallSession {
Future<void> _setupSfu() async { Future<void> _setupSfu() async {
if (_pc != null) { if (_pc != null) {
await _closeSfuChannels();
await _pc!.close(); await _pc!.close();
_pc = null; _pc = null;
_probeChannel = null; _probeChannel = null;
@@ -915,6 +1122,7 @@ class CallSession {
_pc = pc; _pc = pc;
await _addLocalMedia(pc); await _addLocalMedia(pc);
await _republishVideo(pc); await _republishVideo(pc);
await _openSfuChannels(pc);
logger.i( logger.i(
'[call][sfu] allocate-consumer camera=$_localVideo screen=$_localScreen', '[call][sfu] allocate-consumer camera=$_localVideo screen=$_localScreen',
); );
@@ -927,6 +1135,7 @@ class CallSession {
} }
Future<void> _rebuildSfuPc() async { Future<void> _rebuildSfuPc() async {
await _closeSfuChannels();
try { try {
await _pc?.close(); await _pc?.close();
} catch (_) {} } catch (_) {}
@@ -951,6 +1160,7 @@ class CallSession {
_pc = pc; _pc = pc;
await _addLocalMedia(pc); await _addLocalMedia(pc);
await _republishVideo(pc); await _republishVideo(pc);
await _openSfuChannels(pc);
} }
Future<void> _republishVideo(RTCPeerConnection pc) async { Future<void> _republishVideo(RTCPeerConnection pc) async {
@@ -1020,10 +1230,12 @@ class CallSession {
'(${_candidateTypes(sdp)}), ${_sdpSummary(sdp)}, ice=${_iceServerUrls()}', '(${_candidateTypes(sdp)}), ${_sdpSummary(sdp)}, ice=${_iceServerUrls()}',
); );
logger.i('[call][sfu] producer m-lines: ${_mLineDetails(sdp)}'); logger.i('[call][sfu] producer m-lines: ${_mLineDetails(sdp)}');
logger.i('[call][sfu] producer video codecs: ${_videoCodecs(sdp)}');
await pc.setRemoteDescription(RTCSessionDescription(sdp, type)); await pc.setRemoteDescription(RTCSessionDescription(sdp, type));
_remoteDescSet = true; _remoteDescSet = true;
await _flushCandidates(); await _flushCandidates();
await _addRemoteCandidatesFromSdp(pc, sdp); await _addRemoteCandidatesFromSdp(pc, sdp);
await _prepareVideoSlot(pc, sdp);
final answer = await pc.createAnswer({}); final answer = await pc.createAnswer({});
if (_pc != pc) return; if (_pc != pc) return;
@@ -1033,7 +1245,7 @@ class CallSession {
return; return;
} }
await _awaitReflexiveCandidates(pc); await _awaitIceGathering(pc);
if (_pc != pc) { if (_pc != pc) {
logger.w('[call][sfu] peer connection replaced while gathering'); logger.w('[call][sfu] peer connection replaced while gathering');
return; return;
@@ -1054,14 +1266,18 @@ class CallSession {
'gathering=${pc.iceGatheringState}', 'gathering=${pc.iceGatheringState}',
); );
logger.i('[call][sfu] answer m-lines: ${_mLineDetails(answerSdp)}'); logger.i('[call][sfu] answer m-lines: ${_mLineDetails(answerSdp)}');
logger.i('[call][sfu] answer video codecs: ${_videoCodecs(answerSdp)}');
logger.i(
'[call][sfu] video feedback: offer=[${_videoFeedback(sdp)}] '
'answer=[${_videoFeedback(answerSdp)}]',
);
await _logSenders(); await _logSenders();
try { try {
final localSsrcs = _extractSsrcs(answerSdp); logger.i('[call][sfu] accept-producer ssrcs=$ssrcs');
logger.i('[call][sfu] accept-producer ssrcs=$localSsrcs');
final reply = await _signaling?.acceptProducer( final reply = await _signaling?.acceptProducer(
description: _labelLocalTracks(answerSdp), description: _labelLocalTracks(answerSdp),
ssrcs: localSsrcs, ssrcs: ssrcs,
sessionId: _sfuSessionId, sessionId: _sfuSessionId,
); );
logger.i('[call][sfu] accept-producer reply: $reply'); logger.i('[call][sfu] accept-producer reply: $reply');
@@ -1072,9 +1288,18 @@ class CallSession {
Timer(const Duration(seconds: 5), () { Timer(const Duration(seconds: 5), () {
if (_pc == pc && !_ended) unawaited(_dumpIceStats(pc)); if (_pc == pc && !_ended) unawaited(_dumpIceStats(pc));
}); });
_videoStatsTimer?.cancel();
_videoStatsTimer = Timer.periodic(const Duration(seconds: 5), (t) {
if (_pc != pc || _ended) {
t.cancel();
return;
}
unawaited(_dumpVideoStats(pc));
});
if (_accepted) await _sendMediaSettings(); if (_accepted) await _sendMediaSettings();
unawaited(_collectReceivers()); unawaited(_collectReceivers());
unawaited(_publishDisplayLayout(force: true));
} }
int _countCandidates(String sdp) => int _countCandidates(String sdp) =>
@@ -1119,6 +1344,41 @@ class CallSession {
); );
} }
Future<void> _dumpVideoStats(RTCPeerConnection pc) async {
try {
final rows = <String>[];
for (final r in await pc.getStats()) {
if (r.type != 'inbound-rtp') continue;
final v = r.values;
if (v['kind'] != 'video' && v['mediaType'] != 'video') continue;
rows.add(
'[ssrc=${v['ssrc']} bytes=${v['bytesReceived']} '
'packets=${v['packetsReceived']} decoded=${v['framesDecoded']} '
'${v['frameWidth']}x${v['frameHeight']}]',
);
}
var transportBytes = 0;
var audioBytes = 0;
for (final r in await pc.getStats()) {
final v = r.values;
if (r.type == 'transport') {
final b = v['bytesReceived'];
if (b is num) transportBytes += b.toInt();
} else if (r.type == 'inbound-rtp' &&
(v['kind'] == 'audio' || v['mediaType'] == 'audio')) {
final b = v['bytesReceived'];
if (b is num) audioBytes += b.toInt();
}
}
logger.i(
'[call][sfu] inbound video: ${rows.join(' ')} '
'| transport=$transportBytes audio=$audioBytes',
);
} catch (e) {
logger.w('[call][sfu] video stats failed: $e');
}
}
Future<void> _dumpIceStats(RTCPeerConnection pc) async { Future<void> _dumpIceStats(RTCPeerConnection pc) async {
try { try {
final reports = await pc.getStats(); final reports = await pc.getStats();
@@ -1181,6 +1441,38 @@ class CallSession {
'setup=$setup$lite'; 'setup=$setup$lite';
} }
String _videoFeedback(String sdp) {
final fb = <String>{};
var inVideo = false;
for (var line in sdp.split('\n')) {
line = line.trim();
if (line.startsWith('m=')) {
inVideo = line.startsWith('m=video');
} else if (inVideo && line.startsWith('a=rtcp-fb:')) {
final idx = line.indexOf(' ');
if (idx > 0) fb.add(line.substring(idx + 1));
} else if (inVideo && line.startsWith('a=extmap:')) {
if (line.contains('transport-wide-cc')) fb.add('extmap:transport-cc');
}
}
return fb.isEmpty ? 'нет' : fb.join(', ');
}
String _videoCodecs(String sdp) {
final codecs = <String>{};
var inVideo = false;
for (var line in sdp.split('\n')) {
line = line.trim();
if (line.startsWith('m=')) {
inVideo = line.startsWith('m=video');
} else if (inVideo && line.startsWith('a=rtpmap:')) {
final m = RegExp(r'^a=rtpmap:\d+ ([^/]+)/').firstMatch(line);
if (m != null) codecs.add(m.group(1)!);
}
}
return codecs.isEmpty ? 'нет' : codecs.join(',');
}
int _mLines(String sdp) => int _mLines(String sdp) =>
RegExp(r'^m=', multiLine: true).allMatches(sdp).length; RegExp(r'^m=', multiLine: true).allMatches(sdp).length;
@@ -1298,6 +1590,10 @@ class CallSession {
int? _participantFromTrackId(String? trackId) { int? _participantFromTrackId(String? trackId) {
if (trackId == null) return null; if (trackId == null) return null;
final slot = RegExp(r'^video-pat-(\d+)$').firstMatch(trackId);
if (slot != null) {
return _slotParticipant[int.parse(slot.group(1)!)];
}
for (final prefix in const ['video-', 'audio-']) { for (final prefix in const ['video-', 'audio-']) {
if (trackId.length > prefix.length && trackId.startsWith(prefix)) { if (trackId.length > prefix.length && trackId.startsWith(prefix)) {
final parsed = _participantIdFrom(trackId.substring(prefix.length)); final parsed = _participantIdFrom(trackId.substring(prefix.length));
@@ -1505,41 +1801,31 @@ class CallSession {
} }
} }
static bool _isReflexive(String? line) => Future<void> _awaitIceGathering(
line != null &&
(line.contains(' typ srflx') || line.contains(' typ relay'));
Future<void> _awaitReflexiveCandidates(
RTCPeerConnection pc, { RTCPeerConnection pc, {
Duration timeout = const Duration(seconds: 4), Duration timeout = const Duration(seconds: 5),
}) async { }) async {
if (pc.iceGatheringState == if (pc.iceGatheringState ==
RTCIceGatheringState.RTCIceGatheringStateComplete) { RTCIceGatheringState.RTCIceGatheringStateComplete) {
return; return;
} }
final done = Completer<void>();
_gatherDone = done;
try { try {
final current = await pc.getLocalDescription(); await done.future.timeout(timeout);
if (_isReflexive(current?.sdp)) return; logger.i('[call][sfu] relay candidate gathered');
} catch (_) {}
final ready = Completer<void>();
_gatherReady = ready;
try {
await ready.future.timeout(timeout);
logger.i('[call][sfu] reflexive candidate gathered');
} catch (_) { } catch (_) {
logger.w('[call][sfu] no reflexive candidate within $timeout'); logger.w('[call][sfu] no relay candidate within $timeout');
} finally { } finally {
_gatherReady = null; _gatherDone = null;
} }
} }
void _onLocalCandidate(RTCIceCandidate candidate) { void _onLocalCandidate(RTCIceCandidate candidate) {
final pending = _gatherReady; final line = candidate.candidate;
if (pending != null && if (line != null && line.contains(' typ relay')) {
!pending.isCompleted && final done = _gatherDone;
_isReflexive(candidate.candidate)) { if (done != null && !done.isCompleted) done.complete();
pending.complete();
} }
if (_topology == 'SERVER') return; if (_topology == 'SERVER') return;
final peerId = _peerId; final peerId = _peerId;
@@ -1732,9 +2018,14 @@ class CallSession {
} }
Future<void> _clearParticipantStreams() async { Future<void> _clearParticipantStreams() async {
final streams = _participantStreams.values.toList(growable: false); final entries = Map<int, MediaStream>.from(_participantStreams);
_participantStreams.clear(); _participantStreams.clear();
for (final stream in streams) { for (final id in entries.keys) {
if (!_participantStreamUpdates.isClosed) {
_participantStreamUpdates.add(id);
}
}
for (final stream in entries.values) {
try { try {
await stream.dispose(); await stream.dispose();
} catch (_) {} } catch (_) {}
@@ -1779,10 +2070,12 @@ class CallSession {
Future<void> _dispose() async { Future<void> _dispose() async {
_levelTimer?.cancel(); _levelTimer?.cancel();
_videoStatsTimer?.cancel();
try { try {
await _probeChannel?.close(); await _probeChannel?.close();
} catch (_) {} } catch (_) {}
_probeChannel = null; _probeChannel = null;
await _closeSfuChannels();
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) { for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
await track.stop(); await track.stop();
} }
+346
View File
@@ -0,0 +1,346 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../utils/logger.dart';
class MsgpackWriter {
final BytesBuilder _out = BytesBuilder();
Uint8List takeBytes() => _out.takeBytes();
void raw(int byte) => _out.addByte(byte);
void nil() => raw(0xC0);
void boolean(bool value) => raw(value ? 0xC3 : 0xC2);
void integer(int value) {
if (value >= 0) {
if (value < 0x80) return raw(value);
if (value <= 0xFF) {
raw(0xCC);
return raw(value);
}
if (value <= 0xFFFF) {
raw(0xCD);
return _uint(value, 2);
}
if (value <= 0xFFFFFFFF) {
raw(0xCE);
return _uint(value, 4);
}
raw(0xCF);
return _uint(value, 8);
}
if (value >= -32) return raw(0xE0 | (value + 32));
if (value >= -128) {
raw(0xD0);
return _uint(value & 0xFF, 1);
}
if (value >= -32768) {
raw(0xD1);
return _uint(value & 0xFFFF, 2);
}
if (value >= -2147483648) {
raw(0xD2);
return _uint(value & 0xFFFFFFFF, 4);
}
raw(0xD3);
_uint(value, 8);
}
void string(String value) {
final bytes = utf8.encode(value);
final length = bytes.length;
if (length < 32) {
raw(0xA0 | length);
} else if (length <= 0xFF) {
raw(0xD9);
raw(length);
} else if (length <= 0xFFFF) {
raw(0xDA);
_uint(length, 2);
} else {
raw(0xDB);
_uint(length, 4);
}
_out.add(bytes);
}
void arrayHeader(int length) {
if (length < 16) return raw(0x90 | length);
if (length <= 0xFFFF) {
raw(0xDC);
return _uint(length, 2);
}
raw(0xDD);
_uint(length, 4);
}
void _uint(int value, int bytes) {
for (var shift = (bytes - 1) * 8; shift >= 0; shift -= 8) {
raw((value >> shift) & 0xFF);
}
}
}
class MsgpackReader {
final Uint8List _data;
int _pos = 0;
MsgpackReader(this._data);
bool get exhausted => _pos >= _data.length;
bool get nextIsString {
final b = _data[_pos];
return (b & 0xE0) == 0xA0 || b == 0xD9 || b == 0xDA || b == 0xDB;
}
int readInt() {
final b = _data[_pos++];
if (b < 0x80) return b;
if (b >= 0xE0) return b - 256;
switch (b) {
case 0xCC:
return _uint(1);
case 0xCD:
return _uint(2);
case 0xCE:
return _uint(4);
case 0xCF:
return _uint(8);
case 0xD0:
final v = _uint(1);
return v >= 0x80 ? v - 0x100 : v;
case 0xD1:
final v = _uint(2);
return v >= 0x8000 ? v - 0x10000 : v;
case 0xD2:
final v = _uint(4);
return v >= 0x80000000 ? v - 0x100000000 : v;
case 0xD3:
return _uint(8);
}
throw FormatException('не целое: 0x${b.toRadixString(16)}');
}
String readString() {
final b = _data[_pos++];
int length;
if ((b & 0xE0) == 0xA0) {
length = b & 0x1F;
} else if (b == 0xD9) {
length = _uint(1);
} else if (b == 0xDA) {
length = _uint(2);
} else if (b == 0xDB) {
length = _uint(4);
} else {
throw FormatException('не строка: 0x${b.toRadixString(16)}');
}
final value = utf8.decode(_data.sublist(_pos, _pos + length));
_pos += length;
return value;
}
int readMapHeader() {
final b = _data[_pos++];
if ((b & 0xF0) == 0x80) return b & 0x0F;
if (b == 0xDE) return _uint(2);
if (b == 0xDF) return _uint(4);
throw FormatException('не map: 0x${b.toRadixString(16)}');
}
int readArrayHeader() {
final b = _data[_pos++];
if ((b & 0xF0) == 0x90) return b & 0x0F;
if (b == 0xDC) return _uint(2);
if (b == 0xDD) return _uint(4);
throw FormatException('не array: 0x${b.toRadixString(16)}');
}
int _uint(int bytes) {
var value = 0;
for (var i = 0; i < bytes; i++) {
value = (value << 8) | _data[_pos++];
}
return value;
}
}
class SfuLayoutItem {
final String trackKey;
final int width;
final int height;
const SfuLayoutItem({
required this.trackKey,
this.width = 640,
this.height = 360,
});
}
class SfuCommandChannel {
static const int _commandDisplayLayout = 0;
static const int _fitMode = 0;
static const int _notifyAliases = 1;
static const int _notifySlots = 2;
static const int _notifyAudioLevels = 6;
RTCDataChannel? _command;
int _sequence = 1;
final Map<int, String> _aliases = {};
final _slots = StreamController<Map<String, int>>.broadcast();
final _levels = StreamController<Map<String, int>>.broadcast();
Stream<Map<String, int>> get slotUpdates => _slots.stream;
Stream<Map<String, int>> get audioLevels => _levels.stream;
void bind(RTCDataChannel channel) {
if (channel.label == 'producerCommand') {
_command = channel;
channel.onMessage = _onCommandReply;
return;
}
if (channel.label != 'producerNotification') return;
channel.onMessage = _onNotification;
}
bool get ready => _command?.state == RTCDataChannelState.RTCDataChannelOpen;
Future<bool> sendDisplayLayout(
List<SfuLayoutItem> items, {
bool snapshot = true,
}) async {
final channel = _command;
if (channel == null) return false;
if (channel.state != RTCDataChannelState.RTCDataChannelOpen) {
logger.w('[call][sfu] producerCommand не открыт, слои не отправлены');
return false;
}
final writer = MsgpackWriter()
..integer(_commandDisplayLayout)
..integer(0)
..integer(_sequence++)
..boolean(snapshot);
if (items.isEmpty) {
writer.nil();
} else {
writer.arrayHeader(items.length * 2);
for (final item in items) {
writer
..string(item.trackKey)
..integer(0)
..nil()
..integer(item.width)
..integer(item.height)
..integer(_fitMode);
}
}
writer.nil();
final payload = writer.takeBytes();
try {
await channel.send(RTCDataChannelMessage.fromBinary(payload));
logger.i(
'[call][sfu] update-display-layout: '
'${items.map((i) => i.trackKey).join(', ')} '
'raw=${payload.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
return true;
} catch (e) {
logger.w('[call][sfu] update-display-layout failed: $e');
return false;
}
}
void _onCommandReply(RTCDataChannelMessage message) {
if (!message.isBinary) return;
final bytes = message.binary;
final head = bytes.length > 32 ? bytes.sublist(0, 32) : bytes;
final hex = head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
try {
final reader = MsgpackReader(bytes);
final type = reader.readInt();
final version = reader.readInt();
final error = reader.readInt();
if (error != 0) {
logger.w(
'[call][sfu] command reply type=$type version=$version '
'ERROR=$error raw=$hex',
);
return;
}
logger.i('[call][sfu] command reply type=$type ok raw=$hex');
} catch (e) {
logger.w('[call][sfu] command reply parse failed: $e raw=$hex');
}
}
int _dumped = 0;
void _onNotification(RTCDataChannelMessage message) {
if (!message.isBinary) return;
if (_dumped < 12) {
_dumped++;
final bytes = message.binary;
final head = bytes.length > 64 ? bytes.sublist(0, 64) : bytes;
logger.i(
'[call][sfu] notify raw len=${bytes.length} '
'${head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
}
final bytes = message.binary;
if (bytes.isEmpty) return;
final type = bytes[0];
final reader = MsgpackReader(Uint8List.sublistView(bytes, 1));
try {
switch (type) {
case _notifyAliases:
final count = reader.readMapHeader();
for (var i = 0; i < count; i++) {
final key = reader.readString();
_aliases[reader.readInt()] = key;
}
break;
case _notifySlots:
final count = reader.readArrayHeader();
final slots = <String, int>{};
for (var i = 0; i < count; i++) {
final key = _aliases[reader.readInt()];
if (key != null) slots[key] = i;
}
logger.i('[call][sfu] slots: $slots');
if (!_slots.isClosed) _slots.add(slots);
break;
case _notifyAudioLevels:
final count = reader.readMapHeader();
final levels = <String, int>{};
for (var i = 0; i < count; i++) {
final key = _aliases[reader.readInt()];
final level = reader.readInt();
if (key != null) levels[key] = level;
}
if (!_levels.isClosed) _levels.add(levels);
break;
}
} catch (e) {
logger.w('[call][sfu] notify type=$type parse failed: $e');
}
}
Future<void> dispose() async {
_command = null;
_aliases.clear();
if (!_slots.isClosed) await _slots.close();
if (!_levels.isClosed) await _levels.close();
}
}
+14 -21
View File
@@ -20,8 +20,10 @@ class Ws2Config {
const Ws2Config({required this.uri, required this.userId}); const Ws2Config({required this.uri, required this.userId});
static const defaultCapabilities = '3c03f'; static const defaultCapabilities = '3c02f';
static const _appVersion = 'sdk-0.1.16.4'; static const _appVersion = 'sdk-0.2.1.3';
static const defaultDevice = 'Android/Unknown';
static const defaultOsVersion = '34';
/// Входящий звонок: из распакованных параметров [ConversationParams]. /// Входящий звонок: из распакованных параметров [ConversationParams].
/// `userId` — часть после `:` в [ConversationParams.turnUser]. /// `userId` — часть после `:` в [ConversationParams.turnUser].
@@ -29,23 +31,22 @@ class Ws2Config {
ConversationParams params, { ConversationParams params, {
required String conversationId, required String conversationId,
String capabilities = defaultCapabilities, String capabilities = defaultCapabilities,
String device = 'Komet', String? device,
String osVersion = '36', String? osVersion,
}) { }) {
final userId = int.tryParse((params.turnUser ?? '').split(':').last) ?? 0; final userId = int.tryParse((params.turnUser ?? '').split(':').last) ?? 0;
final uri = Uri.parse(params.wsEndpoint).replace( final uri = Uri.parse(params.wsEndpoint).replace(
queryParameters: { queryParameters: {
'userId': '$userId', 'userId': '$userId',
'entityType': 'USER',
'conversationId': conversationId,
'token': params.token, 'token': params.token,
'conversationId': conversationId,
'version': '5', 'version': '5',
'capabilities': capabilities, 'capabilities': capabilities,
'device': device, 'device': device ?? defaultDevice,
'platform': 'ANDROID', 'platform': 'ANDROID',
'clientType': 'ONE_ME', 'clientType': 'ONE_ME',
'appVersion': _appVersion, 'appVersion': _appVersion,
'osVersion': osVersion, 'osVersion': osVersion ?? defaultOsVersion,
}, },
); );
return Ws2Config(uri: uri, userId: userId); return Ws2Config(uri: uri, userId: userId);
@@ -57,19 +58,20 @@ class Ws2Config {
String endpoint, { String endpoint, {
required int userId, required int userId,
String capabilities = defaultCapabilities, String capabilities = defaultCapabilities,
String device = 'Komet', String? device,
String? osVersion,
}) { }) {
final base = Uri.parse(endpoint); final base = Uri.parse(endpoint);
final uri = base.replace( final uri = base.replace(
queryParameters: { queryParameters: {
...base.queryParameters, ...base.queryParameters,
'platform': 'ANDROID',
'version': '5', 'version': '5',
'capabilities': capabilities, 'capabilities': capabilities,
'device': device ?? defaultDevice,
'platform': 'ANDROID',
'clientType': 'ONE_ME', 'clientType': 'ONE_ME',
'appVersion': _appVersion, 'appVersion': _appVersion,
'device': device, 'osVersion': osVersion ?? defaultOsVersion,
'tgt': 'start',
}, },
); );
return Ws2Config(uri: uri, userId: userId); return Ws2Config(uri: uri, userId: userId);
@@ -283,16 +285,7 @@ class Ws2Signaling {
'singleSession': true, 'singleSession': true,
'unifiedPlan': true, 'unifiedPlan': true,
'fastScreenShare': true, 'fastScreenShare': true,
'producerScreenDataChannelVersion': 1,
'consumerScreenDataChannelVersion': 1,
'animojiDataChannelVersion': 2,
'animojiBackendRender': true,
'asrDataChannelVersion': 1,
'consumerFastScreenShare': true,
'consumerFastScreenShareQualityOnDemand': true, 'consumerFastScreenShareQualityOnDemand': true,
'audioShare': true,
'simulcast': true,
'simulcastNativeOrder': true,
'red': true, 'red': true,
'videoTracksCount': 10, 'videoTracksCount': 10,
'csrcAccessible': true, 'csrcAccessible': true,
+1
View File
@@ -144,6 +144,7 @@ class SpoofingService {
'instance_id': profile.instanceId, 'instance_id': profile.instanceId,
'client_session_id': profile.clientSessionId, 'client_session_id': profile.clientSessionId,
'push_device_type': profile.pushDeviceType, 'push_device_type': profile.pushDeviceType,
'user_agent': profile.userAgent,
}; };
} }
+46 -3
View File
@@ -74,6 +74,8 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
late final AnimationController _videoController; late final AnimationController _videoController;
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer(); final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
final RTCVideoRenderer _localRenderer = RTCVideoRenderer(); final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
final Map<int, RTCVideoRenderer> _tileRenderers = {};
StreamSubscription<int>? _tileStreamSub;
bool _rendererReady = false; bool _rendererReady = false;
bool _localRendererReady = false; bool _localRendererReady = false;
bool _videoAttached = false; bool _videoAttached = false;
@@ -89,6 +91,38 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
bool get _isGroup => widget.isGroup || (_session?.participantCount ?? 0) > 2; bool get _isGroup => widget.isGroup || (_session?.participantCount ?? 0) > 2;
void _onTileStream(int id) {
final stream = _session?.streamOf(id);
final existing = _tileRenderers[id];
if (existing != null) {
existing.srcObject = stream;
if (mounted) setState(() {});
return;
}
if (stream == null) return;
unawaited(_createTileRenderer(id, stream));
}
Future<void> _createTileRenderer(int id, MediaStream stream) async {
final renderer = RTCVideoRenderer();
await renderer.initialize();
if (!mounted) {
await renderer.dispose();
return;
}
renderer.srcObject = stream;
_tileRenderers[id] = renderer;
setState(() {});
}
RTCVideoRenderer? _tileRenderer(CallParticipant p) {
if (p.isSelf) return null;
final own = _tileRenderers[p.id];
final src = own?.srcObject;
if (src != null && src.getVideoTracks().isNotEmpty) return own;
return _tileVideoReady ? _remoteRenderer : null;
}
bool get _tileVideoReady { bool get _tileVideoReady {
if (_session?.topology == 'SERVER') return false; if (_session?.topology == 'SERVER') return false;
final others = (_session?.participants ?? const <CallParticipant>[]) final others = (_session?.participants ?? const <CallParticipant>[])
@@ -215,6 +249,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
setState(() {}); setState(() {});
}); });
_remoteStreamSub = session.remoteStreamStream.listen(_attachStream); _remoteStreamSub = session.remoteStreamStream.listen(_attachStream);
_tileStreamSub = session.participantStreamUpdates.listen(_onTileStream);
_kometSub = session.peerKometDetected.listen((_) => _showKometBadge()); _kometSub = session.peerKometDetected.listen((_) => _showKometBadge());
_chatSub = session.chatMessages.listen(_onChatMessage); _chatSub = session.chatMessages.listen(_onChatMessage);
if (session.peerIsKomet) { if (session.peerIsKomet) {
@@ -383,6 +418,12 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
_kometSub?.cancel(); _kometSub?.cancel();
_chatSub?.cancel(); _chatSub?.cancel();
_remoteStreamSub?.cancel(); _remoteStreamSub?.cancel();
_tileStreamSub?.cancel();
for (final renderer in _tileRenderers.values) {
renderer.srcObject = null;
renderer.dispose();
}
_tileRenderers.clear();
_dotsController.dispose(); _dotsController.dispose();
_videoController.dispose(); _videoController.dispose();
if (_rendererReady) _remoteRenderer.srcObject = null; if (_rendererReady) _remoteRenderer.srcObject = null;
@@ -622,8 +663,9 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
final url = p.isSelf ? _avatarUrl : info?.avatar; final url = p.isSelf ? _avatarUrl : info?.avatar;
final muted = p.isSelf ? _isMuted : !p.audioEnabled; final muted = p.isSelf ? _isMuted : !p.audioEnabled;
final speaking = !muted && _session?.isSpeaking(p.id) == true; final speaking = !muted && _session?.isSpeaking(p.id) == true;
final renderer = _tileRenderer(p);
final showVideo = final showVideo =
!p.isSelf && (p.videoEnabled || p.screenSharing) && _tileVideoReady; !p.isSelf && (p.videoEnabled || p.screenSharing) && renderer != null;
return GlossyPill( return GlossyPill(
color: cs.surfaceContainerHigh, color: cs.surfaceContainerHigh,
@@ -634,7 +676,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
: null, : null,
padding: EdgeInsets.all(showVideo ? 0 : 12), padding: EdgeInsets.all(showVideo ? 0 : 12),
child: showVideo child: showVideo
? _videoTile(cs, name, muted, p.handRaised, p.screenSharing) ? _videoTile(cs, renderer, name, muted, p.handRaised, p.screenSharing)
: _avatarTile(cs, name, url, muted, p.handRaised, p.screenSharing), : _avatarTile(cs, name, url, muted, p.handRaised, p.screenSharing),
); );
} }
@@ -719,6 +761,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Widget _videoTile( Widget _videoTile(
ColorScheme cs, ColorScheme cs,
RTCVideoRenderer renderer,
String name, String name,
bool muted, bool muted,
bool hand, bool hand,
@@ -730,7 +773,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
RTCVideoView( RTCVideoView(
_remoteRenderer, renderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
), ),
Positioned( Positioned(
+6 -2
View File
@@ -141,7 +141,9 @@ class GlossyPill extends StatelessWidget {
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: borderRadius, borderRadius: borderRadius,
border: GlossyDecor.rimBorder(base), border: borderSide != null
? Border.fromBorderSide(borderSide!)
: GlossyDecor.rimBorder(base),
boxShadow: [GlossyDecor.dropShadow(base, depth)], boxShadow: [GlossyDecor.dropShadow(base, depth)],
), ),
child: LiquidGlassSurface( child: LiquidGlassSurface(
@@ -204,7 +206,9 @@ class GlossyPill extends StatelessWidget {
borderRadius: borderRadius, borderRadius: borderRadius,
color: gradient ? null : base, color: gradient ? null : base,
gradient: gradient ? GlossyDecor.fillGradient(base) : null, gradient: gradient ? GlossyDecor.fillGradient(base) : null,
border: GlossyDecor.rimBorder(base), border: borderSide != null
? Border.fromBorderSide(borderSide!)
: GlossyDecor.rimBorder(base),
boxShadow: [GlossyDecor.dropShadow(base, depth)], boxShadow: [GlossyDecor.dropShadow(base, depth)],
), ),
child: ClipRRect( child: ClipRRect(