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:
@@ -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 },
|
||||
|
||||
@@ -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<SettingsPage?>(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 <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) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user