feat(android): D-pad / game-controller focus navigation (TV + phone)
apple / swift (push) Successful in 54s
windows-host / package (push) Failing after 2m14s
android / android (push) Has been cancelled
ci / web (push) Successful in 29s
ci / docs-site (push) Failing after 17s
ci / rust (push) Successful in 4m35s
ci / bench (push) Failing after 4m33s
decky / build-publish (push) Successful in 13s
deb / build-publish (push) Successful in 3m10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 32s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 7m11s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m12s
docker / deploy-docs (push) Successful in 9s

Make a controller drive the Compose UI when not streaming, so the menus work on a TV
remote AND on a controller paired to a phone:
- MainActivity maps gamepad face buttons to the keys Compose's focus system
  understands (A -> DPAD_CENTER to activate, B -> BACK); D-pad *keys* already move
  focus and pass through untouched.
- For controllers whose D-pad reports as HAT axes (or to navigate with the left
  stick), dispatchGenericMotionEvent converts AXIS_HAT_X/Y / AXIS_X/Y into discrete
  D-pad key events, edge-detected so a held direction moves focus exactly once.
- HostCard draws a clear primary-colour focus border (the default state layer is too
  subtle across a room on TV).

All gated on "not streaming" -- during a stream the controller still forwards to the
host unchanged. Compile-verified (./gradlew :app:assembleDebug); the focus behaviour
itself needs on-device validation (no KVM here for a TV emulator).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 23:14:29 +00:00
parent 112a054c35
commit af9bb54785
2 changed files with 60 additions and 2 deletions
@@ -79,12 +79,56 @@ class MainActivity : ComponentActivity() {
}
}
}
} else if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
// buttons to the navigation keys the focus system understands; D-pad *keys* already move
// focus on their own, so they fall through to super untouched.
val mapped = when (event.keyCode) {
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss
else -> 0
}
if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped))
}
return super.dispatchKeyEvent(event)
}
/** Last D-pad direction synthesised from a stick/HAT — edge detection (one focus move per push). */
private var lastNavDir = 0
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (streamHandle != 0L && axisMapper?.onMotion(event) == true) return true
if (streamHandle != 0L) {
if (axisMapper?.onMotion(event) == true) return true
return super.dispatchGenericMotionEvent(event)
}
// Not streaming: turn the gamepad HAT / left stick into discrete D-pad focus moves, so a
// controller navigates the menus even when its D-pad reports as axes (not key events) and
// for stick-based navigation. Edge-detected so a held direction moves focus exactly once.
if (event.isFromSource(InputDevice.SOURCE_JOYSTICK) ||
event.isFromSource(InputDevice.SOURCE_GAMEPAD)
) {
val x = event.getAxisValue(MotionEvent.AXIS_HAT_X)
.let { if (it != 0f) it else event.getAxisValue(MotionEvent.AXIS_X) }
val y = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
.let { if (it != 0f) it else event.getAxisValue(MotionEvent.AXIS_Y) }
val dir = when {
x <= -0.5f -> KeyEvent.KEYCODE_DPAD_LEFT
x >= 0.5f -> KeyEvent.KEYCODE_DPAD_RIGHT
y <= -0.5f -> KeyEvent.KEYCODE_DPAD_UP
y >= 0.5f -> KeyEvent.KEYCODE_DPAD_DOWN
else -> 0
}
if (dir != lastNavDir) {
lastNavDir = dir
if (dir != 0) {
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
return true
}
} else if (dir != 0) {
return true // already moved for this push; swallow until the stick returns to centre
}
}
return super.dispatchGenericMotionEvent(event)
}
}
@@ -1,6 +1,7 @@
package io.unom.punktfunk.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
@@ -28,6 +30,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -57,12 +60,23 @@ fun HostCard(
onConnect: () -> Unit,
onForget: (() -> Unit)?,
) {
// 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.
var focused by remember { mutableStateOf(false) }
ElevatedCard(
onClick = onConnect,
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
.padding(4.dp)
.onFocusChanged { focused = it.isFocused }
.then(
if (focused) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CardDefaults.elevatedShape)
} else {
Modifier
},
),
) {
Box(modifier = Modifier.fillMaxWidth()) {
Column(