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 889a29a9b..1d8f2ff23 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 3d3198311..f390eea3e 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 000000000..ae2c25aab --- /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 000000000..bff7e3efb --- /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 bd8d3d7f4..716d0191f 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 6617d5b1f..09b288a3b 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 624a999e5..2a243073b 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 } }