Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d74639de70 | ||
|
|
fbdad8d917 | ||
|
|
78ad675507 | ||
|
|
b25e6eda91 | ||
|
|
c79d9397fe | ||
|
|
3edb01f1b8 | ||
|
|
5a7f7f0fc5 | ||
|
|
25b08916b6 | ||
|
|
35ba64ca0f | ||
|
|
5f71aeb024 | ||
|
|
e1adc5d6d7 | ||
|
|
76a271b97a |
@@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -98,6 +99,13 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// The console backdrop's colour family, published once from the live settings rather than
|
||||
// threaded through every screen that draws a backdrop. Because it is read from the SAME
|
||||
// `settings` state the gamepad settings screen writes, stepping the Background row recolours
|
||||
// the field behind that very row.
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides GamepadPalette.named(settings.uiPalette),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = session,
|
||||
transitionSpec = {
|
||||
@@ -201,8 +209,16 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The console backdrop's colour family for everything under [App] — provided from the live
|
||||
* settings so a change on the gamepad settings screen recolours every backdrop at once. Defaults
|
||||
* to the brand violet, which is also what a preview or a test composition gets.
|
||||
*/
|
||||
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
|
||||
|
||||
/** Which console screen the gamepad shell is showing. */
|
||||
private enum class GamepadScreen { Home, Settings, Library }
|
||||
|
||||
|
||||
@@ -168,8 +168,7 @@ fun ConnectScreen(
|
||||
lnpPrompt = false
|
||||
// The browse started while blocked (its sockets failed or received nothing) — restart it
|
||||
// now that the grant makes them work.
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
discovery.restart()
|
||||
} else {
|
||||
lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly)
|
||||
}
|
||||
@@ -191,12 +190,27 @@ fun ConnectScreen(
|
||||
// or otherwise notify the app — this observer is what turns the grant into a live discovery.
|
||||
DisposableEffect(Unit) {
|
||||
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||
// Whether we've actually been away. ON_RESUME also fires on first entry, right after the
|
||||
// effect below starts the browse — restarting it there would be pure churn.
|
||||
var wasPaused = false
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) {
|
||||
lnpGranted = true
|
||||
lnpPrompt = false
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> wasPaused = true
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
if (!lnpGranted && hasLocalNetworkPermission(context)) {
|
||||
lnpGranted = true
|
||||
lnpPrompt = false
|
||||
discovery.restart()
|
||||
} else if (wasPaused) {
|
||||
// Coming back from the background: the browse may have been sitting idle
|
||||
// (or had its multicast socket torn out from under it) while we were away,
|
||||
// and its own re-query interval has kept doubling. Re-arm and ask again,
|
||||
// so returning to the screen is enough — no app restart.
|
||||
discovery.restart()
|
||||
}
|
||||
wasPaused = false
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycle?.addObserver(obs)
|
||||
@@ -1009,20 +1023,28 @@ fun ConnectScreen(
|
||||
// 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.
|
||||
if (lnpGranted && !connecting && discovered.isEmpty()) {
|
||||
// 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,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -23,6 +25,9 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -31,7 +36,9 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -86,32 +93,53 @@ private val auroraBlobs = listOf(
|
||||
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
|
||||
)
|
||||
|
||||
/** The deep base the field sits on — and, scaled, the [calm] lift that flattens it. */
|
||||
private val auroraBase = Color(0xFF131126)
|
||||
|
||||
/**
|
||||
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
|
||||
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
|
||||
* of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role.
|
||||
* The living console backdrop: soft brand-family blobs drifting over a deep base on slow, seamless
|
||||
* loops, finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose
|
||||
* approximation of the Apple client's MeshGradient aurora — same colour family, same "ambience,
|
||||
* never content" role, and the same [GamepadPalette] setting recolours both.
|
||||
*
|
||||
* [calm] is what the FORM screens wear: the pools dim onto the base so the glass rows keep real
|
||||
* colour and luminance without the launcher's contrast. Motion is identical either way on purpose —
|
||||
* only the contrast differs, so moving between screens can't make the field jump.
|
||||
*
|
||||
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
|
||||
* same courtesy the Apple client pays Reduce Motion.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
|
||||
val palette = LocalGamepadPalette.current
|
||||
val animated = animationsEnabled()
|
||||
val transition = rememberInfiniteTransition(label = "aurora")
|
||||
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
|
||||
// so the field never visibly jumps when the animation restarts.
|
||||
val angle by transition.animateFloat(
|
||||
val swept by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = (2 * PI).toFloat(),
|
||||
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "angle",
|
||||
)
|
||||
val angle = if (animated) swept else 0f
|
||||
// Tinting is per-frame-cheap but not free, and the palette changes about once a year.
|
||||
val blobs = remember(palette.id) { auroraBlobs.map { it to palette.tint(it.color) } }
|
||||
val base = remember(palette.id) { palette.tint(auroraBase) }
|
||||
Canvas(modifier) {
|
||||
drawRect(Color.Black)
|
||||
drawRect(if (calm) base else Color.Black)
|
||||
val span = max(size.width, size.height)
|
||||
for (b in auroraBlobs) {
|
||||
for ((b, tinted) in blobs) {
|
||||
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
|
||||
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
|
||||
val r = span * b.radiusFrac
|
||||
// Calm scales each blob's contribution rather than dimming the whole canvas: the base
|
||||
// stays put and only the pools come down to meet it, which is the same "lower the
|
||||
// contrast, keep the colour" the desktop console's `calm` uniform does.
|
||||
val alpha = if (calm) b.alpha * 0.62f else b.alpha
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
|
||||
colors = listOf(tinted.copy(alpha = alpha), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
),
|
||||
@@ -120,10 +148,15 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
blendMode = BlendMode.Plus,
|
||||
)
|
||||
}
|
||||
// Cinematic vignette: pool light centre, sink the corners.
|
||||
// Cinematic vignette: pool light centre, sink the corners. Halved under calm: a launcher's
|
||||
// cards sit in the pooled centre, but a form screen's rows run out toward the edges, where
|
||||
// crushing to black just eats them. (Matches the Apple client and the desktop console.)
|
||||
drawRect(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = if (calm) 0.22f else 0.44f),
|
||||
),
|
||||
center = Offset(size.width / 2, size.height / 2),
|
||||
radius = span * 0.92f,
|
||||
),
|
||||
@@ -141,33 +174,96 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet
|
||||
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
|
||||
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
|
||||
* `false` when the user has turned animations off system-wide (Developer options' animator duration
|
||||
* scale, or the accessibility "Remove animations" switch, which sets the same global). Read once
|
||||
* per composition — it needs a settings trip to the system, and it changes about never.
|
||||
*/
|
||||
@Composable
|
||||
private fun animationsEnabled(): Boolean {
|
||||
val context = LocalContext.current
|
||||
return remember {
|
||||
runCatching {
|
||||
android.provider.Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f,
|
||||
) != 0f
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backdrop for the console FORM screens (settings, add-host). It used to be a STILL deep-indigo
|
||||
* base with two soft glows; it is now the launcher's own living field at `calm`, which keeps that
|
||||
* colour and luminance under the glass rows, honours the palette setting on every screen rather
|
||||
* than only the launcher, and leaves nothing in the console UI backed by a static image. Mirrors
|
||||
* the Apple client's GamepadFormBackground, which made the same substitution.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
Canvas(modifier) {
|
||||
val span = max(size.width, size.height)
|
||||
drawRect(Color(0xFF131126))
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xE6635AAE), Color.Transparent),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xBF343E96), Color.Transparent),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
GamepadAuroraBackground(modifier, calm = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* The horizontal section switcher above a console list. Purely presentational — the SCREEN owns
|
||||
* which tab is selected and what the shoulders do. Scrollable so a narrow phone in landscape never
|
||||
* has to squeeze the pills, and the selected one is always brought into view whether it was reached
|
||||
* by shoulder button or tap.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleTabStrip(
|
||||
titles: List<String>,
|
||||
selected: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* The strip itself holds the cursor (the caller moved focus UP out of its list). Draws a ring
|
||||
* on the selected pill so it's clear left/right now walks sections rather than values — the
|
||||
* route a D-pad remote, which has no shoulder buttons, needs.
|
||||
*/
|
||||
focused: Boolean = false,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(selected) {
|
||||
runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) }
|
||||
}
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = ConsoleEdgeInset),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(titles) { i, title ->
|
||||
val active = i == selected
|
||||
val background by animateColorAsState(
|
||||
if (active) Color(0xD96656F2) else Color(0x14FFFFFF),
|
||||
tween(180),
|
||||
label = "tabBg",
|
||||
)
|
||||
val ink by animateColorAsState(
|
||||
Color.White.copy(alpha = if (active) 1f else 0.55f),
|
||||
tween(180),
|
||||
label = "tabInk",
|
||||
)
|
||||
val ring by animateColorAsState(
|
||||
Color.White.copy(alpha = if (active && focused) 0.85f else 0f),
|
||||
tween(180),
|
||||
label = "tabRing",
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(background)
|
||||
.border(1.5.dp, ring, RoundedCornerShape(50))
|
||||
.clickable { onSelect(i) }
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +272,7 @@ fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
|
||||
* it cross-fades between screens.
|
||||
*/
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp)
|
||||
|
||||
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
|
||||
val ConsoleEdgeInset = 24.dp
|
||||
@@ -471,7 +567,12 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
||||
Row(
|
||||
modifier = frosted
|
||||
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
// The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a
|
||||
// screen whose legend grew a cell) it scrolls rather than running off the edge and
|
||||
// silently eating the last hint — which is exactly what the settings screen's new
|
||||
// Section cell did on a 360 dp phone.
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
|
||||
@@ -152,8 +152,9 @@ fun GamepadNavEffect(
|
||||
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant
|
||||
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
|
||||
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
|
||||
* [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels
|
||||
* one layer": close the keyboard, then the screen).
|
||||
* [onSecondary] is Y, and [onShoulder] is L1 (-1) / R1 (+1) — a step SIDEWAYS out of the list, which
|
||||
* the settings screen uses for its section tabs. B is left to MainActivity's BACK remap → the
|
||||
* screen's BackHandler (so B "peels one layer": close the keyboard, then the screen).
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadNavEffect2D(
|
||||
@@ -162,6 +163,7 @@ fun GamepadNavEffect2D(
|
||||
onActivate: () -> Unit,
|
||||
onTertiary: () -> Unit = {},
|
||||
onSecondary: () -> Unit = {},
|
||||
onShoulder: (Int) -> Unit = {},
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
@@ -169,6 +171,7 @@ fun GamepadNavEffect2D(
|
||||
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||
val currentOnShoulder by rememberUpdatedState(onShoulder)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||
@@ -196,7 +199,10 @@ fun GamepadNavEffect2D(
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
|
||||
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
|
||||
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true }
|
||||
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true }
|
||||
else -> false // B → MainActivity (remapped to BACK → BackHandler)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// The console (gamepad) UI's background colour families.
|
||||
//
|
||||
// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale
|
||||
// applied to the ONE field GamepadAuroraBackground already draws, so every palette inherits its
|
||||
// structure (dark base, bright drifting pools) and the brand default is exactly the shipped look —
|
||||
// `violet` is the identity transform.
|
||||
//
|
||||
// The table and the `tint` maths are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
|
||||
// Apple client's `GamepadPalette.swift` under the same ids, so the shared `ui_palette` setting
|
||||
// names the same colour family on every client. Keep the three copies in step: a palette added
|
||||
// here without the others is a value the other clients will silently render as Violet.
|
||||
|
||||
/**
|
||||
* One background colour family. [hueDegrees] rotates about the grey axis (positive runs
|
||||
* red → green → blue) and [saturation] scales saturation about luminance.
|
||||
*/
|
||||
class GamepadPalette(
|
||||
/** The stored `ui_palette` value ([Settings.uiPalette]). */
|
||||
val id: String,
|
||||
/** What the settings row shows. */
|
||||
val name: String,
|
||||
val hueDegrees: Double,
|
||||
val saturation: Double,
|
||||
) {
|
||||
/** True for the identity transform, so the default path skips the per-colour work. */
|
||||
val isIdentity: Boolean get() = hueDegrees == 0.0 && saturation == 1.0
|
||||
|
||||
/** Apply this palette to one packed sRGB colour, keeping its alpha. */
|
||||
fun tint(c: Color): Color {
|
||||
if (isIdentity) return c
|
||||
val (r, g, b) = tint(Triple(c.red.toDouble(), c.green.toDouble(), c.blue.toDouble()))
|
||||
return Color(r.toFloat(), g.toFloat(), b.toFloat(), c.alpha)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate `c` about the grey axis by [hueDegrees] (Rodrigues — the same rotation, in the same
|
||||
* orientation, that the desktop console's shader uses for its ±8° warm/cool sway) and scale
|
||||
* its saturation about luminance. Clamped, because a large rotation can push a channel out of
|
||||
* gamut.
|
||||
*/
|
||||
fun tint(c: Triple<Double, Double, Double>): Triple<Double, Double, Double> {
|
||||
val (r, g, b) = c
|
||||
val a = Math.toRadians(hueDegrees)
|
||||
val cs = cos(a)
|
||||
val sn = sin(a)
|
||||
val invSqrt3 = 1.0 / sqrt(3.0)
|
||||
val grey = (r + g + b) / 3.0 * (1.0 - cs)
|
||||
// The `sn` term is cross(k, c) with k = (1,1,1)/√3.
|
||||
val rr = r * cs + (b - g) * invSqrt3 * sn + grey
|
||||
val rg = g * cs + (r - b) * invSqrt3 * sn + grey
|
||||
val rb = b * cs + (g - r) * invSqrt3 * sn + grey
|
||||
val luma = 0.2126 * rr + 0.7152 * rg + 0.0722 * rb
|
||||
fun mix(v: Double) = (luma + (v - luma) * saturation).coerceIn(0.0, 1.0)
|
||||
return Triple(mix(rr), mix(rg), mix(rb))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The six shipped palettes, in cycling order: the brand violet, then cool → warm, then
|
||||
* the neutral.
|
||||
*/
|
||||
val ALL = listOf(
|
||||
GamepadPalette("violet", "Violet", 0.0, 1.0),
|
||||
GamepadPalette("tide", "Tide", -70.0, 1.0),
|
||||
GamepadPalette("forest", "Forest", -130.0, 0.9),
|
||||
GamepadPalette("ember", "Ember", 105.0, 1.0),
|
||||
GamepadPalette("rose", "Rose", 60.0, 0.95),
|
||||
GamepadPalette("graphite", "Graphite", 0.0, 0.12),
|
||||
)
|
||||
|
||||
/**
|
||||
* The palette stored under [id], falling back to the brand default — an unknown name is a
|
||||
* palette a newer client shipped, not a reason to draw nothing.
|
||||
*/
|
||||
fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0]
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -63,10 +64,35 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||
// L1/R1 change SECTION, B closes. Both write the same SharedPreferences, so values round-trip with
|
||||
// the touch settings.
|
||||
//
|
||||
// The rows are split across SECTION TABS ([GpTab]) — a shoulder press on a pad, a tap on a phone.
|
||||
// They used to be one long scroll with inline `Group · Subgroup` headers, which on a TV meant
|
||||
// walking past Display and Audio to reach the controller settings. The tab names match the desktop
|
||||
// console's and the Apple client's, so a setting is found under the same word wherever you look.
|
||||
|
||||
/**
|
||||
* The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
|
||||
* match `pf-console-ui`'s `TABS` and the Apple client's `GpSettingsTab`.
|
||||
*/
|
||||
enum class GpTab(val title: String) {
|
||||
STREAM("Stream"),
|
||||
VIDEO("Video"),
|
||||
AUDIO("Audio"),
|
||||
CONTROLLER("Controller"),
|
||||
INTERFACE("Interface"),
|
||||
PROFILES("Profiles"),
|
||||
}
|
||||
|
||||
internal class GpRow(
|
||||
val id: String,
|
||||
val tab: GpTab,
|
||||
/**
|
||||
* A sub-heading above this row, for the few tabs that hold more than one group. Most rows have
|
||||
* none: the tab pill already names the section, and repeating it would be a second label
|
||||
* saying the same word.
|
||||
*/
|
||||
val header: String?,
|
||||
val label: String,
|
||||
val value: String,
|
||||
@@ -133,10 +159,34 @@ fun GamepadSettingsScreen(
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
// Which section is showing, and where each one's focus was when it was last left — a detour
|
||||
// into another tab shouldn't lose your place.
|
||||
var tab by remember { mutableStateOf(GpTab.STREAM) }
|
||||
// True while the STRIP holds the cursor rather than the list. Up from the first row moves
|
||||
// here and Down goes back — the only route to the sections on a D-pad remote, which has no
|
||||
// shoulder buttons at all (and is exactly what a TV box ships with).
|
||||
var tabFocused by remember { mutableStateOf(false) }
|
||||
val tabFocus = remember { mutableStateMapOf<GpTab, Int>() }
|
||||
val rows = allRows.filter { it.tab == tab }
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
|
||||
|
||||
// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
|
||||
fun selectTab(next: GpTab) {
|
||||
if (next == tab) return
|
||||
tabFocus[tab] = focus
|
||||
tab = next
|
||||
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
|
||||
// outlive the row it pointed at.
|
||||
focus = (tabFocus[next] ?: 0)
|
||||
.coerceIn(0, (allRows.count { it.tab == next } - 1).coerceAtLeast(0))
|
||||
}
|
||||
fun stepTab(delta: Int) {
|
||||
val all = GpTab.entries
|
||||
selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size])
|
||||
}
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
// value text slides in its AnimatedContent, so the motion matches the button press.
|
||||
var adjustDir by remember { mutableIntStateOf(1) }
|
||||
@@ -151,20 +201,28 @@ fun GamepadSettingsScreen(
|
||||
active = navActive && pinProfile == null,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||
// A disabled row is INERT, not just dim — the step is refused instead of writing a
|
||||
// setting that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
NavDir.UP -> if (focus > 0) focus-- else tabFocused = true
|
||||
NavDir.DOWN -> if (tabFocused) tabFocused = false else if (focus < rows.lastIndex) focus++
|
||||
// On the strip, left/right walks sections; on a row it steps the value. A disabled
|
||||
// row is INERT, not just dim — the step is refused instead of writing a setting
|
||||
// that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT ->
|
||||
if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT ->
|
||||
if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
}
|
||||
},
|
||||
onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() },
|
||||
// A on the strip drops into the section you picked, which is what "confirm" means there.
|
||||
onActivate = {
|
||||
if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() }
|
||||
},
|
||||
// The shoulders work from either place — a real pad never has to visit the strip.
|
||||
onShoulder = { delta -> stepTab(delta) },
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||
// +1 accounts for the heading being item 0.
|
||||
LaunchedEffect(focus) {
|
||||
LaunchedEffect(focus, tab) {
|
||||
runCatching {
|
||||
val itemIndex = focus + 1
|
||||
val info = listState.layoutInfo
|
||||
@@ -183,9 +241,21 @@ fun GamepadSettingsScreen(
|
||||
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
// The strip is PINNED while the rows scroll under it: it is this screen's primary
|
||||
// navigation now, and a switcher you have to scroll back up to find isn't one. The
|
||||
// title stays in the scrolling list (landscape has no height to spare, and the
|
||||
// selected pill already says which section you are in).
|
||||
ConsoleTabStrip(
|
||||
titles = GpTab.entries.map { it.title },
|
||||
selected = GpTab.entries.indexOf(tab),
|
||||
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
focused = tabFocused,
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().systemBarsPadding(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
@@ -196,12 +266,19 @@ fun GamepadSettingsScreen(
|
||||
ConsoleHeader("Default settings", horizontalInset = false)
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
|
||||
// its detail explains itself) but never flips it.
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
})
|
||||
SettingRowView(
|
||||
row,
|
||||
focused = index == focus && !tabFocused,
|
||||
adjustDir = adjustDir,
|
||||
onClick = {
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it
|
||||
// (so its detail explains itself) but never flips it.
|
||||
tabFocused = false
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,8 +295,23 @@ fun GamepadSettingsScreen(
|
||||
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
|
||||
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
|
||||
val focused = rows.getOrNull(focus)
|
||||
// The shoulders always change section, so that cell leads on every row. Tappable too,
|
||||
// like the others — a user without a working pad can still reach every tab.
|
||||
// Advertise the shoulders only where they EXIST: a TV remote has none (its route is Up
|
||||
// into the strip) and a touch user taps a pill, so on those the cell would be both a
|
||||
// lie and the reason a 360 dp legend runs out of room. Defaults to the pad case off an
|
||||
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
val sections = listOfNotNull(
|
||||
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
|
||||
.takeIf { padIsGamepad },
|
||||
)
|
||||
GamepadHintBar(
|
||||
when {
|
||||
if (tabFocused) listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Section"),
|
||||
PadGlyph.hint('A', "Open") { tabFocused = false },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
) else sections + when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
@@ -353,7 +445,8 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). */
|
||||
* AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one
|
||||
* tab at a time. */
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
@@ -361,12 +454,12 @@ internal fun buildSettingsRows(
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
id: String, tab: GpTab, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
|
||||
): GpRow {
|
||||
val idx = options.indexOfFirst { it.first == current }
|
||||
return GpRow(
|
||||
id, header, label,
|
||||
id, tab, header, label,
|
||||
value = options.getOrNull(idx)?.second ?: "—",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
@@ -385,10 +478,10 @@ internal fun buildSettingsRows(
|
||||
)
|
||||
}
|
||||
fun toggle(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
id: String, tab: GpTab, header: String?, label: String, detail: String,
|
||||
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
|
||||
): GpRow = GpRow(
|
||||
id, header, label,
|
||||
id, tab, header, label,
|
||||
value = if (value) "On" else "Off",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
@@ -397,36 +490,13 @@ internal fun buildSettingsRows(
|
||||
toggled = value,
|
||||
)
|
||||
|
||||
// Grouped and ordered by the cross-client category map (General / Display / Audio /
|
||||
// Controllers), with the same sub-section names the touch settings and the desktop clients use,
|
||||
// so a setting sits in the same place whichever surface you found it on. The ROWS stay the
|
||||
// couch-relevant subset: a pad can't drive a touch-input picker, and adding one for the sake of
|
||||
// symmetry would be parity in name only.
|
||||
// Grouped by the cross-client tab map (Stream / Video / Audio / Controller / Interface /
|
||||
// Profiles), so a setting sits under the same word whichever client you found it on. The ROWS
|
||||
// stay the couch-relevant subset: a pad can't drive a touch-input picker, and adding one for
|
||||
// the sake of symmetry would be parity in name only.
|
||||
return listOf(
|
||||
choice(
|
||||
"hud", "General · Statistics", "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"autoWake", "General · Session", "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"library", "General · Library", "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", "General · Interface", "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
|
||||
choice(
|
||||
"resolution", "Display · Resolution", "Resolution",
|
||||
"resolution", GpTab.STREAM, null, "Resolution",
|
||||
"The host creates a virtual display at exactly this size — no scaling. " +
|
||||
"Custom sizes are typed in the touch settings.",
|
||||
// A custom size (typed in the touch settings) leads the list so it stays visible and
|
||||
@@ -440,55 +510,56 @@ internal fun buildSettingsRows(
|
||||
s.width to s.height,
|
||||
) { (w, h) -> update(s.copy(width = w, height = h)) },
|
||||
choice(
|
||||
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
|
||||
"refresh", GpTab.STREAM, null, "Refresh rate",
|
||||
"Frame rate the host renders and streams at.",
|
||||
REFRESH_OPTIONS, s.hz,
|
||||
) { update(s.copy(hz = it)) },
|
||||
|
||||
choice(
|
||||
"bitrate", "Display · Quality", "Bitrate",
|
||||
"bitrate", GpTab.STREAM, null, "Bitrate",
|
||||
"Automatic uses the host's default. A host's options (Up on its tile) can measure the " +
|
||||
"link and set an informed value.",
|
||||
BITRATE_OPTIONS, s.bitrateKbps,
|
||||
) { update(s.copy(bitrateKbps = it)) },
|
||||
choice(
|
||||
"codec", null, "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
codecOptionsFor(s.codec, av1Capable), s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
|
||||
toggle(
|
||||
"lowLatency", "Display · Decoding", "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"compositor", "Display · Host output", "Compositor",
|
||||
"compositor", GpTab.STREAM, "Host output", "Compositor",
|
||||
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||
) { update(s.copy(compositor = it)) },
|
||||
|
||||
choice(
|
||||
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||
"codec", GpTab.VIDEO, null, "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
codecOptionsFor(s.codec, av1Capable), s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", GpTab.VIDEO, null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
toggle(
|
||||
"lowLatency", GpTab.VIDEO, "Decoding", "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"audio", GpTab.AUDIO, null, "Audio channels",
|
||||
"The speaker layout requested from the host.",
|
||||
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
|
||||
) { update(s.copy(audioChannels = it)) },
|
||||
toggle(
|
||||
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||
"mic", GpTab.AUDIO, null, "Microphone",
|
||||
"Send this device's microphone to the host's virtual mic.",
|
||||
s.micEnabled,
|
||||
) { update(s.copy(micEnabled = it)) },
|
||||
toggle(
|
||||
"echoCancel", null, "Echo cancellation",
|
||||
"echoCancel", GpTab.AUDIO, null, "Echo cancellation",
|
||||
"Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.",
|
||||
s.echoCancel,
|
||||
) { update(s.copy(echoCancel = it)) },
|
||||
|
||||
toggle(
|
||||
"padForward", "Controllers", "Forward controllers",
|
||||
"padForward", GpTab.CONTROLLER, null, "Forward controllers",
|
||||
"Send this device's controllers to the host. Turn it off when your controller " +
|
||||
"already reaches the host another way — USB passthrough such as VirtualHere — " +
|
||||
"so games don't see two of them.",
|
||||
@@ -499,18 +570,18 @@ internal fun buildSettingsRows(
|
||||
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
|
||||
// the pad rows kept stepping settings that had nothing to act on.
|
||||
choice(
|
||||
"padType", null, "Controller type",
|
||||
"padType", GpTab.CONTROLLER, null, "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
choice(
|
||||
"systemButtons", null, "Guide button",
|
||||
"systemButtons", GpTab.CONTROLLER, null, "Guide button",
|
||||
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
|
||||
"sends them to the host whenever this device delivers them.",
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(systemButtons = it)) },
|
||||
choice(
|
||||
"guideGesture", null, "Hold Select for guide",
|
||||
"guideGesture", GpTab.CONTROLLER, null, "Hold Select for guide",
|
||||
"Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
|
||||
@@ -518,7 +589,7 @@ internal fun buildSettingsRows(
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
"phoneRumble", null, "Rumble on this phone",
|
||||
"phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone",
|
||||
"Also play controller 1's rumble on this phone's own vibration motor — " +
|
||||
"for clip-on pads without rumble motors.",
|
||||
s.rumbleOnPhone,
|
||||
@@ -530,7 +601,7 @@ internal fun buildSettingsRows(
|
||||
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
|
||||
// nothing to do with this device's motor, and a TV box is where it matters most.
|
||||
toggle(
|
||||
"sc2", null, "Steam Controller 2 passthrough",
|
||||
"sc2", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough",
|
||||
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
|
||||
"it as-is — Steam on the host drives it like the physical pad.",
|
||||
s.sc2Capture, enabled = s.gamepadForwarding,
|
||||
@@ -540,20 +611,53 @@ internal fun buildSettingsRows(
|
||||
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
|
||||
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
|
||||
toggle(
|
||||
"dsCapture", null, "DualSense / DualShock passthrough (USB)",
|
||||
"dsCapture", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)",
|
||||
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
|
||||
"triggers, lightbar and gyro.",
|
||||
s.dsCapture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(dsCapture = it)) },
|
||||
|
||||
// The palette leads Interface: it is the one row whose effect you can see while you step
|
||||
// it (the backdrop behind this very list recolours), so it wants to be the first thing
|
||||
// found in the section.
|
||||
choice(
|
||||
"palette", GpTab.INTERFACE, null, "Background",
|
||||
"The colour family this backdrop drifts through — it changes as you step, so pick by " +
|
||||
"looking. Appearance only.",
|
||||
GamepadPalette.ALL.map { it.id to it.name },
|
||||
GamepadPalette.named(s.uiPalette).id,
|
||||
) { update(s.copy(uiPalette = it)) },
|
||||
choice(
|
||||
"hud", GpTab.INTERFACE, null, "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"autoWake", GpTab.INTERFACE, null, "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"library", GpTab.INTERFACE, null, "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", GpTab.INTERFACE, null, "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
|
||||
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
|
||||
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
|
||||
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
|
||||
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
|
||||
* instead of a dead-looking empty tab. On a TV that phrasing changes: "touch interface" points
|
||||
* nowhere useful on a touchless device, so the strings name the actual route — the
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
@@ -574,7 +678,8 @@ private fun buildProfileRows(
|
||||
return listOf(
|
||||
GpRow(
|
||||
id = "noProfiles",
|
||||
header = "Profiles",
|
||||
tab = GpTab.PROFILES,
|
||||
header = null,
|
||||
label = "No profiles yet",
|
||||
value = "",
|
||||
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
|
||||
@@ -586,12 +691,13 @@ private fun buildProfileRows(
|
||||
),
|
||||
)
|
||||
}
|
||||
return profiles.mapIndexed { i, p ->
|
||||
return profiles.map { p ->
|
||||
// Counted straight off the host records, so it agrees with what the carousel renders.
|
||||
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
|
||||
GpRow(
|
||||
id = "profile:${p.id}",
|
||||
header = if (i == 0) "Profiles" else null,
|
||||
tab = GpTab.PROFILES,
|
||||
header = null,
|
||||
label = p.name,
|
||||
value = when (pins) {
|
||||
0 -> "Not pinned"
|
||||
|
||||
@@ -105,6 +105,16 @@ data class Settings(
|
||||
* client's `libraryEnabled`.
|
||||
*/
|
||||
val libraryEnabled: Boolean = true,
|
||||
/**
|
||||
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
|
||||
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
|
||||
* desktop console's and the Apple client's under the same names. Presentation only: nothing
|
||||
* about a stream depends on it, so it is a device preference and never part of a profile.
|
||||
* An unknown value reads as the default rather than failing — a newer client may have shipped
|
||||
* a palette this build doesn't know.
|
||||
*/
|
||||
val uiPalette: String = "violet",
|
||||
/**
|
||||
* "Low-latency mode" — the master switch over the latency pipeline: the async decode loop
|
||||
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
|
||||
@@ -284,6 +294,7 @@ class SettingsStore(context: Context) {
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
|
||||
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||
@@ -323,6 +334,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putString(K_PRESENT_PRIORITY, s.presentPriority)
|
||||
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||
@@ -361,6 +373,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
|
||||
/**
|
||||
* Bumped AGAIN to restart every install at the new default (ON). History: the original
|
||||
@@ -424,6 +437,96 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
return Triple(maxOf(w, h), minOf(w, h), hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel [Settings.width]/[Settings.height] meaning "the native mode, narrowed so the picture
|
||||
* clears the display cutout and the rounded corners" — resolved at connect by [safeDisplayMode],
|
||||
* exactly as `0` is resolved by [nativeDisplayMode]. Negative, so it can never collide with a real
|
||||
* size; distinct from the UI's `-1` "Custom…" sentinel.
|
||||
*/
|
||||
const val SAFE_AREA_MODE = -2
|
||||
|
||||
/**
|
||||
* Safe-area stream geometry — the pure part, so it is unit-testable without a Display.
|
||||
*
|
||||
* The phone clips the picture in HARDWARE: the cutout (notch / punch-hole) and the four rounded
|
||||
* corners eat whatever the stream draws under them. [StreamScreen] deliberately draws edge-to-edge
|
||||
* (`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS`) and centres the video at its own aspect ratio
|
||||
* (`Modifier.aspectRatio`), so which pixels survive is decided purely by the mode's aspect:
|
||||
*
|
||||
* * A 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe
|
||||
* regions — which is why the presets have always "just worked".
|
||||
* * The NATIVE mode has the panel's own aspect, so it fills every pixel, cutout and corners
|
||||
* included. That is the mode that loses its corners.
|
||||
*
|
||||
* So asking the host for a mode narrower by the unsafe inset is the entire fix: the existing
|
||||
* aspect-fit centres it inside the safe region, and pointer mapping follows for free (MouseInput
|
||||
* derives the picture rect from the live video size, not from the window).
|
||||
*/
|
||||
object SafeArea {
|
||||
/** The host rejects odd dimensions and anything under 320 px wide (`validate_dimensions`). */
|
||||
const val MIN_WIDTH = 320
|
||||
|
||||
/**
|
||||
* [nativeWidth] reduced by [perSideInsetPx] on each side, even-floored and clamped to the
|
||||
* host's floor. Height is deliberately untouched: under aspect-fit only one axis can bind, and
|
||||
* on a landscape phone that axis is always the horizontal one — insetting height as well would
|
||||
* shrink the picture without uncovering anything.
|
||||
*/
|
||||
fun insetWidth(nativeWidth: Int, perSideInsetPx: Int): Int {
|
||||
val inset = perSideInsetPx.coerceAtLeast(0)
|
||||
return (nativeWidth - inset * 2).coerceAtLeast(MIN_WIDTH) / 2 * 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-side inset, in pixels, that the **landscape** stream must clear on this display.
|
||||
*
|
||||
* Two contributions, and the larger wins:
|
||||
* * **The cutout.** [DisplayCutout] is rotation-aware, so in landscape the housing shows up on
|
||||
* `left`/`right`. The settings screen may be portrait though, where the very same housing is
|
||||
* reported on `top`/`bottom` and the horizontal insets read zero — which would compute "no inset
|
||||
* needed" for exactly the devices that need one. The stream is always landscape, so a vertical
|
||||
* inset now becomes a horizontal one then: fall back to it.
|
||||
* * **The rounded corners.** These are NOT part of the cutout insets. For a FULL-HEIGHT picture the
|
||||
* horizontal clearance a corner of radius `r` needs is exactly `r`: at the topmost row the
|
||||
* display boundary sits at `x = r`, so anything left of that is clipped. Not conservative — it is
|
||||
* the precise requirement for a picture that spans the full height.
|
||||
*
|
||||
* `0` when the display has neither, which makes the safe mode identical to the native one.
|
||||
*/
|
||||
private fun displaySideInsetPx(context: Context): Int {
|
||||
val display = probeDisplay(context) ?: return 0
|
||||
var inset = 0
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
display.cutout?.let { cut ->
|
||||
val horizontal = maxOf(cut.safeInsetLeft, cut.safeInsetRight)
|
||||
val vertical = maxOf(cut.safeInsetTop, cut.safeInsetBottom)
|
||||
inset = maxOf(inset, if (horizontal > 0) horizontal else vertical)
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
for (position in intArrayOf(
|
||||
android.view.RoundedCorner.POSITION_TOP_LEFT,
|
||||
android.view.RoundedCorner.POSITION_TOP_RIGHT,
|
||||
android.view.RoundedCorner.POSITION_BOTTOM_LEFT,
|
||||
android.view.RoundedCorner.POSITION_BOTTOM_RIGHT,
|
||||
)) {
|
||||
display.getRoundedCorner(position)?.let { inset = maxOf(inset, it.radius) }
|
||||
}
|
||||
}
|
||||
return inset
|
||||
}
|
||||
|
||||
/**
|
||||
* The native mode narrowed to clear the cutout and the rounded corners — the [SAFE_AREA_MODE]
|
||||
* resolution, as a landscape `(width, height, hz)`. Same height and refresh as [nativeDisplayMode];
|
||||
* only the width moves.
|
||||
*/
|
||||
fun safeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
val (w, h, hz) = nativeDisplayMode(context)
|
||||
return Triple(SafeArea.insetWidth(w, displaySideInsetPx(context)), h, hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this device's display can actually present HDR10, so we should advertise HDR to the
|
||||
* host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream
|
||||
@@ -458,12 +561,21 @@ fun displaySupportsHdr(context: Context): Boolean {
|
||||
return supported
|
||||
}
|
||||
|
||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||
/**
|
||||
* Resolve [Settings] (with its `0`=native and [SAFE_AREA_MODE] placeholders) to the concrete mode to
|
||||
* request. The safe-area sentinel is checked first because it resolves BOTH axes together — it is one
|
||||
* mode, not an independent width and height, and mixing half of it with a native height would ask
|
||||
* for a size neither sentinel means.
|
||||
*/
|
||||
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
||||
val native = nativeDisplayMode(context)
|
||||
val w = if (width > 0) width else native.first
|
||||
val h = if (height > 0) height else native.second
|
||||
val hz = if (hz > 0) hz else native.third
|
||||
val base = if (width == SAFE_AREA_MODE && height == SAFE_AREA_MODE) {
|
||||
safeDisplayMode(context)
|
||||
} else {
|
||||
nativeDisplayMode(context)
|
||||
}
|
||||
val w = if (width > 0) width else base.first
|
||||
val h = if (height > 0) height else base.second
|
||||
val hz = if (hz > 0) hz else base.third
|
||||
return Triple(w, h, hz)
|
||||
}
|
||||
|
||||
@@ -517,9 +629,10 @@ val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it)
|
||||
|
||||
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
|
||||
|
||||
/** (width, height, label). `(0,0)` = native display. */
|
||||
/** (width, height, label). `(0,0)` = native display; [SAFE_AREA_MODE] = native minus the cutout. */
|
||||
val RESOLUTION_OPTIONS = listOf(
|
||||
Triple(0, 0, "Native display"),
|
||||
Triple(SAFE_AREA_MODE, SAFE_AREA_MODE, "Native display (safe area)"),
|
||||
Triple(1280, 720, "1280 × 720"),
|
||||
Triple(1920, 1080, "1920 × 1080"),
|
||||
Triple(2560, 1440, "2560 × 1440"),
|
||||
|
||||
@@ -603,6 +603,10 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
@Composable
|
||||
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
|
||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||
// The safe-area row carries its resolved size the same way the native row does. On a display with
|
||||
// no cutout and square corners this equals the native mode — the row stays, honestly showing that
|
||||
// it changes nothing here, rather than silently vanishing on some devices and not others.
|
||||
val (sw, sh, _) = safeDisplayMode(context)
|
||||
// "Custom…" picked while the stored size is still a preset — keeps the size fields visible
|
||||
// until an edit actually makes it custom (or a preset is re-picked). Custom itself is detected
|
||||
// from the stored size, never flagged (see [isCustomResolution]), so nothing new persists.
|
||||
@@ -611,7 +615,13 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
SettingsGroup("Resolution") {
|
||||
SettingDropdown(
|
||||
label = "Resolution",
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } +
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
||||
(w to h) to when (w) {
|
||||
0 -> "$lbl ($nw × $nh)"
|
||||
SAFE_AREA_MODE -> "$lbl ($sw × $sh)"
|
||||
else -> lbl
|
||||
}
|
||||
} +
|
||||
// The (-1, -1) sentinel can't collide with a real size; once a custom size is
|
||||
// stored its label carries the live value, like the native row carries ($nw × $nh).
|
||||
((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"),
|
||||
@@ -620,7 +630,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
caption = "The host makes a display exactly this size — no scaling. Native follows " +
|
||||
"this device's panel.",
|
||||
) { (w, h) ->
|
||||
if (w < 0) {
|
||||
// ONLY -1 is "Custom…". The other negative value is the safe-area sentinel, which is a
|
||||
// stored mode like any preset — a blanket `w < 0` here would open the custom fields for it
|
||||
// and overwrite it with a concrete size.
|
||||
if (w == -1) {
|
||||
// Seed from the current *effective* size so the fields start from something
|
||||
// sensible (the resolved native mode, not the 0 × 0 placeholder).
|
||||
customPicked = true
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
// The console UI's background palettes. These assertions are the CONTRACT the Rust
|
||||
// (`pf-console-ui::library::tint`) and Swift (`GamepadPalette.tint`) ports have to reproduce — the
|
||||
// same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette` value
|
||||
// names the same colour family on every client.
|
||||
class GamepadPaletteTest {
|
||||
/** The brightest pool of the field — the colour a palette is judged by. */
|
||||
private val violetPool = Triple(0.49, 0.39, 0.95)
|
||||
|
||||
/**
|
||||
* The brand default must be the IDENTITY transform. Every existing install already sees the
|
||||
* shipped violet backdrop, and a palette table that quietly restyled it would be a regression
|
||||
* dressed as a feature.
|
||||
*/
|
||||
@Test
|
||||
fun violetIsTheUntouchedShippedField() {
|
||||
val violet = GamepadPalette.named("violet")
|
||||
assertEquals("violet", GamepadPalette.ALL.first().id)
|
||||
assertTrue(violet.isIdentity)
|
||||
assertEquals(violetPool, violet.tint(violetPool))
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
assertEquals("violet", GamepadPalette.named("chartreuse").id)
|
||||
assertEquals("violet", GamepadPalette.named("").id)
|
||||
}
|
||||
|
||||
/** The ids and their order are the cross-client contract (strip order, and the L1/R1 cycle). */
|
||||
@Test
|
||||
fun tableMatchesTheOtherClients() {
|
||||
assertEquals(
|
||||
listOf("violet", "tide", "forest", "ember", "rose", "graphite"),
|
||||
GamepadPalette.ALL.map { it.id },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"),
|
||||
GamepadPalette.ALL.map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A rotation moves the hue while roughly holding luminance, and the saturation scale collapses
|
||||
* toward grey — the same four checks the Rust and Swift tests make.
|
||||
*/
|
||||
@Test
|
||||
fun tintRotatesHueAndScalesSaturation() {
|
||||
assertTrue(violetPool.third > violetPool.first && violetPool.third > violetPool.second)
|
||||
|
||||
// +105° (Ember) turns the blue-dominant pool red-dominant…
|
||||
val ember = GamepadPalette.named("ember").tint(violetPool)
|
||||
assertTrue("$ember should be warm", ember.first > ember.third)
|
||||
// …−130° (Forest) turns it green-dominant…
|
||||
val forest = GamepadPalette.named("forest").tint(violetPool)
|
||||
assertTrue("$forest", forest.second > forest.first && forest.second > forest.third)
|
||||
// …and −70° (Tide) lands on a cyan whose green and blue both beat red.
|
||||
val tide = GamepadPalette.named("tide").tint(violetPool)
|
||||
assertTrue("$tide", tide.second > tide.first && tide.third > tide.first)
|
||||
|
||||
// Graphite's saturation scale leaves the channels nearly equal…
|
||||
val grey = GamepadPalette.named("graphite").tint(violetPool)
|
||||
val channels = listOf(grey.first, grey.second, grey.third)
|
||||
assertTrue("$grey", channels.max() - channels.min() < 0.08)
|
||||
// …at about the source's luminance (it desaturates, it doesn't dim).
|
||||
val luma = 0.2126 * violetPool.first + 0.7152 * violetPool.second + 0.0722 * violetPool.third
|
||||
assertEquals(luma, grey.second, 0.05)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every palette stays in gamut on every colour the field is built from — an out-of-range
|
||||
* channel would clamp differently on each platform's rasteriser.
|
||||
*/
|
||||
@Test
|
||||
fun everyPaletteStaysInGamut() {
|
||||
val field = listOf(
|
||||
Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72), Triple(0.30, 0.26, 0.74),
|
||||
Triple(0.42, 0.20, 0.54), Triple(0.49, 0.39, 0.95), Triple(0.28, 0.31, 0.84),
|
||||
Triple(0.16, 0.26, 0.64), Triple(0.45, 0.23, 0.60), Triple(0.53, 0.31, 0.75),
|
||||
Triple(0.35, 0.35, 0.91), Triple(0.19, 0.28, 0.70), Triple(0.22, 0.18, 0.54),
|
||||
Triple(0.24, 0.20, 0.58),
|
||||
)
|
||||
for (palette in GamepadPalette.ALL) {
|
||||
for (c in field) {
|
||||
val t = palette.tint(c)
|
||||
for (v in listOf(t.first, t.second, t.third)) {
|
||||
assertTrue("${palette.id} $c → $t", v in 0.0..1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every settings row lands in exactly one tab — a row missing from the tab map is a setting
|
||||
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
|
||||
*/
|
||||
@Test
|
||||
fun everySettingsRowHasATab() {
|
||||
val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {}
|
||||
assertTrue(rows.isNotEmpty())
|
||||
assertEquals(rows.size, rows.map { it.id }.toSet().size)
|
||||
// Profiles is built separately (from the catalog), so no settings row claims it.
|
||||
assertTrue(rows.none { it.tab == GpTab.PROFILES })
|
||||
for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) {
|
||||
assertTrue("$t is empty", rows.any { it.tab == t })
|
||||
}
|
||||
}
|
||||
|
||||
/** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */
|
||||
@Test
|
||||
fun backgroundRowStepsTheSharedKey() {
|
||||
var s = Settings()
|
||||
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
|
||||
fun palette() = rows().first { it.id == "palette" }
|
||||
|
||||
assertEquals("violet", s.uiPalette)
|
||||
assertEquals("Violet", palette().value)
|
||||
assertTrue("already the first = thud", !palette().adjust(-1))
|
||||
assertTrue(palette().adjust(1))
|
||||
assertEquals(GamepadPalette.ALL[1].id, s.uiPalette)
|
||||
|
||||
// A from the last entry wraps home.
|
||||
s = s.copy(uiPalette = GamepadPalette.ALL.last().id)
|
||||
palette().activate()
|
||||
assertEquals("violet", s.uiPalette)
|
||||
|
||||
// A store written by a newer client shows the palette that is actually drawing.
|
||||
s = s.copy(uiPalette = "chartreuse")
|
||||
assertEquals("Violet", palette().value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of the safe-area stream geometry ([SafeArea]) and the sentinel that selects it —
|
||||
* the width-only inset that keeps the picture clear of the cutout and the rounded corners.
|
||||
* Run: `./gradlew :app:testDebugUnitTest`.
|
||||
*/
|
||||
class SafeAreaTest {
|
||||
@Test
|
||||
fun insetsBothSidesAndStaysHostValid() {
|
||||
// A punch-hole phone: 2400 px wide, 96 px of unsafe edge per side → 2208.
|
||||
assertEquals(2400 - 96 * 2, SafeArea.insetWidth(2400, 96))
|
||||
// Odd results even-floor — the host rejects odd dimensions outright, and an inset
|
||||
// subtraction lands odd about half the time.
|
||||
assertEquals(0, SafeArea.insetWidth(2401, 95) % 2)
|
||||
// No cutout and square corners → the native width, unchanged.
|
||||
assertEquals(2400, SafeArea.insetWidth(2400, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absurdInsetsCannotDriveTheModeUnderTheHostFloor() {
|
||||
assertEquals(SafeArea.MIN_WIDTH, SafeArea.insetWidth(1280, 5000))
|
||||
// A negative reading is treated as no inset rather than widening past the panel.
|
||||
assertEquals(1280, SafeArea.insetWidth(1280, -40))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun safeModeIsNarrowerThanNativeWheneverThereIsAnInset() {
|
||||
val native = 2556
|
||||
assertTrue(SafeArea.insetWidth(native, 60) < native)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSentinelIsAPresetAndNeverReadsAsCustom() {
|
||||
// The safe-area mode is a stored preset, not a typed size: `isCustomResolution` must be
|
||||
// false for it, or the touch settings would open the custom width/height fields on it and
|
||||
// the gamepad screen would prepend a bogus "Custom · -2 × -2" row.
|
||||
val s = Settings(width = SAFE_AREA_MODE, height = SAFE_AREA_MODE)
|
||||
assertTrue(!s.isCustomResolution())
|
||||
// And it must be distinct from the UI's own "Custom…" sentinel (-1).
|
||||
assertTrue(SAFE_AREA_MODE != -1)
|
||||
assertTrue(RESOLUTION_OPTIONS.any { it.first == SAFE_AREA_MODE && it.second == SAFE_AREA_MODE })
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,9 @@ class ScreenshotTest {
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -31,6 +31,7 @@ import io.unom.punktfunk.BrandDark
|
||||
import io.unom.punktfunk.ConnectModal
|
||||
import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsCategory
|
||||
@@ -406,3 +407,13 @@ internal fun WakeTimedOutScene() =
|
||||
@Composable
|
||||
internal fun ConnectConsoleScene() =
|
||||
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
/**
|
||||
* The real console settings screen — the section tab strip, the glass rows, the focused row's
|
||||
* unfolded detail, and the living (calmed) backdrop behind them. The touch [SettingsScene] can't
|
||||
* stand in for it: this is a different screen with different navigation, and the strip is the part
|
||||
* a layout regression would eat first.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleSettingsScene() =
|
||||
GamepadSettingsScreen(initial = SHOT_SETTINGS, onChange = {}, onBack = {})
|
||||
|
||||
@@ -132,6 +132,27 @@ class HostDiscovery(context: Context) {
|
||||
handler.post(poll)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear the browse down and start a fresh one. This is the manual rescan, and the recovery path
|
||||
* for a browse that started while blocked (permission not yet granted, multicast filtered) or
|
||||
* that never started at all ([start] gives up when `nativeDiscoveryStart` returns 0, and
|
||||
* nothing else would ever retry it).
|
||||
*
|
||||
* It also puts a query back on the wire: `mdns-sd` re-queries on a doubling backoff that caps
|
||||
* at an hour, so a long-lived browse is effectively passive — a host that appeared since, or
|
||||
* whose announcement was lost to multicast, may never be asked for again.
|
||||
*
|
||||
* The currently-shown host set is left alone across the swap (rather than blinking empty via
|
||||
* [stop]'s notification); the first poll of the new browse publishes the fresh set.
|
||||
*/
|
||||
fun restart() {
|
||||
val keep = onChange
|
||||
onChange = null
|
||||
stop()
|
||||
onChange = keep
|
||||
start()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!running && nativeHandle == 0L) return
|
||||
running = false
|
||||
|
||||
@@ -206,6 +206,20 @@ struct ContentView: View {
|
||||
model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal)
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
// Coming back to the app re-arms the LAN browse. The home's `onAppear`/`onDisappear` do
|
||||
// NOT fire across background/foreground, and a browse the system suspended while we were
|
||||
// away does not resume on its own — so the host grid came back empty and stayed empty
|
||||
// until the app was relaunched. No-op unless the browse is already running (mid-session
|
||||
// the home has deliberately torn it down).
|
||||
//
|
||||
// Mobile only: macOS never suspends the process, and its `scenePhase` flips on every
|
||||
// window focus change — re-arming there would rebuild the browser each time you alt-tab.
|
||||
// A Mac browse that genuinely breaks is caught by `HostDiscovery`'s own sweep instead.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { discovery.refreshIfRunning() }
|
||||
}
|
||||
#endif
|
||||
#if os(iOS) || os(tvOS)
|
||||
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
|
||||
// ignored so neither branch fires for a Control-Center pull.
|
||||
//
|
||||
|
||||
@@ -122,12 +122,21 @@ struct GamepadHintBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black
|
||||
/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an
|
||||
/// animated `MeshGradient` — a continuous silk of colour whose control points wander on slow,
|
||||
/// out-of-phase sinusoids — finished with an elliptical vignette (pools light in the centre, sinks
|
||||
/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
|
||||
/// radial-blob field, unchanged, so nothing regresses.
|
||||
/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind
|
||||
/// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` — a continuous
|
||||
/// silk of colour whose control points wander on slow, out-of-phase sinusoids — finished with an
|
||||
/// elliptical vignette (pools light in the centre, sinks the corners) and a top/bottom legibility
|
||||
/// scrim. Older OSes fall back to the original drifting radial-blob field, unchanged, so nothing
|
||||
/// regresses.
|
||||
///
|
||||
/// `calm` is what the FORM screens (settings, add-host) wear: the same living field with its pools
|
||||
/// dimmed onto its own corner colour, so those screens keep real colour under their Liquid Glass
|
||||
/// rows without the launcher's contrast. They used to sit on a still gradient; nothing in the
|
||||
/// gamepad UI is backed by a static image now. Motion is identical in both modes on purpose — only
|
||||
/// the contrast differs, so a screen change can't make the field jump.
|
||||
///
|
||||
/// `GamepadPalette` recolours the whole thing (the shared `ui_palette` setting) by transforming the
|
||||
/// COLOURS, not by stacking a filter — see GamepadPalette.swift for why.
|
||||
///
|
||||
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
|
||||
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
|
||||
@@ -136,35 +145,52 @@ struct GamepadHintBar: View {
|
||||
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
|
||||
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||
struct GamepadScreenBackground: View {
|
||||
/// Quiet the field for a form screen (see the type comment).
|
||||
var calm = false
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
|
||||
var body: some View {
|
||||
let palette = GamepadPalette.named(paletteID)
|
||||
Group {
|
||||
if reduceMotion {
|
||||
composite(at: 0)
|
||||
composite(at: 0, palette: palette)
|
||||
} else {
|
||||
// 30 Hz is plenty for a field that drifts centimetres per minute, and halves the
|
||||
// redraw cost of a battery-fed couch device vs. the display's native rate.
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||
composite(at: context.date.timeIntervalSinceReferenceDate)
|
||||
composite(at: context.date.timeIntervalSinceReferenceDate, palette: palette)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
/// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the
|
||||
/// title/hints legibility scrim.
|
||||
private func composite(at t: TimeInterval) -> some View {
|
||||
/// The colour field under a very slow warm/cool hue sway, the calm flattening, an elliptical
|
||||
/// vignette, and the title/hints legibility scrim — in that order, matching the console
|
||||
/// shader's `composite` so the two platforms' backdrops stay the same picture.
|
||||
private func composite(at t: TimeInterval, palette: GamepadPalette) -> some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
colorField(at: t)
|
||||
colorField(at: t, palette: palette)
|
||||
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||||
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||||
// Calm = col·0.6 + corner·0.4: over black, `.opacity` IS the multiply…
|
||||
.opacity(calm ? 0.6 : 1)
|
||||
if calm {
|
||||
// …and a plusLighter wash of the palette's own corner colour IS the add. Chosen so
|
||||
// a corner lands exactly where it was and the bright pools come down to meet it.
|
||||
Self.color(palette.tint(Self.cornerRGB))
|
||||
.opacity(0.4)
|
||||
.blendMode(.plusLighter)
|
||||
}
|
||||
// Cinematic vignette: darker toward the edges so the cards sit in the pooled light.
|
||||
// Soft (extends past the frame) so the corners deepen rather than crush to black.
|
||||
// Halved under calm: a launcher's cards sit in the pooled centre, but a form screen's
|
||||
// rows run out toward the edges, where crushing to black just eats them.
|
||||
EllipticalGradient(
|
||||
colors: [.clear, .black.opacity(0.42)],
|
||||
colors: [.clear, .black.opacity(calm ? 0.21 : 0.42)],
|
||||
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
|
||||
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
|
||||
// darkens the aurora itself (it's the backdrop's bottom layer — nothing behind it to
|
||||
@@ -180,33 +206,45 @@ struct GamepadScreenBackground: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func colorField(at t: TimeInterval) -> some View {
|
||||
@ViewBuilder private func colorField(at t: TimeInterval, palette: GamepadPalette) -> some View {
|
||||
if #available(iOS 18, macOS 15, tvOS 18, *) {
|
||||
MeshGradient(
|
||||
width: 4, height: 4,
|
||||
points: Self.meshPoints(at: t),
|
||||
colors: Self.meshColors,
|
||||
colors: Self.meshColors(palette),
|
||||
smoothsColors: true)
|
||||
} else {
|
||||
LegacyBlobField(t: t)
|
||||
LegacyBlobField(t: t, palette: palette)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MeshGradient aurora (iOS 18 / macOS 15+)
|
||||
|
||||
static func color(_ c: SIMD3<Double>) -> Color {
|
||||
Color(red: c.x, green: c.y, blue: c.z)
|
||||
}
|
||||
|
||||
/// The corner colour — the four pinned corners AND the calm lift's base.
|
||||
static let cornerRGB = SIMD3(0.075, 0.060, 0.160)
|
||||
|
||||
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
|
||||
/// mid-tone violets, and the four interior points hold the bright brand family — a violet and a
|
||||
/// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on
|
||||
/// the right, and the silk shifts temperature as those interior points drift.
|
||||
private static let meshColors: [Color] = {
|
||||
let corner = Color(red: 0.075, green: 0.060, blue: 0.160)
|
||||
return [
|
||||
corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner,
|
||||
Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64),
|
||||
Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70),
|
||||
corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner,
|
||||
]
|
||||
}()
|
||||
/// the right, and the silk shifts temperature as those interior points drift. A palette rotates
|
||||
/// the whole grid; `violet` is the identity, so this array IS what the default draws.
|
||||
private static let baseMeshRGB: [SIMD3<Double>] = [
|
||||
cornerRGB, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), cornerRGB,
|
||||
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64),
|
||||
SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70),
|
||||
cornerRGB, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), cornerRGB,
|
||||
]
|
||||
|
||||
/// `baseMeshRGB` under a palette. Recomputed per frame rather than cached — sixteen `tint`
|
||||
/// calls at 30 Hz costs nothing next to rasterising the mesh, and the obvious cache would be
|
||||
/// mutable global state on a type SwiftUI is free to evaluate off the main actor.
|
||||
private static func meshColors(_ palette: GamepadPalette) -> [Color] {
|
||||
baseMeshRGB.map { color(palette.tint($0)) }
|
||||
}
|
||||
|
||||
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
|
||||
/// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black
|
||||
@@ -233,15 +271,18 @@ struct GamepadScreenBackground: View {
|
||||
}
|
||||
|
||||
/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four
|
||||
/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see
|
||||
/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+).
|
||||
/// soft colour blobs on slow Lissajous paths, additively blended. Geometry and motion are verbatim
|
||||
/// so older OSes see exactly the aurora they shipped with (the mesh path is the upgrade for OS
|
||||
/// 18/15+); only the blob COLOURS now pass through the palette, so an older device honours the
|
||||
/// setting too instead of being stuck on violet.
|
||||
private struct LegacyBlobField: View {
|
||||
let t: TimeInterval
|
||||
let palette: GamepadPalette
|
||||
|
||||
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
|
||||
/// (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
||||
private struct Blob {
|
||||
let color: Color
|
||||
let rgb: SIMD3<Double>
|
||||
let center: CGPoint
|
||||
let drift: CGSize
|
||||
let speed: (x: Double, y: Double)
|
||||
@@ -252,19 +293,19 @@ private struct LegacyBlobField: View {
|
||||
}
|
||||
|
||||
private static let blobs: [Blob] = [
|
||||
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
|
||||
Blob(rgb: SIMD3(0.53, 0.47, 0.96), // brand violet
|
||||
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
||||
speed: (0.111, 0.083), phase: (0.0, 1.9),
|
||||
radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52),
|
||||
Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo
|
||||
Blob(rgb: SIMD3(0.24, 0.20, 0.72), // deep indigo
|
||||
center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
|
||||
speed: (0.071, 0.096), phase: (2.4, 0.7),
|
||||
radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55),
|
||||
Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum
|
||||
Blob(rgb: SIMD3(0.62, 0.30, 0.80), // plum
|
||||
center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
|
||||
speed: (0.089, 0.067), phase: (4.1, 3.2),
|
||||
radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42),
|
||||
Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue
|
||||
Blob(rgb: SIMD3(0.22, 0.38, 0.86), // cool blue
|
||||
center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
|
||||
speed: (0.059, 0.104), phase: (1.2, 5.0),
|
||||
radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38),
|
||||
@@ -287,9 +328,10 @@ private struct LegacyBlobField: View {
|
||||
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
||||
let r = side * blob.radius
|
||||
* (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
|
||||
let color = GamepadScreenBackground.color(palette.tint(blob.rgb))
|
||||
return Circle()
|
||||
.fill(RadialGradient(
|
||||
colors: [blob.color, blob.color.opacity(0)],
|
||||
colors: [color, color.opacity(0)],
|
||||
center: .center, startRadius: 0, endRadius: r / 2))
|
||||
.frame(width: r, height: r)
|
||||
.position(x: x * size.width, y: y * size.height)
|
||||
@@ -330,27 +372,16 @@ struct GamepadTrayScrim: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The calm backdrop for the gamepad UI's form screens (settings, add-host) — NOT the launcher's
|
||||
/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid
|
||||
/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo
|
||||
/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens,
|
||||
/// so the rows read as glass while the screen stays restful.
|
||||
/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
|
||||
/// of glows over a deep indigo base — deliberately not near-black, because Liquid Glass refracts
|
||||
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
|
||||
/// living field at `calm`, which keeps that luminance under the glass, keeps the palette setting
|
||||
/// honoured on every screen rather than only the launcher, and leaves nothing in the gamepad UI
|
||||
/// backed by a static image. Kept as its own type because that is what the form screens ask for by
|
||||
/// name; the console (`pf-console-ui`) made the same substitution behind its `Bg::Form`.
|
||||
struct GamepadFormBackground: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.075, green: 0.062, blue: 0.150)
|
||||
// Violet lift top-leading, cooler indigo bottom-trailing — resolution-independent
|
||||
// (fraction radii) so the glow scale tracks the window on any screen.
|
||||
EllipticalGradient(
|
||||
colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear],
|
||||
center: UnitPoint(x: 0.26, y: 0.14),
|
||||
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||
EllipticalGradient(
|
||||
colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear],
|
||||
center: UnitPoint(x: 0.82, y: 0.9),
|
||||
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
GamepadScreenBackground(calm: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,14 +23,15 @@ import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
||||
/// action. Hashable so it can be the carousel's scroll-position identity.
|
||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or one of the trailing
|
||||
/// actions. Hashable so it can be the carousel's scroll-position identity.
|
||||
private enum GamepadHomeTarget: Hashable {
|
||||
/// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) — which on a
|
||||
/// controller-first surface are THE profile affordance: focus and press, no menus.
|
||||
case saved(UUID, profile: String?)
|
||||
case discovered(String)
|
||||
case addHost
|
||||
case rescan
|
||||
}
|
||||
|
||||
/// A fully-resolved launcher tile — display fields + the activate action, built fresh each render
|
||||
@@ -262,10 +263,14 @@ struct GamepadHomeView: View {
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
let selected = tiles.first { $0.id == selection }
|
||||
let action: String? = switch selected?.id {
|
||||
case .addHost: "Add Host"
|
||||
case .rescan: "Rescan"
|
||||
default: nil
|
||||
}
|
||||
var hints = [GamepadHint(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||
text: selected?.id == .addHost ? "Add Host"
|
||||
: (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||
if libraryEnabled, selected?.hasLibrary == true {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
||||
}
|
||||
@@ -325,7 +330,15 @@ struct GamepadHomeView: View {
|
||||
subtitle: "Register a host by address",
|
||||
icon: "plus",
|
||||
activate: { showAddHost = true })
|
||||
return saved + discovered + [add]
|
||||
// A controller surface has no toolbar and no pull-to-refresh, so the rescan the field
|
||||
// asked for is a tile like any other — one press from wherever the stick already is.
|
||||
let rescan = HomeTile(
|
||||
id: .rescan,
|
||||
title: "Rescan",
|
||||
subtitle: discovery.isScanning ? "Scanning…" : "Look for hosts on this network",
|
||||
icon: "arrow.clockwise",
|
||||
activate: { discovery.refresh() })
|
||||
return saved + discovered + [add, rescan]
|
||||
}
|
||||
|
||||
/// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a
|
||||
|
||||
@@ -35,6 +35,10 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
let onActivate: (Item) -> Void
|
||||
/// B → back/dismiss; nil disables it.
|
||||
var onBack: (() -> Void)?
|
||||
/// L1 (`-1`) / R1 (`+1`) — a step SIDEWAYS out of the list: the settings screen's section
|
||||
/// tabs. Wired on tvOS too, where the focus engine owns up/down but leaves the shoulders
|
||||
/// to the poll. nil ⇒ the shoulders do nothing.
|
||||
var onShoulder: ((Int) -> Void)?
|
||||
/// Whether this list currently owns controller input — same handoff contract as
|
||||
/// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad).
|
||||
var isActive: Bool = true
|
||||
@@ -159,6 +163,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
case .up, .down: break
|
||||
}
|
||||
}
|
||||
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
|
||||
#else
|
||||
input.onMove = { direction in
|
||||
switch direction {
|
||||
@@ -170,6 +175,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
}
|
||||
input.onConfirm = { activate() }
|
||||
input.onBack = onBack
|
||||
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,18 @@ struct HomeView: View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
|
||||
emptyState
|
||||
#if os(tvOS)
|
||||
emptyState // no pull-to-refresh on a remote; the action row carries Refresh
|
||||
#else
|
||||
// Inside a ScrollView purely so the pull gesture works on the ONE screen
|
||||
// where a rescan matters most: the one that found nothing.
|
||||
ScrollView {
|
||||
emptyState
|
||||
.frame(maxWidth: .infinity)
|
||||
.containerRelativeFrame(.vertical)
|
||||
}
|
||||
.refreshable { await discovery.rescan() }
|
||||
#endif
|
||||
} else {
|
||||
ScrollView {
|
||||
if !store.hosts.isEmpty {
|
||||
@@ -94,6 +105,7 @@ struct HomeView: View {
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
refreshButton
|
||||
}
|
||||
.padding(.top, 24)
|
||||
// One FULL-WIDTH focus target for any downward move out of the grid.
|
||||
@@ -106,6 +118,9 @@ struct HomeView: View {
|
||||
.focusSection()
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.refreshable { await discovery.rescan() }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.navigationTitle("Punktfunk")
|
||||
@@ -151,6 +166,7 @@ struct HomeView: View {
|
||||
if showsArrangeMenu {
|
||||
ToolbarItem(placement: .topBarTrailing) { arrangeMenu }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) { refreshButton }
|
||||
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
||||
#else
|
||||
if showsArrangeMenu {
|
||||
@@ -159,6 +175,10 @@ struct HomeView: View {
|
||||
.help("Sort and group the host list")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
refreshButton
|
||||
.help("Scan the network for hosts again")
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
addHostButton
|
||||
.help("Add a host")
|
||||
@@ -324,13 +344,20 @@ struct HomeView: View {
|
||||
ContentUnavailableView {
|
||||
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
|
||||
} description: {
|
||||
Text("Add your punktfunk host with the + button.")
|
||||
Text("Add your punktfunk host with the + button, or scan the network again.")
|
||||
} actions: {
|
||||
Button("Add Host") { showAddHost = true }
|
||||
.glassProminentButtonStyle()
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
// The screen a host SHOULD have appeared on is where a rescan is worth offering
|
||||
// outright rather than hiding behind a pull gesture.
|
||||
Button("Scan Again") { discovery.refresh() }
|
||||
.disabled(discovery.isScanning)
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
Button("Settings") { showSettings = true }
|
||||
#endif
|
||||
@@ -345,6 +372,18 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-run mDNS discovery from scratch. Discovery heals itself now (`HostDiscovery`'s sweep),
|
||||
/// so this is the fallback the field asked for — and the fastest way past the iOS
|
||||
/// local-network permission gate, which only a NEW browser can clear.
|
||||
private var refreshButton: some View {
|
||||
Button {
|
||||
discovery.refresh()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(discovery.isScanning)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// One host has no order and nothing to divide, so the control stays out of the way until
|
||||
/// there is a list to arrange.
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
|
||||
// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud.
|
||||
//
|
||||
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||||
// The rows are split across SECTION TABS (`GpSettingsTab`) — L1/R1 on a pad, a tap elsewhere. They
|
||||
// used to be one long scroll with inline group headers, which meant thumbing past Video and Audio
|
||||
// to reach the controller settings; a tab is one shoulder press, and each tab remembers where its
|
||||
// focus was. The tab names match the desktop console's and the Android client's, so a setting is
|
||||
// found under the same word wherever you look for it.
|
||||
//
|
||||
// The trailing Profiles tab (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||||
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an
|
||||
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
|
||||
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
|
||||
@@ -27,6 +33,17 @@ import GameController
|
||||
import CoreHaptics
|
||||
#endif
|
||||
|
||||
/// The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
|
||||
/// match `pf-console-ui`'s `TABS` and the Android client's `GpTab`.
|
||||
enum GpSettingsTab: String, CaseIterable, Hashable {
|
||||
case stream = "Stream"
|
||||
case video = "Video"
|
||||
case audio = "Audio"
|
||||
case controller = "Controller"
|
||||
case interface = "Interface"
|
||||
case profiles = "Profiles"
|
||||
}
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
@@ -55,6 +72,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as
|
||||
/// the row steps, which is why the picker lives here and not in a sheet.
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@@ -74,12 +94,23 @@ struct GamepadSettingsView: View {
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so more rows fit.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
/// `.regular` only on an iPad-class window — see `showsSectionHint`.
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
@State private var focusID: String?
|
||||
/// The section showing. The pin picker ignores it — that layer replaces the whole list.
|
||||
@State private var tab: GpSettingsTab = .stream
|
||||
/// Where each tab's focus was when it was last left, so a detour doesn't lose your place.
|
||||
@State private var tabFocus: [GpSettingsTab: String] = [:]
|
||||
@Namespace private var tabHighlight
|
||||
#if os(tvOS)
|
||||
/// Real focus on the strip — the tvOS route to the sections (see `tabStrip`).
|
||||
@FocusState private var focusedTab: GpSettingsTab?
|
||||
#endif
|
||||
/// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per
|
||||
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
|
||||
@State private var pinTarget: StreamProfile?
|
||||
@@ -93,7 +124,8 @@ struct GamepadSettingsView: View {
|
||||
focusID: $focusID,
|
||||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { back() }
|
||||
onBack: { back() },
|
||||
onShoulder: { step(tabBy: $0) }
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -101,14 +133,19 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
VStack(spacing: compact ? 4 : 8) {
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||||
// The picker is one layer deeper — its rows aren't sections of anything, so the
|
||||
// strip would be a control that does nothing while it's up.
|
||||
if pinTarget == nil { tabStrip }
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -127,8 +164,9 @@ struct GamepadSettingsView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
}
|
||||
// No aurora here — the settings read as clean Liquid Glass over a quiet dark base, so the
|
||||
// glass rows are the only material on the screen.
|
||||
// The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real
|
||||
// colour and luminance to lens without the launcher's contrast, and the palette setting
|
||||
// applies here too, so this screen previews the row you're stepping.
|
||||
.background { GamepadFormBackground() }
|
||||
.onAppear {
|
||||
gamepads.refresh()
|
||||
@@ -137,6 +175,101 @@ struct GamepadSettingsView: View {
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
}
|
||||
|
||||
/// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to
|
||||
/// squeeze six pills — the selected one is always scrolled into view, whether it was reached
|
||||
/// by shoulder button, tap, or (tvOS) the focus engine.
|
||||
private var tabStrip: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(GpSettingsTab.allCases, id: \.self) { t in
|
||||
#if os(tvOS)
|
||||
// Focusable, because L1/R1 is NOT a route here: a Siri Remote has no
|
||||
// extended gamepad profile, so it never reaches GamepadMenuList's poll.
|
||||
// As focusable Buttons the pills are simply above the rows, and moving
|
||||
// focus up onto one switches section — the standard tvOS tab bar.
|
||||
Button { select(tab: t) } label: { pill(t) }
|
||||
.buttonStyle(ConsoleBareButtonStyle())
|
||||
.focused($focusedTab, equals: t)
|
||||
.id(t)
|
||||
#else
|
||||
pill(t)
|
||||
.contentShape(Capsule())
|
||||
.onTapGesture { select(tab: t) }
|
||||
.id(t)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.animation(.smooth(duration: 0.22), value: tab)
|
||||
.onChange(of: tab) { _, t in
|
||||
withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(t) }
|
||||
}
|
||||
#if os(tvOS)
|
||||
.onChange(of: focusedTab) { _, t in
|
||||
// Focus IS selection on a tab bar; nil means focus dropped back into the rows.
|
||||
if let t { select(tab: t) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func pill(_ t: GpSettingsTab) -> some View {
|
||||
let selected = t == tab
|
||||
return Text(t.rawValue)
|
||||
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
|
||||
.foregroundStyle(selected ? .white : .white.opacity(0.55))
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 7)
|
||||
.background {
|
||||
// One shared capsule that MOVES between pills, rather than one per pill fading
|
||||
// in and out — the highlight travels the way the press did.
|
||||
if selected {
|
||||
Capsule()
|
||||
.fill(Color.brand.opacity(0.85))
|
||||
.matchedGeometryEffect(id: "tab", in: tabHighlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the legend advertises the shoulder shortcut. Held back on an iPhone, whose legend
|
||||
/// is already at its width and would push "Done" off the edge — the strip is visible and
|
||||
/// tappable there anyway. Never on tvOS: a Siri Remote has no shoulders, and its route to the
|
||||
/// sections is the focus engine (see `tabStrip`).
|
||||
private var showsSectionHint: Bool {
|
||||
#if os(tvOS)
|
||||
false
|
||||
#elseif os(iOS)
|
||||
hSizeClass == .regular
|
||||
#else
|
||||
true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
|
||||
private func step(tabBy delta: Int) {
|
||||
guard pinTarget == nil else { return }
|
||||
let all = GpSettingsTab.allCases
|
||||
guard let i = all.firstIndex(of: tab) else { return }
|
||||
let n = all.count
|
||||
select(tab: all[((i + delta) % n + n) % n])
|
||||
}
|
||||
|
||||
private func select(tab next: GpSettingsTab) {
|
||||
guard next != tab else { return }
|
||||
tabFocus[tab] = focusID
|
||||
// Restore where this tab was, if that row is still in it (a row can come and go with the
|
||||
// hardware it depends on); otherwise the focus list seeds its first row. Resolved against
|
||||
// `allRows` rather than `rows` so it doesn't depend on `tab`'s write being visible yet.
|
||||
let landing = tabFocus[next].flatMap { id in
|
||||
allRows.contains { $0.tab == next && $0.id == id } ? id : nil
|
||||
}
|
||||
tab = next
|
||||
focusID = landing
|
||||
}
|
||||
|
||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||
/// rides the cancel action.
|
||||
private var closeButton: some View {
|
||||
@@ -166,12 +299,19 @@ struct GamepadSettingsView: View {
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
guard pinTarget != nil else {
|
||||
// The shoulders change section, so that cell leads — where it fits and where the
|
||||
// shoulders exist at all (see `showsSectionHint`).
|
||||
let sections: [GamepadHint] = showsSectionHint
|
||||
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||
text: "Section")]
|
||||
: []
|
||||
// A dimmed row takes neither, so offering them would be the same lie the row itself
|
||||
// used to tell — only Done remains, and the detail line says what to turn on first.
|
||||
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
||||
return sections
|
||||
+ [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
||||
}
|
||||
return [
|
||||
return sections + [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
@@ -201,15 +341,9 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = GamepadFormMetrics.self
|
||||
// No section header: the tab strip names the section now, and repeating it above the
|
||||
// first row of every tab was just a second label saying the same word.
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
if let header = row.header {
|
||||
Text(header)
|
||||
.font(.geist(m.headerFont, .semibold, relativeTo: .caption))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.padding(.leading, m.rowHPad)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: row.icon)
|
||||
.font(.system(size: m.iconFont))
|
||||
@@ -276,8 +410,9 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private struct Row: Identifiable {
|
||||
let id: String
|
||||
/// Section header drawn above this row (the first row of each group carries it).
|
||||
var header: String?
|
||||
/// Which section tab this row belongs to. Every row has exactly one, and `rows` shows
|
||||
/// only the current tab's — see `allRows`.
|
||||
var tab: GpSettingsTab = .stream
|
||||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
@@ -313,10 +448,17 @@ struct GamepadSettingsView: View {
|
||||
row.activate()
|
||||
}
|
||||
|
||||
/// What the focus list actually shows: the current tab's rows — or the pin picker's, which
|
||||
/// replaces the whole list while it's up (same screen, one layer deeper, so the focus list's
|
||||
/// controller wiring and the tvOS focus engine carry over as is).
|
||||
private var rows: [Row] {
|
||||
// The pin picker replaces the whole list while it's up — same screen, one layer deeper,
|
||||
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
|
||||
if let profile = pinTarget { return pinRows(for: profile) }
|
||||
return allRows.filter { $0.tab == tab }
|
||||
}
|
||||
|
||||
/// Every row on the screen, tagged with its section. Built as one list (not per tab) so the
|
||||
/// platform-conditional insertions below can still place a row RELATIVE to another by id.
|
||||
private var allRows: [Row] {
|
||||
let resolution = resolutionOptions
|
||||
let refresh = SettingsOptions.refreshRates(including: hz)
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
@@ -324,7 +466,7 @@ struct GamepadSettingsView: View {
|
||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||
var list: [Row] = [
|
||||
choiceRow(
|
||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||
id: "resolution", tab: .stream, icon: "aspectratio",
|
||||
label: "Resolution",
|
||||
detail: "The host creates a virtual display at exactly this size — no scaling.",
|
||||
options: resolution, current: "\(width)x\(height)"
|
||||
@@ -335,53 +477,48 @@ struct GamepadSettingsView: View {
|
||||
height = parts[1]
|
||||
},
|
||||
choiceRow(
|
||||
id: "refresh", icon: "gauge.with.needle", label: "Refresh rate",
|
||||
id: "refresh", tab: .stream, icon: "gauge.with.needle", label: "Refresh rate",
|
||||
detail: "Rates this display can actually show.",
|
||||
options: refresh, current: hz
|
||||
) { hz = $0 },
|
||||
choiceRow(
|
||||
id: "bitrate", icon: "speedometer", label: "Bitrate",
|
||||
id: "bitrate", tab: .stream, icon: "speedometer", label: "Bitrate",
|
||||
detail: "Automatic uses the host's default (20 Mbps). "
|
||||
+ "Run a speed test from the touch UI for an informed value.",
|
||||
options: bitrate, current: bitrateKbps
|
||||
) { bitrateKbps = $0 },
|
||||
choiceRow(
|
||||
id: "compositor", icon: "macwindow", label: "Compositor",
|
||||
id: "compositor", tab: .stream, icon: "macwindow", label: "Compositor",
|
||||
detail: "Which compositor drives the virtual output — honored only if "
|
||||
+ "available on the host.",
|
||||
options: SettingsOptions.compositors, current: compositor
|
||||
) { compositor = $0 },
|
||||
toggleRow(
|
||||
id: "autoWake", icon: "power", label: "Auto-wake on connect",
|
||||
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
|
||||
+ "streaming. Off connects straight through.",
|
||||
value: $autoWakeEnabled),
|
||||
|
||||
choiceRow(
|
||||
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
||||
id: "codec", tab: .video, icon: "film", label: "Video codec",
|
||||
detail: "A preference — the host falls back if it can't encode this one "
|
||||
+ "(10-bit and 4:4:4 are HEVC-only).",
|
||||
options: SettingsOptions.codecs, current: codec
|
||||
) { codec = $0 },
|
||||
toggleRow(
|
||||
id: "hdr", icon: "sun.max", label: "10-bit HDR",
|
||||
id: "hdr", tab: .video, icon: "sun.max", label: "10-bit HDR",
|
||||
detail: "HDR10 — engages when the host sends HDR content and this display "
|
||||
+ "supports it.",
|
||||
value: $hdrEnabled),
|
||||
toggleRow(
|
||||
id: "chroma", icon: "textformat", label: "Full chroma (4:4:4)",
|
||||
id: "chroma", tab: .video, icon: "textformat", label: "Full chroma (4:4:4)",
|
||||
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
|
||||
+ "hardware decode.",
|
||||
value: $enable444),
|
||||
choiceRow(
|
||||
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
|
||||
id: "presentPriority", tab: .video, icon: "rectangle.stack", label: "Prioritize",
|
||||
detail: "Lowest latency shows each frame the moment the display can take it; "
|
||||
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
|
||||
+ "from the next session.",
|
||||
options: SettingsOptions.presentPriorities, current: presentPriority
|
||||
) { presentPriority = $0 },
|
||||
choiceRow(
|
||||
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
|
||||
id: "smoothBuffer", tab: .video, icon: "square.stack.3d.up",
|
||||
label: "Smoothness buffer",
|
||||
detail: "How many frames Smoothness holds — each adds about a refresh of "
|
||||
+ "display latency and absorbs about a refresh of jitter. Only applies "
|
||||
+ "when prioritizing smoothness.",
|
||||
@@ -389,22 +526,22 @@ struct GamepadSettingsView: View {
|
||||
) { smoothBuffer = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||
id: "audio", tab: .audio, icon: "speaker.wave.2", label: "Audio channels",
|
||||
detail: "The speaker layout requested from the host.",
|
||||
options: SettingsOptions.audioChannels, current: audioChannels
|
||||
) { audioChannels = $0 },
|
||||
toggleRow(
|
||||
id: "mic", icon: "mic", label: "Microphone",
|
||||
id: "mic", tab: .audio, icon: "mic", label: "Microphone",
|
||||
detail: "Send this device's microphone to the host's virtual mic.",
|
||||
value: $micEnabled),
|
||||
toggleRow(
|
||||
id: "echoCancel", icon: "waveform", label: "Echo cancellation",
|
||||
id: "echoCancel", tab: .audio, icon: "waveform", label: "Echo cancellation",
|
||||
detail: "Cancel the audio this device plays out of the mic signal — stops "
|
||||
+ "speaker setups feeding the game back to the host.",
|
||||
value: $echoCancel),
|
||||
|
||||
toggleRow(
|
||||
id: "padForward", header: "Controller", icon: "gamecontroller",
|
||||
id: "padForward", tab: .controller, icon: "gamecontroller",
|
||||
label: "Forward controllers",
|
||||
detail: "Send this device's controllers to the host. Turn it off when your "
|
||||
+ "controller already reaches the host another way — USB passthrough such "
|
||||
@@ -415,26 +552,28 @@ struct GamepadSettingsView: View {
|
||||
// `.disabled(!effective.gamepadForwarding)`. This screen could not express it until
|
||||
// `Row.enabled` existed, so it alone left them live and steppable.
|
||||
choiceRow(
|
||||
id: "pad", icon: "gamecontroller", label: "Use controller",
|
||||
id: "pad", tab: .controller, icon: "gamecontroller", label: "Use controller",
|
||||
detail: "Which pad is forwarded to the host, as player 1.",
|
||||
options: controllers, current: gamepads.preferredID,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepads.preferredID = $0 },
|
||||
choiceRow(
|
||||
id: "padType", icon: "dpad", label: "Controller type",
|
||||
id: "padType", tab: .controller, icon: "dpad", label: "Controller type",
|
||||
detail: "The virtual pad the host creates — Automatic matches this controller.",
|
||||
options: SettingsOptions.padTypes, current: gamepadType,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepadType = $0 },
|
||||
choiceRow(
|
||||
id: "systemButtons", icon: "house.circle", label: "Guide button",
|
||||
id: "systemButtons", tab: .controller, icon: "house.circle",
|
||||
label: "Guide button",
|
||||
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
|
||||
+ "Automatic sends them to the host whenever this device delivers them.",
|
||||
options: SettingsOptions.systemButtons, current: systemButtons,
|
||||
enabled: gamepadForwarding
|
||||
) { systemButtons = $0 },
|
||||
choiceRow(
|
||||
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
|
||||
id: "guideGesture", tab: .controller, icon: "hand.point.up.left",
|
||||
label: "Hold Select for guide",
|
||||
detail: "Hold Select alone to press the host's guide button — keep holding "
|
||||
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
|
||||
options: SettingsOptions.guideGestures, current: guideGesture,
|
||||
@@ -442,33 +581,47 @@ struct GamepadSettingsView: View {
|
||||
) { guideGesture = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||
id: "palette", tab: .interface, icon: "paintpalette", label: "Background",
|
||||
detail: "The colour family this backdrop drifts through — it changes as you "
|
||||
+ "step, so pick by looking. Appearance only.",
|
||||
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
|
||||
current: GamepadPalette.named(paletteID).id
|
||||
) { paletteID = $0 },
|
||||
toggleRow(
|
||||
id: "autoWake", tab: .interface, icon: "power", label: "Auto-wake on connect",
|
||||
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
|
||||
+ "streaming. Off connects straight through.",
|
||||
value: $autoWakeEnabled),
|
||||
choiceRow(
|
||||
id: "hud", tab: .interface, icon: "chart.bar", label: "Statistics overlay",
|
||||
detail: "How much to show while streaming — Compact is a one-line pill, "
|
||||
+ "Detailed adds the latency stage breakdown.",
|
||||
options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw
|
||||
) { statsVerbosityRaw = $0 },
|
||||
choiceRow(
|
||||
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
|
||||
id: "hudPlacement", tab: .interface, icon: "rectangle.inset.topright.filled",
|
||||
label: "Overlay position",
|
||||
detail: "Which corner the statistics overlay sits in.",
|
||||
options: SettingsOptions.hudPlacements, current: hudPlacement
|
||||
) { hudPlacement = $0 },
|
||||
toggleRow(
|
||||
id: "library", icon: "square.grid.2x2", label: "Game library",
|
||||
id: "library", tab: .interface, icon: "square.grid.2x2", label: "Game library",
|
||||
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
|
||||
value: $libraryEnabled),
|
||||
toggleRow(
|
||||
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
|
||||
id: "gamepadUI", tab: .interface, icon: "hand.tap",
|
||||
label: "Controller-optimized UI",
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
#if os(macOS)
|
||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
||||
// the Video group) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
// the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
// (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent).
|
||||
if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "windowedSafePresent", icon: "macwindow.badge.plus",
|
||||
id: "windowedSafePresent", tab: .video, icon: "macwindow.badge.plus",
|
||||
label: "Safe windowed presentation",
|
||||
detail: "Windowed streams present in step with the compositor — avoids a "
|
||||
+ "macOS display-driver crash on high-refresh displays, at a small "
|
||||
@@ -478,14 +631,14 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||||
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||||
// practice: hidden where the device itself can't play haptics (iPad).
|
||||
// The device-rumble mirror slots in after "Controller type", inside the Controller tab.
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
|
||||
let at = list.firstIndex(where: { $0.id == "padType" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
|
||||
id: "deviceRumble", tab: .controller,
|
||||
icon: "iphone.radiowaves.left.and.right",
|
||||
label: "Rumble on this iPhone",
|
||||
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
|
||||
+ "for clip-on pads without rumble motors.",
|
||||
@@ -505,17 +658,17 @@ struct GamepadSettingsView: View {
|
||||
private var profileRows: [Row] {
|
||||
guard !profiles.profiles.isEmpty else {
|
||||
return [Row(
|
||||
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
|
||||
id: "noProfiles", tab: .profiles, icon: "slider.horizontal.3",
|
||||
label: "No profiles yet", value: "",
|
||||
detail: emptyCatalogDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return profiles.profiles.enumerated().map { i, profile in
|
||||
return profiles.profiles.map { profile in
|
||||
let pins = store.hosts
|
||||
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
|
||||
return Row(
|
||||
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
|
||||
id: "profile-\(profile.id)", tab: .profiles,
|
||||
icon: "slider.horizontal.3", label: profile.name,
|
||||
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
|
||||
detail: profileDetail,
|
||||
@@ -537,7 +690,8 @@ struct GamepadSettingsView: View {
|
||||
private func pinRows(for profile: StreamProfile) -> [Row] {
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [Row(
|
||||
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
|
||||
id: "noHosts", tab: .profiles, icon: "desktopcomputer",
|
||||
label: "No saved hosts yet",
|
||||
value: "",
|
||||
detail: "Pair with a host first, then pin this profile to it.",
|
||||
adjustable: false,
|
||||
@@ -547,7 +701,7 @@ struct GamepadSettingsView: View {
|
||||
let hostID = host.id
|
||||
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
|
||||
return Row(
|
||||
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
|
||||
id: "pinHost-\(hostID.uuidString)", tab: .profiles, icon: "desktopcomputer",
|
||||
label: host.displayName,
|
||||
value: pinned ? "Pinned" : "Off",
|
||||
detail: "A pinned profile appears as its own card on the host — one press "
|
||||
@@ -609,13 +763,13 @@ struct GamepadSettingsView: View {
|
||||
// MARK: - Row builders
|
||||
|
||||
private func choiceRow<T: Equatable>(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
id: String, tab: GpSettingsTab, icon: String, label: String, detail: String,
|
||||
options: [(label: String, tag: T)], current: T, enabled: Bool = true,
|
||||
write: @escaping (T) -> Void
|
||||
) -> Row {
|
||||
let index = options.firstIndex { $0.tag == current }
|
||||
return Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
id: id, tab: tab, icon: icon, label: label,
|
||||
value: index.map { options[$0].label } ?? "—",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
@@ -638,11 +792,11 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
|
||||
private func toggleRow(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
id: String, tab: GpSettingsTab, icon: String, label: String, detail: String,
|
||||
value: Binding<Bool>, enabled: Bool = true
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
id: id, tab: tab, icon: icon, label: label,
|
||||
value: value.wrappedValue ? "On" : "Off",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
|
||||
@@ -171,14 +171,26 @@ enum SettingsOptions {
|
||||
|
||||
/// This device's native mode first, then the presets, deduped by dimensions (native wins a
|
||||
/// tie).
|
||||
///
|
||||
/// On iOS the native row is followed by its **safe-area** variant, which is the same mode
|
||||
/// narrowed so the picture clears the sensor housing and the rounded corners — see
|
||||
/// [`SafeDisplay`] for why a narrower mode is the whole fix. It is emitted unconditionally and
|
||||
/// left to the dedup below: on a device with no housing the two modes are identical, the
|
||||
/// duplicate is dropped, and no pointless row appears.
|
||||
@MainActor
|
||||
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
|
||||
var native: [(name: String, w: Int, h: Int)] = []
|
||||
#if os(iOS) || os(tvOS)
|
||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
|
||||
native = [("This device",
|
||||
Int(max(bounds.width, bounds.height)),
|
||||
Int(min(bounds.width, bounds.height)))]
|
||||
let nativeW = Int(max(bounds.width, bounds.height))
|
||||
let nativeH = Int(min(bounds.width, bounds.height))
|
||||
native = [("This device", nativeW, nativeH)]
|
||||
#if os(iOS)
|
||||
let safe = SafeDisplay.mode(
|
||||
nativeWidth: nativeW, nativeHeight: nativeH,
|
||||
sideInsetPoints: mainWindowSideInset(), scale: UIScreen.main.nativeScale)
|
||||
native.append(("This device (safe area)", safe.width, safe.height))
|
||||
#endif
|
||||
#else
|
||||
if let screen = NSScreen.main {
|
||||
let scale = screen.backingScaleFactor
|
||||
@@ -191,6 +203,26 @@ enum SettingsOptions {
|
||||
return (native + resolutionPresets).filter { seen.insert("\($0.w)x\($0.h)").inserted }
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The key window's per-side safe-area inset in points, resolved for the LANDSCAPE stream even
|
||||
/// when this settings screen is currently portrait (see `SafeDisplay.sideInsetPoints`).
|
||||
///
|
||||
/// Zero when no window is up yet — the safe mode then equals the native one and `resolutionModes`
|
||||
/// dedups the row away, which is the right answer for a device we can't measure.
|
||||
@MainActor
|
||||
private static func mainWindowSideInset() -> Double {
|
||||
let insets = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first { $0.isKeyWindow }?
|
||||
.safeAreaInsets
|
||||
guard let insets else { return 0 }
|
||||
return SafeDisplay.sideInsetPoints(
|
||||
left: Double(insets.left), right: Double(insets.right), top: Double(insets.top),
|
||||
isPhone: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Refresh rates the device can actually display (no point asking the host to render frames
|
||||
/// the screen can't show), plus any stored custom value so it stays selectable.
|
||||
@MainActor
|
||||
|
||||
@@ -9,6 +9,25 @@
|
||||
//
|
||||
// iOS/tvOS gate Bonjour browsing on Info.plist `NSBonjourServices` listing `_punktfunk._udp`
|
||||
// (Config/Info.plist) — without it the system blocks the browse and nothing is returned.
|
||||
//
|
||||
// SELF-HEALING is what the bookkeeping below is for. Neither Network.framework primitive
|
||||
// recovers on its own, and all three failure modes read as "the host isn't there":
|
||||
//
|
||||
// - `browseResultsChangedHandler` fires only when the result SET changes. A service that is
|
||||
// found but whose resolve fails is never re-offered — from the browser's point of view
|
||||
// nothing changed — so one unlucky resolve hid that host for the life of the process.
|
||||
// - `NWConnection` has no timeout. A resolve that cannot complete (v6-only advert against our
|
||||
// IPv4 pin, Wi-Fi still associating, host mid-reboot) parks in `.preparing`/`.waiting`
|
||||
// forever instead of failing, so the retry path above was never even reached.
|
||||
// - `NWBrowser` parks in `.waiting` when the browse is blocked. On iOS that is where the LOCAL
|
||||
// NETWORK PRIVACY gate lands the first launch after install: the browse starts, the system
|
||||
// puts up its "find and connect to devices on your local network" prompt, and the browser
|
||||
// waits. Granting permission does NOT revive that browser — only a new one sees the grant.
|
||||
//
|
||||
// Every one of those presented as "restarting the app fixes it", which is what field reports
|
||||
// described. A 1 Hz `sweep` therefore times out stuck resolves, retries failed ones on a backoff
|
||||
// and re-arms a browser that stopped working; `refresh()` forces the same recovery immediately,
|
||||
// behind the UI's pull-to-refresh and Refresh button.
|
||||
|
||||
#if canImport(Network)
|
||||
import Foundation
|
||||
@@ -48,12 +67,50 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
||||
public final class HostDiscovery: ObservableObject {
|
||||
/// Currently-visible hosts, deduped by `id`, sorted by name. Main-actor.
|
||||
@Published public private(set) var hosts: [DiscoveredHost] = []
|
||||
/// True for a moment after a rescan is kicked off, so a Refresh control can show that it did
|
||||
/// something on the surfaces with no pull-to-refresh spinner of their own (macOS, tvOS).
|
||||
@Published public private(set) var isScanning = false
|
||||
|
||||
private var browser: NWBrowser?
|
||||
/// Keyed by the service endpoint's description (a stable, Sendable handle we can capture
|
||||
/// into the resolve callbacks without smuggling non-Sendable Network types across hops).
|
||||
private var resolved: [String: DiscoveredHost] = [:]
|
||||
/// Every service the browser currently reports, keyed by the endpoint's description (a stable,
|
||||
/// Sendable handle we can capture into the resolve callbacks without smuggling non-Sendable
|
||||
/// Network types across hops). Held — not just diffed — so a retry can re-resolve a service
|
||||
/// the browser will never report again (see the file header).
|
||||
private var services: [String: NWBrowser.Result] = [:]
|
||||
/// The transport address a completed resolve produced, per service key. The rest of a
|
||||
/// `DiscoveredHost` comes from the advert's TXT, which is re-read on every browse report.
|
||||
private var addresses: [String: (host: String, port: UInt16)] = [:]
|
||||
private var connections: [String: NWConnection] = [:]
|
||||
/// Deadline for each in-flight resolve — `NWConnection` has none of its own.
|
||||
private var deadlines: [String: Date] = [:]
|
||||
/// Consecutive failed resolves per service, and when the next attempt is allowed.
|
||||
private var failures: [String: Int] = [:]
|
||||
private var retryAt: [String: Date] = [:]
|
||||
/// Services whose address should be re-resolved even though we already have one — set by
|
||||
/// `refresh()`. The old address keeps showing until the new one lands, so a rescan never
|
||||
/// blinks the list empty; without this a manual Refresh silently skipped every host it had
|
||||
/// already resolved, which is exactly the host whose address may have moved.
|
||||
private var staleAddresses: Set<String> = []
|
||||
/// Consecutive non-ready browser states, and when to tear it down and re-arm. nil = healthy.
|
||||
private var browserFailures = 0
|
||||
private var browserRearmAt: Date?
|
||||
/// Bumped on every re-arm so callbacks from a superseded browser — and from the resolves it
|
||||
/// started — are ignored instead of clobbering the current generation's bookkeeping.
|
||||
private var generation = 0
|
||||
/// The 1 Hz maintenance tick. Nothing else re-drives a stuck resolve or a sick browser.
|
||||
private var sweep: Task<Void, Never>?
|
||||
private var scanningUntil: Date?
|
||||
|
||||
/// A LAN resolve answers in milliseconds; this only has to outlast a slow Wi-Fi wake.
|
||||
private static let resolveTimeout: TimeInterval = 6
|
||||
/// How long `isScanning` holds — and `rescan()` waits — after a manual refresh.
|
||||
private static let scanSettle: TimeInterval = 1.5
|
||||
/// 1s, 2s, 4s, 8s … capped at 30s, for the resolve retry and the browser re-arm alike. Long
|
||||
/// enough that a genuinely-down network doesn't spin the main queue, short enough that a host
|
||||
/// coming back is picked up while the user is still looking at the screen.
|
||||
private static func backoff(_ failures: Int) -> TimeInterval {
|
||||
min(pow(2, Double(max(0, failures - 1))), 30)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -63,34 +120,73 @@ public final class HostDiscovery: ObservableObject {
|
||||
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
|
||||
#endif
|
||||
guard browser == nil else { return }
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
using: NWParameters())
|
||||
browser.browseResultsChangedHandler = { results, _ in
|
||||
MainActor.assumeIsolated { [weak self] in self?.reconcile(results) }
|
||||
}
|
||||
browser.stateUpdateHandler = { state in
|
||||
// A failed browser never recovers on its own; tear down and re-arm so transient
|
||||
// network changes (Wi-Fi flip, VPN) don't leave discovery silently dead.
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
if case .failed = state { self?.restart() }
|
||||
}
|
||||
}
|
||||
self.browser = browser
|
||||
browser.start(queue: .main)
|
||||
armBrowser()
|
||||
startSweep()
|
||||
}
|
||||
|
||||
/// Stop browsing and drop all discovered state.
|
||||
public func stop() {
|
||||
sweep?.cancel()
|
||||
sweep = nil
|
||||
generation &+= 1
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
for conn in connections.values { conn.cancel() }
|
||||
connections.removeAll()
|
||||
resolved.removeAll()
|
||||
deadlines.removeAll()
|
||||
services.removeAll()
|
||||
addresses.removeAll()
|
||||
failures.removeAll()
|
||||
retryAt.removeAll()
|
||||
staleAddresses.removeAll()
|
||||
browserFailures = 0
|
||||
browserRearmAt = nil
|
||||
scanningUntil = nil
|
||||
if isScanning { isScanning = false }
|
||||
if !hosts.isEmpty { hosts = [] }
|
||||
}
|
||||
|
||||
/// Force a rescan now: re-arm the browser and retry every service whose resolve had failed,
|
||||
/// clearing the backoffs so nothing is left waiting. This is the manual escape hatch for the
|
||||
/// failure modes in the file header — and the only thing that clears the iOS local-network
|
||||
/// permission gate without an app restart, since only a NEW browser sees a permission the
|
||||
/// user granted after the old one started.
|
||||
///
|
||||
/// Also starts discovery if it wasn't running, so a Refresh button does the obvious thing.
|
||||
public func refresh() {
|
||||
#if DEBUG
|
||||
guard !debugPinned else { return } // as in `start()` — the harness's set is the truth
|
||||
#endif
|
||||
isScanning = true
|
||||
scanningUntil = Date().addingTimeInterval(Self.scanSettle)
|
||||
failures.removeAll()
|
||||
retryAt.removeAll()
|
||||
staleAddresses = Set(services.keys)
|
||||
browserFailures = 0
|
||||
armBrowser()
|
||||
startSweep()
|
||||
pump()
|
||||
}
|
||||
|
||||
/// `refresh()` for a `.refreshable` gesture: holds briefly so the control's spinner reflects a
|
||||
/// browse that had time to answer instead of blinking out instantly.
|
||||
public func rescan() async {
|
||||
refresh()
|
||||
try? await Task.sleep(nanoseconds: UInt64(Self.scanSettle * 1_000_000_000))
|
||||
}
|
||||
|
||||
/// `refresh()`, but only when discovery is already running — the app-foreground hook. iOS
|
||||
/// suspends a backgrounded process's browse and `onAppear`/`onDisappear` don't fire across
|
||||
/// background/foreground, so a browse that died while suspended stayed dead on return; this
|
||||
/// re-arms it without starting a browse on a screen that deliberately isn't browsing
|
||||
/// (mid-session, where the home tore discovery down).
|
||||
public func refreshIfRunning() {
|
||||
guard browser != nil else { return }
|
||||
refresh()
|
||||
}
|
||||
|
||||
deinit {
|
||||
sweep?.cancel()
|
||||
browser?.cancel()
|
||||
for conn in connections.values { conn.cancel() }
|
||||
}
|
||||
@@ -124,48 +220,103 @@ public final class HostDiscovery: ObservableObject {
|
||||
}
|
||||
#endif
|
||||
|
||||
private func restart() {
|
||||
stop()
|
||||
start()
|
||||
// MARK: - Browser
|
||||
|
||||
/// Build and start a fresh browser, retiring the previous one and every resolve it started.
|
||||
/// Those resolves' callbacks are gated on `generation`, so they must not be left holding map
|
||||
/// entries — `pump()` restarts them against the new generation.
|
||||
private func armBrowser() {
|
||||
generation &+= 1
|
||||
browser?.cancel()
|
||||
for conn in connections.values { conn.cancel() }
|
||||
connections.removeAll()
|
||||
deadlines.removeAll()
|
||||
browserRearmAt = nil
|
||||
|
||||
let generation = self.generation
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
using: NWParameters())
|
||||
browser.browseResultsChangedHandler = { results, _ in
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self, generation == self.generation else { return }
|
||||
self.reconcile(results)
|
||||
}
|
||||
}
|
||||
browser.stateUpdateHandler = { state in
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self, generation == self.generation else { return }
|
||||
self.browserStateChanged(state)
|
||||
}
|
||||
}
|
||||
self.browser = browser
|
||||
browser.start(queue: .main)
|
||||
}
|
||||
|
||||
/// Diff the browser's current result set against what we're tracking: drop departed
|
||||
/// services, resolve newly-seen ones.
|
||||
private func reconcile(_ results: Set<NWBrowser.Result>) {
|
||||
let live = Set(results.map { Self.key($0) })
|
||||
for key in resolved.keys where !live.contains(key) { resolved[key] = nil }
|
||||
for key in connections.keys where !live.contains(key) {
|
||||
connections[key]?.cancel()
|
||||
connections[key] = nil
|
||||
/// A browser that stops working never recovers on its own, and it has two ways to stop:
|
||||
/// `.failed` (dead) and `.waiting` (blocked — a network change, or the iOS local-network
|
||||
/// permission gate described in the file header). Schedule a re-arm for both, on a backoff:
|
||||
/// re-arming synchronously on `.failed` alone both missed the permission case entirely and
|
||||
/// could spin the main queue on a browser that fails instantly every time.
|
||||
private func browserStateChanged(_ state: NWBrowser.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
browserFailures = 0
|
||||
browserRearmAt = nil
|
||||
case .failed, .waiting:
|
||||
guard browserRearmAt == nil else { return } // one re-arm already scheduled
|
||||
browserFailures += 1
|
||||
browserRearmAt = Date().addingTimeInterval(Self.backoff(browserFailures))
|
||||
default:
|
||||
break // .setup / .cancelled — nothing to heal
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff the browser's current result set against what we're tracking: drop departed services,
|
||||
/// record the rest — re-reading the advert every time, so a host that re-keys, moves or flips
|
||||
/// its pairing policy republishes under the same name and the card follows it — then resolve
|
||||
/// whatever still needs an address.
|
||||
private func reconcile(_ results: Set<NWBrowser.Result>) {
|
||||
var live: Set<String> = []
|
||||
for result in results {
|
||||
let key = Self.key(result)
|
||||
if resolved[key] == nil, connections[key] == nil { resolve(result) }
|
||||
live.insert(key)
|
||||
services[key] = result
|
||||
}
|
||||
for key in Array(services.keys) where !live.contains(key) { forget(key) }
|
||||
publish()
|
||||
pump()
|
||||
}
|
||||
|
||||
private func forget(_ key: String) {
|
||||
connections[key]?.cancel()
|
||||
connections[key] = nil
|
||||
deadlines[key] = nil
|
||||
services[key] = nil
|
||||
addresses[key] = nil
|
||||
failures[key] = nil
|
||||
retryAt[key] = nil
|
||||
staleAddresses.remove(key)
|
||||
}
|
||||
|
||||
// MARK: - Resolve
|
||||
|
||||
/// Start the resolves that are due: every live service with no address yet, nothing in flight,
|
||||
/// and past its retry time.
|
||||
private func pump() {
|
||||
let now = Date()
|
||||
for (key, result) in services {
|
||||
guard addresses[key] == nil || staleAddresses.contains(key) else { continue }
|
||||
guard connections[key] == nil else { continue }
|
||||
if let at = retryAt[key], at > now { continue }
|
||||
resolve(key, result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one service to IP:port via a short UDP connection (it reaches `.ready` once the
|
||||
/// path is established — no data is sent), reading the TXT up front so the callback only
|
||||
/// captures Sendable values + the endpoint key.
|
||||
private func resolve(_ result: NWBrowser.Result) {
|
||||
let key = Self.key(result)
|
||||
let name = Self.instanceName(result.endpoint)
|
||||
var fp: String?
|
||||
var pair: String?
|
||||
var id: String?
|
||||
var macs: [String] = []
|
||||
var osChain = ""
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
fp = Self.entry(txt, "fp")
|
||||
pair = Self.entry(txt, "pair")
|
||||
id = Self.entry(txt, "id")
|
||||
macs = (Self.entry(txt, "mac") ?? "")
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
osChain = sanitizeOsChain(Self.entry(txt, "os") ?? "")
|
||||
}
|
||||
/// path is established — no data is sent). The TXT is NOT read here: it comes from the browse
|
||||
/// result at publish time, so a re-advertised host doesn't need a fresh resolve to be re-read.
|
||||
private func resolve(_ key: String, _ result: NWBrowser.Result) {
|
||||
// Resolve over IPv4 only: Network.framework prefers IPv6 (RFC 6724), and the host's OS
|
||||
// mDNS responder often answers AAAA for its hostname even though the punktfunk host stack
|
||||
// (control QUIC + data UDP) binds IPv4 sockets exclusively — a v6-resolved address would
|
||||
@@ -177,44 +328,125 @@ public final class HostDiscovery: ObservableObject {
|
||||
}
|
||||
let conn = NWConnection(to: result.endpoint, using: params)
|
||||
connections[key] = conn
|
||||
deadlines[key] = Date().addingTimeInterval(Self.resolveTimeout)
|
||||
let generation = self.generation
|
||||
conn.stateUpdateHandler = { state in
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self, let conn = self.connections[key] else { return }
|
||||
// Look the connection back up rather than capturing it — capturing it here would
|
||||
// retain the connection through its own handler.
|
||||
guard let self, generation == self.generation,
|
||||
let conn = self.connections[key] else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
if case let .hostPort(host, port)? = conn.currentPath?.remoteEndpoint,
|
||||
let address = Self.hostString(host) {
|
||||
self.resolved[key] = DiscoveredHost(
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port.rawValue,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional", macAddresses: macs,
|
||||
osChain: osChain)
|
||||
self.publish()
|
||||
}
|
||||
conn.cancel()
|
||||
let endpoint = conn.currentPath?.remoteEndpoint
|
||||
self.connections[key] = nil
|
||||
self.deadlines[key] = nil
|
||||
conn.cancel()
|
||||
if case let .hostPort(host, port)? = endpoint,
|
||||
let address = Self.hostString(host) {
|
||||
self.addresses[key] = (address, port.rawValue)
|
||||
self.failures[key] = nil
|
||||
self.retryAt[key] = nil
|
||||
self.staleAddresses.remove(key)
|
||||
self.publish()
|
||||
} else {
|
||||
// Ready but no usable remote — a failed attempt, not a finished one.
|
||||
self.resolveFailed(key)
|
||||
}
|
||||
case .failed, .cancelled:
|
||||
self.connections[key] = nil
|
||||
self.deadlines[key] = nil
|
||||
self.resolveFailed(key)
|
||||
default:
|
||||
break
|
||||
break // .preparing / .waiting — the sweep's deadline is what ends these
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.start(queue: .main)
|
||||
}
|
||||
|
||||
/// Publish the resolved set, deduped by `id` (a host on several interfaces / re-advertising
|
||||
/// collapses to one row), sorted by name.
|
||||
private func resolveFailed(_ key: String) {
|
||||
let count = (failures[key] ?? 0) + 1
|
||||
failures[key] = count
|
||||
retryAt[key] = Date().addingTimeInterval(Self.backoff(count))
|
||||
}
|
||||
|
||||
// MARK: - Sweep
|
||||
|
||||
private func startSweep() {
|
||||
sweep?.cancel()
|
||||
sweep = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let now = Date()
|
||||
// Time out the resolves that parked. Without this they never end, and `pump()` skips a
|
||||
// service that has a connection in flight — so that host stayed invisible indefinitely.
|
||||
for key in deadlines.filter({ $0.value <= now }).keys {
|
||||
connections[key]?.cancel()
|
||||
connections[key] = nil
|
||||
deadlines[key] = nil
|
||||
resolveFailed(key)
|
||||
}
|
||||
if let at = browserRearmAt, at <= now { armBrowser() }
|
||||
pump()
|
||||
if let until = scanningUntil, until <= now {
|
||||
scanningUntil = nil
|
||||
isScanning = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Publish
|
||||
|
||||
/// Publish the live adverts that have an address, deduped by `id` (a host on several
|
||||
/// interfaces / re-advertising collapses to one row), sorted by name.
|
||||
private func publish() {
|
||||
var byID: [String: DiscoveredHost] = [:]
|
||||
for host in resolved.values { byID[host.id] = host }
|
||||
for key in services.keys.sorted() {
|
||||
guard let result = services[key], let address = addresses[key] else { continue }
|
||||
let host = Self.host(from: result, address: address.host, port: address.port)
|
||||
byID[host.id] = host
|
||||
}
|
||||
let next = byID.values.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
if next != hosts { hosts = next }
|
||||
}
|
||||
|
||||
/// Join a browse result's advert (instance name + TXT) to a resolved address.
|
||||
private static func host(
|
||||
from result: NWBrowser.Result, address: String, port: UInt16
|
||||
) -> DiscoveredHost {
|
||||
let name = instanceName(result.endpoint)
|
||||
var fp: String?
|
||||
var pair: String?
|
||||
var id: String?
|
||||
var macs: [String] = []
|
||||
var osChain = ""
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
fp = entry(txt, "fp")
|
||||
pair = entry(txt, "pair")
|
||||
id = entry(txt, "id")
|
||||
macs = (entry(txt, "mac") ?? "")
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
osChain = sanitizeOsChain(entry(txt, "os") ?? "")
|
||||
}
|
||||
return DiscoveredHost(
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional", macAddresses: macs,
|
||||
osChain: osChain)
|
||||
}
|
||||
|
||||
private static func key(_ result: NWBrowser.Result) -> String {
|
||||
"\(result.endpoint)"
|
||||
}
|
||||
|
||||
@@ -179,6 +179,14 @@ public enum DefaultsKey {
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — a
|
||||
/// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/
|
||||
/// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the
|
||||
/// Android client carry the same table under the same names. Presentation only, so it is
|
||||
/// a device preference and never part of a stream profile. An unknown value reads as the
|
||||
/// default rather than failing — a newer client may have shipped a palette this build
|
||||
/// doesn't know.
|
||||
public static let uiPalette = "punktfunk.uiPalette"
|
||||
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
||||
/// the phone body is the only actuator in the player's hands. Off by default (opt-in); read
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// The gamepad UI's background colour families.
|
||||
//
|
||||
// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale
|
||||
// applied to the ONE field GamepadScreenBackground already draws, so every palette inherits its
|
||||
// structure (dark corners, bright interior pools, warm-left/cool-right) and the brand default is
|
||||
// exactly the shipped look — `violet` is the identity transform.
|
||||
//
|
||||
// The table and the `tint` math are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
|
||||
// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so the shared `ui_palette`
|
||||
// setting names the same colour family on every client. Keep the three copies in step: a palette
|
||||
// added here without the others is a value the other clients will silently render as Violet.
|
||||
//
|
||||
// It lives in PunktfunkShared rather than next to the views because that is the target the tests
|
||||
// can reach — the arithmetic below is the part that has to agree across three languages.
|
||||
|
||||
import Foundation
|
||||
import simd
|
||||
|
||||
public struct GamepadPalette: Identifiable, Equatable, Sendable {
|
||||
/// The stored `ui_palette` value (`DefaultsKey.uiPalette`).
|
||||
public let id: String
|
||||
/// What the settings row shows.
|
||||
public let name: String
|
||||
/// Hue rotation about the grey axis, degrees — positive runs red → green → blue.
|
||||
public let hueDegrees: Double
|
||||
/// Saturation scale about luminance; 1 keeps the source saturation.
|
||||
public let saturation: Double
|
||||
|
||||
/// The six shipped palettes, in cycling order: the brand violet, then cool → warm, then the
|
||||
/// neutral.
|
||||
public static let all: [GamepadPalette] = [
|
||||
GamepadPalette(id: "violet", name: "Violet", hueDegrees: 0, saturation: 1.0),
|
||||
GamepadPalette(id: "tide", name: "Tide", hueDegrees: -70, saturation: 1.0),
|
||||
GamepadPalette(id: "forest", name: "Forest", hueDegrees: -130, saturation: 0.9),
|
||||
GamepadPalette(id: "ember", name: "Ember", hueDegrees: 105, saturation: 1.0),
|
||||
GamepadPalette(id: "rose", name: "Rose", hueDegrees: 60, saturation: 0.95),
|
||||
GamepadPalette(id: "graphite", name: "Graphite", hueDegrees: 0, saturation: 0.12),
|
||||
]
|
||||
|
||||
/// The palette stored under `id`, falling back to the brand default — an unknown name is a
|
||||
/// palette a newer client shipped, not a reason to draw nothing.
|
||||
public static func named(_ id: String) -> GamepadPalette {
|
||||
all.first { $0.id == id } ?? all[0]
|
||||
}
|
||||
|
||||
/// `true` for the identity transform, so the default path can skip the per-colour work.
|
||||
public var isIdentity: Bool { hueDegrees == 0 && saturation == 1 }
|
||||
|
||||
/// Apply this palette to one RGB triple.
|
||||
public func tint(_ c: SIMD3<Double>) -> SIMD3<Double> {
|
||||
guard !isIdentity else { return c }
|
||||
return GamepadPalette.tint(c, hueDegrees: hueDegrees, saturation: saturation)
|
||||
}
|
||||
|
||||
/// Rotate `c` about the grey axis by `hueDegrees` (Rodrigues — the same rotation the field's
|
||||
/// own ±8° warm/cool sway uses, in the same orientation) and scale its saturation about
|
||||
/// luminance. Clamped, because a large rotation can push a channel out of gamut.
|
||||
///
|
||||
/// Deliberately computed here rather than left to SwiftUI's `.hueRotation`: that modifier's
|
||||
/// exact behaviour is the framework's, and the Rust and Kotlin clients have no equivalent —
|
||||
/// doing the arithmetic on the COLOURS keeps the three implementations identical.
|
||||
public static func tint(
|
||||
_ c: SIMD3<Double>, hueDegrees: Double, saturation: Double
|
||||
) -> SIMD3<Double> {
|
||||
let a = hueDegrees * .pi / 180
|
||||
let cs = cos(a)
|
||||
let sn = sin(a)
|
||||
let invSqrt3 = 1 / 3.0.squareRoot()
|
||||
let grey = (c.x + c.y + c.z) / 3 * (1 - cs)
|
||||
// The `sn` term is cross(k, c) with k = (1,1,1)/√3.
|
||||
let rot = SIMD3(
|
||||
c.x * cs + (c.z - c.y) * invSqrt3 * sn + grey,
|
||||
c.y * cs + (c.x - c.z) * invSqrt3 * sn + grey,
|
||||
c.z * cs + (c.y - c.x) * invSqrt3 * sn + grey)
|
||||
let luma = 0.2126 * rot.x + 0.7152 * rot.y + 0.0722 * rot.z
|
||||
func mix(_ v: Double) -> Double { min(max(luma + (v - luma) * saturation, 0), 1) }
|
||||
return SIMD3(mix(rot.x), mix(rot.y), mix(rot.z))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Safe-area stream sizing — the pure geometry behind the "safe area" resolution row.
|
||||
//
|
||||
// An iPhone clips the picture in HARDWARE: the sensor housing (notch / Dynamic Island) and the four
|
||||
// rounded corners eat whatever the stream draws underneath them. The session view is deliberately
|
||||
// edge-to-edge (ContentView's `.ignoresSafeArea()` on iOS) and the presenter aspect-FITS the host
|
||||
// mode into it, so which pixels survive is decided entirely by the mode's aspect ratio:
|
||||
//
|
||||
// * A 16:9 mode on a 19.5:9 phone pillarboxes, and those black bars land exactly on the unsafe
|
||||
// regions. That is why 1080p has always "just worked" and never needed a setting.
|
||||
// * The device's NATIVE mode has the screen's own aspect ratio, so it fills every pixel —
|
||||
// including the ones behind the housing and under the corner radii. That is the mode that
|
||||
// loses its corners, and the reason this file exists.
|
||||
//
|
||||
// So the fix needs no layout change and no input change: ask the host for a mode that is narrower
|
||||
// by the safe-area insets, and the existing aspect-fit centres it inside the safe region. Pointer
|
||||
// input keeps mapping correctly for free, because `hostPoint(from:)` derives the video rect from
|
||||
// the live host mode (`AVMakeRect(aspectRatio:insideRect:)`) instead of assuming full-bleed.
|
||||
//
|
||||
// The formula is Moonlight's (its settings' resolution table carries the same row): full native
|
||||
// height, width reduced by the left+right safe-area insets. Width-only is not a simplification —
|
||||
// under aspect-fit only one axis can bind, and on a landscape phone that axis is always the
|
||||
// horizontal one. Insetting the height too would shrink the picture without uncovering anything.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SafeDisplay {
|
||||
/// The host rejects odd dimensions and anything under 320×200 (`validate_dimensions` in
|
||||
/// `pf-encode`), so the computed mode is even-floored and clamped exactly like `RenderScale`.
|
||||
public static let minWidth = 320
|
||||
public static let minHeight = 200
|
||||
|
||||
/// A portrait top inset at or above this many points means a sensor housing rather than a
|
||||
/// status bar. Notched and Dynamic Island iPhones report 44–59 pt; a plain status bar (older
|
||||
/// iPhones, every iPad) reports 20–24 pt. Used only by [`sideInsetPoints`] and only when the
|
||||
/// horizontal insets are unavailable — see there for why that case exists at all.
|
||||
public static let housingTopInsetThreshold: Double = 40
|
||||
|
||||
/// The per-side inset, in points, that the **landscape** stream will be subject to — which is
|
||||
/// not necessarily the inset the caller can read right now.
|
||||
///
|
||||
/// The stream is always landscape, but the settings screen the resolution row is rendered in may
|
||||
/// be portrait, and `safeAreaInsets` only ever describes the CURRENT orientation. In portrait a
|
||||
/// notched iPhone reports its housing on `top` and reports `left`/`right` as zero, so reading
|
||||
/// the horizontal insets there would compute "no inset needed" for exactly the devices that
|
||||
/// need one.
|
||||
///
|
||||
/// - In landscape, `max(left, right)` is the answer directly. (iOS symmetrizes the two so
|
||||
/// content stays centred, so they normally agree; `max` is simply the safe reduction.)
|
||||
/// - In portrait, the housing's portrait TOP inset equals its landscape SIDE inset on every
|
||||
/// notched/Dynamic Island iPhone — the same physical intrusion, measured on the axis that
|
||||
/// happens to be vertical at the time — so `top` is the correct stand-in. It is accepted only
|
||||
/// on phones and only past [`housingTopInsetThreshold`], so an iPad's status bar (or an older
|
||||
/// iPhone's) never fabricates an inset for a device with nothing to avoid.
|
||||
///
|
||||
/// Returns 0 when there is no housing to route around, which makes the safe mode identical to
|
||||
/// the native one — and the caller's dedup then drops the duplicate row on its own.
|
||||
public static func sideInsetPoints(
|
||||
left: Double, right: Double, top: Double, isPhone: Bool
|
||||
) -> Double {
|
||||
let horizontal = max(left, right)
|
||||
if horizontal > 0 { return horizontal }
|
||||
if isPhone, top >= housingTopInsetThreshold { return top }
|
||||
return 0
|
||||
}
|
||||
|
||||
/// The landscape safe-area mode in PIXELS: full native height, width reduced by
|
||||
/// `sideInsetPoints` on each side.
|
||||
///
|
||||
/// `nativeWidth`/`nativeHeight` are the device's native landscape pixels (the long edge first —
|
||||
/// `UIScreen.main.nativeBounds` is portrait-oriented, so the caller swaps). `scale` converts the
|
||||
/// point-valued insets into those same pixels and must therefore be `nativeScale`, not `scale`:
|
||||
/// with Display Zoom on, the two differ and only the former matches `nativeBounds`.
|
||||
///
|
||||
/// Even-floored and clamped so the result is directly host-valid — an odd width is rejected
|
||||
/// outright by the encoder, and an inset subtraction lands odd about half the time.
|
||||
public static func mode(
|
||||
nativeWidth: Int, nativeHeight: Int, sideInsetPoints: Double, scale: Double
|
||||
) -> (width: Int, height: Int) {
|
||||
let insetPixels = max(0, sideInsetPoints) * max(scale, 1) * 2 // both sides
|
||||
let width = Double(nativeWidth) - insetPixels
|
||||
let evenFloor: (Double, Int) -> Int = { value, minimum in
|
||||
max(Int(value.rounded(.down)), minimum) / 2 * 2
|
||||
}
|
||||
return (evenFloor(width, minWidth), evenFloor(Double(nativeHeight), minHeight))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// The gamepad UI's background palettes. These assertions are the CONTRACT the Rust
|
||||
// (`pf-console-ui::library::tint`) and Kotlin (`GamepadPalette.tint`) ports have to reproduce —
|
||||
// the same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette`
|
||||
// value names the same colour family on every client.
|
||||
|
||||
import XCTest
|
||||
import simd
|
||||
@testable import PunktfunkShared
|
||||
|
||||
final class GamepadPaletteTests: XCTestCase {
|
||||
/// The brightest interior pool of the mesh field — the colour a palette is judged by.
|
||||
private let violetPool = SIMD3(0.49, 0.39, 0.95)
|
||||
|
||||
/// The brand default must be the IDENTITY transform. Every existing install already sees the
|
||||
/// shipped violet backdrop, and a palette table that quietly restyled it would be a
|
||||
/// regression dressed as a feature.
|
||||
func testVioletIsTheUntouchedShippedField() {
|
||||
let violet = GamepadPalette.named("violet")
|
||||
XCTAssertEqual(GamepadPalette.all.first?.id, "violet")
|
||||
XCTAssertTrue(violet.isIdentity)
|
||||
XCTAssertEqual(violet.tint(violetPool), violetPool)
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
XCTAssertEqual(GamepadPalette.named("chartreuse").id, "violet")
|
||||
XCTAssertEqual(GamepadPalette.named("").id, "violet")
|
||||
}
|
||||
|
||||
/// The ids and their order are the cross-client contract (the strip order, and the order
|
||||
/// L1/R1 and A cycle through).
|
||||
func testTableMatchesTheOtherClients() {
|
||||
XCTAssertEqual(
|
||||
GamepadPalette.all.map(\.id),
|
||||
["violet", "tide", "forest", "ember", "rose", "graphite"])
|
||||
XCTAssertEqual(
|
||||
GamepadPalette.all.map(\.name),
|
||||
["Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"])
|
||||
}
|
||||
|
||||
/// A rotation moves the hue while roughly holding luminance, and the saturation scale
|
||||
/// collapses toward grey — the same four checks the Rust test makes.
|
||||
func testTintRotatesHueAndScalesSaturation() {
|
||||
XCTAssertTrue(violetPool.z > violetPool.x && violetPool.z > violetPool.y, "blue-dominant")
|
||||
|
||||
// +105° (Ember) turns the blue-dominant pool red-dominant…
|
||||
let ember = GamepadPalette.named("ember").tint(violetPool)
|
||||
XCTAssertGreaterThan(ember.x, ember.z, "\(ember) should be warm")
|
||||
// …−130° (Forest) turns it green-dominant…
|
||||
let forest = GamepadPalette.named("forest").tint(violetPool)
|
||||
XCTAssertTrue(forest.y > forest.x && forest.y > forest.z, "\(forest)")
|
||||
// …and −70° (Tide) lands on a cyan whose green and blue both beat red.
|
||||
let tide = GamepadPalette.named("tide").tint(violetPool)
|
||||
XCTAssertTrue(tide.y > tide.x && tide.z > tide.x, "\(tide)")
|
||||
|
||||
// Graphite's saturation scale leaves the channels nearly equal…
|
||||
let grey = GamepadPalette.named("graphite").tint(violetPool)
|
||||
let spread = max(grey.x, grey.y, grey.z) - min(grey.x, grey.y, grey.z)
|
||||
XCTAssertLessThan(spread, 0.08, "\(grey)")
|
||||
// …at about the source's luminance (it desaturates, it doesn't dim).
|
||||
let luma = 0.2126 * violetPool.x + 0.7152 * violetPool.y + 0.0722 * violetPool.z
|
||||
XCTAssertEqual(grey.y, luma, accuracy: 0.05)
|
||||
}
|
||||
|
||||
/// Every palette stays in gamut on every colour the field is built from — an out-of-range
|
||||
/// channel would clamp differently on each platform's rasteriser.
|
||||
func testEveryPaletteStaysInGamut() {
|
||||
let field: [SIMD3<Double>] = [
|
||||
SIMD3(0.075, 0.060, 0.160), SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74),
|
||||
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84),
|
||||
SIMD3(0.16, 0.26, 0.64), SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75),
|
||||
SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), SIMD3(0.22, 0.18, 0.54),
|
||||
SIMD3(0.24, 0.20, 0.58),
|
||||
]
|
||||
for palette in GamepadPalette.all {
|
||||
for c in field {
|
||||
let t = palette.tint(c)
|
||||
for v in [t.x, t.y, t.z] {
|
||||
XCTAssertTrue((0...1).contains(v), "\(palette.id) \(c) → \(t)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,5 +50,21 @@ final class HostDiscoveryTests: XCTestCase {
|
||||
XCTAssertEqual(host.fingerprintHex, String(repeating: "ab", count: 32))
|
||||
XCTAssertFalse(host.host.isEmpty, "a resolved address is required to connect")
|
||||
XCTAssertGreaterThan(host.port, 0, "a resolved port is required to connect")
|
||||
|
||||
// A rescan tears the browser down and re-arms it (the only way past the iOS local-network
|
||||
// permission gate without relaunching). The host must come BACK — `refresh()` cancels every
|
||||
// in-flight resolve and invalidates the previous generation's callbacks, so a re-arm that
|
||||
// failed to re-drive them would leave the list permanently empty.
|
||||
await discovery.rescan()
|
||||
var reappeared = false
|
||||
let rescanDeadline = Date().addingTimeInterval(10)
|
||||
while Date() < rescanDeadline {
|
||||
if await discovery.hosts.contains(where: { $0.id == uniqueid }) {
|
||||
reappeared = true
|
||||
break
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
XCTAssertTrue(reappeared, "a rescan must re-find a host that is still advertising")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// The safe-area stream mode (SafeDisplay), as pure geometry: Moonlight's formula — full native
|
||||
// height, width reduced by the left+right safe insets — plus the host's dimension rules (even, and
|
||||
// never under 320×200) and the landscape-inset resolution that makes the row correct even when the
|
||||
// settings screen it is rendered on is currently portrait.
|
||||
|
||||
import XCTest
|
||||
|
||||
import PunktfunkShared
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class SafeDisplayTests: XCTestCase {
|
||||
func testLandscapeUsesTheHorizontalInsets() {
|
||||
// Landscape: the housing is on a side and iOS symmetrizes the two, so either one is the
|
||||
// per-side inset.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 59, right: 59, top: 0, isPhone: true), 59)
|
||||
// Asymmetric (or mid-rotation) readings reduce to the larger — never under-inset.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 44, top: 0, isPhone: true), 44)
|
||||
}
|
||||
|
||||
func testPortraitFallsBackToTheHousingTopInset() {
|
||||
// Portrait on a notched phone: left/right are zero and the housing sits on `top`. Reading
|
||||
// the horizontal insets here would compute "no inset" for exactly the devices that need one,
|
||||
// so the portrait top inset stands in — it is the same physical intrusion.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: true), 59)
|
||||
// A plain status bar is not a housing: an iPad (or a pre-notch iPhone) must not fabricate an
|
||||
// inset for a device with nothing to route around.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 24, isPhone: true), 0)
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: false), 0)
|
||||
}
|
||||
|
||||
func testModeInsetsWidthOnlyAndKeepsFullHeight() {
|
||||
// A Dynamic Island phone: 2556×1179 native, 59 pt per side at nativeScale 3 → 177 px per
|
||||
// side, 354 px total. Height is untouched — under aspect-fit only the horizontal axis binds.
|
||||
let m = SafeDisplay.mode(
|
||||
nativeWidth: 2556, nativeHeight: 1179, sideInsetPoints: 59, scale: 3)
|
||||
XCTAssertEqual(m.width, 2202, "2556 − 2×177")
|
||||
XCTAssertEqual(m.height, 1178, "odd native heights even-floor")
|
||||
// The safe mode must be NARROWER than native, or it would still fill the housing.
|
||||
XCTAssertLessThan(m.width, 2556)
|
||||
}
|
||||
|
||||
func testNoHousingYieldsTheNativeModeSoTheRowDedups() {
|
||||
// Zero inset ⇒ identical to native (bar the even-floor). `resolutionModes` dedups by
|
||||
// dimensions, so this is what makes the extra row vanish on a device that has no housing
|
||||
// rather than showing a pointless duplicate.
|
||||
let m = SafeDisplay.mode(
|
||||
nativeWidth: 2360, nativeHeight: 1640, sideInsetPoints: 0, scale: 2)
|
||||
XCTAssertEqual(m.width, 2360)
|
||||
XCTAssertEqual(m.height, 1640)
|
||||
}
|
||||
|
||||
func testResultIsAlwaysHostValid() {
|
||||
// Odd widths even-floor: `validate_dimensions` rejects odd outright, and an inset
|
||||
// subtraction lands odd about half the time.
|
||||
let odd = SafeDisplay.mode(
|
||||
nativeWidth: 2001, nativeHeight: 1001, sideInsetPoints: 0, scale: 1)
|
||||
XCTAssertEqual(odd.width % 2, 0)
|
||||
XCTAssertEqual(odd.height % 2, 0)
|
||||
// An absurd inset can't drive the mode under the host's floor.
|
||||
let tiny = SafeDisplay.mode(
|
||||
nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: 5000, scale: 3)
|
||||
XCTAssertEqual(tiny.width, SafeDisplay.minWidth)
|
||||
XCTAssertEqual(tiny.height, 720)
|
||||
// A negative inset is treated as none rather than widening past the panel.
|
||||
let neg = SafeDisplay.mode(
|
||||
nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: -40, scale: 3)
|
||||
XCTAssertEqual(neg.width, 1280)
|
||||
}
|
||||
}
|
||||
@@ -674,6 +674,9 @@ pub struct HostsPage {
|
||||
saved: FactoryVecDeque<HostCard>,
|
||||
discovered: FactoryVecDeque<HostCard>,
|
||||
widgets: PageWidgets,
|
||||
/// Forces the mDNS browse to re-query (the header's Refresh button). `None` only if the
|
||||
/// browse never started — the button then just re-renders, which is what it did before.
|
||||
rescan: Option<discovery::Rescan>,
|
||||
}
|
||||
|
||||
struct PageWidgets {
|
||||
@@ -693,6 +696,10 @@ pub enum HostsMsg {
|
||||
},
|
||||
/// Reload the disk store and re-render (fresh pairings, renames, the library gate).
|
||||
Refresh,
|
||||
/// Re-query mDNS *and* re-render — the header's Refresh button. Distinct from [`Self::Refresh`],
|
||||
/// which only re-reads local state: after a while `mdns-sd` re-queries about once an hour, so a
|
||||
/// host that appeared since (or whose announcement was lost) needs an actual query to show up.
|
||||
Rescan,
|
||||
/// A completed reachability sweep: saved-host key → reachable. Merged into the online pips.
|
||||
Probed(HashMap<String, bool>),
|
||||
/// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores.
|
||||
@@ -841,6 +848,13 @@ impl SimpleComponent for HostsPage {
|
||||
add_host_btn.set_tooltip_text(Some("Add host"));
|
||||
add_host_btn.set_action_name(Some("win.add-host"));
|
||||
header.pack_start(&add_host_btn);
|
||||
let rescan_btn = gtk::Button::from_icon_name("view-refresh-symbolic");
|
||||
rescan_btn.set_tooltip_text(Some("Scan the network for hosts again"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan));
|
||||
}
|
||||
header.pack_start(&rescan_btn);
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
||||
@@ -867,8 +881,8 @@ impl SimpleComponent for HostsPage {
|
||||
}
|
||||
|
||||
// Stream mDNS adverts into the model; every add/remove re-evaluates both grids.
|
||||
let (rx, rescan) = discovery::browse();
|
||||
{
|
||||
let rx = discovery::browse();
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
@@ -937,6 +951,7 @@ impl SimpleComponent for HostsPage {
|
||||
disc_heading,
|
||||
searching,
|
||||
},
|
||||
rescan: Some(rescan),
|
||||
};
|
||||
model.rebuild();
|
||||
|
||||
@@ -954,6 +969,14 @@ impl SimpleComponent for HostsPage {
|
||||
self.rebuild();
|
||||
}
|
||||
HostsMsg::Refresh => self.rebuild(),
|
||||
HostsMsg::Rescan => {
|
||||
if let Some(rescan) = &self.rescan {
|
||||
rescan.request();
|
||||
}
|
||||
// Adverts stream in as they answer; re-render now so the local half is current
|
||||
// either way.
|
||||
self.rebuild();
|
||||
}
|
||||
HostsMsg::Probed(map) => {
|
||||
self.probed = map;
|
||||
self.rebuild();
|
||||
|
||||
@@ -46,8 +46,13 @@ pub fn wake_and_connect(
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
use std::time::Duration;
|
||||
let events = crate::discovery::browse();
|
||||
let (events, rescan) = crate::discovery::browse();
|
||||
let mut wait = WakeWait::new();
|
||||
// A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own
|
||||
// re-query interval has doubled well past a minute by the time a boot finishes — so ask
|
||||
// again periodically instead of waiting to be told. Every 5th tick: often enough that a
|
||||
// host that came up is noticed promptly, rare enough not to hammer multicast.
|
||||
let mut ticks: u32 = 0;
|
||||
loop {
|
||||
if cancel.get() {
|
||||
waiting.close();
|
||||
@@ -100,6 +105,10 @@ pub fn wake_and_connect(
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
ticks += 1;
|
||||
if ticks % 5 == 0 {
|
||||
rescan.request();
|
||||
}
|
||||
glib::timeout_future(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -343,6 +343,7 @@ impl Service {
|
||||
probe_inflight: Arc::new(AtomicBool::new(false)),
|
||||
last_probe: Instant::now() - Duration::from_secs(60),
|
||||
wake_cancel: None,
|
||||
rescan: None,
|
||||
}
|
||||
.run(stop_w)
|
||||
})
|
||||
@@ -373,11 +374,14 @@ struct ServiceState {
|
||||
last_probe: Instant,
|
||||
/// Cancels the active wake thread (it owns the model's wake status).
|
||||
wake_cancel: Option<Arc<AtomicBool>>,
|
||||
/// Forces the mDNS browse to re-query. Installed by `run`; `None` before it starts.
|
||||
rescan: Option<discovery::Rescan>,
|
||||
}
|
||||
|
||||
impl ServiceState {
|
||||
fn run(mut self, stop: Arc<AtomicBool>) {
|
||||
let discovery_rx = discovery::browse();
|
||||
let (discovery_rx, rescan) = discovery::browse();
|
||||
self.rescan = Some(rescan);
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
// mDNS churn.
|
||||
while let Ok(ev) = discovery_rx.try_recv() {
|
||||
@@ -512,6 +516,14 @@ impl ServiceState {
|
||||
}
|
||||
ConsoleCmd::Probe => {
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60);
|
||||
// "Refresh presence" means the mDNS half too, not just the QUIC sweep: the browse
|
||||
// runs for the process's lifetime and `mdns-sd` backs its re-query interval off to
|
||||
// as much as an hour, so a host that appeared since startup may never be asked
|
||||
// for again. (No console screen emits Probe yet — every face button on the home
|
||||
// screen is spoken for — but the plumbing is correct for when one does.)
|
||||
if let Some(r) = &self.rescan {
|
||||
r.request();
|
||||
}
|
||||
}
|
||||
ConsoleCmd::SetPin {
|
||||
key,
|
||||
|
||||
@@ -490,9 +490,13 @@ fn wake_and_connect(
|
||||
|
||||
let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
std::thread::spawn(move || {
|
||||
let rx = crate::discovery::browse();
|
||||
let (rx, rescan) = crate::discovery::browse();
|
||||
let mut seen: Vec<DiscoveredHost> = Vec::new();
|
||||
let mut wait = WakeWait::new();
|
||||
// A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own
|
||||
// re-query interval has doubled well past a minute by the time a boot finishes — so ask
|
||||
// again periodically instead of waiting to be told (matches the GTK client's wake wait).
|
||||
let mut ticks: u32 = 0;
|
||||
loop {
|
||||
// Cancel already returned the UI to the host list — stop re-sending and tear down.
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
@@ -555,6 +559,10 @@ fn wake_and_connect(
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
ticks += 1;
|
||||
if ticks % 5 == 0 {
|
||||
rescan.request();
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -595,6 +595,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
move || sa.call(true)
|
||||
})
|
||||
.into()];
|
||||
// Re-query mDNS. The browse runs for the app's lifetime, and `mdns-sd` backs its
|
||||
// re-query interval off to as much as an hour — so a host that appeared since
|
||||
// startup, or whose announcement was lost to multicast, may need an actual ask.
|
||||
actions.push(
|
||||
icon_btn("Scan the network for hosts again", Symbol::Refresh)
|
||||
.on_click({
|
||||
let (c, st) = (ctx.clone(), set_status.clone());
|
||||
move || {
|
||||
if let Some(r) = c.shared.rescan.lock().unwrap().as_ref() {
|
||||
r.request();
|
||||
}
|
||||
st.call("Scanning the network\u{2026}".to_string());
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
// The couch UI's front door, beside the other page actions. Absent on ARM64,
|
||||
// where the session binary ships without its Skia console.
|
||||
if CONSOLE_UI_AVAILABLE {
|
||||
|
||||
@@ -147,6 +147,10 @@ impl PartialEq for Svc {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Shared {
|
||||
pub(crate) target: Mutex<Target>,
|
||||
/// Forces the app's single LAN browse to re-query — the hosts page's Refresh. Installed by
|
||||
/// the discovery effect below; `None` until then (and if the browse never started, in which
|
||||
/// case Refresh is simply inert rather than a second, competing browse).
|
||||
pub(crate) rescan: Mutex<Option<discovery::Rescan>>,
|
||||
/// The live session child (spawn mode) — the status page's Disconnect and the
|
||||
/// request-access Cancel kill it. A FRESH handle is installed per spawn.
|
||||
pub(crate) session: Mutex<crate::spawn::SessionChild>,
|
||||
@@ -459,8 +463,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
|
||||
cx.use_effect((), {
|
||||
let set_hosts = set_hosts.clone();
|
||||
let ctx = ctx.clone();
|
||||
move || {
|
||||
let rx = discovery::browse();
|
||||
let (rx, rescan) = discovery::browse();
|
||||
*ctx.shared.rescan.lock().unwrap() = Some(rescan);
|
||||
std::thread::spawn(move || {
|
||||
let mut acc: Vec<DiscoveredHost> = Vec::new();
|
||||
while let Ok(h) = rx.recv_blocking() {
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
//! results to the UI. Ported verbatim from the GTK client (`mdns-sd` is cross-platform).
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
||||
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DiscoveredHost {
|
||||
@@ -24,10 +30,25 @@ pub struct DiscoveredHost {
|
||||
pub os: String,
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||
/// dropped (the send fails) or the daemon dies.
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
/// Forces the running browse to re-query now — the hosts page's Refresh. Mirrors
|
||||
/// `pf_client_core::discovery::Rescan`; see there for why a client needs one (`mdns-sd` re-queries
|
||||
/// on a backoff that doubles out to an hour, so a long-lived browse is effectively passive).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rescan(Arc<AtomicBool>);
|
||||
|
||||
impl Rescan {
|
||||
/// Ask the browse thread to put a fresh query on the wire. Coalesces; returns immediately.
|
||||
pub fn request(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime, with a handle that forces an immediate re-query.
|
||||
/// The thread exits when the receiver is dropped (the send fails) or the daemon dies.
|
||||
pub fn browse() -> (async_channel::Receiver<DiscoveredHost>, Rescan) {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let requested = flag.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-mdns".into())
|
||||
.spawn(move || {
|
||||
@@ -38,18 +59,45 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let receiver = match daemon.browse("_punktfunk._udp.local.") {
|
||||
let mut receiver = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
|
||||
return;
|
||||
}
|
||||
};
|
||||
while let Ok(event) = receiver.recv() {
|
||||
loop {
|
||||
// The worker has to notice that its consumer went away even when NOTHING is
|
||||
// arriving — the normal state of a LAN with no hosts on it. The old blocking
|
||||
// `recv()` only ever learned that from a failed send, so a bounded consumer (the
|
||||
// wake-and-wait below spawns one browse per wake) left this thread and its daemon
|
||||
// — another thread, and a socket bound to :5353 — running for the app's lifetime.
|
||||
// Checked at the TOP so the `continue` arms below can't skip it either.
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
// Re-browsing the same type replaces the daemon's listener: it replays the cache
|
||||
// into the new channel, queries immediately, and resets the backoff.
|
||||
if requested.swap(false, Ordering::Relaxed) {
|
||||
match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => receiver = r,
|
||||
Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"),
|
||||
}
|
||||
}
|
||||
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => event,
|
||||
Err(_) if receiver.is_disconnected() && receiver.is_empty() => break,
|
||||
Err(_) => continue, // timed out — go round and look for a rescan request
|
||||
};
|
||||
if let ServiceEvent::ServiceResolved(info) = event {
|
||||
let props = info.get_properties();
|
||||
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
|
||||
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
|
||||
// IPv4 only, like every other client (`pf_client_core::discovery`): the core
|
||||
// dials `format!("{host}:{port}").parse::<SocketAddr>()`, which cannot parse a
|
||||
// bare IPv6 literal, and the host stack binds IPv4 sockets exclusively. Taking
|
||||
// an arbitrary first address here rendered cards that failed on every click,
|
||||
// because a host's OS responder commonly answers AAAA for its hostname.
|
||||
let Some(addr) = info.get_addresses_v4().iter().next().map(|a| a.to_string())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -85,5 +133,5 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
let _ = daemon.shutdown();
|
||||
})
|
||||
.expect("spawn mdns thread");
|
||||
rx
|
||||
(rx, Rescan(flag))
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
fn discover_and_print() {
|
||||
use std::time::{Duration, Instant};
|
||||
println!("Browsing the LAN for punktfunk hosts (~5 s)…");
|
||||
let rx = discovery::browse();
|
||||
let (rx, _rescan) = discovery::browse();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while Instant::now() < deadline {
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
||||
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredHost {
|
||||
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
|
||||
@@ -54,10 +59,32 @@ pub enum DiscoveryEvent {
|
||||
Removed { fullname: String },
|
||||
}
|
||||
|
||||
/// Browse continuously. The worker exits when the returned receiver is dropped, or when the
|
||||
/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives.
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
/// Forces the running browse to re-query now. Cheap to clone and hand to a UI thread; a request
|
||||
/// made after the browse has ended is simply never read.
|
||||
///
|
||||
/// Why a client needs one at all: `mdns-sd` re-queries on a DOUBLING backoff (1s, 2s, 4s … capped
|
||||
/// at one hour), so a browse that has been up a while is effectively passive — it is listening for
|
||||
/// announcements rather than asking. A host that starts advertising later, or whose announcement
|
||||
/// was dropped (ordinary for multicast over Wi-Fi), can stay invisible for a very long time.
|
||||
/// Re-querying resets that clock, which is what a Refresh button should do.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rescan(Arc<AtomicBool>);
|
||||
|
||||
impl Rescan {
|
||||
/// Ask the browse thread to put a fresh query on the wire. Returns immediately; the query
|
||||
/// follows within a tick. Coalesces — several requests in a row cost one query.
|
||||
pub fn request(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse continuously, with a handle that forces an immediate re-query ([`Rescan`]). The worker
|
||||
/// exits when the returned receiver is dropped, or when the daemon dies — checked on a tick, so
|
||||
/// it stops even on a LAN where no advert ever arrives.
|
||||
pub fn browse() -> (async_channel::Receiver<DiscoveryEvent>, Rescan) {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let requested = flag.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-mdns".into())
|
||||
.spawn(move || {
|
||||
@@ -68,7 +95,7 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let receiver = match daemon.browse("_punktfunk._udp.local.") {
|
||||
let mut receiver = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
|
||||
@@ -88,6 +115,17 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
// Also at the TOP, and for the same reason: every `continue` below would skip it.
|
||||
if requested.swap(false, Ordering::Relaxed) {
|
||||
// Browsing the same type again REPLACES the daemon's listener for it: it
|
||||
// replays the cache into the new channel (so nothing already known is lost),
|
||||
// puts a fresh PTR query on the wire immediately, and — the point — resets the
|
||||
// re-query backoff described on `Rescan`.
|
||||
match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => receiver = r,
|
||||
Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"),
|
||||
}
|
||||
}
|
||||
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => event,
|
||||
Err(_) if receiver.is_disconnected() => break,
|
||||
@@ -147,7 +185,7 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
let _ = daemon.shutdown();
|
||||
})
|
||||
.expect("spawn mdns thread");
|
||||
rx
|
||||
(rx, Rescan(flag))
|
||||
}
|
||||
|
||||
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
|
||||
@@ -174,7 +212,7 @@ fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
|
||||
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
|
||||
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
|
||||
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
|
||||
let rx = browse();
|
||||
let (rx, _rescan) = browse();
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut adverts = Adverts::new();
|
||||
while Instant::now() < deadline {
|
||||
|
||||
@@ -1111,6 +1111,15 @@ pub struct Settings {
|
||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||
pub library_enabled: bool,
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
|
||||
/// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/
|
||||
/// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android
|
||||
/// clients' twins). Presentation only: nothing about a stream depends on it, which is
|
||||
/// why it is a device preference and never part of a settings profile. An unknown
|
||||
/// name reads as the default rather than erroring — a newer client may have shipped a
|
||||
/// palette this binary doesn't know.
|
||||
#[serde(default = "default_ui_palette")]
|
||||
pub ui_palette: String,
|
||||
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
|
||||
/// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional
|
||||
/// behavior before this became a setting. Off is for hosts reached over a VPN, where
|
||||
@@ -1195,6 +1204,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_ui_palette() -> String {
|
||||
"violet".into()
|
||||
}
|
||||
|
||||
fn default_pad_speaker() -> String {
|
||||
"pad".into()
|
||||
}
|
||||
@@ -1303,6 +1316,7 @@ impl Default for Settings {
|
||||
stats_verbosity: None,
|
||||
fullscreen_on_stream: true,
|
||||
library_enabled: false,
|
||||
ui_palette: default_ui_palette(),
|
||||
auto_wake: true,
|
||||
invert_scroll: false,
|
||||
speaker_device: String::new(),
|
||||
|
||||
@@ -203,17 +203,118 @@ pub const MESH_INTERIOR: [(f64, f64, f64, f64, f64, f64); 4] = [
|
||||
(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
|
||||
];
|
||||
|
||||
/// The mesh gradient as SkSL, palette + motion baked into the source (only time and
|
||||
/// resolution are uniforms). A smooth bicubic blend of the 16 colours — a separable
|
||||
// --- Background palettes -------------------------------------------------------------------
|
||||
|
||||
/// One background colour family for the console's living backdrop. A palette is NOT a second
|
||||
/// hand-tuned 16-colour grid: it is a hue rotation + saturation scale applied to
|
||||
/// [`MESH_COLORS`], so every palette inherits the field's structure (dark corners, bright
|
||||
/// interior pools, warm-left/cool-right) and the brand default is exactly the shipped look —
|
||||
/// `violet` is the identity transform. The Apple and Android clients carry the same table and
|
||||
/// the same [`tint`] math, so a palette reads as the same colour family on every client.
|
||||
pub struct Palette {
|
||||
/// The stored `ui_palette` value (see `trust::Settings::ui_palette`).
|
||||
pub id: &'static str,
|
||||
/// What the settings row shows.
|
||||
pub name: &'static str,
|
||||
/// Hue rotation about the grey axis, degrees — positive runs red → green → blue.
|
||||
pub hue_deg: f64,
|
||||
/// Saturation scale about luminance; `1.0` keeps the source saturation.
|
||||
pub sat: f64,
|
||||
}
|
||||
|
||||
/// The six shipped palettes, in cycling order (the brand violet first, then cool → warm,
|
||||
/// then the neutral). Adding one here adds it to every console settings screen; the Apple
|
||||
/// and Android tables must gain the same entry to keep the `ui_palette` key portable.
|
||||
pub const PALETTES: [Palette; 6] = [
|
||||
Palette {
|
||||
id: "violet",
|
||||
name: "Violet",
|
||||
hue_deg: 0.0,
|
||||
sat: 1.0,
|
||||
},
|
||||
Palette {
|
||||
id: "tide",
|
||||
name: "Tide",
|
||||
hue_deg: -70.0,
|
||||
sat: 1.0,
|
||||
},
|
||||
Palette {
|
||||
id: "forest",
|
||||
name: "Forest",
|
||||
hue_deg: -130.0,
|
||||
sat: 0.9,
|
||||
},
|
||||
Palette {
|
||||
id: "ember",
|
||||
name: "Ember",
|
||||
hue_deg: 105.0,
|
||||
sat: 1.0,
|
||||
},
|
||||
Palette {
|
||||
id: "rose",
|
||||
name: "Rose",
|
||||
hue_deg: 60.0,
|
||||
sat: 0.95,
|
||||
},
|
||||
Palette {
|
||||
id: "graphite",
|
||||
name: "Graphite",
|
||||
hue_deg: 0.0,
|
||||
sat: 0.12,
|
||||
},
|
||||
];
|
||||
|
||||
/// The palette stored under `id`, falling back to the brand default — an unknown name is a
|
||||
/// palette a newer client shipped, not a reason to draw nothing.
|
||||
pub fn palette(id: &str) -> &'static Palette {
|
||||
PALETTES.iter().find(|p| p.id == id).unwrap_or(&PALETTES[0])
|
||||
}
|
||||
|
||||
/// Rotate `(r, g, b)` about the grey axis by `deg` (Rodrigues — the same rotation the shader
|
||||
/// already uses for the ±8° warm/cool sway) and scale its saturation about luminance. Clamped,
|
||||
/// because a large rotation can push a channel out of gamut. Ported verbatim to Swift and
|
||||
/// Kotlin: keep the three copies in step or the palettes drift apart between clients.
|
||||
pub fn tint(c: (f64, f64, f64), deg: f64, sat: f64) -> (f64, f64, f64) {
|
||||
let (r, g, b) = c;
|
||||
let a = deg.to_radians();
|
||||
let (sn, cs) = a.sin_cos();
|
||||
let inv_sqrt3 = 1.0 / 3.0f64.sqrt();
|
||||
let grey = (r + g + b) / 3.0 * (1.0 - cs);
|
||||
// The `sn` term is `cross(k, c)` with k = (1,1,1)/√3 — the SAME orientation the shader's
|
||||
// own `hue()` uses, so a palette rotation and the ±8° sway agree on which way is warmer.
|
||||
let rot = (
|
||||
r * cs + (b - g) * inv_sqrt3 * sn + grey,
|
||||
g * cs + (r - b) * inv_sqrt3 * sn + grey,
|
||||
b * cs + (g - r) * inv_sqrt3 * sn + grey,
|
||||
);
|
||||
let luma = 0.2126 * rot.0 + 0.7152 * rot.1 + 0.0722 * rot.2;
|
||||
let mix = |v: f64| (luma + (v - luma) * sat).clamp(0.0, 1.0);
|
||||
(mix(rot.0), mix(rot.1), mix(rot.2))
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// [`MESH_COLORS`] under this palette's transform.
|
||||
pub fn mesh_colors(&self) -> [(f64, f64, f64); 16] {
|
||||
core::array::from_fn(|i| tint(MESH_COLORS[i], self.hue_deg, self.sat))
|
||||
}
|
||||
}
|
||||
|
||||
/// The mesh gradient as SkSL, palette + motion baked into the source (resolution, time and
|
||||
/// the calm mix are uniforms). A smooth bicubic blend of the 16 colours — a separable
|
||||
/// cubic-Bézier basis in x then y, C∞ and edge-to-edge, the fragment-shader analogue of
|
||||
/// SwiftUI's `MeshGradient(smoothsColors: true)`. The four interior points drive a
|
||||
/// bounded (weighted-average) domain warp so the bright pools drift; then the whole field
|
||||
/// gets the ±8°/~5-min hue sway, an elliptical vignette, and the vertical legibility scrim,
|
||||
/// all matching the Swift `composite(at:)`. Runs on the GPU at full rate.
|
||||
pub fn mesh_sksl() -> String {
|
||||
///
|
||||
/// `u_tc.y` is the CALM mix, 0 → 1: at 1 the same living field is flattened toward its own
|
||||
/// corner colour (`u_lift`), which is how the form screens (settings, add-host, pair) stay
|
||||
/// restful while still drifting — the motion never changes speed, only the contrast, so the
|
||||
/// crossfade between a launcher screen and a form screen can't make the field jump.
|
||||
pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String {
|
||||
// Colours as `float3(r, g, b)` literals, indices 0..15 (row-major 4×4).
|
||||
let c = |i: usize| {
|
||||
let (r, g, b) = MESH_COLORS[i];
|
||||
let (r, g, b) = colors[i];
|
||||
format!("float3({r}, {g}, {b})")
|
||||
};
|
||||
// The four interior-point domain-warp accumulators. Displacement matches Swift `wob()`:
|
||||
@@ -224,14 +325,18 @@ pub fn mesh_sksl() -> String {
|
||||
warp.push_str(&format!(
|
||||
" q = uv - float2({bx}, {by});\n\
|
||||
ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n\
|
||||
d = float2({amp} * sin(u_t * {sx} + {ph}), \
|
||||
{amp} * cos(u_t * {sy} + {ph} * 1.3));\n\
|
||||
d = float2({amp} * sin(tt * {sx} + {ph}), \
|
||||
{amp} * cos(tt * {sy} + {ph} * 1.3));\n\
|
||||
wsum += d * ww; wtot += ww;\n",
|
||||
));
|
||||
}
|
||||
format!(
|
||||
"uniform float2 u_res;\n\
|
||||
uniform float u_t;\n\
|
||||
// x = seconds since the shell started, y = the calm mix (0 launcher, 1 form).\n\
|
||||
uniform float2 u_tc;\n\
|
||||
// rgb = the palette's corner colour scaled for the calm lift; a is unused (float4\n\
|
||||
// so the uniform block stays 16-byte aligned under any packing rule).\n\
|
||||
uniform float4 u_lift;\n\
|
||||
\n\
|
||||
// Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.\n\
|
||||
float bz(float t, float a, float b, float c, float d) {{\n\
|
||||
@@ -250,6 +355,7 @@ pub fn mesh_sksl() -> String {
|
||||
}}\n\
|
||||
\n\
|
||||
half4 main(float2 xy) {{\n\
|
||||
\x20 float tt = u_tc.x; float calm = u_tc.y;\n\
|
||||
\x20 float2 uv = xy / u_res;\n\
|
||||
\x20 // Interior control points wander → bounded domain warp (pools follow them).\n\
|
||||
\x20 float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d;\n\
|
||||
@@ -263,11 +369,18 @@ pub fn mesh_sksl() -> String {
|
||||
\x20 float3 r3 = bz3(uv.x, {c12}, {c13}, {c14}, {c15});\n\
|
||||
\x20 float3 col = bz3(uv.y, r0, r1, r2, r3);\n\
|
||||
\n\
|
||||
\x20 col = hue(col, sin(u_t * 0.021) * 0.1396263);\n\
|
||||
\x20 col = hue(col, sin(tt * 0.021) * 0.1396263);\n\
|
||||
\n\
|
||||
\x20 // Calm: flatten the field toward its own corner colour — the pools dim and the\n\
|
||||
\x20 // corners lift, so a form screen keeps real colour under its glass rows while\n\
|
||||
\x20 // losing the launcher's contrast. Motion is untouched (see the doc comment).\n\
|
||||
\x20 col = mix(col, col * 0.60 + u_lift.rgb, calm);\n\
|
||||
\n\
|
||||
\x20 // Elliptical vignette: clear at r=0.25 → black·0.42 at r=1.15 (aspect-fit ellipse).\n\
|
||||
\x20 // Halved under calm: a launcher's cards sit in the pooled centre, but a form\n\
|
||||
\x20 // screen's rows run out toward the edges, where crushing to black just eats them.\n\
|
||||
\x20 float2 e = (xy / u_res - 0.5) * 2.0;\n\
|
||||
\x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * 0.42;\n\
|
||||
\x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * mix(0.42, 0.21, calm);\n\
|
||||
\x20 col *= 1.0 - vig;\n\
|
||||
\n\
|
||||
\x20 // Vertical legibility scrim: black 0.38/0.06/0.08/0.40 at 0/0.32/0.68/1.\n\
|
||||
@@ -482,10 +595,56 @@ mod tests {
|
||||
/// 16 colours baked in, the five bicubic evals and four interior warp terms present).
|
||||
#[test]
|
||||
fn mesh_sksl_shape() {
|
||||
let src = mesh_sksl();
|
||||
let src = mesh_sksl(&MESH_COLORS);
|
||||
assert!(src.matches("float3(").count() >= 16, "16 colours baked");
|
||||
assert_eq!(src.matches("bz3(").count(), 6); // 1 definition + 5 call sites
|
||||
assert_eq!(src.matches("wtot +=").count(), 4); // one per interior point
|
||||
assert_eq!(src.matches('{').count(), src.matches('}').count());
|
||||
}
|
||||
|
||||
/// The brand default must be the IDENTITY transform — the shipped violet backdrop is
|
||||
/// what every existing install already sees, and a palette table that quietly restyled
|
||||
/// it would be a regression dressed as a feature.
|
||||
#[test]
|
||||
fn violet_is_the_untouched_shipped_field() {
|
||||
assert_eq!(PALETTES[0].id, "violet");
|
||||
for (a, b) in palette("violet").mesh_colors().iter().zip(&MESH_COLORS) {
|
||||
assert!((a.0 - b.0).abs() < 1e-9, "{a:?} vs {b:?}");
|
||||
assert!((a.1 - b.1).abs() < 1e-9, "{a:?} vs {b:?}");
|
||||
assert!((a.2 - b.2).abs() < 1e-9, "{a:?} vs {b:?}");
|
||||
}
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
assert_eq!(palette("chartreuse").id, "violet");
|
||||
assert_eq!(palette("").id, "violet");
|
||||
}
|
||||
|
||||
/// The transform's two knobs do what they claim: a rotation moves the hue while holding
|
||||
/// roughly the same luminance, and the saturation scale collapses toward grey. These are
|
||||
/// the numbers the Swift and Kotlin ports have to reproduce.
|
||||
#[test]
|
||||
fn tint_rotates_hue_and_scales_saturation() {
|
||||
let violet = MESH_COLORS[5]; // the brightest interior pool: blue dominates
|
||||
assert!(violet.2 > violet.0 && violet.2 > violet.1);
|
||||
// +105° (Ember) turns the blue-dominant pool red-dominant.
|
||||
let ember = tint(violet, 105.0, 1.0);
|
||||
assert!(ember.0 > ember.2, "{ember:?} should be warm");
|
||||
// −130° (Forest) turns it green-dominant.
|
||||
let forest = tint(violet, -130.0, 1.0);
|
||||
assert!(forest.1 > forest.0 && forest.1 > forest.2, "{forest:?}");
|
||||
// Graphite's saturation scale leaves the three channels nearly equal…
|
||||
let grey = tint(violet, 0.0, 0.12);
|
||||
let spread = grey.0.max(grey.1).max(grey.2) - grey.0.min(grey.1).min(grey.2);
|
||||
assert!(spread < 0.08, "{grey:?} spread {spread}");
|
||||
// …at about the source's luminance (it desaturates, it doesn't dim).
|
||||
let luma = 0.2126 * violet.0 + 0.7152 * violet.1 + 0.0722 * violet.2;
|
||||
assert!((grey.1 - luma).abs() < 0.05, "{grey:?} vs luma {luma}");
|
||||
// Every palette stays in gamut on every mesh colour.
|
||||
for p in &PALETTES {
|
||||
for c in p.mesh_colors() {
|
||||
for v in [c.0, c.1, c.2] {
|
||||
assert!((0.0..=1.0).contains(&v), "{} {c:?}", p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ use skia_safe::{Canvas, Rect};
|
||||
/// What a screen draws over (the shell crossfades between them on push/pop).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Bg {
|
||||
/// The living mesh aurora (home, library).
|
||||
/// The living mesh aurora at full contrast (home, library).
|
||||
Aurora,
|
||||
/// The quiet indigo form backdrop (settings, add-host, pair).
|
||||
/// The SAME living mesh, calmed — dimmed pools, lifted corners (settings, add-host,
|
||||
/// pair). Not a second backdrop: the shell chases one `calm` uniform between the two.
|
||||
Form,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
//! restyled as glass rows and fully controller-navigable (the Swift
|
||||
//! `GamepadSettingsView`, re-homed): up/down moves focus, left/right steps the focused
|
||||
//! value (clamped — the boundary thud tells the thumb it's the last option), A cycles
|
||||
//! forward wrapping, B closes. Every change persists immediately; the desktop shells
|
||||
//! read the same file, so values round-trip freely.
|
||||
//! forward wrapping, L1/R1 change SECTION, B closes. Every change persists immediately;
|
||||
//! the desktop shells read the same file, so values round-trip freely.
|
||||
//!
|
||||
//! The rows are split across tabs (see [`TABS`]). They used to be one 30-row scroll with
|
||||
//! inline headers, which on a Deck meant thumbing past Video and Audio to reach the pad
|
||||
//! settings; a tab is one shoulder press, and each tab remembers where its cursor was.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::screens::{Ctx, Outbox, Screen};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
@@ -51,6 +55,10 @@ enum RowId {
|
||||
Fullscreen,
|
||||
AutoWake,
|
||||
Library,
|
||||
/// The gamepad UI's background colour family — see [`crate::library::PALETTES`]. The
|
||||
/// backdrop behind this very row re-colours as it steps, which is the whole reason the
|
||||
/// picker lives on a screen rather than in a dialog.
|
||||
Palette,
|
||||
}
|
||||
|
||||
// The couch-relevant subset grew 2026-07-31: this screen is the ONLY settings editor in
|
||||
@@ -58,39 +66,77 @@ enum RowId {
|
||||
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
|
||||
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
|
||||
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
|
||||
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
|
||||
const ROWS: [RowId; 29] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::RenderScale,
|
||||
RowId::Bitrate,
|
||||
RowId::Compositor,
|
||||
RowId::Codec,
|
||||
RowId::Decoder,
|
||||
RowId::Hdr,
|
||||
RowId::Chroma444,
|
||||
RowId::PresentPriority,
|
||||
RowId::SmoothBuffer,
|
||||
RowId::Vsync,
|
||||
RowId::AllowVrr,
|
||||
RowId::Audio,
|
||||
RowId::Mic,
|
||||
RowId::EchoCancel,
|
||||
RowId::PadForward,
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::SystemButtons,
|
||||
RowId::GuideGesture,
|
||||
RowId::Touch,
|
||||
RowId::Mouse,
|
||||
RowId::InvertScroll,
|
||||
RowId::Shortcuts,
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
RowId::Library,
|
||||
// trailing Profiles tab) but created and edited only in the desktop app (design §5.4).
|
||||
//
|
||||
// The tab names are shared with the Apple and Android gamepad settings, so a setting is
|
||||
// found under the same word on every client. Profiles is the trailing tab and is built
|
||||
// from the catalog at render time, which is why it carries no rows here.
|
||||
const TABS: [(&str, &[RowId]); 7] = [
|
||||
(
|
||||
"Stream",
|
||||
&[
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::RenderScale,
|
||||
RowId::Bitrate,
|
||||
RowId::Compositor,
|
||||
],
|
||||
),
|
||||
(
|
||||
"Video",
|
||||
&[
|
||||
RowId::Codec,
|
||||
RowId::Decoder,
|
||||
RowId::Hdr,
|
||||
RowId::Chroma444,
|
||||
RowId::PresentPriority,
|
||||
RowId::SmoothBuffer,
|
||||
RowId::Vsync,
|
||||
RowId::AllowVrr,
|
||||
],
|
||||
),
|
||||
("Audio", &[RowId::Audio, RowId::Mic, RowId::EchoCancel]),
|
||||
(
|
||||
"Controller",
|
||||
&[
|
||||
RowId::PadForward,
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::SystemButtons,
|
||||
RowId::GuideGesture,
|
||||
],
|
||||
),
|
||||
(
|
||||
"Input",
|
||||
&[
|
||||
RowId::Touch,
|
||||
RowId::Mouse,
|
||||
RowId::InvertScroll,
|
||||
RowId::Shortcuts,
|
||||
],
|
||||
),
|
||||
(
|
||||
"Interface",
|
||||
&[
|
||||
RowId::Palette,
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
RowId::Library,
|
||||
],
|
||||
),
|
||||
("Profiles", &[]),
|
||||
];
|
||||
|
||||
/// The index of the trailing Profiles tab (built from the catalog, not from [`TABS`]).
|
||||
const PROFILES_TAB: usize = TABS.len() - 1;
|
||||
|
||||
/// How many sections the strip shows — for the shell's raster test, which walks all of them.
|
||||
/// `cfg(test)` because nothing in a shipping build needs the count: a plain `cargo build` would
|
||||
/// otherwise warn it dead, and this crate's lanes treat warnings as errors.
|
||||
#[cfg(test)]
|
||||
pub(crate) const TAB_COUNT: usize = TABS.len();
|
||||
|
||||
const RESOLUTIONS: [(u32, u32); 6] = [
|
||||
(0, 0), // native
|
||||
(1280, 720),
|
||||
@@ -169,6 +215,12 @@ const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), (
|
||||
|
||||
pub(crate) struct SettingsScreen {
|
||||
list: MenuList,
|
||||
strip: TabStrip,
|
||||
/// Which of [`TABS`] is showing.
|
||||
tab: usize,
|
||||
/// Where each tab's cursor was when it was last left. Coming back to Controller after a
|
||||
/// detour through Video should land where you were, not at the top.
|
||||
tab_cursors: [usize; TABS.len()],
|
||||
/// The profile catalog's `(id, name)` pairs, loaded once at construction — the console
|
||||
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
|
||||
/// for the screen's lifetime.
|
||||
@@ -189,20 +241,37 @@ impl SettingsScreen {
|
||||
fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen {
|
||||
SettingsScreen {
|
||||
list: MenuList::new(),
|
||||
strip: TabStrip::new(),
|
||||
tab: 0,
|
||||
tab_cursors: [0; TABS.len()],
|
||||
profiles,
|
||||
}
|
||||
}
|
||||
|
||||
/// The full row list: the fixed settings rows, then the Profiles section — one row
|
||||
/// per catalog profile, or the explainer placeholder while there are none.
|
||||
/// The rows of the CURRENT tab. Profiles is built from the catalog: one row per
|
||||
/// profile, or the explainer placeholder while there are none.
|
||||
fn row_ids(&self) -> Vec<RowId> {
|
||||
let mut ids = ROWS.to_vec();
|
||||
if self.profiles.is_empty() {
|
||||
ids.push(RowId::NoProfiles);
|
||||
} else {
|
||||
ids.extend((0..self.profiles.len()).map(RowId::Profile));
|
||||
if self.tab != PROFILES_TAB {
|
||||
return TABS[self.tab].1.to_vec();
|
||||
}
|
||||
ids
|
||||
if self.profiles.is_empty() {
|
||||
vec![RowId::NoProfiles]
|
||||
} else {
|
||||
(0..self.profiles.len()).map(RowId::Profile).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// L1/R1 — move one tab, wrapping (the strip is a ring, like A's value cycle), keeping
|
||||
/// each tab's own cursor.
|
||||
fn switch_tab(&mut self, delta: i32) -> Option<MenuPulse> {
|
||||
self.tab_cursors[self.tab] = self.list.cursor;
|
||||
let n = TABS.len() as i32;
|
||||
self.tab = (self.tab as i32 + delta).rem_euclid(n) as usize;
|
||||
// Clamp the remembered cursor: the Profiles tab's length follows the catalog.
|
||||
let len = self.row_ids().len();
|
||||
self.list
|
||||
.jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1)));
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
@@ -211,9 +280,14 @@ impl SettingsScreen {
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if ev == MenuEvent::Back {
|
||||
fx.pop();
|
||||
return None;
|
||||
match ev {
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
MenuEvent::JumpBack => return self.switch_tab(-1),
|
||||
MenuEvent::JumpForward => return self.switch_tab(1),
|
||||
_ => {}
|
||||
}
|
||||
let ids = self.row_ids();
|
||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||
@@ -271,18 +345,22 @@ impl SettingsScreen {
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
match self.row_ids()[self.list.cursor] {
|
||||
RowId::Profile(_) => vec![
|
||||
let ids = self.row_ids();
|
||||
// The shoulders always change section, so that hint leads on every row.
|
||||
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
||||
hints.extend(match ids.get(self.list.cursor) {
|
||||
Some(RowId::Profile(_)) => vec![
|
||||
Hint::new(HintKey::Confirm, "Pin to hosts…"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")],
|
||||
_ => vec![
|
||||
Some(RowId::NoProfiles) | None => vec![Hint::new(HintKey::Back, "Done")],
|
||||
Some(_) => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
}
|
||||
});
|
||||
hints
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
@@ -294,11 +372,23 @@ impl SettingsScreen {
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
// The focused row's explainer sits in a reserved band under the list.
|
||||
// The tab strip takes the top band, the focused row's explainer a reserved band
|
||||
// under the list; the rows get what's between.
|
||||
let detail_h = 34.0 * k;
|
||||
let strip_h = TAB_STRIP_H * k;
|
||||
let labels: Vec<&str> = TABS.iter().map(|(name, _)| *name).collect();
|
||||
self.strip.render(
|
||||
canvas,
|
||||
Rect::from_ltrb(rect.left, rect.top, rect.right, rect.top + strip_h as f32),
|
||||
&labels,
|
||||
self.tab,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
);
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.top + strip_h as f32,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
@@ -309,7 +399,7 @@ impl SettingsScreen {
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
let detail = detail(ids[self.list.cursor]);
|
||||
let detail = ids.get(self.list.cursor).copied().map_or("", detail);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
detail,
|
||||
@@ -335,7 +425,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
.filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid))
|
||||
.count();
|
||||
return RowSpec {
|
||||
header: (i == 0).then_some("Profiles"),
|
||||
header: None,
|
||||
label: name.clone(),
|
||||
value: Some(match pins {
|
||||
0 => "Not pinned".into(),
|
||||
@@ -349,9 +439,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
};
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
let mut row = RowSpec::action("No profiles yet", false);
|
||||
row.header = Some("Profiles");
|
||||
return row;
|
||||
return RowSpec::action("No profiles yet", false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -372,7 +460,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
};
|
||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||
RowId::Resolution => (
|
||||
Some("Stream"),
|
||||
None,
|
||||
"Resolution",
|
||||
if s.match_window {
|
||||
"Match window".into()
|
||||
@@ -416,11 +504,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"Compositor",
|
||||
label_for(&COMPOSITORS, &s.compositor).into(),
|
||||
),
|
||||
RowId::Codec => (
|
||||
Some("Video"),
|
||||
"Video codec",
|
||||
label_for(&CODECS, &s.codec).into(),
|
||||
),
|
||||
RowId::Codec => (None, "Video codec", label_for(&CODECS, &s.codec).into()),
|
||||
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
|
||||
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
|
||||
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
|
||||
@@ -441,7 +525,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()),
|
||||
RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()),
|
||||
RowId::Audio => (
|
||||
Some("Audio"),
|
||||
None,
|
||||
"Audio channels",
|
||||
AUDIO
|
||||
.iter()
|
||||
@@ -452,7 +536,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()),
|
||||
RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()),
|
||||
RowId::PadForward => (
|
||||
Some("Controller"),
|
||||
None,
|
||||
"Forward controllers",
|
||||
on_off(s.gamepad_forwarding).into(),
|
||||
),
|
||||
@@ -483,11 +567,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"Hold Select for guide",
|
||||
label_for(&GUIDE_GESTURE, &s.guide_gesture).into(),
|
||||
),
|
||||
RowId::Touch => (
|
||||
Some("Touchscreen"),
|
||||
"Touch mode",
|
||||
s.touch_mode().label().into(),
|
||||
),
|
||||
RowId::Touch => (None, "Touch mode", s.touch_mode().label().into()),
|
||||
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
|
||||
RowId::InvertScroll => (None, "Invert scroll", on_off(s.invert_scroll).into()),
|
||||
RowId::Shortcuts => (
|
||||
@@ -495,8 +575,13 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"Capture system shortcuts",
|
||||
on_off(s.inhibit_shortcuts).into(),
|
||||
),
|
||||
RowId::Palette => (
|
||||
None,
|
||||
"Background",
|
||||
crate::library::palette(&s.ui_palette).name.into(),
|
||||
),
|
||||
RowId::Stats => (
|
||||
Some("Interface"),
|
||||
None,
|
||||
"Statistics overlay",
|
||||
s.stats_verbosity().label().into(),
|
||||
),
|
||||
@@ -603,6 +688,10 @@ fn detail(id: RowId) -> &'static str {
|
||||
"Alt+Tab, Super and friends reach the host while input is captured. \
|
||||
Off, they act on this device instead."
|
||||
}
|
||||
RowId::Palette => {
|
||||
"The colour family this backdrop drifts through — it changes as you step, so \
|
||||
pick by looking. Appearance only; nothing about a stream depends on it."
|
||||
}
|
||||
RowId::Stats => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
@@ -766,6 +855,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, StatsVerbosity::ALL.len(), delta, wrap)
|
||||
.map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i]))
|
||||
}
|
||||
RowId::Palette => {
|
||||
let all = &crate::library::PALETTES;
|
||||
let cur = all.iter().position(|p| p.id == s.ui_palette);
|
||||
step_option(cur, all.len(), delta, wrap).map(|i| s.ui_palette = all[i].id.to_string())
|
||||
}
|
||||
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
|
||||
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
|
||||
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
|
||||
@@ -1071,19 +1165,18 @@ mod tests {
|
||||
("p1".into(), "Work".into()),
|
||||
("p2".into(), "Game".into()),
|
||||
]);
|
||||
s.tab = PROFILES_TAB;
|
||||
let ids = s.row_ids();
|
||||
assert_eq!(ids.len(), ROWS.len() + 2);
|
||||
assert_eq!(ids[ROWS.len()], RowId::Profile(0));
|
||||
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
|
||||
|
||||
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, Some("Profiles"));
|
||||
assert_eq!(spec.header, None, "the tab pill names the section");
|
||||
assert_eq!(spec.label, "Work");
|
||||
assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host"));
|
||||
let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, None, "only the first row carries the header");
|
||||
assert_eq!(spec.value.as_deref(), Some("Not pinned"));
|
||||
|
||||
s.list.cursor = ROWS.len(); // onto "Work"
|
||||
s.list.cursor = 0; // onto "Work"
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(
|
||||
@@ -1118,10 +1211,10 @@ mod tests {
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
s.tab = PROFILES_TAB;
|
||||
let ids = s.row_ids();
|
||||
assert_eq!(*ids.last().unwrap(), RowId::NoProfiles);
|
||||
assert_eq!(ids, vec![RowId::NoProfiles]);
|
||||
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, Some("Profiles"));
|
||||
assert!(!spec.enabled);
|
||||
|
||||
s.list.cursor = ids.len() - 1;
|
||||
@@ -1130,4 +1223,103 @@ mod tests {
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
assert!(fx.nav.is_none());
|
||||
}
|
||||
|
||||
/// Every row the screen knows about must live in exactly one tab — a row missing from
|
||||
/// [`TABS`] is a setting that became unreachable in Gaming Mode, which is precisely
|
||||
/// what this screen exists to prevent.
|
||||
#[test]
|
||||
fn every_row_has_exactly_one_tab() {
|
||||
let mut seen: Vec<RowId> = Vec::new();
|
||||
for (_, rows) in &TABS {
|
||||
for id in *rows {
|
||||
assert!(!seen.contains(id), "{id:?} is in two tabs");
|
||||
seen.push(*id);
|
||||
}
|
||||
}
|
||||
// The pre-tab flat list, plus the palette row this change added.
|
||||
assert_eq!(seen.len(), 30, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
// The catalog rows belong to the trailing tab, which builds them at render time.
|
||||
assert!(TABS[PROFILES_TAB].1.is_empty());
|
||||
assert_eq!(TABS[PROFILES_TAB].0, "Profiles");
|
||||
}
|
||||
|
||||
/// L1/R1 wrap around the strip and each tab keeps its own cursor, so a detour into
|
||||
/// another section doesn't lose your place.
|
||||
#[test]
|
||||
fn shoulders_cycle_tabs_and_keep_each_cursor() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
let mut fx = Outbox::default();
|
||||
assert_eq!(s.tab, 0);
|
||||
s.list.cursor = 3; // "Bitrate", in Stream
|
||||
s.menu(MenuEvent::JumpForward, &mut ctx, &mut fx);
|
||||
assert_eq!(s.tab, 1);
|
||||
assert_eq!(s.list.cursor, 0, "a fresh tab starts at its first row");
|
||||
s.list.cursor = 2; // "10-bit HDR", in Video
|
||||
s.menu(MenuEvent::JumpBack, &mut ctx, &mut fx);
|
||||
assert_eq!((s.tab, s.list.cursor), (0, 3), "Stream kept its place");
|
||||
// Backwards off the first tab wraps to the last…
|
||||
s.menu(MenuEvent::JumpBack, &mut ctx, &mut fx);
|
||||
assert_eq!(s.tab, PROFILES_TAB);
|
||||
// …whose (catalog-built) length clamps a remembered cursor that no longer fits.
|
||||
assert_eq!(s.list.cursor, 0);
|
||||
s.menu(MenuEvent::JumpForward, &mut ctx, &mut fx);
|
||||
assert_eq!(s.tab, 0);
|
||||
// Switching sections is navigation, never a settings write.
|
||||
assert!(fx.nav.is_none() && fx.cmds.is_empty());
|
||||
}
|
||||
|
||||
/// The palette row steps the shared `ui_palette` key through the table and wraps on A,
|
||||
/// like every other choice row.
|
||||
#[test]
|
||||
fn palette_row_steps_the_shared_key() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert_eq!(ctx.settings.ui_palette, "violet", "the brand default ships");
|
||||
assert_eq!(
|
||||
row_spec(RowId::Palette, &ctx, &[]).value.as_deref(),
|
||||
Some("Violet")
|
||||
);
|
||||
assert!(
|
||||
!adjust(RowId::Palette, -1, false, &mut ctx),
|
||||
"already the first = thud"
|
||||
);
|
||||
assert!(adjust(RowId::Palette, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.ui_palette, crate::library::PALETTES[1].id);
|
||||
// A from the last entry wraps home.
|
||||
ctx.settings.ui_palette = crate::library::PALETTES
|
||||
.last()
|
||||
.expect("non-empty")
|
||||
.id
|
||||
.to_string();
|
||||
assert!(adjust(RowId::Palette, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.ui_palette, "violet");
|
||||
// A store written by a newer client shows that client's value, not a blank row.
|
||||
ctx.settings.ui_palette = "chartreuse".into();
|
||||
assert_eq!(
|
||||
row_spec(RowId::Palette, &ctx, &[]).value.as_deref(),
|
||||
Some("Violet"),
|
||||
"an unknown palette reads as the default it actually draws"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use crate::anim::Progress;
|
||||
use crate::glyphs::GlyphStyle;
|
||||
use crate::library::{mesh_sksl, LibraryShared};
|
||||
use crate::library::{mesh_sksl, palette, LibraryShared};
|
||||
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
||||
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
|
||||
use anyhow::{anyhow, Result};
|
||||
@@ -81,7 +81,17 @@ pub(crate) struct Shell {
|
||||
wake_optimistic: bool,
|
||||
toast: Option<Toast>,
|
||||
mesh: RuntimeEffect,
|
||||
/// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition.
|
||||
/// The `ui_palette` the compiled `mesh` bakes. The settings screen can change the palette
|
||||
/// mid-frame-loop, so [`Self::sync`] recompiles when this falls out of step — the backdrop
|
||||
/// re-colours under the cursor as the row is stepped, which is the whole point of putting
|
||||
/// the picker on a screen the backdrop is behind.
|
||||
mesh_palette: String,
|
||||
/// The palette's corner colour × 0.4 — the calm lift, precomputed with `mesh`. Chosen so
|
||||
/// `col*0.6 + lift` leaves a corner EXACTLY where it was and pulls the bright pools down
|
||||
/// to it: the form screens lose the launcher's contrast, not its colour.
|
||||
mesh_lift: [f32; 3],
|
||||
/// 0 = launcher aurora, 1 = the calm form field — chased, so the backdrop settles into
|
||||
/// (or out of) calm alongside the screen transition.
|
||||
bg_mix: f64,
|
||||
glyphs: GlyphStyle,
|
||||
chip: Option<String>,
|
||||
@@ -99,8 +109,8 @@ impl Shell {
|
||||
stack: Vec<Screen>,
|
||||
) -> Result<Shell> {
|
||||
anyhow::ensure!(!stack.is_empty(), "the console needs a root screen");
|
||||
let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None)
|
||||
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
|
||||
let settings = trust::Settings::load();
|
||||
let (mesh, mesh_lift) = build_mesh(&settings.ui_palette)?;
|
||||
let bg_mix = match stack.last().expect("non-empty").background() {
|
||||
Bg::Aurora => 0.0,
|
||||
Bg::Form => 1.0,
|
||||
@@ -112,7 +122,8 @@ impl Shell {
|
||||
library,
|
||||
bus,
|
||||
actions: VecDeque::new(),
|
||||
settings: trust::Settings::load(),
|
||||
mesh_palette: settings.ui_palette.clone(),
|
||||
settings,
|
||||
hosts: Vec::new(),
|
||||
hosts_gen: u64::MAX,
|
||||
device_name: opts.device_name,
|
||||
@@ -123,6 +134,7 @@ impl Shell {
|
||||
wake_optimistic: false,
|
||||
toast: None,
|
||||
mesh,
|
||||
mesh_lift,
|
||||
bg_mix,
|
||||
glyphs: GlyphStyle::Keyboard,
|
||||
chip: None,
|
||||
@@ -188,6 +200,26 @@ impl Shell {
|
||||
// --- Model sync (hosts, pairing, wake) — before input and before render --------------
|
||||
|
||||
fn sync(&mut self) {
|
||||
// The settings screen writes `ui_palette` straight into `self.settings`; recompiling
|
||||
// here is what makes the backdrop re-colour live under the row being stepped. A
|
||||
// rejected compile keeps the palette that IS drawing — the field never goes black
|
||||
// because someone picked a colour.
|
||||
if self.settings.ui_palette != self.mesh_palette {
|
||||
match build_mesh(&self.settings.ui_palette) {
|
||||
Ok((mesh, lift)) => {
|
||||
self.mesh = mesh;
|
||||
self.mesh_lift = lift;
|
||||
self.mesh_palette = self.settings.ui_palette.clone();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"console: {} palette rejected: {e}",
|
||||
self.settings.ui_palette
|
||||
);
|
||||
self.mesh_palette = self.settings.ui_palette.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.console.hosts_gen() != self.hosts_gen {
|
||||
(self.hosts, self.hosts_gen) = self.console.hosts_snapshot();
|
||||
}
|
||||
@@ -432,12 +464,25 @@ impl Shell {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) {
|
||||
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
|
||||
// SAFETY: `uniforms` is a local `[f32; 3]` — exactly 12 bytes — and `f32` has no padding or
|
||||
/// The living backdrop. `calm` 0 = the launcher's aurora, 1 = the quiet field the form
|
||||
/// screens sit on; the shell chases it, so there is only ever ONE backdrop pass — the
|
||||
/// former aurora-over-static-form crossfade is now a single uniform.
|
||||
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64, calm: f64) {
|
||||
// Laid out to match the SkSL block: u_res (float2), u_tc (float2), u_lift (float4).
|
||||
let uniforms: [f32; 8] = [
|
||||
w as f32,
|
||||
h as f32,
|
||||
t as f32,
|
||||
calm as f32,
|
||||
self.mesh_lift[0],
|
||||
self.mesh_lift[1],
|
||||
self.mesh_lift[2],
|
||||
0.0,
|
||||
];
|
||||
// SAFETY: `uniforms` is a local `[f32; 8]` — exactly 32 bytes — and `f32` has no padding or
|
||||
// invalid bit patterns, so reading it as bytes is sound; the slice is copied by
|
||||
// `Data::new_copy` before `uniforms` goes out of scope.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 12) };
|
||||
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 32) };
|
||||
match self.mesh.make_shader(Data::new_copy(bytes), &[], None) {
|
||||
Some(shader) => {
|
||||
let mut paint = Paint::default();
|
||||
@@ -451,5 +496,30 @@ impl Shell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile the mesh shader for a palette, returning it with its precomputed calm lift.
|
||||
/// `uniform_size` is checked rather than assumed: the byte buffer [`Shell::draw_aurora`]
|
||||
/// hands Skia is hand-packed, and a silent layout change would feed the field garbage
|
||||
/// instead of failing.
|
||||
fn build_mesh(palette_id: &str) -> Result<(RuntimeEffect, [f32; 3])> {
|
||||
let p = palette(palette_id);
|
||||
let colors = p.mesh_colors();
|
||||
let effect = RuntimeEffect::make_for_shader(mesh_sksl(&colors), None)
|
||||
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
|
||||
anyhow::ensure!(
|
||||
effect.uniform_size() == 32,
|
||||
"mesh uniform block is {} bytes, expected 32 (u_res, u_tc, u_lift)",
|
||||
effect.uniform_size()
|
||||
);
|
||||
let corner = colors[0];
|
||||
Ok((
|
||||
effect,
|
||||
[
|
||||
(corner.0 * 0.4) as f32,
|
||||
(corner.1 * 0.4) as f32,
|
||||
(corner.2 * 0.4) as f32,
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -166,7 +166,7 @@ impl Shell {
|
||||
canvas.save_layer_alpha_f(None, appear as f32);
|
||||
// Opaque aurora — the same living backdrop the home wears, so the takeover reads as the
|
||||
// console taking over rather than a card popping up.
|
||||
self.draw_aurora(canvas, w, h, t);
|
||||
self.draw_aurora(canvas, w, h, t, 0.0);
|
||||
// A soft pool of shade under the centre seats the white text against a bright aurora.
|
||||
let mut vignette = Paint::default();
|
||||
vignette.set_shader(gradient_shader::radial(
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::screens::{Bg, Ctx, Screen};
|
||||
use crate::theme::{white, Fonts, PanelStroke, W, WHITE};
|
||||
use pf_client_core::gamepad::PadInfo;
|
||||
use pf_client_core::trust;
|
||||
use skia_safe::{Canvas, Color4f, Rect};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
use std::time::Instant;
|
||||
|
||||
use super::{Motion, Shell, BOTTOM_BAND, TOP_BAND};
|
||||
@@ -67,7 +67,9 @@ impl Shell {
|
||||
}
|
||||
};
|
||||
|
||||
// Backdrop crossfade follows the top screen.
|
||||
// The backdrop settles into (or out of) calm with the screen transition. It is the
|
||||
// SAME living field either way — a form screen quiets it, it doesn't replace it —
|
||||
// so this is one shader pass with a chased uniform, not two stacked backdrops.
|
||||
let bg_target = match self.stack.last().expect("non-empty").background() {
|
||||
Bg::Aurora => 0.0,
|
||||
Bg::Form => 1.0,
|
||||
@@ -76,16 +78,7 @@ impl Shell {
|
||||
if (self.bg_mix - bg_target).abs() < 0.005 {
|
||||
self.bg_mix = bg_target;
|
||||
}
|
||||
if self.bg_mix < 1.0 {
|
||||
self.draw_aurora(canvas, w, h, t);
|
||||
} else {
|
||||
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
|
||||
}
|
||||
if self.bg_mix > 0.0 {
|
||||
canvas.save_layer_alpha_f(None, self.bg_mix as f32);
|
||||
crate::theme::draw_form_background(canvas, w, h);
|
||||
canvas.restore();
|
||||
}
|
||||
self.draw_aurora(canvas, w, h, t, self.bg_mix);
|
||||
|
||||
// The screens, through the transition choreography.
|
||||
let content = Rect::from_ltrb(
|
||||
|
||||
@@ -167,6 +167,47 @@ fn wake_gates_input_in_the_same_press() {
|
||||
assert!(s.handle_menu(MenuEvent::Move(MenuDir::Left)).is_some());
|
||||
}
|
||||
|
||||
/// Every settings tab actually RASTERS. The eyeball dump below is `#[ignore]`d, so without
|
||||
/// this nothing in the normal gate ever ran the tab strip's layout arithmetic or a settings
|
||||
/// screen's rows — a bad index there would only surface on a Deck. CPU raster: the SkSL
|
||||
/// backdrop, the layers and the text all run without a GPU.
|
||||
#[test]
|
||||
fn every_settings_tab_rasters() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let (w, h) = (1280u32, 800u32);
|
||||
let pads: Vec<PadInfo> = Vec::new();
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.handle_menu(MenuEvent::Tertiary); // X → Settings
|
||||
|
||||
let mut frame = |s: &mut Shell| {
|
||||
s.render(
|
||||
surface.canvas(),
|
||||
w,
|
||||
h,
|
||||
&fonts,
|
||||
Some("Xbox Wireless Controller"),
|
||||
Some(GamepadPref::Xbox360),
|
||||
&pads,
|
||||
);
|
||||
};
|
||||
// One lap of the strip — R1 wraps back to where it started. Every tab's rows fit on an
|
||||
// 800-tall window at once, so ONE frame per tab draws all of them; the cursor is walked to
|
||||
// the end first (input only, no render) so the focused and unfocused row paths both run.
|
||||
// Deliberately frugal: a full-screen SkSL field on the CPU costs the better part of a second
|
||||
// per frame in a debug build, and this test's job is to catch a panic, not to look pretty.
|
||||
for _ in 0..crate::screens::settings::TAB_COUNT {
|
||||
for _ in 0..12 {
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
}
|
||||
frame(&mut s);
|
||||
s.handle_menu(MenuEvent::JumpForward);
|
||||
}
|
||||
// A narrow window is the case the strip has to shrink for (the pills are laid out from
|
||||
// measured text, so a too-small width must clamp rather than lay out off-screen).
|
||||
s.render(surface.canvas(), 640, 400, &fonts, None, None, &pads);
|
||||
}
|
||||
|
||||
/// Render every console scene to PNGs for the eyeball pass (ignored; run with
|
||||
/// `PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui --release -- --ignored dump`).
|
||||
/// CPU raster — the SkSL aurora, layers and text all run without a GPU.
|
||||
@@ -208,6 +249,26 @@ fn dump_console_screens() {
|
||||
dump(&mut s, 3, 25, "02-transition", true);
|
||||
dump(&mut s, 40, 8, "03-settings", true);
|
||||
|
||||
// The Interface tab (5 shoulder presses along) leads with the Background row, so this frame
|
||||
// shows both the strip mid-list and the palette picker…
|
||||
for _ in 0..5 {
|
||||
s.handle_menu(MenuEvent::JumpForward);
|
||||
}
|
||||
dump(&mut s, 40, 8, "03b-settings-interface", true);
|
||||
// …and cycling it three times lands on Ember, which is the whole point: the CALM backdrop
|
||||
// behind these rows recolours live.
|
||||
for _ in 0..3 {
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
}
|
||||
dump(&mut s, 40, 8, "03c-settings-ember", true);
|
||||
// Back to the brand default and the first tab so the later scenes look like they always did.
|
||||
for _ in 0..3 {
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
}
|
||||
for _ in 0..5 {
|
||||
s.handle_menu(MenuEvent::JumpBack);
|
||||
}
|
||||
|
||||
// Add Host with the keyboard tray up (keyboard glyph style: no pad).
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
dump(&mut s, 40, 8, "_back", true);
|
||||
|
||||
@@ -112,59 +112,12 @@ pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alph
|
||||
}
|
||||
|
||||
// --- The form backdrop (settings / add-host / pair) --------------------------------------
|
||||
|
||||
/// The calm backdrop for the form screens — NOT the launcher's aurora (this stays still
|
||||
/// and quiet), and deliberately not near-black: a deep indigo base plus two soft static
|
||||
/// glows give the glass rows real color to sit on. A light top/bottom scrim grounds the
|
||||
/// pinned title and hint bar (the Swift build blurs a tray instead; same job).
|
||||
pub(crate) fn draw_form_background(canvas: &Canvas, w: f64, h: f64) {
|
||||
let (wf, hf) = (w as f32, h as f32);
|
||||
canvas.draw_rect(
|
||||
Rect::from_wh(wf, hf),
|
||||
&Paint::new(Color4f::new(0.075, 0.062, 0.150, 1.0), None),
|
||||
);
|
||||
// Violet lift top-leading, cooler indigo bottom-trailing — elliptical (window
|
||||
// aspect) via a unit-radius radial gradient under a scale.
|
||||
for (cx, cy, color, alpha) in [
|
||||
(0.26, 0.14, Color4f::new(0.40, 0.31, 0.68, 1.0), 0.9f32),
|
||||
(0.82, 0.90, Color4f::new(0.20, 0.24, 0.58, 1.0), 0.75),
|
||||
] {
|
||||
let mut paint = Paint::default();
|
||||
let c = Color4f::new(color.r, color.g, color.b, alpha);
|
||||
paint.set_shader(gradient_shader::radial(
|
||||
Point::new(0.0, 0.0),
|
||||
0.78,
|
||||
gradient_shader::GradientShaderColors::Colors(&[
|
||||
c.to_color(),
|
||||
Color4f::new(color.r, color.g, color.b, 0.0).to_color(),
|
||||
]),
|
||||
None,
|
||||
TileMode::Clamp,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
canvas.save();
|
||||
canvas.translate((wf * cx, hf * cy));
|
||||
canvas.scale((wf, hf));
|
||||
canvas.draw_rect(Rect::from_ltrb(-1.0, -1.0, 1.0, 1.0), &paint);
|
||||
canvas.restore();
|
||||
}
|
||||
let mut scrim = Paint::default();
|
||||
scrim.set_shader(gradient_shader::linear(
|
||||
(Point::new(0.0, 0.0), Point::new(0.0, hf)),
|
||||
gradient_shader::GradientShaderColors::Colors(&[
|
||||
Color4f::new(0.0, 0.0, 0.0, 0.30).to_color(),
|
||||
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
|
||||
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
|
||||
Color4f::new(0.0, 0.0, 0.0, 0.32).to_color(),
|
||||
]),
|
||||
Some(&[0.0, 0.22, 0.74, 1.0][..]),
|
||||
TileMode::Clamp,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
canvas.draw_rect(Rect::from_wh(wf, hf), &scrim);
|
||||
}
|
||||
//
|
||||
// There isn't one any more. The form screens used to sit on a STATIC deep-indigo field
|
||||
// drawn here, crossfaded over the launcher's aurora; they now wear the same living mesh at
|
||||
// `calm = 1` (see `library::mesh_sksl` and `Shell::draw_aurora`), which keeps the glass rows
|
||||
// on real colour, keeps the console's one backdrop palette-themed everywhere, and means no
|
||||
// screen in the gamepad UI is ever backed by a still image.
|
||||
|
||||
/// The loading/connecting spinner: a rotating 270° arc driven by the shell clock.
|
||||
pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) {
|
||||
|
||||
@@ -84,6 +84,9 @@ pub(crate) struct MenuList {
|
||||
bump: Spring,
|
||||
scroll: f64,
|
||||
focus: Vec<f64>,
|
||||
/// Next render, seat the scroll and the focus ease instantly instead of chasing — see
|
||||
/// [`MenuList::jump_to`].
|
||||
snap: bool,
|
||||
}
|
||||
|
||||
impl MenuList {
|
||||
@@ -93,9 +96,18 @@ impl MenuList {
|
||||
bump: Spring::rest(0.0),
|
||||
scroll: 0.0,
|
||||
focus: Vec::new(),
|
||||
snap: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor WITHOUT the scroll gliding there. For a tab switch, where the whole
|
||||
/// row set is replaced: chasing would sweep the viewport through rows that no longer
|
||||
/// exist, which reads as a glitch rather than as motion.
|
||||
pub(crate) fn jump_to(&mut self, cursor: usize) {
|
||||
self.cursor = cursor;
|
||||
self.snap = true;
|
||||
}
|
||||
|
||||
/// Route a menu event. Up/down move focus (Boundary = recoil), left/right become
|
||||
/// [`ListMsg::Adjust`], A becomes [`ListMsg::Activate`]. B is the SCREEN's.
|
||||
pub(crate) fn menu(&mut self, ev: MenuEvent, len: usize) -> (ListMsg, Option<MenuPulse>) {
|
||||
@@ -136,10 +148,19 @@ impl MenuList {
|
||||
dt: f64,
|
||||
active: bool,
|
||||
) {
|
||||
if self.snap {
|
||||
// A replaced row set has no shared history with the old one — start every row's
|
||||
// focus ease from scratch so the new cursor is simply THERE.
|
||||
self.focus.clear();
|
||||
}
|
||||
self.focus.resize(rows.len(), 0.0);
|
||||
for (i, f) in self.focus.iter_mut().enumerate() {
|
||||
let target = if active && i == self.cursor { 1.0 } else { 0.0 };
|
||||
*f = approach(*f, target, dt, 0.06);
|
||||
*f = if self.snap {
|
||||
target
|
||||
} else {
|
||||
approach(*f, target, dt, 0.06)
|
||||
};
|
||||
}
|
||||
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
|
||||
self.bump.settle(0.0, 0.3, 4.0);
|
||||
@@ -160,7 +181,11 @@ impl MenuList {
|
||||
// The scroll chases the focused row into the middle band, clamped to content.
|
||||
let focused_center = tops.get(self.cursor).map_or(0.0, |t| (t + ROW_H / 2.0) * k);
|
||||
let target = (focused_center - view_h / 2.0).clamp(0.0, (content_h - view_h).max(0.0));
|
||||
self.scroll = approach(self.scroll, target, dt, 0.08);
|
||||
self.scroll = if std::mem::take(&mut self.snap) {
|
||||
target
|
||||
} else {
|
||||
approach(self.scroll, target, dt, 0.08)
|
||||
};
|
||||
|
||||
let row_w = (ROW_MAX_W * k).min(f64::from(rect.width()) - 48.0 * k);
|
||||
let x0 = f64::from(rect.left) + (f64::from(rect.width()) - row_w) / 2.0;
|
||||
@@ -272,6 +297,98 @@ impl MenuList {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tab strip ---------------------------------------------------------------------------
|
||||
|
||||
/// The strip's design height, including the air under it before the first row.
|
||||
pub(crate) const TAB_STRIP_H: f64 = 46.0;
|
||||
|
||||
/// The horizontal section switcher above a menu list. Purely presentational — the SCREEN
|
||||
/// owns which tab is selected and what the shoulders do; this draws the pills and slides
|
||||
/// one highlight between them, so switching sections reads as travel rather than a swap.
|
||||
pub(crate) struct TabStrip {
|
||||
/// Chased highlight geometry `(x, width)` in device px. `None` until the first render,
|
||||
/// so a freshly opened screen doesn't animate its highlight in from x = 0.
|
||||
indicator: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl TabStrip {
|
||||
pub(crate) fn new() -> TabStrip {
|
||||
TabStrip { indicator: None }
|
||||
}
|
||||
|
||||
/// Draw the pills centered in `rect`'s top band. Returns nothing — the caller already
|
||||
/// knows the band is [`TAB_STRIP_H`] tall.
|
||||
#[allow(clippy::too_many_arguments)] // the crate's render signature, same as MenuList's
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
labels: &[&str],
|
||||
selected: usize,
|
||||
fonts: &Fonts,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
) {
|
||||
if labels.is_empty() {
|
||||
return;
|
||||
}
|
||||
let size = 13.0 * k;
|
||||
let pad_x = 13.0 * k;
|
||||
let gap = 7.0 * k;
|
||||
let pill_h = 30.0 * k;
|
||||
let widths: Vec<f64> = labels
|
||||
.iter()
|
||||
.map(|l| f64::from(fonts.measure(l, W::SemiBold, size)) + 2.0 * pad_x)
|
||||
.collect();
|
||||
let total: f64 = widths.iter().sum::<f64>() + gap * (labels.len() - 1) as f64;
|
||||
let mut x = f64::from(rect.left) + (f64::from(rect.width()) - total) / 2.0;
|
||||
let top = f64::from(rect.top) + 2.0 * k;
|
||||
|
||||
// Where the highlight wants to be, then the eased position it actually draws at.
|
||||
let sel = selected.min(labels.len() - 1);
|
||||
let target = (
|
||||
x + widths[..sel].iter().sum::<f64>() + gap * sel as f64,
|
||||
widths[sel],
|
||||
);
|
||||
let (ix, iw) = match self.indicator {
|
||||
None => target,
|
||||
Some((cx, cw)) => (
|
||||
approach(cx, target.0, dt, 0.07),
|
||||
approach(cw, target.1, dt, 0.07),
|
||||
),
|
||||
};
|
||||
self.indicator = Some((ix, iw));
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
Rect::from_xywh(ix as f32, top as f32, iw as f32, pill_h as f32),
|
||||
(pill_h / 2.0 / k) as f32,
|
||||
Some(brand(0.85)),
|
||||
PanelStroke::Plain(0.22),
|
||||
k as f32,
|
||||
);
|
||||
|
||||
let baseline = top + pill_h / 2.0 + size * 0.36;
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
// Fade each label toward white by how much the highlight actually covers it, so
|
||||
// the two labels a sliding highlight passes between light up together.
|
||||
let pill_x = x;
|
||||
let overlap = (pill_x + widths[i]).min(ix + iw) - pill_x.max(ix);
|
||||
let covered = (overlap / widths[i]).clamp(0.0, 1.0) as f32;
|
||||
let tw = f64::from(fonts.measure(label, W::SemiBold, size));
|
||||
fonts.draw(
|
||||
canvas,
|
||||
label,
|
||||
pill_x + (widths[i] - tw) / 2.0,
|
||||
baseline,
|
||||
W::SemiBold,
|
||||
size,
|
||||
white(0.5 + 0.5 * covered),
|
||||
);
|
||||
x += widths[i] + gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Middle-of-nowhere helper: drop chars from the FRONT until the tail fits.
|
||||
fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> String {
|
||||
if f64::from(fonts.measure(text, w, size)) <= max_w {
|
||||
|
||||
@@ -85,28 +85,43 @@ finish-args:
|
||||
# --- persistent client identity / pairing store (shared with punktfunk-probe) ---
|
||||
- --filesystem=~/.config/punktfunk:create # client-{cert,key}.pem, known-hosts, settings
|
||||
# --- HDR under gamescope (Steam Deck Game Mode) ---
|
||||
# A flatpak's Vulkan loader can't see the host's gamescope WSI layer, so the SDL3 surface never
|
||||
# offers the HDR10 (ST.2084) colorspace and the presenter silently tone-maps PQ->SDR — the
|
||||
# Game-Mode HDR indicator stays dark (verified on a Deck OLED: the sandbox loader found NO
|
||||
# frog/gamescope layer). The layer ships as the runtime extension
|
||||
# `org.freedesktop.Platform.VulkanLayer.gamescope`, which org.gnome.Platform//50 auto-mounts at
|
||||
# /usr/lib/extensions/vulkan/gamescope once installed (a one-time, per-Deck step; keep it in the
|
||||
# Decky plugin's setup / docs):
|
||||
# flatpak install --user -y flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08
|
||||
# THREE things are needed, not two (verified live on a Deck OLED — the env vars alone left
|
||||
# hdr10_format=None). (1) VK_ADD_IMPLICIT_LAYER_PATH puts the layer's implicit-layer JSON on the
|
||||
# Vulkan loader's search path (the runtime point mounts the files but not onto the path). (2)
|
||||
# ENABLE_GAMESCOPE_WSI flips the layer's own `enable_environment` gate. (3) The layer, once
|
||||
# loaded, must open a *Wayland* connection to gamescope's private socket ($GAMESCOPE_WAYLAND_DISPLAY
|
||||
# = gamescope-0) to negotiate the HDR10 colorspace via the gamescope_swapchain protocol — but the
|
||||
# Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported), so --socket=wayland
|
||||
# binds nothing and that socket never enters the sandbox. Without it the layer loads, maps, and
|
||||
# silently can't reach the compositor → no HDR10 offered → PQ tone-mapped to SDR, badge dark.
|
||||
# Binding xdg-run/gamescope-0 is the missing half (chiaki-ng does the same). With all three the
|
||||
# surface offers HDR10 and the presenter's existing HDR10 swapchain path engages — no client code
|
||||
# change. Harmless off-Deck: the layer no-ops when there's no gamescope socket to bind.
|
||||
- --env=VK_ADD_IMPLICIT_LAYER_PATH=/usr/lib/extensions/vulkan/gamescope/share/vulkan/implicit_layer.d
|
||||
# A flatpak's Vulkan loader can't see the host's gamescope WSI layer, so without help the SDL3
|
||||
# surface never offers the HDR10 (ST.2084) colorspace and the presenter silently tone-maps
|
||||
# PQ->SDR — the field-reported "HDR->SDR" badge. The layer is now VENDORED (see the
|
||||
# gamescope-wsi-layer module below), so it is always present and there is no longer any
|
||||
# manual `flatpak install ... VulkanLayer.gamescope` step for the user.
|
||||
# FOUR things are needed. An earlier revision of this block claimed three and was WRONG: the
|
||||
# fourth is the gate that makes the other three moot, so the Deck sat at hdr10_format=None with
|
||||
# all of (1)-(3) in place, which is exactly the field report ("HDR->SDR" in the stats overlay).
|
||||
# (1) the layer's implicit-layer JSON must be on the Vulkan loader's search path — now
|
||||
# automatic, the vendored module installs it to /app/share/vulkan/implicit_layer.d which
|
||||
# XDG_DATA_DIRS already covers. (2) ENABLE_GAMESCOPE_WSI
|
||||
# flips the layer's own `enable_environment` gate. (3) --filesystem=xdg-run/gamescope-0 binds
|
||||
# gamescope's private Wayland socket: the layer must reach the compositor over it to negotiate
|
||||
# HDR10, and the Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported) so
|
||||
# --socket=wayland binds nothing (chiaki-ng does the same). (4) GAMESCOPE_WAYLAND_DISPLAY must be
|
||||
# set INSIDE the sandbox. The layer's `isRunningUnderGamescope()` reads that env var and nothing
|
||||
# else; flatpak does not forward host env, so it arrives unset and the layer's CreateInstance
|
||||
# early-returns before it ever creates a GamescopeInstance. The layer still LOADS and still logs
|
||||
# its generic bits ("Forcing on VK_EXT_swapchain_maintenance1", swapchain destroys), which is why
|
||||
# this reads as working — but no gamescope surface is made, so no HDR10 format is ever appended
|
||||
# and (1)-(3) buy nothing. Measured on a Deck OLED (Galileo, SteamOS 3.8.16) 2026-08-05, client
|
||||
# `--browse`, reading `pf_presenter::vk::setup` "swapchain config":
|
||||
# unset -> no "[Gamescope WSI] Surface state" block at all, hdr10_format=None
|
||||
# set, hdr_enabled=0 -> "server hdr output enabled: false", hdr10_format=None
|
||||
# set, hdr_enabled=1 -> "hdr formats exposed to client: true",
|
||||
# hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT)
|
||||
# DXVK_HDR is NOT the gate for us and was ruled out by measurement: the layer forces it OFF for
|
||||
# clients it has already decided to deny, it does not turn HDR on.
|
||||
# Hardcoding `gamescope-0` matches the socket bound just below, and is safe off-Deck both ways:
|
||||
# on a normal Wayland desktop --socket=wayland sets WAYLAND_DISPLAY=wayland-0 inside the sandbox
|
||||
# and the layer bails on the mismatch; on X11-only there is no gamescope socket to connect to, so
|
||||
# it prints one "Bypass layer will be unavailable" line and passes through.
|
||||
# The REMAINING gate is not ours: gamescope's `hdr_enabled` convar (Steam's HDR display setting)
|
||||
# drives the GAMESCOPE_HDR_OUTPUT_FEEDBACK X property the layer reads, and with it off no app on
|
||||
# the Deck gets HDR. See docs — that one is a user/Decky-side step, not a packaging one.
|
||||
- --env=ENABLE_GAMESCOPE_WSI=1
|
||||
- --env=GAMESCOPE_WAYLAND_DISPLAY=gamescope-0 # the layer's ONLY "am I under gamescope?" signal
|
||||
- --filesystem=xdg-run/gamescope-0 # gamescope's private Wayland socket (HDR negotiation)
|
||||
|
||||
build-options:
|
||||
@@ -180,6 +195,91 @@ modules:
|
||||
cleanup:
|
||||
- '*'
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# gamescope WSI layer — VENDORED, so HDR works from a plain `flatpak install` with no
|
||||
# second step. This is the ONLY route to HDR on a Deck: measured on SteamOS 3.8.16
|
||||
# (gamescope 3.16.23.4), the gamescope-0 socket advertises `gamescope_swapchain_factory_v2`
|
||||
# but NOT `wp_color_manager_v1` (checked with HDR both off and on), so Mesa's Wayland WSI
|
||||
# has no colour-management protocol to negotiate HDR10 through and only this layer can add
|
||||
# the ST.2084 surface formats. Without it: zero `[Gamescope WSI]` lines, hdr10_format=None.
|
||||
#
|
||||
# It used to come from the flathub runtime extension
|
||||
# `org.freedesktop.Platform.VulkanLayer.gamescope`, which every user had to install BY HAND
|
||||
# (documented only in a comment here — so in practice nobody did, and the field report was
|
||||
# "HDR->SDR" in the stats overlay). Vendoring instead of `add-extensions` autodownload,
|
||||
# deliberately: that extension is 94 MB of whole-gamescope to deliver one 4 MB .so, its
|
||||
# layer JSON hardcodes a /usr `library_path` that an app-scoped extension (mounted under
|
||||
# /app) would not satisfy, and it would make flathub a hard install-time dependency of an
|
||||
# app we self-host on flatpak.unom.io.
|
||||
#
|
||||
# Pinned to the SAME gamescope rev as packaging/gamescope/PKGBUILD (`_gsrev`) so the
|
||||
# client's layer and the host's punktfunk-gamescope always come from one tree — bump both
|
||||
# together. `enable_gamescope=false` skips subdir('src') and every compositor dependency
|
||||
# (wlroots, SDL2, libliftoff, ...); only protocol/ and layer/ are built.
|
||||
#
|
||||
# `buildsystem: simple` rather than `meson` because two subprojects need their wrap
|
||||
# `patch_directory` applied by hand: glm and stb ship NO meson.build of their own, and the
|
||||
# one meson would normally inject lives in subprojects/packagefiles/. Cloning them as plain
|
||||
# sources without that copy fails at configure with "Subproject exists but has no
|
||||
# meson.build file". `--wrap-mode=nodownload` then proves the build is genuinely offline.
|
||||
#
|
||||
# The layer JSON is generated by meson from prefix+libdir, so it self-writes
|
||||
# `library_path: /app/lib/libVkLayer_FROG_gamescope_wsi_x86_64.so` and lands in
|
||||
# /app/share/vulkan/implicit_layer.d — already on the loader's search path via
|
||||
# XDG_DATA_DIRS, which is why no VK_ADD_IMPLICIT_LAYER_PATH is needed (and why it was
|
||||
# dropped from finish-args: pointing at the old /usr extension path too would risk
|
||||
# double-loading two layers of the same name).
|
||||
#
|
||||
# Verified on a Deck OLED 2026-08-05: builds offline in org.gnome.Sdk//50, and the
|
||||
# resulting .so drives the Deck's system gamescope to
|
||||
# "hdr formats exposed to client: true" + hdr10_format=Some(...).
|
||||
# ---------------------------------------------------------------------------------------
|
||||
- name: gamescope-wsi-layer
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
# Apply the wraps' patch_directory by hand (see above) — these supply the meson.build
|
||||
# that glm and stb do not ship themselves.
|
||||
- cp -r subprojects/packagefiles/glm/. subprojects/glm/
|
||||
- cp -r subprojects/packagefiles/stb/. subprojects/stb/
|
||||
- meson setup _build --prefix=/app --libdir=lib --wrap-mode=nodownload
|
||||
-Denable_gamescope=false -Denable_gamescope_wsi_layer=true
|
||||
-Denable_tests=false -Denable_openvr_support=false
|
||||
- ninja -C _build
|
||||
- ninja -C _build install
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/ValveSoftware/gamescope.git
|
||||
# KEEP IN SYNC with `_gsrev` in packaging/gamescope/PKGBUILD.
|
||||
commit: 8c676c399c761e4540587f61004c957993d12fea
|
||||
# Wrap pins as of that rev (`subprojects/*.wrap`). These are meson WRAPS, not gamescope
|
||||
# submodules, so nothing else populates them and they need explicit sources.
|
||||
#
|
||||
# vkroots is deliberately NOT listed here. It is a real gamescope SUBMODULE, and
|
||||
# flatpak-builder clones git sources with submodules by default — so it is already
|
||||
# checked out at exactly the rev above (`git ls-tree <rev> subprojects/vkroots`), which
|
||||
# leaves `subprojects/vkroots/.git` as a gitlink FILE. Declaring it again with
|
||||
# `dest: subprojects/vkroots` made the extractor copy the bare mirror onto that path and
|
||||
# die before any build command ran:
|
||||
# cp: cannot overwrite non-directory '.../subprojects/vkroots/.git' with directory
|
||||
# Re-adding it re-breaks the whole flatpak. If the submodule ever needs to be pinned away
|
||||
# from the gamescope rev, set `disable-submodules: true` on the source above and then
|
||||
# declare ALL THREE subprojects explicitly — not one of them alone.
|
||||
- type: git
|
||||
url: https://github.com/g-truc/glm.git
|
||||
commit: 0af55ccecd98d4e5a8d1fad7de25ba429d60e863
|
||||
dest: subprojects/glm
|
||||
- type: git
|
||||
url: https://github.com/nothings/stb.git
|
||||
commit: 5736b15f7ea0ffb08dd38af21067c314d6a3aae9
|
||||
dest: subprojects/stb
|
||||
cleanup:
|
||||
# Only the .so and its implicit-layer JSON are runtime. vkroots installs its dev files
|
||||
# from the subproject, and the layer-only install still drops gamescope's display .lua
|
||||
# scripts + LUT .cube files, none of which a client uses.
|
||||
- /include
|
||||
- /lib/pkgconfig
|
||||
- /share/gamescope
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# The client. cargo-sources.json is the GENERATED offline crate cache:
|
||||
# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json
|
||||
|
||||
Reference in New Issue
Block a user