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