* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  remove unused imports
  guard for NaN crash with short recordings
  fix re-record button
  change icon to stop icon
  change from "hold to record" to "click to start, click to stop"
This commit is contained in:
Vitor Pamplona
2026-01-10 12:10:40 -05:00
12 changed files with 130 additions and 176 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 (!recordPermissionState.status.isGranted) { if (isRecording) {
recordPermissionState.launchPermissionRequest() stopRecording()
} else { } else {
// Start immediately for responsive UX when permission already granted if (!recordPermissionState.status.isGranted) {
startRecording() pendingPermissionStart = true
recordPermissionState.launchPermissionRequest()
} else {
startRecording()
}
} }
}, },
onRelease = { content = { active -> content(active, elapsedSeconds) },
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) },
) )
} }
@@ -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
},
) )
} }
} }
@@ -673,6 +673,7 @@
<string name="posting_policy">Politika příspěvků</string> <string name="posting_policy">Politika příspěvků</string>
<string name="privacy_policy">Zásady ochrany soukromí</string> <string name="privacy_policy">Zásady ochrany soukromí</string>
<string name="terms_and_conditions">Podmínky &amp; ujednání</string> <string name="terms_and_conditions">Podmínky &amp; ujednání</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Chyby a upozornění z tohoto relé</string> <string name="relay_error_messages">Chyby a upozornění z tohoto relé</string>
<string name="message_length">Délka zprávy</string> <string name="message_length">Délka zprávy</string>
<string name="subscriptions">Předplatné</string> <string name="subscriptions">Předplatné</string>
@@ -678,6 +678,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="posting_policy">Veröffentlichungsrichtlinie</string> <string name="posting_policy">Veröffentlichungsrichtlinie</string>
<string name="privacy_policy">Datenschutzerklärung</string> <string name="privacy_policy">Datenschutzerklärung</string>
<string name="terms_and_conditions">Allgemeine Geschäftsbedingungen</string> <string name="terms_and_conditions">Allgemeine Geschäftsbedingungen</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Fehler und Hinweise von diesem Relais</string> <string name="relay_error_messages">Fehler und Hinweise von diesem Relais</string>
<string name="message_length">Nachrichtenlänge</string> <string name="message_length">Nachrichtenlänge</string>
<string name="subscriptions">Abonnements</string> <string name="subscriptions">Abonnements</string>
@@ -64,7 +64,7 @@
<string name="zaps">Zapy</string> <string name="zaps">Zapy</string>
<string name="view_count">Liczba wyświetleń</string> <string name="view_count">Liczba wyświetleń</string>
<string name="boost">Powtórz</string> <string name="boost">Powtórz</string>
<string name="boosted">powtórzony</string> <string name="boosted">powtórzono</string>
<string name="edited">edytowano</string> <string name="edited">edytowano</string>
<string name="edited_number">edytuj #%1$s</string> <string name="edited_number">edytuj #%1$s</string>
<string name="original">oryginalny</string> <string name="original">oryginalny</string>
@@ -135,6 +135,8 @@
<string name="add_a_user">Dodaj użytkownika</string> <string name="add_a_user">Dodaj użytkownika</string>
<string name="add_a_relay">Dodaj Transmiter</string> <string name="add_a_relay">Dodaj Transmiter</string>
<string name="profile_name">Imię</string> <string name="profile_name">Imię</string>
<string name="profile_name_with_explainer">Imię (dla @tagging)</string>
<string name="my_name">Moje imię @tag</string>
<string name="display_name">Nazwa użytkownika</string> <string name="display_name">Nazwa użytkownika</string>
<string name="my_display_name">Mój nick</string> <string name="my_display_name">Mój nick</string>
<string name="my_awesome_name">G Braun</string> <string name="my_awesome_name">G Braun</string>
@@ -673,6 +673,7 @@
<string name="posting_policy">Política de postagem</string> <string name="posting_policy">Política de postagem</string>
<string name="privacy_policy">Política de privacidade</string> <string name="privacy_policy">Política de privacidade</string>
<string name="terms_and_conditions">Termos &amp; condições</string> <string name="terms_and_conditions">Termos &amp; condições</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Erros e Avisos deste Relé</string> <string name="relay_error_messages">Erros e Avisos deste Relé</string>
<string name="message_length">Tamanho da mensagem</string> <string name="message_length">Tamanho da mensagem</string>
<string name="subscriptions">Assinaturas</string> <string name="subscriptions">Assinaturas</string>
@@ -686,6 +687,8 @@
<string name="accepts_up_to">Aceita até</string> <string name="accepts_up_to">Aceita até</string>
<string name="time_in_the_future">%1$s no futuro</string> <string name="time_in_the_future">%1$s no futuro</string>
<string name="time_in_the_past">há %1$s</string> <string name="time_in_the_past">há %1$s</string>
<string name="amount_in_zeros">%1$s zeros</string>
<string name="amount_in_bits">%1$s bits</string>
<string name="event_retention">Retenção de eventos</string> <string name="event_retention">Retenção de eventos</string>
<string name="content_size">Tamanho do conteúdo</string> <string name="content_size">Tamanho do conteúdo</string>
<string name="connectivity">Conectividade</string> <string name="connectivity">Conectividade</string>
@@ -672,6 +672,7 @@
<string name="posting_policy">Publiceringspolicy</string> <string name="posting_policy">Publiceringspolicy</string>
<string name="privacy_policy">Integritetspolicy</string> <string name="privacy_policy">Integritetspolicy</string>
<string name="terms_and_conditions">Villkor &amp; bestämmelser</string> <string name="terms_and_conditions">Villkor &amp; bestämmelser</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Fel och meddelanden från detta relä</string> <string name="relay_error_messages">Fel och meddelanden från detta relä</string>
<string name="message_length">Meddelandelängd</string> <string name="message_length">Meddelandelängd</string>
<string name="subscriptions">Prenumerationer</string> <string name="subscriptions">Prenumerationer</string>
@@ -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