refactor(android): split session JNI into modules, HUD-gated stats, AAudio open retry
- native: the 756-line session.rs becomes session/{mod,connect,input,planes}.rs
around a SessionHandle (connect lifecycle + trust, input plane shims, plane
start/stop + stats drain).
- Decode-stats sampling is HUD-gated (nativeSetVideoStatsEnabled): with the
overlay hidden the decode thread skips the per-AU clock read + lock; enabling
resets the measurement window.
- audio: the AAudio open path is a per-sharing-mode try_open closure — the
realtime callback state (ring, prime, free-list) is rebuilt per attempt, so a
failed exclusive-mode try can't leak state into the shared-mode retry.
- Kotlin: ConnectScreen/StreamScreen slimmed by extracting ConnectDialogs,
StatsOverlay and TouchInput.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The "Add host" bottom sheet: optional name + address + port, then connect at [modeLabel]. Field
|
||||
* state stays hoisted in ConnectScreen so a dismissed sheet keeps its half-typed values.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AddHostSheet(
|
||||
hostName: String,
|
||||
onHostNameChange: (String) -> Unit,
|
||||
host: String,
|
||||
onHostChange: (String) -> Unit,
|
||||
port: String,
|
||||
onPortChange: (String) -> Unit,
|
||||
connecting: Boolean,
|
||||
modeLabel: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConnect: (host: String, port: Int, name: String) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
) {
|
||||
Text("Add a host", style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Enter its address. You'll pair with the host's PIN on first connect.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
OutlinedTextField(
|
||||
value = hostName,
|
||||
onValueChange = onHostNameChange,
|
||||
label = { Text("Name (optional)") },
|
||||
placeholder = { Text("e.g. Living Room") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = onHostChange,
|
||||
label = { Text("Host") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { v -> onPortChange(v.filter { it.isDigit() }.take(5)) },
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(
|
||||
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
|
||||
onClick = {
|
||||
val h = host.trim()
|
||||
val p = port.toIntOrNull() ?: 9777
|
||||
val n = hostName
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||
onDismiss()
|
||||
onConnect(h, p, n)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Connect ($modeLabel)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
|
||||
@Composable
|
||||
internal fun TrustNewHostDialog(
|
||||
pt: PendingTrust,
|
||||
onTrust: () -> Unit,
|
||||
onPairInstead: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Trust this host?") },
|
||||
text = {
|
||||
Column {
|
||||
Text("First connection to ${pt.host}:${pt.port}.")
|
||||
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}…") }
|
||||
Text(
|
||||
"This host allows trust-on-first-use, but that can't tell an impostor " +
|
||||
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onTrust) { Text("Trust (TOFU)") }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onPairInstead) { Text("Pair with PIN…") }
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
|
||||
@Composable
|
||||
internal fun FingerprintChangedDialog(
|
||||
pt: PendingTrust,
|
||||
onRepair: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Host identity changed") },
|
||||
text = {
|
||||
Text(
|
||||
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
|
||||
"advertises. This can mean a host reinstall — or an impostor. Re-pair " +
|
||||
"with the host's PIN to continue.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onRepair) { Text("Re-pair") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is
|
||||
* the no-PIN path — connect and wait for the operator to click Approve in the host's console;
|
||||
* "Use a PIN…" switches to the SPAKE2 ceremony.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RequestAccessDialog(
|
||||
pt: PendingTrust,
|
||||
onRequestAccess: () -> Unit,
|
||||
onUsePin: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Pairing required") },
|
||||
text = {
|
||||
Column {
|
||||
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||
Text(
|
||||
"Request access and approve this device in the host's console (or web " +
|
||||
"UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onRequestAccess) { Text("Request access") }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onUsePin) { Text("Use a PIN…") }
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The SPAKE2 PIN ceremony dialog. Runs [NativeBridge.nativePair] off the UI thread itself (the
|
||||
* pin/name/error state is dialog-local); on success hands the host's verified fingerprint to
|
||||
* [onPaired], which saves + connects. Dismissal is blocked while a pair attempt is in flight.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PairPinDialog(
|
||||
pt: PendingTrust,
|
||||
identity: ClientIdentity?,
|
||||
onPaired: (fpHex: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var pin by remember(pt) { mutableStateOf("") }
|
||||
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
||||
var pairing by remember(pt) { mutableStateOf(false) }
|
||||
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!pairing) onDismiss() },
|
||||
title = { Text("Pair with PIN") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Enter the 4-digit PIN shown on the host.")
|
||||
OutlinedTextField(
|
||||
value = pin,
|
||||
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
|
||||
label = { Text("PIN") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("This device") },
|
||||
singleLine = true,
|
||||
)
|
||||
err?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !pairing && pin.length == 4 && identity != null,
|
||||
onClick = {
|
||||
val id = identity
|
||||
if (id != null) {
|
||||
pairing = true
|
||||
err = null
|
||||
scope.launch {
|
||||
val fp = withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativePair(
|
||||
pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name,
|
||||
)
|
||||
}
|
||||
pairing = false
|
||||
if (fp.isNotEmpty()) {
|
||||
onPaired(fp) // verified host fp — caller saves + connects
|
||||
} else {
|
||||
err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text(if (pairing) "Pairing…" else "Pair") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(enabled = !pairing, onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
|
||||
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
|
||||
* approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text("Waiting for approval") },
|
||||
text = {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text("Approve this device on $hostLabel.")
|
||||
}
|
||||
Text(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
||||
* friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RenameHostDialog(
|
||||
target: KnownHost,
|
||||
onRename: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var newName by remember(target) { mutableStateOf(target.name) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename host") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text(target.address) },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = newName.isNotBlank(),
|
||||
onClick = { onRename(newName.trim()) },
|
||||
) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -6,11 +6,6 @@ import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -27,24 +22,14 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -56,7 +41,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -99,7 +83,6 @@ private class RequestAccessState(val target: PendingTrust) {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -162,6 +145,26 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
|
||||
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
||||
|
||||
// The one place the full nativeConnect is issued (shared by the normal connect and the
|
||||
// request-access path), including the HDR/gamepad derivation both need.
|
||||
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long {
|
||||
// Advertise HDR only when the user enabled it AND this device's display can present it
|
||||
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
||||
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
||||
// explicit choice is passed through unchanged.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
return withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativeConnect(
|
||||
targetHost, targetPort, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
||||
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
||||
// straight through and it appears in the saved-hosts list.
|
||||
@@ -175,22 +178,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
status = "Connecting to $targetHost:$targetPort…"
|
||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||
scope.launch {
|
||||
// Advertise HDR only when the user enabled it AND this device's display can present it
|
||||
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
||||
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
||||
// explicit choice is passed through unchanged.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
val handle = withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativeConnect(
|
||||
targetHost, targetPort, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled, settings.audioChannels, settings.preferredCodec(),
|
||||
CONNECT_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||
connecting = false
|
||||
if (handle != 0L) {
|
||||
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
||||
@@ -225,19 +213,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
status = null
|
||||
discovery.stop() // free the Wi-Fi radio before the (parked) stream session
|
||||
scope.launch {
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
// Pin the advertised fingerprint for a discovered host (defence against an impostor while
|
||||
// we wait); a manually-typed host has none, so trust-on-first-use.
|
||||
val pinHex = target.advertisedFp ?: ""
|
||||
val handle = withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativeConnect(
|
||||
target.host, target.port, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled, settings.audioChannels, REQUEST_ACCESS_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS)
|
||||
// Cancelled while we were parked: tear the (possibly just-approved) session down and
|
||||
// don't touch UI a fresh action may now own.
|
||||
if (req.cancelled.get()) {
|
||||
@@ -296,7 +275,6 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
var showManualSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@@ -428,291 +406,87 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = true, // Static for now, could be based on scroll if needed
|
||||
enter = scaleIn() + fadeIn(),
|
||||
exit = scaleOut() + fadeOut(),
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showManualSheet = true },
|
||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text("Add host") },
|
||||
expanded = !connecting,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showManualSheet = true },
|
||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text("Add host") },
|
||||
expanded = !connecting,
|
||||
)
|
||||
}
|
||||
.padding(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (showManualSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showManualSheet = false },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
) {
|
||||
Text("Add a host", style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Enter its address. You'll pair with the host's PIN on first connect.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
OutlinedTextField(
|
||||
value = hostName,
|
||||
onValueChange = { hostName = it },
|
||||
label = { Text("Name (optional)") },
|
||||
placeholder = { Text("e.g. Living Room") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("Host") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(
|
||||
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
|
||||
onClick = {
|
||||
val h = host.trim()
|
||||
val p = port.toIntOrNull() ?: 9777
|
||||
val n = hostName
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||
showManualSheet = false
|
||||
connect(h, p, manualName = n)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Connect ($w×$h@$hz)") }
|
||||
}
|
||||
}
|
||||
AddHostSheet(
|
||||
hostName = hostName,
|
||||
onHostNameChange = { hostName = it },
|
||||
host = host,
|
||||
onHostChange = { host = it },
|
||||
port = port,
|
||||
onPortChange = { port = it },
|
||||
connecting = connecting,
|
||||
modeLabel = "$w×$h@$hz",
|
||||
onDismiss = { showManualSheet = false },
|
||||
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
||||
)
|
||||
}
|
||||
|
||||
pendingTrust?.let { pt ->
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW -> AlertDialog(
|
||||
onDismissRequest = { pendingTrust = null },
|
||||
title = { Text("Trust this host?") },
|
||||
text = {
|
||||
Column {
|
||||
Text("First connection to ${pt.host}:${pt.port}.")
|
||||
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}…") }
|
||||
Text(
|
||||
"This host allows trust-on-first-use, but that can't tell an impostor " +
|
||||
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton({ pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }) {
|
||||
Text("Trust (TOFU)")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
|
||||
Text("Pair with PIN…")
|
||||
}
|
||||
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
PendingTrust.Kind.TRUST_NEW -> TrustNewHostDialog(
|
||||
pt = pt,
|
||||
onTrust = { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) },
|
||||
onPairInstead = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||
onDismiss = { pendingTrust = null },
|
||||
)
|
||||
PendingTrust.Kind.FP_CHANGED -> AlertDialog(
|
||||
onDismissRequest = { pendingTrust = null },
|
||||
title = { Text("Host identity changed") },
|
||||
text = {
|
||||
Text(
|
||||
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
|
||||
"advertises. This can mean a host reinstall — or an impostor. Re-pair " +
|
||||
"with the host's PIN to continue.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) { Text("Re-pair") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
||||
},
|
||||
PendingTrust.Kind.FP_CHANGED -> FingerprintChangedDialog(
|
||||
pt = pt,
|
||||
onRepair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||
onDismiss = { pendingTrust = null },
|
||||
)
|
||||
// A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request
|
||||
// access" is the no-PIN path — connect and wait for the operator to click Approve in the
|
||||
// host's console; "Use a PIN…" switches to the SPAKE2 ceremony.
|
||||
PendingTrust.Kind.REQUEST_ACCESS -> AlertDialog(
|
||||
onDismissRequest = { pendingTrust = null },
|
||||
title = { Text("Pairing required") },
|
||||
text = {
|
||||
Column {
|
||||
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||
Text(
|
||||
"Request access and approve this device in the host's console (or web " +
|
||||
"UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton({ pendingTrust = null; requestAccess(pt) }) { Text("Request access") }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
|
||||
Text("Use a PIN…")
|
||||
}
|
||||
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessDialog(
|
||||
pt = pt,
|
||||
onRequestAccess = { pendingTrust = null; requestAccess(pt) },
|
||||
onUsePin = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||
onDismiss = { pendingTrust = null },
|
||||
)
|
||||
PendingTrust.Kind.PAIR -> PairPinDialog(
|
||||
pt = pt,
|
||||
identity = identity,
|
||||
onPaired = { fp ->
|
||||
// Verified host fp — save as a paired known host, then connect pinned.
|
||||
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp)
|
||||
},
|
||||
onDismiss = { pendingTrust = null },
|
||||
)
|
||||
PendingTrust.Kind.PAIR -> {
|
||||
var pin by remember(pt) { mutableStateOf("") }
|
||||
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
||||
var pairing by remember(pt) { mutableStateOf(false) }
|
||||
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!pairing) pendingTrust = null },
|
||||
title = { Text("Pair with PIN") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Enter the 4-digit PIN shown on the host.")
|
||||
OutlinedTextField(
|
||||
value = pin,
|
||||
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
|
||||
label = { Text("PIN") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("This device") },
|
||||
singleLine = true,
|
||||
)
|
||||
err?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !pairing && pin.length == 4 && identity != null,
|
||||
onClick = {
|
||||
val id = identity
|
||||
if (id != null) {
|
||||
pairing = true
|
||||
err = null
|
||||
scope.launch {
|
||||
val fp = withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativePair(
|
||||
pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name,
|
||||
)
|
||||
}
|
||||
pairing = false
|
||||
if (fp.isNotEmpty()) {
|
||||
// Verified host fp — save as a paired known host.
|
||||
knownHostStore.save(
|
||||
KnownHost(pt.host, pt.port, pt.name, fp, paired = true),
|
||||
)
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp)
|
||||
} else {
|
||||
err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text(if (pairing) "Pairing…" else "Pair") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(enabled = !pairing, onClick = { pendingTrust = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The no-PIN "request access" wait: the connect is parked on the host until the operator
|
||||
// approves this device. Cancel returns the UI immediately — it trips the per-attempt flag so a
|
||||
// late approval is torn down silently (see requestAccess) and resumes discovery.
|
||||
awaiting?.let { req ->
|
||||
fun cancel() {
|
||||
req.cancelled.set(true)
|
||||
awaiting = null
|
||||
connecting = false
|
||||
discovery.start() // the request may still be pending on the host; keep scanning
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { cancel() },
|
||||
title = { Text("Waiting for approval") },
|
||||
text = {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text("Approve this device on ${req.target.name}.")
|
||||
}
|
||||
Text(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { cancel() }) { Text("Cancel") }
|
||||
AwaitingApprovalDialog(
|
||||
hostLabel = req.target.name,
|
||||
onCancel = {
|
||||
req.cancelled.set(true)
|
||||
awaiting = null
|
||||
connecting = false
|
||||
discovery.start() // the request may still be pending on the host; keep scanning
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
||||
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||
renameTarget?.let { kh ->
|
||||
var newName by remember(kh) { mutableStateOf(kh.name) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renameTarget = null },
|
||||
title = { Text("Rename host") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text(kh.address) },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = newName.isNotBlank(),
|
||||
onClick = {
|
||||
knownHostStore.rename(kh.address, kh.port, newName.trim())
|
||||
savedHosts = knownHostStore.all()
|
||||
renameTarget = null
|
||||
},
|
||||
) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { renameTarget = null }) { Text("Cancel") }
|
||||
RenameHostDialog(
|
||||
target = kh,
|
||||
onRename = { newName ->
|
||||
knownHostStore.rename(kh.address, kh.port, newName)
|
||||
savedHosts = knownHostStore.all()
|
||||
renameTarget = null
|
||||
},
|
||||
onDismiss = { renameTarget = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from
|
||||
* [NativeBridge.nativeVideoStats]:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the
|
||||
* negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
if (s.size < 10) return
|
||||
val w = s[6].toInt()
|
||||
val h = s[7].toInt()
|
||||
val hz = s[8].toInt()
|
||||
val latValid = s[4] != 0.0
|
||||
val skew = s[5] != 0.0
|
||||
val dropped = s[9].toLong()
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
videoFeedLine(s)?.let { feed ->
|
||||
Text(
|
||||
feed,
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
if (latValid) {
|
||||
val tag = if (skew) "" else " (same-host)"
|
||||
Text(
|
||||
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
if (dropped > 0) {
|
||||
Text(
|
||||
"dropped $dropped",
|
||||
color = Color(0xFFFFB0B0),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
||||
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
||||
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
|
||||
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
|
||||
* Android decoder is always HEVC (`video/hevc`).
|
||||
*/
|
||||
private fun videoFeedLine(s: DoubleArray): String? {
|
||||
if (s.size < 14) return null
|
||||
val bitDepth = s[10].toInt()
|
||||
val primaries = s[11].toInt()
|
||||
val transfer = s[12].toInt()
|
||||
val chromaIdc = s[13].toInt()
|
||||
val depthLabel = if (bitDepth > 0) "$bitDepth-bit" else "8-bit"
|
||||
val (dynamicRange, colorSpace) = when (transfer) {
|
||||
16 -> "HDR" to "BT.2020 PQ"
|
||||
18 -> "HDR" to "BT.2020 HLG"
|
||||
else -> "SDR" to if (primaries == 9) "BT.2020" else "BT.709"
|
||||
}
|
||||
val chromaLabel = when (chromaIdc) {
|
||||
3 -> "4:4:4"
|
||||
2 -> "4:2:2"
|
||||
else -> "4:2:0"
|
||||
}
|
||||
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||
}
|
||||
@@ -7,15 +7,9 @@ import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -25,12 +19,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
@@ -41,25 +32,6 @@ import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Touch-gesture tuning (px / ms). TAP_SLOP: movement under this still counts as a tap, not a drag.
|
||||
// TAP_DRAG_MS: a new touch within this long after a tap starts a left-button drag. SCROLL_DIV: px of
|
||||
// two-finger pan per wheel notch (smaller = faster scroll).
|
||||
private const val TAP_SLOP = 12f
|
||||
private const val TAP_DRAG_MS = 250L
|
||||
private const val SCROLL_DIV = 4f
|
||||
|
||||
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
||||
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
||||
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
||||
// px/ms, capped at ACCEL_MAX (so a fast swipe can't fling the cursor uncontrollably).
|
||||
private const val POINTER_SENS = 1.3f
|
||||
private const val ACCEL_GAIN = 0.6f
|
||||
private const val ACCEL_SPEED_FLOOR = 0.3f
|
||||
private const val ACCEL_MAX = 3.0f
|
||||
|
||||
@Composable
|
||||
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
@@ -76,18 +48,25 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
// Live decode stats for the HUD. Poll once a second for the whole stream (cheap, and each call
|
||||
// drains+resets the native window so it never grows unbounded even while the overlay is hidden);
|
||||
// `showStats` only gates rendering. A 3-finger tap toggles it live; the default comes from Settings.
|
||||
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame
|
||||
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the
|
||||
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native
|
||||
// window, so re-showing never renders stale data. A 3-finger tap toggles it live; the default
|
||||
// comes from Settings.
|
||||
val initialSettings = remember { SettingsStore(context).load() }
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
val trackpad = initialSettings.trackpadMode
|
||||
LaunchedEffect(handle) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
LaunchedEffect(handle, showStats) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
||||
if (showStats) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
}
|
||||
} else {
|
||||
stats = null // drop the last snapshot so a re-show never flashes stale numbers
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,240 +148,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
if (showStats) {
|
||||
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||
}
|
||||
// Touch → mouse. Two models, chosen by the Trackpad-mode setting:
|
||||
// • trackpad (default): the cursor STAYS where it is on touch-down and moves by the finger's
|
||||
// relative delta (MouseMove) with mild pointer acceleration — swipe to nudge, lift and
|
||||
// re-swipe to walk it across, tap to click where it is. This is what makes the cursor
|
||||
// reachable on a small screen.
|
||||
// • direct (opt-out): the cursor jumps to the finger and follows it (MouseMoveAbs,
|
||||
// host-normalized against the overlay size), the old "direct pointing" behaviour.
|
||||
// Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||
// two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||
// windows); three-finger tap = toggle the stats HUD.
|
||||
// Touch → mouse (trackpad vs. direct pointing + the shared gesture vocabulary — see
|
||||
// streamTouchInput in TouchInput.kt).
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle, trackpad) {
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
var lastTapY = 0f
|
||||
fun moveAbs(x: Float, y: Float) {
|
||||
val sw = size.width
|
||||
val sh = size.height
|
||||
if (sw <= 0 || sh <= 0) return
|
||||
NativeBridge.nativeSendPointerAbs(
|
||||
handle,
|
||||
x.coerceIn(0f, (sw - 1).toFloat()).roundToInt(),
|
||||
y.coerceIn(0f, (sh - 1).toFloat()).roundToInt(),
|
||||
sw,
|
||||
sh,
|
||||
)
|
||||
}
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val startX = down.position.x
|
||||
val startY = down.position.y
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||
// button for this whole gesture (laptop-trackpad convention).
|
||||
val isDrag = down.uptimeMillis - lastTapUp < TAP_DRAG_MS &&
|
||||
abs(startX - lastTapX) < TAP_SLOP && abs(startY - lastTapY) < TAP_SLOP
|
||||
lastTapUp = 0L // consume the arming either way
|
||||
// Direct mode jumps the cursor to the finger; trackpad mode leaves it put (the
|
||||
// whole point — you nudge it with swipes instead).
|
||||
if (!trackpad) moveAbs(startX, startY)
|
||||
if (isDrag) NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||
|
||||
var moved = false
|
||||
var maxFingers = 1
|
||||
var scrolling = false
|
||||
var prevCx = startX
|
||||
var prevCy = startY
|
||||
var upTime = down.uptimeMillis
|
||||
// Trackpad relative-motion state: the tracked finger, its last position/time, and
|
||||
// the sub-pixel remainder so a slow drag isn't lost to Int truncation.
|
||||
var trackId = down.id
|
||||
var prevX = startX
|
||||
var prevY = startY
|
||||
var prevT = down.uptimeMillis
|
||||
var accX = 0f
|
||||
var accY = 0f
|
||||
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val pressed = ev.changes.filter { it.pressed }
|
||||
if (pressed.isEmpty()) {
|
||||
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
|
||||
break
|
||||
}
|
||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||
|
||||
if (pressed.size >= 2) {
|
||||
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||
if (!scrolling) {
|
||||
scrolling = true
|
||||
prevCx = cx
|
||||
prevCy = cy
|
||||
}
|
||||
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
|
||||
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
|
||||
if (sy != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
||||
prevCy = cy
|
||||
moved = true
|
||||
}
|
||||
if (sx != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
||||
prevCx = cx
|
||||
moved = true
|
||||
}
|
||||
} else if (!scrolling) {
|
||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||
// back to one finger doesn't jerk the cursor).
|
||||
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
|
||||
if (abs(p.position.x - startX) > TAP_SLOP ||
|
||||
abs(p.position.y - startY) > TAP_SLOP
|
||||
) {
|
||||
moved = true
|
||||
}
|
||||
if (trackpad) {
|
||||
// Relative: move by the finger delta × (sensitivity × acceleration),
|
||||
// carrying the sub-pixel remainder. Re-anchor (zero delta this frame)
|
||||
// if the tracked finger changed, so lifting one of several fingers
|
||||
// never jumps the cursor.
|
||||
if (p.id != trackId) {
|
||||
trackId = p.id
|
||||
prevX = p.position.x
|
||||
prevY = p.position.y
|
||||
prevT = p.uptimeMillis
|
||||
}
|
||||
val dx = p.position.x - prevX
|
||||
val dy = p.position.y - prevY
|
||||
val dt = (p.uptimeMillis - prevT).coerceAtLeast(1L)
|
||||
prevX = p.position.x
|
||||
prevY = p.position.y
|
||||
prevT = p.uptimeMillis
|
||||
val speed = hypot(dx, dy) / dt // finger px per ms
|
||||
val accel = (1f + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).coerceAtLeast(0f))
|
||||
.coerceAtMost(ACCEL_MAX)
|
||||
accX += dx * POINTER_SENS * accel
|
||||
accY += dy * POINTER_SENS * accel
|
||||
val outX = accX.toInt() // truncates toward zero → remainder kept w/ sign
|
||||
val outY = accY.toInt()
|
||||
if (outX != 0 || outY != 0) {
|
||||
NativeBridge.nativeSendPointerMove(handle, outX, outY)
|
||||
accX -= outX
|
||||
accY -= outY
|
||||
}
|
||||
} else {
|
||||
moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
|
||||
}
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
}
|
||||
|
||||
if (isDrag) {
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||
} else if (!moved) {
|
||||
when {
|
||||
maxFingers >= 3 -> showStats = !showStats // in-stream HUD toggle
|
||||
maxFingers == 2 -> { // two-finger tap → right click
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||
}
|
||||
else -> { // tap → left click (at the cursor's current spot), arm tap-drag
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
||||
lastTapUp = upTime
|
||||
lastTapX = startX
|
||||
lastTapY = startY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
streamTouchInput(handle, trackpad, onToggleStats = { showStats = !showStats })
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from
|
||||
* [NativeBridge.nativeVideoStats]:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the
|
||||
* negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
if (s.size < 10) return
|
||||
val w = s[6].toInt()
|
||||
val h = s[7].toInt()
|
||||
val hz = s[8].toInt()
|
||||
val latValid = s[4] != 0.0
|
||||
val skew = s[5] != 0.0
|
||||
val dropped = s[9].toLong()
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
videoFeedLine(s)?.let { feed ->
|
||||
Text(
|
||||
feed,
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
if (latValid) {
|
||||
val tag = if (skew) "" else " (same-host)"
|
||||
Text(
|
||||
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
if (dropped > 0) {
|
||||
Text(
|
||||
"dropped $dropped",
|
||||
color = Color(0xFFFFB0B0),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
||||
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
||||
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
|
||||
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
|
||||
* Android decoder is always HEVC (`video/hevc`).
|
||||
*/
|
||||
private fun videoFeedLine(s: DoubleArray): String? {
|
||||
if (s.size < 14) return null
|
||||
val bitDepth = s[10].toInt()
|
||||
val primaries = s[11].toInt()
|
||||
val transfer = s[12].toInt()
|
||||
val chromaIdc = s[13].toInt()
|
||||
val depthLabel = if (bitDepth > 0) "$bitDepth-bit" else "8-bit"
|
||||
val (dynamicRange, colorSpace) = when (transfer) {
|
||||
16 -> "HDR" to "BT.2020 PQ"
|
||||
18 -> "HDR" to "BT.2020 HLG"
|
||||
else -> "SDR" to if (primaries == 9) "BT.2020" else "BT.709"
|
||||
}
|
||||
val chromaLabel = when (chromaIdc) {
|
||||
3 -> "4:4:4"
|
||||
2 -> "4:2:2"
|
||||
else -> "4:2:0"
|
||||
}
|
||||
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Touch-gesture tuning (px / ms). TAP_SLOP: movement under this still counts as a tap, not a drag.
|
||||
// TAP_DRAG_MS: a new touch within this long after a tap starts a left-button drag. SCROLL_DIV: px of
|
||||
// two-finger pan per wheel notch (smaller = faster scroll).
|
||||
private const val TAP_SLOP = 12f
|
||||
private const val TAP_DRAG_MS = 250L
|
||||
private const val SCROLL_DIV = 4f
|
||||
|
||||
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
||||
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
||||
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
||||
// px/ms, capped at ACCEL_MAX (so a fast swipe can't fling the cursor uncontrollably).
|
||||
private const val POINTER_SENS = 1.3f
|
||||
private const val ACCEL_GAIN = 0.6f
|
||||
private const val ACCEL_SPEED_FLOOR = 0.3f
|
||||
private const val ACCEL_MAX = 3.0f
|
||||
|
||||
/**
|
||||
* Touch → mouse, run inside the stream overlay's `pointerInput`. Two models, chosen by the
|
||||
* Trackpad-mode setting:
|
||||
* * trackpad (default): the cursor STAYS where it is on touch-down and moves by the finger's
|
||||
* relative delta (MouseMove) with mild pointer acceleration — swipe to nudge, lift and
|
||||
* re-swipe to walk it across, tap to click where it is. This is what makes the cursor
|
||||
* reachable on a small screen.
|
||||
* * direct (opt-out): the cursor jumps to the finger and follows it (MouseMoveAbs,
|
||||
* host-normalized against the overlay size), the old "direct pointing" behaviour.
|
||||
*
|
||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||
* windows); three-finger tap = [onToggleStats] (the stats HUD).
|
||||
*/
|
||||
internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
trackpad: Boolean,
|
||||
onToggleStats: () -> Unit,
|
||||
) {
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
var lastTapY = 0f
|
||||
fun moveAbs(x: Float, y: Float) {
|
||||
val sw = size.width
|
||||
val sh = size.height
|
||||
if (sw <= 0 || sh <= 0) return
|
||||
NativeBridge.nativeSendPointerAbs(
|
||||
handle,
|
||||
x.coerceIn(0f, (sw - 1).toFloat()).roundToInt(),
|
||||
y.coerceIn(0f, (sh - 1).toFloat()).roundToInt(),
|
||||
sw,
|
||||
sh,
|
||||
)
|
||||
}
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val startX = down.position.x
|
||||
val startY = down.position.y
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||
// button for this whole gesture (laptop-trackpad convention).
|
||||
val isDrag = down.uptimeMillis - lastTapUp < TAP_DRAG_MS &&
|
||||
abs(startX - lastTapX) < TAP_SLOP && abs(startY - lastTapY) < TAP_SLOP
|
||||
lastTapUp = 0L // consume the arming either way
|
||||
// Direct mode jumps the cursor to the finger; trackpad mode leaves it put (the
|
||||
// whole point — you nudge it with swipes instead).
|
||||
if (!trackpad) moveAbs(startX, startY)
|
||||
if (isDrag) NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||
|
||||
var moved = false
|
||||
var maxFingers = 1
|
||||
var scrolling = false
|
||||
var prevCx = startX
|
||||
var prevCy = startY
|
||||
var upTime = down.uptimeMillis
|
||||
// Trackpad relative-motion state: the tracked finger, its last position/time, and
|
||||
// the sub-pixel remainder so a slow drag isn't lost to Int truncation.
|
||||
var trackId = down.id
|
||||
var prevX = startX
|
||||
var prevY = startY
|
||||
var prevT = down.uptimeMillis
|
||||
var accX = 0f
|
||||
var accY = 0f
|
||||
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val pressed = ev.changes.filter { it.pressed }
|
||||
if (pressed.isEmpty()) {
|
||||
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
|
||||
break
|
||||
}
|
||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||
|
||||
if (pressed.size >= 2) {
|
||||
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||
if (!scrolling) {
|
||||
scrolling = true
|
||||
prevCx = cx
|
||||
prevCy = cy
|
||||
}
|
||||
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
|
||||
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
|
||||
if (sy != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
||||
prevCy = cy
|
||||
moved = true
|
||||
}
|
||||
if (sx != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
||||
prevCx = cx
|
||||
moved = true
|
||||
}
|
||||
} else if (!scrolling) {
|
||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||
// back to one finger doesn't jerk the cursor).
|
||||
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
|
||||
if (abs(p.position.x - startX) > TAP_SLOP ||
|
||||
abs(p.position.y - startY) > TAP_SLOP
|
||||
) {
|
||||
moved = true
|
||||
}
|
||||
if (trackpad) {
|
||||
// Relative: move by the finger delta × (sensitivity × acceleration),
|
||||
// carrying the sub-pixel remainder. Re-anchor (zero delta this frame)
|
||||
// if the tracked finger changed, so lifting one of several fingers
|
||||
// never jumps the cursor.
|
||||
if (p.id != trackId) {
|
||||
trackId = p.id
|
||||
prevX = p.position.x
|
||||
prevY = p.position.y
|
||||
prevT = p.uptimeMillis
|
||||
}
|
||||
val dx = p.position.x - prevX
|
||||
val dy = p.position.y - prevY
|
||||
val dt = (p.uptimeMillis - prevT).coerceAtLeast(1L)
|
||||
prevX = p.position.x
|
||||
prevY = p.position.y
|
||||
prevT = p.uptimeMillis
|
||||
val speed = hypot(dx, dy) / dt // finger px per ms
|
||||
val accel = (1f + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).coerceAtLeast(0f))
|
||||
.coerceAtMost(ACCEL_MAX)
|
||||
accX += dx * POINTER_SENS * accel
|
||||
accY += dy * POINTER_SENS * accel
|
||||
val outX = accX.toInt() // truncates toward zero → remainder kept w/ sign
|
||||
val outY = accY.toInt()
|
||||
if (outX != 0 || outY != 0) {
|
||||
NativeBridge.nativeSendPointerMove(handle, outX, outY)
|
||||
accX -= outX
|
||||
accY -= outY
|
||||
}
|
||||
} else {
|
||||
moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
|
||||
}
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
}
|
||||
|
||||
if (isDrag) {
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||
} else if (!moved) {
|
||||
when {
|
||||
maxFingers >= 3 -> onToggleStats() // in-stream HUD toggle
|
||||
maxFingers == 2 -> { // two-finger tap → right click
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||
}
|
||||
else -> { // tap → left click (at the cursor's current spot), arm tap-drag
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
||||
lastTapUp = upTime
|
||||
lastTapX = startX
|
||||
lastTapY = startY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user