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 b4564ee9..7650d9fd 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 @@ -54,6 +54,21 @@ class MainActivity : ComponentActivity() { var padKeyProbe: ((KeyEvent) -> Boolean)? = null var padMotionProbe: ((MotionEvent) -> Boolean)? = null + /** + * Physical-mouse forwarder for the active session (built/released by StreamScreen, like + * [gamepadRouter]): uncaptured hover/click/wheel forwards as absolute cursor input, captured + * ([android.view.View.requestPointerCapture]) raw deltas as relative mouse-look. The dispatch + * overrides below route every SOURCE_MOUSE event here while streaming. Null while not streaming. + */ + var mouseForwarder: MouseForwarder? = null + + /** + * TV remote-as-pointer for the active session (StreamScreen builds it on TV devices only): + * hold SELECT to toggle, then the D-pad glides the host cursor. Consulted first for + * non-gamepad keys while streaming. Null while not streaming or not a TV. + */ + var remotePointer: RemotePointer? = null + /** * Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a * couch user with no keyboard/Back can always leave a stream. @@ -324,9 +339,29 @@ class MainActivity : ComponentActivity() { return true // consumed } } + // TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it; + // while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK). + if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { + remotePointer?.let { if (it.onKey(event)) return true } + } + // Ctrl+Alt+Shift+Q — the cross-client pointer-capture toggle chord. Swallow both + // edges of the Q (the modifiers already went over the wire, exactly like desktop). + if (event.keyCode == KeyEvent.KEYCODE_Q && + event.isCtrlPressed && event.isAltPressed && event.isShiftPressed + ) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + mouseForwarder?.toggleCapture() + } + return true + } when (event.keyCode) { + // A mouse back/forward button whose BUTTON_* press went unconsumed makes the + // framework synthesize a FALLBACK BACK — the button already went over the wire + // as X1/X2, and it must never yank the user out of the stream. + KeyEvent.KEYCODE_BACK -> + if (event.flags and KeyEvent.FLAG_FALLBACK != 0) return true // Leave these to the system even while streaming. - KeyEvent.KEYCODE_BACK, // → BackHandler leaves the stream + // (BACK above → BackHandler leaves the stream.) KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE, @@ -394,6 +429,10 @@ class MainActivity : ComponentActivity() { override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { if (streamHandle != 0L) { if (gamepadRouter?.onMotion(event) == true) return true + // Physical mouse (uncaptured): hover motion, wheel, button edges. + if (event.isFromSource(InputDevice.SOURCE_MOUSE)) { + mouseForwarder?.let { if (it.onGenericMotion(event)) return true } + } return super.dispatchGenericMotionEvent(event) } // The Controllers debug screen sees pad motion before the stick→D-pad synthesis below. @@ -431,6 +470,24 @@ class MainActivity : ComponentActivity() { return super.dispatchGenericMotionEvent(event) } + /** + * Mouse clicks/drags ride the TOUCH stream (the pointer is "down"). While streaming they + * belong to the mouse forwarder, never to the Compose touch-gesture layer — a physical + * mouse click must be a real click at the cursor, not a synthesized trackpad tap. + */ + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + if (streamHandle != 0L && ev.isFromSource(InputDevice.SOURCE_MOUSE)) { + mouseForwarder?.let { if (it.onTouchEvent(ev)) return true } + } + return super.dispatchTouchEvent(ev) + } + + /** The OS is the source of truth for pointer capture (it releases on focus loss). */ + override fun onPointerCaptureChanged(hasCapture: Boolean) { + super.onPointerCaptureChanged(hasCapture) + mouseForwarder?.onCaptureChanged(hasCapture) + } + /** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */ private fun isConsoleNavKey(kc: Int): Boolean = when (kc) { KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MouseInput.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MouseInput.kt new file mode 100644 index 00000000..425bb4e8 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MouseInput.kt @@ -0,0 +1,206 @@ +package io.unom.punktfunk + +import android.view.InputDevice +import android.view.MotionEvent +import io.unom.punktfunk.kit.NativeBridge +import kotlin.math.roundToInt + +/** True when any connected input device is a pointer (USB/BT mouse, or a touchpad driving one). */ +fun hasPhysicalMouse(): Boolean = InputDevice.getDeviceIds().any { id -> + InputDevice.getDevice(id)?.supportsSource(InputDevice.SOURCE_MOUSE) == true +} + +/** + * Physical mouse → wire, in two modes (the iPadOS/desktop model): + * * **uncaptured** (default): hover/drag positions forward as absolute cursor moves + * (`MouseMoveAbs`, host-normalized against the window size) — desktop-style pointing. The + * local cursor is hidden over the stream (StreamScreen sets a TYPE_NULL pointer icon); the + * host's own cursor, composited into the video, is the one you see. + * * **captured**: the OS pointer is grabbed ([android.view.View.requestPointerCapture]) and raw + * relative deltas forward as `MouseMove` — FPS mouse-look. Engaged at stream start / by + * clicking into the stream when the "Capture pointer for games" setting is on, and toggled + * any time by Ctrl+Alt+Shift+Q (the cross-client chord). Focus loss releases it (the OS + * guarantees that); a click re-engages. + * + * Buttons ride [MotionEvent.ACTION_BUTTON_PRESS]/RELEASE edges (left/middle/right/back/forward → + * wire 1/2/3/4/5), the wheel rides [MotionEvent.ACTION_SCROLL] with fractional accumulation so + * high-resolution wheels don't lose sub-notch travel. Held buttons are tracked and flushed on + * capture loss / stream exit so nothing sticks on the host. Events reach this class from + * MainActivity's dispatch overrides (uncaptured) and the capture view's captured-pointer listener. + */ +class MouseForwarder( + private val handle: Long, + private val invertScroll: Boolean, + private val captureWanted: Boolean, + private val surfaceSize: () -> Pair, +) { + /** Capture plumbing, owned by StreamScreen (the focusable capture view). */ + var onRequestCapture: (() -> Unit)? = null + var onReleaseCapture: (() -> Unit)? = null + + /** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */ + var captured = false + private set + + /** Chord-released: no auto re-engage (start / click) until the user opts back in. */ + private var userReleased = false + + private val heldButtons = mutableSetOf() + private var scrollAccV = 0f + private var scrollAccH = 0f + private var moveAccX = 0f + private var moveAccY = 0f + + /** Uncaptured mouse events on the TOUCH stream (position while a button is down). */ + fun onTouchEvent(ev: MotionEvent): Boolean { + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + if (captureWanted && !captured && !userReleased) { + // The engaging click: grab the pointer and swallow the click (desktop + // parity — the click that captures never reaches the host). The paired + // BUTTON_RELEASE is dropped by the held-set guard in [button]. + onRequestCapture?.invoke() + return true + } + sendAbs(ev) + } + MotionEvent.ACTION_MOVE -> sendAbs(ev) + // Button edges are documented on the generic stream, but be robust to either. + MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true) + MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false) + } + return true + } + + /** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */ + fun onGenericMotion(ev: MotionEvent): Boolean { + when (ev.actionMasked) { + MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev) + MotionEvent.ACTION_SCROLL -> wheel(ev) + MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true) + MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false) + MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_EXIT -> {} + else -> return false + } + return true + } + + /** + * Captured-pointer events (the view holds [android.view.View.requestPointerCapture]): x/y ARE + * the relative deltas ([InputDevice.SOURCE_MOUSE_RELATIVE]), batched samples included. A + * captured touchpad reports absolute finger coordinates instead — not handled (the touch + * gesture layer is the touchpad story); returning false leaves those to the framework. + */ + fun onCapturedPointer(ev: MotionEvent): Boolean { + if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false + when (ev.actionMasked) { + MotionEvent.ACTION_MOVE -> { + var dx = 0f + var dy = 0f + for (i in 0 until ev.historySize) { + dx += ev.getHistoricalX(i) + dy += ev.getHistoricalY(i) + } + dx += ev.x + dy += ev.y + moveAccX += dx + moveAccY += dy + val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept w/ sign + val oy = moveAccY.toInt() + if (ox != 0 || oy != 0) { + NativeBridge.nativeSendPointerMove(handle, ox, oy) + moveAccX -= ox + moveAccY -= oy + } + } + MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true) + MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false) + MotionEvent.ACTION_SCROLL -> wheel(ev) + } + return true + } + + /** Ctrl+Alt+Shift+Q: release the grab, or (re-)engage it — works even when auto-capture is off. */ + fun toggleCapture() { + if (captured) { + userReleased = true + onReleaseCapture?.invoke() + } else { + userReleased = false + onRequestCapture?.invoke() + } + } + + /** Auto-engage at stream start (setting on + a mouse actually present). */ + fun engageFromStart() { + if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) { + onRequestCapture?.invoke() + } + } + + /** From [android.app.Activity.onPointerCaptureChanged] — the OS is the source of truth. */ + fun onCaptureChanged(has: Boolean) { + captured = has + // Losing the grab (focus loss, chord) must not leave buttons held on the host. + if (!has) flushButtons() + } + + /** Stream teardown: lift anything held and let the grab go. */ + fun release() { + flushButtons() + if (captured) onReleaseCapture?.invoke() + } + + private fun sendAbs(ev: MotionEvent) { + val (w, h) = surfaceSize() + if (w <= 0 || h <= 0) return + NativeBridge.nativeSendPointerAbs( + handle, + ev.x.roundToInt().coerceIn(0, w - 1), + ev.y.roundToInt().coerceIn(0, h - 1), + w, + h, + ) + } + + private fun wheel(ev: MotionEvent) { + val dir = if (invertScroll) -1f else 1f + // Android: AXIS_VSCROLL + = up/away, AXIS_HSCROLL + = right — the wire's convention too. + scrollAccV += ev.getAxisValue(MotionEvent.AXIS_VSCROLL) * 120f * dir + scrollAccH += ev.getAxisValue(MotionEvent.AXIS_HSCROLL) * 120f * dir + val v = scrollAccV.toInt() + if (v != 0) { + NativeBridge.nativeSendScroll(handle, 0, v) + scrollAccV -= v + } + val h = scrollAccH.toInt() + if (h != 0) { + NativeBridge.nativeSendScroll(handle, 1, h) + scrollAccH -= h + } + } + + private fun button(actionButton: Int, down: Boolean) { + val b = when (actionButton) { + MotionEvent.BUTTON_PRIMARY -> 1 + MotionEvent.BUTTON_TERTIARY -> 2 + MotionEvent.BUTTON_SECONDARY -> 3 + MotionEvent.BUTTON_BACK -> 4 + MotionEvent.BUTTON_FORWARD -> 5 + else -> return + } + if (down) { + heldButtons.add(b) + NativeBridge.nativeSendPointerButton(handle, b, true) + } else if (heldButtons.remove(b)) { + // Only release what we pressed — drops the release of a swallowed engaging click + // and anything that raced a capture transition. + NativeBridge.nativeSendPointerButton(handle, b, false) + } + } + + private fun flushButtons() { + heldButtons.forEach { NativeBridge.nativeSendPointerButton(handle, it, false) } + heldButtons.clear() + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/RemotePointer.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/RemotePointer.kt new file mode 100644 index 00000000..d9ffa5c0 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/RemotePointer.kt @@ -0,0 +1,193 @@ +package io.unom.punktfunk + +import android.os.Handler +import android.os.Looper +import android.view.Choreographer +import android.view.KeyEvent +import io.unom.punktfunk.kit.NativeBridge +import kotlin.math.hypot + +// Hold this long on SELECT (pointer-mode toggle) / PLAY-PAUSE (keyboard toggle) for the long-press +// action instead of the tap action. +private const val LONG_PRESS_MS = 800L + +// D-pad glide ballistics, in screen-widths per second: start slow enough to hit a close button, +// ramp over RAMP_S seconds of continuous hold so crossing the desktop doesn't take all day. +private const val SPEED_MIN = 0.14f +private const val SPEED_MAX = 0.70f +private const val RAMP_S = 1.2f + +/** + * Android TV remote as a pointer — the Android analogue of the Apple client's Siri-remote pointer, + * adapted for D-pad-only remotes (most Android TV remotes have no touch surface). For the + * "TV as a desktop client" use case, where a plain remote is often the only thing in hand. + * + * While streaming on a TV, **hold SELECT ≈ 0.8 s** to toggle pointer mode. While active: + * * D-pad (held) glides the host cursor with ramping acceleration (relative `MouseMove`, + * Choreographer-paced, diagonal-normalized); + * * SELECT tap = left click; PLAY/PAUSE tap = right click (Siri-remote parity); + * * PLAY/PAUSE held = toggle the on-screen keyboard; BACK = leave pointer mode + * (a second BACK then leaves the stream as usual). + * While inactive, everything except the SELECT long-press passes through untouched (D-pad = + * arrow keys, SELECT tap = Enter — synthesized on release, since the down was held back to + * disambiguate the long-press). + * + * Only consulted for non-gamepad key events on TV devices (MainActivity gates the calls); all + * state lives on the main thread. + */ +class RemotePointer( + private val handle: Long, + private val surfaceWidth: () -> Int, + private val onActiveChanged: (Boolean) -> Unit, + private val onKeyboardToggle: () -> Unit, +) { + var active = false + private set + + private val handler = Handler(Looper.getMainLooper()) + private val held = mutableSetOf() // D-pad keycodes currently down + private var moveAccX = 0f + private var moveAccY = 0f + private var lastFrameNs = 0L + private var rampSec = 0f + private var tickerRunning = false + private var centerLongFired = false + private var playLongFired = false + + private val centerLong = Runnable { + centerLongFired = true + toggle() + } + private val playLong = Runnable { + playLongFired = true + onKeyboardToggle() + } + + private val frame = object : Choreographer.FrameCallback { + override fun doFrame(nowNs: Long) { + if (!tickerRunning) return + if (held.isEmpty() || !active) { + tickerRunning = false + return + } + val dt = if (lastFrameNs == 0L) { + 1f / 60f + } else { + ((nowNs - lastFrameNs) / 1e9f).coerceIn(0.001f, 0.1f) + } + lastFrameNs = nowNs + rampSec += dt + var vx = 0f + var vy = 0f + if (KeyEvent.KEYCODE_DPAD_LEFT in held) vx -= 1f + if (KeyEvent.KEYCODE_DPAD_RIGHT in held) vx += 1f + if (KeyEvent.KEYCODE_DPAD_UP in held) vy -= 1f + if (KeyEvent.KEYCODE_DPAD_DOWN in held) vy += 1f + val mag = hypot(vx, vy) + if (mag > 0f) { + val w = surfaceWidth().coerceAtLeast(640) + val speed = w * (SPEED_MIN + (SPEED_MAX - SPEED_MIN) * (rampSec / RAMP_S).coerceAtMost(1f)) + moveAccX += vx / mag * speed * dt + moveAccY += vy / mag * speed * dt + val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept + val oy = moveAccY.toInt() + if (ox != 0 || oy != 0) { + NativeBridge.nativeSendPointerMove(handle, ox, oy) + moveAccX -= ox + moveAccY -= oy + } + } + Choreographer.getInstance().postFrameCallback(this) + } + } + + /** One remote key event; true = consumed. Ignore key repeats — the ticker owns motion. */ + fun onKey(event: KeyEvent): Boolean { + val down = event.action == KeyEvent.ACTION_DOWN + when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER -> { + if (down) { + if (event.repeatCount == 0) { + centerLongFired = false + handler.postDelayed(centerLong, LONG_PRESS_MS) + } + } else { + handler.removeCallbacks(centerLong) + if (!centerLongFired) { + if (active) { + click(1) + } else { + // The down was held back to disambiguate the long-press, so the + // normal path never saw it — synthesize the Enter here instead. + NativeBridge.nativeSendKey(handle, 0x0D, true, 0) + NativeBridge.nativeSendKey(handle, 0x0D, false, 0) + } + } + } + return true + } + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, + -> { + if (!active) return false + if (down) { + if (held.add(event.keyCode) && held.size == 1) startTicker() + } else { + held.remove(event.keyCode) + } + return true + } + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + if (!active) return false // inactive: the media-key VK path owns it + if (down) { + if (event.repeatCount == 0) { + playLongFired = false + handler.postDelayed(playLong, LONG_PRESS_MS) + } + } else { + handler.removeCallbacks(playLong) + if (!playLongFired) click(3) + } + return true + } + KeyEvent.KEYCODE_BACK -> { + if (!active) return false + if (!down) toggle() // leave pointer mode; the next BACK leaves the stream + return true + } + else -> return false + } + } + + /** Stream teardown: stop timers/ticker; nothing wire-held to flush (clicks are edges). */ + fun release() { + handler.removeCallbacks(centerLong) + handler.removeCallbacks(playLong) + active = false + held.clear() + tickerRunning = false + } + + private fun toggle() { + active = !active + if (!active) { + held.clear() + tickerRunning = false + } + onActiveChanged(active) + } + + private fun startTicker() { + rampSec = 0f + lastFrameNs = 0L + if (!tickerRunning) { + tickerRunning = true + Choreographer.getInstance().postFrameCallback(frame) + } + } + + private fun click(button: Int) { + NativeBridge.nativeSendPointerButton(handle, button, true) + NativeBridge.nativeSendPointerButton(handle, button, false) + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 03bcb7c1..a8b18373 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -109,6 +109,20 @@ data class Settings( * setup where the OS-level pad (lizard mode) is preferred. */ val sc2Capture: Boolean = true, + + /** + * Lock a physical mouse to the stream ([android.view.View.requestPointerCapture]) and forward + * raw relative motion — FPS mouse-look, the iPad "Capture pointer for games" twin. Engages at + * stream start and on a click into the stream; Ctrl+Alt+Shift+Q toggles it live (the chord + * works even with this off). Off (default): a mouse points absolutely, desktop-style. + */ + val pointerCapture: Boolean = false, + + /** + * Flip scroll direction — the mouse wheel and the two-finger touch scroll both. Parity with + * the Apple/GTK clients' "Invert scroll direction". + */ + val invertScroll: Boolean = false, ) /** [Settings.touchMode] values; persisted by name. */ @@ -172,6 +186,8 @@ class SettingsStore(context: Context) { autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true), rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false), sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true), + pointerCapture = prefs.getBoolean(K_POINTER_CAPTURE, false), + invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false), ) fun save(s: Settings) { @@ -195,6 +211,8 @@ class SettingsStore(context: Context) { .putBoolean(K_AUTO_WAKE, s.autoWakeEnabled) .putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone) .putBoolean(K_SC2_CAPTURE, s.sc2Capture) + .putBoolean(K_POINTER_CAPTURE, s.pointerCapture) + .putBoolean(K_INVERT_SCROLL, s.invertScroll) .apply() } @@ -233,6 +251,8 @@ class SettingsStore(context: Context) { const val K_AUTO_WAKE = "auto_wake_enabled" const val K_RUMBLE_ON_PHONE = "rumble_on_phone" const val K_SC2_CAPTURE = "sc2_capture" + const val K_POINTER_CAPTURE = "pointer_capture" + const val K_INVERT_SCROLL = "invert_scroll" /** Legacy Boolean the enum replaced — read once as the migration default, never written. */ const val K_TRACKPAD = "trackpad_mode" diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index cd84d31a..e9ef19b2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -412,6 +412,20 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + ToggleRow( + title = "Capture pointer for games", + subtitle = "Lock a connected mouse to the stream and send raw relative motion " + + "(mouse-look). Ctrl+Alt+Shift+Q toggles it live; click the stream to re-capture. " + + "Off: the mouse points at the desktop directly", + checked = s.pointerCapture, + onCheckedChange = { on -> update(s.copy(pointerCapture = on)) }, + ) + ToggleRow( + title = "Invert scroll direction", + subtitle = "Flip the mouse wheel and two-finger touch scrolling", + checked = s.invertScroll, + onCheckedChange = { on -> update(s.copy(invertScroll = on)) }, + ) } SettingsCard { SettingDropdown( 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 d9d862b9..a80f2774 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 @@ -176,6 +176,14 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { // "hold to quit" hint overlay. Set from the router's onExitArmed (main thread). var exitArming by remember { mutableStateOf(false) } + // True while the TV remote is acting as a pointer (hold SELECT toggles) — drives the mode hint. + var remotePointerOn by remember { mutableStateOf(false) } + + // Focus anchor the soft keyboard is summoned onto AND the pointer-capture grab target (a grab + // needs a focusable view; captured-pointer events land on it). Declared before the effect + // below so the capture callbacks can reach the view once it exists. + var keyCapture by remember { mutableStateOf(null) } + DisposableEffect(handle) { window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) wifiLocks.forEach { lock -> @@ -221,6 +229,47 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { // 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 } + // Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured + // (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look. + // The local cursor is hidden over the stream — the host's own cursor, composited into + // the video, is the one the user sees (twin of the desktop clients' hidden cursor). + val decor = window?.decorView + val priorPointerIcon = decor?.pointerIcon + decor?.pointerIcon = android.view.PointerIcon.getSystemIcon( + context, + android.view.PointerIcon.TYPE_NULL, + ) + val mouse = MouseForwarder( + handle, + invertScroll = initialSettings.invertScroll, + captureWanted = initialSettings.pointerCapture, + surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) }, + ) + mouse.onRequestCapture = { + // The grab needs the (focusable) capture view: focus it, then ask. Posted so a + // request racing view attach/focus settles on the next frame. + keyCapture?.let { v -> + v.post { + v.requestFocus() + v.requestPointerCapture() + } + } + } + mouse.onReleaseCapture = { keyCapture?.releasePointerCapture() } + activity?.mouseForwarder = mouse + // TV remote-as-pointer: hold SELECT ≈ 0.8 s to toggle; the D-pad then glides the host + // cursor (see RemotePointer). TV only — a phone's remote-less keys stay on the VK path. + val remote = if (isTv) { + RemotePointer( + handle, + surfaceWidth = { decor?.width ?: 1920 }, + onActiveChanged = { on -> remotePointerOn = on }, + onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } }, + ) + } else { + null + } + activity?.remotePointer = remote 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 @@ -293,6 +342,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { 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 + // Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor. + mouse.release() + activity?.mouseForwarder = null + remote?.release() + activity?.remotePointer = null + decor?.pointerIcon = priorPointerIcon activity?.streamHandle = 0L activity?.requestStreamExit = null // Back in the menus: the SC2 (if present) resumes driving the console UI. @@ -320,8 +375,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { // Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger). BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } - // Focus anchor the three-finger keyboard swipe summons the IME onto (see KeyCaptureView). - var keyCapture by remember { mutableStateOf(null) } + // Auto-engage pointer capture at stream start (setting on + a mouse actually present). + // Delayed a beat: the grab needs window focus and the capture view attached. + LaunchedEffect(handle) { + delay(400) + activity?.mouseForwarder?.engageFromStart() + } Box(modifier = Modifier.fillMaxSize()) { AndroidView( @@ -379,11 +438,23 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { 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. + // Remote-pointer mode hint — the remote's keys are remapped while it's on, so say so. + if (remotePointerOn) { + RemotePointerHint(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) AND the pointer-capture grab target — it never draws or takes + // touches, it just owns IME focus and receives captured-pointer events. AndroidView( modifier = Modifier.size(1.dp), - factory = { ctx -> KeyCaptureView(ctx).also { keyCapture = it } }, + factory = { ctx -> + KeyCaptureView(ctx).also { v -> + keyCapture = v + v.setOnCapturedPointerListener { _, ev -> + (ctx as? MainActivity)?.mouseForwarder?.onCapturedPointer(ev) ?: false + } + } + }, ) // Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture // vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no @@ -396,6 +467,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { else -> streamTouchInput( handle, trackpad = touchMode == TouchMode.TRACKPAD, + invertScroll = initialSettings.invertScroll, onCycleStats = { statsVerbosity = statsVerbosity.next() }, onKeyboard = { show -> keyCapture?.setImeVisible(show) }, ) @@ -423,6 +495,22 @@ private fun ExitChordHint(modifier: Modifier = Modifier) { ) } +/** + * The remote-pointer mode cue: while active the remote's keys are remapped (D-pad glides the host + * cursor, SELECT clicks), so the overlay both confirms the toggle and teaches the vocabulary. + */ +@Composable +private fun RemotePointerHint(modifier: Modifier = Modifier) { + Text( + "Remote pointer — SELECT click · play/pause right-click · hold SELECT to exit", + 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 @@ -431,6 +519,10 @@ private fun ExitChordHint(modifier: Modifier = Modifier) { * committing instead still arrives: the non-editable [BaseInputConnection] synthesizes KeyEvents * for it via `KeyCharacterMap` (with Shift carried as meta state — see the IME-shift wrap in * `MainActivity.dispatchKeyEvent`). + * + * Doubles as the pointer-capture grab target: a grab needs a focusable view, and captured-pointer + * events are delivered to it (routed to [MouseForwarder.onCapturedPointer] via the listener the + * stream screen installs). */ private class KeyCaptureView(context: Context) : View(context) { init { @@ -438,6 +530,10 @@ private class KeyCaptureView(context: Context) : View(context) { isFocusableInTouchMode = true } + /** Whether [setImeVisible] last showed the IME — for toggle-style callers (remote pointer). */ + var imeShown = false + private set + override fun onCheckIsTextEditor(): Boolean = true override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { @@ -449,6 +545,7 @@ private class KeyCaptureView(context: Context) : View(context) { fun setImeVisible(show: Boolean) { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager ?: return + imeShown = show if (show) { requestFocus() imm.showSoftInput(this, 0) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt index 16caa791..af4d298e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt @@ -99,9 +99,11 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) { internal suspend fun PointerInputScope.streamTouchInput( handle: Long, trackpad: Boolean, + invertScroll: Boolean, onCycleStats: () -> Unit, onKeyboard: (show: Boolean) -> Unit, ) { + val scrollDir = if (invertScroll) -1 else 1 var lastTapUp = 0L var lastTapX = 0f var lastTapY = 0f @@ -184,12 +186,12 @@ internal suspend fun PointerInputScope.streamTouchInput( val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up val sx = ((cx - prevCx) / SCROLL_DIV).toInt() if (sy != 0) { - NativeBridge.nativeSendScroll(handle, 0, sy * 120) + NativeBridge.nativeSendScroll(handle, 0, sy * 120 * scrollDir) prevCy = cy moved = true } if (sx != 0) { - NativeBridge.nativeSendScroll(handle, 1, sx * 120) + NativeBridge.nativeSendScroll(handle, 1, sx * 120 * scrollDir) prevCx = cx moved = true } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Keymap.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Keymap.kt index d534de51..eced0281 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Keymap.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Keymap.kt @@ -106,6 +106,17 @@ object Keymap { KeyEvent.KEYCODE_DPAD_UP -> 0x26 KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27 KeyEvent.KEYCODE_DPAD_DOWN -> 0x28 + // TV-remote SELECT = Enter (a gamepad's press routes via SOURCE_GAMEPAD before this). + KeyEvent.KEYCODE_DPAD_CENTER -> 0x0D + + // Consumer/media keys — forwarded to the host while streaming (volume stays local: + // MainActivity's pass-through list wins before the map is consulted). + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_MEDIA_PLAY, + KeyEvent.KEYCODE_MEDIA_PAUSE -> 0xB3 // VK_MEDIA_PLAY_PAUSE + KeyEvent.KEYCODE_MEDIA_NEXT -> 0xB0 // VK_MEDIA_NEXT_TRACK + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> 0xB1 // VK_MEDIA_PREV_TRACK + KeyEvent.KEYCODE_MEDIA_STOP -> 0xB2 // VK_MEDIA_STOP // Modifiers (L/R-specific VKs; the host folds the generic ones onto the left variant) KeyEvent.KEYCODE_SHIFT_LEFT -> 0xA0