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:
parent
6b467d95a7
commit
eb2836f42b
11 changed files with 381 additions and 208 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue