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:
@@ -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) {
|
||||||
|
'A' -> Color(0xFF7C9CE8) // cross — light blue
|
||||||
|
'B' -> Color(0xFFE0736F) // circle — red
|
||||||
|
'X' -> Color(0xFFD48FC7) // square — pink
|
||||||
|
else -> Color(0xFF5FBFA5) // triangle — green
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
|
||||||
.size(width = size * 0.74f, height = size * 0.46f)
|
contentAlignment = Alignment.Center,
|
||||||
.clip(RoundedCornerShape(50))
|
) {
|
||||||
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
|
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,13 +120,11 @@ 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)
|
||||||
@@ -123,29 +132,54 @@ fun GamepadDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.
|
||||||
|
val bg by animateColorAsState(
|
||||||
|
when {
|
||||||
focused -> Color(0xFF6656F2)
|
focused -> Color(0xFF6656F2)
|
||||||
primary -> Color(0x336656F2)
|
primary -> Color(0x336656F2)
|
||||||
else -> Color(0x14FFFFFF)
|
else -> Color(0x14FFFFFF)
|
||||||
}
|
},
|
||||||
val fg = when {
|
tween(160),
|
||||||
|
label = "btnBg",
|
||||||
|
)
|
||||||
|
val fg by animateColorAsState(
|
||||||
|
when {
|
||||||
!enabled -> Color.White.copy(alpha = 0.35f)
|
!enabled -> Color.White.copy(alpha = 0.35f)
|
||||||
focused -> Color.White
|
focused -> Color.White
|
||||||
primary -> Color(0xFF8678F5)
|
primary -> Color(0xFF8678F5)
|
||||||
else -> Color.White.copy(alpha = 0.85f)
|
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) {
|
||||||
|
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
|
||||||
|
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||||
|
} else {
|
||||||
|
Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||||
|
// The value slides in the direction it was stepped and its width animates, so
|
||||||
|
// cycling a choice reads as motion through a list rather than a text swap.
|
||||||
|
AnimatedContent(
|
||||||
|
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(
|
Text(
|
||||||
row.value,
|
value,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
|
color = valueColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f))
|
}
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user