From 4b48748b8a56ee6697ab569166a4aca627ec0420 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 19:26:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20the=20console=20can=20finally?= =?UTF-8?q?=20decide=20a=20host's=20clipboard=20and=20profile=20=E2=80=94?= =?UTF-8?q?=20and=20says=20what=20it=20is=20doing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP8.2 and the rest of WP8.7 from the console visual-refresh plan. **The console's Edit Host was missing two decisions entirely.** The touch edit sheet has always offered a shared-clipboard switch and a profile binding; the console form built only name, address, port and MAC. Nothing was ever LOST — `KnownHost.copy` preserved both — but a couch-only user could never CHANGE either, and a TV box has no touch interface to fall back to. Both are now rows in the console form, driven like settings rows: left/right steps them, A flips or cycles. The binding is filtered through the live catalog, so a host bound to a since-deleted profile reads as unset rather than as a name nothing resolves — the same guard the touch sheet applies. "Default settings" leads the ring as the ABSENCE of a binding, not as a fake catalog entry. **Accessibility, finished.** The library's coverflow now says which poster a press acts on — from the art alone a centred cover and a neighbour are indistinguishable, and only the centred one launches. The group heading is a live region: it is the sole signal that the cursor has crossed from the launchers into the games, and a coverflow gives a reader no other way to notice, being one strip rather than two lists. The store badge says why it is there ("Opens Steam" / "From Steam") instead of reading out a bare vendor name after the title. --- .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 3 + .../io/unom/punktfunk/GamepadAddHostScreen.kt | 151 +++++++++++++++++- .../kotlin/io/unom/punktfunk/LibraryScreen.kt | 36 ++++- 3 files changed, 182 insertions(+), 8 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index 799cda15..6c3c0dac 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -1254,6 +1254,9 @@ fun ConnectScreen( editHost = kh, suggestedMacs = suggested, onSave = onSaveHost, + // Shared clipboard and the profile binding — the two host decisions that used to + // exist only in the touch edit sheet, which a TV box has no way to reach. + profiles = profiles, ) } else { EditHostDialog( diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt index f136cb87..b1640ee4 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt @@ -19,6 +19,7 @@ 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.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -30,6 +31,12 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.input.KeyboardType import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource @@ -67,6 +74,20 @@ private const val KB_ROWS = 5 private class Field(val id: String, val label: String, val value: String, val placeholder: String) +/** + * A non-text row of the EDIT form — a switch or a stepped choice, driven like a settings row rather + * than opening the keyboard. Add-host mode has none: they all edit properties a host only has once + * it is saved. + */ +private class ExtraRow( + val label: String, + val value: String, + /** Non-null = draw a [ConsoleSwitch] instead of the value text. */ + val toggled: Boolean?, + val adjust: (Int) -> Unit, + val activate: () -> Unit, +) + @Composable fun GamepadAddHostScreen( onAdd: (name: String, address: String, port: Int) -> Unit, @@ -76,6 +97,11 @@ fun GamepadAddHostScreen( editHost: KnownHost? = null, suggestedMacs: List = emptyList(), onSave: ((KnownHost) -> Unit)? = null, + /** + * The profile catalog, for the edit form's binding row. Empty (the default) simply omits that + * row — which is also what a device with no profiles yet gets. + */ + profiles: List = emptyList(), ) { val ink = LocalGamepadInk.current val context = LocalContext.current @@ -87,6 +113,15 @@ fun GamepadAddHostScreen( var address by remember { mutableStateOf(editHost?.address ?: "") } var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") } var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") } + // The two host properties the console could not reach at all until now. `copy` preserved them, + // so nothing was ever LOST — but a couch-only user (a TV box has no touch interface to fall + // back to) could never decide either one, which the touch edit sheet has always offered. + var clipboard by remember(editHost) { mutableStateOf(editHost?.clipboardSync ?: true) } + // Filtered through the live catalog, so a binding to a since-deleted profile reads as unset + // rather than as a name nothing can resolve — the same guard the touch sheet applies. + var boundId by remember(editHost, profiles) { + mutableStateOf(editHost?.profileId?.takeIf { id -> profiles.any { it.id == id } }) + } val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0 fun commit() { if (isEdit && editHost != null && onSave != null) { @@ -96,6 +131,8 @@ fun GamepadAddHostScreen( address = address.trim(), port = port.toIntOrNull() ?: editHost.port, mac = KnownHostStore.parseMacs(mac), + clipboardSync = clipboard, + profileId = boundId, ), ) } else { @@ -133,7 +170,43 @@ fun GamepadAddHostScreen( add(Field("port", "Port", port, "9777")) if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen")) } - val actionIndex = fields.size // the Save/Add action sits just after the last field + // The switch/choice rows, between the text fields and the action. Only in EDIT mode: both edit + // properties a host only has once it has been saved. + val extras = buildList { + if (isEdit) { + add( + ExtraRow( + label = "Shared clipboard", + value = if (clipboard) "On" else "Off", + toggled = clipboard, + // Directional = state-targeted, so holding a direction can't oscillate — the + // same rule the settings toggles and the pin picker use. + adjust = { d -> clipboard = d > 0 }, + activate = { clipboard = !clipboard }, + ), + ) + if (profiles.isNotEmpty()) { + // "Default settings" is the absence of a binding, not a profile, so it leads the + // ring as a null rather than being faked as an entry in the catalog. + val options = listOf(null) + profiles + val idx = options.indexOfFirst { it?.id == boundId }.coerceAtLeast(0) + fun stepTo(delta: Int) { + val n = ((idx + delta) % options.size + options.size) % options.size + boundId = options[n]?.id + } + add( + ExtraRow( + label = "Profile", + value = options[idx]?.name ?: "Default settings", + toggled = null, + adjust = { d -> stepTo(d) }, + activate = { stepTo(1) }, + ), + ) + } + } + } + val actionIndex = fields.size + extras.size // the Save/Add action sits after everything fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 } fun closeKeyboard() { editing = null } @@ -150,11 +223,13 @@ fun GamepadAddHostScreen( "address" -> c != ' ' else -> true } + /** The focused row's extra, or null when the cursor is on a text field or the action. */ + fun focusedExtra(): ExtraRow? = extras.getOrNull(focus - fields.size) fun activateField() { - if (focus == actionIndex) { - if (canAdd) commit() else { focus = 1; openKeyboard("address") } - } else { - openKeyboard(fields[focus].id) + when { + focus == actionIndex -> if (canAdd) commit() else { focus = 1; openKeyboard("address") } + focus < fields.size -> openKeyboard(fields[focus].id) + else -> focusedExtra()?.activate() } } fun pressKey() { @@ -177,7 +252,11 @@ fun GamepadAddHostScreen( when (dir) { NavDir.UP -> if (focus > 0) focus-- NavDir.DOWN -> if (focus < actionIndex) focus++ - else -> {} + // Left/right step the switch and the profile ring, exactly as they step a + // settings row. On a text field or the action they still do nothing — there is + // no value there to walk. + NavDir.LEFT -> focusedExtra()?.adjust(-1) + NavDir.RIGHT -> focusedExtra()?.adjust(1) } } else { when (dir) { @@ -221,6 +300,7 @@ fun GamepadAddHostScreen( ) { ConsoleHeader(title, horizontalInset = false) fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } } + extras.forEachIndexed { i, e -> ExtraRowView(e, focused = false) { onFieldClick(fields.size + i) } } AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() } Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left } @@ -249,6 +329,11 @@ fun GamepadAddHostScreen( ) } fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } } + extras.forEachIndexed { i, e -> + ExtraRowView(e, focused = focus == fields.size + i && editing == null) { + onFieldClick(fields.size + i) + } + } AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() } Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled } @@ -393,6 +478,60 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () - } } +/** + * A switch or stepped-choice row of the edit form. Deliberately the settings screen's row in + * miniature — same glass, same end-aligned value slot, same `ConsoleSwitch` — because it IS a + * settings row: it edits a stored property with left/right, and a user who has met one has met + * both. + */ +@Composable +private fun ExtraRowView(row: ExtraRow, focused: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current + val visuals = animateConsoleFocus(active = focused) + Row( + modifier = Modifier + .fillMaxWidth() + .consoleGlass(ConsoleShape.Row, visuals) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .semantics(mergeDescendants = true) { + role = if (row.toggled != null) Role.Switch else Role.Button + contentDescription = "${row.label}, ${row.value}" + row.toggled?.let { + toggleableState = if (it) ToggleableState.On else ToggleableState.Off + } + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + row.label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = ink.fg, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + if (row.toggled != null) { + ConsoleSwitch(on = row.toggled, focused = focused) + } else { + Text( + row.value, + style = MaterialTheme.typography.bodyMedium, + color = ink.fg(if (focused) 1f else 0.6f), + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + @Composable private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) { val ink = LocalGamepadInk.current diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index 22b123b5..6dde7adf 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -40,6 +40,11 @@ import androidx.compose.ui.layout.ContentScale import android.content.res.Configuration import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.zIndex import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource @@ -265,7 +270,13 @@ private fun Coverflow( color = ink.fg(0.45f), letterSpacing = 2.sp, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + modifier = Modifier + .fillMaxWidth() + // A live region: this heading is the ONLY signal that the cursor has + // crossed from the launchers into the games, and a coverflow gives a + // reader no other way to notice — it is one strip, not two lists. + .semantics { liveRegion = LiveRegionMode.Polite } + .padding(bottom = 8.dp), ) } HorizontalPager( @@ -287,10 +298,22 @@ private fun Coverflow( .width(coverWidth) .height(coverHeight) // Touch: tap the centred cover to launch it; tap a neighbour to bring it centre. - .clickable { + // The label says which of the two a press does, because from the poster + // alone they are indistinguishable — and the CENTRED one is the only one A + // acts on, which nothing else in the tree says. + .clickable( + onClickLabel = if (page == pagerState.currentPage) { + "Launch ${games[page].title}" + } else { + "Bring ${games[page].title} to the centre" + }, + ) { if (page == pagerState.currentPage) onLaunch(games[page]) else scope.launch { pagerState.animateScrollToPage(page) } } + .semantics { + if (page == pagerState.currentPage) selected = true + } .graphicsLayer { // Centre at full size; EVERY neighbour settles to one size, so an even pitch // yields even VISUAL gaps. (A progressive shrink made the outer gaps grow — @@ -390,6 +413,15 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo // a plain dark wash over its own art. color = if (game.isLauncher) ink.onAccent else Color.White, modifier = Modifier + // A bare store name read out after the title says nothing about WHY it is + // there; the poster's own description already carries the title. + .semantics { + contentDescription = if (game.isLauncher) { + "Opens ${game.storeLabel}" + } else { + "From ${game.storeLabel}" + } + } .clip(ConsoleShape.Pill) .background( // The console's palette accent, not `MaterialTheme.colorScheme.primary` —