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()) + } + } + } +}