feat: NES portrait controller, remote input handler updates

- 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 5c429f9571
commit d2dc803920
6 changed files with 744 additions and 329 deletions

View File

@ -1,6 +1,7 @@
package com.archipelago.app.ui.components package com.archipelago.app.ui.components
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
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
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@ -34,17 +37,21 @@ 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.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
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.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.changedToUp
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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 androidx.compose.ui.unit.sp
import com.archipelago.app.R import com.archipelago.app.R
import com.archipelago.app.ui.theme.ControllerStyle 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.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Color palettes // Palettes
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
data class NESPalette( data class NESPalette(
val body: Color, val face: Color, val ridge: Color, val body: Color, val face: Color, val ridge: Color,
val label: Color, val labelMuted: Color, val label: Color, val labelMuted: Color,
val dpad: Color, val dpadPress: Color, val dpad: Color, val dpadHi: Color,
val btnMain: Color, val btnMainPress: Color, val btn: Color, val btnPress: Color,
val select: Color, val selectPress: Color, val capsule: Color, val capsulePress: Color,
val inlayBorder: Color, val inlayBg: Color, val inlayBg: Color, val inlayBorder: Color,
) )
val ClassicPalette = NESPalette( val ClassicPalette = NESPalette(
body = NES.ClassicBody, face = NES.ClassicFace, ridge = NES.ClassicRidge, body = NES.ClassicBody, face = NES.ClassicFace, ridge = NES.ClassicRidge,
label = NES.ClassicLabel, labelMuted = NES.ClassicLabelMuted, label = NES.ClassicLabel, labelMuted = NES.ClassicLabelMuted,
dpad = NES.ClassicDPad, dpadPress = NES.ClassicDPadPress, dpad = Color(0xFF0C0C0C), dpadHi = Color(0xFF1A1A1A),
btnMain = NES.ClassicButtonRed, btnMainPress = NES.ClassicButtonRedPress, btn = NES.ClassicButtonRed, btnPress = NES.ClassicButtonRedPress,
select = NES.ClassicSelect, selectPress = Color(0xFF1A1A1A), capsule = Color(0xFF1C1C1C), capsulePress = Color(0xFF0E0E0E),
inlayBorder = Color(0xFF888888), inlayBg = Color(0xFF0E0E0E), inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
) )
val DarkPalette = NESPalette( val DarkPalette = NESPalette(
body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge, body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge,
label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted, label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted,
dpad = NES.DarkDPad, dpadPress = NES.DarkDPadPress, dpad = Color(0xFF080808), dpadHi = Color(0xFF141418),
btnMain = NES.DarkButtonMain, btnMainPress = NES.DarkButtonMainPress, btn = NES.DarkButtonMain, btnPress = NES.DarkButtonMainPress,
select = NES.DarkSelect, selectPress = Color(0xFF0E0E10), capsule = Color(0xFF121216), capsulePress = Color(0xFF0A0A0C),
inlayBorder = Color(0xFF3A3A3E), inlayBg = Color(0xFF0A0A0C), inlayBg = Color(0xFF060608), inlayBorder = Color(0xFF444448),
) )
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Main NES Controller (Gamepad mode) // Landscape NES Controller
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@Composable @Composable
fun NESController( fun NESController(
style: ControllerStyle = ControllerStyle.CLASSIC, style: ControllerStyle = ControllerStyle.CLASSIC,
onKey: (String) -> Unit, onKey: (String) -> Unit,
onTwoFingerHold: () -> Unit, onMenu: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val c = paletteFor(style) val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color(0xFF0C0C0C)) // Slightly lighter than black for shadow visibility
.twoFingerHold(onTwoFingerHold) .twoFingerHold(onMenu)
.padding(horizontal = 32.dp, vertical = 20.dp), .padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center, 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( Box(
Modifier Modifier
.fillMaxWidth(0.88f) .fillMaxWidth(0.86f)
.aspectRatio(2.3f) .aspectRatio(2.3f)
.shadow(24.dp, RoundedCornerShape(16.dp), ambientColor = Color.Black, spotColor = Color.Black) .shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
.shadow(8.dp, RoundedCornerShape(16.dp), ambientColor = Color(0x40000000))
.clip(RoundedCornerShape(16.dp)) .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 // Face plate
Box( Box(
Modifier Modifier
@ -124,25 +150,18 @@ fun NESController(
.padding(14.dp) .padding(14.dp)
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background(c.face) .background(c.face)
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(10.dp)),
) { ) {
// Ridges // Ridges
Ridges(c.ridge, Modifier.align(Alignment.CenterStart).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 = 14.dp)) Ridges(c.ridge, Modifier.align(Alignment.CenterEnd).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
// ── D-Pad in inlay well ────────────────── // D-Pad in inlay (more left margin)
Box( Inlay(c, Modifier.align(Alignment.CenterStart).padding(start = 48.dp).size(140.dp)) {
Modifier OnePointDPad(c, 120.dp, onKey)
.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)
} }
// ── Center: ARCHIPELAGO + START/SELECT ─── // Center: Logo + START/SELECT
Column( Column(
Modifier.align(Alignment.Center), Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -150,53 +169,44 @@ fun NESController(
Image( Image(
painter = painterResource(id = R.drawable.ic_logo_wide), painter = painterResource(id = R.drawable.ic_logo_wide),
contentDescription = "Archipelago", contentDescription = "Archipelago",
modifier = Modifier.width(120.dp), modifier = Modifier.width(180.dp),
colorFilter = ColorFilter.tint( colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
if (style == ControllerStyle.CLASSIC) NES.ClassicLabel else c.label
),
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(10.dp))
// START/SELECT in inlay Inlay(c, Modifier.padding(horizontal = 4.dp)) {
Box( Row(
Modifier Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
.clip(RoundedCornerShape(6.dp)) horizontalArrangement = Arrangement.spacedBy(16.dp),
.background(c.inlayBg) ) {
.border(1.dp, c.inlayBorder, RoundedCornerShape(6.dp)) CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
.padding(horizontal = 10.dp, vertical = 6.dp) CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
CapsuleBtn("SELECT", c) { onKey("Escape") }
CapsuleBtn("START", c) { onKey("Return") }
} }
} }
} }
// ── A/B Buttons in inlay well ──────────── // A/B Buttons in inlay (same size as D-pad inlay, more right margin)
Box( Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) {
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)
) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(14.dp), Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(10.dp))
RoundBtn(c, 48.dp) { onKey("Escape") } RoundBtn(c, 52.dp) { onKey("Escape") }
Text("B", color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold) Text("B", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
} }
Spacer(Modifier.width(16.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
RoundBtn(c, 48.dp) { onKey("Return") } RoundBtn(c, 52.dp) { onKey("Return") }
Text("A", color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold) Text("A", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp)) 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 // 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 @Composable
fun Ridges(color: Color, modifier: Modifier) { fun Ridges(color: Color, modifier: Modifier) {
Canvas(modifier = modifier) { Canvas(modifier = modifier) {
@ -214,129 +325,64 @@ fun Ridges(color: Color, modifier: Modifier) {
} }
} }
/** A/B round button with lighting */
@Composable @Composable
fun CrossDPad(c: NESPalette, sz: Dp, onDir: (String) -> Unit) { fun RoundBtn(c: NESPalette, sz: Dp = 52.dp, onClick: () -> 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) }
var p by remember { mutableStateOf(false) } var p by remember { mutableStateOf(false) }
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Box( Box(
Modifier Modifier
.size(sz) .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) .shadow(if (p) 1.dp else 4.dp, CircleShape)
.clip(CircleShape) .clip(CircleShape)
.background( .background(Brush.verticalGradient(
Brush.verticalGradient( if (p) listOf(c.btnPress, c.btn.copy(alpha = 0.85f))
if (p) listOf(c.btnMainPress, c.btnMain.copy(alpha = 0.9f)) else listOf(c.btn, c.btn.copy(alpha = 0.8f))
else listOf(c.btnMain, c.btnMain.copy(alpha = 0.85f)) ))
) .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
)
.pointerInput(Unit) {
detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false })
},
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
// Lighting highlight if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
if (!p) { Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
Box( ))
Modifier.fillMaxSize().clip(CircleShape).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.20f), Color.Transparent))
)
)
}
} }
} }
/** START/SELECT capsule */
@Composable @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) } var p by remember { mutableStateOf(false) }
Box( Box(
Modifier Modifier
.width(58.dp).height(16.dp) .width(w).height(h)
.shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(3.dp)) .shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(3.dp)) .clip(RoundedCornerShape(4.dp))
.background( .background(Brush.verticalGradient(
Brush.verticalGradient( if (p) listOf(c.capsulePress, c.capsule)
if (p) listOf(c.selectPress, c.select) else listOf(c.capsule, c.capsule.copy(alpha = 0.85f))
else listOf(c.select, c.select.copy(alpha = 0.8f)) ))
) .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
)
.pointerInput(Unit) {
detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false })
},
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
// Lighting if (!p) Box(Modifier.fillMaxSize().clip(RoundedCornerShape(4.dp)).background(
if (!p) { Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.05f), Color.Transparent))
Box( ))
Modifier.fillMaxSize().clip(RoundedCornerShape(3.dp)).background( Text(label, color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)) }
) }
)
} /** Small settings gear button */
Text(label, color = c.labelMuted, fontSize = 7.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp) @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 }) } 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -23,11 +22,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.draw.shadow
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.input.pointer.pointerInput 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.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -39,10 +36,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private enum class NKLayer { ALPHA, NUM, SYM } private enum class NKLayer { ALPHA, NUM, SYM }
private val KEY_H = 38.dp private val KEY_H = 42.dp
private val GAP = 3.dp private val GAP = 4.dp
/** NES-themed keyboard — keys styled like D-pad buttons, inside controller body */
@Composable @Composable
fun NESKeyboard( fun NESKeyboard(
style: ControllerStyle = ControllerStyle.CLASSIC, style: ControllerStyle = ControllerStyle.CLASSIC,
@ -51,11 +47,10 @@ fun NESKeyboard(
) { ) {
val c = paletteFor(style) val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC val isClassic = style == ControllerStyle.CLASSIC
// Keys match the D-pad material
val keyBg = c.dpad val keyBg = c.dpad
val keyBgPress = c.dpadPress val keyBgP = c.dpadHi
val keyText = c.labelMuted val keyTxt = c.labelMuted
val accentText = if (isClassic) NES.ClassicLabel else c.labelMuted val accent = if (isClassic) NES.ClassicLabel else c.labelMuted
var layer by remember { mutableStateOf(NKLayer.ALPHA) } var layer by remember { mutableStateOf(NKLayer.ALPHA) }
var shifted by remember { mutableStateOf(false) } var shifted by remember { mutableStateOf(false) }
@ -65,108 +60,143 @@ fun NESKeyboard(
fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false } 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) } fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) }
// Controller body wrapping the keyboard // NES body wrapping keyboard
Box( Column(
modifier = modifier modifier = modifier
.shadow(16.dp, RoundedCornerShape(14.dp), ambientColor = Color.Black)
.clip(RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp))
.background(c.body) .background(c.body)
.padding(10.dp) .padding(8.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(c.face) .background(c.face)
.padding(8.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(GAP),
) { ) {
Column( when (layer) {
Modifier.fillMaxSize(), NKLayer.ALPHA -> {
verticalArrangement = Arrangement.spacedBy(GAP), 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)
when (layer) { Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKLayer.ALPHA -> { NKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), keyBg, keyBgP, if (up) accent else keyTxt) {
KR("q w e r t y u i o p".split(" "), up, keyBg, keyBgPress, keyText, ::ch) if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true
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") }
} }
} "z x c v b n m".split(" ").forEach { k ->
NKLayer.NUM -> { NKey(if (up) k.uppercase() else k, Modifier.weight(1f), keyBg, keyBgP, keyTxt, 17) { ch(k) }
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") }
} }
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
} }
} }
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { NKLayer.NUM -> {
DK(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgPress, keyText) { KeyRow("1 2 3 4 5 6 7 8 9 0".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false 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 @Composable
private fun KR(keys: List<String>, up: Boolean, bg: Color, bgP: Color, txt: Color, onKey: (String) -> Unit, inset: Dp = 0.dp) { private fun KeyRow(
Row(Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset), Arrangement.spacedBy(GAP)) { keys: List<String>, up: Boolean,
keys.forEach { c -> DK(if (up) c.uppercase() else c, Modifier.height(KEY_H), bg, bgP, txt, 16) { onKey(c) } } 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 @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) } var p by remember { mutableStateOf(false) }
Box( Box(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(3.dp)) .height(KEY_H)
.background( .clip(RoundedCornerShape(4.dp))
Brush.verticalGradient( .background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))))
if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))
)
)
.then( .then(
if (!p) Modifier.border(0.5.dp, if (!p) Modifier.border(0.5.dp,
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)), Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
RoundedCornerShape(3.dp)) RoundedCornerShape(4.dp))
else Modifier 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, 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 @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) } 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() } } DisposableEffect(Unit) { onDispose { job?.cancel() } }
Box( Box(
modifier = modifier 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)))) .background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))))
.pointerInput(Unit) { detectTapGestures(onPress = { .pointerInput(Unit) {
p = true; onTap(); job = scope.launch { delay(400); while (true) { onTap(); delay(55) } } detectTapGestures(onPress = {
tryAwaitRelease(); job?.cancel(); p = false p = true; onTap()
}) }, job = scope.launch { delay(400); while (true) { onTap(); delay(55) } }
tryAwaitRelease(); job?.cancel(); p = false
})
},
contentAlignment = Alignment.Center, 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 android.content.res.Configuration
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.Column 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -32,8 +26,6 @@ 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.graphics.Color 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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp 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.NESController
import com.archipelago.app.ui.components.NESKeyboard import com.archipelago.app.ui.components.NESKeyboard
import com.archipelago.app.ui.components.NESMenu 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.components.Trackpad
import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.ErrorRed 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.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted import com.archipelago.app.ui.theme.TextMuted
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -62,7 +54,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
val savedServers by prefs.savedServers.collectAsState(initial = emptyList()) val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null) 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 showModal by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) } var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
@ -71,51 +63,54 @@ fun RemoteInputScreen(onBack: () -> Unit) {
BackHandler { onBack() } BackHandler { onBack() }
DisposableEffect(Unit) { onDispose { ws.disconnect() } } 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) { LaunchedEffect(connectionState) {
if (connectionState == ConnectionState.ERROR) { if (connectionState == ConnectionState.ERROR) {
kotlinx.coroutines.delay(3000) kotlinx.coroutines.delay(3000); activeServer?.let { ws.connect(it.toUrl(), it.password) }
activeServer?.let { ws.connect(it.toUrl(), it.password) }
} }
} }
Box( Box(
modifier = Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color(0xFF0C0C0C))
.windowInsetsPadding(WindowInsets.safeDrawing), .windowInsetsPadding(WindowInsets.safeDrawing),
) { ) {
if (isGamepadMode) { when {
// NES controller — centered with margins isGamepadMode && isLandscape -> NESController(
NESController(
style = controllerStyle, style = controllerStyle,
onKey = { ws.sendKey(it) }, 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 }, 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 // Connection dot
Box( Box(
Modifier Modifier.align(Alignment.TopStart).padding(6.dp).size(8.dp)
.align(Alignment.TopStart) .clip(CircleShape).background(
.padding(6.dp)
.size(8.dp)
.clip(CircleShape)
.background(
when (connectionState) { when (connectionState) {
ConnectionState.CONNECTED -> SuccessGreen ConnectionState.CONNECTED -> SuccessGreen
ConnectionState.CONNECTING -> BitcoinOrange ConnectionState.CONNECTING -> BitcoinOrange
@ -125,7 +120,6 @@ fun RemoteInputScreen(onBack: () -> Unit) {
), ),
) )
// NES Menu
NESMenu( NESMenu(
visible = showModal, visible = showModal,
servers = savedServers, servers = savedServers,
@ -134,14 +128,10 @@ fun RemoteInputScreen(onBack: () -> Unit) {
controllerStyle = controllerStyle, controllerStyle = controllerStyle,
onDismiss = { showModal = false }, onDismiss = { showModal = false },
onSelectServer = { server -> onSelectServer = { server ->
scope.launch { ws.disconnect(); prefs.setActiveServer(server) } scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
showModal = false
}, },
onAddServer = { server -> onAddServer = { server ->
scope.launch { scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
prefs.addSavedServer(server)
if (activeServer == null) prefs.setActiveServer(server)
}
}, },
onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } }, onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } },
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false }, 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(),
)
}
}

View File

@ -2,6 +2,7 @@ mod content;
mod dwn; mod dwn;
mod node_message; mod node_message;
mod proxy; mod proxy;
mod remote_input;
mod websocket; mod websocket;
use crate::api::rpc::RpcHandler; 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; 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 // Convert body to bytes for non-WS routes
let headers = req.headers().clone(); let headers = req.headers().clone();
let (parts, body) = req.into_parts(); let (parts, body) = req.into_parts();

View File

@ -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<Option<String>> {
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<hyper::Body>,
) -> Result<Response<hyper::Body>> {
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)
}
}