EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

TvLongPress.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.components
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.mutableStateOf
5import androidx.compose.runtime.remember
6import androidx.compose.ui.Modifier
7import androidx.compose.ui.input.key.Key
8import androidx.compose.ui.input.key.KeyEventType
9import androidx.compose.ui.input.key.key
10import androidx.compose.ui.input.key.onPreviewKeyEvent
11import androidx.compose.ui.input.key.type
12
13/**
14 * D-pad long-press for TV remotes.
15 *
16 * `Modifier.combinedClickable(onLongClick = ...)` detects long-press via pointer input (touch).
17 * On an Android TV remote the select button arrives as discrete KeyDown/KeyUp events, and the
18 * gesture recognizer never promotes a held select into a long-press — the user gets a regular
19 * click on release instead (exactly what they reported: "inadvertently selected the thing I
20 * was trying to long-press on").
21 *
22 * This modifier listens in the preview phase:
23 *
24 * 1. Android sends a synthetic KeyDown with `FLAG_LONG_PRESS` set once the key has been held
25 * past the system long-press threshold (~500ms, configurable). We mark the press as
26 * "ready to long-press on release" and consume the event so it doesn't cascade.
27 * 2. The user releases. We see KeyUp; if the flag is set, NOW we fire onLongPress and consume
28 * the KeyUp so the enclosing `clickable`/`combinedClickable` doesn't also fire `onClick`.
29 *
30 * Why fire on KeyUp rather than on the LONG_PRESS KeyDown: if onLongPress synchronously moves
31 * focus (the common case — it opens an ActionSheet that auto-focuses its first item), the
32 * subsequent KeyUp gets dispatched to whatever element is focused at that point. With the old
33 * code that meant the freshly-opened sheet's first action received a synthetic select-press
34 * and immediately fired its onClick — exactly the bug the user reported in the queue
35 * (long-pressing an item would trip the first action of the sheet that opened). By deferring
36 * the focus-changing call to KeyUp, the entire press cycle completes on the original element
37 * before any focus moves.
38 *
39 * Apply BEFORE `clickable` in the modifier chain so the KeyUp we consume never reaches it.
40 * Touch long-press (e.g. Android TV with a mouse/touch emulator) still works via combinedClickable.
41 */
42@Composable
43fun Modifier.onDpadLongPress(onLongPress: (() -> Unit)?): Modifier {
44 if (onLongPress == null) return this
45 // pendingLongPress tracks "we observed the LONG_PRESS KeyDown, fire onLongPress on the
46 // next KeyUp". Reset to false after firing so a second held-press starts fresh.
47 val pendingLongPress = remember { mutableStateOf(false) }
48 return this.onPreviewKeyEvent { event ->
49 val isSelect = event.key == Key.DirectionCenter ||
50 event.key == Key.Enter ||
51 event.key == Key.NumPadEnter
52 if (!isSelect) return@onPreviewKeyEvent false
53 when {
54 // Long-press threshold reached. Don't fire yet — wait for KeyUp so any focus-
55 // moving action (e.g. opening a sheet) happens AFTER the KeyUp is consumed and
56 // can't be misdirected to the newly-focused element.
57 event.type == KeyEventType.KeyDown && event.nativeKeyEvent.isLongPress -> {
58 pendingLongPress.value = true
59 true
60 }
61 // Release after a long-press KeyDown was observed: now fire onLongPress and
62 // consume the KeyUp so the enclosing clickable doesn't ALSO fire its onClick.
63 event.type == KeyEventType.KeyUp && pendingLongPress.value -> {
64 pendingLongPress.value = false
65 onLongPress()
66 true
67 }
68 else -> false
69 }
70 }
71}