Merge pull request #1657 from davotoula/record-voice-on-off-button

Record voice on off button instead of hold to record
This commit is contained in:
Vitor Pamplona
2026-01-10 12:09:59 -05:00
committed by GitHub
7 changed files with 121 additions and 175 deletions
@@ -37,7 +37,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable
import com.vitorpamplona.amethyst.ui.components.ToggleableBox
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@@ -52,15 +52,16 @@ fun RecordAudioBox(
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
val context = LocalContext.current
var elapsedSeconds by remember { mutableIntStateOf(0) }
var wantsToRecord by remember { mutableStateOf(false) }
var pendingPermissionStart by remember { mutableStateOf(false) }
// Must be called at Composable scope, not in callback
val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
val scope = rememberCoroutineScope()
val isRecording = mediaRecorder.value != null
DisposableEffect(Unit) {
onDispose {
wantsToRecord = false
mediaRecorder.value?.stop()
mediaRecorder.value = null
}
@@ -74,8 +75,25 @@ fun RecordAudioBox(
}
}
LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) {
if (recordPermissionState.status.isGranted && wantsToRecord) {
fun stopRecording() {
val result = mediaRecorder.value?.stop()
mediaRecorder.value = null
if (result != null) {
onRecordTaken(result)
} else {
Toast
.makeText(
context,
stringRes(context, R.string.record_a_message_description),
Toast.LENGTH_SHORT,
).show()
}
}
// Start recording after permission is granted
LaunchedEffect(recordPermissionState.status.isGranted) {
if (recordPermissionState.status.isGranted && pendingPermissionStart) {
pendingPermissionStart = false
startRecording()
}
}
@@ -96,38 +114,21 @@ fun RecordAudioBox(
}
}
ClickAndHoldBoxComposable(
ToggleableBox(
modifier = modifier,
onPress = {
wantsToRecord = true
if (!recordPermissionState.status.isGranted) {
recordPermissionState.launchPermissionRequest()
isActive = isRecording,
onClick = {
if (isRecording) {
stopRecording()
} else {
// Start immediately for responsive UX when permission already granted
startRecording()
if (!recordPermissionState.status.isGranted) {
pendingPermissionStart = true
recordPermissionState.launchPermissionRequest()
} else {
startRecording()
}
}
},
onRelease = {
wantsToRecord = false
val result = mediaRecorder.value?.stop()
mediaRecorder.value = null
if (result != null) {
onRecordTaken(result)
} else {
// less disruptive than error messages
Toast
.makeText(
context,
stringRes(context, R.string.record_a_message_description),
Toast.LENGTH_SHORT,
).show()
}
},
onCancel = {
wantsToRecord = false
mediaRecorder.value?.stop()
mediaRecorder.value = null
},
content = @Composable { isRecording -> content(isRecording, elapsedSeconds) },
content = { active -> content(active, elapsedSeconds) },
)
}
@@ -35,7 +35,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -206,8 +206,8 @@ fun FloatingRecordingIndicator(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = innerPadding),
) {
// Pulsing red dot
val infiniteTransition = rememberInfiniteTransition(label = "recording_dot")
// Pulsing stop square
val infiniteTransition = rememberInfiniteTransition(label = "recording_stop")
val dotAlpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0.5f,
@@ -220,7 +220,7 @@ fun FloatingRecordingIndicator(
)
Icon(
imageVector = Icons.Default.FiberManualRecord,
imageVector = Icons.Default.Stop,
contentDescription = recordingLabel,
tint = Color.White,
modifier =
@@ -26,7 +26,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -71,7 +70,6 @@ fun AudioWaveformReadOnly(
progressBrush: Brush = SolidColor(Color.Blue),
waveformAlignment: WaveformAlignment = WaveformAlignment.Center,
amplitudeType: AmplitudeType = AmplitudeType.Avg,
onProgressChangeFinished: (() -> Unit)? = null,
spikeAnimationSpec: AnimationSpec<Float> = tween(500),
spikeWidth: Dp = 3.dp,
spikeRadius: Dp = 2.dp,
@@ -80,7 +78,6 @@ fun AudioWaveformReadOnly(
amplitudes: List<Float>,
onProgressChange: (Float) -> Unit,
) {
val backgroundColor = MaterialTheme.colorScheme.background
val progressState = remember(progress) { progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) }
val spikeWidthState =
remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) }
@@ -195,7 +192,20 @@ internal fun <T> Iterable<T>.chunkToSize(
internal fun Iterable<Float>.normalize(
min: Float,
max: Float,
): List<Float> = map { (max - min) * ((it - min()) / (max() - min())) + min }
): List<Float> {
val values = toList()
if (values.isEmpty()) return emptyList()
val currentMin = values.minOrNull() ?: return emptyList()
val currentMax = values.maxOrNull() ?: return emptyList()
val range = currentMax - currentMin
if (!range.isFinite() || range == 0f) {
return List(values.size) { min }
}
val scale = max - min
return values.map { scale * ((it - currentMin) / range) + min }
}
private fun Int.safeDiv(value: Int): Float {
return if (value == 0) return 0F else this / value.toFloat()
@@ -28,17 +28,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
@@ -64,116 +59,6 @@ fun ClickableBox(
}
}
@Composable
fun ClickAndHoldBox(
modifier: Modifier = Modifier,
onPress: () -> Unit,
onRelease: () -> Unit,
content: @Composable (Boolean) -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
LaunchedEffect(isPressed) {
if (isPressed) {
// Button is pressed
onPress()
} else {
// Button is released
onRelease()
}
}
// Animation for the button scale
val scale by animateFloatAsState(
targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording
animationSpec = tween(durationMillis = 150), // Smooth animation
)
// Animation for the button color
val backgroundColor by animateColorAsState(
targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background,
animationSpec = tween(durationMillis = 150),
)
Box(
modifier
.scale(scale)
.background(backgroundColor, CircleShape)
.clickable(
role = Role.Button,
interactionSource = interactionSource,
indication = ripple24dp,
onClick = { },
),
contentAlignment = Alignment.Center,
) {
content(isPressed)
}
}
@Composable
fun ClickAndHoldBoxComposable(
modifier: Modifier = Modifier,
onPress: () -> Unit,
onRelease: suspend () -> Unit,
onCancel: suspend () -> Unit,
content: @Composable (Boolean) -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
var isPressed by remember { mutableStateOf(false) }
LaunchedEffect(interactionSource) {
val pressInteractions = mutableListOf<PressInteraction.Press>()
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> {
if (pressInteractions.isEmpty()) {
onPress()
}
pressInteractions.add(interaction)
}
is PressInteraction.Release -> {
onRelease()
pressInteractions.remove(interaction.press)
}
is PressInteraction.Cancel -> {
onCancel()
pressInteractions.remove(interaction.press)
}
}
isPressed = pressInteractions.isNotEmpty()
}
}
// Animation for the button scale
val scale by animateFloatAsState(
targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording
animationSpec = tween(durationMillis = 150), // Smooth animation
)
// Animation for the button color
val backgroundColor by animateColorAsState(
targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background,
animationSpec = tween(durationMillis = 150),
)
Box(
modifier
.scale(scale)
.background(backgroundColor, CircleShape)
.clickable(
role = Role.Button,
interactionSource = interactionSource,
indication = ripple24dp,
onClick = { },
),
contentAlignment = Alignment.Center,
) {
content(isPressed)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClickableBox(
@@ -195,3 +80,38 @@ fun ClickableBox(
content()
}
}
@Composable
fun ToggleableBox(
modifier: Modifier = Modifier,
isActive: Boolean,
onClick: () -> Unit,
content: @Composable (Boolean) -> Unit,
) {
// Animation for the button scale
val scale by animateFloatAsState(
targetValue = if (isActive) 1.5f else 1.0f,
animationSpec = tween(durationMillis = 150),
)
// Animation for the button color
val backgroundColor by animateColorAsState(
targetValue = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background,
animationSpec = tween(durationMillis = 150),
)
Box(
modifier
.scale(scale)
.background(backgroundColor, CircleShape)
.clickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = ripple24dp,
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
content(isActive)
}
}
@@ -35,6 +35,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -53,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -212,29 +214,44 @@ private fun ReRecordButton(viewModel: VoiceReplyViewModel) {
onRecordTaken = { recording ->
viewModel.selectRecording(recording)
},
) { isRecording, _ ->
) { isRecording, elapsedSeconds ->
val contentColor =
if (isRecording) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onBackground
}
val icon =
if (isRecording) {
Icons.Default.Stop
} else {
Icons.Default.Mic
}
val label =
if (isRecording) {
formatSecondsToTime(elapsedSeconds)
} else {
stringRes(id = R.string.re_record)
}
val iconDescription =
if (isRecording) {
stringRes(id = R.string.recording_indicator_description)
} else {
stringRes(id = R.string.record_a_message)
}
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = stringRes(id = R.string.record_a_message),
tint =
if (isRecording) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground
},
imageVector = icon,
contentDescription = iconDescription,
tint = contentColor,
)
Text(
text = stringRes(id = R.string.re_record),
color =
if (isRecording) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground
},
text = label,
color = contentColor,
)
}
}
@@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.commons.util
import com.sun.org.apache.xalan.internal.lib.ExsltDatetime.time
import com.vitorpamplona.quartz.utils.TimeUtils
import java.text.SimpleDateFormat
import java.util.Locale