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
This commit is contained in:
Marco Romano 2023-11-21 20:48:08 +01:00 committed by GitHub
parent 6b467d95a7
commit eb2836f42b
11 changed files with 381 additions and 208 deletions

View file

@ -96,7 +96,7 @@ fun TimelineItemVoiceView(
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
val context = LocalContext.current val context = LocalContext.current
WaveformPlaybackView( WaveformPlaybackView(
showCursor = state.button == VoiceMessageState.Button.Pause, showCursor = state.showCursor,
playbackProgress = state.progress, playbackProgress = state.progress,
waveform = content.waveform, waveform = content.waveform,
modifier = Modifier modifier = Modifier

View file

@ -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.matrix.api.media.MediaSource
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update
import java.io.File
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -60,14 +63,18 @@ interface VoiceMessagePlayer {
val state: Flow<State> val state: Flow<State>
/** /**
* Starts playing from the beginning * Acquires control of the underlying [MediaPlayer] and prepares it
* acquiring control of the underlying [MediaPlayer]. * to play the media file.
* If already in control of the underlying [MediaPlayer], starts playing from the
* current position.
* *
* 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<Unit> suspend fun prepare(): Result<Unit>
/**
* Play the media.
*/
fun play()
/** /**
* Pause playback. * Pause playback.
@ -75,28 +82,33 @@ interface VoiceMessagePlayer {
fun pause() fun pause()
/** /**
* Seek to a specific position acquiring control of the * Seek to a specific position.
* underlying [MediaPlayer] if needed.
*
* Will suspend whilst the media file is being downloaded.
* *
* @param positionMs The position in milliseconds. * @param positionMs The position in milliseconds.
*/ */
suspend fun seekTo(positionMs: Long): Result<Unit> fun seekTo(positionMs: Long)
data class State( data class State(
/**
* Whether the player is ready to play.
*/
val isReady: Boolean,
/** /**
* Whether this player is currently playing. * Whether this player is currently playing.
*/ */
val isPlaying: Boolean, 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. * The elapsed time of this player in milliseconds.
*/ */
val currentPosition: Long, val currentPosition: Long,
/**
* The duration of the current content, if available.
*/
val duration: Long?,
) )
} }
@ -140,50 +152,84 @@ class DefaultVoiceMessagePlayer(
body = body body = body
) )
override val state: Flow<VoiceMessagePlayer.State> = mediaPlayer.state.map { state -> private var internalState = MutableStateFlow(
VoiceMessagePlayer.State( VoiceMessagePlayer.State(
isPlaying = state.mediaId.isMyTrack() && state.isPlaying, isReady = false,
isMyMedia = state.mediaId.isMyTrack(), isPlaying = false,
currentPosition = if (state.mediaId.isMyTrack()) state.currentPosition else 0L isEnded = false,
currentPosition = 0L,
duration = null
)
)
override val state: Flow<VoiceMessagePlayer.State> = 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() }.distinctUntilChanged()
override suspend fun play(): Result<Unit> = acquireControl { override suspend fun prepare(): Result<Unit> = if (eventId != null) {
mediaPlayer.play() repo.getMediaFile().mapCatching<Unit, File> { 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() { override fun pause() {
ifInControl { if (inControl()) {
mediaPlayer.pause() mediaPlayer.pause()
} }
} }
override suspend fun seekTo(positionMs: Long): Result<Unit> = acquireControl { override fun seekTo(positionMs: Long) {
mediaPlayer.seekTo(positionMs) if (inControl()) {
} 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<Unit> = 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)
}
} else { } 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)
}
} }

View file

