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))
val context = LocalContext.current
WaveformPlaybackView(
showCursor = state.button == VoiceMessageState.Button.Pause,
showCursor = state.showCursor,
playbackProgress = state.progress,
waveform = content.waveform,
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.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)
}
}

View File

@@ -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) },
)
}

View File

@@ -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 {

View File

@@ -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 = {},
)

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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.
*

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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,
)
}
}
}