mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 19:10:43 +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,
|
showCursor = false,
|
||||||
playbackProgress = 0f,
|
playbackProgress = 0f,
|
||||||
time = 10.seconds,
|
time = 10.seconds,
|
||||||
|
duration = 10.seconds,
|
||||||
waveform = WaveFormSamples.realisticWaveForm,
|
waveform = WaveFormSamples.realisticWaveForm,
|
||||||
)
|
)
|
||||||
|
|||||||
+3
@@ -142,6 +142,9 @@ fun TimelineItemVoiceView(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(34.dp),
|
.height(34.dp),
|
||||||
|
isPlaying = state.isPlaying,
|
||||||
|
durationMs = state.durationMs,
|
||||||
|
playbackSpeed = state.playbackSpeed,
|
||||||
seekEnabled = !isTalkbackActive(),
|
seekEnabled = !isTalkbackActive(),
|
||||||
onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) },
|
onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) },
|
||||||
)
|
)
|
||||||
|
|||||||
+4
@@ -245,12 +245,16 @@ class DefaultVoiceMessageComposerPresenter(
|
|||||||
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
|
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
|
||||||
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
|
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
|
||||||
|
|
||||||
|
val recorderDuration = (recorderState as VoiceRecorderState.Finished).duration
|
||||||
|
val duration = playerState.durationMs?.milliseconds ?: recorderDuration
|
||||||
|
|
||||||
return VoiceMessageState.Preview(
|
return VoiceMessageState.Preview(
|
||||||
isSending = isSending,
|
isSending = isSending,
|
||||||
isPlaying = playerState.isPlaying,
|
isPlaying = playerState.isPlaying,
|
||||||
showCursor = showCursor,
|
showCursor = showCursor,
|
||||||
playbackProgress = playerState.progress,
|
playbackProgress = playerState.progress,
|
||||||
time = playerTime,
|
time = playerTime,
|
||||||
|
duration = duration,
|
||||||
waveform = waveform,
|
waveform = waveform,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -63,6 +63,7 @@ class VoiceMessageComposerPlayer(
|
|||||||
playState = it.playState,
|
playState = it.playState,
|
||||||
currentPosition = it.currentPosition,
|
currentPosition = it.currentPosition,
|
||||||
progress = calcProgress(it),
|
progress = calcProgress(it),
|
||||||
|
durationMs = if (it.playState != PlayState.Stopped) it.duration else null,
|
||||||
)
|
)
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
@@ -183,17 +184,20 @@ class VoiceMessageComposerPlayer(
|
|||||||
* @property playState Whether this player is currently playing. See [PlayState].
|
* @property playState Whether this player is currently playing. See [PlayState].
|
||||||
* @property currentPosition The elapsed time of this player in milliseconds.
|
* @property currentPosition The elapsed time of this player in milliseconds.
|
||||||
* @property progress The progress of this player between 0 and 1.
|
* @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(
|
data class State(
|
||||||
val playState: PlayState,
|
val playState: PlayState,
|
||||||
val currentPosition: Long,
|
val currentPosition: Long,
|
||||||
val progress: Float,
|
val progress: Float,
|
||||||
|
val durationMs: Long?,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val Initial = State(
|
val Initial = State(
|
||||||
playState = PlayState.Stopped,
|
playState = PlayState.Stopped,
|
||||||
currentPosition = 0L,
|
currentPosition = 0L,
|
||||||
progress = 0f,
|
progress = 0f,
|
||||||
|
durationMs = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-4
@@ -103,6 +103,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val RECORDING_DURATION = 1.seconds
|
private val RECORDING_DURATION = 1.seconds
|
||||||
|
private val PLAYER_DURATION = 10.seconds
|
||||||
private val FIRST_LEVEL_DURATION = 500.milliseconds
|
private val FIRST_LEVEL_DURATION = 500.milliseconds
|
||||||
private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList())
|
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())
|
private val FIRST_RECORDING_STATE = VoiceMessageState.Recording(FIRST_LEVEL_DURATION, listOf(0.1f).toImmutableList())
|
||||||
@@ -266,6 +267,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
|||||||
|
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
|
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
|
||||||
|
assertThat((finalState.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(RECORDING_DURATION)
|
||||||
startRecordResult.assertions().isCalledOnce()
|
startRecordResult.assertions().isCalledOnce()
|
||||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||||
deleteRecordingResult.assertions().isNeverCalled()
|
deleteRecordingResult.assertions().isNeverCalled()
|
||||||
@@ -302,6 +304,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
|||||||
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||||
val finalState = awaitItem().also {
|
val finalState = awaitItem().also {
|
||||||
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
|
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
|
||||||
|
assertThat((it.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(PLAYER_DURATION)
|
||||||
}
|
}
|
||||||
startRecordResult.assertions().isCalledOnce()
|
startRecordResult.assertions().isCalledOnce()
|
||||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||||
@@ -338,17 +341,30 @@ class DefaultVoiceMessageComposerPresenterTest {
|
|||||||
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
|
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
|
||||||
awaitItem().apply {
|
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 {
|
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)))
|
eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f)))
|
||||||
}
|
}
|
||||||
awaitItem().apply {
|
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 {
|
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)
|
testPauseAndDestroy(finalState)
|
||||||
@@ -827,6 +843,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
|||||||
playbackProgress: Float = 0f,
|
playbackProgress: Float = 0f,
|
||||||
isSending: Boolean = false,
|
isSending: Boolean = false,
|
||||||
time: Duration = RECORDING_DURATION,
|
time: Duration = RECORDING_DURATION,
|
||||||
|
duration: Duration = RECORDING_DURATION,
|
||||||
showCursor: Boolean = false,
|
showCursor: Boolean = false,
|
||||||
waveform: List<Float> = voiceRecorder.waveform,
|
waveform: List<Float> = voiceRecorder.waveform,
|
||||||
) = VoiceMessageState.Preview(
|
) = VoiceMessageState.Preview(
|
||||||
@@ -834,6 +851,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
|||||||
playbackProgress = playbackProgress,
|
playbackProgress = playbackProgress,
|
||||||
isSending = isSending,
|
isSending = isSending,
|
||||||
time = time,
|
time = time,
|
||||||
|
duration = duration,
|
||||||
showCursor = showCursor,
|
showCursor = showCursor,
|
||||||
waveform = waveform.toImmutableList(),
|
waveform = waveform.toImmutableList(),
|
||||||
)
|
)
|
||||||
@@ -844,6 +862,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
|||||||
playbackProgress = 0.1f,
|
playbackProgress = 0.1f,
|
||||||
showCursor = true,
|
showCursor = true,
|
||||||
time = RECORDING_DURATION,
|
time = RECORDING_DURATION,
|
||||||
|
duration = PLAYER_DURATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun aPausedState() =
|
private fun aPausedState() =
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -36,5 +42,5 @@ dependencies {
|
|||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiStrings)
|
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
|
package io.element.android.libraries.designsystem.components.media
|
||||||
|
|
||||||
import android.view.MotionEvent
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
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.Canvas
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
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.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
|
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 waveform The waveform to display.
|
||||||
* @param onSeek Callback when the user seeks the waveform. Called with a value between 0 and 1.
|
* @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 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 seekEnabled Whether the user can seek the waveform or not.
|
||||||
* @param brush The brush to use to draw the waveform.
|
* @param brush The brush to use to draw the waveform.
|
||||||
* @param progressBrush The brush to use to draw the progress.
|
* @param progressBrush The brush to use to draw the progress.
|
||||||
@@ -67,6 +78,9 @@ fun WaveformPlaybackView(
|
|||||||
waveform: ImmutableList<Float>,
|
waveform: ImmutableList<Float>,
|
||||||
onSeek: (progress: Float) -> Unit,
|
onSeek: (progress: Float) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
isPlaying: Boolean = false,
|
||||||
|
durationMs: Long = 0L,
|
||||||
|
playbackSpeed: Float = 1f,
|
||||||
seekEnabled: Boolean = true,
|
seekEnabled: Boolean = true,
|
||||||
brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary),
|
brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary),
|
||||||
progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary),
|
progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary),
|
||||||
@@ -75,14 +89,36 @@ fun WaveformPlaybackView(
|
|||||||
linePadding: Dp = 2.dp,
|
linePadding: Dp = 2.dp,
|
||||||
) {
|
) {
|
||||||
val seekProgress = remember { mutableStateOf<Float?>(null) }
|
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 canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) }
|
||||||
var canvasSizePx by remember { mutableStateOf(Size(0f, 0f)) }
|
var canvasSizePx by remember { mutableStateOf(Size(0f, 0f)) }
|
||||||
val progress by remember(playbackProgress, seekProgress.value) {
|
val progressAnimated = remember { Animatable(playbackProgress) }
|
||||||
derivedStateOf {
|
// Bumped after a snap or user seek so AnimateCursorWhilePlaying restarts its tween.
|
||||||
seekProgress.value ?: playbackProgress
|
var seekGeneration by remember { mutableIntStateOf(0) }
|
||||||
}
|
val coroutineScope = rememberCoroutineScope()
|
||||||
}
|
|
||||||
val progressAnimated = animateFloatAsState(targetValue = progress, label = "progressAnimation")
|
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) {
|
val amplitudeDisplayCount by remember(canvasSize, lineWidth, linePadding) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
(canvasSize.width.value / (lineWidth.value + linePadding.value)).toInt()
|
(canvasSize.width.value / (lineWidth.value + linePadding.value)).toInt()
|
||||||
@@ -107,30 +143,22 @@ fun WaveformPlaybackView(
|
|||||||
.let {
|
.let {
|
||||||
if (!seekEnabled) return@let it
|
if (!seekEnabled) return@let it
|
||||||
it.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { e ->
|
it.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { e ->
|
||||||
return@pointerInteropFilter when (e.action) {
|
handleWaveformPointerEvent(
|
||||||
MotionEvent.ACTION_DOWN -> {
|
action = e.action,
|
||||||
if (e.x in 0F..waveformWidthPx) {
|
x = e.x,
|
||||||
requestDisallowInterceptTouchEvent.invoke(true)
|
waveformWidthPx = waveformWidthPx,
|
||||||
seekProgress.value = e.x / waveformWidthPx
|
currentSeekProgress = seekProgress.value,
|
||||||
true
|
onDisallowParentIntercept = { requestDisallowInterceptTouchEvent.invoke(it) },
|
||||||
} else {
|
onUpdateSeekProgress = { seekProgress.value = it },
|
||||||
false
|
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)
|
.then(modifier)
|
||||||
@@ -149,7 +177,7 @@ fun WaveformPlaybackView(
|
|||||||
drawRect(
|
drawRect(
|
||||||
brush = progressBrush,
|
brush = progressBrush,
|
||||||
size = Size(
|
size = Size(
|
||||||
width = progressAnimated.value * waveformWidthPx,
|
width = progress * waveformWidthPx,
|
||||||
height = canvasSizePx.height
|
height = canvasSizePx.height
|
||||||
),
|
),
|
||||||
blendMode = BlendMode.SrcAtop
|
blendMode = BlendMode.SrcAtop
|
||||||
@@ -158,7 +186,7 @@ fun WaveformPlaybackView(
|
|||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
brush = cursorBrush,
|
brush = cursorBrush,
|
||||||
topLeft = Offset(
|
topLeft = Offset(
|
||||||
x = progressAnimated.value * waveformWidthPx,
|
x = progress * waveformWidthPx,
|
||||||
y = 1f
|
y = 1f
|
||||||
),
|
),
|
||||||
size = Size(
|
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
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun WaveformPlaybackViewPreview() = ElementPreview {
|
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 = {
|
onSeek = {
|
||||||
state.eventSink(VoiceMessageEvent.Seek(it))
|
state.eventSink(VoiceMessageEvent.Seek(it))
|
||||||
},
|
},
|
||||||
|
isPlaying = state.isPlaying,
|
||||||
|
durationMs = state.durationMs,
|
||||||
|
playbackSpeed = state.playbackSpeed,
|
||||||
seekEnabled = !isValidating,
|
seekEnabled = !isValidating,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -47,6 +47,7 @@ import androidx.core.view.isVisible
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import androidx.media3.common.PlaybackParameters
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.Timeline
|
import androidx.media3.common.Timeline
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
@@ -124,6 +125,7 @@ private fun ExoPlayerMediaAudioView(
|
|||||||
canMute = false,
|
canMute = false,
|
||||||
isMuted = false,
|
isMuted = false,
|
||||||
seekingToMillis = null,
|
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) {
|
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||||
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
|
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
|
||||||
exoPlayer.duration.takeIf { it >= 0 }
|
exoPlayer.duration.takeIf { it >= 0 }
|
||||||
@@ -276,6 +284,9 @@ private fun ExoPlayerMediaAudioView(
|
|||||||
onSeek = {
|
onSeek = {
|
||||||
exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong())
|
exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong())
|
||||||
},
|
},
|
||||||
|
isPlaying = mediaPlayerControllerState.isPlaying,
|
||||||
|
durationMs = mediaPlayerControllerState.durationInMillis,
|
||||||
|
playbackSpeed = mediaPlayerControllerState.playbackSpeed,
|
||||||
seekEnabled = true,
|
seekEnabled = true,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+3
-1
@@ -120,7 +120,9 @@ class ExoPlayerForPreview(
|
|||||||
override fun seekToNext() {}
|
override fun seekToNext() {}
|
||||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {}
|
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {}
|
||||||
override fun setPlaybackSpeed(speed: Float) {}
|
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 stop() {}
|
||||||
override fun release() {}
|
override fun release() {}
|
||||||
override fun getCurrentTracks(): Tracks = throw NotImplementedError()
|
override fun getCurrentTracks(): Tracks = throw NotImplementedError()
|
||||||
|
|||||||
+1
@@ -19,6 +19,7 @@ data class MediaPlayerControllerState(
|
|||||||
val canMute: Boolean,
|
val canMute: Boolean,
|
||||||
val isMuted: Boolean,
|
val isMuted: Boolean,
|
||||||
val seekingToMillis: Long?,
|
val seekingToMillis: Long?,
|
||||||
|
val playbackSpeed: Float,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* The progress in milliseconds to display. When [seekingToMillis] is non-null (during a seek operation),
|
* 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,
|
canMute: Boolean = true,
|
||||||
isMuted: Boolean = false,
|
isMuted: Boolean = false,
|
||||||
seekingToMillis: Long? = null,
|
seekingToMillis: Long? = null,
|
||||||
|
playbackSpeed: Float = 1f,
|
||||||
) = MediaPlayerControllerState(
|
) = MediaPlayerControllerState(
|
||||||
isVisible = isVisible,
|
isVisible = isVisible,
|
||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
@@ -44,4 +45,5 @@ private fun aMediaPlayerControllerState(
|
|||||||
canMute = canMute,
|
canMute = canMute,
|
||||||
isMuted = isMuted,
|
isMuted = isMuted,
|
||||||
seekingToMillis = seekingToMillis,
|
seekingToMillis = seekingToMillis,
|
||||||
|
playbackSpeed = playbackSpeed,
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -118,6 +118,7 @@ private fun ExoPlayerMediaVideoView(
|
|||||||
canMute = true,
|
canMute = true,
|
||||||
isMuted = false,
|
isMuted = false,
|
||||||
seekingToMillis = null,
|
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,
|
waveform = voiceMessageState.waveform,
|
||||||
playbackProgress = voiceMessageState.playbackProgress,
|
playbackProgress = voiceMessageState.playbackProgress,
|
||||||
time = voiceMessageState.time,
|
time = voiceMessageState.time,
|
||||||
|
duration = voiceMessageState.duration,
|
||||||
onPlayClick = onPlayVoiceMessageClick,
|
onPlayClick = onPlayVoiceMessageClick,
|
||||||
onPauseClick = onPauseVoiceMessageClick,
|
onPauseClick = onPauseVoiceMessageClick,
|
||||||
onSeek = onSeekVoiceMessage,
|
onSeek = onSeekVoiceMessage,
|
||||||
@@ -929,6 +930,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
|||||||
showCursor = false,
|
showCursor = false,
|
||||||
waveform = WaveFormSamples.realisticWaveForm,
|
waveform = WaveFormSamples.realisticWaveForm,
|
||||||
time = 0.seconds,
|
time = 0.seconds,
|
||||||
|
duration = 61.seconds,
|
||||||
playbackProgress = 0.0f,
|
playbackProgress = 0.0f,
|
||||||
),
|
),
|
||||||
VoiceMessageState.Preview(
|
VoiceMessageState.Preview(
|
||||||
@@ -937,6 +939,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
|||||||
showCursor = true,
|
showCursor = true,
|
||||||
waveform = WaveFormSamples.realisticWaveForm,
|
waveform = WaveFormSamples.realisticWaveForm,
|
||||||
time = 3.seconds,
|
time = 3.seconds,
|
||||||
|
duration = 0.seconds,
|
||||||
playbackProgress = 0.2f,
|
playbackProgress = 0.2f,
|
||||||
),
|
),
|
||||||
VoiceMessageState.Preview(
|
VoiceMessageState.Preview(
|
||||||
@@ -945,6 +948,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
|||||||
showCursor = false,
|
showCursor = false,
|
||||||
waveform = WaveFormSamples.realisticWaveForm,
|
waveform = WaveFormSamples.realisticWaveForm,
|
||||||
time = 61.seconds,
|
time = 61.seconds,
|
||||||
|
duration = 61.seconds,
|
||||||
playbackProgress = 0.0f,
|
playbackProgress = 0.0f,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -972,6 +976,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview {
|
|||||||
showCursor = false,
|
showCursor = false,
|
||||||
waveform = WaveFormSamples.realisticWaveForm,
|
waveform = WaveFormSamples.realisticWaveForm,
|
||||||
time = 0.seconds,
|
time = 0.seconds,
|
||||||
|
duration = 61.seconds,
|
||||||
playbackProgress = 0.0f
|
playbackProgress = 0.0f
|
||||||
),
|
),
|
||||||
VoiceMessageState.Preview(
|
VoiceMessageState.Preview(
|
||||||
@@ -980,6 +985,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview {
|
|||||||
showCursor = true,
|
showCursor = true,
|
||||||
waveform = WaveFormSamples.realisticWaveForm,
|
waveform = WaveFormSamples.realisticWaveForm,
|
||||||
time = 3.seconds,
|
time = 3.seconds,
|
||||||
|
duration = 0.seconds,
|
||||||
playbackProgress = 0.2f
|
playbackProgress = 0.2f
|
||||||
),
|
),
|
||||||
VoiceMessageState.Preview(
|
VoiceMessageState.Preview(
|
||||||
@@ -988,6 +994,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview {
|
|||||||
showCursor = false,
|
showCursor = false,
|
||||||
waveform = WaveFormSamples.realisticWaveForm,
|
waveform = WaveFormSamples.realisticWaveForm,
|
||||||
time = 61.seconds,
|
time = 61.seconds,
|
||||||
|
duration = 61.seconds,
|
||||||
playbackProgress = 0.0f
|
playbackProgress = 0.0f
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+9
@@ -50,6 +50,7 @@ internal fun VoiceMessagePreview(
|
|||||||
showCursor: Boolean,
|
showCursor: Boolean,
|
||||||
waveform: ImmutableList<Float>,
|
waveform: ImmutableList<Float>,
|
||||||
time: Duration,
|
time: Duration,
|
||||||
|
duration: Duration,
|
||||||
onPlayClick: () -> Unit,
|
onPlayClick: () -> Unit,
|
||||||
onPauseClick: () -> Unit,
|
onPauseClick: () -> Unit,
|
||||||
onSeek: (Float) -> Unit,
|
onSeek: (Float) -> Unit,
|
||||||
@@ -88,6 +89,8 @@ internal fun VoiceMessagePreview(
|
|||||||
playbackProgress = playbackProgress,
|
playbackProgress = playbackProgress,
|
||||||
showCursor = showCursor,
|
showCursor = showCursor,
|
||||||
waveform = waveform,
|
waveform = waveform,
|
||||||
|
isPlaying = isPlaying,
|
||||||
|
durationMs = duration.inWholeMilliseconds,
|
||||||
seekEnabled = true,
|
seekEnabled = true,
|
||||||
onSeek = onSeek,
|
onSeek = onSeek,
|
||||||
)
|
)
|
||||||
@@ -151,6 +154,8 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
|
|||||||
isInteractive = true,
|
isInteractive = true,
|
||||||
isPlaying = true,
|
isPlaying = true,
|
||||||
time = 2.seconds,
|
time = 2.seconds,
|
||||||
|
// Keep previews static: a non-zero duration would start a long linear tween.
|
||||||
|
duration = 0.seconds,
|
||||||
playbackProgress = 0.2f,
|
playbackProgress = 0.2f,
|
||||||
showCursor = true,
|
showCursor = true,
|
||||||
waveform = WaveFormSamples.longRealisticWaveForm,
|
waveform = WaveFormSamples.longRealisticWaveForm,
|
||||||
@@ -159,6 +164,7 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
|
|||||||
isInteractive = true,
|
isInteractive = true,
|
||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
time = 0.seconds,
|
time = 0.seconds,
|
||||||
|
duration = 10.seconds,
|
||||||
playbackProgress = 0.0f,
|
playbackProgress = 0.0f,
|
||||||
showCursor = true,
|
showCursor = true,
|
||||||
waveform = WaveFormSamples.longRealisticWaveForm,
|
waveform = WaveFormSamples.longRealisticWaveForm,
|
||||||
@@ -167,6 +173,7 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
|
|||||||
isInteractive = false,
|
isInteractive = false,
|
||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
time = 789.seconds,
|
time = 789.seconds,
|
||||||
|
duration = 789.seconds,
|
||||||
playbackProgress = 0.0f,
|
playbackProgress = 0.0f,
|
||||||
showCursor = false,
|
showCursor = false,
|
||||||
waveform = WaveFormSamples.longRealisticWaveForm,
|
waveform = WaveFormSamples.longRealisticWaveForm,
|
||||||
@@ -179,6 +186,7 @@ private fun AVoiceMessagePreview(
|
|||||||
isInteractive: Boolean,
|
isInteractive: Boolean,
|
||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
time: Duration,
|
time: Duration,
|
||||||
|
duration: Duration,
|
||||||
playbackProgress: Float,
|
playbackProgress: Float,
|
||||||
showCursor: Boolean,
|
showCursor: Boolean,
|
||||||
waveform: ImmutableList<Float>,
|
waveform: ImmutableList<Float>,
|
||||||
@@ -187,6 +195,7 @@ private fun AVoiceMessagePreview(
|
|||||||
isInteractive = isInteractive,
|
isInteractive = isInteractive,
|
||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
time = time,
|
time = time,
|
||||||
|
duration = duration,
|
||||||
playbackProgress = playbackProgress,
|
playbackProgress = playbackProgress,
|
||||||
showCursor = showCursor,
|
showCursor = showCursor,
|
||||||
waveform = waveform,
|
waveform = waveform,
|
||||||
|
|||||||
+1
@@ -22,6 +22,7 @@ sealed interface VoiceMessageState {
|
|||||||
val showCursor: Boolean,
|
val showCursor: Boolean,
|
||||||
val playbackProgress: Float,
|
val playbackProgress: Float,
|
||||||
val time: Duration,
|
val time: Duration,
|
||||||
|
val duration: Duration,
|
||||||
// Values are between 0 and 1
|
// Values are between 0 and 1
|
||||||
val waveform: ImmutableList<Float>,
|
val waveform: ImmutableList<Float>,
|
||||||
) : VoiceMessageState
|
) : VoiceMessageState
|
||||||
|
|||||||
+2
@@ -13,7 +13,9 @@ data class VoiceMessageState(
|
|||||||
val progress: Float,
|
val progress: Float,
|
||||||
val time: String,
|
val time: String,
|
||||||
val showCursor: Boolean,
|
val showCursor: Boolean,
|
||||||
|
val isPlaying: Boolean,
|
||||||
val playbackSpeed: Float,
|
val playbackSpeed: Float,
|
||||||
|
val durationMs: Long,
|
||||||
val eventSink: (event: VoiceMessageEvent) -> Unit,
|
val eventSink: (event: VoiceMessageEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
enum class ButtonType {
|
enum class ButtonType {
|
||||||
|
|||||||
+6
@@ -34,6 +34,8 @@ open class VoiceMessageStatePreviewParam : PreviewParameterProvider<VoiceMessage
|
|||||||
progress = 0.2f,
|
progress = 0.2f,
|
||||||
time = "10:00",
|
time = "10:00",
|
||||||
showCursor = true,
|
showCursor = true,
|
||||||
|
isPlaying = true,
|
||||||
|
durationMs = 0,
|
||||||
),
|
),
|
||||||
aVoiceMessageState(
|
aVoiceMessageState(
|
||||||
VoiceMessageState.ButtonType.Disabled,
|
VoiceMessageState.ButtonType.Disabled,
|
||||||
@@ -48,12 +50,16 @@ fun aVoiceMessageState(
|
|||||||
progress: Float = 0f,
|
progress: Float = 0f,
|
||||||
time: String = "1:00",
|
time: String = "1:00",
|
||||||
showCursor: Boolean = false,
|
showCursor: Boolean = false,
|
||||||
|
isPlaying: Boolean = false,
|
||||||
playbackSpeed: Float = 1.0f,
|
playbackSpeed: Float = 1.0f,
|
||||||
|
durationMs: Long = 60_000,
|
||||||
) = VoiceMessageState(
|
) = VoiceMessageState(
|
||||||
buttonType = buttonType,
|
buttonType = buttonType,
|
||||||
progress = progress,
|
progress = progress,
|
||||||
time = time,
|
time = time,
|
||||||
showCursor = showCursor,
|
showCursor = showCursor,
|
||||||
|
isPlaying = isPlaying,
|
||||||
playbackSpeed = playbackSpeed,
|
playbackSpeed = playbackSpeed,
|
||||||
|
durationMs = durationMs,
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
)
|
)
|
||||||
|
|||||||
+2
@@ -135,7 +135,9 @@ class VoiceMessagePresenter(
|
|||||||
progress = progress,
|
progress = progress,
|
||||||
time = time,
|
time = time,
|
||||||
showCursor = showCursor,
|
showCursor = showCursor,
|
||||||
|
isPlaying = playerState.isPlaying,
|
||||||
playbackSpeed = VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex],
|
playbackSpeed = VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex],
|
||||||
|
durationMs = duration,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -34,6 +34,8 @@ class VoiceMessagePresenterTest {
|
|||||||
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("1:01")
|
assertThat(it.time).isEqualTo("1:01")
|
||||||
|
assertThat(it.isPlaying).isFalse()
|
||||||
|
assertThat(it.durationMs).isEqualTo(61_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user