@ -72,12 +72,19 @@ class VoiceMessagePresenter @AssistedInject constructor(
) )
private val play = mutableStateOf<Async<Unit>>(Async.Uninitialized) private val play = mutableStateOf<Async<Unit>>(Async.Uninitialized)
private var progressCache: Float = 0f
@Composable @Composable
override fun present(): VoiceMessageState { 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 { val button by remember {
derivedStateOf { derivedStateOf {
@ -90,18 +97,26 @@ class VoiceMessagePresenter @AssistedInject constructor(
} }
} }
} }
val duration by remember {
derivedStateOf { playerState.duration ?: content.duration.toMillis() }
}
val progress by remember { val progress by remember {
derivedStateOf { derivedStateOf {
if (playerState.isMyMedia) { playerState.currentPosition / duration.toFloat()
progressCache = playerState.currentPosition / content.duration.toMillis().toFloat()
}
progressCache
} }
} }
val time by remember { val time by remember {
derivedStateOf { derivedStateOf {
val time = if (playerState.isMyMedia) playerState.currentPosition else content.duration.toMillis() when {
time.milliseconds.formatShort() 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 -> { is VoiceMessageEvents.PlayPause -> {
if (playerState.isPlaying) { if (playerState.isPlaying) {
player.pause() player.pause()
} else if (playerState.isReady) {
player.play()
} else { } else {
scope.launch { scope.launch {
play.runUpdatingState( play.runUpdatingState(
@ -120,24 +137,15 @@ class VoiceMessagePresenter @AssistedInject constructor(
it it
}, },
) { ) {
player.play() player.prepare().apply {
player.play()
}
} }
} }
} }
} }
is VoiceMessageEvents.Seek -> { is VoiceMessageEvents.Seek -> {
scope.launch { player.seekTo((event.percentage * duration).toLong())
play.runUpdatingState(
errorTransform = {
analyticsService.trackError(
VoiceMessageException.PlayMessageError("Error while trying to seek voice message", it)
)
it
},
) {
player.seekTo((event.percentage * content.duration.toMillis()).toLong())
}
}
} }
} }
} }
@ -146,6 +154,7 @@ class VoiceMessagePresenter @AssistedInject constructor(
button = button, button = button,
progress = progress, progress = progress,
time = time, time = time,
showCursor = showCursor,
eventSink = { eventSink(it) }, eventSink = { eventSink(it) },
) )
} }

View file

