diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt index 2cf698dd84..94365ca01d 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt @@ -39,5 +39,6 @@ fun aVoiceMessagePreviewState() = VoiceMessageState.Preview( showCursor = false, playbackProgress = 0f, time = 10.seconds, + duration = 10.seconds, waveform = WaveFormSamples.realisticWaveForm, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index 55bc5866d6..77e29c7a90 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -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)) }, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 96d1787085..ca207757b9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -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, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt index 1e55c45542..81f8c8e7fb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt @@ -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, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index baedfa33b6..e4c3310089 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -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 = 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() = diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 7020b5dba6..7c1f7cd3c4 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -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) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeek.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeek.kt new file mode 100644 index 0000000000..797fee0470 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeek.kt @@ -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 +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt new file mode 100644 index 0000000000..aacc8ccc1c --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt @@ -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) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt index fe9507cb45..3c4f51777c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt @@ -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, 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(null) } + // Last user-seek target, kept until the player reports a nearby sample. + var pendingSeek by remember { mutableStateOf(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, + progressAnimated: Animatable, + 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, +) { + 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 { diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeekTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeekTest.kt new file mode 100644 index 0000000000..fdb1611b75 --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeekTest.kt @@ -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?, + ) +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSyncTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSyncTest.kt new file mode 100644 index 0000000000..3066f179bd --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSyncTest.kt @@ -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) + } +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackViewTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackViewTest.kt new file mode 100644 index 0000000000..5ded20522c --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackViewTest.kt @@ -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.setWaveformPlaybackView( + playbackProgress: () -> Float = { 0.5f }, + showCursor: Boolean = false, + waveform: ImmutableList = 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, + ) + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt index 2ffaed7af0..4ad4d76383 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt @@ -153,6 +153,9 @@ private fun VoiceInfoRow( onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) }, + isPlaying = state.isPlaying, + durationMs = state.durationMs, + playbackSpeed = state.playbackSpeed, seekEnabled = !isValidating, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt index 4ef746cb89..9f68212304 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt @@ -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 { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt index e23cb790db..6b08fb9d04 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt @@ -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() diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt index 79bd7be503..275d001ad9 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt @@ -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), diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt index b229d11283..f60a81d504 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt @@ -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, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt index b5c65fa609..0a06695485 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt @@ -118,6 +118,7 @@ private fun ExoPlayerMediaVideoView( canMute = true, isMuted = false, seekingToMillis = null, + playbackSpeed = exoPlayer.playbackParameters.speed, ) ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioViewTest.kt new file mode 100644 index 0000000000..283fa57f9e --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioViewTest.kt @@ -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.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, + ) + } + } + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index fcb89247b5..020ac6c371 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -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 ), ) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt index 8ca90843a4..6e81eea08f 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt @@ -50,6 +50,7 @@ internal fun VoiceMessagePreview( showCursor: Boolean, waveform: ImmutableList, 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, @@ -187,6 +195,7 @@ private fun AVoiceMessagePreview( isInteractive = isInteractive, isPlaying = isPlaying, time = time, + duration = duration, playbackProgress = playbackProgress, showCursor = showCursor, waveform = waveform, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt index 54fa2bcae7..de8c85d3a6 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt @@ -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, ) : VoiceMessageState diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt index 2faf35f91f..17d2146045 100644 --- a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt @@ -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 { diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStatePreviewParam.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStatePreviewParam.kt index f417947cb6..2f126e1a7f 100644 --- a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStatePreviewParam.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStatePreviewParam.kt @@ -34,6 +34,8 @@ open class VoiceMessageStatePreviewParam : PreviewParameterProvider