diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index f21cc243..75a53c21 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -1,9 +1,6 @@ package io.unom.punktfunk import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -21,7 +18,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem @@ -52,6 +48,7 @@ import io.unom.punktfunk.kit.SessionEndReason import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.models.ActiveSession import io.unom.punktfunk.models.Tab +import kotlin.math.roundToInt @Composable fun App(forceGamepadUi: Boolean = false) { @@ -211,8 +208,9 @@ fun App(forceGamepadUi: Boolean = false) { Spacer(Modifier.weight(1f)) } // The rail handles its own insets; the content pane insets itself (the screens - // don't, since they used to rely on the Scaffold's padding). - Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) } + // don't, since they used to rely on the Scaffold's padding). Cutout included: + // a tablet in landscape puts its punch on exactly this pane's leading edge. + Box(Modifier.weight(1f).fillMaxHeight().consoleSafeArea()) { tabContent(true) } } } else { Scaffold( @@ -245,8 +243,16 @@ fun App(forceGamepadUi: Boolean = false) { */ val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") } -/** Which console screen the gamepad shell is showing. */ -private enum class GamepadScreen { Home, Settings, Library } +/** + * Which console screen the gamepad shell is showing, and how deep it sits — Home is the root, and + * everything reachable from it is one level in. The DEPTH is what decides whether a change is a + * push or a pop, and therefore which way the screens travel. + */ +private enum class GamepadScreen(val depth: Int) { + Home(0), + Settings(1), + Library(1), +} /** * The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch: @@ -297,12 +303,50 @@ fun GamepadShell( val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density + // The console's screen transition, and the desktop console's contract rather than a plain + // cross-fade (see ConsoleMotion for the numbers and where they come from): a PUSH slides the + // incoming screen up out of a fade while the outgoing one recedes; a POP runs it backwards, the + // leaving screen sliding down and the revealed one growing back. Direction comes from the + // screens' nav DEPTH, so Settings → Home pops even though nothing tracks a stack. + // + // Each slot's controller nav is gated on being the CURRENT target (`s == screen`), so mid- + // transition only the incoming screen drives the pad. All screens pin their legend at the same + // ConsoleLegendInset, so it reads as fixed while the content behind it moves. + val animated = animationsEnabled() CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) { - // Cross-fade between console screens so switches are smooth. Each slot's controller nav is gated - // on being the CURRENT target (`s == screen`), so during the fade only the incoming screen drives - // the pad. All screens pin their legend at the same ConsoleLegendInset, so it reads as fixed while - // the content behind it fades. - Crossfade(targetState = screen, animationSpec = tween(240), label = "consoleScreen") { s -> + // Measured INSIDE the console's own density, not the device's: on a TV the console UI runs at a + // reduced density to shrink the 10-foot layout, and a slide sized in device pixels would travel + // further than every other dp in the same animation. + val slidePx = with(LocalDensity.current) { ConsoleMotion.PUSH_SLIDE.toPx() }.roundToInt() + AnimatedContent( + targetState = screen, + transitionSpec = { + if (!animated) { + // Reduce-motion: no travel, no scale — just a fast cross-fade, the same courtesy + // the frozen backdrop pays. + fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith + fadeOut(tween(ConsoleMotion.REDUCED_MS)) + } else if (targetState.depth > initialState.depth) { + ( + fadeIn(ConsoleMotion.ease()) + + slideInVertically(ConsoleMotion.ease()) { slidePx } + + scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.ENTER_SCALE) + ) togetherWith ( + fadeOut(ConsoleMotion.ease()) + + scaleOut(ConsoleMotion.ease(), targetScale = ConsoleMotion.EXIT_SCALE) + ) + } else { + ( + fadeIn(ConsoleMotion.ease(), initialAlpha = ConsoleMotion.REVEAL_ALPHA) + + scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.EXIT_SCALE) + ) togetherWith ( + fadeOut(ConsoleMotion.ease()) + + slideOutVertically(ConsoleMotion.ease()) { slidePx } + ) + } + }, + label = "consoleScreen", + ) { s -> when (s) { GamepadScreen.Home -> ConnectScreen( settings = settings, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt index 1b92b249..469789a1 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -204,7 +203,10 @@ internal fun ConnectTakeover( ) { GamepadAuroraBackground(Modifier.fillMaxSize()) Column( - Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp), + // The backdrop runs full-bleed; the COPY keeps clear of the bars and the cutout. In + // landscape a hole punch is a side inset deeper than this 40 dp gutter, so centred text + // would otherwise sit under the camera. + Modifier.consoleSafeArea().padding(horizontal = 40.dp).widthIn(max = 460.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp), ) { @@ -239,7 +241,10 @@ internal fun ConnectTakeover( add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel)) if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry)) } - GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp)) + GamepadHintBar( + hints, + Modifier.align(Alignment.BottomCenter).consoleSafeArea().padding(bottom = 28.dp), + ) } } 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 00e2cf5a..f136cb87 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 @@ -3,6 +3,7 @@ package io.unom.punktfunk import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -18,10 +19,8 @@ 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.systemBarsPadding import androidx.compose.foundation.layout.widthIn 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.material3.Button @@ -47,7 +46,6 @@ 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.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -214,7 +212,7 @@ fun GamepadAddHostScreen( // visible (stacked, the keyboard covered the whole short screen). The legend is NOT put // under the keyboard here — it floats at the same fixed bottom-left spot as everywhere. Row( - Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp), + Modifier.fillMaxSize().consoleSafeArea().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(18.dp), ) { Column( @@ -236,7 +234,7 @@ fun GamepadAddHostScreen( } else { // Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never // compressed by the keyboard; the keyboard sits below it; the legend floats (fixed). - Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) { + Column(Modifier.fillMaxSize().consoleSafeArea().padding(horizontal = ConsoleEdgeInset)) { Column( Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -268,7 +266,7 @@ fun GamepadAddHostScreen( // open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred. Box( Modifier.align(Alignment.BottomStart) - .then(if (landscape) Modifier else Modifier.systemBarsPadding()) + .consoleLegendInsets(landscape) .padding(ConsoleLegendInset), ) { GamepadHintBar( @@ -315,7 +313,7 @@ private fun TvAddHostForm( Column( Modifier .fillMaxSize() - .systemBarsPadding() + .consoleSafeArea() .padding(horizontal = 56.dp, vertical = 36.dp) .widthIn(max = 720.dp) .verticalScroll(rememberScrollState()), @@ -366,14 +364,18 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row] private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) { val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused || editing, editing = editing) - val shape = RoundedCornerShape(14.dp) + // The caret keeps its slot and only fades, like the settings rows' chevrons. Appending it on + // `editing` shoved the whole value left the instant the keyboard opened — the same + // layout-moves-under-focus bug the settings detail line had, one screen over. + val caretAlpha by animateFloatAsState( + if (editing) 1f else 0f, + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), + label = "caret", + ) Row( modifier = Modifier .fillMaxWidth() - .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } - .clip(shape) - .background(visuals.background) - .border(1.dp, visuals.border, shape) + .consoleGlass(ConsoleShape.Row, visuals) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, @@ -387,7 +389,7 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () - maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (editing) Text(" |", color = ink.accent) + Text(" |", color = ink.accent, modifier = Modifier.graphicsLayer { alpha = caretAlpha }) } } @@ -395,19 +397,15 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () - private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) { val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused) - val shape = RoundedCornerShape(14.dp) val labelColor by animateColorAsState( if (enabled) ink.accent else ink.fg(0.35f), - tween(160), + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), label = "addLabel", ) Box( modifier = Modifier .fillMaxWidth() - .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } - .clip(shape) - .background(visuals.background) - .border(1.dp, visuals.border, shape) + .consoleGlass(ConsoleShape.Row, visuals) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) .padding(vertical = 14.dp), contentAlignment = Alignment.Center, @@ -430,14 +428,16 @@ private fun KeyboardGrid( onKey: (Int, Int) -> Unit, ) { val ink = LocalGamepadInk.current - val shape = RoundedCornerShape(20.dp) + val shape = ConsoleShape.Keyboard val gap = if (compact) 5.dp else 7.dp Column( Modifier .fillMaxWidth() .widthIn(max = 640.dp) .clip(shape) - .background(Color(0x1FFFFFFF)) + // Palette glass, lifted a touch above a row's: the keyboard is a slab the keys sit on, + // and a hardcoded white wash was the one surface a pale palette couldn't recolour. + .background(ink.glass.copy(alpha = (ink.glass.alpha * 1.5f).coerceAtMost(1f))) .border(1.dp, ink.fg(0.12f), shape) .padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset), verticalArrangement = Arrangement.spacedBy(gap), @@ -467,11 +467,13 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: tween(90), label = "keyBg", ) - val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg") + // `onAccent`, not black: a pale palette's accent can be light enough that black-on-it is the + // unreadable combination, and the palette already resolved which way that goes. + val fg by animateColorAsState(if (focused) ink.onAccent else ink.fg, tween(90), label = "keyFg") Box( modifier = modifier .height(if (compact) 34.dp else 44.dp) - .clip(RoundedCornerShape(9.dp)) + .clip(ConsoleShape.Keycap) .background(bg) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick), contentAlignment = Alignment.Center, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAurora.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAurora.kt new file mode 100644 index 00000000..2ca0b9aa --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAurora.kt @@ -0,0 +1,348 @@ +package io.unom.punktfunk + +import android.graphics.RuntimeShader +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.withInfiniteAnimationFrameMillis +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ShaderBrush +import java.util.Locale +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin + +// The living console backdrop, in two renderings of ONE design. +// +// On API 33+ this is the desktop console's actual field: `pf-console-ui`'s `mesh_sksl` +// (library.rs) ported to AGSL — a 4×4 bicubic colour mesh warped by four drifting interior points, +// swayed ±8° in hue, vignetted and scrimmed. AGSL is the SkSL subset Android 13 ships, so the +// shader body is very nearly the same source, and `GamepadPalette.meshColors` is literally the same +// 16-cell table the Rust samples. Below 33 (`RuntimeShader` is 33+) the field falls back to four +// drifting radial blobs sampled from the same palette ramp — an approximation of the same look, and +// the honest one: emulating a mesh gradient with bitmaps would cost more than it bought. +// +// Either way it is AMBIENCE, never content: it runs full-bleed under the cutout and the system bars, +// and every console screen's chrome floats over it. + +/** + * The console backdrop. [calm] is what the FORM screens (settings, add-host) wear: the pools dim + * onto the ground 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 — which doubles as the deterministic mode the + * screenshot harness captures in, since the phase is just a uniform. + */ +@Composable +fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) { + val palette = LocalGamepadPalette.current + val animated = animationsEnabled() + // Compiled once per palette and cached process-wide: stepping the Background row recolours the + // field under the very row being stepped, and a shader compile per D-pad press would be felt on + // a TV box. A compile failure resolves null and takes the blob path — a vendor Skia that + // rejects the source must not take the console UI down with it. + val shader = if (Build.VERSION.SDK_INT >= 33) { + remember(palette.id) { meshShaderFor(palette) } + } else { + null + } + if (shader != null) { + MeshAurora(modifier, shader, calm, animated) + } else { + BlobAurora(modifier, palette, calm, animated) + } +} + +/** + * The backdrop for the console FORM screens (settings, add-host) — the launcher's own living field + * at `calm`, so no screen in the console UI is backed by a still image and the palette setting + * reaches every one of them. Mirrors the Apple client's GamepadFormBackground and the desktop's + * single `calm` uniform. + */ +@Composable +fun GamepadFormBackground(modifier: Modifier = Modifier) { + GamepadAuroraBackground(modifier, calm = true) +} + +// --- The mesh field (API 33+) --------------------------------------------------------------- + +/** The phase a frozen (reduce-motion / screenshot) field is drawn at — the desktop's t = 0. */ +private const val FROZEN_PHASE = 0f + +@RequiresApi(33) +@Composable +private fun MeshAurora( + modifier: Modifier, + shader: RuntimeShader, + calm: Boolean, + animated: Boolean, +) { + val ink = LocalGamepadInk.current + val palette = LocalGamepadPalette.current + val brush = remember(shader) { ShaderBrush(shader) } + // Real monotonic seconds, not a wrapping sweep: the four warp points and the hue sway run at + // mutually irrational rates (periods ~90–130 s), so no loop length exists that would rejoin + // them seamlessly — which is exactly why the desktop feeds its shader elapsed time too. Frozen + // under reduce-motion, where it also makes the field deterministic for a screenshot. + val time by produceState(FROZEN_PHASE, animated) { + if (!animated) return@produceState + while (true) { + withInfiniteAnimationFrameMillis { value = it / 1000f } + } + } + val (gr, gg, gb) = palette.ground + Canvas(modifier) { + // Uniforms are set per draw, not per recomposition: `time` is read HERE, inside the draw + // scope, so a new frame invalidates the draw only — the composition never re-runs. + shader.setFloatUniform("u_res", size.width, size.height) + shader.setFloatUniform("u_tc", time, if (calm) 1f else 0f) + // The calm lift: the palette's ground scaled to 0.4, what the field flattens toward. + shader.setFloatUniform( + "u_lift", + (gr * 0.4).toFloat(), (gg * 0.4).toFloat(), (gb * 0.4).toFloat(), 0f, + ) + // Where the vignette and scrims tend, and how hard — black at full strength on a dark + // field, white at well under half on a pale one (mixing a pastel toward white at the dark + // field's strength bleaches the chroma straight out of the gradient). + shader.setFloatUniform( + "u_scrim", + ink.shade.red, ink.shade.green, ink.shade.blue, ink.shadeScale, + ) + drawRect(brush) + } +} + +/** + * Compiled mesh shaders by palette id — at most the 13 shipped palettes, so it is bounded by the + * table rather than by use. Touched only from the composition (main) thread. + */ +private val meshShaders = HashMap() + +@RequiresApi(33) +private fun meshShaderFor(palette: GamepadPalette): RuntimeShader? = + meshShaders.getOrPut(palette.id) { + runCatching { RuntimeShader(meshAgsl(palette.meshColors)) }.getOrNull() + } + +/** + * Format a shader constant. `Locale.ROOT` is not optional: `String.format` on a German-locale + * device emits `0,075`, which is a syntax error in the shader source and would take the whole + * backdrop out on exactly the devices it was authored on. `%f` also keeps a very small ramp value + * out of exponent notation, which SkSL would still parse but nobody would enjoy reading. + */ +private fun n(v: Double): String = String.format(Locale.ROOT, "%.6f", v) + +/** + * The mesh gradient as AGSL, the palette baked into the source and resolution/time/calm/scrim left + * as uniforms — the direct port of `pf-console-ui`'s `mesh_sksl`, kept structurally line-for-line + * with it so the two can be diffed. A smooth bicubic blend of the 16 colours (a separable + * cubic-Bézier basis in x then y, the fragment-shader analogue of SwiftUI's + * `MeshGradient(smoothsColors: true)`), four interior points driving a bounded domain warp, then + * the ±8° hue sway, an elliptical vignette and the vertical legibility scrim. + */ +private fun meshAgsl(colors: List>): String { + fun c(i: Int): String { + val (r, g, b) = colors[i] + return "float3(${n(r)}, ${n(g)}, ${n(b)})" + } + // The four interior-point domain-warp accumulators. SIG (0.30) sets how far each point's pull + // reaches; the warp is the weight-normalised average displacement, so |warp| ≤ max|amp|. + val warp = buildString { + for (p in GamepadPalette.MESH_INTERIOR) { + append(" q = uv - float2(${n(p.x)}, ${n(p.y)});\n") + append(" ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n") + append(" d = float2(${n(p.amp)} * sin(tt * ${n(p.sx)} + ${n(p.phase)}),\n") + append(" ${n(p.amp)} * cos(tt * ${n(p.sy)} + ${n(p.phase)} * 1.3));\n") + append(" wsum += d * ww; wtot += ww;\n") + } + } + return """ +uniform float2 u_res; +// x = seconds since this field started, y = the calm mix (0 launcher, 1 form). +uniform float2 u_tc; +// rgb = the palette's corner colour scaled for the calm lift; a is unused. +uniform float4 u_lift; +// rgb = what the vignette and scrims tend toward, a = how hard. +uniform float4 u_scrim; + +// Cubic-Bézier basis over four control values — the smooth 4-point blend per axis. +float bz(float t, float a, float b, float c, float d) { + float u = 1.0 - t; + return u*u*u*a + 3.0*u*u*t*b + 3.0*u*t*t*c + t*t*t*d; +} +float3 bz3(float t, float3 a, float3 b, float3 c, float3 d) { + return float3(bz(t, a.r, b.r, c.r, d.r), bz(t, a.g, b.g, c.g, d.g), bz(t, a.b, b.b, c.b, d.b)); +} +// Hue rotation about the grey axis (Rodrigues) — the ±8° warm/cool sway. The desktop's `cross(k, +// col)` is written out here: with k = (c, c, c) it collapses to c·(b-g, r-b, g-r), which needs no +// builtin at all — AGSL's function set is a subset of SkSL's and not worth betting the field on. +float3 hue(float3 col, float a) { + float c = 0.5773503; + float cs = cos(a); float sn = sin(a); + float3 kx = c * float3(col.b - col.g, col.r - col.b, col.g - col.r); + return col*cs + kx*sn + float3(c) * dot(float3(c), col) * (1.0 - cs); +} + +half4 main(float2 xy) { + float tt = u_tc.x; float calm = u_tc.y; + float2 uv = xy / u_res; + // Interior control points wander → bounded domain warp (pools follow them). + float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d; +$warp + uv = clamp(uv - wsum / (wtot + 0.0001), 0.0, 1.0); + + // Bicubic blend of the 16 mesh colours: cubic-Bézier in x per row, then in y. + float3 r0 = bz3(uv.x, ${c(0)}, ${c(1)}, ${c(2)}, ${c(3)}); + float3 r1 = bz3(uv.x, ${c(4)}, ${c(5)}, ${c(6)}, ${c(7)}); + float3 r2 = bz3(uv.x, ${c(8)}, ${c(9)}, ${c(10)}, ${c(11)}); + float3 r3 = bz3(uv.x, ${c(12)}, ${c(13)}, ${c(14)}, ${c(15)}); + float3 col = bz3(uv.y, r0, r1, r2, r3); + + col = hue(col, sin(tt * 0.021) * 0.1396263); + + // Calm: flatten the field toward its own corner colour — the pools dim and the corners lift, + // so a form screen keeps real colour under its glass rows while losing the launcher's + // contrast. Motion is untouched. + col = mix(col, col * 0.60 + u_lift.rgb, calm); + + // Elliptical vignette: clear at r=0.25 → scrim·0.42 at r=1.15. 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 them just eats the list. + float2 e = (xy / u_res - 0.5) * 2.0; + float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * mix(0.42, 0.21, calm) * u_scrim.a; + col = mix(col, u_scrim.rgb, vig); + + // Vertical legibility scrim for the pinned heading + the floating legend. + float v = xy.y / u_res.y; + float s = v < 0.32 ? mix(0.38, 0.06, v / 0.32) + : v < 0.68 ? mix(0.06, 0.08, (v - 0.32) / 0.36) + : mix(0.08, 0.40, (v - 0.68) / 0.32); + col = mix(col, u_scrim.rgb, s * u_scrim.a); + + return half4(half3(col), 1.0); +} +""" +} + +// --- The blob field (API 28–32 fallback) ----------------------------------------------------- + +/** + * One drifting blob of the fallback field: where it sits, how far it wanders, and how fast. Integer + * [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at + * draw time, so the field always shows several of that palette's tones at once. + */ +private class AuroraBlob( + val baseX: Float, + val baseY: Float, + val driftX: Float, + val driftY: Float, + val sx: Int, + val sy: Int, + val phase: Float, + val radiusFrac: Float, + val alpha: Float, +) + +private val auroraBlobs = listOf( + AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), + AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), + AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), + AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), +) + +/** + * Soft blobs from the palette's ramp drifting over its ground on slow, seamless loops, finished + * with a centre-pooling vignette and top/bottom legibility scrims. What API 28–32 sees in place of + * the mesh: the same colour families, the same "ambience, never content" role, and the same + * [GamepadPalette] setting recolours it. + */ +@Composable +private fun BlobAurora( + modifier: Modifier, + palette: GamepadPalette, + calm: Boolean, + animated: Boolean, +) { + val ink = LocalGamepadInk.current + 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 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 + val tones = palette.blobColors + val ground = palette.groundColor + // Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's + // strength bleaches the chroma straight out of the gradient, so a pale palette gets under + // half — the same scrim strength the desktop console's shader carries. + val scrim = if (palette.light) ink.fg else Color.Black + val strength = if (palette.light) 0.45f else 1f + Canvas(modifier) { + drawRect(ground) + val span = max(size.width, size.height) + for ((i, b) in auroraBlobs.withIndex()) { + 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 + // ground 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(tones[i].copy(alpha = alpha), Color.Transparent), + center = Offset(cx, cy), + radius = r, + ), + center = Offset(cx, cy), + radius = r, + // Additive only works over a DARK ground; over a pale one every blob + // saturates to white and the field turns grey. Pale palettes tint instead. + blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus, + ) + } + // Cinematic vignette: pool light centre, settle the corners toward the scrim. 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 them just eats the list. + drawRect( + Brush.radialGradient( + colors = listOf( + Color.Transparent, + scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength), + ), + center = Offset(size.width / 2, size.height / 2), + radius = span * 0.92f, + ), + ) + // Top/bottom legibility scrim for the pinned title + hint bar. + drawRect( + Brush.verticalGradient( + 0.0f to scrim.copy(alpha = 0.40f * strength), + 0.30f to scrim.copy(alpha = 0.05f * strength), + 0.70f to scrim.copy(alpha = 0.06f * strength), + 1.0f to scrim.copy(alpha = 0.42f * strength), + ), + ) + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt index 6e6219b5..c8bffb74 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt @@ -1,33 +1,43 @@ package io.unom.punktfunk +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.view.InputDevice +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith 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 import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union 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.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -38,146 +48,141 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember +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.draw.drawBehind +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import io.unom.punktfunk.kit.Gamepad -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.max +import io.unom.punktfunk.kit.deviceBodyVibrator +import kotlin.math.abs import kotlin.math.roundToInt -import kotlin.math.sin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch // The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's -// GamepadChrome.swift: a slow-drifting violet aurora backdrop, a bottom button-glyph hint bar, and a -// connected-controller status chip. One look across every screen is what makes the console UI read -// as a coherent mode rather than a set of themed pages. +// GamepadChrome.swift: the motion and shape vocabulary every console screen animates in, the glass +// every row and card is cut from, the bottom button-glyph legend, the section strip, and the +// connected-controller chip. One look and one set of timings across every screen is what makes the +// console UI read as a coherent mode rather than a set of themed pages. +// +// The living backdrop itself lives in GamepadAurora.kt (it grew a shader). + +// --- The vocabulary ------------------------------------------------------------------------- /** - * One drifting blob of the aurora field: where it sits, how far it wanders, and how fast. Integer - * [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at - * draw time, so the field always shows several of that palette's tones at once. + * The console's motion vocabulary: one place for the curves and durations every console screen + * animates on, so a screen transition, a focus cross-fade and a stepped value read as one system + * rather than as three components that happen to animate. + * + * The push/pop half is the DESKTOP CONSOLE'S CONTRACT, not a local taste call: `pf-console-ui`'s + * `TRANSITION_S = 0.26` (`shell.rs:29`) and the paint geometry in `shell/render.rs:120-151` — + * incoming slides up 36 px out of a fade at 0.985→1 scale while the outgoing recedes to 0.96; a pop + * runs the other way, the leaving screen sliding DOWN and the revealed one growing back from 0.96 + * at 0.4 alpha. The Apple client mirrors the same numbers in `GamepadShell.swift`. + * + * ⚠ That makes this the THIRD hand-copy of those constants (Rust, Swift, here). Design WP9 promotes + * them to shared parity vectors consumed by all three clients' tests; until it lands, a change here + * is a change owed to the other two. */ -private class AuroraBlob( - val baseX: Float, - val baseY: Float, - val driftX: Float, - val driftY: Float, - val sx: Int, - val sy: Int, - val phase: Float, - val radiusFrac: Float, - val alpha: Float, -) +object ConsoleMotion { + /** The desktop's `ease_out_cubic`, as a Compose easing. */ + val EaseOutCubic = CubicBezierEasing(0.215f, 0.61f, 0.355f, 1f) -private val auroraBlobs = listOf( - AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), - AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), - AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), - AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), -) + /** Screen push/pop, ms — the desktop's `TRANSITION_S` (0.26 s). */ + const val TRANSITION_MS = 260 + + /** + * What push/pop collapses to when the user has asked for less motion: a fast cross-fade with no + * travel and no scale, the same courtesy the frozen backdrop pays. + */ + const val REDUCED_MS = 90 + + /** How far an incoming screen slides up out of its fade (the desktop's `36.0 * k`). */ + val PUSH_SLIDE = 36.dp + + /** The scale an incoming screen grows from. */ + const val ENTER_SCALE = 0.985f + + /** The scale an outgoing screen recedes to — and the one a revealed screen grows back from. */ + const val EXIT_SCALE = 0.96f + + /** The alpha a revealed (popped-back-to) screen fades up from. */ + const val REVEAL_ALPHA = 0.4f + + /** Focus arriving on a row/field/pill: background, border, chevrons, label colour. */ + const val FOCUS_MS = 160 + + /** A stepped value sliding in behind the press; [VALUE_OUT_MS] is the outgoing half. */ + const val VALUE_MS = 180 + const val VALUE_OUT_MS = 140 + + /** The settings strip stepping a section — a short directional slide of the row list. */ + const val TAB_MS = 200 + val TAB_SLIDE = 24.dp + + /** A refused press answering anyway: how far the value gives, and how long it takes to spring back. */ + val REFUSAL_NUDGE = 4.dp + const val REFUSAL_MS = 120 + + /** Everything eased on the console's own curve. */ + fun ease(durationMillis: Int = TRANSITION_MS, delayMillis: Int = 0) = + tween(durationMillis, delayMillis, EaseOutCubic) +} /** - * The living console backdrop: soft blobs from the palette's ramp drifting over its ground 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 families, same - * "ambience, never content" role, and the same [GamepadPalette] setting recolours both. - * - * [calm] is what the FORM screens wear: the pools dim onto the ground 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. + * The console's corner radii. These were four separate literals across as many files, which is how + * a settings row and an add-host field — the same object on two screens — drift apart. */ -@Composable -fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) { - val ink = LocalGamepadInk.current - 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 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 - val tones = palette.blobColors - val ground = palette.groundColor - // Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's - // strength bleaches the chroma straight out of the gradient, so a pale palette gets under - // half — the same scrim strength the desktop console's shader carries. - val scrim = if (palette.light) ink.fg else Color.Black - val strength = if (palette.light) 0.45f else 1f - Canvas(modifier) { - drawRect(ground) - val span = max(size.width, size.height) - for ((i, b) in auroraBlobs.withIndex()) { - 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 - // ground 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(tones[i].copy(alpha = alpha), Color.Transparent), - center = Offset(cx, cy), - radius = r, - ), - center = Offset(cx, cy), - radius = r, - // Additive only works over a DARK ground; over a pale one every blob - // saturates to white and the field turns grey. Pale palettes tint instead. - blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus, - ) - } - // Cinematic vignette: pool light centre, settle the corners toward the scrim. 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 them just eats the list. - drawRect( - Brush.radialGradient( - colors = listOf( - Color.Transparent, - scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength), - ), - center = Offset(size.width / 2, size.height / 2), - radius = span * 0.92f, - ), - ) - // Top/bottom legibility scrim for the pinned title + hint bar. - drawRect( - Brush.verticalGradient( - 0.0f to scrim.copy(alpha = 0.40f * strength), - 0.30f to scrim.copy(alpha = 0.05f * strength), - 0.70f to scrim.copy(alpha = 0.06f * strength), - 1.0f to scrim.copy(alpha = 0.42f * strength), - ), - ) - } +object ConsoleShape { + /** A settings row, an add-host field, a dialog button, a pin row. */ + val Row = RoundedCornerShape(14.dp) + + /** A pill: the section strip, the legend, a badge. */ + val Pill = RoundedCornerShape(50) + + /** A modal card. */ + val Card = RoundedCornerShape(24.dp) + + /** A launcher tile. */ + val Tile = RoundedCornerShape(26.dp) + + /** A library poster. */ + val Poster = RoundedCornerShape(16.dp) + + /** The on-screen keyboard's frame, and one of its keycaps. */ + val Keyboard = RoundedCornerShape(20.dp) + val Keycap = RoundedCornerShape(9.dp) + + /** The floating detail band under a settings list. */ + val Band = RoundedCornerShape(14.dp) } /** @@ -186,7 +191,7 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false * per composition — it needs a settings trip to the system, and it changes about never. */ @Composable -private fun animationsEnabled(): Boolean { +internal fun animationsEnabled(): Boolean { val context = LocalContext.current return remember { runCatching { @@ -199,93 +204,56 @@ private fun animationsEnabled(): Boolean { } } -/** - * 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) { - GamepadAuroraBackground(modifier, calm = true) -} +// --- Safe area ------------------------------------------------------------------------------ /** - * 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. + * The safe area a console screen's CONTENT keeps clear: the system bars UNION the display cutout. + * + * `systemBarsPadding()`, which every console screen used to pad with, EXCLUDES + * `WindowInsets.displayCutout`. In portrait that mostly hides — a top-centre hole punch sits under + * the status-bar inset anyway — but in landscape a cutout is a LEFT or RIGHT edge inset with no bar + * behind it (`cutout=[0,162,0,0]` on the Nothing Phone 3, see the dump quoted in MainActivity), so + * settings rows and add-host fields ran straight under the camera. Material3's own components lay + * out against `systemBars.union(displayCutout)`; this is that rule, applied to the console screens, + * which draw their own chrome instead of sitting in a Scaffold. + * + * The BACKDROP deliberately does not take it: the aurora is ambience, and running full-bleed under + * the camera is exactly what ambience should do. */ @Composable -fun ConsoleTabStrip( - titles: List, - 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 ink = LocalGamepadInk.current - 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) ink.accent(0.85f) else ink.glass, - tween(180), - label = "tabBg", - ) - // Not `ink` — that name is the palette's, and shadowing it here cost a compile. - val labelColor by animateColorAsState( - if (active) ink.onAccent else ink.fg(0.55f), - tween(180), - label = "tabInk", - ) - val ring by animateColorAsState( - ink.fg(if (active && focused) 0.85f else 0f), - tween(180), - label = "tabRing", - ) - Text( - title, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = labelColor, - 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), - ) - } - } -} +fun Modifier.consoleSafeArea(): Modifier = + windowInsetsPadding(WindowInsets.systemBars.union(WindowInsets.displayCutout)) + +/** + * The insets a FLOATING legend takes. In landscape it deliberately ignores the system bars so it + * hugs the corner rather than the nav-bar inset — but it must still clear the CUTOUT: the console + * screens are `SENSOR_LANDSCAPE`, so reverse-landscape parks the punch on exactly the corner the + * legend lives in. + */ +@Composable +fun Modifier.consoleLegendInsets(landscape: Boolean): Modifier = + if (landscape) windowInsetsPadding(WindowInsets.displayCutout) else consoleSafeArea() /** * The exact inset every console screen places its floating legend at (bottom-start), so the legend - * sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind - * it cross-fades between screens. + * sits in the SAME spot across Home / Settings / Add-Host / Library and appears pinned while the + * content behind it transitions. */ 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 +/** + * How much room a scrolling console list leaves at its bottom for the floating legend ZONE to sit + * over — the legend pill PLUS the detail band above it (see [ConsoleDetailBand]). One constant so + * the list's `contentPadding` and the keep-focus-visible scroll margin cannot drift apart; they + * were 104 dp and a bare `96`, and the scroll target moved under the band the moment it grew. + */ +val ConsoleLegendClearance = 152.dp + +// --- Headings and the section strip ---------------------------------------------------------- + /** * The heading every console screen uses — one style, one inset, so titles line up across Home / * Settings / Add-Host / Library. Callers place it at the top of their content (or float it, on Home). @@ -307,6 +275,524 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: ) } +/** + * The horizontal section switcher above a console list. Purely presentational — the SCREEN owns + * which tab is selected and what the shoulders do. + * + * ONE indicator pill glides between the sections rather than each pill cross-fading its own fill in + * and out: a shared element that travels says "you moved along a strip", where a pair of fades says + * "something over there turned off and something here turned on". It is drawn BEHIND the labels + * (`drawBehind` on the strip's content node), so it costs no layout and can't push the pills around + * as it moves. Scrollable, so a narrow phone in landscape never has to squeeze them. + */ +@Composable +fun ConsoleTabStrip( + titles: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + /** + * The strip itself holds the cursor (the caller moved focus UP out of its list). Rings the + * indicator 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 ink = LocalGamepadInk.current + val scroll = rememberScrollState() + val animated = animationsEnabled() + // Pill geometry, measured in ROOT space for both the strip and its pills: the difference is + // scroll-invariant (they scroll together) and needs no assumptions about which layout node sits + // between them. + var stripX by remember { mutableFloatStateOf(0f) } + val pillX = remember { mutableStateMapOf() } + val pillW = remember { mutableStateMapOf() } + val target = pillX[selected]?.let { x -> pillW[selected]?.let { w -> x - stripX to w } } + + val indicatorX = remember { Animatable(0f) } + val indicatorW = remember { Animatable(0f) } + LaunchedEffect(target, animated) { + val (x, w) = target ?: return@LaunchedEffect + // Width 0 = never placed: the first measurement snaps, or the indicator would fly in from + // the left edge every time the strip is composed. + if (indicatorW.value == 0f || !animated) { + indicatorX.snapTo(x) + indicatorW.snapTo(w) + } else { + val spec = ConsoleMotion.ease(ConsoleMotion.TAB_MS) + coroutineScope { + launch { indicatorX.animateTo(x, spec) } + launch { indicatorW.animateTo(w, spec) } + } + } + } + // The strip scrolls the selected pill into view whether it was reached by shoulder or by tap. + LaunchedEffect(selected, target) { + val (x, w) = target ?: return@LaunchedEffect + val viewport = scroll.viewportSize.toFloat() + if (viewport <= 0f) return@LaunchedEffect + // A pill's own half-width of lead-in, so the neighbour you are stepping toward is visible + // rather than flush against the edge. + val lead = (w * 0.5f).coerceAtMost(60f) + val left = x - lead + val right = x + w + val to = when { + left < scroll.value -> left + right > scroll.value + viewport -> right - viewport + else -> return@LaunchedEffect + } + runCatching { scroll.animateScrollTo(to.roundToInt().coerceAtLeast(0)) } + } + + val fill = ink.accent(0.85f) + val ringAlpha by animateFloatAsState( + if (focused) 0.85f else 0f, + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), + label = "tabRing", + ) + val ring = ink.fg(ringAlpha) + Box(modifier.horizontalScroll(scroll)) { + Box( + Modifier + .onGloballyPositioned { stripX = it.positionInRoot().x } + .drawBehind { + val w = indicatorW.value + if (w <= 0f) return@drawBehind + val r = CornerRadius(size.height / 2f) + drawRoundRect( + color = fill, + topLeft = Offset(indicatorX.value, 0f), + size = Size(w, size.height), + cornerRadius = r, + ) + if (ring.alpha > 0f) { + drawRoundRect( + color = ring, + topLeft = Offset(indicatorX.value, 0f), + size = Size(w, size.height), + cornerRadius = r, + style = Stroke(width = 1.5.dp.toPx()), + ) + } + }, + ) { + Row( + Modifier.padding(horizontal = ConsoleEdgeInset), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + titles.forEachIndexed { i, title -> + val active = i == selected + // Not `ink` — that name is the palette's, and shadowing it here cost a compile. + val labelColor by animateColorAsState( + if (active) ink.onAccent else ink.fg(0.55f), + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), + label = "tabInk", + ) + Text( + title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = labelColor, + maxLines = 1, + modifier = Modifier + .onGloballyPositioned { + pillX[i] = it.positionInRoot().x + pillW[i] = it.size.width.toFloat() + } + .clip(ConsoleShape.Pill) + .clickable { onSelect(i) } + .padding(horizontal = 14.dp, vertical = 7.dp), + ) + } + } + } + } +} + +// --- Focus, glass, and the surfaces cut from it ------------------------------------------------ + +/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */ +class ConsoleFocusVisuals( + val scale: Float, + val background: Color, + val border: Color, + /** 0 → 1 as focus arrives; drives the lift and the accent bloom in [consoleGlass]. */ + val focus: Float, +) + +/** + * The focus visuals every console form element shares (settings rows, add-host fields, action + * rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and + * resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border + * of a field actively receiving keyboard input. + */ +@Composable +fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals { + val ink = LocalGamepadInk.current + val scale by animateFloatAsState( + targetValue = if (active) 1f else 0.98f, + animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), + label = "consoleScale", + ) + val focus by animateFloatAsState( + targetValue = if (active) 1f else 0f, + animationSpec = ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), + label = "consoleFocus", + ) + val background by animateColorAsState( + if (active) ink.accent(0.20f) else ink.glass, + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), + label = "consoleBg", + ) + val border by animateColorAsState( + when { + editing -> ink.accent(0.70f) + active -> ink.fg(0.28f) + else -> ink.fg(0.06f) + }, + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), + label = "consoleBorder", + ) + return ConsoleFocusVisuals(scale, background, border, focus) +} + +/** + * The console's glass: what every row, field, card and tile is cut from, in one place. + * + * Four things separate it from the flat translucent fill it replaces, all of them the desktop + * console's `theme::panel()` in Compose terms: + * * a vertical LUMINANCE gradient in the fill, so the pane has a lit top and a settled bottom + * instead of one even wash; + * * a 1 px top-edge HIGHLIGHT that fades down into the border — the cue that reads as "this is a + * physical pane catching the light above it"; + * * a drop SHADOW that grows with focus, so the focused row genuinely sits above its neighbours; + * * a soft accent BLOOM behind it, drawn outside the clip, so focus reads as a lens over the + * backdrop rather than a recolour of the row. + * + * The bloom and shadow are driven by [ConsoleFocusVisuals.focus], so they arrive and leave with the + * same curve as the fill — one focus change, not four independent animations. + */ +@Composable +fun Modifier.consoleGlass(shape: Shape, visuals: ConsoleFocusVisuals): Modifier { + val ink = LocalGamepadInk.current + val focus = visuals.focus + val accent = ink.accent + val highlight = ink.highlight + val fill = visuals.background + val border = visuals.border + return this + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } + // BEFORE the clip, so the bloom can spill past the row's own rectangle — a glow that stops + // at the edge is just a brighter border. + .drawBehind { + if (focus <= 0.01f) return@drawBehind + val r = size.height * 1.35f + drawRect( + brush = Brush.radialGradient( + colors = listOf(accent.copy(alpha = 0.18f * focus), Color.Transparent), + center = Offset(size.width / 2f, size.height / 2f), + radius = r, + ), + topLeft = Offset(-r, -r), + size = Size(size.width + 2f * r, size.height + 2f * r), + ) + } + .shadow(elevation = ConsoleGlassLift * focus, shape = shape, clip = false) + .clip(shape) + .background( + Brush.verticalGradient( + listOf( + fill.copy(alpha = (fill.alpha * 1.35f + 0.03f).coerceAtMost(1f)), + fill.copy(alpha = fill.alpha * 0.72f), + ), + ), + ) + .border( + width = 1.dp, + brush = Brush.verticalGradient( + 0f to highlight.copy(alpha = highlight.alpha * (0.55f + 0.45f * focus)), + 0.45f to border, + 1f to border, + ), + shape = shape, + ) +} + +/** How far a fully focused console surface lifts off the field. */ +private val ConsoleGlassLift = 8.dp + +/** + * A MODAL card's surface. Unlike [consoleGlass] this one is near-opaque: a dialog's job is to + * occlude the screen it covers, and it carries body text that has to stay readable over a moving + * backdrop. It keeps the same lit top edge, so it still belongs to the console's material. + * + * The ground is [GamepadInk.card] — palette-derived, which is the fix for a real bug: the cards + * were a hardcoded near-black indigo while their text came from the palette, so on any of the six + * PALE palettes a dialog rendered dark ink on a dark card and was unreadable. + */ +@Composable +fun Modifier.consoleCard(): Modifier { + val ink = LocalGamepadInk.current + val card = ink.card + return this + .clip(ConsoleShape.Card) + .background( + Brush.verticalGradient( + listOf( + // Lit from above like every other console surface, but gently — a modal is a + // slab, not a pane of glass. + card.copy(alpha = (card.alpha * 1.03f).coerceAtMost(1f)), + card, + ), + ), + ) + .border( + width = 1.dp, + brush = Brush.verticalGradient( + 0f to ink.highlight.copy(alpha = ink.highlight.alpha * 0.8f), + 0.4f to ink.fg(0.12f), + 1f to ink.fg(0.12f), + ), + shape = ConsoleShape.Card, + ) +} + +/** + * The scrim + entrance every console modal shares: the backdrop dims, and the card fades up from + * [ConsoleMotion.EXIT_SCALE] on the console's own curve. Deliberately NOT a spring — an overshoot + * on a modal reads as bounciness rather than as arrival, which is why the desktop's dialogs don't + * have one either. Collapses to a plain fast fade under reduce-motion. + */ +@Composable +fun ConsoleModal(content: @Composable () -> Unit) { + val ink = LocalGamepadInk.current + val animated = animationsEnabled() + val enter = remember { Animatable(0f) } + LaunchedEffect(Unit) { + enter.animateTo( + 1f, + ConsoleMotion.ease(if (animated) ConsoleMotion.TRANSITION_MS else ConsoleMotion.REDUCED_MS), + ) + } + Box( + Modifier + .fillMaxSize() + .background(ink.modalScrim.copy(alpha = ink.modalScrim.alpha * enter.value)), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier.graphicsLayer { + alpha = enter.value + val s = if (animated) { + ConsoleMotion.EXIT_SCALE + (1f - ConsoleMotion.EXIT_SCALE) * enter.value + } else { + 1f + } + scaleX = s + scaleY = s + }, + ) { + content() + } + } +} + +/** + * The frosted band that carries the FOCUSED row's description, floating above a screen's legend + * pill rather than unfolding inside the row itself. + * + * This is the desktop console's reserved detail band (`screens/settings.rs`) achieved by FLOAT + * instead of by subtraction: the band lives in the screen's bottom-start overlay, so it can never + * displace the list behind it — which is the whole point. Growing the row instead (what this + * replaces) shifted every row below the cursor on every single D-pad step, and fought the + * keep-focus-visible scroll, whose target moved out from under it mid-animation. + * + * [key] is what the cross-fade keys on — the focused row's id, so stepping between two rows that + * happen to share a description doesn't flicker. + */ +@Composable +fun ConsoleDetailBand( + text: String, + key: Any?, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, +) { + val ink = LocalGamepadInk.current + AnimatedContent( + targetState = key to text, + transitionSpec = { + fadeIn(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) togetherWith + fadeOut(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) + }, + modifier = modifier, + label = "detail", + ) { (_, body) -> + if (body.isBlank()) { + // An empty band still occupies its slot in the AnimatedContent so the pill below it + // doesn't hop up and down as focus crosses a row with no description. + Spacer(Modifier.height(0.dp)) + } else { + Text( + body, + style = MaterialTheme.typography.bodySmall, + color = ink.fg(0.6f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .widthIn(max = 560.dp) + .clip(ConsoleShape.Band) + .then( + if (hazeState != null) { + Modifier.hazeEffect(hazeState).background(ink.shade(0.25f)) + } else { + Modifier.background(ink.shade(0.55f)) + }, + ) + .padding(horizontal = 14.dp, vertical = 9.dp), + ) + } + } +} + +/** + * The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet + * track that tints as it engages while the knob slides across on a spring — the state change reads + * from across the room, and the motion confirms the press. + * + * The knob SQUASHES as it travels (widest at mid-flight, round at either rest) — the elastic cue + * that makes a slide read as a thrown object rather than a repositioned dot. Taken from the + * travel itself rather than from a velocity read, so it can't disagree with where the knob is. + */ +@Composable +fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current + val travel by animateFloatAsState( + targetValue = if (on) 1f else 0f, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f), + label = "switchKnob", + ) + val track by animateColorAsState( + if (on) ink.accent else ink.fg(0.15f), + ConsoleMotion.ease(200), + label = "switchTrack", + ) + val outline by animateColorAsState( + ink.fg(if (focused) 0.45f else 0.15f), + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), + label = "switchOutline", + ) + val trackW = 44.dp + val trackH = 24.dp + val pad = 3.dp + val knob = trackH - pad * 2 + Box( + modifier + .size(trackW, trackH) + .clip(ConsoleShape.Pill) + .background( + Brush.verticalGradient( + listOf(track.copy(alpha = track.alpha * 0.82f), track), + ), + ) + .border(1.dp, outline, ConsoleShape.Pill), + contentAlignment = Alignment.CenterStart, + ) { + // 1 at either rest, 0 at mid-flight — so the squash peaks exactly where the knob is fastest. + val settle = abs(travel * 2f - 1f) + Box( + Modifier + .padding(horizontal = pad) + .offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) } + .graphicsLayer { + scaleX = 1f + 0.15f * (1f - settle) + scaleY = 1f - 0.07f * (1f - settle) + } + .size(knob) + .clip(CircleShape) + .background(ink.fg), + ) + } +} + +// --- Menu haptics ----------------------------------------------------------------------------- + +/** + * The console's menu feel: a tick as the cursor moves, a heavier thud when a press is refused at a + * boundary, a pulse on confirm. The Apple client's `MenuHaptics` semantics and the desktop's + * `menu_rumble(pulse)`, on whatever actuator this device actually has. + * + * Constructed by [rememberConsoleHaptics], which resolves the actuator in the order that matches + * where the player's hands are: the DRIVING controller's own vibrator first, then the phone body + * (which is what a clip-on pad without motors leaves you holding), and nothing at all on a TV — a + * remote has no motor and a TV box has no body, so both resolve null and every call is a no-op. + */ +class ConsoleHaptics internal constructor(private val vibrator: Vibrator?) { + /** The cursor moved one step. */ + fun tick() = play(7, 40) + + /** The press was refused — the list ended, or the value is already at its limit. */ + fun boundary() = play(18, 95) + + /** Something was confirmed, cycled, or flipped. */ + fun confirm() = play(12, 70) + + private fun play(durationMs: Long, amplitude: Int) { + val v = vibrator ?: return + runCatching { + v.vibrate( + if (v.hasAmplitudeControl()) { + VibrationEffect.createOneShot(durationMs, amplitude) + } else { + VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE) + }, + ) + } + } +} + +/** A haptics object that renders nothing — previews, tests, and every device with no actuator. */ +private val SilentHaptics = ConsoleHaptics(null) + +/** + * Resolve the console's haptics for whatever is driving the UI right now. Re-resolves when the + * driving pad changes (a fresh controller may have motors where the last one had none), and honours + * the system's own "touch feedback" switch — a user who turned haptics off meant it here too. + */ +@Composable +fun rememberConsoleHaptics(): ConsoleHaptics { + val context = LocalContext.current + val activity = context as? MainActivity ?: return SilentHaptics + val padId = activity.lastPadDeviceId + @Suppress("DEPRECATION") // still the canonical read for the user's touch-feedback switch + val enabled = remember { + runCatching { + android.provider.Settings.System.getInt( + context.contentResolver, + android.provider.Settings.System.HAPTIC_FEEDBACK_ENABLED, + 1, + ) != 0 + }.getOrDefault(true) + } + return remember(padId, enabled) { + if (!enabled) SilentHaptics + else ConsoleHaptics(padVibrator(padId) ?: deviceBodyVibrator(context)) + } +} + +/** The vibrator of the controller with this device id, or null (no such device / no motors). */ +private fun padVibrator(deviceId: Int): Vibrator? = runCatching { + val dev = InputDevice.getDevice(deviceId) ?: return null + val v = if (Build.VERSION.SDK_INT >= 31) { + dev.vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + dev.vibrator + } + v?.takeIf { it.hasVibrator() } +}.getOrNull() + +// --- Button glyphs and the legend -------------------------------------------------------------- + /** * One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android * `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is @@ -338,97 +824,21 @@ object PadGlyph { val B = Color(0xFFD14B4B) val X = Color(0xFF4B7BD1) val Y = Color(0xFFE0B23C) + + /** The tint the DIRECTIONAL hints (↔ ⇄ ↑ ↓) wear — not a face button, so not a face colour. */ + val Arrow = Color(0xFF9A93C7) + fun hint(glyph: Char, text: String, onClick: (() -> Unit)? = null) = GamepadHint( - glyph, when (glyph) { 'A' -> A; 'B' -> B; 'X' -> X; 'Y' -> Y; else -> Color(0xFF9A93C7) }, text, onClick, + glyph, when (glyph) { 'A' -> A; 'B' -> B; 'X' -> X; 'Y' -> Y; else -> Arrow }, text, onClick, ) } /** The dark button-face fill shared by the PlayStation / Nintendo / select-button badges. */ internal val PadButtonFace = Color(0xFF2A2740) -/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */ -class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: Color) - -/** - * The focus visuals every console form element shares (settings rows, add-host fields, action - * rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and - * resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border - * of a field actively receiving keyboard input. - */ -@Composable -fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals { - val ink = LocalGamepadInk.current - val scale by animateFloatAsState( - targetValue = if (active) 1f else 0.98f, - animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), - label = "consoleScale", - ) - val background by animateColorAsState( - if (active) ink.accent(0.20f) else ink.glass, - tween(160), - label = "consoleBg", - ) - val border by animateColorAsState( - when { - editing -> ink.accent(0.70f) - active -> ink.fg(0.28f) - else -> ink.fg(0.06f) - }, - tween(160), - label = "consoleBorder", - ) - return ConsoleFocusVisuals(scale, background, border) -} - -/** - * The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet - * track that tints as it engages while the knob slides across on a spring — the state change reads - * from across the room, and the motion confirms the press. - */ -@Composable -fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) { - val ink = LocalGamepadInk.current - val travel by animateFloatAsState( - targetValue = if (on) 1f else 0f, - animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f), - label = "switchKnob", - ) - val track by animateColorAsState( - if (on) ink.accent else Color(0x26FFFFFF), - tween(200), - label = "switchTrack", - ) - val outline by animateColorAsState( - ink.fg(if (focused) 0.45f else 0.15f), - tween(160), - label = "switchOutline", - ) - val trackW = 44.dp - val trackH = 24.dp - val pad = 3.dp - val knob = trackH - pad * 2 - Box( - modifier - .size(trackW, trackH) - .clip(RoundedCornerShape(50)) - .background(track) - .border(1.dp, outline, RoundedCornerShape(50)), - contentAlignment = Alignment.CenterStart, - ) { - Box( - Modifier - .padding(horizontal = pad) - .offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) } - .size(knob) - .clip(CircleShape) - .background(ink.fg), - ) - } -} - /** A round face-button badge: a coloured disc with the button letter, like a controller's face. */ @Composable -fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) { +fun GamepadButtonGlyph(glyph: Char, color: Color, size: Dp = 26.dp) { val ink = LocalGamepadInk.current Box( modifier = Modifier @@ -449,7 +859,7 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit /** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */ @Composable -private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { +private fun SelectGlyph(size: Dp = 26.dp) { val ink = LocalGamepadInk.current Box( modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A), @@ -461,7 +871,7 @@ private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { /** The remote's "Back" button — a back-arrow disc; the TV-remote glyph for B (back / cancel / done). */ @Composable -private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { +private fun BackGlyph(size: Dp = 26.dp) { GamepadButtonGlyph('↩', PadGlyph.B, size) } @@ -472,7 +882,7 @@ private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { * DualShock colours. */ @Composable -internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) { +internal fun PsFaceGlyph(glyph: Char, size: Dp = 26.dp) { val color = when (glyph) { 'A' -> Color(0xFF7C9CE8) // cross — light blue 'B' -> Color(0xFFE0736F) // circle — red @@ -519,7 +929,7 @@ internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) * fallback wears the capsule too (the near-universal select shape). */ @Composable -internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) { +internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: Dp = 26.dp) { val ink = LocalGamepadInk.current Box( Modifier.size(size).clip(CircleShape).background(PadButtonFace), @@ -550,8 +960,8 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u else -> Box( Modifier .size(width = size * 0.58f, height = size * 0.30f) - .clip(RoundedCornerShape(50)) - .border(1.6.dp, ink.fg(0.9f), RoundedCornerShape(50)), + .clip(ConsoleShape.Pill) + .border(1.6.dp, ink.fg(0.9f), ConsoleShape.Pill), ) } } @@ -572,7 +982,7 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze val activity = LocalContext.current as? MainActivity val padIsGamepad = activity?.lastPadIsGamepad ?: true val padStyle = activity?.lastPadStyle ?: Gamepad.PadStyle.GENERIC - val shape = RoundedCornerShape(50) + val shape = ConsoleShape.Pill // With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent // scrim below) + a light tint; otherwise fall back to a solid frosted fill. val frosted = if (hazeState != null) { @@ -582,7 +992,17 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze } Row( modifier = frosted - .border(1.dp, ink.fg(0.14f), shape) + .border( + width = 1.dp, + // The same top-edge highlight the glass rows carry, so the legend belongs to the + // same material rather than reading as a flat sticker over it. + brush = Brush.verticalGradient( + 0f to ink.highlight.copy(alpha = ink.highlight.alpha * 0.7f), + 0.5f to ink.fg(0.14f), + 1f to ink.fg(0.14f), + ), + shape = shape, + ) .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 @@ -595,7 +1015,7 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze for (h in hints) { val cb = h.onClick val cell = if (cb != null) { - Modifier.clip(RoundedCornerShape(50)).clickable(onClick = cb).padding(horizontal = 4.dp, vertical = 5.dp) + Modifier.clip(ConsoleShape.Pill).clickable(onClick = cb).padding(horizontal = 4.dp, vertical = 5.dp) } else { Modifier } @@ -629,7 +1049,7 @@ fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) { val ink = LocalGamepadInk.current Row( modifier = modifier - .clip(RoundedCornerShape(50)) + .clip(ConsoleShape.Pill) .background(ink.fg(0.08f)) .padding(horizontal = 12.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, @@ -646,6 +1066,8 @@ fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) { style = MaterialTheme.typography.labelMedium, color = ink.fg(0.75f), maxLines = 1, + // The chip yields to the screen title on a narrow phone — see GamepadHome's header row. + overflow = TextOverflow.Ellipsis, ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index 4a2911e7..baa16fc4 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -18,7 +17,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -45,7 +43,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -107,18 +104,13 @@ fun GamepadDialog( // the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows // the current action even when the stack scrolls. val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp - Box( - Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), - contentAlignment = Alignment.Center, - ) { + ConsoleModal { Column( Modifier .padding(24.dp) .widthIn(max = 520.dp) .heightIn(max = maxCardHeight) - .clip(RoundedCornerShape(24.dp)) - .background(Color(0xF01A1730)) - .border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) + .consoleCard() .padding(28.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { @@ -150,7 +142,11 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab // that's scrolled out of a short window, pull it into view (no-op when already visible). val intoView = remember { BringIntoViewRequester() } LaunchedEffect(focused) { if (focused) intoView.bringIntoView() } - val shape = RoundedCornerShape(14.dp) + val focus by animateFloatAsState( + if (focused) 1f else 0f, + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), + label = "btnFocus", + ) // Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping. val bg by animateColorAsState( when { @@ -158,32 +154,30 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab primary -> ink.accent(0.20f) else -> ink.glass }, - tween(160), + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), label = "btnBg", ) val fg by animateColorAsState( when { !enabled -> ink.fg(0.35f) - focused -> ink.fg + // On the accent, not on the field — a pale palette's accent decides this, not the ink. + focused -> ink.onAccent primary -> ink.accent else -> ink.fg(0.85f) }, - tween(160), + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), label = "btnFg", ) val borderColor by animateColorAsState( - Color.White.copy(alpha = if (focused) 0.3f else 0.08f), - tween(160), + ink.fg(if (focused) 0.3f else 0.08f), + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), label = "btnBorder", ) Box( modifier = Modifier .fillMaxWidth() .bringIntoViewRequester(intoView) - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(shape) - .background(bg) - .border(1.dp, borderColor, shape) + .consoleGlass(ConsoleShape.Row, ConsoleFocusVisuals(scale, bg, borderColor, focus)) .clickable( enabled = enabled, interactionSource = remember { MutableInteractionSource() }, @@ -305,18 +299,13 @@ fun GamepadPinHostsDialog( }, ) val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp - Box( - Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), - contentAlignment = Alignment.Center, - ) { + ConsoleModal { Column( Modifier .padding(24.dp) .widthIn(max = 520.dp) .heightIn(max = maxCardHeight) - .clip(RoundedCornerShape(24.dp)) - .background(Color(0xF01A1730)) - .border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) + .consoleCard() .padding(28.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { @@ -368,15 +357,11 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () // landscape window pulls itself into view. val intoView = remember { BringIntoViewRequester() } LaunchedEffect(focused) { if (focused) intoView.bringIntoView() } - val shape = RoundedCornerShape(14.dp) Row( Modifier .fillMaxWidth() .bringIntoViewRequester(intoView) - .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } - .clip(shape) - .background(visuals.background) - .border(1.dp, visuals.border, shape) + .consoleGlass(ConsoleShape.Row, visuals) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -598,11 +583,10 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: ) val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp - Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) { + ConsoleModal { Column( Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight) - .clip(RoundedCornerShape(24.dp)) - .background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) + .consoleCard() .verticalScroll(rememberScrollState()) .padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -616,7 +600,7 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) } } - err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) } + err?.let { Text(it, color = ink.danger, style = MaterialTheme.typography.bodyMedium) } DialogButton( label = if (pairing) "Pairing…" else "Pair", focused = slot == 4 && !pairing, @@ -638,6 +622,12 @@ private fun PinSlot(value: Int, focused: Boolean) { .border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape), contentAlignment = Alignment.Center, ) { - Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace) + Text( + value.toString(), + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + color = ink.fg, + fontFamily = FontFamily.Monospace, + ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt index b590e094..594f161e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt @@ -1,8 +1,10 @@ package io.unom.punktfunk import android.content.res.Configuration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -17,7 +19,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PageSize @@ -33,6 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -44,6 +46,7 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -55,6 +58,7 @@ import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import io.unom.punktfunk.kit.security.KnownHost import kotlin.math.absoluteValue +import kotlin.math.cos import kotlinx.coroutines.launch // The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct, @@ -62,6 +66,12 @@ import kotlinx.coroutines.launch // active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add // Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library. +/** + * How far a fully off-centre card turns away from the viewer, in radians (~48°). Never rendered as + * a rotation — see the projection note at the call site. + */ +private const val CARD_TURN_RAD = 0.838f + /** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */ class HomeTile( val id: String, @@ -118,6 +128,16 @@ fun GamepadHome( LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage } val current = tiles.getOrNull(navTarget) + // Bumped on every confirm — the centred card dips under the press and springs back, so A reads + // as a button being pushed rather than as a screen simply changing. + var pressToken by remember { mutableIntStateOf(0) } + val press = remember { Animatable(1f) } + LaunchedEffect(pressToken) { + if (pressToken == 0) return@LaunchedEffect + press.animateTo(0.97f, ConsoleMotion.ease(70)) + press.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = Spring.StiffnessMedium)) + } + GamepadNavEffect( active = navActive && tiles.isNotEmpty(), onMove = { dir -> @@ -127,7 +147,8 @@ fun GamepadHome( scope.launch { pagerState.animateScrollToPage(target) } } }, - onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect + // A / D-pad-center → Connect + onActivate = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) }, onSecondary = { // Y (gamepad) → Library tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary) }, @@ -145,9 +166,9 @@ fun GamepadHome( // way. Each hint is also TAPPABLE (touch hatch). val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect" - val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) } + val connectAction: () -> Unit = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) } val optionsAction: () -> Unit = { current?.let(onOptions) } - val arrowTint = Color(0xFF9A93C7) + val arrowTint = PadGlyph.Arrow val hints = buildList { if (padIsGamepad) { add(PadGlyph.hint('A', connectLabel, onClick = connectAction)) @@ -177,7 +198,7 @@ fun GamepadHome( val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp) val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp) val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp) - Box(Modifier.fillMaxSize().systemBarsPadding()) { + Box(Modifier.fillMaxSize().consoleSafeArea()) { HorizontalPager( state = pagerState, pageSize = PageSize.Fixed(cardWidth), @@ -189,17 +210,35 @@ fun GamepadHome( val tile = tiles[page] // Real distance-from-centered (page + fractional drag), so the pop tracks the // live scroll: centered tile at full scale/brightness, neighbours recede + blur. - val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction) - .absoluteValue.coerceIn(0f, 1f) + // Signed, because which SIDE a card fans to decides which edge it turns on. + val signed = (page - pagerState.currentPage) - pagerState.currentPageOffsetFraction + val offset = signed.absoluteValue.coerceIn(0f, 1f) GamepadHostTile( tile = tile, + centred = offset < 0.5f, modifier = Modifier .graphicsLayer { - val s = lerp(1f, 0.86f, offset) + // The press dip applies to the CENTRED card only — it is the one + // the button acted on, and a whole carousel flinching would read + // as the screen moving rather than a card being pressed. + val s = lerp(1f, 0.86f, offset) * lerp(press.value, 1f, offset) scaleX = s scaleY = s alpha = lerp(1f, 0.5f, offset) } + .graphicsLayer { + // The neighbours TURN away, projected rather than rendered in 3D. + // `cos(angle)` as a horizontal squeeze IS the orthographic + // projection of a Y-axis rotation, and hinging it on the edge the + // card fans from is what carries the direction the rotation's sign + // would have. The Apple client arrived here the hard way (see + // GamepadCarousel.swift): a real `rotation3DEffect` renders the + // card through an offscreen pass and flashed as the strip settled. + // Affine transforms don't. + scaleX = cos(CARD_TURN_RAD * offset) + transformOrigin = + TransformOrigin(if (signed > 0f) 0f else 1f, 0.5f) + } // Unbounded so the depth blur isn't hard-clipped at the card's rectangle // (the cut-off edge). No-op below API 31; a soft blur above. .blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded) @@ -209,6 +248,7 @@ fun GamepadHome( indication = null, ) { if (page == navTarget) { + pressToken++ onActivate(tile) } else { navTarget = page @@ -223,20 +263,28 @@ fun GamepadHome( // Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses // the shared ConsoleHeader so it lines up with every other screen's heading. Row( - Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding() + Modifier.align(Alignment.TopStart).fillMaxWidth().consoleSafeArea() .padding(end = ConsoleEdgeInset), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - ConsoleHeader("Select a Host", modifier = Modifier.weight(1f)) - if (controllerName != null) ControllerStatusChip(controllerName) + // The TITLE has priority (unweighted, so it is measured at its full width first) and the + // chip takes what is left, ellipsizing its device name. The other way round — which is + // what a weighted header gave — a talkative controller name ("Xbox Wireless Controller") + // ate a 360 dp portrait phone's title down to "Selec…". + ConsoleHeader("Select a Host") + if (controllerName != null) { + ControllerStatusChip(controllerName, Modifier.weight(1f, fill = false)) + } } // Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE - // it ignores the safe area (the nav-bar inset made the bottom gap look oversized). + // it ignores the system bars (the nav-bar inset made the bottom gap look oversized) but never + // the cutout — reverse-landscape parks the punch on this very corner. Box( Modifier .align(Alignment.BottomStart) - .then(if (landscape) Modifier else Modifier.systemBarsPadding()) + .consoleLegendInsets(landscape) .padding(ConsoleLegendInset), ) { GamepadHintBar(hints, hazeState = hazeState) @@ -244,22 +292,24 @@ fun GamepadHome( } } -/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */ +/** + * One glass landscape console tile — bigger and bolder than the touch grid's HostCard, and cut from + * the same [Modifier.consoleGlass] every console surface is, so a card and a settings row catch the + * light the same way. [centred] is the carousel's own focus: the tile the pad is pointing at, which + * earns the lift and the accent bloom. + */ @Composable -private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { +private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier = Modifier) { val ink = LocalGamepadInk.current - val shape = RoundedCornerShape(26.dp) - val wash = if (tile.filled) { - Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A))) - } else { - Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF))) - } + val visuals = animateConsoleFocus(active = centred) + // A SAVED host wears the palette's accent; a discovered one (or the Add tile) stays neutral + // glass, so "already yours" reads before you get to the label. + val fill = if (tile.filled) ink.accent(0.20f) else ink.glass Column( modifier = modifier .fillMaxWidth() - .clip(shape) - .background(wash) - .border(1.dp, ink.fg(0.16f), shape) + // The carousel already drives its own scale; the glass must not fight it with a second. + .consoleGlass(ConsoleShape.Tile, ConsoleFocusVisuals(1f, fill, ink.fg(0.16f), visuals.focus)) .padding(22.dp), ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { @@ -305,10 +355,13 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { private fun MonogramBadge(tile: HomeTile) { val ink = LocalGamepadInk.current val shape = RoundedCornerShape(15.dp) + // Lit from the top like every other console surface — and the unsaved badge takes the palette's + // own accent at low opacity rather than the brand violet, which on a copper or moss field was + // the one square of the wrong hue on the screen. val fill = if (tile.filled) { - Brush.verticalGradient(listOf(ink.accent, ink.accent)) + Brush.verticalGradient(listOf(ink.accent.copy(alpha = 0.92f), ink.accent)) } else { - Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2))) + Brush.verticalGradient(listOf(ink.accent(0.20f), ink.accent(0.14f))) } Box( modifier = Modifier.size(52.dp).clip(shape).background(fill), diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt index a4546749..07b47f7f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt @@ -32,6 +32,31 @@ class GamepadInk( val shadeScale: Float, /** True when the field is pale, for the few places that branch rather than blend. */ val isLight: Boolean, + /** + * The near-opaque ground a MODAL card sits on. A dialog can't be glass: it has to occlude the + * screen it covers, and it carries [fg] text — which is why this must follow the palette. It + * was a hardcoded near-black indigo, so on a pale palette the card's dark ink landed on a dark + * card and the dialogs were unreadable. + */ + val card: Color, + /** + * What dims the screen BEHIND a modal. Always dark, whatever the field: a scrim's job is to + * push the backdrop down, and a pale field lit with more white doesn't recede — it glares. A + * pale one needs less of it, because it has further to fall. + */ + val modalScrim: Color, + /** + * The light a glass surface catches along its top edge. White either way — a highlight is a + * specular, not a tint — but a pale field's frost is already bright, so it takes MORE to read + * as an edge against the pastel showing through it. + */ + val highlight: Color, + /** + * What a failure says itself in — the pairing error, and anything else the console has to + * refuse in words. Follows the palette because it lands on [card], not on the field: the salmon + * that reads on a dark modal is washed out on a near-white one. + */ + val danger: Color, ) { /** The foreground at [alpha]. */ fun fg(alpha: Float): Color = fg.copy(alpha = alpha) @@ -50,6 +75,7 @@ class GamepadInk( val accentLuma = 0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third val onAccent = if (accentLuma > 0.55) Color.Black else Color.White + val (gr, gg, gb) = p.ground if (!p.light) { return GamepadInk( fg = Color.White, @@ -59,9 +85,20 @@ class GamepadInk( shade = Color.Black, shadeScale = 1f, isLight = false, + // The palette's own ground, lifted just off it so the card reads as a surface + // ABOVE the field rather than a hole in it. For the brand violet that lands on + // the #1A1730 the dialogs were hardcoded to, which is where the number came from. + card = Color( + (gr + 0.030).toFloat().coerceAtMost(1f), + (gg + 0.030).toFloat().coerceAtMost(1f), + (gb + 0.040).toFloat().coerceAtMost(1f), + 0.94f, + ), + modalScrim = Color.Black.copy(alpha = 0.62f), + highlight = Color.White.copy(alpha = 0.30f), + danger = Color(0xFFE0736F), ) } - val (gr, gg, gb) = p.ground return GamepadInk( // Tinted toward the palette's own ground so it doesn't read as a foreign grey. fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()), @@ -73,6 +110,15 @@ class GamepadInk( shade = Color.White, shadeScale = 0.45f, isLight = true, + // Near-white rather than near-black: the card carries this palette's DARK ink. + card = Color.White.copy(alpha = 0.94f), + // Lighter than the dark field's: a pastel backdrop is closer to the card already, + // so the same 0.62 would read as a bruise rather than a recession. + modalScrim = Color.Black.copy(alpha = 0.38f), + highlight = Color.White.copy(alpha = 0.55f), + // Deepened for the near-white card the pale palettes' modals use — the dark + // field's salmon has nothing like enough contrast against it. + danger = Color(0xFFB3352F), ) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt index fe64b01f..73782564 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt @@ -65,6 +65,10 @@ fun GamepadNavEffect( ) { val activity = LocalContext.current as? MainActivity ?: return val state = remember { NavInputState() } + // Menu feel, inherited by every console screen that navigates through here rather than wired + // per screen: a tick as the cursor steps, a pulse on confirm. Renders on the driving pad's own + // motors, the phone body if it has none, and nothing at all on a TV. + val haptics by rememberUpdatedState(rememberConsoleHaptics()) // The effects below are keyed on `active` only (they must NOT restart on every recomposition), so // they'd otherwise capture the FIRST callbacks — closing over a stale `tiles` (fewer hosts than are // discovered later, which clamped navigation to that old count). rememberUpdatedState keeps the @@ -98,7 +102,10 @@ fun GamepadNavEffect( KeyEvent.KEYCODE_DPAD_UP -> { if (edge) currentOnUp(); true } KeyEvent.KEYCODE_DPAD_DOWN -> { if (edge) currentOnDown(); true } KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true } + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { + if (edge) { haptics.confirm(); currentOnActivate() } + true + } // The gamepad Select / View / Share button → context options (a remote uses Down). KeyEvent.KEYCODE_BUTTON_SELECT -> { if (edge) currentOnOptions(); true } KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true } @@ -139,8 +146,10 @@ fun GamepadNavEffect( } when { dir == 0 -> committed = 0 - dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS } - now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS } + dir != committed -> { + haptics.tick(); currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS + } + now >= fireAt -> { haptics.tick(); currentOnMove(dir); fireAt = now + REPEAT_MS } } delay(16) } @@ -167,6 +176,9 @@ fun GamepadNavEffect2D( ) { val activity = LocalContext.current as? MainActivity ?: return val state = remember { NavInputState() } + // See [GamepadNavEffect] — the same menu feel, so a form screen and a carousel answer a press + // identically. + val haptics by rememberUpdatedState(rememberConsoleHaptics()) val currentOnDirection by rememberUpdatedState(onDirection) val currentOnActivate by rememberUpdatedState(onActivate) val currentOnTertiary by rememberUpdatedState(onTertiary) @@ -196,12 +208,15 @@ fun GamepadNavEffect2D( KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true } KeyEvent.KEYCODE_DPAD_DOWN -> { state.dpadY = if (down) 1 else 0; true } KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true } + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { + if (edge) { haptics.confirm(); currentOnActivate() } + true + } KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true } KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true } // 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 } + KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) { haptics.tick(); currentOnShoulder(-1) }; true } + KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) { haptics.tick(); currentOnShoulder(1) }; true } else -> false // B → MainActivity (remapped to BACK → BackHandler) } } @@ -229,8 +244,10 @@ fun GamepadNavEffect2D( when { raw == null && nearCentre -> committed = null raw == null -> { /* in the hysteresis band → hold, don't fire */ } - raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS } - now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS } + raw != committed -> { + haptics.tick(); currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS + } + now >= fireAt -> { haptics.tick(); currentOnDirection(raw); fireAt = now + REPEAT_MS } } delay(16) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt index 92cb0a41..dd3011f4 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt @@ -17,6 +17,21 @@ import androidx.compose.ui.graphics.Color // on every client. Keep the three copies in step: a palette added here without the others is a // value the other clients silently render as Violet. +/** + * One wandering interior control point of the mesh: [x]/[y] its resting place in unit UV, [amp] how + * far it strays, [sx]/[sy] its per-axis rates in rad·s⁻¹ and [phase] its offset. Its live + * displacement `(amp·sin(t·sx+ph), amp·cos(t·sy+ph·1.3))` drives a bounded domain warp, so the + * bright colour pools drift with it. + */ +class MeshWarpPoint( + val x: Double, + val y: Double, + val amp: Double, + val sx: Double, + val sy: Double, + val phase: Double, +) + /** One background colour family. */ class GamepadPalette( /** The stored `ui_palette` value ([Settings.uiPalette]). */ @@ -47,11 +62,29 @@ class GamepadPalette( /** The accent as a Compose colour. */ val accentColor: Color by lazy { color(accent) } + /** + * The 16 mesh colours this palette's field is woven from: the ramp sampled per cell (see + * [CELL_RAMP]), or [MESH_COLORS] verbatim for the brand default — the exact rule + * `pf-console-ui`'s `Palette::mesh_colors` follows, so one `ui_palette` value is one field on + * every client. Consumed by the AGSL backdrop on API 33+; the blob field + * ([blobColors]) approximates the same table below that. + */ + val meshColors: List> by lazy { + if (stops.isEmpty()) { + MESH_COLORS + } else { + (0..15).map { i -> + ramp(stops, 0.5 * ((i % 4) / 3.0 + (i / 4) / 3.0) + CELL_RAMP[i]) + } + } + } + companion object { /** - * Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept - * here so the three ports stay one table even though this client approximates the field - * with blobs. + * Where each of the 16 mesh cells samples the ramp. The base is the diagonal + * `0.5·(x + y)` — top-left the ramp's dark end, bottom-right its bright one — and the + * per-cell nudges break the banding a pure diagonal would show. Mirrored from + * `pf-console-ui`'s `CELL_RAMP`. */ val CELL_RAMP = listOf( 0.10, -0.06, 0.04, -0.12, @@ -60,6 +93,34 @@ class GamepadPalette( -0.10, 0.08, -0.06, 0.12, ) + /** + * The brand default's 16 mesh colours, used verbatim (rather than sampled from a ramp) so + * `violet` stays bit-identical to what every install already sees. Mirrors + * `pf-console-ui`'s `MESH_COLORS`. + */ + val MESH_COLORS = listOf( + Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72), + Triple(0.30, 0.26, 0.74), Triple(0.075, 0.060, 0.160), + 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.075, 0.060, 0.160), Triple(0.22, 0.18, 0.54), + Triple(0.24, 0.20, 0.58), Triple(0.075, 0.060, 0.160), + ) + + /** + * The four interior points that wander; the 12 boundary points stay pinned to the frame (a + * drifting edge point would shrink the field and expose the ground behind it). Periods + * ~90–130 s, out of phase, so the field never visibly loops. Mirrors `MESH_INTERIOR`. + */ + val MESH_INTERIOR = listOf( + MeshWarpPoint(0.333, 0.333, 0.11, 0.049, 0.063, 0.4), + MeshWarpPoint(0.667, 0.333, 0.10, 0.055, 0.052, 2.1), + MeshWarpPoint(0.333, 0.667, 0.10, 0.058, 0.049, 3.6), + MeshWarpPoint(0.667, 0.667, 0.12, 0.047, 0.061, 5.0), + ) + /** The brand default's blob ramp — the colours the pre-palette field used. */ private val VIOLET_BLOBS = listOf( Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80), diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 040152fa..306083a1 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -3,20 +3,18 @@ package io.unom.punktfunk import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -27,13 +25,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -46,13 +44,14 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState @@ -176,10 +175,15 @@ fun GamepadSettingsScreen( var focus by remember { mutableIntStateOf(0) } if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0) + // Which way the section last moved (+1 forward / -1 back) — the row list slides in from that + // side, so stepping sections reads as travelling along a strip rather than teleporting. + var tabDir by remember { mutableIntStateOf(1) } + // 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 + tabDir = if (next.ordinal > tab.ordinal) 1 else -1 tab = next // Clamp: a tab's length follows the hardware and the catalog, so a remembered index can // outlive the row it pointed at. @@ -188,15 +192,36 @@ fun GamepadSettingsScreen( } fun stepTab(delta: Int) { val all = GpTab.entries - selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size]) + val next = all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size] + selectTab(next) + // A wrap (last → first) is still a step in the direction you pressed, whatever the ordinals + // say — selectTab's ordinal compare would read it backwards. + tabDir = delta } // 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) } + // Bumped on every ACCEPTED step of the focused row (the chevron ticks) and every REFUSED one + // (the value gives a little and springs back). A press always gets an answer, even "no". + var stepToken by remember { mutableIntStateOf(0) } + var refusalToken by remember { mutableIntStateOf(0) } val listState = rememberLazyListState() + val haptics = rememberConsoleHaptics() val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + // Step the focused row's value, answering a refusal rather than swallowing it. + fun step(delta: Int) { + adjustDir = delta + val row = liveRow(rows, focus) + if (row != null && row.adjust(delta)) { + stepToken++ + } else { + refusalToken++ + haptics.boundary() + } + } + BackHandler(onBack = onBack) GamepadNavEffect2D( // The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen @@ -209,10 +234,8 @@ fun GamepadSettingsScreen( // 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) } + NavDir.LEFT -> if (tabFocused) stepTab(-1) else step(-1) + NavDir.RIGHT -> if (tabFocused) stepTab(1) else step(1) } }, // A on the strip drops into the section you picked, which is what "confirm" means there. @@ -225,6 +248,7 @@ fun GamepadSettingsScreen( // 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. + val legendClearancePx = with(LocalDensity.current) { ConsoleLegendClearance.roundToPx() } LaunchedEffect(focus, tab) { runCatching { val itemIndex = focus + 1 @@ -232,19 +256,35 @@ fun GamepadSettingsScreen( val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex } val offScreen = item == null || item.offset < info.viewportStartOffset || - item.offset + item.size > info.viewportEndOffset - 96 // keep clear of the floating legend + // The SAME constant the list pads its bottom with, rather than a literal that has + // to be remembered when the legend zone grows — which is exactly how a row ended up + // scrolling to a position the legend then covered. + item.offset + item.size > info.viewportEndOffset - legendClearancePx if (offScreen) listState.animateScrollToItem(itemIndex) } } + // The section slide: one shared list whose CONTENT slides, rather than an AnimatedContent + // holding two LazyColumns — two lists would mean two scroll states fighting over one cursor. + val animated = animationsEnabled() + val tabSlide = remember { Animatable(0f) } + LaunchedEffect(tab, animated) { + if (!animated) { tabSlide.snapTo(0f); return@LaunchedEffect } + tabSlide.snapTo(1f) + tabSlide.animateTo(0f, ConsoleMotion.ease(ConsoleMotion.TAB_MS)) + } + val tabSlidePx = with(LocalDensity.current) { ConsoleMotion.TAB_SLIDE.toPx() } + val hazeState = remember { HazeState() } Box(Modifier.fillMaxSize()) { // Everything scrolls — including the heading — so nothing is pinned. Vital in landscape, // where a fixed title + a fixed detail/legend strip ate most of the (short) height. Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + // The backdrop stays full-bleed under the cutout and the bars — it is ambience. Only + // the CONTENT column takes the safe area. GamepadFormBackground(Modifier.fillMaxSize()) - Column(Modifier.fillMaxSize().systemBarsPadding()) { + Column(Modifier.fillMaxSize().consoleSafeArea()) { // 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 @@ -258,8 +298,19 @@ fun GamepadSettingsScreen( ) LazyColumn( state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp), + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationX = tabSlidePx * tabSlide.value * tabDir + alpha = 1f - 0.85f * tabSlide.value + }, + contentPadding = PaddingValues( + start = ConsoleEdgeInset, + end = ConsoleEdgeInset, + top = 8.dp, + // Clears the whole floating legend ZONE — the detail band as well as the pill. + bottom = ConsoleLegendClearance, + ), verticalArrangement = Arrangement.spacedBy(6.dp), ) { item(key = "__title") { @@ -269,10 +320,14 @@ fun GamepadSettingsScreen( ConsoleHeader("Default settings", horizontalInset = false) } itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> + val rowFocused = index == focus && !tabFocused SettingRowView( row, - focused = index == focus && !tabFocused, + focused = rowFocused, adjustDir = adjustDir, + // Only the focused row can be stepped, so only it needs to answer one. + stepToken = if (rowFocused) stepToken else 0, + refusalToken = if (rowFocused) refusalToken else 0, onClick = { // Same inertness as the pad path above — tapping a dimmed row focuses it // (so its detail explains itself) but never flips it. @@ -286,12 +341,15 @@ fun GamepadSettingsScreen( } } - // Floating frosted legend — a real backdrop blur of the rows scrolling behind it (no dedicated - // strip). In landscape it ignores the safe area so it hugs the corner instead of the nav-bar inset. + // The floating legend ZONE: the focused row's description above, the controls pill below, + // both frosted over whatever scrolls behind them. It is an OVERLAY, so nothing in it can + // ever displace the list — which is the whole reason the detail moved here out of the row. + // In landscape it ignores the system bars so it hugs the corner instead of the nav-bar + // inset, but it still takes the display cutout (reverse-landscape parks the punch here). Box( Modifier .align(Alignment.BottomStart) - .then(if (landscape) Modifier else Modifier.systemBarsPadding()) + .consoleLegendInsets(landscape) .padding(ConsoleLegendInset), ) { // The legend follows the focused row (the desktop console's hints() does the same): @@ -306,31 +364,40 @@ fun GamepadSettingsScreen( // 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) }) + GamepadHint('⇄', PadGlyph.Arrow, "Section", onClick = { stepTab(1) }) .takeIf { padIsGamepad }, ) - GamepadHintBar( - 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( + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ConsoleDetailBand( + // On the strip there is no row to describe, and the pills already name the + // sections — a stale row's description there would describe the wrong thing. + text = if (tabFocused) "" else focused?.detail.orEmpty(), + key = if (tabFocused) "__strip" else focused?.id, + hazeState = hazeState, + ) + GamepadHintBar( + if (tabFocused) listOf( + GamepadHint('↔', PadGlyph.Arrow, "Section"), + PadGlyph.hint('A', "Open") { tabFocused = false }, PadGlyph.hint('B', "Done", onClick = onBack), - ) - focused != null && !focused.adjustable -> listOf( - PadGlyph.hint('A', "Pin to hosts") { focused.activate() }, - PadGlyph.hint('B', "Done", onClick = onBack), - ) - else -> listOf( - GamepadHint('↔', Color(0xFF9A93C7), "Adjust"), - // Tappable too (touch escape hatch): Change cycles the focused row, Done leaves. - PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() }, - PadGlyph.hint('B', "Done", onClick = onBack), - ) - }, - hazeState = hazeState, - ) + ) else sections + when { + focused != null && !focused.enabled -> listOf( + PadGlyph.hint('B', "Done", onClick = onBack), + ) + focused != null && !focused.adjustable -> listOf( + PadGlyph.hint('A', "Pin to hosts") { focused.activate() }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) + else -> listOf( + GamepadHint('↔', PadGlyph.Arrow, "Adjust"), + // Tappable too (touch hatch): Change cycles the focused row, Done leaves. + PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) + }, + hazeState = hazeState, + ) + } } // The pin-to-hosts picker for the activated profile row — the console counterpart of the @@ -347,24 +414,62 @@ fun GamepadSettingsScreen( } } +/** + * One settings row. Its geometry NEVER changes with focus — that is the whole design of it. + * + * It used to unfold its description in place, which meant every D-pad step shrank one row and grew + * another, shifting the entire list under the cursor and moving the keep-focus-visible scroll's + * target out from under it mid-animation. The description now lives in the screen's floating + * [ConsoleDetailBand], which is an overlay and cannot displace anything. Focus changes colour, + * lift and bloom here; it does not change size. + * + * The value gets the same treatment sideways: a fixed minimum slot, end-aligned, with the size + * transform snapped so the slot's WIDTH never animates. Stepping a choice used to widen and narrow + * that slot on every press, walking the ‹ chevron back and forth. Tabular figures finish the job — + * without them `1920 × 1080 → 2560 × 1440` changes width on the digits alone. + */ @Composable -private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) { +private fun SettingRowView( + row: GpRow, + focused: Boolean, + adjustDir: Int, + stepToken: Int, + refusalToken: Int, + onClick: () -> Unit, +) { val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused) - val shape = RoundedCornerShape(14.dp) // The chevrons keep their layout slot and only fade, so the value never jumps sideways when // focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row // navigates, the empty-catalog placeholder does nothing) never shows them at all. val chevronAlpha by animateFloatAsState( if (focused && row.adjustable) 0.6f else 0f, - tween(160), + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), label = "chevrons", ) val valueColor by animateColorAsState( ink.fg(if (focused) 1f else 0.6f), - tween(160), + ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), label = "valueColor", ) + // A press always gets an answer. Accepted: the chevron on the pressed side ticks outward and + // springs back. Refused: the whole value slot gives 4 dp toward the press and springs back — + // the "door is locked" motion, so a limit reads as a limit instead of as a dropped input. + val chevronKick = remember { Animatable(0f) } + LaunchedEffect(stepToken) { + if (stepToken == 0) return@LaunchedEffect + chevronKick.snapTo(2f * adjustDir) + chevronKick.animateTo(0f, spring(dampingRatio = 0.45f, stiffness = 900f)) + } + val refusal = remember { Animatable(0f) } + LaunchedEffect(refusalToken) { + if (refusalToken == 0) return@LaunchedEffect + refusal.animateTo( + ConsoleMotion.REFUSAL_NUDGE.value * adjustDir, + ConsoleMotion.ease(ConsoleMotion.REFUSAL_MS / 2), + ) + refusal.animateTo(0f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium)) + } Column { if (row.header != null) { Text( @@ -375,74 +480,94 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp), ) } - Column( + Row( modifier = Modifier .fillMaxWidth() - .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } - .clip(shape) - .background(visuals.background) - .border(1.dp, visuals.border, shape) + .consoleGlass(ConsoleShape.Row, visuals) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick, ) .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text( - row.label, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - // A disabled row (the "No profiles yet" placeholder) dims but stays focusable, - // so its detail line can still explain what would go here. - color = ink.fg(if (row.enabled) 1f else 0.45f), - maxLines = 1, - ) - Spacer(Modifier.weight(1f)) + Text( + row.label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + // A disabled row (the "No profiles yet" placeholder) dims but stays focusable, so + // the detail band can still explain what would go here. + color = ink.fg(if (row.enabled) 1f else 0.45f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + // Takes the slack rather than a Spacer doing it, so a long label ellipsizes into + // the room it actually has instead of shoving the value slot off the row. + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Row( + modifier = Modifier.offset { IntOffset(refusal.value.dp.roundToPx(), 0) }, + verticalAlignment = Alignment.CenterVertically, + ) { if (row.toggled != null) { // A toggle is a switch, not text — the sliding knob + tinting track IS the value. ConsoleSwitch(on = row.toggled, focused = focused) } else { - Text("‹ ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) - // The value slides in the direction it was stepped and its width animates, so - // cycling a choice reads as motion through a list rather than a text swap. - AnimatedContent( - targetState = row.value, - transitionSpec = { - val dir = adjustDir - (slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith - (slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using - SizeTransform(clip = false) - }, - label = "value", - ) { value -> - Text( - value, - style = MaterialTheme.typography.bodyMedium, - color = valueColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text( + "‹ ", + color = ink.fg, + modifier = Modifier + .graphicsLayer { alpha = chevronAlpha } + .offset { IntOffset(minOf(chevronKick.value, 0f).dp.roundToPx(), 0) }, + ) + Box( + Modifier.widthIn(min = 96.dp), + contentAlignment = Alignment.CenterEnd, + ) { + // The value slides in the direction it was stepped, so cycling a choice + // reads as motion through a list rather than a text swap — but its slot + // does NOT resize with it (`snap`), which is what used to jiggle the row. + AnimatedContent( + targetState = row.value, + transitionSpec = { + val dir = adjustDir + ( + slideInHorizontally( + ConsoleMotion.ease(ConsoleMotion.VALUE_MS), + ) { w -> w / 2 * dir } + + fadeIn(ConsoleMotion.ease(ConsoleMotion.VALUE_MS)) + ) togetherWith ( + slideOutHorizontally( + ConsoleMotion.ease(ConsoleMotion.VALUE_OUT_MS), + ) { w -> -w / 2 * dir } + + fadeOut(ConsoleMotion.ease(100)) + ) using SizeTransform(clip = false) { _, _ -> snap() } + }, + label = "value", + ) { value -> + Text( + value, + // Tabular figures: every digit the same width, so stepping a + // resolution or a bitrate cannot change the text's width. + style = MaterialTheme.typography.bodyMedium + .copy(fontFeatureSettings = "tnum"), + color = valueColor, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - Text(" ›", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + Text( + " ›", + color = ink.fg, + modifier = Modifier + .graphicsLayer { alpha = chevronAlpha } + .offset { IntOffset(maxOf(chevronKick.value, 0f).dp.roundToPx(), 0) }, + ) } } - // The focused row carries its own one-line description — no dedicated (space-eating) - // detail strip. It unfolds right where you're looking, and the row grows to fit. - AnimatedVisibility( - visible = focused && row.detail.isNotBlank(), - enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)), - exit = fadeOut(tween(90)) + shrinkVertically(tween(150)), - ) { - Text( - row.detail, - style = MaterialTheme.typography.bodySmall, - color = ink.fg(0.6f), - maxLines = 2, - modifier = Modifier.padding(top = 6.dp), - ) - } } } } 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 525ee127..22b123b5 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 @@ -15,12 +15,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -127,7 +125,7 @@ fun LibraryScreen( Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize().hazeSource(hazeState)) { GamepadAuroraBackground(Modifier.fillMaxSize()) - Column(Modifier.fillMaxSize().systemBarsPadding()) { + Column(Modifier.fillMaxSize().consoleSafeArea()) { ConsoleHeader("${host.name} — Library") Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { when (val s = state) { @@ -171,7 +169,7 @@ fun LibraryScreen( // Launching overlay — the connect + host-side game boot takes a moment; block the pad while it runs. if (launching) { Box( - Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)), + Modifier.fillMaxSize().background(ink.modalScrim), contentAlignment = Alignment.Center, ) { Column( @@ -187,7 +185,7 @@ fun LibraryScreen( // screen (ignore the safe area in landscape, where the bottom edge isn't a tap target). Box( Modifier.align(Alignment.BottomStart) - .then(if (landscape) Modifier else Modifier.systemBarsPadding()) + .consoleLegendInsets(landscape) .padding(ConsoleLegendInset), ) { GamepadHintBar( @@ -262,7 +260,9 @@ private fun Coverflow( Text( if (current?.isLauncher == true) "LAUNCHERS" else "GAMES", style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.45f), + // The palette's ink, not white: on a pale field this heading was white on + // near-white and simply wasn't there. + color = ink.fg(0.45f), letterSpacing = 2.sp, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), @@ -349,11 +349,14 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo val ink = LocalGamepadInk.current val candidates = game.art.posterCandidates var idx by remember(game.id) { mutableStateOf(0) } - val shape = RoundedCornerShape(16.dp) + val shape = ConsoleShape.Poster Box( modifier = modifier .clip(shape) - .background(Color(0xFF241F3D)) + // The ground a cover sits on while its art loads (and the permanent one for a launcher + // entry, which rarely has art). Palette-derived rather than a fixed indigo, so a poster + // wall on a pale field isn't a grid of dark holes. + .background(LocalGamepadPalette.current.groundColor) .border(1.dp, ink.fg(0.12f), shape), contentAlignment = Alignment.Center, ) { @@ -383,11 +386,16 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo Text( game.storeLabel, style = MaterialTheme.typography.labelSmall, - color = ink.fg, + // A launcher's badge is brand-filled, so it reads on the ACCENT; a game's sits on + // a plain dark wash over its own art. + color = if (game.isLauncher) ink.onAccent else Color.White, modifier = Modifier - .clip(RoundedCornerShape(50)) + .clip(ConsoleShape.Pill) .background( - if (game.isLauncher) MaterialTheme.colorScheme.primary + // The console's palette accent, not `MaterialTheme.colorScheme.primary` — + // that is the TOUCH theme's colour (Material You, seeded from the user's + // wallpaper), which had nothing to do with the field this poster sits on. + if (game.isLauncher) ink.accent else Color.Black.copy(alpha = 0.5f), ) .padding(horizontal = 8.dp, vertical = 3.dp), diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index 0b4ab965..a4866715 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -142,6 +143,16 @@ class MainActivity : ComponentActivity() { var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC) private set + /** + * The `InputDevice.id` of the controller driving the console UI, or 0 for none. Kept beside + * [lastPadStyle] because the console's menu haptics render on the DRIVING pad's own motors when + * it has any — a rumble that comes out of the device you are not holding is worse than none. + * Falls back to the phone body (see `rememberConsoleHaptics`), and to silence on a TV, where + * neither a remote nor the box has an actuator. + */ + var lastPadDeviceId by mutableIntStateOf(0) + private set + /** * A `punktfunk://` URL waiting to be routed — set from the VIEW intent that started (or * re-entered) this activity, cleared by whoever handles it. Compose observes it. @@ -606,7 +617,10 @@ class MainActivity : ComponentActivity() { // pad, WHICH pad family, so the glyphs wear its lettering/shapes. if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) { lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD) - if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device) + if (lastPadIsGamepad) { + lastPadStyle = Gamepad.styleFor(event.device) + lastPadDeviceId = event.deviceId + } } // The Controllers debug screen sees pad events before the navigation remap below. padKeyProbe?.let { if (it(event)) return true } @@ -695,6 +709,7 @@ class MainActivity : ComponentActivity() { if (dir != 0) { lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad lastPadStyle = Gamepad.styleFor(event.device) + lastPadDeviceId = event.deviceId super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir)) super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir)) return true diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsLayoutTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsLayoutTest.kt new file mode 100644 index 00000000..cdc10332 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsLayoutTest.kt @@ -0,0 +1,111 @@ +package io.unom.punktfunk + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The console settings list must not MOVE under the cursor. This is the regression net for the + * layout instability the visual refresh fixed, and it needs the real Compose runtime because the + * bug was entirely a layout one — every value the model held was correct throughout. + * + * What used to happen: the focused row unfolded its description in place + * (`AnimatedVisibility` + `expandVertically`), so every step of the cursor shrank one row and grew + * another and shifted every row below the focus point — on a list that is simultaneously being + * scrolled to keep the focused row visible, whose target therefore moved mid-animation. The + * description now renders in the screen's floating `ConsoleDetailBand`, which is an overlay and + * cannot displace anything. Sideways, the value's `AnimatedContent` animated its own WIDTH on every + * step, walking the ‹ chevron and the label's right edge back and forth. + * + * Focus is moved by TAP here rather than by pad: the pad path needs a `MainActivity` for its input + * probes, and the screen routes both to the same `focus` state — the geometry under test is the + * same either way. + * + * `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36 + * while the app compiles against 37. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi") +class GamepadSettingsLayoutTest { + @get:Rule + val compose = createAndroidComposeRule() + + private fun settings() { + compose.setContent { + GamepadSettingsScreen(initial = Settings(), onChange = {}, onBack = {}) + } + } + + /** A Dp compared at hairline tolerance — a rounding difference is not a layout shift. */ + private fun assertSame(what: String, expected: Dp, actual: Dp) { + assertEquals(what, expected.value.toDouble(), actual.value.toDouble(), 0.5) + } + + /** + * Moving the cursor down the list leaves every OTHER row exactly where it was. The rows below + * the new focus are the ones the old in-row detail pushed around, so they are the assertion + * that matters; the row above proves the shrink half. + */ + @Test + fun focusingARowMovesNoOtherRow() { + settings() + // Entry focus is the first row (Resolution), so "Refresh rate" starts unfocused and + // "Compositor" sits below both candidates. + val refreshBefore = compose.onNodeWithText("Refresh rate").getBoundsInRoot() + val compositorBefore = compose.onNodeWithText("Compositor").getBoundsInRoot() + + // One tap on an unfocused row focuses it (a second would activate it — see the screen). + compose.onNodeWithText("Bitrate").performClick() + compose.waitForIdle() + + val refreshAfter = compose.onNodeWithText("Refresh rate").getBoundsInRoot() + val compositorAfter = compose.onNodeWithText("Compositor").getBoundsInRoot() + assertSame("row above the cursor moved", refreshBefore.top, refreshAfter.top) + assertSame("row below the cursor moved", compositorBefore.top, compositorAfter.top) + } + + /** + * Stepping a value leaves the row's own geometry alone. The label's right edge is the probe: + * it is what the widening value slot used to shove, and it is stable for any value that fits + * the slot (which every shipped Bitrate label does). + */ + @Test + fun steppingAValueMovesNoLabel() { + settings() + compose.onNodeWithText("Bitrate").performClick() // focus it + compose.waitForIdle() + val labelBefore = compose.onNodeWithText("Bitrate").getBoundsInRoot() + + compose.onNodeWithText("Bitrate").performClick() // now activates → cycles the value + compose.waitForIdle() + + val labelAfter = compose.onNodeWithText("Bitrate").getBoundsInRoot() + assertSame("label moved sideways under a value step", labelBefore.left, labelAfter.left) + assertSame("label moved sideways under a value step", labelBefore.right, labelAfter.right) + assertSame("row changed height under a value step", labelBefore.top, labelAfter.top) + } + + /** + * The focused row's description is on screen — in the floating band, not inside the row. Proves + * the detail did not simply get dropped when it left the row: it is still what the cursor + * explains itself with. + */ + @Test + fun theFocusedRowsDetailIsShown() { + settings() + compose.onNodeWithText("Refresh rate").performClick() + compose.waitForIdle() + compose.onNodeWithText("Frame rate the host renders and streams at.").assertExists() + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 8af7d299..7e9ede3f 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -114,6 +114,32 @@ class ScreenshotTest { fun consoleSettingsLight() = shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") } + // The console home, the screen the living backdrop is most of. The default sdk (36) draws the + // real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two + // renderings of the same palette can be compared rather than assumed equivalent. + @Test + fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() } + + @Test + fun consoleHomeLight() = shootRoot("console-home-light") { ConsoleHomeScene(paletteId = "holo") } + + /** + * Landscape — the orientation the console UI actually runs in, and the only one wide enough to + * show the carousel's NEIGHBOURS, which is where the projected turn (`CARD_TURN_RAD`) lives. + */ + @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") + fun consoleHomeLandscape() = shootRoot("console-home-landscape") { ConsoleHomeScene() } + + /** + * The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four + * drifting blobs — an honest approximation rather than an emulation, and the thing this shot + * exists to keep honest. + */ + @Test + @Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi") + fun consoleHomeBlobFallback() = shootRoot("console-home-blobs") { ConsoleHomeScene() } + @Test fun trust() = shootScreen("trust") { HostsScene() diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 31123c98..2a58d78c 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -32,9 +32,11 @@ import io.unom.punktfunk.ConnectModal import io.unom.punktfunk.ConnectPhase import io.unom.punktfunk.ConnectTakeover import androidx.compose.runtime.CompositionLocalProvider +import io.unom.punktfunk.GamepadHome import io.unom.punktfunk.GamepadInk import io.unom.punktfunk.GamepadPalette import io.unom.punktfunk.GamepadSettingsScreen +import io.unom.punktfunk.HomeTile import io.unom.punktfunk.LocalGamepadInk import io.unom.punktfunk.LocalGamepadPalette import io.unom.punktfunk.Settings @@ -427,6 +429,44 @@ internal fun ConnectConsoleScene() = * stand in for it: this is a different screen with different navigation, and the strip is the part * a layout regression would eat first. */ +/** + * The console HOME — the host carousel over the living backdrop, which is the screen the aurora is + * most of. Worth its own shot for exactly that reason: on API 33+ the field is the real bicubic + * MESH (`GamepadAurora`'s AGSL port of the desktop console's shader) and below it the four-blob + * fallback, and the two are only comparable side by side. The scene composes [GamepadHome] + * directly with mock tiles — it needs no JNI core and no session, unlike the ConnectScreen that + * normally feeds it. + */ +@Composable +internal fun ConsoleHomeScene(paletteId: String = "violet") { + val palette = GamepadPalette.named(paletteId) + val tiles = listOf( + HomeTile( + id = "living", title = "Living Room PC", subtitle = "192.168.1.42 · Paired", + filled = true, online = true, paired = true, activate = {}, + ), + HomeTile( + id = "studio", title = "studio-deck", subtitle = "192.168.1.61 · Discovered", + online = true, activate = {}, + ), + HomeTile(id = "add", title = "Add Host", subtitle = "By address", isAdd = true, activate = {}), + ) + CompositionLocalProvider( + LocalGamepadPalette provides palette, + LocalGamepadInk provides GamepadInk.of(palette), + ) { + GamepadHome( + tiles = tiles, + libraryEnabled = true, + controllerName = "Xbox Wireless Controller", + navActive = false, + onActivate = {}, + onOpenLibrary = {}, + onOpenSettings = {}, + ) + } +} + @Composable internal fun ConsoleSettingsScene(paletteId: String = "violet") { // The scene calls the screen directly, so it has to publish the palette locals `App` would