Compare commits

..
Author SHA1 Message Date
enricobuehler 1009e14a44 build(web): silence rollup's "use client" directive warnings in the nitro pass
ci / bun-nix (pull_request) Successful in 31s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m19s
ci / rust-arm64 (pull_request) Successful in 2m52s
ci / rust (pull_request) Successful in 7m9s
The nitro server build re-bundles the whole dep tree (`noExternals: true`), so
every React package shipping a `"use client"` banner earns a MODULE_LEVEL_DIRECTIVE
warning — ~150 locally, ~800 in CI — which buries the warnings worth reading.

Ignoring the banner is correct rather than papered over: this bundle is the
Bun/Nitro server, not an RSC module graph, and TanStack Start splits client from
server with its own transform, so nothing downstream consults it.

Supplying `onwarn` replaces nitro's own handler, so its three filters
(CIRCULAR_DEPENDENCY, EVAL, "Unsupported source map comment") are restated.

Verified: `bun run build` drops from 148 such lines to 0 with no other log
delta; `tsc --noEmit` and `biome check` clean.
2026-08-11 21:00:19 +02:00
50 changed files with 1676 additions and 8284 deletions
-20
View File
@@ -184,26 +184,6 @@ 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:
-50
View File
@@ -42,56 +42,6 @@ availability probe. The `comm` fast path is still one read for every ordinary di
Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a
*wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe.
### Game Mode on Nobara — the WSI opt-out never reached the games
🛑 **v0.27.0's fix for the distro Vulkan WSI layer was clobbered by the session script, so games ran
on a black screen** while the host's own log claimed the layer had been disabled. Steam Big Picture
came up, showed the right mode, showed the perf overlay — and then every game played sound and took
input over a black picture, with no error on either side.
The layer (`VkLayer_FROG_gamescope_wsi`) ships with the *distro's* gamescope and speaks its
`gamescope_swapchain` protocol; ours disagrees, so the compositor rejects the client's
`swapchain_feedback` and kills it. v0.27.0 turned the layer off with `ENABLE_GAMESCOPE_WSI=0` on the
session unit. `gamescope-session-plus` then runs an unconditional `export ENABLE_GAMESCOPE_WSI=1`
near the top of the script — before it launches anything — so the opt-out survived exactly as long
as it took the script to start, and every process the session spawned got the layer back. Nothing
looked wrong because the casualty is Vulkan clients specifically: Steam's own UI is not one.
The opt-out is now `DISABLE_GAMESCOPE_WSI=1` as well. The Vulkan loader reads an implicit layer's
two manifest knobs in a fixed order: `enable_environment` must equal `"1"` to switch the layer on,
and `disable_environment` is then consulted last and wins on **presence alone**, at any value. The
session script never mentions that second variable, so it is the one that survives. Both spellings
go out, on the transient unit and on the box's own session drop-in.
### punktfunk-gamescope `+pfhdr6` — a NO_FOCUS window can no longer steal the composite
🛑 **A mapped-but-unpainted window carrying `GAMESCOPE_NO_FOCUS=1` could win gamescope's focus
selection and turn the composite — and the stream fed from it — black while every health signal
stayed green.** Bazzite's hhd-ui (Handheld Daemon overlay) sets that atom once at init, stamps
Steam's appid, and crash-loops under a headless takeover; each respawn remapped a fullscreen black
window that steamcompmgr then chose over Big Picture (observed on a Bazzite box: client stats
happily decoding 60 fps at 0.1 Mb/s of black; killing hhd-ui restored the picture instantly). No
gamescope — upstream or Bazzite's fork — ever consumed the atom; its setters (hhd-ui, MangoHud)
show and hide via the `STEAM_OVERLAY` protocol and rely on never being focusable. Patch 0008 wires
`GAMESCOPE_NO_FOCUS` exactly like `GAMESCOPE_EXTERNAL_OVERLAY` (read at map, PropertyNotify-tracked,
skipped by both focus-candidate collectors) without touching compositing or `appID`. Banner
`+pfhdr5``+pfhdr6`; no new capability — the bump is so a field box's banner tells the two
behaviors apart.
### Linux capture — the truncated first attempt no longer latches sticky downgrades
🛑 **The pipeline retry loop's deliberately short (2.5 s) first-frame attempt could permanently
downgrade the whole host process.** On expiry, the portal capturer's timeout diagnosis latched
whichever offer it implicated — HDR capture off (per source), the raw-dmabuf offer off, the
EGL→CUDA offer off — as if the compositor had refused it, when the budget was truncated by design
and a gamescope cold start routinely needs longer before delivering anything. One lost race at
connect then pinned every later session to SDR and/or CPU capture until the host restarted. The
truncated attempt is now declared provisional end to end
(`Capturer::next_frame_within_provisional`): its expiry names the same suspect in the error text
but latches nothing; only the full-length attempts that follow hand down negotiation verdicts. The
classification is a pure function with tests
(`pf_capture::linux::first_frame_timeout_tests`).
## v0.27.0
87 commits since v0.26.0.
Generated
-1
View File
@@ -3078,7 +3078,6 @@ dependencies = [
"pf-presenter",
"punktfunk-core",
"sdl3",
"serde_json",
"skia-safe",
"tracing",
]
-4
View File
@@ -144,10 +144,6 @@ 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")
@@ -1,350 +0,0 @@
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<DialogAction>,
/**
* 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."
}
@@ -1,6 +1,9 @@
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
@@ -11,9 +14,6 @@ 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,17 +21,14 @@ 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
@@ -40,10 +37,8 @@ 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
@@ -57,8 +52,6 @@ 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) {
@@ -218,9 +211,8 @@ 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). Cutout included:
// a tablet in landscape puts its punch on exactly this pane's leading edge.
Box(Modifier.weight(1f).fillMaxHeight().consoleSafeArea()) { tabContent(true) }
// don't, since they used to rely on the Scaffold's padding).
Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) }
}
} else {
Scaffold(
@@ -253,21 +245,8 @@ fun App(forceGamepadUi: Boolean = false) {
*/
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
/**
* 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),
}
/** Which console screen the gamepad shell is showing. */
private enum class GamepadScreen { Home, Settings, Library }
/**
* The console (gamepad) shell the Android mirror of the Apple client's ContentView gamepad branch:
@@ -291,12 +270,6 @@ fun GamepadShell(
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(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<GpSettingsPlace?>(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.
@@ -324,50 +297,12 @@ 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)) {
// 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 ->
// 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 ->
when (s) {
GamepadScreen.Home -> ConnectScreen(
settings = settings,
@@ -383,23 +318,7 @@ fun GamepadShell(
GamepadScreen.Settings -> GamepadSettingsScreen(
initial = settings,
onChange = onSettingsChange,
// 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 },
onBack = { screen = GamepadScreen.Home },
navActive = s == screen,
)
GamepadScreen.Library -> libraryHost?.let { host ->
@@ -418,128 +337,3 @@ 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
@@ -123,6 +123,130 @@ 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
@@ -194,6 +318,41 @@ 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).
@@ -308,3 +467,103 @@ 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."
}
@@ -1,339 +0,0 @@
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<KnownHost>,
/** Every live advert — the OS mark prefers it over the stored one, and "searching…" reads it. */
discovered: List<DiscoveredHost>,
/** Adverts with no saved record behind them, de-duped by the caller (it needs them too). */
discoveredUnsaved: List<DiscoveredHost>,
/** Saved hosts answering the QUIC probe, "address:port" — the routed half of "online". */
reachable: Set<String>,
profiles: List<StreamProfile>,
pinsFor: (KnownHost) -> List<StreamProfile>,
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<HostMenuItem> = 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),
)
}
}
@@ -1,6 +1,5 @@
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
@@ -30,8 +29,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
@@ -205,10 +204,7 @@ internal fun ConnectTakeover(
) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(
// 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),
Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
@@ -243,19 +239,7 @@ internal fun ConnectTakeover(
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
}
// 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)
}
GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp))
}
}
@@ -1,195 +0,0 @@
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<StreamProfile>,
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<String>,
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,
)
}
@@ -11,6 +11,31 @@ 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
@@ -20,11 +45,19 @@ 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
@@ -40,6 +73,7 @@ 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
@@ -74,20 +108,6 @@ 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,
@@ -631,6 +651,52 @@ 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<HostMenuItem> = 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
@@ -716,61 +782,77 @@ 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 = 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 },
),
tiles = tiles,
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 &&
speedTest == null && waker.waking == null && !lnpPrompt,
waker.waking == null && !lnpPrompt,
onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenSettings = onOpenSettings,
@@ -781,35 +863,239 @@ fun ConnectScreen(
},
)
} else {
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 },
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),
)
}
}
// 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
@@ -837,81 +1123,148 @@ fun ConnectScreen(
}
}
// 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 ->
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 ->
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)
},
onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) },
awaitingHostName = awaiting?.target?.name,
onCancelApproval = {
awaiting?.cancelled?.set(true)
}
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)
awaiting = null
connecting = false
discovery.start() // the request may still be pending on the host; keep scanning
},
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 ->
}
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 ->
val done = speedTestPhase as? SpeedTestPhase.Done
if (done != null && speedTestTarget != null) {
if (done != null) {
val where = applySpeedTestResult(
done.recommendedKbps, speedTestTarget, toProfile, profileStore, settings,
onSettingsChange,
done.recommendedKbps, target, toProfile, profileStore, settings, onSettingsChange,
)
profiles = profileStore.all()
notice = "%.0f Mbit/s set in %s".format(done.recommendedMbps, where)
}
speedTest = null
},
onDismissSpeedTest = { speedTest = null },
editTarget = editTarget,
editSuggestedMacs = editSuggestedMacs,
onSaveHost = { updated ->
}
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 ->
knownHostStore.save(updated)
savedHosts = knownHostStore.all()
editTarget = null
},
onDismissEdit = { editTarget = null },
lnpPrompt = lnpPrompt,
onAllowLocalNetwork = {
}
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 = {
lnpPrompt = false
localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
},
onOpenSystemSettings = {
}
val onSettings = {
lnpPrompt = false
context.startActivity(
Intent(
@@ -919,10 +1272,21 @@ fun ConnectScreen(
Uri.fromParts("package", context.packageName, null),
),
)
},
onDismissLnpPrompt = { lnpPrompt = false },
}
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(
connectingHostName = attempt?.hostName,
waker = waker,
gamepadUi = gamepadUi,
onCancelConnect = { cancelConnect() },
)
}
@@ -931,11 +1295,8 @@ 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.
*/
internal data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}"
}
@@ -944,7 +1305,7 @@ internal 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.
*/
internal fun hasNearbyPermission(context: Context): Boolean =
fun hasNearbyPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED
@@ -956,7 +1317,7 @@ internal 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.
*/
internal fun hasLocalNetworkPermission(context: Context): Boolean =
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
@@ -966,7 +1327,7 @@ internal 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".
*/
internal fun KnownHost.matches(dh: DiscoveredHost): Boolean {
private 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
@@ -976,9 +1337,6 @@ internal 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.
*/
internal fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
private fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
discovered.any { matches(it) } || reachable.contains("$address:$port")
@@ -1,7 +1,6 @@
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
@@ -12,15 +11,12 @@ 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
@@ -43,15 +39,11 @@ 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
@@ -63,145 +55,10 @@ 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
@@ -227,31 +84,17 @@ private fun ControllersBody(
// 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.
// 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) }
val held = remember { mutableStateMapOf<Int, Boolean>() }
val axes = remember { mutableStateMapOf<String, Float>() }
var lastInput by remember { mutableStateOf<String?>(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(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 ->
DisposableEffect(Unit) {
activity?.padKeyProbe = 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
@@ -259,34 +102,13 @@ private fun ControllersBody(
}
KeyEvent.ACTION_UP -> {
held[event.keyCode] = 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()
}
}
}
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = false
}
}
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
consume
testing
}
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
activity?.padMotionProbe = probe@{ event ->
if (!Gamepad.isPad(event.device)) return@probe false
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
@@ -302,43 +124,31 @@ private fun ControllersBody(
)
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
consuming
}
if (observeInput) {
activity?.padKeyProbe = keyProbe
activity?.padMotionProbe = motionProbe
testing
}
onDispose {
activity?.let { a ->
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
}
activity?.padKeyProbe = null
activity?.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). 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) {
// test instead (touch still works). A short tap cancels the effect before the delay fires.
LaunchedEffect(bHeld) {
if (bHeld && testing) {
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
delay(1_200)
testing = false
held.clear()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(contentPadding),
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
heading()
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
// 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
@@ -402,19 +212,13 @@ private fun ControllersBody(
Column(Modifier.weight(1f)) {
Text("Test inputs", style = MaterialTheme.typography.bodyLarge)
Text(
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"
},
if (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 = { on -> onTestingChange(on); if (!on) held.clear() },
)
Switch(checked = testing, onCheckedChange = { testing = it; if (!it) held.clear() })
}
if (testing) {
ButtonGrid(held)
@@ -876,11 +680,3 @@ 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
@@ -3,7 +3,6 @@ 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
@@ -19,9 +18,10 @@ 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.width
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
@@ -31,12 +31,6 @@ 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
@@ -53,6 +47,7 @@ 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
@@ -74,20 +69,6 @@ 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,
@@ -97,11 +78,6 @@ fun GamepadAddHostScreen(
editHost: KnownHost? = null,
suggestedMacs: List<String> = 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<StreamProfile> = emptyList(),
) {
val ink = LocalGamepadInk.current
val context = LocalContext.current
@@ -113,15 +89,6 @@ 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) {
@@ -131,8 +98,6 @@ fun GamepadAddHostScreen(
address = address.trim(),
port = port.toIntOrNull() ?: editHost.port,
mac = KnownHostStore.parseMacs(mac),
clipboardSync = clipboard,
profileId = boundId,
),
)
} else {
@@ -170,43 +135,7 @@ fun GamepadAddHostScreen(
add(Field("port", "Port", port, "9777"))
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
}
// 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<StreamProfile?>(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
val actionIndex = fields.size // the Save/Add action sits just after the last field
fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 }
fun closeKeyboard() { editing = null }
@@ -223,13 +152,11 @@ 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() {
when {
focus == actionIndex -> if (canAdd) commit() else { focus = 1; openKeyboard("address") }
focus < fields.size -> openKeyboard(fields[focus].id)
else -> focusedExtra()?.activate()
if (focus == actionIndex) {
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
} else {
openKeyboard(fields[focus].id)
}
}
fun pressKey() {
@@ -252,11 +179,7 @@ fun GamepadAddHostScreen(
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < actionIndex) focus++
// 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 -> {}
}
} else {
when (dir) {
@@ -291,17 +214,15 @@ 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().consoleSafeArea().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Column(
Modifier.weight(1f).fillMaxHeight().widthIn(max = 620.dp)
.verticalScroll(rememberScrollState()),
Modifier.weight(1f).fillMaxHeight().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
}
@@ -315,11 +236,9 @@ 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().consoleSafeArea().padding(horizontal = ConsoleEdgeInset)) {
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
Column(
// 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()),
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ConsoleHeader(title, horizontalInset = false)
@@ -332,11 +251,6 @@ 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
}
@@ -354,7 +268,7 @@ fun GamepadAddHostScreen(
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
Box(
Modifier.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
@@ -401,7 +315,7 @@ private fun TvAddHostForm(
Column(
Modifier
.fillMaxSize()
.consoleSafeArea()
.systemBarsPadding()
.padding(horizontal = 56.dp, vertical = 36.dp)
.widthIn(max = 720.dp)
.verticalScroll(rememberScrollState()),
@@ -452,18 +366,14 @@ 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)
// 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",
)
val shape = RoundedCornerShape(14.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.consoleGlass(ConsoleShape.Row, visuals)
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -477,61 +387,7 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
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,
)
}
if (editing) Text(" |", color = ink.accent)
}
}
@@ -539,15 +395,19 @@ private fun ExtraRowView(row: ExtraRow, focused: Boolean, onClick: () -> Unit) {
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),
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
tween(160),
label = "addLabel",
)
Box(
modifier = Modifier
.fillMaxWidth()
.consoleGlass(ConsoleShape.Row, visuals)
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
@@ -570,16 +430,14 @@ private fun KeyboardGrid(
onKey: (Int, Int) -> Unit,
) {
val ink = LocalGamepadInk.current
val shape = ConsoleShape.Keyboard
val shape = RoundedCornerShape(20.dp)
val gap = if (compact) 5.dp else 7.dp
Column(
Modifier
.fillMaxWidth()
.widthIn(max = 640.dp)
.clip(shape)
// 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)))
.background(Color(0x1FFFFFFF))
.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),
@@ -609,13 +467,11 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
tween(90),
label = "keyBg",
)
// `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")
val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
.clip(ConsoleShape.Keycap)
.clip(RoundedCornerShape(9.dp))
.background(bg)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
@@ -1,348 +0,0 @@
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 ~90130 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<String, RuntimeShader?>()
@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<Triple<Double, Double, Double>>): 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 2832 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 2832 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),
),
)
}
}
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ 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
@@ -17,6 +18,7 @@ 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
@@ -43,6 +45,7 @@ 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
@@ -104,13 +107,18 @@ 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
ConsoleModal {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.consoleCard()
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
@@ -142,11 +150,7 @@ 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 focus by animateFloatAsState(
if (focused) 1f else 0f,
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "btnFocus",
)
val shape = RoundedCornerShape(14.dp)
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
val bg by animateColorAsState(
when {
@@ -154,30 +158,32 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
primary -> ink.accent(0.20f)
else -> ink.glass
},
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
tween(160),
label = "btnBg",
)
val fg by animateColorAsState(
when {
!enabled -> ink.fg(0.35f)
// On the accent, not on the field — a pale palette's accent decides this, not the ink.
focused -> ink.onAccent
focused -> ink.fg
primary -> ink.accent
else -> ink.fg(0.85f)
},
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
tween(160),
label = "btnFg",
)
val borderColor by animateColorAsState(
ink.fg(if (focused) 0.3f else 0.08f),
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
tween(160),
label = "btnBorder",
)
Box(
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.consoleGlass(ConsoleShape.Row, ConsoleFocusVisuals(scale, bg, borderColor, focus))
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(bg)
.border(1.dp, borderColor, shape)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
@@ -299,13 +305,18 @@ fun GamepadPinHostsDialog(
},
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
ConsoleModal {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.consoleCard()
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
@@ -357,11 +368,15 @@ 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)
.consoleGlass(ConsoleShape.Row, visuals)
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -383,6 +398,157 @@ 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
* 09), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
@@ -432,10 +598,11 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
ConsoleModal {
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) {
Column(
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
.consoleCard()
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.verticalScroll(rememberScrollState())
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -449,7 +616,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 = ink.danger, style = MaterialTheme.typography.bodyMedium) }
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
DialogButton(
label = if (pairing) "Pairing…" else "Pair",
focused = slot == 4 && !pairing,
@@ -471,12 +638,6 @@ 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)
}
}
@@ -1,10 +1,8 @@
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
@@ -19,11 +17,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
@@ -35,7 +33,6 @@ 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
@@ -47,7 +44,6 @@ 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
@@ -59,7 +55,6 @@ 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,
@@ -67,12 +62,6 @@ 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,
@@ -90,15 +79,6 @@ 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
@@ -138,16 +118,6 @@ 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 ->
@@ -157,8 +127,7 @@ fun GamepadHome(
scope.launch { pagerState.animateScrollToPage(target) }
}
},
// A / D-pad-center → Connect
onActivate = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) },
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
onSecondary = { // Y (gamepad) → Library
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
},
@@ -176,9 +145,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 = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) }
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
val optionsAction: () -> Unit = { current?.let(onOptions) }
val arrowTint = PadGlyph.Arrow
val arrowTint = Color(0xFF9A93C7)
val hints = buildList {
if (padIsGamepad) {
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
@@ -208,12 +177,7 @@ fun GamepadHome(
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
// 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()) {
Box(Modifier.fillMaxSize().systemBarsPadding()) {
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(cardWidth),
@@ -225,35 +189,17 @@ 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.
// 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)
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
.absoluteValue.coerceIn(0f, 1f)
GamepadHostTile(
tile = tile,
centred = offset < 0.5f,
modifier = Modifier
.graphicsLayer {
// 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)
val s = lerp(1f, 0.86f, 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)
@@ -263,7 +209,6 @@ fun GamepadHome(
indication = null,
) {
if (page == navTarget) {
pressToken++
onActivate(tile)
} else {
navTarget = page
@@ -278,28 +223,20 @@ 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().consoleSafeArea()
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
.padding(end = ConsoleEdgeInset),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
// 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))
}
ConsoleHeader("Select a Host", modifier = Modifier.weight(1f))
if (controllerName != null) ControllerStatusChip(controllerName)
}
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
// 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.
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
Box(
Modifier
.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
GamepadHintBar(hints, hazeState = hazeState)
@@ -307,31 +244,22 @@ fun GamepadHome(
}
}
/**
* 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.
*/
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
@Composable
private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier = Modifier) {
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
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
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)))
}
Column(
modifier = modifier
.fillMaxWidth()
// 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,
)
.clip(shape)
.background(wash)
.border(1.dp, ink.fg(0.16f), shape)
.padding(22.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
@@ -363,64 +291,12 @@ private fun GamepadHostTile(tile: HomeTile, centred: Boolean, 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,
)
}
}
@@ -429,13 +305,10 @@ private fun ConsoleProfileChip(
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.copy(alpha = 0.92f), ink.accent))
Brush.verticalGradient(listOf(ink.accent, ink.accent))
} else {
Brush.verticalGradient(listOf(ink.accent(0.20f), ink.accent(0.14f)))
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
}
Box(
modifier = Modifier.size(52.dp).clip(shape).background(fill),
@@ -32,31 +32,6 @@ 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)
@@ -75,7 +50,6 @@ 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,
@@ -85,20 +59,9 @@ 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()),
@@ -110,15 +73,6 @@ 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),
)
}
@@ -65,10 +65,6 @@ 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
@@ -102,10 +98,7 @@ 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) { haptics.confirm(); currentOnActivate() }
true
}
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) 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 }
@@ -146,10 +139,8 @@ fun GamepadNavEffect(
}
when {
dir == 0 -> committed = 0
dir != committed -> {
haptics.tick(); currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS
}
now >= fireAt -> { haptics.tick(); currentOnMove(dir); fireAt = now + REPEAT_MS }
dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS }
now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS }
}
delay(16)
}
@@ -176,9 +167,6 @@ 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)
@@ -208,15 +196,12 @@ 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) { haptics.confirm(); currentOnActivate() }
true
}
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) { haptics.tick(); currentOnShoulder(-1) }; true }
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) { haptics.tick(); currentOnShoulder(1) }; true }
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true }
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true }
else -> false // B → MainActivity (remapped to BACK → BackHandler)
}
}
@@ -244,10 +229,8 @@ fun GamepadNavEffect2D(
when {
raw == null && nearCentre -> committed = null
raw == null -> { /* in the hysteresis band → hold, don't fire */ }
raw != committed -> {
haptics.tick(); currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS
}
now >= fireAt -> { haptics.tick(); currentOnDirection(raw); fireAt = now + REPEAT_MS }
raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS }
now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS }
}
delay(16)
}
@@ -17,21 +17,6 @@ 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]). */
@@ -62,29 +47,11 @@ 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<Triple<Double, Double, Double>> 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. 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`.
* 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.
*/
val CELL_RAMP = listOf(
0.10, -0.06, 0.04, -0.12,
@@ -93,34 +60,6 @@ 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
* ~90130 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),
@@ -3,19 +3,20 @@ 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
@@ -24,28 +25,15 @@ 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.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.systemBarsPadding
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.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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -58,23 +46,13 @@ 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
@@ -125,19 +103,6 @@ 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<String>? = null,
val selectedIndex: Int = -1,
)
/**
@@ -149,28 +114,12 @@ internal class GpRow(
internal fun liveRow(rows: List<GpRow>, 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) }
@@ -213,46 +162,24 @@ 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) }
// 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 }
val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) +
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
// Which section is showing, and where each one's focus was when it was last left — a detour
// into another tab shouldn't lose your place.
var tab by remember { mutableStateOf(resume?.tab ?: GpTab.STREAM) }
var tab by remember { mutableStateOf(GpTab.STREAM) }
// True while the STRIP holds the cursor rather than the list. Up from the first row moves
// here and Down goes back — the only route to the sections on a D-pad remote, which has no
// shoulder buttons at all (and is exactly what a TV box ships with).
var tabFocused by remember { mutableStateOf(false) }
val tabFocus = remember { mutableStateMapOf<GpTab, Int>() }
val rows = allRows.filter { it.tab == tab }
// 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))
}
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
// Which way the section last moved (+1 forward / -1 back) — the row list slides in from that
// side, so stepping sections reads as travelling along a strip rather than teleporting.
var tabDir by remember { mutableIntStateOf(1) }
// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
fun selectTab(next: GpTab) {
if (next == tab) return
tabFocus[tab] = focus
tabDir = if (next.ordinal > tab.ordinal) 1 else -1
tab = next
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
// outlive the row it pointed at.
@@ -261,45 +188,15 @@ fun GamepadSettingsScreen(
}
fun stepTab(delta: Int) {
val all = GpTab.entries
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
selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size])
}
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
// value text slides in its AnimatedContent, so the motion matches the button press.
var adjustDir by remember { mutableIntStateOf(1) }
// 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 listState = rememberLazyListState()
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
@@ -312,233 +209,89 @@ 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 step(-1)
NavDir.RIGHT -> if (tabFocused) stepTab(1) else step(1)
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) }
}
},
// A on the strip drops into the section you picked, which is what "confirm" means there.
onActivate = {
if (tabFocused) tabFocused = false else liveRow(rows, focus)?.let { activate(it) }
if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() }
},
// The shoulders work from either place — a real pad never has to visit the strip.
onShoulder = { delta -> stepTab(delta) },
)
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
/**
* 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)
}
}
// 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)
}
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
}
val hazeState = remember { HazeState() }
Box(Modifier.fillMaxSize()) {
// Everything scrolls — including the heading — so nothing is pinned. Vital in landscape,
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
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)
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(
row,
focused = rowFocused,
focused = index == focus && !tabFocused,
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) activate(row)
else if (row.enabled) { adjustDir = 1; row.activate() }
},
)
}
}
}
/** 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())
}
}
}
}
// 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).
// 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.
Box(
Modifier
.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
// The legend follows the focused row (the desktop console's hints() does the same):
@@ -553,49 +306,31 @@ fun GamepadSettingsScreen(
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
val sections = listOfNotNull(
GamepadHint('⇄', PadGlyph.Arrow, "Section", onClick = { stepTab(1) })
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
.takeIf { padIsGamepad },
)
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,
)
}
GamepadHintBar(
if (tabFocused) listOf(
GamepadHint('↔', PadGlyph.Arrow, "Section"),
PadGlyph.hint('A', "Open") { tabFocused = false },
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),
) 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,
)
}
)
focused != null && !focused.adjustable -> listOf(
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
else -> listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
},
hazeState = hazeState,
)
}
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
@@ -612,64 +347,24 @@ 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,
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,
) {
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
val chevronAlpha by animateFloatAsState(
if (focused && row.adjustable) 0.6f else 0f,
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
tween(160),
label = "chevrons",
)
val valueColor by animateColorAsState(
ink.fg(if (focused) 1f else 0.6f),
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
tween(160),
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(
@@ -680,127 +375,74 @@ private fun SettingRowView(
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
)
}
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.consoleGlass(ConsoleShape.Row, visuals)
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.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,
) {
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,
) {
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))
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 {
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,
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,
)
} 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,
)
}
}
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) },
)
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
}
}
// 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),
)
}
}
}
}
@@ -808,17 +450,12 @@ private fun SettingRowView(
/** 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`). [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. */
* `codecOptionsFor`). 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<GpRow> {
fun <T> choice(
@@ -843,8 +480,6 @@ 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(
@@ -998,28 +633,6 @@ 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
@@ -1034,7 +647,7 @@ internal fun buildSettingsRows(
choice(
"hud", GpTab.INTERFACE, null, "Statistics overlay",
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
"Select + X on a pad, or a 3-finger tap, cycles the tiers live.",
"A 3-finger tap cycles the tiers live.",
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
) { update(s.copy(statsVerbosity = it)) },
toggle(
@@ -1067,22 +680,6 @@ 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,
),
)
}
@@ -1097,7 +694,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).
*/
internal fun buildProfileRows(
private fun buildProfileRows(
profiles: List<StreamProfile>,
savedHosts: List<KnownHost>,
tv: Boolean,
@@ -1144,7 +741,6 @@ internal fun buildProfileRows(
adjust = { false },
activate = { openPinPicker(p) },
adjustable = false,
actionHint = "Pin to hosts",
)
}
}
@@ -1,100 +0,0 @@
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<KnownHost>,
/** The live catalog — resolves each host's binding into the name and colour its chip wears. */
profiles: List<StreamProfile>,
pinsFor: (KnownHost) -> List<StreamProfile>,
/** Already de-duped against [savedHosts] by the caller: a saved host is not also "discovered". */
discoveredUnsaved: List<DiscoveredHost>,
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<HomeTile> = 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,
),
)
}
@@ -15,10 +15,12 @@ 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
@@ -41,11 +43,6 @@ 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
@@ -132,7 +129,7 @@ fun LibraryScreen(
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().consoleSafeArea()) {
Column(Modifier.fillMaxSize().systemBarsPadding()) {
ConsoleHeader("${host.name} — Library")
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
when (val s = state) {
@@ -176,7 +173,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(ink.modalScrim),
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center,
) {
Column(
@@ -192,7 +189,7 @@ fun LibraryScreen(
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
Box(
Modifier.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
@@ -267,18 +264,10 @@ private fun Coverflow(
Text(
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
style = MaterialTheme.typography.labelSmall,
// 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),
color = Color.White.copy(alpha = 0.45f),
letterSpacing = 2.sp,
textAlign = TextAlign.Center,
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),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
)
}
HorizontalPager(
@@ -300,22 +289,10 @@ private fun Coverflow(
.width(coverWidth)
.height(coverHeight)
// Touch: tap the centred cover to launch it; tap a neighbour to bring it centre.
// 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"
},
) {
.clickable {
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 —
@@ -374,14 +351,11 @@ 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 = ConsoleShape.Poster
val shape = RoundedCornerShape(16.dp)
Box(
modifier = modifier
.clip(shape)
// 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)
.background(Color(0xFF241F3D))
.border(1.dp, ink.fg(0.12f), shape),
contentAlignment = Alignment.Center,
) {
@@ -423,25 +397,11 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
Text(
game.storeLabel,
style = MaterialTheme.typography.labelSmall,
// 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,
color = ink.fg,
modifier = Modifier
// 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)
.clip(RoundedCornerShape(50))
.background(
// 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
if (game.isLauncher) MaterialTheme.colorScheme.primary
else Color.Black.copy(alpha = 0.5f),
)
.padding(horizontal = 8.dp, vertical = 3.dp),
@@ -1,12 +1,8 @@
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
@@ -23,130 +19,20 @@ 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) {
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
BackHandler(onBack = onBack)
val notices = remember {
runCatching {
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
@@ -166,40 +52,52 @@ private fun LicensesBody(
}.getOrNull()
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
heading()
if (version != null) {
Text(
"Punktfunk $version",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
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)
}
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)
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,
)
}
Text(
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
"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(
fontLicense,
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(
fontLicense,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
}
}
}
}
@@ -24,7 +24,6 @@ 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
@@ -143,16 +142,6 @@ 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.
@@ -617,10 +606,7 @@ 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)
lastPadDeviceId = event.deviceId
}
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
}
// The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true }
@@ -709,7 +695,6 @@ 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
@@ -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, or Select + X on a pad, cycles the tiers in-stream.",
"A 3-finger tap cycles the tiers in-stream.",
) { v -> update(s.copy(statsVerbosity = v)) }
}
DeviceScopeOnly {
@@ -28,9 +28,6 @@ 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
@@ -56,7 +53,6 @@ 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
@@ -156,35 +152,6 @@ 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
@@ -194,8 +161,7 @@ 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 — or the Select + X
// pad chord, which is the only route a TV or a passthrough-touch session has — cycles the
// the native window, so re-showing never renders stale data. A 3-finger tap 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
@@ -218,11 +184,6 @@ 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) {
@@ -401,9 +362,6 @@ 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
@@ -426,11 +384,6 @@ 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
@@ -552,12 +505,7 @@ 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)
// 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
}
router.onSlotOpened = { deviceId -> padSensors?.onSlotOpened(deviceId) }
// 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
@@ -697,7 +645,6 @@ 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
@@ -900,42 +847,6 @@ 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.
@@ -1142,33 +1053,6 @@ 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:
@@ -1,107 +0,0 @@
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<ComponentActivity>()
@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()
}
}
@@ -1,117 +0,0 @@
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<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
hasGyroscope = true,
av1Capable = true,
appVersion = version,
openControllers = controllers,
openLicenses = licenses,
) {}
private fun row(rows: List<GpRow>, 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)
}
}
@@ -1,168 +0,0 @@
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<Double> =
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 2832 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 (1t)³` 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,
)
}
}
}
@@ -1,111 +0,0 @@
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<ComponentActivity>()
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()
}
}
@@ -1,210 +0,0 @@
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<String> = 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<KnownHost> = emptyList(),
profiles: List<StreamProfile> = emptyList(),
pins: Map<String, List<StreamProfile>> = emptyMap(),
discoveredUnsaved: List<DiscoveredHost> = emptyList(),
online: Set<String> = 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<Pair<String, String?>>()
val discovered = mutableListOf<String>()
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)
}
}
@@ -83,16 +83,6 @@ 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") {
@@ -124,59 +114,6 @@ 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()
@@ -32,13 +32,9 @@ 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
@@ -47,11 +43,10 @@ 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.SpeedTestPrompt
import io.unom.punktfunk.SpeedTestDialog
import io.unom.punktfunk.SpeedTestPhase
import io.unom.punktfunk.SpeedTestTarget
import io.unom.punktfunk.components.HostCard
@@ -247,8 +242,7 @@ internal fun SettingsProfileScene() {
*/
@Composable
internal fun SpeedTestScene() {
SpeedTestPrompt(
gamepadUi = false,
SpeedTestDialog(
hostName = "Living Room PC",
target = SpeedTestTarget.Ask(newProfile("Game")),
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),
@@ -433,111 +427,6 @@ 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
@@ -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, 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
* 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
* 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,20 +149,6 @@ 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
@@ -218,7 +204,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 instant chords ([MIC_CHORD], [STATS_CHORD]).
* the mic-mute chord ([MIC_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
@@ -246,11 +232,13 @@ 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 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()
// 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()
}
} else {
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
if (!owned && send && forwarding) {
@@ -640,11 +628,7 @@ class GamepadRouter(
return null
}
// `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 {
private companion object {
/** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */
const val MAX_PADS = 16
@@ -666,28 +650,6 @@ 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
@@ -1,183 +0,0 @@
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<Int> {
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<Int> = 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<Int>(), pad.press(Gamepad.BTN_BACK))
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
assertEquals("auto-repeat re-fired the chord", emptyList<Int>(), pad.repeat(Gamepad.BTN_X))
assertEquals("a press on top re-fired the chord", emptyList<Int>(), pad.press(Gamepad.BTN_A))
assertEquals(emptyList<Int>(), 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<Int>(), pad.press(opening))
for (other in listOf(Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_LB, Gamepad.BTN_DPAD_UP)) {
assertEquals(emptyList<Int>(), 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<Int>(), 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 <T> List<T>.permutations(): List<List<T>> =
if (size <= 1) listOf(this)
else flatMap { head -> (this - head).permutations().map { listOf(head) + it } }
}
}
@@ -1,127 +0,0 @@
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)")
}
}
}
File diff suppressed because it is too large Load Diff
-15
View File
@@ -43,21 +43,6 @@ pub trait Capturer: Send {
self.next_frame()
}
/// [`next_frame_within`](Self::next_frame_within), but the caller declares the budget
/// PROVISIONAL: its expiry is the retry schedule firing (the deliberately truncated first
/// attempt), not a verdict on anything this capture offered. The portal backend must NOT
/// latch its sticky process-wide downgrades (HDR capture, either dmabuf-only offer) from a
/// provisional expiry — a gamescope cold start routinely outlives the short window while it
/// would have accepted every offer, and one latched race used to pin the whole host process
/// to SDR/CPU capture. The full-length attempt that follows delivers the honest verdict.
/// Backends that latch nothing from a timeout just delegate.
fn next_frame_within_provisional(
&mut self,
budget: std::time::Duration,
) -> Result<CapturedFrame> {
self.next_frame_within(budget)
}
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
/// just produces a frame each call — fine for instant synthetic sources; the portal
+64 -252
View File
@@ -533,7 +533,7 @@ fn spawn_pipewire(
impl Capturer for PortalCapturer {
fn next_frame(&mut self) -> Result<CapturedFrame> {
self.frame_within(Duration::from_secs(10), TimeoutVerdict::Conclusive)
self.frame_within(Duration::from_secs(10))
}
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
@@ -563,13 +563,7 @@ impl Capturer for PortalCapturer {
}
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
self.frame_within(budget, TimeoutVerdict::Conclusive)
}
fn next_frame_within_provisional(&mut self, budget: Duration) -> Result<CapturedFrame> {
// The retry loop's truncated first attempt: its expiry re-runs the schedule, it does not
// convict an offer — see `TimeoutVerdict` and the latch arms in `next_frame_timed_out`.
self.frame_within(budget, TimeoutVerdict::Provisional)
self.frame_within(budget)
}
fn supports_arrival_wait(&self) -> bool {
@@ -705,73 +699,12 @@ impl Capturer for PortalCapturer {
}
}
/// Whether an expired first-frame budget is allowed to CONVICT an offer. The retry loop's
/// deliberately truncated first attempt passes `Provisional`: its expiry means the schedule
/// moved on, not that the compositor refused anything — a gamescope cold start regularly needs
/// longer than that window to accept every offer it would have accepted. Latching from it pinned
/// the whole host process to SDR + CPU capture off a race the attempt lost by design; only a
/// full-length wait carries a verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimeoutVerdict {
Conclusive,
Provisional,
}
/// Which offer a first-frame timeout implicates — the diagnosis behind
/// [`PortalCapturer::next_frame_timed_out`], split out pure so the latch policy is testable.
/// Mirrors the negotiation state exactly: a negotiated format clears every offer (the compositor
/// accepted, it just produced nothing), and a forced `PUNKTFUNK_ZEROCOPY=1` keeps both dmabuf
/// arms erroring loudly instead of implicating them (the operator asked for exactly that path).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimeoutOffer {
/// Format negotiated; no offer implicated — the compositor produced no buffers.
NoBuffers,
/// The 10-bit PQ/BT.2020 (HDR) dmabuf offer was never accepted.
Hdr,
/// The dmabuf-only raw-passthrough offer was never accepted.
RawDmabuf,
/// The dmabuf-only EGL→CUDA offer was never accepted.
GpuDmabuf,
/// Nothing negotiated and no offer implicated — format/modifier mismatch.
NoFormat,
}
fn classify_first_frame_timeout(
negotiated: bool,
hdr_offer: bool,
vaapi_dmabuf: bool,
gpu_dmabuf_offer: bool,
zerocopy_forced: bool,
) -> TimeoutOffer {
if negotiated {
TimeoutOffer::NoBuffers
} else if hdr_offer {
TimeoutOffer::Hdr
} else if vaapi_dmabuf && !zerocopy_forced {
TimeoutOffer::RawDmabuf
} else if gpu_dmabuf_offer && !zerocopy_forced {
TimeoutOffer::GpuDmabuf
} else {
TimeoutOffer::NoFormat
}
}
/// The latch policy: only a conclusive expiry of an offer-implicating timeout fires the offer's
/// sticky process-wide downgrade.
fn timeout_convicts(offer: TimeoutOffer, verdict: TimeoutVerdict) -> bool {
verdict == TimeoutVerdict::Conclusive
&& matches!(
offer,
TimeoutOffer::Hdr | TimeoutOffer::RawDmabuf | TimeoutOffer::GpuDmabuf
)
}
impl PortalCapturer {
/// The blocking first-frame wait behind [`Capturer::next_frame`] /
/// [`Capturer::next_frame_within`]. First frame can lag behind format negotiation; later
/// frames arrive at ~fps. Wait in short slices so a GPU-import poison (worker death) fails
/// the capture within ~0.5 s instead of sitting out the full first-frame budget.
fn frame_within(&mut self, budget: Duration, verdict: TimeoutVerdict) -> Result<CapturedFrame> {
fn frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
let deadline = std::time::Instant::now() + budget;
loop {
if self.signals.broken.load(Ordering::Relaxed) {
@@ -797,7 +730,7 @@ impl PortalCapturer {
if let Some(f) = self.take_frame() {
return Ok(f);
}
return self.next_frame_timed_out(e, budget, verdict);
return self.next_frame_timed_out(e, budget);
}
}
}
@@ -819,118 +752,83 @@ impl PortalCapturer {
}
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
/// into the diagnosis-bearing error, and fire the offer's sticky downgrade latch when — and
/// only when — the expiry convicts the offer (see [`timeout_convicts`]).
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
fn next_frame_timed_out(
&self,
err: RecvTimeoutError,
budget: Duration,
verdict: TimeoutVerdict,
) -> Result<CapturedFrame> {
let within = budget.as_secs_f32();
match err {
RecvTimeoutError::Timeout => {
let offer = classify_first_frame_timeout(
self.signals.negotiated.load(Ordering::Relaxed),
self.hdr_offer,
self.vaapi_dmabuf,
self.signals.gpu_dmabuf_offer.load(Ordering::Relaxed),
pf_zerocopy::zerocopy_forced(),
);
let convicted = timeout_convicts(offer, verdict);
// A provisional expiry names the same suspect but hands down no sentence — the
// full-length retry that follows is the one whose timeout latches.
let sentence = if convicted {
"" // each arm below states its own downgrade
} else {
" (short first-attempt window — nothing is latched; the full-length retry \
decides)"
};
match offer {
TimeoutOffer::NoBuffers => Err(anyhow!(
// Split the two black-screen root causes apart so the operator gets a cause, not
// just a symptom: did the format negotiate (compositor produced no buffers) or
// not (no acceptable format / node never emitted a param)?
if self.signals.negotiated.load(Ordering::Relaxed) {
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): format negotiated but no \
buffers arrived the compositor produced no frames (virtual output \
idle/unmapped, capture never started, or a stream bound during a \
compositor (re)start that will never deliver a reconnect fixes that)",
self.node_id
)),
TimeoutOffer::Hdr => {
// The HDR (10-bit PQ dmabuf) offer was never accepted — the monitor left HDR
// mode between the probe and the negotiation, the compositor pre-dates the
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
// Latch the SDR downgrade for THIS source (`HdrSource`, not process-wide — one
// shared flag let either Linux HDR source disable the other) so the next session
// (Moonlight auto-reconnects) negotiates SDR instead of re-running this timeout.
if convicted {
super::note_hdr_capture_failed(self.hdr_source);
}
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer is the mirrored \
monitor in HDR mode on GNOME 50+?{}",
self.node_id,
if convicted {
" Downgrading this host to SDR capture; reconnect to stream SDR"
} else {
sentence
}
))
}
TimeoutOffer::RawDmabuf => {
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
// instead of failing this same negotiation forever. The latch is SCOPED to the
// raw-passthrough decision: it used to be `note_vaapi_dmabuf_failed`, which fed
// `pf_zerocopy::enabled()` and therefore dropped every later session on this
// host — NVENC's EGL→CUDA path included — to CPU capture. Since this offer is
// also the PyroWave one (any vendor), a single PyroWave negotiation timeout was
// enough to do that.
if convicted {
pf_zerocopy::note_raw_dmabuf_negotiation_failed();
}
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (raw-dmabuf passthrough){}",
self.node_id,
if convicted {
" — downgrading THIS path to CPU capture for the rest of the \
process; the pipeline rebuild will renegotiate without dmabuf"
} else {
sentence
}
))
}
TimeoutOffer::GpuDmabuf => {
// The EGL→CUDA dmabuf-only offer was never accepted — the twin of the raw-
// passthrough arm above (the offer the thread ACTUALLY made, per the signal
// it set — see `CaptureSignals::gpu_dmabuf_offer`). One FULL-LENGTH timeout
// is conclusive: a compositor that allocates none of the importer's
// modifiers refuses them identically on every retry, so latch the offer off
// and let the pipeline rebuild renegotiate the CPU path instead of
// re-running this same 10 s timeout on every reconnect. A forced
// PUNKTFUNK_ZEROCOPY=1 keeps erroring loudly instead (same rule as the raw
// arm).
if convicted {
pf_zerocopy::note_gpu_dmabuf_negotiation_failed();
}
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (EGLCUDA GPU import){}",
self.node_id,
if convicted {
" — downgrading THIS offer to the CPU path for the rest of the \
process; the pipeline rebuild will renegotiate without dmabuf"
} else {
sentence
}
))
}
TimeoutOffer::NoFormat => Err(anyhow!(
))
} else if self.hdr_offer {
// The HDR (10-bit PQ dmabuf) offer was never accepted — the monitor left HDR
// mode between the probe and the negotiation, the compositor pre-dates the
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
// Latch the process-wide SDR downgrade so the next session (Moonlight
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
super::note_hdr_capture_failed(self.hdr_source);
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer is the mirrored \
monitor in HDR mode on GNOME 50+? Downgrading this host to SDR capture; \
reconnect to stream SDR",
self.node_id
))
} else if self.vaapi_dmabuf && !pf_zerocopy::zerocopy_forced() {
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
// instead of failing this same negotiation forever. The latch is SCOPED to the
// raw-passthrough decision: it used to be `note_vaapi_dmabuf_failed`, which fed
// `pf_zerocopy::enabled()` and therefore dropped every later session on this
// host — NVENC's EGL→CUDA path included — to CPU capture. Since this offer is
// also the PyroWave one (any vendor), a single PyroWave negotiation timeout was
// enough to do that.
pf_zerocopy::note_raw_dmabuf_negotiation_failed();
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (raw-dmabuf passthrough) downgrading \
THIS path to CPU capture for the rest of the process; the pipeline \
rebuild will renegotiate without dmabuf",
self.node_id
))
} else if self.signals.gpu_dmabuf_offer.load(Ordering::Relaxed)
&& !pf_zerocopy::zerocopy_forced()
{
// The EGL→CUDA dmabuf-only offer was never accepted — the twin of the raw-
// passthrough arm above (the offer the thread ACTUALLY made, per the signal
// it set — see `CaptureSignals::gpu_dmabuf_offer`). One timeout is conclusive:
// a compositor that allocates none of the importer's modifiers refuses them
// identically on every retry, so latch the offer off and let the pipeline
// rebuild renegotiate the CPU path instead of re-running this same 10 s
// timeout on every reconnect. A forced PUNKTFUNK_ZEROCOPY=1 keeps erroring
// loudly instead (same rule as the raw arm).
pf_zerocopy::note_gpu_dmabuf_negotiation_failed();
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (EGLCUDA GPU import) downgrading THIS \
offer to the CPU path for the rest of the process; the pipeline rebuild \
will renegotiate without dmabuf",
self.node_id
))
} else {
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): format negotiation never \
completed the compositor offered no format this consumer accepts \
(pixel-format/modifier mismatch) or the node never emitted a Format param",
self.node_id
)),
))
}
}
RecvTimeoutError::Disconnected => Err(anyhow!(
@@ -976,89 +874,3 @@ mod pipewire;
// unit-test without a compositor, which is the point.
mod pw_cursor;
mod pw_pods;
#[cfg(test)]
mod first_frame_timeout_tests {
use super::{classify_first_frame_timeout, timeout_convicts, TimeoutOffer, TimeoutVerdict};
#[test]
fn a_provisional_expiry_convicts_no_offer_whatever_was_on_the_table() {
// The bug this pins down: the retry loop's truncated 2.5 s first attempt latched all
// three sticky process-wide downgrades as if the compositor had refused the offers — a
// gamescope HDR cold start then streamed SDR (and CPU-copied) for the process lifetime.
for offer in [
TimeoutOffer::NoBuffers,
TimeoutOffer::Hdr,
TimeoutOffer::RawDmabuf,
TimeoutOffer::GpuDmabuf,
TimeoutOffer::NoFormat,
] {
assert!(
!timeout_convicts(offer, TimeoutVerdict::Provisional),
"provisional expiry must not latch {offer:?}"
);
}
}
#[test]
fn a_conclusive_expiry_convicts_exactly_the_offer_bearing_diagnoses() {
assert!(timeout_convicts(
TimeoutOffer::Hdr,
TimeoutVerdict::Conclusive
));
assert!(timeout_convicts(
TimeoutOffer::RawDmabuf,
TimeoutVerdict::Conclusive
));
assert!(timeout_convicts(
TimeoutOffer::GpuDmabuf,
TimeoutVerdict::Conclusive
));
// A negotiated-but-idle stream and a plain format mismatch implicate no offer — nothing
// to latch even on a full-length wait.
assert!(!timeout_convicts(
TimeoutOffer::NoBuffers,
TimeoutVerdict::Conclusive
));
assert!(!timeout_convicts(
TimeoutOffer::NoFormat,
TimeoutVerdict::Conclusive
));
}
#[test]
fn classification_mirrors_the_negotiation_state_precedence() {
// A negotiated format clears every offer, whatever else was on the table.
assert_eq!(
classify_first_frame_timeout(true, true, true, true, false),
TimeoutOffer::NoBuffers
);
// The HDR offer outranks the dmabuf arms (it is the offer that failed to negotiate).
assert_eq!(
classify_first_frame_timeout(false, true, true, true, false),
TimeoutOffer::Hdr
);
assert_eq!(
classify_first_frame_timeout(false, false, true, true, false),
TimeoutOffer::RawDmabuf
);
assert_eq!(
classify_first_frame_timeout(false, false, false, true, false),
TimeoutOffer::GpuDmabuf
);
assert_eq!(
classify_first_frame_timeout(false, false, false, false, false),
TimeoutOffer::NoFormat
);
}
#[test]
fn a_forced_zerocopy_keeps_both_dmabuf_arms_erroring_loudly_instead_of_implicated() {
// PUNKTFUNK_ZEROCOPY=1 is the operator insisting on the path — the timeout falls through
// to the generic diagnosis (and so never latches), exactly as the old else-if chain did.
assert_eq!(
classify_first_frame_timeout(false, false, true, true, true),
TimeoutOffer::NoFormat
);
}
}
-6
View File
@@ -34,11 +34,5 @@ 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
-78
View File
@@ -674,84 +674,6 @@ 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<f64> {
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() {
@@ -1021,29 +1021,6 @@ 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<pf_client_core::gamepad::PadInfo>) {
(Settings::default(), Vec::new())
}
-40
View File
@@ -4,46 +4,6 @@ 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 (1t)³`, 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() {
@@ -1032,7 +1032,7 @@ fn write_session_plus_dropin(
wsi = if wsi_ok {
String::new()
} else {
wsi_off_unit_lines()
"Environment=ENABLE_GAMESCOPE_WSI=0\n".to_string()
},
);
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))?;
@@ -3521,52 +3521,6 @@ fn arm_session_bind(wrapper: &std::path::Path) -> Option<SessionBind> {
Some(bind)
}
/// The environment that turns the distro's `VkLayer_FROG_gamescope_wsi` off for a whole session
/// tree — applied wherever we start one: [`launch_session`]'s transient unit and the box-session
/// drop-in ([`write_session_plus_dropin`]).
///
/// ⭐⭐⭐ **`DISABLE_GAMESCOPE_WSI` is the one that actually works, and it is not the obvious one.**
/// `gamescope-session-plus` does an unconditional `export ENABLE_GAMESCOPE_WSI=1` near the top of
/// the script, before it launches anything — so a `--setenv=ENABLE_GAMESCOPE_WSI=0` on the unit is
/// CLOBBERED for the script and every child it spawns: gamescope, Steam, and every game Steam
/// launches. The Vulkan loader resolves an implicit layer's two manifest knobs in a fixed order
/// (`loader.c`, `loader_implicit_layer_is_enabled`): `enable_environment` switches the layer on
/// only when the variable equals exactly `"1"`, and then `disable_environment` is consulted last —
/// *"has priority over everything else"* — where the mere PRESENCE of the variable, at any value,
/// forces the layer off. Nothing in the session script mentions `DISABLE_GAMESCOPE_WSI`, so it is
/// the only one of the two that survives the script.
///
/// ⚠️⚠️ What getting this wrong looks like in the field (Nobara 44, 2026-08-11): the layer stayed
/// on for games while this host's own log said it had been disabled. Neither Steam Big Picture nor
/// mangoapp is a Vulkan client, so both paint regardless and the session looks perfectly healthy —
/// right up until a game starts. Then the game runs with sound and input while its swapchain is
/// dead: a black screen, and not one line of error anywhere.
///
/// `ENABLE_GAMESCOPE_WSI=0` is kept alongside for a layer built without a `disable_environment`
/// (the loader warns about such a layer but honours its `enable_environment`), and because it is
/// what an operator reading the unit will look for.
const WSI_OFF_ENV: [(&str, &str); 2] = [
("DISABLE_GAMESCOPE_WSI", "1"),
("ENABLE_GAMESCOPE_WSI", "0"),
];
/// [`WSI_OFF_ENV`] as `systemd-run` arguments, for the transient unit.
fn wsi_off_setenv_args() -> Vec<String> {
WSI_OFF_ENV
.iter()
.map(|(name, value)| format!("--setenv={name}={value}"))
.collect()
}
/// [`WSI_OFF_ENV`] as unit-file lines, for the box-session drop-in. Trailing newline included, so
/// whatever the body puts after it still parses — same contract as [`SessionBind::unit_lines`].
fn wsi_off_unit_lines() -> String {
WSI_OFF_ENV
.iter()
.map(|(name, value)| format!("Environment={name}={value}\n"))
.collect()
}
/// Whether the box's `VkLayer_FROG_gamescope_wsi` can be trusted against the gamescope we run.
///
/// The layer ships with the DISTRO's gamescope and speaks its `gamescope_swapchain` protocol; we
@@ -3579,10 +3533,8 @@ fn wsi_off_unit_lines() -> String {
/// byte-identical between those commits, so this is the distro PATCHING gamescope, not a version
/// bump — which is why the check is "do the version triples differ", not a floor.
///
/// Disabling it costs only the layer's extras (XWayland bypass, present-mode control, client HDR
/// metadata) — far cheaper than a client that cannot start.
///
/// ⚠️ **`ENABLE_GAMESCOPE_WSI=0` is NOT enough on its own**, which is what [`WSI_OFF_ENV`] is for.
/// `ENABLE_GAMESCOPE_WSI=0` is gamescope's own opt-out and costs only the layer's extras
/// (present-mode control, client HDR metadata) — far cheaper than a client that cannot start.
fn wsi_layer_matches_our_gamescope() -> bool {
let ours = discovery::gamescope_version_of(std::path::Path::new(gamescope_bin()));
let distro = discovery::gamescope_version_of(std::path::Path::new(DISTRO_GAMESCOPE_PATH));
@@ -3637,17 +3589,14 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
// an armed bind is the one thing here that can stop the session coming up at all.
let mut bind = arm_session_bind(&wrapper);
// The distro's Vulkan WSI layer speaks the distro gamescope's protocol; ours may differ, and a
// mismatch kills every Vulkan client with no error but a black screen. Steam Big Picture is not
// one of them, so the casualty is the GAMES — see [`WSI_OFF_ENV`] for why both variables go.
// mismatch kills every Vulkan client (Steam included) with no error but a black screen.
let wsi_ok = wsi_layer_matches_our_gamescope();
if !wsi_ok {
tracing::warn!(
"gamescope: this box's VkLayer_FROG_gamescope_wsi was built for a different gamescope \
than the one we run disabling it for this session (DISABLE_GAMESCOPE_WSI=1, which \
the session script cannot clobber the way it clobbers ENABLE_GAMESCOPE_WSI). Left \
enabled it rejects the client's swapchain_feedback and every Vulkan client dies; \
Steam's own UI is not one, so what you see is a game that runs with sound and input \
on a black screen, with no other symptom."
than the one we run disabling it for this session (ENABLE_GAMESCOPE_WSI=0). Left \
enabled it rejects the client's swapchain_feedback and every Vulkan client dies, \
which shows up as a black screen with no other symptom."
);
}
let start_unit = |bind: Option<&SessionBind>| -> Result<()> {
@@ -3657,9 +3606,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
cmd.arg(arg);
}
if !wsi_ok {
for arg in wsi_off_setenv_args() {
cmd.arg(arg);
}
cmd.arg("--setenv=ENABLE_GAMESCOPE_WSI=0");
}
let status = cmd
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
@@ -4096,9 +4043,9 @@ mod tests {
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args,
is_steam_launch, mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind,
release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
shape_dedicated_command, switch_ends_mask_window, unmask_unit, wsi_off_setenv_args,
wsi_off_unit_lines, xwayland_refusal_marker, BindOff, BindPlan, DmHelperError, SessionBind,
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
shape_dedicated_command, switch_ends_mask_window, unmask_unit, xwayland_refusal_marker,
BindOff, BindPlan, DmHelperError, SessionBind, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH,
STOPPED_AUTOLOGIN, X11_SOCKET_DIR,
};
/// The HDR spawn flags are what make a nested game render HDR at all — and their absence is
@@ -4759,33 +4706,4 @@ mod tests {
None
);
}
/// `gamescope-session-plus` runs `export ENABLE_GAMESCOPE_WSI=1` before it launches anything,
/// so that variable alone cannot turn the layer off for Steam or for the games Steam launches
/// — only `DISABLE_GAMESCOPE_WSI`, which the script never mentions and which the Vulkan loader
/// treats as an unconditional force-off, survives. Dropping it would restore the field bug
/// (a game with sound and input on a black screen) while the host's log still claimed the
/// layer was disabled, which is what made it so expensive to find. See [`WSI_OFF_ENV`].
#[test]
fn the_wsi_opt_out_carries_the_variable_the_session_script_cannot_clobber() {
assert!(
WSI_OFF_ENV.contains(&("DISABLE_GAMESCOPE_WSI", "1")),
"the clobber-proof variable is the whole point of the opt-out"
);
// Both spellings reach both launch paths, and neither may lose the other.
let args = wsi_off_setenv_args();
let lines = wsi_off_unit_lines();
for (name, value) in WSI_OFF_ENV {
assert!(args.contains(&format!("--setenv={name}={value}")), "{name}");
assert!(
lines.contains(&format!("Environment={name}={value}\n")),
"{name}"
);
}
// Trailing newline: the drop-in body appends nothing after this block today, but the bind
// lines above it rely on the same contract and the order has changed before.
assert!(lines.ends_with('\n'));
}
}
+2 -11
View File
@@ -4129,10 +4129,7 @@ fn build_pipeline_with_retry(
// SteamOS: every gamescope bring-up burned the full 10 s on attempt 1, then attempt 2 got
// frames instantly → 17 s bring-ups). Healthy compositors deliver the first frame well inside
// this window (KWin ~0.3 s), and the genuinely-slow cold start above still gets the patient
// 10 s window on every later attempt. The truncated attempt is PROVISIONAL end to end: its
// expiry must not latch the capturer's sticky downgrades (see
// `Capturer::next_frame_within_provisional`) — only the full-length attempts hand down
// negotiation verdicts.
// 10 s window on every later attempt.
const FIRST_ATTEMPT_FRAME_BUDGET: std::time::Duration = std::time::Duration::from_millis(2500);
let mut backoff = std::time::Duration::from_millis(500);
for attempt in 1..=max_attempts {
@@ -4487,13 +4484,7 @@ fn build_pipeline(
}
capturer.set_active(true);
let first = match first_frame_budget {
// Provisional: this is the retry loop's deliberately truncated first attempt, and its
// expiry is the schedule firing, not a negotiation verdict — the capturer must not latch
// its sticky process-wide downgrades (HDR capture, the dmabuf-only offers) from it. A
// gamescope cold start regularly outlives this window and then accepts every offer on the
// full-length attempt that follows (observed on .41: one truncated expiry pinned the whole
// host process to SDR + CPU capture).
Some(budget) => capturer.next_frame_within_provisional(budget),
Some(budget) => capturer.next_frame_within(budget),
None => capturer.next_frame(),
};
let frame = match first.context("first frame") {
+1 -7
View File
@@ -19,7 +19,7 @@ pkgname=punktfunk-gamescope
# bump it with the marker so pacman sees a new version when only our patches moved.
_gsver=3.16.25
_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843
pkgver="${_gsver}.pfhdr6"
pkgver="${_gsver}.pfhdr5"
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
# `.pfhdrN` level deliberately stays put — see README.md.
# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own
@@ -33,12 +33,6 @@ pkgver="${_gsver}.pfhdr6"
# a session on every capture renegotiation — i.e. on every client connect, since the host sets the
# session to the client's mode. This one DOES move `.pfhdrN`, even though it adds no capability:
# every deployed pfhdr4 binary crash-loops, so an operator has to be able to tell them apart.
#
# pfhdr6 / rel 1: patch 0008 honors GAMESCOPE_NO_FOCUS — a mapped-but-unpainted window carrying it
# (Bazzite's hhd-ui crash-looping under a headless takeover) used to WIN focus selection and turn
# the composite (and the stream) black while every health signal stayed green. No capability the
# host probes for, but a field box's banner has to distinguish a build that can lose its composite
# this way from one that cannot.
pkgrel=1
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
+5 -10
View File
@@ -18,7 +18,6 @@ The patches here add the missing half, and nothing else. See
| `0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr<N>` to the `--version` banner | **No** — ours only, retired when the functional patches above land upstream |
| `0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch` | Give `g_device` and `g_output` storage that is never destroyed, so their destructors cannot call a Vulkan driver glibc has already unloaded at `exit()` | **Yes** — a plain static-destruction-order bug, not punktfunk-specific |
| `0007-pipewire-never-leave-pw_buffer-user_data-pointing-at.patch` | Associate `pw_buffer->user_data` with its `pipewire_buffer` for every path out of `add_buffer`, clear it in `remove_buffer` (the last point both halves are known), and null-check the consumers — killing the use-after-free that aborted the session on every capture renegotiation | **Yes** — a plain use-after-free in the PipeWire buffer lifecycle |
| `0008-steamcompmgr-honor-GAMESCOPE_NO_FOCUS-never-a-focus-.patch` | Honor `GAMESCOPE_NO_FOCUS` (set by hhd-ui and MangoHud, consumed by nobody): such windows are skipped by both focus-candidate collectors, so a mapped-but-unpainted overlay app can no longer win focus and turn the composite black. Compositing is untouched — only focus SELECTION is barred | **Yes** — the atom's setters already exist in the wild; some compositor has to keep the promise |
### Why the headless patch matters
@@ -85,18 +84,14 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
| `+pfhdr2` | …and `--pipewire-composite-cursor` |
| `+pfhdr3` | …and the headless connector advertises its mode + `--custom-refresh-rates` |
| `+pfhdr4` | …and `--pipewire-composite-external-overlay` |
| `+pfhdr5` | …and the PipeWire buffer use-after-free is fixed (no new capability) |
| `+pfhdr6` | …and `GAMESCOPE_NO_FOCUS` windows are never focus candidates (no new capability) |
Bump it whenever a patch adds or changes something the host must know about before it spawns.
A patch that only fixes a crash does **not** automatically bump it: `0006` (the exit-time Vulkan
teardown fix) changes nothing the host probes for, so it shipped as a `pkgrel` bump at `+pfhdr4`
exactly the split the PKGBUILD's own comment describes. Since every host probe is `>=`, a bump for
a bugfix is safe but must earn its place: `0007` and `0008` moved the level anyway because their
absence is invisible until a stream fails (a crash-loop per connect; a composite lost to a
NO_FOCUS window), so field triage has to be able to read the difference off a box's banner.
Bumping without either reason would advertise a capability tier that does not exist.
A patch that only fixes a crash does **not** bump it: `0006` (the exit-time Vulkan teardown fix)
changes nothing the host probes for, so the level stays `+pfhdr4` and the rebuild ships as a
`pkgrel` bump instead — exactly the split the PKGBUILD's own comment describes. Bumping the level
for a bugfix would be worse than useless: it would advertise a capability tier that does not exist
and strand hosts that gate on it.
⚠️ The two indirect spawn modes (the `GAMESCOPE_BIN` wrapper for gamescope-session-plus, and the
SteamOS PATH shim) pass these flags through `PF_HDR_ARGS`, so they share one dependency: if the
@@ -1,160 +0,0 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: enricobuehler <enrico.buehler@unom.io>
Date: Tue, 11 Aug 2026 21:48:29 +0200
Subject: [PATCH] =?UTF-8?q?steamcompmgr:=20honor=20GAMESCOPE=5FNO=5FFOCUS?=
=?UTF-8?q?=20=E2=80=94=20such=20windows=20are=20never=20focus=20candidate?=
=?UTF-8?q?s?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
hhd (Handheld Daemon) sets GAMESCOPE_NO_FOCUS=1 on its hhd-ui overlay window once at init and
never clears it (hhd src/hhd/plugins/overlay/x11.py, prepare_hhd); MangoHud sets the same atom.
The show/hide protocol for these clients is STEAM_OVERLAY / STEAM_INPUT_FOCUS — the window is
never meant to win focus selection on its own.
Nothing consumed the atom: neither this tree nor Bazzite's fork (checked ba148) interns it, so a
mapped-but-unpainted hhd-ui window — it crash-loops under a headless punktfunk takeover and
remaps on every respawn, stamping Steam's appid 769 — was a perfectly ordinary focus candidate.
steamcompmgr picked it over Big Picture, and the composite (and the stream fed from it) went
black while every other health signal stayed green (observed on Bazzite .41, 2026-08-11:
GAMESCOPE_FOCUSED_WINDOW = the hhd-ui window, GAMESCOPE_NO_FOCUS(CARDINAL)=1 on that window,
client stats 60 fps at 0.1 Mb/s of black; killing hhd-ui flipped focus back to Steam and the
picture returned instantly).
Wire the atom exactly like GAMESCOPE_EXTERNAL_OVERLAY — read at map, tracked on PropertyNotify
(with MakeFocusDirty), skipped in both focus-candidate collectors (X11 and XDG). Unlike the
overlay flags it does NOT zero appID and does not change compositing: the window still paints
normally if something else (the baselayer protocol) brings it into view; it is only barred from
being CHOSEN.
Banner: +pfhdr6 (no new capability — the bump exists so a field box's banner distinguishes a
build that can lose its composite to a NO_FOCUS window from one that cannot).
---
src/meson.build | 3 ++-
src/steamcompmgr.cpp | 24 ++++++++++++++++++++----
src/steamcompmgr_shared.hpp | 4 ++++
src/xwayland_ctx.hpp | 1 +
4 files changed, 27 insertions(+), 5 deletions(-)
diff --git a/src/meson.build b/src/meson.build
index 9a3a287..acfcaea 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -185,7 +185,8 @@ vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
# +pfhdr3 — …and the headless connector advertises its mode + `--custom-refresh-rates`
# +pfhdr4 — …and `--pipewire-composite-external-overlay`
# +pfhdr5 — …and the PipeWire buffer use-after-free is fixed (no new capability)
-version_tag = vcs_tag + '+pfhdr5' + ' (' + compiler_name + ' ' + compiler_version + ')'
+# +pfhdr6 — …and GAMESCOPE_NO_FOCUS windows are never focus candidates (no new capability)
+version_tag = vcs_tag + '+pfhdr6' + ' (' + compiler_name + ' ' + compiler_version + ')'
gamescope_version_conf = configuration_data()
gamescope_version_conf.set('VCS_TAG', version_tag)
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 64e1a8c..14596ae 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -1109,6 +1109,7 @@ bool g_bPendingFade = false;
#define STEAM_PROP "STEAM_BIGPICTURE"
#define OVERLAY_PROP "STEAM_OVERLAY"
#define EXTERNAL_OVERLAY_PROP "GAMESCOPE_EXTERNAL_OVERLAY"
+#define NO_FOCUS_PROP "GAMESCOPE_NO_FOCUS"
#define GAMES_RUNNING_PROP "STEAM_GAMES_RUNNING"
#define SCREEN_SCALE_PROP "STEAM_SCREEN_SCALE"
#define SCREEN_MAGNIFICATION_PROP "STEAM_SCREEN_MAGNIFICATION"
@@ -3848,8 +3849,8 @@ found:;
for (steamcompmgr_win_t *w = this->list; w; w = w->xwayland().next)
{
- // Always skip system tray icons and overlays
- if ( w->isSysTrayIcon || w->isOverlay || w->isExternalOverlay )
+ // Always skip system tray icons, overlays, and windows that asked never to be focused
+ if ( w->isSysTrayIcon || w->isOverlay || w->isExternalOverlay || w->isNoFocus )
{
continue;
}
@@ -4197,8 +4198,8 @@ steamcompmgr_xdg_get_possible_focus_windows()
std::vector< steamcompmgr_win_t* > windows;
for ( auto &win : g_steamcompmgr_xdg_wins )
{
- // Always skip system tray icons and overlays
- if ( win->isSysTrayIcon || win->isOverlay || win->isExternalOverlay )
+ // Always skip system tray icons, overlays, and windows that asked never to be focused
+ if ( win->isSysTrayIcon || win->isOverlay || win->isExternalOverlay || win->isNoFocus )
{
continue;
}
@@ -4960,6 +4961,10 @@ map_win(xwayland_ctx_t* ctx, Window id, unsigned long sequence)
if ( w->isExternalOverlay )
w->appID = 0;
+ // Never a focus candidate; appID stays — the window may share the focused app's id (hhd-ui
+ // stamps Steam's) and zeroing it here is not needed to keep it out of focus selection.
+ w->isNoFocus = get_prop(ctx, w->xwayland().id, ctx->atoms.noFocusAtom, 0);
+
w->oulTargetVROverlay = get_u64_prop(ctx, w->xwayland().id, ctx->atoms.steamGamescopeVROverlayTarget);
if ( w->oulTargetVROverlay )
{
@@ -5243,6 +5248,7 @@ add_win(xwayland_ctx_t *ctx, Window id, Window prev, unsigned long sequence)
new_win->isOverlay = false;
new_win->isExternalOverlay = false;
+ new_win->isNoFocus = false;
new_win->isSteamLegacyBigPicture = false;
new_win->isSteamStreamingClient = false;
new_win->isSteamStreamingClientVideo = false;
@@ -6193,6 +6199,15 @@ handle_property_notify(xwayland_ctx_t *ctx, XPropertyEvent *ev)
MakeFocusDirty();
}
}
+ if (ev->atom == ctx->atoms.noFocusAtom)
+ {
+ steamcompmgr_win_t * w = find_win(ctx, ev->window);
+ if (w)
+ {
+ w->isNoFocus = get_prop(ctx, w->xwayland().id, ctx->atoms.noFocusAtom, 0);
+ MakeFocusDirty();
+ }
+ }
if (ev->atom == ctx->atoms.winTypeAtom)
{
steamcompmgr_win_t * w = find_win(ctx, ev->window);
@@ -7927,6 +7942,7 @@ void init_xwayland_ctx(uint32_t serverId, gamescope_xwayland_server_t *xwayland_
ctx->atoms.gameAtom = XInternAtom(ctx->dpy, GAME_PROP, false);
ctx->atoms.overlayAtom = XInternAtom(ctx->dpy, OVERLAY_PROP, false);
ctx->atoms.externalOverlayAtom = XInternAtom(ctx->dpy, EXTERNAL_OVERLAY_PROP, false);
+ ctx->atoms.noFocusAtom = XInternAtom(ctx->dpy, NO_FOCUS_PROP, false);
ctx->atoms.opacityAtom = XInternAtom(ctx->dpy, OPACITY_PROP, false);
ctx->atoms.gamesRunningAtom = XInternAtom(ctx->dpy, GAMES_RUNNING_PROP, false);
ctx->atoms.screenScaleAtom = XInternAtom(ctx->dpy, SCREEN_SCALE_PROP, false);
diff --git a/src/steamcompmgr_shared.hpp b/src/steamcompmgr_shared.hpp
index 21ddc5f..924e0d2 100644
--- a/src/steamcompmgr_shared.hpp
+++ b/src/steamcompmgr_shared.hpp
@@ -116,6 +116,10 @@ struct steamcompmgr_win_t {
uint32_t appID = 0;
bool isOverlay = false;
bool isExternalOverlay = false;
+ // GAMESCOPE_NO_FOCUS on the window: the client asks never to be a focus candidate (hhd-ui and
+ // MangoHud set it once at init). Unlike an overlay it still composites normally if something
+ // else focuses it into view; it is only excluded from focus selection.
+ bool isNoFocus = false;
bool bIsSteamPid = false;
bool bIsSteamWebHelperPid = false;
diff --git a/src/xwayland_ctx.hpp b/src/xwayland_ctx.hpp
index 978728a..d6ce90f 100644
--- a/src/xwayland_ctx.hpp
+++ b/src/xwayland_ctx.hpp
@@ -105,6 +105,7 @@ struct xwayland_ctx_t final : public gamescope::IWaitable
Atom gameAtom;
Atom overlayAtom;
Atom externalOverlayAtom;
+ Atom noFocusAtom;
Atom gamesRunningAtom;
Atom screenZoomAtom;
Atom screenScaleAtom;
--
2.50.1 (Apple Git-155)
+23
View File
@@ -211,6 +211,29 @@ export default defineConfig({
compatibilityDate: "2026-06-10",
// Scan server/{middleware,routes} for the auth gate + the /api proxy.
scanDirs: [serverDir],
// Silence rollup's MODULE_LEVEL_DIRECTIVE noise. Because `noExternals` re-bundles the
// whole dep tree into the server output, every React package that ships a `"use client"`
// banner — @tanstack/react-router, radix-ui, framer-motion under @unom/ui — earns one
// "directive was ignored" line, ~800 of them per build, which buries the warnings worth
// reading. Ignoring is the CORRECT outcome here and not a papered-over bug: this bundle is
// the Bun/Nitro server, not an RSC module graph, and Start splits client/server with its
// own transform, so nothing downstream ever consults the banner.
//
// Supplying `onwarn` REPLACES nitro's own (it defu-merges ours over its default), so
// nitro's three filters are restated here — drop this and CIRCULAR_DEPENDENCY comes back.
rollupConfig: {
onwarn(warning, defaultHandler) {
if (
["CIRCULAR_DEPENDENCY", "EVAL", "MODULE_LEVEL_DIRECTIVE"].includes(
warning.code ?? "",
) ||
warning.message.includes("Unsupported source map comment")
) {
return;
}
defaultHandler(warning);
},
},
}),
// Must come AFTER tanstackStart — provides the React JSX transform + Refresh runtime
// that Start's dev mode requires (omitting it leaves the client JS unable to load).