feat: NES portrait controller, remote input handler updates
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 53m5s
Build Archipelago ISO / build-iso (push) Successful in 46m4s

- NESPortraitController layout for vertical phone use
- Updated NESController and NESKeyboard components
- Remote input WebSocket handler and API route registration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-01 23:37:55 +01:00
parent 479fbe0d21
commit 21071e73f1
6 changed files with 744 additions and 329 deletions

View File

@@ -1,6 +1,7 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
@@ -15,13 +16,15 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -34,17 +37,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.ControllerStyle
@@ -52,71 +59,90 @@ import com.archipelago.app.ui.theme.NES
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
// ═══════════════════════════════════════════════════════════
// Color palettes
// Palettes
// ═══════════════════════════════════════════════════════════
data class NESPalette(
val body: Color, val face: Color, val ridge: Color,
val label: Color, val labelMuted: Color,
val dpad: Color, val dpadPress: Color,
val btnMain: Color, val btnMainPress: Color,
val select: Color, val selectPress: Color,
val inlayBorder: Color, val inlayBg: Color,
val dpad: Color, val dpadHi: Color,
val btn: Color, val btnPress: Color,
val capsule: Color, val capsulePress: Color,
val inlayBg: Color, val inlayBorder: Color,
)
val ClassicPalette = NESPalette(
body = NES.ClassicBody, face = NES.ClassicFace, ridge = NES.ClassicRidge,
label = NES.ClassicLabel, labelMuted = NES.ClassicLabelMuted,
dpad = NES.ClassicDPad, dpadPress = NES.ClassicDPadPress,
btnMain = NES.ClassicButtonRed, btnMainPress = NES.ClassicButtonRedPress,
select = NES.ClassicSelect, selectPress = Color(0xFF1A1A1A),
inlayBorder = Color(0xFF888888), inlayBg = Color(0xFF0E0E0E),
dpad = Color(0xFF0C0C0C), dpadHi = Color(0xFF1A1A1A),
btn = NES.ClassicButtonRed, btnPress = NES.ClassicButtonRedPress,
capsule = Color(0xFF1C1C1C), capsulePress = Color(0xFF0E0E0E),
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
)
val DarkPalette = NESPalette(
body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge,
label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted,
dpad = NES.DarkDPad, dpadPress = NES.DarkDPadPress,
btnMain = NES.DarkButtonMain, btnMainPress = NES.DarkButtonMainPress,
select = NES.DarkSelect, selectPress = Color(0xFF0E0E10),
inlayBorder = Color(0xFF3A3A3E), inlayBg = Color(0xFF0A0A0C),
dpad = Color(0xFF080808), dpadHi = Color(0xFF141418),
btn = NES.DarkButtonMain, btnPress = NES.DarkButtonMainPress,
capsule = Color(0xFF121216), capsulePress = Color(0xFF0A0A0C),
inlayBg = Color(0xFF060608), inlayBorder = Color(0xFF444448),
)
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
// ═══════════════════════════════════════════════════════════
// Main NES Controller (Gamepad mode)
// Landscape NES Controller
// ═══════════════════════════════════════════════════════════
@Composable
fun NESController(
style: ControllerStyle = ControllerStyle.CLASSIC,
onKey: (String) -> Unit,
onTwoFingerHold: () -> Unit,
onMenu: () -> Unit,
modifier: Modifier = Modifier,
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
.twoFingerHold(onTwoFingerHold)
.padding(horizontal = 32.dp, vertical = 20.dp),
.background(Color(0xFF0C0C0C)) // Slightly lighter than black for shadow visibility
.twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
// 3D drop shadow layers for realism
// Shadow platform
Box(
modifier = Modifier
.fillMaxWidth(0.86f)
.aspectRatio(2.3f)
.padding(top = 6.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color(0xFF000000)),
)
// Controller body
Box(
Modifier
.fillMaxWidth(0.88f)
.fillMaxWidth(0.86f)
.aspectRatio(2.3f)
.shadow(24.dp, RoundedCornerShape(16.dp), ambientColor = Color.Black, spotColor = Color.Black)
.shadow(8.dp, RoundedCornerShape(16.dp), ambientColor = Color(0x40000000))
.shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
.clip(RoundedCornerShape(16.dp))
.background(c.body)
.background(
Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f)))
)
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)),
) {
// Top highlight edge
Box(
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
)
// Face plate
Box(
Modifier
@@ -124,25 +150,18 @@ fun NESController(
.padding(14.dp)
.clip(RoundedCornerShape(10.dp))
.background(c.face)
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(10.dp)),
) {
// Ridges
Ridges(c.ridge, Modifier.align(Alignment.CenterStart).width(7.dp).fillMaxHeight().padding(vertical = 14.dp))
Ridges(c.ridge, Modifier.align(Alignment.CenterEnd).width(7.dp).fillMaxHeight().padding(vertical = 14.dp))
Ridges(c.ridge, Modifier.align(Alignment.CenterStart).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
Ridges(c.ridge, Modifier.align(Alignment.CenterEnd).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
// ── D-Pad in inlay well ──────────────────
Box(
Modifier
.align(Alignment.CenterStart)
.padding(start = 32.dp)
.clip(RoundedCornerShape(8.dp))
.background(c.inlayBg)
.border(1.dp, c.inlayBorder, RoundedCornerShape(8.dp))
.padding(6.dp)
) {
CrossDPad(c, 48.dp, onKey)
// D-Pad in inlay (more left margin)
Inlay(c, Modifier.align(Alignment.CenterStart).padding(start = 48.dp).size(140.dp)) {
OnePointDPad(c, 120.dp, onKey)
}
// ── Center: ARCHIPELAGO + START/SELECT ───
// Center: Logo + START/SELECT
Column(
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -150,53 +169,44 @@ fun NESController(
Image(
painter = painterResource(id = R.drawable.ic_logo_wide),
contentDescription = "Archipelago",
modifier = Modifier.width(120.dp),
colorFilter = ColorFilter.tint(
if (style == ControllerStyle.CLASSIC) NES.ClassicLabel else c.label
),
modifier = Modifier.width(180.dp),
colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
)
Spacer(Modifier.height(12.dp))
// START/SELECT in inlay
Box(
Modifier
.clip(RoundedCornerShape(6.dp))
.background(c.inlayBg)
.border(1.dp, c.inlayBorder, RoundedCornerShape(6.dp))
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
CapsuleBtn("SELECT", c) { onKey("Escape") }
CapsuleBtn("START", c) { onKey("Return") }
Spacer(Modifier.height(10.dp))
Inlay(c, Modifier.padding(horizontal = 4.dp)) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
}
}
}
// ── A/B Buttons in inlay well ────────────
Box(
Modifier
.align(Alignment.CenterEnd)
.padding(end = 32.dp)
.clip(RoundedCornerShape(8.dp))
.background(c.inlayBg)
.border(1.dp, c.inlayBorder, RoundedCornerShape(8.dp))
.padding(8.dp)
) {
// A/B Buttons in inlay (same size as D-pad inlay, more right margin)
Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(8.dp))
RoundBtn(c, 48.dp) { onKey("Escape") }
Text("B", color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(10.dp))
RoundBtn(c, 52.dp) { onKey("Escape") }
Text("B", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(16.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
RoundBtn(c, 48.dp) { onKey("Return") }
Text("A", color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
RoundBtn(c, 52.dp) { onKey("Return") }
Text("A", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(10.dp))
}
}
}
// Settings button (bottom center)
SettingsBtn(c, Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp), onMenu)
}
}
}
@@ -206,6 +216,107 @@ fun NESController(
// Shared sub-components
// ═══════════════════════════════════════════════════════════
/** Inlay well — dark recessed area with border */
@Composable
fun Inlay(c: NESPalette, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(c.inlayBg)
.border(3.dp, c.inlayBorder, RoundedCornerShape(10.dp))
.padding(4.dp),
contentAlignment = Alignment.Center,
) { content() }
}
/** One-piece D-pad — single cross shape, touch detects direction */
@Composable
fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
val scope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
var activeDir by remember { mutableStateOf<String?>(null) }
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Canvas(
modifier = Modifier
.size(size)
.pointerInput(Unit) {
detectTapGestures(
onPress = { offset ->
val cx = this@pointerInput.size.width / 2f
val cy = this@pointerInput.size.height / 2f
val dx = offset.x - cx
val dy = offset.y - cy
val dead = cx * 0.24f
if (abs(dx) < dead && abs(dy) < dead) {
tryAwaitRelease(); return@detectTapGestures
}
val dir = if (abs(dx) > abs(dy)) {
if (dx > 0) "Right" else "Left"
} else {
if (dy > 0) "Down" else "Up"
}
activeDir = dir; onDir(dir)
job?.cancel()
job = scope.launch { delay(300); while (true) { onDir(dir); delay(90) } }
tryAwaitRelease()
job?.cancel(); activeDir = null
},
)
},
) {
val w = size.toPx()
val arm = w * 0.33f // arm width = 1/3 of total
val offset = (w - arm) / 2f
// Cross shape
val crossColor = c.dpad
// Vertical bar
drawRoundRect(
color = crossColor,
topLeft = Offset(offset, 0f),
size = Size(arm, w),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Horizontal bar
drawRoundRect(
color = crossColor,
topLeft = Offset(0f, offset),
size = Size(w, arm),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Top-edge lighting
drawRoundRect(
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
topLeft = Offset(offset, 0f),
size = Size(arm, w * 0.15f),
cornerRadius = CornerRadius(4.dp.toPx()),
)
drawRoundRect(
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
topLeft = Offset(0f, offset),
size = Size(w, arm * 0.3f),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Active direction highlight
activeDir?.let { dir ->
val hi = c.dpadHi
when (dir) {
"Up" -> drawRoundRect(hi, Offset(offset, 0f), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Down" -> drawRoundRect(hi, Offset(offset, w - arm), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Left" -> drawRoundRect(hi, Offset(0f, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Right" -> drawRoundRect(hi, Offset(w - arm, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
}
}
// Center circle
drawCircle(c.dpadHi, radius = w * 0.06f, center = Offset(w / 2f, w / 2f))
}
}
@Composable
fun Ridges(color: Color, modifier: Modifier) {
Canvas(modifier = modifier) {
@@ -214,129 +325,64 @@ fun Ridges(color: Color, modifier: Modifier) {
}
}
/** A/B round button with lighting */
@Composable
fun CrossDPad(c: NESPalette, sz: Dp, onDir: (String) -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CrossBtn("\u25B2", sz, "Up", c, onDir)
Row(verticalAlignment = Alignment.CenterVertically) {
CrossBtn("\u25C0", sz, "Left", c, onDir)
// Center with lighting
Box(
Modifier.size(sz).background(c.dpad),
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(sz)
.background(
Brush.radialGradient(
listOf(Color.White.copy(alpha = 0.06f), Color.Transparent),
radius = sz.value * 2f,
)
)
)
Box(Modifier.size(12.dp).clip(CircleShape).background(c.dpadPress)
.border(0.5.dp, Color.White.copy(alpha = 0.08f), CircleShape))
}
CrossBtn("\u25B6", sz, "Right", c, onDir)
}
CrossBtn("\u25BC", sz, "Down", c, onDir)
}
}
@Composable
private fun CrossBtn(sym: String, sz: Dp, key: String, c: NESPalette, onDir: (String) -> Unit) {
val scope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
fun RoundBtn(c: NESPalette, sz: Dp = 52.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Box(
Modifier
.size(sz)
.background(
Brush.verticalGradient(
if (p) listOf(c.dpadPress, c.dpad)
else listOf(c.dpad, c.dpad.copy(alpha = 0.9f))
)
)
// Top-edge lighting effect
.then(
if (!p) Modifier.border(
width = 0.5.dp,
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.08f), Color.Transparent)),
shape = RoundedCornerShape(0.dp),
) else Modifier
)
.pointerInput(key) {
detectTapGestures(onPress = {
p = true; onDir(key)
job = scope.launch { delay(300); while (true) { onDir(key); delay(90) } }
tryAwaitRelease(); p = false; job?.cancel()
})
},
contentAlignment = Alignment.Center,
) { Text(sym, color = c.labelMuted, fontSize = 13.sp) }
}
@Composable
fun RoundBtn(c: NESPalette, size: Dp = 48.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(size)
.shadow(if (p) 1.dp else 4.dp, CircleShape)
.clip(CircleShape)
.background(
Brush.verticalGradient(
if (p) listOf(c.btnMainPress, c.btnMain.copy(alpha = 0.9f))
else listOf(c.btnMain, c.btnMain.copy(alpha = 0.85f))
)
)
.pointerInput(Unit) {
detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false })
},
.background(Brush.verticalGradient(
if (p) listOf(c.btnPress, c.btn.copy(alpha = 0.85f))
else listOf(c.btn, c.btn.copy(alpha = 0.8f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
// Lighting highlight
if (!p) {
Box(
Modifier.fillMaxSize().clip(CircleShape).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.20f), Color.Transparent))
)
)
}
if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
))
}
}
/** START/SELECT capsule */
@Composable
fun CapsuleBtn(label: String, c: NESPalette, onClick: () -> Unit) {
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.width(58.dp).height(16.dp)
.shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(3.dp))
.clip(RoundedCornerShape(3.dp))
.background(
Brush.verticalGradient(
if (p) listOf(c.selectPress, c.select)
else listOf(c.select, c.select.copy(alpha = 0.8f))
)
)
.pointerInput(Unit) {
detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false })
},
.width(w).height(h)
.shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(4.dp))
.background(Brush.verticalGradient(
if (p) listOf(c.capsulePress, c.capsule)
else listOf(c.capsule, c.capsule.copy(alpha = 0.85f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
// Lighting
if (!p) {
Box(
Modifier.fillMaxSize().clip(RoundedCornerShape(3.dp)).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent))
)
)
}
Text(label, color = c.labelMuted, fontSize = 7.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
if (!p) Box(Modifier.fillMaxSize().clip(RoundedCornerShape(4.dp)).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.05f), Color.Transparent))
))
Text(label, color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
}
}
/** Small settings gear button */
@Composable
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(24.dp)
.clip(CircleShape)
.background(if (p) c.capsulePress else c.capsule)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Settings, "Settings", Modifier.size(14.dp), tint = c.labelMuted)
}
}
@@ -354,3 +400,4 @@ fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
} while (ev.changes.any { it.pressed })
}
}

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -23,11 +22,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -39,10 +36,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private enum class NKLayer { ALPHA, NUM, SYM }
private val KEY_H = 38.dp
private val GAP = 3.dp
private val KEY_H = 42.dp
private val GAP = 4.dp
/** NES-themed keyboard — keys styled like D-pad buttons, inside controller body */
@Composable
fun NESKeyboard(
style: ControllerStyle = ControllerStyle.CLASSIC,
@@ -51,11 +47,10 @@ fun NESKeyboard(
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
// Keys match the D-pad material
val keyBg = c.dpad
val keyBgPress = c.dpadPress
val keyText = c.labelMuted
val accentText = if (isClassic) NES.ClassicLabel else c.labelMuted
val keyBgP = c.dpadHi
val keyTxt = c.labelMuted
val accent = if (isClassic) NES.ClassicLabel else c.labelMuted
var layer by remember { mutableStateOf(NKLayer.ALPHA) }
var shifted by remember { mutableStateOf(false) }
@@ -65,108 +60,143 @@ fun NESKeyboard(
fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false }
fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) }
// Controller body wrapping the keyboard
Box(
// NES body wrapping keyboard
Column(
modifier = modifier
.shadow(16.dp, RoundedCornerShape(14.dp), ambientColor = Color.Black)
.clip(RoundedCornerShape(14.dp))
.background(c.body)
.padding(10.dp)
.padding(8.dp)
.clip(RoundedCornerShape(8.dp))
.background(c.face)
.padding(8.dp),
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(GAP),
) {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(GAP),
) {
when (layer) {
NKLayer.ALPHA -> {
KR("q w e r t y u i o p".split(" "), up, keyBg, keyBgPress, keyText, ::ch)
KR("a s d f g h j k l".split(" "), up, keyBg, keyBgPress, keyText, ::ch, inset = 14.dp)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
DK(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), keyBg, keyBgPress, if (up) accentText else keyText) {
if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true
}
"z x c v b n m".split(" ").forEach { DK(if (up) it.uppercase() else it, Modifier.height(KEY_H), keyBg, keyBgPress, keyText, 16) { ch(it) } }
DKRepeat("\u232B", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { emit("BackSpace") }
when (layer) {
NKLayer.ALPHA -> {
KeyRow("q w e r t y u i o p".split(" "), up, keyBg, keyBgP, keyTxt, ::ch)
KeyRow("a s d f g h j k l".split(" "), up, keyBg, keyBgP, keyTxt, ::ch, inset = 16.dp)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), keyBg, keyBgP, if (up) accent else keyTxt) {
if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true
}
}
NKLayer.NUM -> {
KR("1 2 3 4 5 6 7 8 9 0".split(" "), false, keyBg, keyBgPress, keyText, ::emit)
KR("- / : ; ( ) \$ & @ \"".split(" "), false, keyBg, keyBgPress, keyText, ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
DK("#+=", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { layer = NKLayer.SYM }
". , ? ! '".split(" ").forEach { DK(it, Modifier.height(KEY_H), keyBg, keyBgPress, keyText) { emit(it) } }
DKRepeat("\u232B", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { emit("BackSpace") }
}
}
NKLayer.SYM -> {
KR("[ ] { } # % ^ * + =".split(" "), false, keyBg, keyBgPress, keyText, ::emit)
KR("_ \\ | ~ < > ` @ !".split(" "), false, keyBg, keyBgPress, keyText, ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
DK("123", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { layer = NKLayer.NUM }
". , ? ! '".split(" ").forEach { DK(it, Modifier.height(KEY_H), keyBg, keyBgPress, keyText) { emit(it) } }
DKRepeat("\u232B", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { emit("BackSpace") }
"z x c v b n m".split(" ").forEach { k ->
NKey(if (up) k.uppercase() else k, Modifier.weight(1f), keyBg, keyBgP, keyTxt, 17) { ch(k) }
}
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
}
}
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
DK(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) {
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false
NKLayer.NUM -> {
KeyRow("1 2 3 4 5 6 7 8 9 0".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
KeyRow("- / : ; ( ) \$ & @ \"".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey("#+=", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.SYM }
". , ? ! '".split(" ").forEach { k ->
NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) }
}
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
}
DK(",", Modifier.height(KEY_H), keyBg, keyBgPress, keyText) { emit("comma") }
DK("space", Modifier.weight(5f), keyBg, keyBgPress, keyText, 12) { emit("space") }
DK(".", Modifier.height(KEY_H), keyBg, keyBgPress, keyText) { emit("period") }
DK("\u23CE", Modifier.weight(1.4f), keyBg, keyBgPress, accentText, 15) { emit("Return") }
}
NKLayer.SYM -> {
KeyRow("[ ] { } # % ^ * + =".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
KeyRow("_ \\ | ~ < > ` @ !".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey("123", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.NUM }
". , ? ! '".split(" ").forEach { k ->
NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) }
}
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
}
}
}
// Bottom row
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) {
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false
}
NKey(",", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("comma") }
NKey("space", Modifier.weight(5f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
NKey(".", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("period") }
NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") }
}
}
}
/** Key row — each key gets equal weight */
@Composable
private fun KR(keys: List<String>, up: Boolean, bg: Color, bgP: Color, txt: Color, onKey: (String) -> Unit, inset: Dp = 0.dp) {
Row(Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset), Arrangement.spacedBy(GAP)) {
keys.forEach { c -> DK(if (up) c.uppercase() else c, Modifier.height(KEY_H), bg, bgP, txt, 16) { onKey(c) } }
private fun KeyRow(
keys: List<String>, up: Boolean,
bg: Color, bgP: Color, txt: Color,
onKey: (String) -> Unit, inset: Dp = 0.dp,
) {
Row(
Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset),
Arrangement.spacedBy(GAP),
) {
keys.forEach { k ->
NKey(
label = if (up) k.uppercase() else k,
modifier = Modifier.weight(1f),
bg = bg, bgP = bgP, txt = txt,
fontSize = 17,
onTap = { onKey(k) },
)
}
}
}
/** D-pad style key — flat, dark, with subtle top-edge lighting */
/** Single NES key — D-pad style flat dark button */
@Composable
private fun DK(label: String, modifier: Modifier = Modifier, bg: Color, bgP: Color, txt: Color, fontSize: Int = 12, onTap: () -> Unit) {
private fun NKey(
label: String, modifier: Modifier = Modifier,
bg: Color, bgP: Color, txt: Color,
fontSize: Int = 13, onTap: () -> Unit,
) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.clip(RoundedCornerShape(3.dp))
.background(
Brush.verticalGradient(
if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))
)
)
.height(KEY_H)
.clip(RoundedCornerShape(4.dp))
.background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))))
.then(
if (!p) Modifier.border(0.5.dp,
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
RoundedCornerShape(3.dp))
RoundedCornerShape(4.dp))
else Modifier
)
.pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) },
.pointerInput(label) {
detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false })
},
contentAlignment = Alignment.Center,
) { Text(label, color = txt, fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1) }
) {
Text(label, color = txt, fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1)
}
}
/** Repeatable NES key (backspace) */
@Composable
private fun DKRepeat(label: String, modifier: Modifier, bg: Color, bgP: Color, txt: Color, onTap: () -> Unit) {
private fun NRepKey(
label: String, modifier: Modifier,
bg: Color, bgP: Color, txt: Color, onTap: () -> Unit,
) {
var p by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope(); var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Box(
modifier = modifier
.clip(RoundedCornerShape(3.dp))
.height(KEY_H)
.clip(RoundedCornerShape(4.dp))
.background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))))
.pointerInput(Unit) { detectTapGestures(onPress = {
p = true; onTap(); job = scope.launch { delay(400); while (true) { onTap(); delay(55) } }
tryAwaitRelease(); job?.cancel(); p = false
}) },
.pointerInput(Unit) {
detectTapGestures(onPress = {
p = true; onTap()
job = scope.launch { delay(400); while (true) { onTap(); delay(55) } }
tryAwaitRelease(); job?.cancel(); p = false
})
},
contentAlignment = Alignment.Center,
) { Text(label, color = txt, fontSize = 15.sp) }
) {
Text(label, color = txt, fontSize = 16.sp)
}
}

