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:
@@ -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
|
||||
|
||||
@@ -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<State>
|
||||
|
||||
/**
|
||||
* 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<Unit>
|
||||
suspend fun prepare(): Result<Unit>
|
||||
|
||||
/**
|
||||
* 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<Unit>
|
||||
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<VoiceMessagePlayer.State> = 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<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()
|
||||
|
||||
override suspend fun play(): Result<Unit> = acquireControl {
|
||||
mediaPlayer.play()
|
||||
override suspend fun prepare(): Result<Unit> = if (eventId != null) {
|
||||
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() {
|
||||
ifInControl {
|
||||
if (inControl()) {
|
||||
mediaPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun seekTo(positionMs: Long): Result<Unit> = 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<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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +72,19 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
||||
)
|
||||
|
||||
private val play = mutableStateOf<Async<Unit>>(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) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -35,11 +35,13 @@ open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageStat
|
||||
VoiceMessageState.Button.Play,
|
||||
progress = 1f,
|
||||
time = "1:00",
|
||||
showCursor = true,
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Pause,
|
||||
progress = 0.2f,
|
||||
time = "10:00",
|
||||
showCursor = true,
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Disabled,
|
||||
@@ -53,9 +55,11 @@ fun aVoiceMessageState(
|
||||
button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
|
||||
progress: Float = 0f,
|
||||
time: String = "1:00",
|
||||
showCursor: Boolean = false,
|
||||
) = VoiceMessageState(
|
||||
button = button,
|
||||
progress = progress,
|
||||
time = time,
|
||||
showCursor = showCursor,
|
||||
eventSink = {},
|
||||
)
|
||||
|
||||
@@ -16,15 +16,17 @@
|
||||
|
||||
package io.element.android.features.messages.voicemessages.timeline
|
||||
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
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.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.media.MediaSource
|
||||
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 org.junit.Test
|
||||
|
||||
@@ -33,68 +35,176 @@ class DefaultVoiceMessagePlayerTest {
|
||||
@Test
|
||||
fun `initial state`() = runTest {
|
||||
createDefaultVoiceMessagePlayer().state.test {
|
||||
awaitItem().let {
|
||||
Truth.assertThat(it.isPlaying).isEqualTo(false)
|
||||
Truth.assertThat(it.isMyMedia).isEqualTo(false)
|
||||
Truth.assertThat(it.currentPosition).isEqualTo(0)
|
||||
}
|
||||
matchInitialState()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloading and play works`() = runTest {
|
||||
fun `prepare succeeds`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
skipItems(1) // skip initial state.
|
||||
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||
awaitItem().let {
|
||||
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)
|
||||
}
|
||||
matchInitialState()
|
||||
Truth.assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloading and play fails`() = runTest {
|
||||
fun `prepare fails when repo fails`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer(
|
||||
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply {
|
||||
shouldFail = true
|
||||
},
|
||||
)
|
||||
player.state.test {
|
||||
skipItems(1) // skip initial state.
|
||||
Truth.assertThat(player.play().isFailure).isTrue()
|
||||
matchInitialState()
|
||||
Truth.assertThat(player.prepare().isFailure).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play fails with no eventId`() = runTest {
|
||||
fun `prepare fails with no eventId`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer(
|
||||
eventId = null
|
||||
)
|
||||
player.state.test {
|
||||
skipItems(1) // skip initial state.
|
||||
Truth.assertThat(player.play().isFailure).isTrue()
|
||||
matchInitialState()
|
||||
Truth.assertThat(player.prepare().isFailure).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pause playing works`() = runTest {
|
||||
fun `play after prepare works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
skipItems(1) // skip initial state.
|
||||
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||
skipItems(2) // skip play states
|
||||
matchInitialState()
|
||||
Truth.assertThat(player.prepare().isSuccess).isTrue()
|
||||
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()
|
||||
awaitItem().let {
|
||||
Truth.assertThat(it.isPlaying).isEqualTo(false)
|
||||
Truth.assertThat(it.isMyMedia).isEqualTo(true)
|
||||
Truth.assertThat(it.currentPosition).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
@@ -104,72 +214,72 @@ class DefaultVoiceMessagePlayerTest {
|
||||
fun `play after pause works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
skipItems(1) // skip initial state.
|
||||
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||
skipItems(2) // skip play states
|
||||
matchInitialState()
|
||||
Truth.assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
player.play()
|
||||
skipItems(1) // skip play state
|
||||
player.pause()
|
||||
skipItems(1)
|
||||
skipItems(1) // skip pause state
|
||||
player.play()
|
||||
awaitItem().let {
|
||||
Truth.assertThat(it.isPlaying).isEqualTo(true)
|
||||
Truth.assertThat(it.isMyMedia).isEqualTo(true)
|
||||
Truth.assertThat(it.currentPosition).isEqualTo(2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `download and seek to works`() = runTest {
|
||||
fun `seek before prepare works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
skipItems(1) // skip initial state.
|
||||
Truth.assertThat(player.seekTo(2000).isSuccess).isTrue()
|
||||
matchInitialState()
|
||||
player.seekTo(2000)
|
||||
awaitItem().let {
|
||||
Truth.assertThat(it.isReady).isEqualTo(false)
|
||||
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(false)
|
||||
Truth.assertThat(it.isMyMedia).isEqualTo(true)
|
||||
Truth.assertThat(it.isEnded).isEqualTo(false)
|
||||
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
|
||||
fun `download and seek to fails`() = 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 {
|
||||
fun `seek after prepare works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
skipItems(1) // skip initial state.
|
||||
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||
skipItems(2) // skip play states
|
||||
matchInitialState()
|
||||
Truth.assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
player.seekTo(2000)
|
||||
awaitItem().let {
|
||||
Truth.assertThat(it.isPlaying).isEqualTo(true)
|
||||
Truth.assertThat(it.isMyMedia).isEqualTo(true)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val FAKE_TOTAL_DURATION_MS = 10_000L
|
||||
private const val FAKE_PLAYED_DURATION_MS = 1000L
|
||||
|
||||
private fun createDefaultVoiceMessagePlayer(
|
||||
mediaPlayer: MediaPlayer = FakeMediaPlayer(),
|
||||
mediaPlayer: MediaPlayer = FakeMediaPlayer(
|
||||
fakeTotalDurationMs = FAKE_TOTAL_DURATION_MS,
|
||||
fakePlayedDurationMs = FAKE_PLAYED_DURATION_MS
|
||||
),
|
||||
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
|
||||
eventId: EventId? = AN_EVENT_ID,
|
||||
) = DefaultVoiceMessagePlayer(
|
||||
@@ -185,3 +295,25 @@ private fun createDefaultVoiceMessagePlayer(
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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<MediaPlayer.State> = _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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user