diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MediaAudioDeviceButton.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MediaAudioDeviceButton.kt
new file mode 100644
index 00000000..55ace16d
--- /dev/null
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MediaAudioDeviceButton.kt
@@ -0,0 +1,66 @@
+package com.utun.chat.ui.components
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.filled.HeadsetMic
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.core.content.ContextCompat
+import com.utun.chat.R
+import com.utun.chat.data.MediaAudioController
+import com.utun.chat.data.MediaAudioProfile
+import com.utun.chat.data.LogManager
+
+/** Один выбор для панели рации, публикации и всех экранов просмотра. */
+@Composable
+fun MediaAudioDeviceButton(tint: Color = LocalContentColor.current, onExpandedChange: (Boolean) -> Unit = {}) {
+ val state by MediaAudioController.state.collectAsState()
+ val context = LocalContext.current
+ var expanded by remember { mutableStateOf(false) }
+ val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) MediaAudioController.select(MediaAudioProfile.BLUETOOTH)
+ else LogManager.addLog("WARN", "media_audio", "Bluetooth selection cancelled: BLUETOOTH_CONNECT denied")
+ }
+ val bluetoothPermission = Build.VERSION.SDK_INT < 31 ||
+ ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
+ fun expand(value: Boolean) { expanded = value; onExpandedChange(value) }
+ val labels = mapOf(MediaAudioProfile.AUTO to R.string.media_audio_auto, MediaAudioProfile.PHONE to R.string.media_audio_phone,
+ MediaAudioProfile.WIRED to R.string.media_audio_wired, MediaAudioProfile.BLUETOOTH to R.string.media_audio_bluetooth)
+ Box {
+ IconButton(onClick = { expand(true) }) {
+ Icon(if (state.active == MediaAudioProfile.PHONE) Icons.AutoMirrored.Filled.VolumeUp else Icons.Filled.HeadsetMic,
+ stringResource(R.string.media_audio_devices, stringResource(labels.getValue(state.preferred))),
+ tint = if (state.notice != null) MaterialTheme.colorScheme.error else tint)
+ }
+ DropdownMenu(expanded, onDismissRequest = { expand(false) }) {
+ for ((profile, label) in labels) {
+ val available = when (profile) {
+ MediaAudioProfile.WIRED -> state.wiredAvailable
+ MediaAudioProfile.BLUETOOTH -> state.bluetoothAvailable || !bluetoothPermission
+ else -> true
+ }
+ DropdownMenuItem(text = { Text(stringResource(label)) }, enabled = available,
+ trailingIcon = { if (state.preferred == profile) Text("✓") }, onClick = {
+ expand(false)
+ if (profile == MediaAudioProfile.BLUETOOTH && !bluetoothPermission)
+ permission.launch(Manifest.permission.BLUETOOTH_CONNECT)
+ else MediaAudioController.select(profile)
+ })
+ }
+ HorizontalDivider()
+ DropdownMenuItem(text = { Text(stringResource(R.string.media_audio_actual, state.inputName ?: "—", state.outputName ?: "—")) },
+ onClick = {}, enabled = false)
+ if (state.switching) DropdownMenuItem(text = { Text(stringResource(R.string.media_audio_switching)) }, onClick = {}, enabled = false)
+ state.notice?.let { notice -> DropdownMenuItem(text = { Text(stringResource(notice), color = MaterialTheme.colorScheme.error) }, onClick = {}) }
+ }
+ }
+}
diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
index 1597a647..3516ac33 100644
--- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
+++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
@@ -582,4 +582,13 @@
Отклонять входящие звонки vibechat без рингтона
Отклонять все вызовы по рации
Отклонять приглашения; включённая рация продолжит работать
+ Аудиоустройство: %1$s
+ Автоматически
+ Телефон
+ Проводное / USB
+ Bluetooth
+ Микрофон: %1$s\nВыход: %2$s
+ Переключение аудиоустройства…
+ Выбранное устройство отключено. Используется микрофон и динамик телефона.
+ Не удалось использовать выбранное аудиоустройство. Проверьте текущие микрофон и выход.
diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml
index f0c15dd7..d8c905dd 100644
--- a/tools/chatgui-android/app/src/main/res/values/strings.xml
+++ b/tools/chatgui-android/app/src/main/res/values/strings.xml
@@ -581,4 +581,13 @@
Reject incoming vibechat calls without ringing
Reject all radio calls
Reject radio invitations; the channel radio keeps working
+ Audio device: %1$s
+ Automatic
+ Phone
+ Wired / USB
+ Bluetooth
+ Microphone: %1$s\nOutput: %2$s
+ Switching audio device…
+ The selected device is disconnected. Phone audio is in use.
+ Could not use the selected audio device. Check the current microphone and output.