diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt index bd823378c47..48c29624ffa 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt @@ -13,9 +13,9 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.database.model.SendStatus import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DrawableUtils -import com.nextcloud.talk.ui.PlaybackSpeed import java.time.LocalDate // immutable class for chat message UI. only val, no vars! diff --git a/app/src/main/java/com/nextcloud/talk/ui/ComposeWaveformSeekbar.kt b/app/src/main/java/com/nextcloud/talk/ui/ComposeWaveformSeekbar.kt new file mode 100644 index 00000000000..fe6a7e21b2a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/ComposeWaveformSeekbar.kt @@ -0,0 +1,95 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +const val WAVEFORM_THUMB_SIZE = 20 +const val WAVEFORM_SIZE = 30 +const val OVERLAP = 0.025 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ComposeWaveformSeekBar(value: Float, onValueChange: (Float) -> Unit, modifier: Modifier, waveData: FloatArray) { + val barWidth = Stroke.DefaultMiter + val thumbSize = WAVEFORM_THUMB_SIZE.dp + val inversePrimary = MaterialTheme.colorScheme.inversePrimary + val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer + + Slider( + value = value, + onValueChange = onValueChange, + track = { + Box( + modifier = modifier + .drawWithCache { + onDrawBehind { + val height = this.size.height + val width = this.size.width + val midpoint = (this.size.height / 2f) + + val barGap = (width - waveData.size * barWidth) / (waveData.size - 1).toFloat() + 1 + for (i in waveData.indices) { + val x: Float = i * (barWidth + barGap) + val y: Float = waveData[i] * height + val isXBeforeThumb = (x / this.size.width) <= value + OVERLAP + + drawLine( + if (isXBeforeThumb) inversePrimary else onPrimaryContainer, + start = Offset(x, midpoint - y), + end = Offset(x, midpoint + y), + strokeWidth = Stroke.DefaultMiter, + cap = StrokeCap.Round + ) + } + } + } + ) + }, + thumb = { + Box( + modifier = Modifier + .size(thumbSize) + .background(inversePrimary, shape = CircleShape) + ) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Preview +fun Preview() { + val waveData = remember { FloatArray(WAVEFORM_SIZE) { (Math.random() % 1).toFloat() } } + + ComposeWaveformSeekBar( + 0f, + {}, + modifier = Modifier + .height(MAX_HEIGHT.dp) + .fillMaxWidth(), + waveData + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt index 308a650f88e..be42be087e8 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt @@ -8,7 +8,7 @@ package com.nextcloud.talk.ui.chat import android.text.format.DateUtils -import android.widget.SeekBar +import androidx.compose.animation.core.animateIntAsState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -19,26 +19,34 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow 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.colorScheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import com.nextcloud.talk.R import com.nextcloud.talk.chat.ui.model.ChatMessageUi import com.nextcloud.talk.chat.ui.model.MessageTypeContent -import com.nextcloud.talk.ui.WaveformSeekBar +import com.nextcloud.talk.ui.ComposeWaveformSeekBar +import com.nextcloud.talk.ui.WAVEFORM_SIZE +import com.nextcloud.talk.utils.AudioUtils private const val SEEKBAR_MAX = 100 +private const val START_WAVE_FORM_HEIGHT = 10 +private const val END_WAVE_FORM_HEIGHT = 56 +@OptIn(ExperimentalMaterial3Api::class) @Suppress("Detekt.LongMethod", "LongParameterList") @Composable fun VoiceMessage( @@ -57,12 +65,23 @@ fun VoiceMessage( forceTimeBelow = true, content = { val inversePrimaryColor = colorScheme.inversePrimary - val inversePrimary = remember(inversePrimaryColor) { inversePrimaryColor.toArgb() } + remember(inversePrimaryColor) { inversePrimaryColor.toArgb() } val onPrimaryContainerColor = colorScheme.onPrimaryContainer - val onPrimaryContainer = remember(onPrimaryContainerColor) { onPrimaryContainerColor.toArgb() } + remember(onPrimaryContainerColor) { onPrimaryContainerColor.toArgb() } val remainingSeconds = (typeContent.durationSeconds - typeContent.playedSeconds).coerceAtLeast(0) - val waveformData = remember(typeContent.waveform) { typeContent.waveform.toFloatArray() } - val lastWaveformData = remember { mutableListOf() } + val waveformData = remember(typeContent.waveform) { + val floatArr = typeContent.waveform.toFloatArray() + if (floatArr.size < WAVEFORM_SIZE) { + FloatArray(WAVEFORM_SIZE) + } else { + AudioUtils.shrinkFloatArray(floatArr, WAVEFORM_SIZE) + } + } + + val animValue by animateIntAsState( + if (waveformData.average() > 0) END_WAVE_FORM_HEIGHT else START_WAVE_FORM_HEIGHT, + label = "size" + ) Column { Row( @@ -84,49 +103,20 @@ fun VoiceMessage( } } - AndroidView( - factory = { ctx -> - WaveformSeekBar(ctx).apply { - max = SEEKBAR_MAX - setWaveData(waveformData) - setColors( - inversePrimary, - onPrimaryContainer - ) - setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged( - seekBar: SeekBar?, - progress: Int, - fromUser: Boolean - ) { - if (fromUser) { - onSeek(message.id, progress) - } - } + var sliderValue by remember { mutableFloatStateOf(0f) } + sliderValue = typeContent.seekbarProgress * 1f / SEEKBAR_MAX - override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit - - override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit - }) - } - }, - update = { seekBar -> - seekBar.max = SEEKBAR_MAX - val waveformChanged = typeContent.waveform != lastWaveformData - if (waveformChanged) { - lastWaveformData.clear() - lastWaveformData.addAll(typeContent.waveform) - seekBar.setWaveData(waveformData) - seekBar.requestLayout() - } - seekBar.setColors(inversePrimary, onPrimaryContainer) - seekBar.progress = typeContent.seekbarProgress - seekBar.isEnabled = !typeContent.isDownloading - seekBar.invalidate() + ComposeWaveformSeekBar( + sliderValue, + { + val progressI = (it * SEEKBAR_MAX).toInt() + onSeek(message.id, progressI) }, modifier = Modifier - .weight(1f) - .height(56.dp) + .height(animValue.dp) + .fillMaxWidth() + .padding(8.dp), // or weight(1f), + waveformData ) TextButton(