From 8ebd76b77b6a23d7248e4c188a5974f3c0e680f8 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 17:10:30 +0100 Subject: [PATCH 01/11] add a record voice button to new post screen --- .../ui/actions/uploads/RecordVoiceButton.kt | 49 +++++++ .../loggedIn/home/ShortNotePostScreen.kt | 20 ++- .../loggedIn/home/ShortNotePostViewModel.kt | 125 +++++++++++++++++- 3 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt new file mode 100644 index 0000000000..3d31983110 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { + RecordAudioBox( + modifier = Modifier, + onRecordTaken = { recording -> + onVoiceTaken(recording) + }, + ) { isRecording -> + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + modifier = Modifier.height(22.dp), + tint = if (isRecording) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 581d6825dd..6617d5b1f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -57,6 +57,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -405,12 +406,16 @@ private fun NewPostScreenBody( ) } - BottomRowActions(postViewModel) + BottomRowActions(postViewModel, accountViewModel) } } @Composable -private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { +private fun BottomRowActions( + postViewModel: ShortNotePostViewModel, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current val scrollState = rememberScrollState() Row( modifier = @@ -440,6 +445,17 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { }, ) + RecordVoiceButton( + onVoiceTaken = { recording -> + postViewModel.selectVoiceRecording(recording) + postViewModel.uploadVoiceMessage( + accountViewModel.account.settings.defaultFileServer, + accountViewModel.toastManager::toast, + context, + ) + }, + ) + if (postViewModel.canUsePoll) { // These should be hashtag recommendations the user selects in the future. // val hashtag = stringRes(R.string.poll_hashtag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index bb07d2c194..624a999e59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -42,11 +42,14 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -116,6 +119,9 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers @@ -177,6 +183,11 @@ open class ShortNotePostViewModel : // Images and Videos var multiOrchestrator by mutableStateOf(null) + // Voice Messages + var voiceRecording by mutableStateOf(null) + var isUploadingVoice by mutableStateOf(false) + var voiceMetadata by mutableStateOf(null) + // Polls var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) @@ -504,6 +515,23 @@ open class ShortNotePostViewModel : } private suspend fun createTemplate(): EventTemplate? { + // Check if this is a voice message + voiceMetadata?.let { audioMeta -> + return if (originalNote != null) { + // Create voice reply event + @Suppress("UNCHECKED_CAST") + VoiceReplyEvent.build( + voiceMessage = audioMeta, + replyingTo = originalNote!!.toEventHint() as com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle, + ) + } else { + // Create root voice event + VoiceEvent.build( + voiceMessage = audioMeta, + ) + } + } + val tagger = NewMessageTagger( message.text, @@ -715,6 +743,9 @@ open class ShortNotePostViewModel : multiOrchestrator = null isUploadingImage = false + voiceRecording = null + isUploadingVoice = false + voiceMetadata = null pTags = null wantsPoll = false @@ -833,9 +864,16 @@ open class ShortNotePostViewModel : private fun newStateMapPollOptions(): SnapshotStateMap = mutableStateMapOf(Pair(0, ""), Pair(1, "")) - fun canPost(): Boolean = - message.text.isNotBlank() && + fun canPost(): Boolean { + // Voice messages can be posted without text + if (voiceMetadata != null) { + return !isUploadingVoice && !isUploadingImage + } + + // Regular text/media posts require text + return message.text.isNotBlank() && !isUploadingImage && + !isUploadingVoice && !wantsInvoice && (!wantsZapRaiser || zapRaiserAmount.value != null) && ( @@ -846,7 +884,9 @@ open class ShortNotePostViewModel : isValidValueMaximum.value ) ) && - multiOrchestrator == null + multiOrchestrator == null && + voiceRecording == null + } fun insertAtCursor(newElement: String) { message = message.insertUrlAtCursor(newElement) @@ -856,6 +896,85 @@ open class ShortNotePostViewModel : multiOrchestrator = MultiOrchestrator(uris) } + fun selectVoiceRecording(recording: RecordingResult) { + voiceRecording = recording + } + + fun uploadVoiceMessage( + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + val recording = voiceRecording ?: return + + viewModelScope.launch(Dispatchers.IO) { + isUploadingVoice = true + + try { + val uri = android.net.Uri.fromFile(recording.file) + val orchestrator = UploadOrchestrator() + + val result = + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = server, + account = account, + context = context, + useH265 = false, + ) + + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + voiceMetadata = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, + ) + voiceRecording = null + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + // For NIP95, we need to create the event and get the nevent URL + // This is handled differently - skip for now + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + "NIP95 not yet supported for voice messages", + ) + } + } + } + is UploadingState.Error -> { + val errorMessage = stringRes(context, result.errorResource, *result.params) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessage) + voiceRecording = null + } + else -> { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + "Unexpected upload state", + ) + } + } + } catch (e: Exception) { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + e.message ?: e.javaClass.simpleName, + ) + voiceRecording = null + } finally { + isUploadingVoice = false + } + } + } + override fun locationFlow(): StateFlow { if (location == null) { location = locationManager().geohashStateFlow From c0d7afe86c0c286c7fc4f71e9aa98d6e7f250aa9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 17:24:45 +0100 Subject: [PATCH 02/11] fix potential race condition with previous recorder in method stop --- .../ui/actions/uploads/VoiceMessageRecorder.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt index 5c5115eae2..6e5323cc41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import android.content.Context import android.media.MediaRecorder import android.os.Build +import android.util.Log import androidx.media3.common.MimeTypes import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils @@ -53,7 +54,7 @@ class VoiceMessageRecorder { MediaRecorder() } - suspend fun start( + fun start( context: Context, scope: CoroutineScope, ) { @@ -88,8 +89,15 @@ class VoiceMessageRecorder { } } - suspend fun stop(): RecordingResult? { - recorder?.stop() + fun stop(): RecordingResult? { + job?.cancel() + job = null + + try { + recorder?.stop() + } catch (e: RuntimeException) { + Log.w("VoiceMessageRecorder", "Failed to stop recording... Too short?", e) + } recorder?.reset() recorder = null val currentTime = TimeUtils.now() From f3fea8cfb4c42972d6fe9dc4186d5845355d6b88 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 18:05:53 +0100 Subject: [PATCH 03/11] Flow: Record, preview, then post or cancel Added media server selection for upload Added recording indicator Added upload progress Text input disabled for voice messages --- .../ui/actions/uploads/RecordAudio.kt | 24 +- .../ui/actions/uploads/RecordVoiceButton.kt | 76 +++++- .../ui/actions/uploads/RecordingIndicators.kt | 248 ++++++++++++++++++ .../ui/actions/uploads/VoiceMessagePreview.kt | 219 ++++++++++++++++ .../amethyst/ui/note/ReactionsRow.kt | 2 +- .../loggedIn/home/ShortNotePostScreen.kt | 155 +++++++++-- .../loggedIn/home/ShortNotePostViewModel.kt | 154 ++++++----- 7 files changed, 784 insertions(+), 94 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 889a29a9b0..1d8f2ff23b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -24,9 +24,12 @@ import android.Manifest import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -35,16 +38,30 @@ import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.delay @OptIn(ExperimentalPermissionsApi::class) @Composable fun RecordAudioBox( modifier: Modifier, onRecordTaken: (RecordingResult) -> Unit, - content: @Composable (Boolean) -> Unit, + content: @Composable (Boolean, Int) -> Unit, ) { val mediaRecorder = remember { mutableStateOf(null) } val context = LocalContext.current + var elapsedSeconds by remember { mutableIntStateOf(0) } + + // Track elapsed time while recording + LaunchedEffect(mediaRecorder.value) { + if (mediaRecorder.value != null) { + while (mediaRecorder.value != null) { + delay(1000) + elapsedSeconds++ + } + } else { + elapsedSeconds = 0 + } + } ClickAndHoldBoxComposable( modifier = modifier, @@ -55,6 +72,7 @@ fun RecordAudioBox( if (!recordPermissionState.status.isGranted) { recordPermissionState.launchPermissionRequest() } else { + elapsedSeconds = 0 mediaRecorder.value = VoiceMessageRecorder() mediaRecorder.value?.start(context, scope) } @@ -62,6 +80,7 @@ fun RecordAudioBox( }, onRelease = { val result = mediaRecorder.value?.stop() + mediaRecorder.value = null if (result != null) { onRecordTaken(result) } else { @@ -76,7 +95,8 @@ fun RecordAudioBox( }, onCancel = { mediaRecorder.value?.stop() + mediaRecorder.value = null }, - content, + content = @Composable { isRecording -> content(isRecording, elapsedSeconds) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index 3d31983110..f390eea3ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -20,12 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.actions.uploads +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.unit.dp import com.vitorpamplona.amethyst.R @@ -33,17 +42,62 @@ import com.vitorpamplona.amethyst.ui.stringRes @Composable fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { - RecordAudioBox( - modifier = Modifier, - onRecordTaken = { recording -> - onVoiceTaken(recording) - }, - ) { isRecording -> - Icon( - imageVector = Icons.Default.Mic, - contentDescription = stringRes(id = R.string.record_a_message), - modifier = Modifier.height(22.dp), - tint = if (isRecording) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + var isRecording by remember { mutableStateOf(false) } + var elapsedSeconds by remember { mutableIntStateOf(0) } + + Column { + // Floating recording indicator at the top + FloatingRecordingIndicator( + modifier = Modifier.height(50.dp), + isRecording = isRecording, + elapsedSeconds = elapsedSeconds, + ) + + RecordAudioBox( + modifier = Modifier, + onRecordTaken = { recording -> + isRecording = false + elapsedSeconds = 0 + onVoiceTaken(recording) + }, + ) { recordingState, elapsed -> + // Update parent state to trigger recompositions + if (isRecording != recordingState) { + isRecording = recordingState + } + if (elapsedSeconds != elapsed) { + elapsedSeconds = elapsed + } + + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + // Expanding circles background animation + ExpandingCirclesAnimation( + modifier = Modifier.size(48.dp), + isRecording = recordingState, + primaryColor = MaterialTheme.colorScheme.primary, + ) + + // Microphone icon + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + modifier = Modifier.height(22.dp), + tint = + if (recordingState) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } + } + + // Empty space at the bottom for layout balance + Box( + modifier = Modifier.height(50.dp), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt new file mode 100644 index 0000000000..ae2c25aab7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -0,0 +1,248 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Animated expanding circles that pulse outward from the recording button + */ +@Composable +fun ExpandingCirclesAnimation( + modifier: Modifier = Modifier, + isRecording: Boolean, + primaryColor: Color = MaterialTheme.colorScheme.primary, +) { + val infiniteTransition = rememberInfiniteTransition(label = "expanding_circles") + + // First circle animation + val scale1 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 2.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle1_scale", + ) + + val alpha1 by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle1_alpha", + ) + + // Second circle animation (offset by 500ms) + val scale2 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 2.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle2_scale", + ) + + val alpha2 by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle2_alpha", + ) + + // Third circle animation (offset by 1000ms) + val scale3 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 2.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle3_scale", + ) + + val alpha3 by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle3_alpha", + ) + + if (!isRecording) return + + Layout( + modifier = modifier, + content = { + // Circle 1 + Box( + modifier = + Modifier + .scale(scale1) + .alpha(alpha1) + .background(primaryColor.copy(alpha = 0.3f), CircleShape), + ) + + // Circle 2 + Box( + modifier = + Modifier + .scale(scale2) + .alpha(alpha2) + .background(primaryColor.copy(alpha = 0.2f), CircleShape), + ) + + // Circle 3 + Box( + modifier = + Modifier + .scale(scale3) + .alpha(alpha3) + .background(primaryColor.copy(alpha = 0.1f), CircleShape), + ) + }, + ) { measurables, constraints -> + // All circles are centered at the same position + val placeables = measurables.map { it.measure(constraints) } + layout(constraints.maxWidth, constraints.maxHeight) { + placeables.forEach { placeable -> + placeable.placeRelative( + x = (constraints.maxWidth - placeable.width) / 2, + y = (constraints.maxHeight - placeable.height) / 2, + ) + } + } + } +} + +/** + * Floating recording indicator showing elapsed time + */ +@Composable +fun FloatingRecordingIndicator( + modifier: Modifier = Modifier, + isRecording: Boolean, + elapsedSeconds: Int, +) { + if (!isRecording) return + + Box( + modifier = + modifier + .fillMaxWidth() + .height(48.dp) + .padding(horizontal = 16.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + // Pulsing red dot + val infiniteTransition = rememberInfiniteTransition(label = "recording_dot") + val dotAlpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1000), + repeatMode = RepeatMode.Reverse, + ), + label = "dot_alpha", + ) + + Icon( + imageVector = Icons.Default.FiberManualRecord, + contentDescription = "Recording", + tint = Color.White, + modifier = + Modifier + .alpha(dotAlpha) + .padding(end = 8.dp), + ) + + Text( + text = "Recording ${formatSeconds(elapsedSeconds)}", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + } + } +} + +private fun formatSeconds(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return if (minutes > 0) { + String.format("%d:%02d", minutes, secs) + } else { + String.format("0:%02d", secs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt new file mode 100644 index 0000000000..bff7e3efbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import android.content.Context +import android.media.MediaPlayer +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.Brush +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import java.io.File + +@Composable +fun VoiceMessagePreview( + voiceMetadata: AudioMeta, + localFile: File? = null, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var isPlaying by remember { mutableStateOf(false) } + var progress by remember { mutableFloatStateOf(0f) } + var mediaPlayer by remember { mutableStateOf(null) } + + // Initialize MediaPlayer + DisposableEffect(voiceMetadata.url, localFile) { + val player = createMediaPlayer(context, voiceMetadata.url, localFile) + mediaPlayer = player + + onDispose { + player?.release() + mediaPlayer = null + isPlaying = false + } + } + + // Update progress while playing + LaunchedEffect(isPlaying) { + if (isPlaying && mediaPlayer != null) { + while (isActive && isPlaying) { + val player = mediaPlayer + if (player != null && player.isPlaying) { + val current = player.currentPosition.toFloat() + val duration = player.duration.toFloat() + progress = if (duration > 0) current / duration else 0f + delay(100) + } else { + isPlaying = false + progress = 0f + } + } + } + } + + Box( + modifier = + modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp), + ).padding(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // Play/Pause Button + IconButton( + onClick = { + mediaPlayer?.let { player -> + if (isPlaying) { + player.pause() + isPlaying = false + } else { + if (progress >= 1f) { + player.seekTo(0) + progress = 0f + } + player.start() + isPlaying = true + } + } + }, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Waveform and Duration + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + AudioWaveformReadOnly( + amplitudes = voiceMetadata.waveform ?: emptyList(), + progress = progress, + waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), + progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), + onProgressChange = { newProgress -> + mediaPlayer?.let { player -> + val newPosition = (newProgress * player.duration).toInt() + player.seekTo(newPosition) + progress = newProgress + } + }, + ) + + Text( + text = formatDuration(voiceMetadata.duration ?: 0), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Remove Button + IconButton( + onClick = onRemove, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringRes(context, R.string.remove), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +private fun createMediaPlayer( + context: Context, + url: String, + localFile: File?, +): MediaPlayer? = + try { + MediaPlayer().apply { + if (localFile != null && localFile.exists()) { + setDataSource(localFile.absolutePath) + } else { + setDataSource(url) + } + prepare() + setOnCompletionListener { + // Reset to beginning when playback completes + seekTo(0) + } + } + } catch (e: Exception) { + null + } + +private fun formatDuration(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return String.format("%d:%02d", minutes, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index bd8d3d7f4a..716d0191f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -609,7 +609,7 @@ fun ReplyViaVoiceReaction( onRecordTaken = { audio -> accountViewModel.sendVoiceReply(baseNote, audio, context) }, - ) { + ) { _, _ -> VoiceReplyIcon(iconSizeModifier, grayTint) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 6617d5b1f5..09b288a3ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -24,7 +24,10 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -35,33 +38,47 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -94,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -261,18 +279,21 @@ private fun NewPostScreenBody( } } - Row( - modifier = Modifier.padding(vertical = Size10dp), - ) { - BaseUserPicture( - accountViewModel.userProfile(), - Size35dp, - accountViewModel = accountViewModel, - ) - MessageField( - R.string.what_s_on_your_mind, - postViewModel, - ) + // Only show text input if no voice message is being posted + if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) { + Row( + modifier = Modifier.padding(vertical = Size10dp), + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + MessageField( + R.string.what_s_on_your_mind, + postViewModel, + ) + } } if (postViewModel.wantsPoll) { @@ -342,6 +363,58 @@ private fun NewPostScreenBody( } } + // Show preview for both uploaded messages (voiceMetadata) and pending recordings + (postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata -> + val nip95description = stringRes(id = R.string.upload_server_relays_nip95) + val fileServersState = + accountViewModel.account.serverLists.liveServerList + .collectAsState() + val fileServers = fileServersState.value + + val fileServerOptions = + remember(fileServers) { + fileServers + .map { + if (it.type == ServerType.NIP95) { + TitleExplainer(it.name, nip95description) + } else { + TitleExplainer(it.name, it.baseUrl) + } + }.toImmutableList() + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = Size5dp, horizontal = Size10dp), + ) { + // Display voice preview or uploading progress + postViewModel.voiceOrchestrator?.let { orchestrator -> + VoiceUploadingProgress(orchestrator) + } ?: run { + VoiceMessagePreview( + voiceMetadata = metadata, + localFile = postViewModel.voiceLocalFile, + onRemove = { postViewModel.removeVoiceMessage() }, + ) + } + + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = + fileServers + .firstOrNull { it == (postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) } + ?.name + ?: fileServers[0].name, + options = fileServerOptions, + onSelect = { postViewModel.voiceSelectedServer = fileServers[it] }, + ) + } + } + } + if (postViewModel.wantsInvoice) { postViewModel.lnAddress()?.let { lud16 -> InvoiceRequest( @@ -448,11 +521,6 @@ private fun BottomRowActions( RecordVoiceButton( onVoiceTaken = { recording -> postViewModel.selectVoiceRecording(recording) - postViewModel.uploadVoiceMessage( - accountViewModel.account.settings.defaultFileServer, - accountViewModel.toastManager::toast, - context, - ) }, ) @@ -520,3 +588,56 @@ private fun AddPollButton( } } } + +@Composable +private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { + val progressValue = orchestrator.progress.collectAsState().value + val progressStatusValue = orchestrator.progressState.collectAsState().value + + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + Box( + modifier = Modifier.size(55.dp), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + val animatedProgress = + animateFloatAsState( + targetValue = progressValue.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ).value + + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + + val txt = + when (progressStatusValue) { + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) + } + + Text( + txt, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 624a999e59..2a243073b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing @@ -185,8 +186,11 @@ open class ShortNotePostViewModel : // Voice Messages var voiceRecording by mutableStateOf(null) + var voiceLocalFile by mutableStateOf(null) var isUploadingVoice by mutableStateOf(false) var voiceMetadata by mutableStateOf(null) + var voiceSelectedServer by mutableStateOf(null) + var voiceOrchestrator by mutableStateOf(null) // Polls var canUsePoll by mutableStateOf(false) @@ -477,6 +481,19 @@ open class ShortNotePostViewModel : } suspend fun sendPostSync() { + // Upload voice message first if it hasn't been uploaded yet + if (voiceRecording != null && voiceMetadata == null) { + val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer + uploadVoiceMessageSync( + serverToUse, + { _, _ -> }, // Ignore errors during sync upload before post + ) + // Update default server if voice message was successfully uploaded + if (voiceMetadata != null && voiceSelectedServer != null && voiceSelectedServer?.type != ServerType.NIP95) { + account.settings.changeDefaultFileServer(voiceSelectedServer!!) + } + } + val template = createTemplate() ?: return val extraNotesToBroadcast = mutableListOf() @@ -744,8 +761,11 @@ open class ShortNotePostViewModel : multiOrchestrator = null isUploadingImage = false voiceRecording = null + voiceLocalFile = null isUploadingVoice = false voiceMetadata = null + voiceSelectedServer = null + voiceOrchestrator = null pTags = null wantsPoll = false @@ -865,8 +885,8 @@ open class ShortNotePostViewModel : private fun newStateMapPollOptions(): SnapshotStateMap = mutableStateMapOf(Pair(0, ""), Pair(1, "")) fun canPost(): Boolean { - // Voice messages can be posted without text - if (voiceMetadata != null) { + // Voice messages can be posted without text (with either uploaded or pending recording) + if (voiceMetadata != null || voiceRecording != null) { return !isUploadingVoice && !isUploadingImage } @@ -884,8 +904,7 @@ open class ShortNotePostViewModel : isValidValueMaximum.value ) ) && - multiOrchestrator == null && - voiceRecording == null + multiOrchestrator == null } fun insertAtCursor(newElement: String) { @@ -898,80 +917,89 @@ open class ShortNotePostViewModel : fun selectVoiceRecording(recording: RecordingResult) { voiceRecording = recording + voiceLocalFile = recording.file } - fun uploadVoiceMessage( + fun getVoicePreviewMetadata(): AudioMeta? = + voiceRecording?.let { recording -> + AudioMeta( + url = "", // Empty URL for preview (local file will be used) + mimeType = recording.mimeType, + duration = recording.duration, + waveform = recording.amplitudes, + ) + } + + fun removeVoiceMessage() { + voiceRecording = null + voiceLocalFile = null + voiceMetadata = null + voiceSelectedServer = null + isUploadingVoice = false + voiceOrchestrator = null + } + + suspend fun uploadVoiceMessageSync( server: ServerName, onError: (title: String, message: String) -> Unit, - context: Context, ) { val recording = voiceRecording ?: return - viewModelScope.launch(Dispatchers.IO) { - isUploadingVoice = true + isUploadingVoice = true - try { - val uri = android.net.Uri.fromFile(recording.file) - val orchestrator = UploadOrchestrator() + try { + val uri = android.net.Uri.fromFile(recording.file) + val orchestrator = UploadOrchestrator() + voiceOrchestrator = orchestrator - val result = - orchestrator.upload( - uri = uri, - mimeType = recording.mimeType, - alt = null, - contentWarningReason = null, - compressionQuality = CompressorQuality.UNCOMPRESSED, - server = server, - account = account, - context = context, - useH265 = false, - ) + val result = + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = server, + account = account, + context = Amethyst.instance.appContext, + useH265 = false, + ) - when (result) { - is UploadingState.Finished -> { - when (val orchestratorResult = result.result) { - is UploadOrchestrator.OrchestratorResult.ServerResult -> { - voiceMetadata = - AudioMeta( - url = orchestratorResult.url, - mimeType = recording.mimeType, - hash = orchestratorResult.fileHeader.hash, - duration = recording.duration, - waveform = recording.amplitudes, - ) - voiceRecording = null - } - is UploadOrchestrator.OrchestratorResult.NIP95Result -> { - // For NIP95, we need to create the event and get the nevent URL - // This is handled differently - skip for now - onError( - stringRes(context, R.string.failed_to_upload_media_no_details), - "NIP95 not yet supported for voice messages", + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + voiceMetadata = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, ) - } + voiceRecording = null + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + // For NIP95, we need to create the event and get the nevent URL + // This is handled differently - skip for now + onError("Upload Error", "NIP95 not yet supported for voice messages") } } - is UploadingState.Error -> { - val errorMessage = stringRes(context, result.errorResource, *result.params) - onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessage) - voiceRecording = null - } - else -> { - onError( - stringRes(context, R.string.failed_to_upload_media_no_details), - "Unexpected upload state", - ) - } } - } catch (e: Exception) { - onError( - stringRes(context, R.string.failed_to_upload_media_no_details), - e.message ?: e.javaClass.simpleName, - ) - voiceRecording = null - } finally { - isUploadingVoice = false + is UploadingState.Error -> { + onError("Upload Error", "Failed to upload voice message") + voiceRecording = null + } + else -> { + onError("Upload Error", "Unexpected upload state") + } } + } catch (e: Exception) { + onError("Upload Error", e.message ?: e.javaClass.simpleName) + voiceRecording = null + } finally { + isUploadingVoice = false + voiceOrchestrator = null } } From 555fc983f6146f56c2f75db604eced045f3ca3d6 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 18:35:24 +0100 Subject: [PATCH 04/11] Fix rate conditions between subsequent recordings reduce duplicate code fix a few coding issues (state management, callback usage, scope management) --- .../ui/actions/uploads/RecordAudio.kt | 51 +++++-- .../ui/actions/uploads/RecordVoiceButton.kt | 15 ++- .../ui/actions/uploads/RecordingIndicators.kt | 12 +- .../ui/actions/uploads/TimeFormatUtils.kt | 39 ++++++ .../ui/actions/uploads/VoiceMessagePreview.kt | 113 +++++++++++----- .../actions/uploads/VoiceMessageRecorder.kt | 127 +++++++++++++++--- .../amethyst/ui/components/ClickableBox.kt | 13 +- 7 files changed, 277 insertions(+), 93 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TimeFormatUtils.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 1d8f2ff23b..6d5c7d8964 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import android.Manifest import android.widget.Toast import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -39,6 +40,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -50,15 +52,41 @@ fun RecordAudioBox( val mediaRecorder = remember { mutableStateOf(null) } val context = LocalContext.current var elapsedSeconds by remember { mutableIntStateOf(0) } + var wantsToRecord by remember { mutableStateOf(false) } + + // Must be called at Composable scope, not in callback + val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) + val scope = rememberCoroutineScope() + + DisposableEffect(Unit) { + onDispose { + wantsToRecord = false + mediaRecorder.value?.stop() + mediaRecorder.value = null + } + } + + // Start recording once permission is granted AND user wants to record + LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) { + if (recordPermissionState.status.isGranted && wantsToRecord && mediaRecorder.value == null) { + elapsedSeconds = 0 + mediaRecorder.value = VoiceMessageRecorder() + mediaRecorder.value?.start(context, scope) + } + } // Track elapsed time while recording LaunchedEffect(mediaRecorder.value) { - if (mediaRecorder.value != null) { - while (mediaRecorder.value != null) { + // Capture the current recorder state to avoid repeated reads of volatile state + val currentRecorder = mediaRecorder.value + if (currentRecorder != null) { + // Loop while coroutine is active - LaunchedEffect will cancel when mediaRecorder.value changes + while (isActive) { delay(1000) elapsedSeconds++ } } else { + // Reset elapsed time when not recording elapsedSeconds = 0 } } @@ -66,19 +94,17 @@ fun RecordAudioBox( ClickAndHoldBoxComposable( modifier = modifier, onPress = { - val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) - val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { - if (!recordPermissionState.status.isGranted) { - recordPermissionState.launchPermissionRequest() - } else { - elapsedSeconds = 0 - mediaRecorder.value = VoiceMessageRecorder() - mediaRecorder.value?.start(context, scope) - } + wantsToRecord = true + if (!recordPermissionState.status.isGranted) { + recordPermissionState.launchPermissionRequest() + } else if (mediaRecorder.value == null) { + elapsedSeconds = 0 + mediaRecorder.value = VoiceMessageRecorder() + mediaRecorder.value?.start(context, scope) } }, onRelease = { + wantsToRecord = false val result = mediaRecorder.value?.stop() mediaRecorder.value = null if (result != null) { @@ -94,6 +120,7 @@ fun RecordAudioBox( } }, onCancel = { + wantsToRecord = false mediaRecorder.value?.stop() mediaRecorder.value = null }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index f390eea3ed..9cedeeab6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Mic import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -61,12 +62,14 @@ fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { onVoiceTaken(recording) }, ) { recordingState, elapsed -> - // Update parent state to trigger recompositions - if (isRecording != recordingState) { - isRecording = recordingState - } - if (elapsedSeconds != elapsed) { - elapsedSeconds = elapsed + // Update parent state after composition completes + SideEffect { + if (isRecording != recordingState) { + isRecording = recordingState + } + if (elapsedSeconds != elapsed) { + elapsedSeconds = elapsed + } } Box( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index ae2c25aab7..45b41fb7dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -228,7 +228,7 @@ fun FloatingRecordingIndicator( ) Text( - text = "Recording ${formatSeconds(elapsedSeconds)}", + text = "Recording ${formatSecondsToTime(elapsedSeconds)}", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, @@ -236,13 +236,3 @@ fun FloatingRecordingIndicator( } } } - -private fun formatSeconds(seconds: Int): String { - val minutes = seconds / 60 - val secs = seconds % 60 - return if (minutes > 0) { - String.format("%d:%02d", minutes, secs) - } else { - String.format("0:%02d", secs) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TimeFormatUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TimeFormatUtils.kt new file mode 100644 index 0000000000..80cac54e02 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TimeFormatUtils.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import java.util.Locale + +/** + * Formats seconds into a human-readable time string (M:SS or MM:SS format). + * + * @param seconds The number of seconds to format + * @return Formatted time string (e.g., "0:05", "1:23", "12:45") + */ +fun formatSecondsToTime(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return if (minutes > 0) { + String.format(Locale.getDefault(), "%d:%02d", minutes, secs) + } else { + String.format(Locale.getDefault(), "0:%02d", secs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index bff7e3efbf..7a2fb1759e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import android.content.Context import android.media.MediaPlayer +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -77,9 +78,20 @@ fun VoiceMessagePreview( // Initialize MediaPlayer DisposableEffect(voiceMetadata.url, localFile) { val player = createMediaPlayer(context, voiceMetadata.url, localFile) + player?.setOnCompletionListener { + isPlaying = false + progress = 0f + } mediaPlayer = player onDispose { + // Stop playback and clean up + try { + player?.stop() + } catch (e: IllegalStateException) { + // Player might already be stopped + Log.d("VoiceMessagePreview", "MediaPlayer stop failed (already stopped)", e) + } player?.release() mediaPlayer = null isPlaying = false @@ -87,19 +99,37 @@ fun VoiceMessagePreview( } // Update progress while playing - LaunchedEffect(isPlaying) { - if (isPlaying && mediaPlayer != null) { - while (isActive && isPlaying) { - val player = mediaPlayer - if (player != null && player.isPlaying) { - val current = player.currentPosition.toFloat() - val duration = player.duration.toFloat() - progress = if (duration > 0) current / duration else 0f - delay(100) - } else { + LaunchedEffect(mediaPlayer, isPlaying) { + // Capture player reference to avoid reading volatile state repeatedly + val player = mediaPlayer + if (player != null && isPlaying) { + while (isActive) { + try { + if (player.isPlaying) { + val current = player.currentPosition.toFloat() + val duration = player.duration.toFloat() + // Validate values before calculating progress + val newProgress = + if (duration > 0 && current >= 0) { + (current / duration).coerceIn(0f, 1f) + } else { + 0f + } + // Only update if value is valid (not NaN or Infinity) + if (newProgress.isFinite()) { + progress = newProgress + } + } else { + // Player stopped, exit loop and let LaunchedEffect restart + break + } + } catch (e: IllegalStateException) { + // Player in invalid state, stop tracking + Log.w("VoiceMessagePreview", "MediaPlayer in invalid state during progress tracking", e) isPlaying = false - progress = 0f + break } + delay(100) } } } @@ -121,17 +151,25 @@ fun VoiceMessagePreview( // Play/Pause Button IconButton( onClick = { - mediaPlayer?.let { player -> - if (isPlaying) { - player.pause() - isPlaying = false - } else { - if (progress >= 1f) { - player.seekTo(0) - progress = 0f + val player = mediaPlayer + if (player != null) { + try { + if (isPlaying) { + player.pause() + isPlaying = false + } else { + // Validate progress before comparison + if (progress.isFinite() && progress >= 1f) { + player.seekTo(0) + progress = 0f + } + player.start() + isPlaying = true } - player.start() - isPlaying = true + } catch (e: IllegalStateException) { + // MediaPlayer in invalid state, ignore + Log.w("VoiceMessagePreview", "MediaPlayer operation failed in onClick handler", e) + isPlaying = false } } }, @@ -157,16 +195,29 @@ fun VoiceMessagePreview( waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), onProgressChange = { newProgress -> - mediaPlayer?.let { player -> - val newPosition = (newProgress * player.duration).toInt() - player.seekTo(newPosition) - progress = newProgress + // Validate incoming progress value + if (newProgress.isFinite() && newProgress >= 0f && newProgress <= 1f) { + val player = mediaPlayer + if (player != null) { + try { + val duration = player.duration + // Only seek if duration is valid + if (duration > 0) { + val newPosition = (newProgress * duration).toInt() + player.seekTo(newPosition) + progress = newProgress + } + } catch (e: IllegalStateException) { + // MediaPlayer in invalid state, ignore + Log.w("VoiceMessagePreview", "MediaPlayer seek failed in onProgressChange", e) + } + } } }, ) Text( - text = formatDuration(voiceMetadata.duration ?: 0), + text = formatSecondsToTime(voiceMetadata.duration ?: 0), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), @@ -203,17 +254,7 @@ private fun createMediaPlayer( setDataSource(url) } prepare() - setOnCompletionListener { - // Reset to beginning when playback completes - seekTo(0) - } } } catch (e: Exception) { null } - -private fun formatDuration(seconds: Int): String { - val minutes = seconds / 60 - val secs = seconds % 60 - return String.format("%d:%02d", minutes, secs) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt index 6e5323cc41..dae416df77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt @@ -23,13 +23,17 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import android.content.Context import android.media.MediaRecorder import android.os.Build -import android.util.Log import androidx.media3.common.MimeTypes +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File @@ -41,30 +45,44 @@ class RecordingResult( ) class VoiceMessageRecorder { + @Volatile private var recorder: MediaRecorder? = null private var outputFile: File? = null private var startTime: Long = 0 - private var job: Job? = null + + // Own scope to manage lifecycle independently from caller + private var recorderScope: CoroutineScope? = null + + @Volatile + private var amplitudeSamplingJob: Job? = null private var amplitudes: MutableList = mutableListOf() private fun createRecorder(context: Context): MediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaRecorder(context) + MediaRecorder(context.applicationContext) } else { MediaRecorder() } + @Synchronized fun start( context: Context, - scope: CoroutineScope, + parentScope: CoroutineScope, ) { + // Clean up any existing recording first + cleanup() + val fileName = RandomInstance.randomChars(16) + ".mp4" - val outputFile = File(context.cacheDir, "/voice/$fileName") + val outputFile = File(context.cacheDir, "voice/$fileName") outputFile.parentFile?.mkdirs() this.outputFile = outputFile this.startTime = TimeUtils.now() this.amplitudes.clear() + // Create own scope with SupervisorJob so failures don't cascade + val scopeJob = SupervisorJob(parentScope.coroutineContext[Job]) + recorderScope = CoroutineScope(Dispatchers.Main.immediate + scopeJob) + createRecorder(context).apply { setAudioEncodingBitRate(16 * 44100) setAudioSamplingRate(44100) // Set the desired audio sampling rate (e.g., 44.1 kHz) @@ -79,38 +97,103 @@ class VoiceMessageRecorder { recorder = this } - job?.cancel() - job = - scope.launch { - while (recorder != null) { - amplitudes.add(recorder?.maxAmplitude?.toFloat() ?: 0f) + // Launch amplitude sampling in our own scope + amplitudeSamplingJob = + recorderScope?.launch { + while (isActive) { + val recorderRef = recorder ?: break + try { + val amplitude = recorderRef.maxAmplitude.toFloat() + synchronized(amplitudes) { + amplitudes.add(amplitude) + } + } catch (e: IllegalStateException) { + // MediaRecorder might be in invalid state, stop sampling + Log.w("VoiceMessageRecorder", "MediaRecorder in invalid state during amplitude sampling", e) + break + } delay(1000) } } } + @Synchronized fun stop(): RecordingResult? { - job?.cancel() - job = null - - try { - recorder?.stop() - } catch (e: RuntimeException) { - Log.w("VoiceMessageRecorder", "Failed to stop recording... Too short?", e) + if (recorder == null) { + cleanup() + return null } - recorder?.reset() - recorder = null val currentTime = TimeUtils.now() val file = outputFile - return if (currentTime - startTime >= 1 && file != null) { + + // Capture amplitudes before cleanup + val amplitudesCopy = + synchronized(amplitudes) { + amplitudes.toList() + } + val duration = (currentTime - startTime).toInt() + + // Clean up recorder and scope + cleanup() + + return if (duration >= 1 && file != null) { RecordingResult( file, MimeTypes.AUDIO_AAC, - amplitudes, - (currentTime - startTime).toInt(), + amplitudesCopy, + duration, ) } else { null } } + + /** + * Cleans up all resources: stops recorder, cancels jobs, cancels scope. + * Safe to call multiple times. + */ + @Synchronized + private fun cleanup() { + // Cancel amplitude sampling job + amplitudeSamplingJob?.cancel() + amplitudeSamplingJob = null + + // Stop any remaining coroutines before touching the recorder + recorderScope?.cancel() + recorderScope = null + + // Swap local reference so we always null out the volatile field + val recorderToRelease = recorder + recorder = null + + recorderToRelease?.let { mediaRecorder -> + try { + mediaRecorder.stop() + } catch (e: IllegalStateException) { + Log.w("VoiceMessageRecorder", "Failed to stop MediaRecorder due to illegal state", e) + } catch (e: RuntimeException) { + // MediaRecorder.stop() can throw RuntimeException if the recording is too short + // or if no valid audio data was captured. This is a known Android issue. + Log.w("VoiceMessageRecorder", "Failed to stop MediaRecorder (recording may be too short or invalid)", e) + } finally { + try { + mediaRecorder.reset() + } catch (resetError: Exception) { + Log.w("VoiceMessageRecorder", "Failed to reset MediaRecorder before release", resetError) + } + try { + mediaRecorder.release() + } catch (releaseError: Exception) { + Log.w("VoiceMessageRecorder", "Failed to release MediaRecorder resources", releaseError) + } + } + } + + // Reset transient state so a fresh recording always starts cleanly + outputFile = null + startTime = 0 + synchronized(amplitudes) { + amplitudes.clear() + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt index 100f123985..6df79b5cda 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt @@ -115,7 +115,7 @@ fun ClickAndHoldBox( @Composable fun ClickAndHoldBoxComposable( modifier: Modifier = Modifier, - onPress: @Composable () -> Unit, + onPress: () -> Unit, onRelease: suspend () -> Unit, onCancel: suspend () -> Unit, content: @Composable (Boolean) -> Unit, @@ -123,15 +123,16 @@ fun ClickAndHoldBoxComposable( val interactionSource = remember { MutableInteractionSource() } var isPressed by remember { mutableStateOf(false) } - if (isPressed) { - onPress() - } - LaunchedEffect(interactionSource) { val pressInteractions = mutableListOf() interactionSource.interactions.collect { interaction -> when (interaction) { - is PressInteraction.Press -> pressInteractions.add(interaction) + is PressInteraction.Press -> { + if (pressInteractions.isEmpty()) { + onPress() + } + pressInteractions.add(interaction) + } is PressInteraction.Release -> { onRelease() pressInteractions.remove(interaction.press) From 2a0eeed55fabba04ce5caa2d323b43c114b671fe Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 18:55:34 +0100 Subject: [PATCH 05/11] code review: remove unsafe casting to avoid app crash if a non-voice reply context leaks through lint fixes --- .../ui/actions/uploads/VoiceMessagePreview.kt | 5 ++--- .../ui/screen/loggedIn/home/ShortNotePostScreen.kt | 8 ++------ .../ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 11 ++++++----- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index 7a2fb1759e..a601837be5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.actions.uploads -import android.content.Context import android.media.MediaPlayer import android.util.Log import androidx.compose.foundation.background @@ -77,7 +76,7 @@ fun VoiceMessagePreview( // Initialize MediaPlayer DisposableEffect(voiceMetadata.url, localFile) { - val player = createMediaPlayer(context, voiceMetadata.url, localFile) + val player = createMediaPlayer(voiceMetadata.url, localFile) player?.setOnCompletionListener { isPlaying = false progress = 0f @@ -242,7 +241,6 @@ fun VoiceMessagePreview( } private fun createMediaPlayer( - context: Context, url: String, localFile: File?, ): MediaPlayer? = @@ -256,5 +254,6 @@ private fun createMediaPlayer( prepare() } } catch (e: Exception) { + Log.w("VoiceMessagePreview", "Failed to create MediaPlayer", e) null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 09b288a3ba..b347e68b31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -479,16 +479,12 @@ private fun NewPostScreenBody( ) } - BottomRowActions(postViewModel, accountViewModel) + BottomRowActions(postViewModel) } } @Composable -private fun BottomRowActions( - postViewModel: ShortNotePostViewModel, - accountViewModel: AccountViewModel, -) { - val context = LocalContext.current +private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { val scrollState = rememberScrollState() Row( modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 2a243073b7..8915803d6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -235,7 +235,7 @@ open class ShortNotePostViewModel : fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null - fun user(): User? = account.userProfile() + fun user(): User = account.userProfile() open fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM @@ -534,15 +534,16 @@ open class ShortNotePostViewModel : private suspend fun createTemplate(): EventTemplate? { // Check if this is a voice message voiceMetadata?.let { audioMeta -> - return if (originalNote != null) { + // Only create voice reply if original note is also a VoiceEvent + val originalVoiceHint = originalNote?.toEventHint() + return if (originalVoiceHint != null) { // Create voice reply event - @Suppress("UNCHECKED_CAST") VoiceReplyEvent.build( voiceMessage = audioMeta, - replyingTo = originalNote!!.toEventHint() as com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle, + replyingTo = originalVoiceHint, ) } else { - // Create root voice event + // Create root voice event (no reply or original is not a voice message) VoiceEvent.build( voiceMessage = audioMeta, ) From 0f06838c257724b95013a594abe1a94163567318 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 19:09:09 +0100 Subject: [PATCH 06/11] code review: replace layout with box (Calling a androidx.compose.ui.UiComposable composable function where a UI Composable composable was expected ) --- .../ui/actions/uploads/RecordingIndicators.kt | 68 ++++++++----------- 1 file changed, 28 insertions(+), 40 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index 45b41fb7dc..76125fdada 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.Layout import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -133,47 +132,36 @@ fun ExpandingCirclesAnimation( if (!isRecording) return - Layout( - modifier = modifier, - content = { - // Circle 1 - Box( - modifier = - Modifier - .scale(scale1) - .alpha(alpha1) - .background(primaryColor.copy(alpha = 0.3f), CircleShape), - ) + Box(modifier = modifier, contentAlignment = Alignment.Center) { + // Circle 1 + Box( + modifier = + Modifier + .matchParentSize() + .scale(scale1) + .alpha(alpha1) + .background(primaryColor.copy(alpha = 0.3f), CircleShape), + ) - // Circle 2 - Box( - modifier = - Modifier - .scale(scale2) - .alpha(alpha2) - .background(primaryColor.copy(alpha = 0.2f), CircleShape), - ) + // Circle 2 + Box( + modifier = + Modifier + .matchParentSize() + .scale(scale2) + .alpha(alpha2) + .background(primaryColor.copy(alpha = 0.2f), CircleShape), + ) - // Circle 3 - Box( - modifier = - Modifier - .scale(scale3) - .alpha(alpha3) - .background(primaryColor.copy(alpha = 0.1f), CircleShape), - ) - }, - ) { measurables, constraints -> - // All circles are centered at the same position - val placeables = measurables.map { it.measure(constraints) } - layout(constraints.maxWidth, constraints.maxHeight) { - placeables.forEach { placeable -> - placeable.placeRelative( - x = (constraints.maxWidth - placeable.width) / 2, - y = (constraints.maxHeight - placeable.height) / 2, - ) - } - } + // Circle 3 + Box( + modifier = + Modifier + .matchParentSize() + .scale(scale3) + .alpha(alpha3) + .background(primaryColor.copy(alpha = 0.1f), CircleShape), + ) } } From 09f66359adfd260e2034192195b3bcc82a29711a Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 19:17:24 +0100 Subject: [PATCH 07/11] code review: delete temp voice files in removeVoiceMessage(), cancel() and uploadVoiceMessageSync() --- .../loggedIn/home/ShortNotePostViewModel.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 8915803d6c..b7430793df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -761,6 +761,7 @@ open class ShortNotePostViewModel : multiOrchestrator = null isUploadingImage = false + deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null isUploadingVoice = false @@ -932,6 +933,7 @@ open class ShortNotePostViewModel : } fun removeVoiceMessage() { + deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null voiceMetadata = null @@ -940,6 +942,19 @@ open class ShortNotePostViewModel : voiceOrchestrator = null } + private fun deleteVoiceLocalFile() { + voiceLocalFile?.let { file -> + try { + if (file.exists()) { + file.delete() + Log.d("ShortNotePostViewModel", "Deleted voice file: ${file.absolutePath}") + } + } catch (e: Exception) { + Log.w("ShortNotePostViewModel", "Failed to delete voice file: ${file.absolutePath}", e) + } + } + } + suspend fun uploadVoiceMessageSync( server: ServerName, onError: (title: String, message: String) -> Unit, @@ -978,6 +993,9 @@ open class ShortNotePostViewModel : duration = recording.duration, waveform = recording.amplitudes, ) + // Delete the local file after successful upload + deleteVoiceLocalFile() + voiceLocalFile = null voiceRecording = null } is UploadOrchestrator.OrchestratorResult.NIP95Result -> { From a370112bc5cde56729bda5d4edaeaf01bf62ba7c Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 19:33:08 +0100 Subject: [PATCH 08/11] code review: extract startRecording code --- .../amethyst/ui/actions/uploads/RecordAudio.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 6d5c7d8964..c5083fb12c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -66,15 +66,20 @@ fun RecordAudioBox( } } - // Start recording once permission is granted AND user wants to record - LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) { - if (recordPermissionState.status.isGranted && wantsToRecord && mediaRecorder.value == null) { + fun startRecording() { + if (mediaRecorder.value == null) { elapsedSeconds = 0 mediaRecorder.value = VoiceMessageRecorder() mediaRecorder.value?.start(context, scope) } } + LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) { + if (recordPermissionState.status.isGranted && wantsToRecord) { + startRecording() + } + } + // Track elapsed time while recording LaunchedEffect(mediaRecorder.value) { // Capture the current recorder state to avoid repeated reads of volatile state @@ -97,10 +102,9 @@ fun RecordAudioBox( wantsToRecord = true if (!recordPermissionState.status.isGranted) { recordPermissionState.launchPermissionRequest() - } else if (mediaRecorder.value == null) { - elapsedSeconds = 0 - mediaRecorder.value = VoiceMessageRecorder() - mediaRecorder.value?.start(context, scope) + } else { + // Start immediately for responsive UX when permission already granted + startRecording() } }, onRelease = { From 3dd42492200e9dc80c2e79a7a523149795427be0 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 19:39:24 +0100 Subject: [PATCH 09/11] code review: abort posting message if voice upload fails --- .../ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index b7430793df..a477ddd95e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -486,10 +486,15 @@ open class ShortNotePostViewModel : val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer uploadVoiceMessageSync( serverToUse, - { _, _ -> }, // Ignore errors during sync upload before post + { _, _ -> }, // Error handling is done by checking voiceMetadata below ) + // Abort if upload failed - don't post without voice data + if (voiceMetadata == null) { + Log.w("ShortNotePostViewModel", "Voice upload failed, aborting post") + return + } // Update default server if voice message was successfully uploaded - if (voiceMetadata != null && voiceSelectedServer != null && voiceSelectedServer?.type != ServerType.NIP95) { + if (voiceSelectedServer != null && voiceSelectedServer?.type != ServerType.NIP95) { account.settings.changeDefaultFileServer(voiceSelectedServer!!) } } From de927b62bdb51d27993c077573710ee8402b7863 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 20:11:00 +0100 Subject: [PATCH 10/11] translate hardcoded strings --- .../ui/actions/uploads/RecordingIndicators.kt | 9 +++++++-- .../loggedIn/home/ShortNotePostViewModel.kt | 18 +++++++++++++----- .../src/main/res/values-cs-rCZ/strings.xml | 8 ++++++++ .../src/main/res/values-de-rDE/strings.xml | 8 ++++++++ .../src/main/res/values-pt-rBR/strings.xml | 8 ++++++++ .../src/main/res/values-sv-rSE/strings.xml | 8 ++++++++ amethyst/src/main/res/values/strings.xml | 7 +++++++ 7 files changed, 59 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index 76125fdada..9b81d710fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -49,6 +49,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes /** * Animated expanding circles that pulse outward from the recording button @@ -176,6 +178,9 @@ fun FloatingRecordingIndicator( ) { if (!isRecording) return + val recordingLabel = stringRes(id = R.string.recording_indicator_description) + val recordingWithTime = stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds)) + Box( modifier = modifier @@ -207,7 +212,7 @@ fun FloatingRecordingIndicator( Icon( imageVector = Icons.Default.FiberManualRecord, - contentDescription = "Recording", + contentDescription = recordingLabel, tint = Color.White, modifier = Modifier @@ -216,7 +221,7 @@ fun FloatingRecordingIndicator( ) Text( - text = "Recording ${formatSecondsToTime(elapsedSeconds)}", + text = recordingWithTime, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index a477ddd95e..8b671c856b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -965,6 +965,14 @@ open class ShortNotePostViewModel : onError: (title: String, message: String) -> Unit, ) { val recording = voiceRecording ?: return + val appContext = Amethyst.instance.appContext + val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) + val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) + val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed) + val uploadVoiceUnexpected = stringRes(appContext, R.string.upload_error_voice_message_unexpected_state) + val uploadVoiceExceptionMessage: (String) -> String = { detail -> + stringRes(appContext, R.string.upload_error_voice_message_exception, detail) + } isUploadingVoice = true @@ -982,7 +990,7 @@ open class ShortNotePostViewModel : compressionQuality = CompressorQuality.UNCOMPRESSED, server = server, account = account, - context = Amethyst.instance.appContext, + context = appContext, useH265 = false, ) @@ -1006,20 +1014,20 @@ open class ShortNotePostViewModel : is UploadOrchestrator.OrchestratorResult.NIP95Result -> { // For NIP95, we need to create the event and get the nevent URL // This is handled differently - skip for now - onError("Upload Error", "NIP95 not yet supported for voice messages") + onError(uploadErrorTitle, uploadVoiceNip95NotSupported) } } } is UploadingState.Error -> { - onError("Upload Error", "Failed to upload voice message") + onError(uploadErrorTitle, uploadVoiceFailed) voiceRecording = null } else -> { - onError("Upload Error", "Unexpected upload state") + onError(uploadErrorTitle, uploadVoiceUnexpected) } } } catch (e: Exception) { - onError("Upload Error", e.message ?: e.javaClass.simpleName) + onError(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName)) voiceRecording = null } finally { isUploadingVoice = false diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 4578cac80d..26e878db31 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1176,4 +1176,12 @@ Broadcast sady doporučení Smazat seznam Smazat sadu doporučení + Nahrávání + Nahrávání %1$s + Chyba nahrávání + Nepodařilo se nahrát hlasovou zprávu + Neočekávaný stav nahrávání + NIP-95 zatím není pro hlasové zprávy podporován + Nahrávání hlasu selhalo: %1$s + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 02887615c7..3afa6ac9d0 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1181,4 +1181,12 @@ anz der Bedingungen ist erforderlich Empfehlungspaket veröffentlichen Liste löschen Empfehlungspaket löschen + Aufnahme + Aufnahme %1$s + Upload-Fehler + Sprachnachricht konnte nicht hochgeladen werden + Unerwarteter Upload-Status + NIP-95 wird für Sprachnachrichten noch nicht unterstützt + Sprach-Upload fehlgeschlagen: %1$s + diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index be0152b9bc..09490718ae 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1176,4 +1176,12 @@ Transmitir pacote de recomendações Excluir lista Excluir pacote de recomendações + Gravando + Gravando %1$s + Erro de upload + Falha ao enviar mensagem de voz + Estado de upload inesperado + O NIP-95 ainda não é suportado para mensagens de voz + Falha no upload de voz: %1$s + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 435c7b261f..cfa126897f 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1175,4 +1175,12 @@ Sänd rekommendationspaket Ta bort lista Ta bort rekommendationspaket + Spelar in + Spelar in %1$s + Uppladdningsfel + Misslyckades med att ladda upp röstmeddelandet + Oväntat uppladdningstillstånd + NIP-95 stöds ännu inte för röstmeddelanden + Uppladdning av röst misslyckades: %1$s + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 61221bc959..8db1152f4f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -170,7 +170,14 @@ Record a message Record a message Click and hold to record a message + Recording + Recording %1$s Uploading… + Upload Error + Failed to upload voice message + Unexpected upload state + NIP-95 is not supported for voice messages yet + Voice upload failed: %1$s User does not have a lightning address set up to receive sats "reply here.. " Copies the Note ID to the clipboard for sharing in Nostr From 7e1998174ca57efa28a54e5accf2b1e413d23047 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 16 Dec 2025 09:36:24 +0100 Subject: [PATCH 11/11] Delete any existing temp file before replacing --- .../amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 8b671c856b..87b87b5fc9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -923,6 +923,8 @@ open class ShortNotePostViewModel : } fun selectVoiceRecording(recording: RecordingResult) { + // Delete any existing temp file before replacing + deleteVoiceLocalFile() voiceRecording = recording voiceLocalFile = recording.file }