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

* Add linear animation to voice message playback progress

Instead of animating the small `progress` value changes, calculate an animation for the range `maxProgress - progress`, with a linear interpolator so it's completely smooth. Previously, the progress was updated in 'steps' that were quite noticeable.
This commit is contained in:
bxdxnn
2026-09-18 10:39:15 +03:00
committed by GitHub
parent c0bb68c872
commit 5cac4767f1
26 changed files with 1120 additions and 39 deletions
@@ -153,6 +153,9 @@ private fun VoiceInfoRow(
onSeek = {
state.eventSink(VoiceMessageEvent.Seek(it))
},
isPlaying = state.isPlaying,
durationMs = state.durationMs,
playbackSpeed = state.playbackSpeed,
seekEnabled = !isValidating,
)
}
@@ -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 {
@@ -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()
@@ -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),
@@ -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,
)
@@ -118,6 +118,7 @@ private fun ExoPlayerMediaVideoView(
canMute = true,
isMuted = false,
seekingToMillis = null,
playbackSpeed = exoPlayer.playbackParameters.speed,
)
)
}
@@ -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,
)
}
}
}
}