View File

@@ -0,0 +1,146 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.NES
/**
* Portrait gamepad — vertical remote shape like Apple TV but NES-styled.
* Large trackpad top, D-pad middle, A/B + START/SELECT bottom.
*/
@Composable
fun NESPortraitController(
style: ControllerStyle = ControllerStyle.CLASSIC,
onKey: (String) -> Unit,
onMenu: () -> Unit,
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF0C0C0C))
.twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
// Remote body — tall vertical shape
Box(
Modifier
.fillMaxWidth(0.75f)
.fillMaxSize()
.shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black)
.clip(RoundedCornerShape(20.dp))
.background(Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f))))
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)),
) {
// Top highlight
Box(
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
)
// Face plate
Column(
Modifier
.fillMaxSize()
.padding(14.dp)
.clip(RoundedCornerShape(14.dp))
.background(c.face)
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(14.dp))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
// Trackpad area (touch surface for mouse)
Trackpad(
onMove = { _, _ -> }, // Not used in gamepad, but keeps the visual
onClick = { onKey("Return") },
onScroll = { dy ->
if (dy > 0) onKey("Down") else onKey("Up")
},
onTwoFingerHold = onMenu,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
Spacer(Modifier.height(12.dp))
// D-Pad
Inlay(c, Modifier.size(150.dp)) {
OnePointDPad(c, 130.dp, onKey)
}
Spacer(Modifier.height(12.dp))
// Logo
Image(
painter = painterResource(id = R.drawable.ic_logo_wide),
contentDescription = "Archipelago",
modifier = Modifier.width(140.dp),
colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
)
Spacer(Modifier.height(12.dp))
// A/B Buttons
Inlay(c, Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
RoundBtn(c, 52.dp) { onKey("Escape") }
Spacer(Modifier.width(24.dp))
RoundBtn(c, 52.dp) { onKey("Return") }
}
}
Spacer(Modifier.height(10.dp))
// START / SELECT
Inlay(c, Modifier) {
Row(
Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
}
}
Spacer(Modifier.height(6.dp))
// Settings
SettingsBtn(c, Modifier, onMenu)
}
}
}
}

