Merge branch 'main' into share-videos-to-other-apps
This commit is contained in:
@@ -42,11 +42,14 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
const val MAX_VOICE_RECORD_SECONDS = 600
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun RecordAudioBox(
|
||||
modifier: Modifier,
|
||||
onRecordTaken: (RecordingResult) -> Unit,
|
||||
maxDurationSeconds: Int? = null,
|
||||
content: @Composable (Boolean, Int) -> Unit,
|
||||
) {
|
||||
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
|
||||
@@ -107,6 +110,10 @@ fun RecordAudioBox(
|
||||
while (isActive) {
|
||||
delay(1000)
|
||||
elapsedSeconds++
|
||||
if (maxDurationSeconds != null && elapsedSeconds >= maxDurationSeconds) {
|
||||
stopRecording()
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset elapsed time when not recording
|
||||
|
||||
+5
-1
@@ -42,7 +42,10 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
|
||||
fun RecordVoiceButton(
|
||||
onVoiceTaken: (RecordingResult) -> Unit,
|
||||
maxDurationSeconds: Int? = null,
|
||||
) {
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
var elapsedSeconds by remember { mutableIntStateOf(0) }
|
||||
|
||||
@@ -61,6 +64,7 @@ fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
|
||||
elapsedSeconds = 0
|
||||
onVoiceTaken(recording)
|
||||
},
|
||||
maxDurationSeconds = maxDurationSeconds,
|
||||
) { recordingState, elapsed ->
|
||||
// Update parent state after composition completes
|
||||
SideEffect {
|
||||
|
||||
+7
-3
@@ -61,6 +61,7 @@ class VoiceAnonymizationController(
|
||||
preset: VoicePreset,
|
||||
originalFile: File?,
|
||||
) {
|
||||
Log.d(logTag, "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}")
|
||||
if (processingPreset != null || preset == selectedPreset) return
|
||||
|
||||
if (preset == VoicePreset.NONE) {
|
||||
@@ -76,9 +77,9 @@ class VoiceAnonymizationController(
|
||||
val file = originalFile ?: return
|
||||
|
||||
processingJob?.cancel()
|
||||
processingPreset = preset
|
||||
processingJob =
|
||||
scope.launch {
|
||||
processingPreset = preset
|
||||
try {
|
||||
val anonymizer = VoiceAnonymizer()
|
||||
val result = anonymizer.anonymize(file, preset)
|
||||
@@ -108,8 +109,11 @@ class VoiceAnonymizationController(
|
||||
distortedFiles.values.forEach { result ->
|
||||
try {
|
||||
if (result.file.exists()) {
|
||||
result.file.delete()
|
||||
Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}")
|
||||
if (result.file.delete()) {
|
||||
Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}")
|
||||
} else {
|
||||
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e)
|
||||
|
||||
+1
-2
@@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -63,7 +62,7 @@ fun VoiceAnonymizationSection(
|
||||
Text(
|
||||
text = stringRes(R.string.voice_anonymize_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
+53
-11
@@ -41,12 +41,26 @@ import java.io.File
|
||||
import java.nio.ByteOrder
|
||||
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(
|
||||
val file: File,
|
||||
val waveform: List<Float>,
|
||||
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 {
|
||||
companion object {
|
||||
private const val TAG = "VoiceAnonymizer"
|
||||
@@ -54,6 +68,20 @@ class VoiceAnonymizer {
|
||||
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(
|
||||
inputFile: File,
|
||||
preset: VoicePreset,
|
||||
@@ -98,7 +126,8 @@ class VoiceAnonymizer {
|
||||
): File {
|
||||
val baseName = inputFile.nameWithoutExtension
|
||||
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(
|
||||
@@ -129,9 +158,7 @@ class VoiceAnonymizer {
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTrackIndex == -1 || format == null) {
|
||||
throw IllegalStateException("No audio track found in file")
|
||||
}
|
||||
check(audioTrackIndex != -1 && format != null) { "No audio track found in file" }
|
||||
|
||||
extractor.selectTrack(audioTrackIndex)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm"
|
||||
@@ -143,7 +170,8 @@ class VoiceAnonymizer {
|
||||
decoder.configure(format, null, null, 0)
|
||||
decoder.start()
|
||||
|
||||
val pcmSamples = mutableListOf<Float>()
|
||||
val estimatedSamples = (sampleRate.toLong() * durationUs / 1_000_000).toInt()
|
||||
val pcmSamples = ArrayList<Float>(estimatedSamples)
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
var inputDone = false
|
||||
var outputDone = false
|
||||
@@ -196,7 +224,11 @@ class VoiceAnonymizer {
|
||||
|
||||
return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration)
|
||||
} finally {
|
||||
decoder?.stop()
|
||||
try {
|
||||
decoder?.stop()
|
||||
} catch (_: IllegalStateException) {
|
||||
// Decoder was never started
|
||||
}
|
||||
decoder?.release()
|
||||
extractor.release()
|
||||
}
|
||||
@@ -218,8 +250,8 @@ class VoiceAnonymizer {
|
||||
}
|
||||
else -> baseFactor
|
||||
}
|
||||
val processedSamples = mutableListOf<Float>()
|
||||
val totalSamples = pcmData.size
|
||||
val processedSamples = ArrayList<Float>(totalSamples)
|
||||
|
||||
val wsola =
|
||||
WaveformSimilarityBasedOverlapAdd(
|
||||
@@ -252,7 +284,9 @@ class VoiceAnonymizer {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun processingFinished() {}
|
||||
override fun processingFinished() {
|
||||
// No-op: no cleanup needed
|
||||
}
|
||||
}
|
||||
|
||||
val dispatcher =
|
||||
@@ -276,7 +310,9 @@ class VoiceAnonymizer {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun processingFinished() {}
|
||||
override fun processingFinished() {
|
||||
// No-op: no cleanup needed
|
||||
}
|
||||
}
|
||||
dispatcher.addAudioProcessor(progressProcessor)
|
||||
|
||||
@@ -400,7 +436,11 @@ class VoiceAnonymizer {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
encoder.stop()
|
||||
try {
|
||||
encoder.stop()
|
||||
} catch (_: IllegalStateException) {
|
||||
// Encoder was never started
|
||||
}
|
||||
encoder.release()
|
||||
if (muxerStarted) {
|
||||
muxer.stop()
|
||||
@@ -446,5 +486,7 @@ private class FloatArrayAudioInputStream(
|
||||
return actualSkip.toLong() * 2
|
||||
}
|
||||
|
||||
override fun close() {}
|
||||
override fun close() {
|
||||
// No-op: no cleanup needed
|
||||
}
|
||||
}
|
||||
|
||||
+144
-58
@@ -35,8 +35,10 @@ 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.Mic
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -67,6 +69,8 @@ fun VoiceMessagePreview(
|
||||
voiceMetadata: AudioMeta,
|
||||
localFile: File? = null,
|
||||
onRemove: () -> Unit,
|
||||
onReRecord: ((RecordingResult) -> Unit)? = null,
|
||||
isUploading: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -101,77 +105,159 @@ fun VoiceMessagePreview(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
).padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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),
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
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 ->
|
||||
handleWaveformScrub(
|
||||
newProgress = newProgress,
|
||||
// Play/Pause Button
|
||||
IconButton(
|
||||
onClick = {
|
||||
handlePlayPauseClick(
|
||||
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(
|
||||
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))
|
||||
|
||||
// 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,
|
||||
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))
|
||||
|
||||
// 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,
|
||||
if (onReRecord != null) {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
ReRecordButton(
|
||||
isUploading = isUploading,
|
||||
isPlaying = isPlaying,
|
||||
onRecordTaken = onReRecord,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
private fun ManageMediaPlayer(
|
||||
voiceMetadata: AudioMeta,
|
||||
|
||||
@@ -29,5 +29,5 @@ enum class VoicePreset(
|
||||
NONE(1.0, R.string.voice_preset_none),
|
||||
DEEP(1.4, R.string.voice_preset_deep),
|
||||
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.ui.actions.CrossfadeIfEnabled
|
||||
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.components.AnimatedBorderTextCornerRadius
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
@@ -635,6 +636,7 @@ fun ReplyViaVoiceReaction(
|
||||
)
|
||||
}
|
||||
},
|
||||
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
|
||||
) { isRecording, elapsedSeconds ->
|
||||
if (voiceRecordingState != null) {
|
||||
SideEffect {
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
*/
|
||||
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.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
|
||||
+4
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
|
||||
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.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
@@ -374,6 +375,8 @@ private fun NewPostScreenBody(
|
||||
VoiceMessagePreview(
|
||||
voiceMetadata = displayMetadata,
|
||||
localFile = postViewModel.activeFile,
|
||||
onReRecord = { recording -> postViewModel.selectVoiceRecording(recording) },
|
||||
isUploading = postViewModel.isUploadingVoice,
|
||||
onRemove = { postViewModel.removeVoiceMessage() },
|
||||
)
|
||||
|
||||
@@ -497,6 +500,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
|
||||
onVoiceTaken = { recording ->
|
||||
postViewModel.selectVoiceRecording(recording)
|
||||
},
|
||||
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
|
||||
)
|
||||
|
||||
if (postViewModel.canUsePoll) {
|
||||
|
||||
+2
-94
@@ -21,46 +21,32 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.uploads.RecordAudioBox
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
|
||||
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.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
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.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
@@ -103,9 +89,6 @@ fun VoiceReplyScreen(
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
ReRecordButton(viewModel)
|
||||
},
|
||||
) { pad ->
|
||||
Surface(
|
||||
modifier =
|
||||
@@ -161,6 +144,8 @@ private fun VoiceReplyScreenBody(
|
||||
VoiceMessagePreview(
|
||||
voiceMetadata = displayMetadata,
|
||||
localFile = viewModel.activeFile,
|
||||
onReRecord = { recording -> viewModel.selectRecording(recording) },
|
||||
isUploading = viewModel.isUploading,
|
||||
onRemove = {
|
||||
viewModel.cancel()
|
||||
nav.popBack()
|
||||
@@ -192,80 +177,3 @@ private fun VoiceReplyScreenBody(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,8 @@
|
||||
<string name="add_a_user">Ajouter un utilisateur</string>
|
||||
<string name="add_a_relay">Ajouter un relais</string>
|
||||
<string name="profile_name">Nom</string>
|
||||
<string name="profile_name_with_explainer">Nom (pour @tagging)</string>
|
||||
<string name="my_name">Mon nom @tag</string>
|
||||
<string name="display_name">Nom visible du public</string>
|
||||
<string name="my_display_name">Mon nom visible du public</string>
|
||||
<string name="my_awesome_name">Ostrich McGénial</string>
|
||||
@@ -170,6 +172,12 @@
|
||||
<string name="upload_error_voice_message_unexpected_state">Erreur lors de l\'envoi</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 n\'est pas encore supporté pour les messages vocaux</string>
|
||||
<string name="upload_error_voice_message_exception">Échec de l\'envoi de la voix : %1$s</string>
|
||||
<string name="voice_preset_none">Aucun</string>
|
||||
<string name="voice_preset_deep">Profond</string>
|
||||
<string name="voice_preset_high">Haute</string>
|
||||
<string name="voice_preset_neutral">Neutre</string>
|
||||
<string name="voice_anonymize_title">Anonymisation</string>
|
||||
<string name="voice_anonymize_description">Modifie votre niveau de voix. Remarque : les changements de hauteur de base peuvent potentiellement être inversés par des auditeurs déterminés.</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats</string>
|
||||
<string name="reply_here">"répondre ici"</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copie l\'ID de note dans le presse-papiers pour le partage sur Nostr</string>
|
||||
@@ -364,6 +372,7 @@
|
||||
<string name="manual_zaps">Division manuelle de zaps</string>
|
||||
<string name="bookmarks">Favoris</string>
|
||||
<string name="bookmarks_title">Signets par défaut</string>
|
||||
<string name="bookmarks_explainer">Vos favoris par défaut que de nombreux clients prennent en charge</string>
|
||||
<string name="drafts">Brouillons</string>
|
||||
<string name="private_bookmarks">Favoris Privés</string>
|
||||
<string name="public_bookmarks">Favoris Publics</string>
|
||||
@@ -371,6 +380,40 @@
|
||||
<string name="add_to_public_bookmarks">Ajouter aux Favoris Publics</string>
|
||||
<string name="remove_from_private_bookmarks">Supprimer des Favoris Privés</string>
|
||||
<string name="remove_from_public_bookmarks">Supprimer des Favoris Publics</string>
|
||||
<string name="bookmark_lists">Listes de favoris</string>
|
||||
<string name="bookmark_list_icon_label">Icône pour la liste de favoris</string>
|
||||
<string name="bookmark_list_creation_screen_title">Nouvelle liste de favoris</string>
|
||||
<string name="bookmark_list_edit_sub_title">Métadonnées de la liste de favoris</string>
|
||||
<string name="bookmark_list_clone_btn_label">Dupliquer la liste de favoris</string>
|
||||
<string name="bookmark_list_broadcast_btn_label">Diffuser la liste de favoris</string>
|
||||
<string name="bookmark_list_delete_btn_label">Supprimer la liste de favoris</string>
|
||||
<string name="bookmark_list_posts_btn_label">Voir les publications</string>
|
||||
<string name="bookmark_list_articles_btn_label">Voir les articles</string>
|
||||
<string name="bookmark_list_links_btn_label">Voir les liens</string>
|
||||
<string name="bookmark_list_hashtags_btn_label">Voir les hashtags</string>
|
||||
<string name="bookmark_list_feed_empty_msg">Vous n\'avez pas encore de liste de favoris. Appuyez sur le bouton ci-dessous pour en créer une.</string>
|
||||
<string name="private_posts_label">Publications privées</string>
|
||||
<string name="private_posts_count">Publications privées (%1$s)</string>
|
||||
<string name="public_posts_label">Publications publiques</string>
|
||||
<string name="public_posts_count">Publications publiques (%1$s)</string>
|
||||
<string name="private_articles_label">Articles privés</string>
|
||||
<string name="private_articles_count">Articles privés (%1$s)</string>
|
||||
<string name="public_articles_label">Articles publics</string>
|
||||
<string name="public_articles_count">Articles publics (%1$s)</string>
|
||||
<string name="manage_bookmark_label">Gérer %1$s dans les listes de favoris</string>
|
||||
<string name="post_bookmark_management_title">Ajouter cette publication aux favoris</string>
|
||||
<string name="article_bookmark_management_title">Ajouter cet article aux favoris</string>
|
||||
<string name="public_bookmark_presence_indicator">est un favori public ici</string>
|
||||
<string name="private_bookmark_presence_indicator">est un favori privé ici</string>
|
||||
<string name="bookmark_absence_indicator">n\'est pas un favori ici</string>
|
||||
<string name="bookmark_remove_action_desc">Supprimer le favori de la liste</string>
|
||||
<string name="bookmark_add_action_desc">Ajouter un favori à la liste</string>
|
||||
<string name="public_bookmark_add_action_label">Ajouter comme favori public</string>
|
||||
<string name="private_bookmark_add_action_label">Ajouter comme favori privé</string>
|
||||
<string name="bookmark_remove_action_label">Supprimer de la liste de favoris</string>
|
||||
<string name="bookmark_list_explainer">Les métadonnées des listes de favoris peuvent être vues par n\'importe qui sur Nostr. Seuls vos membres privés sont chiffrés.</string>
|
||||
<string name="move_bookmark_to_public_label">Déplacer en public</string>
|
||||
<string name="move_bookmark_to_private_label">Déplacer en privé</string>
|
||||
<string name="wallet_connect_service">Service Wallet Connect</string>
|
||||
<string name="wallet_connect_service_explainer">Autorise un Nostr Secret à payer des zaps sans quitter l\'application. Gardez le secret en toute sécurité et utilisez un relais privé si possible</string>
|
||||
<string name="wallet_connect_service_pubkey">Clé publique Wallet Connect</string>
|
||||
@@ -604,12 +647,23 @@
|
||||
<string name="contact">Contact</string>
|
||||
<string name="supports">NIPs supportés</string>
|
||||
<string name="admission_fees">Frais d\'admission</string>
|
||||
<string name="publication">Publication</string>
|
||||
<string name="payment_link">Paiements %1$s</string>
|
||||
<string name="payments_url">URL de payments</string>
|
||||
<string name="target_audience">Audience cible</string>
|
||||
<string name="policies_and_links">Politiques & liens</string>
|
||||
<string name="fees_and_payments">Frais & paiements</string>
|
||||
<string name="limitations">Limites</string>
|
||||
<string name="countries">Pays</string>
|
||||
<string name="languages">Langages</string>
|
||||
<string name="tags">Étiquettes</string>
|
||||
<string name="topics">Sujets</string>
|
||||
<string name="all_countries">Tous les pays</string>
|
||||
<string name="all_languages">Toutes les langues</string>
|
||||
<string name="posting_policy">Politique de publication</string>
|
||||
<string name="privacy_policy">Politique de confidentialité</string>
|
||||
<string name="terms_and_conditions">Termes & Conditions</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="relay_error_messages">Erreurs et Notifications de ce Relais</string>
|
||||
<string name="message_length">Longueur du message</string>
|
||||
<string name="subscriptions">Abonnements</string>
|
||||
@@ -618,9 +672,19 @@
|
||||
<string name="minimum_prefix">Préfixe minimum</string>
|
||||
<string name="maximum_event_tags">Tags d\'événement maximum</string>
|
||||
<string name="content_length">Longueur du contenu</string>
|
||||
<string name="max_content_length">Longueur maximale du contenu</string>
|
||||
<string name="discards_older_than">Rejeter plus anciens que</string>
|
||||
<string name="accepts_up_to">Accepte jusqu\'à</string>
|
||||
<string name="time_in_the_future">%1$s dans le futur</string>
|
||||
<string name="time_in_the_past">Il y a %1$s</string>
|
||||
<string name="content_size">Taille du contenu</string>
|
||||
<string name="connectivity">Connectivité</string>
|
||||
<string name="access_control">Contrôle d\'accès</string>
|
||||
<string name="minimum_pow">PoW minimum</string>
|
||||
<string name="auth">Authentification</string>
|
||||
<string name="auth_required">Authentification requise</string>
|
||||
<string name="payment">Paiement</string>
|
||||
<string name="payment_required">Paiement requis</string>
|
||||
<string name="cashu">Jeton Cashu</string>
|
||||
<string name="cashu_redeem">Échanger</string>
|
||||
<string name="cashu_redeem_to_zap">Envoyer vers le portefeuille Zap</string>
|
||||
|
||||
@@ -172,6 +172,12 @@
|
||||
<string name="upload_error_voice_message_unexpected_state">अनपेक्षित आरोहण स्थिति</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए</string>
|
||||
<string name="upload_error_voice_message_exception">ध्वनि आरोहण असफल : %1$s</string>
|
||||
<string name="voice_preset_none">कुछ भी नहीं</string>
|
||||
<string name="voice_preset_deep">गहन</string>
|
||||
<string name="voice_preset_high">उच्च</string>
|
||||
<string name="voice_preset_neutral">मध्यम</string>
|
||||
<string name="voice_anonymize_title">अनामीकरण</string>
|
||||
<string name="voice_anonymize_description">आपके स्वर का परिवर्तन करता है। ध्यातव्य। आधारभूत स्वर परिवर्तन को पूर्ववत किया जा सकता है दृढ निश्चित श्रोताओं द्वारा।</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</string>
|
||||
<string name="reply_here">"उत्तर यहाँ दें.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">नोस्ट्र में बाँटने के लिए टीका विभेदक की अनुकृति करता है टाँकाफलक में</string>
|
||||
@@ -1232,4 +1238,5 @@
|
||||
<string name="follow_pack_broadcast">पोटली प्रसारण</string>
|
||||
<string name="follow_set_delete">सूची मिटाएँ</string>
|
||||
<string name="follow_pack_delete">पोटली मिटाएँ</string>
|
||||
<string name="kinds">प्रकार</string>
|
||||
</resources>
|
||||
|
||||
@@ -172,6 +172,12 @@
|
||||
<string name="upload_error_voice_message_unexpected_state">Váratlan feltöltési állapot</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">A NIP-95 még nem támogatja a hangüzeneteket</string>
|
||||
<string name="upload_error_voice_message_exception">Nem sikerült feltölteni a hangüzenetet: %1$s</string>
|
||||
<string name="voice_preset_none">Semmi</string>
|
||||
<string name="voice_preset_deep">Mély</string>
|
||||
<string name="voice_preset_high">Magas</string>
|
||||
<string name="voice_preset_neutral">Semleges</string>
|
||||
<string name="voice_anonymize_title">Névtelenítés</string>
|
||||
<string name="voice_anonymize_description">Módosítja az Ön hangmagasságát. Megjegyzés: az alapvető hangmagasság-változásokat a figyelmes hallgatók visszafordíthatják.</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">A felhasználó nem rendelkezik a satoshik fogadásához beállított lightning-címmel</string>
|
||||
<string name="reply_here">"Válasz írása… "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">A bejegyzés azonosítóját a vágólapra másolja a Nostr-ban való megosztáshoz</string>
|
||||
|
||||
@@ -172,6 +172,12 @@
|
||||
<string name="upload_error_voice_message_unexpected_state">未知的上传状态</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">语音消息尚不支持 NIP-95</string>
|
||||
<string name="upload_error_voice_message_exception">语音上传失败:%1$s</string>
|
||||
<string name="voice_preset_none">无</string>
|
||||
<string name="voice_preset_deep">深沉</string>
|
||||
<string name="voice_preset_high">高音</string>
|
||||
<string name="voice_preset_neutral">中性</string>
|
||||
<string name="voice_anonymize_title">匿名化</string>
|
||||
<string name="voice_anonymize_description">更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未设置闪电地址以接收聪</string>
|
||||
<string name="reply_here">"🔏在此回复… "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">复制笔记ID到剪贴板,供分享</string>
|
||||
@@ -1232,4 +1238,5 @@
|
||||
<string name="follow_pack_broadcast">广播包</string>
|
||||
<string name="follow_set_delete">删除列表</string>
|
||||
<string name="follow_pack_delete">删除包</string>
|
||||
<string name="kinds">类型</string>
|
||||
</resources>
|
||||
|
||||
-1
@@ -34,7 +34,6 @@ import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.collections.plus
|
||||
|
||||
@Stable
|
||||
class Chatroom : NotesGatherer {
|
||||
|
||||
-1
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import java.util.Base64
|
||||
|
||||
object Base64Image {
|
||||
|
||||
-1
@@ -45,7 +45,6 @@ import java.net.MalformedURLException
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URL
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.text.iterator
|
||||
|
||||
class RichTextParser {
|
||||
fun createMediaContent(
|
||||
|
||||
-1
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.network
|
||||
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||
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.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.serialization.json.JsonNull.content
|
||||
|
||||
@Immutable
|
||||
class TextNoteEvent(
|
||||
|
||||
Reference in New Issue
Block a user