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

TvLongPress.kt

package dev.stave.ui.components

import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type

/**
 * D-pad long-press for TV remotes.
 *
 * `Modifier.combinedClickable(onLongClick = ...)` detects long-press via pointer input (touch).
 * On an Android TV remote the select button arrives as discrete KeyDown/KeyUp events, and the
 * gesture recognizer never promotes a held select into a long-press — the user gets a regular
 * click on release instead (exactly what they reported: "inadvertently selected the thing I
 * was trying to long-press on").
 *
 * This modifier listens in the preview phase:
 *
 * 1. Android sends a synthetic KeyDown with `FLAG_LONG_PRESS` set once the key has been held
 *    past the system long-press threshold (~500ms, configurable). We mark the press as
 *    "ready to long-press on release" and consume the event so it doesn't cascade.
 * 2. The user releases. We see KeyUp; if the flag is set, NOW we fire onLongPress and consume
 *    the KeyUp so the enclosing `clickable`/`combinedClickable` doesn't also fire `onClick`.
 *
 * Why fire on KeyUp rather than on the LONG_PRESS KeyDown: if onLongPress synchronously moves
 * focus (the common case — it opens an ActionSheet that auto-focuses its first item), the
 * subsequent KeyUp gets dispatched to whatever element is focused at that point. With the old
 * code that meant the freshly-opened sheet's first action received a synthetic select-press
 * and immediately fired its onClick — exactly the bug the user reported in the queue
 * (long-pressing an item would trip the first action of the sheet that opened). By deferring
 * the focus-changing call to KeyUp, the entire press cycle completes on the original element
 * before any focus moves.
 *
 * Apply BEFORE `clickable` in the modifier chain so the KeyUp we consume never reaches it.
 * Touch long-press (e.g. Android TV with a mouse/touch emulator) still works via combinedClickable.
 */
@Composable
fun Modifier.onDpadLongPress(onLongPress: (() -> Unit)?): Modifier {
    if (onLongPress == null) return this
    // pendingLongPress tracks "we observed the LONG_PRESS KeyDown, fire onLongPress on the
    // next KeyUp". Reset to false after firing so a second held-press starts fresh.
    val pendingLongPress = remember { mutableStateOf(false) }
    return this.onPreviewKeyEvent { event ->
        val isSelect = event.key == Key.DirectionCenter ||
            event.key == Key.Enter ||
            event.key == Key.NumPadEnter
        if (!isSelect) return@onPreviewKeyEvent false
        when {
            // Long-press threshold reached. Don't fire yet — wait for KeyUp so any focus-
            // moving action (e.g. opening a sheet) happens AFTER the KeyUp is consumed and
            // can't be misdirected to the newly-focused element.
            event.type == KeyEventType.KeyDown && event.nativeKeyEvent.isLongPress -> {
                pendingLongPress.value = true
                true
            }
            // Release after a long-press KeyDown was observed: now fire onLongPress and
            // consume the KeyUp so the enclosing clickable doesn't ALSO fire its onClick.
            event.type == KeyEventType.KeyUp && pendingLongPress.value -> {
                pendingLongPress.value = false
                onLongPress()
                true
            }
            else -> false
        }
    }
}