Browse Source

Radio: Opus mode/bitrate + mic source + PTT release tail; drop online status from member JSON

proxy
evgeny 3 days ago
parent
commit
331970a121
  1. 5
      lib/opus_codec.c
  2. 5
      lib/opus_codec.h
  3. 2
      src/chat/chat_setting.c
  4. 4
      src/config_parser.c
  5. 10
      src/radio/radio_audio.c
  6. 28
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  7. 98
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  8. 13
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
  9. 69
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  10. 36
      tools/chatgui-android/jni_bridge/android_jni_bridge.c

5
lib/opus_codec.c

@ -146,6 +146,11 @@ int opus_codec_encoder_signal_set(opus_codec_encoder_t* enc, int signal_type) {
return opus_encoder_ctl(enc->opus_enc, OPUS_SET_SIGNAL(signal_type));
}
int opus_codec_encoder_application_set(opus_codec_encoder_t* enc, int application) {
if (!enc || !enc->opus_enc) return -1;
return opus_encoder_ctl(enc->opus_enc, OPUS_SET_APPLICATION(application));
}
const char* opus_codec_strerror(int err) {
switch (err) {
case OPUS_OK: return "OK";

5
lib/opus_codec.h

@ -10,6 +10,10 @@ extern "C" {
typedef struct opus_codec_encoder opus_codec_encoder_t;
typedef struct opus_codec_decoder opus_codec_decoder_t;
/* Режим кодирования Opus: голос (VOIP, default) или музыка (AUDIO). */
#define OPUS_CODEC_APP_VOIP 2048 /* OPUS_APPLICATION_VOIP */
#define OPUS_CODEC_APP_AUDIO 2049 /* OPUS_APPLICATION_AUDIO */
opus_codec_encoder_t* opus_codec_encoder_create(int sample_rate, int channels);
void opus_codec_encoder_destroy(opus_codec_encoder_t* enc);
int opus_codec_encode(opus_codec_encoder_t* enc, const int16_t* pcm, int frame_samples,
@ -23,6 +27,7 @@ int opus_codec_decode(opus_codec_decoder_t* dec, const uint8_t* data, int len,
int opus_codec_encoder_bitrate_set(opus_codec_encoder_t* enc, int bitrate_bps);
int opus_codec_encoder_complexity_set(opus_codec_encoder_t* enc, int complexity);
int opus_codec_encoder_signal_set(opus_codec_encoder_t* enc, int signal_type);
int opus_codec_encoder_application_set(opus_codec_encoder_t* enc, int application);
const char* opus_codec_strerror(int err);

2
src/chat/chat_setting.c

@ -34,6 +34,8 @@ struct chat_setting_def {
static const struct chat_setting_def g_setting_defs[] = {
{"storage_autoload", CHAT_SETTING_BOOL, 1, 0, 1},
{"opus_codec_preset", CHAT_SETTING_INT, 1, 0, 4},
{"radio_opus_mode", CHAT_SETTING_INT, 0, 0, 1},
{"radio_opus_bitrate", CHAT_SETTING_INT, 32, 16, 128},
{"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},

4
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, 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, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, 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, 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, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, 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;

10
src/radio/radio_audio.c

@ -264,7 +264,10 @@ int radio_audio_start(uint64_t group_id) {
opus_codec_encoder_t* enc = opus_codec_encoder_create(RADIO_AUDIO_SAMPLE_RATE, 1);
if (!enc) { pthread_mutex_unlock(&g_mtx); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RADIO_AUDIO_ID); return -1; }
opus_codec_encoder_bitrate_set(enc, RADIO_AUDIO_BITRATE);
int opus_mode = chat_setting_get_int(g_inst, "radio_opus_mode", 0);
int opus_bitrate_kbps = chat_setting_get_int(g_inst, "radio_opus_bitrate", RADIO_AUDIO_BITRATE / 1000);
opus_codec_encoder_application_set(enc, opus_mode ? OPUS_CODEC_APP_AUDIO : OPUS_CODEC_APP_VOIP);
opus_codec_encoder_bitrate_set(enc, opus_bitrate_kbps * 1000);
/* компрессор микрофона (AGC): конфиг из [chatserver], lookahead=0 — без задержки и хвоста.
* gain_smoothed не сбрасывается между PTT — адаптивная громкость запоминается. */
@ -290,8 +293,9 @@ int radio_audio_start(uint64_t group_id) {
memset(g_sources, 0, sizeof(g_sources));
pthread_mutex_unlock(&g_mtx);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx compressor=%s", RADIO_AUDIO_ID,
(unsigned long long)group_id, ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable");
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx opus_mode=%s bitrate=%dkbps compressor=%s", RADIO_AUDIO_ID,
(unsigned long long)group_id, opus_mode ? "AUDIO" : "VOIP", opus_bitrate_kbps,
ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable");
return 0;
}

28
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt

@ -56,6 +56,9 @@ class ConfigProvider(private val context: Context) {
private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
private val radioMicSourceKey = intPreferencesKey("radio_mic_source")
private val radioOpusModeKey = intPreferencesKey("radio_opus_mode")
private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate")
private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
@ -134,6 +137,8 @@ class ConfigProvider(private val context: Context) {
key == "chat.standby_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15
key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000
key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1
key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0
key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32
else -> 0
}
}
@ -166,6 +171,8 @@ class ConfigProvider(private val context: Context) {
"chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15
"chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000
"chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1
"radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0
"radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32
}
}
}
@ -302,6 +309,23 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[radioOverlayKey] = enabled }
}
@Volatile private var radioMicSourceCached = 0
@Volatile private var radioMicSourceLoaded = false
fun getRadioMicSource(): Int {
if (!radioMicSourceLoaded) {
radioMicSourceCached = runBlocking { context.dataStore.data.first()[radioMicSourceKey] ?: 0 }
radioMicSourceLoaded = true
}
return radioMicSourceCached
}
suspend fun setRadioMicSource(source: Int) {
radioMicSourceCached = source
radioMicSourceLoaded = true
context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source }
}
/* ── Server entries ── */
suspend fun detectInterfaces(saved: List<ServerEntry> = emptyList()): List<ServerEntry> {
@ -430,6 +454,8 @@ class ConfigProvider(private val context: Context) {
val chatAutoloadMaxMb = getInt("chat.storage_autoload_maxsize_mb")
val chatMaxGb = getInt("chat.storage_maxsize_gb")
val chatOpusPreset = getInt("chat.opus_codec_preset")
val radioOpusMode = getInt("radio.opus_mode")
val radioOpusBitrate = getInt("radio.opus_bitrate")
val chatCompEnabled = getInt("chat.compressor_enabled")
val chatCompMaxGain = getInt("chat.compressor_max_gain_db")
val chatCompRiseRate = getInt("chat.compressor_rise_rate")
@ -438,6 +464,8 @@ class ConfigProvider(private val context: Context) {
appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb")
appendLine("storage_maxsize_gb=$chatMaxGb")
appendLine("opus_codec_preset=$chatOpusPreset")
appendLine("radio_opus_mode=$radioOpusMode")
appendLine("radio_opus_bitrate=$radioOpusBitrate")
appendLine("compressor_enabled=$chatCompEnabled")
appendLine("compressor_max_gain_db=$chatCompMaxGain")
appendLine("compressor_rise_rate=$chatCompRiseRate")

98
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt

@ -7,6 +7,9 @@ import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.utun.chat.ChatApplication
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -29,6 +32,9 @@ class RadioAudioEngine {
const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960
/** Хвост передачи после отпускания PTT: продолжаем слать голос, затем FIN. */
const val RELEASE_HOLD_MS = 200L
/** Процессный синглтон: доступен из Activity и оверлей-сервиса. */
@Volatile private var inst: RadioAudioEngine? = null
fun instance(): RadioAudioEngine = inst ?: synchronized(this) {
@ -56,6 +62,10 @@ class RadioAudioEngine {
Thread(r, "radio-audio-io").apply { isDaemon = true }
}
/* Отложенный FIN после релиза PTT (все talkBegin/talkEnd зовутся с main-потока). */
private val mainHandler = Handler(Looper.getMainLooper())
private val releaseRunnable = Runnable { finishTalk() }
fun isActive(): Boolean = active
fun isTalking(): Boolean = talking
@ -75,6 +85,7 @@ class RadioAudioEngine {
active = false
talking = false
_talkingFlow.value = false
mainHandler.removeCallbacks(releaseRunnable)
io.execute { stopBlocking() }
}
@ -88,7 +99,25 @@ class RadioAudioEngine {
active = false
releaseLocked(false)
active = true
setupAudio()
}
/* Полный перезапуск аудио на том же канале — применение новых настроек на лету. */
fun reconfigure() {
val g = groupId
if (g == 0L) return
io.execute {
if (groupId != g) return@execute
if (talking) finishTalk()
if (active) { active = false; releaseLocked(false) }
active = true
setupAudio()
}
}
/* Инициализация C-движка + AudioRecord/AudioTrack + потоков (только io-поток). */
private fun setupAudio() {
val group = groupId
if (!NativeLib.radioAudioStart(group)) {
active = false
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group")
@ -109,18 +138,8 @@ class RadioAudioEngine {
// ── AudioRecord (захват) ──
val recMin = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
val rec = try {
AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed: ${e.message}")
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
}
if (rec.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized")
rec.release()
val rec = createAudioRecord((recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
if (rec == null) {
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
}
@ -159,6 +178,40 @@ class RadioAudioEngine {
LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf")
}
/* Создание AudioRecord по выбранному источнику; UNPROCESSED с fallback на MIC. */
private fun createAudioRecord(bufferSize: Int): AudioRecord? {
val pref = ChatApplication.instance.configProvider.getRadioMicSource()
val primary = when (pref) {
1 -> MediaRecorder.AudioSource.MIC
2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
MediaRecorder.AudioSource.UNPROCESSED else MediaRecorder.AudioSource.MIC
3 -> MediaRecorder.AudioSource.VOICE_RECOGNITION
else -> MediaRecorder.AudioSource.VOICE_COMMUNICATION
}
val rec = tryCreateRecord(primary, bufferSize)
if (rec != null) { LogManager.addLog("INFO", "RadioAudio", "AudioRecord source=$primary"); return rec }
if (pref == 2 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
LogManager.addLog("WARN", "RadioAudio", "UNPROCESSED unavailable — fallback to MIC")
return tryCreateRecord(MediaRecorder.AudioSource.MIC, bufferSize)
}
return null
}
private fun tryCreateRecord(source: Int, bufferSize: Int): AudioRecord? {
val rec = try {
AudioRecord(source, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize)
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed (source=$source): ${e.message}")
return null
}
if (rec.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized (source=$source)")
rec.release()
return null
}
return rec
}
private fun stopBlocking() {
releaseLocked(true)
}
@ -184,7 +237,9 @@ class RadioAudioEngine {
/** PTT зажата — начинаем передачу. */
fun talkBegin() {
if (!active || talking) return
if (!active) return
mainHandler.removeCallbacks(releaseRunnable)
if (talking) return
if (micDead) {
LogManager.addLog("WARN", "RadioAudio", "talkBegin but mic is dead (background capture stopped)")
}
@ -193,12 +248,27 @@ class RadioAudioEngine {
NativeLib.radioTalkBegin(groupId)
}
/** PTT отпущена — завершаем передачу (FIN). */
/** PTT отпущена — добиваем хвост RELEASE_HOLD_MS, затем завершаем (FIN). */
fun talkEnd() {
if (!active || !talking) return
mainHandler.removeCallbacks(releaseRunnable)
mainHandler.postDelayed(releaseRunnable, RELEASE_HOLD_MS)
LogManager.addLog("DEBUG", "RadioAudio", "talk release scheduled +${RELEASE_HOLD_MS}ms")
}
/** Немедленное завершение передачи без хвоста (drag кнопки рации). */
fun talkEndImmediate() {
if (!active || !talking) return
mainHandler.removeCallbacks(releaseRunnable)
finishTalk()
}
private fun finishTalk() {
if (!talking) return
talking = false
_talkingFlow.value = false
NativeLib.radioTalkEnd(groupId)
LogManager.addLog("DEBUG", "RadioAudio", "talk finished (FIN)")
}
private fun captureLoop() {

13
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt

@ -185,7 +185,7 @@ class RadioOverlayService : Service() {
dragAllowed = false
} else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) {
dragging = true
endTalk()
endTalkImmediate()
lp.x = startX + (event.rawX - touchX).toInt()
lp.y = startY + (event.rawY - touchY).toInt()
wm.updateViewLayout(v, lp)
@ -216,10 +216,19 @@ class RadioOverlayService : Service() {
}
private fun endTalk() {
endTalkInternal(immediate = false)
}
private fun endTalkImmediate() {
endTalkInternal(immediate = true)
}
private fun endTalkInternal(immediate: Boolean) {
if (!talking) return
talking = false
updateButtonColor()
RadioAudioEngine.instance().talkEnd()
if (immediate) RadioAudioEngine.instance().talkEndImmediate()
else RadioAudioEngine.instance().talkEnd()
}
private fun updateButtonColor() {

69
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt

@ -471,6 +471,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
/* ── Radio Settings Tab ── */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RadioSettingsTab() {
val provider = ChatApplication.instance.configProvider
@ -528,6 +529,74 @@ private fun RadioSettingsTab() {
RadioOverlayService.stop(ChatApplication.instance)
})
}
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Источник микрофона ── */
val micSources = listOf(
"Голос (VOICE_COMMUNICATION)", "Микрофон (MIC)",
"Без обработки (UNPROCESSED)", "Распознавание (VOICE_RECOGNITION)")
var micSource by remember { mutableStateOf(provider.getRadioMicSource().coerceIn(0, micSources.size - 1)) }
var micExpanded by remember { mutableStateOf(false) }
Text("Источник микрофона", style = MaterialTheme.typography.titleMedium)
ExposedDropdownMenuBox(expanded = micExpanded, onExpandedChange = { micExpanded = it }) {
OutlinedTextField(micSources[micSource], {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(micExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(micExpanded, onDismissRequest = { micExpanded = false }) {
micSources.forEachIndexed { i, n ->
DropdownMenuItem(text = { Text(n) }, onClick = {
micSource = i
scope.launch { provider.setRadioMicSource(i) }
RadioAudioEngine.instance().reconfigure()
micExpanded = false
})
}
}
}
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Opus для рации: режим + битрейт ── */
val opusModes = listOf("Голос (VOIP)", "Музыка (AUDIO)")
val opusBitrates = listOf(16, 32, 64, 96, 128)
var opusMode by remember { mutableStateOf(provider.getInt("radio.opus_mode").coerceIn(0, 1)) }
var opusModeExpanded by remember { mutableStateOf(false) }
var opusBitrate by remember { mutableStateOf(provider.getInt("radio.opus_bitrate")) }
var opusBitrateExpanded by remember { mutableStateOf(false) }
Text("Opus (рация)", style = MaterialTheme.typography.titleMedium)
ExposedDropdownMenuBox(expanded = opusModeExpanded, onExpandedChange = { opusModeExpanded = it }) {
OutlinedTextField(opusModes[opusMode], {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(opusModeExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(opusModeExpanded, onDismissRequest = { opusModeExpanded = false }) {
opusModes.forEachIndexed { i, n ->
DropdownMenuItem(text = { Text(n) }, onClick = {
opusMode = i
scope.launch { provider.setValue("radio.opus_mode", i.toString()) }
NativeLib.setChatSetting("radio_opus_mode", i.toString())
RadioAudioEngine.instance().reconfigure()
opusModeExpanded = false
})
}
}
}
ExposedDropdownMenuBox(expanded = opusBitrateExpanded, onExpandedChange = { opusBitrateExpanded = it }) {
OutlinedTextField("${opusBitrate} kbps", {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(opusBitrateExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(opusBitrateExpanded, onDismissRequest = { opusBitrateExpanded = false }) {
opusBitrates.forEach { b ->
DropdownMenuItem(text = { Text("$b kbps") }, onClick = {
opusBitrate = b
scope.launch { provider.setValue("radio.opus_bitrate", b.toString()) }
NativeLib.setChatSetting("radio_opus_bitrate", b.toString())
RadioAudioEngine.instance().reconfigure()
opusBitrateExpanded = false
})
}
}
}
}
}

36
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -710,11 +710,11 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
if (member_count == 0) return u_strdup("[]");
snprintf(sql, sizeof(sql),
"SELECT p.node_id, COALESCE(n.online,0), COALESCE(n.name,''),"
"SELECT p.node_id, COALESCE(n.name,''),"
" COALESCE(p.local_nick,''), COALESCE(p.adm_tags,''),"
" COALESCE(p.node_type,0), COALESCE(p.node_RTT,0), COALESCE(p.storage,0)"
" FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id"
" ORDER BY n.online DESC, p.node_id ASC", peers_tbl);
" ORDER BY p.node_id ASC", peers_tbl);
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
@ -733,13 +733,12 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
while (sqlite3_step(st) == SQLITE_ROW) {
uint64_t node_id = (uint64_t)sqlite3_column_int64(st, 0);
int online = sqlite3_column_int(st, 1);
const char* name = (const char*)sqlite3_column_text(st, 2);
const char* local_nick = (const char*)sqlite3_column_text(st, 3);
const char* adm_tags = (const char*)sqlite3_column_text(st, 4);
int node_type = sqlite3_column_int(st, 5);
int node_rtt = sqlite3_column_int(st, 6);
int storage_flag = sqlite3_column_int(st, 7);
const char* name = (const char*)sqlite3_column_text(st, 1);
const char* local_nick = (const char*)sqlite3_column_text(st, 2);
const char* adm_tags = (const char*)sqlite3_column_text(st, 3);
int node_type = sqlite3_column_int(st, 4);
int node_rtt = sqlite3_column_int(st, 5);
int storage_flag = sqlite3_column_int(st, 6);
int is_admin = 0, is_super = 0, is_moder = 0, is_verified = 0, is_deleted = 0;
if (adm_tags) {
@ -763,18 +762,18 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
first = 0;
size_t needed = snprintf(NULL, 0,
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d,"
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d,"
"\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d}",
sep, esc_name, (unsigned long long)node_id, online,
sep, esc_name, (unsigned long long)node_id,
is_admin, is_super, is_moder, storage_flag,
is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0);
while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
if (pos + needed + 2 <= cap)
pos += (size_t)snprintf(json + pos, cap - pos,
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d,"
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d,"
"\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d}",
sep, esc_name, (unsigned long long)node_id, online,
sep, esc_name, (unsigned long long)node_id,
is_admin, is_super, is_moder, storage_flag,
is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0);
u_free(esc_name);
@ -796,19 +795,18 @@ char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_i
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(db,
"SELECT COALESCE(name,''), COALESCE(online,0),"
"SELECT COALESCE(name,''),"
" COALESCE(last_seen_at,0), COALESCE(created_at,0)"
" FROM nodes WHERE node_id=?", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
if (sqlite3_step(st) == SQLITE_ROW) {
const char* name = (const char*)sqlite3_column_text(st, 0);
int online = sqlite3_column_int(st, 1);
int64_t last_seen = sqlite3_column_int64(st, 2);
int64_t created = sqlite3_column_int64(st, 3);
int64_t last_seen = sqlite3_column_int64(st, 1);
int64_t created = sqlite3_column_int64(st, 2);
char* esc_name = json_escape_alloc(name && name[0] ? name : "");
pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos,
",\"n\":\"%s\",\"on\":%d,\"ls\":%lld,\"cr\":%lld",
esc_name, online, (long long)last_seen, (long long)created);
",\"n\":\"%s\",\"on\":0,\"ls\":%lld,\"cr\":%lld",
esc_name, (long long)last_seen, (long long)created);
u_free(esc_name);
}
sqlite3_finalize(st);

Loading…
Cancel
Save