@ -20,6 +20,7 @@ data class VoiceMessageState(
val button: Button, val button: Button,
val progress: Float, val progress: Float,
val time: String, val time: String,
val showCursor: Boolean,
val eventSink: (event: VoiceMessageEvents) -> Unit, val eventSink: (event: VoiceMessageEvents) -> Unit,
) { ) {
enum class Button { enum class Button {

View file

@ -35,11 +35,13 @@ open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageStat
VoiceMessageState.Button.Play, VoiceMessageState.Button.Play,
progress = 1f, progress = 1f,
time = "1:00", time = "1:00",
showCursor = true,
), ),
aVoiceMessageState( aVoiceMessageState(
VoiceMessageState.Button.Pause, VoiceMessageState.Button.Pause,
progress = 0.2f, progress = 0.2f,
time = "10:00", time = "10:00",
showCursor = true,
), ),
aVoiceMessageState( aVoiceMessageState(
VoiceMessageState.Button.Disabled, VoiceMessageState.Button.Disabled,
@ -53,9 +55,11 @@ fun aVoiceMessageState(
button: VoiceMessageState.Button = VoiceMessageState.Button.Play, button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
progress: Float = 0f, progress: Float = 0f,
time: String = "1:00", time: String = "1:00",
showCursor: Boolean = false,
) = VoiceMessageState( ) = VoiceMessageState(
button = button, button = button,
progress = progress, progress = progress,
time = time, time = time,
showCursor = showCursor,
eventSink = {}, eventSink = {},
) )

View file

@ -16,15 +16,17 @@
package io.element.android.features.messages.voicemessages.timeline package io.element.android.features.messages.voicemessages.timeline
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.features.messages.impl.voicemessages.timeline.DefaultVoiceMessagePlayer import io.element.android.features.messages.impl.voicemessages.timeline.DefaultVoiceMessagePlayer
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageMediaRepo import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageMediaRepo
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessagePlayer
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -33,68 +35,176 @@ class DefaultVoiceMessagePlayerTest {
@Test @Test
fun `initial state`() = runTest { fun `initial state`() = runTest {
createDefaultVoiceMessagePlayer().state.test { createDefaultVoiceMessagePlayer().state.test {
awaitItem().let { matchInitialState()
Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isMyMedia).isEqualTo(false)
Truth.assertThat(it.currentPosition).isEqualTo(0)
}
} }
} }
@Test @Test
fun `downloading and play works`() = runTest { fun `prepare succeeds`() = runTest {
val player = createDefaultVoiceMessagePlayer() val player = createDefaultVoiceMessagePlayer()
player.state.test { player.state.test {
skipItems(1) // skip initial state. matchInitialState()
Truth.assertThat(player.play().isSuccess).isTrue() Truth.assertThat(player.prepare().isSuccess).isTrue()
awaitItem().let { matchReadyState()
Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isMyMedia).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(0)
}
awaitItem().let {
Truth.assertThat(it.isPlaying).isEqualTo(true)
Truth.assertThat(it.isMyMedia).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(1000)
}
} }
} }
@Test @Test
fun `downloading and play fails`() = runTest { fun `prepare fails when repo fails`() = runTest {
val player = createDefaultVoiceMessagePlayer( val player = createDefaultVoiceMessagePlayer(
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply {
shouldFail = true shouldFail = true
}, },
) )
player.state.test { player.state.test {
skipItems(1) // skip initial state. matchInitialState()
Truth.assertThat(player.play().isFailure).isTrue() Truth.assertThat(player.prepare().isFailure).isTrue()
} }
} }
@Test @Test
fun `play fails with no eventId`() = runTest { fun `prepare fails with no eventId`() = runTest {
val player = createDefaultVoiceMessagePlayer( val player = createDefaultVoiceMessagePlayer(
eventId = null eventId = null
) )
player.state.test { player.state.test {
skipItems(1) // skip initial state. matchInitialState()
Truth.assertThat(player.play().isFailure).isTrue() Truth.assertThat(player.prepare().isFailure).isTrue()
} }
} }
@Test @Test
fun `pause playing works`() = runTest { fun `play after prepare works`() = runTest {
val player = createDefaultVoiceMessagePlayer() val player = createDefaultVoiceMessagePlayer()
player.state.test { player.state.test {
skipItems(1) // skip initial state. matchInitialState()
Truth.assertThat(player.play().isSuccess).isTrue() Truth.assertThat(player.prepare().isSuccess).isTrue()
skipItems(2) // skip play states matchReadyState()
player.play()
awaitItem().let {
Truth.assertThat(it.isPlaying).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(1000)
}
}
}
@Test
fun `play reaches end of media`() = runTest {
val player = createDefaultVoiceMessagePlayer(
mediaPlayer = FakeMediaPlayer(
fakeTotalDurationMs = 1000,
fakePlayedDurationMs = 1000
)
)
player.state.test {
matchInitialState()
Truth.assertThat(player.prepare().isSuccess).isTrue()
matchReadyState(fakeTotalDurationMs = 1000)
player.play()
awaitItem().let {
Truth.assertThat(it.isReady).isEqualTo(false)
Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isEnded).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(1000)
Truth.assertThat(it.duration).isEqualTo(1000)
}
}
}
@Test
fun `player1 plays again after both player1 and player2 are finished`() = runTest {
val mediaPlayer = FakeMediaPlayer(
fakeTotalDurationMs = 1_000L,
fakePlayedDurationMs = 1_000L,
)
val player1 = createDefaultVoiceMessagePlayer(mediaPlayer = mediaPlayer)
val player2 = createDefaultVoiceMessagePlayer(mediaPlayer = mediaPlayer)
// Play player1 until the end.
player1.state.test {
matchInitialState()
Truth.assertThat(player1.prepare().isSuccess).isTrue()
matchReadyState(1_000L)
player1.play()
awaitItem().let { // it plays until the end.
Truth.assertThat(it.isReady).isEqualTo(false)
Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isEnded).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(1000)
Truth.assertThat(it.duration).isEqualTo(1000)
}
}
// Play player2 until the end.
player2.state.test {
matchInitialState()
Truth.assertThat(player2.prepare().isSuccess).isTrue()
awaitItem().let { // Additional spurious state due to MediaPlayer owner change.
Truth.assertThat(it.isReady).isEqualTo(false)
Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isEnded).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(1000)
Truth.assertThat(it.duration).isEqualTo(1000)
}
awaitItem().let {// Additional spurious state due to MediaPlayer owner change.
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)
}
matchReadyState(1_000L)
player2.play()
awaitItem().let { // it plays until the end.
Truth.assertThat(it.isReady).isEqualTo(false)
Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isEnded).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(1000)
Truth.assertThat(it.duration).isEqualTo(1000)
}
}
// Play player1 again.
player1.state.test {
awaitItem().let {// Last previous state/
Truth.assertThat(it.isReady).isEqualTo(false)
Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isEnded).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(1000)
Truth.assertThat(it.duration).isEqualTo(1000)
}
Truth.assertThat(player1.prepare().isSuccess).isTrue()
awaitItem().let {// Additional spurious state due to MediaPlayer owner change.
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)
}
matchReadyState(1_000L)
player1.play()
awaitItem().let { // it played again until the end.
Truth.assertThat(it.isReady).isEqualTo(false)
Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isEnded).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(1000)
Truth.assertThat(it.duration).isEqualTo(1000)
}
}
}
@Test
fun `pause after play pauses`() = runTest {
val player = createDefaultVoiceMessagePlayer()
player.state.test {
matchInitialState()
Truth.assertThat(player.prepare().isSuccess).isTrue()
matchReadyState()
player.play()
skipItems(1) // skip play state
player.pause() player.pause()
awaitItem().let { awaitItem().let {
Truth.assertThat(it.isPlaying).isEqualTo(false) Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isMyMedia).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(1000) Truth.assertThat(it.currentPosition).isEqualTo(1000)
} }
} }
@ -104,72 +214,72 @@ class DefaultVoiceMessagePlayerTest {
fun `play after pause works`() = runTest { fun `play after pause works`() = runTest {
val player = createDefaultVoiceMessagePlayer() val player = createDefaultVoiceMessagePlayer()
player.state.test { player.state.test {
skipItems(1) // skip initial state. matchInitialState()
Truth.assertThat(player.play().isSuccess).isTrue() Truth.assertThat(player.prepare().isSuccess).isTrue()
skipItems(2) // skip play states matchReadyState()
player.play()
skipItems(1) // skip play state
player.pause() player.pause()
skipItems(1) skipItems(1) // skip pause state
player.play() player.play()
awaitItem().let { awaitItem().let {
Truth.assertThat(it.isPlaying).isEqualTo(true) Truth.assertThat(it.isPlaying).isEqualTo(true)
Truth.assertThat(it.isMyMedia).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(2000) Truth.assertThat(it.currentPosition).isEqualTo(2000)
} }
} }
} }
@Test @Test
fun `download and seek to works`() = runTest { fun `seek before prepare works`() = runTest {
val player = createDefaultVoiceMessagePlayer() val player = createDefaultVoiceMessagePlayer()
player.state.test { player.state.test {
skipItems(1) // skip initial state. matchInitialState()
Truth.assertThat(player.seekTo(2000).isSuccess).isTrue()
player.seekTo(2000) player.seekTo(2000)
awaitItem().let { awaitItem().let {
Truth.assertThat(it.isReady).isEqualTo(false)
Truth.assertThat(it.isPlaying).isEqualTo(false) Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isMyMedia).isEqualTo(true) Truth.assertThat(it.isEnded).isEqualTo(false)
Truth.assertThat(it.currentPosition).isEqualTo(0)
}
awaitItem().let {
Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isMyMedia).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(2000) Truth.assertThat(it.currentPosition).isEqualTo(2000)
Truth.assertThat(it.duration).isEqualTo(null)
}
Truth.assertThat(player.prepare().isSuccess).isTrue()
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(2000)
Truth.assertThat(it.duration).isEqualTo(FAKE_TOTAL_DURATION_MS)
} }
} }
} }
@Test @Test
fun `download and seek to fails`() = runTest { fun `seek after prepare works`() = runTest {
val player = createDefaultVoiceMessagePlayer(
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply {
shouldFail = true
},
)
player.state.test {
skipItems(1) // skip initial state.
Truth.assertThat(player.seekTo(2000).isFailure).isTrue()
}
}
@Test
fun `seek to works`() = runTest {
val player = createDefaultVoiceMessagePlayer() val player = createDefaultVoiceMessagePlayer()
player.state.test { player.state.test {
skipItems(1) // skip initial state. matchInitialState()
Truth.assertThat(player.play().isSuccess).isTrue() Truth.assertThat(player.prepare().isSuccess).isTrue()
skipItems(2) // skip play states matchReadyState()
player.seekTo(2000) player.seekTo(2000)
awaitItem().let { awaitItem().let {
Truth.assertThat(it.isPlaying).isEqualTo(true) Truth.assertThat(it.isReady).isEqualTo(true)
Truth.assertThat(it.isMyMedia).isEqualTo(true) Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isEnded).isEqualTo(false)
Truth.assertThat(it.currentPosition).isEqualTo(2000) Truth.assertThat(it.currentPosition).isEqualTo(2000)
Truth.assertThat(it.duration).isEqualTo(FAKE_TOTAL_DURATION_MS)
} }
} }
} }
} }
private const val FAKE_TOTAL_DURATION_MS = 10_000L
private const val FAKE_PLAYED_DURATION_MS = 1000L
private fun createDefaultVoiceMessagePlayer( private fun createDefaultVoiceMessagePlayer(
mediaPlayer: MediaPlayer = FakeMediaPlayer(), mediaPlayer: MediaPlayer = FakeMediaPlayer(
fakeTotalDurationMs = FAKE_TOTAL_DURATION_MS,
fakePlayedDurationMs = FAKE_PLAYED_DURATION_MS
),
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(), voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
eventId: EventId? = AN_EVENT_ID, eventId: EventId? = AN_EVENT_ID,
) = DefaultVoiceMessagePlayer( ) = DefaultVoiceMessagePlayer(
@ -185,3 +295,25 @@ private fun createDefaultVoiceMessagePlayer(
) )
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg" private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
private suspend fun TurbineTestContext<VoiceMessagePlayer.State>.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<VoiceMessagePlayer.State>.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)
}
}

