diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index 9a4492d5..2f34c876 100644 --- a/src/chat/chat_setting.c +++ b/src/chat/chat_setting.c @@ -40,6 +40,8 @@ static const struct chat_setting_def g_setting_defs[] = { {"compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, {"compressor_max_gain_db", CHAT_SETTING_INT, 15, 1, 100}, {"compressor_rise_rate", CHAT_SETTING_INT, 2, 1, 100}, + {"radio_compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, + {"radio_compressor_max_gain_db",CHAT_SETTING_INT, 15, 1, 100}, {"media_download_max_peers", CHAT_SETTING_INT, 3, 1, 16}, {"storage_backfill_days", CHAT_SETTING_INT, 7, 1, 365}, {"standby_active_sec", CHAT_SETTING_INT, 2, 1, 3600}, diff --git a/src/config_parser.c b/src/config_parser.c index 4825af85..aaa632e1 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -1243,7 +1243,7 @@ static struct utun_config* parse_config_internal(FILE *fp, const char *filename) assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); } else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, radio_opus_mode, radio_opus_bitrate, radio_capture_stereo, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, media_download_max_peers", filename, line_num, key); + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, radio_opus_mode, radio_opus_bitrate, radio_capture_stereo, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, radio_compressor_enabled, radio_compressor_max_gain_db, media_download_max_peers", filename, line_num, key); } break; case SECTION_GUI: @@ -1402,7 +1402,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha break; case SECTION_NETWORK: if (cur_network) parse_network(key, value, cur_network, filename, line_num); break; case SECTION_NTP: if (strcmp(key, "enabled") == 0) cfg->global.ntp_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; else if (strcmp(key, "server") == 0) { struct CFG_NTP_SERVER *ns = u_calloc(1, sizeof(struct CFG_NTP_SERVER)); if (ns) { strncpy(ns->name, value, sizeof(ns->name) - 1); ns->name[sizeof(ns->name) - 1] = '\0'; ns->next = cfg->global.ntp_servers; cfg->global.ntp_servers = ns; cfg->global.ntp_server_count++; } } else if (strcmp(key, "interval") == 0) { cfg->global.ntp_resync_interval = atoi(value); if (cfg->global.ntp_resync_interval < 60) cfg->global.ntp_resync_interval = 60; } else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'", filename, line_num, key); break; - case SECTION_CHATSERVER: cfg->global.chatserver_enabled = 1; cfg->global.db_sync_enabled = 1; if (strcmp(key, "db_path") == 0) assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, radio_opus_mode, radio_opus_bitrate, radio_capture_stereo, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, media_download_max_peers", filename, line_num, key); } break; + case SECTION_CHATSERVER: cfg->global.chatserver_enabled = 1; cfg->global.db_sync_enabled = 1; if (strcmp(key, "db_path") == 0) assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, radio_opus_mode, radio_opus_bitrate, radio_capture_stereo, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, radio_compressor_enabled, radio_compressor_max_gain_db, media_download_max_peers", filename, line_num, key); } break; case SECTION_GUI: break; case SECTION_REALITY: parse_reality(key, value, &cfg->global.reality, filename, line_num); break; case SECTION_SOCKS: parse_socks(key, value, &cfg->global, filename, line_num); break; diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c index 292cf0e3..817e91a2 100644 --- a/src/radio/radio_audio.c +++ b/src/radio/radio_audio.c @@ -282,11 +282,11 @@ int radio_audio_start(uint64_t group_id, int capture_channels) { accfg.block_duration_ms = RADIO_AUDIO_FRAME_MS; accfg.lookback_ms = 200; accfg.lookahead_ms = 0; - accfg.max_gain_db = (float)chat_setting_get_int(g_inst, "compressor_max_gain_db", 15); + accfg.max_gain_db = (float)chat_setting_get_int(g_inst, "radio_compressor_max_gain_db", 15); accfg.rise_rate_per_sec = (float)chat_setting_get_int(g_inst, "compressor_rise_rate", 2); accfg.target_level = 0.25f; /* -12 dBFS — как у голосовых сообщений */ audio_compressor_configure(ac, &accfg); - audio_compressor_set_enabled(ac, chat_setting_get_int(g_inst, "compressor_enabled", 1)); + audio_compressor_set_enabled(ac, chat_setting_get_int(g_inst, "radio_compressor_enabled", 1)); } g_encoder = enc; diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt index dd32aefd..0754d50c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt @@ -42,6 +42,14 @@ class CallAudioRouter { @Volatile var hasEarpiece = true private set + /** Куда откатываться при потере гарнитуры (звонок — EARPIECE, рация — SPEAKER). */ + @Volatile var fallbackRoute = AudioRoute.EARPIECE + + /** Автоматически следовать за гарнитурой: появилась → HEADSET, исчезла → fallbackRoute. + * Ручной setRoute() сбрасывает в false. */ + @Volatile var autoHeadset = false + private set + /** Уведомление об изменении маршрута или доступности гарнитуры. */ var onChanged: ((AudioRoute, Boolean) -> Unit)? = null @@ -73,10 +81,10 @@ class CallAudioRouter { bindBtHeadsetProfile() hasEarpiece = findBuiltinEarpiece() != null - route = preferredRoute + route = if (autoHeadset) (if (isHeadsetConnected()) AudioRoute.HEADSET else fallbackRoute) else preferredRoute applyRoute(route) refresh() - LogManager.addLog("INFO", "CallAudioRouter", "started (hasEarpiece=$hasEarpiece)") + LogManager.addLog("INFO", "CallAudioRouter", "started (hasEarpiece=$hasEarpiece autoHeadset=$autoHeadset fallback=$fallbackRoute)") } fun stop() { @@ -102,6 +110,7 @@ class CallAudioRouter { LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available") return } + autoHeadset = false if (route == r) return route = r preferredRoute = r @@ -116,6 +125,12 @@ class CallAudioRouter { LogManager.addLog("INFO", "CallAudioRouter", "preferredRoute -> $r") } + /** Включить/выключить автоследование за гарнитурой (рация включает, звонок — нет). */ + fun setAutoHeadset(enabled: Boolean) { + autoHeadset = enabled + LogManager.addLog("INFO", "CallAudioRouter", "autoHeadset -> $enabled") + } + /* ── применение маршрута ── */ private fun applyRoute(r: AudioRoute) { try { @@ -203,17 +218,27 @@ class CallAudioRouter { if (now != prev) { LogManager.addLog("INFO", "CallAudioRouter", "headsetAvailable -> $now") } - /* гарнитуру отключили — откатываемся на динамик */ - if (!now && route == AudioRoute.HEADSET) { - route = AudioRoute.EARPIECE - applyRoute(route) - LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> EARPIECE") - } - /* гарнитура появилась, а пользователь ранее выбрал её — восстанавливаем */ - if (now && !prev && preferredRoute == AudioRoute.HEADSET && route != AudioRoute.HEADSET) { - route = AudioRoute.HEADSET - applyRoute(route) - LogManager.addLog("INFO", "CallAudioRouter", "headset available, restore preferred route -> HEADSET") + /* авторежим: маршрут следует за гарнитурой */ + if (autoHeadset) { + val want = if (now) AudioRoute.HEADSET else fallbackRoute + if (route != want) { + route = want + applyRoute(route) + LogManager.addLog("INFO", "CallAudioRouter", "auto route -> $want") + } + } else { + /* гарнитуру отключили — откатываемся на fallback-маршрут */ + if (!now && route == AudioRoute.HEADSET) { + route = fallbackRoute + applyRoute(route) + LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> $fallbackRoute") + } + /* гарнитура появилась, а пользователь ранее выбрал её — восстанавливаем */ + if (now && !prev && preferredRoute == AudioRoute.HEADSET && route != AudioRoute.HEADSET) { + route = AudioRoute.HEADSET + applyRoute(route) + LogManager.addLog("INFO", "CallAudioRouter", "headset available, restore preferred route -> HEADSET") + } } if (now != prev || route == AudioRoute.HEADSET) { onChanged?.invoke(route, now) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index d3ddc6f7..7bb9f422 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -60,6 +60,8 @@ class ConfigProvider(private val context: Context) { private val radioOpusModeKey = intPreferencesKey("radio_opus_mode") private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate") private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo") + private val radioCompressorEnabledKey = intPreferencesKey("radio_compressor_enabled") + private val radioCompressorMaxGainDbKey = intPreferencesKey("radio_compressor_max_gain_db") private fun keysFilePath(): String { val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath @@ -141,6 +143,8 @@ class ConfigProvider(private val context: Context) { key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0 key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32 key == "radio.capture_stereo" -> context.dataStore.data.first()[radioCaptureStereoKey] ?: 0 + key == "radio.compressor_enabled" -> context.dataStore.data.first()[radioCompressorEnabledKey] ?: 1 + key == "radio.compressor_max_gain_db" -> context.dataStore.data.first()[radioCompressorMaxGainDbKey] ?: 15 else -> 0 } } @@ -176,6 +180,8 @@ class ConfigProvider(private val context: Context) { "radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0 "radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32 "radio.capture_stereo" -> prefs[radioCaptureStereoKey] = value.toIntOrNull() ?: 0 + "radio.compressor_enabled" -> prefs[radioCompressorEnabledKey] = value.toIntOrNull() ?: 1 + "radio.compressor_max_gain_db" -> prefs[radioCompressorMaxGainDbKey] = value.toIntOrNull() ?: 15 } } } @@ -477,6 +483,8 @@ class ConfigProvider(private val context: Context) { val radioOpusMode = getInt("radio.opus_mode") val radioOpusBitrate = getInt("radio.opus_bitrate") val radioCaptureStereo = getInt("radio.capture_stereo") + val radioCompEnabled = getInt("radio.compressor_enabled") + val radioCompMaxGain = getInt("radio.compressor_max_gain_db") val chatCompEnabled = getInt("chat.compressor_enabled") val chatCompMaxGain = getInt("chat.compressor_max_gain_db") val chatCompRiseRate = getInt("chat.compressor_rise_rate") @@ -488,6 +496,8 @@ class ConfigProvider(private val context: Context) { appendLine("radio_opus_mode=$radioOpusMode") appendLine("radio_opus_bitrate=$radioOpusBitrate") appendLine("radio_capture_stereo=$radioCaptureStereo") + appendLine("radio_compressor_enabled=$radioCompEnabled") + appendLine("radio_compressor_max_gain_db=$radioCompMaxGain") appendLine("compressor_enabled=$chatCompEnabled") appendLine("compressor_max_gain_db=$chatCompMaxGain") appendLine("compressor_rise_rate=$chatCompRiseRate") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index 999b6ecb..5d6d7d5f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -2,6 +2,7 @@ package com.utun.chat.data import android.content.Context import android.media.AudioAttributes +import android.media.AudioDeviceInfo import android.media.AudioFormat import android.media.AudioManager import android.media.AudioRecord @@ -58,6 +59,9 @@ class RadioAudioEngine { @Volatile private var micDead = false @Volatile private var captureChannels = 1 /* 1=моно, 2=стерео — по настройке захвата */ + /** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */ + val router = CallAudioRouter() + private val _talkingFlow = MutableStateFlow(false) val talkingFlow: StateFlow = _talkingFlow @@ -74,6 +78,15 @@ class RadioAudioEngine { fun isTalking(): Boolean = talking + /** Текущий маршрут выхода рации (SPEAKER/HEADSET). */ + fun route(): AudioRoute = router.route + + /** Доступна ли гарнитура (проводная/USB/BT). */ + fun headsetAvailable(): Boolean = router.headsetAvailable + + /** Ручной выбор выхода (отключает автоследование за гарнитурой). */ + fun setRoute(r: AudioRoute) = router.setRoute(r) + /** Начать прослушивание канала (переключатель рации вкл). * Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */ fun start(group: Long) { @@ -128,21 +141,25 @@ class RadioAudioEngine { prevSpeaker = am.isSpeakerphoneOn try { am.mode = AudioManager.MODE_IN_COMMUNICATION - am.isSpeakerphoneOn = true } catch (e: Exception) { LogManager.addLog("WARN", "RadioAudio", "set audio mode failed: ${e.message}") } + /* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, вынули → SPEAKER). */ + router.fallbackRoute = AudioRoute.SPEAKER + router.setAutoHeadset(true) + router.start() + // ── AudioRecord (захват) с откатом стерео→моно ── val (rec, capCh) = createAudioRecord() if (rec == null) { - restoreAudioMode(); active = false; return + router.stop(); restoreAudioMode(); active = false; return } captureChannels = capCh if (!NativeLib.radioAudioStart(group, capCh)) { rec.release() - restoreAudioMode(); active = false + router.stop(); restoreAudioMode(); active = false LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group ch=$capCh") return } @@ -163,12 +180,18 @@ class RadioAudioEngine { } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") rec.release() - NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return + NativeLib.radioAudioStop(); router.stop(); restoreAudioMode(); active = false; return } audioRecord = rec audioTrack = track micDead = false + + /* Прямое роутирование трека на выбранное устройство (обход глючной глобальной + * маршрутизации), как в CallAudioEngine. */ + router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } + applyTrackOutputDevice(router.currentOutputDevice) + rec.startRecording() track.play() @@ -256,6 +279,8 @@ class RadioAudioEngine { audioTrack = null NativeLib.radioAudioStop() + router.onOutputDevice = null + router.stop() restoreAudioMode() if (resetGroup) { @@ -367,4 +392,15 @@ class RadioAudioEngine { LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}") } } + + private fun applyTrackOutputDevice(dev: AudioDeviceInfo?) { + val track = audioTrack ?: return + if (dev == null) return + try { + track.setPreferredDevice(dev) + LogManager.addLog("DEBUG", "RadioAudio", "track preferred device -> type=${dev.type}") + } catch (e: Exception) { + LogManager.addLog("WARN", "RadioAudio", "setPreferredDevice failed: ${e.message}") + } + } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index d4bca457..9608f66c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Headset import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.People import androidx.compose.material3.* @@ -25,6 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.utun.chat.data.AudioRoute import com.utun.chat.data.Channel import com.utun.chat.data.Message import com.utun.chat.data.NativeLib @@ -69,6 +72,8 @@ fun ChatScreen( val radioTalking by viewModel.radioTalking.collectAsState() val overlayShown by RadioOverlayService.isShown.collectAsState() val localTalking by RadioAudioEngine.instance().talkingFlow.collectAsState() + val radioRoute by viewModel.radioRoute.collectAsState() + val radioHeadsetAvailable by viewModel.radioHeadsetAvailable.collectAsState() /* звук сообщений канала */ val soundOn by viewModel.soundOn.collectAsState() @@ -185,12 +190,31 @@ fun ChatScreen( } } if (radioActive && !overlayShown) { - RadioPttButton( + Column( modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), - isTalking = localTalking, - onTalkBegin = { viewModel.radioTalkBegin() }, - onTalkEnd = { viewModel.radioTalkEnd() } - ) + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RadioRouteButton( + active = radioRoute == AudioRoute.SPEAKER, + icon = { Icon(Icons.AutoMirrored.Filled.VolumeUp, "Динамик", tint = Color.White, modifier = Modifier.size(22.dp)) }, + onClick = { viewModel.setRadioRoute(AudioRoute.SPEAKER) } + ) + if (radioHeadsetAvailable) { + RadioRouteButton( + active = radioRoute == AudioRoute.HEADSET, + icon = { Icon(Icons.Filled.Headset, "Гарнитура", tint = Color.White, modifier = Modifier.size(22.dp)) }, + onClick = { viewModel.setRadioRoute(AudioRoute.HEADSET) } + ) + } + } + Spacer(Modifier.height(12.dp)) + RadioPttButton( + isTalking = localTalking, + onTalkBegin = { viewModel.radioTalkBegin() }, + onTalkEnd = { viewModel.radioTalkEnd() } + ) + } } } InputBar( @@ -297,3 +321,24 @@ private fun RadioPttButton( } } } + +/* Круглая кнопка выбора выхода рации (динамик/гарнитура). */ +@Composable +private fun RadioRouteButton( + active: Boolean, + icon: @Composable () -> Unit, + onClick: () -> Unit +) { + Surface( + modifier = Modifier + .size(44.dp) + .clickable(onClick = onClick), + shape = CircleShape, + color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D), + shadowElevation = 3.dp + ) { + Box(contentAlignment = Alignment.Center) { + icon() + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index 297bbed0..ba7ed682 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -575,6 +575,31 @@ private fun RadioSettingsTab() { HorizontalDivider(Modifier.padding(vertical = 4.dp)) + /* ── Компрессор (AGC) рации ── */ + var radioCompOn by remember { mutableStateOf(provider.getInt("radio.compressor_enabled") != 0) } + var radioCompMaxGain by remember { mutableStateOf(provider.getInt("radio.compressor_max_gain_db")) } + Text("Компрессор (AGC) рации", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Enable compressor", modifier = Modifier.weight(1f)) + Switch(checked = radioCompOn, onCheckedChange = { + radioCompOn = it + scope.launch { provider.setValue("radio.compressor_enabled", if (it) "1" else "0") } + NativeLib.setChatSetting("radio_compressor_enabled", if (it) "1" else "0") + RadioAudioEngine.instance().reconfigure() + }) + } + LabeledSlider("Max gain:", radioCompMaxGain.toFloat(), 1f..45f, 44, "${radioCompMaxGain} dB", + { v -> + radioCompMaxGain = v.toInt() + NativeLib.setChatSetting("radio_compressor_max_gain_db", radioCompMaxGain.toString()) + }, + onValueChangeFinished = { + scope.launch { provider.setValue("radio.compressor_max_gain_db", radioCompMaxGain.toString()) } + RadioAudioEngine.instance().reconfigure() + }) + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + /* ── Opus для рации: режим + битрейт ── */ val opusModes = listOf("Голос (VOIP)", "Музыка (AUDIO)") val opusBitrates = listOf(16, 32, 64, 96, 128) @@ -833,7 +858,8 @@ private fun StorageSettingsTab() { @Composable private fun LabeledSlider( label: String, value: Float, range: ClosedFloatingPointRange, - steps: Int, displayValue: String, onValueChange: (Float) -> Unit + steps: Int, displayValue: String, onValueChange: (Float) -> Unit, + onValueChangeFinished: (() -> Unit)? = null ) { Column { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { @@ -842,6 +868,7 @@ private fun LabeledSlider( } Slider( value = value, onValueChange = onValueChange, + onValueChangeFinished = onValueChangeFinished, valueRange = range, steps = steps, modifier = Modifier.fillMaxWidth() ) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index db924ed7..614a4b4b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -65,6 +65,12 @@ class ChatViewModel : ViewModel() { private val _radioTalking = MutableStateFlow(null) val radioTalking: StateFlow = _radioTalking + private val _radioRoute = MutableStateFlow(AudioRoute.SPEAKER) + val radioRoute: StateFlow = _radioRoute + + private val _radioHeadsetAvailable = MutableStateFlow(false) + val radioHeadsetAvailable: StateFlow = _radioHeadsetAvailable + private var radioToggleCount = 0 /* рация: активный канал (null = выключена). Одна рация — один канал. */ @@ -764,11 +770,19 @@ class ChatViewModel : ViewModel() { radioChannelId = ch.id NativeLib.radioSetActive(groupId, true) radioAudio.start(groupId) + radioAudio.router.onChanged = { r, headset -> + _radioRoute.value = r + _radioHeadsetAvailable.value = headset + } + _radioRoute.value = radioAudio.route() + _radioHeadsetAvailable.value = radioAudio.headsetAvailable() } else { radioChannelId = null NativeLib.radioSetActive(groupId, false) radioAudio.stop() _radioTalking.value = null + _radioRoute.value = AudioRoute.SPEAKER + _radioHeadsetAvailable.value = false } _radioActive.value = next syncRadioOverlay() @@ -785,6 +799,14 @@ class ChatViewModel : ViewModel() { radioAudio.talkEnd() } + /** Ручной выбор выхода рации (динамик/гарнитура). */ + fun setRadioRoute(r: AudioRoute) { + if (!_radioActive.value) return + radioAudio.setRoute(r) + _radioRoute.value = radioAudio.route() + LogManager.addLog("INFO", "VM", "setRadioRoute -> ${radioAudio.route()}") + } + /* Полностью выключить рацию (используется при удалении активного канала). */ private fun turnRadioOff() { val chId = radioChannelId ?: return @@ -792,6 +814,8 @@ class ChatViewModel : ViewModel() { radioChannelId = null _radioActive.value = false _radioTalking.value = null + _radioRoute.value = AudioRoute.SPEAKER + _radioHeadsetAvailable.value = false radioAudio.stop() syncRadioOverlay() }