Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 324da666e5 | |||
| efed8d5a20 | |||
| 636b9c1d1f | |||
| 92f078adaf | |||
| 17685ff73b | |||
| 08ab2b6bee | |||
| b271d0c816 | |||
| 83b7c7adf5 | |||
| eea23c5647 | |||
| 912d7de2e6 | |||
| e788d0de84 | |||
| 53c8eefa99 |
Generated
+10
-9
@@ -2129,7 +2129,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2908,7 +2908,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -2918,11 +2918,12 @@ dependencies = [
|
|||||||
"ndk",
|
"ndk",
|
||||||
"opus",
|
"opus",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2945,7 +2946,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2968,7 +2969,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2999,7 +3000,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3071,7 +3072,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3085,7 +3086,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ members = [
|
|||||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.8.1"
|
"version": "0.8.2"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
reception needs it (also an OEM Wi-Fi power-save hedge). -->
|
reception needs it (also an OEM Wi-Fi power-save hedge). -->
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<!-- WifiLock.acquire() ENFORCES this (a normal permission, granted at install). Without it the
|
||||||
|
stream's Wi-Fi locks throw SecurityException and power save stays on: downlink delivery
|
||||||
|
clumps at beacon intervals — hundreds of ms of latency mush + periodic whole-frame loss.
|
||||||
|
Its absence went unnoticed for weeks because the acquire was wrapped in a silent
|
||||||
|
runCatching (now logged). -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
||||||
Harmless to declare on earlier releases. -->
|
Harmless to declare on earlier releases. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ package io.unom.punktfunk
|
|||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.activity.compose.BackHandler
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
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
|
@Composable
|
||||||
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
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)
|
val shape = RoundedCornerShape(14.dp)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
|
.background(visuals.background)
|
||||||
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
.border(1.dp, visuals.border, shape)
|
||||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -389,15 +390,20 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
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 shape = RoundedCornerShape(14.dp)
|
||||||
|
val labelColor by animateColorAsState(
|
||||||
|
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
||||||
|
tween(160),
|
||||||
|
label = "addLabel",
|
||||||
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
.background(visuals.background)
|
||||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
.border(1.dp, visuals.border, shape)
|
||||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||||
.padding(vertical = 14.dp),
|
.padding(vertical = 14.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -406,7 +412,7 @@ private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onCl
|
|||||||
label,
|
label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
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
|
@Composable
|
||||||
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
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(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.height(if (compact) 34.dp else 44.dp)
|
.height(if (compact) 34.dp else 44.dp)
|
||||||
.clip(RoundedCornerShape(9.dp))
|
.clip(RoundedCornerShape(9.dp))
|
||||||
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
|
.background(bg)
|
||||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@@ -460,7 +474,7 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
|
|||||||
label,
|
label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = if (focused) Color.Black else Color.White,
|
color = fg,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -31,20 +36,28 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
|
||||||
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
|
// 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.
|
* One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android
|
||||||
* [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working
|
* `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is
|
||||||
* controller can still drive the console UI (and reach Settings to switch it off).
|
* 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(
|
class GamepadHint(
|
||||||
val glyph: Char,
|
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 —
|
// 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.
|
// for a TV remote, which has no A/B/X/Y.
|
||||||
val select: Boolean = false,
|
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,
|
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 {
|
object PadGlyph {
|
||||||
val A = Color(0xFF6BBE45)
|
val A = Color(0xFF6BBE45)
|
||||||
val B = Color(0xFFD14B4B)
|
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. */
|
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
|
||||||
@Composable
|
@Composable
|
||||||
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
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)
|
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
|
@Composable
|
||||||
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
Box(Modifier.size(size), contentAlignment = Alignment.Center) {
|
val color = when (glyph) {
|
||||||
Box(
|
'A' -> Color(0xFF7C9CE8) // cross — light blue
|
||||||
Modifier
|
'B' -> Color(0xFFE0736F) // circle — red
|
||||||
.size(width = size * 0.74f, height = size * 0.46f)
|
'X' -> Color(0xFFD48FC7) // square — pink
|
||||||
.clip(RoundedCornerShape(50))
|
else -> Color(0xFF5FBFA5) // triangle — green
|
||||||
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
|
}
|
||||||
)
|
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<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
|
fun GamepadHintBar(hints: List<GamepadHint>, 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:
|
// 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
|
// 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).
|
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
|
||||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
// 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)
|
val shape = RoundedCornerShape(50)
|
||||||
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
|
// 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.
|
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
|
||||||
@@ -300,9 +484,13 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
|||||||
}
|
}
|
||||||
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
|
||||||
when {
|
when {
|
||||||
h.viewButton -> ViewButtonGlyph()
|
h.viewButton -> SelectButtonGlyph(padStyle)
|
||||||
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
|
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
|
||||||
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
|
!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)
|
else -> GamepadButtonGlyph(h.glyph, h.color)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ package io.unom.punktfunk
|
|||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.BackHandler
|
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.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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -26,6 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
@@ -90,8 +98,11 @@ fun GamepadDialog(
|
|||||||
},
|
},
|
||||||
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
|
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
|
// Cap the card to most of the screen and let body + BUTTONS scroll together — in a short
|
||||||
// title + body + buttons would otherwise overflow and compress/clip the bottom button.
|
// 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
|
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||||
@@ -109,43 +120,66 @@ fun GamepadDialog(
|
|||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
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(
|
Column(
|
||||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
body()
|
body()
|
||||||
}
|
Spacer(Modifier.size(4.dp))
|
||||||
Spacer(Modifier.size(4.dp))
|
actions.forEachIndexed { i, a ->
|
||||||
actions.forEachIndexed { i, a ->
|
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
|
||||||
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
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 shape = RoundedCornerShape(14.dp)
|
||||||
val bg = when {
|
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
|
||||||
focused -> Color(0xFF6656F2)
|
val bg by animateColorAsState(
|
||||||
primary -> Color(0x336656F2)
|
when {
|
||||||
else -> Color(0x14FFFFFF)
|
focused -> Color(0xFF6656F2)
|
||||||
}
|
primary -> Color(0x336656F2)
|
||||||
val fg = when {
|
else -> Color(0x14FFFFFF)
|
||||||
!enabled -> Color.White.copy(alpha = 0.35f)
|
},
|
||||||
focused -> Color.White
|
tween(160),
|
||||||
primary -> Color(0xFF8678F5)
|
label = "btnBg",
|
||||||
else -> Color.White.copy(alpha = 0.85f)
|
)
|
||||||
}
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.bringIntoViewRequester(intoView)
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(bg)
|
.background(bg)
|
||||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
|
.border(1.dp, borderColor, shape)
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ package io.unom.punktfunk
|
|||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.activity.compose.BackHandler
|
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.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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -57,6 +69,7 @@ private class GpRow(
|
|||||||
val detail: String,
|
val detail: String,
|
||||||
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||||
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||||
|
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -72,6 +85,9 @@ fun GamepadSettingsScreen(
|
|||||||
val rows = buildSettingsRows(s, ::update)
|
val rows = buildSettingsRows(s, ::update)
|
||||||
var focus by remember { mutableIntStateOf(0) }
|
var focus by remember { mutableIntStateOf(0) }
|
||||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
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 listState = rememberLazyListState()
|
||||||
|
|
||||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
@@ -83,11 +99,11 @@ fun GamepadSettingsScreen(
|
|||||||
when (dir) {
|
when (dir) {
|
||||||
NavDir.UP -> if (focus > 0) focus--
|
NavDir.UP -> if (focus > 0) focus--
|
||||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||||
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
|
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
|
||||||
NavDir.RIGHT -> 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
|
// 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.
|
// 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)
|
ConsoleHeader("Settings", horizontalInset = false)
|
||||||
}
|
}
|
||||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||||
SettingRowView(row, focused = index == focus, onClick = {
|
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||||
if (focus == index) row.activate() else focus = index
|
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,9 +166,17 @@ fun GamepadSettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
|
||||||
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
|
val visuals = animateConsoleFocus(active = focused)
|
||||||
val shape = RoundedCornerShape(14.dp)
|
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 {
|
Column {
|
||||||
if (row.header != null) {
|
if (row.header != null) {
|
||||||
Text(
|
Text(
|
||||||
@@ -166,10 +190,10 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
.background(visuals.background)
|
||||||
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
.border(1.dp, visuals.border, shape)
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
@@ -186,19 +210,41 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
if (focused) Text("‹ ", color = Color.White.copy(alpha = 0.6f))
|
if (row.toggled != null) {
|
||||||
Text(
|
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
|
||||||
row.value,
|
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
} else {
|
||||||
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
|
Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||||
maxLines = 1,
|
// The value slides in the direction it was stepped and its width animates, so
|
||||||
overflow = TextOverflow.Ellipsis,
|
// cycling a choice reads as motion through a list rather than a text swap.
|
||||||
)
|
AnimatedContent(
|
||||||
if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f))
|
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)
|
// 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.
|
// detail strip. It unfolds right where you're looking, and the row grows to fit.
|
||||||
if (focused && row.detail.isNotBlank()) {
|
AnimatedVisibility(
|
||||||
|
visible = focused && row.detail.isNotBlank(),
|
||||||
|
enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
|
||||||
|
exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
row.detail,
|
row.detail,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -245,6 +291,7 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
detail = detail,
|
detail = detail,
|
||||||
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||||
activate = { write(!value) },
|
activate = { write(!value) },
|
||||||
|
toggled = value,
|
||||||
)
|
)
|
||||||
|
|
||||||
return listOf(
|
return listOf(
|
||||||
@@ -278,6 +325,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||||
s.hdrEnabled,
|
s.hdrEnabled,
|
||||||
) { update(s.copy(hdrEnabled = it)) },
|
) { update(s.copy(hdrEnabled = it)) },
|
||||||
|
toggle(
|
||||||
|
"lowLatency", null, "Low-latency mode",
|
||||||
|
"Experimental — aggressive decoder and system tuning. Turn off if the stream stutters or glitches.",
|
||||||
|
s.lowLatencyMode,
|
||||||
|
) { update(s.copy(lowLatencyMode = it)) },
|
||||||
|
|
||||||
choice(
|
choice(
|
||||||
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||||
@@ -304,6 +356,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
"Browse a paired host's games with Y (experimental).",
|
"Browse a paired host's games with Y (experimental).",
|
||||||
s.libraryEnabled,
|
s.libraryEnabled,
|
||||||
) { update(s.copy(libraryEnabled = it)) },
|
) { update(s.copy(libraryEnabled = it)) },
|
||||||
|
toggle(
|
||||||
|
"autoWake", 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(
|
toggle(
|
||||||
"gamepadUI", null, "Controller-optimized UI",
|
"gamepadUI", null, "Controller-optimized UI",
|
||||||
"Turn off to use the touch interface even with a controller connected.",
|
"Turn off to use the touch interface even with a controller connected.",
|
||||||
|
|||||||
@@ -59,12 +59,22 @@ class MainActivity : ComponentActivity() {
|
|||||||
var lastPadIsGamepad by mutableStateOf(true)
|
var lastPadIsGamepad by mutableStateOf(true)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The glyph family of the controller driving the console UI (Xbox letters / PlayStation shapes /
|
||||||
|
* Nintendo monochrome) — seeded from the first connected pad, then kept live by real input the
|
||||||
|
* same way [lastPadIsGamepad] is. Compose observes it (a snapshot state); the hint bar picks its
|
||||||
|
* button glyphs from it so a DualSense user isn't shown Xbox lettering.
|
||||||
|
*/
|
||||||
|
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
|
||||||
|
private set
|
||||||
|
|
||||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||||
private var highRefreshModeId = 0
|
private var highRefreshModeId = 0
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
lastPadIsGamepad = !isTvDevice(this)
|
lastPadIsGamepad = !isTvDevice(this)
|
||||||
|
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
|
||||||
resolveHighRefreshMode()
|
resolveHighRefreshMode()
|
||||||
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
|
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
|
||||||
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
||||||
@@ -159,9 +169,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
|
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
|
||||||
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are).
|
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
|
||||||
|
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
|
||||||
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||||
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||||
|
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
|
||||||
}
|
}
|
||||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||||
padKeyProbe?.let { if (it(event)) return true }
|
padKeyProbe?.let { if (it(event)) return true }
|
||||||
@@ -217,6 +229,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
lastNavDir = dir
|
lastNavDir = dir
|
||||||
if (dir != 0) {
|
if (dir != 0) {
|
||||||
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
||||||
|
lastPadStyle = Gamepad.styleFor(event.device)
|
||||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
||||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -55,12 +55,12 @@ data class Settings(
|
|||||||
*/
|
*/
|
||||||
val libraryEnabled: Boolean = true,
|
val libraryEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* "Low-latency mode (experimental)" — the master switch over the whole latency overhaul: decoder
|
* "Low-latency mode (experimental)" — the master switch over the latency overhaul: decoder
|
||||||
* ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF
|
* ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF
|
||||||
* max-performance, game-tagged AAudio, DSCP marking on the media sockets, the Wi-Fi low-latency
|
* max-performance, game-tagged AAudio, DSCP marking on the media sockets, HDMI ALLM, and the
|
||||||
* lock, HDMI ALLM, and the forced TV mode switch. Off (default): the original pre-overhaul
|
* forced TV mode switch. (The Wi-Fi locks are NOT part of this — both are always held while
|
||||||
* pipeline, kept byte-for-byte as the known-good baseline — the overhaul regressed badly on some
|
* streaming; see StreamScreen.) Off (default): the original decode pipeline, kept as the
|
||||||
* phones, so it's opt-in until it's proven per-device.
|
* known-good baseline until the aggressive stack is proven per-device.
|
||||||
*/
|
*/
|
||||||
val lowLatencyMode: Boolean = false,
|
val lowLatencyMode: Boolean = false,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
|||||||
ToggleRow(
|
ToggleRow(
|
||||||
title = "Low-latency mode (experimental)",
|
title = "Low-latency mode (experimental)",
|
||||||
subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " +
|
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.",
|
"some devices — turn off if the stream misbehaves.",
|
||||||
checked = s.lowLatencyMode,
|
checked = s.lowLatencyMode,
|
||||||
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.pm.ActivityInfo
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import android.view.WindowManager
|
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).
|
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||||
val touchMode = initialSettings.touchMode
|
val touchMode = initialSettings.touchMode
|
||||||
// "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the
|
// "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 —
|
// default) runs the original decode pipeline; on enables the aggressive stack — decoder
|
||||||
// decoder ranking + vendor keys + async loop (native side), the Wi-Fi low-latency lock and
|
// ranking + vendor keys + async loop (native side), HDMI ALLM below, game-tagged audio, and
|
||||||
// HDMI ALLM below, game-tagged audio, and DSCP marking (applied earlier, at connect).
|
// DSCP marking (applied earlier, at connect).
|
||||||
val lowLatencyMode = initialSettings.lowLatencyMode
|
val lowLatencyMode = initialSettings.lowLatencyMode
|
||||||
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
|
// 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.
|
// 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.
|
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
|
||||||
val closed = remember { AtomicBoolean(false) }
|
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
|
// Wi-Fi locks held for the stream's duration — BOTH of them, unconditionally (Moonlight does
|
||||||
// power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API
|
// the same). Without an effective lock, Wi-Fi power save batches downlink delivery into
|
||||||
// 29+) is the strongest; older releases fall back to FULL_HIGH_PERF. Needs no extra permission
|
// beacon-interval clumps: hundreds of ms of latency mush, sawtoothing bitrate, and periodic
|
||||||
// beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release.
|
// whole-frame loss when the AP's power-save buffer overflows (all observed live on a phone).
|
||||||
// Part of the experimental low-latency stack — not created at all when the toggle is off.
|
// - FULL_LOW_LATENCY (API 29+) is the only lock that actually disables power save on modern
|
||||||
val wifiLock = remember(handle) {
|
// Android; it needs the app foreground + screen on, which a stream always is.
|
||||||
if (!lowLatencyMode) return@remember null
|
// - 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 wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||||
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
?: return@remember emptyList<WifiManager.WifiLock>()
|
||||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
buildList {
|
||||||
} else {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "punktfunk:stream-ll")
|
||||||
|
?.let(::add)
|
||||||
|
}
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "punktfunk:stream-hp")
|
||||||
}
|
?.let(::add)
|
||||||
wm?.createWifiLock(mode, "punktfunk:stream")?.apply { setReferenceCounted(false) }
|
}.onEach { it.setReferenceCounted(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(handle) {
|
DisposableEffect(handle) {
|
||||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
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) —
|
// 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
|
// 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.
|
// 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) {
|
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
window?.setPreferMinimalPostProcessing(false)
|
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.
|
// Release the landscape lock so the rest of the app follows the device/system again.
|
||||||
activity?.requestedOrientation =
|
activity?.requestedOrientation =
|
||||||
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
|
|||||||
@@ -187,12 +187,19 @@ internal fun StreamScene() {
|
|||||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped,
|
// The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
||||||
// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit
|
// e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer,
|
||||||
// BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line.
|
// 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(
|
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),
|
doubleArrayOf(
|
||||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,8 +110,18 @@ afterEvaluate {
|
|||||||
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
|
// 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
|
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
|
||||||
// for every normal APK/AAR build.
|
// 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")) {
|
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) }
|
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ object Gamepad {
|
|||||||
private const val VID_SONY = 0x054C
|
private const val VID_SONY = 0x054C
|
||||||
private const val VID_MICROSOFT = 0x045E
|
private const val VID_MICROSOFT = 0x045E
|
||||||
private const val VID_VALVE = 0x28DE
|
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.
|
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
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. */
|
/** True when [dev]'s source classes include gamepad or joystick. */
|
||||||
fun isPad(dev: InputDevice?): Boolean {
|
fun isPad(dev: InputDevice?): Boolean {
|
||||||
val s = dev?.sources ?: return false
|
val s = dev?.sources ?: return false
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ mdns-sd = "0.20"
|
|||||||
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
|
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
android_logger = "0.14"
|
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).
|
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
||||||
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
# 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).
|
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ mod stats;
|
|||||||
mod wol;
|
mod wol;
|
||||||
|
|
||||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
/// 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")]
|
#[cfg(target_os = "android")]
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn JNI_OnLoad(
|
pub extern "system" fn JNI_OnLoad(
|
||||||
|
|||||||
@@ -162,9 +162,20 @@ pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
|
|||||||
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
|
// 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
|
// 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.
|
// 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] {
|
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();
|
child.activate();
|
||||||
|
activating.set(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,3 +731,53 @@ fn add_host_dialog(state: &Rc<State>) {
|
|||||||
}
|
}
|
||||||
dialog.present(Some(&state.stack));
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -806,6 +806,10 @@ fn attach_keyboard(
|
|||||||
| gdk::ModifierType::ALT_MASK
|
| gdk::ModifierType::ALT_MASK
|
||||||
| gdk::ModifierType::SHIFT_MASK;
|
| gdk::ModifierType::SHIFT_MASK;
|
||||||
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
|
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() {
|
if cap.captured.get() {
|
||||||
cap.release();
|
cap.release();
|
||||||
} else {
|
} else {
|
||||||
@@ -816,6 +820,7 @@ fn attach_keyboard(
|
|||||||
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
|
// 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.
|
// 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 {
|
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
|
||||||
|
tracing::info!("chord: Ctrl+Alt+Shift+D (disconnect) — releasing capture + quitting");
|
||||||
cap.release();
|
cap.release();
|
||||||
// Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session down
|
// 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.
|
// immediately instead of holding the keep-alive linger for a reconnect.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use punktfunk_core::session::Session;
|
|||||||
use punktfunk_core::transport::loopback_pair;
|
use punktfunk_core::transport::loopback_pair;
|
||||||
|
|
||||||
const TAG_LEN: usize = 16; // AES-GCM authentication tag
|
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 {
|
fn cfg(role: Role, scheme: FecScheme) -> Config {
|
||||||
Config {
|
Config {
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ documentation_style = "c99"
|
|||||||
parse_deps = false
|
parse_deps = false
|
||||||
|
|
||||||
[export]
|
[export]
|
||||||
# Internal Apple-only FFI (transport/udp.rs `recvmsg_x` batched recv + its `MsghdrX`) — NOT part of
|
# Internal platform-only FFI — NOT part of the C ABI. cbindgen otherwise sweeps the foreign
|
||||||
# the C ABI. cbindgen otherwise sweeps the foreign import and its #[repr(C)] struct into the header,
|
# imports and their #[repr(C)] structs into the header, where socklen_t/ssize_t/iovec/msghdr are
|
||||||
# where socklen_t/ssize_t/iovec are undefined and the C harness fails to compile.
|
# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs
|
||||||
exclude = ["MsghdrX", "recvmsg_x"]
|
# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module).
|
||||||
|
exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
|
||||||
|
|
||||||
[export.rename]
|
[export.rename]
|
||||||
"InputEvent" = "PunktfunkInputEvent"
|
"InputEvent" = "PunktfunkInputEvent"
|
||||||
|
|||||||
@@ -123,6 +123,24 @@ pub struct ProbeOutcome {
|
|||||||
/// (display freshness over completeness — FEC/keyframes recover).
|
/// (display freshness over completeness — FEC/keyframes recover).
|
||||||
const FRAME_QUEUE: usize = 16;
|
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
|
/// 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).
|
/// embedder drops the newest packet (the audio renderer conceals the gap).
|
||||||
const AUDIO_QUEUE: usize = 64;
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// the inversion without slowing the Swift side. Android gets a nice-level analogue (see the
|
||||||
/// run a QoS scheduler, and `punktfunk-probe` doesn't care).
|
/// 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")]
|
#[cfg(target_vendor = "apple")]
|
||||||
fn pin_thread_user_interactive() {
|
fn pin_thread_user_interactive() {
|
||||||
// SAFETY: sets only the current thread's QoS class — always valid to call.
|
// 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);
|
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() {}
|
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
|
/// 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
|
/// session today; the consumer is platform-specific). Linux/Android expose `gettid`; elsewhere
|
||||||
/// there's nothing to hint with, so registration is a no-op.
|
/// 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);
|
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||||
let mut last_report = Instant::now();
|
let mut last_report = Instant::now();
|
||||||
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
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<Instant> = None;
|
||||||
while !pump_shutdown.load(Ordering::SeqCst) {
|
while !pump_shutdown.load(Ordering::SeqCst) {
|
||||||
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
|
// 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
|
// 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 {
|
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||||
continue; // speed-test filler, not video — measured via the counters above
|
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);
|
let _ = frame_tx.try_send(frame);
|
||||||
}
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {
|
Err(PunktfunkError::NoFrame) => {
|
||||||
|
|||||||
@@ -256,6 +256,19 @@ pub const fn max_shard_payload() -> usize {
|
|||||||
MAX_DATAGRAM_BYTES - HEADER_LEN - CRYPTO_OVERHEAD
|
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).
|
/// Everything needed to construct a [`Session`](crate::session::Session).
|
||||||
///
|
///
|
||||||
/// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized
|
/// `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());
|
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]
|
#[test]
|
||||||
fn rejects_block_exceeding_scheme_ceiling() {
|
fn rejects_block_exceeding_scheme_ceiling() {
|
||||||
let mut c = Config::p1_defaults(Role::Host); // Gf8, ceiling 255
|
let mut c = Config::p1_defaults(Role::Host); // Gf8, ceiling 255
|
||||||
|
|||||||
@@ -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`.
|
/// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||||
pub const MAX_DATAGRAM_BYTES: usize = 2048;
|
pub const MAX_DATAGRAM_BYTES: usize = 2048;
|
||||||
|
|
||||||
/// How many frames behind the newest the reassembler keeps before pruning stragglers.
|
/// How far behind the newest frame's capture pts an INCOMPLETE frame may sit before it is
|
||||||
const REORDER_WINDOW: u32 = 16;
|
/// 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.
|
/// Fixed per-packet header. `#[repr(C)]`, no padding, zero-copy (de)serializable.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -274,7 +295,10 @@ pub struct Reassembler {
|
|||||||
/// Recently-emitted frames, so stray/late shards can't resurrect them. Pruned to
|
/// Recently-emitted frames, so stray/late shards can't resurrect them. Pruned to
|
||||||
/// the reorder window alongside `frames`.
|
/// the reorder window alongside `frames`.
|
||||||
completed: HashSet<u32>,
|
completed: HashSet<u32>,
|
||||||
newest_frame: Option<u32>,
|
/// 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 {
|
impl Reassembler {
|
||||||
@@ -344,12 +368,12 @@ impl Reassembler {
|
|||||||
}
|
}
|
||||||
let payload = pkt[HEADER_LEN..HEADER_LEN + shard_bytes].to_vec();
|
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
|
// 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
|
// frame that completed early via the all-originals-present fast path) or that
|
||||||
// have fallen out of the reorder window.
|
// have fallen out of the loss window.
|
||||||
if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index) {
|
if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index, hdr.pts_ns) {
|
||||||
drop(stats);
|
drop(stats);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -461,19 +485,31 @@ impl Reassembler {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track the newest frame and prune stragglers that fell out of the reorder window
|
/// Track the newest frame, declare incomplete frames that fell out of the loss window
|
||||||
/// (counting them as dropped).
|
/// ([`LOSS_WINDOW_NS`] behind the newest pts, or [`HARD_LOSS_WINDOW`] indices) lost — counting
|
||||||
fn advance_window(&mut self, frame_index: u32, stats: &StatsCounters) {
|
/// them dropped, which is what drives the client's recovery-keyframe request — and prune the
|
||||||
let newest = match self.newest_frame {
|
/// 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.
|
// `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,
|
Some((n, p)) if frame_index.wrapping_sub(n) > u32::MAX / 2 => (n, p),
|
||||||
_ => frame_index,
|
_ => (frame_index, pts_ns),
|
||||||
};
|
};
|
||||||
self.newest_frame = Some(newest);
|
self.newest_frame = Some((newest, newest_pts));
|
||||||
|
|
||||||
let before = self.frames.len();
|
let before = self.frames.len();
|
||||||
self.frames
|
let completed = &mut self.completed;
|
||||||
.retain(|&idx, _| newest.wrapping_sub(idx) <= REORDER_WINDOW);
|
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();
|
let pruned = before - self.frames.len();
|
||||||
if pruned > 0 {
|
if pruned > 0 {
|
||||||
StatsCounters::add(&stats.frames_dropped, pruned as u64);
|
StatsCounters::add(&stats.frames_dropped, pruned as u64);
|
||||||
@@ -482,13 +518,29 @@ impl Reassembler {
|
|||||||
.retain(|&idx| newest.wrapping_sub(idx) <= REORDER_WINDOW);
|
.retain(|&idx| newest.wrapping_sub(idx) <= REORDER_WINDOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if `frame_index` lies behind the newest frame by more than the reorder
|
/// Drop all in-flight state — every partially-assembled frame and the completed/abandoned
|
||||||
/// window (so its shards arrive too late to be useful).
|
/// index memory — as if the session just started. Used by the client's backlog flush
|
||||||
fn is_stale(&self, frame_index: u32) -> bool {
|
/// ([`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 {
|
match self.newest_frame {
|
||||||
Some(n) => {
|
Some((n, newest_pts)) => {
|
||||||
let behind = n.wrapping_sub(frame_index);
|
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,
|
None => false,
|
||||||
}
|
}
|
||||||
@@ -585,6 +637,82 @@ mod tests {
|
|||||||
assert_eq!(stats.snapshot().packets_dropped, 1);
|
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]
|
#[test]
|
||||||
fn rejects_wrong_shard_bytes_and_oversized_frame() {
|
fn rejects_wrong_shard_bytes_and_oversized_frame() {
|
||||||
let coder = coder_for(FecScheme::Gf8);
|
let coder = coder_for(FecScheme::Gf8);
|
||||||
|
|||||||
@@ -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<u64> {
|
||||||
|
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.
|
/// Client: serialize and send one input event to the host.
|
||||||
pub fn send_input(&mut self, event: &InputEvent) -> Result<()> {
|
pub fn send_input(&mut self, event: &InputEvent) -> Result<()> {
|
||||||
if self.config.role != Role::Client {
|
if self.config.role != Role::Client {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
//! Real UDP datagram transport — native sockets, no async runtime.
|
//! Real UDP datagram transport — native sockets, no async runtime.
|
||||||
//!
|
//!
|
||||||
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
|
//! 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
|
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) on Linux AND Android (which is
|
||||||
//! (~125k → a few-k syscalls/sec at line rate). The host additionally paces each frame's send
|
//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`])
|
||||||
//! across the frame interval (see `punktfunk1.rs::paced_submit`) so a real NIC doesn't drop a line-rate
|
//! — the 1 Gbps+ syscall lever (~125k → a few-k syscalls/sec at line rate). The host additionally
|
||||||
//! burst. All three layer on this same [`Transport`] seam (scalar fallbacks for loopback/non-Linux).
|
//! 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 super::Transport;
|
||||||
use crate::packet::MAX_DATAGRAM_BYTES;
|
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
|
/// 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.
|
/// 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
|
/// 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.
|
/// `iovs` alive and unmoved for as long as the headers are passed to the syscall.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<libc::mmsghdr> {
|
fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<mmsghdr> {
|
||||||
iovs.iter_mut()
|
iovs.iter_mut()
|
||||||
.map(|iov| {
|
.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_iov = iov;
|
||||||
h.msg_hdr.msg_iovlen = 1;
|
h.msg_hdr.msg_iovlen = 1;
|
||||||
h
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Other targets fall back
|
||||||
/// the trait's scalar `send` loop (no `sendmmsg`).
|
/// to the trait's scalar `send` loop (no `sendmmsg`).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
fn send_batch(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
|
fn send_batch(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
const CHUNK: usize = 64;
|
const CHUNK: usize = 64;
|
||||||
@@ -593,7 +630,7 @@ impl Transport for UdpTransport {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut hdrs = mmsghdrs(&mut iovs);
|
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 {
|
if n < 0 {
|
||||||
let err = std::io::Error::last_os_error();
|
let err = std::io::Error::last_os_error();
|
||||||
// Nothing fit in the send buffer (or a stale ICMP from a connected-socket blip) —
|
// 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
|
/// 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
|
/// (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
|
/// `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
|
/// `recv`'s oversized-drop. Android uses the local bionic binding (see [`android_mmsg`]).
|
||||||
/// trait's scalar default.
|
/// Apple/BSD use the `recv`-loop override below; other non-unix the trait's scalar default.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
let fd = self.socket.as_raw_fd();
|
let fd = self.socket.as_raw_fd();
|
||||||
@@ -743,7 +780,7 @@ impl Transport for UdpTransport {
|
|||||||
.collect();
|
.collect();
|
||||||
let mut hdrs = mmsghdrs(&mut iovs);
|
let mut hdrs = mmsghdrs(&mut iovs);
|
||||||
let n = unsafe {
|
let n = unsafe {
|
||||||
libc::recvmmsg(
|
recvmmsg(
|
||||||
fd,
|
fd,
|
||||||
hdrs.as_mut_ptr(),
|
hdrs.as_mut_ptr(),
|
||||||
n_bufs as libc::c_uint,
|
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
|
/// 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
|
/// `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`.
|
/// `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<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
||||||
// Apple: prefer the batched `recvmsg_x` syscall when enabled; a surprise error disables it
|
// Apple: prefer the batched `recvmsg_x` syscall when enabled; a surprise error disables it
|
||||||
// and falls through to the always-correct scalar loop below.
|
// and falls through to the always-correct scalar loop below.
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn input_round_trips_client_to_host() {
|
fn input_round_trips_client_to_host() {
|
||||||
let (host_tp, client_tp) = loopback_pair(0, 0);
|
let (host_tp, client_tp) = loopback_pair(0, 0);
|
||||||
|
|||||||
@@ -807,11 +807,27 @@ struct Component(*mut sys::AmfComponent);
|
|||||||
impl Drop for Component {
|
impl Drop for Component {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// SAFETY: `self.0` is the non-null component `CreateComponent` returned with its own
|
// SAFETY: `self.0` is the non-null component `CreateComponent` returned with its own
|
||||||
// reference, owned exclusively by this guard; both calls go through its runtime-provided
|
// reference, owned exclusively by this guard; every call goes through its runtime-provided
|
||||||
// vtable on the owning thread. Terminate-then-Release is the documented teardown order,
|
// vtable on the owning thread. Flush-then-Terminate-then-Release is the teardown order
|
||||||
// and this drop runs exactly once.
|
// `reset()` and design/native-amf-encoder.md §"reset natively" use, and this drop runs
|
||||||
|
// exactly once.
|
||||||
unsafe {
|
unsafe {
|
||||||
((*(*self.0).vtbl).terminate)(self.0);
|
// Flush BEFORE Terminate so the VCN hardware encode session is released cleanly. An
|
||||||
|
// un-flushed Terminate (surfaces still in flight) can leave AMD's limited VCN
|
||||||
|
// session slots occupied for a beat, and the NEXT session's `Init` — a reconnect
|
||||||
|
// whose teardown overlaps ours, since a client may not signal an explicit exit — then
|
||||||
|
// opens onto a busy/wedged session that returns AMF_OK but never emits an AU. That is
|
||||||
|
// the "second connection silently dead on AMD" symptom; NVENC has no equivalent
|
||||||
|
// per-session cap, so it never shows. Results are best-effort (a wedged component is
|
||||||
|
// legal to flush/terminate), logged for the teardown trace.
|
||||||
|
((*(*self.0).vtbl).flush)(self.0);
|
||||||
|
let tr = ((*(*self.0).vtbl).terminate)(self.0);
|
||||||
|
if tr != sys::AMF_OK {
|
||||||
|
tracing::debug!(
|
||||||
|
result = %format!("{} ({tr})", result_name(tr)),
|
||||||
|
"AMF component Terminate returned non-OK on drop"
|
||||||
|
);
|
||||||
|
}
|
||||||
((*(*self.0).vtbl).release)(self.0);
|
((*(*self.0).vtbl).release)(self.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -826,7 +842,13 @@ impl Drop for Ctx {
|
|||||||
// first — `Inner` declares `comp` before `ctx`). Terminate releases the D3D11 device
|
// first — `Inner` declares `comp` before `ctx`). Terminate releases the D3D11 device
|
||||||
// binding; Release drops the last reference. Runs exactly once on the owning thread.
|
// binding; Release drops the last reference. Runs exactly once on the owning thread.
|
||||||
unsafe {
|
unsafe {
|
||||||
((*(*self.0).vtbl).terminate)(self.0);
|
let tr = ((*(*self.0).vtbl).terminate)(self.0);
|
||||||
|
if tr != sys::AMF_OK {
|
||||||
|
tracing::debug!(
|
||||||
|
result = %format!("{} ({tr})", result_name(tr)),
|
||||||
|
"AMF context Terminate returned non-OK on drop (D3D11 device unbind)"
|
||||||
|
);
|
||||||
|
}
|
||||||
((*(*self.0).vtbl).release)(self.0);
|
((*(*self.0).vtbl).release)(self.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -890,8 +912,15 @@ unsafe fn set_prop(
|
|||||||
/// depth-1/2 steady state only 1-2 slots are ever live.
|
/// depth-1/2 steady state only 1-2 slots are ever live.
|
||||||
const RING: usize = 6;
|
const RING: usize = 6;
|
||||||
|
|
||||||
|
/// Process-wide count of AMF encoder contexts brought up (`ensure_inner` bumps it on a successful
|
||||||
|
/// `Init`). Logged per bring-up so the trace distinguishes a first connection (`context #1`) from a
|
||||||
|
/// reconnect's fresh context (`context #2`, `#3`, …) — the axis the "second connection silently
|
||||||
|
/// dead on AMD" report lives on. A reconnect whose context number climbs but whose "first AU"
|
||||||
|
/// line (see [`Inner::note_first_au`]) never follows is a silent VCN-session wedge.
|
||||||
|
static AMF_CONTEXTS_OPENED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
|
||||||
/// The live AMF session: context + encoder component on the capturer's device, plus the owned
|
/// The live AMF session: context + encoder component on the capturer's device, plus the owned
|
||||||
/// input texture ring. Field order matters: `comp` drops (Terminate+Release) before `ctx`.
|
/// input texture ring. Field order matters: `comp` drops (Flush+Terminate+Release) before `ctx`.
|
||||||
struct Inner {
|
struct Inner {
|
||||||
comp: Component,
|
comp: Component,
|
||||||
ctx: Ctx,
|
ctx: Ctx,
|
||||||
@@ -913,6 +942,26 @@ struct Inner {
|
|||||||
/// The HDR mastering metadata last pushed to THIS component (`*InHDRMetadata`), so `submit`
|
/// The HDR mastering metadata last pushed to THIS component (`*InHDRMetadata`), so `submit`
|
||||||
/// re-pushes only on change — and a rebuilt component starts clean and gets it again.
|
/// re-pushes only on change — and a rebuilt component starts clean and gets it again.
|
||||||
hdr_pushed: Option<punktfunk_core::quic::HdrMeta>,
|
hdr_pushed: Option<punktfunk_core::quic::HdrMeta>,
|
||||||
|
/// Whether this context has emitted its first AU yet — gates a single info log confirming the
|
||||||
|
/// encoder actually produces output. Its ABSENCE after a `context #N created` line is the
|
||||||
|
/// smoking gun for a silently-wedged reconnect (Init succeeded, VCN never encodes).
|
||||||
|
first_au_logged: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inner {
|
||||||
|
/// Log the first AU this context produces, exactly once. The presence of this line pairs a
|
||||||
|
/// `context #N created` bring-up with proof the encoder is live; its absence is the diagnostic
|
||||||
|
/// for the "no errors, just black" reconnect wedge.
|
||||||
|
fn note_first_au(&mut self, au: &EncodedFrame) {
|
||||||
|
if !self.first_au_logged {
|
||||||
|
self.first_au_logged = true;
|
||||||
|
tracing::info!(
|
||||||
|
bytes = au.data.len(),
|
||||||
|
keyframe = au.keyframe,
|
||||||
|
"AMF produced its first AU on this context"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AmfEncoder {
|
pub struct AmfEncoder {
|
||||||
@@ -939,6 +988,14 @@ pub struct AmfEncoder {
|
|||||||
/// gates [`EncoderCaps::intra_refresh`] so keyframe-request rate-limiting only happens when
|
/// gates [`EncoderCaps::intra_refresh`] so keyframe-request rate-limiting only happens when
|
||||||
/// the wave really runs.
|
/// the wave really runs.
|
||||||
ir_active: bool,
|
ir_active: bool,
|
||||||
|
/// Consecutive [`reset`](Self::reset)s that have NOT been followed by a produced AU (cleared in
|
||||||
|
/// `poll` on any output). An in-place `Terminate`+re-`Init` heals a transient component stall,
|
||||||
|
/// but it re-inits the SAME context — so if the fault is the context / VCN session itself (the
|
||||||
|
/// AMD reconnect wedge), in-place recovery loops forever re-initing a dead session. Once this
|
||||||
|
/// reaches 2, `reset` escalates to a FULL context teardown (drop `inner`) so the next submit
|
||||||
|
/// brings up a brand-new `CreateContext`+`InitDX11` — which, once the prior session's VCN slot
|
||||||
|
/// has drained, actually encodes. Bounded by the session's `MAX_ENCODER_RESETS` either way.
|
||||||
|
resets_without_output: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: `AmfEncoder` owns raw AMF interface pointers (context/component) and windows-rs COM
|
// SAFETY: `AmfEncoder` owns raw AMF interface pointers (context/component) and windows-rs COM
|
||||||
@@ -1027,6 +1084,7 @@ impl AmfEncoder {
|
|||||||
force_kf: false,
|
force_kf: false,
|
||||||
hdr_meta: None,
|
hdr_meta: None,
|
||||||
ir_active: false,
|
ir_active: false,
|
||||||
|
resets_without_output: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1309,9 +1367,17 @@ impl AmfEncoder {
|
|||||||
let dctx = device
|
let dctx = device
|
||||||
.GetImmediateContext()
|
.GetImmediateContext()
|
||||||
.context("ID3D11Device immediate context")?;
|
.context("ID3D11Device immediate context")?;
|
||||||
|
// Bump AFTER a successful Init — a bring-up that failed above never counts. The
|
||||||
|
// sequence number is the reconnect axis: `context #1` is the first connection, `#2+`
|
||||||
|
// are reconnects; a climbing number with no following "first AU" line is the silent
|
||||||
|
// AMD wedge.
|
||||||
|
let context_no =
|
||||||
|
AMF_CONTEXTS_OPENED.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
codec = ?self.codec,
|
codec = ?self.codec,
|
||||||
"native AMF encode active ({}x{}@{}, zero-copy D3D11 {} ring, runtime {}.{}.{})",
|
context = context_no,
|
||||||
|
device = format!("{:#x}", device.as_raw() as usize),
|
||||||
|
"native AMF encode active (context #{context_no}, {}x{}@{}, zero-copy D3D11 {} ring, runtime {}.{}.{})",
|
||||||
self.width,
|
self.width,
|
||||||
self.height,
|
self.height,
|
||||||
self.fps,
|
self.fps,
|
||||||
@@ -1330,6 +1396,7 @@ impl AmfEncoder {
|
|||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
ready: VecDeque::new(),
|
ready: VecDeque::new(),
|
||||||
hdr_pushed: None,
|
hdr_pushed: None,
|
||||||
|
first_au_logged: false,
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1846,35 +1913,53 @@ impl Encoder for AmfEncoder {
|
|||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||||
let odt = self.props.output_data_type;
|
let odt = self.props.output_data_type;
|
||||||
let okm = self.props.output_key_max;
|
let okm = self.props.output_key_max;
|
||||||
let Some(inner) = self.inner.as_mut() else {
|
// Pull one AU (buffered or freshly queried) with the inner borrow scoped, so the produced
|
||||||
return Ok(None);
|
// AU can clear `resets_without_output` on `self` afterward without a borrow conflict.
|
||||||
};
|
let au = {
|
||||||
// Back-pressure-buffered AUs first (strictly older than anything still in `pending`).
|
let Some(inner) = self.inner.as_mut() else {
|
||||||
if let Some(au) = inner.ready.pop_front() {
|
|
||||||
return Ok(Some(au));
|
|
||||||
}
|
|
||||||
let budget = std::time::Duration::from_micros(750_000 / self.fps.max(1) as u64)
|
|
||||||
.min(std::time::Duration::from_millis(12));
|
|
||||||
let deadline = std::time::Instant::now() + budget;
|
|
||||||
loop {
|
|
||||||
// SAFETY: `inner.comp.0` is the live component and `inner.pending` its FIFO, used only
|
|
||||||
// on this (encode) thread with no other AMF call to it in flight — `drain_one_output`'s
|
|
||||||
// documented contract.
|
|
||||||
match unsafe { drain_one_output(inner.comp.0, &mut inner.pending, odt, okm) }? {
|
|
||||||
DrainOutcome::Frame(au) => return Ok(Some(au)),
|
|
||||||
// Drained (post-`Drain`): nothing further is owed.
|
|
||||||
DrainOutcome::Eof => {
|
|
||||||
inner.pending.clear();
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
DrainOutcome::NotReady => {}
|
|
||||||
}
|
|
||||||
// Not ready: only wait while a frame is actually owed, ~250 µs between checks.
|
|
||||||
if inner.pending.is_empty() || std::time::Instant::now() >= deadline {
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
|
};
|
||||||
|
// Back-pressure-buffered AUs first (strictly older than anything still in `pending`).
|
||||||
|
if let Some(au) = inner.ready.pop_front() {
|
||||||
|
inner.note_first_au(&au);
|
||||||
|
Some(au)
|
||||||
|
} else {
|
||||||
|
let budget = std::time::Duration::from_micros(750_000 / self.fps.max(1) as u64)
|
||||||
|
.min(std::time::Duration::from_millis(12));
|
||||||
|
let deadline = std::time::Instant::now() + budget;
|
||||||
|
let mut out = None;
|
||||||
|
loop {
|
||||||
|
// SAFETY: `inner.comp.0` is the live component and `inner.pending` its FIFO,
|
||||||
|
// used only on this (encode) thread with no other AMF call to it in flight —
|
||||||
|
// `drain_one_output`'s documented contract.
|
||||||
|
match unsafe { drain_one_output(inner.comp.0, &mut inner.pending, odt, okm) }? {
|
||||||
|
DrainOutcome::Frame(au) => {
|
||||||
|
inner.note_first_au(&au);
|
||||||
|
out = Some(au);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Drained (post-`Drain`): nothing further is owed.
|
||||||
|
DrainOutcome::Eof => {
|
||||||
|
inner.pending.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
DrainOutcome::NotReady => {}
|
||||||
|
}
|
||||||
|
// Not ready: only wait while a frame is actually owed, ~250 µs between checks.
|
||||||
|
if inner.pending.is_empty() || std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_micros(250));
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
std::thread::sleep(std::time::Duration::from_micros(250));
|
};
|
||||||
|
// Any produced AU proves this context encodes — clear the no-output reset streak so a
|
||||||
|
// later, unrelated stall starts fresh at the cheap in-place recovery.
|
||||||
|
if au.is_some() {
|
||||||
|
self.resets_without_output = 0;
|
||||||
}
|
}
|
||||||
|
Ok(au)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode-stall recovery (design §3.5), cheaper than the ffmpeg path's drop-and-reopen:
|
/// Encode-stall recovery (design §3.5), cheaper than the ffmpeg path's drop-and-reopen:
|
||||||
@@ -1882,11 +1967,37 @@ impl Encoder for AmfEncoder {
|
|||||||
/// same context. If the in-place rebuild fails, fall back to full teardown — the next
|
/// same context. If the in-place rebuild fails, fall back to full teardown — the next
|
||||||
/// `submit` rebuilds context + component lazily, exactly like first-frame bring-up. Either
|
/// `submit` rebuilds context + component lazily, exactly like first-frame bring-up. Either
|
||||||
/// way the owed AUs are forfeited and the next frame is a forced IDR.
|
/// way the owed AUs are forfeited and the next frame is a forced IDR.
|
||||||
|
///
|
||||||
|
/// In-place re-`Init` reuses the SAME context, so it can't clear a fault that lives in the
|
||||||
|
/// context / VCN session (the AMD reconnect wedge: Init returns OK but the hardware session
|
||||||
|
/// never encodes). [`resets_without_output`](Self::resets_without_output) counts resets not
|
||||||
|
/// followed by an AU; once it reaches 2 this escalates to a FULL context teardown so the next
|
||||||
|
/// submit brings up a fresh `CreateContext`+`InitDX11` on a (by then) drained VCN slot.
|
||||||
fn reset(&mut self) -> bool {
|
fn reset(&mut self) -> bool {
|
||||||
self.force_kf = true;
|
self.force_kf = true;
|
||||||
let Some(inner) = self.inner.as_mut() else {
|
self.resets_without_output = self.resets_without_output.saturating_add(1);
|
||||||
|
if self.inner.is_none() {
|
||||||
return true; // nothing live — the next submit rebuilds lazily
|
return true; // nothing live — the next submit rebuilds lazily
|
||||||
};
|
}
|
||||||
|
// Escalate: an in-place re-Init already ran without producing an AU, so the fault is the
|
||||||
|
// context itself — tear it fully down and reopen fresh instead of re-initing a dead session
|
||||||
|
// in a loop until MAX_ENCODER_RESETS ends the whole session. Checked before borrowing
|
||||||
|
// `inner` so this can drop `self.inner`.
|
||||||
|
if self.resets_without_output >= 2 {
|
||||||
|
tracing::warn!(
|
||||||
|
resets = self.resets_without_output,
|
||||||
|
"AMF stall persisted across in-place re-Init — full context teardown, reopening a \
|
||||||
|
fresh context (next submit)"
|
||||||
|
);
|
||||||
|
self.inner = None;
|
||||||
|
self.bound_device = 0;
|
||||||
|
self.ir_active = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let inner = self
|
||||||
|
.inner
|
||||||
|
.as_mut()
|
||||||
|
.expect("inner is Some — checked above and not cleared since");
|
||||||
inner.pending.clear();
|
inner.pending.clear();
|
||||||
inner.ready.clear(); // owed + buffered AUs are forfeited; the rebuilt stream restarts at IDR
|
inner.ready.clear(); // owed + buffered AUs are forfeited; the rebuilt stream restarts at IDR
|
||||||
inner.hdr_pushed = None; // a re-Init'd component needs the HDR metadata again
|
inner.hdr_pushed = None; // a re-Init'd component needs the HDR metadata again
|
||||||
|
|||||||
@@ -26,7 +26,9 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
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::input::{InputEvent, InputKind};
|
||||||
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
||||||
use punktfunk_core::quic::{
|
use punktfunk_core::quic::{
|
||||||
@@ -969,11 +971,14 @@ async fn serve_session(
|
|||||||
fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START),
|
fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START),
|
||||||
max_data_per_block: 4096,
|
max_data_per_block: 4096,
|
||||||
},
|
},
|
||||||
// ~1452-byte payload keeps the IP datagram within a 1500 MTU (1452 + 40 header + 24
|
// The largest even payload whose sealed datagram (header + shard + crypto) fits an
|
||||||
// crypto + 8 IP/UDP ≈ 1500), vs the old 1200 — ~17% fewer packets for free, and an even
|
// unfragmented IPv4/UDP packet on a 1500 MTU — 1408, giving 1472 = the exact ceiling.
|
||||||
// size (FEC requires even shards). Negotiated, so the client follows. Jumbo (≈8900) is a
|
// The previous 1452 overshot it (its math forgot the header/crypto ride inside the UDP
|
||||||
// future negotiated bump (needs MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
|
// payload) and silently IP-fragmented EVERY video datagram, doubling per-datagram loss
|
||||||
shard_payload: 1452,
|
// 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,
|
encrypt: true,
|
||||||
key,
|
key,
|
||||||
salt: *b"pkf1",
|
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
|
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
|
||||||
// is pinned via PUNKTFUNK_FEC_PCT.
|
// is pinned via PUNKTFUNK_FEC_PCT.
|
||||||
if adaptive_fec {
|
if adaptive_fec {
|
||||||
let target = adapt_fec(rep.loss_ppm);
|
// Fast attack, slow decay: jump straight to what the reported loss
|
||||||
let prev = fec_target_ctl.swap(target, Ordering::Relaxed);
|
// 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 {
|
if prev != target {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
loss_ppm = rep.loss_ppm,
|
loss_ppm = rep.loss_ppm,
|
||||||
@@ -3096,7 +3111,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
|
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
|
||||||
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
|
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
|
||||||
let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) =
|
let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) =
|
||||||
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit)?;
|
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit, &stop)?;
|
||||||
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
|
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
drop(_idd_setup_guard);
|
drop(_idd_setup_guard);
|
||||||
@@ -3280,6 +3295,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
bit_depth,
|
bit_depth,
|
||||||
plan,
|
plan,
|
||||||
&quit,
|
&quit,
|
||||||
|
&stop,
|
||||||
)?;
|
)?;
|
||||||
Ok((new_vd, pipe))
|
Ok((new_vd, pipe))
|
||||||
})();
|
})();
|
||||||
@@ -3486,6 +3502,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
bit_depth,
|
bit_depth,
|
||||||
plan,
|
plan,
|
||||||
&quit,
|
&quit,
|
||||||
|
&stop,
|
||||||
) {
|
) {
|
||||||
Ok(p) => break p,
|
Ok(p) => break p,
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
@@ -3793,6 +3810,7 @@ fn build_pipeline_with_retry(
|
|||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
plan: crate::session_plan::SessionPlan,
|
plan: crate::session_plan::SessionPlan,
|
||||||
quit: &Arc<AtomicBool>,
|
quit: &Arc<AtomicBool>,
|
||||||
|
stop: &Arc<AtomicBool>,
|
||||||
) -> Result<Pipeline> {
|
) -> Result<Pipeline> {
|
||||||
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
||||||
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
|
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
|
||||||
@@ -3819,6 +3837,17 @@ fn build_pipeline_with_retry(
|
|||||||
const MAX_ATTEMPTS: u32 = 8;
|
const MAX_ATTEMPTS: u32 = 8;
|
||||||
let mut backoff = std::time::Duration::from_millis(500);
|
let mut backoff = std::time::Duration::from_millis(500);
|
||||||
for attempt in 1..=MAX_ATTEMPTS {
|
for attempt in 1..=MAX_ATTEMPTS {
|
||||||
|
// The client is gone (connection closed → `stop`): every further attempt only churns the
|
||||||
|
// box for a session no one is watching — on a Bazzite takeover that means SIGKILLing and
|
||||||
|
// relaunching the box's Steam session once per attempt for minutes (the .181 storm
|
||||||
|
// 2026-07-07). One in-flight attempt can still overhang; this bounds the damage to it.
|
||||||
|
if attempt > 1 && stop.load(Ordering::SeqCst) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"session ended (client disconnected) during pipeline build — aborting retries \
|
||||||
|
after {} attempt(s)",
|
||||||
|
attempt - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) {
|
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) {
|
||||||
Ok(pipe) => {
|
Ok(pipe) => {
|
||||||
if attempt > 1 {
|
if attempt > 1 {
|
||||||
|
|||||||
@@ -824,19 +824,46 @@ fn kill_unit(unit: &str) {
|
|||||||
.status();
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
/// Runtime-mask `unit` so the box's session supervisor cannot restart it underneath the takeover.
|
||||||
|
/// Bazzite/SteamOS autologin runs under SDDM with `Relogin=true` (`/etc/sddm.conf.d/steamos.conf`):
|
||||||
|
/// the moment the autologin session dies — including our own deliberate stop — SDDM logs back in and
|
||||||
|
/// starts the unit again within the same second. A merely-stopped unit then fights our host-managed
|
||||||
|
/// session over the Steam single instance and the GPU for the whole stream (the restarted wrapper
|
||||||
|
/// relaunches gamescope every ~7 s; the contention SIGSEGVs gamescopes and eventually kills the
|
||||||
|
/// streaming one — the "stream dies after 30 s–5 min" field reports, diagnosed live on .181
|
||||||
|
/// 2026-07-07). `--runtime` keeps the mask in tmpfs so a reboot clears it even if the host dies
|
||||||
|
/// without restoring (the same semantics as the persisted takeover file).
|
||||||
|
fn mask_unit(unit: &str) {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "mask", "--runtime", unit])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo [`mask_unit`] — every restore path must unmask before (or regardless of) restarting, or the
|
||||||
|
/// box's own return-to-gaming-mode stays broken until reboot.
|
||||||
|
fn unmask_unit(unit: &str) {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "unmask", "--runtime", unit])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||||
/// single-instance Steam is free for our own host-managed session. Records the units so
|
/// single-instance Steam is free for our own host-managed session. Records the units so
|
||||||
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
||||||
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
|
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
|
||||||
/// is autologged in (e.g. a box that boots headless). Uses the **SIGKILL** teardown ([`kill_unit`])
|
/// is autologged in (e.g. a box that boots headless). Each unit is **masked first** ([`mask_unit`] —
|
||||||
/// to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
|
/// SDDM's `Relogin=true` would otherwise restart it instantly), then torn down with **SIGKILL**
|
||||||
|
/// ([`kill_unit`]) to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
|
||||||
|
/// Matches every loaded instance, not just `running` ones — under the SDDM relogin churn the unit
|
||||||
|
/// flaps through `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the
|
||||||
|
/// fight the moment the supervisor restarts it.
|
||||||
fn stop_autologin_sessions() {
|
fn stop_autologin_sessions() {
|
||||||
let Ok(out) = Command::new("systemctl")
|
let Ok(out) = Command::new("systemctl")
|
||||||
.args([
|
.args([
|
||||||
"--user",
|
"--user",
|
||||||
"list-units",
|
"list-units",
|
||||||
"--type=service",
|
"--type=service",
|
||||||
"--state=running",
|
"--all",
|
||||||
"--no-legend",
|
"--no-legend",
|
||||||
"--plain",
|
"--plain",
|
||||||
"gamescope-session-plus@*.service",
|
"gamescope-session-plus@*.service",
|
||||||
@@ -849,10 +876,11 @@ fn stop_autologin_sessions() {
|
|||||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||||
if let Some(unit) = line.split_whitespace().next() {
|
if let Some(unit) = line.split_whitespace().next() {
|
||||||
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") {
|
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") {
|
||||||
|
mask_unit(unit); // block the SDDM relogin loop from restarting it mid-stream
|
||||||
kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
unit,
|
unit,
|
||||||
"freed Steam: SIGKILL-stopped the autologin gaming session for this stream"
|
"freed Steam: masked + SIGKILL-stopped the autologin gaming session for this stream"
|
||||||
);
|
);
|
||||||
stopped.push(unit.to_string());
|
stopped.push(unit.to_string());
|
||||||
}
|
}
|
||||||
@@ -980,6 +1008,11 @@ fn do_restore_tv_session() {
|
|||||||
}
|
}
|
||||||
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
|
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
|
||||||
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
|
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
|
||||||
|
// Unmask UNCONDITIONALLY (before the desktop-active early return below): a unit left masked
|
||||||
|
// would break the user's own return to gaming mode until reboot.
|
||||||
|
for unit in &units {
|
||||||
|
unmask_unit(unit);
|
||||||
|
}
|
||||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
||||||
// user switched to a desktop session (KDE/GNOME/wlroots) in the meantime, don't yank them back
|
// user switched to a desktop session (KDE/GNOME/wlroots) in the meantime, don't yank them back
|
||||||
@@ -1095,26 +1128,32 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
|
|||||||
let wrapper = write_gamescope_bin_wrapper()?;
|
let wrapper = write_gamescope_bin_wrapper()?;
|
||||||
stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean
|
stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean
|
||||||
let hz = mode.refresh_hz.max(1);
|
let hz = mode.refresh_hz.max(1);
|
||||||
let status = Command::new("systemd-run")
|
let start_unit = || -> Result<()> {
|
||||||
.args(["--user", "--collect", &format!("--unit={unit_name}")])
|
let status = Command::new("systemd-run")
|
||||||
.arg("--setenv=BACKEND=headless")
|
.args(["--user", "--collect", &format!("--unit={unit_name}")])
|
||||||
.arg(format!("--setenv=SCREEN_WIDTH={}", mode.width))
|
.arg("--setenv=BACKEND=headless")
|
||||||
.arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height))
|
.arg(format!("--setenv=SCREEN_WIDTH={}", mode.width))
|
||||||
.arg(format!("--setenv=PF_HZ={hz}"))
|
.arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height))
|
||||||
.arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display()))
|
.arg(format!("--setenv=PF_HZ={hz}"))
|
||||||
.arg("--setenv=DRM_MODE=cvt")
|
.arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display()))
|
||||||
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}"))
|
.arg("--setenv=DRM_MODE=cvt")
|
||||||
.arg("--")
|
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}"))
|
||||||
.arg(SESSION_PLUS_BIN)
|
.arg("--")
|
||||||
.arg(client)
|
.arg(SESSION_PLUS_BIN)
|
||||||
.status()
|
.arg(client)
|
||||||
.context(
|
.status()
|
||||||
"launch gamescope-session-plus via `systemd-run --user` (is the user systemd manager \
|
.context(
|
||||||
up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)",
|
"launch gamescope-session-plus via `systemd-run --user` (is the user systemd \
|
||||||
)?;
|
manager up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)",
|
||||||
if !status.success() {
|
)?;
|
||||||
anyhow::bail!("`systemd-run --user` failed to start the gamescope session (exit {status})");
|
if !status.success() {
|
||||||
}
|
anyhow::bail!(
|
||||||
|
"`systemd-run --user` failed to start the gamescope session (exit {status})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
start_unit()?;
|
||||||
// Steam Big Picture cold-start is far slower than a bare app — poll the node for up to 45s.
|
// Steam Big Picture cold-start is far slower than a bare app — poll the node for up to 45s.
|
||||||
let deadline = Instant::now() + Duration::from_secs(45);
|
let deadline = Instant::now() + Duration::from_secs(45);
|
||||||
loop {
|
loop {
|
||||||
@@ -1128,10 +1167,45 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
|
|||||||
(Steam failed to start? — `journalctl --user -u {unit_name}`)"
|
(Steam failed to start? — `journalctl --user -u {unit_name}`)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The session-plus wrapper hard-kills a gamescope that missed its 5 s readiness handshake
|
||||||
|
// and exits 1 (a slow NVIDIA cold start routinely needs 5-15 s — the .181 storm 2026-07-07),
|
||||||
|
// and the transient unit has no Restart= — without supervision the rest of this poll would
|
||||||
|
// wait on a corpse. Re-run the unit so every readiness attempt inside the deadline is used.
|
||||||
|
if !unit_starting_or_active(unit_name) {
|
||||||
|
tracing::info!(
|
||||||
|
unit = unit_name,
|
||||||
|
"gamescope session: transient unit died (missed the wrapper's 5 s gamescope \
|
||||||
|
readiness window?) — relaunching"
|
||||||
|
);
|
||||||
|
// Brief cooldown before the relaunch: the wrapper SIGKILLed a gamescope mid-Vulkan-init,
|
||||||
|
// and the NVIDIA driver reclaims that context asynchronously — an instant relaunch pays
|
||||||
|
// the reclaim serialization on top of device init and misses the 5 s window again.
|
||||||
|
std::thread::sleep(Duration::from_millis(1500));
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "reset-failed", unit_name])
|
||||||
|
.status();
|
||||||
|
start_unit()?;
|
||||||
|
}
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is the unit currently starting or up (`activating` / `active` — also `deactivating`: let a stop
|
||||||
|
/// finish; the next poll tick sees the settled state)? Unknown/unreachable states report `true` so a
|
||||||
|
/// systemctl hiccup can't trigger a relaunch storm.
|
||||||
|
fn unit_starting_or_active(unit: &str) -> bool {
|
||||||
|
let Ok(out) = Command::new("systemctl")
|
||||||
|
.args(["--user", "is-active", unit])
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
matches!(
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim(),
|
||||||
|
"active" | "activating" | "reloading" | "deactivating"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the host-managed session's transient unit ([`kill_unit`] — SIGKILL teardown to avoid the F44
|
/// Stop the host-managed session's transient unit ([`kill_unit`] — SIGKILL teardown to avoid the F44
|
||||||
/// GPU-context leak) and clear the EIS relay so a dead session's socket name can't be reconnected.
|
/// GPU-context leak) and clear the EIS relay so a dead session's socket name can't be reconnected.
|
||||||
fn stop_session(unit_name: &str) {
|
fn stop_session(unit_name: &str) {
|
||||||
|
|||||||
Reference in New Issue
Block a user