View file

@ -53,6 +53,7 @@ class VoiceMessagePresenterTest {
@Test @Test
fun `pressing play downloads and plays`() = runTest { fun `pressing play downloads and plays`() = runTest {
val presenter = createVoiceMessagePresenter( val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(durationMs = 2_000), content = aTimelineItemVoiceContent(durationMs = 2_000),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -88,6 +89,7 @@ class VoiceMessagePresenterTest {
fun `pressing play downloads and fails`() = runTest { fun `pressing play downloads and fails`() = runTest {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val presenter = createVoiceMessagePresenter( val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true }, voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
analyticsService = analyticsService, analyticsService = analyticsService,
content = aTimelineItemVoiceContent(durationMs = 2_000), content = aTimelineItemVoiceContent(durationMs = 2_000),
@ -125,6 +127,7 @@ class VoiceMessagePresenterTest {
@Test @Test
fun `pressing pause while playing pauses`() = runTest { fun `pressing pause while playing pauses`() = runTest {
val presenter = createVoiceMessagePresenter( val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(durationMs = 2_000), content = aTimelineItemVoiceContent(durationMs = 2_000),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -171,8 +174,9 @@ class VoiceMessagePresenterTest {
} }
@Test @Test
fun `seeking downloads and seeks`() = runTest { fun `seeking before play`() = runTest {
val presenter = createVoiceMessagePresenter( val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(durationMs = 10_000), content = aTimelineItemVoiceContent(durationMs = 10_000),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -186,21 +190,6 @@ class VoiceMessagePresenterTest {
initialState.eventSink(VoiceMessageEvents.Seek(0.5f)) 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 { awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
Truth.assertThat(it.progress).isEqualTo(0.5f) Truth.assertThat(it.progress).isEqualTo(0.5f)
@ -210,45 +199,7 @@ class VoiceMessagePresenterTest {
} }
@Test @Test
fun `seeking downloads and fails`() = runTest { fun `seeking after play`() = 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 {
val presenter = createVoiceMessagePresenter( val presenter = createVoiceMessagePresenter(
content = aTimelineItemVoiceContent(durationMs = 10_000), content = aTimelineItemVoiceContent(durationMs = 10_000),
) )
@ -283,13 +234,14 @@ class VoiceMessagePresenterTest {
} }
fun TestScope.createVoiceMessagePresenter( fun TestScope.createVoiceMessagePresenter(
mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(),
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(), voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
content: TimelineItemVoiceContent = aTimelineItemVoiceContent(), content: TimelineItemVoiceContent = aTimelineItemVoiceContent(),
) = VoiceMessagePresenter( ) = VoiceMessagePresenter(
voiceMessagePlayerFactory = { eventId, mediaSource, mimeType, body -> voiceMessagePlayerFactory = { eventId, mediaSource, mimeType, body ->
DefaultVoiceMessagePlayer( DefaultVoiceMessagePlayer(
mediaPlayer = FakeMediaPlayer(), mediaPlayer = mediaPlayer,
voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo }, voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
eventId = eventId, eventId = eventId,
mediaSource = mediaSource, mediaSource = mediaSource,

View file

@ -38,6 +38,7 @@ interface MediaPlayer : AutoCloseable {
uri: String, uri: String,
mediaId: String, mediaId: String,
mimeType: String, mimeType: String,
startPositionMs: Long = 0,
): State ): State
/** /**
@ -69,6 +70,10 @@ interface MediaPlayer : AutoCloseable {
* Whether the player is currently playing. * Whether the player is currently playing.
*/ */
val isPlaying: Boolean, 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. * The id of the media which is currently playing.
* *

View file

@ -77,6 +77,7 @@ class MediaPlayerImpl @Inject constructor(
_state.update { _state.update {
it.copy( it.copy(
isReady = playbackState == Player.STATE_READY, isReady = playbackState == Player.STATE_READY,
isEnded = playbackState == Player.STATE_ENDED,
currentPosition = player.currentPosition, currentPosition = player.currentPosition,
duration = duration, duration = duration,
) )
@ -95,16 +96,22 @@ class MediaPlayerImpl @Inject constructor(
MediaPlayer.State( MediaPlayer.State(
isReady = false, isReady = false,
isPlaying = false, isPlaying = false,
isEnded = false,
mediaId = null, mediaId = null,
currentPosition = 0L, currentPosition = 0L,
duration = 0L duration = null,
) )
) )
override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow() override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow()
@OptIn(FlowPreview::class) @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.pause() // Must pause here otherwise if the player was playing it would keep on playing the new media item.
player.clearMediaItems() player.clearMediaItems()
player.setMediaItem( player.setMediaItem(
@ -112,7 +119,8 @@ class MediaPlayerImpl @Inject constructor(
.setUri(uri) .setUri(uri)
.setMediaId(mediaId) .setMediaId(mediaId)
.setMimeType(mimeType) .setMimeType(mimeType)
.build() .build(),
startPositionMs,
) )
player.prepare() player.prepare()
// Will throw TimeoutCancellationException if the player is not ready after 1 second. // Will throw TimeoutCancellationException if the player is not ready after 1 second.
@ -129,7 +137,7 @@ class MediaPlayerImpl @Inject constructor(
// playing no sound. // playing no sound.
// This is a workaround which will reload the media file. // This is a workaround which will reload the media file.
player.getCurrentMediaItem()?.let { player.getCurrentMediaItem()?.let {
player.setMediaItem(it) player.setMediaItem(it, 0)
player.prepare() player.prepare()
player.play() player.play()
} }

View file

@ -35,7 +35,7 @@ interface SimplePlayer {
val playbackState: Int val playbackState: Int
val duration: Long val duration: Long
fun clearMediaItems() fun clearMediaItems()
fun setMediaItem(mediaItem: MediaItem) fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long)
fun getCurrentMediaItem(): MediaItem? fun getCurrentMediaItem(): MediaItem?
fun prepare() fun prepare()
fun play() fun play()
@ -81,7 +81,7 @@ class SimplePlayerImpl(
override fun clearMediaItems() = p.clearMediaItems() 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 override fun getCurrentMediaItem(): MediaItem? = p.currentMediaItem

View file

@ -26,31 +26,37 @@ import kotlinx.coroutines.flow.update
/** /**
* Fake implementation of [MediaPlayer] for testing purposes. * Fake implementation of [MediaPlayer] for testing purposes.
*/ */
class FakeMediaPlayer : MediaPlayer { class FakeMediaPlayer(
companion object { private val fakeTotalDurationMs: Long = 10_000L,
private const val FAKE_TOTAL_DURATION_MS = 10_000L private val fakePlayedDurationMs: Long = 1000L,
private const val FAKE_PLAYED_DURATION_MS = 1000L ) : MediaPlayer {
}
private val _state = MutableStateFlow( private val _state = MutableStateFlow(
MediaPlayer.State( MediaPlayer.State(
isReady = false, isReady = false,
isPlaying = false, isPlaying = false,
isEnded = false,
mediaId = null, mediaId = null,
currentPosition = 0L, currentPosition = 0L,
duration = 0L duration = null
) )
) )
override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow() override val state: StateFlow<MediaPlayer.State> = _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 { _state.update {
it.copy( it.copy(
isReady = false, isReady = false,
isPlaying = false, isPlaying = false,
isEnded = false,
mediaId = mediaId, mediaId = mediaId,
currentPosition = 0, currentPosition = startPositionMs,
duration = null, duration = null,
) )
} }
@ -58,7 +64,7 @@ class FakeMediaPlayer : MediaPlayer {
_state.update { _state.update {
it.copy( it.copy(
isReady = true, isReady = true,
duration = FAKE_TOTAL_DURATION_MS, duration = fakeTotalDurationMs,
) )
} }
return _state.value return _state.value
@ -66,10 +72,20 @@ class FakeMediaPlayer : MediaPlayer {
override fun play() { override fun play() {
_state.update { _state.update {
it.copy( val newPosition = it.currentPosition + fakePlayedDurationMs
isPlaying = true, if (newPosition < fakeTotalDurationMs) {
currentPosition = it.currentPosition + FAKE_PLAYED_DURATION_MS, it.copy(
) isPlaying = true,
currentPosition = newPosition,
)
} else {
it.copy(
isReady = false,
isPlaying = false,
isEnded = true,
currentPosition = fakeTotalDurationMs,
)
}
} }
} }