diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt index fee25bf9..a5c7742f 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt @@ -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(null) } + var activeDir by remember { mutableStateOf(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(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 }) } } + diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt index d1b80409..84cf4cd2 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt @@ -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, 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, 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(null) } + val scope = rememberCoroutineScope() + var job by remember { mutableStateOf(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) + } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt new file mode 100644 index 00000000..4249ad29 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt @@ -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) + } + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt index 8e4c07e0..7fdee9cb 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt @@ -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(), - ) - } -} diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 358d37c7..2e380895 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -2,6 +2,7 @@ mod content; mod dwn; mod node_message; mod proxy; +mod remote_input; mod websocket; use crate::api::rpc::RpcHandler; @@ -140,6 +141,15 @@ impl ApiHandler { return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await; } + // Remote input WebSocket — companion app sends keyboard/mouse events + if method == Method::GET && path == "/ws/remote-input" { + if !self.is_authenticated(req.headers()).await { + tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing"); + return Ok(Self::unauthorized()); + } + return Self::handle_remote_input(req).await; + } + // Convert body to bytes for non-WS routes let headers = req.headers().clone(); let (parts, body) = req.into_parts(); diff --git a/core/archipelago/src/api/handler/remote_input.rs b/core/archipelago/src/api/handler/remote_input.rs new file mode 100644 index 00000000..c9e8187d --- /dev/null +++ b/core/archipelago/src/api/handler/remote_input.rs @@ -0,0 +1,223 @@ +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use hyper::{Request, Response}; +use hyper_ws_listener::WsStream; +use serde::Deserialize; +use std::time::Instant; +use tokio::process::Command; +use tokio_tungstenite::tungstenite::Message; +use tracing::{debug, info, warn}; + +use super::ApiHandler; + +/// Allowed xdotool key names. Only these pass validation. +const ALLOWED_KEYS: &[&str] = &[ + // Letters + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", + "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", + "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", + // Numbers + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + // Navigation + "Up", "Down", "Left", "Right", + "Return", "Escape", "Tab", "BackSpace", "Delete", + "Home", "End", "Prior", "Next", // Prior=PageUp, Next=PageDown + // Modifiers (for combos like shift+a) + "space", "minus", "equal", "bracketleft", "bracketright", + "backslash", "semicolon", "apostrophe", "grave", "comma", + "period", "slash", + // Function keys + "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", + // Symbols — xdotool names + "exclam", "at", "numbersign", "dollar", "percent", "asciicircum", + "ampersand", "asterisk", "parenleft", "parenright", "underscore", + "plus", "braceleft", "braceright", "bar", "colon", "quotedbl", + "less", "greater", "question", "asciitilde", +]; + +/// Validate a key name against the whitelist. +/// Also allows "shift+X" combos where X is in the whitelist. +fn validate_key(key: &str) -> bool { + if ALLOWED_KEYS.contains(&key) { + return true; + } + // Allow modifier combos: "shift+a", "ctrl+c", etc. + if let Some((modifier, base)) = key.split_once('+') { + let valid_modifiers = ["shift", "ctrl", "alt", "super"]; + return valid_modifiers.contains(&modifier) && ALLOWED_KEYS.contains(&base); + } + false +} + +#[derive(Deserialize)] +#[serde(tag = "t")] +enum InputCommand { + #[serde(rename = "k")] + Key { k: String }, + #[serde(rename = "m")] + MouseMove { x: i32, y: i32 }, + #[serde(rename = "c")] + Click { b: u8 }, + #[serde(rename = "s")] + Scroll { y: i32 }, + #[serde(rename = "p")] + Ping, +} + +async fn xdotool(args: &[&str]) -> Result<()> { + let output = Command::new("xdotool") + .env("DISPLAY", ":0") + .args(args) + .output() + .await + .context("xdotool execution failed")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + debug!("xdotool error: {}", stderr); + } + Ok(()) +} + +async fn handle_input(msg: &str) -> Result> { + let cmd: InputCommand = serde_json::from_str(msg) + .context("invalid input command")?; + + match cmd { + InputCommand::Key { ref k } => { + if !validate_key(k) { + warn!("rejected key: {}", k); + return Ok(Some(r#"{"t":"e","m":"invalid key"}"#.to_string())); + } + xdotool(&["key", "--clearmodifiers", k]).await?; + } + InputCommand::MouseMove { x, y } => { + let x = x.clamp(-50, 50); + let y = y.clamp(-50, 50); + let xs = x.to_string(); + let ys = y.to_string(); + xdotool(&["mousemove_relative", "--", &xs, &ys]).await?; + } + InputCommand::Click { b } => { + let b = b.clamp(1, 3); + let bs = b.to_string(); + xdotool(&["click", &bs]).await?; + } + InputCommand::Scroll { y } => { + // xdotool: button 4 = scroll up, button 5 = scroll down + let btn = if y < 0 { "4" } else { "5" }; + let count = y.unsigned_abs().clamp(1, 10).to_string(); + xdotool(&["click", "--repeat", &count, btn]).await?; + } + InputCommand::Ping => { + return Ok(Some(r#"{"t":"p"}"#.to_string())); + } + } + + Ok(None) +} + +impl ApiHandler { + pub(super) async fn handle_remote_input( + req: Request, + ) -> Result> { + let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req) + .map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?; + + if let Some(ws_fut) = ws_fut_opt { + tokio::spawn(async move { + let ws_stream: WsStream = match ws_fut.await { + Ok(Ok(s)) => s, + Ok(Err(e)) => { + debug!("Remote input WS handshake failed: {}", e); + return; + } + Err(e) => { + debug!("Remote input WS task join failed: {}", e); + return; + } + }; + + info!("Remote input connected"); + + let (mut tx, mut rx) = ws_stream.split(); + + // Send ready message + let _ = tx.send(Message::Text(r#"{"t":"ok"}"#.to_string())).await; + + let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30)); + tokio::pin!(ping_interval); + let mut last_activity = Instant::now(); + let mut msg_count: u64 = 0; + let mut rate_window_start = Instant::now(); + let mut rate_count: u32 = 0; + const MAX_RATE: u32 = 120; // messages per second + const INACTIVITY_TIMEOUT: u64 = 300; + + loop { + tokio::select! { + _ = ping_interval.tick() => { + if last_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT { + info!("Remote input inactive, closing"); + let _ = tx.send(Message::Close(None)).await; + break; + } + if tx.send(Message::Ping(vec![])).await.is_err() { + break; + } + } + msg = rx.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + last_activity = Instant::now(); + msg_count += 1; + + // Rate limiting + if rate_window_start.elapsed().as_millis() >= 1000 { + rate_window_start = Instant::now(); + rate_count = 0; + } + rate_count += 1; + if rate_count > MAX_RATE { + continue; // silently drop + } + + match handle_input(&text).await { + Ok(Some(reply)) => { + let _ = tx.send(Message::Text(reply)).await; + } + Ok(None) => {} + Err(e) => { + debug!("Input error: {}", e); + let err = format!(r#"{{"t":"e","m":"{}"}}"#, + e.to_string().replace('"', "'")); + let _ = tx.send(Message::Text(err)).await; + } + } + } + Some(Ok(Message::Pong(_))) => { + last_activity = Instant::now(); + } + Some(Ok(Message::Ping(data))) => { + last_activity = Instant::now(); + let _ = tx.send(Message::Pong(data)).await; + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => { last_activity = Instant::now(); } + Some(Err(e)) => { + debug!("Remote input stream error: {}", e); + break; + } + } + } + } + } + + info!("Remote input disconnected ({} messages processed)", msg_count); + }); + } + + Ok(response) + } +}