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:
+22
-2
@@ -24,9 +24,12 @@ import android.Manifest
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
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.R
|
||||||
import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable
|
import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RecordAudioBox(
|
fun RecordAudioBox(
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
onRecordTaken: (RecordingResult) -> Unit,
|
onRecordTaken: (RecordingResult) -> Unit,
|
||||||
content: @Composable (Boolean) -> Unit,
|
content: @Composable (Boolean, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
|
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
var elapsedSeconds by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
// Track elapsed time while recording
|
||||||
|
LaunchedEffect(mediaRecorder.value) {
|
||||||
|
if (mediaRecorder.value != null) {
|
||||||
|
while (mediaRecorder.value != null) {
|
||||||
|
delay(1000)
|
||||||
|
elapsedSeconds++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
elapsedSeconds = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ClickAndHoldBoxComposable(
|
ClickAndHoldBoxComposable(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -55,6 +72,7 @@ fun RecordAudioBox(
|
|||||||
if (!recordPermissionState.status.isGranted) {
|
if (!recordPermissionState.status.isGranted) {
|
||||||
recordPermissionState.launchPermissionRequest()
|
recordPermissionState.launchPermissionRequest()
|
||||||
} else {
|
} else {
|
||||||
|
elapsedSeconds = 0
|
||||||
mediaRecorder.value = VoiceMessageRecorder()
|
mediaRecorder.value = VoiceMessageRecorder()
|
||||||
mediaRecorder.value?.start(context, scope)
|
mediaRecorder.value?.start(context, scope)
|
||||||
}
|
}
|
||||||
@@ -62,6 +80,7 @@ fun RecordAudioBox(
|
|||||||
},
|
},
|
||||||
onRelease = {
|
onRelease = {
|
||||||
val result = mediaRecorder.value?.stop()
|
val result = mediaRecorder.value?.stop()
|
||||||
|
mediaRecorder.value = null
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
onRecordTaken(result)
|
onRecordTaken(result)
|
||||||
} else {
|
} else {
|
||||||
@@ -76,7 +95,8 @@ fun RecordAudioBox(
|
|||||||
},
|
},
|
||||||
onCancel = {
|
onCancel = {
|
||||||
mediaRecorder.value?.stop()
|
mediaRecorder.value?.stop()
|
||||||
|
mediaRecorder.value = null
|
||||||
},
|
},
|
||||||
content,
|
content = @Composable { isRecording -> content(isRecording, elapsedSeconds) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-11
@@ -20,12 +20,21 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
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.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
@@ -33,17 +42,62 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
|
fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
|
||||||
RecordAudioBox(
|
var isRecording by remember { mutableStateOf(false) }
|
||||||
modifier = Modifier,
|
var elapsedSeconds by remember { mutableIntStateOf(0) }
|
||||||
onRecordTaken = { recording ->
|
|
||||||
onVoiceTaken(recording)
|
Column {
|
||||||
},
|
// Floating recording indicator at the top
|
||||||
) { isRecording ->
|
FloatingRecordingIndicator(
|
||||||
Icon(
|
modifier = Modifier.height(50.dp),
|
||||||
imageVector = Icons.Default.Mic,
|
isRecording = isRecording,
|
||||||
contentDescription = stringRes(id = R.string.record_a_message),
|
elapsedSeconds = elapsedSeconds,
|
||||||
modifier = Modifier.height(22.dp),
|
)
|
||||||
tint = if (isRecording) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
|
|
||||||
|
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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+248
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+219
@@ -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 ->
|
onRecordTaken = { audio ->
|
||||||
accountViewModel.sendVoiceReply(baseNote, audio, context)
|
accountViewModel.sendVoiceReply(baseNote, audio, context)
|
||||||
},
|
},
|
||||||
) {
|
) { _, _ ->
|
||||||
VoiceReplyIcon(iconSizeModifier, grayTint)
|
VoiceReplyIcon(iconSizeModifier, grayTint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+138
-17
@@ -24,7 +24,10 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.activity.compose.BackHandler
|
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.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.heightIn
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.Alignment.Companion.CenterVertically
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
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.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.util.Consumer
|
import androidx.core.util.Consumer
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
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.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton
|
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
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.components.getActivity
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||||
@@ -94,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
|||||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
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.Size5dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||||
@@ -261,18 +279,21 @@ private fun NewPostScreenBody(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
// Only show text input if no voice message is being posted
|
||||||
modifier = Modifier.padding(vertical = Size10dp),
|
if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) {
|
||||||
) {
|
Row(
|
||||||
BaseUserPicture(
|
modifier = Modifier.padding(vertical = Size10dp),
|
||||||
accountViewModel.userProfile(),
|
) {
|
||||||
Size35dp,
|
BaseUserPicture(
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel.userProfile(),
|
||||||
)
|
Size35dp,
|
||||||
MessageField(
|
accountViewModel = accountViewModel,
|
||||||
R.string.what_s_on_your_mind,
|
)
|
||||||
postViewModel,
|
MessageField(
|
||||||
)
|
R.string.what_s_on_your_mind,
|
||||||
|
postViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (postViewModel.wantsPoll) {
|
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) {
|
if (postViewModel.wantsInvoice) {
|
||||||
postViewModel.lnAddress()?.let { lud16 ->
|
postViewModel.lnAddress()?.let { lud16 ->
|
||||||
InvoiceRequest(
|
InvoiceRequest(
|
||||||
@@ -448,11 +521,6 @@ private fun BottomRowActions(
|
|||||||
RecordVoiceButton(
|
RecordVoiceButton(
|
||||||
onVoiceTaken = { recording ->
|
onVoiceTaken = { recording ->
|
||||||
postViewModel.selectVoiceRecording(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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+91
-63
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
|||||||
import com.vitorpamplona.amethyst.service.uploads.UploadingState
|
import com.vitorpamplona.amethyst.service.uploads.UploadingState
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
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.RecordingResult
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||||
@@ -185,8 +186,11 @@ open class ShortNotePostViewModel :
|
|||||||
|
|
||||||
// Voice Messages
|
// Voice Messages
|
||||||
var voiceRecording by mutableStateOf<RecordingResult?>(null)
|
var voiceRecording by mutableStateOf<RecordingResult?>(null)
|
||||||
|
var voiceLocalFile by mutableStateOf<java.io.File?>(null)
|
||||||
var isUploadingVoice by mutableStateOf(false)
|
var isUploadingVoice by mutableStateOf(false)
|
||||||
var voiceMetadata by mutableStateOf<AudioMeta?>(null)
|
var voiceMetadata by mutableStateOf<AudioMeta?>(null)
|
||||||
|
var voiceSelectedServer by mutableStateOf<ServerName?>(null)
|
||||||
|
var voiceOrchestrator by mutableStateOf<UploadOrchestrator?>(null)
|
||||||
|
|
||||||
// Polls
|
// Polls
|
||||||
var canUsePoll by mutableStateOf(false)
|
var canUsePoll by mutableStateOf(false)
|
||||||
@@ -477,6 +481,19 @@ open class ShortNotePostViewModel :
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendPostSync() {
|
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 template = createTemplate() ?: return
|
||||||
val extraNotesToBroadcast = mutableListOf<Event>()
|
val extraNotesToBroadcast = mutableListOf<Event>()
|
||||||
|
|
||||||
@@ -744,8 +761,11 @@ open class ShortNotePostViewModel :
|
|||||||
multiOrchestrator = null
|
multiOrchestrator = null
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
voiceRecording = null
|
voiceRecording = null
|
||||||
|
voiceLocalFile = null
|
||||||
isUploadingVoice = false
|
isUploadingVoice = false
|
||||||
voiceMetadata = null
|
voiceMetadata = null
|
||||||
|
voiceSelectedServer = null
|
||||||
|
voiceOrchestrator = null
|
||||||
pTags = null
|
pTags = null
|
||||||
|
|
||||||
wantsPoll = false
|
wantsPoll = false
|
||||||
@@ -865,8 +885,8 @@ open class ShortNotePostViewModel :
|
|||||||
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> = mutableStateMapOf(Pair(0, ""), Pair(1, ""))
|
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> = mutableStateMapOf(Pair(0, ""), Pair(1, ""))
|
||||||
|
|
||||||
fun canPost(): Boolean {
|
fun canPost(): Boolean {
|
||||||
// Voice messages can be posted without text
|
// Voice messages can be posted without text (with either uploaded or pending recording)
|
||||||
if (voiceMetadata != null) {
|
if (voiceMetadata != null || voiceRecording != null) {
|
||||||
return !isUploadingVoice && !isUploadingImage
|
return !isUploadingVoice && !isUploadingImage
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,8 +904,7 @@ open class ShortNotePostViewModel :
|
|||||||
isValidValueMaximum.value
|
isValidValueMaximum.value
|
||||||
)
|
)
|
||||||
) &&
|
) &&
|
||||||
multiOrchestrator == null &&
|
multiOrchestrator == null
|
||||||
voiceRecording == null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun insertAtCursor(newElement: String) {
|
fun insertAtCursor(newElement: String) {
|
||||||
@@ -898,80 +917,89 @@ open class ShortNotePostViewModel :
|
|||||||
|
|
||||||
fun selectVoiceRecording(recording: RecordingResult) {
|
fun selectVoiceRecording(recording: RecordingResult) {
|
||||||
voiceRecording = recording
|
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,
|
server: ServerName,
|
||||||
onError: (title: String, message: String) -> Unit,
|
onError: (title: String, message: String) -> Unit,
|
||||||
context: Context,
|
|
||||||
) {
|
) {
|
||||||
val recording = voiceRecording ?: return
|
val recording = voiceRecording ?: return
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
isUploadingVoice = true
|
||||||
isUploadingVoice = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val uri = android.net.Uri.fromFile(recording.file)
|
val uri = android.net.Uri.fromFile(recording.file)
|
||||||
val orchestrator = UploadOrchestrator()
|
val orchestrator = UploadOrchestrator()
|
||||||
|
voiceOrchestrator = orchestrator
|
||||||
|
|
||||||
val result =
|
val result =
|
||||||
orchestrator.upload(
|
orchestrator.upload(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = recording.mimeType,
|
mimeType = recording.mimeType,
|
||||||
alt = null,
|
alt = null,
|
||||||
contentWarningReason = null,
|
contentWarningReason = null,
|
||||||
compressionQuality = CompressorQuality.UNCOMPRESSED,
|
compressionQuality = CompressorQuality.UNCOMPRESSED,
|
||||||
server = server,
|
server = server,
|
||||||
account = account,
|
account = account,
|
||||||
context = context,
|
context = Amethyst.instance.appContext,
|
||||||
useH265 = false,
|
useH265 = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is UploadingState.Finished -> {
|
is UploadingState.Finished -> {
|
||||||
when (val orchestratorResult = result.result) {
|
when (val orchestratorResult = result.result) {
|
||||||
is UploadOrchestrator.OrchestratorResult.ServerResult -> {
|
is UploadOrchestrator.OrchestratorResult.ServerResult -> {
|
||||||
voiceMetadata =
|
voiceMetadata =
|
||||||
AudioMeta(
|
AudioMeta(
|
||||||
url = orchestratorResult.url,
|
url = orchestratorResult.url,
|
||||||
mimeType = recording.mimeType,
|
mimeType = recording.mimeType,
|
||||||
hash = orchestratorResult.fileHeader.hash,
|
hash = orchestratorResult.fileHeader.hash,
|
||||||
duration = recording.duration,
|
duration = recording.duration,
|
||||||
waveform = recording.amplitudes,
|
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",
|
|
||||||
)
|
)
|
||||||
}
|
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) {
|
is UploadingState.Error -> {
|
||||||
onError(
|
onError("Upload Error", "Failed to upload voice message")
|
||||||
stringRes(context, R.string.failed_to_upload_media_no_details),
|
voiceRecording = null
|
||||||
e.message ?: e.javaClass.simpleName,
|
}
|
||||||
)
|
else -> {
|
||||||
voiceRecording = null
|
onError("Upload Error", "Unexpected upload state")
|
||||||
} finally {
|
}
|
||||||
isUploadingVoice = false
|
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Upload Error", e.message ?: e.javaClass.simpleName)
|
||||||
|
voiceRecording = null
|
||||||
|
} finally {
|
||||||
|
isUploadingVoice = false
|
||||||
|
voiceOrchestrator = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user