diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 9707c2c0..eda12c3f 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -184,6 +184,26 @@ jobs: working-directory: clients/android run: ./gradlew :kit:testDebugUnitTest --stacktrace + # The cross-client contract in `clients/shared/console-vectors.json` — the console palette + # table, the settings section names and the screen-transition motion, each of which exists in + # three hand-written copies (here, pf-console-ui, the Apple client). The other two check it + # from their own suites; this is Android's side. + # + # FILTERED, not a plain `:app:testDebugUnitTest`: that task also runs the ~20 Roborazzi + # screenshot scenes, which are a release-artifact job (android-screenshots.yml, gated to v* + # tags) and have no business adding a minute to every push. The filter is what lets the + # contract gate here without dragging the rest of the app suite in with it. + - name: console parity vectors + app-module logic tests + working-directory: clients/android + run: >- + ./gradlew :app:testDebugUnitTest + --tests 'io.unom.punktfunk.ConsoleVectorsTest' + --tests 'io.unom.punktfunk.HomeTilesTest' + --tests 'io.unom.punktfunk.GamepadSettingsLayoutTest' + --tests 'io.unom.punktfunk.ConsoleSubScreenRowsTest' + --tests 'io.unom.punktfunk.ConsoleSubScreenRoutesTest' + --stacktrace + - name: assembleDebug (cargo-ndk → jniLibs → APK) working-directory: clients/android env: diff --git a/Cargo.lock b/Cargo.lock index d73bce67..ea3d34eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3078,6 +3078,7 @@ dependencies = [ "pf-presenter", "punktfunk-core", "sdl3", + "serde_json", "skia-safe", "tracing", ] diff --git a/clients/android/app/build.gradle.kts b/clients/android/app/build.gradle.kts index 19d171a2..171a617d 100644 --- a/clients/android/app/build.gradle.kts +++ b/clients/android/app/build.gradle.kts @@ -144,6 +144,10 @@ dependencies { testImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host testImplementation("junit:junit:4.13.2") + // Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub + // set whose every method throws "Stub!", so a plain JVM unit test cannot parse with it. Same + // dependency, same reason, as the kit module's deeplink-vectors test. + testImplementation("org.json:json:20250107") testImplementation("org.robolectric:robolectric:4.16.1") testImplementation("io.github.takahirom.roborazzi:roborazzi:1.64.0") testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.64.0") diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/AdaptiveDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/AdaptiveDialogs.kt new file mode 100644 index 00000000..282b537b --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/AdaptiveDialogs.kt @@ -0,0 +1,350 @@ +package io.unom.punktfunk + +import android.os.Build +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import io.unom.punktfunk.models.PendingTrust + +// The prompts that say the SAME thing in both interfaces. +// +// Every one of these existed twice — a Material `AlertDialog` in ConnectDialogs.kt and a console +// glass card in GamepadDialogs.kt — with the two copies maintained by hand. Predictably they +// drifted, and always in the direction of the console losing something: "Pair with PIN…" lost its +// ellipsis, "if no prompt appears when you tap Allow" became "after Allow", and the speed test +// stopped telling console users which layer Apply would write to at all. +// +// What is shared here is the DESCRIPTION of a prompt — a title, a list of [DialogAction]s and a +// body — and what stays per-interface is only how that description is drawn. That split is the +// whole point: a copy change now lands in both places because there is only one place. +// +// ⚠ Deliberately NOT unified, and they belong apart: the PIN ceremony (a numeric keyboard field +// and an editable device name on touch; four D-pad digit slots on the console — different input +// models, not different skins), Add/Edit Host (a bottom sheet and a full screen with its own +// on-screen keyboard), and the host action list (an anchored dropdown vs a modal stack, and the +// touch one grows a row per profile). + +/** + * One prompt, drawn as whichever interface is running. + * + * [actions] is ordered PRIMARY FIRST — the console stacks them in that order with the cursor on + * the first, and the touch renderer lifts that same first action into `confirmButton` and lays the + * rest out beside it. One order, two idioms, no per-dialog bookkeeping. + * + * The two renderers cannot be one tree: an `AlertDialog` composes into its own platform window + * while [ConsoleModal] is a plain Box in the calling tree — which is also why the console one + * needs a `BackHandler` and the caller's `navActive` gate while the touch one needs neither. + */ +@Composable +fun PunktfunkDialog( + gamepadUi: Boolean, + title: String, + onDismiss: () -> Unit, + actions: List, + /** + * False pins the prompt open against a stray tap outside it — for a dialog sitting over work + * in flight, where a mis-tap would abandon it. Console-side there is no outside to tap, so + * this only reaches the touch renderer. + */ + dismissOnOutsideTap: Boolean = true, + body: @Composable ColumnScope.() -> Unit, +) { + if (gamepadUi) { + GamepadDialog(title = title, onDismiss = onDismiss, actions = actions, body = body) + return + } + val primary = actions.firstOrNull { it.primary } ?: actions.firstOrNull() + val rest = actions.filter { it !== primary } + AlertDialog( + onDismissRequest = onDismiss, + properties = DialogProperties(dismissOnClickOutside = dismissOnOutsideTap), + title = { Text(title) }, + text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { body() } }, + confirmButton = { + primary?.let { a -> + TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) } + } + }, + dismissButton = { + if (rest.isNotEmpty()) { + Row { + rest.forEach { a -> + TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) } + } + } + } + }, + ) +} + +/** A prompt's body paragraph, dimmed to sit under the title in either interface. */ +@Composable +private fun PromptText(text: String, gamepadUi: Boolean) { + val ink = LocalGamepadInk.current + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = if (gamepadUi) ink.fg(0.7f) else MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */ +@Composable +fun TrustNewHostPrompt( + gamepadUi: Boolean, + pt: PendingTrust, + onTrust: () -> Unit, + onPairInstead: () -> Unit, + onDismiss: () -> Unit, +) { + PunktfunkDialog( + gamepadUi = gamepadUi, + title = "Trust this host?", + onDismiss = onDismiss, + actions = listOf( + DialogAction("Trust (TOFU)", primary = true, onClick = onTrust), + DialogAction("Pair with PIN…", onClick = onPairInstead), + DialogAction("Cancel", onClick = onDismiss), + ), + ) { + PromptText("First connection to ${pt.host}:${pt.port}.", gamepadUi) + pt.advertisedFp?.let { PromptText("Fingerprint ${it.take(16)}…", gamepadUi) } + PromptText( + "This host allows trust-on-first-use, but that can't tell an impostor from the real " + + "host. Pairing with a PIN is stronger — it proves both sides.", + gamepadUi, + ) + } +} + +/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */ +@Composable +fun FingerprintChangedPrompt( + gamepadUi: Boolean, + pt: PendingTrust, + onRepair: () -> Unit, + onDismiss: () -> Unit, +) { + PunktfunkDialog( + gamepadUi = gamepadUi, + title = "Host identity changed", + onDismiss = onDismiss, + actions = listOf( + DialogAction("Re-pair", primary = true, onClick = onRepair), + DialogAction("Cancel", onClick = onDismiss), + ), + ) { + PromptText( + "The pinned fingerprint for ${pt.host} no longer matches what it now advertises. " + + "This can mean a host reinstall — or an impostor. Re-pair with the host's PIN to " + + "continue.", + gamepadUi, + ) + } +} + +/** + * A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is + * the no-PIN path — connect and wait for the operator to click Approve in the host's console; + * "Use a PIN…" switches to the SPAKE2 ceremony. + */ +@Composable +fun RequestAccessPrompt( + gamepadUi: Boolean, + pt: PendingTrust, + onRequestAccess: () -> Unit, + onUsePin: () -> Unit, + onDismiss: () -> Unit, +) { + PunktfunkDialog( + gamepadUi = gamepadUi, + title = "Pairing required", + onDismiss = onDismiss, + actions = listOf( + DialogAction("Request access", primary = true, onClick = onRequestAccess), + DialogAction("Use a PIN…", onClick = onUsePin), + DialogAction("Cancel", onClick = onDismiss), + ), + ) { + PromptText("${pt.host}:${pt.port} requires pairing before it will stream.", gamepadUi) + PromptText( + "Request access and approve this device in the host's console (or web UI) — no PIN " + + "needed. Or pair with the 4-digit PIN the host displays.", + gamepadUi, + ) + } +} + +/** + * The no-PIN "request access" wait: the connect is parked on the host until the operator approves + * this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late + * approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery. + * + * Outside taps are ignored: a connect is parked on the host, and a stray tap beside the card is not + * a decision to abandon it. + */ +@Composable +fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -> Unit) { + val ink = LocalGamepadInk.current + PunktfunkDialog( + gamepadUi = gamepadUi, + title = "Waiting for approval", + onDismiss = onCancel, + actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)), + dismissOnOutsideTap = false, + ) { + val deviceName = Build.MODEL ?: "this device" + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.primary, + ) + Text( + "Approve this device on $hostLabel.", + color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface, + ) + } + PromptText( + "Open the host's console (or web UI) and approve “$deviceName”. It connects " + + "automatically once you approve — no PIN needed.", + gamepadUi, + ) + } +} + +/** + * Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and + * every connect are dead — offer the system prompt again and a settings deep link (a permanently- + * denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough). + */ +@Composable +fun LocalNetworkPrompt( + gamepadUi: Boolean, + onAllow: () -> Unit, + onSettings: () -> Unit, + onDismiss: () -> Unit, +) { + PunktfunkDialog( + gamepadUi = gamepadUi, + title = "Allow local network access", + onDismiss = onDismiss, + actions = listOf( + DialogAction("Allow", primary = true, onClick = onAllow), + DialogAction("Open settings", onClick = onSettings), + DialogAction("Not now", onClick = onDismiss), + ), + ) { + PromptText( + "Android blocks Punktfunk from talking to devices on your network, so it can't find " + + "or reach any host until you allow it.", + gamepadUi, + ) + PromptText( + "If no prompt appears after you allow it, enable “Nearby devices” for Punktfunk in " + + "system settings.", + gamepadUi, + ) + } +} + +/** + * The link measurement and what to do with the result. A TV box on a powerline adapter is exactly + * the machine whose link is worth measuring, so this belongs on the couch surface too — and so + * does [speedTestTargetNote], which the console used to omit, leaving a console user to guess + * which layer Apply would write to. + */ +@Composable +fun SpeedTestPrompt( + gamepadUi: Boolean, + hostName: String, + target: SpeedTestTarget, + phase: SpeedTestPhase, + onApply: (toProfile: Boolean) -> Unit, + onDismiss: () -> Unit, +) { + val ink = LocalGamepadInk.current + val done = phase as? SpeedTestPhase.Done + PunktfunkDialog( + gamepadUi = gamepadUi, + title = "Network speed test", + onDismiss = onDismiss, + // Measuring bursts traffic for two seconds; a tap outside must not abandon it midway. + dismissOnOutsideTap = phase !is SpeedTestPhase.Measuring, + actions = buildList { + if (done != null) { + add( + DialogAction( + when (target) { + SpeedTestTarget.Global -> "Apply" + is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}”" + is SpeedTestTarget.Ask -> "Set in “${target.profile.name}”" + }, + primary = true, + ) { onApply(true) }, + ) + if (target is SpeedTestTarget.Ask) { + add(DialogAction("Set as default") { onApply(false) }) + } + } + add(DialogAction("Close", primary = done == null, onClick = onDismiss)) + }, + ) { + PromptText(hostName, gamepadUi) + when (phase) { + SpeedTestPhase.Connecting -> PromptText("Connecting…", gamepadUi) + SpeedTestPhase.Measuring -> + PromptText( + "Measuring — the host is bursting test traffic for two seconds.", + gamepadUi, + ) + is SpeedTestPhase.Failed -> Text( + phase.message, + style = MaterialTheme.typography.bodyMedium, + color = if (gamepadUi) ink.danger else MaterialTheme.colorScheme.error, + ) + is SpeedTestPhase.Done -> { + Text( + "%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface, + ) + PromptText( + "Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps), + gamepadUi, + ) + PromptText(speedTestTargetNote(target), gamepadUi) + } + } + } +} + +/** One line saying which layer an Apply will write to, and why that one. */ +private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) { + SpeedTestTarget.Global -> + "This host uses the default settings, so the bitrate goes there." + is SpeedTestTarget.Profile -> + "This host streams with “${target.profile.name}”, which sets its own bitrate — " + + "that override is what it actually reads." + is SpeedTestTarget.Ask -> + "This host streams with “${target.profile.name}”, which currently inherits the default " + + "bitrate. Setting it in the profile affects only this host's profile; setting it as " + + "the default affects everything that inherits it." +} 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..3b1e0d85 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 @@ -14,6 +11,9 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row @@ -21,14 +21,17 @@ 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.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf @@ -37,8 +40,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -52,6 +57,8 @@ 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 +import kotlinx.coroutines.launch @Composable fun App(forceGamepadUi: Boolean = false) { @@ -211,8 +218,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 +253,21 @@ 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), + // Reached FROM Settings, not from Home, so they sit a level deeper again — which is precisely + // what makes Settings → Controllers travel like a push and the way back like a pop. Give one of + // these depth 1 and the transition would read as a sideways swap between two peers. + Controllers(2), + Licenses(2), +} /** * The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch: @@ -270,6 +291,12 @@ fun GamepadShell( val context = LocalContext.current var screen by remember { mutableStateOf(GamepadScreen.Home) } var libraryHost by remember { mutableStateOf(null) } + // Where the settings screen was when a sub-screen took over. The shell's AnimatedContent + // discards a screen's `remember`s the moment it stops being the target, so a trip out to the + // Controllers view and back would otherwise land on the Stream tab's first row — the couch + // equivalent of a browser losing your scroll position on Back. Held here because this is the + // only thing that outlives the screen. + var settingsPlace by remember { mutableStateOf(null) } // Consume the "come back to this library" intent once, on entry. Keyed on the id so a second // game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out. @@ -297,12 +324,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, @@ -318,7 +383,23 @@ fun GamepadShell( GamepadScreen.Settings -> GamepadSettingsScreen( initial = settings, onChange = onSettingsChange, - onBack = { screen = GamepadScreen.Home }, + // Leaving for HOME forgets the place: coming back in from the carousel should start + // at the top of the first section, exactly as it always has. Only a sub-screen's + // Back is a return. + onBack = { screen = GamepadScreen.Home; settingsPlace = null }, + navActive = s == screen, + resume = settingsPlace, + onPlace = { settingsPlace = it }, + onOpenControllers = { screen = GamepadScreen.Controllers }, + onOpenLicenses = { screen = GamepadScreen.Licenses }, + ) + GamepadScreen.Controllers -> ConsoleControllersScreen( + gamepadSetting = settings.gamepad, + onBack = { screen = GamepadScreen.Settings }, + navActive = s == screen, + ) + GamepadScreen.Licenses -> ConsoleLicensesScreen( + onBack = { screen = GamepadScreen.Settings }, navActive = s == screen, ) GamepadScreen.Library -> libraryHost?.let { host -> @@ -337,3 +418,128 @@ fun GamepadShell( /** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */ private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f + +// --- Showing a TOUCH-written screen on the console's field ------------------------------------- +// +// Two screens (Controllers, Licenses) exist once and are shown in both interfaces. They live beside +// the shell rather than in `GamepadChrome.kt` because they are about the SHELL's job — putting a +// screen that was written for one interface onto the other's field — rather than about the console's +// own material. + +/** + * Re-inks a screen written against the TOUCH theme so it can be shown on the console's field. + * + * `ControllersScreen` alone pulls `MaterialTheme.colorScheme` at 27 explicit sites, plus implicitly + * through every `OutlinedCard`, `Switch`, `OutlinedButton` and `LinearProgressIndicator` it draws. + * Dropped into the shell those keep the touch palette — light-grey body text with no background of + * its own, which over the six PALE console palettes (`GamepadPalette`, `light = true`) is grey on + * pastel: technically painted, in practice unreadable. That is the same class of bug as the console + * dialogs that spent a release rendering dark ink on a dark card. + * + * The fix is deliberately ONE derived colour scheme rather than 27 call-site branches: + * * a call-site branch cannot reach the IMPLICIT pulls at all — a `Switch`'s track and an + * `OutlinedCard`'s border are resolved inside Material, not here; + * * two colours per site is exactly the shape that drifts, and it would leave the touch screen + * carrying console vocabulary it has no use for. + * + * The alternative — give the console presentation an opaque backdrop and let the touch theme read on + * its own ground — was rejected because it splits the screen's material in two: an opaque touch-grey + * slab under a palette-inked header and legend, with a visible seam between them, on a field whose + * whole point is that one look runs through it. + * + * The base scheme follows the field's lightness, so anything not overridden here (a container role + * some Material component reaches for) still lands on the right side of the contrast line. + */ +@Composable +internal fun ConsoleInkedTheme(content: @Composable () -> Unit) { + val ink = LocalGamepadInk.current + val scheme = remember(ink) { + val base = if (ink.isLight) lightColorScheme() else darkColorScheme() + base.copy( + primary = ink.accent, + onPrimary = ink.onAccent, + // A card becomes a PANE over the aurora rather than a slab on top of it: the console's + // own glass fill, so an OutlinedCard here is cut from the material the settings rows are. + surface = ink.glass, + onSurface = ink.fg, + surfaceVariant = ink.fg(0.12f), + onSurfaceVariant = ink.fg(0.68f), + outline = ink.fg(0.30f), + outlineVariant = ink.fg(0.16f), + // Nothing here paints a background — the aurora is the ground — but a component that + // resolves `background` (or the content colour for it) must still land on the palette. + background = Color.Transparent, + onBackground = ink.fg, + ) + } + // The typography and shapes are the app's, not Material's defaults: this swaps the INK, not the + // brand typeface. And `LocalContentColor` has to be provided by hand — outside a Surface or a + // Scaffold it defaults to BLACK, which is how an unstyled `Text` would vanish into a dark field. + MaterialTheme( + colorScheme = scheme, + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes, + ) { + CompositionLocalProvider(LocalContentColor provides ink.fg, content = content) + } +} + +/** + * The console's scroll route for a screen that is a WALL of content rather than a list of focusable + * rows. + * + * Compose only scrolls a container to keep a FOCUSED child visible, so a screen whose body holds no + * focusable nodes (the licenses notices are one enormous `Text`) simply cannot be scrolled by a + * controller: the D-pad has nothing to move to. These screens therefore drive the scroll state + * directly — up/down steps, the shoulders page. + * + * Returned as a plain function so a screen's nav callbacks read `scroll(-1, page = false)` rather + * than each screen minting its own coroutine + viewport arithmetic (which is how the two would end + * up scrolling at different speeds). + */ +@Composable +internal fun rememberConsoleScroller(scroll: ScrollState): (dir: Int, page: Boolean) -> Unit { + val scope = rememberCoroutineScope() + val animated = animationsEnabled() + return remember(scroll, animated) { + { dir, page -> + val delta = consoleScrollDelta(scroll.viewportSize.toFloat(), page, dir) + if (delta != 0f) { + scope.launch { + // Auto-repeat fires every 150 ms while a direction is held, so each animation is + // short enough to have landed (or nearly) before the next one cancels it — + // otherwise a held D-pad crawls, each step restarting from where the last was + // interrupted. + if (animated) { + scroll.animateScrollBy( + delta, + ConsoleMotion.ease( + if (page) ConsoleMotion.TRANSITION_MS else ConsoleMotion.FOCUS_MS, + ), + ) + } else { + scroll.scrollBy(delta) + } + } + } + } + } +} + +/** + * How far one console scroll press travels: [dir] is -1 (up/left) or +1 (down/right), [page] picks + * the shoulders' full page over a D-pad step. Zero while the viewport is unmeasured — a first press + * that arrived before layout must do nothing rather than fling the content by zero-times-nothing. + */ +internal fun consoleScrollDelta(viewportPx: Float, page: Boolean, dir: Int): Float = + if (viewportPx <= 0f) 0f else viewportPx * (if (page) CONSOLE_PAGE else CONSOLE_STEP) * dir + +/** + * A page keeps a band of what you were reading on screen rather than jumping a clean screenful — the + * overlap every reader has used since the printed page, and the difference between "I moved down" + * and "where was I". + */ +private const val CONSOLE_PAGE = 0.88f + +/** A D-pad step is about a quarter screen, so holding the direction walks the wall rather than flicking it. */ +private const val CONSOLE_STEP = 0.28f diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt index 35785456..28902e5d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt @@ -123,130 +123,6 @@ internal fun AddHostSheet( } } -/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */ -@Composable -internal fun TrustNewHostDialog( - pt: PendingTrust, - onTrust: () -> Unit, - onPairInstead: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Trust this host?") }, - text = { - Column { - Text("First connection to ${pt.host}:${pt.port}.") - pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}…") } - Text( - "This host allows trust-on-first-use, but that can't tell an impostor " + - "from the real host. Pairing with a PIN is stronger — it proves both sides.", - ) - } - }, - confirmButton = { - TextButton(onClick = onTrust) { Text("Trust (TOFU)") } - }, - dismissButton = { - Row { - TextButton(onClick = onPairInstead) { Text("Pair with PIN…") } - TextButton(onClick = onDismiss) { Text("Cancel") } - } - }, - ) -} - -/** - * Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and - * every connect are dead — offer the system prompt again and a settings deep link (a permanently- - * denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough). - */ -@Composable -internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Allow local network access") }, - text = { - Text( - "Android blocks Punktfunk from talking to devices on your network, so it can't " + - "find or reach any host until you allow it. If no prompt appears when you tap " + - "Allow, enable “Nearby devices” for Punktfunk in system settings.", - ) - }, - confirmButton = { - TextButton(onClick = onAllow) { Text("Allow") } - }, - dismissButton = { - Row { - TextButton(onClick = onSettings) { Text("Open settings") } - TextButton(onClick = onDismiss) { Text("Not now") } - } - }, - ) -} - -/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */ -@Composable -internal fun FingerprintChangedDialog( - pt: PendingTrust, - onRepair: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Host identity changed") }, - text = { - Text( - "The pinned fingerprint for ${pt.host} no longer matches what it now " + - "advertises. This can mean a host reinstall — or an impostor. Re-pair " + - "with the host's PIN to continue.", - ) - }, - confirmButton = { - TextButton(onClick = onRepair) { Text("Re-pair") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -/** - * A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is - * the no-PIN path — connect and wait for the operator to click Approve in the host's console; - * "Use a PIN…" switches to the SPAKE2 ceremony. - */ -@Composable -internal fun RequestAccessDialog( - pt: PendingTrust, - onRequestAccess: () -> Unit, - onUsePin: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Pairing required") }, - text = { - Column { - Text("${pt.host}:${pt.port} requires pairing before it will stream.") - Text( - "Request access and approve this device in the host's console (or web " + - "UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.", - ) - } - }, - confirmButton = { - TextButton(onClick = onRequestAccess) { Text("Request access") } - }, - dismissButton = { - Row { - TextButton(onClick = onUsePin) { Text("Use a PIN…") } - TextButton(onClick = onDismiss) { Text("Cancel") } - } - }, - ) -} - /** * The SPAKE2 PIN ceremony dialog. Runs [NativeBridge.nativePair] off the UI thread itself (the * pin/name/error state is dialog-local); on success hands the host's verified fingerprint to @@ -318,41 +194,6 @@ internal fun PairPinDialog( ) } -/** - * The no-PIN "request access" wait: the connect is parked on the host until the operator approves - * this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late - * approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery. - */ -@Composable -internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { - AlertDialog( - onDismissRequest = onCancel, - title = { Text("Waiting for approval") }, - text = { - val deviceName = Build.MODEL ?: "this device" - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - Text("Approve this device on $hostLabel.") - } - Text( - "Open the host's console (or web UI) and approve “$deviceName”. It connects " + - "automatically once you approve — no PIN needed.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = onCancel) { Text("Cancel") } - }, - ) -} - /** * Edit a saved host: name, address, port, the Wake-on-LAN MAC, and the per-host settings the record * owns — shared clipboard (a trust decision about THIS machine, so it was never really a global). @@ -467,103 +308,3 @@ internal fun EditHostDialog( }, ) } - -/** - * The network speed test, as a dialog: it narrates while it measures, then offers to apply the - * recommendation to the layer the tested host actually reads bitrate from — see [SpeedTestTarget] - * for why that is the interesting part. The apply buttons name their destination, so the write is - * never a surprise. - */ -@Composable -internal fun SpeedTestDialog( - hostName: String, - target: SpeedTestTarget, - phase: SpeedTestPhase, - onApply: (toProfile: Boolean) -> Unit, - onDismiss: () -> Unit, -) { - val done = phase as? SpeedTestPhase.Done - AlertDialog( - // Measuring can't be cancelled mid-burst (the host is already sending), so a stray tap - // outside shouldn't look like it did something. - onDismissRequest = { if (done != null || phase is SpeedTestPhase.Failed) onDismiss() }, - title = { Text("Network speed test") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(hostName, style = MaterialTheme.typography.titleMedium) - when (phase) { - SpeedTestPhase.Connecting, SpeedTestPhase.Measuring -> Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - Text( - if (phase == SpeedTestPhase.Connecting) { - "Connecting…" - } else { - "Measuring — the host is bursting test traffic for two seconds." - }, - ) - } - is SpeedTestPhase.Failed -> Text( - phase.message, - color = MaterialTheme.colorScheme.error, - ) - is SpeedTestPhase.Done -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - "%.0f Mbit/s measured · %.1f %% loss".format( - phase.measuredMbps, - phase.lossPct, - ), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - "Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - speedTestTargetNote(target), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - }, - confirmButton = { - if (done != null) { - TextButton(onClick = { onApply(true) }) { - Text( - when (target) { - SpeedTestTarget.Global -> "Apply" - is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}”" - is SpeedTestTarget.Ask -> "Set in “${target.profile.name}”" - }, - ) - } - } - }, - dismissButton = { - Row { - // The both-are-defensible case: the user picks the layer, we don't guess. - if (done != null && target is SpeedTestTarget.Ask) { - TextButton(onClick = { onApply(false) }) { Text("Set as default") } - } - TextButton(onClick = onDismiss) { Text("Close") } - } - }, - ) -} - -/** One line saying which layer an Apply will write to, and why that one. */ -private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) { - SpeedTestTarget.Global -> - "This host uses the default settings, so the bitrate goes there." - is SpeedTestTarget.Profile -> - "This host streams with “${target.profile.name}”, which sets its own bitrate — " + - "that override is what it actually reads." - is SpeedTestTarget.Ask -> - "This host streams with “${target.profile.name}”, which currently inherits the default " + - "bitrate. Setting it in the profile affects only this host's profile; setting it as " + - "the default affects everything that inherits it." -} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectGrid.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectGrid.kt new file mode 100644 index 00000000..ed8155df --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectGrid.kt @@ -0,0 +1,339 @@ +package io.unom.punktfunk + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.unom.punktfunk.components.EmptyHostsState +import io.unom.punktfunk.components.HostCard +import io.unom.punktfunk.components.HostMenuItem +import io.unom.punktfunk.components.SectionLabel +import io.unom.punktfunk.kit.discovery.DiscoveredHost +import io.unom.punktfunk.kit.security.KnownHost +import io.unom.punktfunk.models.HostStatus + +/** + * The touch home: the saved/discovered host grid with the Add-host FAB over it — everything + * `ConnectScreen` draws when the console UI is off, and the counterpart of [buildHomeTiles] + + * `GamepadHome` when it is on. + * + * Pure display: every action arrives as a callback, because they all end in state the screen owns + * (a dial in flight, the trust prompt, the host store). What this file DOES own is the arrangement + * — which sections exist, in what order, and which actions a given card offers — and the two rules + * that are easy to get wrong from the outside: a pinned card is a shortcut and so withholds the + * host's destructive actions, and every card in a section reserves the profile chip's space as soon + * as one of them needs it. + */ +@Composable +internal fun ConnectGrid( + savedHosts: List, + /** Every live advert — the OS mark prefers it over the stored one, and "searching…" reads it. */ + discovered: List, + /** Adverts with no saved record behind them, de-duped by the caller (it needs them too). */ + discoveredUnsaved: List, + /** Saved hosts answering the QUIC probe, "address:port" — the routed half of "online". */ + reachable: Set, + profiles: List, + pinsFor: (KnownHost) -> List, + connecting: Boolean, + /** A confirmation ("75 Mbit/s set in …"); [status] is the failure line. Never the same thing. */ + notice: String?, + status: String?, + lnpGranted: Boolean, + /** Raise the local-network-permission prompt — the banner's "Allow…" and the wake guard. */ + onAskLocalNetwork: () -> Unit, + /** + * Dial a saved host. The second argument is `connect`'s one-off profile reference: null follows + * the host's binding (a plain tap), a profile id forces that profile, and the empty string + * forces the global defaults — a real, different action on a bound host, which is why it has to + * survive as a value rather than collapsing into "unset". + */ + onConnect: (KnownHost, String?) -> Unit, + onConnectDiscovered: (DiscoveredHost) -> Unit, + onForget: (KnownHost) -> Unit, + onEdit: (KnownHost) -> Unit, + onWake: (KnownHost) -> Unit, + onSpeedTest: (KnownHost) -> Unit, + onCopyLink: (KnownHost, StreamProfile?) -> Unit, + onTogglePin: (KnownHost, StreamProfile) -> Unit, + onRescan: () -> Unit, + onAddHost: () -> Unit, +) { + // The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a + // user who never wants this feature sees no new clutter anywhere but the settings scope chips. + // "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding + // lives in the Edit sheet instead. + fun hostMenu(kh: KnownHost, pin: StreamProfile?): List = buildList { + if (pin == null) { + add(HostMenuItem("Network speed test") { onSpeedTest(kh) }) + } + add(HostMenuItem("Copy link") { onCopyLink(kh, pin) }) + if (profiles.isEmpty()) return@buildList + if (pin != null) { + add(HostMenuItem("Unpin card", startsSection = true) { onTogglePin(kh, pin) }) + } + add( + HostMenuItem("Connect with: Default settings", startsSection = true) { + // The empty reference is "force the defaults", not "unset" — on a bound host that + // is a real, different action from a plain tap. + onConnect(kh, "") + }, + ) + profiles.forEach { p -> + add(HostMenuItem("Connect with: ${p.name}") { onConnect(kh, p.id) }) + } + if (pin == null) { + profiles.forEachIndexed { i, p -> + val pinned = p.id in kh.pinnedProfileIds + add( + HostMenuItem( + if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}", + startsSection = i == 0, + ) { onTogglePin(kh, p) }, + ) + } + } + } + + // The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a + // pinned combination is a plain one-click connect instead of a trip through a menu. + val savedCards = savedHosts.flatMap { kh -> + listOf(HostCardEntry(kh, null)) + pinsFor(kh).map { HostCardEntry(kh, it) } + } + // Cards in one grid row must be the same height (the grid won't stretch them), so as soon as + // ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use + // profiles ever sees the gap. + val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null } + + Box(Modifier.fillMaxSize()) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(8.dp)) + Text("Punktfunk", style = MaterialTheme.typography.headlineLarge) + Text( + "stream a remote desktop", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + + notice?.let { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + Spacer(Modifier.height(16.dp)) + } + + status?.let { + // In-flight progress (connecting / waking) is the full-screen ConnectOverlay's + // job now, so `status` only ever carries a result/error here — a filled error + // container reads as a real failure banner, not just red text lost in the layout. + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + Spacer(Modifier.height(16.dp)) + } + } + } + + if (!lnpGranted) { + // Local network access denied: discovery can't ever find anything and every connect + // would time out — say so at the top, with the fix one tap away, instead of letting + // the screen look idle/broken. + item(span = { GridItemSpan(maxLineSpan) }) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Local network access is off", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + "Android blocks Punktfunk from finding or reaching hosts until you allow it.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + textAlign = TextAlign.Center, + ) + TextButton(onClick = onAskLocalNetwork) { Text("Allow…") } + } + } + Spacer(Modifier.height(12.dp)) + } + } + + if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + EmptyHostsState() + } + } + + if (savedHosts.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + SectionLabel("Saved hosts") + } + items(savedCards, key = { it.key }) { entry -> + val kh = entry.host + val pin = entry.pin + val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } } + HostCard( + name = kh.name, + address = "${kh.address}:${kh.port}", + status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU, + online = kh.isOnline(discovered, reachable), + // Live advert preferred (the store lags a discovery tick), else stored. + os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os + ?: kh.os, + enabled = !connecting, + // A pinned card connects with ITS profile; the host's own card follows the + // binding, which is exactly what its chip says it will do. + onConnect = { onConnect(kh, pin?.id) }, + // Edit / Forget / Wake live on the host's own card only: a pinned card is a + // shortcut, not a second host, and offering destructive host actions on it + // would blur exactly that. + onForget = if (pin != null) null else ({ onForget(kh) }), + onEdit = if (pin != null) null else ({ onEdit(kh) }), + // Explicit wake-only: offered when the host is offline and we have a MAC. The + // screen runs it through the WakeController so it shows the "Waking…" overlay + // and waits for the host to come online (matched by fingerprint, so a new DHCP + // address on a cold boot still counts as "up") rather than firing a single + // silent packet. + onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) { + ({ onWake(kh) }) + } else { + null + }, + profileLabel = pin?.name ?: bound?.name, + profileProminent = pin != null, + accent = accentColor(pin?.accent ?: bound?.accent), + menuItems = hostMenu(kh, pin), + reserveProfileSlot = anyProfileChip, + ) + } + } + + if (discoveredUnsaved.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Spacer(Modifier.height(12.dp)) + SectionLabel("Discovered on the network") + } + items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh -> + HostCard( + name = dh.name, + address = "${dh.host}:${dh.port}", + status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU, + online = true, // in the discovered list ⇒ live on mDNS right now + os = dh.os, + enabled = !connecting, + onConnect = { onConnectDiscovered(dh) }, + onForget = null, + ) + } + } + + // Active-discovery hint: discovery runs whenever this screen is up, so while it's + // scanning but nothing's turned up yet (and we're not mid-connect), show it's working + // rather than looking idle/empty. Suppressed while local network access is denied — + // a spinner would be a lie there (the browse can't receive anything); the banner above + // owns that state. + // Scan again is offered whether or not anything turned up: the case that sends people + // here is ONE expected host missing, not an empty list, and a browse that quietly went + // deaf (blocked when it started, or backed off to its hour-long re-query) looks + // exactly like a network without that host on it. + if (lnpGranted && !connecting) { + item(span = { GridItemSpan(maxLineSpan) }) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (discovered.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text( + "Searching the local network…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + } + TextButton(onClick = onRescan) { Text("Scan again") } + } + } + } + + item(span = { GridItemSpan(maxLineSpan) }) { + Spacer(Modifier.height(96.dp)) + } + } + + ExtendedFloatingActionButton( + onClick = onAddHost, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text("Add host") }, + expanded = !connecting, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(20.dp), + ) + } +} 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..6363f523 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 @@ -1,5 +1,6 @@ package io.unom.punktfunk +import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -29,8 +30,8 @@ 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.platform.LocalConfiguration import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -204,7 +205,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 +243,19 @@ 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)) + // The SAME bottom-start spot every console screen pins its legend at — this takeover sat + // its pill at bottom-CENTRE, so pressing Connect made the one piece of chrome that is + // supposed to read as fixed jump halfway across the screen (second on-glass verdict). + val landscape = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + Box( + Modifier + .align(Alignment.BottomStart) + .consoleLegendInsets(landscape) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar(hints) + } } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectPrompts.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectPrompts.kt new file mode 100644 index 00000000..801bf0dd --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectPrompts.kt @@ -0,0 +1,195 @@ +package io.unom.punktfunk + +import androidx.compose.runtime.Composable +import io.unom.punktfunk.kit.security.ClientIdentity +import io.unom.punktfunk.kit.security.KnownHost +import io.unom.punktfunk.models.PendingTrust + +/** + * Everything `ConnectScreen` puts ON TOP of whichever home it drew — the trust and pairing + * ceremony, the parked "Waiting for approval…", the console's host options, the speed test, the + * edit form, the local-network rationale, and finally the connect takeover. + * + * They live together because their ORDER is the contract: this is a stack of siblings in one tree, + * so the last one drawn is the one on top, and [ConnectOverlay] is last on purpose — a dial can + * start from any of the prompts above it, and its takeover has to cover the prompt that started it. + * + * Only the state each prompt reads comes in; every action goes back out as a callback, because they + * all end in the connect/pair engine or in the host store, which the screen owns. Nothing in here + * decides anything — it decides only what is visible. + */ +@Composable +internal fun ConnectPrompts( + gamepadUi: Boolean, + /** The client identity — the PIN ceremony needs it to run SPAKE2; null while it is still minting. */ + identity: ClientIdentity?, + profiles: List, + isOnline: (KnownHost) -> Boolean, + // ---- trust / pairing -------------------------------------------------------------------- + pendingTrust: PendingTrust?, + /** Dismiss (null) or re-aim the SAME decision at another kind — "Pair with PIN…" does that. */ + onPendingTrustChange: (PendingTrust?) -> Unit, + /** Trust-on-first-use accepted: dial with no pin. Offered only for a `pair=optional` host. */ + onTrustNew: (PendingTrust) -> Unit, + /** The PIN ceremony completed with this host fingerprint — save as paired, then dial. */ + onPaired: (PendingTrust, String) -> Unit, + onRequestAccess: (PendingTrust) -> Unit, + // ---- the parked no-PIN request ---------------------------------------------------------- + /** Non-null while a "request access" connect sits parked on the host awaiting approval. */ + awaitingHostName: String?, + onCancelApproval: () -> Unit, + // ---- console host options (Up on a saved carousel tile) --------------------------------- + optionsTarget: HostCardEntry?, + onDismissOptions: () -> Unit, + libraryEnabled: Boolean, + onOpenLibrary: (KnownHost) -> Unit, + onWake: (KnownHost) -> Unit, + onSpeedTest: (KnownHost) -> Unit, + onCopyLink: (KnownHost, StreamProfile?) -> Unit, + onEditHost: (KnownHost) -> Unit, + onForgetHost: (KnownHost) -> Unit, + onTogglePin: (KnownHost, StreamProfile) -> Unit, + // ---- speed test -------------------------------------------------------------------------- + speedTest: HostCardEntry?, + /** Which layer Apply writes to. Resolved by the caller (it holds the store); set with [speedTest]. */ + speedTestTarget: SpeedTestTarget?, + speedTestPhase: SpeedTestPhase, + /** true = write the measured bitrate to the profile, false = to the global default. */ + onApplySpeedTest: (Boolean) -> Unit, + onDismissSpeedTest: () -> Unit, + // ---- edit host --------------------------------------------------------------------------- + editTarget: KnownHost?, + /** A MAC from the live advert, for a host whose own is not learned yet. */ + editSuggestedMacs: List, + onSaveHost: (KnownHost) -> Unit, + onDismissEdit: () -> Unit, + // ---- local network permission ------------------------------------------------------------ + lnpPrompt: Boolean, + onAllowLocalNetwork: () -> Unit, + onOpenSystemSettings: () -> Unit, + onDismissLnpPrompt: () -> Unit, + // ---- the connect takeover ---------------------------------------------------------------- + connectingHostName: String?, + waker: WakeController, + onCancelConnect: () -> Unit, +) { + pendingTrust?.let { pt -> + // Same trust/pairing logic, console-styled + controller-navigable in gamepad mode. + val onPair = { onPendingTrustChange(pt.copy(kind = PendingTrust.Kind.PAIR)) } + // Three of the four say the same thing in both interfaces, so they are ONE prompt that + // knows which one is running. Only the PIN ceremony genuinely differs — a keyboard field + // against four D-pad digit slots is a different input model, not a different skin. + when (pt.kind) { + PendingTrust.Kind.TRUST_NEW -> TrustNewHostPrompt( + gamepadUi, pt, + onTrust = { onTrustNew(pt) }, + onPairInstead = onPair, + onDismiss = { onPendingTrustChange(null) }, + ) + PendingTrust.Kind.FP_CHANGED -> + FingerprintChangedPrompt(gamepadUi, pt, onPair) { onPendingTrustChange(null) } + PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessPrompt( + gamepadUi, pt, + onRequestAccess = { onRequestAccess(pt) }, + onUsePin = onPair, + onDismiss = { onPendingTrustChange(null) }, + ) + PendingTrust.Kind.PAIR -> { + val onSavePaired = { fp: String -> onPaired(pt, fp) } + if (gamepadUi) { + GamepadPairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) } + } else { + PairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) } + } + } + } + } + + awaitingHostName?.let { hostLabel -> + AwaitingApprovalPrompt(gamepadUi, hostLabel = hostLabel, onCancel = onCancelApproval) + } + + // Console host options (Up on a saved carousel tile): Wake / Edit / Forget. + optionsTarget?.let { entry -> + val kh = entry.host + val pin = entry.pin + val offline = !isOnline(kh) + GamepadHostOptionsDialog( + hostName = kh.name, + canWake = kh.mac.isNotEmpty() && offline, + onWake = { onDismissOptions(); onWake(kh) }, + // A saved host always has a library (it's a knownHost) → offer it when the setting's on, + // so a TV remote reaches the library here instead of via the Y face button. + onLibrary = if (libraryEnabled && pin == null) { + { onDismissOptions(); onOpenLibrary(kh) } + } else { + null + }, + onSpeedTest = if (pin == null) { + { onDismissOptions(); onSpeedTest(kh) } + } else { + null + }, + onCopyLink = { onDismissOptions(); onCopyLink(kh, pin) }, + onEdit = { onDismissOptions(); onEditHost(kh) }, + onForget = { onForgetHost(kh); onDismissOptions() }, + onDismiss = onDismissOptions, + // A pin's only action: unpinning touches neither the host nor the profile. + onUnpin = pin?.let { p -> { onTogglePin(kh, p); onDismissOptions() } }, + profileName = pin?.name, + ) + } + + if (speedTest != null && speedTestTarget != null) { + SpeedTestPrompt( + gamepadUi, speedTest.host.name, speedTestTarget, speedTestPhase, + onApplySpeedTest, onDismissSpeedTest, + ) + } + + editTarget?.let { kh -> + if (gamepadUi) { + // Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the + // host with an extra MAC row; the action SAVES instead of connecting. + GamepadAddHostScreen( + onAdd = { _, _, _ -> }, + onDismiss = onDismissEdit, + editHost = kh, + suggestedMacs = editSuggestedMacs, + onSave = onSaveHost, + // Shared clipboard and the profile binding — the two host decisions that used to + // exist only in the touch edit sheet, which a TV box has no way to reach. + profiles = profiles, + ) + } else { + EditHostDialog( + target = kh, + suggestedMacs = editSuggestedMacs, + profiles = profiles, + onSave = onSaveHost, + onDismiss = onDismissEdit, + ) + } + } + + if (lnpPrompt) { + // Android 17+ local-network-permission rationale: re-request (a permanently-denied request + // returns instantly without a system prompt — hence the settings deep link alongside). + LocalNetworkPrompt( + gamepadUi, + onAllow = onAllowLocalNetwork, + onSettings = onOpenSystemSettings, + onDismiss = onDismissLnpPrompt, + ) + } + + // Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing + // seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch + // grid and the console home. + ConnectOverlay( + connectingHostName = connectingHostName, + waker = waker, + gamepadUi = gamepadUi, + onCancelConnect = onCancelConnect, + ) +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index bafadedb..7116fe91 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -11,31 +11,6 @@ import android.os.Build import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -45,19 +20,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner -import io.unom.punktfunk.components.EmptyHostsState -import io.unom.punktfunk.components.HostCard -import io.unom.punktfunk.components.HostMenuItem -import io.unom.punktfunk.components.SectionLabel import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.discovery.DiscoveredHost @@ -73,7 +40,6 @@ import io.unom.punktfunk.kit.security.KnownHost import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.kit.security.obtainIdentity import io.unom.punktfunk.models.ActiveSession -import io.unom.punktfunk.models.HostStatus import io.unom.punktfunk.models.PendingTrust import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.Dispatchers @@ -108,6 +74,20 @@ private class ConnectAttempt(val hostName: String) { val cancelled = AtomicBoolean(false) } +/** + * The connect screen — discovery, trust and the dial itself, under either interface. + * + * What is left in this file is the STATE and the engine: the mDNS browse and the permission that + * gates it, the identity, the host and profile stores, the trust decision, the dial and its wake + * fallback, and the `punktfunk://` router. What was drawn from that state now lives beside it — + * `buildHomeTiles` (the console carousel's contents), `ConnectGrid` (the touch home) and + * `ConnectPrompts` (everything modal, plus the connect takeover). They hold no state of their own, + * which is why they could leave: each one takes what it displays and hands back what was pressed. + * + * The engine did NOT leave, and shouldn't until it has somewhere to live: it closes over ~20 locals + * that a dozen callbacks read and write, and hoisting it means inventing a state holder — a second + * refactor, and a second thing to get wrong. + */ @Composable fun ConnectScreen( settings: Settings, @@ -651,52 +631,6 @@ fun ConnectScreen( } } - // The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a - // user who never wants this feature sees no new clutter anywhere but the settings scope chips. - // "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding - // lives in the Edit sheet instead. - fun hostMenu(kh: KnownHost, pin: StreamProfile?): List = buildList { - if (pin == null) { - add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) }) - } - add(HostMenuItem("Copy link") { copyLink(kh, pin) }) - if (profiles.isEmpty()) return@buildList - if (pin != null) { - add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) }) - } - add( - HostMenuItem("Connect with: Default settings", startsSection = true) { - // The empty reference is "force the defaults", not "unset" — on a bound host that - // is a real, different action from a plain tap. - connect(kh.address, kh.port, oneOffProfile = "") - }, - ) - profiles.forEach { p -> - add(HostMenuItem("Connect with: ${p.name}") { connect(kh.address, kh.port, oneOffProfile = p.id) }) - } - if (pin == null) { - profiles.forEachIndexed { i, p -> - val pinned = p.id in kh.pinnedProfileIds - add( - HostMenuItem( - if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}", - startsSection = i == 0, - ) { togglePin(kh, p) }, - ) - } - } - } - - // The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a - // pinned combination is a plain one-click connect instead of a trip through a menu. - val savedCards = savedHosts.flatMap { kh -> - listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) } - } - // Cards in one grid row must be the same height (the grid won't stretch them), so as soon as - // ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use - // profiles ever sees the gap. - val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null } - // ---- punktfunk:// routing (design/client-deep-links.md §3) -------------------------------- // // The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust @@ -782,77 +716,61 @@ fun ConnectScreen( var showManualSheet by remember { mutableStateOf(false) } + // Wake a saved host on demand — the touch card's Wake item and the console options dialog run + // the same action. Through the WakeController, so it shows the "Waking…" overlay and waits for + // the host to come back rather than firing one silent packet at it. + fun wakeHost(kh: KnownHost) { + // The magic packet is UDP broadcast — LNP-blocked like everything else. + if (!lnpGranted) { + lnpPrompt = true + return + } + waker.start( + hostName = kh.name, + connectsAfter = false, + macs = kh.mac, + lastIp = kh.address, + // "Back up" is mDNS presence ONLY — narrower than the [isOnline] that decides whether to + // OFFER Wake, which also counts a QUIC probe answer. Matched through `matches`, so a + // cold boot onto a new DHCP address still ends the wait. + isOnline = { discovered.any { kh.matches(it) } }, + onOnline = {}, + ) + } + + fun forgetHost(kh: KnownHost) { + knownHostStore.remove(kh) + savedHosts = knownHostStore.all() + } + if (gamepadUi) { // Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares // every action above; the trailing Add Host tile opens the same manual-entry sheet. - val tiles = buildList { - savedHosts.forEach { kh -> - val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } } - add( - HomeTile( - id = "saved-${kh.id}", - title = kh.name, - // The binding is what a press will actually do, so the tile says so — the - // console can't edit profiles, but it must never lie about which one it uses. - subtitle = bound?.let { "${kh.address}:${kh.port} · ${it.name}" } - ?: "${kh.address}:${kh.port}", - filled = true, - online = kh.isOnline(discovered, reachable), - paired = kh.paired, - knownHost = kh, - activate = { connect(kh.address, kh.port) }, - ), - ) - // Pinned host+profile combinations, right after their host: one focus-and-press - // each, which is the affordance a controller surface does well (menus are not). - profileStore.pinsFor(kh).forEach { p -> - add( - HomeTile( - id = "pin-${kh.id}-${p.id}", - title = kh.name, - subtitle = p.name, - filled = true, - online = kh.isOnline(discovered, reachable), - paired = kh.paired, - knownHost = kh, - pinnedProfileId = p.id, - activate = { connect(kh.address, kh.port, oneOffProfile = p.id) }, - ), - ) - } - } - discoveredUnsaved.forEach { dh -> - add( - HomeTile( - id = "disc-${dh.host}:${dh.port}", - title = dh.name, - subtitle = "${dh.host}:${dh.port}", - online = true, - activate = { connect(dh.host, dh.port, dh) }, - ), - ) - } - add( - HomeTile( - id = "add", - title = "Add Host", - subtitle = "Register a host by address", - isAdd = true, - activate = { showManualSheet = true }, - ), - ) - } GamepadHome( - tiles = tiles, + tiles = buildHomeTiles( + savedHosts = savedHosts, + profiles = profiles, + pinsFor = profileStore::pinsFor, + discoveredUnsaved = discoveredUnsaved, + isOnline = { it.isOnline(discovered, reachable) }, + onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) }, + onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) }, + onAddHost = { showManualSheet = true }, + ), libraryEnabled = settings.libraryEnabled, controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name, // Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen, // while a connect is in flight (else a second A launches a concurrent connect that leaks a // handle — the touch grid guards the same way with enabled=!connecting), or while the whole // console home is cross-fading out. + // ⚠ `speedTest` belongs in this list and was missing. It LOOKED covered by `!connecting`, + // and is — right up until the measurement finishes: `startSpeedTest` clears `connecting` + // before its Done/Failed card is dismissed, so from that moment the card AND the + // carousel underneath both consumed the pad. One A then dismissed the card and started + // a connect. Every other modal on this screen is named here for exactly this reason. navActive = navGate && !connecting && !showManualSheet && pendingTrust == null && awaiting == null && editTarget == null && optionsTarget == null && - waker.waking == null && !lnpPrompt, + speedTest == null && waker.waking == null && !lnpPrompt, onActivate = { it.activate() }, onOpenLibrary = { it.knownHost?.let(onOpenLibrary) }, onOpenSettings = onOpenSettings, @@ -863,239 +781,35 @@ fun ConnectScreen( }, ) } else { - Box(Modifier.fillMaxSize()) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 160.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - item(span = { GridItemSpan(maxLineSpan) }) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Spacer(Modifier.height(8.dp)) - Text("Punktfunk", style = MaterialTheme.typography.headlineLarge) - Text( - "stream a remote desktop", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(24.dp)) - - notice?.let { - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } - Spacer(Modifier.height(16.dp)) - } - - status?.let { - // In-flight progress (connecting / waking) is the full-screen ConnectOverlay's - // job now, so `status` only ever carries a result/error here — a filled error - // container reads as a real failure banner, not just red text lost in the layout. - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } - Spacer(Modifier.height(16.dp)) - } - } - } - - if (!lnpGranted) { - // Local network access denied: discovery can't ever find anything and every connect - // would time out — say so at the top, with the fix one tap away, instead of letting - // the screen look idle/broken. - item(span = { GridItemSpan(maxLineSpan) }) { - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Column( - Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "Local network access is off", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - "Android blocks Punktfunk from finding or reaching hosts until you allow it.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - textAlign = TextAlign.Center, - ) - TextButton(onClick = { lnpPrompt = true }) { Text("Allow…") } - } - } - Spacer(Modifier.height(12.dp)) - } - } - - if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - EmptyHostsState() - } - } - - if (savedHosts.isNotEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - SectionLabel("Saved hosts") - } - items(savedCards, key = { it.key }) { entry -> - val kh = entry.host - val pin = entry.pin - val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } } - HostCard( - name = kh.name, - address = "${kh.address}:${kh.port}", - status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU, - online = kh.isOnline(discovered, reachable), - // Live advert preferred (the store lags a discovery tick), else stored. - os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os - ?: kh.os, - enabled = !connecting, - // A pinned card connects with ITS profile; the host's own card follows the - // binding, which is exactly what its chip says it will do. - onConnect = { - if (pin != null) { - connect(kh.address, kh.port, oneOffProfile = pin.id) - } else { - connect(kh.address, kh.port) - } - }, - // Edit / Forget / Wake live on the host's own card only: a pinned card is a - // shortcut, not a second host, and offering destructive host actions on it - // would blur exactly that. - onForget = if (pin != null) { - null - } else { - { - knownHostStore.remove(kh) - savedHosts = knownHostStore.all() - } - }, - onEdit = if (pin != null) null else ({ editTarget = kh }), - // Explicit wake-only: offered when the host is offline and we have a MAC. Runs - // through the WakeController so it shows the "Waking…" overlay and waits for - // the host to come online (matched by fingerprint, so a new DHCP address on a - // cold boot still counts as "up") rather than firing a single silent packet. - onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) { - { - // The magic packet is UDP broadcast — LNP-blocked like everything else. - if (!lnpGranted) { - lnpPrompt = true - } else { - waker.start( - hostName = kh.name, - connectsAfter = false, - macs = kh.mac, - lastIp = kh.address, - isOnline = { discovered.any { kh.matches(it) } }, - onOnline = {}, - ) - } - } - } else { - null - }, - profileLabel = pin?.name ?: bound?.name, - profileProminent = pin != null, - accent = accentColor(pin?.accent ?: bound?.accent), - menuItems = hostMenu(kh, pin), - reserveProfileSlot = anyProfileChip, - ) - } - } - - if (discoveredUnsaved.isNotEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - Spacer(Modifier.height(12.dp)) - SectionLabel("Discovered on the network") - } - items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh -> - HostCard( - name = dh.name, - address = "${dh.host}:${dh.port}", - status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU, - online = true, // in the discovered list ⇒ live on mDNS right now - os = dh.os, - enabled = !connecting, - onConnect = { connect(dh.host, dh.port, dh) }, - onForget = null, - ) - } - } - - // Active-discovery hint: discovery runs whenever this screen is up, so while it's - // scanning but nothing's turned up yet (and we're not mid-connect), show it's working - // rather than looking idle/empty. Suppressed while local network access is denied — - // a spinner would be a lie there (the browse can't receive anything); the banner above - // owns that state. - // Scan again is offered whether or not anything turned up: the case that sends people - // here is ONE expected host missing, not an empty list, and a browse that quietly went - // deaf (blocked when it started, or backed off to its hour-long re-query) looks - // exactly like a network without that host on it. - if (lnpGranted && !connecting) { - item(span = { GridItemSpan(maxLineSpan) }) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - if (discovered.isEmpty()) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(8.dp)) - Text( - "Searching the local network…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(8.dp)) - } - TextButton(onClick = { discovery.restart() }) { Text("Scan again") } - } - } - } - - item(span = { GridItemSpan(maxLineSpan) }) { - Spacer(Modifier.height(96.dp)) - } - } - - ExtendedFloatingActionButton( - onClick = { showManualSheet = true }, - icon = { Icon(Icons.Filled.Add, contentDescription = null) }, - text = { Text("Add host") }, - expanded = !connecting, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(20.dp), + ConnectGrid( + savedHosts = savedHosts, + discovered = discovered, + discoveredUnsaved = discoveredUnsaved, + reachable = reachable, + profiles = profiles, + pinsFor = profileStore::pinsFor, + connecting = connecting, + notice = notice, + status = status, + lnpGranted = lnpGranted, + onAskLocalNetwork = { lnpPrompt = true }, + onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) }, + onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) }, + onForget = { kh -> forgetHost(kh) }, + onEdit = { kh -> editTarget = kh }, + onWake = { kh -> wakeHost(kh) }, + onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) }, + onCopyLink = { kh, pin -> copyLink(kh, pin) }, + onTogglePin = { kh, p -> togglePin(kh, p) }, + onRescan = { discovery.restart() }, + onAddHost = { showManualSheet = true }, ) - } } + // Add Host stayed behind while the other modals moved into ConnectPrompts: its form fields are + // remembered HERE, on purpose, so a half-typed address survives the sheet being dismissed and + // reopened. Moving the block without moving that state would quietly change what a dismiss + // costs; moving both is a separate decision from this one. if (showManualSheet) { if (gamepadUi) { // Console add-host: field list + on-screen controller keyboard. "Add" connects (which @@ -1123,148 +837,81 @@ fun ConnectScreen( } } - pendingTrust?.let { pt -> - // Same trust/pairing logic, console-styled + controller-navigable in gamepad mode. - val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) } - val onSavePaired = { fp: String -> + // Which layer a measurement would land in. Resolved here, not in the prompt: it is a question + // for the profile store, and the Apply button and the caption above it must agree on the answer. + val speedTestTarget = speedTest?.let { SpeedTestTarget.resolve(it.host, it.pin?.id, profileStore) } + // Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's + // `discovery.hosts.first { host.matches($0) }?.macAddresses`. + val editSuggestedMacs = + editTarget?.let { kh -> discovered.firstOrNull { kh.matches(it) }?.mac } ?: emptyList() + + // Everything that floats above whichever home was drawn, in one place and in one order — see + // ConnectPrompts.kt. It decides nothing: each action below lands right back in the engine above. + ConnectPrompts( + gamepadUi = gamepadUi, + identity = identity, + profiles = profiles, + isOnline = { it.isOnline(discovered, reachable) }, + pendingTrust = pendingTrust, + onPendingTrustChange = { pendingTrust = it }, + onTrustNew = { pt -> + pendingTrust = null + doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) + }, + onPaired = { pt, fp -> knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true) savedHosts = knownHostStore.all() pendingTrust = null doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch) - } - when (pt.kind) { - PendingTrust.Kind.TRUST_NEW -> - if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null }) - else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null }) - PendingTrust.Kind.FP_CHANGED -> - if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null }) - else FingerprintChangedDialog(pt, onPair, { pendingTrust = null }) - PendingTrust.Kind.REQUEST_ACCESS -> - if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null }) - else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null }) - PendingTrust.Kind.PAIR -> - if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null }) - else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null }) - } - } - - awaiting?.let { req -> - val onCancel = { - req.cancelled.set(true) + }, + onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) }, + awaitingHostName = awaiting?.target?.name, + onCancelApproval = { + awaiting?.cancelled?.set(true) awaiting = null connecting = false discovery.start() // the request may still be pending on the host; keep scanning - } - if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel) - else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel) - } - - // Console host options (Up on a saved carousel tile): Wake / Edit / Forget. - optionsTarget?.let { entry -> - val kh = entry.host - val pin = entry.pin - val offline = !kh.isOnline(discovered, reachable) - GamepadHostOptionsDialog( - hostName = kh.name, - canWake = kh.mac.isNotEmpty() && offline, - onWake = { - optionsTarget = null - // The magic packet is UDP broadcast — LNP-blocked like everything else. - if (!lnpGranted) { - lnpPrompt = true - } else { - waker.start( - hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address, - isOnline = { discovered.any { kh.matches(it) } }, - onOnline = {}, - ) - } - }, - // A saved host always has a library (it's a knownHost) → offer it when the setting's on, - // so a TV remote reaches the library here instead of via the Y face button. - onLibrary = if (settings.libraryEnabled && pin == null) { - { optionsTarget = null; onOpenLibrary(kh) } - } else { - null - }, - onSpeedTest = if (pin == null) { - { optionsTarget = null; startSpeedTest(HostCardEntry(kh, null)) } - } else { - null - }, - onCopyLink = { optionsTarget = null; copyLink(kh, pin) }, - onEdit = { optionsTarget = null; editTarget = kh }, - onForget = { - knownHostStore.remove(kh) - savedHosts = knownHostStore.all() - optionsTarget = null - }, - onDismiss = { optionsTarget = null }, - // A pin's only action: unpinning touches neither the host nor the profile. - onUnpin = pin?.let { p -> { togglePin(kh, p); optionsTarget = null } }, - profileName = pin?.name, - ) - } - - speedTest?.let { entry -> - val target = SpeedTestTarget.resolve(entry.host, entry.pin?.id, profileStore) - val dismiss = { speedTest = null } - val apply: (Boolean) -> Unit = { toProfile -> + }, + optionsTarget = optionsTarget, + onDismissOptions = { optionsTarget = null }, + libraryEnabled = settings.libraryEnabled, + onOpenLibrary = onOpenLibrary, + onWake = { kh -> wakeHost(kh) }, + onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) }, + onCopyLink = { kh, pin -> copyLink(kh, pin) }, + onEditHost = { kh -> editTarget = kh }, + onForgetHost = { kh -> forgetHost(kh) }, + onTogglePin = { kh, p -> togglePin(kh, p) }, + speedTest = speedTest, + speedTestTarget = speedTestTarget, + speedTestPhase = speedTestPhase, + onApplySpeedTest = { toProfile -> val done = speedTestPhase as? SpeedTestPhase.Done - if (done != null) { + if (done != null && speedTestTarget != null) { val where = applySpeedTestResult( - done.recommendedKbps, target, toProfile, profileStore, settings, onSettingsChange, + done.recommendedKbps, speedTestTarget, toProfile, profileStore, settings, + onSettingsChange, ) profiles = profileStore.all() notice = "%.0f Mbit/s set in %s".format(done.recommendedMbps, where) } speedTest = null - } - if (gamepadUi) { - GamepadSpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss) - } else { - SpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss) - } - } - - editTarget?.let { kh -> - // Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's - // `discovery.hosts.first { host.matches($0) }?.macAddresses`. - val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList() - val onSaveHost: (KnownHost) -> Unit = { updated -> + }, + onDismissSpeedTest = { speedTest = null }, + editTarget = editTarget, + editSuggestedMacs = editSuggestedMacs, + onSaveHost = { updated -> knownHostStore.save(updated) savedHosts = knownHostStore.all() editTarget = null - } - if (gamepadUi) { - // Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the - // host with an extra MAC row; the action SAVES instead of connecting. - GamepadAddHostScreen( - onAdd = { _, _, _ -> }, - onDismiss = { editTarget = null }, - editHost = kh, - suggestedMacs = suggested, - onSave = onSaveHost, - ) - } else { - EditHostDialog( - target = kh, - suggestedMacs = suggested, - profiles = profiles, - onSave = onSaveHost, - onDismiss = { editTarget = null }, - ) - } - } - - if (lnpPrompt) { - // Android 17+ local-network-permission rationale: re-request (a permanently-denied request - // returns instantly without a system prompt — hence the settings deep link alongside). - val onAllow = { + }, + onDismissEdit = { editTarget = null }, + lnpPrompt = lnpPrompt, + onAllowLocalNetwork = { lnpPrompt = false localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) - } - val onSettings = { + }, + onOpenSystemSettings = { lnpPrompt = false context.startActivity( Intent( @@ -1272,21 +919,10 @@ fun ConnectScreen( Uri.fromParts("package", context.packageName, null), ), ) - } - if (gamepadUi) { - GamepadLocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false }) - } else { - LocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false }) - } - } - - // Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing - // seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch - // grid and the console home. - ConnectOverlay( + }, + onDismissLnpPrompt = { lnpPrompt = false }, connectingHostName = attempt?.hostName, waker = waker, - gamepadUi = gamepadUi, onCancelConnect = { cancelConnect() }, ) } @@ -1295,8 +931,11 @@ fun ConnectScreen( * One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned * host+profile cards. Pins are additive presentation state on the host record — never duplicated * host entries, which would fork pairing, trust and renames (design §5.2a). + * + * The console reuses it deliberately: its options dialog acts on a host-or-pin exactly as the touch + * card's overflow menu does, and one currency for "which card is this" keeps the two from drifting. */ -private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) { +internal data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) { val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}" } @@ -1305,7 +944,7 @@ private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) { * as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via * the native core + MulticastLock) does not depend on it. */ -fun hasNearbyPermission(context: Context): Boolean = +internal fun hasNearbyPermission(context: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED @@ -1317,7 +956,7 @@ fun hasNearbyPermission(context: Context): Boolean = * QUIC dial surfaces as a silent handshake timeout and the mDNS browse receives nothing. Unlike * [hasNearbyPermission] this is load-bearing — nothing on the connect screen works without it. */ -fun hasLocalNetworkPermission(context: Context): Boolean = +internal fun hasLocalNetworkPermission(context: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED @@ -1327,7 +966,7 @@ fun hasLocalNetworkPermission(context: Context): Boolean = * fingerprint when both carry it (so it survives a DHCP address change), else by address:port. * Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts". */ -private fun KnownHost.matches(dh: DiscoveredHost): Boolean { +internal fun KnownHost.matches(dh: DiscoveredHost): Boolean { val advFp = dh.fingerprint?.lowercase() if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true return address == dh.host && port == dh.port @@ -1337,6 +976,9 @@ private fun KnownHost.matches(dh: DiscoveredHost): Boolean { * True when a saved host is reachable RIGHT NOW: advertising on mDNS OR answering the QUIC probe * (a host reached over a routed network — Tailscale/VPN — never advertises but is reachable). The * display-side companion to dial-first: presence no longer means "on this LAN". + * + * `internal`, not private: the touch grid draws the same pip in its own file now, and the console's + * tile builder is handed this as a lambda so it never has to know what "reachable" is made of. */ -private fun KnownHost.isOnline(discovered: List, reachable: Set): Boolean = +internal fun KnownHost.isOnline(discovered: List, reachable: Set): Boolean = discovered.any { matches(it) } || reachable.contains("$address:$port") diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index c95a6e79..df960c1c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -1,6 +1,7 @@ package io.unom.punktfunk import android.content.Context +import android.content.res.Configuration import android.hardware.input.InputManager import android.os.Build import android.os.CombinedVibration @@ -11,12 +12,15 @@ import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -39,11 +43,15 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource import io.unom.punktfunk.kit.DsDevice import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.Sc2Capture @@ -55,10 +63,145 @@ import kotlinx.coroutines.delay * case where a pad "doesn't work" — adapters and BT-to-USB dongles often enumerate with a different * identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices * Android classifies as gamepad/joystick. This screen makes that visible on the device itself. + * + * This is the TOUCH entry point; [ConsoleControllersScreen] shows the same body on the console's + * field. Both drive [ControllersBody] — the screen exists once, and the support answer it gives has + * to be the same one whichever interface asked. */ @Composable fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { BackHandler(onBack = onBack) + var testing by remember { mutableStateOf(false) } + ControllersBody( + gamepadSetting = gamepadSetting, + scroll = rememberScrollState(), + testing = testing, + onTestingChange = { testing = it }, + // The touch screen holds the probes for its whole life: events are OBSERVED (not consumed) + // while the test is off, which is what keeps the "Last input" line live while browsing. + // Nothing else here wants the pad, so there is no one to hand them to. + observeInput = true, + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp), + ) { + Text("Controllers", style = MaterialTheme.typography.headlineMedium) + } +} + +/** + * The same screen on the console's field — the couch route to it, which a TV box has no other way to + * reach (there is no touch interface to fall back to there, which is exactly why this matters). + * + * Navigation, and how the pad is shared with the test: + * * up/down scrolls, the shoulders page — the body is cards and prose with no focusable rows, and + * Compose only scrolls to keep a FOCUSED child visible (see [rememberConsoleScroller]); + * * A starts the input test, which is the one thing on this screen a controller can act on; + * * while the test runs it OWNS the pad — that is the whole point of it — so this screen's nav + * drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends. + */ +@Composable +fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: Boolean = true) { + BackHandler(onBack = onBack) + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val hazeState = remember { HazeState() } + val scroll = rememberScrollState() + val scrollBy = rememberConsoleScroller(scroll) + var testing by remember { mutableStateOf(false) } + val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true + + GamepadNavEffect2D( + // Off while the test runs: both want the same single probe slot, and the test is the one + // the user just asked for. The identity check in each teardown (here and in the body) is + // what makes the handover safe in either direction. + active = navActive && !testing, + onDirection = { dir -> + when (dir) { + NavDir.UP -> scrollBy(-1, false) + NavDir.DOWN -> scrollBy(1, false) + // Nothing on this screen steps sideways; paging is the shoulders' job. + NavDir.LEFT, NavDir.RIGHT -> {} + } + }, + onActivate = { testing = true }, + onShoulder = { delta -> scrollBy(delta, true) }, + ) + + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + // The calm backdrop, full-bleed under the bars and the cutout: this is a screen to READ, + // and the aurora is ambience. Only the content takes the safe area. + GamepadFormBackground(Modifier.fillMaxSize()) + // The body is written against the touch theme; on the console field it has to be inked + // from the palette or it is grey-on-pastel over the six pale palettes. + ConsoleInkedTheme { + Column(Modifier.fillMaxSize().consoleSafeArea()) { + ControllersBody( + gamepadSetting = gamepadSetting, + scroll = scroll, + testing = testing, + onTestingChange = { testing = it }, + // Only while testing: the rest of the time the screen's own nav holds the + // probes, so the "Last input" line is a test-time readout here rather than + // an always-on one. A pad that reaches this screen at all has already + // proved it is seen — by moving the cursor here. + observeInput = testing, + contentPadding = PaddingValues( + start = ConsoleEdgeInset, + end = ConsoleEdgeInset, + // Clears the floating legend zone, like every other console list. + bottom = ConsoleLegendClearance, + ), + ) { + ConsoleHeader("Connected controllers", horizontalInset = false) + } + } + } + } + Box( + Modifier + .align(Alignment.BottomStart) + .consoleLegendInsets(landscape) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar( + if (testing) { + // The rule, stated at the moment it applies: while the test runs, B is a BUTTON + // UNDER TEST like any other — it lights its own chip — so only a hold ends the + // test, after which B is the universal Back again. Tappable as the touch hatch. + listOf(PadGlyph.hint('B', "Hold to finish") { testing = false }) + } else { + listOfNotNull( + GamepadHint('↕', PadGlyph.Arrow, "Scroll"), + // Advertised only where they exist — a TV remote has no shoulders, and + // claiming otherwise is both a lie and the reason a narrow legend overflows. + GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad }, + PadGlyph.hint('A', "Test inputs") { testing = true }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) + }, + hazeState = hazeState, + ) + } + } +} + +/** + * The screen itself, shared by both interfaces. [contentPadding] and [heading] are where they + * differ: the touch screen pads for a thumb and titles with the Material headline, the console pads + * to the shared edge inset, clears its floating legend, and titles with [ConsoleHeader]. + * + * [observeInput] decides whether this body installs the shared MainActivity probes at all — see the + * two call sites, and [ConsoleControllersScreen] for why they cannot both be on at once. + */ +@Composable +private fun ControllersBody( + gamepadSetting: Int, + scroll: ScrollState, + testing: Boolean, + onTestingChange: (Boolean) -> Unit, + observeInput: Boolean, + contentPadding: PaddingValues, + heading: @Composable () -> Unit, +) { val context = LocalContext.current val activity = context as? MainActivity @@ -84,17 +227,31 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { // Live input test. While `testing`, the MainActivity probes consume pad events (so they show up // here instead of driving focus navigation); holding B releases, since the pad can no longer - // reach the Switch. Events are observed (not consumed) even when the test is off, so the - // "last input" line works while browsing. - var testing by remember { mutableStateOf(false) } + // reach the Switch. val held = remember { mutableStateMapOf() } val axes = remember { mutableStateMapOf() } var lastInput by remember { mutableStateOf(null) } var bHeld by remember { mutableStateOf(false) } + // The hold has lasted long enough; the test ends when B is let go (see the probe). + var holdSatisfied by remember { mutableStateOf(false) } + // The probes below are built ONCE per `observeInput` and then read these for the life of that + // installation. `testing` and the callback arrive as parameters now, so capturing them plainly + // would freeze the values they had when the probe was made — the test would consume nothing. + val consuming by rememberUpdatedState(testing) + // The console's refusal thud, on whatever actuator the driving pad or this device has. + val haptics by rememberUpdatedState(rememberConsoleHaptics()) - DisposableEffect(Unit) { - activity?.padKeyProbe = probe@{ event -> + DisposableEffect(observeInput) { + // Stable probe refs, and a teardown that releases the slot only if WE still hold it — the + // rule GamepadNavEffect2D follows. Without it this screen's dispose nulls whatever is in the + // slot: during the console shell's push/pop BOTH screens are briefly composed, so leaving + // here would kill the pad navigation the arriving screen had just installed. The same + // teardown also runs when this screen hands the pad to its own input test and back. + val keyProbe: (KeyEvent) -> Boolean = probe@{ event -> if (!Gamepad.isPad(event.device)) return@probe false + // Read ONCE, up front: the test can end inside this very event, and the release that + // ended it still has to be swallowed here — see the B branch below. + val consume = consuming when (event.action) { KeyEvent.ACTION_DOWN -> { held[event.keyCode] = true @@ -102,13 +259,34 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { } KeyEvent.ACTION_UP -> { held[event.keyCode] = false - if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = false + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) { + bHeld = false + if (consume) { + if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) { + // The hold ends the test HERE, on the release, and NOT the moment + // the 1.2 s elapsed: end it a moment earlier and this release falls + // through unconsumed to the activity's B→BACK remap, which takes the + // whole screen with it. Finishing the test and leaving the screen on + // one press is not what "hold B to finish" says. + onTestingChange(false) + held.clear() + } else { + // A short B is not swallowed either. While the test owns the pad, B + // is a BUTTON UNDER TEST — it lights its chip like every other — so + // a tap can't also mean "leave", and in the console B is otherwise + // the universal back. The press gets the boundary thud instead, the + // same answer a refused step gets on the settings screen: heard, and + // it means something else here. + haptics.boundary() + } + } + } } } lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}" - testing + consume } - activity?.padMotionProbe = probe@{ event -> + val motionProbe: (MotionEvent) -> Boolean = probe@{ event -> if (!Gamepad.isPad(event.device)) return@probe false axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X) axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y) @@ -124,31 +302,43 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { ) axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X) axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y) - testing + consuming + } + if (observeInput) { + activity?.padKeyProbe = keyProbe + activity?.padMotionProbe = motionProbe } onDispose { - activity?.padKeyProbe = null - activity?.padMotionProbe = null + activity?.let { a -> + if (a.padKeyProbe === keyProbe) a.padKeyProbe = null + if (a.padMotionProbe === motionProbe) a.padMotionProbe = null + } } } // Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the - // test instead (touch still works). A short tap cancels the effect before the delay fires. - LaunchedEffect(bHeld) { + // test instead (touch still works). This half only ANSWERS the hold once it is long enough; the + // release is what ends the test (see the probe). Letting go early cancels the effect before the + // delay fires, so nothing is announced. + LaunchedEffect(bHeld, testing) { if (bHeld && testing) { - delay(1_200) - testing = false - held.clear() + delay(HOLD_TO_FINISH_MS) + holdSatisfied = true + // A hold with no answer at the moment it lands is a hold you keep holding. Say it in + // both channels a couch user has: a pulse in the hands, a changed line on the screen. + haptics.confirm() + } else { + holdSatisfied = false } } Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 24.dp), + .verticalScroll(scroll) + .padding(contentPadding), verticalArrangement = Arrangement.spacedBy(24.dp), ) { - Text("Controllers", style = MaterialTheme.typography.headlineMedium) + heading() // Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice // (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from @@ -212,13 +402,19 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { Column(Modifier.weight(1f)) { Text("Test inputs", style = MaterialTheme.typography.bodyLarge) Text( - if (testing) "Controller input stays on this screen — hold B to finish" - else "Show button presses and stick motion live", + when { + holdSatisfied -> "Release B to finish" + testing -> "Controller input stays on this screen — hold B to finish" + else -> "Show button presses and stick motion live" + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Switch(checked = testing, onCheckedChange = { testing = it; if (!it) held.clear() }) + Switch( + checked = testing, + onCheckedChange = { on -> onTestingChange(on); if (!on) held.clear() }, + ) } if (testing) { ButtonGrid(held) @@ -680,3 +876,11 @@ private val TEST_BUTTONS = listOf( /** Axis bars shown in the test view, in display order. */ private val AXIS_LABELS = listOf("LX", "LY", "RX", "RY", "LT", "RT", "HX", "HY") + +/** + * How long B must be held to end the input test — and, below that, how long a press still counts as + * a tap that gets answered rather than ignored. One constant, because a hold that ends at 1.2 s + * while the "you tapped" answer stops at some other number leaves a window where a press does + * nothing at all. + */ +private const val HOLD_TO_FINISH_MS = 1_200L 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..590a69a8 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,9 @@ 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.width 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 @@ -31,6 +31,12 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.input.KeyboardType import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource @@ -47,7 +53,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 @@ -69,6 +74,20 @@ private const val KB_ROWS = 5 private class Field(val id: String, val label: String, val value: String, val placeholder: String) +/** + * A non-text row of the EDIT form — a switch or a stepped choice, driven like a settings row rather + * than opening the keyboard. Add-host mode has none: they all edit properties a host only has once + * it is saved. + */ +private class ExtraRow( + val label: String, + val value: String, + /** Non-null = draw a [ConsoleSwitch] instead of the value text. */ + val toggled: Boolean?, + val adjust: (Int) -> Unit, + val activate: () -> Unit, +) + @Composable fun GamepadAddHostScreen( onAdd: (name: String, address: String, port: Int) -> Unit, @@ -78,6 +97,11 @@ fun GamepadAddHostScreen( editHost: KnownHost? = null, suggestedMacs: List = emptyList(), onSave: ((KnownHost) -> Unit)? = null, + /** + * The profile catalog, for the edit form's binding row. Empty (the default) simply omits that + * row — which is also what a device with no profiles yet gets. + */ + profiles: List = emptyList(), ) { val ink = LocalGamepadInk.current val context = LocalContext.current @@ -89,6 +113,15 @@ fun GamepadAddHostScreen( var address by remember { mutableStateOf(editHost?.address ?: "") } var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") } var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") } + // The two host properties the console could not reach at all until now. `copy` preserved them, + // so nothing was ever LOST — but a couch-only user (a TV box has no touch interface to fall + // back to) could never decide either one, which the touch edit sheet has always offered. + var clipboard by remember(editHost) { mutableStateOf(editHost?.clipboardSync ?: true) } + // Filtered through the live catalog, so a binding to a since-deleted profile reads as unset + // rather than as a name nothing can resolve — the same guard the touch sheet applies. + var boundId by remember(editHost, profiles) { + mutableStateOf(editHost?.profileId?.takeIf { id -> profiles.any { it.id == id } }) + } val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0 fun commit() { if (isEdit && editHost != null && onSave != null) { @@ -98,6 +131,8 @@ fun GamepadAddHostScreen( address = address.trim(), port = port.toIntOrNull() ?: editHost.port, mac = KnownHostStore.parseMacs(mac), + clipboardSync = clipboard, + profileId = boundId, ), ) } else { @@ -135,7 +170,43 @@ fun GamepadAddHostScreen( add(Field("port", "Port", port, "9777")) if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen")) } - val actionIndex = fields.size // the Save/Add action sits just after the last field + // The switch/choice rows, between the text fields and the action. Only in EDIT mode: both edit + // properties a host only has once it has been saved. + val extras = buildList { + if (isEdit) { + add( + ExtraRow( + label = "Shared clipboard", + value = if (clipboard) "On" else "Off", + toggled = clipboard, + // Directional = state-targeted, so holding a direction can't oscillate — the + // same rule the settings toggles and the pin picker use. + adjust = { d -> clipboard = d > 0 }, + activate = { clipboard = !clipboard }, + ), + ) + if (profiles.isNotEmpty()) { + // "Default settings" is the absence of a binding, not a profile, so it leads the + // ring as a null rather than being faked as an entry in the catalog. + val options = listOf(null) + profiles + val idx = options.indexOfFirst { it?.id == boundId }.coerceAtLeast(0) + fun stepTo(delta: Int) { + val n = ((idx + delta) % options.size + options.size) % options.size + boundId = options[n]?.id + } + add( + ExtraRow( + label = "Profile", + value = options[idx]?.name ?: "Default settings", + toggled = null, + adjust = { d -> stepTo(d) }, + activate = { stepTo(1) }, + ), + ) + } + } + } + val actionIndex = fields.size + extras.size // the Save/Add action sits after everything fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 } fun closeKeyboard() { editing = null } @@ -152,11 +223,13 @@ fun GamepadAddHostScreen( "address" -> c != ' ' else -> true } + /** The focused row's extra, or null when the cursor is on a text field or the action. */ + fun focusedExtra(): ExtraRow? = extras.getOrNull(focus - fields.size) fun activateField() { - if (focus == actionIndex) { - if (canAdd) commit() else { focus = 1; openKeyboard("address") } - } else { - openKeyboard(fields[focus].id) + when { + focus == actionIndex -> if (canAdd) commit() else { focus = 1; openKeyboard("address") } + focus < fields.size -> openKeyboard(fields[focus].id) + else -> focusedExtra()?.activate() } } fun pressKey() { @@ -179,7 +252,11 @@ fun GamepadAddHostScreen( when (dir) { NavDir.UP -> if (focus > 0) focus-- NavDir.DOWN -> if (focus < actionIndex) focus++ - else -> {} + // Left/right step the switch and the profile ring, exactly as they step a + // settings row. On a text field or the action they still do nothing — there is + // no value there to walk. + NavDir.LEFT -> focusedExtra()?.adjust(-1) + NavDir.RIGHT -> focusedExtra()?.adjust(1) } } else { when (dir) { @@ -214,15 +291,17 @@ 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( - Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()), + Modifier.weight(1f).fillMaxHeight().widthIn(max = 620.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { ConsoleHeader(title, horizontalInset = false) fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } } + extras.forEachIndexed { i, e -> ExtraRowView(e, focused = false) { onFieldClick(fields.size + i) } } AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() } Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left } @@ -236,9 +315,11 @@ 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()), + // Same 620 dp cap as the settings rows: a field stretched across a wide + // landscape phone is a ribbon, not an input. + Modifier.weight(1f).widthIn(max = 620.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { ConsoleHeader(title, horizontalInset = false) @@ -251,6 +332,11 @@ fun GamepadAddHostScreen( ) } fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } } + extras.forEachIndexed { i, e -> + ExtraRowView(e, focused = focus == fields.size + i && editing == null) { + onFieldClick(fields.size + i) + } + } AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() } Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled } @@ -268,7 +354,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 +401,7 @@ private fun TvAddHostForm( Column( Modifier .fillMaxSize() - .systemBarsPadding() + .consoleSafeArea() .padding(horizontal = 56.dp, vertical = 36.dp) .widthIn(max = 720.dp) .verticalScroll(rememberScrollState()), @@ -366,14 +452,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 +477,61 @@ 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 }) + } +} + +/** + * A switch or stepped-choice row of the edit form. Deliberately the settings screen's row in + * miniature — same glass, same end-aligned value slot, same `ConsoleSwitch` — because it IS a + * settings row: it edits a stored property with left/right, and a user who has met one has met + * both. + */ +@Composable +private fun ExtraRowView(row: ExtraRow, focused: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current + val visuals = animateConsoleFocus(active = focused) + Row( + modifier = Modifier + .fillMaxWidth() + .consoleGlass(ConsoleShape.Row, visuals) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .semantics(mergeDescendants = true) { + role = if (row.toggled != null) Role.Switch else Role.Button + contentDescription = "${row.label}, ${row.value}" + row.toggled?.let { + toggleableState = if (it) ToggleableState.On else ToggleableState.Off + } + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + row.label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = ink.fg, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + if (row.toggled != null) { + ConsoleSwitch(on = row.toggled, focused = focused) + } else { + Text( + row.value, + style = MaterialTheme.typography.bodyMedium, + color = ink.fg(if (focused) 1f else 0.6f), + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } @@ -395,19 +539,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 +570,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 +609,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..2d8d2314 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,46 @@ 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.Easing 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.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -38,146 +51,173 @@ 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.mutableIntStateOf +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.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +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.PathEffect +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.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState 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 io.unom.punktfunk.kit.deviceBodyVibrator +import androidx.compose.ui.zIndex import kotlin.math.PI +import kotlin.math.abs import kotlin.math.cos import kotlin.math.max +import kotlin.math.min 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. - */ -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), -) - -/** - * 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. + * 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. * - * [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. + * 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`. * - * Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the - * same courtesy the Apple client pays Reduce Motion. + * ⚠ 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. */ -@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 ConsoleMotion { + /** + * The desktop's `ease_out_cubic` — `1 − (1−t)³`, ANALYTICALLY, not as a Bézier approximation + * of it (`crates/pf-console-ui/src/anim.rs`). + * + * ⚠ Two different curves are published under the name "easeOutCubic" and neither is the real + * one: the Penner/Ceaser CSS table's `cubic-bezier(0.215, 0.61, 0.355, 1)` and easings.net's + * `cubic-bezier(0.33, 1, 0.68, 1)`. At the midpoint the true curve is 0.875, the second bezier + * ≈0.87, and the first ≈0.80 — visibly slacker. Compose's `Easing` is a plain function, so + * there is no reason to approximate at all; the Apple client uses the second bezier only + * because SwiftUI's `timingCurve` cannot take a closure. + */ + val EaseOutCubic = Easing { t -> + val u = 1f - t + 1f - u * u * u } + + /** 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 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. + */ +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 +226,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 +239,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 +310,719 @@ 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( + // The pills are one mutually-exclusive set, and saying so is the only way a screen + // reader can know it: the SELECTION is drawn as an indicator in the parent's + // `drawBehind`, which is paint and nothing else — no pill differs from its + // neighbours in the tree. + Modifier.padding(horizontal = ConsoleEdgeInset).selectableGroup(), + 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) + // `selectable`, not `clickable`: same ripple and the same click, but it + // also publishes Role.Tab + the selected flag, so "Video, tab, selected" + // reaches a screen reader that cannot see the indicator behind the pill. + .selectable(selected = active, role = Role.Tab) { 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( + // 0.28, not the 0.20 it launched with: with the shadow and bloom gone (see consoleGlass), + // the tint IS the whole focus statement, and Apple's rows carry accent(0.30) for the same + // reason. Slightly under Apple's because this fill also rides a luminance gradient. + if (active) ink.accent(0.28f) 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. + * + * Two things separate it from a flat translucent fill: + * * 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". + * + * Focus is the FILL and the BORDER brightening — nothing else, which matches the Apple client's + * glass (`GlassStyle.swift`: material + an animatable tint, full stop). Two earlier additions were + * removed after the first on-glass review, and neither may come back: + * * a drop shadow. An elevation shadow is drawn UNDER the surface, and this surface is + * translucent — the shadow showed straight through the fill as a dark rectangle floating + * inside every row and card. + * * an accent bloom drawn outside the clip. Unclipped drawing does not stop at the row's + * neighbours either: in a list the glow painted over the rows above and below, and in the + * carousel it escaped the card entirely. + */ +@Composable +fun Modifier.consoleGlass( + shape: Shape, + visuals: ConsoleFocusVisuals, + /** + * Draw the edge DASHED instead of solid — the convention for a surface that is offered but not + * yet yours (a host discovered on the network, the Add-Host tile). Dashes need a real stroke + * rather than `Modifier.border`, which takes no path effect, so this branch draws the edge + * itself; the top-edge highlight comes with it either way. + */ + dashed: Boolean = false, +): Modifier { + val ink = LocalGamepadInk.current + val focus = visuals.focus + val highlight = ink.highlight + val fill = visuals.background + val border = visuals.border + if (dashed) { + return this + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } + // BEFORE the clip, so the full stroke width shows: drawn inside it, the outer half of + // the line would be clipped away and the dashes would read as a hairline. + .drawWithContent { + drawContent() + // The console's shapes are all rounded rects; anything else simply gets a square + // dashed edge rather than no edge at all. + val r = (shape as? RoundedCornerShape)?.topStart?.toPx(size, this) ?: 0f + drawRoundRect( + brush = Brush.verticalGradient( + listOf(highlight.copy(alpha = highlight.alpha * 0.7f), border), + ), + cornerRadius = CornerRadius(r), + style = Stroke( + width = 1.dp.toPx(), + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(6.dp.toPx(), 5.dp.toPx()), + ), + ), + ) + } + .clip(shape) + .background( + Brush.verticalGradient( + listOf( + fill.copy(alpha = (fill.alpha * 1.35f + 0.03f).coerceAtMost(1f)), + fill.copy(alpha = fill.alpha * 0.72f), + ), + ), + ) + } + return this + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } + .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, + ) +} + +/** + * 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. + * + * ⚠ The band is HIDDEN FROM ACCESSIBILITY, and the row carries the same words in its own + * description instead (see `SettingRowView`). Of the two ways to make a floating band speak — hide + * it and merge, or leave it and make it a live region — merging wins because the band is a + * SIGHTED-ONLY relationship: it is a strip in the bottom-left corner whose only tie to the row it + * describes is that they happen to be on screen together. A screen reader walking the list would + * meet it as a stray paragraph several nodes away from its subject, and a live region would + * additionally interrupt the row announcement it duplicates. `hideFromAccessibility` rather than + * `clearAndSetSemantics` deliberately: the node stays in the semantics tree (where the layout tests + * assert the description is still rendered), it is only skipped by screen readers. + */ +@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)) + }, + // Silent to a screen reader, deliberately. The band is a place to LOOK — it sits at the + // far bottom of the screen, describing a row that may be anywhere in the list — so read + // aloud in layout order it arrives long after, and out of any useful context. The SETTINGS + // ROW merges this same string into its own announcement instead, which is where a reader + // is when it matters. Sighted focus and screen-reader focus want the text in different + // places; this is the one giving each what it needs rather than one of them both. + modifier = modifier.semantics { hideFromAccessibility() }, + 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 + .semantics { hideFromAccessibility() } + .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. + * + * Being hand-drawn, it announces nothing on its own — a `Box` with a gradient is not a switch to + * anything but an eye. The semantics here make it one wherever it is used; a caller that MERGES it + * into a bigger node (a settings row does) restates them on that node, since `Role` never + * propagates from a child. + */ +@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 + // A hand-drawn track and knob are two `Box`es to a screen reader — nothing about them + // says "switch" or says which way it is thrown. The row that owns this one is the + // thing that gets pressed (the pad and a tap both act on the ROW), so the switch is + // not itself toggleable here: it only has to REPORT. See the settings row, which + // merges this into its own announcement. + .semantics { + role = Role.Switch + toggleableState = if (on) ToggleableState.On else ToggleableState.Off + } + .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), + ) + } +} + +// --- The option band --------------------------------------------------------------------------- + +/** + * A choice row's value as a REAL band — the Android port of the Apple client's + * `GamepadOptionBand.swift`, and the fix for "the select field is nowhere near the Apple client": + * the options sit side by side on a drum segment curving about a vertical axis. The current one + * faces you flat; a step rotates the next one in with perspective. The drum's position is ONE + * continuous value driven by a spring, and Compose's `Animatable` retargets with velocity — rapid + * presses accumulate into one accelerating travel instead of five restarted crossfades. + * + * The band is LINEAR, not a ring (the Apple field verdict, inherited whole): a ring showed the + * first option waiting to the right of the last one, which left/right can't reach — a promise the + * navigation doesn't keep. Positions are fixed, the ends are the ends, and A's wrap from the last + * option travels BACK across the list to the first. Neighbours exist only while the drum is + * actually moving — at rest a row shows exactly its value (a resting neighbour under a long label + * rendered as overlapping, unreadable text on Apple, and would here too). + * + * [width] is FIXED by the row: a step must never reflow the row (the free-width value shifted the + * chevrons with every label), and the drum needs its stage even when the facing label is short. + * + * Purely presentational: stepping semantics (clamp with a boundary thud, A cycles wrapping, + * disabled rows refuse) stay in the settings screen's row closures. + */ +@Composable +fun ConsoleOptionBand( + options: List, + selection: Int, + focused: Boolean, + width: Dp, + modifier: Modifier = Modifier, +) { + val ink = LocalGamepadInk.current + val animated = animationsEnabled() + val color = ink.fg(if (focused) 1f else 0.6f) + val drum = remember { Animatable(selection.toFloat()) } + // Where the spring is headed (jumps instantly on a step; only the drum chases it). The + // distance between them is "how mid-flight are we" — the neighbours exist exactly as long as + // the drum is moving. + var target by remember { mutableIntStateOf(selection) } + LaunchedEffect(selection, options.size, animated) { + val old = target + target = selection + // A step (or A's wrap — on a linear band a fast travel back to the start) springs the + // drum; anything else (an external write from the touch settings, a re-derived options + // list) re-seats it — a travel to a value the user didn't step to would read as the UI + // acting on its own. + val wrapped = options.size > 1 && old == options.size - 1 && selection == 0 + if (animated && (abs(selection - old) == 1 || wrapped)) { + drum.animateTo( + selection.toFloat(), + // Apple's `.spring(response: 0.32, dampingFraction: 0.78)`; stiffness is + // (2π / response)² ≈ 385. + spring(dampingRatio = 0.78f, stiffness = 385f), + ) + } else { + drum.snapTo(selection.toFloat()) + } + } + Box( + modifier + .width(width) + .clipToBounds() + // One element to a screen reader — the neighbour texts are rendering, not content. + .clearAndSetSemantics { + contentDescription = options.getOrNull(selection).orEmpty() + }, + contentAlignment = Alignment.Center, + ) { + val rotation = drum.value + val flight = min(1f, abs(rotation - target) * 3f) + val widthPx = with(LocalDensity.current) { width.toPx() } + // Puts the ±1 neighbour ~40 % of the band off-centre, curling toward the edge. + val radius = widthPx * 0.72f + options.forEachIndexed { i, label -> + // Plain signed distance — option i has ONE home and nothing waits beyond the ends. + val d = i - rotation + // Only the facing option at rest; its neighbours join it for the travel. + if (abs(d) < 0.5f || (flight > 0.001f && abs(d) <= 2.5f)) { + val angle = d * DRUM_STEP_RAD + val depth = cos(angle) + val x = radius * sin(angle) + val alpha = max(depth, 0f).let { it * it * it } * + (if (abs(d) < 0.5f) 1f else flight) * drumEdgeFade(x, widthPx) + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = color, + maxLines = 1, + softWrap = false, + modifier = Modifier + // The Apple `fixedSize()`: never let a turning label re-wrap to the + // band's width mid-flight — it clips at the band's edge fade instead. + .wrapContentWidth(unbounded = true) + .zIndex(depth) + .graphicsLayer { + translationX = x + val s = 0.70f + 0.30f * depth + scaleX = s + scaleY = s + // Foreshorten the label as it turns away — what sells the cylinder. + rotationY = Math.toDegrees(angle.toDouble()).toFloat() + cameraDistance = 6f * density + this.alpha = alpha + }, + ) + } + } + } +} + +/** Angular pitch between adjacent options on the drum — Apple's 34°. */ +private const val DRUM_STEP_RAD = 34f * (PI.toFloat() / 180f) + +/** + * The soft edge, per option rather than as a container mask: full strength through the middle of + * the band, dissolving to nothing by an option's rim, so the drum never ends on a hard cut. (The + * Apple file documents why it must not be a mask: a mask rasterises what it covers, which throws + * the 3D projection away every frame — the same trap exists in Compose via `graphicsLayer` alpha + * masking a parent.) + */ +private fun drumEdgeFade(x: Float, width: Float): Float { + val half = width / 2f + if (half <= 0f) return 1f + val fadeStart = half * 0.55f + if (abs(x) <= fadeStart) return 1f + return ((half - abs(x)) / (half - fadeStart)).coerceIn(0f, 1f) +} + +// --- 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 +1054,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 +1089,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 +1101,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 +1112,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 +1159,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 +1190,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 +1212,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 +1222,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 +1245,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 +1279,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 +1296,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..44c3334a 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, @@ -398,157 +383,6 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () } } -/** - * Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a - * powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the - * couch surface too, even though profile EDITING doesn't. - */ -@Composable -fun GamepadSpeedTestDialog( - hostName: String, - target: SpeedTestTarget, - phase: SpeedTestPhase, - onApply: (toProfile: Boolean) -> Unit, - onDismiss: () -> Unit, -) { - val done = phase as? SpeedTestPhase.Done - GamepadDialog( - title = "Network speed test", - onDismiss = onDismiss, - actions = buildList { - if (done != null) { - add( - DialogAction( - when (target) { - SpeedTestTarget.Global -> "Apply" - is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}”" - is SpeedTestTarget.Ask -> "Set in “${target.profile.name}”" - }, - primary = true, - ) { onApply(true) }, - ) - if (target is SpeedTestTarget.Ask) { - add(DialogAction("Set as default") { onApply(false) }) - } - } - add(DialogAction("Close", primary = done == null, onClick = onDismiss)) - }, - ) { - DialogText(hostName) - when (phase) { - SpeedTestPhase.Connecting -> DialogText("Connecting…") - SpeedTestPhase.Measuring -> - DialogText("Measuring — the host is bursting test traffic for two seconds.") - is SpeedTestPhase.Failed -> DialogText(phase.message) - is SpeedTestPhase.Done -> { - DialogText( - "%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct), - ) - DialogText("Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps)) - } - } - } -} - -/** Console counterpart of [LocalNetworkDialog] — the Android 17+ ACCESS_LOCAL_NETWORK rationale. */ -@Composable -fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) { - GamepadDialog( - title = "Allow local network access", - onDismiss = onDismiss, - actions = listOf( - DialogAction("Allow", primary = true, onClick = onAllow), - DialogAction("Open settings", onClick = onSettings), - DialogAction("Not now", onClick = onDismiss), - ), - ) { - DialogText( - "Android blocks Punktfunk from talking to devices on your network, so it can't find " + - "or reach any host until you allow it.", - ) - DialogText( - "If no prompt appears after Allow, enable “Nearby devices” for Punktfunk in " + - "system settings.", - ) - } -} - -@Composable -fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) { - GamepadDialog( - title = "Trust this host?", - onDismiss = onDismiss, - actions = listOf( - DialogAction("Cancel", onClick = onDismiss), - DialogAction("Pair with PIN", onClick = onPairInstead), - DialogAction("Trust (TOFU)", primary = true, onClick = onTrust), - ), - ) { - DialogText("First connection to ${pt.host}:${pt.port}.") - pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}…") } - DialogText( - "This host allows trust-on-first-use, but that can't tell an impostor from the real host. " + - "Pairing with a PIN is stronger — it proves both sides.", - ) - } -} - -@Composable -fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) { - GamepadDialog( - title = "Host identity changed", - onDismiss = onDismiss, - actions = listOf( - DialogAction("Cancel", onClick = onDismiss), - DialogAction("Re-pair", primary = true, onClick = onRepair), - ), - ) { - DialogText( - "The pinned fingerprint for ${pt.host} no longer matches what it now advertises. This can " + - "mean a host reinstall — or an impostor. Re-pair with the host's PIN to continue.", - ) - } -} - -@Composable -fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) { - GamepadDialog( - title = "Pairing required", - onDismiss = onDismiss, - actions = listOf( - DialogAction("Cancel", onClick = onDismiss), - DialogAction("Use a PIN", onClick = onUsePin), - DialogAction("Request access", primary = true, onClick = onRequestAccess), - ), - ) { - DialogText("${pt.host}:${pt.port} requires pairing before it will stream.") - DialogText( - "Request access and approve this device in the host's console (or web UI) — no PIN needed. " + - "Or pair with the 4-digit PIN the host displays.", - ) - } -} - -@Composable -fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { - val ink = LocalGamepadInk.current - GamepadDialog( - title = "Waiting for approval", - onDismiss = onCancel, - actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)), - ) { - val deviceName = Build.MODEL ?: "this device" - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg) - Text("Approve this device on $hostLabel.", color = ink.fg) - } - DialogText( - "Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " + - "once you approve — no PIN needed.", - ) - } -} - /** * Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes * 0–9), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified @@ -598,11 +432,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 +449,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 +471,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..ea29e0fe 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,11 +19,11 @@ 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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -33,6 +35,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 +47,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 +59,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 +67,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, @@ -79,6 +90,15 @@ class HomeTile( * belong to the host's own tile, and this one offers only Unpin. */ val pinnedProfileId: String? = null, + /** + * The profile a press will actually connect with — the host's binding, or the pin's own + * profile. Rendered as a chip on the card rather than appended to the subtitle: on a PIN card + * the profile is the entire reason the card exists, and a card that only whispers it in grey + * body text can't say that. Matches the Apple client's tile. + */ + val profileName: String? = null, + /** The profile's `#RRGGBB` chip colour, if it set one. */ + val profileAccent: Color? = null, val activate: () -> Unit, ) { // Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair @@ -118,6 +138,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 +157,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 +176,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 +208,12 @@ 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()) { + // The carousel deliberately IGNORES the safe area (first on-glass verdict): only the + // CENTRED card matters, and it sits mid-screen; the fanned neighbours running under + // the hole punch is ambience, while insetting the pager CLIPPED them at the cutout + // edge — cards visibly cut off is worse than cards behind a camera. The title and the + // legend keep their insets; they are content. + Box(Modifier.fillMaxSize()) { HorizontalPager( state = pagerState, pageSize = PageSize.Fixed(cardWidth), @@ -189,17 +225,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 +263,7 @@ fun GamepadHome( indication = null, ) { if (page == navTarget) { + pressToken++ onActivate(tile) } else { navTarget = page @@ -223,20 +278,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 +307,31 @@ 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), + // A DASHED edge on anything not yet saved — a host found on the network, and the + // Add tile. It is the touch grid's own convention and the Apple client's, and it + // says "not yours yet" before the subtitle has to. + dashed = !tile.filled, + ) .padding(22.dp), ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { @@ -291,12 +363,64 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (tile.profileName != null) { + ConsoleProfileChip( + name = tile.profileName, + accent = tile.profileAccent, + // On a PIN card the profile is why the card exists; on a bound host's own card it + // is a note about what a press will do. Same chip, two weights. + prominent = tile.pinnedProfileId != null, + modifier = Modifier.padding(top = 5.dp), + ) + } Text( tile.subtitle, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.55f), maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } +} + +/** + * The profile a card connects with, worn as a tinted capsule. The console counterpart of the touch + * grid's own chip (`HostComponents.kt`) — same shape and the same quiet/prominent split, but inked + * from the console palette rather than `MaterialTheme`, since it sits on the aurora. + * + * A profile that set no accent falls back to the palette's, not to the touch theme's primary: on a + * moss or copper field the brand violet would be the one foreign colour on the card. + */ +@Composable +private fun ConsoleProfileChip( + name: String, + accent: Color?, + prominent: Boolean, + modifier: Modifier = Modifier, +) { + val ink = LocalGamepadInk.current + val tint = accent ?: ink.accent + Row( + modifier = modifier + .clip(ConsoleShape.Pill) + .background(tint.copy(alpha = if (prominent) 0.24f else 0.12f)) + .padding(horizontal = 9.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(7.dp).clip(CircleShape).background(tint)) + Spacer(Modifier.width(6.dp)) + Text( + name, + style = if (prominent) { + MaterialTheme.typography.labelLarge + } else { + MaterialTheme.typography.labelMedium + }, + fontWeight = if (prominent) FontWeight.Bold else FontWeight.SemiBold, + color = tint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -305,10 +429,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..ff16e42b 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,19 @@ 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.snap +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically 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 @@ -25,15 +24,28 @@ import androidx.compose.foundation.layout.Column 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.WindowInsetsSides +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding +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.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding 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.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -46,13 +58,23 @@ 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.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.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 @@ -103,6 +125,19 @@ internal class GpRow( val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text) val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail) + /** + * What A does on a non-adjustable row, for the legend. It was the literal "Pin to hosts" in the + * hint bar back when a profile row was the only kind of row that acted rather than stepped; a + * row that opens the Controllers view was then advertised as pinning something. + */ + val actionHint: String = "Open", + /** + * A choice row's full option list + where [value] sits in it — what the [ConsoleOptionBand] + * drum turns through. Null (with [selectedIndex] -1) on everything that is not a stepped + * choice: toggles are a switch, and the flat rows keep the quiet text slip. + */ + val options: List? = null, + val selectedIndex: Int = -1, ) /** @@ -114,12 +149,28 @@ internal class GpRow( internal fun liveRow(rows: List, index: Int): GpRow? = rows.getOrNull(index)?.takeIf { it.enabled } +/** + * Where the cursor was when a row opened a SUB-SCREEN. The shell holds it across the trip (this + * screen's own state does not outlive it) and hands it back, so Back from the Controllers view lands + * on the row that opened it rather than on the first row of the first section. + * + * The row is remembered by ID, not by index: a tab's length follows the hardware and the profile + * catalog, and a remembered index is the stale-pointer bug the tab-switch clamp already exists for. + */ +data class GpSettingsPlace(val tab: GpTab, val rowId: String) + @Composable fun GamepadSettingsScreen( initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit, navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad + /** Open the connected-controllers view / the open-source notices — the shell pushes them. */ + onOpenControllers: () -> Unit = {}, + onOpenLicenses: () -> Unit = {}, + /** Where a return from one of those lands; null = a fresh entry, which starts at the top. */ + resume: GpSettingsPlace? = null, + onPlace: (GpSettingsPlace) -> Unit = {}, ) { var s by remember { mutableStateOf(initial) } fun update(next: Settings) { s = next; onChange(next) } @@ -162,24 +213,46 @@ fun GamepadSettingsScreen( // path there is this screen's own Controller-optimized UI toggle, which swaps in the standard // interface remote-navigably. The strings branch on it. val tv = remember { isTvDevice(context) } - val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) + - buildProfileRows(profiles, savedHosts, tv) { pinProfile = it } + // The installed version, for the About row — the console is the ONLY interface on a TV box, so + // the identity the touch About page states has to be reachable from here too. + val appVersion = remember { + runCatching { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull().orEmpty() + } + val allRows = buildSettingsRows( + s, hasBodyVibrator, hasGyroscope, av1Capable, + appVersion = appVersion, + openControllers = onOpenControllers, + openLicenses = onOpenLicenses, + update = ::update, + ) + buildProfileRows(profiles, savedHosts, tv) { pinProfile = it } // Which section is showing, and where each one's focus was when it was last left — a detour // into another tab shouldn't lose your place. - var tab by remember { mutableStateOf(GpTab.STREAM) } + var tab by remember { mutableStateOf(resume?.tab ?: GpTab.STREAM) } // True while the STRIP holds the cursor rather than the list. Up from the first row moves // here and Down goes back — the only route to the sections on a D-pad remote, which has no // shoulder buttons at all (and is exactly what a TV box ships with). var tabFocused by remember { mutableStateOf(false) } val tabFocus = remember { mutableStateMapOf() } val rows = allRows.filter { it.tab == tab } - var focus by remember { mutableIntStateOf(0) } + // Entry focus: the row a sub-screen was opened from, if we are coming back from one. Resolved + // ONCE, against the first row list — after that the cursor belongs to this screen. + var focus by remember { + mutableIntStateOf(rows.indexOfFirst { it.id == resume?.rowId }.coerceAtLeast(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 +261,45 @@ 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) } - val listState = rememberLazyListState() + // 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 haptics = rememberConsoleHaptics() val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + // Act on a row, publishing where the cursor was FIRST. A row that opens a sub-screen unmounts + // this one on the spot, so the place has to be out of here before its `activate` runs; every + // activation route (pad, tap, the legend's own A cell) goes through this one door so none of + // them can be the one that forgets. + fun activate(row: GpRow) { + onPlace(GpSettingsPlace(tab, row.id)) + adjustDir = 1 + row.activate() + } + + // 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,89 +312,233 @@ 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. onActivate = { - if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() } + if (tabFocused) tabFocused = false else liveRow(rows, focus)?.let { activate(it) } }, // The shoulders work from either place — a real pad never has to visit the strip. onShoulder = { delta -> stepTab(delta) }, ) - // Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the - // screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it. - // +1 accounts for the heading being item 0. - LaunchedEffect(focus, tab) { - runCatching { - val itemIndex = focus + 1 - val info = listState.layoutInfo - 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 - if (offScreen) listState.animateScrollToItem(itemIndex) - } - } - + val animated = animationsEnabled() val hazeState = remember { HazeState() } + val ink = LocalGamepadInk.current + // The list runs to the PHYSICAL bottom edge (see the column's insets below), so the legend + // zone's clearance has to carry the bottom bar inset itself. Landscape's zone is only the + // pill — its detail lives in the side pane — so it clears less. + val bottomInset = with(LocalDensity.current) { + WindowInsets.systemBars.getBottom(this).toDp() + } + val legendClearance = (if (landscape) 92.dp else ConsoleLegendClearance) + bottomInset + val legendClearancePx = with(LocalDensity.current) { legendClearance.roundToPx() } + // The drum's fixed stage: a portrait phone is the one place the full width starves the row's + // label, so it alone narrows it — the Apple band makes the same single exception. 132, not the + // 156 of the first cut: at 156 the LABELS truncated ("Resoluti…"), and a clipped label loses + // meaning where a drum value only loses its tail into the edge fade. + val bandWidth = if (landscape) 220.dp else 132.dp - 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)) { - GamepadFormBackground(Modifier.fillMaxSize()) - Column(Modifier.fillMaxSize().systemBarsPadding()) { - // The strip is PINNED while the rows scroll under it: it is this screen's primary - // navigation now, and a switcher you have to scroll back up to find isn't one. The - // title stays in the scrolling list (landscape has no height to spare, and the - // selected pill already says which section you are in). - ConsoleTabStrip( - titles = GpTab.entries.map { it.title }, - selected = GpTab.entries.indexOf(tab), - onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp), - focused = tabFocused, - ) - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - item(key = "__title") { - // "Default settings", not "Settings": this screen edits the base layer only. The - // console honours a host's profile but doesn't edit profiles (design §5.4), so a - // bare "Settings" would quietly imply it changes whatever that host streams with. - ConsoleHeader("Default settings", horizontalInset = false) + /** + * One section's rows as a scrolling pane. A composable local rather than inline because the + * tab transition composes TWO of these at once (incoming and outgoing), and each needs its own + * [LazyListState] — Compose refuses one state attached to two lists, which is why the previous + * cut animated a single list's contents and read as the same fade in every direction. + */ + val tabPane: @Composable (GpTab, Modifier) -> Unit = { paneTab, paneModifier -> + val paneRows = if (paneTab == tab) rows else allRows.filter { it.tab == paneTab } + val paneFocus = if (paneTab == tab) focus else (tabFocus[paneTab] ?: 0) + // Seeded at the restored cursor, so re-entering a section lands where it was left without + // a visible catch-up scroll on the first frame. + val paneListState = rememberLazyListState( + initialFirstVisibleItemIndex = paneFocus.coerceIn(0, paneRows.lastIndex.coerceAtLeast(0)), + ) + // Keep the focused row on screen, but only SCROLL when it's actually off-screen. Only the + // LIVE pane tracks the cursor; the outgoing one is a photograph on its way out. + if (paneTab == tab) { + LaunchedEffect(focus) { + runCatching { + val info = paneListState.layoutInfo + val item = info.visibleItemsInfo.firstOrNull { it.index == focus } + val offScreen = item == null || + item.offset < info.viewportStartOffset || + // The SAME clearance the list pads its bottom with, rather than a literal + // that has to be remembered when the legend zone grows. + item.offset + item.size > info.viewportEndOffset - legendClearancePx + if (offScreen) paneListState.animateScrollToItem(focus) + } } - itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> + } + LazyColumn( + state = paneListState, + // Capped at the Apple client's 620 row width: a landscape phone is WIDER than it is + // useful, and a settings row stretched across 900 dp reads as a ribbon, not a control. + // Start-aligned (not centred) so the rows and the side detail pane split the screen + // rather than both crowding the middle. + modifier = paneModifier.widthIn(max = 620.dp + ConsoleEdgeInset * 2), + contentPadding = PaddingValues( + start = ConsoleEdgeInset, + end = ConsoleEdgeInset, + top = 8.dp, + // Clears the whole floating legend ZONE, bottom bar included — the list itself + // runs to the screen edge now. + bottom = legendClearance, + ), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(paneRows, key = { _, r -> r.id }) { index, row -> + val rowFocused = paneTab == tab && 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, + bandWidth = bandWidth, onClick = { // Same inertness as the pad path above — tapping a dimmed row focuses it // (so its detail explains itself) but never flips it. tabFocused = false if (focus != index) focus = index - else if (row.enabled) { adjustDir = 1; row.activate() } + else if (row.enabled) activate(row) }, ) } - } + } + } + + /** The section switcher with its directional content — shared by both orientations below. */ + val tabbedContent: @Composable (Modifier) -> Unit = { contentModifier -> + AnimatedContent( + targetState = tab, + modifier = contentModifier, + transitionSpec = { + if (!animated) { + fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith + fadeOut(tween(ConsoleMotion.REDUCED_MS)) + } else { + // DIRECTION-driven, with a real exit: the incoming section slides in from the + // side the press pointed at while the outgoing leaves the other way — paging + // along a strip. The previous cut slid a single list's contents 24 dp under an + // 85 % fade, which read as the same crossfade whichever shoulder was pressed. + val dir = tabDir + ( + slideInHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { it / 6 * dir } + + fadeIn(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) + ) togetherWith ( + slideOutHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { -it / 6 * dir } + + fadeOut(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) + ) + } + }, + label = "settingsTab", + ) { t -> + tabPane(t, Modifier.fillMaxHeight()) + } + } + + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + // The backdrop stays full-bleed — it is ambience. The CHROME (strip, rows' start + // edge) takes the safe area on the sides and top only: the LIST deliberately runs to + // the physical bottom of the screen, with the bottom inset folded into its + // contentPadding, so scrolled rows glide off the edge instead of being guillotined at + // an invisible inset line 24 px above it (third on-glass verdict). + GamepadFormBackground(Modifier.fillMaxSize()) + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding( + WindowInsets.systemBars.union(WindowInsets.displayCutout) + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + ), + ) { + // The strip is PINNED while the rows scroll under it: it is this screen's primary + // navigation, and a switcher you have to scroll back up to find isn't one. + Row( + Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ConsoleTabStrip( + titles = GpTab.entries.map { it.title }, + selected = GpTab.entries.indexOf(tab), + onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) }, + modifier = Modifier.weight(1f), + focused = tabFocused, + ) + // The base-layer marker, where a full "Default settings" heading used to eat a + // headline row on EVERY tab. The honesty it carried stays: this screen edits + // the defaults only — the console honours a host's profile but doesn't edit + // profiles (design §5.4) — and this quiet chip at the strip's end says so + // without a second heading repeating the tab pill's own word. + Text( + "Defaults", + style = MaterialTheme.typography.labelMedium, + color = ink.fg(0.45f), + maxLines = 1, + modifier = Modifier.padding(start = 10.dp, end = ConsoleEdgeInset), + ) + } + if (landscape) { + Row(Modifier.fillMaxSize()) { + tabbedContent(Modifier.weight(0.6f)) + // The focused row's description, in the width a wide phone wastes — beside + // the rows instead of floating over the list's tail (the portrait band). + // Presentation only: the row already merges this text into its own + // announcement, so the pane is hidden from a screen reader like the band. + val focusedRow = rows.getOrNull(focus) + AnimatedContent( + targetState = if (tabFocused) null else focusedRow, + transitionSpec = { + fadeIn(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) togetherWith + fadeOut(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) + }, + modifier = Modifier + .weight(0.4f) + .semantics { hideFromAccessibility() }, + label = "sideDetail", + ) { r -> + Column( + Modifier + .fillMaxHeight() + .padding(start = 6.dp, end = ConsoleEdgeInset, top = 22.dp), + ) { + if (r != null && r.detail.isNotBlank()) { + Text( + r.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = ink.fg(0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + r.detail, + style = MaterialTheme.typography.bodyMedium, + color = ink.fg(0.6f), + modifier = Modifier.padding(top = 6.dp), + ) + } + } + } + } + } else { + tabbedContent(Modifier.fillMaxSize()) + } } } - // 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 +553,49 @@ 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( - PadGlyph.hint('B', "Done", onClick = onBack), + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Portrait only: landscape's description lives in the side pane, where the wide + // aspect has room for it — a band AND a pane would say the same thing twice. + if (!landscape) { + 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, ) - focused != null && !focused.adjustable -> listOf( - PadGlyph.hint('A', "Pin to hosts") { focused.activate() }, + } + GamepadHintBar( + if (tabFocused) listOf( + GamepadHint('↔', PadGlyph.Arrow, "Section"), + PadGlyph.hint('A', "Open") { tabFocused = false }, 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), + ) + // What A does here follows the ROW: it opens the pin picker on a profile, + // the connected-controllers view on that one, the notices on the About row. + // It was the literal "Pin to hosts" while profiles were the only such rows. + focused != null && !focused.adjustable -> listOf( + PadGlyph.hint('A', focused.actionHint) { activate(focused) }, + 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)?.let { activate(it) } + }, + 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 +612,64 @@ 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, + /** The option drum's fixed stage — sized by the SCREEN (orientation decides how much a row can spare). */ + bandWidth: Dp, + 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 +680,127 @@ 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, ) + // ONE announcement per row, not five leaves read in layout order. Three things had + // to be gathered to make it true: + // * the VALUE of a toggle row existed nowhere in the tree — the switch replaces + // the value text (see below), so `row.value` ("On"/"Off") was drawn by nothing; + // * the DESCRIPTION lives in the floating band at the far bottom of the screen, + // which is the right place to LOOK and the wrong place to be read — so it is + // merged here, where the row it explains is; + // * `enabled` was a colour and nothing else. + // A row therefore announces "Refresh rate, 120 Hz, Frame rate the host renders and + // streams at" — which is what the screen already means, said once. + .semantics(mergeDescendants = true) { + role = if (row.toggled != null) Role.Switch else Role.Button + contentDescription = listOfNotNull( + row.label, + row.value.takeIf { it.isNotBlank() }, + row.detail.takeIf { it.isNotBlank() }, + ).joinToString(", ") + row.toggled?.let { + toggleableState = if (it) ToggleableState.On else ToggleableState.Off + } + if (!row.enabled) disabled() + } .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, + Icon( + Icons.Filled.ChevronLeft, + // Decoration: it says "this value steps", which the row's Switch/Button + // role already says. Left in the tree it is read out on every focused row. + contentDescription = null, + tint = ink.fg, + modifier = Modifier + .size(18.dp) + .semantics { hideFromAccessibility() } + .graphicsLayer { alpha = chevronAlpha } + .offset { IntOffset(minOf(chevronKick.value, 0f).dp.roundToPx(), 0) }, + ) + if (row.options != null && row.selectedIndex in row.options.indices) { + // The drum — see ConsoleOptionBand. Its width is FIXED by the row, so a + // step can never reflow the chevrons, and the tabular-figures concern + // dissolves with it: nothing about the row's layout depends on the label. + ConsoleOptionBand( + options = row.options, + selection = row.selectedIndex, + focused = focused, + width = bandWidth, ) + } else { + // The flat rows (profile pin counts, the empty-catalog placeholder) keep + // the quiet slip: the changed string slides in following the motion. + 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, + style = MaterialTheme.typography.bodyMedium, + color = valueColor, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - Text(" ›", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + Icon( + Icons.Filled.ChevronRight, + contentDescription = null, + tint = ink.fg, + modifier = Modifier + .size(18.dp) + .semantics { hideFromAccessibility() } + .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), - ) - } } } } @@ -450,12 +808,17 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick /** Build the console settings rows from the current [Settings], writing through [update]. * [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this * phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see - * `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */ + * `codecOptionsFor`). [appVersion] is the installed version the About row states, and + * [openControllers] / [openLicenses] are the two rows that navigate rather than set anything. + * Every row declares its [GpTab]; the screen shows one tab at a time. */ internal fun buildSettingsRows( s: Settings, hasBodyVibrator: Boolean, hasGyroscope: Boolean, av1Capable: Boolean, + appVersion: String = "", + openControllers: () -> Unit = {}, + openLicenses: () -> Unit = {}, update: (Settings) -> Unit, ): List { fun choice( @@ -480,6 +843,8 @@ internal fun buildSettingsRows( val i = if (idx < 0) 0 else (idx + 1) % options.size options.getOrNull(i)?.let { write(it.first) } }, + options = options.map { it.second }, + selectedIndex = idx, ) } fun toggle( @@ -633,6 +998,28 @@ internal fun buildSettingsRows( "triggers, lightbar and gyro.", s.dsCapture, enabled = s.gamepadForwarding, ) { update(s.copy(dsCapture = it)) }, + // The diagnostics view — same screen the touch settings reach, same words for it. It was + // reachable from touch ONLY, which on a TV box means not at all: there is no touch interface + // to fall back to there, and "my controller does nothing" is the support case it answers. + // + // Deliberately NOT gated on the master forwarding switch its neighbours all follow: this is + // the row you reach for when forwarding looks broken, and a diagnostic that dims itself when + // the thing it diagnoses is off is worse than none. + // + // No value: this row navigates, it doesn't hold a setting (a count read here would be a + // snapshot, and a stale "none detected" is worse than no number at all — the screen it opens + // watches hot-plug live). + GpRow( + id = "controllers", + tab = GpTab.CONTROLLER, + header = "Diagnostics", + label = "Connected controllers", + value = "", + detail = "What the app detects, with a live input test.", + adjust = { false }, + activate = openControllers, + adjustable = false, + ), // The palette leads Interface: it is the one row whose effect you can see while you step // it (the backdrop behind this very list recolours), so it wants to be the first thing @@ -647,7 +1034,7 @@ internal fun buildSettingsRows( choice( "hud", GpTab.INTERFACE, null, "Statistics overlay", "How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " + - "A 3-finger tap cycles the tiers live.", + "Select + X on a pad, or a 3-finger tap, cycles the tiers live.", STATS_VERBOSITY_OPTIONS, s.statsVerbosity, ) { update(s.copy(statsVerbosity = it)) }, toggle( @@ -680,6 +1067,22 @@ internal fun buildSettingsRows( } else { null }, + ) + listOf( + // About closes the Interface section, the way the touch settings' last category does. The + // notices are a licence obligation and were reachable from touch only — on a TV box that is + // nowhere. The version rides in the VALUE slot rather than as a second, inert row: it is the + // identity half of an About page, and the screen this opens states it again at the top. + GpRow( + id = "licenses", + tab = GpTab.INTERFACE, + header = "About", + label = "Open-source licenses", + value = appVersion, + detail = "Third-party notices and credits.", + adjust = { false }, + activate = openLicenses, + adjustable = false, + ), ) } @@ -694,7 +1097,7 @@ internal fun buildSettingsRows( * Controller-optimized UI toggle a few rows up, which swaps the standard interface in * (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists). */ -private fun buildProfileRows( +internal fun buildProfileRows( profiles: List, savedHosts: List, tv: Boolean, @@ -741,6 +1144,7 @@ private fun buildProfileRows( adjust = { false }, activate = { openPinPicker(p) }, adjustable = false, + actionHint = "Pin to hosts", ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HomeTiles.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HomeTiles.kt new file mode 100644 index 00000000..89aacf53 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HomeTiles.kt @@ -0,0 +1,100 @@ +package io.unom.punktfunk + +import io.unom.punktfunk.kit.discovery.DiscoveredHost +import io.unom.punktfunk.kit.security.KnownHost + +/** + * The console home's tiles, in carousel order: every saved host with its pinned host+profile cards + * immediately behind it, then the hosts seen on the network but not yet saved, then Add Host. + * + * Pure, and deliberately not a composable. The half of this that can be WRONG is the ordering and + * what a tile claims — a pin drifting away from the host it belongs to, a discovered host offered a + * second time next to the saved record it already is, a chip naming a profile the press won't + * actually use. None of that needs a display to be checked, and `HomeTilesTest` checks it without + * one; the console home itself needs the live JNI core to compose at all. + * + * [isOnline] and [pinsFor] arrive as lambdas rather than as the discovery lists and the profile + * store behind them: "online" means advertising on mDNS OR answering a QUIC probe (the routed + * Tailscale/VPN case), which is a rule belonging to the screen that does the probing, not to a list + * builder. + */ +internal fun buildHomeTiles( + savedHosts: List, + /** The live catalog — resolves each host's binding into the name and colour its chip wears. */ + profiles: List, + pinsFor: (KnownHost) -> List, + /** Already de-duped against [savedHosts] by the caller: a saved host is not also "discovered". */ + discoveredUnsaved: List, + isOnline: (KnownHost) -> Boolean, + /** + * Dial a saved host. The second argument is `connect`'s one-off profile reference: null on a + * host's own tile (follow whatever the host is bound to), the pinned profile's id on a pin tile. + */ + onConnect: (KnownHost, String?) -> Unit, + onConnectDiscovered: (DiscoveredHost) -> Unit, + onAddHost: () -> Unit, +): List = buildList { + savedHosts.forEach { kh -> + val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } } + add( + HomeTile( + id = "saved-${kh.id}", + title = kh.name, + subtitle = "${kh.address}:${kh.port}", + filled = true, + online = isOnline(kh), + paired = kh.paired, + knownHost = kh, + // The binding is what a press will actually do, so the tile says so — the console + // can't edit profiles, but it must never lie about which one it uses. It rides in + // the card's own chip now rather than as a "· Name" tail on the address, which is + // where it read as an afterthought. + profileName = bound?.name, + profileAccent = accentColor(bound?.accent), + activate = { onConnect(kh, null) }, + ), + ) + // Pinned host+profile combinations, right after their host: one focus-and-press each, + // which is the affordance a controller surface does well (menus are not). + pinsFor(kh).forEach { p -> + add( + HomeTile( + id = "pin-${kh.id}-${p.id}", + title = kh.name, + // The address, like every other card — the PROFILE is what makes this card + // different, and it now says so in the chip instead of standing in for the + // subtitle, which left a pin card unable to say where it pointed. + subtitle = "${kh.address}:${kh.port}", + filled = true, + online = isOnline(kh), + paired = kh.paired, + knownHost = kh, + pinnedProfileId = p.id, + profileName = p.name, + profileAccent = accentColor(p.accent), + activate = { onConnect(kh, p.id) }, + ), + ) + } + } + discoveredUnsaved.forEach { dh -> + add( + HomeTile( + id = "disc-${dh.host}:${dh.port}", + title = dh.name, + subtitle = "${dh.host}:${dh.port}", + online = true, + activate = { onConnectDiscovered(dh) }, + ), + ) + } + add( + HomeTile( + id = "add", + title = "Add Host", + subtitle = "Register a host by address", + isAdd = true, + activate = onAddHost, + ), + ) +} 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 c34d17c0..4e2cfb80 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.Icon import androidx.compose.material3.MaterialTheme @@ -43,6 +41,11 @@ import androidx.compose.ui.layout.ContentScale import android.content.res.Configuration import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.zIndex import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource @@ -129,7 +132,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) { @@ -173,7 +176,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( @@ -189,7 +192,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( @@ -264,10 +267,18 @@ 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), + modifier = Modifier + .fillMaxWidth() + // A live region: this heading is the ONLY signal that the cursor has + // crossed from the launchers into the games, and a coverflow gives a + // reader no other way to notice — it is one strip, not two lists. + .semantics { liveRegion = LiveRegionMode.Polite } + .padding(bottom = 8.dp), ) } HorizontalPager( @@ -289,10 +300,22 @@ private fun Coverflow( .width(coverWidth) .height(coverHeight) // Touch: tap the centred cover to launch it; tap a neighbour to bring it centre. - .clickable { + // The label says which of the two a press does, because from the poster + // alone they are indistinguishable — and the CENTRED one is the only one A + // acts on, which nothing else in the tree says. + .clickable( + onClickLabel = if (page == pagerState.currentPage) { + "Launch ${games[page].title}" + } else { + "Bring ${games[page].title} to the centre" + }, + ) { if (page == pagerState.currentPage) onLaunch(games[page]) else scope.launch { pagerState.animateScrollToPage(page) } } + .semantics { + if (page == pagerState.currentPage) selected = true + } .graphicsLayer { // Centre at full size; EVERY neighbour settles to one size, so an even pitch // yields even VISUAL gaps. (A progressive shrink made the outer gaps grow — @@ -351,11 +374,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, ) { @@ -397,11 +423,25 @@ 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)) + // A bare store name read out after the title says nothing about WHY it is + // there; the poster's own description already carries the title. + .semantics { + contentDescription = if (game.isLauncher) { + "Opens ${game.storeLabel}" + } else { + "From ${game.storeLabel}" + } + } + .clip(ConsoleShape.Pill) .background( - 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/LicensesScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LicensesScreen.kt index e6f3ee33..29e5b90f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LicensesScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LicensesScreen.kt @@ -1,8 +1,12 @@ package io.unom.punktfunk +import android.content.res.Configuration import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -19,20 +23,130 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource /** * Open-source licenses: punktfunk's own license (MIT OR Apache-2.0) plus the third-party software * notices, read from the bundled `THIRD-PARTY-NOTICES.txt` asset (generated by * scripts/gen-third-party-notices.sh). Reached from [SettingsScreen]; Back returns there. + * + * This is the TOUCH entry point; [ConsoleLicensesScreen] shows the same notices on the console's + * field, where they need a scroll route a controller can actually drive. */ @Composable fun LicensesScreen(onBack: () -> Unit) { - val context = LocalContext.current BackHandler(onBack = onBack) + Column(Modifier.fillMaxSize()) { + // Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler). + Row( + modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall) + } + LicensesBody( + scroll = rememberScrollState(), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 24.dp), + ) + } +} + +/** + * The notices on the console's field. The reason this exists as its own screen rather than the touch + * one dropped into the shell is the SCROLL: the body is a wall of text with exactly one focusable + * node (the touch screen's back arrow), and Compose scrolls a container only to keep a FOCUSED child + * visible — so a controller could reach the first screenful of `THIRD-PARTY-NOTICES.txt` and not one + * line further. Here up/down steps and the shoulders page, driving the scroll state directly. + * + * B closes, as everywhere else; there is nothing on this screen to confirm, so A is not advertised. + */ +@Composable +fun ConsoleLicensesScreen(onBack: () -> Unit, navActive: Boolean = true) { + BackHandler(onBack = onBack) + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val hazeState = remember { HazeState() } + val scroll = rememberScrollState() + val scrollBy = rememberConsoleScroller(scroll) + val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true + + GamepadNavEffect2D( + active = navActive, + onDirection = { dir -> + when (dir) { + NavDir.UP -> scrollBy(-1, false) + NavDir.DOWN -> scrollBy(1, false) + // Left/right are deliberately inert: there is nothing beside this text, and paging + // sideways off a D-pad would be a second, undocumented way to do the shoulders' job. + NavDir.LEFT, NavDir.RIGHT -> {} + } + }, + onActivate = {}, + onShoulder = { delta -> scrollBy(delta, true) }, + ) + + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + // Calm: this is a screen to read, and a drifting field behind small monospace text is + // the one place the aurora would be actively unhelpful. Full-bleed under the cutout — + // only the content takes the safe area. + GamepadFormBackground(Modifier.fillMaxSize()) + // Inked from the palette: the notices carry no colour of their own, so outside a Surface + // they would render in Material's default BLACK content colour over the aurora. + ConsoleInkedTheme { + Column(Modifier.fillMaxSize().consoleSafeArea()) { + LicensesBody( + scroll = scroll, + contentPadding = PaddingValues( + start = ConsoleEdgeInset, + end = ConsoleEdgeInset, + bottom = ConsoleLegendClearance, + ), + ) { + ConsoleHeader("Open-source licenses", horizontalInset = false) + } + } + } + } + Box( + Modifier + .align(Alignment.BottomStart) + .consoleLegendInsets(landscape) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar( + listOfNotNull( + GamepadHint('↕', PadGlyph.Arrow, "Scroll"), + // A TV remote has no shoulders — its route is the D-pad, one step at a time. + GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad }, + PadGlyph.hint('B', "Close", onClick = onBack), + ), + hazeState = hazeState, + ) + } + } +} + +/** + * The notices themselves, shared by both interfaces — the licenses are a legal obligation, so the + * two routes must show the same text rather than two copies that can drift. [heading] is empty for + * the touch screen, which pins its own title row above the scroll. + */ +@Composable +private fun LicensesBody( + scroll: ScrollState, + contentPadding: PaddingValues, + heading: @Composable () -> Unit = {}, +) { + val context = LocalContext.current val notices = remember { runCatching { context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() } @@ -52,52 +166,40 @@ fun LicensesScreen(onBack: () -> Unit) { }.getOrNull() } - Column(Modifier.fillMaxSize()) { - // Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler). - Row( - modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall) - } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (version != null) { - Text( - "Punktfunk $version", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + heading() + if (version != null) { Text( - "Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " + - "components below, each under its own license.", + "Punktfunk $version", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + "Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " + + "components below, each under its own license.", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + notices, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + if (fontLicense != null) { + Text("Bundled font", style = MaterialTheme.typography.titleMedium) + Text( + "The Geist typeface is licensed under the SIL Open Font License 1.1.", style = MaterialTheme.typography.bodyMedium, ) Text( - notices, + fontLicense, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), ) - if (fontLicense != null) { - Text("Bundled font", style = MaterialTheme.typography.titleMedium) - Text( - "The Geist typeface is licensed under the SIL Open Font License 1.1.", - style = MaterialTheme.typography.bodyMedium, - ) - Text( - fontLicense, - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - ) - } } } } 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/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 3e49b414..572b28f2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -577,7 +577,7 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) { selected = s.statsVerbosity, field = "stats_verbosity", caption = "Compact is one line; Detailed adds the decoder and latency breakdown. " + - "A 3-finger tap cycles the tiers in-stream.", + "A 3-finger tap, or Select + X on a pad, cycles the tiers in-stream.", ) { v -> update(s.copy(statsVerbosity = v)) } } DeviceScopeOnly { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 31e52f6b..54f91936 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -28,6 +28,9 @@ import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -53,6 +56,7 @@ 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.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -152,6 +156,35 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U motionHint = false } } + // Whether this session has a controller — the start banner names pad chords only when there is + // a pad to press them on. Seeded from the router the moment it is built (it opens a slot for + // every already-connected controller) and latched true by a pad that arrives later; it never + // goes back to false. A pad LEAVING inside the banner's six seconds is not worth the write: + // teardown closes every slot, and poking Compose state from there is exactly what the nulled + // callbacks in onDispose avoid. The latch is also what carries a pad through a USB capture + // claiming it — its InputDevice slot closes and reopens as a capture-link one. + var padPresent by remember(handle) { mutableStateOf(false) } + // The start-of-stream banner: what this session's shortcuts ARE, said once. A stream takes the + // whole screen and answers to none of the device's usual gestures, so it has to say how to get + // back out — the desktop console draws the same pill for the same reason + // (`pf-console-ui/src/skia_overlay.rs`, BANNER_S = 6 s with a BANNER_FADE_S = 0.6 s tail). + // Two states because the fade and the removal are different moments: `bannerUp` composes the + // pill at all, `bannerFading` runs its alpha down over the last 600 ms. + var bannerUp by remember(handle) { mutableStateOf(true) } + var bannerFading by remember(handle) { mutableStateOf(false) } + val bannerAlpha by animateFloatAsState( + targetValue = if (bannerFading) 0f else 1f, + // Linear, like the desktop's (BANNER_S - age) / BANNER_FADE_S ramp — Compose's default + // easing would hold near-opaque and then drop, which reads as a glitch rather than a fade. + animationSpec = tween(600, easing = LinearEasing), + label = "streamStartBanner", + ) + LaunchedEffect(handle) { + delay(5400) // 6 s − the 0.6 s tail: fully opaque until here, exactly as on the desktop + bannerFading = true + delay(600) + bannerUp = false // stop composing it once it is invisible + } // The one place mute is toggled — Compose state + the native flag, always together. val setMicMuted = { muted: Boolean -> micMuted = muted @@ -161,7 +194,8 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U // Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline: // the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per // frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets - // the native window, so re-showing never renders stale data. A 3-finger tap cycles the + // the native window, so re-showing never renders stale data. A 3-finger tap — or the Select + X + // pad chord, which is the only route a TV or a passthrough-touch session has — cycles the // verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from // Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the // visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never @@ -184,6 +218,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U // TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream // refresh; a phone/tablet gets the softer seamless frame-rate hint instead. val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) } + // A screen with fingers on it — the start banner may only name the three-finger stats tap on a + // device that can perform it. A TV box has no touchscreen at all, and its remote is not one. + val hasTouch = remember { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN) + } LaunchedEffect(handle, statsOn) { NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn) if (statsOn) { @@ -362,6 +401,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(), ) activity?.gamepadRouter = router + // Every controller that was already connected got a slot in the router's constructor, so + // this is the session's pad answer at t=0 — what the start banner's words are chosen from. + padPresent = router.forwardedDevices().isNotEmpty() // Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips // the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it // (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream @@ -384,6 +426,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U micHint = if (next) "Microphone muted" else "Microphone live" } } + // Select + X steps the stats overlay one tier — the same live cycle the three-finger tap + // performs, and the ONLY route to it on a TV or in a passthrough-touch session. Session- + // local on purpose: this mirrors the tap exactly (`onCycleStats` below), and the settings + // row calls it a live cycle — the stored default is what the next stream starts from. + router.onStatsChord = { statsVerbosity = statsVerbosity.next() } // Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured // (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look. // The local cursor is hidden over the stream — the host's own cursor, composited into @@ -505,7 +552,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U // The other edge: a controller that arrives (or first speaks) mid-session gets its sensors // read too. The pads already connected were swept by PadSensors.start() above — both run // on the main thread with nothing between them, so no controller falls through the gap. - router.onSlotOpened = { deviceId -> padSensors?.onSlotOpened(deviceId) } + router.onSlotOpened = { deviceId -> + padSensors?.onSlotOpened(deviceId) + // A pad that wakes up a second into the stream still deserves the chord banner — the + // desktop rebuilds its banner text every frame for exactly this case. + padPresent = true + } // Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an // already-paired BLE one — and forward its raw reports; the host mirrors a real // 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back @@ -645,6 +697,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down router.onMicChord = null // same: no mute toggle on buttons released during teardown + router.onStatsChord = null // same: no tier cycle on buttons released during teardown router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener activity?.gamepadRouter = null @@ -847,6 +900,42 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U if (remotePointerOn) { RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) } + // The start banner (desktop parity), naming ONLY the shortcuts this session actually has: + // pad chords when a controller is here, the Back gesture and the three-finger tap when it + // is not. Recomputed rather than captured, because both inputs change under it — a pad can + // wake mid-banner, and `micRunning` only settles once the capture has actually opened. + // Above the video and below the gesture layer: it teaches touches, it must never eat one. + // + // Bottom-centre is the desktop's placement and the only edge left — TopStart is the HUD, + // TopEnd the mic badge, TopCentre the three transient cues — but MotionUnreachableHint + // already owns it, and both of these can be up at t≈0. The banner YIELDS rather than + // stacking or sliding off-centre: the notice reports something broken about THIS session + // and names the setting that fixes it, while the banner repeats shortcuts that will be + // there next stream too. Two pills sharing an edge for six seconds would cost the reader + // both. + if (bannerUp && !motionHint) { + StreamStartBanner( + text = buildList { + if (padPresent) { + add("Hold Select + Start + L1 + R1 to leave") + // Only while a capture is actually running: the chord itself no-ops + // without one, and offering a mute for a mic nobody has is the lie the + // whole control exists to avoid. + if (micRunning) add("Select + Y mic") + add("Select + X stats") + } else { + // No pad: Back is the deliberate exit (gesture, key, or a TV remote's + // button — all land on the same BackHandler). + add("Back leaves the stream") + // The tap lives in the pointer touch models only — passthrough gives every + // finger to the host verbatim — and needs a screen to put three fingers on. + if (hasTouch && touchMode != TouchMode.TOUCH) add("three-finger tap for stats") + } + }.joinToString(" · "), + alpha = bannerAlpha, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp), + ) + } // Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe up // in the mouse modes) AND the pointer-capture grab target — it never draws or takes // touches, it just owns IME focus and receives captured-pointer events. @@ -1053,6 +1142,33 @@ private fun RemotePointerHint(modifier: Modifier = Modifier) { ) } +/** + * The start-of-stream banner: the shortcuts this session actually has, in the same pill as every + * other in-stream cue, shown once and then gone. The desktop console draws the identical thing + * bottom-centre (`pf-console-ui/src/skia_overlay.rs` — six seconds with a 0.6 s fade), because a + * stream owns the whole screen and answers to none of the device's usual gestures: without a line + * saying how to get back out, the only discoverable exit is force-quitting the app. + * + * [text] and [alpha] are the caller's. Only it knows what this session HAS — a pad, a mic, a + * touchscreen — and only it owns the timer, which is precisely what a screenshot wants to skip. + * Purely visual: it sits below the gesture layer, takes no touches and is never clickable. Internal + * so the screenshot scene can shoot the real pill instead of a copy of it that drifts. + */ +@Composable +internal fun StreamStartBanner(text: String, alpha: Float, modifier: Modifier = Modifier) { + Text( + text, + // Alpha FIRST: the fade has to take the pill's backdrop with it, and everything after this + // in the chain draws inside the layer it opens. + modifier = modifier + .alpha(alpha) + .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + color = Color.White, + fontSize = 15.sp, + ) +} + /** * Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME * onto this view. Two IME models, picked by the host's capabilities: diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRoutesTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRoutesTest.kt new file mode 100644 index 00000000..b35fe887 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRoutesTest.kt @@ -0,0 +1,107 @@ +package io.unom.punktfunk + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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 route to the two sub-screens, driven through the REAL settings screen — the rows + * themselves are pinned by `ConsoleSubScreenRowsTest`; what needs the Compose runtime is the trip: + * that a press on the row reaches the shell, and that coming back lands where you left rather than + * at the top of the first section (the shell's `AnimatedContent` discards a screen's state the + * moment it stops being the target, so the place has to travel out and back). + * + * Rows are activated by TAP for the same reason `GamepadSettingsLayoutTest` does it: the pad path + * needs a `MainActivity` for its probes, and both routes end in the same `activate`. + * + * `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 ConsoleSubScreenRoutesTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun openingTheControllersRowNavigatesAndReportsWhereItWas() { + var opened = 0 + var place: GpSettingsPlace? = null + compose.setContent { + GamepadSettingsScreen( + initial = Settings(), + onChange = {}, + onBack = {}, + onOpenControllers = { opened++ }, + // Entering as if we had just come back from it, which is also what puts the cursor + // on the row — so a single tap ACTIVATES rather than merely focusing. + resume = GpSettingsPlace(GpTab.CONTROLLER, "controllers"), + onPlace = { place = it }, + ) + } + compose.waitForIdle() + + compose.onNodeWithText("Connected controllers").performClick() + compose.waitForIdle() + + assertEquals("the console never reached the diagnostics screen", 1, opened) + assertEquals( + "the place has to leave before the row does — this screen is gone the next frame", + GpSettingsPlace(GpTab.CONTROLLER, "controllers"), + place, + ) + } + + /** + * Back from a sub-screen lands on the section it was opened from, with the row on screen. The + * cursor is restored by row ID rather than index, so it survives a section whose length follows + * the hardware. + */ + @Test + fun comingBackFromTheNoticesLandsOnTheRowThatOpenedThem() { + compose.setContent { + GamepadSettingsScreen( + initial = Settings(), + onChange = {}, + onBack = {}, + resume = GpSettingsPlace(GpTab.INTERFACE, "licenses"), + ) + } + compose.waitForIdle() + + compose.onNodeWithText("Open-source licenses").assertIsDisplayed() + // Not back at the top of the first section — "Resolution" leads the Stream tab, which is + // where a screen that forgot its place would be. + compose.onNodeWithText("Resolution").assertDoesNotExist() + // And the legend describes THIS row's A. It said the literal "Pin to hosts" on every + // non-adjustable row back when profiles were the only ones. + compose.onNodeWithText("Open").assertIsDisplayed() + compose.onNodeWithText("Pin to hosts").assertDoesNotExist() + } + + /** + * The notices screen stands on its own on the console's field: no Scaffold or Surface above it + * (the shell has neither), its own backdrop, and a legend that says how to leave. Composing it + * is most of the assertion — a screen that only ever ran inside the touch Scaffold takes its + * content colour from one. + */ + @Test + fun theConsoleNoticesScreenStandsOnItsOwn() { + compose.setContent { ConsoleLicensesScreen(onBack = {}) } + compose.waitForIdle() + + compose.onNodeWithText("Open-source licenses").assertIsDisplayed() + compose.onNodeWithText("Scroll").assertIsDisplayed() + compose.onNodeWithText("Close").assertIsDisplayed() + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRowsTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRowsTest.kt new file mode 100644 index 00000000..9744e5ea --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRowsTest.kt @@ -0,0 +1,117 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The console's route to the two screens that were reachable from touch only — the + * connected-controllers diagnostics and the open-source notices. + * + * "Touch only" reads as a minor gap on a phone and is a dead end on a TV box, where the console IS + * the interface: there is no touch UI to fall back to, so a screen with no console row could not be + * opened at all. These pin the rows themselves; `ConsoleSubScreenRoutesTest` drives the real screen. + */ +class ConsoleSubScreenRowsTest { + + private fun rows( + forwarding: Boolean = true, + version: String = "1.2.3", + controllers: () -> Unit = {}, + licenses: () -> Unit = {}, + ): List = buildSettingsRows( + Settings(gamepadForwarding = forwarding), + hasBodyVibrator = true, + hasGyroscope = true, + av1Capable = true, + appVersion = version, + openControllers = controllers, + openLicenses = licenses, + ) {} + + private fun row(rows: List, id: String): GpRow = rows.first { it.id == id } + + @Test + fun `the controllers row opens the diagnostics view from the controller section`() { + var opened = 0 + val r = row(rows(controllers = { opened++ }), "controllers") + assertEquals(GpTab.CONTROLLER, r.tab) + assertEquals("Connected controllers", r.label) + r.activate() + assertEquals(1, opened) + } + + /** + * It must NOT follow the master forwarding switch, unlike every other row in its section: the + * screen it opens is what you reach for precisely when forwarding looks broken, and a diagnostic + * that dims itself when the thing it diagnoses is off is worse than no diagnostic. + */ + @Test + fun `the controllers row stays live with forwarding off`() { + val off = rows(forwarding = false) + assertTrue(row(off, "controllers").enabled) + assertNotNull(liveRow(off, off.indexOfFirst { it.id == "controllers" })) + // Its neighbours in the section still dim, so this is a deliberate exemption and not a + // forgotten `enabled =`. + assertFalse(row(off, "sc2").enabled) + } + + @Test + fun `the about row opens the notices and states the installed version`() { + var opened = 0 + val r = row(rows(version = "0.27.0", licenses = { opened++ }), "licenses") + assertEquals(GpTab.INTERFACE, r.tab) + assertEquals("About", r.header) + // The version rides in the value slot — on a TV this row is the whole About page. + assertEquals("0.27.0", r.value) + r.activate() + assertEquals(1, opened) + } + + /** Both navigate; neither holds a value, so left/right must be refused rather than silently eaten. */ + @Test + fun `neither row steps a value`() { + val all = rows() + for (id in listOf("controllers", "licenses")) { + val r = row(all, id) + assertFalse("$id should draw no chevrons", r.adjustable) + assertFalse("$id must refuse a step", r.adjust(1)) + assertFalse("$id must refuse a step", r.adjust(-1)) + } + } + + /** + * The legend follows the ROW. It used to say the literal "Pin to hosts" on every non-adjustable + * row, because a profile row was the only kind there was — so the moment another one existed, + * A on it was advertised as pinning something. + */ + @Test + fun `an action row advertises what A actually does`() { + val all = rows() + assertEquals("Open", row(all, "controllers").actionHint) + assertEquals("Open", row(all, "licenses").actionHint) + val profiles = buildProfileRows(listOf(newProfile("Work")), emptyList(), tv = false) {} + assertEquals("Pin to hosts", profiles.first().actionHint) + } + + /** + * The scroll geometry both console sub-screens share. A wall of text has no focusable rows for + * Compose to keep visible, so these screens move the scroll state themselves — and how far one + * press travels is the whole of their feel. + */ + @Test + fun `a page overlaps what you were reading and a step is shorter still`() { + val viewport = 1000f + val page = consoleScrollDelta(viewport, page = true, dir = 1) + val step = consoleScrollDelta(viewport, page = false, dir = 1) + assertTrue("a page that skips a whole screenful loses your place", page < viewport) + assertTrue("a page has to be worth pressing", page > viewport / 2f) + assertTrue("a D-pad step must be shorter than a shoulder page", step > 0f && step < page) + assertEquals("the other direction is the other way", -page, consoleScrollDelta(viewport, true, -1), 0.001f) + // Before the first layout there is no viewport: a press then moves nothing, rather than + // scrolling by a fraction of zero and reading as a dead button on the way in. + assertEquals(0f, consoleScrollDelta(0f, page = true, dir = 1), 0f) + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleVectorsTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleVectorsTest.kt new file mode 100644 index 00000000..f4cc3946 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleVectorsTest.kt @@ -0,0 +1,168 @@ +package io.unom.punktfunk + +import java.io.File +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The console UI's cross-client contract, against `clients/shared/console-vectors.json`. + * + * The background palettes, the settings section names and the screen-transition motion each exist + * in three hand-written copies — this client, `pf-console-ui` (Rust) and the Apple client — and + * until this file they were held together by nothing but a comment asking the next person to keep + * them in step. Two of the three had already drifted. + * + * Read straight off disk with a relative path rather than copied into test resources, for the + * reason the deeplink vectors state: a copy would be a fourth contract, free to go stale. Gradle + * runs a unit test with the MODULE directory as its working directory, so `../../shared/` from + * `clients/android/app` lands on `clients/shared`. + * + * What this pins that the older [GamepadPaletteTest] could not: the DERIVED 16-cell mesh and the + * 4 blob colours per palette. Those are what actually reach the screen — the mesh through the AGSL + * shader on API 33+, the blobs through the fallback field below it — and the existing tests only + * ever measured the `stops` they are computed from. + */ +class ConsoleVectorsTest { + private companion object { + /** One step of Compose's 8-bit-per-component sRGB packing — see the blob comparison. */ + const val EIGHT_BIT_STEP = 1.0 / 255.0 + } + + private val vectors: JSONObject by lazy { + val file = File("../../shared/console-vectors.json") + assertTrue( + "the shared vector file must be reachable at ${file.absolutePath}", + file.isFile, + ) + JSONObject(file.readText()) + } + + private fun JSONObject.doubles(key: String): List = + getJSONArray(key).let { a -> (0 until a.length()).map { a.getDouble(it) } } + + private fun close(what: String, got: Double, want: Double, tol: Double = 1e-6) { + assertTrue( + "$what: vectors say $want, this client computes $got", + kotlin.math.abs(got - want) <= tol, + ) + } + + @Test + fun cellRampAndMeshInteriorMatch() { + assertEquals("CELL_RAMP", vectors.doubles("cell_ramp"), GamepadPalette.CELL_RAMP) + + val interior = vectors.getJSONArray("mesh_interior") + assertEquals("mesh interior count", GamepadPalette.MESH_INTERIOR.size, interior.length()) + GamepadPalette.MESH_INTERIOR.forEachIndexed { i, p -> + val w = interior.getJSONArray(i) + val got = listOf(p.x, p.y, p.amp, p.sx, p.sy, p.phase) + got.forEachIndexed { k, v -> close("mesh_interior[$i][$k]", v, w.getDouble(k)) } + } + } + + /** Every palette, field by field — and then the two tables derived from it. */ + @Test + fun everyPaletteMatchesTheSharedVectors() { + val want = vectors.getJSONArray("palettes") + assertEquals("palette count", want.length(), GamepadPalette.ALL.size) + GamepadPalette.ALL.forEachIndexed { i, p -> + val w = want.getJSONObject(i) + val id = w.getString("id") + assertEquals("palette order", id, p.id) + assertEquals("$id name", w.getString("name"), p.name) + assertEquals("$id light", w.getBoolean("light"), p.light) + + val stops = w.getJSONArray("stops") + assertEquals("$id stop count", stops.length(), p.stops.size) + p.stops.forEachIndexed { s, t -> + val ws = stops.getJSONArray(s) + close("$id stops[$s].r", t.first, ws.getDouble(0)) + close("$id stops[$s].g", t.second, ws.getDouble(1)) + close("$id stops[$s].b", t.third, ws.getDouble(2)) + } + + val ground = w.doubles("ground") + close("$id ground.r", p.ground.first, ground[0]) + close("$id ground.g", p.ground.second, ground[1]) + close("$id ground.b", p.ground.third, ground[2]) + val accent = w.doubles("accent") + close("$id accent.r", p.accent.first, accent[0]) + close("$id accent.g", p.accent.second, accent[1]) + close("$id accent.b", p.accent.third, accent[2]) + + // The mesh the shader is built from — 16 cells, sampled off the ramp per CELL_RAMP. + val mesh = w.getJSONArray("mesh") + assertEquals("$id mesh cells", mesh.length(), p.meshColors.size) + p.meshColors.forEachIndexed { c, t -> + val wc = mesh.getJSONArray(c) + close("$id mesh[$c].r", t.first, wc.getDouble(0)) + close("$id mesh[$c].g", t.second, wc.getDouble(1)) + close("$id mesh[$c].b", t.third, wc.getDouble(2)) + } + + // The four blobs the API 28–32 fallback field drifts. These come back as Compose + // `Color`s, which pack an sRGB colour at 8 bits per component — so the table + // round-trips through 1/255 quantisation and the tolerance below IS that quantisation, + // not slack. Anything the contract actually cares about (a mistyped stop, a shifted + // sample point) moves these by far more than one 8-bit step. + val blobs = w.getJSONArray("blobs") + assertEquals("$id blob count", blobs.length(), p.blobColors.size) + p.blobColors.forEachIndexed { b, colour -> + val wb = blobs.getJSONArray(b) + close("$id blob[$b].r", colour.red.toDouble(), wb.getDouble(0), EIGHT_BIT_STEP) + close("$id blob[$b].g", colour.green.toDouble(), wb.getDouble(1), EIGHT_BIT_STEP) + close("$id blob[$b].b", colour.blue.toDouble(), wb.getDouble(2), EIGHT_BIT_STEP) + } + } + } + + /** + * The section names, in order. The desktop console carries one tab this client does not — + * Input, which holds touch mode, mouse, invert-scroll and shortcuts: desktop-host settings + * with nothing to set on a phone or a TV. The vectors flag it `desktop_only` rather than + * leaving it out, so neither side has to red the other to be right. + */ + @Test + fun tabNamesMatchTheSharedVectors() { + val tabs = vectors.getJSONArray("tabs") + val want = (0 until tabs.length()) + .map { tabs.getJSONObject(it) } + .filterNot { it.optBoolean("desktop_only", false) } + .map { it.getString("name") } + assertEquals("console settings tabs", want, GpTab.entries.map { it.title }) + } + + /** + * The screen-transition contract. The easing is sampled rather than compared as Bézier + * control points: this client evaluates the desktop's analytic `1 − (1−t)³` directly, while + * SwiftUI can only approximate it — samples with a tolerance are the one form all three can + * meet. It is also the assertion that would have caught the curve this client shipped with + * first, a "cubic-bezier(0.215, 0.61, 0.355, 1)" that is a full 0.08 slack at the midpoint. + */ + @Test + fun motionMatchesTheSharedVectors() { + val motion = vectors.getJSONObject("motion") + close("transition", ConsoleMotion.TRANSITION_MS / 1000.0, motion.getDouble("transition_s")) + close("push slide", ConsoleMotion.PUSH_SLIDE.value.toDouble(), motion.getDouble("push_slide_dp")) + close("enter scale", ConsoleMotion.ENTER_SCALE.toDouble(), motion.getDouble("enter_scale"), 1e-5) + close("exit scale", ConsoleMotion.EXIT_SCALE.toDouble(), motion.getDouble("exit_scale"), 1e-5) + close("reveal alpha", ConsoleMotion.REVEAL_ALPHA.toDouble(), motion.getDouble("reveal_alpha"), 1e-5) + + val curve = motion.getJSONObject("ease_out_cubic") + val tol = curve.getDouble("tolerance") + val samples = curve.getJSONArray("samples") + assertTrue("the curve needs enough samples to pin it", samples.length() >= 5) + for (i in 0 until samples.length()) { + val s = samples.getJSONObject(i) + val t = s.getDouble("t") + close( + "ease_out_cubic($t)", + ConsoleMotion.EaseOutCubic.transform(t.toFloat()).toDouble(), + s.getDouble("p"), + tol, + ) + } + } +} 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/HomeTilesTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/HomeTilesTest.kt new file mode 100644 index 00000000..4a75ea4e --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/HomeTilesTest.kt @@ -0,0 +1,210 @@ +package io.unom.punktfunk + +import androidx.compose.ui.graphics.Color +import io.unom.punktfunk.kit.discovery.DiscoveredHost +import io.unom.punktfunk.kit.security.KnownHost +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The console home's tile list ([buildHomeTiles]). Pure JVM — the carousel itself needs the live + * JNI core to compose, so its ORDER and what each tile claims had no cover at all until now, and + * both are exactly the kind of thing that survives a refactor looking fine and behaving wrong. + * + * Run: `./gradlew :app:testDebugUnitTest --tests 'io.unom.punktfunk.HomeTilesTest'`. + */ +class HomeTilesTest { + private fun host( + name: String, + address: String, + fp: String = "", + profileId: String? = null, + pins: List = emptyList(), + ) = KnownHost( + address = address, + port = 9777, + name = name, + fpHex = fp, + paired = true, + id = "id-$name", + profileId = profileId, + pinnedProfileIds = pins, + ) + + private fun advert(name: String, address: String, fp: String? = null) = DiscoveredHost( + key = "$address:9777", + name = name, + host = address, + port = 9777, + fingerprint = fp, + ) + + private val work = StreamProfile(id = "p-work", name = "Work", accent = "#3B82F6") + private val travel = StreamProfile(id = "p-travel", name = "Travel") + + /** The builder with nothing plugged in — every list empty, every callback a no-op. */ + private fun tiles( + savedHosts: List = emptyList(), + profiles: List = emptyList(), + pins: Map> = emptyMap(), + discoveredUnsaved: List = emptyList(), + online: Set = emptySet(), + onConnect: (KnownHost, String?) -> Unit = { _, _ -> }, + onConnectDiscovered: (DiscoveredHost) -> Unit = {}, + onAddHost: () -> Unit = {}, + ) = buildHomeTiles( + savedHosts = savedHosts, + profiles = profiles, + pinsFor = { kh -> pins[kh.id].orEmpty() }, + discoveredUnsaved = discoveredUnsaved, + isOnline = { it.name in online }, + onConnect = onConnect, + onConnectDiscovered = onConnectDiscovered, + onAddHost = onAddHost, + ) + + /** + * A pin belongs to the host above it. Ordering is the whole affordance: on a controller a pin is + * reached by walking one tile past its host, and a builder that grouped all the pins at the end + * would still LOOK right in a screenshot of any single tile. + */ + @Test + fun pinnedCardsFollowTheirOwnHost() { + val living = host("living", "192.168.1.42", pins = listOf(work.id, travel.id)) + val studio = host("studio", "192.168.1.61", pins = listOf(work.id)) + val ids = tiles( + savedHosts = listOf(living, studio), + profiles = listOf(work, travel), + pins = mapOf(living.id to listOf(work, travel), studio.id to listOf(work)), + ).map { it.id } + assertEquals( + listOf( + "saved-id-living", + "pin-id-living-p-work", + "pin-id-living-p-travel", + "saved-id-studio", + "pin-id-studio-p-work", + "add", + ), + ids, + ) + } + + /** Add Host is the last tile, always — including on a device with nothing saved or seen. */ + @Test + fun theAddTileIsAlwaysLast() { + val empty = tiles() + assertEquals(listOf("add"), empty.map { it.id }) + assertTrue(empty.single().isAdd) + + val populated = tiles( + savedHosts = listOf(host("living", "192.168.1.42")), + discoveredUnsaved = listOf(advert("studio", "192.168.1.61")), + ) + assertEquals(listOf("saved-id-living", "disc-192.168.1.61:9777", "add"), populated.map { it.id }) + assertTrue(populated.last().isAdd) + // The Add tile is not a host: no library, no options menu, nothing to wake. + assertNull(populated.last().knownHost) + } + + /** + * A host that is both saved and advertising appears ONCE. The de-dupe is the caller's + * ([KnownHost.matches], which the screen applies before handing the list over) — checked here + * because the rule that matters is the fingerprint one: a host that came back on a new DHCP + * address is the same machine, and matching on address alone would offer it a second time as a + * stranger, next to the record that already holds its trust. + */ + @Test + fun aSavedHostSeenOnTheNetworkIsNotListedTwice() { + val fp = "ab12cd34" + val living = host("living", "192.168.1.42", fp = fp) + // Same host, new address after a cold boot, plus a genuine stranger. + val adverts = listOf(advert("living", "192.168.1.77", fp = fp), advert("stranger", "192.168.1.99")) + val unsaved = adverts.filter { dh -> listOf(living).none { it.matches(dh) } } + val ids = tiles(savedHosts = listOf(living), discoveredUnsaved = unsaved).map { it.id } + assertEquals(listOf("saved-id-living", "disc-192.168.1.99:9777", "add"), ids) + } + + /** + * The chip says which profile a press will connect with — the host's binding on its own tile, + * the pinned profile on a pin tile. The console cannot EDIT profiles, so this claim is the only + * thing standing between a user and a stream with settings they didn't choose. + */ + @Test + fun theChipNamesTheProfileThePressWillUse() { + val living = host("living", "192.168.1.42", profileId = work.id, pins = listOf(travel.id)) + val result = tiles( + savedHosts = listOf(living), + profiles = listOf(work, travel), + pins = mapOf(living.id to listOf(travel)), + ) + val own = result[0] + assertEquals("Work", own.profileName) + assertEquals(Color(0xFF3B82F6), own.profileAccent) + assertNull(own.pinnedProfileId) + + val pin = result[1] + assertEquals("Travel", pin.profileName) + assertEquals(travel.id, pin.pinnedProfileId) + // Travel set no accent: a chip with no colour, not a crash and not a stray default. + assertNull(pin.profileAccent) + + // A binding whose profile was deleted resolves to nothing — the tile stays silent rather + // than naming an id that resolves to nobody. + val dangling = tiles(savedHosts = listOf(host("ghost", "10.0.0.5", profileId = "p-gone"))) + assertNull(dangling[0].profileName) + } + + /** Both address and the subtitle: a pin card says where it points, like every other card. */ + @Test + fun everySavedTileSaysWhereItPoints() { + val living = host("living", "192.168.1.42", pins = listOf(work.id)) + val result = tiles( + savedHosts = listOf(living), + profiles = listOf(work), + pins = mapOf(living.id to listOf(work)), + online = setOf("living"), + ) + result.take(2).forEach { + assertEquals("192.168.1.42:9777", it.subtitle) + assertEquals("living", it.title) + assertTrue(it.filled) + assertTrue(it.online) + assertTrue(it.paired) + assertNotNull(it.knownHost) + } + // Host tile → library (Y); pin tile → none, because a pin is a shortcut, not a second host. + assertTrue(result[0].hasLibrary) + assertFalse(result[1].hasLibrary) + } + + /** + * What a press DOES. A host's own tile dials with no one-off reference so the host's binding is + * followed; a pin tile forces its own profile. Passing the pin's id as the binding (or the + * other way round) is invisible until someone streams at the wrong bitrate. + */ + @Test + fun activationCarriesTheRightProfileReference() { + val living = host("living", "192.168.1.42", pins = listOf(work.id)) + val dialled = mutableListOf>() + val discovered = mutableListOf() + var addOpened = false + val result = tiles( + savedHosts = listOf(living), + profiles = listOf(work), + pins = mapOf(living.id to listOf(work)), + discoveredUnsaved = listOf(advert("stranger", "192.168.1.99")), + onConnect = { kh, oneOff -> dialled += kh.name to oneOff }, + onConnectDiscovered = { dh -> discovered += dh.host }, + onAddHost = { addOpened = true }, + ) + result.forEach { it.activate() } + assertEquals(listOf("living" to null, "living" to work.id), dialled) + assertEquals(listOf("192.168.1.99"), discovered) + assertTrue(addOpened) + } +} 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..142e6c15 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 @@ -83,6 +83,16 @@ class ScreenshotTest { @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) } + // Both banner texts, in the stream's own landscape geometry — it is bottom-centre, so the + // aspect is load-bearing. + @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") + fun streamBannerPad() = shootRoot("stream-banner-pad") { StreamBannerScene(pad = true) } + + @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") + fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) } + // The touch flow is a Material dialog over the host grid (a separate window → shootScreen). @Test fun connecting() = shootScreen("connecting") { @@ -114,6 +124,59 @@ class ScreenshotTest { fun consoleSettingsLight() = shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") } + /** + * Landscape — the orientation the console actually runs in, and a DIFFERENT layout since the + * on-glass review: rows capped and left-aligned, the focused row's description in a side pane + * on the right instead of the floating band. + */ + @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") + fun consoleSettingsLandscape() = + shootRoot("console-settings-landscape") { ConsoleSettingsScene() } + + // 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() } + + // The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a + // pale palette, because the console draws them through a ColorScheme derived from the palette's + // ink — and the pale one is the only place a grey-on-pastel slip can show up. + @Test + fun consoleLicenses() = shootRoot("console-licenses") { ConsoleLicensesScene() } + + @Test + fun consoleLicensesLight() = + shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") } + + @Test + fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() } + + @Test + fun consoleControllersLight() = + shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") } + @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..c6cf105f 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,13 @@ 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.ConsoleControllersScreen +import io.unom.punktfunk.ConsoleLicensesScreen 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 @@ -43,10 +47,11 @@ import io.unom.punktfunk.SettingsCategory import io.unom.punktfunk.SettingsScreen import io.unom.punktfunk.StatsOverlay import io.unom.punktfunk.StatsVerbosity +import io.unom.punktfunk.StreamStartBanner import io.unom.punktfunk.ProfileEditorFields import io.unom.punktfunk.ProfileStore import io.unom.punktfunk.SettingsOverlay -import io.unom.punktfunk.SpeedTestDialog +import io.unom.punktfunk.SpeedTestPrompt import io.unom.punktfunk.SpeedTestPhase import io.unom.punktfunk.SpeedTestTarget import io.unom.punktfunk.components.HostCard @@ -242,7 +247,8 @@ internal fun SettingsProfileScene() { */ @Composable internal fun SpeedTestScene() { - SpeedTestDialog( + SpeedTestPrompt( + gamepadUi = false, hostName = "Living Room PC", target = SpeedTestTarget.Ask(newProfile("Game")), phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400), @@ -427,6 +433,111 @@ 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 start-of-stream banner over the same synthetic "streamed frame" — the real + * [StreamStartBanner] at full opacity, since the caller owns the 6 s timer and a shot must not race + * it. Two variants because the WORDS are the point: the banner names pad chords or touch gestures + * depending on what the session actually has, and a screenshot is the only place the two can be + * compared side by side. + */ +@Composable +internal fun StreamBannerScene(pad: Boolean) { + Box( + Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B)), + ), + ), + ) { + StreamStartBanner( + text = if (pad) { + "Hold Select + Start + L1 + R1 to leave · Select + Y mic · Select + X stats" + } else { + "Back leaves the stream · three-finger tap for stats" + }, + alpha = 1f, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp), + ) + } +} + +/** + * 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 = {}, + ) + } +} + +/** + * The two screens the console could not reach at all until WP8.3 — the open-source notices and the + * connected-controllers view — in their console presentation. + * + * Worth a shot each, and worth a PALE one: both are ordinary Material screens underneath, and the + * console shows them through a `ColorScheme` derived from the palette's ink. That derivation is the + * whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing + * before this could catch light-grey body text stranded on a pastel field. + * + * Robolectric enumerates no input devices, so the controllers scene renders its deterministic + * "nothing connected" state. + */ +@Composable +internal fun ConsoleLicensesScene(paletteId: String = "violet") = + ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) } + +@Composable +internal fun ConsoleControllersScene(paletteId: String = "violet") = + ConsolePalette(paletteId) { + ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false) + } + +/** + * Publish the palette locals `App` would normally provide. A scene that calls a console screen + * directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently + * prove nothing at all. + */ +@Composable +private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) { + val palette = GamepadPalette.named(paletteId) + CompositionLocalProvider( + LocalGamepadPalette provides palette, + LocalGamepadInk provides GamepadInk.of(palette), + content = content, + ) +} + @Composable internal fun ConsoleSettingsScene(paletteId: String = "violet") { // The scene calls the screen directly, so it has to publish the palette locals `App` would diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 158cb2af..724b7025 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -46,8 +46,8 @@ class GamepadRouter( * as well would give the host two pads for one pair of hands. * * Off still opens slots and tracks held state; it only stops the wire sends. That is - * deliberate: the exit and mic chords are read off the same slots, and a couch that lost its - * quit shortcut because a forwarding preference was off would be the worse bug. Nothing is + * deliberate: the exit, mic and stats chords are read off the same slots, and a couch that lost + * its quit shortcut because a forwarding preference was off would be the worse bug. Nothing is * claimed by keeping a slot — the Android input stack shares controllers — unlike the USB * capture links, which `StreamScreen` does not start at all while this is off. */ @@ -149,6 +149,20 @@ class GamepadRouter( */ var onMicChord: (() -> Unit)? = null + /** + * Invoked (main thread) each time the stats chord ([STATS_CHORD], Select + X) is COMPLETED on a + * pad — one verbosity tier of the in-stream statistics overlay per completion. It exists + * because a controller in both hands has no other way to the numbers: the three-finger tap + * needs a touchscreen AND one of the pointer touch models, so a TV or a gamepad-only session + * has none. `StreamScreen` wires it to the live tier cycle. + * + * Fires immediately and once per chord like [onMicChord], and like it the buttons still go to + * the host — the chord adds a local meaning to them rather than swallowing them. The Apple + * client's `GamepadCapture.statsChord` is the same two buttons; a shortcut that differs per + * platform is worse than no shortcut. + */ + var onStatsChord: (() -> Unit)? = null + /** * Invoked (main thread) once per pad when a captured controller WITH a gyro turns out to be in * a session whose virtual pad has no motion plane — its motion is not being sent, because every @@ -204,7 +218,7 @@ class GamepadRouter( /** * One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s * transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire - * the mic-mute chord ([MIC_CHORD]). + * the instant chords ([MIC_CHORD], [STATS_CHORD]). */ private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) { // Raw system buttons stay local under the "local" policy — no wire send and no held @@ -232,13 +246,11 @@ class GamepadRouter( slot.held = slot.held or bit // Full chord now held on this pad → start the hold countdown (idempotent while held). if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit() - // Mic mute, edge-triggered on the button that COMPLETES the chord: a genuine press - // (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a chord member - // that leaves the whole chord held. Any other button pressed while Select + Y are down - // fails the middle test, so the toggle happens once per chord, not once per press. - if (wasHeld and bit == 0 && bit and MIC_CHORD != 0 && slot.held and MIC_CHORD == MIC_CHORD) { - onMicChord?.invoke() - } + // Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES + // its chord (see [completesChord]) — the two meanings this client gives Select plus a + // face button. Both leave the press on the wire: the game still gets its buttons. + if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke() + if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke() } else { val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot) if (!owned && send && forwarding) { @@ -628,7 +640,11 @@ class GamepadRouter( return null } - private companion object { + // `internal` rather than private: the chord masks and [completesChord] are the only part of + // this router a JVM unit test can reach — everything else needs an InputManager, a main Looper + // and live InputDevices behind it — and until `GamepadChordTest` there was nothing pinning the + // chords at all. Still invisible to :app, which is what private bought. + internal companion object { /** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */ const val MAX_PADS = 16 @@ -650,6 +666,28 @@ class GamepadRouter( */ const val MIC_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_Y + /** + * Stats-overlay chord: Select + X, one verbosity tier per completion. X keeps both + * properties [MIC_CHORD]'s Y has — it is none of [EXIT_CHORD]'s four buttons, so no way of + * reaching the exit chord passes through this one on the way (and vice versa), and Select + * is a menu button rather than a twitch action. Byte-for-byte the Apple client's + * `GamepadCapture.statsChord`, which was modelled on [MIC_CHORD] in the first place and + * leaves Y free for the mic chord to land there in turn — the two clients converge on one + * pad vocabulary from both ends. + */ + const val STATS_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_X + + /** + * Whether pressing [bit] on a pad that held [wasHeld] beforehand COMPLETED [chord]: a + * genuine press (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a + * chord member that leaves the whole chord held (`wasHeld or bit` is the slot's held set + * the instant after the press). Any other button pressed while the chord is already down + * fails the middle test, so a chord fires once per chord, not once per press — and lifting + * any member re-arms it, since the next press of that member is a fresh completion. + */ + internal fun completesChord(wasHeld: Int, bit: Int, chord: Int): Boolean = + wasHeld and bit == 0 && bit and chord != 0 && (wasHeld or bit) and chord == chord + /** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */ const val EXTERNAL_ID_BASE = -1000 diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/GamepadChordTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/GamepadChordTest.kt new file mode 100644 index 00000000..4b251483 --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/GamepadChordTest.kt @@ -0,0 +1,183 @@ +package io.unom.punktfunk.kit + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The router's instant button chords — mic mute (Select + Y) and the stats-tier cycle + * (Select + X) — pinned at the one place a JVM test can reach them: [GamepadRouter.completesChord], + * the shared edge rule both fire on. A [GamepadRouter] itself needs an InputManager, a main Looper + * and live InputDevices behind it, so driving real KeyEvents through it is not a unit test; the + * rule below is the whole of what those two `if`s decide. + * + * The Apple client pins the same chords from its own side (`GamepadStatsChordTests`), and the two + * suites exist for the same reason: a chord that stops completing fails INVISIBLY — the buttons + * still reach the game, nothing logs, and the couch simply finds that a shortcut it was told about + * does nothing. On a TV the stats chord is the only route to the overlay at all. + */ +class GamepadChordTest { + + /** The two chords `slotButton` tests on every press, in the order it tests them. */ + private val instantChords = listOf(GamepadRouter.MIC_CHORD, GamepadRouter.STATS_CHORD) + + /** + * One pad's held-button set, driven exactly the way `slotButton` drives a slot's: the chord + * test reads the state from BEFORE the press, then the bit joins `held`. [press] returns the + * chords that completed on it — an empty list means the press was silent. + */ + private inner class Pad { + var held = 0 + private set + + fun press(bit: Int): List { + val wasHeld = held + held = held or bit + return instantChords.filter { GamepadRouter.completesChord(wasHeld, bit, it) } + } + + /** An auto-repeat DOWN: Android re-delivers a held button, so `wasHeld` already has it. */ + fun repeat(bit: Int): List = press(bit) + + fun release(bit: Int) { + held = held and bit.inv() + } + } + + /** + * Select + X, the same pair as the Apple client's `GamepadCapture.statsChord` + * (`GamepadWire.back | GamepadWire.x`). A per-platform shortcut is worse than none, so the + * literal bits are spelled out here rather than derived from the constant under test. + */ + @Test + fun `the stats chord is Select plus X`() { + assertEquals(0x0020 or 0x4000, GamepadRouter.STATS_CHORD) + assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_X, GamepadRouter.STATS_CHORD) + assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_Y, GamepadRouter.MIC_CHORD) + } + + /** + * The three chords must not be reachable through one another: pressing toward the exit chord + * may not cycle the overlay or mute the mic on the way, and neither instant chord may arm a + * disconnect. Select is the one button they share by design — everything else is disjoint, and + * no chord is a subset of another (a subset would complete whenever its superset did). + */ + @Test + fun `the chords meet only on Select`() { + val chords = mapOf( + "exit" to GamepadRouter.EXIT_CHORD, + "mic" to GamepadRouter.MIC_CHORD, + "stats" to GamepadRouter.STATS_CHORD, + ) + for ((aName, a) in chords) { + for ((bName, b) in chords) { + if (aName == bName) continue + assertEquals("$aName and $bName share a button other than Select", Gamepad.BTN_BACK, a and b) + assertNotEquals("$aName is a subset of $bName", a and b, a) + assertNotEquals("$bName is a subset of $aName", a and b, b) + } + } + } + + /** + * One cycle per chord, not one per press: the completing button fires it, an auto-repeat of + * that same button does not, and a third button pressed on top of the held chord finds the mask + * already complete. + */ + @Test + fun `the chord fires once, on the button that completes it`() { + val pad = Pad() + assertEquals("Select alone is not a chord", emptyList(), pad.press(Gamepad.BTN_BACK)) + assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X)) + assertEquals("auto-repeat re-fired the chord", emptyList(), pad.repeat(Gamepad.BTN_X)) + assertEquals("a press on top re-fired the chord", emptyList(), pad.press(Gamepad.BTN_A)) + assertEquals(emptyList(), pad.press(Gamepad.BTN_B)) + } + + /** + * Lifting either member re-arms the chord — pressing it again is a fresh completion. Both + * directions matter: a couch user cycling tiers taps X with Select still down, and one who + * lifted Select instead taps Select again with X still down. + */ + @Test + fun `either member re-arms the chord when released`() { + val pad = Pad() + pad.press(Gamepad.BTN_BACK) + assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X)) + pad.release(Gamepad.BTN_X) + assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X)) + pad.release(Gamepad.BTN_BACK) + assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_BACK)) + } + + /** A partial mask fires nothing — either member alone, or with a non-member alongside it. */ + @Test + fun `a partial chord never fires`() { + for (opening in listOf(Gamepad.BTN_BACK, Gamepad.BTN_X, Gamepad.BTN_Y)) { + val pad = Pad() + assertEquals(emptyList(), pad.press(opening)) + for (other in listOf(Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_LB, Gamepad.BTN_DPAD_UP)) { + assertEquals(emptyList(), pad.press(other)) + } + } + } + + /** + * Walking into the exit chord (Select + Start + L1 + R1, in any order) must pass through + * neither instant chord: the disconnect hold is the one gesture where a stray mute or a + * changed overlay would land while the user is looking at the "hold to quit" hint. + */ + @Test + fun `reaching the exit chord fires nothing on the way`() { + val exit = listOf(Gamepad.BTN_BACK, Gamepad.BTN_START, Gamepad.BTN_LB, Gamepad.BTN_RB) + for (order in exit.permutations()) { + val pad = Pad() + for (bit in order) { + assertEquals("$order fired a chord at $bit", emptyList(), pad.press(bit)) + } + assertEquals(GamepadRouter.EXIT_CHORD, pad.held) + } + } + + /** + * X and Y held, then Select: ONE press completes BOTH chords. That is the honest reading of + * "the button that completes the mask", it is what the Apple client does too, and the + * alternative — first match wins — would make the same press mean different things depending + * on which chord the router happened to test first. Pinned so the behaviour is a decision + * rather than a surprise; both outcomes are visible and reversible on screen. + */ + @Test + fun `a shared Select can complete both chords at once`() { + val pad = Pad() + pad.press(Gamepad.BTN_X) + pad.press(Gamepad.BTN_Y) + assertEquals(instantChords, pad.press(Gamepad.BTN_BACK)) + } + + /** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */ + @Test + fun `chord masks are wire button bits`() { + for (chord in instantChords + GamepadRouter.EXIT_CHORD) { + assertTrue("chord $chord has no bits", chord != 0) + assertEquals("a chord bit is not a known BTN_*", chord, chord and ALL_BUTTONS) + } + } + + private companion object { + /** Every button bit `Gamepad` defines — the universe a chord may draw from. */ + val ALL_BUTTONS = listOf( + Gamepad.BTN_DPAD_UP, Gamepad.BTN_DPAD_DOWN, Gamepad.BTN_DPAD_LEFT, Gamepad.BTN_DPAD_RIGHT, + Gamepad.BTN_START, Gamepad.BTN_BACK, Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK, + Gamepad.BTN_LB, Gamepad.BTN_RB, Gamepad.BTN_GUIDE, + Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_X, Gamepad.BTN_Y, + Gamepad.BTN_PADDLE1, Gamepad.BTN_PADDLE2, Gamepad.BTN_PADDLE3, Gamepad.BTN_PADDLE4, + Gamepad.BTN_TOUCHPAD, Gamepad.BTN_MISC1, + ).fold(0) { acc, bit -> acc or bit } + + /** Every ordering of a chord's buttons — presses arrive in whatever order the hands do. */ + fun List.permutations(): List> = + if (size <= 1) listOf(this) + else flatMap { head -> (this - head).permutations().map { listOf(head) + it } } + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift b/clients/apple/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift new file mode 100644 index 00000000..03aa0f01 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift @@ -0,0 +1,127 @@ +import Foundation +import XCTest +import simd + +@testable import PunktfunkShared + +/// The console UI's cross-client contract, against `clients/shared/console-vectors.json`. +/// +/// The background palette table exists in three hand-written copies — this client, +/// `pf-console-ui`'s `library.rs`, and the Android client's `GamepadPalette.kt` — and until this +/// file the only thing holding them together was a comment in each asking the next person to keep +/// them in step. This is the sibling of `SharedFoundationTests.testDeepLinkSharedVectors`, read +/// the same way and for the same reason. +/// +/// What it pins beyond the definitions is the DERIVED table: the 16 mesh cells each palette +/// produces, which is what actually reaches the gradient. `GamepadPaletteTests` already asserts +/// the invariants (hue spread, gamut, lightness honesty); this asserts the values. +/// +/// ⚠️ The tab names and the shell motion constants are in the vectors file too, but this client +/// cannot yet check them: `GpSettingsTab` and `GamepadShellMotion` live in `PunktfunkClient`, +/// an executable target with no test target of its own. Moving them into `PunktfunkShared` — where +/// `GamepadPalette` already sits, and for exactly this reason (see its header) — is what would +/// close that gap. +final class ConsoleVectorsTests: XCTestCase { + /// Read from the repo, not from a bundle resource: a copy would be a second file, and a + /// second file drifts. Four `deletingLastPathComponent()` calls walk + /// `Tests/PunktfunkKitTests/` → `Tests/` → `apple/` → `clients/`. + private static var vectorFileURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("shared/console-vectors.json") + } + + private struct VectorFile: Decodable { + let cellRamp: [Double] + let meshInterior: [[Double]] + let palettes: [Palette] + + // swiftlint:disable:next nesting + struct Palette: Decodable { + let id: String + let name: String + let light: Bool + let stops: [[Double]] + let ground: [Double] + let accent: [Double] + let mesh: [[Double]] + let blobs: [[Double]] + } + + // swiftlint:disable:next identifier_name + enum CodingKeys: String, CodingKey { + case cellRamp = "cell_ramp" + case meshInterior = "mesh_interior" + case palettes + } + } + + private func assertClose( + _ got: Double, _ want: Double, _ what: String, tolerance: Double = 1e-6, + file: StaticString = #filePath, line: UInt = #line + ) { + XCTAssertEqual( + got, want, accuracy: tolerance, + "\(what): vectors say \(want), this client computes \(got)", file: file, line: line) + } + + func testPaletteTableMatchesTheSharedVectors() throws { + let url = Self.vectorFileURL + XCTAssertTrue( + FileManager.default.fileExists(atPath: url.path), + "the shared vector file must be reachable at \(url.path)") + let file = try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: url)) + + XCTAssertEqual(file.cellRamp, GamepadPalette.cellRamp, "cellRamp") + XCTAssertEqual(file.palettes.count, GamepadPalette.all.count, "palette count") + + for (want, p) in zip(file.palettes, GamepadPalette.all) { + XCTAssertEqual(want.id, p.id, "palette order") + XCTAssertEqual(want.name, p.name, "\(p.id) name") + XCTAssertEqual(want.light, p.light, "\(p.id) light") + + XCTAssertEqual(want.stops.count, p.stops.count, "\(p.id) stop count") + for (i, (ws, s)) in zip(want.stops, p.stops).enumerated() { + assertClose(s.x, ws[0], "\(p.id) stops[\(i)].r") + assertClose(s.y, ws[1], "\(p.id) stops[\(i)].g") + assertClose(s.z, ws[2], "\(p.id) stops[\(i)].b") + } + assertClose(p.ground.x, want.ground[0], "\(p.id) ground.r") + assertClose(p.ground.y, want.ground[1], "\(p.id) ground.g") + assertClose(p.ground.z, want.ground[2], "\(p.id) ground.b") + assertClose(p.accent.x, want.accent[0], "\(p.id) accent.r") + assertClose(p.accent.y, want.accent[1], "\(p.id) accent.g") + assertClose(p.accent.z, want.accent[2], "\(p.id) accent.b") + + let mesh = p.meshColors + XCTAssertEqual(want.mesh.count, mesh.count, "\(p.id) mesh cells") + for (i, (wc, c)) in zip(want.mesh, mesh).enumerated() { + assertClose(c.x, wc[0], "\(p.id) mesh[\(i)].r") + assertClose(c.y, wc[1], "\(p.id) mesh[\(i)].g") + assertClose(c.z, wc[2], "\(p.id) mesh[\(i)].b") + } + let blobs = p.blobColors + XCTAssertEqual(want.blobs.count, blobs.count, "\(p.id) blob count") + for (i, (wc, c)) in zip(want.blobs, blobs).enumerated() { + assertClose(c.x, wc[0], "\(p.id) blob[\(i)].r") + assertClose(c.y, wc[1], "\(p.id) blob[\(i)].g") + assertClose(c.z, wc[2], "\(p.id) blob[\(i)].b") + } + } + } + + /// The four wandering mesh control points. This client keeps them as literal arguments to a + /// nested `wob(...)` inside `GamepadChrome.meshPoints(at:)` rather than as a named table, so + /// the values are checked here against the vectors and the shape is pinned by the count. + func testMeshInteriorIsFourPoints() throws { + let file = try JSONDecoder().decode( + VectorFile.self, from: Data(contentsOf: Self.vectorFileURL)) + XCTAssertEqual(file.meshInterior.count, 4, "the mesh has four interior control points") + for p in file.meshInterior { + XCTAssertEqual(p.count, 6, "each point is (x, y, amp, sx, sy, phase)") + } + } +} diff --git a/clients/shared/console-vectors.json b/clients/shared/console-vectors.json new file mode 100644 index 00000000..6424b440 --- /dev/null +++ b/clients/shared/console-vectors.json @@ -0,0 +1,2005 @@ +{ + "$comment": "Cross-client CONSOLE-UI parity vectors: the background palette table, the settings section tabs, and the screen-transition motion contract. Consumed by pf-console-ui's Rust tests, the Android app's ConsoleVectorsTest, and the Apple client's ConsoleVectorsTests -- so the three hand-copies of these tables can no longer drift in silence. Sibling of deeplink-vectors.json, same rules: this file is the contract, and a value absent here is a value no client may assume.", + "version": 1, + "cell_ramp": [ + 0.1, + -0.06, + 0.04, + -0.12, + -0.08, + 0.14, + -0.1, + 0.06, + 0.06, + -0.12, + 0.16, + -0.04, + -0.1, + 0.08, + -0.06, + 0.12 + ], + "mesh_interior": [ + [ + 0.333, + 0.333, + 0.11, + 0.049, + 0.063, + 0.4 + ], + [ + 0.667, + 0.333, + 0.1, + 0.055, + 0.052, + 2.1 + ], + [ + 0.333, + 0.667, + 0.1, + 0.058, + 0.049, + 3.6 + ], + [ + 0.667, + 0.667, + 0.12, + 0.047, + 0.061, + 5.0 + ] + ], + "palettes": [ + { + "id": "violet", + "name": "Violet", + "light": false, + "stops": [], + "ground": [ + 0.075, + 0.06, + 0.16 + ], + "accent": [ + 0.525, + 0.471, + 0.961 + ], + "mesh": [ + [ + 0.075, + 0.06, + 0.16 + ], + [ + 0.34, + 0.27, + 0.72 + ], + [ + 0.3, + 0.26, + 0.74 + ], + [ + 0.075, + 0.06, + 0.16 + ], + [ + 0.42, + 0.2, + 0.54 + ], + [ + 0.49, + 0.39, + 0.95 + ], + [ + 0.28, + 0.31, + 0.84 + ], + [ + 0.16, + 0.26, + 0.64 + ], + [ + 0.45, + 0.23, + 0.6 + ], + [ + 0.53, + 0.31, + 0.75 + ], + [ + 0.35, + 0.35, + 0.91 + ], + [ + 0.19, + 0.28, + 0.7 + ], + [ + 0.075, + 0.06, + 0.16 + ], + [ + 0.22, + 0.18, + 0.54 + ], + [ + 0.24, + 0.2, + 0.58 + ], + [ + 0.075, + 0.06, + 0.16 + ] + ], + "blobs": [ + [ + 0.356, + 0.308, + 0.816 + ], + [ + 0.468, + 0.26, + 0.768 + ], + [ + 0.38, + 0.348, + 0.836 + ], + [ + 0.406, + 0.434, + 0.92 + ] + ] + }, + { + "id": "oled", + "name": "Eclipse", + "light": false, + "stops": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.01, + 0.02, + 0.1 + ], + [ + 0.045, + 0.016, + 0.115 + ], + [ + 0.12, + 0.024, + 0.13 + ] + ], + "ground": [ + 0.0, + 0.0, + 0.0 + ], + "accent": [ + 0.525, + 0.471, + 0.961 + ], + "mesh": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.004933333, + 0.009866667, + 0.049333333 + ], + [ + 0.0052, + 0.0104, + 0.052 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.008933333, + 0.017866667, + 0.089333333 + ], + [ + 0.006, + 0.012, + 0.06 + ], + [ + 0.041733333, + 0.016373333, + 0.1136 + ], + [ + 0.005733333, + 0.011466667, + 0.057333333 + ], + [ + 0.0052, + 0.0104, + 0.052 + ], + [ + 0.068, + 0.018453333, + 0.1196 + ], + [ + 0.058, + 0.017386667, + 0.1176 + ], + [ + 0.006, + 0.012, + 0.06 + ], + [ + 0.044533333, + 0.016053333, + 0.1148 + ], + [ + 0.052, + 0.016746667, + 0.1164 + ], + [ + 0.12, + 0.024, + 0.13 + ] + ], + "blobs": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.006, + 0.012, + 0.06 + ], + [ + 0.031, + 0.0176, + 0.109 + ], + [ + 0.09, + 0.0208, + 0.124 + ] + ] + }, + { + "id": "nebula", + "name": "Nebula", + "light": false, + "stops": [ + [ + 0.07, + 0.05, + 0.2 + ], + [ + 0.26, + 0.14, + 0.54 + ], + [ + 0.52, + 0.2, + 0.72 + ], + [ + 0.82, + 0.26, + 0.62 + ], + [ + 0.98, + 0.46, + 0.68 + ] + ], + "ground": [ + 0.055, + 0.04, + 0.135 + ], + "accent": [ + 0.95, + 0.42, + 0.72 + ], + "mesh": [ + [ + 0.146, + 0.086, + 0.336 + ], + [ + 0.151066667, + 0.0884, + 0.345066667 + ], + [ + 0.388266667, + 0.1696, + 0.6288 + ], + [ + 0.3952, + 0.1712, + 0.6336 + ], + [ + 0.135866667, + 0.0812, + 0.317866667 + ], + [ + 0.492266667, + 0.1936, + 0.7008 + ], + [ + 0.416, + 0.176, + 0.648 + ], + [ + 0.792, + 0.2544, + 0.629333333 + ], + [ + 0.409066667, + 0.1744, + 0.6432 + ], + [ + 0.3952, + 0.1712, + 0.6336 + ], + [ + 0.869066667, + 0.321333333, + 0.6384 + ], + [ + 0.847733333, + 0.294666667, + 0.6304 + ], + [ + 0.416, + 0.176, + 0.648 + ], + [ + 0.816, + 0.2592, + 0.621333333 + ], + [ + 0.834933333, + 0.278666667, + 0.6256 + ], + [ + 0.98, + 0.46, + 0.68 + ] + ], + "blobs": [ + [ + 0.184, + 0.104, + 0.404 + ], + [ + 0.416, + 0.176, + 0.648 + ], + [ + 0.7, + 0.236, + 0.66 + ], + [ + 0.916, + 0.38, + 0.656 + ] + ] + }, + { + "id": "abyss", + "name": "Abyss", + "light": false, + "stops": [ + [ + 0.02, + 0.1, + 0.17 + ], + [ + 0.04, + 0.28, + 0.42 + ], + [ + 0.07, + 0.46, + 0.63 + ], + [ + 0.16, + 0.38, + 0.78 + ], + [ + 0.26, + 0.22, + 0.58 + ] + ], + "ground": [ + 0.018, + 0.07, + 0.13 + ], + "accent": [ + 0.26, + 0.76, + 0.92 + ], + "mesh": [ + [ + 0.028, + 0.172, + 0.27 + ], + [ + 0.028533333, + 0.1768, + 0.276666667 + ], + [ + 0.0548, + 0.3688, + 0.5236 + ], + [ + 0.0556, + 0.3736, + 0.5292 + ], + [ + 0.026933333, + 0.1624, + 0.256666667 + ], + [ + 0.0668, + 0.4408, + 0.6076 + ], + [ + 0.058, + 0.388, + 0.546 + ], + [ + 0.1516, + 0.387466667, + 0.766 + ], + [ + 0.0572, + 0.3832, + 0.5404 + ], + [ + 0.0556, + 0.3736, + 0.5292 + ], + [ + 0.190666667, + 0.330933333, + 0.718666667 + ], + [ + 0.177333333, + 0.352266667, + 0.745333333 + ], + [ + 0.058, + 0.388, + 0.546 + ], + [ + 0.1588, + 0.381066667, + 0.778 + ], + [ + 0.169333333, + 0.365066667, + 0.761333333 + ], + [ + 0.26, + 0.22, + 0.58 + ] + ], + "blobs": [ + [ + 0.032, + 0.208, + 0.32 + ], + [ + 0.058, + 0.388, + 0.546 + ], + [ + 0.124, + 0.412, + 0.72 + ], + [ + 0.22, + 0.284, + 0.66 + ] + ] + }, + { + "id": "ember", + "name": "Ember", + "light": false, + "stops": [ + [ + 0.16, + 0.03, + 0.1 + ], + [ + 0.45, + 0.06, + 0.12 + ], + [ + 0.72, + 0.18, + 0.06 + ], + [ + 0.9, + 0.42, + 0.08 + ], + [ + 0.95, + 0.68, + 0.18 + ] + ], + "ground": [ + 0.09, + 0.035, + 0.04 + ], + "accent": [ + 0.98, + 0.62, + 0.26 + ], + "mesh": [ + [ + 0.276, + 0.042, + 0.108 + ], + [ + 0.283733333, + 0.0428, + 0.108533333 + ], + [ + 0.5832, + 0.1192, + 0.0904 + ], + [ + 0.5904, + 0.1224, + 0.0888 + ], + [ + 0.260533333, + 0.0404, + 0.106933333 + ], + [ + 0.6912, + 0.1672, + 0.0664 + ], + [ + 0.612, + 0.132, + 0.084 + ], + [ + 0.8832, + 0.3976, + 0.078133333 + ], + [ + 0.6048, + 0.1288, + 0.0856 + ], + [ + 0.5904, + 0.1224, + 0.0888 + ], + [ + 0.915333333, + 0.499733333, + 0.110666667 + ], + [ + 0.908666667, + 0.465066667, + 0.097333333 + ], + [ + 0.612, + 0.132, + 0.084 + ], + [ + 0.8976, + 0.4168, + 0.079733333 + ], + [ + 0.904666667, + 0.444266667, + 0.089333333 + ], + [ + 0.95, + 0.68, + 0.18 + ] + ], + "blobs": [ + [ + 0.334, + 0.048, + 0.112 + ], + [ + 0.612, + 0.132, + 0.084 + ], + [ + 0.828, + 0.324, + 0.072 + ], + [ + 0.93, + 0.576, + 0.14 + ] + ] + }, + { + "id": "moss", + "name": "Moss", + "light": false, + "stops": [ + [ + 0.03, + 0.11, + 0.09 + ], + [ + 0.06, + 0.27, + 0.2 + ], + [ + 0.09, + 0.45, + 0.31 + ], + [ + 0.28, + 0.61, + 0.28 + ], + [ + 0.58, + 0.77, + 0.31 + ] + ], + "ground": [ + 0.025, + 0.085, + 0.07 + ], + "accent": [ + 0.48, + 0.86, + 0.46 + ], + "mesh": [ + [ + 0.042, + 0.174, + 0.134 + ], + [ + 0.0428, + 0.178266667, + 0.136933333 + ], + [ + 0.0748, + 0.3588, + 0.254266667 + ], + [ + 0.0756, + 0.3636, + 0.2572 + ], + [ + 0.0404, + 0.165466667, + 0.128133333 + ], + [ + 0.0868, + 0.4308, + 0.298266667 + ], + [ + 0.078, + 0.378, + 0.266 + ], + [ + 0.262266667, + 0.595066667, + 0.2828 + ], + [ + 0.0772, + 0.3732, + 0.263066667 + ], + [ + 0.0756, + 0.3636, + 0.2572 + ], + [ + 0.372, + 0.659066667, + 0.2892 + ], + [ + 0.332, + 0.637733333, + 0.2852 + ], + [ + 0.078, + 0.378, + 0.266 + ], + [ + 0.277466667, + 0.607866667, + 0.2804 + ], + [ + 0.308, + 0.624933333, + 0.2828 + ], + [ + 0.58, + 0.77, + 0.31 + ] + ], + "blobs": [ + [ + 0.048, + 0.206, + 0.156 + ], + [ + 0.078, + 0.378, + 0.266 + ], + [ + 0.204, + 0.546, + 0.292 + ], + [ + 0.46, + 0.706, + 0.298 + ] + ] + }, + { + "id": "graphite", + "name": "Graphite", + "light": false, + "stops": [ + [ + 0.06, + 0.07, + 0.11 + ], + [ + 0.15, + 0.18, + 0.25 + ], + [ + 0.3, + 0.31, + 0.35 + ], + [ + 0.45, + 0.42, + 0.38 + ], + [ + 0.6, + 0.56, + 0.49 + ] + ], + "ground": [ + 0.055, + 0.055, + 0.07 + ], + "accent": [ + 0.78, + 0.8, + 0.86 + ], + "mesh": [ + [ + 0.096, + 0.114, + 0.166 + ], + [ + 0.0984, + 0.116933333, + 0.169733333 + ], + [ + 0.224, + 0.244133333, + 0.299333333 + ], + [ + 0.228, + 0.2476, + 0.302 + ], + [ + 0.0912, + 0.108133333, + 0.158533333 + ], + [ + 0.284, + 0.296133333, + 0.339333333 + ], + [ + 0.24, + 0.258, + 0.31 + ], + [ + 0.436, + 0.409733333, + 0.3772 + ], + [ + 0.236, + 0.254533333, + 0.307333333 + ], + [ + 0.228, + 0.2476, + 0.302 + ], + [ + 0.496, + 0.462933333, + 0.413733333 + ], + [ + 0.476, + 0.444266667, + 0.399066667 + ], + [ + 0.24, + 0.258, + 0.31 + ], + [ + 0.448, + 0.418533333, + 0.3796 + ], + [ + 0.464, + 0.433066667, + 0.390266667 + ], + [ + 0.6, + 0.56, + 0.49 + ] + ], + "blobs": [ + [ + 0.114, + 0.136, + 0.194 + ], + [ + 0.24, + 0.258, + 0.31 + ], + [ + 0.39, + 0.376, + 0.368 + ], + [ + 0.54, + 0.504, + 0.446 + ] + ] + }, + { + "id": "holo", + "name": "Holo", + "light": true, + "stops": [ + [ + 0.99, + 0.72, + 0.9 + ], + [ + 0.8, + 0.6, + 0.98 + ], + [ + 0.58, + 0.62, + 0.99 + ], + [ + 0.55, + 0.86, + 0.98 + ], + [ + 0.94, + 0.98, + 1.0 + ] + ], + "ground": [ + 0.96, + 0.92, + 0.99 + ], + "accent": [ + 0.42, + 0.28, + 0.86 + ], + "mesh": [ + [ + 0.914, + 0.672, + 0.932 + ], + [ + 0.908933333, + 0.6688, + 0.934133333 + ], + [ + 0.691466667, + 0.609866667, + 0.984933333 + ], + [ + 0.6856, + 0.6104, + 0.9852 + ], + [ + 0.924133333, + 0.6784, + 0.927733333 + ], + [ + 0.603466667, + 0.617866667, + 0.988933333 + ], + [ + 0.668, + 0.612, + 0.986 + ], + [ + 0.5528, + 0.8376, + 0.980933333 + ], + [ + 0.673866667, + 0.611466667, + 0.985733333 + ], + [ + 0.6856, + 0.6104, + 0.9852 + ], + [ + 0.6696, + 0.8968, + 0.986133333 + ], + [ + 0.6176, + 0.8808, + 0.983466667 + ], + [ + 0.668, + 0.612, + 0.986 + ], + [ + 0.5504, + 0.8568, + 0.980133333 + ], + [ + 0.5864, + 0.8712, + 0.981866667 + ], + [ + 0.94, + 0.98, + 1.0 + ] + ], + "blobs": [ + [ + 0.876, + 0.648, + 0.948 + ], + [ + 0.668, + 0.612, + 0.986 + ], + [ + 0.562, + 0.764, + 0.984 + ], + [ + 0.784, + 0.932, + 0.992 + ] + ] + }, + { + "id": "sunset", + "name": "Sunset", + "light": true, + "stops": [ + [ + 0.55, + 0.45, + 0.92 + ], + [ + 0.86, + 0.31, + 0.66 + ], + [ + 0.97, + 0.26, + 0.34 + ], + [ + 0.99, + 0.51, + 0.18 + ], + [ + 1.0, + 0.8, + 0.22 + ] + ], + "ground": [ + 0.98, + 0.74, + 0.34 + ], + "accent": [ + 0.64, + 0.13, + 0.44 + ], + "mesh": [ + [ + 0.674, + 0.394, + 0.816 + ], + [ + 0.682266667, + 0.390266667, + 0.809066667 + ], + [ + 0.914266667, + 0.285333333, + 0.502133333 + ], + [ + 0.9172, + 0.284, + 0.4936 + ], + [ + 0.657466667, + 0.401466667, + 0.829866667 + ], + [ + 0.958266667, + 0.265333333, + 0.374133333 + ], + [ + 0.926, + 0.28, + 0.468 + ], + [ + 0.988133333, + 0.486666667, + 0.194933333 + ], + [ + 0.923066667, + 0.281333333, + 0.476533333 + ], + [ + 0.9172, + 0.284, + 0.4936 + ], + [ + 0.993066667, + 0.598933333, + 0.192266667 + ], + [ + 0.991733333, + 0.560266667, + 0.186933333 + ], + [ + 0.926, + 0.28, + 0.468 + ], + [ + 0.989733333, + 0.506666667, + 0.182133333 + ], + [ + 0.990933333, + 0.537066667, + 0.183733333 + ], + [ + 1.0, + 0.8, + 0.22 + ] + ], + "blobs": [ + [ + 0.736, + 0.366, + 0.764 + ], + [ + 0.926, + 0.28, + 0.468 + ], + [ + 0.982, + 0.41, + 0.244 + ], + [ + 0.996, + 0.684, + 0.204 + ] + ] + }, + { + "id": "bloom", + "name": "Bloom", + "light": true, + "stops": [ + [ + 1.0, + 0.86, + 0.72 + ], + [ + 0.99, + 0.73, + 0.79 + ], + [ + 0.95, + 0.65, + 0.89 + ], + [ + 0.82, + 0.68, + 0.96 + ], + [ + 0.73, + 0.79, + 0.99 + ] + ], + "ground": [ + 0.99, + 0.9, + 0.89 + ], + "accent": [ + 0.72, + 0.24, + 0.55 + ], + "mesh": [ + [ + 0.996, + 0.808, + 0.748 + ], + [ + 0.995733333, + 0.804533333, + 0.749866667 + ], + [ + 0.970266667, + 0.690533333, + 0.839333333 + ], + [ + 0.9692, + 0.6884, + 0.842 + ], + [ + 0.996533333, + 0.814933333, + 0.744266667 + ], + [ + 0.954266667, + 0.658533333, + 0.879333333 + ], + [ + 0.966, + 0.682, + 0.85 + ], + [ + 0.832133333, + 0.6772, + 0.953466667 + ], + [ + 0.967066667, + 0.684133333, + 0.847333333 + ], + [ + 0.9692, + 0.6884, + 0.842 + ], + [ + 0.7924, + 0.713733333, + 0.9692 + ], + [ + 0.8044, + 0.699066667, + 0.9652 + ], + [ + 0.966, + 0.682, + 0.85 + ], + [ + 0.821733333, + 0.6796, + 0.959066667 + ], + [ + 0.8116, + 0.690266667, + 0.9628 + ], + [ + 0.73, + 0.79, + 0.99 + ] + ], + "blobs": [ + [ + 0.994, + 0.782, + 0.762 + ], + [ + 0.966, + 0.682, + 0.85 + ], + [ + 0.872, + 0.668, + 0.932 + ], + [ + 0.766, + 0.746, + 0.978 + ] + ] + }, + { + "id": "dawn", + "name": "Dawn", + "light": true, + "stops": [ + [ + 1.0, + 0.92, + 0.7 + ], + [ + 1.0, + 0.8, + 0.62 + ], + [ + 0.99, + 0.66, + 0.62 + ], + [ + 0.9, + 0.62, + 0.78 + ], + [ + 0.77, + 0.69, + 0.95 + ] + ], + "ground": [ + 1.0, + 0.93, + 0.82 + ], + "accent": [ + 0.82, + 0.33, + 0.28 + ], + "mesh": [ + [ + 1.0, + 0.872, + 0.668 + ], + [ + 1.0, + 0.8688, + 0.665866667 + ], + [ + 0.995066667, + 0.730933333, + 0.62 + ], + [ + 0.9948, + 0.7272, + 0.62 + ], + [ + 1.0, + 0.8784, + 0.672266667 + ], + [ + 0.991066667, + 0.674933333, + 0.62 + ], + [ + 0.994, + 0.716, + 0.62 + ], + [ + 0.9084, + 0.623733333, + 0.765066667 + ], + [ + 0.994266667, + 0.719733333, + 0.62 + ], + [ + 0.9948, + 0.7272, + 0.62 + ], + [ + 0.860133333, + 0.641466667, + 0.832133333 + ], + [ + 0.877466667, + 0.632133333, + 0.809466667 + ], + [ + 0.994, + 0.716, + 0.62 + ], + [ + 0.9012, + 0.620533333, + 0.777866667 + ], + [ + 0.887866667, + 0.626533333, + 0.795866667 + ], + [ + 0.77, + 0.69, + 0.95 + ] + ], + "blobs": [ + [ + 1.0, + 0.848, + 0.652 + ], + [ + 0.994, + 0.716, + 0.62 + ], + [ + 0.936, + 0.636, + 0.716 + ], + [ + 0.822, + 0.662, + 0.882 + ] + ] + }, + { + "id": "mint", + "name": "Mint", + "light": true, + "stops": [ + [ + 0.82, + 0.98, + 0.9 + ], + [ + 0.62, + 0.94, + 0.88 + ], + [ + 0.55, + 0.88, + 0.95 + ], + [ + 0.63, + 0.82, + 0.99 + ], + [ + 0.82, + 0.87, + 1.0 + ] + ], + "ground": [ + 0.9, + 0.98, + 0.96 + ], + "accent": [ + 0.04, + 0.42, + 0.4 + ], + "mesh": [ + [ + 0.74, + 0.964, + 0.892 + ], + [ + 0.734666667, + 0.962933333, + 0.891466667 + ], + [ + 0.585466667, + 0.9104, + 0.914533333 + ], + [ + 0.5836, + 0.9088, + 0.9164 + ], + [ + 0.750666667, + 0.966133333, + 0.893066667 + ], + [ + 0.557466667, + 0.8864, + 0.942533333 + ], + [ + 0.578, + 0.904, + 0.922 + ], + [ + 0.622533333, + 0.8256, + 0.986266667 + ], + [ + 0.579866667, + 0.9056, + 0.920133333 + ], + [ + 0.5836, + 0.9088, + 0.9164 + ], + [ + 0.688266667, + 0.835333333, + 0.993066667 + ], + [ + 0.662933333, + 0.828666667, + 0.991733333 + ], + [ + 0.578, + 0.904, + 0.922 + ], + [ + 0.628933333, + 0.8208, + 0.989466667 + ], + [ + 0.647733333, + 0.824666667, + 0.990933333 + ], + [ + 0.82, + 0.87, + 1.0 + ] + ], + "blobs": [ + [ + 0.7, + 0.956, + 0.888 + ], + [ + 0.578, + 0.904, + 0.922 + ], + [ + 0.598, + 0.844, + 0.974 + ], + [ + 0.744, + 0.85, + 0.996 + ] + ] + }, + { + "id": "opal", + "name": "Opal", + "light": true, + "stops": [ + [ + 0.98, + 0.92, + 0.96 + ], + [ + 0.87, + 0.93, + 0.99 + ], + [ + 0.91, + 0.99, + 0.95 + ], + [ + 0.99, + 0.96, + 0.88 + ], + [ + 0.94, + 0.9, + 0.99 + ] + ], + "ground": [ + 0.97, + 0.96, + 0.99 + ], + "accent": [ + 0.36, + 0.32, + 0.44 + ], + "mesh": [ + [ + 0.936, + 0.924, + 0.972 + ], + [ + 0.933066667, + 0.924266667, + 0.9728 + ], + [ + 0.889733333, + 0.9596, + 0.970266667 + ], + [ + 0.8908, + 0.9612, + 0.9692 + ], + [ + 0.941866667, + 0.923466667, + 0.9704 + ], + [ + 0.905733333, + 0.9836, + 0.954266667 + ], + [ + 0.894, + 0.966, + 0.966 + ], + [ + 0.982533333, + 0.9628, + 0.886533333 + ], + [ + 0.892933333, + 0.9644, + 0.967066667 + ], + [ + 0.8908, + 0.9612, + 0.9692 + ], + [ + 0.974666667, + 0.9416, + 0.913733333 + ], + [ + 0.981333333, + 0.9496, + 0.899066667 + ], + [ + 0.894, + 0.966, + 0.966 + ], + [ + 0.988933333, + 0.9604, + 0.880933333 + ], + [ + 0.985333333, + 0.9544, + 0.890266667 + ], + [ + 0.94, + 0.9, + 0.99 + ] + ], + "blobs": [ + [ + 0.914, + 0.926, + 0.978 + ], + [ + 0.894, + 0.966, + 0.966 + ], + [ + 0.958, + 0.972, + 0.908 + ], + [ + 0.96, + 0.924, + 0.946 + ] + ] + } + ], + "tabs": [ + { + "name": "Stream" + }, + { + "name": "Video" + }, + { + "name": "Audio" + }, + { + "name": "Controller" + }, + { + "name": "Input", + "desktop_only": true, + "$why": "Touch mode, mouse, invert-scroll and shortcuts are desktop-host settings with nothing to set on a phone or a TV, so the mobile clients ship six tabs and the desktop console seven. Modelled rather than omitted: a flat six-name list would red the desktop on day one, and a seven-name list would red both mobile clients." + }, + { + "name": "Interface" + }, + { + "name": "Profiles" + } + ], + "motion": { + "$why": "The console screen transition, from pf-console-ui's shell: TRANSITION_S and the paint geometry in shell/render.rs. Every client re-implements this by hand in its own animation system, which is why it is pinned here.", + "transition_s": 0.26, + "push_slide_dp": 36.0, + "enter_scale": 0.985, + "exit_scale": 0.96, + "reveal_alpha": 0.4, + "ease_out_cubic": { + "$why": "Sampled, not given as Bezier control points: the desktop's curve is the analytic 1-(1-t)^3, Android reproduces it exactly, and SwiftUI can only approximate it with a timing curve. A tolerance on samples is the only form all three can meet. Two different beziers are published under the name 'easeOutCubic' and neither is this curve -- that is the drift this pins.", + "tolerance": 0.02, + "samples": [ + { + "t": 0.0, + "p": 0.0 + }, + { + "t": 0.125, + "p": 0.330078125 + }, + { + "t": 0.25, + "p": 0.578125 + }, + { + "t": 0.375, + "p": 0.755859375 + }, + { + "t": 0.5, + "p": 0.875 + }, + { + "t": 0.625, + "p": 0.947265625 + }, + { + "t": 0.75, + "p": 0.984375 + }, + { + "t": 0.875, + "p": 0.998046875 + }, + { + "t": 1.0, + "p": 1.0 + } + ] + } + } +} diff --git a/crates/pf-console-ui/Cargo.toml b/crates/pf-console-ui/Cargo.toml index e3293660..bb26a3a7 100644 --- a/crates/pf-console-ui/Cargo.toml +++ b/crates/pf-console-ui/Cargo.toml @@ -34,5 +34,11 @@ sdl3 = { version = "0.18", features = ["hidapi", "ash"] } [target.'cfg(windows)'.dependencies] sdl3 = { version = "0.18", features = ["hidapi", "ash", "build-from-source"] } +# The shared console parity vectors (`clients/shared/console-vectors.json`) are read by three +# tests here — the palette table, the tab names and the transition motion. Dev-only: nothing in the +# shipping crate parses JSON. `pf-client-core` reads its own deeplink vectors the same way. +[dev-dependencies] +serde_json = "1" + [lints] workspace = true diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index 9cf376fa..0ffbdfc5 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -674,6 +674,84 @@ pub fn initials(title: &str) -> String { mod tests { use super::*; + /// The shared console parity vectors — `clients/shared/console-vectors.json`, the sibling of + /// `deeplink-vectors.json` and read the same way (`include_str!`, so a missing file is a + /// compile error rather than a skipped test). + /// + /// This table lives in THREE hand-written copies — here, `GamepadPalette.kt` and + /// `GamepadPalette.swift` — and until now nothing but prose held them together. What the file + /// pins is not only the 13 palette definitions but the DERIVED 16-cell mesh each one produces, + /// which is the half that actually reaches the screen and the half a transcription slip would + /// change invisibly. + #[test] + fn shared_console_vectors() { + let raw = include_str!("../../../clients/shared/console-vectors.json"); + let file: serde_json::Value = + serde_json::from_str(raw).expect("console-vectors.json must parse"); + let nums = |v: &serde_json::Value| -> Vec { + v.as_array() + .expect("array") + .iter() + .map(|n| n.as_f64().expect("number")) + .collect() + }; + let close = |what: &str, a: f64, b: f64| { + assert!( + (a - b).abs() < 1e-6, + "{what}: vectors say {b}, this client computes {a}" + ); + }; + + assert_eq!(nums(&file["cell_ramp"]), CELL_RAMP.to_vec(), "CELL_RAMP"); + + let interior = file["mesh_interior"].as_array().expect("mesh_interior"); + assert_eq!(interior.len(), MESH_INTERIOR.len(), "mesh interior count"); + for (w, p) in interior.iter().zip(MESH_INTERIOR.iter()) { + let w = nums(w); + let got = [p.0, p.1, p.2, p.3, p.4, p.5]; + for (i, (a, b)) in got.iter().zip(w.iter()).enumerate() { + close(&format!("mesh_interior[{i}]"), *a, *b); + } + } + + let want = file["palettes"].as_array().expect("palettes"); + assert_eq!(want.len(), PALETTES.len(), "palette count"); + for (w, p) in want.iter().zip(PALETTES.iter()) { + let id = w["id"].as_str().expect("id"); + assert_eq!(id, p.id, "palette order"); + assert_eq!(w["name"].as_str().expect("name"), p.name, "{id} name"); + assert_eq!(w["light"].as_bool().expect("light"), p.light, "{id} light"); + let g = nums(&w["ground"]); + close(&format!("{id} ground.r"), p.ground.0, g[0]); + close(&format!("{id} ground.g"), p.ground.1, g[1]); + close(&format!("{id} ground.b"), p.ground.2, g[2]); + let a = nums(&w["accent"]); + close(&format!("{id} accent.r"), p.accent.0, a[0]); + close(&format!("{id} accent.g"), p.accent.1, a[1]); + close(&format!("{id} accent.b"), p.accent.2, a[2]); + + // The derived tables — the ones that reach the shader and the fallback field. + let mesh = p.mesh_colors(); + let wm = w["mesh"].as_array().expect("mesh"); + assert_eq!(wm.len(), mesh.len(), "{id} mesh cells"); + for (i, (c, wc)) in mesh.iter().zip(wm.iter()).enumerate() { + let wc = nums(wc); + close(&format!("{id} mesh[{i}].r"), c.0, wc[0]); + close(&format!("{id} mesh[{i}].g"), c.1, wc[1]); + close(&format!("{id} mesh[{i}].b"), c.2, wc[2]); + } + let blobs = p.blob_colors(); + let wb = w["blobs"].as_array().expect("blobs"); + assert_eq!(wb.len(), blobs.len(), "{id} blob count"); + for (i, (c, wc)) in blobs.iter().zip(wb.iter()).enumerate() { + let wc = nums(wc); + close(&format!("{id} blob[{i}].r"), c.0, wc[0]); + close(&format!("{id} blob[{i}].g"), c.1, wc[1]); + close(&format!("{id} blob[{i}].b"), c.2, wc[2]); + } + } + } + /// The GTK launcher's cursor tests, ported with the math. #[test] fn step_refuses_the_ends() { diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 1a7c214e..4f495763 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -1021,6 +1021,29 @@ mod tests { use super::*; use pf_client_core::trust::Settings; + /// The section names, against the shared vectors. The comment above [`TABS`] claims a setting + /// is found under the same word on every client; this is what makes that claim checkable. + /// + /// The desktop has one tab the mobile clients do not — Input, holding touch mode, mouse, + /// invert-scroll and shortcuts, which are desktop-host settings with nothing to set on a phone + /// or a TV. The vectors model it with a `desktop_only` flag rather than omitting it, because a + /// flat six-name list would red this test on day one and a seven-name list would red both + /// mobile clients: the disagreement is real and belongs in the contract, not in prose. + #[test] + fn tab_names_match_the_shared_vectors() { + let raw = include_str!("../../../../clients/shared/console-vectors.json"); + let file: serde_json::Value = + serde_json::from_str(raw).expect("console-vectors.json must parse"); + let want: Vec<&str> = file["tabs"] + .as_array() + .expect("tabs") + .iter() + .map(|t| t["name"].as_str().expect("tab name")) + .collect(); + let got: Vec<&str> = TABS.iter().map(|(name, _)| *name).collect(); + assert_eq!(got, want, "the desktop console's tab names and order"); + } + fn ctx_parts() -> (Settings, Vec) { (Settings::default(), Vec::new()) } diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index 5d223633..5d97774c 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -4,6 +4,46 @@ use crate::screens::home::HomeScreen; use crate::screens::library::LibraryScreen; use punktfunk_core::config::GamepadPref; +/// The screen-transition contract, against the shared vectors. Every client re-implements this +/// motion in its own animation system, so the numbers exist in three places and drifted in two of +/// them before this test. +/// +/// The EASING is sampled rather than compared as control points, and that is the point of it: +/// this side is the analytic `1 − (1−t)³`, Android reproduces it exactly, and SwiftUI can only +/// approximate it with a Bézier. Worse, two different Béziers are published under the name +/// "easeOutCubic" — `(0.215, 0.61, 0.355, 1)` and `(0.33, 1, 0.68, 1)` — and neither IS this +/// curve; they differ from it by up to ~0.08 at the midpoint, which is visible on a 260 ms +/// transition. Samples with a tolerance are the only form all three runtimes can meet. +#[test] +fn motion_matches_the_shared_vectors() { + let raw = include_str!("../../../../clients/shared/console-vectors.json"); + let file: serde_json::Value = + serde_json::from_str(raw).expect("console-vectors.json must parse"); + let motion = &file["motion"]; + let want_s = motion["transition_s"].as_f64().expect("transition_s"); + assert!( + (TRANSITION_S - want_s).abs() < 1e-9, + "TRANSITION_S is {TRANSITION_S}, vectors say {want_s}" + ); + + let curve = &motion["ease_out_cubic"]; + let tol = curve["tolerance"].as_f64().expect("tolerance"); + let samples = curve["samples"].as_array().expect("samples"); + assert!( + samples.len() >= 5, + "the curve needs enough samples to pin it" + ); + for s in samples { + let t = s["t"].as_f64().expect("t"); + let want = s["p"].as_f64().expect("p"); + let got = crate::anim::ease_out_cubic(t); + assert!( + (got - want).abs() <= tol, + "ease_out_cubic({t}) is {got}, vectors say {want} (±{tol})" + ); + } +} + /// Point the settings/known-hosts stores at a throwaway HOME — the settings screen /// SAVES on adjust, and a test must never write the developer's real config. fn fake_home() {