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:
parent
9d013dbcb5
commit
1807ceeebd
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 4
|
||||||
versionName = "0.1.0"
|
versionName = "0.4.0"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@ -32,6 +32,9 @@ class InputWebSocket(
|
|||||||
private var password: String = ""
|
private var password: String = ""
|
||||||
private var sessionCookie: String? = null
|
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)
|
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||||
val state: StateFlow<ConnectionState> = _state
|
val state: StateFlow<ConnectionState> = _state
|
||||||
|
|
||||||
@ -109,10 +112,11 @@ class InputWebSocket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun doConnect() {
|
private fun doConnect() {
|
||||||
|
val basePath = "/ws/remote-input" + if (playerId > 0) "?p=$playerId" else ""
|
||||||
val wsUrl = serverUrl
|
val wsUrl = serverUrl
|
||||||
.replace("https://", "wss://")
|
.replace("https://", "wss://")
|
||||||
.replace("http://", "ws://")
|
.replace("http://", "ws://")
|
||||||
.trimEnd('/') + "/ws/remote-input"
|
.trimEnd('/') + basePath
|
||||||
|
|
||||||
val reqBuilder = Request.Builder().url(wsUrl)
|
val reqBuilder = Request.Builder().url(wsUrl)
|
||||||
sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") }
|
sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") }
|
||||||
@ -160,7 +164,8 @@ class InputWebSocket(
|
|||||||
// ─── Input senders ──────────────────────────────────────────
|
// ─── Input senders ──────────────────────────────────────────
|
||||||
|
|
||||||
fun sendKey(key: String) {
|
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) {
|
fun sendMouseMove(dx: Int, dy: Int) {
|
||||||
|
|||||||
@ -101,8 +101,10 @@ fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) C
|
|||||||
@Composable
|
@Composable
|
||||||
fun NESController(
|
fun NESController(
|
||||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||||
|
playerId: Int = 0,
|
||||||
onKey: (String) -> Unit,
|
onKey: (String) -> Unit,
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
|
onPlayerToggle: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
@ -205,8 +207,15 @@ fun NESController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings button (bottom center)
|
// Player toggle + settings (bottom center)
|
||||||
SettingsBtn(c, Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp), onMenu)
|
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
|
@Composable
|
||||||
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
var p by remember { mutableStateOf(false) }
|
var p by remember { mutableStateOf(false) }
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(24.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (p) c.capsulePress else c.capsule)
|
.background(if (p) c.capsulePress else c.capsule)
|
||||||
.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,
|
||||||
) {
|
) {
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -55,9 +55,15 @@ fun NESKeyboard(
|
|||||||
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) }
|
||||||
var capsLock by remember { mutableStateOf(false) }
|
var capsLock by remember { mutableStateOf(false) }
|
||||||
|
var ctrlHeld by remember { mutableStateOf(false) }
|
||||||
val up = shifted || capsLock
|
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) }
|
fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) }
|
||||||
|
|
||||||
// NES body wrapping keyboard
|
// 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) {
|
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
|
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false
|
||||||
}
|
}
|
||||||
NKey(",", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("comma") }
|
NKey("Ctrl", Modifier.weight(1.2f), keyBg, keyBgP, if (ctrlHeld) accent else keyTxt, 11) {
|
||||||
NKey("space", Modifier.weight(5f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
|
ctrlHeld = !ctrlHeld
|
||||||
NKey(".", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("period") }
|
}
|
||||||
|
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") }
|
NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,11 +36,13 @@ import com.archipelago.app.ui.theme.NES
|
|||||||
@Composable
|
@Composable
|
||||||
fun NESPortraitController(
|
fun NESPortraitController(
|
||||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||||
|
playerId: Int = 0,
|
||||||
onKey: (String) -> Unit,
|
onKey: (String) -> Unit,
|
||||||
onMouseMove: (Int, Int) -> Unit = { _, _ -> },
|
onMouseMove: (Int, Int) -> Unit = { _, _ -> },
|
||||||
onMouseClick: (Int) -> Unit = { _ -> },
|
onMouseClick: (Int) -> Unit = { _ -> },
|
||||||
onMouseScroll: (Int) -> Unit = { _ -> },
|
onMouseScroll: (Int) -> Unit = { _ -> },
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
|
onPlayerToggle: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
val isClassic = style == ControllerStyle.CLASSIC
|
val isClassic = style == ControllerStyle.CLASSIC
|
||||||
@ -139,8 +141,16 @@ fun NESPortraitController(
|
|||||||
|
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
// Settings
|
// Player toggle + Settings
|
||||||
SettingsBtn(c, Modifier, onMenu)
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
PlayerPill(c, playerId, onPlayerToggle)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
SettingsBtn(c, Modifier, onMenu)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,8 +59,14 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
var isGamepadMode by remember { mutableStateOf(true) }
|
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) }
|
||||||
|
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
||||||
|
|
||||||
val ws = remember { InputWebSocket(scope) }
|
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 connectionState by ws.state.collectAsState()
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
@ -98,32 +104,44 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
when {
|
when {
|
||||||
isGamepadMode && isLandscape -> NESController(
|
isGamepadMode && isLandscape -> NESController(
|
||||||
style = controllerStyle,
|
style = controllerStyle,
|
||||||
|
playerId = playerId,
|
||||||
onKey = { ws.sendKey(it) },
|
onKey = { ws.sendKey(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
|
onPlayerToggle = ::togglePlayer,
|
||||||
)
|
)
|
||||||
isGamepadMode && !isLandscape -> NESPortraitController(
|
isGamepadMode && !isLandscape -> NESPortraitController(
|
||||||
style = controllerStyle,
|
style = controllerStyle,
|
||||||
|
playerId = playerId,
|
||||||
onKey = { ws.sendKey(it) },
|
onKey = { ws.sendKey(it) },
|
||||||
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||||
onMouseClick = { ws.sendClick(it) },
|
onMouseClick = { ws.sendClick(it) },
|
||||||
onMouseScroll = { ws.sendScroll(it) },
|
onMouseScroll = { ws.sendScroll(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
|
onPlayerToggle = ::togglePlayer,
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
||||||
Column(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Trackpad(
|
Column(Modifier.fillMaxSize()) {
|
||||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
Trackpad(
|
||||||
onClick = { ws.sendClick(it) },
|
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||||
onScroll = { ws.sendScroll(it) },
|
onClick = { ws.sendClick(it) },
|
||||||
onTwoFingerHold = { showModal = true },
|
onScroll = { ws.sendScroll(it) },
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
onTwoFingerHold = { showModal = true },
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||||
)
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
NESKeyboard(
|
)
|
||||||
style = controllerStyle,
|
NESKeyboard(
|
||||||
onKey = { ws.sendKey(it) },
|
style = controllerStyle,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,7 +55,13 @@ fn validate_key(key: &str) -> bool {
|
|||||||
#[serde(tag = "t")]
|
#[serde(tag = "t")]
|
||||||
enum InputCommand {
|
enum InputCommand {
|
||||||
#[serde(rename = "k")]
|
#[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")]
|
#[serde(rename = "m")]
|
||||||
MouseMove { x: i32, y: i32 },
|
MouseMove { x: i32, y: i32 },
|
||||||
#[serde(rename = "c")]
|
#[serde(rename = "c")]
|
||||||
@ -86,7 +92,7 @@ async fn handle_input(msg: &str) -> Result<Option<String>> {
|
|||||||
.context("invalid input command")?;
|
.context("invalid input command")?;
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
InputCommand::Key { ref k } => {
|
InputCommand::Key { ref k, .. } => {
|
||||||
if !validate_key(k) {
|
if !validate_key(k) {
|
||||||
warn!("rejected key: {}", k);
|
warn!("rejected key: {}", k);
|
||||||
return Ok(Some(r#"{"t":"e","m":"invalid key"}"#.to_string()));
|
return Ok(Some(r#"{"t":"e","m":"invalid key"}"#.to_string()));
|
||||||
@ -124,6 +130,13 @@ impl ApiHandler {
|
|||||||
req: Request<hyper::Body>,
|
req: Request<hyper::Body>,
|
||||||
relay_tx: broadcast::Sender<String>,
|
relay_tx: broadcast::Sender<String>,
|
||||||
) -> Result<Response<hyper::Body>> {
|
) -> 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)
|
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||||
|
|
||||||
@ -185,8 +198,28 @@ impl ApiHandler {
|
|||||||
continue; // silently drop
|
continue; // silently drop
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always relay to browser clients (remote browser sessions)
|
// Relay to browser clients. If this connection has a
|
||||||
let _ = relay_tx.send(text.clone());
|
// 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 {
|
match handle_input(&text).await {
|
||||||
Ok(Some(reply)) => {
|
Ok(Some(reply)) => {
|
||||||
|
|||||||
@ -99,7 +99,7 @@ function mapKey(xdotoolKey: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(data: 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 {
|
try {
|
||||||
msg = JSON.parse(data)
|
msg = JSON.parse(data)
|
||||||
} catch {
|
} catch {
|
||||||
@ -114,6 +114,18 @@ function handleMessage(data: string) {
|
|||||||
case 'k': {
|
case 'k': {
|
||||||
if (!msg.k) break
|
if (!msg.k) break
|
||||||
const key = mapKey(msg.k)
|
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('keydown', { key, bubbles: true }))
|
||||||
document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
||||||
break
|
break
|
||||||
|
|||||||
98
neode-ui/src/components/CompanionIntroOverlay.vue
Normal file
98
neode-ui/src/components/CompanionIntroOverlay.vue
Normal 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 & 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>
|
||||||
@ -121,6 +121,9 @@
|
|||||||
|
|
||||||
<!-- Health Notifications Toast -->
|
<!-- Health Notifications Toast -->
|
||||||
<HealthNotifications />
|
<HealthNotifications />
|
||||||
|
|
||||||
|
<!-- First-use companion intro overlay -->
|
||||||
|
<CompanionIntroOverlay />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -137,6 +140,7 @@ import DashboardSidebar from '@/views/dashboard/DashboardSidebar.vue'
|
|||||||
import DashboardMobileNav from '@/views/dashboard/DashboardMobileNav.vue'
|
import DashboardMobileNav from '@/views/dashboard/DashboardMobileNav.vue'
|
||||||
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
||||||
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
||||||
|
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
|
||||||
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
||||||
import '@/views/dashboard/dashboard-styles.css'
|
import '@/views/dashboard/dashboard-styles.css'
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user