diff --git a/Cargo.lock b/Cargo.lock
index 83290f80..9e234aac 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2918,6 +2918,7 @@ dependencies = [
"ndk",
"opus",
"punktfunk-core",
+ "tracing",
]
[[package]]
diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml
index 932c3d6c..1bc78c58 100644
--- a/clients/android/app/src/main/AndroidManifest.xml
+++ b/clients/android/app/src/main/AndroidManifest.xml
@@ -13,6 +13,12 @@
reception needs it (also an OEM Wi-Fi power-save hedge). -->
+
+
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt
index e00a2d6c..7da0c8a2 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt
@@ -2,7 +2,8 @@ package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
-import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -361,15 +362,15 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
@Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
- val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale")
+ val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
val shape = RoundedCornerShape(14.dp)
Row(
modifier = Modifier
.fillMaxWidth()
- .graphicsLayer { scaleX = scale; scaleY = scale }
+ .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
- .background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
- .border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
+ .background(visuals.background)
+ .border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -389,15 +390,20 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
@Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
- val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale")
+ val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
+ val labelColor by animateColorAsState(
+ if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
+ tween(160),
+ label = "addLabel",
+ )
Box(
modifier = Modifier
.fillMaxWidth()
- .graphicsLayer { scaleX = scale; scaleY = scale }
+ .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
- .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
- .border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
+ .background(visuals.background)
+ .border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
@@ -406,7 +412,7 @@ private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onCl
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
- color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
+ color = labelColor,
)
}
}
@@ -448,11 +454,19 @@ private fun KeyboardGrid(
@Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
+ // Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
+ // trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
+ val bg by animateColorAsState(
+ if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
+ tween(90),
+ label = "keyBg",
+ )
+ val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
.clip(RoundedCornerShape(9.dp))
- .background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
+ .background(bg)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
@@ -460,7 +474,7 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
- color = if (focused) Color.Black else Color.White,
+ color = fg,
textAlign = TextAlign.Center,
)
}
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt
index 61c89430..e7e4cefb 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt
@@ -1,10 +1,14 @@
package io.unom.punktfunk
+import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
@@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -31,20 +36,28 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
+import io.unom.punktfunk.kit.Gamepad
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.max
+import kotlin.math.roundToInt
import kotlin.math.sin
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
@@ -189,9 +202,12 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
}
/**
- * One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue.
- * [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working
- * controller can still drive the console UI (and reach Settings to switch it off).
+ * One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android
+ * `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is
+ * actually DRAWN is the hint bar's decision, per the driving controller's [Gamepad.PadStyle] — a
+ * DualSense renders 'A' as the ✕ shape, a Switch pad as a monochrome letter. [onClick], when set,
+ * makes the cell tappable — a TOUCH escape hatch so a user without a working controller can still
+ * drive the console UI (and reach Settings to switch it off).
*/
class GamepadHint(
val glyph: Char,
@@ -201,11 +217,16 @@ class GamepadHint(
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
// for a TV remote, which has no A/B/X/Y.
val select: Boolean = false,
- // Render as the gamepad Select/View button (a small capsule).
+ // Render as the pad's physical Select/View/Create/− button (per PadStyle) — the button that
+ // delivers KEYCODE_BUTTON_SELECT.
val viewButton: Boolean = false,
)
-/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */
+/**
+ * Xbox-convention face-button colours, so the glyphs read at a glance across the room. These are
+ * the DEFAULT (Xbox/generic) rendering; the hint bar swaps in PlayStation shapes or Nintendo
+ * monochrome per the driving pad's [Gamepad.PadStyle] at draw time.
+ */
object PadGlyph {
val A = Color(0xFF6BBE45)
val B = Color(0xFFD14B4B)
@@ -216,6 +237,87 @@ object PadGlyph {
)
}
+/** The dark button-face fill shared by the PlayStation / Nintendo / select-button badges. */
+internal val PadButtonFace = Color(0xFF2A2740)
+
+/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */
+class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: Color)
+
+/**
+ * The focus visuals every console form element shares (settings rows, add-host fields, action
+ * rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and
+ * resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border
+ * of a field actively receiving keyboard input.
+ */
+@Composable
+fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
+ val scale by animateFloatAsState(
+ targetValue = if (active) 1f else 0.98f,
+ animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
+ label = "consoleScale",
+ )
+ val background by animateColorAsState(
+ if (active) Color(0x336656F2) else Color(0x14FFFFFF),
+ tween(160),
+ label = "consoleBg",
+ )
+ val border by animateColorAsState(
+ when {
+ editing -> Color(0xB38678F5)
+ active -> Color.White.copy(alpha = 0.28f)
+ else -> Color.White.copy(alpha = 0.06f)
+ },
+ tween(160),
+ label = "consoleBorder",
+ )
+ return ConsoleFocusVisuals(scale, background, border)
+}
+
+/**
+ * The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet
+ * track that tints as it engages while the knob slides across on a spring — the state change reads
+ * from across the room, and the motion confirms the press.
+ */
+@Composable
+fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
+ val travel by animateFloatAsState(
+ targetValue = if (on) 1f else 0f,
+ animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
+ label = "switchKnob",
+ )
+ val track by animateColorAsState(
+ if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
+ tween(200),
+ label = "switchTrack",
+ )
+ val outline by animateColorAsState(
+ Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
+ tween(160),
+ label = "switchOutline",
+ )
+ val trackW = 44.dp
+ val trackH = 24.dp
+ val pad = 3.dp
+ val knob = trackH - pad * 2
+ Box(
+ modifier
+ .size(trackW, trackH)
+ .clip(RoundedCornerShape(50))
+ .background(track)
+ .border(1.dp, outline, RoundedCornerShape(50)),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ Box(
+ Modifier
+ .padding(horizontal = pad)
+ .offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
+ .size(knob)
+ .clip(CircleShape)
+ .background(Color.White),
+ )
+ }
+}
+
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
@Composable
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
@@ -253,16 +355,94 @@ private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
GamepadButtonGlyph('↩', PadGlyph.B, size)
}
-/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */
+/**
+ * A PlayStation face button: the dark button face with the coloured shape outline Sony prints on it.
+ * Keyed by the SEMANTIC letter (Android keycode name): A = ✕ cross, B = ○ circle, X = □ square,
+ * Y = △ triangle — exactly how a Sony pad's buttons map to `KEYCODE_BUTTON_*`, in the classic
+ * DualShock colours.
+ */
@Composable
-private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
- Box(Modifier.size(size), contentAlignment = Alignment.Center) {
- Box(
- Modifier
- .size(width = size * 0.74f, height = size * 0.46f)
- .clip(RoundedCornerShape(50))
- .border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
- )
+internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) {
+ val color = when (glyph) {
+ 'A' -> Color(0xFF7C9CE8) // cross — light blue
+ 'B' -> Color(0xFFE0736F) // circle — red
+ 'X' -> Color(0xFFD48FC7) // square — pink
+ else -> Color(0xFF5FBFA5) // triangle — green
+ }
+ Box(
+ Modifier.size(size).clip(CircleShape).background(PadButtonFace),
+ contentAlignment = Alignment.Center,
+ ) {
+ Canvas(Modifier.size(size * 0.46f)) {
+ val w = this.size.minDimension
+ val stroke = Stroke(width = w * 0.17f, cap = StrokeCap.Round, join = StrokeJoin.Round)
+ when (glyph) {
+ 'A' -> { // ✕ — the two diagonals
+ drawLine(color, Offset(0f, 0f), Offset(w, w), stroke.width, StrokeCap.Round)
+ drawLine(color, Offset(w, 0f), Offset(0f, w), stroke.width, StrokeCap.Round)
+ }
+ 'B' -> drawCircle(color, radius = (w - stroke.width) / 2f, style = stroke)
+ 'X' -> drawRect(
+ color,
+ topLeft = Offset(stroke.width / 2f, stroke.width / 2f),
+ size = Size(w - stroke.width, w - stroke.width),
+ style = stroke,
+ )
+ else -> { // △
+ val p = Path().apply {
+ moveTo(w / 2f, stroke.width / 2f)
+ lineTo(w - stroke.width / 2f, w - stroke.width / 2f)
+ lineTo(stroke.width / 2f, w - stroke.width / 2f)
+ close()
+ }
+ drawPath(p, color, style = stroke)
+ }
+ }
+ }
+ }
+}
+
+/**
+ * The pad's physical Select-family button — the one that delivers `KEYCODE_BUTTON_SELECT` and opens
+ * Options — drawn per [Gamepad.PadStyle] as a badge with the button's real face: Xbox View (two
+ * overlapping windows), PlayStation Create/Share (a slim capsule), Nintendo − (minus). The generic
+ * fallback wears the capsule too (the near-universal select shape).
+ */
+@Composable
+internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
+ Box(
+ Modifier.size(size).clip(CircleShape).background(PadButtonFace),
+ contentAlignment = Alignment.Center,
+ ) {
+ when (style) {
+ Gamepad.PadStyle.XBOX -> Box(Modifier.size(size * 0.50f)) {
+ // The View icon: two overlapping outlined windows; the front one is filled with the
+ // button face so it visibly occludes the back one.
+ val corner = RoundedCornerShape(2.dp)
+ Box(
+ Modifier.size(size * 0.32f).align(Alignment.TopEnd)
+ .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
+ )
+ Box(
+ Modifier.size(size * 0.32f).align(Alignment.BottomStart)
+ .clip(corner).background(PadButtonFace)
+ .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
+ )
+ }
+ Gamepad.PadStyle.NINTENDO -> Text(
+ "−",
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ fontSize = (size.value * 0.62f).sp,
+ textAlign = TextAlign.Center,
+ )
+ else -> Box(
+ Modifier
+ .size(width = size * 0.58f, height = size * 0.30f)
+ .clip(RoundedCornerShape(50))
+ .border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
+ )
+ }
}
}
@@ -274,8 +454,12 @@ private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
- // home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests).
- val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
+ // home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
+ // PlayStation shapes / Nintendo monochrome) from the controller that last drove the UI.
+ // Defaults to the generic gamepad look off an Activity (preview/tests).
+ val activity = LocalContext.current as? MainActivity
+ val padIsGamepad = activity?.lastPadIsGamepad ?: true
+ val padStyle = activity?.lastPadStyle ?: Gamepad.PadStyle.GENERIC
val shape = RoundedCornerShape(50)
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
@@ -300,9 +484,13 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze
}
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
when {
- h.viewButton -> ViewButtonGlyph()
+ h.viewButton -> SelectButtonGlyph(padStyle)
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
+ padStyle == Gamepad.PadStyle.PLAYSTATION && h.glyph in "ABXY" ->
+ PsFaceGlyph(h.glyph)
+ padStyle == Gamepad.PadStyle.NINTENDO && h.glyph in "ABXY" ->
+ GamepadButtonGlyph(h.glyph, PadButtonFace)
else -> GamepadButtonGlyph(h.glyph, h.color)
}
Spacer(Modifier.width(6.dp))
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt
index 0f87d2c3..5d11b84f 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt
@@ -2,7 +2,12 @@ package io.unom.punktfunk
import android.os.Build
import androidx.activity.compose.BackHandler
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -19,6 +24,8 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -26,6 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
@@ -90,8 +98,11 @@ fun GamepadDialog(
},
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
)
- // Cap the card to most of the screen and let the BODY scroll — in a short landscape window the
- // title + body + buttons would otherwise overflow and compress/clip the bottom button.
+ // Cap the card to most of the screen and let body + BUTTONS scroll together — in a short
+ // landscape window a 5-action stack (host options) exceeds the card even with an empty body, and
+ // a pinned actions column can only compress/clip its last button. Only the title stays pinned;
+ // the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
+ // the current action even when the stack scrolls.
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
@@ -109,43 +120,66 @@ fun GamepadDialog(
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
- // The body scrolls; the title above and the buttons below stay pinned + always visible.
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
body()
- }
- Spacer(Modifier.size(4.dp))
- actions.forEachIndexed { i, a ->
- DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
+ Spacer(Modifier.size(4.dp))
+ actions.forEachIndexed { i, a ->
+ DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
+ }
}
}
}
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
- val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale")
+ val scale by animateFloatAsState(
+ if (focused) 1.02f else 1f,
+ spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
+ label = "btnScale",
+ )
+ // The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button
+ // that's scrolled out of a short window, pull it into view (no-op when already visible).
+ val intoView = remember { BringIntoViewRequester() }
+ LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp)
- val bg = when {
- focused -> Color(0xFF6656F2)
- primary -> Color(0x336656F2)
- else -> Color(0x14FFFFFF)
- }
- val fg = when {
- !enabled -> Color.White.copy(alpha = 0.35f)
- focused -> Color.White
- primary -> Color(0xFF8678F5)
- else -> Color.White.copy(alpha = 0.85f)
- }
+ // Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
+ val bg by animateColorAsState(
+ when {
+ focused -> Color(0xFF6656F2)
+ primary -> Color(0x336656F2)
+ else -> Color(0x14FFFFFF)
+ },
+ tween(160),
+ label = "btnBg",
+ )
+ val fg by animateColorAsState(
+ when {
+ !enabled -> Color.White.copy(alpha = 0.35f)
+ focused -> Color.White
+ primary -> Color(0xFF8678F5)
+ else -> Color.White.copy(alpha = 0.85f)
+ },
+ tween(160),
+ label = "btnFg",
+ )
+ val borderColor by animateColorAsState(
+ Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
+ tween(160),
+ label = "btnBorder",
+ )
Box(
modifier = Modifier
.fillMaxWidth()
+ .bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(bg)
- .border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
+ .border(1.dp, borderColor, shape)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt
index 8ef08b12..85a548fa 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt
@@ -2,7 +2,19 @@ package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -57,6 +69,7 @@ private class GpRow(
val detail: String,
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
+ val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
)
@Composable
@@ -72,6 +85,9 @@ fun GamepadSettingsScreen(
val rows = buildSettingsRows(s, ::update)
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex
+ // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
+ // value text slides in its AnimatedContent, so the motion matches the button press.
+ var adjustDir by remember { mutableIntStateOf(1) }
val listState = rememberLazyListState()
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
@@ -83,11 +99,11 @@ fun GamepadSettingsScreen(
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
- NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
- NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1)
+ NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
+ NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
}
},
- onActivate = { rows.getOrNull(focus)?.activate() },
+ onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
)
// 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.
@@ -121,8 +137,8 @@ fun GamepadSettingsScreen(
ConsoleHeader("Settings", horizontalInset = false)
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
- SettingRowView(row, focused = index == focus, onClick = {
- if (focus == index) row.activate() else focus = index
+ SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
+ if (focus == index) { adjustDir = 1; row.activate() } else focus = index
})
}
}
@@ -150,9 +166,17 @@ fun GamepadSettingsScreen(
}
@Composable
-private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
- val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
+private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
+ val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
+ // The chevrons keep their layout slot and only fade, so the value never jumps sideways when
+ // focus arrives; the value colour cross-fades with them.
+ val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
+ val valueColor by animateColorAsState(
+ Color.White.copy(alpha = if (focused) 1f else 0.6f),
+ tween(160),
+ label = "valueColor",
+ )
Column {
if (row.header != null) {
Text(
@@ -166,10 +190,10 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
- .graphicsLayer { scaleX = scale; scaleY = scale }
+ .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
- .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
- .border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
+ .background(visuals.background)
+ .border(1.dp, visuals.border, shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -186,19 +210,41 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
maxLines = 1,
)
Spacer(Modifier.weight(1f))
- if (focused) Text("‹ ", color = Color.White.copy(alpha = 0.6f))
- Text(
- row.value,
- style = MaterialTheme.typography.bodyMedium,
- color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f))
+ if (row.toggled != null) {
+ // A toggle is a switch, not text — the sliding knob + tinting track IS the value.
+ ConsoleSwitch(on = row.toggled, focused = focused)
+ } else {
+ Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
+ // The value slides in the direction it was stepped and its width animates, so
+ // cycling a choice reads as motion through a list rather than a text swap.
+ AnimatedContent(
+ targetState = row.value,
+ transitionSpec = {
+ val dir = adjustDir
+ (slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith
+ (slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using
+ SizeTransform(clip = false)
+ },
+ label = "value",
+ ) { value ->
+ Text(
+ value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = valueColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Text(" ›", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
+ }
}
// The focused row carries its own one-line description — no dedicated (space-eating)
- // detail strip. It appears right where you're looking, and the row grows to fit.
- if (focused && row.detail.isNotBlank()) {
+ // detail strip. It unfolds right where you're looking, and the row grows to fit.
+ AnimatedVisibility(
+ visible = focused && row.detail.isNotBlank(),
+ enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
+ exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
+ ) {
Text(
row.detail,
style = MaterialTheme.typography.bodySmall,
@@ -245,6 +291,7 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
+ toggled = value,
)
return listOf(
@@ -278,6 +325,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List Unit): List Unit, context: an
ToggleRow(
title = "Low-latency mode (experimental)",
subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " +
- "decode, Wi-Fi and HDMI hints). Can lower latency, but may stutter or glitch on " +
+ "decode, HDMI game mode). Can lower latency, but may stutter or glitch on " +
"some devices — turn off if the stream misbehaves.",
checked = s.lowLatencyMode,
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt
index 8ce26d40..fd91c1c8 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt
@@ -6,6 +6,7 @@ import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.os.Build
+import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
@@ -65,9 +66,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val touchMode = initialSettings.touchMode
// "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the
- // default) runs the original pre-overhaul pipeline; on enables the whole aggressive stack —
- // decoder ranking + vendor keys + async loop (native side), the Wi-Fi low-latency lock and
- // HDMI ALLM below, game-tagged audio, and DSCP marking (applied earlier, at connect).
+ // default) runs the original decode pipeline; on enables the aggressive stack — decoder
+ // ranking + vendor keys + async loop (native side), HDMI ALLM below, game-tagged audio, and
+ // DSCP marking (applied earlier, at connect).
val lowLatencyMode = initialSettings.lowLatencyMode
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
@@ -117,26 +118,40 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
val closed = remember { AtomicBoolean(false) }
- // A Wi-Fi low-latency lock held for the stream's duration: asks the Wi-Fi firmware to drop its
- // power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API
- // 29+) is the strongest; older releases fall back to FULL_HIGH_PERF. Needs no extra permission
- // beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release.
- // Part of the experimental low-latency stack — not created at all when the toggle is off.
- val wifiLock = remember(handle) {
- if (!lowLatencyMode) return@remember null
+ // Wi-Fi locks held for the stream's duration — BOTH of them, unconditionally (Moonlight does
+ // the same). Without an effective lock, Wi-Fi power save batches downlink delivery into
+ // beacon-interval clumps: hundreds of ms of latency mush, sawtoothing bitrate, and periodic
+ // whole-frame loss when the AP's power-save buffer overflows (all observed live on a phone).
+ // - FULL_LOW_LATENCY (API 29+) is the only lock that actually disables power save on modern
+ // Android; it needs the app foreground + screen on, which a stream always is.
+ // - FULL_HIGH_PERF covers older releases — it is deprecated AND a documented no-op on recent
+ // Android, which is exactly why it can't be the only lock (a lesson learned: holding just
+ // HIGH_PERF left power save fully active on Android 13+).
+ // acquire() ENFORCES the WAKE_LOCK permission (manifest) — and a failed acquire MUST be loud:
+ // a silent runCatching hid the missing permission for weeks (dumpsys wifi showed
+ // low_latency_active_time_ms=0 across every "locked" stream). Non-reference-counted: one
+ // explicit acquire/release each.
+ val wifiLocks = remember(handle) {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
- val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- WifiManager.WIFI_MODE_FULL_LOW_LATENCY
- } else {
+ ?: return@remember emptyList()
+ buildList {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "punktfunk:stream-ll")
+ ?.let(::add)
+ }
@Suppress("DEPRECATION")
- WifiManager.WIFI_MODE_FULL_HIGH_PERF
- }
- wm?.createWifiLock(mode, "punktfunk:stream")?.apply { setReferenceCounted(false) }
+ wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "punktfunk:stream-hp")
+ ?.let(::add)
+ }.onEach { it.setReferenceCounted(false) }
}
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- runCatching { wifiLock?.acquire() }
+ wifiLocks.forEach { lock ->
+ runCatching { lock.acquire() }.onFailure { e ->
+ Log.w("punktfunk", "WifiLock acquire failed — power save stays ON: $lock", e)
+ }
+ }
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API
// 30+. Part of the experimental low-latency stack.
@@ -175,7 +190,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(false)
}
- runCatching { if (wifiLock?.isHeld == true) wifiLock.release() }
+ wifiLocks.forEach { runCatching { if (it.isHeld) it.release() } }
// Release the landscape lock so the rest of the app follows the device/system again.
activity?.requestedOrientation =
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt
index e2a5a6ea..f60c01fe 100644
--- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt
+++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt
@@ -187,12 +187,19 @@ internal fun StreamScene() {
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
),
) {
- // [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped,
- // bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit
- // BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line.
+ // The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
+ // e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer,
+ // chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50]. 10/9/16/1 = a 10-bit BT.2020
+ // PQ (HDR) 4:2:0 feed so the HUD renders its video-feed line; the Phase-2 stage terms
+ // (host 0.6 + network 0.3 + decode 0.4) tile the 1.3 ms headline so it renders the full
+ // split equation, and the decoder label line shows the ranked low-latency decoder.
StatsOverlay(
- doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0),
- Modifier.align(Alignment.TopStart).padding(12.dp),
+ doubleArrayOf(
+ 238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0,
+ 10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
+ ),
+ decoderLabel = "c2.qti.hevc.decoder · low-latency",
+ modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
)
}
}
diff --git a/clients/android/kit/build.gradle.kts b/clients/android/kit/build.gradle.kts
index 61d06187..4220df88 100644
--- a/clients/android/kit/build.gradle.kts
+++ b/clients/android/kit/build.gradle.kts
@@ -110,8 +110,18 @@ afterEvaluate {
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
// for every normal APK/AAR build.
+ //
+ // DEBUG APKs SHIP RELEASE RUST. Cargo's debug profile is not "a bit slower" for this library —
+ // it is unusable: the AES-GCM data-plane decrypt runs through generic-array iterator closures
+ // with per-byte UB checks instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf):
+ // ~800 µs of user CPU per 1.4 KB packet, the receive pump pinned over a full core yet unable to
+ // drain a 20 Mbps stream — every debug-APK on-device test was silently benchmarking unoptimized
+ // crypto, not the streaming pipeline. Kotlin debuggability is untouched (the APK is still a
+ // debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
+ // build for the rare session that actually steps through Rust.
if (!project.hasProperty("skipRustBuild")) {
- tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
+ val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease
+ tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
}
}
diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt
index 9712b96b..33812c44 100644
--- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt
+++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt
@@ -57,6 +57,7 @@ object Gamepad {
private const val VID_SONY = 0x054C
private const val VID_MICROSOFT = 0x045E
private const val VID_VALVE = 0x28DE
+ private const val VID_NINTENDO = 0x057E
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
@@ -98,6 +99,28 @@ object Gamepad {
}
}
+ /**
+ * The glyph family a controller's physical buttons belong to, for the console UI's hint bar —
+ * so a DualSense user sees ✕/○/□/△ shapes and a Switch pad its monochrome lettering instead of
+ * Xbox's coloured letters. PURELY visual: the wire mapping ([buttonBit]) is unaffected.
+ */
+ enum class PadStyle { GENERIC, XBOX, PLAYSTATION, NINTENDO }
+
+ /**
+ * Resolve the [PadStyle] for a connected controller by USB vendor id. Vendor alone is enough —
+ * every pad a vendor ships wears its family's glyphs (any Sony pad has the shapes, any Nintendo
+ * pad the −/+ system buttons), so unlike [prefFor] no PID table is needed. Valve renders as
+ * [PadStyle.XBOX]: Steam pads carry A/B/X/Y in Xbox positions. Unknown vendors (8BitDo & co.,
+ * which near-universally clone the Xbox layout) fall back to [PadStyle.GENERIC], drawn with the
+ * Xbox convention.
+ */
+ fun styleFor(dev: InputDevice?): PadStyle = when (dev?.vendorId) {
+ VID_SONY -> PadStyle.PLAYSTATION
+ VID_MICROSOFT, VID_VALVE -> PadStyle.XBOX
+ VID_NINTENDO -> PadStyle.NINTENDO
+ else -> PadStyle.GENERIC
+ }
+
/** True when [dev]'s source classes include gamepad or joystick. */
fun isPad(dev: InputDevice?): Boolean {
val s = dev?.sources ?: return false
diff --git a/clients/android/native/Cargo.toml b/clients/android/native/Cargo.toml
index a4f92725..24ada0dd 100644
--- a/clients/android/native/Cargo.toml
+++ b/clients/android/native/Cargo.toml
@@ -31,6 +31,14 @@ mdns-sd = "0.20"
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14"
+# Feature bridge, no code here: punktfunk-core logs through `tracing`, but this client only
+# installs `android_logger` (a `log` backend). Core transport warnings (e.g. "UDP socket buffer
+# capped well below target") reach logcat only via tracing's "log" feature, which forwards events
+# as `log` records when no tracing subscriber is set (always, here). Today that feature happens to
+# be enabled transitively — quinn's default `log` feature unifies `tracing/log` onto the whole
+# graph — but nothing about this client's logging should hinge on a QUIC crate's default feature
+# set, so declare it explicitly.
+tracing = { version = "0.1", default-features = false, features = ["std", "log"] }
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
diff --git a/clients/android/native/src/lib.rs b/clients/android/native/src/lib.rs
index 25dee21c..d9dbf1ee 100644
--- a/clients/android/native/src/lib.rs
+++ b/clients/android/native/src/lib.rs
@@ -44,7 +44,10 @@ mod stats;
mod wol;
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
-/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
+/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
+/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
+/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
+/// installed. Android-only — there is no JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn JNI_OnLoad(
diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs
index fc7d8c85..ecf98381 100644
--- a/clients/linux/src/ui_hosts.rs
+++ b/clients/linux/src/ui_hosts.rs
@@ -162,9 +162,20 @@ pub fn new(settings: Rc>, cbs: HostsCallbacks) -> HostsUi {
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
// the child's own `activate` signal — so bridge it back to the child, where each card wires
// its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead.
+ //
+ // `child.activate()` in turn runs `GtkFlowBoxChild`'s own default handler, which re-emits
+ // `child-activated` on the FlowBox — bouncing straight back into this closure. Unguarded,
+ // that ping-pong recurses forever and overflows the stack on every single card click/Enter
+ // (a real crash seen live, not hypothetical); the re-entrancy flag breaks the cycle after
+ // the one real activation.
for flow in [&saved_flow, &disc_flow] {
- flow.connect_child_activated(|_, child| {
+ let activating = std::cell::Cell::new(false);
+ flow.connect_child_activated(move |_, child| {
+ if activating.replace(true) {
+ return;
+ }
child.activate();
+ activating.set(false);
});
}
@@ -720,3 +731,53 @@ fn add_host_dialog(state: &Rc) {
}
dialog.present(Some(&state.stack));
}
+
+#[cfg(test)]
+mod tests {
+ use adw::prelude::*;
+ use std::cell::Cell;
+ use std::rc::Rc;
+
+ // Reproduces the exact FlowBox/FlowBoxChild wiring from `new()`: `child-activated` bridges
+ // to `child.activate()`, whose own default handler re-emits `child-activated` on the
+ // FlowBox — that ping-pong recursed forever (stack overflow on every host-card click/Enter)
+ // until the re-entrancy guard was added. This exercises the *real* GTK signal cycle, not a
+ // simulation of it, so it fails the same way the shipped bug did if the guard regresses.
+ #[test]
+ #[ignore = "needs a Wayland/X display"]
+ fn flow_box_activation_bridge_does_not_recurse() {
+ assert!(gtk::init().is_ok(), "no display");
+
+ let flow = gtk::FlowBox::builder()
+ .selection_mode(gtk::SelectionMode::None)
+ .activate_on_single_click(true)
+ .build();
+ let activating = Cell::new(false);
+ flow.connect_child_activated(move |_, child| {
+ if activating.replace(true) {
+ return;
+ }
+ child.activate();
+ activating.set(false);
+ });
+
+ let child = gtk::FlowBoxChild::new();
+ flow.insert(&child, -1);
+ let fired = Rc::new(Cell::new(0u32));
+ {
+ let fired = fired.clone();
+ child.connect_activate(move |_| fired.set(fired.get() + 1));
+ }
+
+ // What a pointer click with `activate_on_single_click` does internally: emit
+ // `child-activated` directly on the FlowBox. A regression here overflows the stack
+ // instead of returning.
+ flow.emit_by_name::<()>("child-activated", &[&child]);
+
+ assert_eq!(
+ fired.get(),
+ 1,
+ "the per-card handler should fire exactly once"
+ );
+ }
+}
diff --git a/clients/linux/src/ui_stream.rs b/clients/linux/src/ui_stream.rs
index 07f86d94..91cb8796 100644
--- a/clients/linux/src/ui_stream.rs
+++ b/clients/linux/src/ui_stream.rs
@@ -806,6 +806,10 @@ fn attach_keyboard(
| gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SHIFT_MASK;
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
+ tracing::info!(
+ captured = cap.captured.get(),
+ "chord: Ctrl+Alt+Shift+Q (release/engage)"
+ );
if cap.captured.get() {
cap.release();
} else {
@@ -816,6 +820,7 @@ fn attach_keyboard(
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
+ tracing::info!("chord: Ctrl+Alt+Shift+D (disconnect) — releasing capture + quitting");
cap.release();
// Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session down
// immediately instead of holding the keep-alive linger for a reconnect.
diff --git a/crates/punktfunk-core/benches/pipeline.rs b/crates/punktfunk-core/benches/pipeline.rs
index 4abc929d..4fc48bf0 100644
--- a/crates/punktfunk-core/benches/pipeline.rs
+++ b/crates/punktfunk-core/benches/pipeline.rs
@@ -16,7 +16,7 @@ use punktfunk_core::session::Session;
use punktfunk_core::transport::loopback_pair;
const TAG_LEN: usize = 16; // AES-GCM authentication tag
-const SHARD: usize = 1452; // ~one MTU-sized data shard
+const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard
fn cfg(role: Role, scheme: FecScheme) -> Config {
Config {
diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml
index 4bfbb531..0e263da0 100644
--- a/crates/punktfunk-core/cbindgen.toml
+++ b/crates/punktfunk-core/cbindgen.toml
@@ -13,10 +13,11 @@ documentation_style = "c99"
parse_deps = false
[export]
-# Internal Apple-only FFI (transport/udp.rs `recvmsg_x` batched recv + its `MsghdrX`) — NOT part of
-# the C ABI. cbindgen otherwise sweeps the foreign import and its #[repr(C)] struct into the header,
-# where socklen_t/ssize_t/iovec are undefined and the C harness fails to compile.
-exclude = ["MsghdrX", "recvmsg_x"]
+# Internal platform-only FFI — NOT part of the C ABI. cbindgen otherwise sweeps the foreign
+# imports and their #[repr(C)] structs into the header, where socklen_t/ssize_t/iovec/msghdr are
+# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs
+# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module).
+exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
[export.rename]
"InputEvent" = "PunktfunkInputEvent"
diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs
index 5f560932..7141c1fd 100644
--- a/crates/punktfunk-core/src/client.rs
+++ b/crates/punktfunk-core/src/client.rs
@@ -123,6 +123,24 @@ pub struct ProbeOutcome {
/// (display freshness over completeness — FEC/keyframes recover).
const FRAME_QUEUE: usize = 16;
+/// Backlog latency bound: when completed frames keep arriving further than this behind the host's
+/// capture clock (skew-corrected), the pump flushes the receive backlog
+/// ([`Session::flush_backlog`]) and requests a keyframe instead of playing that far behind
+/// forever. Deliberately generous — an interactive stream is unusable well before 400 ms, but the
+/// bound must sit safely above the skew handshake's own error (≈ RTT/2) plus normal delivery
+/// jitter so a healthy stream can never trip it.
+const FLUSH_LATENCY: Duration = Duration::from_millis(400);
+
+/// How many CONSECUTIVE over-bound frames arm a flush (~0.5 s at 60 fps). A genuine standing queue
+/// puts EVERY frame over the bound; a one-off burst (an IDR, a Wi-Fi scan blip) clears within a
+/// frame or two and never reaches the count.
+const FLUSH_AFTER_FRAMES: u32 = 30;
+
+/// Minimum spacing between backlog flushes, so a bottleneck that instantly rebuilds the queue (a
+/// link that can't sustain the bitrate at all) degrades into a periodic skip + a logged warning
+/// instead of a continuous flush/keyframe storm.
+const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
+
/// Audio packets buffered for the embedder: 64 × 5 ms = 320 ms of slack. A lagging
/// embedder drops the newest packet (the audio renderer conceals the gap).
const AUDIO_QUEUE: usize = 64;
@@ -248,8 +266,9 @@ pub struct NativeClient {
/// std channels these worker threads feed; if the producers run at the default QoS, the
/// kernel sees a high-QoS thread parked waiting on a lower-QoS one and the Thread Performance
/// Checker flags a priority inversion. Matching the producers to the consumers' QoS removes
-/// the inversion without slowing the Swift side. No-op off Apple (the Linux client/host don't
-/// run a QoS scheduler, and `punktfunk-probe` doesn't care).
+/// the inversion without slowing the Swift side. Android gets a nice-level analogue (see the
+/// android arm below); a no-op elsewhere (the Linux client/host don't run a QoS scheduler, and
+/// `punktfunk-probe` doesn't care).
#[cfg(target_vendor = "apple")]
fn pin_thread_user_interactive() {
// SAFETY: sets only the current thread's QoS class — always valid to call.
@@ -257,9 +276,33 @@ fn pin_thread_user_interactive() {
libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, 0);
}
}
-#[cfg(not(target_vendor = "apple"))]
+/// Android analogue of the Apple QoS pin: raise the calling thread to nice −8 (the framework's
+/// URGENT_DISPLAY band — apps may set negative nice on their own threads). At default nice 0 the
+/// EAS scheduler happily parks the data-plane pump (UDP receive + decrypt + FEC — a thread that
+/// sleeps between bursts) on a down-clocked little core, and a few ms of scheduling delay during a
+/// keyframe burst overflows the socket receive buffer → wire loss the link never saw. −8 keeps the
+/// pipeline below the decode thread's −10 (the display path still wins). Best-effort, like Apple's.
+#[cfg(target_os = "android")]
+fn pin_thread_user_interactive() {
+ // SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls; a refusal is
+ // reported via the return value (ignored — a missed boost, not an error on the data path).
+ unsafe {
+ let tid = libc::gettid();
+ let _ = libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8);
+ }
+}
+#[cfg(not(any(target_vendor = "apple", target_os = "android")))]
fn pin_thread_user_interactive() {}
+/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped
+/// capture `pts_ns` after the skew offset is applied — the same latency math the stats HUDs use.
+fn now_realtime_ns() -> i128 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos() as i128)
+ .unwrap_or(0)
+}
+
/// The calling thread's kernel id, for hot-thread performance hints (the Android client's ADPF
/// session today; the consumer is platform-specific). Linux/Android expose `gettid`; elsewhere
/// there's nothing to hint with, so registration is a no-op.
@@ -1196,6 +1239,11 @@ async fn worker_main(args: WorkerArgs) {
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
let mut last_report = Instant::now();
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
+ // Backlog latency bound (see FLUSH_LATENCY): consecutive over-bound frames + the last
+ // flush, for the cooldown. Armed only when the skew handshake succeeded (offset ≠ 0) —
+ // without it the host and client clocks aren't comparable and the bound would misfire.
+ let mut stale_frames: u32 = 0;
+ let mut last_flush: Option = None;
while !pump_shutdown.load(Ordering::SeqCst) {
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
// loop, and (during a speed test) the packet-level receive counters for the throughput
@@ -1230,6 +1278,36 @@ async fn worker_main(args: WorkerArgs) {
if frame.flags & FLAG_PROBE as u32 != 0 {
continue; // speed-test filler, not video — measured via the counters above
}
+ // Latency bound: a standing receive queue (pump transiently outpaced, a Wi-Fi
+ // stall, power-save clumping) never drains by itself — the pump consumes at
+ // exactly the arrival rate, so once behind, the stream stays behind for good
+ // (observed live: stuck 6–7 s). When frames keep completing over the bound,
+ // discard the whole backlog and ask for a keyframe: one visible skip instead of
+ // a permanently unusable stream. Suspended during a speed test (the probe
+ // MEASURES a saturated queue; flushing would corrupt its receive counters).
+ if clock_offset_ns != 0 && !probe_active {
+ let lat_ns =
+ now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128;
+ if lat_ns > FLUSH_LATENCY.as_nanos() as i128 {
+ stale_frames += 1;
+ } else {
+ stale_frames = 0;
+ }
+ if stale_frames >= FLUSH_AFTER_FRAMES
+ && last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN)
+ {
+ stale_frames = 0;
+ last_flush = Some(Instant::now());
+ let flushed = session.flush_backlog().unwrap_or(0);
+ let _ = ctrl_tx.send(CtrlRequest::Keyframe);
+ tracing::warn!(
+ behind_ms = lat_ns / 1_000_000,
+ flushed_datagrams = flushed,
+ "receive backlog exceeded the latency bound — flushed to live"
+ );
+ continue; // this frame is part of the stale past — don't render it
+ }
+ }
let _ = frame_tx.try_send(frame);
}
Err(PunktfunkError::NoFrame) => {
diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs
index ad6ea7e9..cbe623ed 100644
--- a/crates/punktfunk-core/src/config.rs
+++ b/crates/punktfunk-core/src/config.rs
@@ -256,6 +256,19 @@ pub const fn max_shard_payload() -> usize {
MAX_DATAGRAM_BYTES - HEADER_LEN - CRYPTO_OVERHEAD
}
+/// Largest **even** shard payload whose sealed wire datagram still fits an unfragmented IPv4/UDP
+/// packet on a standard 1500-byte MTU: `1500 − 20 (IPv4) − 8 (UDP) − HEADER_LEN − CRYPTO_OVERHEAD`
+/// = 1408. Hosts should default `shard_payload` to this: one byte more and the kernel silently
+/// splits EVERY video datagram into two IP fragments (a full frame plus a runt) — either fragment
+/// lost = the datagram lost, roughly doubling per-datagram loss on Wi-Fi and eating straight into
+/// FEC's recovery margin, plus per-pair kernel reassembly and runt airtime at line rate. (Exactly
+/// what the previous hardcoded 1452 did: its MTU math forgot the punktfunk header + crypto ride
+/// inside the UDP payload and counted the IP+UDP headers as 8 bytes instead of 28.)
+pub const fn mtu1500_shard_payload() -> usize {
+ let p = 1500 - 20 - 8 - HEADER_LEN - CRYPTO_OVERHEAD;
+ p - p % 2 // FEC requires even shards
+}
+
/// Everything needed to construct a [`Session`](crate::session::Session).
///
/// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized
@@ -392,6 +405,19 @@ mod tests {
assert!(c.validate().is_err());
}
+ /// Pin the 1500-MTU wire math: the sealed datagram (header + shard + crypto) at the MTU-safe
+ /// shard payload must be ≤ 1472 (1500 − IPv4 20 − UDP 8), and one shard-step (+2) above must
+ /// not — the regression that shipped as 1452 and IP-fragmented every video datagram.
+ #[test]
+ fn mtu1500_shard_payload_never_fragments() {
+ let p = mtu1500_shard_payload();
+ assert_eq!(p % 2, 0, "FEC requires even shards");
+ assert!(p <= max_shard_payload());
+ let wire = HEADER_LEN + p + CRYPTO_OVERHEAD;
+ assert!(wire <= 1472, "sealed datagram {wire} B would IP-fragment");
+ assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1472, "not maximal");
+ }
+
#[test]
fn rejects_block_exceeding_scheme_ceiling() {
let mut c = Config::p1_defaults(Role::Host); // Gf8, ceiling 255
diff --git a/crates/punktfunk-core/src/packet.rs b/crates/punktfunk-core/src/packet.rs
index 78cd1de2..a2c712df 100644
--- a/crates/punktfunk-core/src/packet.rs
+++ b/crates/punktfunk-core/src/packet.rs
@@ -43,8 +43,29 @@ pub const CRYPTO_OVERHEAD: usize = 8 + crate::crypto::TAG_LEN;
/// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
pub const MAX_DATAGRAM_BYTES: usize = 2048;
-/// How many frames behind the newest the reassembler keeps before pruning stragglers.
-const REORDER_WINDOW: u32 = 16;
+/// How far behind the newest frame's capture pts an INCOMPLETE frame may sit before it is
+/// declared lost (counted in `frames_dropped`, which triggers the client's recovery-keyframe
+/// request). TIME-based, not frame-count-based, so the fuse is the same at every refresh rate: a
+/// fixed index window is refresh-relative (4 frames = 66 ms at 60 fps but only 33 ms at 120 fps —
+/// inside normal Wi-Fi retry/block-ack reorder timescales, where a delayed-not-lost shard can
+/// trail newer frames). Observed live at 120 fps: the too-tight fuse declared merely-late frames
+/// dead every few seconds, and each false loss cost a recovery-IDR burst + an inflated loss report
+/// (FEC churn) — a self-sustaining latency/bitrate oscillation. 120 ms rides safely above radio
+/// retry jitter while still detecting a real loss ~2× faster than the original 16-frame window did
+/// at 60 fps.
+const LOSS_WINDOW_NS: u64 = 120_000_000;
+
+/// Hard cap on how many frame INDICES behind the newest an incomplete frame may sit, whatever its
+/// pts claims — bounds the reassembler's memory against a corrupt/hostile pts (which
+/// [`LOSS_WINDOW_NS`] alone would trust) and against pathologically high frame rates. At 120 fps,
+/// 120 ms ≈ 14 indices, so 64 leaves ample slack up to ~500 fps.
+const HARD_LOSS_WINDOW: u32 = 64;
+
+/// How many frames behind the newest the reassembler remembers emitted/abandoned frame indices
+/// (`completed`), so a straggler shard can neither resurrect an abandoned frame nor re-open an
+/// emitted one. Must cover at least [`HARD_LOSS_WINDOW`]: stragglers can trickle in later than the
+/// loss verdict.
+const REORDER_WINDOW: u32 = 64;
/// Fixed per-packet header. `#[repr(C)]`, no padding, zero-copy (de)serializable.
#[repr(C)]
@@ -274,7 +295,10 @@ pub struct Reassembler {
/// Recently-emitted frames, so stray/late shards can't resurrect them. Pruned to
/// the reorder window alongside `frames`.
completed: HashSet,
- newest_frame: Option,
+ /// The newest frame seen, as `(frame_index, capture pts)` — the loss-window anchor: an
+ /// incomplete frame is declared lost once it sits [`LOSS_WINDOW_NS`] behind this pts (or
+ /// [`HARD_LOSS_WINDOW`] indices, whichever trips first).
+ newest_frame: Option<(u32, u64)>,
}
impl Reassembler {
@@ -344,12 +368,12 @@ impl Reassembler {
}
let payload = pkt[HEADER_LEN..HEADER_LEN + shard_bytes].to_vec();
- self.advance_window(hdr.frame_index, stats);
+ self.advance_window(hdr.frame_index, hdr.pts_ns, stats);
// Drop shards for frames we've already emitted (e.g. the recovery shards of a
// frame that completed early via the all-originals-present fast path) or that
- // have fallen out of the reorder window.
- if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index) {
+ // have fallen out of the loss window.
+ if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index, hdr.pts_ns) {
drop(stats);
return Ok(None);
}
@@ -461,19 +485,31 @@ impl Reassembler {
Ok(None)
}
- /// Track the newest frame and prune stragglers that fell out of the reorder window
- /// (counting them as dropped).
- fn advance_window(&mut self, frame_index: u32, stats: &StatsCounters) {
- let newest = match self.newest_frame {
+ /// Track the newest frame, declare incomplete frames that fell out of the loss window
+ /// ([`LOSS_WINDOW_NS`] behind the newest pts, or [`HARD_LOSS_WINDOW`] indices) lost — counting
+ /// them dropped, which is what drives the client's recovery-keyframe request — and prune the
+ /// completed-index memory to [`REORDER_WINDOW`].
+ fn advance_window(&mut self, frame_index: u32, pts_ns: u64, stats: &StatsCounters) {
+ let (newest, newest_pts) = match self.newest_frame {
// `frame_index` is newer iff it's within the forward half of the index space.
- Some(n) if frame_index.wrapping_sub(n) > u32::MAX / 2 => n,
- _ => frame_index,
+ Some((n, p)) if frame_index.wrapping_sub(n) > u32::MAX / 2 => (n, p),
+ _ => (frame_index, pts_ns),
};
- self.newest_frame = Some(newest);
+ self.newest_frame = Some((newest, newest_pts));
let before = self.frames.len();
- self.frames
- .retain(|&idx, _| newest.wrapping_sub(idx) <= REORDER_WINDOW);
+ let completed = &mut self.completed;
+ self.frames.retain(|&idx, f| {
+ let keep = newest.wrapping_sub(idx) <= HARD_LOSS_WINDOW
+ && newest_pts.saturating_sub(f.pts_ns) <= LOSS_WINDOW_NS;
+ if !keep {
+ // Remember the abandoned index so a straggler shard is dropped (below, and in
+ // `push`) instead of resurrecting the frame — which would re-allocate its buffers
+ // and double-count the drop when it aged out again.
+ completed.insert(idx);
+ }
+ keep
+ });
let pruned = before - self.frames.len();
if pruned > 0 {
StatsCounters::add(&stats.frames_dropped, pruned as u64);
@@ -482,13 +518,29 @@ impl Reassembler {
.retain(|&idx| newest.wrapping_sub(idx) <= REORDER_WINDOW);
}
- /// True if `frame_index` lies behind the newest frame by more than the reorder
- /// window (so its shards arrive too late to be useful).
- fn is_stale(&self, frame_index: u32) -> bool {
+ /// Drop all in-flight state — every partially-assembled frame and the completed/abandoned
+ /// index memory — as if the session just started. Used by the client's backlog flush
+ /// ([`Session::flush_backlog`](crate::session::Session::flush_backlog)): after the socket
+ /// backlog is discarded wholesale, the partial frames here can never complete (their remaining
+ /// shards were just thrown away) and the window anchor (`newest_frame`) points into the
+ /// discarded past.
+ pub fn reset(&mut self) {
+ self.frames.clear();
+ self.completed.clear();
+ self.newest_frame = None;
+ }
+
+ /// True if this packet's frame lies outside the loss window (behind the newest frame by more
+ /// than [`LOSS_WINDOW_NS`] of capture time or [`HARD_LOSS_WINDOW`] indices) — its shards
+ /// arrive too late to be useful, and accepting one would only create a frame buffer the next
+ /// [`advance_window`] immediately declares lost.
+ fn is_stale(&self, frame_index: u32, pts_ns: u64) -> bool {
match self.newest_frame {
- Some(n) => {
+ Some((n, newest_pts)) => {
let behind = n.wrapping_sub(frame_index);
- behind > REORDER_WINDOW && behind <= u32::MAX / 2
+ behind <= u32::MAX / 2
+ && (behind > HARD_LOSS_WINDOW
+ || newest_pts.saturating_sub(pts_ns) > LOSS_WINDOW_NS)
}
None => false,
}
@@ -585,6 +637,82 @@ mod tests {
assert_eq!(stats.snapshot().packets_dropped, 1);
}
+ /// The loss window is TIME-based: an incomplete frame survives newer frames arriving within
+ /// [`LOSS_WINDOW_NS`] of its capture pts (a 33 ms-late shard at 120 fps is late, not lost —
+ /// the old 4-INDEX window wrongly killed it), is declared lost once the newest pts moves past
+ /// the window (`frames_dropped`), and a straggler shard can't resurrect it afterwards.
+ #[test]
+ fn incomplete_frames_age_out_by_capture_time_not_frame_count() {
+ let mut r = Reassembler::new(limits());
+ let coder = coder_for(FecScheme::Gf8);
+ let stats = StatsCounters::default();
+ const FRAME_NS: u64 = 8_333_333; // 120 fps
+
+ // Frame 0: one of its two shards arrives — incomplete.
+ let mut h = base_header();
+ h.data_shards = 2;
+ h.frame_bytes = 32;
+ assert!(r
+ .push(&packet(h), coder.as_ref(), &stats)
+ .unwrap()
+ .is_none());
+
+ // Frames 1..=8 complete around it (well past the old 4-index window, inside 120 ms):
+ // frame 0 must still be alive — no drop counted.
+ for i in 1..=8u32 {
+ let mut h = base_header();
+ h.frame_index = i;
+ h.pts_ns = i as u64 * FRAME_NS;
+ assert!(r
+ .push(&packet(h), coder.as_ref(), &stats)
+ .unwrap()
+ .is_some());
+ }
+ assert_eq!(stats.snapshot().frames_dropped, 0);
+
+ // Frame 0's second shard arrives 8 frames late (~66 ms at 120 fps) — completes fine.
+ let mut h = base_header();
+ h.data_shards = 2;
+ h.frame_bytes = 32;
+ h.shard_index = 1;
+ assert!(r
+ .push(&packet(h), coder.as_ref(), &stats)
+ .unwrap()
+ .is_some());
+
+ // Frame 20: incomplete again; then a frame lands past the 120 ms window → declared lost.
+ let mut h = base_header();
+ h.frame_index = 20;
+ h.pts_ns = 20 * FRAME_NS;
+ h.data_shards = 2;
+ h.frame_bytes = 32;
+ assert!(r
+ .push(&packet(h), coder.as_ref(), &stats)
+ .unwrap()
+ .is_none());
+ let mut h = base_header();
+ h.frame_index = 21;
+ h.pts_ns = 20 * FRAME_NS + LOSS_WINDOW_NS + 1;
+ assert!(r
+ .push(&packet(h), coder.as_ref(), &stats)
+ .unwrap()
+ .is_some());
+ assert_eq!(stats.snapshot().frames_dropped, 1);
+
+ // A straggler shard for the abandoned frame 20 is dropped, never resurrected.
+ let mut h = base_header();
+ h.frame_index = 20;
+ h.pts_ns = 20 * FRAME_NS;
+ h.data_shards = 2;
+ h.frame_bytes = 32;
+ h.shard_index = 1;
+ assert!(r
+ .push(&packet(h), coder.as_ref(), &stats)
+ .unwrap()
+ .is_none());
+ assert_eq!(stats.snapshot().frames_dropped, 1, "no double-count");
+ }
+
#[test]
fn rejects_wrong_shard_bytes_and_oversized_frame() {
let coder = coder_for(FecScheme::Gf8);
diff --git a/crates/punktfunk-core/src/session.rs b/crates/punktfunk-core/src/session.rs
index 450f25ca..feafa28f 100644
--- a/crates/punktfunk-core/src/session.rs
+++ b/crates/punktfunk-core/src/session.rs
@@ -290,6 +290,45 @@ impl Session {
}
}
+ /// Client: discard the ENTIRE pending receive backlog — the current recv ring plus everything
+ /// queued in the kernel socket buffer — and reset the reassembler. Returns how many datagrams
+ /// were thrown away (counted into `packets_dropped`).
+ ///
+ /// This is the latency-bound escape hatch: the receive path has no other way to skip ahead.
+ /// Packets arrive strictly in order, so once a standing queue forms (the pump transiently
+ /// slower than the wire, a Wi-Fi stall, power-save delivery clumping), the client plays that
+ /// far behind FOREVER — it consumes at exactly the arrival rate, so the backlog never shrinks
+ /// (observed live: a stream stuck 6–7 s behind, socket buffers full end to end). Discarding
+ /// is memcpy-speed (no decrypt/reassembly/allocation), so this empties even a 32 MB buffer in
+ /// milliseconds; the caller then requests a keyframe and the stream resumes live. The iteration
+ /// cap (4096 batches ≈ 128k datagrams ≈ 190 MB) only guards against a line-rate sender
+ /// outpacing the discard loop indefinitely.
+ pub fn flush_backlog(&mut self) -> Result {
+ if self.config.role != Role::Client {
+ return Err(PunktfunkError::InvalidArg(
+ "flush_backlog called on a host session",
+ ));
+ }
+ // The undelivered tail of the current ring is backlog too.
+ let mut flushed = self.recv_count.saturating_sub(self.recv_idx) as u64;
+ self.recv_count = 0;
+ self.recv_idx = 0;
+ if !self.recv_scratch.is_empty() {
+ for _ in 0..4096 {
+ let n = self
+ .transport
+ .recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
+ if n == 0 {
+ break;
+ }
+ flushed += n as u64;
+ }
+ }
+ self.reassembler.reset();
+ StatsCounters::add(&self.stats.packets_dropped, flushed);
+ Ok(flushed)
+ }
+
/// Client: serialize and send one input event to the host.
pub fn send_input(&mut self, event: &InputEvent) -> Result<()> {
if self.config.role != Role::Client {
diff --git a/crates/punktfunk-core/src/transport/udp.rs b/crates/punktfunk-core/src/transport/udp.rs
index 0589edd0..0c6b48ce 100644
--- a/crates/punktfunk-core/src/transport/udp.rs
+++ b/crates/punktfunk-core/src/transport/udp.rs
@@ -1,10 +1,12 @@
//! Real UDP datagram transport — native sockets, no async runtime.
//!
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
-//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) — the 1 Gbps+ syscall lever
-//! (~125k → a few-k syscalls/sec at line rate). The host additionally paces each frame's send
-//! across the frame interval (see `punktfunk1.rs::paced_submit`) so a real NIC doesn't drop a line-rate
-//! burst. All three layer on this same [`Transport`] seam (scalar fallbacks for loopback/non-Linux).
+//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) on Linux AND Android (which is
+//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`])
+//! — the 1 Gbps+ syscall lever (~125k → a few-k syscalls/sec at line rate). The host additionally
+//! paces each frame's send across the frame interval (see `punktfunk1.rs::paced_submit`) so a real
+//! NIC doesn't drop a line-rate burst. All three layer on this same [`Transport`] seam (scalar
+//! fallbacks for loopback and the remaining targets).
use super::Transport;
use crate::packet::MAX_DATAGRAM_BYTES;
@@ -57,16 +59,51 @@ fn is_transient_io(e: &std::io::Error) -> bool {
}
}
+/// `sendmmsg`/`recvmmsg` + `mmsghdr` for Android, where the `libc` crate binds only the syscall
+/// number (`SYS_recvmmsg`) and neither the wrapper functions nor the struct — even though bionic
+/// has exported both since API 21 (below our API-28 floor), and Rust's `target_os = "android"` is
+/// NOT `"linux"`, so the batched paths below silently excluded Android and the client fell back to
+/// one syscall per datagram. The struct layout is stable kernel ABI (`struct mmsghdr` in
+/// `linux/socket.h`): a `msghdr` followed by the received byte count.
+#[cfg(target_os = "android")]
+mod android_mmsg {
+ #[repr(C)]
+ #[allow(non_camel_case_types)]
+ pub struct mmsghdr {
+ pub msg_hdr: libc::msghdr,
+ pub msg_len: libc::c_uint,
+ }
+ extern "C" {
+ pub fn sendmmsg(
+ sockfd: libc::c_int,
+ msgvec: *mut mmsghdr,
+ vlen: libc::c_uint,
+ flags: libc::c_int,
+ ) -> libc::c_int;
+ pub fn recvmmsg(
+ sockfd: libc::c_int,
+ msgvec: *mut mmsghdr,
+ vlen: libc::c_uint,
+ flags: libc::c_int,
+ timeout: *mut libc::timespec,
+ ) -> libc::c_int;
+ }
+}
+#[cfg(target_os = "android")]
+use android_mmsg::{mmsghdr, recvmmsg, sendmmsg};
+#[cfg(target_os = "linux")]
+use libc::{mmsghdr, recvmmsg, sendmmsg};
+
/// Build one `mmsghdr` per `iovec` (each a single-buffer message) for `sendmmsg`/`recvmmsg`. Shared
/// by `send_batch` + `recv_batch` so the raw-pointer scaffolding lives in exactly one place.
///
/// SAFETY (caller's): each returned header holds a raw pointer into `iovs`; the caller MUST keep
/// `iovs` alive and unmoved for as long as the headers are passed to the syscall.
-#[cfg(target_os = "linux")]
-fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec {
+#[cfg(any(target_os = "linux", target_os = "android"))]
+fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec {
iovs.iter_mut()
.map(|iov| {
- let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
+ let mut h: mmsghdr = unsafe { std::mem::zeroed() };
h.msg_hdr.msg_iov = iov;
h.msg_hdr.msg_iovlen = 1;
h
@@ -575,9 +612,9 @@ impl Transport for UdpTransport {
/// no per-message address. The socket is non-blocking, so a full send buffer surfaces as a
/// short count (or `EAGAIN` with nothing sent); we stop and report what went out rather than
/// block or retry — the data plane is lossy + FEC-protected, and blocking would queue stale
- /// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Non-Linux falls back to
- /// the trait's scalar `send` loop (no `sendmmsg`).
- #[cfg(target_os = "linux")]
+ /// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Other targets fall back
+ /// to the trait's scalar `send` loop (no `sendmmsg`).
+ #[cfg(any(target_os = "linux", target_os = "android"))]
fn send_batch(&self, packets: &[&[u8]]) -> std::io::Result {
use std::os::fd::AsRawFd;
const CHUNK: usize = 64;
@@ -593,7 +630,7 @@ impl Transport for UdpTransport {
})
.collect();
let mut hdrs = mmsghdrs(&mut iovs);
- let n = unsafe { libc::sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) };
+ let n = unsafe { sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) };
if n < 0 {
let err = std::io::Error::last_os_error();
// Nothing fit in the send buffer (or a stale ICMP from a connected-socket blip) —
@@ -723,9 +760,9 @@ impl Transport for UdpTransport {
/// caller's reused buffers (no per-packet allocation). `MSG_DONTWAIT` keeps it non-blocking
/// (the socket already is); `EAGAIN` → `0`. A datagram larger than a buffer is truncated and
/// `lens[i]` reaches the buffer size — the reassembler then rejects it as malformed, matching
- /// `recv`'s oversized-drop. Apple/BSD use the `recv`-loop override below; other non-unix the
- /// trait's scalar default.
- #[cfg(target_os = "linux")]
+ /// `recv`'s oversized-drop. Android uses the local bionic binding (see [`android_mmsg`]).
+ /// Apple/BSD use the `recv`-loop override below; other non-unix the trait's scalar default.
+ #[cfg(any(target_os = "linux", target_os = "android"))]
fn recv_batch(&self, out: &mut [Vec], lens: &mut [usize]) -> std::io::Result {
use std::os::fd::AsRawFd;
let fd = self.socket.as_raw_fd();
@@ -743,7 +780,7 @@ impl Transport for UdpTransport {
.collect();
let mut hdrs = mmsghdrs(&mut iovs);
let n = unsafe {
- libc::recvmmsg(
+ recvmmsg(
fd,
hdrs.as_mut_ptr(),
n_bufs as libc::c_uint,
@@ -772,7 +809,7 @@ impl Transport for UdpTransport {
/// batches; our client per-packet-allocated). It is still one syscall per datagram (a future
/// `recvmsg_x` batch would cut that too); `EAGAIN` ends the drain. Oversized datagrams set
/// `lens[i] == buf.len()` and the caller (`poll_frame`) drops them — same contract as `recvmmsg`.
- #[cfg(all(unix, not(target_os = "linux")))]
+ #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
fn recv_batch(&self, out: &mut [Vec], lens: &mut [usize]) -> std::io::Result {
// Apple: prefer the batched `recvmsg_x` syscall when enabled; a surprise error disables it
// and falls through to the always-correct scalar loop below.
diff --git a/crates/punktfunk-core/tests/loopback.rs b/crates/punktfunk-core/tests/loopback.rs
index df8cabec..17e9aa77 100644
--- a/crates/punktfunk-core/tests/loopback.rs
+++ b/crates/punktfunk-core/tests/loopback.rs
@@ -112,6 +112,48 @@ fn lossless_stream_is_exact() {
);
}
+/// The client's latency-bound escape hatch: `flush_backlog` must discard every queued datagram
+/// (counting them dropped), reset the reassembler so half-assembled frames from the flushed past
+/// can't linger, and leave the session healthy — the next submitted frame recovers byte-exact.
+#[test]
+fn flush_backlog_discards_queue_and_recovers() {
+ let (host_tp, client_tp) = loopback_pair(0, 0);
+ let mut host = Session::new(
+ config(Role::Host, FecScheme::Gf16, false, 0),
+ Box::new(host_tp),
+ )
+ .unwrap();
+ let mut client = Session::new(
+ config(Role::Client, FecScheme::Gf16, false, 0),
+ Box::new(client_tp),
+ )
+ .unwrap();
+
+ let frames = sample_frames();
+ // Read one frame first so the client's recv ring exists and may hold an undelivered tail.
+ host.submit_frame(&frames[0], 0, 0).unwrap();
+ client.poll_frame().unwrap();
+ // Queue a multi-frame backlog, then flush it: everything pending is discarded.
+ for (i, f) in frames.iter().enumerate().skip(1) {
+ host.submit_frame(f, i as u64 * 1_000_000, 0).unwrap();
+ }
+ let flushed = client.flush_backlog().unwrap();
+ assert!(flushed > 0, "a queued backlog must be discarded");
+ assert_eq!(client.stats().packets_dropped, flushed);
+ assert!(
+ matches!(
+ client.poll_frame(),
+ Err(punktfunk_core::PunktfunkError::NoFrame)
+ ),
+ "nothing pending after a flush"
+ );
+ // The stream resumes cleanly: the next frame (the "recovery keyframe") completes byte-exact.
+ let recovery = vec![0xA5u8; 100_000];
+ host.submit_frame(&recovery, 99_000_000, 0).unwrap();
+ let got = client.poll_frame().expect("post-flush frame completes");
+ assert_eq!(got.data, recovery);
+}
+
#[test]
fn input_round_trips_client_to_host() {
let (host_tp, client_tp) = loopback_pair(0, 0);
diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs
index 1bddcd44..ae389eae 100644
--- a/crates/punktfunk-host/src/punktfunk1.rs
+++ b/crates/punktfunk-host/src/punktfunk1.rs
@@ -26,7 +26,9 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{anyhow, Context, Result};
-use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
+use punktfunk_core::config::{
+ mtu1500_shard_payload, CompositorPref, FecConfig, FecScheme, GamepadPref, Role,
+};
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
use punktfunk_core::quic::{
@@ -969,11 +971,14 @@ async fn serve_session(
fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START),
max_data_per_block: 4096,
},
- // ~1452-byte payload keeps the IP datagram within a 1500 MTU (1452 + 40 header + 24
- // crypto + 8 IP/UDP ≈ 1500), vs the old 1200 — ~17% fewer packets for free, and an even
- // size (FEC requires even shards). Negotiated, so the client follows. Jumbo (≈8900) is a
- // future negotiated bump (needs MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
- shard_payload: 1452,
+ // The largest even payload whose sealed datagram (header + shard + crypto) fits an
+ // unfragmented IPv4/UDP packet on a 1500 MTU — 1408, giving 1472 = the exact ceiling.
+ // The previous 1452 overshot it (its math forgot the header/crypto ride inside the UDP
+ // payload) and silently IP-fragmented EVERY video datagram, doubling per-datagram loss
+ // on Wi-Fi — the "100 Mbps badly fails on the phone" root cause. Negotiated, so the
+ // client follows. Jumbo (≈8900) is a future negotiated bump (needs MAX_DATAGRAM_BYTES
+ // raised + end-to-end 9000 MTU).
+ shard_payload: mtu1500_shard_payload() as u16,
encrypt: true,
key,
salt: *b"pkf1",
@@ -1092,8 +1097,18 @@ async fn serve_session(
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
// is pinned via PUNKTFUNK_FEC_PCT.
if adaptive_fec {
- let target = adapt_fec(rep.loss_ppm);
- let prev = fec_target_ctl.swap(target, Ordering::Relaxed);
+ // Fast attack, slow decay: jump straight to what the reported loss
+ // needs, but come DOWN only one point per clean report (~750 ms). The
+ // memoryless controller ping-ponged on periodic burst loss (Wi-Fi
+ // scans / BT coexistence, a burst every few seconds): a single clean
+ // window dropped FEC back to the floor, so every next burst hit an
+ // unprotected stream — an unrecoverable frame, a freeze, and a
+ // recovery-IDR burst, once per cycle. Decaying over ~10 windows keeps
+ // the stream covered across the gap while still converging to FEC_MIN
+ // on a genuinely clean link.
+ let prev = fec_target_ctl.load(Ordering::Relaxed);
+ let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1));
+ fec_target_ctl.store(target, Ordering::Relaxed);
if prev != target {
tracing::info!(
loss_ppm = rep.loss_ppm,