From 3a85041d801420d024bf137cec4b0504f01deeb9 Mon Sep 17 00:00:00 2001 From: Avently <7953703+avently@users.noreply.github.com> Date: Tue, 9 May 2023 17:47:22 +0700 Subject: [PATCH] android: voice message slider --- .../chat/simplex/app/views/chat/ChatView.kt | 15 +- .../app/views/chat/ComposeVoiceView.kt | 198 +++++++++++------- .../app/views/chat/item/CIVoiceView.kt | 36 +++- .../simplex/app/views/helpers/RecAndPlay.kt | 5 + .../chat/simplex/app/views/helpers/Util.kt | 6 + 5 files changed, 175 insertions(+), 85 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index c75e490849..5926601094 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -351,7 +351,20 @@ fun ChatLayout( modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, ) { contentPadding -> - BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) { + BoxWithConstraints(Modifier + .fillMaxHeight() + .padding(if (composeState.value.preview is ComposePreview.VoicePreview) { + PaddingValues( + contentPadding.calculateStartPadding(LocalLayoutDirection.current), + contentPadding.calculateTopPadding(), + contentPadding.calculateEndPadding(LocalLayoutDirection.current), + contentPadding.calculateBottomPadding() - 22.dp + ) + } else { + contentPadding + } + ) + ) { ChatItemsList( chat, unreadCount, composeState, chatItems, searchValue, useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt index 9c6babe65e..03a261ccaf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt @@ -6,6 +6,10 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -25,99 +29,135 @@ fun ComposeVoiceView( cancelEnabled: Boolean, cancelVoice: () -> Unit ) { - BoxWithConstraints(Modifier - .fillMaxWidth() - ) { - val audioPlaying = rememberSaveable { mutableStateOf(false) } - val progress = rememberSaveable { mutableStateOf(0) } - val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) } - val progressBarWidth = remember { Animatable(0f) } - LaunchedEffect(recordedDurationMs, finishedRecording) { - snapshotFlow { progress.value } - .distinctUntilChanged() - .collect { - val startTime = when { - finishedRecording -> progress.value - else -> recordedDurationMs - } - val endTime = when { - finishedRecording -> duration.value - audioPlaying.value -> recordedDurationMs - else -> MAX_VOICE_MILLIS_FOR_SENDING - } - val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp - progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec()) - } - } - Spacer( + val progress = rememberSaveable { mutableStateOf(0) } + val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) } + Box { + Box( Modifier - .requiredWidth(progressBarWidth.value.dp) - .padding(top = 58.dp) - .height(3.dp) - .background(MaterialTheme.colors.primary) - ) - val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage - Row( - Modifier - .height(60.dp) - .fillMaxWidth() - .padding(top = 8.dp) - .background(sentColor), - verticalAlignment = Alignment.CenterVertically + .fillMaxWidth().padding(top = 22.dp) ) { - IconButton( - onClick = { - if (!audioPlaying.value) { - AudioPlayer.play(filePath, audioPlaying, progress, duration, false) - } else { - AudioPlayer.pause(audioPlaying, progress) - } - }, - enabled = finishedRecording) { - Icon( - if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled), - stringResource(R.string.icon_descr_file), - Modifier - .padding(start = 4.dp, end = 2.dp) - .size(36.dp), - tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) - } - val numberInText = remember(recordedDurationMs, progress.value) { - derivedStateOf { - when { - finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000 - finishedRecording -> progress.value / 1000 - else -> recordedDurationMs / 1000 - } - } - } - Text( - durationText(numberInText.value), - fontSize = 18.sp, - color = MaterialTheme.colors.secondary, - ) - Spacer(Modifier.weight(1f)) - if (cancelEnabled) { + val audioPlaying = rememberSaveable { mutableStateOf(false) } + val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage + Row( + Modifier + .height(60.dp) + .fillMaxWidth() + .background(sentColor) + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { IconButton( onClick = { - AudioPlayer.stop(filePath) - cancelVoice() + if (!audioPlaying.value) { + AudioPlayer.play(filePath, audioPlaying, progress, duration, false) + } else { + AudioPlayer.pause(audioPlaying, progress) + } }, - modifier = Modifier.padding(0.dp) + enabled = finishedRecording ) { Icon( - painterResource(R.drawable.ic_close), - contentDescription = stringResource(R.string.icon_descr_cancel_file_preview), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) + if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled), + stringResource(R.string.icon_descr_file), + Modifier + .padding(start = 4.dp, end = 2.dp) + .size(36.dp), + tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary ) } + val numberInText = remember(recordedDurationMs, progress.value) { + derivedStateOf { + when { + finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000 + finishedRecording -> progress.value / 1000 + else -> recordedDurationMs / 1000 + } + } + } + Text( + durationText(numberInText.value), + fontSize = 18.sp, + color = MaterialTheme.colors.secondary, + ) + Spacer(Modifier.weight(1f)) + if (cancelEnabled) { + IconButton( + onClick = { + AudioPlayer.stop(filePath) + cancelVoice() + }, + modifier = Modifier.padding(0.dp) + ) { + Icon( + painterResource(R.drawable.ic_close), + contentDescription = stringResource(R.string.icon_descr_cancel_file_preview), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } } } + + if (finishedRecording) { + FinishedRecordingSlider(progress, duration) + } else { + RecordingInProgressSlider(recordedDurationMs) + } } } +@Composable +fun FinishedRecordingSlider(progress: MutableState, duration: MutableState) { + val dp4 = with(LocalDensity.current) { 4.dp.toPx() } + val dp10 = with(LocalDensity.current) { 10.dp.toPx() } + val primary = MaterialTheme.colors.primary + val inactiveTrackColor = MaterialTheme.colors.primary.mixWith(MaterialTheme.colors.background, 0.24f) + Slider( + progress.value.toFloat(), + onValueChange = { AudioPlayer.seekTo(it.toInt(), progress) }, + Modifier + .fillMaxWidth() + .padding(top = 0.dp) + .drawBehind { + drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4)) + drawRect(inactiveTrackColor, Offset(size.width - dp10, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4)) + }, + colors = SliderDefaults.colors(inactiveTrackColor = inactiveTrackColor), + valueRange = 0f..duration.value.toFloat() + ) +} + +@Composable +fun RecordingInProgressSlider(recordedDurationMs: Int) { + val thumbPosition = remember { Animatable(0f) } + val recDuration = rememberUpdatedState(recordedDurationMs) + LaunchedEffect(Unit) { + snapshotFlow { recDuration.value } + .distinctUntilChanged() + .collect { + thumbPosition.animateTo(it.toFloat(), audioProgressBarAnimationSpec()) + } + } + val dp4 = with(LocalDensity.current) { 4.dp.toPx() } + val dp10 = with(LocalDensity.current) { 10.dp.toPx() } + val primary = MaterialTheme.colors.primary + val inactiveTrackColor = Color.Transparent + Slider( + thumbPosition.value, + onValueChange = {}, + Modifier + .fillMaxWidth() + .padding(top = 0.dp) + .drawBehind { + drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4)) + }, + colors = SliderDefaults.colors(disabledInactiveTrackColor = inactiveTrackColor, disabledActiveTrackColor = primary, thumbColor = Color.Transparent, disabledThumbColor = Color.Transparent), + enabled = false, + valueRange = 0f..MAX_VOICE_MILLIS_FOR_SENDING.toFloat() + ) +} + @Preview @Composable fun PreviewComposeAudioView() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt index d8acbe6c49..0b82f77419 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt @@ -64,7 +64,9 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick) + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick) { + AudioPlayer.seekTo(it, progress) + } } else { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick) val metaReserve = if (edited) @@ -90,18 +92,41 @@ private fun VoiceLayout( timedMessagesTTL: Int?, play: () -> Unit, pause: () -> Unit, - longClick: () -> Unit + longClick: () -> Unit, + onProgressChanged: (Int) -> Unit, + ) { + val colors = SliderDefaults.colors( + inactiveTrackColor = MaterialTheme.colors.primary.mixWith(MaterialTheme.colors.background, 0.24f) + ) + + @Composable + fun RowScope.Slider() { + if (audioPlaying.value || progress.value > 0) { + Slider( + progress.value.toFloat(), + onValueChange = { onProgressChanged(it.toInt()) }, + Modifier.weight(1f).padding(horizontal = DEFAULT_PADDING_HALF / 2), + valueRange = 0f..duration.value.toFloat(), + colors = colors + ) + } + } when { hasText -> { Spacer(Modifier.width(6.dp)) VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) - DurationText(text, PaddingValues(start = 12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + DurationText(text, PaddingValues(start = 12.dp)) + // Will crash currently + Slider() + } } sent -> { Row { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) { Spacer(Modifier.height(56.dp)) + Slider() DurationText(text, PaddingValues(end = 12.dp)) } Column { @@ -120,8 +145,9 @@ private fun VoiceLayout( CIMetaView(ci, timedMessagesTTL) } } - Row(verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { DurationText(text, PaddingValues(start = 12.dp)) + Slider() Spacer(Modifier.height(56.dp)) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt index 7e0e481bc1..5253918979 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt @@ -274,6 +274,11 @@ object AudioPlayer { audioPlaying.value = false } + fun seekTo(ms: Int, pro: MutableState) { + player.seekTo(ms) + pro.value = ms + } + fun duration(filePath: String): Int? { var res: Int? = null kotlin.runCatching { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index 39f7d88932..f001719b1f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -23,9 +23,11 @@ import android.view.View import android.view.ViewTreeObserver import android.view.inputmethod.InputMethodManager import androidx.annotation.StringRes +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.runtime.saveable.Saver import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.font.* @@ -33,6 +35,7 @@ import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.* import androidx.core.content.FileProvider +import androidx.core.graphics.ColorUtils import androidx.core.text.HtmlCompat import chat.simplex.app.* import chat.simplex.app.R @@ -587,6 +590,9 @@ fun Color.darker(factor: Float = 0.1f): Color = fun Color.lighter(factor: Float = 0.1f): Color = Color(min(red * (1 + factor), 1f), min(green * (1 + factor), 1f), min(blue * (1 + factor), 1f), alpha) +fun Color.mixWith(color: Color, alpha: Float): Color = + Color(ColorUtils.blendARGB(color.toArgb(), toArgb(), alpha)) + fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT) fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)