mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Add smooth animation to voice messages play progress (#7698)
* Add linear animation to voice message playback progress Instead of animating the small `progress` value changes, calculate an animation for the range `maxProgress - progress`, with a linear interpolator so it's completely smooth. Previously, the progress was updated in 'steps' that were quite noticeable.
This commit is contained in:
+3
@@ -153,6 +153,9 @@ private fun VoiceInfoRow(
|
||||
onSeek = {
|
||||
state.eventSink(VoiceMessageEvent.Seek(it))
|
||||
},
|
||||
isPlaying = state.isPlaying,
|
||||
durationMs = state.durationMs,
|
||||
playbackSpeed = state.playbackSpeed,
|
||||
seekEnabled = !isValidating,
|
||||
)
|
||||
}
|
||||
|
||||
+11
@@ -47,6 +47,7 @@ import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
@@ -124,6 +125,7 @@ private fun ExoPlayerMediaAudioView(
|
||||
canMute = false,
|
||||
isMuted = false,
|
||||
seekingToMillis = null,
|
||||
playbackSpeed = exoPlayer.playbackParameters.speed,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -156,6 +158,12 @@ private fun ExoPlayerMediaAudioView(
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) {
|
||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||
playbackSpeed = playbackParameters.speed,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
|
||||
exoPlayer.duration.takeIf { it >= 0 }
|
||||
@@ -276,6 +284,9 @@ private fun ExoPlayerMediaAudioView(
|
||||
onSeek = {
|
||||
exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong())
|
||||
},
|
||||
isPlaying = mediaPlayerControllerState.isPlaying,
|
||||
durationMs = mediaPlayerControllerState.durationInMillis,
|
||||
playbackSpeed = mediaPlayerControllerState.playbackSpeed,
|
||||
seekEnabled = true,
|
||||
)
|
||||
} else {
|
||||
|
||||
+3
-1
@@ -120,7 +120,9 @@ class ExoPlayerForPreview(
|
||||
override fun seekToNext() {}
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {}
|
||||
override fun setPlaybackSpeed(speed: Float) {}
|
||||
override fun getPlaybackParameters(): PlaybackParameters = throw NotImplementedError()
|
||||
|
||||
// Cannot throw, this method is invoked when recording Paparazzi screenshots.
|
||||
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
|
||||
override fun stop() {}
|
||||
override fun release() {}
|
||||
override fun getCurrentTracks(): Tracks = throw NotImplementedError()
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ data class MediaPlayerControllerState(
|
||||
val canMute: Boolean,
|
||||
val isMuted: Boolean,
|
||||
val seekingToMillis: Long?,
|
||||
val playbackSpeed: Float,
|
||||
) {
|
||||
/**
|
||||
* The progress in milliseconds to display. When [seekingToMillis] is non-null (during a seek operation),
|
||||
|
||||
+2
@@ -35,6 +35,7 @@ private fun aMediaPlayerControllerState(
|
||||
canMute: Boolean = true,
|
||||
isMuted: Boolean = false,
|
||||
seekingToMillis: Long? = null,
|
||||
playbackSpeed: Float = 1f,
|
||||
) = MediaPlayerControllerState(
|
||||
isVisible = isVisible,
|
||||
isPlaying = isPlaying,
|
||||
@@ -44,4 +45,5 @@ private fun aMediaPlayerControllerState(
|
||||
canMute = canMute,
|
||||
isMuted = isMuted,
|
||||
seekingToMillis = seekingToMillis,
|
||||
playbackSpeed = playbackSpeed,
|
||||
)
|
||||
|
||||
+1
@@ -118,6 +118,7 @@ private fun ExoPlayerMediaVideoView(
|
||||
canMute = true,
|
||||
isMuted = false,
|
||||
seekingToMillis = null,
|
||||
playbackSpeed = exoPlayer.playbackParameters.speed,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.local.audio
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
|
||||
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import org.junit.Test
|
||||
|
||||
class MediaAudioViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `an audio file without a waveform shows the filename`() = runAndroidComposeUiTest {
|
||||
val info = anAudioMediaInfo(filename = "track.mp3")
|
||||
setMediaAudioView(info = info)
|
||||
onNodeWithText("track.mp3").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio file with a waveform hides the filename in favour of the waveform`() = runAndroidComposeUiTest {
|
||||
val info = anAudioMediaInfo(
|
||||
filename = "voice.ogg",
|
||||
waveForm = WaveFormSamples.realisticWaveForm,
|
||||
)
|
||||
setMediaAudioView(info = info)
|
||||
onNodeWithText("voice.ogg").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio is paused when the view is not displayed`() = runAndroidComposeUiTest {
|
||||
setMediaAudioView(isDisplayed = false)
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio is paused when the host node is not visible`() = runAndroidComposeUiTest {
|
||||
setMediaAudioView(isTargetVisible = false)
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local media uri is prepared on the player`() = runAndroidComposeUiTest {
|
||||
setMediaAudioView(
|
||||
info = anAudioMediaInfo(
|
||||
filename = "voice.ogg",
|
||||
waveForm = WaveFormSamples.realisticWaveForm,
|
||||
),
|
||||
localMediaUri = Uri.parse("file://voice.ogg"),
|
||||
)
|
||||
waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setMediaAudioView(
|
||||
info: MediaInfo = anAudioMediaInfo(),
|
||||
isDisplayed: Boolean = true,
|
||||
isTargetVisible: Boolean = true,
|
||||
localMediaUri: Uri? = null,
|
||||
) {
|
||||
setSafeContent {
|
||||
CompositionLocalProvider(
|
||||
LocalInspectionMode provides true,
|
||||
LocalNodeTargetVisibility provides isTargetVisible,
|
||||
) {
|
||||
ElementTheme {
|
||||
MediaAudioView(
|
||||
localMediaViewState = rememberLocalMediaViewState(),
|
||||
bottomPaddingInPixels = 0,
|
||||
localMedia = localMediaUri?.let { aLocalMedia(uri = it, mediaInfo = info) },
|
||||
info = info,
|
||||
audioFocus = null,
|
||||
isDisplayed = isDisplayed,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user