feat(clients/android): custom resolutions in both settings UIs

The Settings model always carried arbitrary width/height (0 = native) end to
end — only the two UIs' preset lists gated it. Mirror the Apple client's
pattern: the touch Resolution dropdown gains a "Custom…" row revealing numeric
W×H fields (seeded from the current effective size, coerced even on commit,
capped at 8192 — the host's encode.rs stays the validation authority), and the
gamepad screen surfaces a stored custom size as a leading "Custom · W × H"
choice instead of silently snapping it to Native — a pad can keep a custom
size, it just can't type one. Custom-ness is detected from the stored size
itself (no persisted flag), so it can never disagree with what's saved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 22:18:21 +02:00
parent 3952672873
commit 03a2fabff1
3 changed files with 78 additions and 5 deletions
@@ -297,8 +297,17 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
return listOf(
choice(
"resolution", "Stream", "Resolution",
"The host creates a virtual display at exactly this size — no scaling.",
RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl }, s.width to s.height,
"The host creates a virtual display at exactly this size — no scaling. " +
"Custom sizes are typed in the touch settings.",
// A custom size (typed in the touch settings) leads the list so it stays visible and
// selectable here instead of being silently snapped to Native — a pad can keep a
// custom size, it just can't type one.
(if (s.isCustomResolution()) {
listOf((s.width to s.height) to "Custom · ${s.width} × ${s.height}")
} else {
emptyList()
}) + RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl },
s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) },
choice(
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
@@ -253,6 +253,12 @@ val RESOLUTION_OPTIONS = listOf(
Triple(3840, 2160, "3840 × 2160"),
)
/** True when the stored size is none of the [RESOLUTION_OPTIONS] presets — a custom resolution
* typed in the touch settings. Detected from the size itself rather than a persisted flag, so it
* can never disagree with what's actually stored (mirrors the Apple client). */
fun Settings.isCustomResolution(): Boolean =
RESOLUTION_OPTIONS.none { (w, h, _) -> w == width && h == height }
/** (hz, label). `0` = native refresh. */
val REFRESH_OPTIONS = listOf(
0 to "Native",
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -60,9 +61,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import io.unom.punktfunk.kit.VideoDecoders
@@ -284,12 +287,40 @@ private fun CategoryDetail(
@Composable
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
val (nw, nh, nhz) = nativeDisplayMode(context)
// "Custom…" picked while the stored size is still a preset — keeps the size fields visible
// until an edit actually makes it custom (or a preset is re-picked). Custom itself is detected
// from the stored size, never flagged (see [isCustomResolution]), so nothing new persists.
var customPicked by remember { mutableStateOf(false) }
val showCustom = customPicked || s.isCustomResolution()
SettingsCard {
SettingDropdown(
label = "Resolution",
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) },
selected = s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) }
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } +
// The (-1, -1) sentinel can't collide with a real size; once a custom size is
// stored its label carries the live value, like the native row carries ($nw × $nh).
((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"),
selected = if (showCustom) -1 to -1 else s.width to s.height,
) { (w, h) ->
if (w < 0) {
// Seed from the current *effective* size so the fields start from something
// sensible (the resolved native mode, not the 0 × 0 placeholder).
customPicked = true
update(s.copy(width = if (s.width > 0) s.width else nw, height = if (s.height > 0) s.height else nh))
} else {
customPicked = false
update(s.copy(width = w, height = h))
}
}
if (showCustom) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ResolutionField(label = "Width", value = s.width, modifier = Modifier.weight(1f)) { w ->
update(s.copy(width = w))
}
ResolutionField(label = "Height", value = s.height, modifier = Modifier.weight(1f)) { h ->
update(s.copy(height = h))
}
}
}
SettingDropdown(
label = "Refresh rate",
@@ -535,3 +566,30 @@ private fun <T> SettingDropdown(
}
}
}
/** One side of a custom resolution. Digits only; every usable keystroke commits — coerced even
* (encoders reject odd dimensions) and capped at 8192, the HEVC/AV1 per-side ceiling (the host
* clamps H.264's tighter 4096 itself) — while the field keeps the raw text so intermediate states
* ("15" on the way to "1512") aren't rewritten mid-typing; it snaps to the committed value when
* focus leaves. */
@Composable
private fun ResolutionField(
label: String,
value: Int,
modifier: Modifier = Modifier,
onCommit: (Int) -> Unit,
) {
var text by remember { mutableStateOf(if (value > 0) value.toString() else "") }
OutlinedTextField(
value = text,
onValueChange = { raw ->
text = raw.filter { it.isDigit() }.take(4)
val v = (text.toIntOrNull() ?: 0).let { it - it % 2 }.coerceAtMost(8192)
if (v > 0) onCommit(v)
},
label = { Text(label) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier.onFocusChanged { if (!it.isFocused) text = if (value > 0) value.toString() else "" },
)
}