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 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 10:52:56 +00:00
parent e7741e8b34
commit 5c0a5e4bcf
4 changed files with 408 additions and 325 deletions

View File

@@ -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 },

View File

@@ -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 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 }
}
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)) }
var page by rememberSaveable { mutableStateOf<SettingsPage?>(null) }
when (page) {
SettingsPage.SOUND -> return SoundSettingsScreen(vm, onBack = { page = null })
SettingsPage.MY_TEAMSPEAK -> return MyTeamSpeakScreen(vm, onBack = { page = null })
null -> {}
}
Section("Transmission")
for ((mode, label) in INPUT_MODES) {
Choice(label, inputMode == mode) {
inputMode = mode
change { this.inputMode = mode }
SubScreen("Settings", onBack) { padding ->
Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) {
LinkRow("Sound Settings", "Devices, transmission, voice activation, processing, volume", "PLAYBACK") {
page = SettingsPage.SOUND
}
}
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 <T> Picker(label: String, options: List<Pair<T, String>>, 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<AudioDevice>, 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<Float>,
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) })
}
}

View File

@@ -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 <T> Segments(options: List<Pair<T, String>>, 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 <T> Picker(label: String, options: List<Pair<T, String>>, 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<Float>,
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) })
}
}

View File

@@ -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<AudioDevice>, 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())
}
}
}
}