ПОШЛО НАХУЙ КТО ЭТИ ЗВОВНКИ ДЕЛАЛ
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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`
|
||||
и половина полей в объекте согласования фич добавляются **только** при включённой фиче.
|
||||
Слать их безусловно — рискованно.
|
||||
@@ -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