voice: compute cancel verdict from the final pointer coordinate - recomposed shouldCancel state could be one frame stale on a fast slide-and-lift (PR review)

This commit is contained in:
callebtc 2026-07-29 12:55:36 +02:00
parent 8eddcb15ca
commit 6a904a1849
2 changed files with 14 additions and 10 deletions

View File

@ -616,7 +616,9 @@ fun MessageInput(
VoiceRecordButton(
isRecording = isRecording,
shouldCancel = { cancelHover },
shouldCancel = { pos ->
cancelBounds?.inflate(cancelSlackPx)?.contains(pos) == true
},
onTrackFinger = { cancelFinger = it },
onStart = {
isRecording = true

View File

@ -68,10 +68,12 @@ fun VoiceRecordButton(
*/
isRecording: Boolean = false,
/**
* Consulted the instant the finger lifts: when it reports true (finger over the
* slide-to-cancel target), the recording is discarded instead of sent.
* Consulted the instant the finger lifts, with the final pointer position in root
* coordinates: when it lands inside the slide-to-cancel target, the recording is
* discarded instead of sent. Receiving the position here (instead of reading composed
* state) keeps the verdict exact even for a slide-and-lift within a single frame.
*/
shouldCancel: () -> Boolean = { false },
shouldCancel: (Offset) -> Boolean = { false },
/**
* Finger position in root coordinates while a capture is live (drives the magnetic
* cancel target); null once the gesture ends.
@ -216,20 +218,20 @@ fun VoiceRecordButton(
// Track the finger in root coordinates until it lifts, so the composer can
// run the magnetic slide-to-cancel target. A cancelled pointer (stolen by a
// scroller) ends the capture the same way a lift does.
var finalPos: Offset? = null
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: continue
buttonCoords?.let {
latestOnTrackFinger.value(it.localToRoot(change.position))
}
finalPos = buttonCoords?.localToRoot(change.position)
finalPos?.let { latestOnTrackFinger.value(it) }
if (!change.pressed) break
}
// Cancelling discards immediately; sending keeps a short tail so the last
// syllable is not clipped (an early pointer event simply ends the tail).
// The cancel verdict is read BEFORE the tracker is cleared so the composer
// still sees the final finger position.
val cancel = latestShouldCancel.value()
// The verdict is computed from the final pointer coordinate directly —
// reading recomposed state here could be one frame stale.
val cancel = finalPos?.let { latestShouldCancel.value(it) } == true
latestOnTrackFinger.value(null)
if (isCapturing && !cancel) {
withTimeoutOrNull(ReleaseTailMs) { awaitPointerEvent() }