From 799ed820e39fc18fe4c1c3bea6a23b1392488bad Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Tue, 26 Aug 2025 09:36:55 +0300 Subject: [PATCH] feat: audio message preview --- .../src/main/res/drawable/round_pause_24.xml | 5 + .../presentation/attachments/Attachments.kt | 32 ++++- .../presentation/attachments/AudioMessage.kt | 110 ++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 core/ui/src/main/res/drawable/round_pause_24.xml create mode 100644 feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/AudioMessage.kt diff --git a/core/ui/src/main/res/drawable/round_pause_24.xml b/core/ui/src/main/res/drawable/round_pause_24.xml new file mode 100644 index 00000000..36ff7f73 --- /dev/null +++ b/core/ui/src/main/res/drawable/round_pause_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt index 090cae11..451aacc5 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt @@ -35,6 +35,7 @@ import coil.compose.AsyncImage import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAudioDomain +import dev.meloda.fast.model.api.domain.VkAudioMessageDomain import dev.meloda.fast.model.api.domain.VkFileDomain import dev.meloda.fast.model.api.domain.VkLinkDomain import dev.meloda.fast.model.api.domain.VkPhotoDomain @@ -129,7 +130,7 @@ fun Attachments( } val imageSize by animateDpAsState( - targetValue = if (isPlaying) 320.dp else 192.dp, + targetValue = if (isPlaying) 256.dp else 192.dp, label = "video message preview animation", animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, @@ -158,6 +159,35 @@ fun Attachments( } } + AttachmentType.AUDIO_MESSAGE -> { + fun downsampleWaveform(wave: List): List { + val result = mutableListOf() + for (i in wave.indices step 2) { + val first = wave[i] + val second = wave.getOrNull(i + 1) ?: first + result.add((first + second) / 2) + } + return result + } + + fun amplifyWaveform(wave: List, originalMax: Int): List { + val newMax = wave.maxOrNull() ?: 1 + val factor = if (newMax == 0) 1.0 else originalMax.toDouble() / newMax + return wave.map { (it * factor).toInt() } + } + + val audioMessage = attachment as VkAudioMessageDomain + AudioMessage( + waveform = audioMessage.waveform + .let(::downsampleWaveform) + .let(::downsampleWaveform) + .let { amplifyWaveform(it, audioMessage.waveform.max()) } + .map(::WaveForm), + isPlaying = false, + onPlayClick = {} + ) + } + else -> { Text( text = buildAnnotatedString { diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/AudioMessage.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/AudioMessage.kt new file mode 100644 index 00000000..31f70ac2 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/AudioMessage.kt @@ -0,0 +1,110 @@ +package dev.meloda.fast.messageshistory.presentation.attachments + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.components.IconButton +import kotlin.collections.forEachIndexed + +@Composable +fun AudioMessage( + waveform: List, + isPlaying: Boolean, + onPlayClick: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(25), + backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer, + strokeWidth: Dp = 2.dp, + spacer: Dp = 1.dp, + strokeColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, +) { + val density = LocalDensity.current + val requiredWidthDp = waveform.size * (2.dp + 1.dp) + 1.dp + + Box( + modifier = modifier + .clip(shape) + .background(backgroundColor) + .height(50.dp) + .widthIn(min = requiredWidthDp + 20.dp + 36.dp + 2.dp) + .padding(10.dp) + ) { + Canvas( + modifier = Modifier + .padding(start = 36.dp) + .padding(start = 2.dp) + .matchParentSize() + .align(Alignment.Center) + ) { + val width = size.width + val height = size.height + + waveform.forEachIndexed { index, form -> + val start = with(density) { + Offset( + x = index * (strokeWidth.toPx() + spacer.toPx()) + spacer.toPx(), + y = height / 2 + form.value * 0.5f + ) + } + val end = Offset(x = start.x, y = height / 2 - form.value * 0.5f) + + drawLine( + color = strokeColor, + start = start, + end = end, + strokeWidth = with(density) { strokeWidth.toPx() }, + cap = StrokeCap.Round + ) + } + } + + IconButton( + onClick = onPlayClick, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.inversePrimary) + .size(30.dp, 30.dp) + .align(Alignment.CenterStart) + ) { + Icon( + painter = painterResource( + if (isPlaying) R.drawable.round_pause_24 + else R.drawable.round_play_arrow_24, + ), + contentDescription = null + ) + } + } +} + +data class WaveFormState( + val lines: List, + val isPlaying: Boolean = false +) + +data class WaveForm( + val value: Int, + val played: Boolean = false +)