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
This commit is contained in:
davotoula
2025-12-15 18:05:53 +01:00
parent c0d7afe86c
commit f3fea8cfb4
7 changed files with 784 additions and 94 deletions
@@ -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<VoiceMessageRecorder?>(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) },
)
}
@@ -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),
)
}
}
@@ -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)
}
}
@@ -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<MediaPlayer?>(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)
}
@@ -609,7 +609,7 @@ fun ReplyViaVoiceReaction(
onRecordTaken = { audio ->
accountViewModel.sendVoiceReply(baseNote, audio, context)
},
) {
) { _, _ ->
VoiceReplyIcon(iconSizeModifier, grayTint)
}
@@ -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,
)
}
}
}
@@ -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<RecordingResult?>(null)
var voiceLocalFile by mutableStateOf<java.io.File?>(null)
var isUploadingVoice by mutableStateOf(false)
var voiceMetadata by mutableStateOf<AudioMeta?>(null)
var voiceSelectedServer by mutableStateOf<ServerName?>(null)
var voiceOrchestrator by mutableStateOf<UploadOrchestrator?>(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<Event>()
@@ -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<Int, String> = 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
}
}