From e957689eaedfbcbbe60f04ef637a6953dcdcc926 Mon Sep 17 00:00:00 2001 From: shum Date: Sat, 24 Oct 2026 07:26:13 +0000 Subject: [PATCH] desktop: show call devices in one menu --- .../moko-resources/base/strings.xml | 1 + .../common/views/call/CallView.desktop.kt | 53 ++++++++++--------- apps/multiplatform/product/views/call.md | 4 +- apps/multiplatform/spec/services/calls.md | 4 +- 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index cd149f2950..2959a6c476 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -1408,6 +1408,7 @@ audio call Calls are not available Screen %d + Devices System default Microphone %s connected Microphone %s disconnected, using System default diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 824deddaaf..de32f9d8f3 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -283,9 +283,7 @@ private fun CallControls(engine: NativeCallEngine, call: Call, modifier: Modifie if (cameraOn) MR.strings.icon_descr_video_off else MR.strings.icon_descr_video_on ) { chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.Camera, enable = !cameraOn)) } ScreenShareButton(engine, sources.screenVideo) - CameraPickerButton(engine) - AudioDevicePickerButton(engine, AudioDeviceKind.Mic, appPrefs.desktopCallMic, painterResource(MR.images.ic_keyboard_voice), MR.strings.permissions_record_audio) - AudioDevicePickerButton(engine, AudioDeviceKind.Speaker, appPrefs.desktopCallSpeaker, painterResource(MR.images.ic_headphones), MR.strings.audio_device_speaker) + DevicesButton(engine) ControlButton(painterResource(MR.images.ic_call_end_filled), MR.strings.icon_descr_hang_up, background = Color.Red) { withBGApi { chatModel.callManager.endCall(call) } } @@ -328,15 +326,22 @@ private fun ScreenShareButton(engine: NativeCallEngine, sharing: Boolean) { } @Composable -private fun CameraPickerButton(engine: NativeCallEngine) { +private fun DevicesButton(engine: NativeCallEngine) { val showMenu = remember { mutableStateOf(false) } + val mics = remember { mutableStateOf(emptyList()) } + val speakers = remember { mutableStateOf(emptyList()) } val cameras = remember { mutableStateOf(emptyList()) } Box { - ControlButton(painterResource(MR.images.ic_settings), MR.strings.permissions_camera) { + ControlButton(painterResource(MR.images.ic_settings), MR.strings.call_devices) { + mics.value = engine.audioDevices(AudioDeviceKind.Mic) + speakers.value = engine.audioDevices(AudioDeviceKind.Speaker) cameras.value = NativeCallEngine.videoCaptureDevices() showMenu.value = true } DefaultDropdownMenu(showMenu) { + AudioDeviceItems(engine, AudioDeviceKind.Mic, appPrefs.desktopCallMic, mics.value, MR.strings.permissions_record_audio, showMenu) + AudioDeviceItems(engine, AudioDeviceKind.Speaker, appPrefs.desktopCallSpeaker, speakers.value, MR.strings.audio_device_speaker, showMenu) + DevicesSectionTitle(MR.strings.permissions_camera) if (cameras.value.isEmpty()) { Text(stringResource(MR.strings.camera_not_available), Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), color = MaterialTheme.colors.secondary) } @@ -351,31 +356,27 @@ private fun CameraPickerButton(engine: NativeCallEngine) { } @Composable -private fun AudioDevicePickerButton(engine: NativeCallEngine, kind: AudioDeviceKind, pref: SharedPreference, icon: Painter, description: StringResource) { - val showMenu = remember { mutableStateOf(false) } - val devices = remember { mutableStateOf(emptyList()) } - Box { - ControlButton(icon, description) { - devices.value = engine.audioDevices(kind) - showMenu.value = true - } - DefaultDropdownMenu(showMenu) { - val chosenId = storedAudioDevice(pref.state.value)?.id - (listOf(null) + devices.value).forEach { device -> - ItemAction( - device?.name ?: stringResource(MR.strings.call_audio_device_system_default), - { if (device?.descriptor == chosenId) Icon(painterResource(MR.images.ic_check), null, tint = MenuTextColor) }, - onClick = { - showMenu.value = false - pref.set(device?.let { json.encodeToString(SavedAudioDevice.serializer(), savedAudioDevice(it)) }) - engine.selectAudioDevice(kind, device) - } - ) +private fun AudioDeviceItems(engine: NativeCallEngine, kind: AudioDeviceKind, pref: SharedPreference, devices: List, title: StringResource, showMenu: MutableState) { + DevicesSectionTitle(title) + val chosenId = storedAudioDevice(pref.state.value)?.id + (listOf(null) + devices).forEach { device -> + ItemAction( + device?.name ?: stringResource(MR.strings.call_audio_device_system_default), + { if (device?.descriptor == chosenId) Icon(painterResource(MR.images.ic_check), null, tint = MenuTextColor) }, + onClick = { + showMenu.value = false + pref.set(device?.let { json.encodeToString(SavedAudioDevice.serializer(), savedAudioDevice(it)) }) + engine.selectAudioDevice(kind, device) } - } + ) } } +@Composable +private fun DevicesSectionTitle(title: StringResource) { + Text(stringResource(title), Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), color = MaterialTheme.colors.secondary) +} + private fun callAudioSettings() = CallAudioSettings( mic = storedAudioDevice(appPrefs.desktopCallMic.get()), speaker = storedAudioDevice(appPrefs.desktopCallSpeaker.get()), diff --git a/apps/multiplatform/product/views/call.md b/apps/multiplatform/product/views/call.md index dd0e1d451f..ab59127e7d 100644 --- a/apps/multiplatform/product/views/call.md +++ b/apps/multiplatform/product/views/call.md @@ -21,7 +21,7 @@ Make and receive end-to-end encrypted audio and video calls over WebRTC. The imp | WebRTC host | `WebView` with `WebViewAssetLoader` serving local assets | `NativeCallEngine` (webrtc-java, native libwebrtc) | | Call activity | `CallActivity` (separate Android Activity) with lifecycle management | Full-window composable over the app, collapsible to the floating call avatar | | PiP support | Native Android PiP via `CallActivity` | Not supported | -| Audio management | `CallAudioDeviceManager` with Android `AudioManager`, proximity wake lock | Microphone and speaker pickers (System default or a device), remembered across calls | +| Audio management | `CallAudioDeviceManager` with Android `AudioManager`, proximity wake lock | Microphone and speaker in the devices menu (System default or a device), remembered across calls | | Screen sharing | Not supported | Screen or window picker | ## Page Sections @@ -61,7 +61,7 @@ Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call | `NativeCallController` | Creates `NativeCallEngine` for the call and passes it commands from the `chatModel.callCommand` queue | | `NativeCallEngine` | Native WebRTC with `call.ts` semantics: transceivers, VP8, frame encryption, ICE batching, peer media detection | | `ActiveCallLayout` | Remote video or avatar, local camera preview, call state and encryption info | -| Controls | Mic, peer audio, camera, screen share picker, camera, microphone and speaker pickers, hang up; back button collapses the view | +| Controls | Mic, peer audio, camera, screen share picker, devices menu (microphone, speaker, camera), hang up; back button collapses the view | | Audio device notices | Toast when the chosen microphone or speaker disconnects (System default used, or unavailable when the fallback is disabled) and when it reconnects | ### WebRTC Signaling Flow diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md index 8676958a1b..83a27851e1 100644 --- a/apps/multiplatform/spec/services/calls.md +++ b/apps/multiplatform/spec/services/calls.md @@ -122,7 +122,7 @@ The `actual` platform implementation of `ActiveCallView()` and supporting compos - **ActiveCallView** ([line 59](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L59)): Handles `WCallResponse` messages with the same state machine as Android. A `Capabilities` error shows the engine error alert and ends the call; a call without a microphone proceeds without sending audio. - **NativeCallController** ([line 172](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L172)): Creates the engine for the call, drains the `chatModel.callCommand` queue into it, and closes it on dispose. -- **ActiveCallLayout** ([line 203](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L203)): Full-window call view over the app: remote screen or camera video, local camera preview, state and encryption info, and controls for mic, peer audio, camera, screen share (screen or window picker), camera, microphone and speaker pickers and hang up. The back button collapses the view to the floating call avatar, which expands it again. +- **ActiveCallLayout** ([line 203](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L203)): Full-window call view over the app: remote screen or camera video, local camera preview, state and encryption info, and controls for mic, peer audio, camera, screen share (screen or window picker), a devices menu (microphone, speaker and camera) and hang up. The back button collapses the view to the floating call avatar, which expands it again. - **NativeCallEngine** ([line 29](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/NativeCallEngine.kt#L29)): Processes `WCallCommand` on a single thread and reports `WCallResponse` with `call.ts` semantics: four transceivers (mic, camera, screen audio, screen video), VP8 preferred, ICE candidates batched as in `call.ts`, connection state and peer media changes reported. - **Audio sending** ([line 330](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/NativeCallEngine.kt#L330)): Device-captured audio reaches every audio sender, so the mic is muted and the screen audio sender silenced by deactivating the sender's encoding, which stops RTP. - **Peer media** ([line 580](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/NativeCallEngine.kt#L580)): A received source is reported muted after 3 seconds without received bytes, as in `call.ts`. @@ -171,7 +171,7 @@ An in-app notification banner shown when a call invitation arrives while the app |---|---|---|---| | `CallView.kt` | [`common/src/commonMain/.../views/call/CallView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt) | 28 | `expect fun ActiveCallView()`, delivery receipt waiting | | `CallView.android.kt` | [`common/src/androidMain/.../views/call/CallView.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt) | 891 | Android WebView WebRTC, overlay, permissions | -| `CallView.desktop.kt` | [`common/src/desktopMain/.../views/call/CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) | 461 | Desktop call view and state machine | +| `CallView.desktop.kt` | [`common/src/desktopMain/.../views/call/CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) | 462 | Desktop call view and state machine | | `NativeCallEngine.kt` | [`common/src/desktopMain/.../views/call/NativeCallEngine.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/NativeCallEngine.kt) | 764 | Desktop native WebRTC engine (webrtc-java) | | `CallAudioDevices.kt` | [`common/src/desktopMain/.../views/call/CallAudioDevices.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallAudioDevices.kt) | 62 | Desktop microphone and speaker choice and fallback notices | | `CallActivity.kt` | [`android/src/main/java/.../views/call/CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) | 472 | Android call Activity, PiP, lock screen |