mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
voice: watch-quality recording UX - slide-to-cancel with magnetic X target (leans toward finger, blushes red with proximity, snaps on hover, REJECT haptic on cancel, firm click on enter/exit), release-anywhere tracking replaces button-owned release, timestamp left of waveform showing elapsed only, muted 1.5dp red outline, neutral grey recording pill with depth
This commit is contained in:
parent
3b53b9bdcc
commit
56f52218ed
@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
// [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher
|
||||
|
||||
@ -13,7 +14,9 @@ import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.animateOffsetAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
@ -43,6 +46,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@ -57,7 +65,10 @@ import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
@ -317,21 +328,68 @@ fun MessageInput(
|
||||
var elapsedMs by remember { mutableStateOf(0L) }
|
||||
var amplitude by remember { mutableStateOf(0) }
|
||||
|
||||
// Recording is the one state worth shouting about, so it overrides focus.
|
||||
// Slide-to-cancel: while recording, the mic button streams the finger position (root
|
||||
// coords) up here; the cancel disc beside it reports its bounds. Approaching the disc
|
||||
// makes it lean toward the finger and blush red; only entering it activates cancel.
|
||||
var cancelBounds by remember { mutableStateOf<Rect?>(null) }
|
||||
var cancelFinger by remember { mutableStateOf<Offset?>(null) }
|
||||
val density = LocalDensity.current
|
||||
val cancelSlackPx = with(density) { 8.dp.toPx() }
|
||||
val cancelHover = cancelFinger != null &&
|
||||
cancelBounds?.inflate(cancelSlackPx)?.contains(cancelFinger!!) == true
|
||||
val cancelCenter = cancelBounds?.center
|
||||
val cancelProximity: Float
|
||||
val cancelPull: Offset
|
||||
val trackedFinger = cancelFinger
|
||||
if (trackedFinger != null && cancelCenter != null) {
|
||||
val toFinger = trackedFinger - cancelCenter
|
||||
val dist = toFinger.getDistance()
|
||||
val outer = with(density) { 36.dp.toPx() }
|
||||
val inner = with(density) { 18.dp.toPx() }
|
||||
cancelProximity = ((outer - dist) / (outer - inner)).coerceIn(0f, 1f)
|
||||
cancelPull = if (dist > 1f) {
|
||||
toFinger * (cancelProximity * with(density) { 12.dp.toPx() } / dist)
|
||||
} else Offset.Zero
|
||||
} else {
|
||||
cancelProximity = 0f
|
||||
cancelPull = Offset.Zero
|
||||
}
|
||||
// A firm, physical click each time the finger enters or leaves the cancel target.
|
||||
val view = LocalView.current
|
||||
var cancelHoverHapticState by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(cancelHover, isRecording) {
|
||||
if (!isRecording) {
|
||||
cancelHoverHapticState = false
|
||||
} else if (cancelHover != cancelHoverHapticState) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
|
||||
cancelHoverHapticState = cancelHover
|
||||
}
|
||||
}
|
||||
|
||||
// Recording is the one state worth shouting about, so it overrides focus. While recording
|
||||
// the outline also firms up slightly in the same fast sweep — present, but muted.
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isRecording -> colorScheme.error.copy(alpha = 0.7f)
|
||||
isRecording -> colorScheme.error.copy(alpha = 0.65f)
|
||||
isFocused.value -> palette.inputOutlineFocused
|
||||
else -> palette.inputOutline
|
||||
},
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "composerBorder"
|
||||
)
|
||||
// A barely-there lift on focus. Enough to register, not enough to look like a different
|
||||
// component. Slightly translucent so the messages scrolling underneath stay faintly visible.
|
||||
val borderWidth by animateDpAsState(
|
||||
targetValue = if (isRecording) 1.5.dp else 1.dp,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "composerBorderWidth"
|
||||
)
|
||||
// A barely-there lift on focus. While recording the pill turns into a neutral grey slab
|
||||
// (NOT the brand-tinted elevation color) so it protrudes from the flat black chat.
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface)
|
||||
.copy(alpha = ComposerFillAlpha),
|
||||
targetValue = when {
|
||||
isRecording -> colorScheme.surfaceVariant.copy(alpha = 0.97f)
|
||||
else -> (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface)
|
||||
.copy(alpha = ComposerFillAlpha)
|
||||
},
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "composerContainer"
|
||||
)
|
||||
@ -351,7 +409,7 @@ fun MessageInput(
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
|
||||
)
|
||||
.background(containerColor, ComposerShape)
|
||||
.border(1.dp, borderColor, ComposerShape),
|
||||
.border(borderWidth, borderColor, ComposerShape),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Box(
|
||||
@ -439,22 +497,21 @@ fun MessageInput(
|
||||
.alpha(waveformAlpha),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RealtimeScrollingWaveform(
|
||||
modifier = Modifier.weight(1f).height(22.dp),
|
||||
amplitudeNorm = normalizeAmplitudeSample(amplitude)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
// Timestamp on the left, clear of the thumb resting on the record
|
||||
// button; the waveform keeps the remaining width and its history
|
||||
// scrolls off the left edge while live data streams in from the right.
|
||||
val secs = (elapsedMs / 1000).toInt()
|
||||
val maxSecs = 10 // 10 second max recording time
|
||||
Text(
|
||||
text = String.format(
|
||||
"%02d:%02d / %02d:%02d",
|
||||
secs / 60, secs % 60, maxSecs / 60, maxSecs % 60
|
||||
),
|
||||
text = String.format("%02d:%02d", secs / 60, secs % 60),
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.error,
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
RealtimeScrollingWaveform(
|
||||
modifier = Modifier.weight(1f).height(22.dp),
|
||||
amplitudeNorm = normalizeAmplitudeSample(amplitude)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -528,8 +585,35 @@ fun MessageInput(
|
||||
)
|
||||
}
|
||||
|
||||
// The slide-to-cancel target sits well clear of the record
|
||||
// button (camera's slot plus a gap), rests as a cancel disc,
|
||||
// leans toward an approaching finger and snaps red on hover.
|
||||
AnimatedVisibility(
|
||||
visible = isRecording,
|
||||
enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
|
||||
expandHorizontally(
|
||||
tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
|
||||
),
|
||||
exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
|
||||
shrinkHorizontally(
|
||||
tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing)
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RecordingCancelButton(
|
||||
hover = cancelHover,
|
||||
proximity = cancelProximity,
|
||||
pull = cancelPull,
|
||||
onBounds = { cancelBounds = it }
|
||||
)
|
||||
Spacer(Modifier.width(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
VoiceRecordButton(
|
||||
isRecording = isRecording,
|
||||
shouldCancel = { cancelHover },
|
||||
onTrackFinger = { cancelFinger = it },
|
||||
onStart = {
|
||||
isRecording = true
|
||||
elapsedMs = 0L
|
||||
@ -583,6 +667,76 @@ fun MessageInput(
|
||||
// Auto-stop handled inside VoiceRecordButton
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-to-cancel target shown beside the record button while capturing. It always shows the
|
||||
* cancel glyph so the destination is unambiguous; as the finger approaches it leans toward
|
||||
* it (magnetic pull) and blushes red, and on contact it blooms. Release there cancels;
|
||||
* sliding back out returns to send mode. All motion is spring-driven so it stays fluid.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecordingCancelButton(
|
||||
hover: Boolean,
|
||||
proximity: Float,
|
||||
pull: Offset,
|
||||
onBounds: (Rect) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val pullAnim by animateOffsetAsState(
|
||||
targetValue = pull,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
),
|
||||
label = "cancelPull"
|
||||
)
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (hover) 1.28f else 1f + 0.1f * proximity,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
),
|
||||
label = "cancelScale"
|
||||
)
|
||||
val container = androidx.compose.ui.graphics.lerp(
|
||||
palette.inputButton,
|
||||
colorScheme.error,
|
||||
if (hover) 1f else proximity * 0.85f
|
||||
)
|
||||
val tint = androidx.compose.ui.graphics.lerp(
|
||||
colorScheme.onSurfaceVariant,
|
||||
colorScheme.onError,
|
||||
if (hover) 1f else proximity * 0.6f
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onGloballyPositioned { coords ->
|
||||
onBounds(Rect(coords.localToRoot(Offset.Zero), coords.size.toSize()))
|
||||
}
|
||||
.size(ComposerButtonSize),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(ComposerButtonDisc)
|
||||
.scale(scale)
|
||||
.offset { IntOffset(pullAnim.x.roundToInt(), pullAnim.y.roundToInt()) }
|
||||
.background(container, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Cancel recording",
|
||||
tint = tint,
|
||||
modifier = Modifier.size(ComposerIconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send affordance. Only rendered when there is something to send, so its mere presence is the
|
||||
* signal; it does not need to shout in the terminal's full-brightness green as well.
|
||||
|
||||
@ -1,18 +1,25 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.features.voice.VoiceRecorder
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionStatus
|
||||
@ -22,6 +29,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* How long the button must be held before a recording starts.
|
||||
@ -59,24 +67,35 @@ fun VoiceRecordButton(
|
||||
* pill's border change together instead of one lagging the other.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
shouldCancel: () -> Boolean = { false },
|
||||
/**
|
||||
* Finger position in root coordinates while a capture is live (drives the magnetic
|
||||
* cancel target); null once the gesture ends.
|
||||
*/
|
||||
onTrackFinger: (Offset?) -> Unit = {},
|
||||
onStart: () -> Unit,
|
||||
onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit,
|
||||
onFinish: (filePath: String) -> Unit,
|
||||
/**
|
||||
* Invoked whenever a recording ends without producing a file — permission denied, recorder
|
||||
* failure, or the button being torn down mid-capture. The caller needs this to clear its own
|
||||
* recording state; without it a failed capture left the composer stuck in recording mode.
|
||||
* failure, the button being torn down mid-capture, or a deliberate slide-to-cancel.
|
||||
*/
|
||||
onCancel: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val view = LocalView.current
|
||||
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
|
||||
|
||||
var isCapturing by remember { mutableStateOf(false) }
|
||||
var recorder by remember { mutableStateOf<VoiceRecorder?>(null) }
|
||||
var recordedFilePath by remember { mutableStateOf<String?>(null) }
|
||||
var recordingStart by remember { mutableStateOf(0L) }
|
||||
var buttonCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var ampJob by remember { mutableStateOf<Job?>(null) }
|
||||
@ -86,6 +105,8 @@ fun VoiceRecordButton(
|
||||
val latestOnAmplitude = rememberUpdatedState(onAmplitude)
|
||||
val latestOnFinish = rememberUpdatedState(onFinish)
|
||||
val latestOnCancel = rememberUpdatedState(onCancel)
|
||||
val latestShouldCancel = rememberUpdatedState(shouldCancel)
|
||||
val latestOnTrackFinger = rememberUpdatedState(onTrackFinger)
|
||||
|
||||
// Set when this instance was composed, so presses inherited from whatever occupied this spot
|
||||
// beforehand can be rejected.
|
||||
@ -110,6 +131,7 @@ fun VoiceRecordButton(
|
||||
runCatching { recorder?.stop() }
|
||||
recorder = null
|
||||
recordedFilePath = null
|
||||
latestOnTrackFinger.value(null)
|
||||
latestOnCancel.value()
|
||||
}
|
||||
}
|
||||
@ -120,99 +142,123 @@ fun VoiceRecordButton(
|
||||
isActive = isRecording || isCapturing,
|
||||
isPressed = isCapturing,
|
||||
modifier = modifier
|
||||
.onGloballyPositioned { buttonCoords = it }
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
// Guard 1: ignore anything arriving before the swap animation settled.
|
||||
if (System.currentTimeMillis() - composedAt < ArmDelayMs) {
|
||||
return@detectTapGestures
|
||||
}
|
||||
// Guard 2: never start a second capture on top of a live one.
|
||||
if (isCapturing) return@detectTapGestures
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
// Guard 1: ignore anything arriving before the swap animation settled.
|
||||
if (System.currentTimeMillis() - composedAt < ArmDelayMs) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
// Guard 2: never start a second capture on top of a live one.
|
||||
if (isCapturing) return@awaitEachGesture
|
||||
|
||||
if (micPermission.status !is PermissionStatus.Granted) {
|
||||
micPermission.launchPermissionRequest()
|
||||
return@detectTapGestures
|
||||
}
|
||||
if (micPermission.status !is PermissionStatus.Granted) {
|
||||
micPermission.launchPermissionRequest()
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
// Guard 3: require a deliberate hold. `tryAwaitRelease` returns true on
|
||||
// release and false on cancellation; either way the press was not a hold,
|
||||
// so nothing should happen. Only a timeout means the finger is still down.
|
||||
val stillHeld = withTimeoutOrNull(HoldToRecordMs) {
|
||||
tryAwaitRelease()
|
||||
} == null
|
||||
if (!stillHeld) return@detectTapGestures
|
||||
// Guard 3: require a deliberate hold. An up (or a stolen pointer) inside the
|
||||
// arm window means the press was never a hold; only the timeout means the
|
||||
// finger is still down.
|
||||
var stolenDuringArm = false
|
||||
val releasedEarly = withTimeoutOrNull(HoldToRecordMs) {
|
||||
waitForUpOrCancellation().also { if (it == null) stolenDuringArm = true }
|
||||
}
|
||||
if (releasedEarly != null || stolenDuringArm) return@awaitEachGesture
|
||||
|
||||
val rec = VoiceRecorder(context)
|
||||
val startedFile = rec.start()
|
||||
if (startedFile == null) {
|
||||
// Recorder refused to start; make sure the caller does not sit in a
|
||||
// recording state that never began.
|
||||
runCatching { rec.stop() }
|
||||
latestOnCancel.value()
|
||||
return@detectTapGestures
|
||||
}
|
||||
val rec = VoiceRecorder(context)
|
||||
val startedFile = rec.start()
|
||||
if (startedFile == null) {
|
||||
// Recorder refused to start; make sure the caller does not sit in a
|
||||
// recording state that never began.
|
||||
runCatching { rec.stop() }
|
||||
latestOnCancel.value()
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
recorder = rec
|
||||
recordedFilePath = startedFile.absolutePath
|
||||
recordingStart = System.currentTimeMillis()
|
||||
isCapturing = true
|
||||
latestOnStart.value()
|
||||
buzz()
|
||||
recorder = rec
|
||||
recordedFilePath = startedFile.absolutePath
|
||||
recordingStart = System.currentTimeMillis()
|
||||
isCapturing = true
|
||||
latestOnStart.value()
|
||||
buzz()
|
||||
|
||||
ampJob?.cancel()
|
||||
ampJob = scope.launch {
|
||||
while (isActive && isCapturing) {
|
||||
val amp = recorder?.pollAmplitude() ?: 0
|
||||
val elapsed =
|
||||
(System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
|
||||
latestOnAmplitude.value(amp, elapsed)
|
||||
ampJob?.cancel()
|
||||
ampJob = scope.launch {
|
||||
while (isActive && isCapturing) {
|
||||
val amp = recorder?.pollAmplitude() ?: 0
|
||||
val elapsed =
|
||||
(System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
|
||||
latestOnAmplitude.value(amp, elapsed)
|
||||
|
||||
if (elapsed >= MaxRecordingMs && isCapturing) {
|
||||
val file = recorder?.stop()
|
||||
isCapturing = false
|
||||
recorder = null
|
||||
val path = file?.absolutePath ?: recordedFilePath
|
||||
recordedFilePath = null
|
||||
buzz()
|
||||
// Always report the outcome, even when the file is unusable,
|
||||
// or the caller stays stuck showing the waveform.
|
||||
if (!path.isNullOrBlank()) {
|
||||
latestOnFinish.value(path)
|
||||
} else {
|
||||
latestOnCancel.value()
|
||||
}
|
||||
break
|
||||
}
|
||||
delay(80)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
tryAwaitRelease()
|
||||
} finally {
|
||||
if (isCapturing) {
|
||||
// Keep going briefly past the release so the tail is not clipped.
|
||||
delay(ReleaseTailMs)
|
||||
}
|
||||
if (isCapturing) {
|
||||
if (elapsed >= MaxRecordingMs && isCapturing) {
|
||||
val file = recorder?.stop()
|
||||
isCapturing = false
|
||||
recorder = null
|
||||
val path = file?.absolutePath ?: recordedFilePath
|
||||
recordedFilePath = null
|
||||
latestOnTrackFinger.value(null)
|
||||
buzz()
|
||||
// Always report the outcome, even when the file is unusable,
|
||||
// or the caller stays stuck showing the waveform.
|
||||
if (!path.isNullOrBlank()) {
|
||||
latestOnFinish.value(path)
|
||||
} else {
|
||||
latestOnCancel.value()
|
||||
}
|
||||
break
|
||||
}
|
||||
ampJob?.cancel()
|
||||
ampJob = null
|
||||
delay(80)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 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.
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: continue
|
||||
buttonCoords?.let {
|
||||
latestOnTrackFinger.value(it.localToRoot(change.position))
|
||||
}
|
||||
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()
|
||||
latestOnTrackFinger.value(null)
|
||||
if (isCapturing && !cancel) {
|
||||
withTimeoutOrNull(ReleaseTailMs) { awaitPointerEvent() }
|
||||
}
|
||||
if (isCapturing) {
|
||||
val file = recorder?.stop()
|
||||
isCapturing = false
|
||||
recorder = null
|
||||
val path = file?.absolutePath ?: recordedFilePath
|
||||
recordedFilePath = null
|
||||
if (cancel) {
|
||||
path?.let { runCatching { File(it).delete() } }
|
||||
try {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.REJECT)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
latestOnCancel.value()
|
||||
} else {
|
||||
buzz()
|
||||
if (!path.isNullOrBlank()) {
|
||||
latestOnFinish.value(path)
|
||||
} else {
|
||||
latestOnCancel.value()
|
||||
}
|
||||
}
|
||||
}
|
||||
ampJob?.cancel()
|
||||
ampJob = null
|
||||
}
|
||||
}
|
||||
) { tint ->
|
||||
Icon(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user