mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+72
@@ -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
|
||||
}
|
||||
+159
@@ -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)
|
||||
}
|
||||
+158
-33
@@ -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 {
|
||||
|
||||
+134
@@ -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?,
|
||||
)
|
||||
}
|
||||
+280
@@ -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)
|
||||
}
|
||||
}
|
||||
+127
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user