feat(android): native mDNS discovery, host naming, touch mouse, stock selects
apple / swift (push) Successful in 1m1s
android / android (push) Successful in 4m14s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 54s
windows-host / package (push) Successful in 5m45s
ci / rust (push) Successful in 6m1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
release / apple (push) Successful in 7m45s
deb / build-publish (push) Successful in 2m40s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m9s
ci / bench (push) Successful in 4m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m56s
apple / screenshots (push) Successful in 5m22s
flatpak / build-publish (push) Successful in 6m32s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m32s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m47s
audit / cargo-audit (push) Failing after 1m13s

Discovery: replace the flaky per-OEM NsdManager with the same mdns-sd browse
the Linux/Windows clients use, in the Rust core over JNI and polled by Kotlin
(discovery.rs + nativeDiscovery{Start,Poll,Stop}); Kotlin keeps only the Wi-Fi
MulticastLock + permission UX. IPv4-only (the core can't dial a bare/scoped v6
literal); daemon + fold-thread cleanup on every failure path; field
sanitization so a rogue advert can't corrupt the picker snapshot. Discovery
now starts regardless of NEARBY_WIFI_DEVICES (raw multicast only needs the
MulticastLock) — a denial no longer kills it forever. ParseTxtTest replaced by
ParseRecordTest.

Hosts: hide already-saved hosts from the "Discovered" section (match by
fingerprint, else address:port — mirrors the Apple client); add an optional
Name field to the Add-host sheet and a Rename action on saved cards.

Input: touch -> absolute mouse "direct pointing" like the Apple client — the
host cursor follows the finger (new nativeSendPointerAbs -> MouseMoveAbs). Tap
= left click, two-finger tap = right click, two-finger drag = scroll,
tap-then-drag = left-drag, three-finger tap = HUD toggle.

Settings: revert the dropdowns to the stock ExposedDropdownMenuBox look (a
controller-focus UI will come separately); even out the Add-host field gaps.

