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:
+1
@@ -39,5 +39,6 @@ fun aVoiceMessagePreviewState() = VoiceMessageState.Preview(
|
||||
showCursor = false,
|
||||
playbackProgress = 0f,
|
||||
time = 10.seconds,
|
||||
duration = 10.seconds,
|
||||
waveform = WaveFormSamples.realisticWaveForm,
|
||||
)
|
||||
|
||||
+3
@@ -142,6 +142,9 @@ fun TimelineItemVoiceView(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(34.dp),
|
||||
isPlaying = state.isPlaying,
|
||||
durationMs = state.durationMs,
|
||||
playbackSpeed = state.playbackSpeed,
|
||||
seekEnabled = !isTalkbackActive(),
|
||||
onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) },
|
||||
)
|
||||
|
||||
+4
@@ -245,12 +245,16 @@ class DefaultVoiceMessageComposerPresenter(
|
||||
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
|
||||
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
|
||||
|
||||
val recorderDuration = (recorderState as VoiceRecorderState.Finished).duration
|
||||
val duration = playerState.durationMs?.milliseconds ?: recorderDuration
|
||||
|
||||
return VoiceMessageState.Preview(
|
||||
isSending = isSending,
|
||||
isPlaying = playerState.isPlaying,
|
||||
showCursor = showCursor,
|
||||
playbackProgress = playerState.progress,
|
||||
time = playerTime,
|
||||
duration = duration,
|
||||
waveform = waveform,
|
||||
)
|
||||
}
|
||||
|
||||
+4
@@ -63,6 +63,7 @@ class VoiceMessageComposerPlayer(
|
||||
playState = it.playState,
|
||||
currentPosition = it.currentPosition,
|
||||
progress = calcProgress(it),
|
||||
durationMs = if (it.playState != PlayState.Stopped) it.duration else null,
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
@@ -183,17 +184,20 @@ class VoiceMessageComposerPlayer(
|
||||
* @property playState Whether this player is currently playing. See [PlayState].
|
||||
* @property currentPosition The elapsed time of this player in milliseconds.
|
||||
* @property progress The progress of this player between 0 and 1.
|
||||
* @property durationMs The player-reported duration in milliseconds, when known and the player is not stopped.
|
||||
*/
|
||||
data class State(
|
||||
val playState: PlayState,
|
||||
val currentPosition: Long,
|
||||
val progress: Float,
|
||||
val durationMs: Long?,
|
||||
) {
|
||||
companion object {
|
||||
val Initial = State(
|
||||
playState = PlayState.Stopped,
|
||||
currentPosition = 0L,
|
||||
progress = 0f,
|
||||
durationMs = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+23
-4
@@ -103,6 +103,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
|
||||
companion object {
|
||||
private val RECORDING_DURATION = 1.seconds
|
||||
private val PLAYER_DURATION = 10.seconds
|
||||
private val FIRST_LEVEL_DURATION = 500.milliseconds
|
||||
private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList())
|
||||
private val FIRST_RECORDING_STATE = VoiceMessageState.Recording(FIRST_LEVEL_DURATION, listOf(0.1f).toImmutableList())
|
||||
@@ -266,6 +267,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
|
||||
assertThat((finalState.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(RECORDING_DURATION)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
deleteRecordingResult.assertions().isNeverCalled()
|
||||
@@ -302,6 +304,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||
val finalState = awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
|
||||
assertThat((it.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(PLAYER_DURATION)
|
||||
}
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
@@ -338,17 +341,30 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true))
|
||||
assertThat(voiceMessageState).isEqualTo(
|
||||
aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true)
|
||||
)
|
||||
}
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true))
|
||||
assertThat(voiceMessageState).isEqualTo(
|
||||
aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true, duration = PLAYER_DURATION)
|
||||
)
|
||||
}
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(
|
||||
aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true, duration = PLAYER_DURATION)
|
||||
)
|
||||
eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f)))
|
||||
}
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true))
|
||||
assertThat(voiceMessageState).isEqualTo(
|
||||
aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true, duration = PLAYER_DURATION)
|
||||
)
|
||||
}
|
||||
val finalState = awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true))
|
||||
assertThat(voiceMessageState).isEqualTo(
|
||||
aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true, duration = PLAYER_DURATION)
|
||||
)
|
||||
}
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
@@ -827,6 +843,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
playbackProgress: Float = 0f,
|
||||
isSending: Boolean = false,
|
||||
time: Duration = RECORDING_DURATION,
|
||||
duration: Duration = RECORDING_DURATION,
|
||||
showCursor: Boolean = false,
|
||||
waveform: List<Float> = voiceRecorder.waveform,
|
||||
) = VoiceMessageState.Preview(
|
||||
@@ -834,6 +851,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
playbackProgress = playbackProgress,
|
||||
isSending = isSending,
|
||||
time = time,
|
||||
duration = duration,
|
||||
showCursor = showCursor,
|
||||
waveform = waveform.toImmutableList(),
|
||||
)
|
||||
@@ -844,6 +862,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
playbackProgress = 0.1f,
|
||||
showCursor = true,
|
||||
time = RECORDING_DURATION,
|
||||
duration = PLAYER_DURATION,
|
||||
)
|
||||
|
||||
private fun aPausedState() =
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -153,6 +153,9 @@ private fun VoiceInfoRow(
|
||||
onSeek = {
|
||||
state.eventSink(VoiceMessageEvent.Seek(it))
|
||||
},
|
||||
isPlaying = state.isPlaying,
|
||||
durationMs = state.durationMs,
|
||||
playbackSpeed = state.playbackSpeed,
|
||||
seekEnabled = !isValidating,
|
||||
)
|
||||
}
|
||||
|
||||
+11
@@ -47,6 +47,7 @@ import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
@@ -124,6 +125,7 @@ private fun ExoPlayerMediaAudioView(
|
||||
canMute = false,
|
||||
isMuted = false,
|
||||
seekingToMillis = null,
|
||||
playbackSpeed = exoPlayer.playbackParameters.speed,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -156,6 +158,12 @@ private fun ExoPlayerMediaAudioView(
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) {
|
||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||
playbackSpeed = playbackParameters.speed,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
|
||||
exoPlayer.duration.takeIf { it >= 0 }
|
||||
@@ -276,6 +284,9 @@ private fun ExoPlayerMediaAudioView(
|
||||
onSeek = {
|
||||
exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong())
|
||||
},
|
||||
isPlaying = mediaPlayerControllerState.isPlaying,
|
||||
durationMs = mediaPlayerControllerState.durationInMillis,
|
||||
playbackSpeed = mediaPlayerControllerState.playbackSpeed,
|
||||
seekEnabled = true,
|
||||
)
|
||||
} else {
|
||||
|
||||
+3
-1
@@ -120,7 +120,9 @@ class ExoPlayerForPreview(
|
||||
override fun seekToNext() {}
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {}
|
||||
override fun setPlaybackSpeed(speed: Float) {}
|
||||
override fun getPlaybackParameters(): PlaybackParameters = throw NotImplementedError()
|
||||
|
||||
// Cannot throw, this method is invoked when recording Paparazzi screenshots.
|
||||
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
|
||||
override fun stop() {}
|
||||
override fun release() {}
|
||||
override fun getCurrentTracks(): Tracks = throw NotImplementedError()
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ data class MediaPlayerControllerState(
|
||||
val canMute: Boolean,
|
||||
val isMuted: Boolean,
|
||||
val seekingToMillis: Long?,
|
||||
val playbackSpeed: Float,
|
||||
) {
|
||||
/**
|
||||
* The progress in milliseconds to display. When [seekingToMillis] is non-null (during a seek operation),
|
||||
|
||||
+2
@@ -35,6 +35,7 @@ private fun aMediaPlayerControllerState(
|
||||
canMute: Boolean = true,
|
||||
isMuted: Boolean = false,
|
||||
seekingToMillis: Long? = null,
|
||||
playbackSpeed: Float = 1f,
|
||||
) = MediaPlayerControllerState(
|
||||
isVisible = isVisible,
|
||||
isPlaying = isPlaying,
|
||||
@@ -44,4 +45,5 @@ private fun aMediaPlayerControllerState(
|
||||
canMute = canMute,
|
||||
isMuted = isMuted,
|
||||
seekingToMillis = seekingToMillis,
|
||||
playbackSpeed = playbackSpeed,
|
||||
)
|
||||
|
||||
+1
@@ -118,6 +118,7 @@ private fun ExoPlayerMediaVideoView(
|
||||
canMute = true,
|
||||
isMuted = false,
|
||||
seekingToMillis = null,
|
||||
playbackSpeed = exoPlayer.playbackParameters.speed,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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.mediaviewer.impl.local.audio
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
|
||||
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import org.junit.Test
|
||||
|
||||
class MediaAudioViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `an audio file without a waveform shows the filename`() = runAndroidComposeUiTest {
|
||||
val info = anAudioMediaInfo(filename = "track.mp3")
|
||||
setMediaAudioView(info = info)
|
||||
onNodeWithText("track.mp3").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio file with a waveform hides the filename in favour of the waveform`() = runAndroidComposeUiTest {
|
||||
val info = anAudioMediaInfo(
|
||||
filename = "voice.ogg",
|
||||
waveForm = WaveFormSamples.realisticWaveForm,
|
||||
)
|
||||
setMediaAudioView(info = info)
|
||||
onNodeWithText("voice.ogg").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio is paused when the view is not displayed`() = runAndroidComposeUiTest {
|
||||
setMediaAudioView(isDisplayed = false)
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio is paused when the host node is not visible`() = runAndroidComposeUiTest {
|
||||
setMediaAudioView(isTargetVisible = false)
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local media uri is prepared on the player`() = runAndroidComposeUiTest {
|
||||
setMediaAudioView(
|
||||
info = anAudioMediaInfo(
|
||||
filename = "voice.ogg",
|
||||
waveForm = WaveFormSamples.realisticWaveForm,
|
||||
),
|
||||
localMediaUri = Uri.parse("file://voice.ogg"),
|
||||
)
|
||||
waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setMediaAudioView(
|
||||
info: MediaInfo = anAudioMediaInfo(),
|
||||
isDisplayed: Boolean = true,
|
||||
isTargetVisible: Boolean = true,
|
||||
localMediaUri: Uri? = null,
|
||||
) {
|
||||
setSafeContent {
|
||||
CompositionLocalProvider(
|
||||
LocalInspectionMode provides true,
|
||||
LocalNodeTargetVisibility provides isTargetVisible,
|
||||
) {
|
||||
ElementTheme {
|
||||
MediaAudioView(
|
||||
localMediaViewState = rememberLocalMediaViewState(),
|
||||
bottomPaddingInPixels = 0,
|
||||
localMedia = localMediaUri?.let { aLocalMedia(uri = it, mediaInfo = info) },
|
||||
info = info,
|
||||
audioFocus = null,
|
||||
isDisplayed = isDisplayed,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -370,6 +370,7 @@ fun TextComposer(
|
||||
waveform = voiceMessageState.waveform,
|
||||
playbackProgress = voiceMessageState.playbackProgress,
|
||||
time = voiceMessageState.time,
|
||||
duration = voiceMessageState.duration,
|
||||
onPlayClick = onPlayVoiceMessageClick,
|
||||
onPauseClick = onPauseVoiceMessageClick,
|
||||
onSeek = onSeekVoiceMessage,
|
||||
@@ -929,6 +930,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
||||
showCursor = false,
|
||||
waveform = WaveFormSamples.realisticWaveForm,
|
||||
time = 0.seconds,
|
||||
duration = 61.seconds,
|
||||
playbackProgress = 0.0f,
|
||||
),
|
||||
VoiceMessageState.Preview(
|
||||
@@ -937,6 +939,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
||||
showCursor = true,
|
||||
waveform = WaveFormSamples.realisticWaveForm,
|
||||
time = 3.seconds,
|
||||
duration = 0.seconds,
|
||||
playbackProgress = 0.2f,
|
||||
),
|
||||
VoiceMessageState.Preview(
|
||||
@@ -945,6 +948,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
||||
showCursor = false,
|
||||
waveform = WaveFormSamples.realisticWaveForm,
|
||||
time = 61.seconds,
|
||||
duration = 61.seconds,
|
||||
playbackProgress = 0.0f,
|
||||
),
|
||||
)
|
||||
@@ -972,6 +976,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview {
|
||||
showCursor = false,
|
||||
waveform = WaveFormSamples.realisticWaveForm,
|
||||
time = 0.seconds,
|
||||
duration = 61.seconds,
|
||||
playbackProgress = 0.0f
|
||||
),
|
||||
VoiceMessageState.Preview(
|
||||
@@ -980,6 +985,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview {
|
||||
showCursor = true,
|
||||
waveform = WaveFormSamples.realisticWaveForm,
|
||||
time = 3.seconds,
|
||||
duration = 0.seconds,
|
||||
playbackProgress = 0.2f
|
||||
),
|
||||
VoiceMessageState.Preview(
|
||||
@@ -988,6 +994,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview {
|
||||
showCursor = false,
|
||||
waveform = WaveFormSamples.realisticWaveForm,
|
||||
time = 61.seconds,
|
||||
duration = 61.seconds,
|
||||
playbackProgress = 0.0f
|
||||
),
|
||||
)
|
||||
|
||||
+9
@@ -50,6 +50,7 @@ internal fun VoiceMessagePreview(
|
||||
showCursor: Boolean,
|
||||
waveform: ImmutableList<Float>,
|
||||
time: Duration,
|
||||
duration: Duration,
|
||||
onPlayClick: () -> Unit,
|
||||
onPauseClick: () -> Unit,
|
||||
onSeek: (Float) -> Unit,
|
||||
@@ -88,6 +89,8 @@ internal fun VoiceMessagePreview(
|
||||
playbackProgress = playbackProgress,
|
||||
showCursor = showCursor,
|
||||
waveform = waveform,
|
||||
isPlaying = isPlaying,
|
||||
durationMs = duration.inWholeMilliseconds,
|
||||
seekEnabled = true,
|
||||
onSeek = onSeek,
|
||||
)
|
||||
@@ -151,6 +154,8 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
|
||||
isInteractive = true,
|
||||
isPlaying = true,
|
||||
time = 2.seconds,
|
||||
// Keep previews static: a non-zero duration would start a long linear tween.
|
||||
duration = 0.seconds,
|
||||
playbackProgress = 0.2f,
|
||||
showCursor = true,
|
||||
waveform = WaveFormSamples.longRealisticWaveForm,
|
||||
@@ -159,6 +164,7 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
|
||||
isInteractive = true,
|
||||
isPlaying = false,
|
||||
time = 0.seconds,
|
||||
duration = 10.seconds,
|
||||
playbackProgress = 0.0f,
|
||||
showCursor = true,
|
||||
waveform = WaveFormSamples.longRealisticWaveForm,
|
||||
@@ -167,6 +173,7 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
|
||||
isInteractive = false,
|
||||
isPlaying = false,
|
||||
time = 789.seconds,
|
||||
duration = 789.seconds,
|
||||
playbackProgress = 0.0f,
|
||||
showCursor = false,
|
||||
waveform = WaveFormSamples.longRealisticWaveForm,
|
||||
@@ -179,6 +186,7 @@ private fun AVoiceMessagePreview(
|
||||
isInteractive: Boolean,
|
||||
isPlaying: Boolean,
|
||||
time: Duration,
|
||||
duration: Duration,
|
||||
playbackProgress: Float,
|
||||
showCursor: Boolean,
|
||||
waveform: ImmutableList<Float>,
|
||||
@@ -187,6 +195,7 @@ private fun AVoiceMessagePreview(
|
||||
isInteractive = isInteractive,
|
||||
isPlaying = isPlaying,
|
||||
time = time,
|
||||
duration = duration,
|
||||
playbackProgress = playbackProgress,
|
||||
showCursor = showCursor,
|
||||
waveform = waveform,
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ sealed interface VoiceMessageState {
|
||||
val showCursor: Boolean,
|
||||
val playbackProgress: Float,
|
||||
val time: Duration,
|
||||
val duration: Duration,
|
||||
// Values are between 0 and 1
|
||||
val waveform: ImmutableList<Float>,
|
||||
) : VoiceMessageState
|
||||
|
||||
+2
@@ -13,7 +13,9 @@ data class VoiceMessageState(
|
||||
val progress: Float,
|
||||
val time: String,
|
||||
val showCursor: Boolean,
|
||||
val isPlaying: Boolean,
|
||||
val playbackSpeed: Float,
|
||||
val durationMs: Long,
|
||||
val eventSink: (event: VoiceMessageEvent) -> Unit,
|
||||
) {
|
||||
enum class ButtonType {
|
||||
|
||||
+6
@@ -34,6 +34,8 @@ open class VoiceMessageStatePreviewParam : PreviewParameterProvider<VoiceMessage
|
||||
progress = 0.2f,
|
||||
time = "10:00",
|
||||
showCursor = true,
|
||||
isPlaying = true,
|
||||
durationMs = 0,
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.ButtonType.Disabled,
|
||||
@@ -48,12 +50,16 @@ fun aVoiceMessageState(
|
||||
progress: Float = 0f,
|
||||
time: String = "1:00",
|
||||
showCursor: Boolean = false,
|
||||
isPlaying: Boolean = false,
|
||||
playbackSpeed: Float = 1.0f,
|
||||
durationMs: Long = 60_000,
|
||||
) = VoiceMessageState(
|
||||
buttonType = buttonType,
|
||||
progress = progress,
|
||||
time = time,
|
||||
showCursor = showCursor,
|
||||
isPlaying = isPlaying,
|
||||
playbackSpeed = playbackSpeed,
|
||||
durationMs = durationMs,
|
||||
eventSink = {},
|
||||
)
|
||||
|
||||
+2
@@ -135,7 +135,9 @@ class VoiceMessagePresenter(
|
||||
progress = progress,
|
||||
time = time,
|
||||
showCursor = showCursor,
|
||||
isPlaying = playerState.isPlaying,
|
||||
playbackSpeed = VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex],
|
||||
durationMs = duration,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -34,6 +34,8 @@ class VoiceMessagePresenterTest {
|
||||
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("1:01")
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.durationMs).isEqualTo(61_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user