feat(android): console UI — per-controller glyphs, dialog scrolling, animated forms

- Hint-bar glyphs now wear the driving controller's family (kit
  Gamepad.styleFor by USB vendor id → MainActivity.lastPadStyle, kept live by
  real input like lastPadIsGamepad): PlayStation pads get Canvas-drawn
  cross/circle/square/triangle shapes in the classic colours, Nintendo pads
  monochrome lettering, Xbox/Valve/unknown the coloured letter discs. Hint
  chars stay semantic (KEYCODE_BUTTON names); only the rendering changes.
- The Options legend renders the pad's real Select-family button
  (SelectButtonGlyph): Xbox View windows, PlayStation Create capsule,
  Nintendo minus — instead of a bare capsule outline.
- GamepadDialog: body + action stack scroll together (title pinned) with
  BringIntoViewRequester keeping the focused button visible — a 5-action host
  options dialog compressed/clipped its last button in short landscape
  windows because the pinned stack could not scroll.
- Console form polish: shared animateConsoleFocus (bg/border cross-fade +
  spring scale) across settings rows / add-host fields / action rows;
  ConsoleSwitch (spring knob, tinting track) replaces On/Off text on toggle
  rows; choice values slide in the direction they were stepped
  (AnimatedContent + SizeTransform) with chevrons that fade in place; the
  focused row's detail unfolds via AnimatedVisibility; dialog buttons and
  keyboard keycaps cross-fade (keycaps at 90 ms for hold-to-repeat).
- Console settings gain the "Low-latency mode" (Video) and "Auto-wake on
  connect" (Interface) rows, round-tripping with the touch settings.
