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

RandomPickerScreen.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.random
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.Image
5import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
7import androidx.compose.foundation.interaction.MutableInteractionSource
8import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.Spacer
13import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.height
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.rememberScrollState
19import androidx.compose.foundation.verticalScroll
20import androidx.compose.runtime.Composable
21import androidx.compose.runtime.remember
22import androidx.compose.ui.Alignment
23import androidx.compose.ui.Modifier
24import androidx.compose.ui.res.painterResource
25import androidx.compose.ui.unit.dp
26import androidx.tv.material3.MaterialTheme
27import androidx.tv.material3.Text
28import dev.stave.ui.components.staveFocusable
29import dev.stave.ui.theme.OnMuted
30import dev.stave.ui.theme.OnPrimary
31import dev.stave.ui.theme.SurfaceRaised
32import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
33import dev.stave.ui.theme.adaptiveScreenPaddingVertical
34import dev.stave.ui.theme.isCompactHeight
35import dev.stave.ui.theme.staveInterFamily
36import dev.stave.ui.theme.withInterDigits
37
38private val CHOICES = listOf(10, 25, 50, 100)
39
40/**
41 * Picker for the "Random" feature. User picks a count and a type (albums or tracks).
42 * Works with both UPnP servers and local files — whichever source is active.
43 */
44@Composable
45fun RandomPickerScreen(
46 onPickAlbums: (count: Int) -> Unit,
47 onPickTracks: (count: Int) -> Unit,
48 onBack: () -> Unit,
49) {
50 BackHandler(onBack = onBack)
51
52 // Maintainer, 2026-10-29: phone landscape (~245dp usable) showed only the first count
53 // button per column. On compact height the screen scrolls and the header spacing
54 // tightens; TV keeps the non-scrolling layout (its weight spacer pins the gramophone
55 // to the bottom, which a scrolling Column would ignore).
56 val compactHeight = isCompactHeight()
57 val scroll = rememberScrollState()
58 Column(
59 Modifier
60 .fillMaxSize()
61 .then(if (compactHeight) Modifier.verticalScroll(scroll) else Modifier)
62 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
63 ) {
64 Text("Random", style = MaterialTheme.typography.displaySmall, color = OnPrimary)
65 Text(
66 "Pick a count to pull a random sample from the library.",
67 style = MaterialTheme.typography.bodyMedium,
68 color = OnMuted,
69 )
70
71 // Maintainer, 2026-10-29: 12dp on compact height (phone landscape), 28dp elsewhere.
72 Spacer(Modifier.height(if (compactHeight) 12.dp else 28.dp))
73
74 // Side-by-side columns. On phone portrait the 16dp gap and
75 // 1.5f weight on the left column prevent text clipping.
76 val compact = dev.stave.ui.theme.isCompactWidth()
77 Row(
78 Modifier.fillMaxWidth(),
79 horizontalArrangement = Arrangement.spacedBy(if (compact) 16.dp else 40.dp),
80 ) {
81 PickerColumn(
82 modifier = Modifier.weight(if (compact) 1.5f else 1f),
83 header = "Albums",
84 subtitle = "Browse a\nshelf of\nrandom albums",
85 onPick = onPickAlbums,
86 )
87 PickerColumn(
88 modifier = Modifier.weight(1f),
89 header = "Tracks",
90 subtitle = "Queue up\nand play\nimmediately",
91 onPick = onPickTracks,
92 )
93 }
94
95 Spacer(Modifier.weight(1f))
96 Spacer(Modifier.height(32.dp))
97
98 // Gramophone branding at the bottom, matching the Ready to Party dialog
99 Box(
100 modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
101 contentAlignment = Alignment.Center,
102 ) {
103 // ╔══════════════════════════════════════════════════════════════╗
104 // ║ DO NOT TOUCH THIS GRAMOPHONE. ║
105 // ║ ║
106 // ║ This AsyncImage — the drawable reference, the size, the ║
107 // ║ contentDescription, the surrounding Box — is the canonical ║
108 // ║ reference rendering of the glowing gramophone. the maintainer has ║
109 // ║ declared it perfect and will make whoever touches it pay a ║
110 // ║ severe price. That applies to humans, agents, refactors, ║
111 // ║ IDE auto-import cleanups, "while I was nearby" tidy-ups, ║
112 // ║ and anything else that looks like a casual edit. ║
113 // ║ ║
114 // ║ Do not change the drawable. Do not resize. Do not wrap it. ║
115 // ║ Do not "upgrade" it to a higher-resolution variant. Do not ║
116 // ║ layer effects on top. Do not move it. Do not rename it. ║
117 // ║ If you think this needs to change, you are wrong, and you ║
118 // ║ need to ask the maintainer first. ║
119 // ╚══════════════════════════════════════════════════════════════╝
120 coil3.compose.AsyncImage(
121 model = dev.stave.R.drawable.earhorn_gramophone_glow,
122 contentDescription = null,
123 modifier = Modifier.size(160.dp),
124 )
125 }
126 }
127}
128
129@Composable
130private fun PickerColumn(
131 modifier: Modifier = Modifier,
132 header: String,
133 subtitle: String,
134 onPick: (count: Int) -> Unit,
135) {
136 Column(
137 modifier,
138 verticalArrangement = Arrangement.spacedBy(8.dp),
139 ) {
140 Text(withInterDigits(header), style = MaterialTheme.typography.titleLarge, color = OnPrimary)
141 // Maintainer, 2026-10-29: on compact height the subtitle drops minLines 3→1 and its
142 // hard line breaks become spaces (otherwise the "\n"s still force 3 lines); the
143 // landscape columns are wide enough for one line. Other sizes unchanged.
144 val compactHeight = isCompactHeight()
145 Text(
146 if (compactHeight) subtitle.replace('\n', ' ') else subtitle,
147 style = MaterialTheme.typography.bodySmall,
148 color = OnMuted,
149 minLines = if (compactHeight) 1 else 3,
150 maxLines = 3,
151 )
152 Spacer(Modifier.height(4.dp))
153 CHOICES.forEach { count ->
154 CountButton(count) { onPick(count) }
155 }
156 }
157}
158
159@Composable
160private fun CountButton(count: Int, onClick: () -> Unit) {
161 val interaction = remember { MutableInteractionSource() }
162 Box(
163 Modifier
164 .fillMaxWidth()
165 .background(SurfaceRaised)
166 .staveFocusable(interaction, cornerRadiusDp = 6)
167 .clickable(interaction, indication = null) { onClick() }
168 .padding(horizontal = 20.dp, vertical = 12.dp),
169 contentAlignment = Alignment.CenterStart,
170 ) {
171 Text(
172 count.toString(),
173 style = MaterialTheme.typography.titleMedium.copy(fontFamily = staveInterFamily()),
174 color = OnPrimary,
175 )
176 }
177}