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
@@ -39,5 +39,6 @@ fun aVoiceMessagePreviewState() = VoiceMessageState.Preview(
showCursor = false,
playbackProgress = 0f,
time = 10.seconds,
duration = 10.seconds,
waveform = WaveFormSamples.realisticWaveForm,
)
@@ -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)) },
)
@@ -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,
)
}
@@ -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,
)
}
@@ -103,6 +103,7 @@ class DefaultVoiceMessageComposerPresenterTest {
companion object {
private val RECORDING_DURATION = 1.seconds
private val PLAYER_DURATION = 10.seconds
private val FIRST_LEVEL_DURATION = 500.milliseconds
private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList())
private val FIRST_RECORDING_STATE = VoiceMessageState.Recording(FIRST_LEVEL_DURATION, listOf(0.1f).toImmutableList())
@@ -266,6 +267,7 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
assertThat((finalState.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(RECORDING_DURATION)
startRecordResult.assertions().isCalledOnce()
stopRecordResult.assertions().isCalledOnce().with(value(false))
deleteRecordingResult.assertions().isNeverCalled()
@@ -302,6 +304,7 @@ class DefaultVoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
assertThat((it.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(PLAYER_DURATION)
}
startRecordResult.assertions().isCalledOnce()
stopRecordResult.assertions().isCalledOnce().with(value(false))
@@ -338,17 +341,30 @@ class DefaultVoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true))
assertThat(voiceMessageState).isEqualTo(
aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true)
)
}
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true))
assertThat(voiceMessageState).isEqualTo(
aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true, duration = PLAYER_DURATION)
)
}
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(
aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true, duration = PLAYER_DURATION)
)
eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f)))
}
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true))
assertThat(voiceMessageState).isEqualTo(
aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true, duration = PLAYER_DURATION)
)
}
val finalState = awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true))
assertThat(voiceMessageState).isEqualTo(
aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true, duration = PLAYER_DURATION)
)
}
testPauseAndDestroy(finalState)
@@ -827,6 +843,7 @@ class DefaultVoiceMessageComposerPresenterTest {
playbackProgress: Float = 0f,
isSending: Boolean = false,
time: Duration = RECORDING_DURATION,
duration: Duration = RECORDING_DURATION,
showCursor: Boolean = false,
waveform: List<Float> = voiceRecorder.waveform,
) = VoiceMessageState.Preview(
@@ -834,6 +851,7 @@ class DefaultVoiceMessageComposerPresenterTest {
playbackProgress = playbackProgress,
isSending = isSending,
time = time,
duration = duration,
showCursor = showCursor,
waveform = waveform.toImmutableList(),
)
@@ -844,6 +862,7 @@ class DefaultVoiceMessageComposerPresenterTest {
playbackProgress = 0.1f,
showCursor = true,
time = RECORDING_DURATION,
duration = PLAYER_DURATION,
)
private fun aPausedState() =