feat: companion app improvements and intro overlay

Android: NES controller/keyboard enhancements, WebSocket reconnect,
portrait mode. Backend: remote input handler updates. UI: companion
intro overlay on dashboard, relay improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-11 20:00:05 +01:00
parent 9d013dbcb5
commit 1807ceeebd
10 changed files with 251 additions and 33 deletions

View File

@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionCode = 4
versionName = "0.4.0"
vectorDrawables {
useSupportLibrary = true

View File

@ -32,6 +32,9 @@ class InputWebSocket(
private var password: String = ""
private var sessionCookie: String? = null
/** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */
var playerId: Int = 0
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
val state: StateFlow<ConnectionState> = _state
@ -109,10 +112,11 @@ class InputWebSocket(
}
private fun doConnect() {
val basePath = "/ws/remote-input" + if (playerId > 0) "?p=$playerId" else ""
val wsUrl = serverUrl
.replace("https://", "wss://")
.replace("http://", "ws://")
.trimEnd('/') + "/ws/remote-input"
.trimEnd('/') + basePath
val reqBuilder = Request.Builder().url(wsUrl)
sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") }
@ -160,7 +164,8 @@ class InputWebSocket(
// ─── Input senders ──────────────────────────────────────────
fun sendKey(key: String) {
ws?.send("""{"t":"k","k":"$key"}""")
val pField = if (playerId > 0) ""","p":$playerId""" else ""
ws?.send("""{"t":"k","k":"$key"$pField}""")
}
fun sendMouseMove(dx: Int, dy: Int) {

View File

@ -101,8 +101,10 @@ fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) C
@Composable
fun NESController(
style: ControllerStyle = ControllerStyle.CLASSIC,
playerId: Int = 0,
onKey: (String) -> Unit,
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val c = paletteFor(style)
@ -205,8 +207,15 @@ fun NESController(
}
}
// Settings button (bottom center)
SettingsBtn(c, Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp), onMenu)
// Player toggle + settings (bottom center)
Row(
Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PlayerPill(c, playerId, onPlayerToggle)
SettingsBtn(c, Modifier, onMenu)
}
}
}
}
@ -370,19 +379,39 @@ fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onCli
}
}
/** Small settings gear button */
/** Settings gear button (48dp — large enough for easy tap on TV) */
@Composable
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(24.dp)
.size(48.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)
Icon(Icons.Default.Settings, "Settings", Modifier.size(28.dp), tint = c.labelMuted)
}
}
/** Player ID toggle pill (P1/P2/ALL) */
@Composable
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
val label = when (playerId) { 1 -> "P1"; 2 -> "P2"; else -> "ALL" }
val accent = when (playerId) { 1 -> Color(0xFF00F0FF); 2 -> Color(0xFFFF0080); else -> c.labelMuted }
var p by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.height(28.dp)
.width(44.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (p) c.capsulePress else c.capsule)
.border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(6.dp))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onToggle(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = accent, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
}
}

View File

@ -55,9 +55,15 @@ fun NESKeyboard(
var layer by remember { mutableStateOf(NKLayer.ALPHA) }
var shifted by remember { mutableStateOf(false) }
var capsLock by remember { mutableStateOf(false) }
var ctrlHeld by remember { mutableStateOf(false) }
val up = shifted || capsLock
fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false }
fun emit(k: String) {
val key = if (ctrlHeld) "ctrl+$k" else k
onKey(key)
if (shifted && !capsLock) shifted = false
if (ctrlHeld) ctrlHeld = false
}
fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) }
// NES body wrapping keyboard
@ -113,9 +119,12 @@ fun NESKeyboard(
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("Ctrl", Modifier.weight(1.2f), keyBg, keyBgP, if (ctrlHeld) accent else keyTxt, 11) {
ctrlHeld = !ctrlHeld
}
NKey(",", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("comma") }
NKey("space", Modifier.weight(4f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
NKey(".", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("period") }
NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") }
}
}

View File

@ -36,11 +36,13 @@ import com.archipelago.app.ui.theme.NES
@Composable
fun NESPortraitController(
style: ControllerStyle = ControllerStyle.CLASSIC,
playerId: Int = 0,
onKey: (String) -> Unit,
onMouseMove: (Int, Int) -> Unit = { _, _ -> },
onMouseClick: (Int) -> Unit = { _ -> },
onMouseScroll: (Int) -> Unit = { _ -> },
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
@ -139,8 +141,16 @@ fun NESPortraitController(
Spacer(Modifier.height(6.dp))
// Settings
SettingsBtn(c, Modifier, onMenu)
// Player toggle + Settings
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
PlayerPill(c, playerId, onPlayerToggle)
Spacer(Modifier.width(10.dp))
SettingsBtn(c, Modifier, onMenu)
}
}
}
}

