android: voice message slider (#2421)

* android: voice message slider

* no ring line

* different color of sliders

* Revert "no ring line"

This reverts commit aaec2a1136.

* size of preview and non-crashed slider

* padding

* size of voice slider

* padding

* background width

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2023-05-10 15:59:59 +03:00
committed by GitHub
parent d86cca2e26
commit 2945f688fa
6 changed files with 199 additions and 96 deletions

View File

@@ -351,7 +351,10 @@ fun ChatLayout(
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
) { contentPadding ->
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
BoxWithConstraints(Modifier
.fillMaxHeight()
.padding(contentPadding)
) {
ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
@@ -615,12 +618,13 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
}
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
val member = cItem.chatDir.groupMember
val showMember = showMemberImage(member, prevItem)
Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
Row(Modifier.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp).then(swipeableModifier)) {
if (showMember) {
val contactId = member.memberContactId
if (contactId == null) {
@@ -643,7 +647,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
}
} else {
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
Box(Modifier.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
}
}
@@ -651,8 +655,8 @@ fun BoxWithConstraintsScope.ChatItemsList(
val sent = cItem.chatDir.sent
Box(
Modifier.padding(
start = if (sent) 76.dp else 12.dp,
end = if (sent) 12.dp else 76.dp,
start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp,
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)

View File

@@ -686,11 +686,22 @@ fun ComposeView(
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
Column {
contextItemView()
when {
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
else -> previewView()
if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) {
contextItemView()
when {
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
else -> previewView()
}
} else {
Box {
Box(Modifier.align(Alignment.TopStart).padding(bottom = 69.dp)) {
contextItemView()
}
Box(Modifier.align(Alignment.BottomStart)) {
previewView()
}
}
}
Row(
modifier = Modifier.padding(end = 8.dp),

View File

@@ -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) }
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
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) }
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(sentColor, progress, duration)
} else {
RecordingInProgressSlider(recordedDurationMs)
}
}
}
@Composable
fun FinishedRecordingSlider(backgroundColor: Color, progress: MutableState<Int>, duration: MutableState<Int>) {
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(backgroundColor, 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() {

View File

@@ -15,14 +15,14 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.flow.distinctUntilChanged
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
@@ -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,52 @@ 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() {
var movedManuallyTo by rememberSaveable(file.fileId) { mutableStateOf(-1) }
if (audioPlaying.value || progress.value > 0 || movedManuallyTo == progress.value) {
val width = with(LocalDensity.current) { LocalView.current.width.toDp() }
Slider(
progress.value.toFloat(),
onValueChange = {
onProgressChanged(it.toInt())
movedManuallyTo = it.toInt()
},
Modifier.size(width, 48.dp).weight(1f).padding(horizontal = DEFAULT_PADDING_HALF / 2),
valueRange = 0f..duration.value.toFloat(),
colors = colors
)
LaunchedEffect(Unit) {
snapshotFlow { audioPlaying.value }
.distinctUntilChanged()
.collect {
movedManuallyTo = -1
}
}
}
}
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))
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 +156,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))
}
}

View File

@@ -274,6 +274,11 @@ object AudioPlayer {
audioPlaying.value = false
}
fun seekTo(ms: Int, pro: MutableState<Int>) {
player.seekTo(ms)
pro.value = ms
}
fun duration(filePath: String): Int? {
var res: Int? = null
kotlin.runCatching {

View File

@@ -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)