Merge pull request #5504 from Medformatik/feat/variable-playback-speed
Add variable playback speed feature for voice messages
This commit is contained in:
commit
83ffb60d13
13 changed files with 203 additions and 15 deletions
|
|
@ -9,6 +9,8 @@
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -17,6 +19,7 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -110,19 +113,30 @@ fun TimelineItemVoiceView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Column(
|
||||||
text = state.time,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
color = ElementTheme.colors.textSecondary,
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
style = ElementTheme.typography.fontBodySmMedium,
|
) {
|
||||||
maxLines = 1,
|
PlaybackSpeedButton(
|
||||||
overflow = TextOverflow.Ellipsis,
|
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))
|
Spacer(Modifier.width(8.dp))
|
||||||
WaveformPlaybackView(
|
WaveformPlaybackView(
|
||||||
showCursor = state.showCursor,
|
showCursor = state.showCursor,
|
||||||
playbackProgress = state.progress,
|
playbackProgress = state.progress,
|
||||||
waveform = content.waveform,
|
waveform = content.waveform,
|
||||||
modifier = Modifier.height(34.dp),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(34.dp),
|
||||||
seekEnabled = !isTalkbackActive(),
|
seekEnabled = !isTalkbackActive(),
|
||||||
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
|
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
|
||||||
)
|
)
|
||||||
|
|
@ -173,6 +187,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)
|
||||||
|
)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = speedText,
|
||||||
|
color = ElementTheme.colors.iconSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodyXsMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ControlIcon(
|
private fun ControlIcon(
|
||||||
imageVector: ImageVector,
|
imageVector: ImageVector,
|
||||||
|
|
@ -296,3 +340,14 @@ internal fun ProgressButtonPreview() = ElementPreview {
|
||||||
ProgressButton(displayImmediately = false)
|
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 = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,12 @@ interface MediaPlayer : AutoCloseable {
|
||||||
*/
|
*/
|
||||||
fun seekTo(positionMs: Long)
|
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.
|
* Releases any resources associated with this player.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,10 @@ class DefaultMediaPlayer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setPlaybackSpeed(speed: Float) {
|
||||||
|
player.setPlaybackSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
player.release()
|
player.release()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ interface SimplePlayer {
|
||||||
fun isPlaying(): Boolean
|
fun isPlaying(): Boolean
|
||||||
fun pause()
|
fun pause()
|
||||||
fun seekTo(positionMs: Long)
|
fun seekTo(positionMs: Long)
|
||||||
|
fun setPlaybackSpeed(speed: Float)
|
||||||
fun release()
|
fun release()
|
||||||
interface Listener {
|
interface Listener {
|
||||||
fun onIsPlayingChanged(isPlaying: Boolean)
|
fun onIsPlayingChanged(isPlaying: Boolean)
|
||||||
|
|
@ -88,5 +89,9 @@ class DefaultSimplePlayer(
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
|
override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
|
||||||
|
|
||||||
|
override fun setPlaybackSpeed(speed: Float) {
|
||||||
|
p.setPlaybackParameters(p.playbackParameters.withSpeed(speed))
|
||||||
|
}
|
||||||
|
|
||||||
override fun release() = p.release()
|
override fun release() = p.release()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class FakeSimplePlayer(
|
||||||
private val isPlayingLambda: () -> Boolean = { lambdaError() },
|
private val isPlayingLambda: () -> Boolean = { lambdaError() },
|
||||||
private val pauseLambda: () -> Unit = { lambdaError() },
|
private val pauseLambda: () -> Unit = { lambdaError() },
|
||||||
private val seekToLambda: (Long) -> Unit = { lambdaError() },
|
private val seekToLambda: (Long) -> Unit = { lambdaError() },
|
||||||
|
private val setPlaybackSpeedLambda: (Float) -> Unit = { lambdaError() },
|
||||||
private val releaseLambda: () -> Unit = { lambdaError() },
|
private val releaseLambda: () -> Unit = { lambdaError() },
|
||||||
) : SimplePlayer {
|
) : SimplePlayer {
|
||||||
private val listeners = mutableListOf<SimplePlayer.Listener>()
|
private val listeners = mutableListOf<SimplePlayer.Listener>()
|
||||||
|
|
@ -45,6 +46,7 @@ class FakeSimplePlayer(
|
||||||
override fun isPlaying() = isPlayingLambda()
|
override fun isPlaying() = isPlayingLambda()
|
||||||
override fun pause() = pauseLambda()
|
override fun pause() = pauseLambda()
|
||||||
override fun seekTo(positionMs: Long) = seekToLambda(positionMs)
|
override fun seekTo(positionMs: Long) = seekToLambda(positionMs)
|
||||||
|
override fun setPlaybackSpeed(speed: Float) = setPlaybackSpeedLambda(speed)
|
||||||
override fun release() = releaseLambda()
|
override fun release() = releaseLambda()
|
||||||
|
|
||||||
fun simulateIsPlayingChanged(isPlaying: Boolean) {
|
fun simulateIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@ class FakeMediaPlayer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setPlaybackSpeed(speed: Float) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -120,13 +122,22 @@ private fun VoiceInfoRow(
|
||||||
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
|
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Column(
|
||||||
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
color = ElementTheme.colors.textSecondary,
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
style = ElementTheme.typography.fontBodyMdMedium,
|
) {
|
||||||
maxLines = 1,
|
PlaybackSpeedButton(
|
||||||
overflow = TextOverflow.Ellipsis,
|
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))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
WaveformPlaybackView(
|
WaveformPlaybackView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -224,6 +235,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
|
@Composable
|
||||||
private fun ControlIcon(
|
private fun ControlIcon(
|
||||||
imageVector: ImageVector,
|
imageVector: ImageVector,
|
||||||
|
|
@ -284,3 +325,14 @@ internal fun VoiceItemViewPlayPreview(
|
||||||
onLongClick = {},
|
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 = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,5 @@ package io.element.android.libraries.voiceplayer.api
|
||||||
sealed interface VoiceMessageEvents {
|
sealed interface VoiceMessageEvents {
|
||||||
data object PlayPause : VoiceMessageEvents
|
data object PlayPause : VoiceMessageEvents
|
||||||
data class Seek(val percentage: Float) : VoiceMessageEvents
|
data class Seek(val percentage: Float) : VoiceMessageEvents
|
||||||
|
data object ChangePlaybackSpeed : VoiceMessageEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ data class VoiceMessageState(
|
||||||
val progress: Float,
|
val progress: Float,
|
||||||
val time: String,
|
val time: String,
|
||||||
val showCursor: Boolean,
|
val showCursor: Boolean,
|
||||||
|
val playbackSpeed: Float,
|
||||||
val eventSink: (event: VoiceMessageEvents) -> Unit,
|
val eventSink: (event: VoiceMessageEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
enum class Button {
|
enum class Button {
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,12 @@ fun aVoiceMessageState(
|
||||||
progress: Float = 0f,
|
progress: Float = 0f,
|
||||||
time: String = "1:00",
|
time: String = "1:00",
|
||||||
showCursor: Boolean = false,
|
showCursor: Boolean = false,
|
||||||
|
playbackSpeed: Float = 1.0f,
|
||||||
) = VoiceMessageState(
|
) = VoiceMessageState(
|
||||||
button = button,
|
button = button,
|
||||||
progress = progress,
|
progress = progress,
|
||||||
time = time,
|
time = time,
|
||||||
showCursor = showCursor,
|
showCursor = showCursor,
|
||||||
|
playbackSpeed = playbackSpeed,
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,13 @@ interface VoiceMessagePlayer {
|
||||||
*/
|
*/
|
||||||
fun seekTo(positionMs: Long)
|
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(
|
data class State(
|
||||||
/**
|
/**
|
||||||
* Whether the player is ready to play.
|
* Whether the player is ready to play.
|
||||||
|
|
@ -217,6 +224,10 @@ class DefaultVoiceMessagePlayer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setPlaybackSpeed(speed: Float) {
|
||||||
|
mediaPlayer.setPlaybackSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
private val MediaPlayer.State.isMyTrack: Boolean
|
private val MediaPlayer.State.isMyTrack: Boolean
|
||||||
get() = if (eventId == null) false else this.mediaId == eventId.value
|
get() = if (eventId == null) false else this.mediaId == eventId.value
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ class VoiceMessagePresenter(
|
||||||
private val duration: Duration,
|
private val duration: Duration,
|
||||||
) : Presenter<VoiceMessageState> {
|
) : Presenter<VoiceMessageState> {
|
||||||
private val play = mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
|
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
|
@Composable
|
||||||
override fun present(): VoiceMessageState {
|
override fun present(): VoiceMessageState {
|
||||||
|
|
@ -112,6 +115,13 @@ class VoiceMessagePresenter(
|
||||||
is VoiceMessageEvents.Seek -> {
|
is VoiceMessageEvents.Seek -> {
|
||||||
player.seekTo((event.percentage * duration).toLong())
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,6 +130,7 @@ class VoiceMessagePresenter(
|
||||||
progress = progress,
|
progress = progress,
|
||||||
time = time,
|
time = time,
|
||||||
showCursor = showCursor,
|
showCursor = showCursor,
|
||||||
|
playbackSpeed = playbackSpeed.value,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,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(
|
fun TestScope.createVoiceMessagePresenter(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue