ПОШЛО НАХУЙ КТО ЭТИ ЗВОВНКИ ДЕЛАЛ

This commit is contained in:
Jganenokk
2026-07-29 19:10:03 +07:00
parent f4f9e22be8
commit 0e51038051
17 changed files with 2562 additions and 217 deletions
+2 -1
View File
@@ -15,6 +15,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
@@ -123,7 +124,7 @@
tools:node="remove" />
<service
android:name=".CallForegroundService"
android:foregroundServiceType="microphone"
android:foregroundServiceType="microphone|mediaProjection"
android:exported="false" />
<receiver
android:name=".CallActionReceiver"
@@ -20,8 +20,31 @@ class CallForegroundService : Service() {
companion object {
const val ACTION_START = "ru.komet.app.CALL_ONGOING_START"
const val ACTION_STOP = "ru.komet.app.CALL_ONGOING_STOP"
const val ACTION_SCREEN_SHARE = "ru.komet.app.CALL_SCREEN_SHARE"
const val EXTRA_SCREEN_SHARE = "screenShare"
const val ONGOING_ID = 424243
@Volatile
var screenShare = false
fun setScreenShare(ctx: Context, enabled: Boolean, caller: String) {
screenShare = enabled
val intent = Intent(ctx, CallForegroundService::class.java).apply {
action = ACTION_SCREEN_SHARE
putExtra(CallConst.EXTRA_CALLER, caller)
putExtra(EXTRA_SCREEN_SHARE, enabled)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
} catch (e: Exception) {
Log.w("KometFcm", "screen share FGS update failed: ${e.message}")
}
}
fun start(ctx: Context, caller: String) {
CallState.inCall = true
val intent = Intent(ctx, CallForegroundService::class.java).apply {
@@ -41,6 +64,7 @@ class CallForegroundService : Service() {
fun stop(ctx: Context) {
CallState.inCall = false
screenShare = false
try {
ctx.startService(
Intent(ctx, CallForegroundService::class.java).apply {
@@ -66,6 +90,12 @@ class CallForegroundService : Service() {
stopForeground(true)
stopSelf()
}
ACTION_SCREEN_SHARE -> {
screenShare = intent.getBooleanExtra(EXTRA_SCREEN_SHARE, false)
val caller = intent.getStringExtra(CallConst.EXTRA_CALLER) ?: "Звонок"
CallNotifier.ensureChannel(this)
startAsForeground(caller)
}
else -> {
val caller = intent?.getStringExtra(CallConst.EXTRA_CALLER) ?: "Звонок"
CallNotifier.ensureChannel(this)
@@ -103,15 +133,16 @@ class CallForegroundService : Service() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
ONGOING_ID, notif,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
if (screenShare) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
startForeground(ONGOING_ID, notif, types)
} else {
startForeground(ONGOING_ID, notif)
}
} catch (e: Exception) {
Log.w("KometFcm", "startForeground(mic) failed: ${e.message}")
Log.w("KometFcm", "startForeground failed: ${e.message}")
stopSelf()
}
}
@@ -296,6 +296,16 @@ class MainActivity : FlutterActivity() {
CallForegroundService.start(applicationContext, caller)
result.success(null)
}
"setScreenShare" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
val caller = call.argument<String>("caller") ?: "Звонок"
CallForegroundService.setScreenShare(
applicationContext,
enabled,
caller,
)
result.success(null)
}
"notifyEnded" -> {
CallRinger.stop()
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
+175
View File
@@ -0,0 +1,175 @@
1 PING
2 DEBUG
3 RECONNECT
5 LOG
6 SESSION_INIT
8 LOGIN2
16 PROFILE
17 AUTH_REQUEST
18 AUTH
19 LOGIN
20 LOGOUT
21 SYNC
22 CONFIG
23 AUTH_CONFIRM
25 PRESET_AVATARS
26 ASSETS_GET
27 ASSETS_UPDATE
28 ASSETS_GET_BY_IDS
29 ASSETS_ADD
32 CONTACT_INFO
33 CONTACT_ADD
34 CONTACT_UPDATE
35 CONTACT_PRESENCE
36 CONTACT_LIST
37 CONTACT_SEARCH
39 CONTACT_PHOTOS
40 CONTACT_SORT
42 CONTACT_VERIFY
43 REMOVE_CONTACT_PHOTO
46 CONTACT_INFO_BY_PHONE
48 CHAT_INFO
49 CHAT_HISTORY
50 CHAT_MARK
51 CHAT_MEDIA
52 CHAT_DELETE
53 CHATS_LIST
54 CHAT_CLEAR
55 CHAT_UPDATE
56 CHAT_CHECK_LINK
57 CHAT_JOIN
58 CHAT_LEAVE
59 CHAT_MEMBERS
60 PUBLIC_SEARCH
61 CHAT_PERSONAL_CONFIG
62 CHAT_LIVESTREAM_INFO
64 MSG_SEND
65 MSG_TYPING
66 MSG_DELETE
67 MSG_EDIT
68 CHAT_SEARCH
70 MSG_SHARE_PREVIEW
71 MSG_GET
72 MSG_SEARCH_TOUCH
73 MSG_SEARCH
74 MSG_GET_STAT
75 CHAT_SUBSCRIBE
76 VIDEO_CHAT_START
77 CHAT_MEMBERS_UPDATE
78 VIDEO_CHAT_START_ACTIVE
79 VIDEO_CHAT_HISTORY
80 PHOTO_UPLOAD
81 STICKER_UPLOAD
82 VIDEO_UPLOAD
83 VIDEO_PLAY
84 VIDEO_CHAT_CREATE_JOIN_LINK
86 CHAT_PIN_SET_VISIBILITY
87 FILE_UPLOAD
88 FILE_DOWNLOAD
89 LINK_INFO
91 GET_COMMENTS_UPDATES
92 MSG_DELETE_RANGE
94 MSG_DELETE_USER
96 SESSIONS_INFO
97 SESSIONS_CLOSE
98 PHONE_BIND_REQUEST
99 PHONE_BIND_CONFIRM
101 AUTH_LOGIN_RESTORE_PASSWORD
103 GET_INBOUND_CALLS
104 AUTH_2FA_DETAILS
105 EXTERNAL_CALLBACK
106 PHONE_WEBAPP_SHARE
107 AUTH_VALIDATE_PASSWORD
108 AUTH_VALIDATE_HINT
109 AUTH_VERIFY_EMAIL
110 AUTH_CHECK_EMAIL
111 AUTH_SET_2FA
112 AUTH_CREATE_TRACK
113 AUTH_CHECK_PASSWORD
115 AUTH_LOGIN_CHECK_PASSWORD
116 AUTH_LOGIN_PROFILE_DELETE
117 CHAT_COMPLAIN
118 MSG_SEND_CALLBACK
119 SUSPEND_BOT
124 LOCATION_STOP
125 LOCATION_SEND
126 LOCATION_REQUEST
127 GET_LAST_MENTIONS
128 NOTIF_MESSAGE
129 NOTIF_TYPING
130 NOTIF_MARK
131 NOTIF_CONTACT
132 NOTIF_PRESENCE
134 NOTIF_CONFIG
135 NOTIF_CHAT
136 NOTIF_ATTACH
137 NOTIF_CALL_START
139 NOTIF_CONTACT_SORT
140 NOTIF_MSG_DELETE_RANGE
142 NOTIF_MSG_DELETE
143 NOTIF_CALLBACK_ANSWER
144 CHAT_BOT_COMMANDS
145 BOT_INFO
147 NOTIF_LOCATION
148 NOTIF_LOCATION_REQUEST
150 NOTIF_ASSETS_UPDATE
154 NOTIF_MSG_DELAYED
155 NOTIF_MSG_REACTIONS_CHANGED
156 NOTIF_MSG_YOU_REACTED
158 OK_TOKEN
159 NOTIF_PROFILE
160 WEB_APP_INIT_DATA
161 COMPLAIN
162 COMPLAIN_REASONS_GET
163 CALL_HISTORY
164 CALL_HISTORY_CLEAR
165 NOTIF_CALL_HISTORY
166 VIDEO_CHAT_JOIN
178 MSG_REACTION
179 MSG_CANCEL_REACTION
180 MSG_GET_REACTIONS
181 MSG_GET_DETAILED_REACTIONS
193 STICKER_CREATE
194 STICKER_SUGGEST
195 VIDEO_CHAT_MEMBERS
196 CHAT_HIDE
198 CHAT_SEARCH_COMMON_PARTICIPANTS
199 PROFILE_DELETE
200 PROFILE_DELETE_TIME
202 TRANSCRIBE_MEDIA
203 PHOTO_URL_REFRESH
208 STORIES_LIST
209 STORIES_LIST_BY_OWNER_ID
210 STORIES_GET_BY_OWNER_ID
211 STORIES_GET_STATS
212 STORIES_GET_DETAILED_STATS
213 STORIES_REACT
214 STORIES_MARK
215 STORIES_SEND
216 NOTIF_STORIES_UPDATE
217 STORIES_EDIT
218 STORIES_DELETE
220 STORIES_GET_BY_STORY_ID
256 ORG_INFO
257 CHAT_REACTIONS_SETTINGS_SET
258 REACTIONS_SETTINGS_GET_BY_CHAT_ID
259 ASSETS_REMOVE
260 ASSETS_MOVE
261 ASSETS_LIST_MODIFY
272 FOLDERS_GET
273 FOLDERS_GET_BY_ID
274 FOLDERS_UPDATE
275 FOLDERS_REORDER
276 FOLDERS_DELETE
277 NOTIF_FOLDERS
290 AUTH_QR_APPROVE
292 NOTIF_BANNERS
293 NOTIF_TRANSCRIPTION
300 CHAT_SUGGEST
301 AUDIO_PLAY
302 BANNERS_GET
303 MSG_DELIVERY
304 SEND_VOTE
305 VOTERS_LIST_BY_ANSWER
306 GET_POLL_UPDATES
307 CHAT_CHECK_ESIA
+583
View File
@@ -0,0 +1,583 @@
# Протокол звонков (ru.oneme.app 26.24.0, versionCode 6784)
Источник: реверс APK `ru.oneme.app.apk` (jadx). SDK звонков — `ru.ok.android.externcalls.sdk`
(`calls-sdk`, sdkVersion `0.2.3`), медиаядро — `libjingle_peerconnection_so.so` (WebRTC).
## 0. Два независимых слоя
Звонок живёт в **двух** протоколах, и это ключ ко всему:
| Слой | Транспорт | Формат | За что отвечает |
|------|-----------|--------|-----------------|
| **MAX** | основной сокет приложения | MessagePack + Zstd, опкоды | создать/присоединиться к звонку, ссылки, история, пуш о входящем |
| **Signaling** | отдельный WebSocket на `endpoint` из ответа MAX | **JSON текстом** | всё внутри звонка: медиа, SDP/ICE, админка, участники |
MAX-слой выдаёт `endpoint` + `token`, дальше клиент открывает второй сокет и всё
управление звонком идёт уже там. Админка групповых звонков — целиком второй слой.
---
## 1. MAX-слой: опкоды звонков
Таблица опкодов вытащена из enum `p000/kzb.java` (`new kzb("ИМЯ", ordinal, (short) opcode, parser)`).
| Опкод | Имя | Назначение |
|-------|-----|-----------|
| 76 | `VIDEO_CHAT_START` | старт видеочата (в этой сборке ссылок на отправку нет — легаси) |
| 78 | `VIDEO_CHAT_START_ACTIVE` | **создать / войти в звонок** |
| 79 | `VIDEO_CHAT_HISTORY` | история видеочатов |
| 84 | `VIDEO_CHAT_CREATE_JOIN_LINK` | **создать ссылку-приглашение** |
| 89 | `LINK_INFO` | превью по ссылке (в т.ч. звонковой) |
| 137 | `NOTIF_CALL_START` | пуш «входящий звонок» |
| 163 | `CALL_HISTORY` | история звонков |
| 164 | `CALL_HISTORY_CLEAR` | очистка истории |
| 165 | `NOTIF_CALL_HISTORY` | пуш обновления истории |
| 166 | `VIDEO_CHAT_JOIN` | **вход по ссылке** |
| 195 | `VIDEO_CHAT_MEMBERS` | участники видеочата |
Полный дамп всех 175 опкодов — `calls-opcodes.txt` рядом с этим файлом.
### 1.0 Сверка с нашим `opcode_map.dart`
По звонкам расхождений в номерах нет, только в названиях (`164` мы зовём
`videoChatDeleteHistory`, в APK это `CALL_HISTORY_CLEAR`; `166``videoChatJoinByLink`
против `VIDEO_CHAT_JOIN`). Не хватает трёх звонковых опкодов:
`163 CALL_HISTORY`, `165 NOTIF_CALL_HISTORY``103 GET_INBOUND_CALLS` у нас есть).
Вне звонков сверка дала одно расхождение, которое стоит проверить отдельно:
у нас `contactsGet = 8`, в APK опкод `8` — это `LOGIN2`. Остальные отличия — только имена
(`91` GET_COMMENTS_UPDATES / commentsInfo, `202`, `209`, `210`, `293`, `302`).
### 1.1 Создание / вход в звонок — opcode 78 `VIDEO_CHAT_START_ACTIVE`
`p000/vjb.java``m30941c()`:
```jsonc
// request payload
{
"conversationId": "<string>", // ApiProtocol.PARAM_CONVERSATION_ID
"calleeIds": [<long>, ...], // только если массив непустой (p2p-звонок контактам)
"chatId": <long>, // только если != null (звонок в чате/группе)
"isVideo": <bool>,
"internalParams": "<json-string>" // см. 1.4
}
```
`conversationId` генерируется клиентом (`one.me.calls.api.conversationid.ConversationIdGenerator`),
а не сервером — это идемпотентный ключ звонка.
### 1.2 Ссылка на групповой звонок — opcode 84 `VIDEO_CHAT_CREATE_JOIN_LINK`
`p000/rg4.java:1664`:
```jsonc
// request
{ "conversationId": "<string>" }
```
Вызывается уже **после** того, как звонок создан (лог `"start creating p2p join link"`,
ошибки `"create p2p join link failed due to conversationId in null or empty"`,
`"join link already exist"`, метрика провала `CREATE_LINK_FAILED`).
То есть ссылка не создаёт звонок — она выдаётся к существующему `conversationId`.
Ответ — объект `VideoConference` (`p000/r6i.java`, msgpack-ключи):
```jsonc
{
"conferenceId": "<string>",
"conversationId": "<string>",
"joinLink": "<string>",
"chatId": <long>,
"callName": "<string>",
"participantsCount": <int>,
"previewParticipantIds": [<long>, ...],
"startAt": <long>,
"type": <int>,
"owner": <...>
}
```
Этот же объект приходит в превью ссылки (`LINK_INFO`, 89).
### 1.3 Вход по ссылке — opcode 166 `VIDEO_CHAT_JOIN`
`p000/vjb.java``m30940b()`:
```jsonc
// request
{
"joinLink": "<string>", // ApiProtocol.PARAM_JOIN_LINK
"isVideo": <bool>,
"internalParams": "<json-string>"
}
```
Deep-link/роут разбирается в `p000/wg1.java` по ключам
`call_link`, `is_link_call`, `call_chat_id`, `call_title`, `chat_id`.
### 1.4 `internalParams`
JSON-строка, сериализуется из `InternalParamsDto`
(`ru/ok/android/externcalls/sdk/api/delegate/InternalParamsDto`), собирается в `p000/f98.java`:
```jsonc
{
"platform": "ANDROID", // UploadHelper.SDK_TYPE_STRING
"sdkVersion": "0.2.3",
"clientAppKey": "<string>",
"deviceId": "<string>",
"protocolVersion": 6, // 6 если multiple-devices включён, иначе 5
"domainId": "<string|null>",
"onlyAdminCanRecord": false,
"isWaitForAdminEnabled": <bool>,
"hexCapability": "1877f" // см. раздел 6
}
```
### 1.5 Ответ: параметры конференции
Разбирается `CallInfoParser` / `ConversationParams` / `CallInfo`, ключи из `ApiProtocol`:
```jsonc
{
"endpoint": "<wss url>", // сюда открываем signaling-сокет
"wt_endpoint": "<wss url>", // watch-together
"id": "<string>",
"token": "<string>",
"join_link": "<string>",
"client_type": "<string>",
"device_idx": <int>,
"is_concurrent": <bool>,
"p2p_forbidden": <bool>,
"upload_url": "<string>",
"stun_server": { "urls": [ ... ] },
"turn_server": { "urls": [ ... ], "username": "...", "credential": "..." }
}
```
Плюс `wsIps` / `wtIps` — списки IP для обхода DNS.
---
## 2. Signaling: конверт сообщений
WebSocket на `endpoint`, полезная нагрузка — **JSON строкой** (не msgpack).
**Клиент → сервер** (`p000/r7l.java:m26483a`, `p000/tkf.java:117`):
```jsonc
{ "command": "<имя>", ...поля..., "sequence": <long> }
```
**Сервер → клиент**, три вида (`p000/tkf.java:m29114f`, `p000/r51.java:100`):
```jsonc
{ "type": "response", "sequence": <long>, "response": "<имя команды>", ... }
{ "type": "error", "sequence": <long>, "error": "<код>", "errorCode": <int>, "recoverable": <bool> }
{ "notification": "<имя>", ... } // + "stamp": <long> для порядка
```
Коды ошибок, которые встречаются: `service-unavailable`, `participants-limit-reached` (+`limit`),
`conversation-ended`, `conversation-not-found`, `conversation-recording`, `closed-conversation`,
`illegal-conversation-state`, `illegal-participant-state`, `invalid-request`, `invalid-token`,
`no-call`, `call-unfeasible`, `command-not-delivered`, `command-discarded`,
`command-can-not-be-postponed`, `movie-not-found`, `movie-limit-exceeded`.
### 2.1 Идентификатор участника
`participantId` в разных местах в двух формах:
- разложенный: `{"participantId": <long>, "participantType": "GROUP"|"USER", "deviceIdx": <int>}`
- строкой (`mq1.m20652b()`): `u<id>` для юзера, `g<id>` для группы, с суффиксом устройства
Идентификаторы медиа-треков (`p000/r7l.java:m26478K`):
```
<participantId>:sCAMERA // камера
<participantId>:sSCREEN // ДЕМОНСТРАЦИЯ ЭКРАНА
<participantId>:sMOVIE:m<id> // расшаренное видео
<participantId>:sSTREAM
<participantId>:sANIMOJI
audio-<participantId> // аудио-трек
video-<participantId> // видео-трек
```
---
## 3. Медиа: камера / микрофон / экран
### 3.1 Своё состояние — `change-media-settings`
`p000/zkf.java` + `p000/r7l.java:m26497o`. Шлётся **целиком, не дельтой**, при любом переключении:
```jsonc
{
"command": "change-media-settings",
"mediaSettings": {
"isVideoEnabled": <bool>, // камера
"isAudioEnabled": <bool>, // микрофон
"isScreenSharingEnabled": <bool>, // ДЕМОНСТРАЦИЯ ЭКРАНА
"isAnimojiEnabled": <bool>,
"isFastScreenSharingEnabled": <bool>, // только если включён fastScreenShare
"isAudioSharingEnabled": <bool> // только если включён audioShare
},
"sequence": <long>
}
```
Последние два поля **условные** — добавляются только когда соответствующая фича согласована
при подключении. Если слать их всегда — есть риск, что старый сервер отвергнет команду.
### 3.2 Чужое состояние — нотификации
```jsonc
{ "notification": "media-settings-changed",
"participantId": <long>, "participantType": "...", "deviceIdx": <int>,
"mediaSettings": { "isAudioEnabled": ..., "isVideoEnabled": ...,
"isScreenSharingEnabled": ..., "isAnimojiEnabled": ... } }
```
```jsonc
// админ принудительно выключил медиа — клиент ОБЯЗАН применить и погасить у себя
{ "notification": "force-media-settings-change", "mediaSettings": { ... } }
```
```jsonc
{ "notification": "audio-activity", "activeParticipants": ["u123", ...] }
{ "notification": "speaker-changed", "speaker": "u123" }
```
### 3.3 Демонстрация экрана — детали
Что нужно, чтобы экран реально поехал (`ScreenCaptureManagerImpl.setScreenCaptureEnabled`):
1. **Capability `SCREEN_TRACK_PRODUCER` (бит 0)** должен быть в маске (см. 6).
Без него сервер не примет screen-трек. `SCREEN_TRACK_CONSUMER` (бит 4) — чтобы видеть чужой.
2. Проверяется медиа-опция `screenshareState`: если админ поставил `MUTE_PERMANENT`
на `SCREEN_SHARING` — включение молча игнорируется.
3. `change-media-settings` с `isScreenSharingEnabled: true`.
4. В PeerConnection добавляется **отдельный видео-трек** с id `<pid>:sSCREEN`
(unified plan, вторая m-line) — камера при этом остаётся своим треком `<pid>:sCAMERA`.
Это самое частое место поломки: если слать экран в тот же трек, что и камеру,
удалённая сторона увидит либо камеру, либо ничего.
5. Foreground-сервис `CallScreenShareService` с типом `mediaProjection` — андроид-специфика,
но без него система убивает захват.
**Про 1:1 (topology `DIRECT`).** Топология бывает `DIRECT` (p2p) и `SERVER` (`p000/f9h.java`).
Само видео экрана в `DIRECT` работает, а вот **звук демонстрации** (`setAudioCaptureEnabled`,
`n61.m21001I`) жёстко требует `SERVER`:
```java
if (m21020p() && this.f46846n0.m24163I(f9h.f22608c)) { // f22608c == SERVER
```
Апгрейд топологии клиент запрашивает сам:
```jsonc
{ "command": "switch-topology", "topology": "SERVER", "force": false }
```
В стоковом клиенте это шлётся при развале p2p ICE (`onTopologyUpgradeProposed`), но команда
доступна в любой момент. Сервер отвечает нотификацией:
```jsonc
{ "notification": "topology-changed", "topology": "SERVER" | "DIRECT" }
```
Практический вывод для нашего клиента: если экран в 1:1 не едет — проверять в порядке
(1) бит capability, (2) отдельный `sSCREEN`-трек, (3) при необходимости `switch-topology → SERVER`.
### 3.4 Согласование фич при подключении
`p000/r7l.java:m26485c` — объект, который клиент отдаёт при инициализации сессии.
Именно он включает `fastScreenShare` и остальное:
```jsonc
{
"maxH264Decoders": <int>,
"estimatedPerformanceIndex": <int>, // опционально
"producerNotificationDataChannelVersion": 7,
"producerCommandDataChannelVersion": <int>,
"audioMix": true,
"consumerUpdate": <bool>,
"onDemandTracks": <bool>,
"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": <int>, // если > 0
"csrcAccessible": true // если videoTracksCount > 0
}
```
---
## 4. SDP / ICE
Всё заворачивается в `transmit-data` (`p000/r7l.java:m26493k`, `m26502t`, `m26503u`):
```jsonc
// offer / answer
{ "command": "transmit-data",
"participantId": <long>, "participantType": "...", "deviceIdx": <int>,
"data": {
"sdp": { "type": "offer"|"answer", "sdp": "<sdp>", "p2pRelay": "true" },
"capabilities": "1877f", // hex, если != 0
"label": "<string>" // опционально
},
"sequence": <long> }
// одиночный кандидат
{ "command": "transmit-data", ..., "data": { "candidate": { "candidate": "...", "sdpMid": "...", "sdpMLineIndex": <int> } } }
// удалённые кандидаты
{ "command": "transmit-data", ..., "data": { "candidates-removed": [ {...}, ... ] } }
```
`p2pRelay: "true"` (строкой!) добавляется только в offer при включённом P2P-relay.
Топология SERVER добавляет отдельный набор команд (`p000/ooh.java`):
`allocate-consumer`, `accept-producer`, `request-realloc` — аллокация консьюмеров/продюсеров
на медиасервере. Плюс `change-simulcast`, `update-display-layout`, `report-network-stat`,
`report-perf-stat` (`p000/ug8.java`).
---
## 5. Групповые звонки: администрирование
Роли (`p000/pq1.java`): **`CREATOR`**, **`ADMIN`**, **`SPEAKER`**.
### 5.1 Мьют участников
```jsonc
// запросить включение медиа у участника (или у всех, если participantId=null)
{ "command": "mute-participant",
"participantId": "u123" | null,
"requestedMedia": ["AUDIO"|"VIDEO"|"SCREEN_SHARING"|"MOVIE_SHARING", ...],
"roomId": "<id>" // опционально, для session room
}
```
```jsonc
// жёстко выставить состояния мьюта
{ "command": "mute-participant",
"participantId": "u123" | null,
"muteStates": {
"AUDIO": "UNMUTE"|"MUTE"|"MUTE_PERMANENT"|null,
"VIDEO": ...,
"SCREEN_SHARING": ...,
"MOVIE_SHARING": ...
},
"roomId": "<id>" // опционально
}
```
`MUTE` — выключить сейчас (можно включить обратно), `MUTE_PERMANENT` — запретить.
Внутренние состояния клиента: `UNMUTED`, `UNMUTED_BUT_MUTED_ONCE`, `MUTED_PERMANENT`,
`MUTED_PERMANENT_BUT_UNMUTED_ONCE`.
Быстрый мьют микрофона (`ConversationImpl:2331`, `:3446`):
```jsonc
{ "command": "switch-micro", "eId": "u123", "muteTarget": <bool> }
{ "command": "switch-micro", "all": true, "muteTarget": true }
```
Обратная нотификация: `{ "notification": "switch-micro", "mute": <bool> }`
(если поля `mute` нет — клиент логирует `switch-micro without 'mute'` и игнорирует).
### 5.2 Роли и промоушен
```jsonc
{ "command": "promote-participant", "participantId": "u123", "demote": <bool> }
{ "command": "grant-roles", "participantId": "u123",
"roles": ["ADMIN", "SPEAKER", ...], "revoke": <bool> }
// стерео-режим / запрос слова
{ "command": "request-promotion", ... } // + reject / unrequest
{ "command": "accept-promotion", ... }
{ "command": "get-hand-queue" }
```
### 5.3 Участники
```jsonc
// по внешним id
{ "command": "add-participant",
"externalIds": [ ... ],
"unban": true, // опционально
"payload": "{\"show_chat_history\":true}" } // опционально, СТРОКОЙ
// по ссылке/QR
{ "command": "add-participant", "participantIdAsQRCodeLink": "<link>" }
{ "command": "remove-participant", "participantId": "u123" }
{ "command": "pin-participant", "participantId": "u123",
"unpin": <bool>, "roomId": "<id>" }
{ "command": "get-participant-list-chunk",
"count": <int>, "listType": "GRID"|"SIDE", "roomId": "<id>" }
{ "command": "get-waiting-hall", "count": <int>, "fromId": "...", "backward": <bool> }
```
### 5.4 Состояния участника (рука, помощь)
```jsonc
{ "command": "change-participant-state",
"participantId": "u123", // отсутствует → своё состояние
"participantState": { "state": { "hand": "1"|"0", "drat": "1"|"0" } } }
{ "command": "put-hands-down" } // опустить руки всем
```
`hand` — поднятая рука, `drat` — запрошена помощь (`ASSISTANCE_REQUESTED`).
Значения строковые `"1"` / `"0"`.
### 5.5 Опции конференции
```jsonc
{ "command": "change-options",
"options": { "WAITING_HALL": true, "AUDIENCE_MODE": false, ... } }
```
Доступные опции (`p000/l61.java`): `REQUIRE_AUTH_TO_JOIN`, `WAITING_HALL`, `RECURRING`,
`FEEDBACK`, `AUDIENCE_MODE`, `ASR`, `WAIT_FOR_ADMIN`, `ADMIN_IS_HERE`.
```jsonc
{ "command": "enable-feature-for-roles",
"feature": "ADD_PARTICIPANT"|"ADMIN"|"ASR"|"MOVIE_SHARE"|"RECORD"|"SPEAKER",
"roles": ["ADMIN", "SPEAKER", ...] }
```
### 5.6 Запись
```jsonc
{ "command": "record-start",
"movieId": <long>, "name": "...", "description": "...",
"privacy": "...", "groupId": <long>, "albumId": "...",
"streamMovie": <bool>, "roomId": "<id>" }
{ "command": "record-stop", "roomId": "<id>", "remove": <bool> }
```
Ответ на `record-start`: `{"type":"response","sequence":N,"response":"record-start","recordMovieId":<id>}`.
### 5.7 Комнаты (session rooms)
```jsonc
{ "command": "switch-room", "participantId": "u123", "toRoomId": "<id>" }
{ "command": "update-rooms", "rooms": [ { "id": "...",
"addParticipantIds": [...], "removeParticipantIds": [...] } ] }
{ "command": "get-rooms" }
{ "command": "room-query", ... }
{ "command": "room-tx", ... }
```
### 5.8 Совместный просмотр / шаринг ссылки
```jsonc
{ "command": "add-movie", "movieId": <long>, ... }
{ "command": "update-movie", "movieId": <long>, "pause": <bool> }
{ "command": "remove-movie", "movieId": <long> }
{ "command": "start-url-sharing", "sharedUrl": "<url>" }
{ "command": "stop-url-sharing" }
```
### 5.9 Прочее
```jsonc
{ "command": "hangup", "reason": "..." }
{ "command": "accept-call", ... }
{ "command": "custom-data", "participantId": <long>, "participantType": "...",
"deviceIdx": <int>, "data": { ...произвольный JSON... } }
{ "command": "request-asr", ... }
```
---
## 6. Capabilities
`ClientCapabilities` — битовая маска, передаётся **hex-строкой**`capabilities` внутри
`transmit-data.data` и в `hexCapability` внутри `internalParams`).
| Бит | Имя | Что даёт |
|-----|-----|----------|
| 0 | `SCREEN_TRACK_PRODUCER` | **отдавать демонстрацию экрана** |
| 1 | `VIDEO_TRACKS` | видео-треки |
| 2 | `WAITING_HALL` | зал ожидания |
| 3 | `FILTER_DEFAULTS` | |
| 4 | `SCREEN_TRACK_CONSUMER` | **принимать чужую демонстрацию** |
| 5 | `ADMIN_MUTE_NOTIFY` | нотификации админского мьюта |
| 6 | `WATCH_MOVIE` | совместный просмотр |
| 8 | `SESSION_ROOMS` | комнаты |
| 9 | `VMOJI` | анимоджи |
| 10 | `CALL_TO_CONTACTS` | |
| 11 | `AUDIENCE_MODE` | режим зрителей |
| 14 | `SESSION_STATE_UPDATES` | |
| 15 | `ADD_PARTICIPANT` | добавление участников |
| 16 | `USE_P2P_RELAY` | p2p-relay |
| 17 | `WAIT_FOR_ADMIN` | ждать админа |
| 18 | `HOLD` | удержание |
Дефолт стокового клиента — биты `0,1,2,3,4,5,6,8,9,10,15,16` = **`0x1877f`**.
Обрати внимание: `AUDIENCE_MODE`(11), `SESSION_STATE_UPDATES`(14), `WAIT_FOR_ADMIN`(17),
`HOLD`(18) в дефолт **не входят** и включаются по конфигу.
---
## 7. Нотификации сервер → клиент (полный список)
Из switch в `p000/r51.java` (ключ `"notification"`):
**Сессия и участники**
`connection`, `registered-peer`, `participant-added`, `participant-joined`, `accepted-call`,
`participants-state-changed`, `participant-state-changed`, `participant-animoji-changed`,
`decorative-participant-id-changed`, `session-state`, `stalled-activity`, `hold`, `hungup`,
`closed-conversation`, `multiparty-chat-created`
**Медиа**
`media-settings-changed`, `force-media-settings-change`, `audio-activity`, `speaker-changed`,
`switch-micro`, `mute-participant`, `topology-changed`, `realloc-con`, `transmitted-data`
**Админка**
`roles-changed`, `promote-participant`, `promotion-approved`, `options-changed`,
`feature-set-changed`, `features-per-role-changed`, `pin-participant`, `settings-update`
**Комнаты**
`room-updated`, `rooms-updated`, `room-participants-updated`, `chat-room-updated`
**Контент**
`record-started`, `record-stopped`, `movie-share-started`, `movie-share-stopped`,
`url-sharing-info-updated`, `asr-started`, `asr-stopped`, `chat-message`, `custom-data`,
`join-link-changed`, `feedback`, `rate-call-data`
---
## 8. Что из этого чинит наши баги
**Демонстрация экрана в 1:1** — раздел 3.3. Порядок проверки: бит 0 в capabilities →
отдельный трек `<pid>:sSCREEN``change-media-settings` с полным объектом →
при необходимости `switch-topology → SERVER` (обязательно, если нужен звук демонстрации).
**Групповые звонки** — скорее всего не хватает серверной топологии целиком:
`allocate-consumer` / `accept-producer` / `request-realloc` (`p000/ooh.java`),
плюс `get-participant-list-chunk` для подгрузки участников пачками и
`update-display-layout` для раскладки. В `DIRECT` группа не работает by design.
**Условные поля.** `isFastScreenSharingEnabled` / `isAudioSharingEnabled` в `mediaSettings`
и половина полей в объекте согласования фич добавляются **только** при включённой фиче.
Слать их безусловно — рискованно.
+45 -2
View File
@@ -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');
+298
View File
@@ -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},
);
}
}
+12
View File
@@ -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 {
+26 -1
View File
@@ -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();
+2 -1
View File
@@ -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';
}
+1 -2
View File
@@ -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
View File
@@ -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 {
+107 -70
View File
@@ -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),
);
}
}
+66 -10
View File
@@ -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();
+121 -20
View File
@@ -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(
+3 -1
View File
@@ -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) {