From c421fdb064ad7675e0b0b87d9495125e1ebf9360 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 11 Apr 2026 20:00:05 +0100 Subject: [PATCH] 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 --- Android/app/build.gradle.kts | 4 +- .../archipelago/app/network/InputWebSocket.kt | 9 +- .../app/ui/components/NESController.kt | 39 +++++++- .../app/ui/components/NESKeyboard.kt | 17 +++- .../ui/components/NESPortraitController.kt | 14 ++- .../app/ui/screens/RemoteInputScreen.kt | 44 ++++++--- .../src/api/handler/remote_input.rs | 41 +++++++- neode-ui/src/api/remote-relay.ts | 14 ++- .../src/components/CompanionIntroOverlay.vue | 98 +++++++++++++++++++ neode-ui/src/views/Dashboard.vue | 4 + 10 files changed, 251 insertions(+), 33 deletions(-) create mode 100644 neode-ui/src/components/CompanionIntroOverlay.vue diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index d788d408..e761858c 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -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 diff --git a/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt b/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt index af389534..40703d46 100644 --- a/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt +++ b/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt @@ -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 = _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) { 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 a5c7742f..19e75422 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 @@ -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) } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt index 84cf4cd2..f5b1c756 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt @@ -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") } } } 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 476b9be8..662d160a 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 @@ -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) + } } } } 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 841fa2d8..97052dc6 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 @@ -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 }, ) } } diff --git a/core/archipelago/src/api/handler/remote_input.rs b/core/archipelago/src/api/handler/remote_input.rs index a7a535e8..123cb24f 100644 --- a/core/archipelago/src/api/handler/remote_input.rs +++ b/core/archipelago/src/api/handler/remote_input.rs @@ -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, + }, #[serde(rename = "m")] MouseMove { x: i32, y: i32 }, #[serde(rename = "c")] @@ -86,7 +92,7 @@ async fn handle_input(msg: &str) -> Result> { .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, relay_tx: broadcast::Sender, ) -> Result> { + // Extract optional player ID from query string: /ws/remote-input?p=1 + let player_id: Option = 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)) => { diff --git a/neode-ui/src/api/remote-relay.ts b/neode-ui/src/api/remote-relay.ts index c8848b1b..2c21caaf 100644 --- a/neode-ui/src/api/remote-relay.ts +++ b/neode-ui/src/api/remote-relay.ts @@ -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 diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue new file mode 100644 index 00000000..fcaac655 --- /dev/null +++ b/neode-ui/src/components/CompanionIntroOverlay.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index 9ed860b9..a6bb7dfd 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -121,6 +121,9 @@ + + + @@ -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'