ПОШЛО НАХУЙ КТО ЭТИ ЗВОВНКИ ДЕЛАЛ
This commit is contained in:
@@ -135,6 +135,47 @@ class CallsModule {
|
||||
);
|
||||
}
|
||||
|
||||
Future<OutgoingCallParams> startGroupCall({bool isVideo = false}) async {
|
||||
final conversationId = uuidV4();
|
||||
logger.i('[call] VIDEO_CHAT_START_ACTIVE group conv=$conversationId');
|
||||
|
||||
final payload = await _api.sendRequestMap(Opcode.videoChatStartActive, {
|
||||
'conversationId': conversationId,
|
||||
'internalParams': _internalParams(),
|
||||
'isVideo': isVideo,
|
||||
});
|
||||
logger.i('[call] VIDEO_CHAT_START_ACTIVE keys=${payload?.keys.toList()}');
|
||||
|
||||
if (payload == null) {
|
||||
throw Exception('startGroupCall: bad response');
|
||||
}
|
||||
|
||||
final parsed = _parseCallerEndpoint(
|
||||
payload,
|
||||
'internalCallerParams',
|
||||
context: 'startGroupCall',
|
||||
);
|
||||
|
||||
return OutgoingCallParams(
|
||||
conversationId: (payload['conversationId'] as String?) ?? conversationId,
|
||||
endpoint: parsed.endpoint,
|
||||
callsUserId: parsed.callsUserId,
|
||||
peerExternalId: 0,
|
||||
isVideo: isVideo,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> createJoinLink(String conversationId) async {
|
||||
if (conversationId.isEmpty) return null;
|
||||
|
||||
final payload = await _api.sendRequestMap(Opcode.videoChatCreateJoinLink, {
|
||||
'conversationId': conversationId,
|
||||
});
|
||||
|
||||
final link = payload?['joinLink'];
|
||||
return link is String && link.isNotEmpty ? link : null;
|
||||
}
|
||||
|
||||
String _internalParams() => jsonEncode({
|
||||
'platform': 'ANDROID',
|
||||
'sdkVersion': '0.1.16.4',
|
||||
@@ -142,8 +183,8 @@ class CallsModule {
|
||||
'deviceId': _api.deviceId ?? '',
|
||||
'protocolVersion': 5,
|
||||
'onlyAdminCanRecord': false,
|
||||
'waitForAdmin': false,
|
||||
'capabilities': '3c03f',
|
||||
'isWaitForAdminEnabled': false,
|
||||
'hexCapability': '3c03f',
|
||||
});
|
||||
|
||||
Future<CallLinkPreview?> resolveCallLink(String url) async {
|
||||
@@ -165,11 +206,13 @@ class CallsModule {
|
||||
String token, {
|
||||
bool isVideo = false,
|
||||
}) async {
|
||||
logger.i('[call] VIDEO_CHAT_JOIN link=$token isVideo=$isVideo');
|
||||
final payload = await _api.sendRequestMap(Opcode.videoChatJoinByLink, {
|
||||
'joinLink': token,
|
||||
'internalParams': _internalParams(),
|
||||
'isVideo': isVideo,
|
||||
});
|
||||
logger.i('[call] VIDEO_CHAT_JOIN keys=${payload?.keys.toList()}');
|
||||
|
||||
if (payload == null) {
|
||||
throw Exception('joinByLink: bad response');
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import 'ws2_signaling.dart';
|
||||
|
||||
enum CallMedia {
|
||||
audio('AUDIO'),
|
||||
video('VIDEO'),
|
||||
screenShare('SCREEN_SHARING'),
|
||||
movieShare('MOVIE_SHARING');
|
||||
|
||||
const CallMedia(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
enum CallMuteState {
|
||||
unmute('UNMUTE'),
|
||||
mute('MUTE'),
|
||||
mutePermanent('MUTE_PERMANENT');
|
||||
|
||||
const CallMuteState(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
enum CallRoleName {
|
||||
creator('CREATOR'),
|
||||
admin('ADMIN'),
|
||||
speaker('SPEAKER');
|
||||
|
||||
const CallRoleName(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
enum CallOption {
|
||||
requireAuthToJoin('REQUIRE_AUTH_TO_JOIN'),
|
||||
waitingHall('WAITING_HALL'),
|
||||
recurring('RECURRING'),
|
||||
feedback('FEEDBACK'),
|
||||
audienceMode('AUDIENCE_MODE'),
|
||||
asr('ASR'),
|
||||
waitForAdmin('WAIT_FOR_ADMIN'),
|
||||
adminIsHere('ADMIN_IS_HERE');
|
||||
|
||||
const CallOption(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
enum CallFeature {
|
||||
addParticipant('ADD_PARTICIPANT'),
|
||||
admin('ADMIN'),
|
||||
asr('ASR'),
|
||||
movieShare('MOVIE_SHARE'),
|
||||
record('RECORD'),
|
||||
speaker('SPEAKER');
|
||||
|
||||
const CallFeature(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
enum CallListType {
|
||||
grid('GRID'),
|
||||
side('SIDE');
|
||||
|
||||
const CallListType(this.wire);
|
||||
final String wire;
|
||||
}
|
||||
|
||||
class CallParticipantRef {
|
||||
final int id;
|
||||
final int deviceIdx;
|
||||
final bool isGroup;
|
||||
|
||||
const CallParticipantRef(this.id, {this.deviceIdx = 0, this.isGroup = false});
|
||||
|
||||
String get wire => '${isGroup ? 'g' : 'u'}$id:d$deviceIdx';
|
||||
}
|
||||
|
||||
class CallAdmin {
|
||||
final Ws2Signaling _signaling;
|
||||
|
||||
const CallAdmin(this._signaling);
|
||||
|
||||
Future<void> requestMedia(
|
||||
Set<CallMedia> media, {
|
||||
CallParticipantRef? participant,
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'mute-participant',
|
||||
extra: {
|
||||
'participantId': ?participant?.wire,
|
||||
'requestedMedia': media.map((m) => m.wire).toList(),
|
||||
'roomId': ?roomId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setMuteStates(
|
||||
Map<CallMedia, CallMuteState?> states, {
|
||||
CallParticipantRef? participant,
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'mute-participant',
|
||||
extra: {
|
||||
'participantId': ?participant?.wire,
|
||||
'muteStates': {
|
||||
for (final media in CallMedia.values) media.wire: states[media]?.wire,
|
||||
},
|
||||
'roomId': ?roomId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> muteMicrophone(
|
||||
CallParticipantRef participant, {
|
||||
bool muted = true,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'switch-micro',
|
||||
extra: {'eId': participant.wire, 'muteTarget': muted},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> muteEveryone() {
|
||||
return _signaling.sendCommand(
|
||||
'switch-micro',
|
||||
extra: const {'all': true, 'muteTarget': true},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setPromoted(CallParticipantRef participant, bool promoted) {
|
||||
return _signaling.sendCommand(
|
||||
'promote-participant',
|
||||
extra: {'participantId': participant.wire, 'demote': !promoted},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setRoles(
|
||||
CallParticipantRef participant,
|
||||
List<CallRoleName> roles, {
|
||||
bool revoke = false,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'grant-roles',
|
||||
extra: {
|
||||
'participantId': participant.wire,
|
||||
'roles': roles.map((r) => r.wire).toList(),
|
||||
'revoke': revoke,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeParticipant(CallParticipantRef participant) {
|
||||
return _signaling.sendCommand(
|
||||
'remove-participant',
|
||||
extra: {'participantId': participant.wire},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setPinned(
|
||||
CallParticipantRef participant,
|
||||
bool pinned, {
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'pin-participant',
|
||||
extra: {
|
||||
'participantId': participant.wire,
|
||||
'unpin': !pinned,
|
||||
'roomId': ?roomId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setOptions(Map<CallOption, bool> options) {
|
||||
return _signaling.sendCommand(
|
||||
'change-options',
|
||||
extra: {
|
||||
'options': {
|
||||
for (final entry in options.entries) entry.key.wire: entry.value,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> enableFeatureForRoles(
|
||||
CallFeature feature,
|
||||
List<CallRoleName> roles,
|
||||
) {
|
||||
return _signaling.sendCommand(
|
||||
'enable-feature-for-roles',
|
||||
extra: {
|
||||
'feature': feature.wire,
|
||||
'roles': roles.map((r) => r.wire).toList(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> lowerAllHands() => _signaling.sendCommand('put-hands-down');
|
||||
|
||||
Future<void> setHandRaised(bool raised, {CallParticipantRef? participant}) {
|
||||
return _setState({'hand': raised ? '1' : '0'}, participant: participant);
|
||||
}
|
||||
|
||||
Future<void> setAssistanceRequested(
|
||||
bool requested, {
|
||||
CallParticipantRef? participant,
|
||||
}) {
|
||||
return _setState({'drat': requested ? '1' : '0'}, participant: participant);
|
||||
}
|
||||
|
||||
Future<void> _setState(
|
||||
Map<String, String> state, {
|
||||
CallParticipantRef? participant,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'change-participant-state',
|
||||
extra: {
|
||||
'participantState': {'state': state},
|
||||
'participantId': ?participant?.wire,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addParticipants(
|
||||
List<String> externalIds, {
|
||||
bool? unban,
|
||||
bool showChatHistory = false,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'add-participant',
|
||||
extra: {
|
||||
'externalIds': externalIds,
|
||||
if (unban == true) 'unban': true,
|
||||
if (showChatHistory) 'payload': '{"show_chat_history":true}',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addParticipantByLink(String link) {
|
||||
return _signaling.sendCommand(
|
||||
'add-participant',
|
||||
extra: {'participantIdAsQRCodeLink': link},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> startRecord({
|
||||
int? movieId,
|
||||
String? name,
|
||||
String? description,
|
||||
String? privacy,
|
||||
int? groupId,
|
||||
String? albumId,
|
||||
bool streamMovie = false,
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'record-start',
|
||||
extra: {
|
||||
'movieId': movieId,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'privacy': privacy,
|
||||
'groupId': groupId,
|
||||
'albumId': albumId,
|
||||
'streamMovie': streamMovie,
|
||||
'roomId': ?roomId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stopRecord({bool remove = false, String? roomId}) {
|
||||
return _signaling.sendCommand(
|
||||
'record-stop',
|
||||
extra: {if (remove) 'remove': true, 'roomId': ?roomId},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> participantChunk({
|
||||
int count = 50,
|
||||
CallListType listType = CallListType.grid,
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'get-participant-list-chunk',
|
||||
extra: {'count': count, 'listType': listType.wire, 'roomId': ?roomId},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> waitingHall({
|
||||
int count = 50,
|
||||
String? fromId,
|
||||
bool backward = false,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'get-waiting-hall',
|
||||
extra: {'count': count, 'fromId': ?fromId, 'backward': backward},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,18 @@ class CallBridge {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setScreenShare(bool enabled, {String? caller}) async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('setScreenShare', {
|
||||
'enabled': enabled,
|
||||
'caller': caller,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.setScreenShare: enabled=$enabled $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> notifyEnded() async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
|
||||
@@ -158,6 +158,27 @@ class CallController {
|
||||
return session;
|
||||
}
|
||||
|
||||
Future<({CallSession session, String? joinLink})> createGroupCall({
|
||||
bool isVideo = false,
|
||||
}) async {
|
||||
if (_active != null) throw StateError('уже идёт звонок');
|
||||
final out = await _calls!.startGroupCall(isVideo: isVideo);
|
||||
final joinLink = await _calls!.createJoinLink(out.conversationId);
|
||||
final config = Ws2Config.fromEndpoint(
|
||||
out.endpoint,
|
||||
userId: out.callsUserId,
|
||||
);
|
||||
final session = CallSession(
|
||||
ws2Config: config,
|
||||
role: CallRole.caller,
|
||||
isGroup: true,
|
||||
);
|
||||
_bind(session);
|
||||
await session.start();
|
||||
CallBridge.instance.notifyAccepted();
|
||||
return (session: session, joinLink: joinLink);
|
||||
}
|
||||
|
||||
Future<CallLinkPreview?> previewCallLink(String url) =>
|
||||
_calls!.resolveCallLink(url);
|
||||
|
||||
@@ -168,7 +189,11 @@ class CallController {
|
||||
params.endpoint,
|
||||
userId: params.callsUserId,
|
||||
);
|
||||
final session = CallSession(ws2Config: config, role: CallRole.joiner);
|
||||
final session = CallSession(
|
||||
ws2Config: config,
|
||||
role: CallRole.joiner,
|
||||
isGroup: true,
|
||||
);
|
||||
_bind(session);
|
||||
await session.start();
|
||||
CallBridge.instance.notifyAccepted();
|
||||
|
||||
@@ -61,7 +61,8 @@ class CallParse {
|
||||
for (final line in const LineSplitter().convert(sdp)) {
|
||||
if (!line.startsWith('o=')) continue;
|
||||
final l = line.toLowerCase();
|
||||
if (l.contains('mozilla') || l.contains('sdparta')) return 'Firefox (web)';
|
||||
if (l.contains('mozilla') || l.contains('sdparta'))
|
||||
return 'Firefox (web)';
|
||||
if (l.contains('gstreamer')) return 'GStreamer';
|
||||
return 'нативный libwebrtc';
|
||||
}
|
||||
|
||||
@@ -6,6 +6,5 @@ class CallLink {
|
||||
|
||||
static bool isCallLink(String url) => token(url) != null;
|
||||
|
||||
static String? token(String url) =>
|
||||
_pattern.firstMatch(url.trim())?.group(1);
|
||||
static String? token(String url) => _pattern.firstMatch(url.trim())?.group(1);
|
||||
}
|
||||
|
||||
+565
-104
@@ -7,6 +7,8 @@ import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
import '../utils/parse.dart';
|
||||
import 'call_admin.dart';
|
||||
import 'call_bridge.dart';
|
||||
import 'call_info.dart';
|
||||
import 'conversation_params.dart';
|
||||
import 'ws2_signaling.dart';
|
||||
@@ -24,6 +26,7 @@ class CallParticipant {
|
||||
bool videoEnabled;
|
||||
bool screenSharing;
|
||||
bool handRaised;
|
||||
List<String> roles;
|
||||
|
||||
CallParticipant({
|
||||
required this.id,
|
||||
@@ -34,7 +37,12 @@ class CallParticipant {
|
||||
this.videoEnabled = false,
|
||||
this.screenSharing = false,
|
||||
this.handRaised = false,
|
||||
this.roles = const [],
|
||||
});
|
||||
|
||||
bool get isAdmin => roles.contains('ADMIN') || roles.contains('CREATOR');
|
||||
bool get isCreator => roles.contains('CREATOR');
|
||||
bool get isSpeaker => roles.contains('SPEAKER');
|
||||
}
|
||||
|
||||
class CallChatMessage {
|
||||
@@ -50,8 +58,14 @@ class CallSession {
|
||||
|
||||
final ConversationParams? params;
|
||||
final CallRole role;
|
||||
final bool isGroup;
|
||||
|
||||
CallSession({required this.ws2Config, required this.role, this.params});
|
||||
CallSession({
|
||||
required this.ws2Config,
|
||||
required this.role,
|
||||
this.params,
|
||||
this.isGroup = false,
|
||||
});
|
||||
|
||||
Ws2Signaling? _signaling;
|
||||
RTCPeerConnection? _pc;
|
||||
@@ -81,8 +95,20 @@ class CallSession {
|
||||
|
||||
bool _localVideo = false;
|
||||
bool _localScreen = false;
|
||||
MediaStream? _localVideoStream;
|
||||
MediaStream? _cameraStream;
|
||||
MediaStream? _screenStream;
|
||||
RTCRtpSender? _videoSender;
|
||||
RTCRtpSender? _screenSender;
|
||||
bool _fastScreenShare = false;
|
||||
|
||||
bool _reconnecting = false;
|
||||
bool _iceRestarting = false;
|
||||
int _iceRestarts = 0;
|
||||
static const int _maxIceRestarts = 6;
|
||||
static const int _maxReconnectAttempts = 12;
|
||||
static const Duration _maxReconnectDelay = Duration(seconds: 20);
|
||||
|
||||
bool get isReconnecting => _reconnecting;
|
||||
|
||||
Timer? _levelTimer;
|
||||
final Map<int, int> _speakHold = {};
|
||||
@@ -109,7 +135,15 @@ class CallSession {
|
||||
|
||||
bool get localVideo => _localVideo;
|
||||
bool get localScreen => _localScreen;
|
||||
MediaStream? get localVideoStream => _localVideoStream;
|
||||
MediaStream? get localVideoStream =>
|
||||
_localScreen ? _screenStream : _cameraStream;
|
||||
MediaStream? get localCameraStream => _cameraStream;
|
||||
MediaStream? get localScreenStream => _screenStream;
|
||||
|
||||
CallAdmin? get admin {
|
||||
final signaling = _signaling;
|
||||
return signaling == null ? null : CallAdmin(signaling);
|
||||
}
|
||||
|
||||
List<CallParticipant> get participants =>
|
||||
_participants.values.toList(growable: false);
|
||||
@@ -166,17 +200,124 @@ class CallSession {
|
||||
Future<void> start() async {
|
||||
_setState(CallSessionState.connecting);
|
||||
info.region = ws2Config.uri.host;
|
||||
final signaling = Ws2Signaling(ws2Config);
|
||||
_signaling = signaling;
|
||||
signaling.notifications.listen(_enqueue, onError: (_) => _end());
|
||||
signaling.done.then((_) => _end());
|
||||
await signaling.connect();
|
||||
await _openSignaling();
|
||||
_levelTimer = Timer.periodic(
|
||||
const Duration(milliseconds: 300),
|
||||
(_) => unawaited(_sampleLevels()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openSignaling() async {
|
||||
final signaling = Ws2Signaling(ws2Config);
|
||||
_signaling = signaling;
|
||||
signaling.notifications.listen(
|
||||
_enqueue,
|
||||
onError: (_) => _onSignalingLost(),
|
||||
);
|
||||
signaling.done.then((_) => _onSignalingLost());
|
||||
await signaling.connect();
|
||||
logger.i('[call] signaling connected to ${ws2Config.uri.host}');
|
||||
}
|
||||
|
||||
void _onSignalingLost() {
|
||||
if (_ended || _reconnecting) return;
|
||||
logger.w('[call] signaling lost, reconnecting');
|
||||
unawaited(_reconnect());
|
||||
}
|
||||
|
||||
Future<void> _reconnect() async {
|
||||
_reconnecting = true;
|
||||
_setState(CallSessionState.connecting);
|
||||
_notifyInfo();
|
||||
|
||||
for (var attempt = 1; attempt <= _maxReconnectAttempts; attempt++) {
|
||||
final backoff = Duration(seconds: 1 << (attempt - 1));
|
||||
final delay = backoff > _maxReconnectDelay ? _maxReconnectDelay : backoff;
|
||||
await Future<void>.delayed(delay);
|
||||
if (_ended) break;
|
||||
|
||||
logger.i('[call] reconnect attempt $attempt/$_maxReconnectAttempts');
|
||||
try {
|
||||
await _resetForReconnect();
|
||||
await _openSignaling();
|
||||
_reconnecting = false;
|
||||
return;
|
||||
} catch (e) {
|
||||
logger.w('[call] reconnect attempt $attempt failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_reconnecting = false;
|
||||
if (!_ended) {
|
||||
logger.w('[call] reconnect gave up');
|
||||
_end();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restartIce() async {
|
||||
if (_ended || _iceRestarting || _topology == 'SERVER') return;
|
||||
if (_iceRestarts >= _maxIceRestarts) {
|
||||
logger.w('[call] ice restart budget exhausted, ending call');
|
||||
_end();
|
||||
return;
|
||||
}
|
||||
_iceRestarting = true;
|
||||
_iceRestarts++;
|
||||
_setState(CallSessionState.connecting);
|
||||
_notifyInfo();
|
||||
logger.i('[call] ice restart $_iceRestarts/$_maxIceRestarts');
|
||||
try {
|
||||
_pendingCandidates.clear();
|
||||
await _createAndSendOffer(iceRestart: true);
|
||||
} catch (e) {
|
||||
logger.w('[call] ice restart failed: $e');
|
||||
} finally {
|
||||
_iceRestarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetForReconnect() async {
|
||||
try {
|
||||
await _signaling?.close();
|
||||
} catch (_) {}
|
||||
_signaling = null;
|
||||
|
||||
try {
|
||||
await _probeChannel?.close();
|
||||
} catch (_) {}
|
||||
_probeChannel = null;
|
||||
|
||||
try {
|
||||
await _pc?.close();
|
||||
} catch (_) {}
|
||||
_pc = null;
|
||||
|
||||
_videoSender = null;
|
||||
_screenSender = null;
|
||||
_remoteDescSet = false;
|
||||
_pendingCandidates.clear();
|
||||
_accepted = false;
|
||||
_mediaConnected = false;
|
||||
_sfuSessionId = null;
|
||||
|
||||
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
|
||||
try {
|
||||
await track.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
await _localStream?.dispose();
|
||||
} catch (_) {}
|
||||
_localStream = null;
|
||||
|
||||
await _disposeStream(_cameraStream);
|
||||
await _disposeStream(_screenStream);
|
||||
_cameraStream = null;
|
||||
_screenStream = null;
|
||||
_localVideo = false;
|
||||
_localScreen = false;
|
||||
}
|
||||
|
||||
Future<void> _sampleLevels() async {
|
||||
final pc = _pc;
|
||||
if (pc == null || _ended) return;
|
||||
@@ -220,7 +361,12 @@ class CallSession {
|
||||
}
|
||||
|
||||
void _enqueue(Map<String, dynamic> msg) {
|
||||
_tail = _tail.then((_) => _onNotification(msg)).catchError((_) {});
|
||||
_tail = _tail.then((_) => _onNotification(msg)).catchError((
|
||||
Object e,
|
||||
StackTrace st,
|
||||
) {
|
||||
logger.w('[call] handler failed for ${msg['notification']}: $e\n$st');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onNotification(Map<String, dynamic> msg) async {
|
||||
@@ -243,12 +389,18 @@ class CallSession {
|
||||
_applyRegisteredPeer(msg);
|
||||
break;
|
||||
case 'participant-joined':
|
||||
case 'participant-added':
|
||||
_onParticipantJoined(msg);
|
||||
break;
|
||||
case 'media-settings-changed':
|
||||
_onParticipantMedia(msg);
|
||||
break;
|
||||
case 'participant-state-changed':
|
||||
_onParticipantStateChanged(msg);
|
||||
break;
|
||||
case 'roles-changed':
|
||||
_onRolesChanged(msg);
|
||||
break;
|
||||
case 'participants-state-changed':
|
||||
_onParticipantsStateChanged(msg);
|
||||
break;
|
||||
@@ -283,7 +435,7 @@ class CallSession {
|
||||
|
||||
void _onWs2Error(Map<String, dynamic> msg) {
|
||||
final err = msg['error'];
|
||||
logger.t('[call] ws2 error: $err');
|
||||
logger.w('[call] ws2 error: $err');
|
||||
if (err == 'conversation-ended') _end();
|
||||
}
|
||||
|
||||
@@ -373,6 +525,7 @@ class CallSession {
|
||||
state: p['state'] as String?,
|
||||
mediaSettings: p['mediaSettings'],
|
||||
muteStates: p['muteStates'],
|
||||
roles: p['roles'],
|
||||
);
|
||||
}
|
||||
_participants.removeWhere((key, _) => !seen.contains(key));
|
||||
@@ -386,6 +539,7 @@ class CallSession {
|
||||
Object? mediaSettings,
|
||||
Object? muteStates,
|
||||
bool? handRaised,
|
||||
Object? roles,
|
||||
}) {
|
||||
final p = _participants.putIfAbsent(
|
||||
id,
|
||||
@@ -410,6 +564,9 @@ class CallSession {
|
||||
if (s is String) p.screenSharing = s == 'UNMUTE';
|
||||
}
|
||||
if (handRaised != null) p.handRaised = handRaised;
|
||||
if (roles is List) {
|
||||
p.roles = roles.whereType<String>().toList(growable: false);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -434,24 +591,50 @@ class CallSession {
|
||||
mediaSettings: msg['mediaSettings'],
|
||||
muteStates: msg['muteStates'],
|
||||
);
|
||||
_maybeAdoptPeer(msg);
|
||||
_maybeAdoptPeer(id, msg);
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
void _maybeAdoptPeer(Map<String, dynamic> msg) {
|
||||
void _onParticipantJoined(Map<String, dynamic> msg) {
|
||||
final nested = msg['participant'];
|
||||
final p = nested is Map ? nested : msg;
|
||||
final id = _participantIdFrom(
|
||||
p['id'] ?? p['participantId'] ?? msg['participantId'],
|
||||
);
|
||||
if (id == null) return;
|
||||
_upsertParticipant(
|
||||
id,
|
||||
externalId: _externalId(p['externalId']),
|
||||
state: p['state'] as String?,
|
||||
mediaSettings: p['mediaSettings'],
|
||||
muteStates: p['muteStates'],
|
||||
handRaised: _handFrom(p['participantState']),
|
||||
roles: p['roles'],
|
||||
);
|
||||
_maybeAdoptPeer(id, p);
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
void _maybeAdoptPeer(int id, Map<dynamic, dynamic> source) {
|
||||
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;
|
||||
if (id == ws2Config.userId) return;
|
||||
_peerId = id;
|
||||
final type = msg['participantType'];
|
||||
final type = source['participantType'] ?? source['idType'];
|
||||
if (type is String && type.isNotEmpty) _peerType = type;
|
||||
final deviceIdx = msg['deviceIdx'];
|
||||
final deviceIdx = source['deviceIdx'];
|
||||
if (deviceIdx is int) _peerDeviceIdx = deviceIdx;
|
||||
logger.t('[call] adopting peer $_peerId on join');
|
||||
unawaited(_createAndSendOffer());
|
||||
}
|
||||
|
||||
void _onRolesChanged(Map<String, dynamic> msg) {
|
||||
final id = _participantIdFrom(msg['participantId']);
|
||||
if (id == null) return;
|
||||
_upsertParticipant(id, roles: msg['roles']);
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
void _onParticipantStateChanged(Map<String, dynamic> msg) {
|
||||
final id = msg['participantId'];
|
||||
if (id is! int) return;
|
||||
@@ -472,6 +655,7 @@ class CallSession {
|
||||
mediaSettings: p['mediaSettings'],
|
||||
muteStates: p['muteStates'],
|
||||
handRaised: _handFrom(p['participantState']),
|
||||
roles: p['roles'],
|
||||
);
|
||||
}
|
||||
_notifyInfo();
|
||||
@@ -484,6 +668,7 @@ class CallSession {
|
||||
}
|
||||
|
||||
Future<void> _onConnection(Map<String, dynamic> msg) async {
|
||||
logger.i('[call] connection notification received');
|
||||
final convParams = msg['conversationParams'];
|
||||
final conversation = msg['conversation'];
|
||||
|
||||
@@ -496,10 +681,11 @@ class CallSession {
|
||||
_topology =
|
||||
(conversation is Map ? conversation['topology']?.toString() : null) ??
|
||||
_topology;
|
||||
logger.t('[call] connection role=$role peer=$_peerId topology=$_topology');
|
||||
logger.i('[call] connection role=$role peer=$_peerId topology=$_topology');
|
||||
|
||||
if (_topology == 'SERVER') {
|
||||
await _setupSfu();
|
||||
await accept(activate: role != CallRole.caller);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -522,6 +708,7 @@ class CallSession {
|
||||
} else if (role == CallRole.joiner) {
|
||||
await _createAndSendOffer();
|
||||
}
|
||||
await accept(activate: role != CallRole.caller);
|
||||
}
|
||||
|
||||
Future<RTCPeerConnection> _createPc(List ice) async {
|
||||
@@ -530,19 +717,35 @@ class CallSession {
|
||||
'sdpSemantics': 'unified-plan',
|
||||
'bundlePolicy': 'max-bundle',
|
||||
'rtcpMuxPolicy': 'require',
|
||||
'tcpCandidatePolicy': 'enabled',
|
||||
'continualGatheringPolicy': 'gather_continually',
|
||||
'audioJitterBufferMaxPackets': 200,
|
||||
});
|
||||
pc.onIceCandidate = _onLocalCandidate;
|
||||
pc.onTrack = (event) => unawaited(_onRemoteTrack(event));
|
||||
pc.onDataChannel = (channel) => _bindProbeChannel(channel, ask: false);
|
||||
pc.onIceConnectionState = (s) => logger.t('[call] ice $s');
|
||||
pc.onIceConnectionState = (s) {
|
||||
logger.i('[call] ice $s');
|
||||
if (s != RTCIceConnectionState.RTCIceConnectionStateFailed) return;
|
||||
if (_topology != 'SERVER' || _ended) return;
|
||||
unawaited(_dumpIceStats(pc));
|
||||
logger.w('[call][sfu] ice failed, request-realloc');
|
||||
unawaited(
|
||||
_signaling?.requestRealloc().catchError(
|
||||
(e) => logger.w('[call] request-realloc failed: $e'),
|
||||
) ??
|
||||
Future.value(),
|
||||
);
|
||||
};
|
||||
pc.onConnectionState = (s) {
|
||||
logger.t('[call] pc $s');
|
||||
logger.i('[call] pc $s');
|
||||
final connected =
|
||||
s == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
|
||||
if (connected != _mediaConnected) {
|
||||
_mediaConnected = connected;
|
||||
_notifyInfo();
|
||||
if (connected) {
|
||||
_iceRestarts = 0;
|
||||
if (role == CallRole.joiner || _topology == 'SERVER') {
|
||||
_setState(CallSessionState.active);
|
||||
}
|
||||
@@ -550,10 +753,13 @@ class CallSession {
|
||||
unawaited(_collectReceivers());
|
||||
}
|
||||
}
|
||||
if ((s == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
||||
s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) &&
|
||||
_topology != 'SERVER') {
|
||||
if (_topology == 'SERVER') return;
|
||||
if (s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
|
||||
_end();
|
||||
return;
|
||||
}
|
||||
if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed) {
|
||||
unawaited(_restartIce());
|
||||
}
|
||||
};
|
||||
return pc;
|
||||
@@ -680,22 +886,67 @@ class CallSession {
|
||||
await _localStream?.dispose();
|
||||
_localStream = null;
|
||||
_videoSender = null;
|
||||
await _disposeLocalVideoStream();
|
||||
_localVideo = false;
|
||||
_localScreen = false;
|
||||
_screenSender = null;
|
||||
}
|
||||
_setState(CallSessionState.connecting);
|
||||
final pc = await _createPc(_iceServers);
|
||||
_pc = pc;
|
||||
await _addLocalMedia(pc);
|
||||
logger.t('[call][sfu] allocate-consumer');
|
||||
await _republishVideo(pc);
|
||||
logger.i(
|
||||
'[call][sfu] allocate-consumer camera=$_localVideo screen=$_localScreen',
|
||||
);
|
||||
await _signaling?.allocateConsumer();
|
||||
_fastScreenShare = true;
|
||||
}
|
||||
|
||||
Future<void> _rebuildSfuPc() async {
|
||||
try {
|
||||
await _pc?.close();
|
||||
} catch (_) {}
|
||||
_pc = null;
|
||||
_videoSender = null;
|
||||
_screenSender = null;
|
||||
_remoteDescSet = false;
|
||||
_pendingCandidates.clear();
|
||||
|
||||
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
|
||||
try {
|
||||
await track.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
await _localStream?.dispose();
|
||||
} catch (_) {}
|
||||
_localStream = null;
|
||||
|
||||
final pc = await _createPc(_iceServers);
|
||||
_pc = pc;
|
||||
await _addLocalMedia(pc);
|
||||
await _republishVideo(pc);
|
||||
}
|
||||
|
||||
Future<void> _republishVideo(RTCPeerConnection pc) async {
|
||||
final camera = _cameraStream;
|
||||
if (camera != null) {
|
||||
final tracks = camera.getVideoTracks();
|
||||
if (tracks.isNotEmpty) {
|
||||
_videoSender = await pc.addTrack(tracks.first, camera);
|
||||
}
|
||||
}
|
||||
final screen = _screenStream;
|
||||
if (screen != null) {
|
||||
final tracks = screen.getVideoTracks();
|
||||
if (tracks.isNotEmpty) {
|
||||
_screenSender = await pc.addTrack(tracks.first, screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onTopologyChanged(Map<String, dynamic> msg) async {
|
||||
final topo = msg['topology']?.toString();
|
||||
if (topo == null) return;
|
||||
logger.t('[call] topology-changed -> $topo');
|
||||
logger.i('[call] topology-changed -> $topo');
|
||||
info.topology = topo;
|
||||
final switchingToSfu = topo == 'SERVER' && _topology != 'SERVER';
|
||||
_topology = topo;
|
||||
@@ -704,11 +955,18 @@ class CallSession {
|
||||
}
|
||||
|
||||
Future<void> _onProducerUpdated(Map<String, dynamic> msg) async {
|
||||
final pc = _pc;
|
||||
if (pc == null) return;
|
||||
if (_pc == null) return;
|
||||
|
||||
final session = msg['sessionId'];
|
||||
final previous = _sfuSessionId;
|
||||
if (session != null) _sfuSessionId = session;
|
||||
if (previous != null && session != null && session != previous) {
|
||||
logger.i('[call][sfu] session changed, recreating peer connection');
|
||||
await _rebuildSfuPc();
|
||||
}
|
||||
|
||||
final pc = _pc;
|
||||
if (pc == null) return;
|
||||
|
||||
final description = msg['description'];
|
||||
String? sdp;
|
||||
@@ -720,34 +978,61 @@ class CallSession {
|
||||
sdp = description;
|
||||
}
|
||||
if (sdp == null) {
|
||||
logger.t('[call][sfu] producer-updated without sdp: $msg');
|
||||
logger.w('[call][sfu] producer-updated without sdp: $msg');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.t('[call][sfu] producer offer: ${_mLines(sdp)} m-lines');
|
||||
final ssrcs = _extractSsrcs(sdp);
|
||||
logger.i(
|
||||
'[call][sfu] producer offer: ${_mLines(sdp)} m-lines, '
|
||||
'ssrcs=${ssrcs.length}, candidates=${_countCandidates(sdp)} '
|
||||
'(${_candidateTypes(sdp)}), ${_sdpSummary(sdp)}, ice=${_iceServerUrls()}',
|
||||
);
|
||||
await pc.setRemoteDescription(RTCSessionDescription(sdp, type));
|
||||
_remoteDescSet = true;
|
||||
await _flushCandidates();
|
||||
await _addRemoteCandidatesFromSdp(pc, sdp);
|
||||
|
||||
final answer = await pc.createAnswer({});
|
||||
if (_pc != pc) return;
|
||||
await pc.setLocalDescription(answer);
|
||||
await _waitIceGathering(pc, const Duration(seconds: 3));
|
||||
if (_pc != pc) {
|
||||
logger.w('[call][sfu] peer connection replaced, dropping answer');
|
||||
return;
|
||||
}
|
||||
|
||||
final local = await pc.getLocalDescription();
|
||||
RTCSessionDescription? local;
|
||||
try {
|
||||
local = await pc.getLocalDescription();
|
||||
} catch (e) {
|
||||
logger.w('[call][sfu] getLocalDescription failed: $e');
|
||||
}
|
||||
final answerSdp = local?.sdp ?? answer.sdp ?? '';
|
||||
final ssrcs = _extractSsrcs(answerSdp);
|
||||
logger.t(
|
||||
if (answerSdp.isEmpty) return;
|
||||
logger.i(
|
||||
'[call][sfu] answer: ${_mLines(answerSdp)} m-lines, '
|
||||
'ssrcs=${ssrcs.length}',
|
||||
'candidates=${_countCandidates(answerSdp)} '
|
||||
'(${_candidateTypes(answerSdp)}), ${_sdpSummary(answerSdp)}, '
|
||||
'gathering=${pc.iceGatheringState}',
|
||||
);
|
||||
|
||||
await _signaling?.acceptProducer(
|
||||
description: answerSdp,
|
||||
ssrcs: ssrcs,
|
||||
sessionId: _sfuSessionId,
|
||||
);
|
||||
try {
|
||||
final reply = await _signaling?.acceptProducer(
|
||||
description: answerSdp,
|
||||
ssrcs: ssrcs,
|
||||
sessionId: _sfuSessionId,
|
||||
);
|
||||
logger.i('[call][sfu] accept-producer reply: $reply');
|
||||
} catch (e) {
|
||||
logger.w('[call][sfu] accept-producer failed: $e');
|
||||
}
|
||||
|
||||
Timer(const Duration(seconds: 5), () {
|
||||
if (_pc == pc && !_ended) unawaited(_dumpIceStats(pc));
|
||||
});
|
||||
|
||||
if (_wantVideo) await _publishCamera();
|
||||
if (_accepted) await _sendMediaSettings();
|
||||
unawaited(_collectReceivers());
|
||||
}
|
||||
|
||||
@@ -768,38 +1053,122 @@ class CallSession {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
int _countCandidates(String sdp) =>
|
||||
RegExp(r'^a=candidate:', multiLine: true).allMatches(sdp).length;
|
||||
|
||||
String _candidateTypes(String sdp) {
|
||||
final counts = <String, int>{};
|
||||
for (final m in RegExp(
|
||||
r'^a=candidate:.* typ (\w+)',
|
||||
multiLine: true,
|
||||
).allMatches(sdp)) {
|
||||
final type = m.group(1) ?? '?';
|
||||
counts[type] = (counts[type] ?? 0) + 1;
|
||||
}
|
||||
return counts.isEmpty
|
||||
? 'none'
|
||||
: counts.entries.map((e) => '${e.key}=${e.value}').join(' ');
|
||||
}
|
||||
|
||||
Future<void> _addRemoteCandidatesFromSdp(
|
||||
RTCPeerConnection pc,
|
||||
String sdp,
|
||||
) async {
|
||||
final mid = RegExp(r'^a=mid:(\S+)', multiLine: true).firstMatch(sdp);
|
||||
if (mid == null) return;
|
||||
final seen = <String>{};
|
||||
var added = 0;
|
||||
for (final m in RegExp(
|
||||
r'^a=(candidate:\S.*)$',
|
||||
multiLine: true,
|
||||
).allMatches(sdp)) {
|
||||
final line = m.group(1)!.trim();
|
||||
if (!seen.add(line)) continue;
|
||||
try {
|
||||
await pc.addCandidate(RTCIceCandidate(line, mid.group(1), 0));
|
||||
added++;
|
||||
} catch (_) {}
|
||||
}
|
||||
logger.i(
|
||||
'[call][sfu] remote candidates added=$added: '
|
||||
'${seen.map((c) => c.split(' ').take(6).join(' ')).join(' | ')}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dumpIceStats(RTCPeerConnection pc) async {
|
||||
try {
|
||||
final reports = await pc.getStats();
|
||||
final candidates = <String, String>{};
|
||||
for (final r in reports) {
|
||||
if (r.type != 'local-candidate' && r.type != 'remote-candidate') {
|
||||
continue;
|
||||
}
|
||||
final v = r.values;
|
||||
candidates[r.id] =
|
||||
'${v['candidateType']}/${v['protocol']} '
|
||||
'${v['ip'] ?? v['address']}:${v['port']}';
|
||||
}
|
||||
|
||||
for (final r in reports) {
|
||||
if (r.type != 'candidate-pair' && r.type != 'googCandidatePair') {
|
||||
continue;
|
||||
}
|
||||
final v = r.values;
|
||||
final from = candidates[v['localCandidateId']] ?? '?';
|
||||
final to = candidates[v['remoteCandidateId']] ?? '?';
|
||||
logger.w(
|
||||
'[call][sfu] pair ${v['state'] ?? v['googState']}: $from -> $to '
|
||||
'sent=${v['requestsSent']} recv=${v['responsesReceived']} '
|
||||
'inRecv=${v['requestsReceived']} nominated=${v['nominated']}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('[call][sfu] ice stats failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _iceServerUrls() =>
|
||||
_iceServers.whereType<Map>().map((s) => '${s['urls']}').join(' | ');
|
||||
|
||||
String _sdpSummary(String sdp) {
|
||||
final bundle = RegExp(
|
||||
r'^a=group:BUNDLE (.*)$',
|
||||
multiLine: true,
|
||||
).firstMatch(sdp);
|
||||
final mids = bundle == null
|
||||
? 'none'
|
||||
: '${bundle.group(1)!.trim().split(RegExp(r'\s+')).length}';
|
||||
final ufrags = RegExp(
|
||||
r'^a=ice-ufrag:(\S+)',
|
||||
multiLine: true,
|
||||
).allMatches(sdp).map((m) => m.group(1)).toSet().length;
|
||||
var active = 0;
|
||||
var total = 0;
|
||||
for (final m in RegExp(r'^m=\S+ (\d+)', multiLine: true).allMatches(sdp)) {
|
||||
total++;
|
||||
if (m.group(1) != '0') active++;
|
||||
}
|
||||
final setup = RegExp(
|
||||
r'^a=setup:(\S+)',
|
||||
multiLine: true,
|
||||
).allMatches(sdp).map((m) => m.group(1)).toSet().join(',');
|
||||
final lite = sdp.contains('a=ice-lite') ? ' ice-lite' : '';
|
||||
return 'bundle=$mids ufrags=$ufrags active=$active/$total '
|
||||
'setup=$setup$lite';
|
||||
}
|
||||
|
||||
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) ?? '');
|
||||
List<String> _extractSsrcs(String sdp) {
|
||||
final set = <String>{};
|
||||
for (final m in RegExp(r'a=ssrc:(\d+)', multiLine: true).allMatches(sdp)) {
|
||||
final v = m.group(1);
|
||||
if (v != null) set.add(v);
|
||||
}
|
||||
return set.toList();
|
||||
}
|
||||
|
||||
Future<void> _waitIceGathering(RTCPeerConnection pc, Duration timeout) async {
|
||||
if (pc.iceGatheringState ==
|
||||
RTCIceGatheringState.RTCIceGatheringStateComplete) {
|
||||
return;
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
Timer? timer;
|
||||
void finish() {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
}
|
||||
|
||||
pc.onIceGatheringState = (state) {
|
||||
if (state == RTCIceGatheringState.RTCIceGatheringStateComplete) finish();
|
||||
};
|
||||
timer = Timer(timeout, finish);
|
||||
await completer.future;
|
||||
timer.cancel();
|
||||
pc.onIceGatheringState = null;
|
||||
}
|
||||
|
||||
String _videoDir(String sdp) {
|
||||
var inVideo = false;
|
||||
String? mline;
|
||||
@@ -861,12 +1230,12 @@ class CallSession {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _createAndSendOffer() async {
|
||||
Future<void> _createAndSendOffer({bool iceRestart = false}) async {
|
||||
final pc = _pc;
|
||||
final peerId = _peerId;
|
||||
if (pc == null || peerId == null) return;
|
||||
|
||||
final offer = await pc.createOffer({});
|
||||
final offer = await pc.createOffer(iceRestart ? {'iceRestart': true} : {});
|
||||
final sdp = offer.sdp ?? '';
|
||||
await pc.setLocalDescription(RTCSessionDescription(sdp, offer.type));
|
||||
logger.t('[call] our offer video: ${_videoDir(sdp)}');
|
||||
@@ -928,6 +1297,13 @@ class CallSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'offer' &&
|
||||
pc.signalingState ==
|
||||
RTCSignalingState.RTCSignalingStateHaveLocalOffer) {
|
||||
logger.w('[call] offer glare, rolling back local offer');
|
||||
await pc.setLocalDescription(RTCSessionDescription(null, 'rollback'));
|
||||
}
|
||||
|
||||
await pc.setRemoteDescription(RTCSessionDescription(desc, type));
|
||||
_remoteDescSet = true;
|
||||
await _flushCandidates();
|
||||
@@ -998,13 +1374,16 @@ class CallSession {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> accept() async {
|
||||
Future<void> accept({bool activate = true}) async {
|
||||
if (_accepted) return;
|
||||
_accepted = true;
|
||||
logger.t('[call] accepted');
|
||||
await _signaling?.acceptCall();
|
||||
await _sendMediaSettings();
|
||||
_setState(CallSessionState.active);
|
||||
logger.i('[call] accept-call sent (activate=$activate)');
|
||||
await _signaling?.acceptCall(
|
||||
isAudioEnabled: !_muted,
|
||||
isVideoEnabled: _localVideo,
|
||||
isScreenSharingEnabled: _localScreen,
|
||||
);
|
||||
if (activate) _setState(CallSessionState.active);
|
||||
}
|
||||
|
||||
Future<void> sendAudioEnabledSignal(bool enabled) async {
|
||||
@@ -1030,37 +1409,35 @@ class CallSession {
|
||||
isAudioEnabled: !_muted,
|
||||
isVideoEnabled: _localVideo,
|
||||
isScreenSharingEnabled: _localScreen,
|
||||
isFastScreenSharingEnabled: _fastScreenShare ? _localScreen : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setVideoEnabled(bool on) =>
|
||||
on ? _startLocalVideo(screen: false) : _stopLocalVideo();
|
||||
Future<void> setVideoEnabled(bool on) => on ? _startCamera() : _stopCamera();
|
||||
|
||||
Future<void> setScreenSharing(bool on) =>
|
||||
on ? _startLocalVideo(screen: true) : _stopLocalVideo();
|
||||
on ? _startScreenShare() : _stopScreenShare();
|
||||
|
||||
Future<void> _startLocalVideo({required bool screen}) async {
|
||||
Future<void> switchToServerTopology({bool force = false}) async {
|
||||
if (_topology == 'SERVER') return;
|
||||
try {
|
||||
await _signaling?.switchTopology(force: force);
|
||||
} catch (e) {
|
||||
logger.w('[call] switch-topology failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startCamera() async {
|
||||
final pc = _pc;
|
||||
if (pc == null) return;
|
||||
|
||||
MediaStream stream;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
final stream = await navigator.mediaDevices.getUserMedia(<String, dynamic>{
|
||||
'video': true,
|
||||
'audio': false,
|
||||
});
|
||||
|
||||
await _disposeLocalVideoStream();
|
||||
_localVideoStream = stream;
|
||||
await _disposeStream(_cameraStream);
|
||||
_cameraStream = stream;
|
||||
|
||||
final tracks = stream.getVideoTracks();
|
||||
final track = tracks.isEmpty ? null : tracks.first;
|
||||
@@ -1072,28 +1449,109 @@ class CallSession {
|
||||
}
|
||||
}
|
||||
|
||||
_localVideo = !screen;
|
||||
_localScreen = screen;
|
||||
|
||||
if (_topology != 'SERVER') await _createAndSendOffer();
|
||||
_localVideo = true;
|
||||
await _renegotiate();
|
||||
await _sendMediaSettings();
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
Future<void> _stopLocalVideo() async {
|
||||
Future<void> _stopCamera() async {
|
||||
try {
|
||||
await _videoSender?.replaceTrack(null);
|
||||
} catch (_) {}
|
||||
await _disposeLocalVideoStream();
|
||||
await _disposeStream(_cameraStream);
|
||||
_cameraStream = null;
|
||||
_localVideo = false;
|
||||
_localScreen = false;
|
||||
await _sendMediaSettings();
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
Future<void> _disposeLocalVideoStream() async {
|
||||
final stream = _localVideoStream;
|
||||
_localVideoStream = null;
|
||||
Future<void> _startScreenShare() async {
|
||||
if (_pc == null) return;
|
||||
|
||||
await CallBridge.instance.setScreenShare(true);
|
||||
|
||||
_localScreen = true;
|
||||
await _sendMediaSettings();
|
||||
_notifyInfo();
|
||||
|
||||
final MediaStream stream;
|
||||
try {
|
||||
stream = await _captureScreen();
|
||||
} catch (e) {
|
||||
_localScreen = false;
|
||||
await CallBridge.instance.setScreenShare(false);
|
||||
await _sendMediaSettings();
|
||||
_notifyInfo();
|
||||
rethrow;
|
||||
}
|
||||
logger.i('[call] screen captured, topology=$_topology');
|
||||
|
||||
await _disposeStream(_screenStream);
|
||||
_screenStream = stream;
|
||||
|
||||
final pc = _pc;
|
||||
if (pc == null) return;
|
||||
|
||||
final tracks = stream.getVideoTracks();
|
||||
final track = tracks.isEmpty ? null : tracks.first;
|
||||
if (track != null) {
|
||||
if (_screenSender == null) {
|
||||
_screenSender = await pc.addTrack(track, stream);
|
||||
} else {
|
||||
await _screenSender!.replaceTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
logger.i('[call] screen share published, topology=$_topology');
|
||||
await _sendMediaSettings();
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
Future<MediaStream> _captureScreen() async {
|
||||
if (!_isDesktop) {
|
||||
return navigator.mediaDevices.getDisplayMedia(<String, dynamic>{
|
||||
'video': true,
|
||||
'audio': false,
|
||||
});
|
||||
}
|
||||
final sources = await desktopCapturer.getSources(
|
||||
types: [SourceType.Screen],
|
||||
);
|
||||
if (sources.isEmpty) {
|
||||
throw StateError('нет доступных экранов для захвата');
|
||||
}
|
||||
return navigator.mediaDevices.getDisplayMedia(<String, dynamic>{
|
||||
'video': {
|
||||
'deviceId': {'exact': sources.first.id},
|
||||
'mandatory': {'frameRate': 30.0},
|
||||
},
|
||||
'audio': false,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _stopScreenShare() async {
|
||||
try {
|
||||
await _screenSender?.replaceTrack(null);
|
||||
} catch (_) {}
|
||||
await _disposeStream(_screenStream);
|
||||
_screenStream = null;
|
||||
_localScreen = false;
|
||||
await CallBridge.instance.setScreenShare(false);
|
||||
await _sendMediaSettings();
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
Future<void> _renegotiate() async {
|
||||
if (_topology == 'SERVER') return;
|
||||
try {
|
||||
await _createAndSendOffer();
|
||||
} catch (e) {
|
||||
logger.w('[call] renegotiation offer failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disposeStream(MediaStream? stream) async {
|
||||
if (stream == null) return;
|
||||
for (final track in stream.getTracks()) {
|
||||
try {
|
||||
@@ -1139,7 +1597,10 @@ class CallSession {
|
||||
await track.stop();
|
||||
}
|
||||
await _localStream?.dispose();
|
||||
await _disposeLocalVideoStream();
|
||||
await _disposeStream(_cameraStream);
|
||||
await _disposeStream(_screenStream);
|
||||
_cameraStream = null;
|
||||
_screenStream = null;
|
||||
await _pc?.close();
|
||||
if (_ownRemoteStream) {
|
||||
try {
|
||||
|
||||
@@ -32,21 +32,22 @@ class Ws2Config {
|
||||
String device = 'Komet',
|
||||
String osVersion = '36',
|
||||
}) {
|
||||
final userId =
|
||||
int.tryParse((params.turnUser ?? '').split(':').last) ?? 0;
|
||||
final uri = Uri.parse(params.wsEndpoint).replace(queryParameters: {
|
||||
'userId': '$userId',
|
||||
'entityType': 'USER',
|
||||
'conversationId': conversationId,
|
||||
'token': params.token,
|
||||
'version': '5',
|
||||
'capabilities': capabilities,
|
||||
'device': device,
|
||||
'platform': 'ANDROID',
|
||||
'clientType': 'ONE_ME',
|
||||
'appVersion': _appVersion,
|
||||
'osVersion': osVersion,
|
||||
});
|
||||
final userId = int.tryParse((params.turnUser ?? '').split(':').last) ?? 0;
|
||||
final uri = Uri.parse(params.wsEndpoint).replace(
|
||||
queryParameters: {
|
||||
'userId': '$userId',
|
||||
'entityType': 'USER',
|
||||
'conversationId': conversationId,
|
||||
'token': params.token,
|
||||
'version': '5',
|
||||
'capabilities': capabilities,
|
||||
'device': device,
|
||||
'platform': 'ANDROID',
|
||||
'clientType': 'ONE_ME',
|
||||
'appVersion': _appVersion,
|
||||
'osVersion': osVersion,
|
||||
},
|
||||
);
|
||||
return Ws2Config(uri: uri, userId: userId);
|
||||
}
|
||||
|
||||
@@ -59,16 +60,18 @@ class Ws2Config {
|
||||
String device = 'Komet',
|
||||
}) {
|
||||
final base = Uri.parse(endpoint);
|
||||
final uri = base.replace(queryParameters: {
|
||||
...base.queryParameters,
|
||||
'platform': 'ANDROID',
|
||||
'version': '5',
|
||||
'capabilities': capabilities,
|
||||
'clientType': 'ONE_ME',
|
||||
'appVersion': _appVersion,
|
||||
'device': device,
|
||||
'tgt': 'start',
|
||||
});
|
||||
final uri = base.replace(
|
||||
queryParameters: {
|
||||
...base.queryParameters,
|
||||
'platform': 'ANDROID',
|
||||
'version': '5',
|
||||
'capabilities': capabilities,
|
||||
'clientType': 'ONE_ME',
|
||||
'appVersion': _appVersion,
|
||||
'device': device,
|
||||
'tgt': 'start',
|
||||
},
|
||||
);
|
||||
return Ws2Config(uri: uri, userId: userId);
|
||||
}
|
||||
}
|
||||
@@ -154,9 +157,7 @@ class Ws2Signaling {
|
||||
.sendCommand(command: command, extraJson: jsonEncode(extra))
|
||||
.timeout(timeout);
|
||||
final decoded = jsonDecode(response);
|
||||
return decoded is Map<String, dynamic>
|
||||
? decoded
|
||||
: <String, dynamic>{};
|
||||
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
|
||||
} catch (e) {
|
||||
throw Ws2CommandException(command, e);
|
||||
}
|
||||
@@ -216,9 +217,45 @@ class Ws2Signaling {
|
||||
bool isVideoEnabled = false,
|
||||
bool isScreenSharingEnabled = false,
|
||||
bool isAnimojiEnabled = false,
|
||||
bool? isFastScreenSharingEnabled,
|
||||
bool? isAudioSharingEnabled,
|
||||
}) {
|
||||
return sendCommand(
|
||||
'change-media-settings',
|
||||
extra: {
|
||||
'mediaSettings': {
|
||||
'isVideoEnabled': isVideoEnabled,
|
||||
'isAudioEnabled': isAudioEnabled,
|
||||
'isScreenSharingEnabled': isScreenSharingEnabled,
|
||||
'isAnimojiEnabled': isAnimojiEnabled,
|
||||
'isFastScreenSharingEnabled': ?isFastScreenSharingEnabled,
|
||||
'isAudioSharingEnabled': ?isAudioSharingEnabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> switchTopology({
|
||||
String topology = 'SERVER',
|
||||
bool force = false,
|
||||
}) {
|
||||
return sendCommand(
|
||||
'switch-topology',
|
||||
extra: {'topology': topology, 'force': force},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> requestRealloc() => sendCommand('request-realloc');
|
||||
|
||||
/// Принять входящий звонок (сторона вызываемого).
|
||||
Future<void> acceptCall({
|
||||
bool isAudioEnabled = true,
|
||||
bool isVideoEnabled = false,
|
||||
bool isScreenSharingEnabled = false,
|
||||
bool isAnimojiEnabled = false,
|
||||
}) {
|
||||
return sendCommand(
|
||||
'accept-call',
|
||||
extra: {
|
||||
'mediaSettings': {
|
||||
'isVideoEnabled': isVideoEnabled,
|
||||
@@ -230,59 +267,59 @@ class Ws2Signaling {
|
||||
);
|
||||
}
|
||||
|
||||
/// Принять входящий звонок (сторона вызываемого).
|
||||
Future<void> acceptCall() => sendCommand('accept-call');
|
||||
|
||||
Future<void> hangup({String reason = 'HUNGUP'}) =>
|
||||
sendCommand('hangup', extra: {'reason': reason});
|
||||
|
||||
Future<void> allocateConsumer() => sendCommand(
|
||||
'allocate-consumer',
|
||||
extra: const {
|
||||
'capabilities': {
|
||||
'maxH264Decoders': 10,
|
||||
'producerNotificationDataChannelVersion': 7,
|
||||
'producerCommandDataChannelVersion': 2,
|
||||
'audioMix': true,
|
||||
'consumerUpdate': true,
|
||||
'onDemandTracks': true,
|
||||
'singleSession': true,
|
||||
'unifiedPlan': true,
|
||||
'fastScreenShare': true,
|
||||
'producerScreenDataChannelVersion': 1,
|
||||
'consumerScreenDataChannelVersion': 1,
|
||||
'animojiDataChannelVersion': 2,
|
||||
'animojiBackendRender': true,
|
||||
'asrDataChannelVersion': 1,
|
||||
'consumerFastScreenShare': true,
|
||||
'consumerFastScreenShareQualityOnDemand': true,
|
||||
'audioShare': true,
|
||||
'simulcast': true,
|
||||
'simulcastNativeOrder': true,
|
||||
'red': true,
|
||||
'videoTracksCount': 10,
|
||||
'csrcAccessible': true,
|
||||
},
|
||||
},
|
||||
);
|
||||
'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({
|
||||
Future<Map<String, dynamic>> acceptProducer({
|
||||
required String description,
|
||||
required List<int> ssrcs,
|
||||
required List<String> ssrcs,
|
||||
Object? sessionId,
|
||||
}) =>
|
||||
sendCommand('accept-producer', extra: {
|
||||
'description': description,
|
||||
'ssrcs': ssrcs,
|
||||
'sessionId': ?sessionId,
|
||||
});
|
||||
}) => sendCommand(
|
||||
'accept-producer',
|
||||
extra: {
|
||||
'description': description,
|
||||
if (ssrcs.isNotEmpty) 'ssrcs': ssrcs,
|
||||
'sessionId': ?sessionId,
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> changeSimulcast({
|
||||
String mediaSource = 'CAMERA',
|
||||
required List<Map<String, dynamic>> layers,
|
||||
}) =>
|
||||
sendCommand('change-simulcast',
|
||||
extra: {'mediaSource': mediaSource, 'layers': layers});
|
||||
}) => sendCommand(
|
||||
'change-simulcast',
|
||||
extra: {'mediaSource': mediaSource, 'layers': layers},
|
||||
);
|
||||
|
||||
Future<void> close() async {
|
||||
await _notifSub?.cancel();
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/calls/call_admin.dart';
|
||||
import '../../../core/calls/call_session.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/prompt_dialog.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
|
||||
class CallParticipantView {
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
|
||||
const CallParticipantView({required this.name, this.avatarUrl});
|
||||
}
|
||||
|
||||
typedef CallParticipantResolver =
|
||||
CallParticipantView Function(CallParticipant participant);
|
||||
|
||||
Future<void> showCallParticipantsSheet(
|
||||
BuildContext context, {
|
||||
required CallSession session,
|
||||
required ColorScheme scheme,
|
||||
required CallParticipantResolver resolve,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: scheme.surfaceContainerHigh,
|
||||
shape: kSheetShape,
|
||||
builder: (_) => Theme(
|
||||
data: Theme.of(context).copyWith(colorScheme: scheme),
|
||||
child: _ParticipantsSheet(session: session, resolve: resolve),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ParticipantsSheet extends StatefulWidget {
|
||||
final CallSession session;
|
||||
final CallParticipantResolver resolve;
|
||||
|
||||
const _ParticipantsSheet({required this.session, required this.resolve});
|
||||
|
||||
@override
|
||||
State<_ParticipantsSheet> createState() => _ParticipantsSheetState();
|
||||
}
|
||||
|
||||
class _ParticipantsSheetState extends State<_ParticipantsSheet> {
|
||||
final Map<CallOption, bool> _options = {};
|
||||
final Map<CallFeature, Set<CallRoleName>> _features = {};
|
||||
bool _recording = false;
|
||||
StreamSubscription<void>? _infoSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_infoSub = widget.session.infoUpdates.listen((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_infoSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
CallParticipant? get _self {
|
||||
for (final p in widget.session.participants) {
|
||||
if (p.isSelf) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> _run(Future<void> Function(CallAdmin admin) action) async {
|
||||
final admin = widget.session.admin;
|
||||
if (admin == null) {
|
||||
showCustomNotification(context, 'Нет связи с сервером звонка');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await action(admin);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (mounted) showCustomNotification(context, 'Не удалось: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
CallParticipantRef _ref(CallParticipant p) => CallParticipantRef(p.id);
|
||||
|
||||
void _participantActions(CallParticipant p) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final view = widget.resolve(p);
|
||||
final isAdmin = p.isAdmin;
|
||||
final isSpeaker = p.isSpeaker;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: kSheetShape,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||||
child: Text(
|
||||
view.name,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Text(
|
||||
p.roles.isEmpty ? 'Участник' : p.roles.join(' · '),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
),
|
||||
_action(cs, Symbols.mic_off, 'Выключить микрофон', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.muteMicrophone(_ref(p)));
|
||||
}),
|
||||
_action(cs, Symbols.videocam_off, 'Запросить камеру', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run(
|
||||
(a) =>
|
||||
a.requestMedia({CallMedia.video}, participant: _ref(p)),
|
||||
);
|
||||
}),
|
||||
_action(
|
||||
cs,
|
||||
isAdmin ? Symbols.remove_moderator : Symbols.shield_person,
|
||||
isAdmin ? 'Снять администратора' : 'Назначить администратором',
|
||||
() {
|
||||
Navigator.pop(sheetContext);
|
||||
_run(
|
||||
(a) => a.setRoles(_ref(p), [
|
||||
CallRoleName.admin,
|
||||
], revoke: isAdmin),
|
||||
);
|
||||
},
|
||||
),
|
||||
_action(
|
||||
cs,
|
||||
isSpeaker ? Symbols.voice_over_off : Symbols.record_voice_over,
|
||||
isSpeaker ? 'Убрать из спикеров' : 'Сделать спикером',
|
||||
() {
|
||||
Navigator.pop(sheetContext);
|
||||
_run(
|
||||
(a) => a.setRoles(_ref(p), [
|
||||
CallRoleName.speaker,
|
||||
], revoke: isSpeaker),
|
||||
);
|
||||
},
|
||||
),
|
||||
_action(cs, Symbols.arrow_upward, 'Повысить (promote)', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.setPromoted(_ref(p), true));
|
||||
}),
|
||||
_action(cs, Symbols.arrow_downward, 'Понизить (demote)', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.setPromoted(_ref(p), false));
|
||||
}),
|
||||
_action(cs, Symbols.push_pin, 'Закрепить', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.setPinned(_ref(p), true));
|
||||
}),
|
||||
_action(cs, Symbols.keep_off, 'Открепить', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.setPinned(_ref(p), false));
|
||||
}),
|
||||
_action(cs, Symbols.person_remove, 'Удалить из звонка', () {
|
||||
Navigator.pop(sheetContext);
|
||||
_run((a) => a.removeParticipant(_ref(p)));
|
||||
}, destructive: true),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showOptions() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: kSheetShape,
|
||||
builder: (_) => Theme(
|
||||
data: Theme.of(context).copyWith(colorScheme: cs),
|
||||
child: StatefulBuilder(
|
||||
builder: (_, setSheet) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_sheetTitle(cs, 'Настройки звонка'),
|
||||
for (final option in CallOption.values)
|
||||
SwitchListTile(
|
||||
value: _options[option] ?? false,
|
||||
title: Text(
|
||||
_optionLabel(option),
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||
),
|
||||
subtitle: Text(
|
||||
option.wire,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) async {
|
||||
setSheet(() => _options[option] = value);
|
||||
final ok = await _run(
|
||||
(a) => a.setOptions({option: value}),
|
||||
);
|
||||
if (!ok) setSheet(() => _options[option] = !value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFeatures() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: kSheetShape,
|
||||
builder: (_) => Theme(
|
||||
data: Theme.of(context).copyWith(colorScheme: cs),
|
||||
child: StatefulBuilder(
|
||||
builder: (_, setSheet) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_sheetTitle(cs, 'Кому доступны функции'),
|
||||
for (final feature in CallFeature.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_featureLabel(feature),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final role in CallRoleName.values)
|
||||
FilterChip(
|
||||
label: Text(role.wire),
|
||||
selected:
|
||||
_features[feature]?.contains(role) ??
|
||||
false,
|
||||
onSelected: (selected) {
|
||||
final set = _features.putIfAbsent(
|
||||
feature,
|
||||
() => <CallRoleName>{},
|
||||
);
|
||||
setSheet(() {
|
||||
selected
|
||||
? set.add(role)
|
||||
: set.remove(role);
|
||||
});
|
||||
_run(
|
||||
(a) => a.enableFeatureForRoles(
|
||||
feature,
|
||||
set.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addByLink() async {
|
||||
final link = await showTextInputDialog(
|
||||
context,
|
||||
title: 'Добавить участника',
|
||||
description: 'Ссылка-приглашение участника',
|
||||
confirmLabel: 'Добавить',
|
||||
);
|
||||
if (link == null || link.trim().isEmpty || !mounted) return;
|
||||
await _run((a) => a.addParticipantByLink(link.trim()));
|
||||
}
|
||||
|
||||
String _optionLabel(CallOption option) => switch (option) {
|
||||
CallOption.requireAuthToJoin => 'Только авторизованные',
|
||||
CallOption.waitingHall => 'Зал ожидания',
|
||||
CallOption.recurring => 'Повторяющийся звонок',
|
||||
CallOption.feedback => 'Сбор отзывов',
|
||||
CallOption.audienceMode => 'Режим зрителей',
|
||||
CallOption.asr => 'Расшифровка речи',
|
||||
CallOption.waitForAdmin => 'Ждать администратора',
|
||||
CallOption.adminIsHere => 'Администратор на месте',
|
||||
};
|
||||
|
||||
String _featureLabel(CallFeature feature) => switch (feature) {
|
||||
CallFeature.addParticipant => 'Добавлять участников',
|
||||
CallFeature.admin => 'Права администратора',
|
||||
CallFeature.asr => 'Расшифровка речи',
|
||||
CallFeature.movieShare => 'Совместный просмотр',
|
||||
CallFeature.record => 'Запись звонка',
|
||||
CallFeature.speaker => 'Быть спикером',
|
||||
};
|
||||
|
||||
Widget _sheetTitle(ColorScheme cs, String text) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _action(
|
||||
ColorScheme cs,
|
||||
IconData icon,
|
||||
String label,
|
||||
VoidCallback onTap, {
|
||||
bool destructive = false,
|
||||
}) {
|
||||
final color = destructive ? cs.error : cs.onSurface;
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: color),
|
||||
title: Text(label, style: TextStyle(color: color, fontSize: 16)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final participants = widget.session.participants;
|
||||
final self = _self;
|
||||
final handRaised = self?.handRaised ?? false;
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_sheetTitle(cs, 'Участники · ${participants.length}'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_chip(cs, Symbols.mic_off, 'Заглушить всех', () {
|
||||
_run((a) => a.muteEveryone());
|
||||
}),
|
||||
_chip(cs, Symbols.do_not_touch, 'Опустить руки', () {
|
||||
_run((a) => a.lowerAllHands());
|
||||
}),
|
||||
_chip(
|
||||
cs,
|
||||
handRaised ? Symbols.back_hand : Symbols.front_hand,
|
||||
handRaised ? 'Опустить руку' : 'Поднять руку',
|
||||
() => _run((a) => a.setHandRaised(!handRaised)),
|
||||
active: handRaised,
|
||||
),
|
||||
_chip(
|
||||
cs,
|
||||
_recording
|
||||
? Symbols.stop_circle
|
||||
: Symbols.radio_button_checked,
|
||||
_recording ? 'Остановить запись' : 'Начать запись',
|
||||
() async {
|
||||
final next = !_recording;
|
||||
setState(() => _recording = next);
|
||||
final ok = await _run(
|
||||
(a) => next
|
||||
? a.startRecord(name: 'Запись звонка')
|
||||
: a.stopRecord(),
|
||||
);
|
||||
if (!ok && mounted) setState(() => _recording = !next);
|
||||
},
|
||||
active: _recording,
|
||||
),
|
||||
_chip(cs, Symbols.tune, 'Настройки', _showOptions),
|
||||
_chip(cs, Symbols.shield_person, 'Права ролей', _showFeatures),
|
||||
_chip(cs, Symbols.person_add, 'Добавить по ссылке', _addByLink),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: participants.length,
|
||||
itemBuilder: (_, i) => _tile(cs, participants[i]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(
|
||||
ColorScheme cs,
|
||||
IconData icon,
|
||||
String label,
|
||||
VoidCallback onTap, {
|
||||
bool active = false,
|
||||
}) {
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: active ? cs.onPrimary : cs.onSurfaceVariant,
|
||||
),
|
||||
label: Text(label),
|
||||
labelStyle: TextStyle(color: active ? cs.onPrimary : cs.onSurface),
|
||||
backgroundColor: active ? cs.primary : cs.surfaceContainerHighest,
|
||||
side: BorderSide.none,
|
||||
onPressed: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tile(ColorScheme cs, CallParticipant p) {
|
||||
final view = widget.resolve(p);
|
||||
final subtitle = <String>[
|
||||
if (p.isCreator) 'Создатель' else if (p.isAdmin) 'Администратор',
|
||||
if (p.isSpeaker) 'Спикер',
|
||||
if (p.handRaised) 'Поднял руку',
|
||||
];
|
||||
|
||||
return ListTile(
|
||||
leading: KometAvatar(name: view.name, imageUrl: view.avatarUrl, size: 40),
|
||||
title: Text(
|
||||
view.name,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: subtitle.isEmpty
|
||||
? null
|
||||
: Text(
|
||||
subtitle.join(' · '),
|
||||
style: TextStyle(color: cs.primary, fontSize: 13),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (p.screenSharing)
|
||||
Icon(Symbols.screen_share, size: 18, color: cs.primary),
|
||||
if (p.videoEnabled)
|
||||
Icon(Symbols.videocam, size: 18, color: cs.onSurfaceVariant),
|
||||
Icon(
|
||||
p.audioEnabled ? Symbols.mic : Symbols.mic_off,
|
||||
size: 18,
|
||||
color: p.audioEnabled ? cs.onSurfaceVariant : cs.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: p.isSelf ? null : () => _participantActions(p),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
import 'call_participants_sheet.dart';
|
||||
import 'komet_hub.dart';
|
||||
|
||||
const Color _kEndRed = Color(0xFFE5484D);
|
||||
@@ -333,6 +334,8 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
try {
|
||||
await session.setVideoEnabled(!session.localVideo);
|
||||
} catch (e) {
|
||||
if (mounted) showCustomNotification(context, 'Камера недоступна: $e');
|
||||
} finally {
|
||||
_syncLocalPreview();
|
||||
if (mounted) setState(() => _videoBusy = false);
|
||||
@@ -346,6 +349,10 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
try {
|
||||
await session.setScreenSharing(!session.localScreen);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Трансляция не запустилась: $e');
|
||||
}
|
||||
} finally {
|
||||
_syncLocalPreview();
|
||||
if (mounted) setState(() => _videoBusy = false);
|
||||
@@ -367,13 +374,40 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
_remoteStreamSub?.cancel();
|
||||
_dotsController.dispose();
|
||||
_videoController.dispose();
|
||||
_remoteRenderer.srcObject = null;
|
||||
if (_rendererReady) _remoteRenderer.srcObject = null;
|
||||
_remoteRenderer.dispose();
|
||||
_localRenderer.srcObject = null;
|
||||
if (_localRendererReady) _localRenderer.srcObject = null;
|
||||
_localRenderer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showParticipants() {
|
||||
final session = _session;
|
||||
if (session == null) return;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
showCallParticipantsSheet(
|
||||
context,
|
||||
session: session,
|
||||
scheme: _darkScheme(context),
|
||||
resolve: (p) {
|
||||
if (p.isSelf) {
|
||||
return CallParticipantView(
|
||||
name: l10n.callParticipantYou,
|
||||
avatarUrl: _avatarUrl,
|
||||
);
|
||||
}
|
||||
final ext = p.externalId;
|
||||
final info = ext != null ? _peerInfo[ext] : null;
|
||||
return CallParticipantView(
|
||||
name: info?.name?.isNotEmpty == true
|
||||
? info!.name!
|
||||
: l10n.callParticipantFallback,
|
||||
avatarUrl: info?.avatar,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showInfoSheet() {
|
||||
final cs = _darkScheme(context);
|
||||
showModalBottomSheet<void>(
|
||||
@@ -520,9 +554,29 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
InkWell(
|
||||
onTap: count > 0 ? _showParticipants : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
if (count > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Symbols.chevron_right,
|
||||
size: 16,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -927,7 +981,8 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
final session = _session;
|
||||
if (session == null) return null;
|
||||
final pills = <Widget>[
|
||||
if (session.peerMuted) _statePill(cs, Symbols.mic_off, l10n.callPeerMicOff),
|
||||
if (session.peerMuted)
|
||||
_statePill(cs, Symbols.mic_off, l10n.callPeerMicOff),
|
||||
if (session.peerVideo)
|
||||
_statePill(cs, Symbols.videocam, l10n.callPeerCameraOn),
|
||||
];
|
||||
@@ -1346,7 +1401,10 @@ class _CallInfoSheet extends StatelessWidget {
|
||||
add(l10n.callInfoCountry, incoming?.country);
|
||||
final isContact = incoming?.isContact;
|
||||
if (isContact != null) {
|
||||
add(l10n.callInfoInContacts, isContact ? l10n.callValueYes : l10n.callValueNo);
|
||||
add(
|
||||
l10n.callInfoInContacts,
|
||||
isContact ? l10n.callValueYes : l10n.callValueNo,
|
||||
);
|
||||
}
|
||||
add(l10n.callInfoPeerIp, info?.peerIp);
|
||||
add(l10n.callInfoPeerNetwork, info?.peerNetwork);
|
||||
@@ -1378,9 +1436,7 @@ class _CallInfoSheet extends StatelessWidget {
|
||||
final vtracks = renderer.srcObject?.getVideoTracks().length ?? 0;
|
||||
add(
|
||||
l10n.callInfoVideoTrack,
|
||||
vtracks > 0
|
||||
? l10n.callInfoVideoTrackPresent(vtracks)
|
||||
: l10n.callValueNo,
|
||||
vtracks > 0 ? l10n.callInfoVideoTrackPresent(vtracks) : l10n.callValueNo,
|
||||
);
|
||||
final w = renderer.value.width.toInt();
|
||||
final h = renderer.value.height.toInt();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../main.dart' show api, accountModule;
|
||||
import '../../../backend/modules/account.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../core/calls/call_controller.dart';
|
||||
import '../../../core/calls/call_session.dart';
|
||||
import '../../../backend/modules/calls.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
@@ -14,6 +16,8 @@ import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/chat_menu_overlay.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
import '../../widgets/prompt_dialog.dart';
|
||||
import '../../widgets/call_link_handler.dart';
|
||||
import 'call_screen.dart';
|
||||
|
||||
class CallsTab extends StatefulWidget {
|
||||
@@ -330,6 +334,102 @@ class _CallsTabState extends State<CallsTab> with ReloadOnReconnect {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLinkAction(
|
||||
ColorScheme cs, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
bool alignEnd = false,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: alignEnd
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: cs.primary, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createGroupCall() async {
|
||||
final controller = CallController.instance;
|
||||
if (controller.isBusy) {
|
||||
showCustomNotification(context, 'Звонок уже идёт');
|
||||
return;
|
||||
}
|
||||
|
||||
final navigator = Navigator.of(context);
|
||||
({CallSession session, String? joinLink}) created;
|
||||
try {
|
||||
created = await controller.createGroupCall();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось создать звонок: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
final link = created.joinLink;
|
||||
if (link != null) {
|
||||
await Clipboard.setData(ClipboardData(text: link));
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Ссылка на звонок скопирована');
|
||||
}
|
||||
|
||||
await navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
name: 'Групповой звонок',
|
||||
session: created.session,
|
||||
isGroup: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _joinGroupCall() async {
|
||||
if (CallController.instance.isBusy) {
|
||||
showCustomNotification(context, 'Звонок уже идёт');
|
||||
return;
|
||||
}
|
||||
|
||||
final url = await showTextInputDialog(
|
||||
context,
|
||||
title: 'Присоединиться к звонку',
|
||||
description: 'Вставьте ссылку-приглашение',
|
||||
hint: 'https://max.ru/joincall/...',
|
||||
confirmLabel: 'Присоединиться',
|
||||
keyboardType: TextInputType.url,
|
||||
);
|
||||
if (url == null || url.trim().isEmpty || !mounted) return;
|
||||
|
||||
final handled = await tryHandleCallLink(context, url.trim());
|
||||
if (!handled && mounted) {
|
||||
showCustomNotification(context, 'Это не ссылка на звонок');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTabItem(String label, int index, ColorScheme cs) {
|
||||
final isSelected = _selectedTabIndex == index;
|
||||
return GestureDetector(
|
||||
@@ -393,27 +493,28 @@ class _CallsTabState extends State<CallsTab> with ReloadOnReconnect {
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.link, color: cs.primary, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
'Создать групповой звонок',
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildLinkAction(
|
||||
cs,
|
||||
icon: Symbols.link,
|
||||
label: 'Создать звонок',
|
||||
onTap: _createGroupCall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildLinkAction(
|
||||
cs,
|
||||
icon: Symbols.group_add,
|
||||
label: 'Присоединиться',
|
||||
onTap: _joinGroupCall,
|
||||
alignEnd: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
|
||||
@@ -550,7 +550,9 @@ class _CheckersViewState extends State<_CheckersView> {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final w = _result;
|
||||
if (w != null) return w == _me ? l10n.hubCheckersWon : l10n.hubCheckersLost;
|
||||
return _turn == _me ? l10n.hubCheckersYourMove : l10n.hubCheckersOpponentMove;
|
||||
return _turn == _me
|
||||
? l10n.hubCheckersYourMove
|
||||
: l10n.hubCheckersOpponentMove;
|
||||
}
|
||||
|
||||
Widget _boardWidget(ColorScheme cs) {
|
||||
|
||||
Reference in New Issue
Block a user