diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt
index cccd11a0..c5bc1049 100644
--- a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt
+++ b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt
@@ -18,7 +18,11 @@ data class ServerEntry(
val useHttps: Boolean,
val port: String = "",
val password: String = "",
+ val name: String = "",
) {
+ /** Label to show in lists — the user-given name, or the address if unnamed. */
+ fun displayName(): String = name.ifBlank { address }
+
fun toUrl(): String {
val scheme = if (useHttps) "https" else "http"
val portSuffix = if (port.isNotBlank()) ":$port" else ""
@@ -31,7 +35,9 @@ data class ServerEntry(
return "$scheme://$address$portSuffix"
}
- fun serialize(): String = "$address|$useHttps|$port|$password"
+ // name is the trailing field so entries saved before naming existed
+ // (4 fields) still deserialize, with name defaulting to "".
+ fun serialize(): String = "$address|$useHttps|$port|$password|$name"
companion object {
fun deserialize(raw: String): ServerEntry? {
@@ -42,6 +48,7 @@ data class ServerEntry(
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
port = parts.getOrElse(2) { "" },
password = parts.getOrElse(3) { "" },
+ name = parts.getOrElse(4) { "" },
)
}
}
@@ -53,6 +60,7 @@ class ServerPreferences(private val context: Context) {
private val activeHttpsKey = booleanPreferencesKey("active_https")
private val activePortKey = stringPreferencesKey("active_port")
private val activePasswordKey = stringPreferencesKey("active_password")
+ private val activeNameKey = stringPreferencesKey("active_name")
private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen")
@@ -63,6 +71,7 @@ class ServerPreferences(private val context: Context) {
useHttps = prefs[activeHttpsKey] ?: false,
port = prefs[activePortKey] ?: "",
password = prefs[activePasswordKey] ?: "",
+ name = prefs[activeNameKey] ?: "",
)
}
@@ -81,6 +90,7 @@ class ServerPreferences(private val context: Context) {
prefs[activeHttpsKey] = server.useHttps
prefs[activePortKey] = server.port
prefs[activePasswordKey] = server.password
+ prefs[activeNameKey] = server.name
}
addSavedServer(server)
}
@@ -91,6 +101,7 @@ class ServerPreferences(private val context: Context) {
prefs.remove(activeHttpsKey)
prefs.remove(activePortKey)
prefs.remove(activePasswordKey)
+ prefs.remove(activeNameKey)
}
}
@@ -104,7 +115,16 @@ class ServerPreferences(private val context: Context) {
suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
- prefs[savedServersKey] = current - server.serialize()
+ // Match by connection identity (address/port/scheme) rather than the
+ // exact serialized string, so a rename — or the legacy 4-field format
+ // saved before names existed — still removes the right entry.
+ prefs[savedServersKey] = current.filterNot { raw ->
+ val e = ServerEntry.deserialize(raw)
+ e != null &&
+ e.address == server.address &&
+ e.port == server.port &&
+ e.useHttps == server.useHttps
+ }.toSet()
}
}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt
index 8f7e043c..ec40e657 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt
@@ -108,7 +108,9 @@ private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) {
.pointerInput(key) {
detectTapGestures(onPress = {
p = true; onDir(key)
- job = scope.launch { delay(350); while (true) { onDir(key); delay(100) } }
+ // 500ms initial delay so a normal tap sends one key, not two
+ // (a touch tap often exceeds 350ms → doubled nav sound).
+ job = scope.launch { delay(500); while (true) { onDir(key); delay(100) } }
tryAwaitRelease(); p = false; job?.cancel()
})
},
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 4b18b23c..05751f9c 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
@@ -83,13 +83,15 @@ val ClassicPalette = NESPalette(
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
)
+// Glassmorphism-black (OS design): #0A0A0A surfaces, subtle white-alpha borders,
+// translucent-white buttons. Accents come from each button's ring.
val DarkPalette = NESPalette(
- body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge,
- label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted,
- dpad = Color(0xFF080808), dpadHi = Color(0xFF141418),
- btn = NES.DarkButtonMain, btnPress = NES.DarkButtonMainPress,
- capsule = Color(0xFF121216), capsulePress = Color(0xFF0A0A0C),
- inlayBg = Color(0xFF060608), inlayBorder = Color(0xFF444448),
+ body = Color(0xFF0D0D0F), face = Color(0xFF0A0A0A), ridge = Color(0x14FFFFFF),
+ label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777),
+ dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A),
+ btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF),
+ capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF),
+ inlayBg = Color(0xFF0A0A0A), inlayBorder = Color(0x1FFFFFFF),
)
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
@@ -113,7 +115,6 @@ fun NESController(
Box(
modifier = modifier
.fillMaxSize()
- .background(Color(0xFF0C0C0C)) // Slightly lighter than black for shadow visibility
.twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
@@ -193,13 +194,13 @@ fun NESController(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
- // C on top (white)
- ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 44.dp) { onKey("c") }
+ // C on top
+ GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
// B + A on bottom row
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 44.dp) { onKey("b") }
- ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 44.dp) { onKey("a") }
+ GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") }
+ GlassFaceBtn("A", Color(0xFFF7931A), 44.dp) { onKey("a") }
}
}
}
@@ -264,7 +265,9 @@ fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
}
activeDir = dir; onDir(dir)
job?.cancel()
- job = scope.launch { delay(300); while (true) { onDir(dir); delay(90) } }
+ // 500ms initial delay so a normal tap sends one key, not
+ // two (a touch tap often exceeds 300ms → doubled nav sound).
+ job = scope.launch { delay(500); while (true) { onDir(dir); delay(90) } }
tryAwaitRelease()
job?.cancel(); activeDir = null
},
@@ -375,6 +378,28 @@ fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Uni
}
}
+/** Glass face button — dark translucent fill, colored ring + letter (OS style) */
+@Composable
+fun GlassFaceBtn(label: String, accent: Color, sz: Dp = 44.dp, onClick: () -> Unit) {
+ var p by remember { mutableStateOf(false) }
+ Box(
+ Modifier
+ .size(sz)
+ .clip(CircleShape)
+ .background(
+ Brush.verticalGradient(
+ if (p) listOf(Color.White.copy(alpha = 0.05f), Color.White.copy(alpha = 0.02f))
+ else listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
+ )
+ )
+ .border(1.5.dp, accent.copy(alpha = if (p) 0.95f else 0.55f), CircleShape)
+ .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(label, color = accent.copy(alpha = if (p) 1f else 0.85f), fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ }
+}
+
/** START/SELECT capsule */
@Composable
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt
index d30805fd..ca827a5e 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt
@@ -3,6 +3,8 @@ package com.archipelago.app.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -34,17 +36,35 @@ 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.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.data.ServerEntry
+import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
-import com.archipelago.app.ui.theme.NES
+import com.archipelago.app.ui.theme.SurfaceDark
+import com.archipelago.app.ui.theme.TextMuted
+import com.archipelago.app.ui.theme.TextPrimary
-/** NES-styled modal menu — dark blue panel with white borders */
+// Glassmorphism palette (OS design): near-black surfaces, subtle white borders,
+// Bitcoin-orange accent.
+private val PanelBg = SurfaceDark // #0A0A0A
+private val PanelBorder = Color.White.copy(alpha = 0.12f)
+private val RowBg = Color.White.copy(alpha = 0.05f)
+private val RowBorder = Color.White.copy(alpha = 0.08f)
+private val FieldBg = Color.White.copy(alpha = 0.04f)
+
+private val PANEL_R = 20.dp
+private val ROW_R = 14.dp
+private val ROW_H = 54.dp
+private val FIELD_H = 58.dp
+
+/** Glassmorphism modal menu — #0A0A0A surface, subtle white borders. */
@Composable
fun NESMenu(
visible: Boolean,
@@ -66,7 +86,9 @@ fun NESMenu(
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center,
) {
- MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
+ AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
+ MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
+ }
}
}
}
@@ -86,29 +108,45 @@ private fun MenuPanel(
onBackToWebView: (() -> Unit)?,
) {
var showAdd by remember { mutableStateOf(false) }
+ var nm by remember { mutableStateOf("") }
var addr by remember { mutableStateOf("") }
var pwd by remember { mutableStateOf("") }
+ fun submit() {
+ if (addr.isNotBlank()) {
+ onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
+ nm = ""; addr = ""; pwd = ""; showAdd = false
+ }
+ }
+
Column(
modifier = Modifier
- .widthIn(max = 360.dp)
- .clip(RoundedCornerShape(4.dp))
- .background(NES.MenuPanel)
- .border(3.dp, NES.MenuBorder, RoundedCornerShape(4.dp))
+ .widthIn(max = 420.dp)
+ .padding(horizontal = 20.dp)
+ .clip(RoundedCornerShape(PANEL_R))
+ .background(PanelBg)
+ .border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(6.dp),
+ .padding(22.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// Title
- Text("- MENU -", color = NES.MenuText, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp,
- modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
- Spacer(Modifier.height(4.dp))
+ Text(
+ "Menu",
+ color = TextPrimary,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 2.sp,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(2.dp))
// Servers
servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize()
MenuItem(
- label = (if (active) "\u25B6 " else " ") + server.address,
+ label = server.displayName(),
selected = active,
onClick = { onSelectServer(server) },
onRemove = { onRemoveServer(server) },
@@ -116,69 +154,70 @@ private fun MenuPanel(
}
if (servers.isEmpty()) {
- Text(" NO SERVERS", color = NES.MenuMuted, fontSize = 11.sp, modifier = Modifier.padding(vertical = 4.dp))
+ Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
}
// Add server
if (showAdd) {
Column(
- Modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.3f)).padding(8.dp),
- verticalArrangement = Arrangement.spacedBy(6.dp),
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(ROW_R))
+ .background(FieldBg)
+ .border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
) {
- OutlinedTextField(
- value = addr, onValueChange = { addr = it.trim() },
- placeholder = { Text("192.168.1.100", color = NES.MenuMuted, fontSize = 11.sp) },
- modifier = Modifier.fillMaxWidth().height(48.dp), singleLine = true,
- textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
- colors = nesFieldColors(),
- shape = RoundedCornerShape(2.dp),
+ GlassField(
+ value = nm, onValueChange = { nm = it },
+ placeholder = "Name (optional)",
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
- Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
- OutlinedTextField(
+ GlassField(
+ value = addr, onValueChange = { addr = it.trim() },
+ placeholder = "192.168.1.100",
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ GlassField(
value = pwd, onValueChange = { pwd = it },
- placeholder = { Text("PASSWORD", color = NES.MenuMuted, fontSize = 11.sp) },
- modifier = Modifier.weight(1f).height(48.dp), singleLine = true,
+ placeholder = "Password",
+ modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
- keyboardActions = KeyboardActions(onGo = {
- if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
- }),
- textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
- colors = nesFieldColors(),
- shape = RoundedCornerShape(2.dp),
+ keyboardActions = KeyboardActions(onGo = { submit() }),
)
Box(
- Modifier.size(48.dp).clip(RoundedCornerShape(2.dp)).background(NES.MenuSelected)
- .clickable {
- if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
- },
+ Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
+ .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
+ .clickable { submit() },
contentAlignment = Alignment.Center,
- ) { Text("OK", color = NES.MenuText, fontSize = 10.sp, fontWeight = FontWeight.Bold) }
+ ) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
}
}
} else {
- MenuItem(label = " ADD SERVER", onClick = { showAdd = true })
+ MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
Spacer(Modifier.height(2.dp))
- Box(Modifier.fillMaxWidth().height(1.dp).background(NES.MenuBorder.copy(alpha = 0.3f)))
+ Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
Spacer(Modifier.height(2.dp))
// Mode toggle
MenuItem(
- label = if (isGamepadMode) " SWITCH TO KEYBOARD" else " SWITCH TO GAMEPAD",
+ label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
onClick = onToggleMode,
)
// Style toggle
MenuItem(
- label = if (controllerStyle == ControllerStyle.CLASSIC) " STYLE: CLASSIC" else " STYLE: DARK",
+ label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
onClick = onToggleStyle,
)
// Back to dashboard
if (onBackToWebView != null) {
- MenuItem(label = " BACK TO DASHBOARD", onClick = onBackToWebView)
+ MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
}
}
}
@@ -187,32 +226,69 @@ private fun MenuPanel(
private fun MenuItem(
label: String,
selected: Boolean = false,
+ labelColor: Color = TextPrimary,
onClick: () -> Unit,
onRemove: (() -> Unit)? = null,
) {
Row(
Modifier
.fillMaxWidth()
- .height(32.dp)
- .background(if (selected) NES.MenuSelected.copy(alpha = 0.15f) else Color.Transparent)
+ .height(ROW_H)
+ .clip(RoundedCornerShape(ROW_R))
+ .background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
+ .border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() }
- .padding(horizontal = 8.dp),
+ .padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
- Text(label, color = if (selected) NES.MenuSelected else NES.MenuText, fontSize = 11.sp, fontWeight = FontWeight.Medium)
+ Text(
+ label,
+ color = if (selected) BitcoinOrange else labelColor,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ )
if (onRemove != null) {
- Text("\u2715", color = NES.MenuMuted, fontSize = 10.sp,
- modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp))
+ Text(
+ "✕",
+ color = TextMuted,
+ fontSize = 16.sp,
+ modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp),
+ )
}
}
}
+/** Glass text field with centered input text. */
@Composable
-private fun nesFieldColors() = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = NES.MenuBorder,
- unfocusedBorderColor = NES.MenuMuted,
- cursorColor = NES.MenuText,
- focusedTextColor = NES.MenuText,
- unfocusedTextColor = NES.MenuText,
-)
+private fun GlassField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ placeholder: String,
+ modifier: Modifier = Modifier,
+ visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ placeholder = {
+ Text(placeholder, color = TextMuted, fontSize = 15.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
+ },
+ modifier = modifier.fillMaxWidth().height(FIELD_H),
+ singleLine = true,
+ visualTransformation = visualTransformation,
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, textAlign = TextAlign.Center),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color.White.copy(alpha = 0.3f),
+ unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
+ cursorColor = BitcoinOrange,
+ focusedTextColor = TextPrimary,
+ unfocusedTextColor = TextPrimary,
+ ),
+ shape = RoundedCornerShape(12.dp),
+ )
+}
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
index 8894c8c8..2193d4b8 100644
--- 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
@@ -50,7 +50,6 @@ fun NESPortraitController(
Box(
Modifier
.fillMaxSize()
- .background(Color(0xFF0C0C0C))
.twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
@@ -119,11 +118,11 @@ fun NESPortraitController(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
- ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 46.dp) { onKey("c") }
+ GlassFaceBtn("C", Color(0xFFBBBBBB), 46.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
- ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 46.dp) { onKey("b") }
- ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 46.dp) { onKey("a") }
+ GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") }
+ GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") }
}
}
}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt
index 56a860c3..5426a825 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt
@@ -23,6 +23,7 @@ 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
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -41,7 +42,7 @@ 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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -67,26 +68,45 @@ fun IntroScreen(onContinue: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
- .background(SurfaceBlack)
- .windowInsetsPadding(WindowInsets.safeDrawing),
- contentAlignment = Alignment.Center,
+ .background(SurfaceBlack),
) {
+ // Reddish synthwave backdrop
+ Image(
+ painter = painterResource(id = R.drawable.bg_synthwave),
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ // Dark scrim so the title/buttons stay legible over the art
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ Color.Black.copy(alpha = 0.55f),
+ Color.Black.copy(alpha = 0.35f),
+ Color.Black.copy(alpha = 0.75f),
+ ),
+ )
+ ),
+ )
Column(
modifier = Modifier
+ .align(Alignment.Center)
.fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
- // Wide pixel-art logo
+ // Circular badge logo
Image(
- painter = painterResource(id = R.drawable.ic_logo_wide),
+ painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp)
+ .size(160.dp)
.alpha(logoAlpha.value),
- colorFilter = ColorFilter.tint(Color.White),
)
Spacer(modifier = Modifier.height(48.dp))
@@ -102,7 +122,7 @@ fun IntroScreen(onContinue: () -> Unit) {
Text(
text = stringResource(R.string.welcome_title),
style = MaterialTheme.typography.headlineLarge,
- color = TextPrimary,
+ color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center,
)
@@ -111,7 +131,7 @@ fun IntroScreen(onContinue: () -> Unit) {
Text(
text = stringResource(R.string.welcome_subtitle),
style = MaterialTheme.typography.bodyLarge,
- color = TextMuted,
+ color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center,
lineHeight = 26.sp,
)
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 4e743eb7..94322deb 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
@@ -2,6 +2,7 @@ package com.archipelago.app.ui.screens
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -24,13 +25,17 @@ 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.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
+import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.network.ConnectionState
import com.archipelago.app.network.InputWebSocket
@@ -58,7 +63,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
var isGamepadMode by remember { mutableStateOf(true) }
var showModal by remember { mutableStateOf(false) }
- var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
+ var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
val ws = remember { InputWebSocket(scope) }
@@ -113,9 +118,31 @@ fun RemoteInputScreen(onBack: () -> Unit) {
Box(
Modifier
.fillMaxSize()
- .background(Color(0xFF0C0C0C))
- .windowInsetsPadding(WindowInsets.safeDrawing),
+ .background(Color(0xFF0C0C0C)),
) {
+ // Reddish synthwave backdrop behind the controller
+ Image(
+ painter = painterResource(id = R.drawable.bg_synthwave),
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ // Light scrim — the controller body provides its own contrast, so keep
+ // this subtle and let the backdrop show through around it.
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ Color.Black.copy(alpha = 0.4f),
+ Color.Black.copy(alpha = 0.25f),
+ Color.Black.copy(alpha = 0.45f),
+ ),
+ )
+ ),
+ )
+ Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
when {
isGamepadMode && isLandscape -> NESController(
style = controllerStyle,
@@ -174,6 +201,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
}
),
)
+ }
NESMenu(
visible = showModal,
@@ -188,7 +216,20 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onAddServer = { server ->
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
},
- onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } },
+ onRemoveServer = { server ->
+ scope.launch {
+ prefs.removeSavedServer(server)
+ // Deleting the last server leaves nothing to control — drop the
+ // active server and return to the Connect screen.
+ val remaining = savedServers.count { it.serialize() != server.serialize() }
+ if (remaining == 0) {
+ ws.disconnect()
+ prefs.clearActiveServer()
+ showModal = false
+ onBack()
+ }
+ }
+ },
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
onToggleStyle = {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt
index 0712f825..738eb4a8 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt
@@ -55,6 +55,7 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
@@ -97,6 +98,7 @@ fun ServerConnectScreen(
val scope = rememberCoroutineScope()
val keyboard = LocalSoftwareKeyboardController.current
+ var name by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") }
var port by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
@@ -132,12 +134,33 @@ fun ServerConnectScreen(
Box(
modifier = Modifier
.fillMaxSize()
- .background(SurfaceBlack)
- .windowInsetsPadding(WindowInsets.safeDrawing),
+ .background(SurfaceBlack),
) {
+ // Reddish synthwave backdrop
+ Image(
+ painter = painterResource(id = R.drawable.bg_synthwave),
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ // Dark scrim so the form stays legible over the art
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ Color.Black.copy(alpha = 0.6f),
+ Color.Black.copy(alpha = 0.45f),
+ Color.Black.copy(alpha = 0.8f),
+ ),
+ )
+ ),
+ )
Column(
modifier = Modifier
.fillMaxSize()
+ .windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(state = rememberScrollState())
.drawWithContent { drawContent() }
.padding(horizontal = 24.dp)
@@ -145,14 +168,11 @@ fun ServerConnectScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
- // Wide logo
+ // Circular badge logo
Image(
- painter = painterResource(id = R.drawable.ic_logo_wide),
+ painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- colorFilter = ColorFilter.tint(Color.White),
+ modifier = Modifier.size(96.dp),
)
Spacer(modifier = Modifier.height(4.dp))
@@ -178,6 +198,7 @@ fun ServerConnectScreen(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
+ .background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
@@ -190,6 +211,34 @@ fun ServerConnectScreen(
.padding(20.dp),
) {
Column {
+ OutlinedTextField(
+ value = name,
+ onValueChange = {
+ name = it
+ errorMessage = null
+ },
+ label = { Text(stringResource(R.string.server_name_label)) },
+ placeholder = { Text(stringResource(R.string.server_name_placeholder)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Next,
+ ),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color.White.copy(alpha = 0.3f),
+ unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
+ cursorColor = Color.White,
+ focusedLabelColor = Color.White.copy(alpha = 0.7f),
+ unfocusedLabelColor = TextMuted,
+ focusedTextColor = TextPrimary,
+ unfocusedTextColor = TextPrimary,
+ ),
+ shape = RoundedCornerShape(12.dp),
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
OutlinedTextField(
value = address,
onValueChange = {
@@ -275,7 +324,7 @@ fun ServerConnectScreen(
keyboardActions = KeyboardActions(
onGo = {
keyboard?.hide()
- connect(ServerEntry(address, useHttps, port, password))
+ connect(ServerEntry(address, useHttps, port, password, name))
},
),
colors = OutlinedTextFieldDefaults.colors(
@@ -345,7 +394,7 @@ fun ServerConnectScreen(
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
onClick = {
keyboard?.hide()
- connect(ServerEntry(address, useHttps, port, password))
+ connect(ServerEntry(address, useHttps, port, password, name))
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
@@ -391,6 +440,7 @@ private fun SavedServerItem(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
+ .background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
@@ -414,9 +464,15 @@ private fun SavedServerItem(
)
Spacer(modifier = Modifier.width(12.dp))
Column {
- Text(text = server.address, style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
- if (server.port.isNotBlank()) {
- Text(text = "Port ${server.port}", style = MaterialTheme.typography.labelMedium, color = TextMuted)
+ Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ val secondary = buildString {
+ if (server.name.isNotBlank()) append(server.address)
+ if (server.port.isNotBlank()) {
+ if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
+ }
+ }
+ if (secondary.isNotBlank()) {
+ Text(text = secondary, style = MaterialTheme.typography.labelMedium, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
diff --git a/Android/app/src/main/res/drawable/bg_synthwave.jpg b/Android/app/src/main/res/drawable/bg_synthwave.jpg
new file mode 100644
index 00000000..2f3afb80
Binary files /dev/null and b/Android/app/src/main/res/drawable/bg_synthwave.jpg differ
diff --git a/Android/app/src/main/res/drawable/ic_launcher_background.xml b/Android/app/src/main/res/drawable/ic_launcher_background.xml
index 2fc77436..539a3dc0 100644
--- a/Android/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/Android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -5,6 +5,6 @@
android:viewportWidth="108"
android:viewportHeight="108">
diff --git a/Android/app/src/main/res/drawable/ic_launcher_foreground.xml b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml
index f631630b..88405981 100644
--- a/Android/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,45 +1,20 @@
-
+
+ android:viewportWidth="752"
+ android:viewportHeight="752">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:pivotX="376"
+ android:pivotY="376"
+ android:scaleX="0.72"
+ android:scaleY="0.72">
+
diff --git a/Android/app/src/main/res/drawable/ic_logo.xml b/Android/app/src/main/res/drawable/ic_logo.xml
new file mode 100644
index 00000000..275ad1d6
--- /dev/null
+++ b/Android/app/src/main/res/drawable/ic_logo.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml
index d28fcec6..a6fadd29 100644
--- a/Android/app/src/main/res/values/strings.xml
+++ b/Android/app/src/main/res/values/strings.xml
@@ -23,4 +23,6 @@
Use your phone as a keyboard and mouse for the kiosk
Close
Open in browser
+ Server Name (optional)
+ My Archipelago
diff --git a/Android/logo.svg b/Android/logo.svg
new file mode 100644
index 00000000..f218f5a4
--- /dev/null
+++ b/Android/logo.svg
@@ -0,0 +1,10 @@
+