View File

@@ -3,17 +3,11 @@ package com.archipelago.app.ui.screens
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
@@ -32,8 +26,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -43,11 +35,11 @@ import com.archipelago.app.network.InputWebSocket
import com.archipelago.app.ui.components.NESController
import com.archipelago.app.ui.components.NESKeyboard
import com.archipelago.app.ui.components.NESMenu
import com.archipelago.app.ui.components.NESPortraitController
import com.archipelago.app.ui.components.Trackpad
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.NES
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted
import kotlinx.coroutines.launch
@@ -62,7 +54,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null)
var isGamepadMode by remember { mutableStateOf(true) } // Default to gamepad (NES controller)
var isGamepadMode by remember { mutableStateOf(true) }
var showModal by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
@@ -71,51 +63,54 @@ fun RemoteInputScreen(onBack: () -> Unit) {
BackHandler { onBack() }
DisposableEffect(Unit) { onDispose { ws.disconnect() } }
LaunchedEffect(activeServer) {
activeServer?.let { ws.connect(it.toUrl(), it.password) }
}
LaunchedEffect(activeServer) { activeServer?.let { ws.connect(it.toUrl(), it.password) } }
LaunchedEffect(connectionState) {
if (connectionState == ConnectionState.ERROR) {
kotlinx.coroutines.delay(3000)
activeServer?.let { ws.connect(it.toUrl(), it.password) }
kotlinx.coroutines.delay(3000); activeServer?.let { ws.connect(it.toUrl(), it.password) }
}
}
Box(
modifier = Modifier
Modifier
.fillMaxSize()
.background(Color.Black)
.background(Color(0xFF0C0C0C))
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
if (isGamepadMode) {
// NES controller — centered with margins
NESController(
when {
isGamepadMode && isLandscape -> NESController(
style = controllerStyle,
onKey = { ws.sendKey(it) },
onTwoFingerHold = { showModal = true },
)
} else {
// Keyboard mode with trackpad
NESKeyboardLayout(
style = controllerStyle,
isLandscape = isLandscape,
onKey = { ws.sendKey(it) },
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onClick = { ws.sendClick(it) },
onScroll = { ws.sendScroll(it) },
onMenu = { showModal = true },
)
isGamepadMode && !isLandscape -> NESPortraitController(
style = controllerStyle,
onKey = { ws.sendKey(it) },
onMenu = { showModal = true },
)
else -> {
// Keyboard mode: trackpad fills top, keyboard pinned bottom
Column(Modifier.fillMaxSize()) {
Trackpad(
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onClick = { ws.sendClick(it) },
onScroll = { ws.sendScroll(it) },
onTwoFingerHold = { showModal = true },
modifier = Modifier.fillMaxWidth().weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
NESKeyboard(
style = controllerStyle,
onKey = { ws.sendKey(it) },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
// Connection dot
Box(
Modifier
.align(Alignment.TopStart)
.padding(6.dp)
.size(8.dp)
.clip(CircleShape)
.background(
Modifier.align(Alignment.TopStart).padding(6.dp).size(8.dp)
.clip(CircleShape).background(
when (connectionState) {
ConnectionState.CONNECTED -> SuccessGreen
ConnectionState.CONNECTING -> BitcoinOrange
@@ -125,7 +120,6 @@ fun RemoteInputScreen(onBack: () -> Unit) {
),
)
// NES Menu
NESMenu(
visible = showModal,
servers = savedServers,
@@ -134,14 +128,10 @@ fun RemoteInputScreen(onBack: () -> Unit) {
controllerStyle = controllerStyle,
onDismiss = { showModal = false },
onSelectServer = { server ->
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }
showModal = false
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
},
onAddServer = { server ->
scope.launch {
prefs.addSavedServer(server)
if (activeServer == null) prefs.setActiveServer(server)
}
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
},
onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } },
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
@@ -152,34 +142,3 @@ fun RemoteInputScreen(onBack: () -> Unit) {
)
}
}
@Composable
private fun NESKeyboardLayout(
style: ControllerStyle,
isLandscape: Boolean,
onKey: (String) -> Unit,
onMouseMove: (Int, Int) -> Unit,
onClick: (Int) -> Unit,
onScroll: (Int) -> Unit,
onMenu: () -> Unit,
) {
Column(Modifier.fillMaxSize()) {
// Trackpad fills available space above keyboard
Trackpad(
onMove = onMouseMove,
onClick = onClick,
onScroll = onScroll,
onTwoFingerHold = onMenu,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 16.dp, vertical = if (isLandscape) 6.dp else 10.dp),
)
// NES keyboard pinned to bottom
NESKeyboard(
style = style,
onKey = onKey,
modifier = Modifier.fillMaxWidth(),
)
}
}