From eb2836f42bc501bbac15e8dd210e53b4f95f6ee0 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Tue, 21 Nov 2023 20:48:08 +0100 Subject: [PATCH] Voice message scrubbing improvements (#1847) - Voice messages can be scrubbed (i.e. seeked to) even when they have not been played yet.. - The progress bar is displayed also when paused. - Multiple voice messages can keep their state when paused. - Tries to adhere as much as possible at the detailed "green cursor" behavior in the story (but might not be 100% compliant). Story: https://github.com/vector-im/element-meta/issues/2113 --- .../components/event/TimelineItemVoiceView.kt | 2 +- .../timeline/VoiceMessagePlayer.kt | 138 ++++++--- .../timeline/VoiceMessagePresenter.kt | 51 ++-- .../timeline/VoiceMessageState.kt | 1 + .../timeline/VoiceMessageStateProvider.kt | 4 + .../timeline/DefaultVoiceMessagePlayerTest.kt | 262 +++++++++++++----- .../timeline/VoiceMessagePresenterTest.kt | 64 +---- .../libraries/mediaplayer/api/MediaPlayer.kt | 5 + .../mediaplayer/impl/MediaPlayerImpl.kt | 16 +- .../mediaplayer/impl/SimplePlayer.kt | 4 +- .../mediaplayer/test/FakeMediaPlayer.kt | 42 ++- 11 files changed, 381 insertions(+), 208 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index e1494b9da0..6d3dd57994 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -96,7 +96,7 @@ fun TimelineItemVoiceView( Spacer(Modifier.width(8.dp)) val context = LocalContext.current WaveformPlaybackView( - showCursor = state.button == VoiceMessageState.Button.Pause, + showCursor = state.showCursor, playbackProgress = state.progress, waveform = content.waveform, modifier = Modifier diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt index 57607507c9..a6f002c38a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt @@ -22,8 +22,11 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.mediaplayer.api.MediaPlayer import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import java.io.File import javax.inject.Inject /** @@ -60,14 +63,18 @@ interface VoiceMessagePlayer { val state: Flow /** - * Starts playing from the beginning - * acquiring control of the underlying [MediaPlayer]. - * If already in control of the underlying [MediaPlayer], starts playing from the - * current position. + * Acquires control of the underlying [MediaPlayer] and prepares it + * to play the media file. * - * Will suspend whilst the media file is being downloaded. + * Will suspend whilst the media file is being downloaded and/or + * the underlying [MediaPlayer] is loading the media file. */ - suspend fun play(): Result + suspend fun prepare(): Result + + /** + * Play the media. + */ + fun play() /** * Pause playback. @@ -75,28 +82,33 @@ interface VoiceMessagePlayer { fun pause() /** - * Seek to a specific position acquiring control of the - * underlying [MediaPlayer] if needed. - * - * Will suspend whilst the media file is being downloaded. + * Seek to a specific position. * * @param positionMs The position in milliseconds. */ - suspend fun seekTo(positionMs: Long): Result + fun seekTo(positionMs: Long) data class State( + /** + * Whether the player is ready to play. + */ + val isReady: Boolean, /** * Whether this player is currently playing. */ val isPlaying: Boolean, /** - * Whether this player has control of the underlying [MediaPlayer]. + * Whether the player has reached the end of the media. */ - val isMyMedia: Boolean, + val isEnded: Boolean, /** * The elapsed time of this player in milliseconds. */ val currentPosition: Long, + /** + * The duration of the current content, if available. + */ + val duration: Long?, ) } @@ -140,50 +152,84 @@ class DefaultVoiceMessagePlayer( body = body ) - override val state: Flow = mediaPlayer.state.map { state -> + private var internalState = MutableStateFlow( VoiceMessagePlayer.State( - isPlaying = state.mediaId.isMyTrack() && state.isPlaying, - isMyMedia = state.mediaId.isMyTrack(), - currentPosition = if (state.mediaId.isMyTrack()) state.currentPosition else 0L + isReady = false, + isPlaying = false, + isEnded = false, + currentPosition = 0L, + duration = null + ) + ) + + override val state: Flow = combine(mediaPlayer.state, internalState) { mediaPlayerState, internalState -> + if (mediaPlayerState.isMyTrack) { + this.internalState.update { + it.copy( + isReady = mediaPlayerState.isReady, + isPlaying = mediaPlayerState.isPlaying, + isEnded = mediaPlayerState.isEnded, + currentPosition = mediaPlayerState.currentPosition, + duration = mediaPlayerState.duration, + ) + } + } else { + this.internalState.update { + it.copy( + isReady = false, + isPlaying = false, + ) + } + } + VoiceMessagePlayer.State( + isReady = internalState.isReady, + isPlaying = internalState.isPlaying, + isEnded = internalState.isEnded, + currentPosition = internalState.currentPosition, + duration = internalState.duration, ) }.distinctUntilChanged() - override suspend fun play(): Result = acquireControl { - mediaPlayer.play() + override suspend fun prepare(): Result = if (eventId != null) { + repo.getMediaFile().mapCatching { mediaFile -> + val state = internalState.value + mediaPlayer.setMedia( + uri = mediaFile.path, + mediaId = eventId.value, + mimeType = "audio/ogg", // Files in the voice cache have no extension so we need to set the mime type manually. + startPositionMs = if (state.isEnded) 0L else state.currentPosition, + ) + } + } else { + Result.failure(IllegalStateException("Cannot acquireControl on a voice message with no eventId")) + } + + override fun play() { + if (inControl()) { + mediaPlayer.play() + } } override fun pause() { - ifInControl { + if (inControl()) { mediaPlayer.pause() } } - override suspend fun seekTo(positionMs: Long): Result = acquireControl { - mediaPlayer.seekTo(positionMs) - } - - private fun String?.isMyTrack(): Boolean = if (eventId == null) false else this == eventId.value - - private inline fun ifInControl(block: () -> Unit) { - if (inControl()) block() - } - - private fun inControl(): Boolean = mediaPlayer.state.value.mediaId.isMyTrack() - - private suspend inline fun acquireControl(onReady: (state: MediaPlayer.State) -> Unit): Result = if (inControl()) { - onReady(mediaPlayer.state.value) - Result.success(Unit) - } else { - if (eventId != null) { - repo.getMediaFile().mapCatching { mediaFile -> - mediaPlayer.setMedia( - uri = mediaFile.path, - mediaId = eventId.value, - mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually. - ).let(onReady) - } + override fun seekTo(positionMs: Long) { + if (inControl()) { + mediaPlayer.seekTo(positionMs) } else { - Result.failure(IllegalStateException("Cannot acquireControl on a voice message with no eventId")) + internalState.update { + it.copy(currentPosition = positionMs) + } } } + + private val MediaPlayer.State.isMyTrack: Boolean + get() = if (eventId == null) false else this.mediaId == eventId.value + + private fun inControl(): Boolean = mediaPlayer.state.value.let { + it.isMyTrack && (it.isReady || it.isEnded) + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt index d0f7f3b2c1..aebed9dd57 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt @@ -72,12 +72,19 @@ class VoiceMessagePresenter @AssistedInject constructor( ) private val play = mutableStateOf>(Async.Uninitialized) - private var progressCache: Float = 0f @Composable override fun present(): VoiceMessageState { - val playerState by player.state.collectAsState(VoiceMessagePlayer.State(isPlaying = false, isMyMedia = false, currentPosition = 0L)) + val playerState by player.state.collectAsState( + VoiceMessagePlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + currentPosition = 0L, + duration = null + ) + ) val button by remember { derivedStateOf { @@ -90,18 +97,26 @@ class VoiceMessagePresenter @AssistedInject constructor( } } } + val duration by remember { + derivedStateOf { playerState.duration ?: content.duration.toMillis() } + } val progress by remember { derivedStateOf { - if (playerState.isMyMedia) { - progressCache = playerState.currentPosition / content.duration.toMillis().toFloat() - } - progressCache + playerState.currentPosition / duration.toFloat() } } val time by remember { derivedStateOf { - val time = if (playerState.isMyMedia) playerState.currentPosition else content.duration.toMillis() - time.milliseconds.formatShort() + when { + playerState.isReady && !playerState.isEnded -> playerState.currentPosition + playerState.currentPosition > 0 -> playerState.currentPosition + else -> duration + }.milliseconds.formatShort() + } + } + val showCursor by remember { + derivedStateOf { + !play.value.isUninitialized() && !playerState.isEnded } } @@ -110,6 +125,8 @@ class VoiceMessagePresenter @AssistedInject constructor( is VoiceMessageEvents.PlayPause -> { if (playerState.isPlaying) { player.pause() + } else if (playerState.isReady) { + player.play() } else { scope.launch { play.runUpdatingState( @@ -120,24 +137,15 @@ class VoiceMessagePresenter @AssistedInject constructor( it }, ) { - player.play() + player.prepare().apply { + player.play() + } } } } } is VoiceMessageEvents.Seek -> { - scope.launch { - play.runUpdatingState( - errorTransform = { - analyticsService.trackError( - VoiceMessageException.PlayMessageError("Error while trying to seek voice message", it) - ) - it - }, - ) { - player.seekTo((event.percentage * content.duration.toMillis()).toLong()) - } - } + player.seekTo((event.percentage * duration).toLong()) } } } @@ -146,6 +154,7 @@ class VoiceMessagePresenter @AssistedInject constructor( button = button, progress = progress, time = time, + showCursor = showCursor, eventSink = { eventSink(it) }, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt index 093d5336fd..8940374cc7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt @@ -20,6 +20,7 @@ data class VoiceMessageState( val button: Button, val progress: Float, val time: String, + val showCursor: Boolean, val eventSink: (event: VoiceMessageEvents) -> Unit, ) { enum class Button { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt index 83d2f1141f..188ff6656d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt @@ -35,11 +35,13 @@ open class VoiceMessageStateProvider : PreviewParameterProvider.matchInitialState() { + awaitItem().let { + Truth.assertThat(it.isReady).isEqualTo(false) + Truth.assertThat(it.isPlaying).isEqualTo(false) + Truth.assertThat(it.isEnded).isEqualTo(false) + Truth.assertThat(it.currentPosition).isEqualTo(0) + Truth.assertThat(it.duration).isEqualTo(null) + } +} + +private suspend fun TurbineTestContext.matchReadyState( + fakeTotalDurationMs: Long = FAKE_TOTAL_DURATION_MS, +) { + awaitItem().let { + Truth.assertThat(it.isReady).isEqualTo(true) + Truth.assertThat(it.isPlaying).isEqualTo(false) + Truth.assertThat(it.isEnded).isEqualTo(false) + Truth.assertThat(it.currentPosition).isEqualTo(0) + Truth.assertThat(it.duration).isEqualTo(fakeTotalDurationMs) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt index af8dbfc1d4..00c8cd1f70 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt @@ -53,6 +53,7 @@ class VoiceMessagePresenterTest { @Test fun `pressing play downloads and plays`() = runTest { val presenter = createVoiceMessagePresenter( + mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), content = aTimelineItemVoiceContent(durationMs = 2_000), ) moleculeFlow(RecompositionMode.Immediate) { @@ -88,6 +89,7 @@ class VoiceMessagePresenterTest { fun `pressing play downloads and fails`() = runTest { val analyticsService = FakeAnalyticsService() val presenter = createVoiceMessagePresenter( + mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true }, analyticsService = analyticsService, content = aTimelineItemVoiceContent(durationMs = 2_000), @@ -125,6 +127,7 @@ class VoiceMessagePresenterTest { @Test fun `pressing pause while playing pauses`() = runTest { val presenter = createVoiceMessagePresenter( + mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), content = aTimelineItemVoiceContent(durationMs = 2_000), ) moleculeFlow(RecompositionMode.Immediate) { @@ -171,8 +174,9 @@ class VoiceMessagePresenterTest { } @Test - fun `seeking downloads and seeks`() = runTest { + fun `seeking before play`() = runTest { val presenter = createVoiceMessagePresenter( + mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), content = aTimelineItemVoiceContent(durationMs = 10_000), ) moleculeFlow(RecompositionMode.Immediate) { @@ -186,21 +190,6 @@ class VoiceMessagePresenterTest { initialState.eventSink(VoiceMessageEvents.Seek(0.5f)) - awaitItem().also { - Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) - Truth.assertThat(it.progress).isEqualTo(0f) - Truth.assertThat(it.time).isEqualTo("0:10") - } - awaitItem().also { - Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) - Truth.assertThat(it.progress).isEqualTo(0f) - Truth.assertThat(it.time).isEqualTo("0:00") - } - awaitItem().also { - Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) - Truth.assertThat(it.progress).isEqualTo(0.5f) - Truth.assertThat(it.time).isEqualTo("0:05") - } awaitItem().also { Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) Truth.assertThat(it.progress).isEqualTo(0.5f) @@ -210,45 +199,7 @@ class VoiceMessagePresenterTest { } @Test - fun `seeking downloads and fails`() = runTest { - val analyticsService = FakeAnalyticsService() - val presenter = createVoiceMessagePresenter( - voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true }, - analyticsService = analyticsService, - content = aTimelineItemVoiceContent(durationMs = 10_000), - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem().also { - Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) - Truth.assertThat(it.progress).isEqualTo(0f) - Truth.assertThat(it.time).isEqualTo("0:10") - } - - initialState.eventSink(VoiceMessageEvents.Seek(0.5f)) - - awaitItem().also { - Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) - Truth.assertThat(it.progress).isEqualTo(0f) - Truth.assertThat(it.time).isEqualTo("0:10") - } - awaitItem().also { - Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry) - Truth.assertThat(it.progress).isEqualTo(0f) - Truth.assertThat(it.time).isEqualTo("0:10") - } - analyticsService.trackedErrors.first().also { - Truth.assertThat(it).apply { - isInstanceOf(VoiceMessageException.PlayMessageError::class.java) - hasMessageThat().isEqualTo("Error while trying to seek voice message") - } - } - } - } - - @Test - fun `seeking seeks`() = runTest { + fun `seeking after play`() = runTest { val presenter = createVoiceMessagePresenter( content = aTimelineItemVoiceContent(durationMs = 10_000), ) @@ -283,13 +234,14 @@ class VoiceMessagePresenterTest { } fun TestScope.createVoiceMessagePresenter( + mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(), voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(), analyticsService: AnalyticsService = FakeAnalyticsService(), content: TimelineItemVoiceContent = aTimelineItemVoiceContent(), ) = VoiceMessagePresenter( voiceMessagePlayerFactory = { eventId, mediaSource, mimeType, body -> DefaultVoiceMessagePlayer( - mediaPlayer = FakeMediaPlayer(), + mediaPlayer = mediaPlayer, voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo }, eventId = eventId, mediaSource = mediaSource, diff --git a/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt b/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt index 46c5b40ead..3598edccf3 100644 --- a/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt +++ b/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt @@ -38,6 +38,7 @@ interface MediaPlayer : AutoCloseable { uri: String, mediaId: String, mimeType: String, + startPositionMs: Long = 0, ): State /** @@ -69,6 +70,10 @@ interface MediaPlayer : AutoCloseable { * Whether the player is currently playing. */ val isPlaying: Boolean, + /** + * Whether the player has reached the end of the current media. + */ + val isEnded: Boolean, /** * The id of the media which is currently playing. * diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImpl.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImpl.kt index 66e4e748a5..04919b0e56 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImpl.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImpl.kt @@ -77,6 +77,7 @@ class MediaPlayerImpl @Inject constructor( _state.update { it.copy( isReady = playbackState == Player.STATE_READY, + isEnded = playbackState == Player.STATE_ENDED, currentPosition = player.currentPosition, duration = duration, ) @@ -95,16 +96,22 @@ class MediaPlayerImpl @Inject constructor( MediaPlayer.State( isReady = false, isPlaying = false, + isEnded = false, mediaId = null, currentPosition = 0L, - duration = 0L + duration = null, ) ) override val state: StateFlow = _state.asStateFlow() @OptIn(FlowPreview::class) - override suspend fun setMedia(uri: String, mediaId: String, mimeType: String): MediaPlayer.State { + override suspend fun setMedia( + uri: String, + mediaId: String, + mimeType: String, + startPositionMs: Long, + ): MediaPlayer.State { player.pause() // Must pause here otherwise if the player was playing it would keep on playing the new media item. player.clearMediaItems() player.setMediaItem( @@ -112,7 +119,8 @@ class MediaPlayerImpl @Inject constructor( .setUri(uri) .setMediaId(mediaId) .setMimeType(mimeType) - .build() + .build(), + startPositionMs, ) player.prepare() // Will throw TimeoutCancellationException if the player is not ready after 1 second. @@ -129,7 +137,7 @@ class MediaPlayerImpl @Inject constructor( // playing no sound. // This is a workaround which will reload the media file. player.getCurrentMediaItem()?.let { - player.setMediaItem(it) + player.setMediaItem(it, 0) player.prepare() player.play() } diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt index 1395c69e57..ff8ff786ec 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt @@ -35,7 +35,7 @@ interface SimplePlayer { val playbackState: Int val duration: Long fun clearMediaItems() - fun setMediaItem(mediaItem: MediaItem) + fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) fun getCurrentMediaItem(): MediaItem? fun prepare() fun play() @@ -81,7 +81,7 @@ class SimplePlayerImpl( override fun clearMediaItems() = p.clearMediaItems() - override fun setMediaItem(mediaItem: MediaItem) = p.setMediaItem(mediaItem) + override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = p.setMediaItem(mediaItem, startPositionMs) override fun getCurrentMediaItem(): MediaItem? = p.currentMediaItem diff --git a/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt b/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt index 4f7d19df47..9bfb974622 100644 --- a/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt +++ b/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt @@ -26,31 +26,37 @@ import kotlinx.coroutines.flow.update /** * Fake implementation of [MediaPlayer] for testing purposes. */ -class FakeMediaPlayer : MediaPlayer { - companion object { - private const val FAKE_TOTAL_DURATION_MS = 10_000L - private const val FAKE_PLAYED_DURATION_MS = 1000L - } +class FakeMediaPlayer( + private val fakeTotalDurationMs: Long = 10_000L, + private val fakePlayedDurationMs: Long = 1000L, +) : MediaPlayer { private val _state = MutableStateFlow( MediaPlayer.State( isReady = false, isPlaying = false, + isEnded = false, mediaId = null, currentPosition = 0L, - duration = 0L + duration = null ) ) override val state: StateFlow = _state.asStateFlow() - override suspend fun setMedia(uri: String, mediaId: String, mimeType: String): MediaPlayer.State { + override suspend fun setMedia( + uri: String, + mediaId: String, + mimeType: String, + startPositionMs: Long, + ): MediaPlayer.State { _state.update { it.copy( isReady = false, isPlaying = false, + isEnded = false, mediaId = mediaId, - currentPosition = 0, + currentPosition = startPositionMs, duration = null, ) } @@ -58,7 +64,7 @@ class FakeMediaPlayer : MediaPlayer { _state.update { it.copy( isReady = true, - duration = FAKE_TOTAL_DURATION_MS, + duration = fakeTotalDurationMs, ) } return _state.value @@ -66,10 +72,20 @@ class FakeMediaPlayer : MediaPlayer { override fun play() { _state.update { - it.copy( - isPlaying = true, - currentPosition = it.currentPosition + FAKE_PLAYED_DURATION_MS, - ) + val newPosition = it.currentPosition + fakePlayedDurationMs + if (newPosition < fakeTotalDurationMs) { + it.copy( + isPlaying = true, + currentPosition = newPosition, + ) + } else { + it.copy( + isReady = false, + isPlaying = false, + isEnded = true, + currentPosition = fakeTotalDurationMs, + ) + } } }