Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)) },
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ class DefaultMediaPlayer(
}
}

override fun setPlaybackSpeed(speed: Float) {
player.setPlaybackSpeed(speed)
}

override fun close() {
player.release()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>()
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ class FakeMediaPlayer(
}
}

override fun setPlaybackSpeed(speed: Float) {
// no-op
}

override fun close() {
// no-op
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
)
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}

Expand All @@ -119,6 +129,7 @@ class VoiceMessagePresenter(
progress = progress,
time = time,
showCursor = showCursor,
playbackSpeed = playbackSpeed.value,
eventSink = { eventSink(it) },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down