diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 7f7824683fa..13f8ceebd70 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1149,6 +1149,16 @@ public final class io/getstream/chat/android/compose/ui/components/audio/Composa public final fun getLambda$655444177$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } +public final class io/getstream/chat/android/compose/ui/components/audio/ComposableSingletons$WaveformSliderKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/audio/ComposableSingletons$WaveformSliderKt; + public fun ()V + public final fun getLambda$-1036173266$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1516446859$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-570083019$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$53626762$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$709997198$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; +} + public final class io/getstream/chat/android/compose/ui/components/audio/WaveformSliderKt { public static final fun StaticWaveformSlider (Ljava/util/List;FZLandroidx/compose/ui/Modifier;IZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/WaveformSlider.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/WaveformSlider.kt index 767d4145ae6..5ab70b6fc60 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/WaveformSlider.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/WaveformSlider.kt @@ -21,17 +21,16 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.progressSemantics import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -41,9 +40,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.tooling.preview.Preview @@ -60,7 +57,6 @@ import kotlin.random.Random * * @param modifier Modifier for styling. * @param waveformData The waveform data to display. - * @param style The style for the waveform slider. * @param visibleBarLimit The number of bars to display at once. * @param adjustBarWidthToLimit Whether to adjust the bar width to fit the visible bar limit. * @param progress The current progress of the waveform. @@ -84,43 +80,45 @@ public fun StaticWaveformSlider( ) { val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val currentProgress by rememberUpdatedState(progress) - var widthPx by remember { mutableFloatStateOf(0f) } - Box( + BoxWithConstraints( modifier = modifier .fillMaxSize() - .progressSemantics(value = progress) - .onSizeChanged { size -> - widthPx = size.width.toFloat() - } - .dragPointerInput( - enabled = isThumbVisible, - onDragStart = { - onDragStart(it.toHorizontalProgress(widthPx, isRtl)) - }, - onDrag = { - onDrag(it.toHorizontalProgress(widthPx, isRtl)) - }, - onDragStop = { - onDragStop(it?.toHorizontalProgress(widthPx, isRtl) ?: currentProgress) - }, - ), + .progressSemantics(value = progress), ) { - // Draw the waveform - WaveformTrack( - modifier = Modifier.fillMaxSize(), - waveformData = waveformData, - visibleBarLimit = visibleBarLimit, - adjustBarWidthToLimit = adjustBarWidthToLimit, - progress = progress, - ) - - // Draw the thumb - if (isThumbVisible) { - WaveformHandle( - isPlaying = isPlaying, + val widthPx = constraints.maxWidth.toFloat() + Box( + modifier = Modifier + .fillMaxSize() + .dragPointerInput( + enabled = isThumbVisible, + onDragStart = { + onDragStart(it.toHorizontalProgress(widthPx, isRtl)) + }, + onDrag = { + onDrag(it.toHorizontalProgress(widthPx, isRtl)) + }, + onDragStop = { + onDragStop(it?.toHorizontalProgress(widthPx, isRtl) ?: currentProgress) + }, + ), + ) { + // Draw the waveform + WaveformTrack( + modifier = Modifier.fillMaxSize(), + waveformData = waveformData, + visibleBarLimit = visibleBarLimit, + adjustBarWidthToLimit = adjustBarWidthToLimit, progress = progress, - parentWidthPx = widthPx, ) + + // Draw the thumb + if (isThumbVisible) { + WaveformHandle( + isPlaying = isPlaying, + progress = progress, + parentWidthPx = widthPx, + ) + } } } } @@ -175,25 +173,24 @@ internal fun WaveformTrack( val totalBars = when (adjustBarWidthToLimit) { true -> visibleBarLimit - else -> when (waveformData.size > visibleBarLimit) { - true -> visibleBarLimit - else -> waveformData.size - } + else -> minOf(waveformData.size, visibleBarLimit) } val visibleBars = minOf(visibleBarLimit, waveformData.size) var barCornerRadius by remember(totalBars) { mutableStateOf(CornerRadius.Zero) } Canvas( modifier = modifier.graphicsLayer { scaleX = if (isRtl) -1f else 1f }, ) { + if (totalBars <= 0) return@Canvas + val canvasW = size.width val canvasH = size.height val spaceWidth = canvasW * BarSpacingRatio val barsWidth = canvasW - spaceWidth val totalSpaces = totalBars - 1 val barWidth = barsWidth / totalBars - val barSpacing = spaceWidth / totalSpaces + val barSpacing = if (totalSpaces > 0) spaceWidth / totalSpaces else 0f - val thresholdX = canvasW * finalProgress * visibleBars / visibleBarLimit + val thresholdX = canvasW * finalProgress * visibleBars / totalBars val halfHeight = canvasH / 2 if (barCornerRadius.x != barWidth || barCornerRadius.y != barWidth) { barCornerRadius = CornerRadius(barWidth, barWidth) @@ -227,61 +224,80 @@ internal fun WaveformTrack( } } -@Preview(showBackground = true) +@Preview(showBackground = true, widthDp = 250) @Composable -internal fun WaveformSeekBarPreview() { - val rand = Random(50) - val waveform = List(50) { rand.nextFloat() } +private fun StaticWaveformSliderAtStartPreview() { + ChatPreviewTheme { StaticWaveformSliderAtStart() } +} - ChatPreviewTheme { - Box( - modifier = Modifier - .width(250.dp) - .height(60.dp) - .background(Color.Cyan), - contentAlignment = Alignment.Center, - ) { - StaticWaveformSlider( - modifier = Modifier - .fillMaxWidth() - .height(36.dp), - waveformData = waveform, - progress = 0.0f, - isPlaying = true, - ) - } - } +@Preview(showBackground = true, widthDp = 250) +@Composable +private fun StaticWaveformSliderMidwayPreview() { + ChatPreviewTheme { StaticWaveformSliderMidway() } } -@Preview(showBackground = true) +@Preview(showBackground = true, widthDp = 250) @Composable -internal fun WaveformTrackPreview() { - val waveform = mutableListOf() - val barCount = 100 - for (i in 0 until barCount) { - waveform.add((i + 1) / barCount.toFloat()) - } +private fun StaticWaveformSliderPausedPreview() { + ChatPreviewTheme { StaticWaveformSliderPaused() } +} - ChatPreviewTheme { - Box( - modifier = Modifier - .width(250.dp) - .height(80.dp) - .background(Color.Black), - contentAlignment = Alignment.Center, - ) { - WaveformTrack( - modifier = Modifier - .background(Color.Red) - .fillMaxWidth() - .height(60.dp), - waveformData = waveform, - progress = 0f, - adjustBarWidthToLimit = true, - visibleBarLimit = 100, - ) - } +@Preview(showBackground = true, widthDp = 250) +@Composable +private fun StaticWaveformSliderWithoutThumbPreview() { + ChatPreviewTheme { StaticWaveformSliderWithoutThumb() } +} + +@Composable +internal fun StaticWaveformSliderAtStart() = StaticWaveformSliderSample(progress = 0f, isPlaying = true) + +@Composable +internal fun StaticWaveformSliderMidway() = StaticWaveformSliderSample(progress = 0.5f, isPlaying = true) + +@Composable +internal fun StaticWaveformSliderPaused() = StaticWaveformSliderSample(progress = 0.3f, isPlaying = false) + +@Composable +internal fun StaticWaveformSliderWithoutThumb() = + StaticWaveformSliderSample(progress = 0.7f, isPlaying = true, isThumbVisible = false) + +@Composable +private fun StaticWaveformSliderSample(progress: Float, isPlaying: Boolean, isThumbVisible: Boolean = true) { + val previewWaveform = remember { + val rand = Random(50) + List(50) { rand.nextFloat() } } + + StaticWaveformSlider( + modifier = Modifier + .fillMaxWidth() + .height(36.dp), + waveformData = previewWaveform, + progress = progress, + isPlaying = isPlaying, + isThumbVisible = isThumbVisible, + ) +} + +@Preview(showBackground = true, widthDp = 250) +@Composable +private fun FullWaveformTrackPreview() { + ChatPreviewTheme { FullWaveformTrack() } +} + +@Suppress("MagicNumber") +@Composable +internal fun FullWaveformTrack() { + val waveform = List(100) { (it + 1) / 100f } + WaveformTrack( + modifier = Modifier + .fillMaxWidth() + .height(36.dp), + waveformData = waveform, + progress = 1f, + adjustBarWidthToLimit = true, + visibleBarLimit = 100, + ) } private fun Offset.toHorizontalProgress(base: Float, isRtl: Boolean): Float { diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/audio/WaveformSliderTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/audio/WaveformSliderTest.kt new file mode 100644 index 00000000000..6630ea16633 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/audio/WaveformSliderTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.components.audio + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import com.android.ide.common.rendering.api.SessionParams +import io.getstream.chat.android.compose.ui.PaparazziComposeTest +import org.junit.Rule +import org.junit.Test + +internal class WaveformSliderTest : PaparazziComposeTest { + + @get:Rule + override val paparazzi = Paparazzi( + deviceConfig = DeviceConfig.PIXEL_2, + renderingMode = SessionParams.RenderingMode.SHRINK, + ) + + @Test + fun `slider playing at start`() { + snapshotWithDarkModeRow { StaticWaveformSliderAtStart() } + } + + @Test + fun `slider playing midway`() { + snapshotWithDarkModeRow { StaticWaveformSliderMidway() } + } + + @Test + fun `slider paused`() { + snapshotWithDarkModeRow { StaticWaveformSliderPaused() } + } + + @Test + fun `slider without thumb`() { + snapshotWithDarkModeRow { StaticWaveformSliderWithoutThumb() } + } + + @Test + fun `full track`() { + snapshotWithDarkModeRow { FullWaveformTrack() } + } + + @Test + fun `slider playing midway rtl`() { + snapshotWithDarkModeRow { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + StaticWaveformSliderMidway() + } + } + } +} diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_full_track.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_full_track.png new file mode 100644 index 00000000000..4a64c726302 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_full_track.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_paused.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_paused.png new file mode 100644 index 00000000000..d9269bb9003 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_paused.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_playing_at_start.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_playing_at_start.png new file mode 100644 index 00000000000..9e97eac141f Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_playing_at_start.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_playing_midway.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_playing_midway.png new file mode 100644 index 00000000000..21ff07fb6fa Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_playing_midway.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_playing_midway_rtl.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_playing_midway_rtl.png new file mode 100644 index 00000000000..6b4c4e48c51 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_playing_midway_rtl.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_without_thumb.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_without_thumb.png new file mode 100644 index 00000000000..c9492e7c7c4 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.audio_WaveformSliderTest_slider_without_thumb.png differ