- Screenshot scene: StatsOverlay call updated to the 18-double layout + the
  new decoderLabel parameter (fixes the android-screenshots CI compile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 07:34:42 +02:00
parent e490564316
commit f992298da8
7 changed files with 412 additions and 76 deletions
@@ -2,7 +2,8 @@ package io.unom.punktfunk
import android.content.res.Configuration import android.content.res.Configuration
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -361,15 +362,15 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
@Composable @Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) { private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale") val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
val shape = RoundedCornerShape(14.dp) val shape = RoundedCornerShape(14.dp)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale } .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape) .clip(shape)
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF)) .background(visuals.background)
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) .border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp), .padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -389,15 +390,20 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
@Composable @Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) { private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale") val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp) val shape = RoundedCornerShape(14.dp)
val labelColor by animateColorAsState(
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
tween(160),
label = "addLabel",
)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale } .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape) .clip(shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) .background(visuals.background)
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) .border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(vertical = 14.dp), .padding(vertical = 14.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -406,7 +412,7 @@ private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onCl
label, label,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f), color = labelColor,
) )
} }
} }
@@ -448,11 +454,19 @@ private fun KeyboardGrid(
@Composable @Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
val bg by animateColorAsState(
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
tween(90),
label = "keyBg",
)
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
Box( Box(
modifier = modifier modifier = modifier
.height(if (compact) 34.dp else 44.dp) .height(if (compact) 34.dp else 44.dp)
.clip(RoundedCornerShape(9.dp)) .clip(RoundedCornerShape(9.dp))
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF)) .background(bg)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick), .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -460,7 +474,7 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
label, label,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = if (focused) Color.Black else Color.White, color = fg,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
@@ -1,10 +1,14 @@
package io.unom.punktfunk package io.unom.punktfunk
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -31,20 +36,28 @@ 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.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import io.unom.punktfunk.kit.Gamepad
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sin import kotlin.math.sin
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's // The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
@@ -189,9 +202,12 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
} }
/** /**
* One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue. * One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android
* [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working * `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is
* controller can still drive the console UI (and reach Settings to switch it off). * actually DRAWN is the hint bar's decision, per the driving controller's [Gamepad.PadStyle] — a
* DualSense renders 'A' as the ✕ shape, a Switch pad as a monochrome letter. [onClick], when set,
* makes the cell tappable — a TOUCH escape hatch so a user without a working controller can still
* drive the console UI (and reach Settings to switch it off).
*/ */
class GamepadHint( class GamepadHint(
val glyph: Char, val glyph: Char,
@@ -201,11 +217,16 @@ class GamepadHint(
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc — // Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
// for a TV remote, which has no A/B/X/Y. // for a TV remote, which has no A/B/X/Y.
val select: Boolean = false, val select: Boolean = false,
// Render as the gamepad Select/View button (a small capsule). // Render as the pad's physical Select/View/Create/ button (per PadStyle) — the button that
// delivers KEYCODE_BUTTON_SELECT.
val viewButton: Boolean = false, val viewButton: Boolean = false,
) )
/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */ /**
* Xbox-convention face-button colours, so the glyphs read at a glance across the room. These are
* the DEFAULT (Xbox/generic) rendering; the hint bar swaps in PlayStation shapes or Nintendo
* monochrome per the driving pad's [Gamepad.PadStyle] at draw time.
*/
object PadGlyph { object PadGlyph {
val A = Color(0xFF6BBE45) val A = Color(0xFF6BBE45)
val B = Color(0xFFD14B4B) val B = Color(0xFFD14B4B)
@@ -216,6 +237,87 @@ object PadGlyph {
) )
} }
/** The dark button-face fill shared by the PlayStation / Nintendo / select-button badges. */
internal val PadButtonFace = Color(0xFF2A2740)
/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */
class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: Color)
/**
* The focus visuals every console form element shares (settings rows, add-host fields, action
* rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and
* resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border
* of a field actively receiving keyboard input.
*/
@Composable
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
val scale by animateFloatAsState(
targetValue = if (active) 1f else 0.98f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "consoleScale",
)
val background by animateColorAsState(
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
tween(160),
label = "consoleBg",
)
val border by animateColorAsState(
when {
editing -> Color(0xB38678F5)
active -> Color.White.copy(alpha = 0.28f)
else -> Color.White.copy(alpha = 0.06f)
},
tween(160),
label = "consoleBorder",
)
return ConsoleFocusVisuals(scale, background, border)
}
/**
* The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet
* track that tints as it engages while the knob slides across on a spring — the state change reads
* from across the room, and the motion confirms the press.
*/
@Composable
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
val travel by animateFloatAsState(
targetValue = if (on) 1f else 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
label = "switchKnob",
)
val track by animateColorAsState(
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
tween(200),
label = "switchTrack",
)
val outline by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
tween(160),
label = "switchOutline",
)
val trackW = 44.dp
val trackH = 24.dp
val pad = 3.dp
val knob = trackH - pad * 2
Box(
modifier
.size(trackW, trackH)
.clip(RoundedCornerShape(50))
.background(track)
.border(1.dp, outline, RoundedCornerShape(50)),
contentAlignment = Alignment.CenterStart,
) {
Box(
Modifier
.padding(horizontal = pad)
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
.size(knob)
.clip(CircleShape)
.background(Color.White),
)
}
}
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */ /** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
@Composable @Composable
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) { fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
@@ -253,16 +355,94 @@ private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
GamepadButtonGlyph('↩', PadGlyph.B, size) GamepadButtonGlyph('↩', PadGlyph.B, size)
} }
/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */ /**
* A PlayStation face button: the dark button face with the coloured shape outline Sony prints on it.
* Keyed by the SEMANTIC letter (Android keycode name): A = ✕ cross, B = ○ circle, X = □ square,
* Y = △ triangle — exactly how a Sony pad's buttons map to `KEYCODE_BUTTON_*`, in the classic
* DualShock colours.
*/
@Composable @Composable
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) {
Box(Modifier.size(size), contentAlignment = Alignment.Center) { val color = when (glyph) {
Box( 'A' -> Color(0xFF7C9CE8) // cross — light blue
Modifier 'B' -> Color(0xFFE0736F) // circle — red
.size(width = size * 0.74f, height = size * 0.46f) 'X' -> Color(0xFFD48FC7) // square — pink
.clip(RoundedCornerShape(50)) else -> Color(0xFF5FBFA5) // triangle — green
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)), }
) Box(
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
) {
Canvas(Modifier.size(size * 0.46f)) {
val w = this.size.minDimension
val stroke = Stroke(width = w * 0.17f, cap = StrokeCap.Round, join = StrokeJoin.Round)
when (glyph) {
'A' -> { // ✕ — the two diagonals
drawLine(color, Offset(0f, 0f), Offset(w, w), stroke.width, StrokeCap.Round)
drawLine(color, Offset(w, 0f), Offset(0f, w), stroke.width, StrokeCap.Round)
}
'B' -> drawCircle(color, radius = (w - stroke.width) / 2f, style = stroke)
'X' -> drawRect(
color,
topLeft = Offset(stroke.width / 2f, stroke.width / 2f),
size = Size(w - stroke.width, w - stroke.width),
style = stroke,
)
else -> { // △
val p = Path().apply {
moveTo(w / 2f, stroke.width / 2f)
lineTo(w - stroke.width / 2f, w - stroke.width / 2f)
lineTo(stroke.width / 2f, w - stroke.width / 2f)
close()
}
drawPath(p, color, style = stroke)
}
}
}
}
}
/**
* The pad's physical Select-family button — the one that delivers `KEYCODE_BUTTON_SELECT` and opens
* Options — drawn per [Gamepad.PadStyle] as a badge with the button's real face: Xbox View (two
* overlapping windows), PlayStation Create/Share (a slim capsule), Nintendo (minus). The generic
* fallback wears the capsule too (the near-universal select shape).
*/
@Composable
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
Box(
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
) {
when (style) {
Gamepad.PadStyle.XBOX -> Box(Modifier.size(size * 0.50f)) {
// The View icon: two overlapping outlined windows; the front one is filled with the
// button face so it visibly occludes the back one.
val corner = RoundedCornerShape(2.dp)
Box(
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
Box(
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
.clip(corner).background(PadButtonFace)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
}
Gamepad.PadStyle.NINTENDO -> Text(
"",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.62f).sp,
textAlign = TextAlign.Center,
)
else -> Box(
Modifier
.size(width = size * 0.58f, height = size * 0.30f)
.clip(RoundedCornerShape(50))
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
)
}
} }
} }
@@ -274,8 +454,12 @@ private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) { fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses: // On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the // A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
// home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests). // home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true // PlayStation shapes / Nintendo monochrome) from the controller that last drove the UI.
// Defaults to the generic gamepad look off an Activity (preview/tests).
val activity = LocalContext.current as? MainActivity
val padIsGamepad = activity?.lastPadIsGamepad ?: true
val padStyle = activity?.lastPadStyle ?: Gamepad.PadStyle.GENERIC
val shape = RoundedCornerShape(50) val shape = RoundedCornerShape(50)
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent // With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
// scrim below) + a light tint; otherwise fall back to a solid frosted fill. // scrim below) + a light tint; otherwise fall back to a solid frosted fill.
@@ -300,9 +484,13 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
} }
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) { Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
when { when {
h.viewButton -> ViewButtonGlyph() h.viewButton -> SelectButtonGlyph(padStyle)
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph() h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
!padIsGamepad && h.glyph == 'B' -> BackGlyph() !padIsGamepad && h.glyph == 'B' -> BackGlyph()
padStyle == Gamepad.PadStyle.PLAYSTATION && h.glyph in "ABXY" ->
PsFaceGlyph(h.glyph)
padStyle == Gamepad.PadStyle.NINTENDO && h.glyph in "ABXY" ->
GamepadButtonGlyph(h.glyph, PadButtonFace)
else -> GamepadButtonGlyph(h.glyph, h.color) else -> GamepadButtonGlyph(h.glyph, h.color)
} }
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
@@ -2,7 +2,12 @@ package io.unom.punktfunk
import android.os.Build import android.os.Build
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -19,6 +24,8 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -26,6 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@@ -90,8 +98,11 @@ fun GamepadDialog(
}, },
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() }, onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
) )
// Cap the card to most of the screen and let the BODY scroll — in a short landscape window the // Cap the card to most of the screen and let body + BUTTONS scroll together — in a short
// title + body + buttons would otherwise overflow and compress/clip the bottom button. // landscape window a 5-action stack (host options) exceeds the card even with an empty body, and
// a pinned actions column can only compress/clip its last button. Only the title stays pinned;
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
// the current action even when the stack scrolls.
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box( Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
@@ -109,43 +120,66 @@ fun GamepadDialog(
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White) Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
// The body scrolls; the title above and the buttons below stay pinned + always visible.
Column( Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
body() body()
} Spacer(Modifier.size(4.dp))
Spacer(Modifier.size(4.dp)) actions.forEachIndexed { i, a ->
actions.forEachIndexed { i, a -> DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick) }
} }
} }
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) { private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale") val scale by animateFloatAsState(
if (focused) 1.02f else 1f,
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "btnScale",
)
// The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button
// that's scrolled out of a short window, pull it into view (no-op when already visible).
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp) val shape = RoundedCornerShape(14.dp)
val bg = when { // Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
focused -> Color(0xFF6656F2) val bg by animateColorAsState(
primary -> Color(0x336656F2) when {
else -> Color(0x14FFFFFF) focused -> Color(0xFF6656F2)
} primary -> Color(0x336656F2)
val fg = when { else -> Color(0x14FFFFFF)
!enabled -> Color.White.copy(alpha = 0.35f) },
focused -> Color.White tween(160),
primary -> Color(0xFF8678F5) label = "btnBg",
else -> Color.White.copy(alpha = 0.85f) )
} val fg by animateColorAsState(
when {
!enabled -> Color.White.copy(alpha = 0.35f)
focused -> Color.White
primary -> Color(0xFF8678F5)
else -> Color.White.copy(alpha = 0.85f)
},
tween(160),
label = "btnFg",
)
val borderColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
tween(160),
label = "btnBorder",
)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = scale; scaleY = scale } .graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape) .clip(shape)
.background(bg) .background(bg)
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape) .border(1.dp, borderColor, shape)
.clickable( .clickable(
enabled = enabled, enabled = enabled,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@@ -2,7 +2,19 @@ package io.unom.punktfunk
import android.content.res.Configuration import android.content.res.Configuration
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -57,6 +69,7 @@ private class GpRow(
val detail: String, val detail: String,
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
val activate: () -> Unit, // A → cycle forward (wrapping) / flip val activate: () -> Unit, // A → cycle forward (wrapping) / flip
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
) )
@Composable @Composable
@@ -72,6 +85,9 @@ fun GamepadSettingsScreen(
val rows = buildSettingsRows(s, ::update) val rows = buildSettingsRows(s, ::update)
var focus by remember { mutableIntStateOf(0) } var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
// value text slides in its AnimatedContent, so the motion matches the button press.
var adjustDir by remember { mutableIntStateOf(1) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
@@ -83,11 +99,11 @@ fun GamepadSettingsScreen(
when (dir) { when (dir) {
NavDir.UP -> if (focus > 0) focus-- NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++ NavDir.DOWN -> if (focus < rows.lastIndex) focus++
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1) NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1) NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
} }
}, },
onActivate = { rows.getOrNull(focus)?.activate() }, onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
) )
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the // Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it. // screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
@@ -121,8 +137,8 @@ fun GamepadSettingsScreen(
ConsoleHeader("Settings", horizontalInset = false) ConsoleHeader("Settings", horizontalInset = false)
} }
itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(row, focused = index == focus, onClick = { SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
if (focus == index) row.activate() else focus = index if (focus == index) { adjustDir = 1; row.activate() } else focus = index
}) })
} }
} }
@@ -150,9 +166,17 @@ fun GamepadSettingsScreen(
} }
@Composable @Composable
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) { private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale") val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp) val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
// focus arrives; the value colour cross-fades with them.
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
val valueColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 1f else 0.6f),
tween(160),
label = "valueColor",
)
Column { Column {
if (row.header != null) { if (row.header != null) {
Text( Text(
@@ -166,10 +190,10 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale } .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape) .clip(shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) .background(visuals.background)
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) .border(1.dp, visuals.border, shape)
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
@@ -186,19 +210,41 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
maxLines = 1, maxLines = 1,
) )
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (focused) Text(" ", color = Color.White.copy(alpha = 0.6f)) if (row.toggled != null) {
Text( // A toggle is a switch, not text — the sliding knob + tinting track IS the value.
row.value, ConsoleSwitch(on = row.toggled, focused = focused)
style = MaterialTheme.typography.bodyMedium, } else {
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f), Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
maxLines = 1, // The value slides in the direction it was stepped and its width animates, so
overflow = TextOverflow.Ellipsis, // cycling a choice reads as motion through a list rather than a text swap.
) AnimatedContent(
if (focused) Text(" ", color = Color.White.copy(alpha = 0.6f)) targetState = row.value,
transitionSpec = {
val dir = adjustDir
(slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith
(slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using
SizeTransform(clip = false)
},
label = "value",
) { value ->
Text(
value,
style = MaterialTheme.typography.bodyMedium,
color = valueColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
}
} }
// The focused row carries its own one-line description — no dedicated (space-eating) // The focused row carries its own one-line description — no dedicated (space-eating)
// detail strip. It appears right where you're looking, and the row grows to fit. // detail strip. It unfolds right where you're looking, and the row grows to fit.
if (focused && row.detail.isNotBlank()) { AnimatedVisibility(
visible = focused && row.detail.isNotBlank(),
enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
) {
Text( Text(
row.detail, row.detail,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -245,6 +291,7 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
detail = detail, detail = detail,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false }, adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) }, activate = { write(!value) },
toggled = value,
) )
return listOf( return listOf(
@@ -278,6 +325,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
"HDR10 — engages when the host sends HDR content and this display supports it.", "HDR10 — engages when the host sends HDR content and this display supports it.",
s.hdrEnabled, s.hdrEnabled,
) { update(s.copy(hdrEnabled = it)) }, ) { update(s.copy(hdrEnabled = it)) },
toggle(
"lowLatency", null, "Low-latency mode",
"Experimental — aggressive decoder and system tuning. Turn off if the stream stutters or glitches.",
s.lowLatencyMode,
) { update(s.copy(lowLatencyMode = it)) },
choice( choice(
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.", "audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
@@ -304,6 +356,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
"Browse a paired host's games with Y (experimental).", "Browse a paired host's games with Y (experimental).",
s.libraryEnabled, s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) }, ) { update(s.copy(libraryEnabled = it)) },
toggle(
"autoWake", null, "Auto-wake on connect",
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
s.autoWakeEnabled,
) { update(s.copy(autoWakeEnabled = it)) },
toggle( toggle(
"gamepadUI", null, "Controller-optimized UI", "gamepadUI", null, "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.", "Turn off to use the touch interface even with a controller connected.",
@@ -59,12 +59,22 @@ class MainActivity : ComponentActivity() {
var lastPadIsGamepad by mutableStateOf(true) var lastPadIsGamepad by mutableStateOf(true)
private set private set
/**
* The glyph family of the controller driving the console UI (Xbox letters / PlayStation shapes /
* Nintendo monochrome) — seeded from the first connected pad, then kept live by real input the
* same way [lastPadIsGamepad] is. Compose observes it (a snapshot state); the hint bar picks its
* button glyphs from it so a DualSense user isn't shown Xbox lettering.
*/
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
private set
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */ /** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
private var highRefreshModeId = 0 private var highRefreshModeId = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
lastPadIsGamepad = !isTvDevice(this) lastPadIsGamepad = !isTvDevice(this)
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
resolveHighRefreshMode() resolveHighRefreshMode()
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so // Dark, transparent system bars regardless of the system theme — our UI is always dark, so
@@ -159,9 +169,11 @@ class MainActivity : ComponentActivity() {
} }
} else { } else {
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's // Note which input the console UI is being driven by, so its glyphs match (a TV remote's
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are). // D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) { if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD) lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
} }
// The Controllers debug screen sees pad events before the navigation remap below. // The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true } padKeyProbe?.let { if (it(event)) return true }
@@ -217,6 +229,7 @@ class MainActivity : ComponentActivity() {
lastNavDir = dir lastNavDir = dir
if (dir != 0) { if (dir != 0) {
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
lastPadStyle = Gamepad.styleFor(event.device)
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir)) super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir)) super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
return true return true
@@ -187,12 +187,19 @@ internal fun StreamScene() {
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))), Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
), ),
) { ) {
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped, // The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit // e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer,
// BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line. // chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50]. 10/9/16/1 = a 10-bit BT.2020
// PQ (HDR) 4:2:0 feed so the HUD renders its video-feed line; the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4) tile the 1.3 ms headline so it renders the full
// split equation, and the decoder label line shows the ranked low-latency decoder.
StatsOverlay( StatsOverlay(
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0), doubleArrayOf(
Modifier.align(Alignment.TopStart).padding(12.dp), 238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0,
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
),
decoderLabel = "c2.qti.hevc.decoder · low-latency",
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
) )
} }
} }
@@ -57,6 +57,7 @@ object Gamepad {
private const val VID_SONY = 0x054C private const val VID_SONY = 0x054C
private const val VID_MICROSOFT = 0x045E private const val VID_MICROSOFT = 0x045E
private const val VID_VALVE = 0x28DE private const val VID_VALVE = 0x28DE
private const val VID_NINTENDO = 0x057E
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types. // Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2) private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
@@ -98,6 +99,28 @@ object Gamepad {
} }
} }
/**
* The glyph family a controller's physical buttons belong to, for the console UI's hint bar —
* so a DualSense user sees ✕/○/□/△ shapes and a Switch pad its monochrome lettering instead of
* Xbox's coloured letters. PURELY visual: the wire mapping ([buttonBit]) is unaffected.
*/
enum class PadStyle { GENERIC, XBOX, PLAYSTATION, NINTENDO }
/**
* Resolve the [PadStyle] for a connected controller by USB vendor id. Vendor alone is enough —
* every pad a vendor ships wears its family's glyphs (any Sony pad has the shapes, any Nintendo
* pad the /+ system buttons), so unlike [prefFor] no PID table is needed. Valve renders as
* [PadStyle.XBOX]: Steam pads carry A/B/X/Y in Xbox positions. Unknown vendors (8BitDo & co.,
* which near-universally clone the Xbox layout) fall back to [PadStyle.GENERIC], drawn with the
* Xbox convention.
*/
fun styleFor(dev: InputDevice?): PadStyle = when (dev?.vendorId) {
VID_SONY -> PadStyle.PLAYSTATION
VID_MICROSOFT, VID_VALVE -> PadStyle.XBOX
VID_NINTENDO -> PadStyle.NINTENDO
else -> PadStyle.GENERIC
}
/** True when [dev]'s source classes include gamepad or joystick. */ /** True when [dev]'s source classes include gamepad or joystick. */
fun isPad(dev: InputDevice?): Boolean { fun isPad(dev: InputDevice?): Boolean {
val s = dev?.sources ?: return false val s = dev?.sources ?: return false