Merge pull request #1622 from davotoula/voice-notes-replies
Christmas delivery: Voice notes replies
This commit is contained in:
@@ -202,6 +202,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -1002,7 +1003,7 @@ class Account(
|
||||
hash: String,
|
||||
duration: Int,
|
||||
waveform: List<Float>,
|
||||
replyTo: EventHintBundle<VoiceEvent>,
|
||||
replyTo: EventHintBundle<BaseVoiceEvent>,
|
||||
) {
|
||||
signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo))
|
||||
}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.mediaServers
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun FileServerSelectionRow(
|
||||
fileServers: List<ServerName>,
|
||||
selectedServer: ServerName?,
|
||||
onSelect: (ServerName) -> Unit,
|
||||
) {
|
||||
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
|
||||
|
||||
val fileServerOptions =
|
||||
remember(fileServers) {
|
||||
fileServers
|
||||
.map {
|
||||
if (it.type == ServerType.NIP95) {
|
||||
TitleExplainer(it.name, nip95description)
|
||||
} else {
|
||||
TitleExplainer(it.name, it.baseUrl)
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
val placeholder =
|
||||
fileServers
|
||||
.firstOrNull { it == selectedServer }
|
||||
?.name
|
||||
?: fileServers.firstOrNull()?.name
|
||||
?: ""
|
||||
|
||||
SettingsRow(R.string.file_server, R.string.file_server_description) {
|
||||
TextSpinner(
|
||||
label = "",
|
||||
placeholder = placeholder,
|
||||
options = fileServerOptions,
|
||||
onSelect = { index -> fileServers.getOrNull(index)?.let(onSelect) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -175,18 +175,27 @@ fun FloatingRecordingIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
isRecording: Boolean,
|
||||
elapsedSeconds: Int,
|
||||
isCompact: Boolean = false,
|
||||
) {
|
||||
if (!isRecording) return
|
||||
|
||||
val recordingLabel = stringRes(id = R.string.recording_indicator_description)
|
||||
val recordingWithTime = stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds))
|
||||
val recordingWithTime =
|
||||
if (isCompact) {
|
||||
formatSecondsToTime(elapsedSeconds)
|
||||
} else {
|
||||
stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds))
|
||||
}
|
||||
val horizontalPadding = if (isCompact) 8.dp else 16.dp
|
||||
val innerPadding = if (isCompact) 6.dp else 12.dp
|
||||
val textSize = if (isCompact) 12.sp else 14.sp
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -195,7 +204,7 @@ fun FloatingRecordingIndicator(
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
modifier = Modifier.padding(horizontal = innerPadding),
|
||||
) {
|
||||
// Pulsing red dot
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "recording_dot")
|
||||
@@ -223,8 +232,10 @@ fun FloatingRecordingIndicator(
|
||||
Text(
|
||||
text = recordingWithTime,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontSize = textSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
|
||||
@Composable
|
||||
fun UploadProgressIndicator(
|
||||
orchestrator: UploadOrchestrator,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val progressValue = orchestrator.progress.collectAsState().value
|
||||
val progressStatusValue = orchestrator.progressState.collectAsState().value
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(55.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val animatedProgress =
|
||||
animateFloatAsState(
|
||||
targetValue = progressValue.toFloat(),
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
).value
|
||||
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier =
|
||||
Size55Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
strokeWidth = 5.dp,
|
||||
)
|
||||
|
||||
val txt =
|
||||
when (progressStatusValue) {
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error)
|
||||
}
|
||||
|
||||
Text(
|
||||
txt,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 10.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen
|
||||
@@ -289,6 +290,18 @@ fun AppNavigation(
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottomArgs<Route.VoiceReply> {
|
||||
VoiceReplyScreen(
|
||||
replyToNoteId = it.replyToNoteId,
|
||||
recordingFilePath = it.recordingFilePath,
|
||||
mimeType = it.mimeType,
|
||||
duration = it.duration,
|
||||
amplitudesJson = it.amplitudes,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,15 @@ sealed class Route {
|
||||
val draft: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class VoiceReply(
|
||||
val replyToNoteId: String,
|
||||
val recordingFilePath: String,
|
||||
val mimeType: String,
|
||||
val duration: Int,
|
||||
val amplitudes: String, // JSON-encoded List<Float>
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class ManualZapSplitPayment(
|
||||
val paymentId: String,
|
||||
@@ -334,6 +343,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
|
||||
dest.hasRoute<Route.Nip47NWCSetup>() -> entry.toRoute<Route.Nip47NWCSetup>()
|
||||
dest.hasRoute<Route.Room>() -> entry.toRoute<Route.Room>()
|
||||
dest.hasRoute<Route.NewShortNote>() -> entry.toRoute<Route.NewShortNote>()
|
||||
dest.hasRoute<Route.VoiceReply>() -> entry.toRoute<Route.VoiceReply>()
|
||||
dest.hasRoute<Route.NewProduct>() -> entry.toRoute<Route.NewProduct>()
|
||||
dest.hasRoute<Route.GeoPost>() -> entry.toRoute<Route.GeoPost>()
|
||||
dest.hasRoute<Route.HashtagPost>() -> entry.toRoute<Route.HashtagPost>()
|
||||
|
||||
@@ -47,6 +47,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -61,6 +63,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -109,6 +112,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
|
||||
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.RecordAudioBox
|
||||
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
@@ -169,6 +173,7 @@ import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
@@ -204,9 +209,12 @@ private fun InnerReactionRow(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val voiceRecordingState = remember(baseNote.idHex) { mutableStateOf(false) }
|
||||
|
||||
GenericInnerReactionRow(
|
||||
showReactionDetail = showReactionDetail,
|
||||
addPadding = addPadding,
|
||||
weightTwo = if (voiceRecordingState.value) 2f else 1f,
|
||||
one = {
|
||||
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) {
|
||||
RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel)
|
||||
@@ -218,6 +226,7 @@ private fun InnerReactionRow(
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav,
|
||||
voiceRecordingState = voiceRecordingState,
|
||||
)
|
||||
},
|
||||
three = {
|
||||
@@ -288,6 +297,7 @@ fun ShareReaction(
|
||||
private fun GenericInnerReactionRow(
|
||||
showReactionDetail: Boolean,
|
||||
addPadding: Boolean,
|
||||
weightTwo: Float = 1f,
|
||||
one: @Composable () -> Unit,
|
||||
two: @Composable () -> Unit,
|
||||
three: @Composable () -> Unit,
|
||||
@@ -309,7 +319,7 @@ private fun GenericInnerReactionRow(
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(1f)) { two() }
|
||||
Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(weightTwo)) { two() }
|
||||
|
||||
Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(1f)) { three() }
|
||||
|
||||
@@ -583,9 +593,16 @@ private fun ReplyReactionWithDialog(
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
voiceRecordingState: MutableState<Boolean>? = null,
|
||||
) {
|
||||
if (baseNote.event is BaseVoiceEvent) {
|
||||
ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel)
|
||||
ReplyViaVoiceReaction(
|
||||
baseNote,
|
||||
grayTint,
|
||||
accountViewModel,
|
||||
nav,
|
||||
voiceRecordingState = voiceRecordingState,
|
||||
)
|
||||
} else {
|
||||
ReplyReaction(baseNote, grayTint, accountViewModel) {
|
||||
nav.nav { routeReplyTo(baseNote, accountViewModel.account) }
|
||||
@@ -599,18 +616,45 @@ fun ReplyViaVoiceReaction(
|
||||
baseNote: Note,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
showCounter: Boolean = true,
|
||||
iconSizeModifier: Modifier = Size19Modifier,
|
||||
voiceRecordingState: MutableState<Boolean>? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
RecordAudioBox(
|
||||
modifier = iconSizeModifier,
|
||||
modifier = Modifier,
|
||||
onRecordTaken = { audio ->
|
||||
accountViewModel.sendVoiceReply(baseNote, audio, context)
|
||||
nav.nav {
|
||||
Route.VoiceReply(
|
||||
replyToNoteId = baseNote.idHex,
|
||||
recordingFilePath = audio.file.absolutePath,
|
||||
mimeType = audio.mimeType,
|
||||
duration = audio.duration,
|
||||
amplitudes = Json.encodeToString(audio.amplitudes),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { _, _ ->
|
||||
VoiceReplyIcon(iconSizeModifier, grayTint)
|
||||
) { isRecording, elapsedSeconds ->
|
||||
if (voiceRecordingState != null) {
|
||||
SideEffect {
|
||||
if (voiceRecordingState.value != isRecording) {
|
||||
voiceRecordingState.value = isRecording
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isRecording) {
|
||||
FloatingRecordingIndicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp),
|
||||
isRecording = true,
|
||||
elapsedSeconds = elapsedSeconds,
|
||||
isCompact = true,
|
||||
)
|
||||
} else {
|
||||
VoiceReplyIcon(iconSizeModifier, grayTint)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCounter) {
|
||||
|
||||
+2
-2
@@ -134,7 +134,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -1154,7 +1154,7 @@ class AccountViewModel(
|
||||
context: Context,
|
||||
) {
|
||||
if (isWriteable()) {
|
||||
val hint = note.toEventHint<VoiceEvent>() ?: return
|
||||
val hint = note.toEventHint<BaseVoiceEvent>() ?: return
|
||||
|
||||
launchSigner {
|
||||
val uploader = UploadOrchestrator()
|
||||
|
||||
+8
-95
@@ -24,10 +24,7 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -38,47 +35,37 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
|
||||
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
@@ -111,7 +98,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
@@ -365,24 +351,11 @@ private fun NewPostScreenBody(
|
||||
|
||||
// Show preview for both uploaded messages (voiceMetadata) and pending recordings
|
||||
(postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata ->
|
||||
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
|
||||
val fileServersState =
|
||||
accountViewModel.account.serverLists.liveServerList
|
||||
.collectAsState()
|
||||
val fileServers = fileServersState.value
|
||||
|
||||
val fileServerOptions =
|
||||
remember(fileServers) {
|
||||
fileServers
|
||||
.map {
|
||||
if (it.type == ServerType.NIP95) {
|
||||
TitleExplainer(it.name, nip95description)
|
||||
} else {
|
||||
TitleExplainer(it.name, it.baseUrl)
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -391,7 +364,7 @@ private fun NewPostScreenBody(
|
||||
) {
|
||||
// Display voice preview or uploading progress
|
||||
postViewModel.voiceOrchestrator?.let { orchestrator ->
|
||||
VoiceUploadingProgress(orchestrator)
|
||||
UploadProgressIndicator(orchestrator)
|
||||
} ?: run {
|
||||
VoiceMessagePreview(
|
||||
voiceMetadata = metadata,
|
||||
@@ -400,18 +373,11 @@ private fun NewPostScreenBody(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsRow(R.string.file_server, R.string.file_server_description) {
|
||||
TextSpinner(
|
||||
label = "",
|
||||
placeholder =
|
||||
fileServers
|
||||
.firstOrNull { it == (postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) }
|
||||
?.name
|
||||
?: fileServers[0].name,
|
||||
options = fileServerOptions,
|
||||
onSelect = { postViewModel.voiceSelectedServer = fileServers[it] },
|
||||
)
|
||||
}
|
||||
FileServerSelectionRow(
|
||||
fileServers = fileServers,
|
||||
selectedServer = postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer,
|
||||
onSelect = { postViewModel.voiceSelectedServer = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,56 +550,3 @@ private fun AddPollButton(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) {
|
||||
val progressValue = orchestrator.progress.collectAsState().value
|
||||
val progressStatusValue = orchestrator.progressState.collectAsState().value
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(55.dp),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center,
|
||||
) {
|
||||
val animatedProgress =
|
||||
animateFloatAsState(
|
||||
targetValue = progressValue.toFloat(),
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
).value
|
||||
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier =
|
||||
Size55Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
strokeWidth = 5.dp,
|
||||
)
|
||||
|
||||
val txt =
|
||||
when (progressStatusValue) {
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error)
|
||||
}
|
||||
|
||||
Text(
|
||||
txt,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 10.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -121,6 +121,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -539,8 +540,8 @@ open class ShortNotePostViewModel :
|
||||
private suspend fun createTemplate(): EventTemplate<out Event>? {
|
||||
// Check if this is a voice message
|
||||
voiceMetadata?.let { audioMeta ->
|
||||
// Only create voice reply if original note is also a VoiceEvent
|
||||
val originalVoiceHint = originalNote?.toEventHint<VoiceEvent>()
|
||||
// Only create voice reply if original note is also a voice message
|
||||
val originalVoiceHint = originalNote?.toEventHint<BaseVoiceEvent>()
|
||||
return if (originalVoiceHint != null) {
|
||||
// Create voice reply event
|
||||
VoiceReplyEvent.build(
|
||||
@@ -1024,9 +1025,6 @@ open class ShortNotePostViewModel :
|
||||
onError(uploadErrorTitle, uploadVoiceFailed)
|
||||
voiceRecording = null
|
||||
}
|
||||
else -> {
|
||||
onError(uploadErrorTitle, uploadVoiceUnexpected)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName))
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.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.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.VoiceMessagePreview
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VoiceReplyScreen(
|
||||
replyToNoteId: String,
|
||||
recordingFilePath: String,
|
||||
mimeType: String,
|
||||
duration: Int,
|
||||
amplitudesJson: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
val viewModel: VoiceReplyViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(replyToNoteId, recordingFilePath, accountViewModel) {
|
||||
viewModel.init(accountViewModel)
|
||||
viewModel.load(replyToNoteId, recordingFilePath, mimeType, duration, amplitudesJson)
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
viewModel.cancel()
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
PostingTopBar(
|
||||
isActive = viewModel::canSend,
|
||||
onPost = {
|
||||
viewModel.sendVoiceReply {
|
||||
nav.popBack()
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
viewModel.cancel()
|
||||
nav.popBack()
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
ReRecordButton(viewModel)
|
||||
},
|
||||
) { pad ->
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad),
|
||||
) {
|
||||
VoiceReplyScreenBody(viewModel, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceReplyScreenBody(
|
||||
viewModel: VoiceReplyViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = Size10dp),
|
||||
) {
|
||||
// Show the note being replied to
|
||||
viewModel.replyToNote?.let { note ->
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = false,
|
||||
makeItShort = true,
|
||||
quotesLeft = 1,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
|
||||
// Voice preview or upload progress
|
||||
viewModel.voiceOrchestrator?.let { orchestrator ->
|
||||
UploadProgressIndicator(orchestrator)
|
||||
} ?: run {
|
||||
viewModel.getVoicePreviewMetadata()?.let { metadata ->
|
||||
VoiceMessagePreview(
|
||||
voiceMetadata = metadata,
|
||||
localFile = viewModel.voiceLocalFile,
|
||||
onRemove = {
|
||||
viewModel.cancel()
|
||||
nav.popBack()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Server selection
|
||||
val fileServers =
|
||||
accountViewModel.account.serverLists.liveServerList
|
||||
.collectAsState()
|
||||
.value
|
||||
FileServerSelectionRow(
|
||||
fileServers = fileServers,
|
||||
selectedServer = viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer,
|
||||
onSelect = { viewModel.voiceSelectedServer = it },
|
||||
)
|
||||
|
||||
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, _ ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = stringRes(id = R.string.record_a_message),
|
||||
tint =
|
||||
if (isRecording) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = stringRes(id = R.string.re_record),
|
||||
color =
|
||||
if (isRecording) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
@Stable
|
||||
class VoiceReplyViewModel : ViewModel() {
|
||||
lateinit var accountViewModel: AccountViewModel
|
||||
|
||||
var replyToNote: Note? by mutableStateOf(null)
|
||||
|
||||
var voiceRecording: RecordingResult? by mutableStateOf(null)
|
||||
var voiceLocalFile: File? by mutableStateOf(null)
|
||||
var voiceMetadata: AudioMeta? by mutableStateOf(null)
|
||||
var voiceSelectedServer: ServerName? by mutableStateOf(null)
|
||||
var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null)
|
||||
var isUploading: Boolean by mutableStateOf(false)
|
||||
|
||||
private var uploadJob: Job? = null
|
||||
|
||||
fun init(accountVM: AccountViewModel) {
|
||||
this.accountViewModel = accountVM
|
||||
}
|
||||
|
||||
fun load(
|
||||
replyToNoteId: String,
|
||||
recordingFilePath: String,
|
||||
mimeType: String,
|
||||
duration: Int,
|
||||
amplitudesJson: String,
|
||||
) {
|
||||
replyToNote = accountViewModel.getNoteIfExists(replyToNoteId)
|
||||
|
||||
val amplitudes =
|
||||
try {
|
||||
Json.decodeFromString<List<Float>>(amplitudesJson)
|
||||
} catch (e: Exception) {
|
||||
Log.w("VoiceReplyViewModel", "Failed to parse amplitudes", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val file = File(recordingFilePath)
|
||||
voiceLocalFile = file
|
||||
voiceRecording =
|
||||
RecordingResult(
|
||||
file = file,
|
||||
mimeType = mimeType,
|
||||
duration = duration,
|
||||
amplitudes = amplitudes,
|
||||
)
|
||||
}
|
||||
|
||||
fun getVoicePreviewMetadata(): AudioMeta? =
|
||||
voiceRecording?.let { recording ->
|
||||
AudioMeta(
|
||||
url = "",
|
||||
mimeType = recording.mimeType,
|
||||
duration = recording.duration,
|
||||
waveform = recording.amplitudes,
|
||||
)
|
||||
}
|
||||
|
||||
fun selectRecording(recording: RecordingResult) {
|
||||
cancelUpload()
|
||||
deleteVoiceLocalFile()
|
||||
voiceRecording = recording
|
||||
voiceLocalFile = recording.file
|
||||
voiceMetadata = null
|
||||
}
|
||||
|
||||
private fun cancelUpload() {
|
||||
uploadJob?.cancel()
|
||||
uploadJob = null
|
||||
}
|
||||
|
||||
private fun deleteVoiceLocalFile() {
|
||||
voiceLocalFile?.let { file ->
|
||||
try {
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
Log.d("VoiceReplyViewModel", "Deleted voice file: ${file.absolutePath}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canSend(): Boolean = voiceRecording != null && !isUploading
|
||||
|
||||
fun sendVoiceReply(onSuccess: () -> Unit) {
|
||||
val note = replyToNote ?: return
|
||||
val recording = voiceRecording ?: return
|
||||
val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer
|
||||
|
||||
cancelUpload()
|
||||
uploadJob =
|
||||
viewModelScope.launch {
|
||||
isUploading = true
|
||||
val orchestrator = UploadOrchestrator()
|
||||
voiceOrchestrator = orchestrator
|
||||
|
||||
try {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
val uri = android.net.Uri.fromFile(recording.file)
|
||||
orchestrator.upload(
|
||||
uri = uri,
|
||||
mimeType = recording.mimeType,
|
||||
alt = null,
|
||||
contentWarningReason = null,
|
||||
compressionQuality = CompressorQuality.UNCOMPRESSED,
|
||||
server = serverToUse,
|
||||
account = accountViewModel.account,
|
||||
context = Amethyst.instance.appContext,
|
||||
useH265 = false,
|
||||
)
|
||||
}
|
||||
|
||||
handleUploadResult(note, recording, serverToUse, result, onSuccess)
|
||||
} catch (e: CancellationException) {
|
||||
Log.w("VoiceReplyViewModel", "User canceled, or ViewModel cleared", e)
|
||||
} catch (e: Exception) {
|
||||
val appContext = Amethyst.instance.appContext
|
||||
val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title)
|
||||
val uploadVoiceExceptionMessage: (String) -> String = { detail ->
|
||||
stringRes(appContext, R.string.upload_error_voice_message_exception, detail)
|
||||
}
|
||||
accountViewModel.toastManager.toast(
|
||||
uploadErrorTitle,
|
||||
uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName),
|
||||
)
|
||||
} finally {
|
||||
isUploading = false
|
||||
voiceOrchestrator = null
|
||||
uploadJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleUploadResult(
|
||||
note: Note,
|
||||
recording: RecordingResult,
|
||||
server: ServerName,
|
||||
result: UploadingState,
|
||||
onSuccess: () -> Unit,
|
||||
) {
|
||||
val appContext = Amethyst.instance.appContext
|
||||
val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title)
|
||||
val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported)
|
||||
val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed)
|
||||
|
||||
when (result) {
|
||||
is UploadingState.Finished -> {
|
||||
when (val orchestratorResult = result.result) {
|
||||
is UploadOrchestrator.OrchestratorResult.ServerResult -> {
|
||||
val hint = note.toEventHint<BaseVoiceEvent>()
|
||||
if (hint == null) {
|
||||
accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed)
|
||||
return
|
||||
}
|
||||
|
||||
val audioMeta =
|
||||
AudioMeta(
|
||||
url = orchestratorResult.url,
|
||||
mimeType = recording.mimeType,
|
||||
hash = orchestratorResult.fileHeader.hash,
|
||||
duration = recording.duration,
|
||||
waveform = recording.amplitudes,
|
||||
)
|
||||
|
||||
accountViewModel.account.signAndComputeBroadcast(VoiceReplyEvent.build(audioMeta, hint))
|
||||
|
||||
if (server.type != ServerType.NIP95) {
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||
}
|
||||
|
||||
deleteVoiceLocalFile()
|
||||
voiceLocalFile = null
|
||||
voiceRecording = null
|
||||
voiceMetadata = audioMeta
|
||||
|
||||
onSuccess()
|
||||
}
|
||||
is UploadOrchestrator.OrchestratorResult.NIP95Result -> {
|
||||
accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceNip95NotSupported)
|
||||
}
|
||||
}
|
||||
}
|
||||
is UploadingState.Error -> {
|
||||
accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed)
|
||||
}
|
||||
else -> {
|
||||
accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
cancelUpload()
|
||||
deleteVoiceLocalFile()
|
||||
voiceRecording = null
|
||||
voiceLocalFile = null
|
||||
voiceMetadata = null
|
||||
voiceSelectedServer = null
|
||||
isUploading = false
|
||||
voiceOrchestrator = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
cancel()
|
||||
super.onCleared()
|
||||
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
@@ -190,8 +190,8 @@ val VideoReactionColumnPadding = Modifier.padding(bottom = 75.dp)
|
||||
|
||||
val DividerThickness = 0.25.dp
|
||||
|
||||
val ReactionRowHeight = Modifier.padding(vertical = 7.dp).height(24.dp)
|
||||
val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).height(24.dp).padding(horizontal = 10.dp)
|
||||
val ReactionRowHeight = Modifier.padding(vertical = 7.dp).heightIn(min = 24.dp)
|
||||
val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).heightIn(min = 24.dp).padding(horizontal = 10.dp)
|
||||
val ReactionRowHeightChat = Modifier.height(20.dp)
|
||||
val ReactionRowHeightChatMaxWidth = Modifier.height(25.dp).fillMaxWidth()
|
||||
val UserNameRowHeight = Modifier.fillMaxWidth()
|
||||
|
||||
@@ -1183,4 +1183,5 @@
|
||||
<string name="follow_pack_broadcast">Broadcast sady doporučení</string>
|
||||
<string name="follow_set_delete">Smazat seznam</string>
|
||||
<string name="follow_pack_delete">Smazat sadu doporučení</string>
|
||||
<string name="re_record">Znovu nahrát</string>
|
||||
</resources>
|
||||
|
||||
@@ -1188,4 +1188,5 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="follow_pack_broadcast">Empfehlungspaket veröffentlichen</string>
|
||||
<string name="follow_set_delete">Liste löschen</string>
|
||||
<string name="follow_pack_delete">Empfehlungspaket löschen</string>
|
||||
<string name="re_record">Neu aufnehmen</string>
|
||||
</resources>
|
||||
|
||||
@@ -1183,4 +1183,5 @@
|
||||
<string name="follow_pack_broadcast">Transmitir pacote de recomendações</string>
|
||||
<string name="follow_set_delete">Excluir lista</string>
|
||||
<string name="follow_pack_delete">Excluir pacote de recomendações</string>
|
||||
<string name="re_record">Gravar novamente</string>
|
||||
</resources>
|
||||
|
||||
@@ -1182,4 +1182,5 @@
|
||||
<string name="follow_pack_broadcast">Sänd rekommendationspaket</string>
|
||||
<string name="follow_set_delete">Ta bort lista</string>
|
||||
<string name="follow_pack_delete">Ta bort rekommendationspaket</string>
|
||||
<string name="re_record">Spela in igen</string>
|
||||
</resources>
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
<string name="record_a_message">Record a message</string>
|
||||
<string name="record_a_message_title">Record a message</string>
|
||||
<string name="record_a_message_description">Click and hold to record a message</string>
|
||||
<string name="re_record">Re-record</string>
|
||||
<string name="recording_indicator_description">Recording</string>
|
||||
<string name="recording_indicator_with_time">Recording %1$s</string>
|
||||
<string name="uploading">Uploading…</string>
|
||||
|
||||
+2
-2
@@ -65,12 +65,12 @@ class VoiceReplyEvent(
|
||||
hash: String,
|
||||
duration: Int,
|
||||
waveform: List<Float>,
|
||||
replyingTo: EventHintBundle<VoiceEvent>,
|
||||
replyingTo: EventHintBundle<BaseVoiceEvent>,
|
||||
) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo)
|
||||
|
||||
fun build(
|
||||
voiceMessage: AudioMeta,
|
||||
replyingTo: EventHintBundle<VoiceEvent>,
|
||||
replyingTo: EventHintBundle<BaseVoiceEvent>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<VoiceReplyEvent>.() -> Unit = {},
|
||||
) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) {
|
||||
|
||||
Reference in New Issue
Block a user