Merge pull request #5963 from element-hq/feature/bma/variablePlayBackSpeed
Voice message: variable play back speed
This commit is contained in:
commit
b01135aeef
75 changed files with 478 additions and 223 deletions
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -159,6 +159,10 @@ class DefaultMediaPlayer(
|
|||
}
|
||||
}
|
||||
|
||||
override fun setPlaybackSpeed(speed: Float) {
|
||||
player.setPlaybackSpeed(speed)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
player.release()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@ class FakeMediaPlayer(
|
|||
}
|
||||
}
|
||||
|
||||
override fun setPlaybackSpeed(speed: Float) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// no-op
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue