Merge pull request #1672 from davotoula/voice-notes-code-review-fixes

Voice notes enhancements and code review fixes
This commit is contained in:
Vitor Pamplona
2026-01-20 08:33:11 -05:00
committed by GitHub
16 changed files with 226 additions and 177 deletions
@@ -42,11 +42,14 @@ import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
const val MAX_VOICE_RECORD_SECONDS = 180
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun RecordAudioBox( fun RecordAudioBox(
modifier: Modifier, modifier: Modifier,
onRecordTaken: (RecordingResult) -> Unit, onRecordTaken: (RecordingResult) -> Unit,
maxDurationSeconds: Int? = null,
content: @Composable (Boolean, Int) -> Unit, content: @Composable (Boolean, Int) -> Unit,
) { ) {
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) } val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
@@ -107,6 +110,10 @@ fun RecordAudioBox(
while (isActive) { while (isActive) {
delay(1000) delay(1000)
elapsedSeconds++ elapsedSeconds++
if (maxDurationSeconds != null && elapsedSeconds >= maxDurationSeconds) {
stopRecording()
break
}
} }
} else { } else {
// Reset elapsed time when not recording // Reset elapsed time when not recording
@@ -42,7 +42,10 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@Composable @Composable
fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { fun RecordVoiceButton(
onVoiceTaken: (RecordingResult) -> Unit,
maxDurationSeconds: Int? = null,
) {
var isRecording by remember { mutableStateOf(false) } var isRecording by remember { mutableStateOf(false) }
var elapsedSeconds by remember { mutableIntStateOf(0) } var elapsedSeconds by remember { mutableIntStateOf(0) }
@@ -61,6 +64,7 @@ fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
elapsedSeconds = 0 elapsedSeconds = 0
onVoiceTaken(recording) onVoiceTaken(recording)
}, },
maxDurationSeconds = maxDurationSeconds,
) { recordingState, elapsed -> ) { recordingState, elapsed ->
// Update parent state after composition completes // Update parent state after composition completes
SideEffect { SideEffect {
@@ -61,6 +61,7 @@ class VoiceAnonymizationController(
preset: VoicePreset, preset: VoicePreset,
originalFile: File?, originalFile: File?,
) { ) {
Log.d(logTag, "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}")
if (processingPreset != null || preset == selectedPreset) return if (processingPreset != null || preset == selectedPreset) return
if (preset == VoicePreset.NONE) { if (preset == VoicePreset.NONE) {
@@ -76,9 +77,9 @@ class VoiceAnonymizationController(
val file = originalFile ?: return val file = originalFile ?: return
processingJob?.cancel() processingJob?.cancel()
processingPreset = preset
processingJob = processingJob =
scope.launch { scope.launch {
processingPreset = preset
try { try {
val anonymizer = VoiceAnonymizer() val anonymizer = VoiceAnonymizer()
val result = anonymizer.anonymize(file, preset) val result = anonymizer.anonymize(file, preset)
@@ -108,8 +109,11 @@ class VoiceAnonymizationController(
distortedFiles.values.forEach { result -> distortedFiles.values.forEach { result ->
try { try {
if (result.file.exists()) { if (result.file.exists()) {
result.file.delete() if (result.file.delete()) {
Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}") Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}")
} else {
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}")
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e) Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e)
@@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
@@ -63,7 +62,7 @@ fun VoiceAnonymizationSection(
Text( Text(
text = stringRes(R.string.voice_anonymize_description), text = stringRes(R.string.voice_anonymize_description),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color.Gray, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -41,12 +41,26 @@ import java.io.File
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.math.abs import kotlin.math.abs
/**
* Result of voice anonymization processing.
*
* @property file The output audio file (AAC in MP4 container)
* @property waveform Amplitude data for waveform visualization (one value per second)
* @property duration Audio duration in seconds
*/
data class AnonymizedResult( data class AnonymizedResult(
val file: File, val file: File,
val waveform: List<Float>, val waveform: List<Float>,
val duration: Int, val duration: Int,
) )
/**
* Processes audio files to alter voice characteristics for privacy.
*
* Uses TarsosDSP's WSOLA (Waveform Similarity Overlap-Add) algorithm combined with
* rate transposition to shift pitch while preserving duration. Note that in TarsosDSP,
* pitch factors work inversely: factor < 1 raises pitch, factor > 1 lowers pitch.
*/
class VoiceAnonymizer { class VoiceAnonymizer {
companion object { companion object {
private const val TAG = "VoiceAnonymizer" private const val TAG = "VoiceAnonymizer"
@@ -54,6 +68,20 @@ class VoiceAnonymizer {
private const val BIT_RATE = 128000 private const val BIT_RATE = 128000
} }
/**
* Applies voice anonymization to an audio file.
*
* The process involves three stages:
* 1. Decode input audio to PCM (0-30% progress)
* 2. Apply pitch shifting with TarsosDSP (30-70% progress)
* 3. Encode processed audio to AAC (70-100% progress)
*
* @param inputFile Source audio file (supports formats decodable by MediaCodec)
* @param preset Voice transformation preset (NONE is not allowed)
* @param onProgress Callback invoked with progress value from 0.0 to 1.0
* @return [Result.success] with [AnonymizedResult] containing the output file,
* waveform data, and duration; or [Result.failure] with the exception
*/
suspend fun anonymize( suspend fun anonymize(
inputFile: File, inputFile: File,
preset: VoicePreset, preset: VoicePreset,
@@ -98,7 +126,8 @@ class VoiceAnonymizer {
): File { ): File {
val baseName = inputFile.nameWithoutExtension val baseName = inputFile.nameWithoutExtension
val presetSuffix = preset.name.lowercase() val presetSuffix = preset.name.lowercase()
return File(inputFile.parentFile, "${baseName}_$presetSuffix.mp4") val parentDir = inputFile.parentFile ?: inputFile.absoluteFile.parentFile
return File(parentDir, "${baseName}_$presetSuffix.mp4")
} }
private data class DecodedAudio( private data class DecodedAudio(
@@ -129,9 +158,7 @@ class VoiceAnonymizer {
} }
} }
if (audioTrackIndex == -1 || format == null) { check(audioTrackIndex != -1 && format != null) { "No audio track found in file" }
throw IllegalStateException("No audio track found in file")
}
extractor.selectTrack(audioTrackIndex) extractor.selectTrack(audioTrackIndex)
val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm" val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm"
@@ -143,7 +170,8 @@ class VoiceAnonymizer {
decoder.configure(format, null, null, 0) decoder.configure(format, null, null, 0)
decoder.start() decoder.start()
val pcmSamples = mutableListOf<Float>() val estimatedSamples = (sampleRate.toLong() * durationUs / 1_000_000).toInt()
val pcmSamples = ArrayList<Float>(estimatedSamples)
val bufferInfo = MediaCodec.BufferInfo() val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false var inputDone = false
var outputDone = false var outputDone = false
@@ -196,7 +224,11 @@ class VoiceAnonymizer {
return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration) return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration)
} finally { } finally {
decoder?.stop() try {
decoder?.stop()
} catch (_: IllegalStateException) {
// Decoder was never started
}
decoder?.release() decoder?.release()
extractor.release() extractor.release()
} }
@@ -218,8 +250,8 @@ class VoiceAnonymizer {
} }
else -> baseFactor else -> baseFactor
} }
val processedSamples = mutableListOf<Float>()
val totalSamples = pcmData.size val totalSamples = pcmData.size
val processedSamples = ArrayList<Float>(totalSamples)
val wsola = val wsola =
WaveformSimilarityBasedOverlapAdd( WaveformSimilarityBasedOverlapAdd(
@@ -252,7 +284,9 @@ class VoiceAnonymizer {
return true return true
} }
override fun processingFinished() {} override fun processingFinished() {
// No-op: no cleanup needed
}
} }
val dispatcher = val dispatcher =
@@ -276,7 +310,9 @@ class VoiceAnonymizer {
return true return true
} }
override fun processingFinished() {} override fun processingFinished() {
// No-op: no cleanup needed
}
} }
dispatcher.addAudioProcessor(progressProcessor) dispatcher.addAudioProcessor(progressProcessor)
@@ -400,7 +436,11 @@ class VoiceAnonymizer {
} }
} }
} finally { } finally {
encoder.stop() try {
encoder.stop()
} catch (_: IllegalStateException) {
// Encoder was never started
}
encoder.release() encoder.release()
if (muxerStarted) { if (muxerStarted) {
muxer.stop() muxer.stop()
@@ -446,5 +486,7 @@ private class FloatArrayAudioInputStream(
return actualSkip.toLong() * 2 return actualSkip.toLong() * 2
} }
override fun close() {} override fun close() {
// No-op: no cleanup needed
}
} }
@@ -35,8 +35,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
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
@@ -67,6 +69,8 @@ fun VoiceMessagePreview(
voiceMetadata: AudioMeta, voiceMetadata: AudioMeta,
localFile: File? = null, localFile: File? = null,
onRemove: () -> Unit, onRemove: () -> Unit,
onReRecord: ((RecordingResult) -> Unit)? = null,
isUploading: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -101,77 +105,159 @@ fun VoiceMessagePreview(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
).padding(12.dp), ).padding(12.dp),
) { ) {
Row( Column {
modifier = Modifier.fillMaxWidth(), Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically,
) { horizontalArrangement = Arrangement.SpaceBetween,
// Play/Pause Button
IconButton(
onClick = {
handlePlayPauseClick(
mediaPlayer = mediaPlayer,
isPlaying = isPlaying,
progress = progress,
onProgressReset = { progress = 0f },
onPlayingChanged = { isPlaying = it },
)
},
modifier = Modifier.size(48.dp),
) { ) {
Icon( // Play/Pause Button
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, IconButton(
contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play), onClick = {
tint = MaterialTheme.colorScheme.primary, handlePlayPauseClick(
)
}
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 ->
handleWaveformScrub(
newProgress = newProgress,
mediaPlayer = mediaPlayer, mediaPlayer = mediaPlayer,
onProgressChanged = { progress = it }, isPlaying = isPlaying,
progress = progress,
onProgressReset = { progress = 0f },
onPlayingChanged = { isPlaying = it },
) )
}, },
) 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,
)
}
Text( Spacer(modifier = Modifier.width(8.dp))
text = formatSecondsToTime(voiceMetadata.duration ?: 0),
style = MaterialTheme.typography.bodySmall, // Waveform and Duration
color = MaterialTheme.colorScheme.onSurfaceVariant, Column(
modifier = Modifier.padding(top = 4.dp), 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 ->
handleWaveformScrub(
newProgress = newProgress,
mediaPlayer = mediaPlayer,
onProgressChanged = { progress = it },
)
},
)
Text(
text = formatSecondsToTime(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,
)
}
} }
Spacer(modifier = Modifier.width(8.dp)) if (onReRecord != null) {
Spacer(modifier = Modifier.size(8.dp))
// Remove Button ReRecordButton(
IconButton( isUploading = isUploading,
onClick = onRemove, isPlaying = isPlaying,
modifier = Modifier.size(48.dp), onRecordTaken = onReRecord,
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringRes(context, R.string.remove),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
} }
} }
@Composable
private fun ReRecordButton(
isUploading: Boolean,
isPlaying: Boolean,
onRecordTaken: (RecordingResult) -> Unit,
) {
if (isUploading || isPlaying) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = stringRes(id = R.string.record_a_message),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringRes(id = R.string.re_record),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
RecordAudioBox(
modifier = Modifier,
onRecordTaken = onRecordTaken,
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
) { isRecording, elapsedSeconds ->
val contentColor =
if (isRecording) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val icon =
if (isRecording) {
Icons.Default.Stop
} else {
Icons.Default.Mic
}
val label =
if (isRecording) {
formatSecondsToTime(elapsedSeconds)
} else {
stringRes(id = R.string.re_record)
}
val iconDescription =
if (isRecording) {
stringRes(id = R.string.recording_indicator_description)
} else {
stringRes(id = R.string.record_a_message)
}
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = icon,
contentDescription = iconDescription,
tint = contentColor,
)
Text(
text = label,
color = contentColor,
)
}
}
}
@Composable @Composable
private fun ManageMediaPlayer( private fun ManageMediaPlayer(
voiceMetadata: AudioMeta, voiceMetadata: AudioMeta,
@@ -29,5 +29,5 @@ enum class VoicePreset(
NONE(1.0, R.string.voice_preset_none), NONE(1.0, R.string.voice_preset_none),
DEEP(1.4, R.string.voice_preset_deep), DEEP(1.4, R.string.voice_preset_deep),
HIGH(0.75, R.string.voice_preset_high), HIGH(0.75, R.string.voice_preset_high),
NEUTRAL(0.9, R.string.voice_preset_neutral), NEUTRAL(1.1, R.string.voice_preset_neutral),
} }
@@ -113,6 +113,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ClickableBox
@@ -635,6 +636,7 @@ fun ReplyViaVoiceReaction(
) )
} }
}, },
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
) { isRecording, elapsedSeconds -> ) { isRecording, elapsedSeconds ->
if (voiceRecordingState != null) { if (voiceRecordingState != null) {
SideEffect { SideEffect {
@@ -20,8 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.note.types package com.vitorpamplona.amethyst.ui.note.types
import android.R.attr.label
import android.R.attr.maxLines
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
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
@@ -374,6 +375,8 @@ private fun NewPostScreenBody(
VoiceMessagePreview( VoiceMessagePreview(
voiceMetadata = displayMetadata, voiceMetadata = displayMetadata,
localFile = postViewModel.activeFile, localFile = postViewModel.activeFile,
onReRecord = { recording -> postViewModel.selectVoiceRecording(recording) },
isUploading = postViewModel.isUploadingVoice,
onRemove = { postViewModel.removeVoiceMessage() }, onRemove = { postViewModel.removeVoiceMessage() },
) )
@@ -497,6 +500,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
onVoiceTaken = { recording -> onVoiceTaken = { recording ->
postViewModel.selectVoiceRecording(recording) postViewModel.selectVoiceRecording(recording)
}, },
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
) )
if (postViewModel.canUsePoll) { if (postViewModel.canUsePoll) {
@@ -21,46 +21,32 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
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 androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
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.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.replyModifier
@@ -103,9 +89,6 @@ fun VoiceReplyScreen(
}, },
) )
}, },
bottomBar = {
ReRecordButton(viewModel)
},
) { pad -> ) { pad ->
Surface( Surface(
modifier = modifier =
@@ -161,6 +144,8 @@ private fun VoiceReplyScreenBody(
VoiceMessagePreview( VoiceMessagePreview(
voiceMetadata = displayMetadata, voiceMetadata = displayMetadata,
localFile = viewModel.activeFile, localFile = viewModel.activeFile,
onReRecord = { recording -> viewModel.selectRecording(recording) },
isUploading = viewModel.isUploading,
onRemove = { onRemove = {
viewModel.cancel() viewModel.cancel()
nav.popBack() nav.popBack()
@@ -192,80 +177,3 @@ private fun VoiceReplyScreenBody(
Spacer(modifier = Modifier.height(80.dp)) Spacer(modifier = Modifier.height(80.dp))
} }
} }
@Composable
private fun ReRecordButton(viewModel: VoiceReplyViewModel) {
Column(
modifier =
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(vertical = 16.dp, horizontal = Size10dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (viewModel.isUploading) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = stringRes(id = R.string.record_a_message),
tint = MaterialTheme.colorScheme.onBackground,
)
Text(
text = stringRes(id = R.string.re_record),
color = MaterialTheme.colorScheme.onBackground,
)
}
return
}
RecordAudioBox(
modifier = Modifier,
onRecordTaken = { recording ->
viewModel.selectRecording(recording)
},
) { isRecording, elapsedSeconds ->
val contentColor =
if (isRecording) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onBackground
}
val icon =
if (isRecording) {
Icons.Default.Stop
} else {
Icons.Default.Mic
}
val label =
if (isRecording) {
formatSecondsToTime(elapsedSeconds)
} else {
stringRes(id = R.string.re_record)
}
val iconDescription =
if (isRecording) {
stringRes(id = R.string.recording_indicator_description)
} else {
stringRes(id = R.string.record_a_message)
}
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = icon,
contentDescription = iconDescription,
tint = contentColor,
)
Text(
text = label,
color = contentColor,
)
}
}
}
}
@@ -34,7 +34,6 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import kotlin.collections.plus
@Stable @Stable
class Chatroom : NotesGatherer { class Chatroom : NotesGatherer {
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.commons.richtext package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import java.util.Base64 import java.util.Base64
object Base64Image { object Base64Image {
@@ -45,7 +45,6 @@ import java.net.MalformedURLException
import java.net.URISyntaxException import java.net.URISyntaxException
import java.net.URL import java.net.URL
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.text.iterator
class RichTextParser { class RichTextParser {
fun createMediaContent( fun createMediaContent(
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.desktop.network package com.vitorpamplona.amethyst.desktop.network
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
/** /**
@@ -49,7 +49,6 @@ import com.vitorpamplona.quartz.nip19Bech32.pubKeys
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.JsonNull.content
@Immutable @Immutable
class TextNoteEvent( class TextNoteEvent(