diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index 7833c9e..daf5768 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -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) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt index 62a25dc..bf4d35d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt @@ -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(