Merge pull request #5963 from element-hq/feature/bma/variablePlayBackSpeed

Voice message: variable play back speed
This commit is contained in:
Benoit Marty
2026-01-05 13:58:01 +01:00
committed by GitHub
75 changed files with 478 additions and 223 deletions

View File

@@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -42,6 +43,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -51,7 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import kotlinx.coroutines.delay
@@ -64,26 +66,26 @@ fun TimelineItemVoiceView(
modifier: Modifier = Modifier,
) {
fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause)
state.eventSink(VoiceMessageEvent.PlayPause)
}
val a11y = stringResource(CommonStrings.common_voice_message)
val a11yActionLabel = stringResource(
when (state.button) {
VoiceMessageState.Button.Play -> CommonStrings.a11y_play
VoiceMessageState.Button.Pause -> CommonStrings.a11y_pause
VoiceMessageState.Button.Downloading -> CommonStrings.common_downloading
VoiceMessageState.Button.Retry -> CommonStrings.action_retry
VoiceMessageState.Button.Disabled -> CommonStrings.error_unknown
when (state.buttonType) {
VoiceMessageState.ButtonType.Play -> CommonStrings.a11y_play
VoiceMessageState.ButtonType.Pause -> CommonStrings.a11y_pause
VoiceMessageState.ButtonType.Downloading -> CommonStrings.common_downloading
VoiceMessageState.ButtonType.Retry -> CommonStrings.action_retry
VoiceMessageState.ButtonType.Disabled -> CommonStrings.error_unknown
}
)
Row(
modifier = modifier
.clearAndSetSemantics {
contentDescription = a11y
if (state.button == VoiceMessageState.Button.Disabled) {
if (state.buttonType == VoiceMessageState.ButtonType.Disabled) {
disabled()
} else if (state.button in listOf(VoiceMessageState.Button.Play, VoiceMessageState.Button.Pause)) {
} else if (state.buttonType in listOf(VoiceMessageState.ButtonType.Play, VoiceMessageState.ButtonType.Pause)) {
onClick(label = a11yActionLabel) {
playPause()
true
@@ -101,30 +103,41 @@ fun TimelineItemVoiceView(
verticalAlignment = Alignment.CenterVertically,
) {
if (!isTalkbackActive()) {
when (state.button) {
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.Button.Downloading -> ProgressButton()
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
when (state.buttonType) {
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
}
}
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(VoiceMessageEvent.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)) },
onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) },
)
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
@Composable
fun PlaybackSpeedButton(
speed: Float,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val speedText = when (speed) {
0.5f -> "0.5×"
1.0f -> "1×"
1.5f -> "1.5×"
2.0f -> "2×"
else -> "$speed×"
}
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgCanvasDefault,
)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = speedText,
color = ElementTheme.colors.iconSecondary,
style = ElementTheme.typography.fontBodyXsMedium,
)
}
}
@PreviewsDayNight
@Composable
internal fun PlaybackSpeedButtonPreview() = ElementPreview {
Row(
modifier = Modifier
.background(ElementTheme.colors.messageFromMeBackground)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(0.5f, 1.0f, 1.5f, 2.0f, 3.0f).forEach { speed ->
PlaybackSpeedButton(
speed = speed,
onClick = {},
)
}
}
}

View File

@@ -47,6 +47,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

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

View File

