feat(client): 3-finger swipe toggles the on-screen keyboard mid-stream
iOS + Android: a three-finger vertical swipe up/down summons/dismisses the device soft keyboard while streaming (trackpad + pointer modes). Mobile scroll is now exactly two fingers so it never collides with the 3+-finger gesture (3+ only fell into the old `>= 2` scroll path by accident). Android: a TYPE_NULL KeyCaptureView plus IME meta-shift wrapping feeds key events through. iOS: UIKeyInput plus a SoftKeyMap char->VK table with a GCKeyboard dup gate so a hardware keyboard and the soft keyboard don't double-emit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package io.unom.punktfunk
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyCharacterMap
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -153,7 +154,18 @@ class MainActivity : ComponentActivity() {
|
||||
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||
val vk = Keymap.toVk(event)
|
||||
if (vk != 0) {
|
||||
// Soft-keyboard events (the IME's virtual device — the stream's
|
||||
// KeyCaptureView path) carry Shift only as META state, where a real
|
||||
// keyboard sends discrete Shift transitions — so mirror the meta bit as
|
||||
// a VK_LSHIFT wrap or every IME capital/symbol lands unshifted on the
|
||||
// host. Never applied to hardware events: their Shift already went over
|
||||
// the wire, and a synthetic release here would un-hold a physical Shift
|
||||
// the user is still pressing.
|
||||
val imeShift = event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD &&
|
||||
event.isShiftPressed && vk != 0xA0 && vk != 0xA1
|
||||
if (down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, true, 0)
|
||||
NativeBridge.nativeSendKey(handle, vk, down, 0)
|
||||
if (!down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, false, 0)
|
||||
return true // consumed — don't let the system also act on it
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,22 @@ import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -166,6 +173,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
|
||||
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
|
||||
// Scoped to the stream; the app's other screens keep the default for their text fields.
|
||||
val priorSoftInput = window?.attributes?.softInputMode
|
||||
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
||||
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
||||
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
||||
@@ -201,6 +214,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
activity?.streamHandle = 0L
|
||||
activity?.requestStreamExit = null
|
||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||
window?.setSoftInputMode(priorSoftInput)
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
@@ -221,6 +236,9 @@ 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<KeyCaptureView?>(null) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -271,8 +289,16 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.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(
|
||||
modifier = Modifier.size(1.dp),
|
||||
factory = { ctx -> KeyCaptureView(ctx).also { keyCapture = it } },
|
||||
)
|
||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
|
||||
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
|
||||
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||
when (touchMode) {
|
||||
@@ -281,9 +307,45 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
handle,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (no composing text, no autocorrect), which flow through `MainActivity.dispatchKeyEvent` →
|
||||
* `Keymap.toVk` → the host, the exact path a hardware keyboard takes. Text an IME insists on
|
||||
* 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`).
|
||||
*/
|
||||
private class KeyCaptureView(context: Context) : View(context) {
|
||||
init {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
|
||||
override fun onCheckIsTextEditor(): Boolean = true
|
||||
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
||||
outAttrs.inputType = InputType.TYPE_NULL
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||
return BaseInputConnection(this, false)
|
||||
}
|
||||
|
||||
fun setImeVisible(show: Boolean) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
?: return
|
||||
if (show) {
|
||||
requestFocus()
|
||||
imm.showSoftInput(this, 0)
|
||||
} else {
|
||||
imm.hideSoftInputFromWindow(windowToken, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ private const val TAP_SLOP = 12f
|
||||
private const val TAP_DRAG_MS = 250L
|
||||
private const val SCROLL_DIV = 4f
|
||||
|
||||
// Three-finger vertical swipe: the fraction of the view height the centroid must travel to
|
||||
// summon (up) / dismiss (down) the local soft keyboard.
|
||||
private const val KB_SWIPE_FRACTION = 0.10f
|
||||
|
||||
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
||||
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
||||
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
||||
@@ -40,7 +44,9 @@ private const val ACCEL_MAX = 3.0f
|
||||
*
|
||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
|
||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier);
|
||||
* three-finger swipe up/down = [onKeyboard] (summon/dismiss the local soft keyboard, for
|
||||
* typing on the host).
|
||||
*/
|
||||
/**
|
||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||
@@ -94,6 +100,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
trackpad: Boolean,
|
||||
onCycleStats: () -> Unit,
|
||||
onKeyboard: (show: Boolean) -> Unit,
|
||||
) {
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
@@ -128,6 +135,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
var maxFingers = 1
|
||||
var scrolling = false
|
||||
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
||||
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the
|
||||
// scroll anchor) and a once-per-gesture latch.
|
||||
var kbCount = 0
|
||||
var kbAnchorX = 0f
|
||||
var kbAnchorY = 0f
|
||||
var kbFired = false
|
||||
var prevCx = startX
|
||||
var prevCy = startY
|
||||
var upTime = down.uptimeMillis
|
||||
@@ -148,9 +161,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
break
|
||||
}
|
||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3
|
||||
// bounce re-anchors instead of reading the count change as swipe travel.
|
||||
if (pressed.size < 3) kbCount = 0
|
||||
|
||||
if (pressed.size >= 2) {
|
||||
// Two+ fingers → scroll by the centroid delta; never move the cursor.
|
||||
if (pressed.size == 2) {
|
||||
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
||||
@@ -177,6 +193,36 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
prevCx = cx
|
||||
moved = true
|
||||
}
|
||||
} else if (pressed.size >= 3) {
|
||||
// Three+ fingers → the keyboard swipe, never scroll (the documented
|
||||
// vocabulary is TWO-finger scroll; 3+ only fell into the scroll path as an
|
||||
// accident of its old `>= 2` bound). Anchor the centroid per finger count
|
||||
// (same reasoning as the scroll anchor above) and fire once per gesture when
|
||||
// the vertical travel crosses the threshold: up = show, down = hide.
|
||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||
if (pressed.size != kbCount) {
|
||||
kbCount = pressed.size
|
||||
kbAnchorX = cx
|
||||
kbAnchorY = cy
|
||||
} else {
|
||||
val dy = cy - kbAnchorY
|
||||
// Real centroid travel disqualifies the tap classification below (else a
|
||||
// sub-threshold swipe would still fire the three-finger stats tap).
|
||||
if (abs(dy) > TAP_SLOP || abs(cx - kbAnchorX) > TAP_SLOP) moved = true
|
||||
if (!kbFired && abs(dy) >= size.height * KB_SWIPE_FRACTION) {
|
||||
kbFired = true
|
||||
onKeyboard(dy < 0) // finger up → show, finger down → hide
|
||||
}
|
||||
}
|
||||
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel
|
||||
// notch; clearing it makes a return to two fingers re-anchor fresh. Same for
|
||||
// the trackpad's tracked finger: its prev position froze while 3+ fingers were
|
||||
// down, so dropping straight back to one finger must re-anchor (zero delta),
|
||||
// not replay the whole 3-finger phase as one cursor jump.
|
||||
scrolling = false
|
||||
scrollCount = 0
|
||||
trackId = PointerId(Long.MIN_VALUE)
|
||||
} else if (!scrolling) {
|
||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||
// back to one finger doesn't jerk the cursor).
|
||||
|
||||
Reference in New Issue
Block a user