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.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
|
const val MAX_VOICE_RECORD_SECONDS = 600
|
||||||
|
|
||||||
@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
|
||||||
|
|||||||
+5
-1
@@ -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 {
|
||||||
|
|||||||
+7
-3
@@ -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)
|
||||||
|
|||||||
+1
-2
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+53
-11
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+144
-58
@@ -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
|
||||||
|
|||||||
+4
@@ -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) {
|
||||||
|
|||||||
+2
-94
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -135,6 +135,8 @@
|
|||||||
<string name="add_a_user">Ajouter un utilisateur</string>
|
<string name="add_a_user">Ajouter un utilisateur</string>
|
||||||
<string name="add_a_relay">Ajouter un relais</string>
|
<string name="add_a_relay">Ajouter un relais</string>
|
||||||
<string name="profile_name">Nom</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="display_name">Nom visible du public</string>
|
||||||
<string name="my_display_name">Mon 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>
|
<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_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_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="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="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="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>
|
<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="manual_zaps">Division manuelle de zaps</string>
|
||||||
<string name="bookmarks">Favoris</string>
|
<string name="bookmarks">Favoris</string>
|
||||||
<string name="bookmarks_title">Signets par défaut</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="drafts">Brouillons</string>
|
||||||
<string name="private_bookmarks">Favoris Privés</string>
|
<string name="private_bookmarks">Favoris Privés</string>
|
||||||
<string name="public_bookmarks">Favoris Publics</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="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_private_bookmarks">Supprimer des Favoris Privés</string>
|
||||||
<string name="remove_from_public_bookmarks">Supprimer des Favoris Publics</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">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_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>
|
<string name="wallet_connect_service_pubkey">Clé publique Wallet Connect</string>
|
||||||
@@ -604,12 +647,23 @@
|
|||||||
<string name="contact">Contact</string>
|
<string name="contact">Contact</string>
|
||||||
<string name="supports">NIPs supportés</string>
|
<string name="supports">NIPs supportés</string>
|
||||||
<string name="admission_fees">Frais d\'admission</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="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="limitations">Limites</string>
|
||||||
<string name="countries">Pays</string>
|
<string name="countries">Pays</string>
|
||||||
<string name="languages">Langages</string>
|
<string name="languages">Langages</string>
|
||||||
<string name="tags">Étiquettes</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="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="relay_error_messages">Erreurs et Notifications de ce Relais</string>
|
||||||
<string name="message_length">Longueur du message</string>
|
<string name="message_length">Longueur du message</string>
|
||||||
<string name="subscriptions">Abonnements</string>
|
<string name="subscriptions">Abonnements</string>
|
||||||
@@ -618,9 +672,19 @@
|
|||||||
<string name="minimum_prefix">Préfixe minimum</string>
|
<string name="minimum_prefix">Préfixe minimum</string>
|
||||||
<string name="maximum_event_tags">Tags d\'événement maximum</string>
|
<string name="maximum_event_tags">Tags d\'événement maximum</string>
|
||||||
<string name="content_length">Longueur du contenu</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="minimum_pow">PoW minimum</string>
|
||||||
<string name="auth">Authentification</string>
|
<string name="auth">Authentification</string>
|
||||||
|
<string name="auth_required">Authentification requise</string>
|
||||||
<string name="payment">Paiement</string>
|
<string name="payment">Paiement</string>
|
||||||
|
<string name="payment_required">Paiement requis</string>
|
||||||
<string name="cashu">Jeton Cashu</string>
|
<string name="cashu">Jeton Cashu</string>
|
||||||
<string name="cashu_redeem">Échanger</string>
|
<string name="cashu_redeem">Échanger</string>
|
||||||
<string name="cashu_redeem_to_zap">Envoyer vers le portefeuille Zap</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_unexpected_state">अनपेक्षित आरोहण स्थिति</string>
|
||||||
<string name="upload_error_voice_message_nip95_not_supported">निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए</string>
|
<string name="upload_error_voice_message_nip95_not_supported">निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए</string>
|
||||||
<string name="upload_error_voice_message_exception">ध्वनि आरोहण असफल : %1$s</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="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</string>
|
||||||
<string name="reply_here">"उत्तर यहाँ दें.. "</string>
|
<string name="reply_here">"उत्तर यहाँ दें.. "</string>
|
||||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">नोस्ट्र में बाँटने के लिए टीका विभेदक की अनुकृति करता है टाँकाफलक में</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_pack_broadcast">पोटली प्रसारण</string>
|
||||||
<string name="follow_set_delete">सूची मिटाएँ</string>
|
<string name="follow_set_delete">सूची मिटाएँ</string>
|
||||||
<string name="follow_pack_delete">पोटली मिटाएँ</string>
|
<string name="follow_pack_delete">पोटली मिटाएँ</string>
|
||||||
|
<string name="kinds">प्रकार</string>
|
||||||
</resources>
|
</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_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_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="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="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="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>
|
<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_unexpected_state">未知的上传状态</string>
|
||||||
<string name="upload_error_voice_message_nip95_not_supported">语音消息尚不支持 NIP-95</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="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="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未设置闪电地址以接收聪</string>
|
||||||
<string name="reply_here">"🔏在此回复… "</string>
|
<string name="reply_here">"🔏在此回复… "</string>
|
||||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">复制笔记ID到剪贴板,供分享</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_pack_broadcast">广播包</string>
|
||||||
<string name="follow_set_delete">删除列表</string>
|
<string name="follow_set_delete">删除列表</string>
|
||||||
<string name="follow_pack_delete">删除包</string>
|
<string name="follow_pack_delete">删除包</string>
|
||||||
|
<string name="kinds">类型</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
-1
@@ -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 {
|
||||||
|
|||||||
-1
@@ -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 {
|
||||||
|
|||||||
-1
@@ -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(
|
||||||
|
|||||||
-1
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user