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( return listOf(
choice( choice(
"resolution", "Stream", "Resolution", "resolution", "Stream", "Resolution",
"The host creates a virtual display at exactly this size — no scaling.", "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, "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)) }, ) { (w, h) -> update(s.copy(width = w, height = h)) },
choice( choice(
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.", "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"), 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. */ /** (hz, label). `0` = native refresh. */
val REFRESH_OPTIONS = listOf( val REFRESH_OPTIONS = listOf(
0 to "Native", 0 to "Native",
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.VideoDecoders
@@ -284,12 +287,40 @@ private fun CategoryDetail(
@Composable @Composable
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) { private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
val (nw, nh, nhz) = nativeDisplayMode(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 { SettingsCard {
SettingDropdown( SettingDropdown(
label = "Resolution", label = "Resolution",
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) }, 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, // The (-1, -1) sentinel can't collide with a real size; once a custom size is
) { (w, h) -> update(s.copy(width = w, height = h)) } // 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( SettingDropdown(
label = "Refresh rate", 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 "" },
)
}