@@ -34,6 +34,7 @@ interface SimplePlayer {
fun isPlaying(): Boolean
fun pause()
fun seekTo(positionMs: Long)
fun setPlaybackSpeed(speed: Float)
fun release()
interface Listener {
fun onIsPlayingChanged(isPlaying: Boolean)
@@ -88,5 +89,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

@@ -20,6 +20,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>()
@@ -45,6 +46,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

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

View File

@@ -11,6 +11,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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
@@ -38,6 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -50,7 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
@@ -92,7 +94,7 @@ private fun VoiceInfoRow(
onLongClick: () -> Unit,
) {
fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause)
state.eventSink(VoiceMessageEvent.PlayPause)
}
Row(
@@ -112,21 +114,30 @@ private fun VoiceInfoRow(
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (state.button) {
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.Button.Downloading -> ProgressButton()
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
when (state.buttonType) {
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.ButtonType.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(VoiceMessageEvent.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
@@ -136,7 +147,7 @@ private fun VoiceInfoRow(
playbackProgress = state.progress,
waveform = voice.mediaInfo.waveform.orEmpty().toImmutableList(),
onSeek = {
state.eventSink(VoiceMessageEvents.Seek(it))
state.eventSink(VoiceMessageEvent.Seek(it))
},
seekEnabled = true,
)

View File

@@ -8,7 +8,8 @@
package io.element.android.libraries.voiceplayer.api
sealed interface VoiceMessageEvents {
data object PlayPause : VoiceMessageEvents
data class Seek(val percentage: Float) : VoiceMessageEvents
sealed interface VoiceMessageEvent {
data object PlayPause : VoiceMessageEvent
data class Seek(val percentage: Float) : VoiceMessageEvent
data object ChangePlaybackSpeed : VoiceMessageEvent
}

View File

@@ -9,13 +9,14 @@
package io.element.android.libraries.voiceplayer.api
data class VoiceMessageState(
val button: Button,
val buttonType: ButtonType,
val progress: Float,
val time: String,
val showCursor: Boolean,
val eventSink: (event: VoiceMessageEvents) -> Unit,
val playbackSpeed: Float,
val eventSink: (event: VoiceMessageEvent) -> Unit,
) {
enum class Button {
enum class ButtonType {
Play,
Pause,
Downloading,

View File

@@ -14,29 +14,29 @@ open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageStat
override val values: Sequence<VoiceMessageState>
get() = sequenceOf(
aVoiceMessageState(
VoiceMessageState.Button.Downloading,
VoiceMessageState.ButtonType.Downloading,
progress = 0f,
time = "0:00",
),
aVoiceMessageState(
VoiceMessageState.Button.Retry,
VoiceMessageState.ButtonType.Retry,
progress = 0.5f,
time = "0:01",
),
aVoiceMessageState(
VoiceMessageState.Button.Play,
VoiceMessageState.ButtonType.Play,
progress = 1f,
time = "1:00",
showCursor = true,
),
aVoiceMessageState(
VoiceMessageState.Button.Pause,
VoiceMessageState.ButtonType.Pause,
progress = 0.2f,
time = "10:00",
showCursor = true,
),
aVoiceMessageState(
VoiceMessageState.Button.Disabled,
VoiceMessageState.ButtonType.Disabled,
progress = 0.2f,
time = "30:00",
),
@@ -44,14 +44,16 @@ open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageStat
}
fun aVoiceMessageState(
button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
buttonType: VoiceMessageState.ButtonType = VoiceMessageState.ButtonType.Play,
progress: Float = 0f,
time: String = "1:00",
showCursor: Boolean = false,
playbackSpeed: Float = 1.0f,
) = VoiceMessageState(
button = button,
buttonType = buttonType,
progress = progress,
time = time,
showCursor = showCursor,
playbackSpeed = playbackSpeed,
eventSink = {},
)

View File

@@ -26,10 +26,12 @@ dependencies {
implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.mediaplayer.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiUtils)
implementation(projects.services.analytics.api)
implementation(libs.androidx.annotationjvm)
implementation(libs.androidx.datastore.preferences)
implementation(libs.coroutines.core)
testCommonDependencies(libs)

View File

@@ -26,6 +26,7 @@ class DefaultVoiceMessagePresenterFactory(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
private val voicePlayerStore: VoicePlayerStore,
) : VoiceMessagePresenterFactory {
override fun createVoiceMessagePresenter(
eventId: EventId?,
@@ -44,6 +45,7 @@ class DefaultVoiceMessagePresenterFactory(
return VoiceMessagePresenter(
analyticsService = analyticsService,
sessionCoroutineScope = sessionCoroutineScope,
voicePlayerStore = voicePlayerStore,
player = player,
eventId = eventId,
duration = duration,

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.
@@ -217,6 +224,10 @@ class DefaultVoiceMessagePlayer(
}
}
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

@@ -9,11 +9,13 @@
package io.element.android.libraries.voiceplayer.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
@@ -21,7 +23,7 @@ import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.utils.time.formatShort
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.services.analytics.api.AnalyticsService
@@ -33,6 +35,7 @@ import kotlin.time.Duration.Companion.milliseconds
class VoiceMessagePresenter(
private val analyticsService: AnalyticsService,
private val sessionCoroutineScope: CoroutineScope,
private val voicePlayerStore: VoicePlayerStore,
private val player: VoiceMessagePlayer,
private val eventId: EventId?,
private val duration: Duration,
@@ -41,6 +44,7 @@ class VoiceMessagePresenter(
@Composable
override fun present(): VoiceMessageState {
val localCoroutineScope = rememberCoroutineScope()
val playerState by player.state.collectAsState(
VoiceMessagePlayer.State(
isReady = false,
@@ -51,14 +55,20 @@ class VoiceMessagePresenter(
)
)
val button by remember {
val playbackSpeedIndex by voicePlayerStore.playBackSpeedIndex().collectAsState(0)
LaunchedEffect(playbackSpeedIndex) {
player.setPlaybackSpeed(VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex])
}
val buttonType by remember {
derivedStateOf {
when {
eventId == null -> VoiceMessageState.Button.Disabled
playerState.isPlaying -> VoiceMessageState.Button.Pause
play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading
play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry
else -> VoiceMessageState.Button.Play
eventId == null -> VoiceMessageState.ButtonType.Disabled
playerState.isPlaying -> VoiceMessageState.ButtonType.Pause
play.value is AsyncData.Loading -> VoiceMessageState.ButtonType.Downloading
play.value is AsyncData.Failure -> VoiceMessageState.ButtonType.Retry
else -> VoiceMessageState.ButtonType.Play
}
}
}
@@ -85,9 +95,9 @@ class VoiceMessagePresenter(
}
}
fun handleEvent(event: VoiceMessageEvents) {
fun handleEvent(event: VoiceMessageEvent) {
when (event) {
is VoiceMessageEvents.PlayPause -> {
is VoiceMessageEvent.PlayPause -> {
if (playerState.isPlaying) {
player.pause()
} else if (playerState.isReady) {
@@ -109,17 +119,23 @@ class VoiceMessagePresenter(
}
}
}
is VoiceMessageEvents.Seek -> {
is VoiceMessageEvent.Seek -> {
player.seekTo((event.percentage * duration).toLong())
}
is VoiceMessageEvent.ChangePlaybackSpeed -> localCoroutineScope.launch {
voicePlayerStore.setPlayBackSpeedIndex(
(playbackSpeedIndex + 1) % VoicePlayerConfig.availablePlaybackSpeeds.size
)
}
}
}
return VoiceMessageState(
button = button,
buttonType = buttonType,
progress = progress,
time = time,
showCursor = showCursor,
playbackSpeed = VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex],
eventSink = ::handleEvent,
)
}

View File

@@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voiceplayer.impl
object VoicePlayerConfig {
// Available playback speeds for voice messages, the first one is the default speed, and
// the UI will allow to change to the next speed in the list, in loop.
val availablePlaybackSpeeds = listOf(1.0f, 1.5f, 2.0f, 0.5f)
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voiceplayer.impl
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface VoicePlayerStore {
suspend fun setPlayBackSpeedIndex(index: Int)
fun playBackSpeedIndex(): Flow<Int>
}
@ContributesBinding(AppScope::class)
class PreferencesVoicePlayerStore(
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : VoicePlayerStore {
private val store = preferenceDataStoreFactory.create("elementx_voice_player")
private val playbackSpeedIndex = intPreferencesKey("playback_speed_index")
override fun playBackSpeedIndex(): Flow<Int> {
return store.data.map { prefs ->
prefs[playbackSpeedIndex] ?: 0
}
}
override suspend fun setPlayBackSpeedIndex(index: Int) {
store.edit { prefs ->
prefs[playbackSpeedIndex] = index
}
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voiceplayer.impl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
internal class InMemoryVoicePlayerStore(
defaultPlaybackSpeedIndex: Int = 0,
) : VoicePlayerStore {
private val playBackSpeedIndex = MutableStateFlow(defaultPlaybackSpeedIndex)
override fun playBackSpeedIndex(): Flow<Int> {
return playBackSpeedIndex.asStateFlow()
}
override suspend fun setPlayBackSpeedIndex(index: Int) {
playBackSpeedIndex.emit(index)
}
}

View File

@@ -8,19 +8,17 @@
package io.element.android.libraries.voiceplayer.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -31,11 +29,9 @@ class VoiceMessagePresenterTest {
@Test
fun `initial state has proper default values`() = runTest {
val presenter = createVoiceMessagePresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
awaitItem().let {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("1:01")
}
@@ -48,29 +44,27 @@ class VoiceMessagePresenterTest {
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
duration = 2_000.milliseconds,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
initialState.eventSink(VoiceMessageEvent.PlayPause)
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:00")
}
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:01")
}
@@ -86,24 +80,22 @@ class VoiceMessagePresenterTest {
analyticsService = analyticsService,
duration = 2_000.milliseconds,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
initialState.eventSink(VoiceMessageEvent.PlayPause)
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Retry)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
@@ -122,27 +114,25 @@ class VoiceMessagePresenterTest {
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
duration = 2_000.milliseconds,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
initialState.eventSink(VoiceMessageEvent.PlayPause)
skipItems(2) // skip downloading states
val playingState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:01")
}
playingState.eventSink(VoiceMessageEvents.PlayPause)
playingState.eventSink(VoiceMessageEvent.PlayPause)
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:01")
}
@@ -154,11 +144,9 @@ class VoiceMessagePresenterTest {
val presenter = createVoiceMessagePresenter(
eventId = null,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Disabled)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("1:01")
}
@@ -171,19 +159,17 @@ class VoiceMessagePresenterTest {
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
duration = 10_000.milliseconds,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:10")
}
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
initialState.eventSink(VoiceMessageEvent.Seek(0.5f))
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:05")
}
@@ -195,40 +181,66 @@ class VoiceMessagePresenterTest {
val presenter = createVoiceMessagePresenter(
duration = 10_000.milliseconds,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:10")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
initialState.eventSink(VoiceMessageEvent.PlayPause)
skipItems(2) // skip downloading states
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
assertThat(it.progress).isEqualTo(0.1f)
assertThat(it.time).isEqualTo("0:01")
it.eventSink(VoiceMessageEvent.Seek(0.5f))
}
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:05")
}
}
}
@Test
fun `changing playback speed cycles through available speeds`() = runTest {
val presenter = createVoiceMessagePresenter(
duration = 10_000.milliseconds,
)
presenter.test {
awaitItem().also {
assertThat(it.playbackSpeed).isEqualTo(1.0f)
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
}
awaitItem().also {
assertThat(it.playbackSpeed).isEqualTo(1.5f)
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
}
awaitItem().also {
assertThat(it.playbackSpeed).isEqualTo(2.0f)
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
}
awaitItem().also {
assertThat(it.playbackSpeed).isEqualTo(0.5f)
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
}
awaitItem().also {
assertThat(it.playbackSpeed).isEqualTo(1.0f)
}
}
}
}
fun TestScope.createVoiceMessagePresenter(
mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(),
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
voicePlayerStore: VoicePlayerStore = InMemoryVoicePlayerStore(),
eventId: EventId? = EventId("\$anEventId"),
filename: String = "filename doesn't really matter for a voice message",
duration: Duration = 61_000.milliseconds,
@@ -246,6 +258,7 @@ fun TestScope.createVoiceMessagePresenter(
mimeType = mimeType,
filename = filename
),
voicePlayerStore = voicePlayerStore,
eventId = eventId,
duration = duration,
)