Docs updated (CLAUDE.md, client READMEs, docs-site status).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:48:45 +02:00
parent de232ec2f7
commit 095540efc2
18 changed files with 782 additions and 306 deletions
@@ -4,11 +4,13 @@
<!-- punktfunk/1 QUIC/UDP data plane. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- mDNS discovery of _punktfunk._udp on the LAN (NsdManager). -->
<!-- mDNS discovery of _punktfunk._udp on the LAN (native mdns-sd browse). Requested
opportunistically — raw multicast reception needs only the MulticastLock, not this. -->
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Hold a MulticastLock while NsdManager discovery runs (OEM Wi-Fi power-save hedge). -->
<!-- HostDiscovery holds a MulticastLock while the native mDNS browse runs — raw multicast
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.ACCESS_WIFI_STATE" />
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
@@ -84,30 +84,33 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var host by remember { mutableStateOf("") }
var hostName by remember { mutableStateOf("") }
var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
// The host streams at exactly this mode; "Native" settings resolve from the device display.
val (w, h, hz) = settings.effectiveMode(context)
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.)
// NsdManager discovery needs NEARBY_WIFI_DEVICES on Android 13+ (a runtime permission) — without
// it discoverServices silently finds nothing. Request it once, then (re)start discovery on grant.
// mDNS discovery scoped to this screen, via the native mdns-sd browse (HostDiscovery) — its
// onChange fires on the main thread, so it can set Compose state directly. (Emulator SLIRP drops
// multicast → empty; that's the network, not the API.) Raw multicast reception only needs the
// Wi-Fi MulticastLock (HostDiscovery holds it), NOT NEARBY_WIFI_DEVICES — that gated the old
// NsdManager path. We still request NEARBY_WIFI_DEVICES opportunistically (some OEMs filter
// multicast without it; harmless where it isn't), but never block discovery on the grant — a
// denial used to leave discovery dead forever.
val discovery = remember { HostDiscovery(context) }
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
var nearbyGranted by remember { mutableStateOf(hasNearbyPermission(context)) }
val nearbyLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> nearbyGranted = granted }
) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
LaunchedEffect(Unit) {
if (!nearbyGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
}
}
DisposableEffect(nearbyGranted) {
DisposableEffect(Unit) {
discovery.onChange = { discovered = it }
if (nearbyGranted) discovery.start()
discovery.start()
onDispose {
discovery.onChange = null
discovery.stop()
@@ -127,6 +130,13 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
// A saved host whose label is being edited (the Rename dialog).
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
// 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
@@ -176,10 +186,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) {
fun connect(
targetHost: String,
targetPort: Int,
dh: DiscoveredHost? = null,
manualName: String? = null,
) {
val known = knownHostStore.get(targetHost, targetPort)
val adv = dh?.fingerprint?.lowercase()
val name = dh?.name ?: targetHost
// Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
// mDNS name; else the name typed in the Add-host sheet; else the bare address.
val name = known?.name ?: dh?.name ?: manualName?.trim()?.takeIf { it.isNotEmpty() } ?: targetHost
when {
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
known != null && (adv == null || adv == known.fpHex) ->
@@ -260,7 +277,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
}
if (savedHosts.isEmpty() && discovered.isEmpty()) {
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyHostsState()
}
@@ -281,16 +298,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all()
},
onRename = { renameTarget = kh },
)
}
}
if (discovered.isNotEmpty()) {
if (discoveredUnsaved.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp))
SectionLabel("Discovered on the network")
}
items(discovered, key = { "disc-${it.host}-${it.port}" }) { dh ->
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
HostCard(
name = dh.name,
address = "${dh.host}:${dh.port}",
@@ -302,9 +320,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
}
// Active-discovery hint: when we're scanning but nothing's turned up yet, show it's
// working rather than looking idle/empty.
if (nearbyGranted && discovered.isEmpty()) {
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
// rather than looking idle/empty.
if (!connecting && discovered.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
@@ -363,6 +382,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
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 },
@@ -370,7 +398,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = port,
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
@@ -385,9 +413,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
onClick = {
val h = host.trim()
val p = port.toIntOrNull() ?: 9777
val n = hostName
scope.launch { sheetState.hide() }.invokeOnCompletion {
showManualSheet = false
connect(h, p)
connect(h, p, manualName = n)
}
},
modifier = Modifier.fillMaxWidth(),
@@ -507,10 +536,57 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
}
}
// 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") }
},
)
}
}
/** NsdManager discovery needs NEARBY_WIFI_DEVICES on API 33+; below that it doesn't apply. */
/**
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
* the native core + MulticastLock) does not depend on it.
*/
fun hasNearbyPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED
/**
* True when a saved host and a discovered advert are the same machine — matched by certificate
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
* Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts".
*/
private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
val advFp = dh.fingerprint?.lowercase()
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
return address == dh.host && port == dh.port
}
@@ -5,9 +5,7 @@ import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
@@ -16,14 +14,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -33,7 +31,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -174,12 +171,8 @@ private fun ToggleRow(
}
}
/**
* A labelled value that opens a menu on click. Uses a clickable [Surface] + [DropdownMenu] rather
* than `ExposedDropdownMenuBox` — that component's read-only text field traps D-pad / controller
* focus (directional keys never leave it), so you can't navigate past it on a TV. Calls [onSelect]
* on a pick. A primary-colour border marks D-pad focus.
*/
/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> SettingDropdown(
label: String,
@@ -188,35 +181,20 @@ private fun <T> SettingDropdown(
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
var focused by remember { mutableStateOf(false) }
val selectedLabel = options.firstOrNull { it.first == selected }?.second
?: options.firstOrNull()?.second.orEmpty()
Box(modifier = Modifier.fillMaxWidth()) {
Surface(
onClick = { expanded = true },
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
border = if (focused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focused = it.isFocused },
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(selectedLabel, style = MaterialTheme.typography.bodyLarge)
}
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (value, lbl) ->
DropdownMenuItem(
text = { Text(lbl) },
@@ -26,7 +26,6 @@ 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.input.pointer.positionChange
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
@@ -44,6 +43,13 @@ import kotlinx.coroutines.delay
import kotlin.math.abs
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
@Composable
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val context = LocalContext.current
@@ -139,41 +145,108 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (showStats) {
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
}
// Touch virtual-trackpad overlay: 1-finger drag → relative mouse move; tap → left click;
// 2-finger drag → scroll; 3-finger tap → toggle the stats HUD. (Physical-mouse pointer
// capture comes in a later increment.)
// Touch → mouse, absolute "direct pointing" like the Apple client: the host cursor follows
// your finger (MouseMoveAbs, host-normalized against the overlay size — which fills the video,
// so finger position maps straight onto the remote screen). Gestures: 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.
Box(
Modifier.fillMaxSize().pointerInput(handle) {
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 first = awaitFirstDown(requireUnconsumed = false)
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
moveAbs(startX, startY) // cursor jumps to the finger immediately
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
while (true) {
val ev = awaitPointerEvent()
val fingers = ev.changes.count { it.pressed }
if (fingers == 0) break
if (fingers > maxFingers) maxFingers = fingers
val primary = ev.changes.firstOrNull { it.id == first.id } ?: ev.changes.first()
val d = primary.positionChange()
if (abs(d.x) > 0.5f || abs(d.y) > 0.5f) {
moved = true
if (fingers >= 2) {
// screen +y down → wire +up, so negate y. Coarse divisor; tune live.
val sy = (-d.y / 4f).toInt()
val sx = (d.x / 4f).toInt()
if (sy != 0) NativeBridge.nativeSendScroll(handle, 0, sy * 120)
if (sx != 0) NativeBridge.nativeSendScroll(handle, 1, sx * 120)
} else {
NativeBridge.nativeSendPointerMove(handle, d.x.toInt(), d.y.toInt())
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 → the cursor follows it (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
}
moveAbs(p.position.x, p.position.y)
}
ev.changes.forEach { it.consume() }
}
if (!moved && maxFingers == 1) {
NativeBridge.nativeSendPointerButton(handle, 1, true)
NativeBridge.nativeSendPointerButton(handle, 1, false)
} else if (!moved && maxFingers >= 3) {
showStats = !showStats // quick in-stream HUD toggle
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, and arm tap-and-drag
NativeBridge.nativeSendPointerButton(handle, 1, true)
NativeBridge.nativeSendPointerButton(handle, 1, false)
lastTapUp = upTime
lastTapX = startX
lastTapY = startY
}
}
}
}
},
@@ -49,7 +49,7 @@ fun SectionLabel(text: String) {
/**
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
* saved hosts) an overflow menu with Forget. Tapping the card connects.
* saved hosts) an overflow menu with Rename / Forget. Tapping the card connects.
*/
@Composable
fun HostCard(
@@ -59,6 +59,7 @@ fun HostCard(
enabled: Boolean,
onConnect: () -> Unit,
onForget: (() -> Unit)?,
onRename: (() -> Unit)? = null,
) {
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
@@ -106,7 +107,7 @@ fun HostCard(
StatusPill(status)
}
if (onForget != null) {
if (onForget != null || onRename != null) {
var menu by remember { mutableStateOf(false) }
Box(modifier = Modifier.align(Alignment.TopEnd)) {
IconButton(enabled = enabled, onClick = { menu = true }) {
@@ -118,13 +119,24 @@ fun HostCard(
)
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Forget") },
onClick = {
menu = false
onForget()
},
)
if (onRename != null) {
DropdownMenuItem(
text = { Text("Rename") },
onClick = {
menu = false
onRename()
},
)
}
if (onForget != null) {
DropdownMenuItem(
text = { Text("Forget") },
onClick = {
menu = false
onForget()
},
)
}
}
}
}