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 02d08a8f434..e12887eab17 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 @@ -8,6 +8,8 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.IconButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -109,19 +112,30 @@ fun TimelineItemVoiceView( } } Spacer(Modifier.width(8.dp)) - Text( - text = state.time, - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodySmMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + PlaybackSpeedButton( + speed = state.playbackSpeed, + onClick = { state.eventSink(VoiceMessageEvents.ChangePlaybackSpeed) }, + ) + Text( + text = state.time, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } Spacer(Modifier.width(8.dp)) WaveformPlaybackView( showCursor = state.showCursor, playbackProgress = state.progress, waveform = content.waveform, - modifier = Modifier.height(34.dp), + modifier = Modifier + .weight(1f) + .height(34.dp), seekEnabled = !isTalkbackActive(), onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) }, ) @@ -172,6 +186,36 @@ private fun RetryButton( } } +@Composable +private fun PlaybackSpeedButton( + speed: Float, + onClick: () -> Unit, +) { + val speedText = when (speed) { + 0.5f -> "0.5×" + 1.0f -> "1×" + 1.5f -> "1.5×" + 2.0f -> "2×" + else -> "${speed}×" + } + androidx.compose.foundation.layout.Box( + modifier = Modifier + .background( + color = ElementTheme.colors.bgCanvasDefault, + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = speedText, + color = ElementTheme.colors.iconSecondary, + style = ElementTheme.typography.fontBodyXsMedium, + ) + } +} + @Composable private fun ControlIcon( imageVector: ImageVector, @@ -295,3 +339,14 @@ internal fun ProgressButtonPreview() = ElementPreview { ProgressButton(displayImmediately = false) } } + +@PreviewsDayNight +@Composable +internal fun PlaybackSpeedButtonPreview() = ElementPreview { + Row { + PlaybackSpeedButton(speed = 0.5f, onClick = {}) + PlaybackSpeedButton(speed = 1.0f, onClick = {}) + PlaybackSpeedButton(speed = 1.5f, onClick = {}) + PlaybackSpeedButton(speed = 2.0f, onClick = {}) + } +} diff --git a/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt b/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt index 0834323f90f..b10dd44b7f2 100644 --- a/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt +++ b/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt @@ -46,6 +46,12 @@ interface MediaPlayer : AutoCloseable { */ fun seekTo(positionMs: Long) + /** + * Sets the playback speed. + * @param speed The playback speed (e.g., 0.5f for half speed, 1.0f for normal, 2.0f for double speed) + */ + fun setPlaybackSpeed(speed: Float) + /** * Releases any resources associated with this player. */ diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt index 5aad37779c6..0767aea3ef2 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt @@ -160,6 +160,10 @@ class DefaultMediaPlayer( } } + override fun setPlaybackSpeed(speed: Float) { + player.setPlaybackSpeed(speed) + } + override fun close() { player.release() } diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt index c8e42b8abf0..b6aff2570a7 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt @@ -33,6 +33,7 @@ interface SimplePlayer { fun isPlaying(): Boolean fun pause() fun seekTo(positionMs: Long) + fun setPlaybackSpeed(speed: Float) fun release() interface Listener { fun onIsPlayingChanged(isPlaying: Boolean) @@ -87,5 +88,9 @@ class DefaultSimplePlayer( override fun seekTo(positionMs: Long) = p.seekTo(positionMs) + override fun setPlaybackSpeed(speed: Float) { + p.setPlaybackParameters(p.playbackParameters.withSpeed(speed)) + } + override fun release() = p.release() } diff --git a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt index 94c880a862c..34078ab4e85 100644 --- a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt +++ b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt @@ -19,6 +19,7 @@ class FakeSimplePlayer( private val isPlayingLambda: () -> Boolean = { lambdaError() }, private val pauseLambda: () -> Unit = { lambdaError() }, private val seekToLambda: (Long) -> Unit = { lambdaError() }, + private val setPlaybackSpeedLambda: (Float) -> Unit = { lambdaError() }, private val releaseLambda: () -> Unit = { lambdaError() }, ) : SimplePlayer { private val listeners = mutableListOf() @@ -44,6 +45,7 @@ class FakeSimplePlayer( override fun isPlaying() = isPlayingLambda() override fun pause() = pauseLambda() override fun seekTo(positionMs: Long) = seekToLambda(positionMs) + override fun setPlaybackSpeed(speed: Float) = setPlaybackSpeedLambda(speed) override fun release() = releaseLambda() fun simulateIsPlayingChanged(isPlaying: Boolean) { diff --git a/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt b/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt index 1e670d46756..0dcd6554247 100644 --- a/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt +++ b/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt @@ -95,6 +95,10 @@ class FakeMediaPlayer( } } + override fun setPlaybackSpeed(speed: Float) { + // no-op + } + override fun close() { // no-op } 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 6d5fdd96128..bd8daa69e1f 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 @@ -9,7 +9,9 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -119,13 +121,22 @@ private fun VoiceInfoRow( VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false) } Spacer(Modifier.width(8.dp)) - Text( - text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time, - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodyMdMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + PlaybackSpeedButton( + speed = state.playbackSpeed, + onClick = { state.eventSink(VoiceMessageEvents.ChangePlaybackSpeed) }, + ) + Text( + text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } Spacer(modifier = Modifier.width(8.dp)) WaveformPlaybackView( modifier = Modifier @@ -223,6 +234,36 @@ private fun RetryButton( } } +@Composable +private fun PlaybackSpeedButton( + speed: Float, + onClick: () -> Unit, +) { + val speedText = when (speed) { + 0.5f -> "0.5×" + 1.0f -> "1×" + 1.5f -> "1.5×" + 2.0f -> "2×" + else -> "${speed}×" + } + androidx.compose.foundation.layout.Box( + modifier = Modifier + .background( + color = ElementTheme.colors.bgCanvasDefault, + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = speedText, + color = ElementTheme.colors.iconSecondary, + style = ElementTheme.typography.fontBodyXsMedium, + ) + } +} + @Composable private fun ControlIcon( imageVector: ImageVector, @@ -283,3 +324,14 @@ internal fun VoiceItemViewPlayPreview( onLongClick = {}, ) } + +@PreviewsDayNight +@Composable +internal fun PlaybackSpeedButtonPreview() = ElementPreview { + Row { + PlaybackSpeedButton(speed = 0.5f, onClick = {}) + PlaybackSpeedButton(speed = 1.0f, onClick = {}) + PlaybackSpeedButton(speed = 1.5f, onClick = {}) + PlaybackSpeedButton(speed = 2.0f, onClick = {}) + } +} diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt index db97331b6f4..7e5430a5665 100644 --- a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt @@ -10,4 +10,5 @@ package io.element.android.libraries.voiceplayer.api sealed interface VoiceMessageEvents { data object PlayPause : VoiceMessageEvents data class Seek(val percentage: Float) : VoiceMessageEvents + data object ChangePlaybackSpeed : VoiceMessageEvents } 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 f14bf690ccf..522b5cfbe22 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 @@ -12,6 +12,7 @@ data class VoiceMessageState( val progress: Float, val time: String, val showCursor: Boolean, + val playbackSpeed: Float, val eventSink: (event: VoiceMessageEvents) -> Unit, ) { enum class Button { diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt index 0ff510a485c..bfc2f144ab8 100644 --- a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt @@ -47,10 +47,12 @@ fun aVoiceMessageState( progress: Float = 0f, time: String = "1:00", showCursor: Boolean = false, + playbackSpeed: Float = 1.0f, ) = VoiceMessageState( button = button, progress = progress, time = time, showCursor = showCursor, + playbackSpeed = playbackSpeed, eventSink = {}, ) diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePlayer.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePlayer.kt index 64a479105de..51a1163e360 100644 --- a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePlayer.kt +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePlayer.kt @@ -79,6 +79,13 @@ interface VoiceMessagePlayer { */ fun seekTo(positionMs: Long) + /** + * Set the playback speed. + * + * @param speed The playback speed (e.g., 0.5f for half speed, 1.0f for normal, 2.0f for double speed) + */ + fun setPlaybackSpeed(speed: Float) + data class State( /** * Whether the player is ready to play. @@ -218,6 +225,10 @@ class Factory( } } + override fun setPlaybackSpeed(speed: Float) { + mediaPlayer.setPlaybackSpeed(speed) + } + private val MediaPlayer.State.isMyTrack: Boolean get() = if (eventId == null) false else this.mediaId == eventId.value diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt index c7811f6d179..9637c5e587a 100644 --- a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt @@ -37,6 +37,9 @@ class VoiceMessagePresenter( private val duration: Duration, ) : Presenter { private val play = mutableStateOf>(AsyncData.Uninitialized) + private val playbackSpeed = mutableStateOf(1.0f) + + private val availablePlaybackSpeeds = listOf(0.5f, 1.0f, 1.5f, 2.0f) @Composable override fun present(): VoiceMessageState { @@ -111,6 +114,13 @@ class VoiceMessagePresenter( is VoiceMessageEvents.Seek -> { player.seekTo((event.percentage * duration).toLong()) } + is VoiceMessageEvents.ChangePlaybackSpeed -> { + val currentIndex = availablePlaybackSpeeds.indexOf(playbackSpeed.value) + val nextIndex = (currentIndex + 1) % availablePlaybackSpeeds.size + val newSpeed = availablePlaybackSpeeds[nextIndex] + playbackSpeed.value = newSpeed + player.setPlaybackSpeed(newSpeed) + } } } @@ -119,6 +129,7 @@ class VoiceMessagePresenter( progress = progress, time = time, showCursor = showCursor, + playbackSpeed = playbackSpeed.value, eventSink = { eventSink(it) }, ) } diff --git a/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt index 9d16cd6bcc1..5f0b34b45e7 100644 --- a/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt @@ -222,6 +222,40 @@ class VoiceMessagePresenterTest { } } } + + @Test + fun `changing playback speed cycles through available speeds`() = runTest { + val presenter = createVoiceMessagePresenter( + duration = 10_000.milliseconds, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + assertThat(it.playbackSpeed).isEqualTo(1.0f) + } + + initialState.eventSink(VoiceMessageEvents.ChangePlaybackSpeed) + awaitItem().also { + assertThat(it.playbackSpeed).isEqualTo(1.5f) + } + + initialState.eventSink(VoiceMessageEvents.ChangePlaybackSpeed) + awaitItem().also { + assertThat(it.playbackSpeed).isEqualTo(2.0f) + } + + initialState.eventSink(VoiceMessageEvents.ChangePlaybackSpeed) + awaitItem().also { + assertThat(it.playbackSpeed).isEqualTo(0.5f) + } + + initialState.eventSink(VoiceMessageEvents.ChangePlaybackSpeed) + awaitItem().also { + assertThat(it.playbackSpeed).isEqualTo(1.0f) + } + } + } } fun TestScope.createVoiceMessagePresenter(