Add smooth animation to voice messages play progress (#7698)

* Add linear animation to voice message playback progress

Instead of animating the small `progress` value changes, calculate an animation for the range `maxProgress - progress`, with a linear interpolator so it's completely smooth. Previously, the progress was updated in 'steps' that were quite noticeable.
This commit is contained in:
bxdxnn
2026-09-18 10:39:15 +03:00
committed by GitHub
parent c0bb68c872
commit 5cac4767f1
26 changed files with 1120 additions and 39 deletions
+7 -1
View File
@@ -19,6 +19,12 @@ android {
buildFeatures {
buildConfig = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
dependencies {
@@ -36,5 +42,5 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
testCommonDependencies(libs)
testCommonDependencies(libs, true)
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.media
import android.view.MotionEvent
/**
* Interprets a waveform pointer event and reports whether it was consumed.
*
* @param action [MotionEvent] action, such as [MotionEvent.ACTION_DOWN].
* @param x Horizontal position of the pointer, in pixels, relative to the waveform canvas.
* @param waveformWidthPx Width of the drawn waveform, in pixels. Touches past this are ignored.
* @param currentSeekProgress In-progress drag progress (`0` to `1`), or `null` if no drag is active.
* @param onDisallowParentIntercept Called with `true` when a drag starts, and `false` when it ends,
* so a parent scroller does not steal the gesture.
* @param onUpdateSeekProgress Called with the current drag progress, or `null` when the drag ends.
* @param onCommitSeek Called with the final progress when the user lifts their finger after a drag.
* @return `true` when the event was consumed by the waveform.
*/
internal fun handleWaveformPointerEvent(
action: Int,
x: Float,
waveformWidthPx: Float,
currentSeekProgress: Float?,
onDisallowParentIntercept: (Boolean) -> Unit,
onUpdateSeekProgress: (Float?) -> Unit,
onCommitSeek: (Float) -> Unit,
): Boolean {
return when (action) {
MotionEvent.ACTION_DOWN -> {
val progress = seekProgressFromPointerX(x, waveformWidthPx)
if (progress != null) {
onDisallowParentIntercept(true)
onUpdateSeekProgress(progress)
true
} else {
false
}
}
MotionEvent.ACTION_MOVE -> {
val progress = seekProgressFromPointerX(x, waveformWidthPx)
if (progress != null) {
onUpdateSeekProgress(progress)
}
true
}
MotionEvent.ACTION_UP -> {
onDisallowParentIntercept(false)
currentSeekProgress?.let(onCommitSeek)
onUpdateSeekProgress(null)
true
}
MotionEvent.ACTION_CANCEL -> {
onDisallowParentIntercept(false)
onUpdateSeekProgress(null)
true
}
else -> false
}
}
internal fun seekProgressFromPointerX(x: Float, waveformWidthPx: Float): Float? {
if (x in 0F..waveformWidthPx) {
return x / waveformWidthPx
}
return null
}
@@ -0,0 +1,159 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.media
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* Maximum gap, in wall-clock milliseconds, between two progress values that we still treat as
* "the same place" while audio is playing.
*
* Player progress is sampled on a timer, while the waveform cursor is animated every frame.
* A gap smaller than this is expected sampling lag and should be ignored. A larger gap means a
* real jump (seek acknowledged, pause, or the animation has drifted) and the cursor should snap.
*
* The comparison uses wall-clock time, so faster playback makes the same progress-fraction
* delta count as a smaller time gap.
*/
internal const val PLAYING_SYNC_THRESHOLD_MS = 200L
/**
* When media duration is unknown (`durationMs <= 0`), progress is compared as a 0–1 fraction
* instead of milliseconds. Values this close are treated as equal.
*/
private const val ZERO_DURATION_PROGRESS_EPSILON = 0.01f
/**
* Whether a player progress sample should update the cursor, or be ignored because a user seek
* has not been acknowledged yet.
*
* After a seek, the player can keep emitting samples from the old position. Those are dropped
* until a sample is close enough to [pendingSeek].
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param pendingSeek Target progress of an in-flight user seek, or `null` if none.
* @param durationMs Total media duration in milliseconds. Used to convert a progress delta into time.
* @param playbackSpeed Current playback rate (`1` = realtime). Used when converting the delta to wall-clock time.
* @param syncThresholdMs How close the sample must be to [pendingSeek], in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS].
*/
internal fun shouldApplyPlayerProgress(
playbackProgress: Float,
pendingSeek: Float?,
durationMs: Long,
playbackSpeed: Float,
syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS,
): Boolean {
if (pendingSeek == null) return true
return hasCaughtUpToSeek(
playbackProgress = playbackProgress,
pendingSeek = pendingSeek,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
syncThresholdMs = syncThresholdMs,
)
}
/**
* Whether [playbackProgress] is close enough to [pendingSeek] that the player has likely applied
* the seek.
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param pendingSeek Target progress of the in-flight user seek, from 0 to 1.
* @param durationMs Total media duration in milliseconds. If `<= 0`, [ZERO_DURATION_PROGRESS_EPSILON] is used instead.
* @param playbackSpeed Current playback rate (`1` = realtime).
* @param syncThresholdMs Allowed wall-clock gap in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS].
*/
internal fun hasCaughtUpToSeek(
playbackProgress: Float,
pendingSeek: Float,
durationMs: Long,
playbackSpeed: Float,
syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS,
): Boolean {
val delta = abs(playbackProgress - pendingSeek)
if (durationMs <= 0L) {
return delta <= ZERO_DURATION_PROGRESS_EPSILON
}
val deltaMs = delta * durationMs / playbackSpeed.coerceAtLeast(0.01f)
return deltaMs <= syncThresholdMs
}
/** Player progress at or below this is treated as "at the start" when detecting a loop/restart. */
private const val RESTART_PROGRESS_EPSILON = 0.01f
/** Animated cursor at or above this is treated as "at the end" when detecting a loop/restart. */
private const val RESTART_ANIMATED_PROGRESS = 0.9f
/**
* Whether the animated cursor should jump to the latest player sample instead of continuing its
* linear animation.
*
* Returns `true` when:
* - playback is paused or duration is unknown (there is no animation to follow)
* - the player is ahead of the cursor by more than [syncThresholdMs]
* - playback restarted from the beginning (cursor near the end, player near the start)
*
* Returns `false` when the values already match, or when the player is only slightly ahead
* (or slightly behind) so the in-flight animation can catch up without a visible jump.
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param animatedProgress Current animated cursor progress, from 0 to 1.
* @param isPlaying Whether media is currently playing.
* @param durationMs Total media duration in milliseconds. Used to convert a progress delta into time.
* @param playbackSpeed Current playback rate (`1` = realtime). Used when converting the delta to wall-clock time.
* @param syncThresholdMs Drift that is still ignored while playing, in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS].
*/
internal fun shouldSnapToPlayer(
playbackProgress: Float,
animatedProgress: Float,
isPlaying: Boolean,
durationMs: Long,
playbackSpeed: Float,
syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS,
): Boolean {
val delta = abs(playbackProgress - animatedProgress)
if (delta == 0f) return false
if (!isPlaying || durationMs <= 0L) return true
val deltaMs = delta * durationMs / playbackSpeed.coerceAtLeast(0.01f)
if (playbackProgress < animatedProgress) {
return isPlaybackRestart(playbackProgress, animatedProgress)
}
return deltaMs > syncThresholdMs
}
/**
* Detects a loop: the player has wrapped to the start while the cursor is still near the end.
*/
private fun isPlaybackRestart(
playbackProgress: Float,
animatedProgress: Float,
): Boolean {
return playbackProgress <= RESTART_PROGRESS_EPSILON && animatedProgress >= RESTART_ANIMATED_PROGRESS
}
/**
* Remaining wall-clock milliseconds to tween the cursor from [progress] to the end of the media.
*
* Used to time the linear playback animation between infrequent player samples. A result of `0`
* means the cursor is already at (or past) the end and should snap rather than tween.
*
* @param progress Current cursor progress, from 0 to 1.
* @param durationMs Total media duration in milliseconds.
* @param playbackSpeed Current playback rate (`1` = realtime). Faster speeds shorten the result.
*/
internal fun remainingPlaybackAnimationMs(
progress: Float,
durationMs: Long,
playbackSpeed: Float,
): Int {
val remainingProgress = (1f - progress).coerceAtLeast(0f)
return (remainingProgress * durationMs / playbackSpeed.coerceAtLeast(0.01f))
.roundToInt()
.coerceAtLeast(0)
}
@@ -8,17 +8,24 @@
package io.element.android.libraries.designsystem.components.media
import android.view.MotionEvent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
@@ -41,6 +48,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
@@ -53,6 +61,9 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
* @param waveform The waveform to display.
* @param onSeek Callback when the user seeks the waveform. Called with a value between 0 and 1.
* @param modifier The modifier to be applied to the view.
* @param isPlaying Whether playback is currently in progress. Used to drive a linear progress animation.
* @param durationMs The total duration of the media in milliseconds. Used to time the linear animation.
* @param playbackSpeed The current playback speed. Used to time the linear animation.
* @param seekEnabled Whether the user can seek the waveform or not.
* @param brush The brush to use to draw the waveform.
* @param progressBrush The brush to use to draw the progress.
@@ -67,6 +78,9 @@ fun WaveformPlaybackView(
waveform: ImmutableList<Float>,
onSeek: (progress: Float) -> Unit,
modifier: Modifier = Modifier,
isPlaying: Boolean = false,
durationMs: Long = 0L,
playbackSpeed: Float = 1f,
seekEnabled: Boolean = true,
brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary),
progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary),
@@ -75,14 +89,36 @@ fun WaveformPlaybackView(
linePadding: Dp = 2.dp,
) {
val seekProgress = remember { mutableStateOf<Float?>(null) }
// Last user-seek target, kept until the player reports a nearby sample.
var pendingSeek by remember { mutableStateOf<Float?>(null) }
var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) }
var canvasSizePx by remember { mutableStateOf(Size(0f, 0f)) }
val progress by remember(playbackProgress, seekProgress.value) {
derivedStateOf {
seekProgress.value ?: playbackProgress
}
}
val progressAnimated = animateFloatAsState(targetValue = progress, label = "progressAnimation")
val progressAnimated = remember { Animatable(playbackProgress) }
// Bumped after a snap or user seek so AnimateCursorWhilePlaying restarts its tween.
var seekGeneration by remember { mutableIntStateOf(0) }
val coroutineScope = rememberCoroutineScope()
ApplyPlayerProgressToCursor(
playbackProgress = playbackProgress,
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
pendingSeek = pendingSeek,
seekProgress = seekProgress,
progressAnimated = progressAnimated,
onAcknowledgePendingSeek = { pendingSeek = null },
onCursorSnap = { seekGeneration++ },
)
AnimateCursorWhilePlaying(
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
seekGeneration = seekGeneration,
progressAnimated = progressAnimated,
)
val progress = seekProgress.value ?: progressAnimated.value
val amplitudeDisplayCount by remember(canvasSize, lineWidth, linePadding) {
derivedStateOf {
(canvasSize.width.value / (lineWidth.value + linePadding.value)).toInt()
@@ -107,30 +143,22 @@ fun WaveformPlaybackView(
.let {
if (!seekEnabled) return@let it
it.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { e ->
return@pointerInteropFilter when (e.action) {
MotionEvent.ACTION_DOWN -> {
if (e.x in 0F..waveformWidthPx) {
requestDisallowInterceptTouchEvent.invoke(true)
seekProgress.value = e.x / waveformWidthPx
true
} else {
false
handleWaveformPointerEvent(
action = e.action,
x = e.x,
waveformWidthPx = waveformWidthPx,
currentSeekProgress = seekProgress.value,
onDisallowParentIntercept = { requestDisallowInterceptTouchEvent.invoke(it) },
onUpdateSeekProgress = { seekProgress.value = it },
onCommitSeek = { seek ->
pendingSeek = seek
onSeek(seek)
coroutineScope.launch {
progressAnimated.snapTo(seek)
seekGeneration++
}
}
MotionEvent.ACTION_MOVE -> {
if (e.x in 0F..waveformWidthPx) {
seekProgress.value = e.x / waveformWidthPx
}
true
}
MotionEvent.ACTION_UP -> {
requestDisallowInterceptTouchEvent.invoke(false)
seekProgress.value?.let(onSeek)
seekProgress.value = null
true
}
else -> false
}
},
)
}
}
.then(modifier)
@@ -149,7 +177,7 @@ fun WaveformPlaybackView(
drawRect(
brush = progressBrush,
size = Size(
width = progressAnimated.value * waveformWidthPx,
width = progress * waveformWidthPx,
height = canvasSizePx.height
),
blendMode = BlendMode.SrcAtop
@@ -158,7 +186,7 @@ fun WaveformPlaybackView(
drawRoundRect(
brush = cursorBrush,
topLeft = Offset(
x = progressAnimated.value * waveformWidthPx,
x = progress * waveformWidthPx,
y = 1f
),
size = Size(
@@ -172,6 +200,103 @@ fun WaveformPlaybackView(
}
}
/**
* Applies discrete player progress samples to the animated cursor.
*
* Samples that belong to a position before an in-flight user seek are ignored. Once a sample
* is close enough to [pendingSeek], that seek is considered acknowledged. The cursor then
* either snaps to the sample (pause, large jump, or playback restart) or keeps interpolating.
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param isPlaying Whether media is currently playing.
* @param durationMs Total media duration in milliseconds.
* @param playbackSpeed Current playback rate (`1` = realtime).
* @param pendingSeek Target progress of an in-flight user seek, or `null` if none.
* @param seekProgress Progress while the user is dragging the cursor; non-null means a drag is in progress.
* @param progressAnimated Animated cursor progress.
* @param onAcknowledgePendingSeek Called when a player sample has caught up to [pendingSeek].
* @param onCursorSnap Called after the cursor jumps, so the playing animation can restart from the new position.
*/
@Composable
private fun ApplyPlayerProgressToCursor(
playbackProgress: Float,
isPlaying: Boolean,
durationMs: Long,
playbackSpeed: Float,
pendingSeek: Float?,
seekProgress: State<Float?>,
progressAnimated: Animatable<Float, AnimationVector1D>,
onAcknowledgePendingSeek: () -> Unit,
onCursorSnap: () -> Unit,
) {
val latestOnAcknowledgePendingSeek by rememberUpdatedState(onAcknowledgePendingSeek)
val latestOnCursorSnap by rememberUpdatedState(onCursorSnap)
LaunchedEffect(playbackProgress, isPlaying, durationMs, playbackSpeed, pendingSeek) {
if (seekProgress.value != null) return@LaunchedEffect
if (!shouldApplyPlayerProgress(
playbackProgress = playbackProgress,
pendingSeek = pendingSeek,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
)
) {
return@LaunchedEffect
}
latestOnAcknowledgePendingSeek()
if (shouldSnapToPlayer(
playbackProgress = playbackProgress,
animatedProgress = progressAnimated.value,
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
)
) {
progressAnimated.snapTo(playbackProgress)
latestOnCursorSnap()
}
}
}
/**
* Animates the cursor linearly from its current position to the end of the media.
*
* Player samples are relatively infrequent, so between them we interpolate using the remaining
* duration and [playbackSpeed]. Changing [seekGeneration] (after a snap or user seek) cancels
* the current tween and starts a new one from the updated cursor position.
*
* @param isPlaying Whether media is currently playing. Animation only runs while this is true.
* @param durationMs Total media duration in milliseconds. Must be `> 0` to animate.
* @param playbackSpeed Current playback rate (`1` = realtime). Faster speeds shorten the tween.
* @param seekGeneration Counter incremented when the cursor jumps, used to restart this effect.
* @param progressAnimated Animated cursor progress, tweened toward `1`.
*/
@Composable
private fun AnimateCursorWhilePlaying(
isPlaying: Boolean,
durationMs: Long,
playbackSpeed: Float,
seekGeneration: Int,
progressAnimated: Animatable<Float, AnimationVector1D>,
) {
LaunchedEffect(isPlaying, durationMs, playbackSpeed, seekGeneration) {
if (isPlaying && durationMs > 0L) {
val remainingMs = remainingPlaybackAnimationMs(
progress = progressAnimated.value,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
)
if (remainingMs == 0) {
progressAnimated.snapTo(1f)
} else {
progressAnimated.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = remainingMs, easing = LinearEasing),
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun WaveformPlaybackViewPreview() = ElementPreview {
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.media
import android.view.MotionEvent
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class WaveformPlaybackSeekTest {
@Test
fun `a down event inside the waveform starts a seek`() {
val result = handleEvent(action = MotionEvent.ACTION_DOWN, x = 40f)
assertThat(result.consumed).isTrue()
assertThat(result.disallowParentIntercept).isTrue()
assertThat(result.seekProgress).isEqualTo(0.4f)
assertThat(result.committedSeek).isNull()
}
@Test
fun `a down event outside the waveform is ignored`() {
val result = handleEvent(action = MotionEvent.ACTION_DOWN, x = 150f)
assertThat(result.consumed).isFalse()
assertThat(result.seekProgress).isNull()
assertThat(result.disallowParentIntercept).isNull()
}
@Test
fun `a move event inside the waveform updates the seek progress`() {
val result = handleEvent(action = MotionEvent.ACTION_MOVE, x = 80f)
assertThat(result.consumed).isTrue()
assertThat(result.seekProgress).isEqualTo(0.8f)
assertThat(result.disallowParentIntercept).isNull()
}
@Test
fun `a move event outside the waveform is still consumed without updating progress`() {
val result = handleEvent(action = MotionEvent.ACTION_MOVE, x = 150f)
assertThat(result.consumed).isTrue()
assertThat(result.seekProgress).isNull()
}
@Test
fun `an up event commits the in-progress seek`() {
val result = handleEvent(
action = MotionEvent.ACTION_UP,
currentSeekProgress = 0.6f,
)
assertThat(result.consumed).isTrue()
assertThat(result.disallowParentIntercept).isFalse()
assertThat(result.committedSeek).isEqualTo(0.6f)
assertThat(result.seekProgress).isNull()
assertThat(result.clearedSeekProgress).isTrue()
}
@Test
fun `an up event without an in-progress seek does not commit`() {
val result = handleEvent(action = MotionEvent.ACTION_UP, currentSeekProgress = null)
assertThat(result.consumed).isTrue()
assertThat(result.committedSeek).isNull()
assertThat(result.clearedSeekProgress).isTrue()
}
@Test
fun `a cancel event clears the in-progress seek without committing`() {
val result = handleEvent(
action = MotionEvent.ACTION_CANCEL,
currentSeekProgress = 0.6f,
)
assertThat(result.consumed).isTrue()
assertThat(result.disallowParentIntercept).isFalse()
assertThat(result.committedSeek).isNull()
assertThat(result.clearedSeekProgress).isTrue()
}
@Test
fun `unknown motion actions are ignored`() {
val result = handleEvent(action = MotionEvent.ACTION_POINTER_DOWN)
assertThat(result.consumed).isFalse()
assertThat(result.seekProgress).isNull()
assertThat(result.committedSeek).isNull()
}
@Test
fun `seek progress is null when the pointer is outside the waveform`() {
assertThat(seekProgressFromPointerX(x = -1f, waveformWidthPx = 100f)).isNull()
assertThat(seekProgressFromPointerX(x = 101f, waveformWidthPx = 100f)).isNull()
assertThat(seekProgressFromPointerX(x = 0f, waveformWidthPx = 100f)).isEqualTo(0f)
assertThat(seekProgressFromPointerX(x = 100f, waveformWidthPx = 100f)).isEqualTo(1f)
}
private fun handleEvent(
action: Int,
x: Float = 0f,
waveformWidthPx: Float = 100f,
currentSeekProgress: Float? = null,
): HandleResult {
var disallowParentIntercept: Boolean? = null
var seekProgress: Float? = currentSeekProgress
var clearedSeekProgress = false
var committedSeek: Float? = null
val consumed = handleWaveformPointerEvent(
action = action,
x = x,
waveformWidthPx = waveformWidthPx,
currentSeekProgress = currentSeekProgress,
onDisallowParentIntercept = { disallowParentIntercept = it },
onUpdateSeekProgress = {
seekProgress = it
if (it == null) clearedSeekProgress = true
},
onCommitSeek = { committedSeek = it },
)
return HandleResult(
consumed = consumed,
disallowParentIntercept = disallowParentIntercept,
seekProgress = seekProgress,
clearedSeekProgress = clearedSeekProgress,
committedSeek = committedSeek,
)
}
private data class HandleResult(
val consumed: Boolean,
val disallowParentIntercept: Boolean?,
val seekProgress: Float?,
val clearedSeekProgress: Boolean,
val committedSeek: Float?,
)
}
@@ -0,0 +1,280 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.media
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class WaveformPlaybackSyncTest {
@Test
fun `paused cursor always snaps to a different player sample`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.5f,
animatedProgress = 0.2f,
isPlaying = false,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `paused cursor does not snap when it already matches the player`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.5f,
animatedProgress = 0.5f,
isPlaying = false,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor ignores drift under the sync threshold`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.21f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor snaps when the player is ahead beyond the sync threshold`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.3f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `playing cursor does not snap back when the player lags behind the animation`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0f,
animatedProgress = 0.05f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor snaps back when playback restarts from the beginning`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0f,
animatedProgress = 1f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `stale player samples are ignored until they catch up to a pending seek`() {
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.2f,
pendingSeek = 0.8f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.8f,
pendingSeek = 0.8f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `player samples apply immediately when there is no pending seek`() {
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.2f,
pendingSeek = null,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `playing cursor snaps when duration is unknown`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.5f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 0,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `playing cursor does not snap when drift is exactly the sync threshold`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.22f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor snaps back at the restart progress boundary`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.01f,
animatedProgress = 0.9f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.011f,
animatedProgress = 0.9f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
assertThat(
shouldSnapToPlayer(
playbackProgress = 0f,
animatedProgress = 0.89f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `faster playback treats the same progress delta as a smaller time gap`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.3f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 8f,
)
).isFalse()
}
@Test
fun `a pending seek is acknowledged when duration is unknown and the sample is close`() {
assertThat(
hasCaughtUpToSeek(
playbackProgress = 0.8f,
pendingSeek = 0.8f,
durationMs = 0,
playbackSpeed = 1f,
)
).isTrue()
assertThat(
hasCaughtUpToSeek(
playbackProgress = 0.82f,
pendingSeek = 0.8f,
durationMs = 0,
playbackSpeed = 1f,
)
).isFalse()
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.805f,
pendingSeek = 0.8f,
durationMs = 0,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `a pending seek is acknowledged at the exact sync threshold`() {
assertThat(
hasCaughtUpToSeek(
playbackProgress = 0.82f,
pendingSeek = 0.8f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `zero playback speed still produces a finite remaining animation duration`() {
assertThat(
remainingPlaybackAnimationMs(
progress = 0.5f,
durationMs = 10_000,
playbackSpeed = 0f,
)
).isEqualTo(500_000)
}
@Test
fun `remaining animation duration is zero when the cursor is already at the end`() {
assertThat(
remainingPlaybackAnimationMs(
progress = 1f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isEqualTo(0)
assertThat(
remainingPlaybackAnimationMs(
progress = 1.2f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isEqualTo(0)
}
@Test
fun `remaining animation duration scales with leftover progress and speed`() {
assertThat(
remainingPlaybackAnimationMs(
progress = 0.25f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isEqualTo(7_500)
assertThat(
remainingPlaybackAnimationMs(
progress = 0.25f,
durationMs = 10_000,
playbackSpeed = 2f,
)
).isEqualTo(3_750)
}
}
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.libraries.designsystem.components.media
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.setSafeContent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class WaveformPlaybackViewTest : RobolectricTest() {
@Test
fun `seeking can be disabled`() = runAndroidComposeUiTest {
setWaveformPlaybackView(seekEnabled = false)
waitForIdle()
}
@Test
fun `a paused cursor follows a new player sample`() = runAndroidComposeUiTest {
val playbackProgress = mutableFloatStateOf(0.2f)
setWaveformPlaybackView(playbackProgress = { playbackProgress.value })
playbackProgress.value = 0.8f
waitForIdle()
}
@Test
fun `playing with a positive duration animates the cursor`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
playbackProgress = { 0f },
isPlaying = true,
durationMs = 50L,
)
waitForIdle()
}
@Test
fun `playing at the end of the media snaps the cursor without tweening`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
playbackProgress = { 1f },
isPlaying = true,
durationMs = 1_000L,
)
waitForIdle()
}
@Test
fun `playing with an unknown duration does not animate`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
playbackProgress = { 0.3f },
isPlaying = true,
durationMs = 0L,
)
waitForIdle()
}
@Test
fun `playing cursor keeps interpolating when the player is only slightly ahead`() = runAndroidComposeUiTest {
val playbackProgress = mutableFloatStateOf(0.2f)
setWaveformPlaybackView(
playbackProgress = { playbackProgress.value },
isPlaying = true,
durationMs = 10_000L,
)
playbackProgress.value = 0.21f
waitForIdle()
}
@Test
fun `an empty waveform still renders with a cursor`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
waveform = persistentListOf(),
showCursor = true,
)
waitForIdle()
}
@Test
fun `a short waveform still renders`() = runAndroidComposeUiTest {
setWaveformPlaybackView(waveform = persistentListOf(0.2f, 0.4f, 0.6f))
waitForIdle()
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setWaveformPlaybackView(
playbackProgress: () -> Float = { 0.5f },
showCursor: Boolean = false,
waveform: ImmutableList<Float> = WaveFormSamples.realisticWaveForm,
isPlaying: Boolean = false,
durationMs: Long = 0L,
playbackSpeed: Float = 1f,
seekEnabled: Boolean = true,
) {
setSafeContent {
ElementTheme {
WaveformPlaybackView(
playbackProgress = playbackProgress(),
showCursor = showCursor,
waveform = waveform,
onSeek = {},
modifier = Modifier
.fillMaxWidth()
.height(34.dp),
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
seekEnabled = seekEnabled,
)
}
}
}