EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

PlaybackSettingsScreen.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.settings
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.interaction.MutableInteractionSource
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.height
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.size
17import androidx.compose.foundation.layout.width
18import androidx.compose.foundation.lazy.LazyColumn
19import androidx.compose.foundation.lazy.items
20import androidx.compose.foundation.shape.RoundedCornerShape
21import androidx.compose.runtime.Composable
22import androidx.compose.runtime.collectAsState
23import androidx.compose.runtime.getValue
24import androidx.compose.runtime.mutableStateOf
25import androidx.compose.runtime.remember
26import androidx.compose.runtime.setValue
27import androidx.compose.ui.Alignment
28import androidx.compose.ui.Modifier
29import androidx.compose.ui.draw.clip
30import androidx.compose.ui.graphics.Color
31import androidx.compose.ui.unit.dp
32import androidx.hilt.navigation.compose.hiltViewModel
33import androidx.tv.material3.MaterialTheme
34import androidx.tv.material3.Text
35import dev.stave.data.LyricsHighlightColor
36import dev.stave.data.ReplayGainMode
37import dev.stave.domain.model.MetadataField
38import dev.stave.ui.components.SystemImeTextDialog
39import dev.stave.ui.components.staveFocusable
40import dev.stave.ui.theme.AccentAmber
41import dev.stave.ui.theme.AccentAmberSoft
42import dev.stave.ui.theme.OnMuted
43import dev.stave.ui.theme.OnPrimary
44import dev.stave.ui.theme.OnQuiet
45import dev.stave.ui.theme.SurfaceRaised
46import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
47import dev.stave.ui.theme.adaptiveScreenPaddingVertical
48import dev.stave.ui.theme.staveInterFamily
49import 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
60fun 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. */
533private val COMMON_EXCLUDABLE_GENRES = listOf(
534 "Podcast",
535 "Audiobook",
536 "Comedy",
537 "Spoken Word",
538 "Interview",
539 "Soundtrack",
540)
541
542@Composable
543private 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
552private 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
581private 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
592private 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
633private 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
660private 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
701private 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
715private fun formatDb(db: Float): String {
716 val sign = if (db >= 0) "+" else ""
717 return "$sign${"%.1f".format(db)} dB"
718}
719
720@Composable
721private 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
744private 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
766private 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
784private 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
806private 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
828private 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}