diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index 0c029a15..b4564ee9 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -317,9 +317,9 @@ class MainActivity : ComponentActivity() { if (bit != 0) { // The router forwards the bit on this device's own wire pad index and tracks held // state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled - // inside the router: holding it for ~1.5 s fires router.onExitChord (wired in - // StreamScreen), so a couch user with no keyboard/Back can still leave — but an - // accidental brush of the four buttons no longer quits instantly. + // inside the router: holding it briefly (~1 s, with an on-screen hint) fires + // router.onExitChord (wired in StreamScreen), so a couch user with no keyboard/Back + // can still leave — but an accidental brush of the four buttons no longer quits. gamepadRouter?.onButton(event, bit) return true // consumed } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index ee3aba78..1b8c5c66 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -23,10 +23,13 @@ import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -36,9 +39,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat @@ -163,6 +168,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { }.onEach { it.setReferenceCounted(false) } } + // True while the gamepad exit chord (Select+Start+L1+R1) is held and counting down — drives the + // "hold to quit" hint overlay. Set from the router's onExitArmed (main thread). + var exitArming by remember { mutableStateOf(false) } + DisposableEffect(handle) { window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) wifiLocks.forEach { lock -> @@ -205,6 +214,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { // the same way the Back gesture does. activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } router.onExitChord = { activity?.requestStreamExit?.invoke() } + // Show a "hold to quit" hint the moment the chord completes (the router debounces the actual + // exit); it clears when the buttons release early or the hold elapses. Runs on the main thread. + router.onExitArmed = { armed -> exitArming = armed } activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate // Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad // index via the router; poll threads stopped + joined before the router is released and the @@ -274,6 +286,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } } sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down) + router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener activity?.gamepadRouter = null activity?.streamHandle = 0L @@ -356,6 +369,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) } } + // "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s + // hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as + // broken. Purely visual; it sits above the video and below the gesture layer. + if (exitArming) { + ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) + } // Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe // up in the mouse modes) — it never draws or takes touches, it just owns IME focus. AndroidView( @@ -382,6 +401,24 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { } } +/** + * The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The + * chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms + * the press registered and tells the user to keep holding. Purely visual; [GamepadRouter.onExitArmed] + * toggles its visibility. + */ +@Composable +private fun ExitChordHint(modifier: Modifier = Modifier) { + Text( + "Hold to quit…", + modifier = modifier + .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + color = Color.White, + fontSize = 15.sp, + ) +} + /** * Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME * onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 567fc025..cf00d309 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -57,6 +57,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett */ var onExitChord: (() -> Unit)? = null + /** + * Invoked (main thread) with `true` the moment the exit chord completes and the hold countdown + * starts, and `false` when it's cancelled (a button lifted early) or the timer elapses. `StreamScreen` + * wires this to a "hold to quit" hint so the hold is discoverable — the chord no longer quits on a + * quick press, and without an on-screen cue that reads as the shortcut being broken. + */ + var onExitArmed: ((armed: Boolean) -> Unit)? = null + private val mainHandler = Handler(Looper.getMainLooper()) /** The pending exit-chord hold timer, or null when the chord isn't currently armed. */ private var pendingExit: Runnable? = null @@ -84,9 +92,10 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett * One gamepad button transition for the device that produced [event] (already resolved to BTN_* * bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the * slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select + - * Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly; - * [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is - * ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave. + * Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly + * ([onExitArmed] fires so the UI can show a "hold to quit" hint); [onExitChord] fires only if the + * chord is still held at expiry (a brief accidental brush is ignored), matching `DISCONNECT_HOLD` + * on the SDL/Apple clients. Any controller can leave. */ fun onButton(event: KeyEvent, bit: Int) { val slot = slotFor(event.device) ?: return @@ -123,6 +132,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett if (pendingExit != null) return // already counting down val r = Runnable { pendingExit = null + onExitArmed?.invoke(false) // countdown over — drop the hint whether or not we leave // Fire only if the chord survived the full hold on some pad. val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD } if (held.isNotEmpty()) { @@ -134,12 +144,15 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett } pendingExit = r mainHandler.postDelayed(r, EXIT_HOLD_MS) + onExitArmed?.invoke(true) // chord complete → show the "hold to quit" hint } /** Cancel a pending exit-chord hold timer. */ private fun disarmExit() { + val wasArmed = pendingExit != null pendingExit?.let { mainHandler.removeCallbacks(it) } pendingExit = null + if (wasArmed) onExitArmed?.invoke(false) // released early — drop the hint } /** @@ -303,8 +316,12 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett /** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */ const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB - /** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */ - const val EXIT_HOLD_MS = 1500L + /** + * How long the exit chord must be held before the stream leaves — long enough that an + * accidental brush of the four buttons doesn't quit, short enough to feel responsive (the + * on-screen hint covers the gap). Roughly matches SDL/Apple `DISCONNECT_HOLD`. + */ + const val EXIT_HOLD_MS = 1000L /** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */ const val EXTERNAL_ID_BASE = -1000