View File

@ -59,8 +59,14 @@ fun RemoteInputScreen(onBack: () -> Unit) {
var isGamepadMode by remember { mutableStateOf(true) }
var showModal by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
val ws = remember { InputWebSocket(scope) }
fun togglePlayer() {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId
}
val connectionState by ws.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
@ -98,32 +104,44 @@ fun RemoteInputScreen(onBack: () -> Unit) {
when {
isGamepadMode && isLandscape -> NESController(
style = controllerStyle,
playerId = playerId,
onKey = { ws.sendKey(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
)
isGamepadMode && !isLandscape -> NESPortraitController(
style = controllerStyle,
playerId = playerId,
onKey = { ws.sendKey(it) },
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onMouseClick = { ws.sendClick(it) },
onMouseScroll = { ws.sendScroll(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
)
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(),
Box(Modifier.fillMaxSize()) {
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(),
)
}
// Settings icon top-right in keyboard mode
com.archipelago.app.ui.components.SettingsBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
onClick = { showModal = true },
)
}
}

View File

@ -55,7 +55,13 @@ fn validate_key(key: &str) -> bool {
#[serde(tag = "t")]
enum InputCommand {
#[serde(rename = "k")]
Key { k: String },
Key {
k: String,
/// Optional player ID (1 or 2) for multi-player arcade games.
/// When absent, input is broadcast without player tagging.
#[serde(default)]
p: Option<u8>,
},
#[serde(rename = "m")]
MouseMove { x: i32, y: i32 },
#[serde(rename = "c")]
@ -86,7 +92,7 @@ async fn handle_input(msg: &str) -> Result<Option<String>> {
.context("invalid input command")?;
match cmd {
InputCommand::Key { ref k } => {
InputCommand::Key { ref k, .. } => {
if !validate_key(k) {
warn!("rejected key: {}", k);
return Ok(Some(r#"{"t":"e","m":"invalid key"}"#.to_string()));
@ -124,6 +130,13 @@ impl ApiHandler {
req: Request<hyper::Body>,
relay_tx: broadcast::Sender<String>,
) -> Result<Response<hyper::Body>> {
// Extract optional player ID from query string: /ws/remote-input?p=1
let player_id: Option<u8> = req.uri().query()
.and_then(|q| q.split('&').find(|s| s.starts_with("p=")))
.and_then(|s| s.get(2..))
.and_then(|v| v.parse().ok())
.filter(|&p: &u8| p == 1 || p == 2);
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
@ -185,8 +198,28 @@ impl ApiHandler {
continue; // silently drop
}
// Always relay to browser clients (remote browser sessions)
let _ = relay_tx.send(text.clone());
// Relay to browser clients. If this connection has a
// player ID from query string and the message is a key
// event without a player field, inject it so the browser
// can route input to the correct player.
let relay_text = if let Some(pid) = player_id {
if text.contains(r#""t":"k""#) && !text.contains(r#""p":"#) {
// Insert "p":N before the closing brace
if let Some(pos) = text.rfind('}') {
let mut tagged = text[..pos].to_string();
tagged.push_str(&format!(r#","p":{}"#, pid));
tagged.push('}');
tagged
} else {
text.clone()
}
} else {
text.clone()
}
} else {
text.clone()
};
let _ = relay_tx.send(relay_text);
match handle_input(&text).await {
Ok(Some(reply)) => {

View File

@ -99,7 +99,7 @@ function mapKey(xdotoolKey: string): string {
}
function handleMessage(data: string) {
let msg: { t: string; k?: string; x?: number; y?: number; b?: number }
let msg: { t: string; k?: string; x?: number; y?: number; b?: number; p?: number }
try {
msg = JSON.parse(data)
} catch {
@ -114,6 +114,18 @@ function handleMessage(data: string) {
case 'k': {
if (!msg.k) break
const key = mapKey(msg.k)
// Dispatch player-tagged event for arcade/game apps (iframe postMessage or direct listeners)
const player = msg.p ?? 0 // 0 = untagged/broadcast, 1 = P1, 2 = P2
document.dispatchEvent(new CustomEvent('arcade-input', {
detail: { key, player, type: 'down' },
bubbles: true,
}))
// Also post to any iframe that might be listening (containerized apps like BotFights)
const iframe = document.querySelector('iframe') as HTMLIFrameElement | null
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type: 'arcade-input', key, player, action: 'down' }, '*')
}
// Keep existing keydown/keyup for backward compat with non-arcade UI navigation
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
break

View File

@ -0,0 +1,98 @@
<template>
<Teleport to="body">
<Transition name="overlay-fade">
<div
v-if="visible"
class="fixed inset-0 flex items-end sm:items-center justify-center p-4 z-[3000]"
@click.self="dismiss"
>
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
@click.stop
>
<!-- Icon -->
<div class="flex justify-center mb-3">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
</svg>
</div>
</div>
<h3 class="text-lg font-semibold text-white text-center mb-2">Remote Companion</h3>
<p class="text-sm text-white/60 text-center mb-4 leading-relaxed">
Control your node from another device. Install the
<span class="text-orange-400 font-medium">Archipelago</span>
companion app on your phone, connect to the same network, and use it as a
gamepad or keyboard.
</p>
<div class="flex flex-col gap-2 text-xs text-white/40">
<div class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">1</span>
<span>Install the APK on your phone</span>
</div>
<div class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">2</span>
<span>Enter your node address and password</span>
</div>
<div class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">3</span>
<span>Use D-pad &amp; buttons or keyboard to control apps</span>
</div>
</div>
<button
class="mt-4 w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30
text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors"
@click="dismiss"
>
Got it
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const STORAGE_KEY = 'neode_companion_intro_seen'
const visible = ref(false)
onMounted(() => {
try {
if (localStorage.getItem(STORAGE_KEY) !== '1') {
// Delay slightly so it doesn't compete with login animation
setTimeout(() => { visible.value = true }, 5000)
}
} catch {
// localStorage unavailable
}
})
function dismiss() {
visible.value = false
try {
localStorage.setItem(STORAGE_KEY, '1')
} catch {
// ignore
}
}
</script>
<style scoped>
.overlay-fade-enter-active { transition: opacity 0.3s ease; }
.overlay-fade-leave-active { transition: opacity 0.2s ease; }
.overlay-fade-enter-from,
.overlay-fade-leave-to { opacity: 0; }
.overlay-fade-enter-active .glass-card { transition: transform 0.3s ease; }
.overlay-fade-enter-from .glass-card { transform: translateY(20px); }
</style>

View File

@ -121,6 +121,9 @@
<!-- Health Notifications Toast -->
<HealthNotifications />
<!-- First-use companion intro overlay -->
<CompanionIntroOverlay />
</div>
</template>
@ -137,6 +140,7 @@ import DashboardSidebar from '@/views/dashboard/DashboardSidebar.vue'
import DashboardMobileNav from '@/views/dashboard/DashboardMobileNav.vue'
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
import '@/views/dashboard/dashboard-styles.css'