PlaybackSettingsScreen.kt
package dev.stave.ui.settings
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.data.LyricsHighlightColor
import dev.stave.data.ReplayGainMode
import dev.stave.domain.model.MetadataField
import dev.stave.ui.components.SystemImeTextDialog
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.theme.AccentAmber
import dev.stave.ui.theme.AccentAmberSoft
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.OnQuiet
import dev.stave.ui.theme.SurfaceRaised
import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
import dev.stave.ui.theme.adaptiveScreenPaddingVertical
import dev.stave.ui.theme.staveInterFamily
import dev.stave.ui.theme.withInterDigits
/**
* Playback settings. Everything that changes *how* audio is played or what metadata is
* shown lives here, so the top-level Settings screen stays navigation-focused.
*
* Sections: house-screen automation, Now Playing corner button, network renderer,
* ReplayGain (mode + pre-amp), random exclusions, lyrics highlight, lyrics source,
* display font, metadata fields.
*/
@Composable
fun PlaybackSettingsScreen(
onBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val enabled by viewModel.enabled.collectAsState()
val rgMode by viewModel.replayGainMode.collectAsState()
val preAmp by viewModel.replayGainPreAmpDb.collectAsState()
val excludedGenres by viewModel.randomExcludedGenres.collectAsState()
val highlightSize by viewModel.lyricsHighlightSize.collectAsState()
val highlightBold by viewModel.lyricsHighlightBold.collectAsState()
val highlightColor by viewModel.lyricsHighlightColor.collectAsState()
val allowExternal by viewModel.allowExternalCalls.collectAsState()
val rendererBroadcast by viewModel.rendererBroadcastEnabled.collectAsState()
val useFraunces by viewModel.useFraunces.collectAsState()
val marqueeOn by viewModel.marqueeEnabled.collectAsState()
val leftyOn by viewModel.leftyMode.collectAsState()
val lyricsMetaOn by viewModel.lyricsMetadataEnabled.collectAsState()
val activeUdn by viewModel.activeRendererUdn.collectAsState()
val renderers by viewModel.renderers.collectAsState()
val friendlyName by viewModel.rendererFriendlyName.collectAsState()
var showNameDialog by remember { mutableStateOf(false) }
var showDomEthaUrlDialog by remember { mutableStateOf(false) }
// True when the URL dialog was opened by selecting the Dom-Etha radio
// with no address set: saving a valid address then also selects the
// radio, so "tap Dom-Etha → enter address → done" is one gesture chain.
var enableDomEthaOnSave by remember { mutableStateOf(false) }
val domEthaUrl by viewModel.domEthaUrl.collectAsState()
val cornerDomEtha by viewModel.cornerButtonDomEtha.collectAsState()
var showAddGenreDialog by remember { mutableStateOf(false) }
BackHandler(onBack = onBack)
Column(
Modifier
.fillMaxSize()
.padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
) {
Text("Playback & Display", style = MaterialTheme.typography.displaySmall, color = OnPrimary)
Spacer(Modifier.height(24.dp))
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
// ------------ House screen behavior -----------
item {
SectionHeader(
"House screens",
"How this device reacts when house music starts",
)
}
item {
val flip by viewModel.kioskFlipEnabled.collectAsState()
PrivacyToggleRow(
title = "Enable Faveat Haus automation",
subtitle = if (flip)
"Music starting anywhere flips this screen to Now Playing (wall-screen mode)"
else
"This device ignores house music events — browse freely (manual mode)",
enabled = flip,
onToggle = { viewModel.setKioskFlipEnabled(!flip) },
)
}
item { Spacer(Modifier.height(16.dp)) }
// ------------ Now Playing corner button -----------
item {
SectionHeader(
"Now Playing corner button",
"What the button beside Cast opens",
)
}
item {
RgModeRow(
label = "Sleep timer",
description = "Countdown to stop playback",
selected = !cornerDomEtha,
onSelect = { viewModel.setCornerButtonDomEtha(false) },
)
}
item {
RgModeRow(
label = "Dom-Etha panel",
description = if (domEthaUrl != null)
"Open the house control panel at ${domEthaUrl}"
else
"Open the house control panel — set the address below first",
selected = cornerDomEtha && domEthaUrl != null,
onSelect = {
if (domEthaUrl != null) {
viewModel.setCornerButtonDomEtha(true)
} else {
// No address yet: selecting the option IS the
// request to enter one. Pop the dialog instead
// of silently doing nothing.
enableDomEthaOnSave = true
showDomEthaUrlDialog = true
}
},
)
}
item {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction)
.clickable(interaction, indication = null) { showDomEthaUrlDialog = true }
.padding(horizontal = 24.dp, vertical = 14.dp)
) {
Column {
Text("Dom-Etha address", style = MaterialTheme.typography.titleSmall, color = OnPrimary)
Text(
domEthaUrl ?: "Not set — tap to enter the panel URL",
style = MaterialTheme.typography.bodySmall, color = OnMuted,
)
}
}
}
item { Spacer(Modifier.height(16.dp)) }
// ------------ Network renderer -----------
item {
SectionHeader(
"Network renderer",
"Play through this device or a remote UPnP renderer on your LAN",
)
}
item {
PrivacyToggleRow(
title = "Broadcast as renderer",
subtitle = "Let other Earhorn instances (and UPnP control points) " +
"play music through this device's speakers",
enabled = rendererBroadcast,
onToggle = { viewModel.setRendererBroadcast(!rendererBroadcast) },
)
}
item {
val displayName = friendlyName
?: "Earhorn on ${android.os.Build.MODEL ?: "Android"}"
// Audit 2026-09-02: this was the one row on the screen without
// staveFocusable, so it could not be reached by D-pad on TV.
val nameInteraction = remember { MutableInteractionSource() }
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(nameInteraction)
.clickable(nameInteraction, indication = null) { showNameDialog = true }
.padding(horizontal = 24.dp, vertical = 14.dp)
) {
Column {
Text(
"Device name",
style = MaterialTheme.typography.bodyLarge,
color = OnPrimary,
)
Spacer(Modifier.height(4.dp))
Text(
displayName,
style = MaterialTheme.typography.bodySmall,
color = OnMuted,
)
}
}
}
item {
RgModeRow(
label = "This device",
description = "Play through the local speakers",
selected = activeUdn == null,
onSelect = { viewModel.setActiveRenderer(null) },
)
}
items(renderers, key = { it.udn }) { renderer ->
RgModeRow(
label = renderer.friendlyName,
description = renderer.udn.removePrefix("uuid:").take(8) + "…",
selected = activeUdn == renderer.udn,
onSelect = { viewModel.setActiveRenderer(renderer.udn) },
)
}
if (renderers.isEmpty()) {
item {
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.padding(horizontal = 24.dp, vertical = 14.dp)
) {
Text(
"No remote renderers discovered yet",
style = MaterialTheme.typography.bodySmall,
color = OnMuted,
)
}
}
}
// ------------ Volume normalization ------------
item {
Spacer(Modifier.height(24.dp))
SectionHeader("Volume normalization", "Apply ReplayGain tags to even out loudness across tracks")
}
item {
RgModeRow(
label = "Off",
description = "No volume correction",
selected = rgMode == ReplayGainMode.OFF,
onSelect = { viewModel.setReplayGainMode(ReplayGainMode.OFF) },
)
}
item {
RgModeRow(
label = "Track gain",
description = "Each track normalized individually",
selected = rgMode == ReplayGainMode.TRACK,
onSelect = { viewModel.setReplayGainMode(ReplayGainMode.TRACK) },
)
}
item {
RgModeRow(
label = "Album gain",
description = "Normalize per album so loudness differences within an album are preserved",
selected = rgMode == ReplayGainMode.ALBUM,
onSelect = { viewModel.setReplayGainMode(ReplayGainMode.ALBUM) },
)
}
item {
PreAmpRow(
preAmpDb = preAmp,
enabled = rgMode != ReplayGainMode.OFF,
onDecrement = {
viewModel.setReplayGainPreAmpDb((preAmp - 0.5f).coerceAtLeast(-15f))
},
onIncrement = {
viewModel.setReplayGainPreAmpDb((preAmp + 0.5f).coerceAtMost(15f))
},
)
}
// ------------ Random exclusions ------------
item {
Spacer(Modifier.height(24.dp))
SectionHeader(
"Random exclusions",
"Genres to skip when picking random albums or tracks",
)
}
items(COMMON_EXCLUDABLE_GENRES, key = { it }) { genre ->
GenreToggleRow(
genre = genre,
excluded = excludedGenres.any { it.equals(genre, ignoreCase = true) },
onToggle = { isExcluded ->
// Maintainer, 2026-10-29: firstOrNull — a quick double tap could run
// this after the genre was already removed (first{} threw
// NoSuchElementException); skip when it's gone.
if (isExcluded) viewModel.addExcludedGenre(genre)
else excludedGenres.firstOrNull { it.equals(genre, ignoreCase = true) }
?.let { viewModel.removeExcludedGenre(it) }
},
)
}
// Also list any user-specified genres that aren't in the preset list, so
// they can be removed.
val extras = excludedGenres.filter { g ->
COMMON_EXCLUDABLE_GENRES.none { it.equals(g, ignoreCase = true) }
}
if (extras.isNotEmpty()) {
items(extras, key = { "extra:$it" }) { genre ->
GenreToggleRow(
genre = genre,
excluded = true,
onToggle = { viewModel.removeExcludedGenre(genre) },
)
}
}
item {
AddGenreRow(onClick = { showAddGenreDialog = true })
}
// ------------ Lyrics ------------
item {
Spacer(Modifier.height(24.dp))
SectionHeader(
"Lyrics highlight",
"How the currently-playing line stands out on the lyrics screen",
)
}
item {
HighlightSizeRow(
multiplier = highlightSize,
onDecrement = {
viewModel.setLyricsHighlightSize((highlightSize - 0.1f).coerceAtLeast(1.0f))
},
onIncrement = {
viewModel.setLyricsHighlightSize((highlightSize + 0.1f).coerceAtMost(2.5f))
},
)
}
item {
PrivacyToggleRow(
title = "Bold current line",
subtitle = "Render the currently-playing line in a bold weight",
enabled = highlightBold,
onToggle = { viewModel.setLyricsHighlightBold(!highlightBold) },
)
}
item {
HighlightColorRow(
label = "Amber",
description = "Earhorn's warm accent — default",
selected = highlightColor == LyricsHighlightColor.AMBER,
onSelect = { viewModel.setLyricsHighlightColor(LyricsHighlightColor.AMBER) },
)
}
item {
HighlightColorRow(
label = "White",
description = "High contrast, no color tint",
selected = highlightColor == LyricsHighlightColor.WHITE,
onSelect = { viewModel.setLyricsHighlightColor(LyricsHighlightColor.WHITE) },
)
}
item {
HighlightColorRow(
label = "Sage",
description = "Cooler, softer highlight",
selected = highlightColor == LyricsHighlightColor.SAGE,
onSelect = { viewModel.setLyricsHighlightColor(LyricsHighlightColor.SAGE) },
)
}
// ------------ Privacy ------------
item {
Spacer(Modifier.height(24.dp))
SectionHeader(
"Lyrics source",
"Control whether Earhorn contacts external services for lyrics",
)
}
item {
PrivacyToggleRow(
title = "Allow online lyrics lookup",
subtitle = "When off, Earhorn never contacts LRCLIB. Local .lrc files next " +
"to your audio files still work.",
enabled = allowExternal,
onToggle = { viewModel.setAllowExternalCalls(!allowExternal) },
)
}
// ------------ Display ------------
item {
Spacer(Modifier.height(24.dp))
SectionHeader(
"Display",
"How text looks across the app",
)
}
// Three settings that had setters and storage but no switch
// (design decision 2026-09-02, I.64).
item {
PrivacyToggleRow(
title = "Scroll long titles (marquee)",
subtitle = "Titles too long for one line scroll sideways instead of ending in \"…\" — Now Playing, the bar, and queue rows.",
enabled = marqueeOn,
onToggle = { viewModel.setMarquee(!marqueeOn) },
)
}
item {
PrivacyToggleRow(
title = "Left-handed album layout",
subtitle = "On phones, put the album action buttons on the left of the artwork.",
enabled = leftyOn,
onToggle = { viewModel.setLeftyMode(!leftyOn) },
)
}
item {
PrivacyToggleRow(
title = "Lyrics details in the corner",
subtitle = "Year, format and genre shown in the corner of the lyrics screen on TV.",
enabled = lyricsMetaOn,
onToggle = { viewModel.setLyricsMetadata(!lyricsMetaOn) },
)
}
item {
PrivacyToggleRow(
title = "Use Fraunces serif font",
subtitle = "Off: render every screen in Inter (sans-serif) for higher " +
"legibility. Numeric displays — timestamps, durations, track numbers — " +
"stay in Inter either way.",
enabled = useFraunces,
onToggle = { viewModel.setUseFraunces(!useFraunces) },
)
}
// ------------ Metadata fields ------------
item {
Spacer(Modifier.height(24.dp))
SectionHeader(
"Album detail fields",
"Which metadata fields appear on the album screen",
)
}
items(MetadataField.entries.toList(), key = { it.key }) { field ->
FieldRow(
field = field,
enabled = field in enabled,
onToggle = { viewModel.toggle(field) }
)
}
}
}
if (showDomEthaUrlDialog) {
SystemImeTextDialog(
title = "Dom-Etha address",
placeholder = "http://<panel-address>/mediacontrol.html",
confirmLabel = "Save",
// Prepopulated with the group panel address (maintainer request,
// 2026-08-29): first open is save-and-done. Still editable —
// other installs overtype it; nothing is hardcoded as a value
// until Save is pressed.
initialText = domEthaUrl ?: dev.stave.BuildConfig.DOM_ETHA_DEFAULT_URL,
plainInput = true,
// Maintainer, 2026-10-29: an empty Save must reach onConfirm so the
// address can be cleared (cleaned == null below → setDomEthaUrl(null)
// and the corner button reverts to the sleep timer).
allowEmpty = true,
onConfirm = { entered ->
val cleaned = entered.trim().takeIf { it.isNotEmpty() }
viewModel.setDomEthaUrl(cleaned)
when {
cleaned == null -> viewModel.setCornerButtonDomEtha(false)
enableDomEthaOnSave -> viewModel.setCornerButtonDomEtha(true)
}
enableDomEthaOnSave = false
showDomEthaUrlDialog = false
},
onDismiss = {
enableDomEthaOnSave = false
showDomEthaUrlDialog = false
},
)
}
if (showNameDialog) {
SystemImeTextDialog(
title = "Device name",
placeholder = "Earhorn on ${android.os.Build.MODEL ?: "Android"}",
confirmLabel = "Save",
initialText = friendlyName ?: "",
// Maintainer, 2026-10-29: an empty Save must reach onConfirm so the
// name resets to the default — setRendererFriendlyName maps a blank
// name to null, which removes the stored name.
allowEmpty = true,
onConfirm = { name ->
viewModel.setRendererFriendlyName(name)
showNameDialog = false
},
onDismiss = { showNameDialog = false },
)
}
if (showAddGenreDialog) {
AddGenreDialog(
onConfirm = { genre ->
viewModel.addExcludedGenre(genre)
showAddGenreDialog = false
},
onDismiss = { showAddGenreDialog = false },
)
}
}
/** Preset list of genres most users want excluded from shuffle. Toggled via the
* settings screen. Not exhaustive — just the common cases that caused complaints. */
private val COMMON_EXCLUDABLE_GENRES = listOf(
"Podcast",
"Audiobook",
"Comedy",
"Spoken Word",
"Interview",
"Soundtrack",
)
@Composable
private fun SectionHeader(title: String, subtitle: String) {
Column {
Text(withInterDigits(title), style = MaterialTheme.typography.titleMedium, color = OnPrimary)
Text(withInterDigits(subtitle), style = MaterialTheme.typography.bodySmall, color = OnMuted)
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun RgModeRow(
label: String,
description: String,
selected: Boolean,
onSelect: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction)
.clickable(interaction, indication = null) { onSelect() }
.padding(horizontal = 24.dp, vertical = 14.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioDot(selected = selected)
Spacer(Modifier.size(16.dp))
Column(Modifier.weight(1f)) {
Text(withInterDigits(label), style = MaterialTheme.typography.titleSmall, color = OnPrimary)
Text(withInterDigits(description), style = MaterialTheme.typography.bodySmall, color = OnMuted)
}
}
}
}
/** Radio-row for picking the lyrics highlight color — amber/white/sage. Thin
* alias over RgModeRow so call sites read naturally. */
@Composable
private fun HighlightColorRow(
label: String,
description: String,
selected: Boolean,
onSelect: () -> Unit,
) = RgModeRow(label, description, selected, onSelect)
/** Stepper row for the lyrics highlight size multiplier. Shows current value
* formatted as a percentage (150%), with -/+ buttons stepping by 10%. Mirrors
* the pattern used by PreAmpRow for ReplayGain. */
@Composable
private fun HighlightSizeRow(
multiplier: Float,
onDecrement: () -> Unit,
onIncrement: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.padding(horizontal = 24.dp, vertical = 14.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
"Current-line size",
style = MaterialTheme.typography.titleSmall,
color = OnPrimary,
)
Text(
"How much larger the current lyric is than the others",
style = MaterialTheme.typography.bodySmall,
color = OnMuted,
)
}
StepperButton(label = "\u2212", enabled = true, onClick = onDecrement)
Spacer(Modifier.width(12.dp))
Text(
"${(multiplier * 100).toInt()}%",
style = MaterialTheme.typography.titleMedium.copy(fontFamily = staveInterFamily()),
color = OnPrimary,
modifier = Modifier.width(72.dp),
)
Spacer(Modifier.width(12.dp))
StepperButton(label = "+", enabled = true, onClick = onIncrement)
}
}
}
/** Two-line toggle row with a checkmark. Used for every on/off setting on
* this screen. */
@Composable
private fun PrivacyToggleRow(
title: String,
subtitle: String,
enabled: Boolean,
onToggle: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction)
.clickable(interaction, indication = null) { onToggle() }
.padding(horizontal = 24.dp, vertical = 14.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkmark(enabled = enabled)
Spacer(Modifier.size(16.dp))
Column(Modifier.weight(1f)) {
Text(withInterDigits(title), style = MaterialTheme.typography.titleSmall, color = OnPrimary)
Text(withInterDigits(subtitle), style = MaterialTheme.typography.bodySmall, color = OnMuted)
}
}
}
}
@Composable
private fun PreAmpRow(
preAmpDb: Float,
enabled: Boolean,
onDecrement: () -> Unit,
onIncrement: () -> Unit,
) {
val alpha = if (enabled) 1f else 0.4f
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.padding(horizontal = 24.dp, vertical = 14.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
"Pre-amp",
style = MaterialTheme.typography.titleSmall,
color = if (enabled) OnPrimary else OnMuted,
)
Text(
"Added to RG-corrected volume. Positive values reclaim headroom.",
style = MaterialTheme.typography.bodySmall,
color = OnMuted,
)
}
StepperButton(label = "\u2212", enabled = enabled, onClick = onDecrement)
Spacer(Modifier.width(12.dp))
Text(
formatDb(preAmpDb),
style = MaterialTheme.typography.titleMedium.copy(fontFamily = staveInterFamily()),
color = if (enabled) OnPrimary else OnMuted,
modifier = Modifier.width(72.dp),
)
Spacer(Modifier.width(12.dp))
StepperButton(label = "+", enabled = enabled, onClick = onIncrement)
}
}
}
@Composable
private fun StepperButton(label: String, enabled: Boolean, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.size(40.dp)
.background(if (enabled) SurfaceRaised else SurfaceRaised.copy(alpha = 0.4f))
.staveFocusable(interaction, cornerRadiusDp = 4)
.clickable(interaction, indication = null, enabled = enabled) { onClick() },
contentAlignment = Alignment.Center,
) {
Text(withInterDigits(label), style = MaterialTheme.typography.titleMedium, color = OnPrimary)
}
}
private fun formatDb(db: Float): String {
val sign = if (db >= 0) "+" else ""
return "$sign${"%.1f".format(db)} dB"
}
@Composable
private fun GenreToggleRow(
genre: String,
excluded: Boolean,
onToggle: (Boolean) -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction)
.clickable(interaction, indication = null) { onToggle(!excluded) }
.padding(horizontal = 24.dp, vertical = 14.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkmark(enabled = excluded)
Spacer(Modifier.size(16.dp))
Text(withInterDigits(genre), style = MaterialTheme.typography.titleSmall, color = OnPrimary)
}
}
}
@Composable
private fun FieldRow(field: MetadataField, enabled: Boolean, onToggle: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction)
.clickable(interaction, indication = null) { onToggle() }
.padding(horizontal = 24.dp, vertical = 14.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkmark(enabled = enabled)
Spacer(Modifier.size(16.dp))
Column(Modifier.weight(1f)) {
Text(withInterDigits(field.label), style = MaterialTheme.typography.titleSmall, color = OnPrimary)
Text(withInterDigits(field.key), style = MaterialTheme.typography.labelSmall, color = OnQuiet)
}
}
}
}
@Composable
private fun Checkmark(enabled: Boolean) {
Box(
Modifier
.size(22.dp)
.background(
if (enabled) AccentAmber else AccentAmberSoft,
RoundedCornerShape(4.dp)
)
.clip(RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center
) {
if (enabled) {
Text("✓", style = MaterialTheme.typography.labelLarge, color = Color.Black)
}
}
}
@Composable
private fun RadioDot(selected: Boolean) {
Box(
Modifier
.size(22.dp)
.background(
if (selected) AccentAmber else AccentAmberSoft,
RoundedCornerShape(11.dp)
),
contentAlignment = Alignment.Center,
) {
if (selected) {
Box(
Modifier
.size(8.dp)
.background(Color.Black, RoundedCornerShape(4.dp))
)
}
}
}
/** "Add genre…" button that opens the custom genre dialog. */
@Composable
private fun AddGenreRow(onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 4)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 24.dp, vertical = 14.dp),
) {
Text(
"+ Add genre…",
style = MaterialTheme.typography.titleSmall,
color = AccentAmber,
)
}
}
/** Modal dialog for entering a custom genre name. Routes through
* [SystemImeTextDialog] which uses a BasicTextField that summons the
* system IME. */
@Composable
private fun AddGenreDialog(
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
SystemImeTextDialog(
title = "Add genre to exclude",
subtitle = "Type the genre name and press Done.",
placeholder = "Genre name",
confirmLabel = "add",
onConfirm = onConfirm,
onDismiss = onDismiss,
)
}