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.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R 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 com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -52,15 +52,16 @@ fun RecordAudioBox(
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) } val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
val context = LocalContext.current val context = LocalContext.current
var elapsedSeconds by remember { mutableIntStateOf(0) } 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 // Must be called at Composable scope, not in callback
val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isRecording = mediaRecorder.value != null
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
wantsToRecord = false
mediaRecorder.value?.stop() mediaRecorder.value?.stop()
mediaRecorder.value = null mediaRecorder.value = null
} }
@@ -74,8 +75,25 @@ fun RecordAudioBox(
} }
} }
LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) { fun stopRecording() {
if (recordPermissionState.status.isGranted && wantsToRecord) { 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() startRecording()
} }
} }
@@ -96,38 +114,21 @@ fun RecordAudioBox(
} }
} }
ClickAndHoldBoxComposable( ToggleableBox(
modifier = modifier, modifier = modifier,
onPress = { isActive = isRecording,
wantsToRecord = true onClick = {
if (isRecording) {
stopRecording()
} else {
if (!recordPermissionState.status.isGranted) { if (!recordPermissionState.status.isGranted) {
pendingPermissionStart = true
recordPermissionState.launchPermissionRequest() recordPermissionState.launchPermissionRequest()
} else { } else {
// Start immediately for responsive UX when permission already granted
startRecording() 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 = { content = { active -> content(active, elapsedSeconds) },
wantsToRecord = false
mediaRecorder.value?.stop()
mediaRecorder.value = null
},
content = @Composable { isRecording -> content(isRecording, elapsedSeconds) },
) )
} }
@@ -35,7 +35,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -206,8 +206,8 @@ fun FloatingRecordingIndicator(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = innerPadding), modifier = Modifier.padding(horizontal = innerPadding),
) { ) {
// Pulsing red dot // Pulsing stop square
val infiniteTransition = rememberInfiniteTransition(label = "recording_dot") val infiniteTransition = rememberInfiniteTransition(label = "recording_stop")
val dotAlpha by infiniteTransition.animateFloat( val dotAlpha by infiniteTransition.animateFloat(
initialValue = 1f, initialValue = 1f,
targetValue = 0.5f, targetValue = 0.5f,
@@ -220,7 +220,7 @@ fun FloatingRecordingIndicator(
) )
Icon( Icon(
imageVector = Icons.Default.FiberManualRecord, imageVector = Icons.Default.Stop,
contentDescription = recordingLabel, contentDescription = recordingLabel,
tint = Color.White, tint = Color.White,
modifier = modifier =
@@ -26,7 +26,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
@@ -71,7 +70,6 @@ fun AudioWaveformReadOnly(
progressBrush: Brush = SolidColor(Color.Blue), progressBrush: Brush = SolidColor(Color.Blue),
waveformAlignment: WaveformAlignment = WaveformAlignment.Center, waveformAlignment: WaveformAlignment = WaveformAlignment.Center,
amplitudeType: AmplitudeType = AmplitudeType.Avg, amplitudeType: AmplitudeType = AmplitudeType.Avg,
onProgressChangeFinished: (() -> Unit)? = null,
spikeAnimationSpec: AnimationSpec<Float> = tween(500), spikeAnimationSpec: AnimationSpec<Float> = tween(500),
spikeWidth: Dp = 3.dp, spikeWidth: Dp = 3.dp,
spikeRadius: Dp = 2.dp, spikeRadius: Dp = 2.dp,
@@ -80,7 +78,6 @@ fun AudioWaveformReadOnly(
amplitudes: List<Float>, amplitudes: List<Float>,
onProgressChange: (Float) -> Unit, onProgressChange: (Float) -> Unit,
) { ) {
val backgroundColor = MaterialTheme.colorScheme.background
val progressState = remember(progress) { progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) } val progressState = remember(progress) { progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) }
val spikeWidthState = val spikeWidthState =
remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) } remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) }
@@ -195,7 +192,20 @@ internal fun <T> Iterable<T>.chunkToSize(
internal fun Iterable<Float>.normalize( internal fun Iterable<Float>.normalize(
min: Float, min: Float,
max: 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 { private fun Int.safeDiv(value: Int): Float {
return if (value == 0) return 0F else this / value.toFloat() 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.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource 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.layout.Box
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ClickableBox( fun ClickableBox(
@@ -195,3 +80,38 @@ fun ClickableBox(
content() 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.RecordAudioBox
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview 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.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -212,29 +214,44 @@ private fun ReRecordButton(viewModel: VoiceReplyViewModel) {
onRecordTaken = { recording -> onRecordTaken = { recording ->
viewModel.selectRecording(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( Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Icon( Icon(
imageVector = Icons.Default.Mic, imageVector = icon,
contentDescription = stringRes(id = R.string.record_a_message), contentDescription = iconDescription,
tint = tint = contentColor,
if (isRecording) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground
},
) )
Text( Text(
text = stringRes(id = R.string.re_record), text = label,
color = color = contentColor,
if (isRecording) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground
},
) )
} }
} }
@@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.commons.util package com.vitorpamplona.amethyst.commons.util
import com.sun.org.apache.xalan.internal.lib.ExsltDatetime.time
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale