From 5c0a5e4bcfd9ff4559852a21f51dbaad0e711bd1 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 26 Sep 2026 10:52:56 +0000 Subject: [PATCH] Sort the Android settings into categories Settings opens on a list of categories, for now Sound Settings (devices, transmission, voice activation, processing, playback) and myTeamSpeak, each a page of its own, with the icon pack beneath them. The nickname field goes, as the connect screen already keeps it. The rows the pages share move into their own file. Co-Authored-By: Claude Opus 5.5 --- .../ts3client/android/ui/MyTeamSpeakScreen.kt | 3 +- .../ts3client/android/ui/SettingsScreen.kt | 341 +----------------- .../ts3client/android/ui/SettingsWidgets.kt | 171 +++++++++ .../android/ui/SoundSettingsScreen.kt | 218 +++++++++++ 4 files changed, 408 insertions(+), 325 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/ui/SettingsWidgets.kt create mode 100644 android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt diff --git a/android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt index 233b328..579919c 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt @@ -55,7 +55,7 @@ private data class ItemRow(val item: SyncItem, val imported: Boolean, val select */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MyTeamSpeakScreen(vm: SessionController, onBack: () -> Unit, onImported: () -> Unit) { +fun MyTeamSpeakScreen(vm: SessionController, onBack: () -> Unit) { val login = vm.myTeamSpeak val scope = rememberCoroutineScope() var signedIn by remember { mutableStateOf(login.isSignedIn) } @@ -186,7 +186,6 @@ fun MyTeamSpeakScreen(vm: SessionController, onBack: () -> Unit, onImported: () rows = rows.map { it.copy(imported = vm.isImported(it.item), selected = false) } message = "Imported ${count(r.identitiesAdded(), "identity", "identities")} and " + "${count(r.bookmarksAdded(), "bookmark", "bookmarks")}." - onImported() } }, enabled = !busy && rows.any { it.selected }, diff --git a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt index b881c0c..b0785a8 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt @@ -1,295 +1,52 @@ package com.ts3client.android.ui -import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ts3client.android.IconTheme -import com.ts3client.android.MicLevel import com.ts3client.android.SessionController -import com.ts3client.android.audio.AudioRoute -import com.ts3client.audio.AudioDevice -import com.ts3client.audio.InputLevel -import com.ts3client.config.Settings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.math.roundToInt -private val DENOISE_LEVELS = listOf("6 dB", "12 dB", "18 dB", "21 dB") +private enum class SettingsPage { SOUND, MY_TEAMSPEAK } /** - * The settings that make sense on a phone. Every change is saved and put into - * effect at once, as there is no dialog to confirm. + * The settings, by category. Every change is saved and put into effect at once, + * as there is no dialog to confirm. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(vm: SessionController, onBack: () -> Unit) { - val settings = vm.settings - fun change(edit: Settings.() -> Unit) { - settings.edit() - vm.applySettings() + var page by rememberSaveable { mutableStateOf(null) } + when (page) { + SettingsPage.SOUND -> return SoundSettingsScreen(vm, onBack = { page = null }) + SettingsPage.MY_TEAMSPEAK -> return MyTeamSpeakScreen(vm, onBack = { page = null }) + null -> {} } - var nickname by remember { mutableStateOf(settings.nickname) } - var inputMode by remember { mutableStateOf(settings.inputMode) } - var vadMode by remember { mutableStateOf(settings.vadMode) } - var denoise by remember { mutableStateOf(settings.denoise) } - var agc by remember { mutableStateOf(settings.agc) } - var threshold by remember { mutableFloatStateOf(settings.vadThresholdDb.toFloat()) } - var inputDevice by remember { mutableStateOf(settings.inputDevice) } - var outputDevice by remember { mutableStateOf(settings.outputDevice) } - val inputs by vm.inputDevices.collectAsStateWithLifecycle() - val outputs by vm.outputDevices.collectAsStateWithLifecycle() - val route by vm.route.collectAsStateWithLifecycle() - val level by vm.micLevel.collectAsStateWithLifecycle() - LifecycleStartEffect(Unit) { - vm.watchLevel(true) - onStopOrDispose { vm.watchLevel(false) } - } - var myTeamSpeak by rememberSaveable { mutableStateOf(false) } - if (myTeamSpeak) { - // The default identity's nickname may come along with an import. - MyTeamSpeakScreen(vm, onBack = { myTeamSpeak = false }, onImported = { nickname = settings.nickname }) - return - } - val leave = { - if (nickname.isNotBlank() && nickname.trim() != settings.nickname) change { this.nickname = nickname.trim() } - onBack() - } - BackHandler(onBack = leave) - Scaffold( - topBar = { - TopAppBar( - title = { Text("Settings") }, - navigationIcon = { IconButton(onClick = leave) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, - ) - }, - ) { padding -> - Column(Modifier.padding(padding).verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) { - OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp), - label = { Text("Nickname") }, singleLine = true) - - Section("Devices") - DevicePicker("Microphone", inputs, inputDevice) { - inputDevice = it - change { this.inputDevice = it } + SubScreen("Settings", onBack) { padding -> + Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) { + LinkRow("Sound Settings", "Devices, transmission, voice activation, processing, volume", "PLAYBACK") { + page = SettingsPage.SOUND } - DevicePicker("Speaker", outputs, outputDevice) { - outputDevice = it - change { this.outputDevice = it } - } - Picker( - "Microphone preset", - INPUT_PRESETS.map { (preset, label) -> - val unsupported = preset == AudioRoute.InputPreset.UNPROCESSED && !vm.unprocessedSupported - preset to if (unsupported) "$label (not supported here)" else label - }, - route.inputPreset(), - ) { vm.setRoute(AudioRoute(it, route.usage())) } - Hint(PRESET_HINTS.getValue(route.inputPreset())) - Text("Play voices", Modifier.padding(start = 16.dp, top = 8.dp)) - for ((usage, label) in USAGES) { - Choice(label, route.usage() == usage) { vm.setRoute(AudioRoute(route.inputPreset(), usage)) } - } - - Section("Transmission") - for ((mode, label) in INPUT_MODES) { - Choice(label, inputMode == mode) { - inputMode = mode - change { this.inputMode = mode } - } - } - val gated = inputMode == Settings.InputMode.VOICE_ACTIVATION && vadMode != Settings.VadMode.AUTOMATIC - LevelMeter(level, threshold.takeIf { gated }) - if (!level.live) { - Hint(if (vm.hasMicPermission()) "The microphone is not available right now." else "Allow the microphone to see its level.") - } - - if (inputMode == Settings.InputMode.VOICE_ACTIVATION) { - Section("Voice activation") - for ((mode, label) in VAD_MODES) { - Choice(label, vadMode == mode) { - vadMode = mode - change { this.vadMode = mode } - } - } - if (vadMode != Settings.VadMode.AUTOMATIC) { - Level("Volume threshold", settings.vadThresholdDb.toFloat(), -50f..50f, { "${it.roundToInt()}" }, - onChange = { threshold = it }) { - change { vadThresholdDb = it.roundToInt().toDouble() } - } - } - if (vadMode != Settings.VadMode.VOLUME_GATE) { - Level("Speech likelihood", settings.speechThreshold.toFloat(), 0f..1f, { "${(it * 100).roundToInt()} %" }) { - change { speechThreshold = it.toDouble() } - } - } - } - - Section("Processing") - Toggle("Noise suppression", denoise) { - denoise = it - change { this.denoise = it } - } - if (denoise) { - Level("Suppression", settings.denoiserLevel.toFloat(), 0f..3f, { DENOISE_LEVELS[it.roundToInt()] }, steps = 2) { - change { denoiserLevel = it.roundToInt() } - } - } - Toggle("Automatic gain control", agc) { - agc = it - change { this.agc = it } - } - if (agc) { - Level("Boost quiet speech", settings.agcBoostDb.toFloat(), 0f..Settings.MAX_AGC_BOOST_DB.toFloat(), - { "+${it.roundToInt()} dB" }, steps = Settings.MAX_AGC_BOOST_DB - 1) { - change { agcBoostDb = it.roundToInt() } - } - } - - Section("Playback") - Level("Volume", settings.outputVolume.toFloat(), 0f..2f, { "${(it * 100).roundToInt()} %" }) { - change { outputVolume = it.toDouble() } - } - + LinkRow("myTeamSpeak", + if (vm.myTeamSpeak.isSignedIn) "Signed in as ${vm.myTeamSpeak.username()}" + else "Sign in to bring over your synchronised bookmarks and identities", + "IDENTITY_MANAGER") { page = SettingsPage.MY_TEAMSPEAK } Section("Appearance") IconPackPicker(vm.icons) - - Section("Account") - Column(Modifier.fillMaxWidth().clickable { myTeamSpeak = true }.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text("myTeamSpeak") - Text(if (vm.myTeamSpeak.isSignedIn) "Signed in as ${vm.myTeamSpeak.username()}" - else "Sign in to bring over your synchronised bookmarks and identities", - style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} - -private val INPUT_MODES = listOf( - Settings.InputMode.VOICE_ACTIVATION to "Voice activation", - Settings.InputMode.PUSH_TO_TALK to "Push to talk", - Settings.InputMode.CONTINUOUS to "Continuous", -) - -private val INPUT_PRESETS = listOf( - AudioRoute.InputPreset.VOICE_RECOGNITION to "Voice recognition", - AudioRoute.InputPreset.VOICE_COMMUNICATION to "Voice communication", - AudioRoute.InputPreset.UNPROCESSED to "Unprocessed", - AudioRoute.InputPreset.VOICE_PERFORMANCE to "Voice performance", - AudioRoute.InputPreset.GENERIC to "Generic", - AudioRoute.InputPreset.CAMCORDER to "Camcorder", -) - -private val PRESET_HINTS = mapOf( - AudioRoute.InputPreset.VOICE_RECOGNITION to "Little or no processing by the phone; the client's own does the rest.", - AudioRoute.InputPreset.VOICE_COMMUNICATION to - "The phone's call processing: echo cancellation, noise suppression and gain. Works best with voices played as a call.", - AudioRoute.InputPreset.UNPROCESSED to "The raw microphone signal.", - AudioRoute.InputPreset.VOICE_PERFORMANCE to "Low latency and no processing, meant for live performance.", - AudioRoute.InputPreset.GENERIC to "The phone's default microphone source, with whatever it applies to it.", - AudioRoute.InputPreset.CAMCORDER to "Tuned for recording video, often with the rear microphone.", -) - -private val USAGES = listOf( - AudioRoute.Usage.MEDIA to "As media (Bluetooth stays in high quality)", - AudioRoute.Usage.VOICE_COMMUNICATION to "As a call (lets the phone cancel echo)", -) - -private val VAD_MODES = listOf( - Settings.VadMode.AUTOMATIC to "Automatic", - Settings.VadMode.VOLUME_GATE to "Volume gate", - Settings.VadMode.HYBRID to "Hybrid", -) - -@Composable -private fun Section(title: String) { - Text(title, Modifier.padding(start = 16.dp, top = 24.dp, bottom = 4.dp), - style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) -} - -@Composable -private fun Choice(label: String, selected: Boolean, onSelect: () -> Unit) { - Row(Modifier.fillMaxWidth().clickable(onClick = onSelect).padding(horizontal = 16.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected, onClick = onSelect) - Text(label, Modifier.padding(start = 8.dp)) - } -} - -@Composable -private fun Toggle(label: String, checked: Boolean, onChange: (Boolean) -> Unit) { - Row(Modifier.fillMaxWidth().clickable { onChange(!checked) }.padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically) { - Text(label, Modifier.weight(1f)) - Switch(checked, onChange) - } -} - -@Composable -private fun Hint(text: String) { - Text(text, Modifier.padding(horizontal = 16.dp, vertical = 2.dp), - style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) -} - -/** One of [options], shown as its label and picked from a menu. */ -@Composable -private fun Picker(label: String, options: List>, selected: T, onSelect: (T) -> Unit) { - var open by remember { mutableStateOf(false) } - Box { - Column(Modifier.fillMaxWidth().clickable { open = true }.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text(label) - Text(options.firstOrNull { it.first == selected }?.second ?: "", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - DropdownMenu(open, { open = false }) { - for ((value, text) in options) { - DropdownMenuItem({ Text(text) }, onClick = { - open = false - onSelect(value) - }) - } } } } @@ -308,71 +65,9 @@ private fun IconPackPicker(theme: IconTheme) { } } Picker("Icon pack", packs.map { it to it.name() }, active) { theme.select(it) } - Column(Modifier.fillMaxWidth().clickable { importer.launch(arrayOf("application/zip", "application/octet-stream")) } - .padding(horizontal = 16.dp, vertical = 8.dp)) { - Text("Import icon pack") - Text("A TeamSpeak 3 pack (.zip), such as those in the gfx folder of a TeamSpeak 3 install", - style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + LinkRow("Import icon pack", "A TeamSpeak 3 pack (.zip), such as those in the gfx folder of a TeamSpeak 3 install") { + importer.launch(arrayOf("application/zip", "application/octet-stream")) } failed?.let { Hint(it) } } -/** Picks a device by its settings id; one that is not plugged in now still shows as chosen. */ -@Composable -private fun DevicePicker(label: String, devices: List, selected: String, onSelect: (String) -> Unit) { - val options = devices.map { it.id() to it.label() }.let { list -> - if (list.any { it.first == selected }) list else list + (selected to "Not connected") - } - Picker(label, options, selected, onSelect) -} - -/** The microphone level, lit while it would be sent, with the volume gate's threshold marked. */ -@Composable -private fun LevelMeter(level: MicLevel, thresholdDb: Float?) { - val track = MaterialTheme.colorScheme.surfaceVariant - val fill = if (level.transmitting) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline - val marker = MaterialTheme.colorScheme.onSurface - fun fraction(db: Double) = - ((db - InputLevel.MIN_DB) / (InputLevel.MAX_DB - InputLevel.MIN_DB)).toFloat().coerceIn(0f, 1f) - Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Row { - Text("Microphone level", Modifier.weight(1f)) - if (level.live) Text("${level.db.roundToInt()}", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Canvas(Modifier.fillMaxWidth().padding(top = 6.dp).height(12.dp)) { - val radius = CornerRadius(size.height / 2) - drawRoundRect(track, cornerRadius = radius) - if (level.live) { - drawRoundRect(fill, size = Size(size.width * fraction(level.db), size.height), cornerRadius = radius) - } - if (thresholdDb != null) { - val x = size.width * fraction(thresholdDb.toDouble()) - drawLine(marker, Offset(x, -2.dp.toPx()), Offset(x, size.height + 2.dp.toPx()), strokeWidth = 2.dp.toPx()) - } - } - } -} - -/** A slider that applies its value once let go of. */ -@Composable -private fun Level( - label: String, - value: Float, - range: ClosedFloatingPointRange, - format: (Float) -> String, - steps: Int = 0, - onChange: (Float) -> Unit = {}, - onDone: (Float) -> Unit, -) { - var current by remember(value) { mutableFloatStateOf(value) } - Column(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { - Row { - Text(label, Modifier.weight(1f)) - Text(format(current), color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Slider(current, { - current = it - onChange(it) - }, valueRange = range, steps = steps, onValueChangeFinished = { onDone(current) }) - } -} diff --git a/android/app/src/main/java/com/ts3client/android/ui/SettingsWidgets.kt b/android/app/src/main/java/com/ts3client/android/ui/SettingsWidgets.kt new file mode 100644 index 0000000..eab07b1 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/SettingsWidgets.kt @@ -0,0 +1,171 @@ +package com.ts3client.android.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** The width a segment needs for a label of a word or two; narrower screens get a list instead. */ +private val SEGMENT_WIDTH = 110.dp + +/** A full-screen page with a back arrow; the system back gesture leaves it the same way. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SubScreen( + title: String, + onBack: () -> Unit, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable (PaddingValues) -> Unit, +) { + BackHandler(onBack = onBack) + Scaffold( + topBar = { + TopAppBar( + title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + actions = actions, + ) + }, + content = content, + ) +} + +/** A row that opens another page, or does one thing when tapped. */ +@Composable +internal fun LinkRow(title: String, summary: String?, iconKey: String? = null, onClick: () -> Unit) { + ListItem( + headlineContent = { Text(title) }, + supportingContent = summary?.let { { Text(it) } }, + leadingContent = iconKey?.let { { PackIcon(it, null) } }, + modifier = Modifier.clickable(onClick = onClick), + ) +} + +@Composable +internal fun Section(title: String) { + Text(title, Modifier.padding(start = 16.dp, top = 24.dp, bottom = 4.dp), + style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) +} + +@Composable +internal fun Choice(label: String, selected: Boolean, onSelect: () -> Unit) { + Row(Modifier.fillMaxWidth().clickable(onClick = onSelect).padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected, onClick = onSelect) + Text(label, Modifier.padding(start = 8.dp)) + } +} + +/** One of [options] as a row of segments, or as radio buttons where the segments would not fit. */ +@Composable +internal fun Segments(options: List>, selected: T, onSelect: (T) -> Unit) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth - 32.dp < SEGMENT_WIDTH * options.size) { + Column { for ((value, label) in options) Choice(label, value == selected) { onSelect(value) } } + return@BoxWithConstraints + } + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { + options.forEachIndexed { index, (value, label) -> + SegmentedButton( + selected = value == selected, + onClick = { onSelect(value) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + ) { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } + } + } +} + +@Composable +internal fun Toggle(label: String, checked: Boolean, onChange: (Boolean) -> Unit) { + Row(Modifier.fillMaxWidth().clickable { onChange(!checked) }.padding(horizontal = 16.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.weight(1f)) + Switch(checked, onChange) + } +} + +@Composable +internal fun Hint(text: String) { + Text(text, Modifier.padding(horizontal = 16.dp, vertical = 2.dp), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +/** One of [options], shown as its label and picked from a menu. */ +@Composable +internal fun Picker(label: String, options: List>, selected: T, onSelect: (T) -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + Column(Modifier.fillMaxWidth().clickable { open = true }.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(label) + Text(options.firstOrNull { it.first == selected }?.second ?: "", style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + DropdownMenu(open, { open = false }) { + for ((value, text) in options) { + DropdownMenuItem({ Text(text) }, onClick = { + open = false + onSelect(value) + }) + } + } + } +} + +/** A slider that applies its value once let go of. */ +@Composable +internal fun Level( + label: String, + value: Float, + range: ClosedFloatingPointRange, + format: (Float) -> String, + steps: Int = 0, + onChange: (Float) -> Unit = {}, + onDone: (Float) -> Unit, +) { + var current by remember(value) { mutableFloatStateOf(value) } + Column(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + Row { + Text(label, Modifier.weight(1f)) + Text(format(current), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Slider(current, { + current = it + onChange(it) + }, valueRange = range, steps = steps, onValueChangeFinished = { onDone(current) }) + } +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt new file mode 100644 index 0000000..6179080 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt @@ -0,0 +1,218 @@ +package com.ts3client.android.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ts3client.android.MicLevel +import com.ts3client.android.SessionController +import com.ts3client.android.audio.AudioRoute +import com.ts3client.audio.AudioDevice +import com.ts3client.audio.InputLevel +import com.ts3client.config.Settings +import kotlin.math.roundToInt + +private val DENOISE_LEVELS = listOf("6 dB", "12 dB", "18 dB", "21 dB") + +/** Audio devices, transmission, voice activation, processing and playback. */ +@Composable +fun SoundSettingsScreen(vm: SessionController, onBack: () -> Unit) { + val settings = vm.settings + fun change(edit: Settings.() -> Unit) { + settings.edit() + vm.applySettings() + } + var inputMode by remember { mutableStateOf(settings.inputMode) } + var vadMode by remember { mutableStateOf(settings.vadMode) } + var denoise by remember { mutableStateOf(settings.denoise) } + var agc by remember { mutableStateOf(settings.agc) } + var threshold by remember { mutableFloatStateOf(settings.vadThresholdDb.toFloat()) } + var inputDevice by remember { mutableStateOf(settings.inputDevice) } + var outputDevice by remember { mutableStateOf(settings.outputDevice) } + val inputs by vm.inputDevices.collectAsStateWithLifecycle() + val outputs by vm.outputDevices.collectAsStateWithLifecycle() + val route by vm.route.collectAsStateWithLifecycle() + val level by vm.micLevel.collectAsStateWithLifecycle() + LifecycleStartEffect(Unit) { + vm.watchLevel(true) + onStopOrDispose { vm.watchLevel(false) } + } + + SubScreen("Sound Settings", onBack) { padding -> + Column(Modifier.padding(padding).verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) { + Section("Devices") + DevicePicker("Microphone", inputs, inputDevice) { + inputDevice = it + change { this.inputDevice = it } + } + DevicePicker("Speaker", outputs, outputDevice) { + outputDevice = it + change { this.outputDevice = it } + } + Picker( + "Microphone preset", + INPUT_PRESETS.map { (preset, label) -> + val unsupported = preset == AudioRoute.InputPreset.UNPROCESSED && !vm.unprocessedSupported + preset to if (unsupported) "$label (not supported here)" else label + }, + route.inputPreset(), + ) { vm.setRoute(AudioRoute(it, route.usage())) } + Hint(PRESET_HINTS.getValue(route.inputPreset())) + Text("Play voices", Modifier.padding(start = 16.dp, top = 8.dp)) + for ((usage, label) in USAGES) { + Choice(label, route.usage() == usage) { vm.setRoute(AudioRoute(route.inputPreset(), usage)) } + } + + Section("Transmission") + for ((mode, label) in INPUT_MODES) { + Choice(label, inputMode == mode) { + inputMode = mode + change { this.inputMode = mode } + } + } + val gated = inputMode == Settings.InputMode.VOICE_ACTIVATION && vadMode != Settings.VadMode.AUTOMATIC + LevelMeter(level, threshold.takeIf { gated }) + if (!level.live) { + Hint(if (vm.hasMicPermission()) "The microphone is not available right now." else "Allow the microphone to see its level.") + } + + if (inputMode == Settings.InputMode.VOICE_ACTIVATION) { + Section("Voice activation") + for ((mode, label) in VAD_MODES) { + Choice(label, vadMode == mode) { + vadMode = mode + change { this.vadMode = mode } + } + } + if (vadMode != Settings.VadMode.AUTOMATIC) { + Level("Volume threshold", settings.vadThresholdDb.toFloat(), -50f..50f, { "${it.roundToInt()}" }, + onChange = { threshold = it }) { + change { vadThresholdDb = it.roundToInt().toDouble() } + } + } + if (vadMode != Settings.VadMode.VOLUME_GATE) { + Level("Speech likelihood", settings.speechThreshold.toFloat(), 0f..1f, { "${(it * 100).roundToInt()} %" }) { + change { speechThreshold = it.toDouble() } + } + } + } + + Section("Processing") + Toggle("Noise suppression", denoise) { + denoise = it + change { this.denoise = it } + } + if (denoise) { + Level("Suppression", settings.denoiserLevel.toFloat(), 0f..3f, { DENOISE_LEVELS[it.roundToInt()] }, steps = 2) { + change { denoiserLevel = it.roundToInt() } + } + } + Toggle("Automatic gain control", agc) { + agc = it + change { this.agc = it } + } + if (agc) { + Level("Boost quiet speech", settings.agcBoostDb.toFloat(), 0f..Settings.MAX_AGC_BOOST_DB.toFloat(), + { "+${it.roundToInt()} dB" }, steps = Settings.MAX_AGC_BOOST_DB - 1) { + change { agcBoostDb = it.roundToInt() } + } + } + + Section("Playback") + Level("Volume", settings.outputVolume.toFloat(), 0f..2f, { "${(it * 100).roundToInt()} %" }) { + change { outputVolume = it.toDouble() } + } + } + } +} + +private val INPUT_MODES = listOf( + Settings.InputMode.VOICE_ACTIVATION to "Voice activation", + Settings.InputMode.PUSH_TO_TALK to "Push to talk", + Settings.InputMode.CONTINUOUS to "Continuous", +) + +private val INPUT_PRESETS = listOf( + AudioRoute.InputPreset.VOICE_RECOGNITION to "Voice recognition", + AudioRoute.InputPreset.VOICE_COMMUNICATION to "Voice communication", + AudioRoute.InputPreset.UNPROCESSED to "Unprocessed", + AudioRoute.InputPreset.VOICE_PERFORMANCE to "Voice performance", + AudioRoute.InputPreset.GENERIC to "Generic", + AudioRoute.InputPreset.CAMCORDER to "Camcorder", +) + +private val PRESET_HINTS = mapOf( + AudioRoute.InputPreset.VOICE_RECOGNITION to "Little or no processing by the phone; the client's own does the rest.", + AudioRoute.InputPreset.VOICE_COMMUNICATION to + "The phone's call processing: echo cancellation, noise suppression and gain. Works best with voices played as a call.", + AudioRoute.InputPreset.UNPROCESSED to "The raw microphone signal.", + AudioRoute.InputPreset.VOICE_PERFORMANCE to "Low latency and no processing, meant for live performance.", + AudioRoute.InputPreset.GENERIC to "The phone's default microphone source, with whatever it applies to it.", + AudioRoute.InputPreset.CAMCORDER to "Tuned for recording video, often with the rear microphone.", +) + +private val USAGES = listOf( + AudioRoute.Usage.MEDIA to "As media (Bluetooth stays in high quality)", + AudioRoute.Usage.VOICE_COMMUNICATION to "As a call (lets the phone cancel echo)", +) + +private val VAD_MODES = listOf( + Settings.VadMode.AUTOMATIC to "Automatic", + Settings.VadMode.VOLUME_GATE to "Volume gate", + Settings.VadMode.HYBRID to "Hybrid", +) + +/** Picks a device by its settings id; one that is not plugged in now still shows as chosen. */ +@Composable +private fun DevicePicker(label: String, devices: List, selected: String, onSelect: (String) -> Unit) { + val options = devices.map { it.id() to it.label() }.let { list -> + if (list.any { it.first == selected }) list else list + (selected to "Not connected") + } + Picker(label, options, selected, onSelect) +} + +/** The microphone level, lit while it would be sent, with the volume gate's threshold marked. */ +@Composable +private fun LevelMeter(level: MicLevel, thresholdDb: Float?) { + val track = MaterialTheme.colorScheme.surfaceVariant + val fill = if (level.transmitting) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + val marker = MaterialTheme.colorScheme.onSurface + fun fraction(db: Double) = + ((db - InputLevel.MIN_DB) / (InputLevel.MAX_DB - InputLevel.MIN_DB)).toFloat().coerceIn(0f, 1f) + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row { + Text("Microphone level", Modifier.weight(1f)) + if (level.live) Text("${level.db.roundToInt()}", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Canvas(Modifier.fillMaxWidth().padding(top = 6.dp).height(12.dp)) { + val radius = CornerRadius(size.height / 2) + drawRoundRect(track, cornerRadius = radius) + if (level.live) { + drawRoundRect(fill, size = Size(size.width * fraction(level.db), size.height), cornerRadius = radius) + } + if (thresholdDb != null) { + val x = size.width * fraction(thresholdDb.toDouble()) + drawLine(marker, Offset(x, -2.dp.toPx()), Offset(x, size.height + 2.dp.toPx()), strokeWidth = 2.dp.toPx()) + } + } + } +}