Add variable playback speed feature for voice messages

Add playback speed control for voice messages with support for 0.5×, 1×, 1.5×, and 2× playback speeds. The speed button is displayed above the timestamp and cycles through the available speeds when tapped.
This commit is contained in:
Florian
2025-10-09 21:43:47 +02:00
committed by GitHub
parent a4707a787a
commit 1f0aa23bff
13 changed files with 203 additions and 15 deletions

View File

@@ -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 = {})
}
}

View File

@@ -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.
*/

View File

@@ -160,6 +160,10 @@ class DefaultMediaPlayer(
}
}
override fun setPlaybackSpeed(speed: Float) {
player.setPlaybackSpeed(speed)
}
override fun close() {
player.release()
}

View File

@@ -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()
}

View File

@@ -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<SimplePlayer.Listener>()
@@ -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) {

View File

@@ -95,6 +95,10 @@ class FakeMediaPlayer(
}
}
override fun setPlaybackSpeed(speed: Float) {
// no-op
}
override fun close() {
// no-op
}

View File

@@ -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 = {})
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 = {},
)

View File

@@ -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

View File

@@ -37,6 +37,9 @@ class VoiceMessagePresenter(
private val duration: Duration,
) : Presenter<VoiceMessageState> {
private val play = mutableStateOf<AsyncData<Unit>>(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) },
)
}

View File

@@ -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(