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)
|
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 {
|
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)
|
return super.dispatchGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.unom.punktfunk.components
|
package io.unom.punktfunk.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
@@ -28,6 +30,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -57,12 +60,23 @@ fun HostCard(
|
|||||||
onConnect: () -> Unit,
|
onConnect: () -> Unit,
|
||||||
onForget: (() -> 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(
|
ElevatedCard(
|
||||||
onClick = onConnect,
|
onClick = onConnect,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.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()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
Reference in New Issue
Block a user