feat(clients/gamepad-ui): section tabs, background palettes, and a backdrop that moves everywhere
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 2m36s
android / android (pull_request) Successful in 3m33s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 8m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m47s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 2m36s
android / android (pull_request) Successful in 3m33s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 8m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m47s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
The console settings were one 30-row scroll, which on a Deck meant thumbing past Video and Audio to reach the pad settings. They are now split across sections — Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the desktop console, which alone carries the touch/mouse rows. L1/R1 walks them, each section remembers where its cursor was, and the names are the same word on every client so a setting is where you looked for it last. Shoulders are not the only route, because a D-pad remote hasn't got any: on Android, Up from the first row moves onto the strip (left/right walks sections there, A drops back in), and on tvOS the pills are focusable, so the focus engine handles it — a Siri Remote has no extended gamepad profile and never reaches the input poll at all. The desktop console needs neither; PageUp and PageDown already map to the same events. New "Background" row, six palettes: Violet (the brand default), Tide, Forest, Ember, Rose, Graphite. A palette is a hue rotation plus a saturation scale over the ONE colour field each client already draws, so every palette inherits its structure and Violet is the identity transform — existing installs see exactly what they see today. The maths is ported three times (Rust/Swift/Kotlin) under one shared `ui_palette` key, with the same assertions pinned in each language. It is presentation only, so it is a device preference and never part of a profile. The form screens no longer have a backdrop of their own. Settings, add-host and pair used to sit on a still gradient; they now wear the same living field at a calm mix — pools dimmed onto the palette's own corner colour, vignette halved so rows that run to the edges don't get crushed. On the desktop console that collapsed the old aurora-over-static crossfade into one shader pass with a chased uniform. Motion speed is identical in both modes on purpose: changing it would make the field jump mid-transition. Nothing in the gamepad UI is backed by a static image now, and Reduce Motion (Apple) / "remove animations" (Android) still freeze it. Also: the settings screen had no raster coverage at all — the eyeball dump is `#[ignore]`d — so a new test draws every tab, and the Android screenshot set gains a console-settings scene. Both earned their keep immediately: the renders showed the extra hint pushing "Done" off a 360 dp phone (the legend scrolls now, and the Section cell only appears where shoulders exist) and the form backdrop crushing its own edges.
This commit is contained in:
@@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -98,6 +99,13 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// The console backdrop's colour family, published once from the live settings rather than
|
||||
// threaded through every screen that draws a backdrop. Because it is read from the SAME
|
||||
// `settings` state the gamepad settings screen writes, stepping the Background row recolours
|
||||
// the field behind that very row.
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides GamepadPalette.named(settings.uiPalette),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = session,
|
||||
transitionSpec = {
|
||||
@@ -201,8 +209,16 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The console backdrop's colour family for everything under [App] — provided from the live
|
||||
* settings so a change on the gamepad settings screen recolours every backdrop at once. Defaults
|
||||
* to the brand violet, which is also what a preview or a test composition gets.
|
||||
*/
|
||||
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
|
||||
|
||||
/** Which console screen the gamepad shell is showing. */
|
||||
private enum class GamepadScreen { Home, Settings, Library }
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -23,6 +25,9 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -31,7 +36,9 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -86,32 +93,53 @@ private val auroraBlobs = listOf(
|
||||
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
|
||||
)
|
||||
|
||||
/** The deep base the field sits on — and, scaled, the [calm] lift that flattens it. */
|
||||
private val auroraBase = Color(0xFF131126)
|
||||
|
||||
/**
|
||||
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
|
||||
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
|
||||
* of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role.
|
||||
* The living console backdrop: soft brand-family blobs drifting over a deep base on slow, seamless
|
||||
* loops, finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose
|
||||
* approximation of the Apple client's MeshGradient aurora — same colour family, same "ambience,
|
||||
* never content" role, and the same [GamepadPalette] setting recolours both.
|
||||
*
|
||||
* [calm] is what the FORM screens wear: the pools dim onto the base so the glass rows keep real
|
||||
* colour and luminance without the launcher's contrast. Motion is identical either way on purpose —
|
||||
* only the contrast differs, so moving between screens can't make the field jump.
|
||||
*
|
||||
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
|
||||
* same courtesy the Apple client pays Reduce Motion.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
|
||||
val palette = LocalGamepadPalette.current
|
||||
val animated = animationsEnabled()
|
||||
val transition = rememberInfiniteTransition(label = "aurora")
|
||||
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
|
||||
// so the field never visibly jumps when the animation restarts.
|
||||
val angle by transition.animateFloat(
|
||||
val swept by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = (2 * PI).toFloat(),
|
||||
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "angle",
|
||||
)
|
||||
val angle = if (animated) swept else 0f
|
||||
// Tinting is per-frame-cheap but not free, and the palette changes about once a year.
|
||||
val blobs = remember(palette.id) { auroraBlobs.map { it to palette.tint(it.color) } }
|
||||
val base = remember(palette.id) { palette.tint(auroraBase) }
|
||||
Canvas(modifier) {
|
||||
drawRect(Color.Black)
|
||||
drawRect(if (calm) base else Color.Black)
|
||||
val span = max(size.width, size.height)
|
||||
for (b in auroraBlobs) {
|
||||
for ((b, tinted) in blobs) {
|
||||
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
|
||||
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
|
||||
val r = span * b.radiusFrac
|
||||
// Calm scales each blob's contribution rather than dimming the whole canvas: the base
|
||||
// stays put and only the pools come down to meet it, which is the same "lower the
|
||||
// contrast, keep the colour" the desktop console's `calm` uniform does.
|
||||
val alpha = if (calm) b.alpha * 0.62f else b.alpha
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
|
||||
colors = listOf(tinted.copy(alpha = alpha), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
),
|
||||
@@ -120,10 +148,15 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
blendMode = BlendMode.Plus,
|
||||
)
|
||||
}
|
||||
// Cinematic vignette: pool light centre, sink the corners.
|
||||
// Cinematic vignette: pool light centre, sink the corners. Halved under calm: a launcher's
|
||||
// cards sit in the pooled centre, but a form screen's rows run out toward the edges, where
|
||||
// crushing to black just eats them. (Matches the Apple client and the desktop console.)
|
||||
drawRect(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = if (calm) 0.22f else 0.44f),
|
||||
),
|
||||
center = Offset(size.width / 2, size.height / 2),
|
||||
radius = span * 0.92f,
|
||||
),
|
||||
@@ -141,33 +174,96 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet
|
||||
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
|
||||
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
|
||||
* `false` when the user has turned animations off system-wide (Developer options' animator duration
|
||||
* scale, or the accessibility "Remove animations" switch, which sets the same global). Read once
|
||||
* per composition — it needs a settings trip to the system, and it changes about never.
|
||||
*/
|
||||
@Composable
|
||||
private fun animationsEnabled(): Boolean {
|
||||
val context = LocalContext.current
|
||||
return remember {
|
||||
runCatching {
|
||||
android.provider.Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f,
|
||||
) != 0f
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backdrop for the console FORM screens (settings, add-host). It used to be a STILL deep-indigo
|
||||
* base with two soft glows; it is now the launcher's own living field at `calm`, which keeps that
|
||||
* colour and luminance under the glass rows, honours the palette setting on every screen rather
|
||||
* than only the launcher, and leaves nothing in the console UI backed by a static image. Mirrors
|
||||
* the Apple client's GamepadFormBackground, which made the same substitution.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
Canvas(modifier) {
|
||||
val span = max(size.width, size.height)
|
||||
drawRect(Color(0xFF131126))
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xE6635AAE), Color.Transparent),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xBF343E96), Color.Transparent),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
GamepadAuroraBackground(modifier, calm = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* The horizontal section switcher above a console list. Purely presentational — the SCREEN owns
|
||||
* which tab is selected and what the shoulders do. Scrollable so a narrow phone in landscape never
|
||||
* has to squeeze the pills, and the selected one is always brought into view whether it was reached
|
||||
* by shoulder button or tap.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleTabStrip(
|
||||
titles: List<String>,
|
||||
selected: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* The strip itself holds the cursor (the caller moved focus UP out of its list). Draws a ring
|
||||
* on the selected pill so it's clear left/right now walks sections rather than values — the
|
||||
* route a D-pad remote, which has no shoulder buttons, needs.
|
||||
*/
|
||||
focused: Boolean = false,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(selected) {
|
||||
runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) }
|
||||
}
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = ConsoleEdgeInset),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(titles) { i, title ->
|
||||
val active = i == selected
|
||||
val background by animateColorAsState(
|
||||
if (active) Color(0xD96656F2) else Color(0x14FFFFFF),
|
||||
tween(180),
|
||||
label = "tabBg",
|
||||
)
|
||||
val ink by animateColorAsState(
|
||||
Color.White.copy(alpha = if (active) 1f else 0.55f),
|
||||
tween(180),
|
||||
label = "tabInk",
|
||||
)
|
||||
val ring by animateColorAsState(
|
||||
Color.White.copy(alpha = if (active && focused) 0.85f else 0f),
|
||||
tween(180),
|
||||
label = "tabRing",
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(background)
|
||||
.border(1.5.dp, ring, RoundedCornerShape(50))
|
||||
.clickable { onSelect(i) }
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +272,7 @@ fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
|
||||
* it cross-fades between screens.
|
||||
*/
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp)
|
||||
|
||||
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
|
||||
val ConsoleEdgeInset = 24.dp
|
||||
@@ -471,7 +567,12 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
||||
Row(
|
||||
modifier = frosted
|
||||
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
// The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a
|
||||
// screen whose legend grew a cell) it scrolls rather than running off the edge and
|
||||
// silently eating the last hint — which is exactly what the settings screen's new
|
||||
// Section cell did on a 360 dp phone.
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
|
||||
@@ -152,8 +152,9 @@ fun GamepadNavEffect(
|
||||
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant
|
||||
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
|
||||
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
|
||||
* [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels
|
||||
* one layer": close the keyboard, then the screen).
|
||||
* [onSecondary] is Y, and [onShoulder] is L1 (-1) / R1 (+1) — a step SIDEWAYS out of the list, which
|
||||
* the settings screen uses for its section tabs. B is left to MainActivity's BACK remap → the
|
||||
* screen's BackHandler (so B "peels one layer": close the keyboard, then the screen).
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadNavEffect2D(
|
||||
@@ -162,6 +163,7 @@ fun GamepadNavEffect2D(
|
||||
onActivate: () -> Unit,
|
||||
onTertiary: () -> Unit = {},
|
||||
onSecondary: () -> Unit = {},
|
||||
onShoulder: (Int) -> Unit = {},
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
@@ -169,6 +171,7 @@ fun GamepadNavEffect2D(
|
||||
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||
val currentOnShoulder by rememberUpdatedState(onShoulder)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||
@@ -196,7 +199,10 @@ fun GamepadNavEffect2D(
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
|
||||
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
|
||||
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true }
|
||||
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true }
|
||||
else -> false // B → MainActivity (remapped to BACK → BackHandler)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// The console (gamepad) UI's background colour families.
|
||||
//
|
||||
// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale
|
||||
// applied to the ONE field GamepadAuroraBackground already draws, so every palette inherits its
|
||||
// structure (dark base, bright drifting pools) and the brand default is exactly the shipped look —
|
||||
// `violet` is the identity transform.
|
||||
//
|
||||
// The table and the `tint` maths are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
|
||||
// Apple client's `GamepadPalette.swift` under the same ids, so the shared `ui_palette` setting
|
||||
// names the same colour family on every client. Keep the three copies in step: a palette added
|
||||
// here without the others is a value the other clients will silently render as Violet.
|
||||
|
||||
/**
|
||||
* One background colour family. [hueDegrees] rotates about the grey axis (positive runs
|
||||
* red → green → blue) and [saturation] scales saturation about luminance.
|
||||
*/
|
||||
class GamepadPalette(
|
||||
/** The stored `ui_palette` value ([Settings.uiPalette]). */
|
||||
val id: String,
|
||||
/** What the settings row shows. */
|
||||
val name: String,
|
||||
val hueDegrees: Double,
|
||||
val saturation: Double,
|
||||
) {
|
||||
/** True for the identity transform, so the default path skips the per-colour work. */
|
||||
val isIdentity: Boolean get() = hueDegrees == 0.0 && saturation == 1.0
|
||||
|
||||
/** Apply this palette to one packed sRGB colour, keeping its alpha. */
|
||||
fun tint(c: Color): Color {
|
||||
if (isIdentity) return c
|
||||
val (r, g, b) = tint(Triple(c.red.toDouble(), c.green.toDouble(), c.blue.toDouble()))
|
||||
return Color(r.toFloat(), g.toFloat(), b.toFloat(), c.alpha)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate `c` about the grey axis by [hueDegrees] (Rodrigues — the same rotation, in the same
|
||||
* orientation, that the desktop console's shader uses for its ±8° warm/cool sway) and scale
|
||||
* its saturation about luminance. Clamped, because a large rotation can push a channel out of
|
||||
* gamut.
|
||||
*/
|
||||
fun tint(c: Triple<Double, Double, Double>): Triple<Double, Double, Double> {
|
||||
val (r, g, b) = c
|
||||
val a = Math.toRadians(hueDegrees)
|
||||
val cs = cos(a)
|
||||
val sn = sin(a)
|
||||
val invSqrt3 = 1.0 / sqrt(3.0)
|
||||
val grey = (r + g + b) / 3.0 * (1.0 - cs)
|
||||
// The `sn` term is cross(k, c) with k = (1,1,1)/√3.
|
||||
val rr = r * cs + (b - g) * invSqrt3 * sn + grey
|
||||
val rg = g * cs + (r - b) * invSqrt3 * sn + grey
|
||||
val rb = b * cs + (g - r) * invSqrt3 * sn + grey
|
||||
val luma = 0.2126 * rr + 0.7152 * rg + 0.0722 * rb
|
||||
fun mix(v: Double) = (luma + (v - luma) * saturation).coerceIn(0.0, 1.0)
|
||||
return Triple(mix(rr), mix(rg), mix(rb))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The six shipped palettes, in cycling order: the brand violet, then cool → warm, then
|
||||
* the neutral.
|
||||
*/
|
||||
val ALL = listOf(
|
||||
GamepadPalette("violet", "Violet", 0.0, 1.0),
|
||||
GamepadPalette("tide", "Tide", -70.0, 1.0),
|
||||
GamepadPalette("forest", "Forest", -130.0, 0.9),
|
||||
GamepadPalette("ember", "Ember", 105.0, 1.0),
|
||||
GamepadPalette("rose", "Rose", 60.0, 0.95),
|
||||
GamepadPalette("graphite", "Graphite", 0.0, 0.12),
|
||||
)
|
||||
|
||||
/**
|
||||
* The palette stored under [id], falling back to the brand default — an unknown name is a
|
||||
* palette a newer client shipped, not a reason to draw nothing.
|
||||
*/
|
||||
fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0]
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -63,10 +64,35 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||
// L1/R1 change SECTION, B closes. Both write the same SharedPreferences, so values round-trip with
|
||||
// the touch settings.
|
||||
//
|
||||
// The rows are split across SECTION TABS ([GpTab]) — a shoulder press on a pad, a tap on a phone.
|
||||
// They used to be one long scroll with inline `Group · Subgroup` headers, which on a TV meant
|
||||
// walking past Display and Audio to reach the controller settings. The tab names match the desktop
|
||||
// console's and the Apple client's, so a setting is found under the same word wherever you look.
|
||||
|
||||
/**
|
||||
* The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
|
||||
* match `pf-console-ui`'s `TABS` and the Apple client's `GpSettingsTab`.
|
||||
*/
|
||||
enum class GpTab(val title: String) {
|
||||
STREAM("Stream"),
|
||||
VIDEO("Video"),
|
||||
AUDIO("Audio"),
|
||||
CONTROLLER("Controller"),
|
||||
INTERFACE("Interface"),
|
||||
PROFILES("Profiles"),
|
||||
}
|
||||
|
||||
internal class GpRow(
|
||||
val id: String,
|
||||
val tab: GpTab,
|
||||
/**
|
||||
* A sub-heading above this row, for the few tabs that hold more than one group. Most rows have
|
||||
* none: the tab pill already names the section, and repeating it would be a second label
|
||||
* saying the same word.
|
||||
*/
|
||||
val header: String?,
|
||||
val label: String,
|
||||
val value: String,
|
||||
@@ -133,10 +159,34 @@ fun GamepadSettingsScreen(
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
// Which section is showing, and where each one's focus was when it was last left — a detour
|
||||
// into another tab shouldn't lose your place.
|
||||
var tab by remember { mutableStateOf(GpTab.STREAM) }
|
||||
// True while the STRIP holds the cursor rather than the list. Up from the first row moves
|
||||
// here and Down goes back — the only route to the sections on a D-pad remote, which has no
|
||||
// shoulder buttons at all (and is exactly what a TV box ships with).
|
||||
var tabFocused by remember { mutableStateOf(false) }
|
||||
val tabFocus = remember { mutableStateMapOf<GpTab, Int>() }
|
||||
val rows = allRows.filter { it.tab == tab }
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
|
||||
|
||||
// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
|
||||
fun selectTab(next: GpTab) {
|
||||
if (next == tab) return
|
||||
tabFocus[tab] = focus
|
||||
tab = next
|
||||
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
|
||||
// outlive the row it pointed at.
|
||||
focus = (tabFocus[next] ?: 0)
|
||||
.coerceIn(0, (allRows.count { it.tab == next } - 1).coerceAtLeast(0))
|
||||
}
|
||||
fun stepTab(delta: Int) {
|
||||
val all = GpTab.entries
|
||||
selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size])
|
||||
}
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
// value text slides in its AnimatedContent, so the motion matches the button press.
|
||||
var adjustDir by remember { mutableIntStateOf(1) }
|
||||
@@ -151,20 +201,28 @@ fun GamepadSettingsScreen(
|
||||
active = navActive && pinProfile == null,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||
// A disabled row is INERT, not just dim — the step is refused instead of writing a
|
||||
// setting that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
NavDir.UP -> if (focus > 0) focus-- else tabFocused = true
|
||||
NavDir.DOWN -> if (tabFocused) tabFocused = false else if (focus < rows.lastIndex) focus++
|
||||
// On the strip, left/right walks sections; on a row it steps the value. A disabled
|
||||
// row is INERT, not just dim — the step is refused instead of writing a setting
|
||||
// that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT ->
|
||||
if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT ->
|
||||
if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
}
|
||||
},
|
||||
onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() },
|
||||
// A on the strip drops into the section you picked, which is what "confirm" means there.
|
||||
onActivate = {
|
||||
if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() }
|
||||
},
|
||||
// The shoulders work from either place — a real pad never has to visit the strip.
|
||||
onShoulder = { delta -> stepTab(delta) },
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||
// +1 accounts for the heading being item 0.
|
||||
LaunchedEffect(focus) {
|
||||
LaunchedEffect(focus, tab) {
|
||||
runCatching {
|
||||
val itemIndex = focus + 1
|
||||
val info = listState.layoutInfo
|
||||
@@ -183,9 +241,21 @@ fun GamepadSettingsScreen(
|
||||
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
// The strip is PINNED while the rows scroll under it: it is this screen's primary
|
||||
// navigation now, and a switcher you have to scroll back up to find isn't one. The
|
||||
// title stays in the scrolling list (landscape has no height to spare, and the
|
||||
// selected pill already says which section you are in).
|
||||
ConsoleTabStrip(
|
||||
titles = GpTab.entries.map { it.title },
|
||||
selected = GpTab.entries.indexOf(tab),
|
||||
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
focused = tabFocused,
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().systemBarsPadding(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
@@ -196,12 +266,19 @@ fun GamepadSettingsScreen(
|
||||
ConsoleHeader("Default settings", horizontalInset = false)
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
|
||||
// its detail explains itself) but never flips it.
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
})
|
||||
SettingRowView(
|
||||
row,
|
||||
focused = index == focus && !tabFocused,
|
||||
adjustDir = adjustDir,
|
||||
onClick = {
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it
|
||||
// (so its detail explains itself) but never flips it.
|
||||
tabFocused = false
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,8 +295,23 @@ fun GamepadSettingsScreen(
|
||||
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
|
||||
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
|
||||
val focused = rows.getOrNull(focus)
|
||||
// The shoulders always change section, so that cell leads on every row. Tappable too,
|
||||
// like the others — a user without a working pad can still reach every tab.
|
||||
// Advertise the shoulders only where they EXIST: a TV remote has none (its route is Up
|
||||
// into the strip) and a touch user taps a pill, so on those the cell would be both a
|
||||
// lie and the reason a 360 dp legend runs out of room. Defaults to the pad case off an
|
||||
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
val sections = listOfNotNull(
|
||||
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
|
||||
.takeIf { padIsGamepad },
|
||||
)
|
||||
GamepadHintBar(
|
||||
when {
|
||||
if (tabFocused) listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Section"),
|
||||
PadGlyph.hint('A', "Open") { tabFocused = false },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
) else sections + when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
@@ -353,7 +445,8 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). */
|
||||
* AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one
|
||||
* tab at a time. */
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
@@ -361,12 +454,12 @@ internal fun buildSettingsRows(
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
id: String, tab: GpTab, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
|
||||
): GpRow {
|
||||
val idx = options.indexOfFirst { it.first == current }
|
||||
return GpRow(
|
||||
id, header, label,
|
||||
id, tab, header, label,
|
||||
value = options.getOrNull(idx)?.second ?: "—",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
@@ -385,10 +478,10 @@ internal fun buildSettingsRows(
|
||||
)
|
||||
}
|
||||
fun toggle(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
id: String, tab: GpTab, header: String?, label: String, detail: String,
|
||||
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
|
||||
): GpRow = GpRow(
|
||||
id, header, label,
|
||||
id, tab, header, label,
|
||||
value = if (value) "On" else "Off",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
@@ -397,36 +490,13 @@ internal fun buildSettingsRows(
|
||||
toggled = value,
|
||||
)
|
||||
|
||||
// Grouped and ordered by the cross-client category map (General / Display / Audio /
|
||||
// Controllers), with the same sub-section names the touch settings and the desktop clients use,
|
||||
// so a setting sits in the same place whichever surface you found it on. The ROWS stay the
|
||||
// couch-relevant subset: a pad can't drive a touch-input picker, and adding one for the sake of
|
||||
// symmetry would be parity in name only.
|
||||
// Grouped by the cross-client tab map (Stream / Video / Audio / Controller / Interface /
|
||||
// Profiles), so a setting sits under the same word whichever client you found it on. The ROWS
|
||||
// stay the couch-relevant subset: a pad can't drive a touch-input picker, and adding one for
|
||||
// the sake of symmetry would be parity in name only.
|
||||
return listOf(
|
||||
choice(
|
||||
"hud", "General · Statistics", "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"autoWake", "General · Session", "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"library", "General · Library", "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", "General · Interface", "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
|
||||
choice(
|
||||
"resolution", "Display · Resolution", "Resolution",
|
||||
"resolution", GpTab.STREAM, null, "Resolution",
|
||||
"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
|
||||
@@ -440,55 +510,56 @@ internal fun buildSettingsRows(
|
||||
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.",
|
||||
"refresh", GpTab.STREAM, null, "Refresh rate",
|
||||
"Frame rate the host renders and streams at.",
|
||||
REFRESH_OPTIONS, s.hz,
|
||||
) { update(s.copy(hz = it)) },
|
||||
|
||||
choice(
|
||||
"bitrate", "Display · Quality", "Bitrate",
|
||||
"bitrate", GpTab.STREAM, null, "Bitrate",
|
||||
"Automatic uses the host's default. A host's options (Up on its tile) can measure the " +
|
||||
"link and set an informed value.",
|
||||
BITRATE_OPTIONS, s.bitrateKbps,
|
||||
) { update(s.copy(bitrateKbps = it)) },
|
||||
choice(
|
||||
"codec", null, "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
codecOptionsFor(s.codec, av1Capable), s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
|
||||
toggle(
|
||||
"lowLatency", "Display · Decoding", "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"compositor", "Display · Host output", "Compositor",
|
||||
"compositor", GpTab.STREAM, "Host output", "Compositor",
|
||||
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||
) { update(s.copy(compositor = it)) },
|
||||
|
||||
choice(
|
||||
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||
"codec", GpTab.VIDEO, null, "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
codecOptionsFor(s.codec, av1Capable), s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", GpTab.VIDEO, null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
toggle(
|
||||
"lowLatency", GpTab.VIDEO, "Decoding", "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"audio", GpTab.AUDIO, null, "Audio channels",
|
||||
"The speaker layout requested from the host.",
|
||||
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
|
||||
) { update(s.copy(audioChannels = it)) },
|
||||
toggle(
|
||||
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||
"mic", GpTab.AUDIO, null, "Microphone",
|
||||
"Send this device's microphone to the host's virtual mic.",
|
||||
s.micEnabled,
|
||||
) { update(s.copy(micEnabled = it)) },
|
||||
toggle(
|
||||
"echoCancel", null, "Echo cancellation",
|
||||
"echoCancel", GpTab.AUDIO, null, "Echo cancellation",
|
||||
"Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.",
|
||||
s.echoCancel,
|
||||
) { update(s.copy(echoCancel = it)) },
|
||||
|
||||
toggle(
|
||||
"padForward", "Controllers", "Forward controllers",
|
||||
"padForward", GpTab.CONTROLLER, null, "Forward controllers",
|
||||
"Send this device's controllers to the host. Turn it off when your controller " +
|
||||
"already reaches the host another way — USB passthrough such as VirtualHere — " +
|
||||
"so games don't see two of them.",
|
||||
@@ -499,18 +570,18 @@ internal fun buildSettingsRows(
|
||||
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
|
||||
// the pad rows kept stepping settings that had nothing to act on.
|
||||
choice(
|
||||
"padType", null, "Controller type",
|
||||
"padType", GpTab.CONTROLLER, null, "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
choice(
|
||||
"systemButtons", null, "Guide button",
|
||||
"systemButtons", GpTab.CONTROLLER, null, "Guide button",
|
||||
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
|
||||
"sends them to the host whenever this device delivers them.",
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(systemButtons = it)) },
|
||||
choice(
|
||||
"guideGesture", null, "Hold Select for guide",
|
||||
"guideGesture", GpTab.CONTROLLER, null, "Hold Select for guide",
|
||||
"Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
|
||||
@@ -518,7 +589,7 @@ internal fun buildSettingsRows(
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
"phoneRumble", null, "Rumble on this phone",
|
||||
"phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone",
|
||||
"Also play controller 1's rumble on this phone's own vibration motor — " +
|
||||
"for clip-on pads without rumble motors.",
|
||||
s.rumbleOnPhone,
|
||||
@@ -530,7 +601,7 @@ internal fun buildSettingsRows(
|
||||
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
|
||||
// nothing to do with this device's motor, and a TV box is where it matters most.
|
||||
toggle(
|
||||
"sc2", null, "Steam Controller 2 passthrough",
|
||||
"sc2", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough",
|
||||
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
|
||||
"it as-is — Steam on the host drives it like the physical pad.",
|
||||
s.sc2Capture, enabled = s.gamepadForwarding,
|
||||
@@ -540,20 +611,53 @@ internal fun buildSettingsRows(
|
||||
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
|
||||
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
|
||||
toggle(
|
||||
"dsCapture", null, "DualSense / DualShock passthrough (USB)",
|
||||
"dsCapture", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)",
|
||||
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
|
||||
"triggers, lightbar and gyro.",
|
||||
s.dsCapture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(dsCapture = it)) },
|
||||
|
||||
// The palette leads Interface: it is the one row whose effect you can see while you step
|
||||
// it (the backdrop behind this very list recolours), so it wants to be the first thing
|
||||
// found in the section.
|
||||
choice(
|
||||
"palette", GpTab.INTERFACE, null, "Background",
|
||||
"The colour family this backdrop drifts through — it changes as you step, so pick by " +
|
||||
"looking. Appearance only.",
|
||||
GamepadPalette.ALL.map { it.id to it.name },
|
||||
GamepadPalette.named(s.uiPalette).id,
|
||||
) { update(s.copy(uiPalette = it)) },
|
||||
choice(
|
||||
"hud", GpTab.INTERFACE, null, "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"autoWake", GpTab.INTERFACE, null, "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"library", GpTab.INTERFACE, null, "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", GpTab.INTERFACE, null, "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
|
||||
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
|
||||
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
|
||||
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
|
||||
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
|
||||
* instead of a dead-looking empty tab. On a TV that phrasing changes: "touch interface" points
|
||||
* nowhere useful on a touchless device, so the strings name the actual route — the
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
@@ -574,7 +678,8 @@ private fun buildProfileRows(
|
||||
return listOf(
|
||||
GpRow(
|
||||
id = "noProfiles",
|
||||
header = "Profiles",
|
||||
tab = GpTab.PROFILES,
|
||||
header = null,
|
||||
label = "No profiles yet",
|
||||
value = "",
|
||||
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
|
||||
@@ -586,12 +691,13 @@ private fun buildProfileRows(
|
||||
),
|
||||
)
|
||||
}
|
||||
return profiles.mapIndexed { i, p ->
|
||||
return profiles.map { p ->
|
||||
// Counted straight off the host records, so it agrees with what the carousel renders.
|
||||
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
|
||||
GpRow(
|
||||
id = "profile:${p.id}",
|
||||
header = if (i == 0) "Profiles" else null,
|
||||
tab = GpTab.PROFILES,
|
||||
header = null,
|
||||
label = p.name,
|
||||
value = when (pins) {
|
||||
0 -> "Not pinned"
|
||||
|
||||
@@ -105,6 +105,16 @@ data class Settings(
|
||||
* client's `libraryEnabled`.
|
||||
*/
|
||||
val libraryEnabled: Boolean = true,
|
||||
/**
|
||||
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
|
||||
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
|
||||
* desktop console's and the Apple client's under the same names. Presentation only: nothing
|
||||
* about a stream depends on it, so it is a device preference and never part of a profile.
|
||||
* An unknown value reads as the default rather than failing — a newer client may have shipped
|
||||
* a palette this build doesn't know.
|
||||
*/
|
||||
val uiPalette: String = "violet",
|
||||
/**
|
||||
* "Low-latency mode" — the master switch over the latency pipeline: the async decode loop
|
||||
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
|
||||
@@ -284,6 +294,7 @@ class SettingsStore(context: Context) {
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
|
||||
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||
@@ -323,6 +334,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putString(K_PRESENT_PRIORITY, s.presentPriority)
|
||||
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||
@@ -361,6 +373,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
|
||||
/**
|
||||
* Bumped AGAIN to restart every install at the new default (ON). History: the original
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
// The console UI's background palettes. These assertions are the CONTRACT the Rust
|
||||
// (`pf-console-ui::library::tint`) and Swift (`GamepadPalette.tint`) ports have to reproduce — the
|
||||
// same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette` value
|
||||
// names the same colour family on every client.
|
||||
class GamepadPaletteTest {
|
||||
/** The brightest pool of the field — the colour a palette is judged by. */
|
||||
private val violetPool = Triple(0.49, 0.39, 0.95)
|
||||
|
||||
/**
|
||||
* The brand default must be the IDENTITY transform. Every existing install already sees the
|
||||
* shipped violet backdrop, and a palette table that quietly restyled it would be a regression
|
||||
* dressed as a feature.
|
||||
*/
|
||||
@Test
|
||||
fun violetIsTheUntouchedShippedField() {
|
||||
val violet = GamepadPalette.named("violet")
|
||||
assertEquals("violet", GamepadPalette.ALL.first().id)
|
||||
assertTrue(violet.isIdentity)
|
||||
assertEquals(violetPool, violet.tint(violetPool))
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
assertEquals("violet", GamepadPalette.named("chartreuse").id)
|
||||
assertEquals("violet", GamepadPalette.named("").id)
|
||||
}
|
||||
|
||||
/** The ids and their order are the cross-client contract (strip order, and the L1/R1 cycle). */
|
||||
@Test
|
||||
fun tableMatchesTheOtherClients() {
|
||||
assertEquals(
|
||||
listOf("violet", "tide", "forest", "ember", "rose", "graphite"),
|
||||
GamepadPalette.ALL.map { it.id },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"),
|
||||
GamepadPalette.ALL.map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A rotation moves the hue while roughly holding luminance, and the saturation scale collapses
|
||||
* toward grey — the same four checks the Rust and Swift tests make.
|
||||
*/
|
||||
@Test
|
||||
fun tintRotatesHueAndScalesSaturation() {
|
||||
assertTrue(violetPool.third > violetPool.first && violetPool.third > violetPool.second)
|
||||
|
||||
// +105° (Ember) turns the blue-dominant pool red-dominant…
|
||||
val ember = GamepadPalette.named("ember").tint(violetPool)
|
||||
assertTrue("$ember should be warm", ember.first > ember.third)
|
||||
// …−130° (Forest) turns it green-dominant…
|
||||
val forest = GamepadPalette.named("forest").tint(violetPool)
|
||||
assertTrue("$forest", forest.second > forest.first && forest.second > forest.third)
|
||||
// …and −70° (Tide) lands on a cyan whose green and blue both beat red.
|
||||
val tide = GamepadPalette.named("tide").tint(violetPool)
|
||||
assertTrue("$tide", tide.second > tide.first && tide.third > tide.first)
|
||||
|
||||
// Graphite's saturation scale leaves the channels nearly equal…
|
||||
val grey = GamepadPalette.named("graphite").tint(violetPool)
|
||||
val channels = listOf(grey.first, grey.second, grey.third)
|
||||
assertTrue("$grey", channels.max() - channels.min() < 0.08)
|
||||
// …at about the source's luminance (it desaturates, it doesn't dim).
|
||||
val luma = 0.2126 * violetPool.first + 0.7152 * violetPool.second + 0.0722 * violetPool.third
|
||||
assertEquals(luma, grey.second, 0.05)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every palette stays in gamut on every colour the field is built from — an out-of-range
|
||||
* channel would clamp differently on each platform's rasteriser.
|
||||
*/
|
||||
@Test
|
||||
fun everyPaletteStaysInGamut() {
|
||||
val field = listOf(
|
||||
Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72), Triple(0.30, 0.26, 0.74),
|
||||
Triple(0.42, 0.20, 0.54), Triple(0.49, 0.39, 0.95), Triple(0.28, 0.31, 0.84),
|
||||
Triple(0.16, 0.26, 0.64), Triple(0.45, 0.23, 0.60), Triple(0.53, 0.31, 0.75),
|
||||
Triple(0.35, 0.35, 0.91), Triple(0.19, 0.28, 0.70), Triple(0.22, 0.18, 0.54),
|
||||
Triple(0.24, 0.20, 0.58),
|
||||
)
|
||||
for (palette in GamepadPalette.ALL) {
|
||||
for (c in field) {
|
||||
val t = palette.tint(c)
|
||||
for (v in listOf(t.first, t.second, t.third)) {
|
||||
assertTrue("${palette.id} $c → $t", v in 0.0..1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every settings row lands in exactly one tab — a row missing from the tab map is a setting
|
||||
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
|
||||
*/
|
||||
@Test
|
||||
fun everySettingsRowHasATab() {
|
||||
val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {}
|
||||
assertTrue(rows.isNotEmpty())
|
||||
assertEquals(rows.size, rows.map { it.id }.toSet().size)
|
||||
// Profiles is built separately (from the catalog), so no settings row claims it.
|
||||
assertTrue(rows.none { it.tab == GpTab.PROFILES })
|
||||
for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) {
|
||||
assertTrue("$t is empty", rows.any { it.tab == t })
|
||||
}
|
||||
}
|
||||
|
||||
/** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */
|
||||
@Test
|
||||
fun backgroundRowStepsTheSharedKey() {
|
||||
var s = Settings()
|
||||
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
|
||||
fun palette() = rows().first { it.id == "palette" }
|
||||
|
||||
assertEquals("violet", s.uiPalette)
|
||||
assertEquals("Violet", palette().value)
|
||||
assertTrue("already the first = thud", !palette().adjust(-1))
|
||||
assertTrue(palette().adjust(1))
|
||||
assertEquals(GamepadPalette.ALL[1].id, s.uiPalette)
|
||||
|
||||
// A from the last entry wraps home.
|
||||
s = s.copy(uiPalette = GamepadPalette.ALL.last().id)
|
||||
palette().activate()
|
||||
assertEquals("violet", s.uiPalette)
|
||||
|
||||
// A store written by a newer client shows the palette that is actually drawing.
|
||||
s = s.copy(uiPalette = "chartreuse")
|
||||
assertEquals("Violet", palette().value)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,9 @@ class ScreenshotTest {
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -31,6 +31,7 @@ import io.unom.punktfunk.BrandDark
|
||||
import io.unom.punktfunk.ConnectModal
|
||||
import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsCategory
|
||||
@@ -406,3 +407,13 @@ internal fun WakeTimedOutScene() =
|
||||
@Composable
|
||||
internal fun ConnectConsoleScene() =
|
||||
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
/**
|
||||
* The real console settings screen — the section tab strip, the glass rows, the focused row's
|
||||
* unfolded detail, and the living (calmed) backdrop behind them. The touch [SettingsScene] can't
|
||||
* stand in for it: this is a different screen with different navigation, and the strip is the part
|
||||
* a layout regression would eat first.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleSettingsScene() =
|
||||
GamepadSettingsScreen(initial = SHOT_SETTINGS, onChange = {}, onBack = {})
|
||||
|
||||
Reference in New Issue
Block a user