Let the brightness sliders stand up as a bank of faders

949a2ee · Gabriel Morell · 2026-09-18 04:04

15 files +269 -51
Message
{commit_body(@commit)}

Files changed

modified android/README.md
+2 −0
@@ -35,11 +35,13 @@ android/
35 35 LambentSocket.kt # the socket, four channels, all commands
36 36 Models.kt # Device / Machine / Link / Service + parsing
37 37 BaseUrlStore.kt # where the box lives
38 + UiPrefs.kt # layout the person picked, e.g. upright faders
38 39 ui/
39 40 ConnectScreen.kt # QR scan or type an address
40 41 DevicesScreen.kt # rename / place / order / poke / find-by-IP
41 42 MachinesScreen.kt # speed, brightness, persist, quit, previews, library
42 43 LinksScreen.kt # toggle / persist / quit, create with channel
44 + BrightnessScreen.kt # global + per-place clamps, rows or upright faders
43 45 ServicesScreen.kt # list + delete, and forget-this-box
44 46 theme/ # the afterdark palette and TF2 Build
45 47 res/font/tf2build.ttf # converted from assets/css/fonts/TF2Build.woff
modified android/app/src/main/java/org/neiam/lambent/app/MainActivity.kt
+5 −3
@@ -37,6 +37,7 @@ import androidx.compose.runtime.LaunchedEffect
37 37 import org.neiam.lambent.app.data.BaseUrlStore
38 38 import org.neiam.lambent.app.data.ConnectionState
39 39 import org.neiam.lambent.app.data.LambentSocket
40 +import org.neiam.lambent.app.data.UiPrefs
40 41 import org.neiam.lambent.app.ui.BrightnessScreen
41 42 import org.neiam.lambent.app.ui.ConnectScreen
42 43 import org.neiam.lambent.app.ui.DevicesScreen
@@ -51,11 +52,12 @@ class MainActivity : ComponentActivity() {
51 52 enableEdgeToEdge()
52 53
53 54 val store = BaseUrlStore(this)
55 + val prefs = UiPrefs(this)
54 56
55 57 setContent {
56 58 LambentTheme {
57 59 Surface(modifier = Modifier.fillMaxSize()) {
58 − App(store)
60 + App(store, prefs)
59 61 }
60 62 }
61 63 }
@@ -77,7 +79,7 @@ private enum class Tab(val label: String, val icon: ImageVector) {
77 79
78 80 @OptIn(ExperimentalMaterial3Api::class)
79 81 @Composable
80 −private fun App(store: BaseUrlStore) {
82 +private fun App(store: BaseUrlStore, prefs: UiPrefs) {
81 83 var baseUrl by remember { mutableStateOf(store.baseUrl) }
82 84 val state by LambentSocket.state.collectAsStateWithLifecycle()
83 85 val snackbar = remember { SnackbarHostState() }
@@ -147,7 +149,7 @@ private fun App(store: BaseUrlStore) {
147 149 Tab.Devices -> DevicesScreen()
148 150 Tab.Machines -> MachinesScreen()
149 151 Tab.Links -> LinksScreen()
150 − Tab.Brightness -> BrightnessScreen()
152 + Tab.Brightness -> BrightnessScreen(prefs)
151 153 Tab.Services -> ServicesScreen(
152 154 baseUrl = baseUrl.orEmpty(),
153 155 onForget = {
added android/app/src/main/java/org/neiam/lambent/app/data/UiPrefs.kt
+20 −0
@@ -0,0 +1,20 @@
1 +package org.neiam.lambent.app.data
2 +
3 +import android.content.Context
4 +
5 +/**
6 + * Small bits of layout the person chose and expects to still be there next
7 + * time. Same prefs file as [BaseUrlStore]; none of it is worth protecting.
8 + */
9 +class UiPrefs(context: Context) {
10 + private val prefs = context.getSharedPreferences("lambent_prefs", Context.MODE_PRIVATE)
11 +
12 + /** brightness sliders as a row of upright faders rather than stacked rows */
13 + var verticalClamps: Boolean
14 + get() = prefs.getBoolean(KEY_VERTICAL_CLAMPS, false)
15 + set(value) = prefs.edit().putBoolean(KEY_VERTICAL_CLAMPS, value).apply()
16 +
17 + companion object {
18 + private const val KEY_VERTICAL_CLAMPS = "brightness_vertical"
19 + }
20 +}
modified android/app/src/main/java/org/neiam/lambent/app/ui/BrightnessScreen.kt
+242 −48
@@ -1,21 +1,28 @@
1 1 package org.neiam.lambent.app.ui
2 2
3 3 import androidx.compose.foundation.layout.Arrangement
4 +import androidx.compose.foundation.layout.Box
4 5 import androidx.compose.foundation.layout.Column
5 6 import androidx.compose.foundation.layout.PaddingValues
6 7 import androidx.compose.foundation.layout.Row
8 +import androidx.compose.foundation.layout.fillMaxSize
7 9 import androidx.compose.foundation.layout.fillMaxWidth
10 +import androidx.compose.foundation.layout.height
8 11 import androidx.compose.foundation.layout.padding
12 +import androidx.compose.foundation.layout.width
9 13 import androidx.compose.foundation.lazy.LazyColumn
14 +import androidx.compose.foundation.lazy.LazyRow
10 15 import androidx.compose.foundation.lazy.items
11 16 import androidx.compose.material.icons.Icons
12 17 import androidx.compose.material.icons.filled.Close
13 18 import androidx.compose.material.icons.filled.LightMode
19 +import androidx.compose.material.icons.filled.SwapVert
14 20 import androidx.compose.material.icons.outlined.LightMode
15 21 import androidx.compose.material3.AssistChip
16 22 import androidx.compose.material3.Card
17 23 import androidx.compose.material3.DropdownMenu
18 24 import androidx.compose.material3.DropdownMenuItem
25 +import androidx.compose.material3.FilterChip
19 26 import androidx.compose.material3.Icon
20 27 import androidx.compose.material3.IconButton
21 28 import androidx.compose.material3.LinearProgressIndicator
@@ -30,29 +37,48 @@ import androidx.compose.runtime.remember
30 37 import androidx.compose.runtime.setValue
31 38 import androidx.compose.ui.Alignment
32 39 import androidx.compose.ui.Modifier
40 +import androidx.compose.ui.draw.rotate
41 +import androidx.compose.ui.layout.layout
42 +import androidx.compose.ui.text.style.TextAlign
43 +import androidx.compose.ui.text.style.TextOverflow
44 +import androidx.compose.ui.unit.Constraints
45 +import androidx.compose.ui.unit.Dp
33 46 import androidx.compose.ui.unit.dp
34 47 import androidx.lifecycle.compose.collectAsStateWithLifecycle
35 48 import org.neiam.lambent.app.data.Clamp
36 49 import org.neiam.lambent.app.data.LambentSocket
37 50 import org.neiam.lambent.app.data.Named
51 +import org.neiam.lambent.app.data.UiPrefs
38 52 import org.neiam.lambent.app.data.brightnessPercent
39 53 import org.neiam.lambent.app.data.ladderIndex
40 54 import org.neiam.lambent.app.data.ladderPosition
41 55
56 +/** how tall an upright fader's travel is */
57 +private val FADER_LENGTH = 220.dp
58 +
59 +/** narrow enough that a handful of places fit on a phone at once */
60 +private val FADER_WIDTH = 104.dp
61 +
42 62 /**
43 63 * The global clamp and one row per place, mirroring /cfg/brightness. A clamp
44 64 * is a ceiling over what the machines produce, not a level, and it walks to
45 65 * its target rather than snapping — so each row shows where it is headed and
46 66 * where it has actually got to.
67 + *
68 + * The toggle swaps the sliders between stacked horizontal rows and a
69 + * side-scrolling bank of upright faders, which is how a lighting desk puts
70 + * them and the only way to reach several at once with a thumb.
47 71 */
48 72 @Composable
49 −fun BrightnessScreen() {
73 +fun BrightnessScreen(prefs: UiPrefs) {
50 74 val global by LambentSocket.globalClamp.collectAsStateWithLifecycle()
51 75 val clamps by LambentSocket.placeClamps.collectAsStateWithLifecycle()
52 76 val devices by LambentSocket.devices.collectAsStateWithLifecycle()
53 77 val ladder by LambentSocket.ladder.collectAsStateWithLifecycle()
54 78 val fades by LambentSocket.fades.collectAsStateWithLifecycle()
55 79
80 + var vertical by remember { mutableStateOf(prefs.verticalClamps) }
81 +
56 82 // every place a device claims, plus any carrying a clamp with no device
57 83 // answering right now
58 84 val places = remember(devices, clamps) {
@@ -61,51 +87,107 @@ fun BrightnessScreen() {
61 87
62 88 val defaultRate = remember(fades) { fades.firstOrNull { it.name == "TWOMINS" }?.value ?: 0 }
63 89
64 − LazyColumn(
65 − contentPadding = PaddingValues(12.dp),
66 − verticalArrangement = Arrangement.spacedBy(8.dp),
67 − ) {
68 − item {
90 + fun clampFor(place: String) = clamps[place] ?: Clamp.unclamped(place, defaultRate)
91 +
92 + fun clearFor(place: String): (() -> Unit)? =
93 + if (clamps.containsKey(place)) {
94 + { LambentSocket.clearClamp(place) }
95 + } else {
96 + null
97 + }
98 +
99 + Column(modifier = Modifier.fillMaxSize()) {
100 + Row(
101 + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, top = 12.dp, end = 12.dp),
102 + verticalAlignment = Alignment.CenterVertically,
103 + ) {
69 104 Text(
70 105 "A clamp is a ceiling over everything underneath it. The global one scales " +
71 106 "every machine; a place clamp scales whatever reaches a device in that place.",
72 107 style = MaterialTheme.typography.labelSmall,
108 + modifier = Modifier.weight(1f),
73 109 )
74 − }
75 −
76 − item {
77 − ClampCard(
78 − title = "Global",
79 − subtitle = "${devices.size} devices",
80 − clamp = global,
81 − ladder = ladder,
82 − fades = fades,
110 + FilterChip(
111 + selected = vertical,
112 + onClick = {
113 + vertical = !vertical
114 + prefs.verticalClamps = vertical
115 + },
116 + label = { Text(if (vertical) "Faders" else "Rows") },
117 + leadingIcon = { Icon(Icons.Filled.SwapVert, contentDescription = null) },
118 + modifier = Modifier.padding(start = 8.dp),
83 119 )
84 120 }
85 121
86 − if (places.isEmpty()) {
87 − item {
122 + if (vertical) {
123 + if (places.isEmpty()) {
88 124 Text(
89 125 "No places yet. Give a device a place and it shows up here.",
90 126 style = MaterialTheme.typography.bodyMedium,
127 + modifier = Modifier.padding(horizontal = 12.dp),
91 128 )
92 129 }
93 − }
94 130
95 − items(places, key = { it }) { place ->
96 − val clamp = clamps[place] ?: Clamp.unclamped(place, defaultRate)
97 − ClampCard(
98 − title = place,
99 − subtitle = "${devices.values.count { it.place == place }} devices",
100 − clamp = clamp,
101 − ladder = ladder,
102 − fades = fades,
103 − onClear = if (clamps.containsKey(place)) {
104 − { LambentSocket.clearClamp(place) }
105 − } else {
106 − null
107 − },
108 − )
131 + LazyRow(
132 + contentPadding = PaddingValues(12.dp),
133 + horizontalArrangement = Arrangement.spacedBy(8.dp),
134 + ) {
135 + item {
136 + FaderCard(
137 + title = "Global",
138 + subtitle = "${devices.size} devices",
139 + clamp = global,
140 + ladder = ladder,
141 + fades = fades,
142 + )
143 + }
144 +
145 + items(places, key = { it }) { place ->
146 + FaderCard(
147 + title = place,
148 + subtitle = "${devices.values.count { it.place == place }} devices",
149 + clamp = clampFor(place),
150 + ladder = ladder,
151 + fades = fades,
152 + onClear = clearFor(place),
153 + )
154 + }
155 + }
156 + } else {
157 + LazyColumn(
158 + contentPadding = PaddingValues(12.dp),
159 + verticalArrangement = Arrangement.spacedBy(8.dp),
160 + ) {
161 + item {
162 + ClampCard(
163 + title = "Global",
164 + subtitle = "${devices.size} devices",
165 + clamp = global,
166 + ladder = ladder,
167 + fades = fades,
168 + )
169 + }
170 +
171 + if (places.isEmpty()) {
172 + item {
173 + Text(
174 + "No places yet. Give a device a place and it shows up here.",
175 + style = MaterialTheme.typography.bodyMedium,
176 + )
177 + }
178 + }
179 +
180 + items(places, key = { it }) { place ->
181 + ClampCard(
182 + title = place,
183 + subtitle = "${devices.values.count { it.place == place }} devices",
184 + clamp = clampFor(place),
185 + ladder = ladder,
186 + fades = fades,
187 + onClear = clearFor(place),
188 + )
189 + }
190 + }
109 191 }
110 192 }
111 193 }
@@ -127,15 +209,7 @@ private fun ClampCard(
127 209 Row(verticalAlignment = Alignment.CenterVertically) {
128 210 Column(modifier = Modifier.weight(1f)) {
129 211 Text(title, style = MaterialTheme.typography.titleMedium)
130 − Text(
131 − if (clamp.walking) {
132 − "${brightnessPercent(clamp.current)}% → " +
133 − "${brightnessPercent(clamp.target)}% · $subtitle"
134 − } else {
135 − "${brightnessPercent(clamp.current)}% · $subtitle"
136 − },
137 − style = MaterialTheme.typography.labelSmall,
138 − )
212 + Text(walkLabel(clamp, subtitle), style = MaterialTheme.typography.labelSmall)
139 213 }
140 214
141 215 IconButton(onClick = { LambentSocket.clampStep(clamp.place, up = false) }) {
@@ -161,9 +235,7 @@ private fun ClampCard(
161 235 // where the walk has actually got to, on the same scale as the
162 236 // slider above it
163 237 LinearProgressIndicator(
164 − progress = {
165 − ladderPosition(clamp.current, ladder) / (ladder.size - 1)
166 − },
238 + progress = { ladderProgress(clamp, ladder) },
167 239 modifier = Modifier.fillMaxWidth(),
168 240 )
169 241 }
@@ -173,13 +245,92 @@ private fun ClampCard(
173 245 }
174 246 }
175 247
248 +/**
249 + * The same controls stood on end: brighter at the top, the name at the bottom
250 + * where a desk labels its channels, and the whole card narrow enough that
251 + * several sit side by side under a thumb.
252 + */
253 +@Composable
254 +private fun FaderCard(
255 + title: String,
256 + subtitle: String,
257 + clamp: Clamp,
258 + ladder: List<Named>,
259 + fades: List<Named>,
260 + onClear: (() -> Unit)? = null,
261 +) {
262 + Card(modifier = Modifier.width(FADER_WIDTH)) {
263 + Column(
264 + modifier = Modifier.padding(8.dp).fillMaxWidth(),
265 + horizontalAlignment = Alignment.CenterHorizontally,
266 + verticalArrangement = Arrangement.spacedBy(4.dp),
267 + ) {
268 + IconButton(onClick = { LambentSocket.clampStep(clamp.place, up = true) }) {
269 + Icon(Icons.Filled.LightMode, contentDescription = "Brighter")
270 + }
271 +
272 + if (ladder.size > 1) {
273 + Row(
274 + modifier = Modifier.height(FADER_LENGTH),
275 + verticalAlignment = Alignment.CenterVertically,
276 + horizontalArrangement = Arrangement.spacedBy(4.dp),
277 + ) {
278 + RungSlider(
279 + clamp = clamp,
280 + ladder = ladder,
281 + modifier = Modifier.upright(FADER_LENGTH),
282 + )
283 +
284 + // the walk's actual position, on the fader's own scale
285 + LinearProgressIndicator(
286 + progress = { ladderProgress(clamp, ladder) },
287 + modifier = Modifier.upright(FADER_LENGTH),
288 + )
289 + }
290 + } else {
291 + Box(modifier = Modifier.height(FADER_LENGTH))
292 + }
293 +
294 + IconButton(onClick = { LambentSocket.clampStep(clamp.place, up = false) }) {
295 + Icon(Icons.Outlined.LightMode, contentDescription = "Dimmer")
296 + }
297 +
298 + FadeChip(clamp = clamp, fades = fades, compact = true)
299 +
300 + Text(
301 + title,
302 + style = MaterialTheme.typography.titleSmall,
303 + maxLines = 1,
304 + overflow = TextOverflow.Ellipsis,
305 + textAlign = TextAlign.Center,
306 + )
307 + Text(
308 + walkLabel(clamp, subtitle),
309 + style = MaterialTheme.typography.labelSmall,
310 + maxLines = 2,
311 + textAlign = TextAlign.Center,
312 + )
313 +
314 + if (onClear != null) {
315 + IconButton(onClick = onClear) {
316 + Icon(
317 + Icons.Filled.Close,
318 + contentDescription = "Drop this clamp",
319 + tint = MaterialTheme.colorScheme.error,
320 + )
321 + }
322 + }
323 + }
324 + }
325 +}
326 +
176 327 /**
177 328 * The target, which can only land on one of the named rungs. The rungs are
178 329 * bunched into the bottom of 0..255, so the slider runs over their positions
179 330 * and gives them even spacing.
180 331 */
181 332 @Composable
182 −private fun RungSlider(clamp: Clamp, ladder: List<Named>) {
333 +private fun RungSlider(clamp: Clamp, ladder: List<Named>, modifier: Modifier = Modifier) {
183 334 val last = ladder.size - 1
184 335 var position by remember { mutableStateOf(ladderIndex(clamp.target, ladder).toFloat()) }
185 336
@@ -197,16 +348,31 @@ private fun RungSlider(clamp: Clamp, ladder: List<Named>) {
197 348 },
198 349 valueRange = 0f..last.toFloat(),
199 350 steps = (last - 1).coerceAtLeast(0),
351 + modifier = modifier,
200 352 )
201 353 }
202 354
203 355 @Composable
204 −private fun FadeChip(clamp: Clamp, fades: List<Named>) {
356 +private fun FadeChip(clamp: Clamp, fades: List<Named>, compact: Boolean = false) {
205 357 var open by remember { mutableStateOf(false) }
206 − val label = fades.firstOrNull { it.value == clamp.rate }?.name ?: "${clamp.rate}ms"
358 + val name = fades.firstOrNull { it.value == clamp.rate }?.name ?: "${clamp.rate}ms"
207 359
208 360 Column {
209 − AssistChip(onClick = { open = true }, label = { Text("fade $label") })
361 + AssistChip(
362 + onClick = { open = true },
363 + label = {
364 + Text(
365 + if (compact) name else "fade $name",
366 + maxLines = 1,
367 + overflow = TextOverflow.Ellipsis,
368 + style = if (compact) {
369 + MaterialTheme.typography.labelSmall
370 + } else {
371 + MaterialTheme.typography.labelLarge
372 + },
373 + )
374 + },
375 + )
210 376 DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
211 377 fades.forEach { fade ->
212 378 DropdownMenuItem(
@@ -220,3 +386,31 @@ private fun FadeChip(clamp: Clamp, fades: List<Named>) {
220 386 }
221 387 }
222 388 }
389 +
390 +private fun walkLabel(clamp: Clamp, subtitle: String): String =
391 + if (clamp.walking) {
392 + "${brightnessPercent(clamp.current)}% → ${brightnessPercent(clamp.target)}% · $subtitle"
393 + } else {
394 + "${brightnessPercent(clamp.current)}% · $subtitle"
395 + }
396 +
397 +private fun ladderProgress(clamp: Clamp, ladder: List<Named>): Float =
398 + ladderPosition(clamp.current, ladder) / (ladder.size - 1)
399 +
400 +/**
401 + * Stands a horizontal control on end: it is measured at [length] wide as it
402 + * would be lying down, reports itself to the parent with those dimensions
403 + * swapped, and is drawn — and dragged — rotated so low sits at the bottom.
404 + */
405 +private fun Modifier.upright(length: Dp): Modifier = this
406 + .layout { measurable, _ ->
407 + val px = length.roundToPx()
408 + val placeable = measurable.measure(Constraints(minWidth = px, maxWidth = px))
409 + layout(placeable.height, placeable.width) {
410 + placeable.place(
411 + x = -(placeable.width / 2 - placeable.height / 2),
412 + y = -(placeable.height / 2 - placeable.width / 2),
413 + )
414 + }
415 + }
416 + .rotate(-90f)

Parents: 6478ec8