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
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user