From 0e510380514ae9d8f6852ab96b5d55a292cbd514 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Wed, 29 Jul 2026 19:10:03 +0700 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=9E=D0=A8=D0=9B=D0=9E=20=D0=9D=D0=90?= =?UTF-8?q?=D0=A5=D0=A3=D0=99=20=D0=9A=D0=A2=D0=9E=20=D0=AD=D0=A2=D0=98=20?= =?UTF-8?q?=D0=97=D0=92=D0=9E=D0=92=D0=9D=D0=9A=D0=98=20=D0=94=D0=95=D0=9B?= =?UTF-8?q?=D0=90=D0=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 3 +- .../ru/komet/app/CallForegroundService.kt | 41 +- .../main/kotlin/ru/komet/app/MainActivity.kt | 10 + calls-opcodes.txt | 175 +++++ calls-protocol.md | 583 +++++++++++++++ lib/backend/modules/calls.dart | 47 +- lib/core/calls/call_admin.dart | 298 ++++++++ lib/core/calls/call_bridge.dart | 12 + lib/core/calls/call_controller.dart | 27 +- lib/core/calls/call_info.dart | 3 +- lib/core/calls/call_link.dart | 3 +- lib/core/calls/call_session.dart | 669 +++++++++++++++--- lib/core/calls/ws2_signaling.dart | 177 +++-- .../calls/call_participants_sheet.dart | 510 +++++++++++++ lib/frontend/screens/calls/call_screen.dart | 76 +- lib/frontend/screens/calls/calls_tab.dart | 141 +++- lib/frontend/screens/calls/komet_hub.dart | 4 +- 17 files changed, 2562 insertions(+), 217 deletions(-) create mode 100644 calls-opcodes.txt create mode 100644 calls-protocol.md create mode 100644 lib/core/calls/call_admin.dart create mode 100644 lib/frontend/screens/calls/call_participants_sheet.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b3176a3..ae75f4b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + @@ -123,7 +124,7 @@ tools:node="remove" /> = 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() } } diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 44cd24d..20f6b74 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -296,6 +296,16 @@ class MainActivity : FlutterActivity() { CallForegroundService.start(applicationContext, caller) result.success(null) } + "setScreenShare" -> { + val enabled = call.argument("enabled") ?: false + val caller = call.argument("caller") ?: "Звонок" + CallForegroundService.setScreenShare( + applicationContext, + enabled, + caller, + ) + result.success(null) + } "notifyEnded" -> { CallRinger.stop() NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID) diff --git a/calls-opcodes.txt b/calls-opcodes.txt new file mode 100644 index 0000000..9a836bc --- /dev/null +++ b/calls-opcodes.txt @@ -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 diff --git a/calls-protocol.md b/calls-protocol.md new file mode 100644 index 0000000..d69a237 --- /dev/null +++ b/calls-protocol.md @@ -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": "", // ApiProtocol.PARAM_CONVERSATION_ID + "calleeIds": [, ...], // только если массив непустой (p2p-звонок контактам) + "chatId": , // только если != null (звонок в чате/группе) + "isVideo": , + "internalParams": "" // см. 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": "" } +``` + +Вызывается уже **после** того, как звонок создан (лог `"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": "", + "conversationId": "", + "joinLink": "", + "chatId": , + "callName": "", + "participantsCount": , + "previewParticipantIds": [, ...], + "startAt": , + "type": , + "owner": <...> +} +``` + +Этот же объект приходит в превью ссылки (`LINK_INFO`, 89). + +### 1.3 Вход по ссылке — opcode 166 `VIDEO_CHAT_JOIN` + +`p000/vjb.java` → `m30940b()`: + +```jsonc +// request +{ + "joinLink": "", // ApiProtocol.PARAM_JOIN_LINK + "isVideo": , + "internalParams": "" +} +``` + +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": "", + "deviceId": "", + "protocolVersion": 6, // 6 если multiple-devices включён, иначе 5 + "domainId": "", + "onlyAdminCanRecord": false, + "isWaitForAdminEnabled": , + "hexCapability": "1877f" // см. раздел 6 +} +``` + +### 1.5 Ответ: параметры конференции + +Разбирается `CallInfoParser` / `ConversationParams` / `CallInfo`, ключи из `ApiProtocol`: + +```jsonc +{ + "endpoint": "", // сюда открываем signaling-сокет + "wt_endpoint": "", // watch-together + "id": "", + "token": "", + "join_link": "", + "client_type": "", + "device_idx": , + "is_concurrent": , + "p2p_forbidden": , + "upload_url": "", + "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": } +``` + +**Сервер → клиент**, три вида (`p000/tkf.java:m29114f`, `p000/r51.java:100`): + +```jsonc +{ "type": "response", "sequence": , "response": "<имя команды>", ... } +{ "type": "error", "sequence": , "error": "<код>", "errorCode": , "recoverable": } +{ "notification": "<имя>", ... } // + "stamp": для порядка +``` + +Коды ошибок, которые встречаются: `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": , "participantType": "GROUP"|"USER", "deviceIdx": }` +- строкой (`mq1.m20652b()`): `u` для юзера, `g` для группы, с суффиксом устройства + +Идентификаторы медиа-треков (`p000/r7l.java:m26478K`): + +``` +:sCAMERA // камера +:sSCREEN // ДЕМОНСТРАЦИЯ ЭКРАНА +:sMOVIE:m // расшаренное видео +:sSTREAM +:sANIMOJI +audio- // аудио-трек +video- // видео-трек +``` + +--- + +## 3. Медиа: камера / микрофон / экран + +### 3.1 Своё состояние — `change-media-settings` + +`p000/zkf.java` + `p000/r7l.java:m26497o`. Шлётся **целиком, не дельтой**, при любом переключении: + +```jsonc +{ + "command": "change-media-settings", + "mediaSettings": { + "isVideoEnabled": , // камера + "isAudioEnabled": , // микрофон + "isScreenSharingEnabled": , // ДЕМОНСТРАЦИЯ ЭКРАНА + "isAnimojiEnabled": , + "isFastScreenSharingEnabled": , // только если включён fastScreenShare + "isAudioSharingEnabled": // только если включён audioShare + }, + "sequence": +} +``` + +Последние два поля **условные** — добавляются только когда соответствующая фича согласована +при подключении. Если слать их всегда — есть риск, что старый сервер отвергнет команду. + +### 3.2 Чужое состояние — нотификации + +```jsonc +{ "notification": "media-settings-changed", + "participantId": , "participantType": "...", "deviceIdx": , + "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 `:sSCREEN` + (unified plan, вторая m-line) — камера при этом остаётся своим треком `: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": , + "estimatedPerformanceIndex": , // опционально + "producerNotificationDataChannelVersion": 7, + "producerCommandDataChannelVersion": , + "audioMix": true, + "consumerUpdate": , + "onDemandTracks": , + "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": , // если > 0 + "csrcAccessible": true // если videoTracksCount > 0 +} +``` + +--- + +## 4. SDP / ICE + +Всё заворачивается в `transmit-data` (`p000/r7l.java:m26493k`, `m26502t`, `m26503u`): + +```jsonc +// offer / answer +{ "command": "transmit-data", + "participantId": , "participantType": "...", "deviceIdx": , + "data": { + "sdp": { "type": "offer"|"answer", "sdp": "", "p2pRelay": "true" }, + "capabilities": "1877f", // hex, если != 0 + "label": "" // опционально + }, + "sequence": } + +// одиночный кандидат +{ "command": "transmit-data", ..., "data": { "candidate": { "candidate": "...", "sdpMid": "...", "sdpMLineIndex": } } } + +// удалённые кандидаты +{ "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": "" // опционально, для session room +} +``` + +```jsonc +// жёстко выставить состояния мьюта +{ "command": "mute-participant", + "participantId": "u123" | null, + "muteStates": { + "AUDIO": "UNMUTE"|"MUTE"|"MUTE_PERMANENT"|null, + "VIDEO": ..., + "SCREEN_SHARING": ..., + "MOVIE_SHARING": ... + }, + "roomId": "" // опционально +} +``` + +`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": } +{ "command": "switch-micro", "all": true, "muteTarget": true } +``` + +Обратная нотификация: `{ "notification": "switch-micro", "mute": }` +(если поля `mute` нет — клиент логирует `switch-micro without 'mute'` и игнорирует). + +### 5.2 Роли и промоушен + +```jsonc +{ "command": "promote-participant", "participantId": "u123", "demote": } + +{ "command": "grant-roles", "participantId": "u123", + "roles": ["ADMIN", "SPEAKER", ...], "revoke": } + +// стерео-режим / запрос слова +{ "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": "" } + +{ "command": "remove-participant", "participantId": "u123" } + +{ "command": "pin-participant", "participantId": "u123", + "unpin": , "roomId": "" } + +{ "command": "get-participant-list-chunk", + "count": , "listType": "GRID"|"SIDE", "roomId": "" } + +{ "command": "get-waiting-hall", "count": , "fromId": "...", "backward": } +``` + +### 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": , "name": "...", "description": "...", + "privacy": "...", "groupId": , "albumId": "...", + "streamMovie": , "roomId": "" } + +{ "command": "record-stop", "roomId": "", "remove": } +``` + +Ответ на `record-start`: `{"type":"response","sequence":N,"response":"record-start","recordMovieId":}`. + +### 5.7 Комнаты (session rooms) + +```jsonc +{ "command": "switch-room", "participantId": "u123", "toRoomId": "" } +{ "command": "update-rooms", "rooms": [ { "id": "...", + "addParticipantIds": [...], "removeParticipantIds": [...] } ] } +{ "command": "get-rooms" } +{ "command": "room-query", ... } +{ "command": "room-tx", ... } +``` + +### 5.8 Совместный просмотр / шаринг ссылки + +```jsonc +{ "command": "add-movie", "movieId": , ... } +{ "command": "update-movie", "movieId": , "pause": } +{ "command": "remove-movie", "movieId": } + +{ "command": "start-url-sharing", "sharedUrl": "" } +{ "command": "stop-url-sharing" } +``` + +### 5.9 Прочее + +```jsonc +{ "command": "hangup", "reason": "..." } +{ "command": "accept-call", ... } +{ "command": "custom-data", "participantId": , "participantType": "...", + "deviceIdx": , "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 → +отдельный трек `: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` +и половина полей в объекте согласования фич добавляются **только** при включённой фиче. +Слать их безусловно — рискованно. diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index 0b0299a..d663410 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -135,6 +135,47 @@ class CallsModule { ); } + Future 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 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 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'); diff --git a/lib/core/calls/call_admin.dart b/lib/core/calls/call_admin.dart new file mode 100644 index 0000000..da9a51a --- /dev/null +++ b/lib/core/calls/call_admin.dart @@ -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 requestMedia( + Set media, { + CallParticipantRef? participant, + String? roomId, + }) { + return _signaling.sendCommand( + 'mute-participant', + extra: { + 'participantId': ?participant?.wire, + 'requestedMedia': media.map((m) => m.wire).toList(), + 'roomId': ?roomId, + }, + ); + } + + Future setMuteStates( + Map 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 muteMicrophone( + CallParticipantRef participant, { + bool muted = true, + }) { + return _signaling.sendCommand( + 'switch-micro', + extra: {'eId': participant.wire, 'muteTarget': muted}, + ); + } + + Future muteEveryone() { + return _signaling.sendCommand( + 'switch-micro', + extra: const {'all': true, 'muteTarget': true}, + ); + } + + Future setPromoted(CallParticipantRef participant, bool promoted) { + return _signaling.sendCommand( + 'promote-participant', + extra: {'participantId': participant.wire, 'demote': !promoted}, + ); + } + + Future setRoles( + CallParticipantRef participant, + List roles, { + bool revoke = false, + }) { + return _signaling.sendCommand( + 'grant-roles', + extra: { + 'participantId': participant.wire, + 'roles': roles.map((r) => r.wire).toList(), + 'revoke': revoke, + }, + ); + } + + Future removeParticipant(CallParticipantRef participant) { + return _signaling.sendCommand( + 'remove-participant', + extra: {'participantId': participant.wire}, + ); + } + + Future setPinned( + CallParticipantRef participant, + bool pinned, { + String? roomId, + }) { + return _signaling.sendCommand( + 'pin-participant', + extra: { + 'participantId': participant.wire, + 'unpin': !pinned, + 'roomId': ?roomId, + }, + ); + } + + Future setOptions(Map options) { + return _signaling.sendCommand( + 'change-options', + extra: { + 'options': { + for (final entry in options.entries) entry.key.wire: entry.value, + }, + }, + ); + } + + Future enableFeatureForRoles( + CallFeature feature, + List roles, + ) { + return _signaling.sendCommand( + 'enable-feature-for-roles', + extra: { + 'feature': feature.wire, + 'roles': roles.map((r) => r.wire).toList(), + }, + ); + } + + Future lowerAllHands() => _signaling.sendCommand('put-hands-down'); + + Future setHandRaised(bool raised, {CallParticipantRef? participant}) { + return _setState({'hand': raised ? '1' : '0'}, participant: participant); + } + + Future setAssistanceRequested( + bool requested, { + CallParticipantRef? participant, + }) { + return _setState({'drat': requested ? '1' : '0'}, participant: participant); + } + + Future _setState( + Map state, { + CallParticipantRef? participant, + }) { + return _signaling.sendCommand( + 'change-participant-state', + extra: { + 'participantState': {'state': state}, + 'participantId': ?participant?.wire, + }, + ); + } + + Future addParticipants( + List 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 addParticipantByLink(String link) { + return _signaling.sendCommand( + 'add-participant', + extra: {'participantIdAsQRCodeLink': link}, + ); + } + + Future> 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 stopRecord({bool remove = false, String? roomId}) { + return _signaling.sendCommand( + 'record-stop', + extra: {if (remove) 'remove': true, 'roomId': ?roomId}, + ); + } + + Future> 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> waitingHall({ + int count = 50, + String? fromId, + bool backward = false, + }) { + return _signaling.sendCommand( + 'get-waiting-hall', + extra: {'count': count, 'fromId': ?fromId, 'backward': backward}, + ); + } +} diff --git a/lib/core/calls/call_bridge.dart b/lib/core/calls/call_bridge.dart index 9de9731..e8f9092 100644 --- a/lib/core/calls/call_bridge.dart +++ b/lib/core/calls/call_bridge.dart @@ -78,6 +78,18 @@ class CallBridge { } } + Future setScreenShare(bool enabled, {String? caller}) async { + if (!_android) return; + try { + await _method.invokeMethod('setScreenShare', { + 'enabled': enabled, + 'caller': caller, + }); + } catch (e) { + logger.w('CallBridge.setScreenShare: enabled=$enabled $e'); + } + } + Future notifyEnded() async { if (!_android) return; try { diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart index cbe2e37..7fc9284 100644 --- a/lib/core/calls/call_controller.dart +++ b/lib/core/calls/call_controller.dart @@ -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 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(); diff --git a/lib/core/calls/call_info.dart b/lib/core/calls/call_info.dart index 5b940cb..bd7ea3e 100644 --- a/lib/core/calls/call_info.dart +++ b/lib/core/calls/call_info.dart @@ -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'; } diff --git a/lib/core/calls/call_link.dart b/lib/core/calls/call_link.dart index 4bccb5e..315fa2a 100644 --- a/lib/core/calls/call_link.dart +++ b/lib/core/calls/call_link.dart @@ -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); } diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart index 2c91f91..ba99160 100644 --- a/lib/core/calls/call_session.dart +++ b/lib/core/calls/call_session.dart @@ -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 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 _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 get participants => _participants.values.toList(growable: false); @@ -166,17 +200,124 @@ class CallSession { Future 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 _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 _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.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 _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 _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() ?? []) { + 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 _sampleLevels() async { final pc = _pc; if (pc == null || _ended) return; @@ -220,7 +361,12 @@ class CallSession { } void _enqueue(Map 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 _onNotification(Map 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 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().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 msg) { + void _onParticipantJoined(Map 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 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 msg) { + final id = _participantIdFrom(msg['participantId']); + if (id == null) return; + _upsertParticipant(id, roles: msg['roles']); + _notifyInfo(); + } + void _onParticipantStateChanged(Map 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 _onConnection(Map 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 _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 _rebuildSfuPc() async { + try { + await _pc?.close(); + } catch (_) {} + _pc = null; + _videoSender = null; + _screenSender = null; + _remoteDescSet = false; + _pendingCandidates.clear(); + + for (final track in _localStream?.getTracks() ?? []) { + 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 _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 _onTopologyChanged(Map 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 _onProducerUpdated(Map 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 = {}; + 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 _addRemoteCandidatesFromSdp( + RTCPeerConnection pc, + String sdp, + ) async { + final mid = RegExp(r'^a=mid:(\S+)', multiLine: true).firstMatch(sdp); + if (mid == null) return; + final seen = {}; + 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 _dumpIceStats(RTCPeerConnection pc) async { + try { + final reports = await pc.getStats(); + final candidates = {}; + 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((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 _extractSsrcs(String sdp) { - final set = {}; - for (final m in RegExp(r'^a=ssrc:(\d+)', multiLine: true).allMatches(sdp)) { - final v = int.tryParse(m.group(1) ?? ''); + List _extractSsrcs(String sdp) { + final set = {}; + 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 _waitIceGathering(RTCPeerConnection pc, Duration timeout) async { - if (pc.iceGatheringState == - RTCIceGatheringState.RTCIceGatheringStateComplete) { - return; - } - final completer = Completer(); - 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 _createAndSendOffer() async { + Future _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 accept() async { + Future 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 sendAudioEnabledSignal(bool enabled) async { @@ -1030,37 +1409,35 @@ class CallSession { isAudioEnabled: !_muted, isVideoEnabled: _localVideo, isScreenSharingEnabled: _localScreen, + isFastScreenSharingEnabled: _fastScreenShare ? _localScreen : null, ); } - Future setVideoEnabled(bool on) => - on ? _startLocalVideo(screen: false) : _stopLocalVideo(); + Future setVideoEnabled(bool on) => on ? _startCamera() : _stopCamera(); Future setScreenSharing(bool on) => - on ? _startLocalVideo(screen: true) : _stopLocalVideo(); + on ? _startScreenShare() : _stopScreenShare(); - Future _startLocalVideo({required bool screen}) async { + Future 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 _startCamera() async { final pc = _pc; if (pc == null) return; - MediaStream stream; - try { - stream = screen - ? await navigator.mediaDevices.getDisplayMedia({ - 'video': true, - 'audio': false, - }) - : await navigator.mediaDevices.getUserMedia({ - 'video': true, - 'audio': false, - }); - } catch (e) { - logger.t('[call] video capture failed: $e'); - return; - } + final stream = await navigator.mediaDevices.getUserMedia({ + '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 _stopLocalVideo() async { + Future _stopCamera() async { try { await _videoSender?.replaceTrack(null); } catch (_) {} - await _disposeLocalVideoStream(); + await _disposeStream(_cameraStream); + _cameraStream = null; _localVideo = false; - _localScreen = false; await _sendMediaSettings(); _notifyInfo(); } - Future _disposeLocalVideoStream() async { - final stream = _localVideoStream; - _localVideoStream = null; + Future _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 _captureScreen() async { + if (!_isDesktop) { + return navigator.mediaDevices.getDisplayMedia({ + 'video': true, + 'audio': false, + }); + } + final sources = await desktopCapturer.getSources( + types: [SourceType.Screen], + ); + if (sources.isEmpty) { + throw StateError('нет доступных экранов для захвата'); + } + return navigator.mediaDevices.getDisplayMedia({ + 'video': { + 'deviceId': {'exact': sources.first.id}, + 'mandatory': {'frameRate': 30.0}, + }, + 'audio': false, + }); + } + + Future _stopScreenShare() async { + try { + await _screenSender?.replaceTrack(null); + } catch (_) {} + await _disposeStream(_screenStream); + _screenStream = null; + _localScreen = false; + await CallBridge.instance.setScreenShare(false); + await _sendMediaSettings(); + _notifyInfo(); + } + + Future _renegotiate() async { + if (_topology == 'SERVER') return; + try { + await _createAndSendOffer(); + } catch (e) { + logger.w('[call] renegotiation offer failed: $e'); + } + } + + Future _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 { diff --git a/lib/core/calls/ws2_signaling.dart b/lib/core/calls/ws2_signaling.dart index d3d952b..308ec14 100644 --- a/lib/core/calls/ws2_signaling.dart +++ b/lib/core/calls/ws2_signaling.dart @@ -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 - ? decoded - : {}; + return decoded is Map ? decoded : {}; } 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 switchTopology({ + String topology = 'SERVER', + bool force = false, + }) { + return sendCommand( + 'switch-topology', + extra: {'topology': topology, 'force': force}, + ); + } + + Future requestRealloc() => sendCommand('request-realloc'); + + /// Принять входящий звонок (сторона вызываемого). + Future 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 acceptCall() => sendCommand('accept-call'); - Future hangup({String reason = 'HUNGUP'}) => sendCommand('hangup', extra: {'reason': reason}); Future 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 acceptProducer({ + Future> acceptProducer({ required String description, - required List ssrcs, + required List 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 changeSimulcast({ String mediaSource = 'CAMERA', required List> layers, - }) => - sendCommand('change-simulcast', - extra: {'mediaSource': mediaSource, 'layers': layers}); + }) => sendCommand( + 'change-simulcast', + extra: {'mediaSource': mediaSource, 'layers': layers}, + ); Future close() async { await _notifSub?.cancel(); diff --git a/lib/frontend/screens/calls/call_participants_sheet.dart b/lib/frontend/screens/calls/call_participants_sheet.dart new file mode 100644 index 0000000..1a0fda8 --- /dev/null +++ b/lib/frontend/screens/calls/call_participants_sheet.dart @@ -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 showCallParticipantsSheet( + BuildContext context, { + required CallSession session, + required ColorScheme scheme, + required CallParticipantResolver resolve, +}) { + return showModalBottomSheet( + 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 _options = {}; + final Map> _features = {}; + bool _recording = false; + StreamSubscription? _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 _run(Future 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( + 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( + 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( + 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, + () => {}, + ); + setSheet(() { + selected + ? set.add(role) + : set.remove(role); + }); + _run( + (a) => a.enableFeatureForRoles( + feature, + set.toList(), + ), + ); + }, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ), + ); + } + + Future _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 = [ + 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), + ); + } +} diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index c11295e..c005998 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -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 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 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 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( @@ -520,9 +554,29 @@ class _CallScreenState extends State 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 with TickerProviderStateMixin { final session = _session; if (session == null) return null; final pills = [ - 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(); diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 0ce16a7..e841c2c 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -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 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 _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 _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 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( diff --git a/lib/frontend/screens/calls/komet_hub.dart b/lib/frontend/screens/calls/komet_hub.dart index 43b6b44..94c2dfa 100644 --- a/lib/frontend/screens/calls/komet_hub.dart +++ b/lib/frontend/screens/calls/komet_hub.dart @@ -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) {