PlaybackSettingsScreen.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.ui.settings |
| 2 | ||
| 3 | import androidx.activity.compose.BackHandler | |
| 4 | import androidx.compose.foundation.background | |
| 5 | import androidx.compose.foundation.clickable | |
| 6 | import androidx.compose.foundation.interaction.MutableInteractionSource | |
| 7 | import androidx.compose.foundation.layout.Arrangement | |
| 8 | import androidx.compose.foundation.layout.Box | |
| 9 | import androidx.compose.foundation.layout.Column | |
| 10 | import androidx.compose.foundation.layout.Row | |
| 11 | import androidx.compose.foundation.layout.Spacer | |
| 12 | import androidx.compose.foundation.layout.fillMaxSize | |
| 13 | import androidx.compose.foundation.layout.fillMaxWidth | |
| 14 | import androidx.compose.foundation.layout.height | |
| 15 | import androidx.compose.foundation.layout.padding | |
| 16 | import androidx.compose.foundation.layout.size | |
| 17 | import androidx.compose.foundation.layout.width | |
| 18 | import androidx.compose.foundation.lazy.LazyColumn | |
| 19 | import androidx.compose.foundation.lazy.items | |
| 20 | import androidx.compose.foundation.shape.RoundedCornerShape | |
| 21 | import androidx.compose.runtime.Composable | |
| 22 | import androidx.compose.runtime.collectAsState | |
| 23 | import androidx.compose.runtime.getValue | |
| 24 | import androidx.compose.runtime.mutableStateOf | |
| 25 | import androidx.compose.runtime.remember | |
| 26 | import androidx.compose.runtime.setValue | |
| 27 | import androidx.compose.ui.Alignment | |
| 28 | import androidx.compose.ui.Modifier | |
| 29 | import androidx.compose.ui.draw.clip | |
| 30 | import androidx.compose.ui.graphics.Color | |
| 31 | import androidx.compose.ui.unit.dp | |
| 32 | import androidx.hilt.navigation.compose.hiltViewModel | |
| 33 | import androidx.tv.material3.MaterialTheme | |
| 34 | import androidx.tv.material3.Text | |
| 35 | import dev.stave.data.LyricsHighlightColor | |
| 36 | import dev.stave.data.ReplayGainMode | |
| 37 | import dev.stave.domain.model.MetadataField | |
| 38 | import dev.stave.ui.components.SystemImeTextDialog | |
| 39 | import dev.stave.ui.components.staveFocusable | |
| 40 | import dev.stave.ui.theme.AccentAmber | |
| 41 | import dev.stave.ui.theme.AccentAmberSoft | |
| 42 | import dev.stave.ui.theme.OnMuted | |
| 43 | import dev.stave.ui.theme.OnPrimary | |
| 44 | import dev.stave.ui.theme.OnQuiet | |
| 45 | import dev.stave.ui.theme.SurfaceRaised | |
| 46 | import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal | |
| 47 | import dev.stave.ui.theme.adaptiveScreenPaddingVertical | |
| 48 | import dev.stave.ui.theme.staveInterFamily | |
| 49 | import dev.stave.ui.theme.withInterDigits | |
| 50 | ||
| 51 | /** | |
| 52 | * Playback settings. Everything that changes *how* audio is played or what metadata is | |
| 53 | * shown lives here, so the top-level Settings screen stays navigation-focused. | |
| 54 | * | |
| 55 | * Sections: house-screen automation, Now Playing corner button, network renderer, | |
| 56 | * ReplayGain (mode + pre-amp), random exclusions, lyrics highlight, lyrics source, | |
| 57 | * display font, metadata fields. | |
| 58 | */ | |
| 59 | @Composable | |
| 60 | fun PlaybackSettingsScreen( | |
| 61 | onBack: () -> Unit, | |
| 62 | viewModel: SettingsViewModel = hiltViewModel(), | |
| 63 | ) { | |
| 64 | val enabled by viewModel.enabled.collectAsState() | |
| 65 | val rgMode by viewModel.replayGainMode.collectAsState() | |
| 66 | val preAmp by viewModel.replayGainPreAmpDb.collectAsState() | |
| 67 | val excludedGenres by viewModel.randomExcludedGenres.collectAsState() | |
| 68 | val highlightSize by viewModel.lyricsHighlightSize.collectAsState() | |
| 69 | val highlightBold by viewModel.lyricsHighlightBold.collectAsState() | |
| 70 | val highlightColor by viewModel.lyricsHighlightColor.collectAsState() | |
| 71 | val allowExternal by viewModel.allowExternalCalls.collectAsState() | |
| 72 | val rendererBroadcast by viewModel.rendererBroadcastEnabled.collectAsState() | |
| 73 | val useFraunces by viewModel.useFraunces.collectAsState() | |
| 74 | val marqueeOn by viewModel.marqueeEnabled.collectAsState() | |
| 75 | val leftyOn by viewModel.leftyMode.collectAsState() | |
| 76 | val lyricsMetaOn by viewModel.lyricsMetadataEnabled.collectAsState() | |
| 77 | val activeUdn by viewModel.activeRendererUdn.collectAsState() | |
| 78 | val renderers by viewModel.renderers.collectAsState() | |
| 79 | val friendlyName by viewModel.rendererFriendlyName.collectAsState() | |
| 80 | var showNameDialog by remember { mutableStateOf(false) } | |
| 81 | var showDomEthaUrlDialog by remember { mutableStateOf(false) } | |
| 82 | // True when the URL dialog was opened by selecting the Dom-Etha radio | |
| 83 | // with no address set: saving a valid address then also selects the | |
| 84 | // radio, so "tap Dom-Etha → enter address → done" is one gesture chain. | |
| 85 | var enableDomEthaOnSave by remember { mutableStateOf(false) } | |
| 86 | val domEthaUrl by viewModel.domEthaUrl.collectAsState() | |
| 87 | val cornerDomEtha by viewModel.cornerButtonDomEtha.collectAsState() | |
| 88 | var showAddGenreDialog by remember { mutableStateOf(false) } | |
| 89 | BackHandler(onBack = onBack) | |
| 90 | ||
| 91 | Column( | |
| 92 | Modifier | |
| 93 | .fillMaxSize() | |
| 94 | .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical()) | |
| 95 | ) { | |
| 96 | Text("Playback & Display", style = MaterialTheme.typography.displaySmall, color = OnPrimary) | |
| 97 | Spacer(Modifier.height(24.dp)) | |
| 98 | ||
| 99 | LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 100 | // ------------ House screen behavior ----------- | |
| 101 | item { | |
| 102 | SectionHeader( | |
| 103 | "House screens", | |
| 104 | "How this device reacts when house music starts", | |
| 105 | ) | |
| 106 | } | |
| 107 | item { | |
| 108 | val flip by viewModel.kioskFlipEnabled.collectAsState() | |
| 109 | PrivacyToggleRow( | |
| 110 | title = "Enable Faveat Haus automation", | |
| 111 | subtitle = if (flip) | |
| 112 | "Music starting anywhere flips this screen to Now Playing (wall-screen mode)" | |
| 113 | else | |
| 114 | "This device ignores house music events — browse freely (manual mode)", | |
| 115 | enabled = flip, | |
| 116 | onToggle = { viewModel.setKioskFlipEnabled(!flip) }, | |
| 117 | ) | |
| 118 | } | |
| 119 | item { Spacer(Modifier.height(16.dp)) } | |
| 120 | ||
| 121 | // ------------ Now Playing corner button ----------- | |
| 122 | item { | |
| 123 | SectionHeader( | |
| 124 | "Now Playing corner button", | |
| 125 | "What the button beside Cast opens", | |
| 126 | ) | |
| 127 | } | |
| 128 | item { | |
| 129 | RgModeRow( | |
| 130 | label = "Sleep timer", | |
| 131 | description = "Countdown to stop playback", | |
| 132 | selected = !cornerDomEtha, | |
| 133 | onSelect = { viewModel.setCornerButtonDomEtha(false) }, | |
| 134 | ) | |
| 135 | } | |
| 136 | item { | |
| 137 | RgModeRow( | |
| 138 | label = "Dom-Etha panel", | |
| 139 | description = if (domEthaUrl != null) | |
| 140 | "Open the house control panel at ${domEthaUrl}" | |
| 141 | else | |
| 142 | "Open the house control panel — set the address below first", | |
| 143 | selected = cornerDomEtha && domEthaUrl != null, | |
| 144 | onSelect = { | |
| 145 | if (domEthaUrl != null) { | |
| 146 | viewModel.setCornerButtonDomEtha(true) | |
| 147 | } else { | |
| 148 | // No address yet: selecting the option IS the | |
| 149 | // request to enter one. Pop the dialog instead | |
| 150 | // of silently doing nothing. | |
| 151 | enableDomEthaOnSave = true | |
| 152 | showDomEthaUrlDialog = true | |
| 153 | } | |
| 154 | }, | |
| 155 | ) | |
| 156 | } | |
| 157 | item { | |
| 158 | val interaction = remember { MutableInteractionSource() } | |
| 159 | Box( | |
| 160 | Modifier | |
| 161 | .fillMaxWidth() | |
| 162 | .background(SurfaceRaised) | |
| 163 | .staveFocusable(interaction) | |
| 164 | .clickable(interaction, indication = null) { showDomEthaUrlDialog = true } | |
| 165 | .padding(horizontal = 24.dp, vertical = 14.dp) | |
| 166 | ) { | |
| 167 | Column { | |
| 168 | Text("Dom-Etha address", style = MaterialTheme.typography.titleSmall, color = OnPrimary) | |
| 169 | Text( | |
| 170 | domEthaUrl ?: "Not set — tap to enter the panel URL", | |
| 171 | style = MaterialTheme.typography.bodySmall, color = OnMuted, | |
| 172 | ) | |
| 173 | } | |
| 174 | } | |
| 175 | } | |
| 176 | item { Spacer(Modifier.height(16.dp)) } | |
| 177 | ||
| 178 | // ------------ Network renderer ----------- | |
| 179 | item { | |
| 180 | SectionHeader( | |
| 181 | "Network renderer", | |
| 182 | "Play through this device or a remote UPnP renderer on your LAN", | |
| 183 | ) | |
| 184 | } | |
| 185 | item { | |
| 186 | PrivacyToggleRow( | |
| 187 | title = "Broadcast as renderer", | |
| 188 | subtitle = "Let other Earhorn instances (and UPnP control points) " + | |
| 189 | "play music through this device's speakers", | |
| 190 | enabled = rendererBroadcast, | |
| 191 | onToggle = { viewModel.setRendererBroadcast(!rendererBroadcast) }, | |
| 192 | ) | |
| 193 | } | |
| 194 | item { | |
| 195 | val displayName = friendlyName | |
| 196 | ?: "Earhorn on ${android.os.Build.MODEL ?: "Android"}" | |
| 197 | // Audit 2026-09-02: this was the one row on the screen without | |
| 198 | // staveFocusable, so it could not be reached by D-pad on TV. | |
| 199 | val nameInteraction = remember { MutableInteractionSource() } | |
| 200 | Box( | |
| 201 | Modifier | |
| 202 | .fillMaxWidth() | |
| 203 | .background(SurfaceRaised) | |
| 204 | .staveFocusable(nameInteraction) | |
| 205 | .clickable(nameInteraction, indication = null) { showNameDialog = true } | |
| 206 | .padding(horizontal = 24.dp, vertical = 14.dp) | |
| 207 | ) { | |
| 208 | Column { | |
| 209 | Text( | |
| 210 | "Device name", | |
| 211 | style = MaterialTheme.typography.bodyLarge, | |
| 212 | color = OnPrimary, | |
| 213 | ) | |
| 214 | Spacer(Modifier.height(4.dp)) | |
| 215 | Text( | |
| 216 | displayName, | |
| 217 | style = MaterialTheme.typography.bodySmall, | |
| 218 | color = OnMuted, | |
| 219 | ) | |
| 220 | } | |
| 221 | } | |
| 222 | } | |
| 223 | item { | |
| 224 | RgModeRow( | |
| 225 | label = "This device", | |
| 226 | description = "Play through the local speakers", | |
| 227 | selected = activeUdn == null, | |
| 228 | onSelect = { viewModel.setActiveRenderer(null) }, | |
| 229 | ) | |
| 230 | } | |
| 231 | items(renderers, key = { it.udn }) { renderer -> | |
| 232 | RgModeRow( | |
| 233 | label = renderer.friendlyName, | |
| 234 | description = renderer.udn.removePrefix("uuid:").take(8) + "…", | |
| 235 | selected = activeUdn == renderer.udn, | |
| 236 | onSelect = { viewModel.setActiveRenderer(renderer.udn) }, | |
| 237 | ) | |
| 238 | } | |
| 239 | if (renderers.isEmpty()) { | |
| 240 | item { | |
| 241 | Box( | |
| 242 | Modifier | |
| 243 | .fillMaxWidth() | |
| 244 | .background(SurfaceRaised) | |
| 245 | .padding(horizontal = 24.dp, vertical = 14.dp) | |
| 246 | ) { | |
| 247 | Text( | |
| 248 | "No remote renderers discovered yet", | |
| 249 | style = MaterialTheme.typography.bodySmall, | |
| 250 | color = OnMuted, | |
| 251 | ) | |
| 252 | } | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| 256 | // ------------ Volume normalization ------------ | |
| 257 | item { | |
| 258 | Spacer(Modifier.height(24.dp)) | |
| 259 | SectionHeader("Volume normalization", "Apply ReplayGain tags to even out loudness across tracks") | |
| 260 | } | |
| 261 | ||
| 262 | item { | |
| 263 | RgModeRow( | |
| 264 | label = "Off", | |
| 265 | description = "No volume correction", | |
| 266 | selected = rgMode == ReplayGainMode.OFF, | |
| 267 | onSelect = { viewModel.setReplayGainMode(ReplayGainMode.OFF) }, | |
| 268 | ) | |
| 269 | } | |
| 270 | item { | |
| 271 | RgModeRow( | |
| 272 | label = "Track gain", | |
| 273 | description = "Each track normalized individually", | |
| 274 | selected = rgMode == ReplayGainMode.TRACK, | |
| 275 | onSelect = { viewModel.setReplayGainMode(ReplayGainMode.TRACK) }, | |
| 276 | ) | |
| 277 | } | |
| 278 | item { | |
| 279 | RgModeRow( | |
| 280 | label = "Album gain", | |
| 281 | description = "Normalize per album so loudness differences within an album are preserved", | |
| 282 | selected = rgMode == ReplayGainMode.ALBUM, | |
| 283 | onSelect = { viewModel.setReplayGainMode(ReplayGainMode.ALBUM) }, | |
| 284 | ) | |
| 285 | } | |
| 286 | item { | |
| 287 | PreAmpRow( | |
| 288 | preAmpDb = preAmp, | |
| 289 | enabled = rgMode != ReplayGainMode.OFF, | |
| 290 | onDecrement = { | |
| 291 | viewModel.setReplayGainPreAmpDb((preAmp - 0.5f).coerceAtLeast(-15f)) | |
| 292 | }, | |
| 293 | onIncrement = { | |
| 294 | viewModel.setReplayGainPreAmpDb((preAmp + 0.5f).coerceAtMost(15f)) | |
| 295 | }, | |
| 296 | ) | |
| 297 | } | |
| 298 | ||
| 299 | // ------------ Random exclusions ------------ | |
| 300 | item { | |
| 301 | Spacer(Modifier.height(24.dp)) | |
| 302 | SectionHeader( | |
| 303 | "Random exclusions", | |
| 304 | "Genres to skip when picking random albums or tracks", | |
| 305 | ) | |
| 306 | } | |
| 307 | items(COMMON_EXCLUDABLE_GENRES, key = { it }) { genre -> | |
| 308 | GenreToggleRow( | |
| 309 | genre = genre, | |
| 310 | excluded = excludedGenres.any { it.equals(genre, ignoreCase = true) }, | |
| 311 | onToggle = { isExcluded -> | |
| 312 | // Maintainer, 2026-10-29: firstOrNull — a quick double tap could run | |
| 313 | // this after the genre was already removed (first{} threw | |
| 314 | // NoSuchElementException); skip when it's gone. | |
| 315 | if (isExcluded) viewModel.addExcludedGenre(genre) | |
| 316 | else excludedGenres.firstOrNull { it.equals(genre, ignoreCase = true) } | |
| 317 | ?.let { viewModel.removeExcludedGenre(it) } | |
| 318 | }, | |
| 319 | ) | |
| 320 | } | |
| 321 | // Also list any user-specified genres that aren't in the preset list, so | |
| 322 | // they can be removed. | |
| 323 | val extras = excludedGenres.filter { g -> | |
| 324 | COMMON_EXCLUDABLE_GENRES.none { it.equals(g, ignoreCase = true) } | |
| 325 | } | |
| 326 | if (extras.isNotEmpty()) { | |
| 327 | items(extras, key = { "extra:$it" }) { genre -> | |
| 328 | GenreToggleRow( | |
| 329 | genre = genre, | |
| 330 | excluded = true, | |
| 331 | onToggle = { viewModel.removeExcludedGenre(genre) }, | |
| 332 | ) | |
| 333 | } | |
| 334 | } | |
| 335 | item { | |
| 336 | AddGenreRow(onClick = { showAddGenreDialog = true }) | |
| 337 | } | |
| 338 | ||
| 339 | // ------------ Lyrics ------------ | |
| 340 | item { | |
| 341 | Spacer(Modifier.height(24.dp)) | |
| 342 | SectionHeader( | |
| 343 | "Lyrics highlight", | |
| 344 | "How the currently-playing line stands out on the lyrics screen", | |
| 345 | ) | |
| 346 | } | |
| 347 | item { | |
| 348 | HighlightSizeRow( | |
| 349 | multiplier = highlightSize, | |
| 350 | onDecrement = { | |
| 351 | viewModel.setLyricsHighlightSize((highlightSize - 0.1f).coerceAtLeast(1.0f)) | |
| 352 | }, | |
| 353 | onIncrement = { | |
| 354 | viewModel.setLyricsHighlightSize((highlightSize + 0.1f).coerceAtMost(2.5f)) | |
| 355 | }, | |
| 356 | ) | |
| 357 | } | |
| 358 | item { | |
| 359 | PrivacyToggleRow( | |
| 360 | title = "Bold current line", | |
| 361 | subtitle = "Render the currently-playing line in a bold weight", | |
| 362 | enabled = highlightBold, | |
| 363 | onToggle = { viewModel.setLyricsHighlightBold(!highlightBold) }, | |
| 364 | ) | |
| 365 | } | |
| 366 | item { | |
| 367 | HighlightColorRow( | |
| 368 | label = "Amber", | |
| 369 | description = "Earhorn's warm accent — default", | |
| 370 | selected = highlightColor == LyricsHighlightColor.AMBER, | |
| 371 | onSelect = { viewModel.setLyricsHighlightColor(LyricsHighlightColor.AMBER) }, | |
| 372 | ) | |
| 373 | } | |
| 374 | item { | |
| 375 | HighlightColorRow( | |
| 376 | label = "White", | |
| 377 | description = "High contrast, no color tint", | |
| 378 | selected = highlightColor == LyricsHighlightColor.WHITE, | |
| 379 | onSelect = { viewModel.setLyricsHighlightColor(LyricsHighlightColor.WHITE) }, | |
| 380 | ) | |
| 381 | } | |
| 382 | item { | |
| 383 | HighlightColorRow( | |
| 384 | label = "Sage", | |
| 385 | description = "Cooler, softer highlight", | |
| 386 | selected = highlightColor == LyricsHighlightColor.SAGE, | |
| 387 | onSelect = { viewModel.setLyricsHighlightColor(LyricsHighlightColor.SAGE) }, | |
| 388 | ) | |
| 389 | } | |
| 390 | ||
| 391 | // ------------ Privacy ------------ | |
| 392 | item { | |
| 393 | Spacer(Modifier.height(24.dp)) | |
| 394 | SectionHeader( | |
| 395 | "Lyrics source", | |
| 396 | "Control whether Earhorn contacts external services for lyrics", | |
| 397 | ) | |
| 398 | } | |
| 399 | item { | |
| 400 | PrivacyToggleRow( | |
| 401 | title = "Allow online lyrics lookup", | |
| 402 | subtitle = "When off, Earhorn never contacts LRCLIB. Local .lrc files next " + | |
| 403 | "to your audio files still work.", | |
| 404 | enabled = allowExternal, | |
| 405 | onToggle = { viewModel.setAllowExternalCalls(!allowExternal) }, | |
| 406 | ) | |
| 407 | } | |
| 408 | ||
| 409 | // ------------ Display ------------ | |
| 410 | item { | |
| 411 | Spacer(Modifier.height(24.dp)) | |
| 412 | SectionHeader( | |
| 413 | "Display", | |
| 414 | "How text looks across the app", | |
| 415 | ) | |
| 416 | } | |
| 417 | // Three settings that had setters and storage but no switch | |
| 418 | // (design decision 2026-09-02, I.64). | |
| 419 | item { | |
| 420 | PrivacyToggleRow( | |
| 421 | title = "Scroll long titles (marquee)", | |
| 422 | subtitle = "Titles too long for one line scroll sideways instead of ending in \"…\" — Now Playing, the bar, and queue rows.", | |
| 423 | enabled = marqueeOn, | |
| 424 | onToggle = { viewModel.setMarquee(!marqueeOn) }, | |
| 425 | ) | |
| 426 | } | |
| 427 | item { | |
| 428 | PrivacyToggleRow( | |
| 429 | title = "Left-handed album layout", | |
| 430 | subtitle = "On phones, put the album action buttons on the left of the artwork.", | |
| 431 | enabled = leftyOn, | |
| 432 | onToggle = { viewModel.setLeftyMode(!leftyOn) }, | |
| 433 | ) | |
| 434 | } | |
| 435 | item { | |
| 436 | PrivacyToggleRow( | |
| 437 | title = "Lyrics details in the corner", | |
| 438 | subtitle = "Year, format and genre shown in the corner of the lyrics screen on TV.", | |
| 439 | enabled = lyricsMetaOn, | |
| 440 | onToggle = { viewModel.setLyricsMetadata(!lyricsMetaOn) }, | |
| 441 | ) | |
| 442 | } | |
| 443 | item { | |
| 444 | PrivacyToggleRow( | |
| 445 | title = "Use Fraunces serif font", | |
| 446 | subtitle = "Off: render every screen in Inter (sans-serif) for higher " + | |
| 447 | "legibility. Numeric displays — timestamps, durations, track numbers — " + | |
| 448 | "stay in Inter either way.", | |
| 449 | enabled = useFraunces, | |
| 450 | onToggle = { viewModel.setUseFraunces(!useFraunces) }, | |
| 451 | ) | |
| 452 | } | |
| 453 | ||
| 454 | // ------------ Metadata fields ------------ | |
| 455 | item { | |
| 456 | Spacer(Modifier.height(24.dp)) | |
| 457 | SectionHeader( | |
| 458 | "Album detail fields", | |
| 459 | "Which metadata fields appear on the album screen", | |
| 460 | ) | |
| 461 | } | |
| 462 | items(MetadataField.entries.toList(), key = { it.key }) { field -> | |
| 463 | FieldRow( | |
| 464 | field = field, | |
| 465 | enabled = field in enabled, | |
| 466 | onToggle = { viewModel.toggle(field) } | |
| 467 | ) | |
| 468 | } | |
| 469 | } | |
| 470 | } | |
| 471 | ||
| 472 | if (showDomEthaUrlDialog) { | |
| 473 | SystemImeTextDialog( | |
| 474 | title = "Dom-Etha address", | |
| 475 | placeholder = "http://<panel-address>/mediacontrol.html", | |
| 476 | confirmLabel = "Save", | |
| 477 | // Prepopulated with the group panel address (maintainer request, | |
| 478 | // 2026-08-29): first open is save-and-done. Still editable — | |
| 479 | // other installs overtype it; nothing is hardcoded as a value | |
| 480 | // until Save is pressed. | |
| 481 | initialText = domEthaUrl ?: dev.stave.BuildConfig.DOM_ETHA_DEFAULT_URL, | |
| 482 | plainInput = true, | |
| 483 | // Maintainer, 2026-10-29: an empty Save must reach onConfirm so the | |
| 484 | // address can be cleared (cleaned == null below → setDomEthaUrl(null) | |
| 485 | // and the corner button reverts to the sleep timer). | |
| 486 | allowEmpty = true, | |
| 487 | onConfirm = { entered -> | |
| 488 | val cleaned = entered.trim().takeIf { it.isNotEmpty() } | |
| 489 | viewModel.setDomEthaUrl(cleaned) | |
| 490 | when { | |
| 491 | cleaned == null -> viewModel.setCornerButtonDomEtha(false) | |
| 492 | enableDomEthaOnSave -> viewModel.setCornerButtonDomEtha(true) | |
| 493 | } | |
| 494 | enableDomEthaOnSave = false | |
| 495 | showDomEthaUrlDialog = false | |
| 496 | }, | |
| 497 | onDismiss = { | |
| 498 | enableDomEthaOnSave = false | |
| 499 | showDomEthaUrlDialog = false | |
| 500 | }, | |
| 501 | ) | |
| 502 | } | |
| 503 | if (showNameDialog) { | |
| 504 | SystemImeTextDialog( | |
| 505 | title = "Device name", | |
| 506 | placeholder = "Earhorn on ${android.os.Build.MODEL ?: "Android"}", | |
| 507 | confirmLabel = "Save", | |
| 508 | initialText = friendlyName ?: "", | |
| 509 | // Maintainer, 2026-10-29: an empty Save must reach onConfirm so the | |
| 510 | // name resets to the default — setRendererFriendlyName maps a blank | |
| 511 | // name to null, which removes the stored name. | |
| 512 | allowEmpty = true, | |
| 513 | onConfirm = { name -> | |
| 514 | viewModel.setRendererFriendlyName(name) | |
| 515 | showNameDialog = false | |
| 516 | }, | |
| 517 | onDismiss = { showNameDialog = false }, | |
| 518 | ) | |
| 519 | } | |
| 520 | if (showAddGenreDialog) { | |
| 521 | AddGenreDialog( | |
| 522 | onConfirm = { genre -> | |
| 523 | viewModel.addExcludedGenre(genre) | |
| 524 | showAddGenreDialog = false | |
| 525 | }, | |
| 526 | onDismiss = { showAddGenreDialog = false }, | |
| 527 | ) | |
| 528 | } | |
| 529 | } | |
| 530 | ||
| 531 | /** Preset list of genres most users want excluded from shuffle. Toggled via the | |
| 532 | * settings screen. Not exhaustive — just the common cases that caused complaints. */ | |
| 533 | private val COMMON_EXCLUDABLE_GENRES = listOf( | |
| 534 | "Podcast", | |
| 535 | "Audiobook", | |
| 536 | "Comedy", | |
| 537 | "Spoken Word", | |
| 538 | "Interview", | |
| 539 | "Soundtrack", | |
| 540 | ) | |
| 541 | ||
| 542 | @Composable | |
| 543 | private fun SectionHeader(title: String, subtitle: String) { | |
| 544 | Column { | |
| 545 | Text(withInterDigits(title), style = MaterialTheme.typography.titleMedium, color = OnPrimary) | |
| 546 | Text(withInterDigits(subtitle), style = MaterialTheme.typography.bodySmall, color = OnMuted) | |
| 547 | Spacer(Modifier.height(8.dp)) | |
| 548 | } | |
| 549 | } | |
| 550 | ||
| 551 | @Composable | |
| 552 | private fun RgModeRow( | |
| 553 | label: String, | |
| 554 | description: String, | |
| 555 | selected: Boolean, | |
| 556 | onSelect: () -> Unit, | |
| 557 | ) { | |
| 558 | val interaction = remember { MutableInteractionSource() } | |
| 559 | Box( | |
| 560 | Modifier | |
| 561 | .fillMaxWidth() | |
| 562 | .background(SurfaceRaised) | |
| 563 | .staveFocusable(interaction) | |
| 564 | .clickable(interaction, indication = null) { onSelect() } | |
| 565 | .padding(horizontal = 24.dp, vertical = 14.dp) | |
| 566 | ) { | |
| 567 | Row(verticalAlignment = Alignment.CenterVertically) { | |
| 568 | RadioDot(selected = selected) | |
| 569 | Spacer(Modifier.size(16.dp)) | |
| 570 | Column(Modifier.weight(1f)) { | |
| 571 | Text(withInterDigits(label), style = MaterialTheme.typography.titleSmall, color = OnPrimary) | |
| 572 | Text(withInterDigits(description), style = MaterialTheme.typography.bodySmall, color = OnMuted) | |
| 573 | } | |
| 574 | } | |
| 575 | } | |
| 576 | } | |
| 577 | ||
| 578 | /** Radio-row for picking the lyrics highlight color — amber/white/sage. Thin | |
| 579 | * alias over RgModeRow so call sites read naturally. */ | |
| 580 | @Composable | |
| 581 | private fun HighlightColorRow( | |
| 582 | label: String, | |
| 583 | description: String, | |
| 584 | selected: Boolean, | |
| 585 | onSelect: () -> Unit, | |
| 586 | ) = RgModeRow(label, description, selected, onSelect) | |
| 587 | ||
| 588 | /** Stepper row for the lyrics highlight size multiplier. Shows current value | |
| 589 | * formatted as a percentage (150%), with -/+ buttons stepping by 10%. Mirrors | |
| 590 | * the pattern used by PreAmpRow for ReplayGain. */ | |
| 591 | @Composable | |
| 592 | private fun HighlightSizeRow( | |
| 593 | multiplier: Float, | |
| 594 | onDecrement: () -> Unit, | |
| 595 | onIncrement: () -> Unit, | |
| 596 | ) { | |
| 597 | Box( | |
| 598 | Modifier | |
| 599 | .fillMaxWidth() | |
| 600 | .background(SurfaceRaised) | |
| 601 | .padding(horizontal = 24.dp, vertical = 14.dp) | |
| 602 | ) { | |
| 603 | Row(verticalAlignment = Alignment.CenterVertically) { | |
| 604 | Column(Modifier.weight(1f)) { | |
| 605 | Text( | |
| 606 | "Current-line size", | |
| 607 | style = MaterialTheme.typography.titleSmall, | |
| 608 | color = OnPrimary, | |
| 609 | ) | |
| 610 | Text( | |
| 611 | "How much larger the current lyric is than the others", | |
| 612 | style = MaterialTheme.typography.bodySmall, | |
| 613 | color = OnMuted, | |
| 614 | ) | |
| 615 | } | |
| 616 | StepperButton(label = "\u2212", enabled = true, onClick = onDecrement) | |
| 617 | Spacer(Modifier.width(12.dp)) | |
| 618 | Text( | |
| 619 | "${(multiplier * 100).toInt()}%", | |
| 620 | style = MaterialTheme.typography.titleMedium.copy(fontFamily = staveInterFamily()), | |
| 621 | color = OnPrimary, | |
| 622 | modifier = Modifier.width(72.dp), | |
| 623 | ) | |
| 624 | Spacer(Modifier.width(12.dp)) | |
| 625 | StepperButton(label = "+", enabled = true, onClick = onIncrement) | |
| 626 | } | |
| 627 | } | |
| 628 | } | |
| 629 | ||
| 630 | /** Two-line toggle row with a checkmark. Used for every on/off setting on | |
| 631 | * this screen. */ | |
| 632 | @Composable | |
| 633 | private fun PrivacyToggleRow( | |
| 634 | title: String, | |
| 635 | subtitle: String, | |
| 636 | enabled: Boolean, | |
| 637 | onToggle: () -> Unit, | |
| 638 | ) { | |
| 639 | val interaction = remember { MutableInteractionSource() } | |
| 640 | Box( | |
| 641 | Modifier | |
| 642 | .fillMaxWidth() | |
| 643 | .background(SurfaceRaised) | |
| 644 | .staveFocusable(interaction) | |
| 645 | .clickable(interaction, indication = null) { onToggle() } | |
| 646 | .padding(horizontal = 24.dp, vertical = 14.dp) | |
| 647 | ) { | |
| 648 | Row(verticalAlignment = Alignment.CenterVertically) { | |
| 649 | Checkmark(enabled = enabled) | |
| 650 | Spacer(Modifier.size(16.dp)) | |
| 651 | Column(Modifier.weight(1f)) { | |
| 652 | Text(withInterDigits(title), style = MaterialTheme.typography.titleSmall, color = OnPrimary) | |
| 653 | Text(withInterDigits(subtitle), style = MaterialTheme.typography.bodySmall, color = OnMuted) | |
| 654 | } | |
| 655 | } | |
| 656 | } | |
| 657 | } | |
| 658 | ||
| 659 | @Composable | |
| 660 | private fun PreAmpRow( | |
| 661 | preAmpDb: Float, | |
| 662 | enabled: Boolean, | |
| 663 | onDecrement: () -> Unit, | |
| 664 | onIncrement: () -> Unit, | |
| 665 | ) { | |
| 666 | val alpha = if (enabled) 1f else 0.4f | |
| 667 | Box( | |
| 668 | Modifier | |
| 669 | .fillMaxWidth() | |
| 670 | .background(SurfaceRaised) | |
| 671 | .padding(horizontal = 24.dp, vertical = 14.dp) | |
| 672 | ) { | |
| 673 | Row(verticalAlignment = Alignment.CenterVertically) { | |
| 674 | Column(Modifier.weight(1f)) { | |
| 675 | Text( | |
| 676 | "Pre-amp", | |
| 677 | style = MaterialTheme.typography.titleSmall, | |
| 678 | color = if (enabled) OnPrimary else OnMuted, | |
| 679 | ) | |
| 680 | Text( | |
| 681 | "Added to RG-corrected volume. Positive values reclaim headroom.", | |
| 682 | style = MaterialTheme.typography.bodySmall, | |
| 683 | color = OnMuted, | |
| 684 | ) | |
| 685 | } | |
| 686 | StepperButton(label = "\u2212", enabled = enabled, onClick = onDecrement) | |
| 687 | Spacer(Modifier.width(12.dp)) | |
| 688 | Text( | |
| 689 | formatDb(preAmpDb), | |
| 690 | style = MaterialTheme.typography.titleMedium.copy(fontFamily = staveInterFamily()), | |
| 691 | color = if (enabled) OnPrimary else OnMuted, | |
| 692 | modifier = Modifier.width(72.dp), | |
| 693 | ) | |
| 694 | Spacer(Modifier.width(12.dp)) | |
| 695 | StepperButton(label = "+", enabled = enabled, onClick = onIncrement) | |
| 696 | } | |
| 697 | } | |
| 698 | } | |
| 699 | ||
| 700 | @Composable | |
| 701 | private fun StepperButton(label: String, enabled: Boolean, onClick: () -> Unit) { | |
| 702 | val interaction = remember { MutableInteractionSource() } | |
| 703 | Box( | |
| 704 | Modifier | |
| 705 | .size(40.dp) | |
| 706 | .background(if (enabled) SurfaceRaised else SurfaceRaised.copy(alpha = 0.4f)) | |
| 707 | .staveFocusable(interaction, cornerRadiusDp = 4) | |
| 708 | .clickable(interaction, indication = null, enabled = enabled) { onClick() }, | |
| 709 | contentAlignment = Alignment.Center, | |
| 710 | ) { | |
| 711 | Text(withInterDigits(label), style = MaterialTheme.typography.titleMedium, color = OnPrimary) | |
| 712 | } | |
| 713 | } | |
| 714 | ||
| 715 | private fun formatDb(db: Float): String { | |
| 716 | val sign = if (db >= 0) "+" else "" | |
| 717 | return "$sign${"%.1f".format(db)} dB" | |
| 718 | } | |
| 719 | ||
| 720 | @Composable | |
| 721 | private fun GenreToggleRow( | |
| 722 | genre: String, | |
| 723 | excluded: Boolean, | |
| 724 | onToggle: (Boolean) -> Unit, | |
| 725 | ) { | |
| 726 | val interaction = remember { MutableInteractionSource() } | |
| 727 | Box( | |
| 728 | Modifier | |
| 729 | .fillMaxWidth() | |
| 730 | .background(SurfaceRaised) | |
| 731 | .staveFocusable(interaction) | |
| 732 | .clickable(interaction, indication = null) { onToggle(!excluded) } | |
| 733 | .padding(horizontal = 24.dp, vertical = 14.dp) | |
| 734 | ) { | |
| 735 | Row(verticalAlignment = Alignment.CenterVertically) { | |
| 736 | Checkmark(enabled = excluded) | |
| 737 | Spacer(Modifier.size(16.dp)) | |
| 738 | Text(withInterDigits(genre), style = MaterialTheme.typography.titleSmall, color = OnPrimary) | |
| 739 | } | |
| 740 | } | |
| 741 | } | |
| 742 | ||
| 743 | @Composable | |
| 744 | private fun FieldRow(field: MetadataField, enabled: Boolean, onToggle: () -> Unit) { | |
| 745 | val interaction = remember { MutableInteractionSource() } | |
| 746 | Box( | |
| 747 | Modifier | |
| 748 | .fillMaxWidth() | |
| 749 | .background(SurfaceRaised) | |
| 750 | .staveFocusable(interaction) | |
| 751 | .clickable(interaction, indication = null) { onToggle() } | |
| 752 | .padding(horizontal = 24.dp, vertical = 14.dp) | |
| 753 | ) { | |
| 754 | Row(verticalAlignment = Alignment.CenterVertically) { | |
| 755 | Checkmark(enabled = enabled) | |
| 756 | Spacer(Modifier.size(16.dp)) | |
| 757 | Column(Modifier.weight(1f)) { | |
| 758 | Text(withInterDigits(field.label), style = MaterialTheme.typography.titleSmall, color = OnPrimary) | |
| 759 | Text(withInterDigits(field.key), style = MaterialTheme.typography.labelSmall, color = OnQuiet) | |
| 760 | } | |
| 761 | } | |
| 762 | } | |
| 763 | } | |
| 764 | ||
| 765 | @Composable | |
| 766 | private fun Checkmark(enabled: Boolean) { | |
| 767 | Box( | |
| 768 | Modifier | |
| 769 | .size(22.dp) | |
| 770 | .background( | |
| 771 | if (enabled) AccentAmber else AccentAmberSoft, | |
| 772 | RoundedCornerShape(4.dp) | |
| 773 | ) | |
| 774 | .clip(RoundedCornerShape(4.dp)), | |
| 775 | contentAlignment = Alignment.Center | |
| 776 | ) { | |
| 777 | if (enabled) { | |
| 778 | Text("✓", style = MaterialTheme.typography.labelLarge, color = Color.Black) | |
| 779 | } | |
| 780 | } | |
| 781 | } | |
| 782 | ||
| 783 | @Composable | |
| 784 | private fun RadioDot(selected: Boolean) { | |
| 785 | Box( | |
| 786 | Modifier | |
| 787 | .size(22.dp) | |
| 788 | .background( | |
| 789 | if (selected) AccentAmber else AccentAmberSoft, | |
| 790 | RoundedCornerShape(11.dp) | |
| 791 | ), | |
| 792 | contentAlignment = Alignment.Center, | |
| 793 | ) { | |
| 794 | if (selected) { | |
| 795 | Box( | |
| 796 | Modifier | |
| 797 | .size(8.dp) | |
| 798 | .background(Color.Black, RoundedCornerShape(4.dp)) | |
| 799 | ) | |
| 800 | } | |
| 801 | } | |
| 802 | } | |
| 803 | ||
| 804 | /** "Add genre…" button that opens the custom genre dialog. */ | |
| 805 | @Composable | |
| 806 | private fun AddGenreRow(onClick: () -> Unit) { | |
| 807 | val interaction = remember { MutableInteractionSource() } | |
| 808 | Box( | |
| 809 | Modifier | |
| 810 | .fillMaxWidth() | |
| 811 | .background(SurfaceRaised) | |
| 812 | .staveFocusable(interaction, cornerRadiusDp = 4) | |
| 813 | .clickable(interaction, indication = null, onClick = onClick) | |
| 814 | .padding(horizontal = 24.dp, vertical = 14.dp), | |
| 815 | ) { | |
| 816 | Text( | |
| 817 | "+ Add genre…", | |
| 818 | style = MaterialTheme.typography.titleSmall, | |
| 819 | color = AccentAmber, | |
| 820 | ) | |
| 821 | } | |
| 822 | } | |
| 823 | ||
| 824 | /** Modal dialog for entering a custom genre name. Routes through | |
| 825 | * [SystemImeTextDialog] which uses a BasicTextField that summons the | |
| 826 | * system IME. */ | |
| 827 | @Composable | |
| 828 | private fun AddGenreDialog( | |
| 829 | onConfirm: (String) -> Unit, | |
| 830 | onDismiss: () -> Unit, | |
| 831 | ) { | |
| 832 | SystemImeTextDialog( | |
| 833 | title = "Add genre to exclude", | |
| 834 | subtitle = "Type the genre name and press Done.", | |
| 835 | placeholder = "Genre name", | |
| 836 | confirmLabel = "add", | |
| 837 | onConfirm = onConfirm, | |
| 838 | onDismiss = onDismiss, | |
| 839 | ) | |
| 840 | } |


