First Commit
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.mediaplayer.impl"
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.mediaplayer.api)
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
|
||||
implementation(projects.libraries.audio.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.di)
|
||||
|
||||
implementation(libs.coroutines.core)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(projects.libraries.audio.test)
|
||||
testImplementation(libs.coroutines.core)
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaplayer.impl
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.audio.api.AudioFocusRequester
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.timeout
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Default implementation of [MediaPlayer] backed by a [SimplePlayer].
|
||||
*/
|
||||
@ContributesBinding(RoomScope::class)
|
||||
@SingleIn(RoomScope::class)
|
||||
class DefaultMediaPlayer(
|
||||
private val player: SimplePlayer,
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val audioFocus: AudioFocus,
|
||||
) : MediaPlayer {
|
||||
private val listener = object : SimplePlayer.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
currentPosition = player.currentPosition,
|
||||
duration = duration,
|
||||
isPlaying = isPlaying,
|
||||
)
|
||||
}
|
||||
if (isPlaying) {
|
||||
job = sessionCoroutineScope.launch { updateCurrentPosition() }
|
||||
} else {
|
||||
audioFocus.releaseAudioFocus()
|
||||
job?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
currentPosition = player.currentPosition,
|
||||
duration = duration,
|
||||
mediaId = mediaItem?.mediaId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isReady = playbackState == Player.STATE_READY,
|
||||
isEnded = playbackState == Player.STATE_ENDED,
|
||||
currentPosition = player.currentPosition,
|
||||
duration = duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
player.addListener(listener)
|
||||
}
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 0L,
|
||||
duration = null,
|
||||
)
|
||||
)
|
||||
|
||||
override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override suspend fun setMedia(
|
||||
uri: String,
|
||||
mediaId: String,
|
||||
mimeType: String,
|
||||
startPositionMs: Long,
|
||||
): MediaPlayer.State {
|
||||
// Must pause here otherwise if the player was playing it would keep on playing the new media item.
|
||||
player.pause()
|
||||
player.clearMediaItems()
|
||||
player.setMediaItem(
|
||||
MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setMediaId(mediaId)
|
||||
.setMimeType(mimeType)
|
||||
.build(),
|
||||
startPositionMs,
|
||||
)
|
||||
player.prepare()
|
||||
// Will throw TimeoutCancellationException if the player is not ready after 1 second.
|
||||
return state.timeout(1.seconds).first { it.isReady && it.mediaId == mediaId }
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
audioFocus.requestAudioFocus(
|
||||
requester = AudioFocusRequester.VoiceMessage,
|
||||
onFocusLost = {
|
||||
if (player.isPlaying()) {
|
||||
player.pause()
|
||||
}
|
||||
},
|
||||
)
|
||||
if (player.playbackState == Player.STATE_ENDED) {
|
||||
// There's a bug with some ogg files that somehow report to
|
||||
// have no duration.
|
||||
// With such files, once playback has ended once, calling
|
||||
// player.seekTo(0) and then player.play() results in the
|
||||
// player starting and stopping playing immediately effectively
|
||||
// playing no sound.
|
||||
// This is a workaround which will reload the media file.
|
||||
player.getCurrentMediaItem()?.let {
|
||||
player.setMediaItem(it, 0)
|
||||
player.prepare()
|
||||
}
|
||||
}
|
||||
player.play()
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
player.seekTo(positionMs)
|
||||
_state.update {
|
||||
it.copy(currentPosition = player.currentPosition)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
player.release()
|
||||
}
|
||||
|
||||
private suspend fun updateCurrentPosition() {
|
||||
while (true) {
|
||||
if (!_state.value.isPlaying) return
|
||||
delay(100)
|
||||
_state.update {
|
||||
it.copy(currentPosition = player.currentPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val duration: Long?
|
||||
get() = player.duration.let {
|
||||
if (it == C.TIME_UNSET) null else it
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaplayer.impl
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
|
||||
/**
|
||||
* A subset of media3 [Player] that only exposes the few methods we need making it easier to mock.
|
||||
*/
|
||||
interface SimplePlayer {
|
||||
fun addListener(listener: Listener)
|
||||
val currentPosition: Long
|
||||
val playbackState: Int
|
||||
val duration: Long
|
||||
fun clearMediaItems()
|
||||
fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long)
|
||||
fun getCurrentMediaItem(): MediaItem?
|
||||
fun prepare()
|
||||
fun play()
|
||||
fun isPlaying(): Boolean
|
||||
fun pause()
|
||||
fun seekTo(positionMs: Long)
|
||||
fun release()
|
||||
interface Listener {
|
||||
fun onIsPlayingChanged(isPlaying: Boolean)
|
||||
fun onMediaItemTransition(mediaItem: MediaItem?)
|
||||
fun onPlaybackStateChanged(playbackState: Int)
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesTo(RoomScope::class)
|
||||
@BindingContainer
|
||||
object SimplePlayerModule {
|
||||
@Provides
|
||||
fun simplePlayerProvider(
|
||||
@ApplicationContext context: Context,
|
||||
): SimplePlayer = DefaultSimplePlayer(ExoPlayer.Builder(context).build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of [SimplePlayer] backed by a media3 [Player].
|
||||
*/
|
||||
class DefaultSimplePlayer(
|
||||
private val p: Player
|
||||
) : SimplePlayer {
|
||||
override fun addListener(listener: SimplePlayer.Listener) {
|
||||
p.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) = listener.onIsPlayingChanged(isPlaying)
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) = listener.onMediaItemTransition(mediaItem)
|
||||
override fun onPlaybackStateChanged(playbackState: Int) = listener.onPlaybackStateChanged(playbackState)
|
||||
})
|
||||
}
|
||||
|
||||
override val currentPosition: Long
|
||||
get() = p.currentPosition
|
||||
override val playbackState: Int
|
||||
get() = p.playbackState
|
||||
override val duration: Long
|
||||
get() = p.duration
|
||||
|
||||
override fun clearMediaItems() = p.clearMediaItems()
|
||||
|
||||
override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = p.setMediaItem(mediaItem, startPositionMs)
|
||||
|
||||
override fun getCurrentMediaItem(): MediaItem? = p.currentMediaItem
|
||||
|
||||
override fun prepare() = p.prepare()
|
||||
|
||||
override fun play() = p.play()
|
||||
|
||||
override fun isPlaying() = p.isPlaying
|
||||
|
||||
override fun pause() = p.pause()
|
||||
|
||||
override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
|
||||
|
||||
override fun release() = p.release()
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaplayer.impl
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.audio.api.AudioFocusRequester
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import io.element.android.libraries.mediaplayer.test.FakeAudioFocus
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultMediaPlayerTest {
|
||||
private val aMediaId = "mediaId"
|
||||
private val aMediaItem = MediaItem.Builder().setMediaId(aMediaId).build()
|
||||
|
||||
@Test
|
||||
fun `initial state`() = runTest {
|
||||
val sut = createDefaultMediaPlayer()
|
||||
sut.state.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 0,
|
||||
duration = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start player will update the current position and pause it will stop`() = runTest {
|
||||
val playLambda = lambdaRecorder<Unit> { }
|
||||
val pauseLambda = lambdaRecorder<Unit> { }
|
||||
val player = FakeSimplePlayer(
|
||||
playLambda = playLambda,
|
||||
pauseLambda = pauseLambda,
|
||||
)
|
||||
val requestAudioFocusResult = lambdaRecorder<AudioFocusRequester, () -> Unit, Unit> { _, _ -> }
|
||||
val releaseAudioFocusResult = lambdaRecorder<Unit> {}
|
||||
val audioFocus = FakeAudioFocus(
|
||||
requestAudioFocusResult = requestAudioFocusResult,
|
||||
releaseAudioFocusResult = releaseAudioFocusResult
|
||||
)
|
||||
val sut = createDefaultMediaPlayer(
|
||||
simplePlayer = player,
|
||||
audioFocus = audioFocus,
|
||||
)
|
||||
sut.state.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 0,
|
||||
duration = null,
|
||||
)
|
||||
)
|
||||
sut.play()
|
||||
playLambda.assertions().isCalledOnce()
|
||||
requestAudioFocusResult.assertions().isCalledOnce()
|
||||
player.durationResult = 123L
|
||||
player.simulateIsPlayingChanged(true)
|
||||
val playingState = awaitItem()
|
||||
assertThat(playingState).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = true,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 0,
|
||||
duration = 123,
|
||||
)
|
||||
)
|
||||
player.currentPositionResult = 1L
|
||||
assertThat(awaitItem()).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = true,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 1,
|
||||
duration = 123,
|
||||
)
|
||||
)
|
||||
player.currentPositionResult = 2L
|
||||
assertThat(awaitItem()).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = true,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 2,
|
||||
duration = 123,
|
||||
)
|
||||
)
|
||||
player.pause()
|
||||
pauseLambda.assertions().isCalledOnce()
|
||||
player.simulateIsPlayingChanged(false)
|
||||
releaseAudioFocusResult.assertions().isCalledOnce()
|
||||
assertThat(awaitItem()).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 2,
|
||||
duration = 123,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start player on ended playback will not invoke more methods if current media item is null`() = runTest {
|
||||
val playLambda = lambdaRecorder<Unit> { }
|
||||
val getCurrentMediaItemLambda = lambdaRecorder<MediaItem?> { null }
|
||||
val player = FakeSimplePlayer(
|
||||
playLambda = playLambda,
|
||||
getCurrentMediaItemLambda = getCurrentMediaItemLambda,
|
||||
)
|
||||
val requestAudioFocusResult = lambdaRecorder<AudioFocusRequester, () -> Unit, Unit> { _, _ -> }
|
||||
val audioFocus = FakeAudioFocus(
|
||||
requestAudioFocusResult = requestAudioFocusResult,
|
||||
)
|
||||
val sut = createDefaultMediaPlayer(
|
||||
simplePlayer = player,
|
||||
audioFocus = audioFocus,
|
||||
)
|
||||
sut.state.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 0,
|
||||
duration = null,
|
||||
)
|
||||
)
|
||||
player.playbackStateResult = Player.STATE_ENDED
|
||||
sut.play()
|
||||
playLambda.assertions().isCalledOnce()
|
||||
requestAudioFocusResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start player on ended playback will invoke more methods if current media item is not null`() = runTest {
|
||||
val playLambda = lambdaRecorder<Unit> { }
|
||||
val prepareLambda = lambdaRecorder<Unit> { }
|
||||
val getCurrentMediaItemLambda = lambdaRecorder<MediaItem?> { aMediaItem }
|
||||
val setMediaItemLambda = lambdaRecorder<MediaItem, Long, Unit> { _, _ -> }
|
||||
val requestAudioFocusResult = lambdaRecorder<AudioFocusRequester, () -> Unit, Unit> { _, _ -> }
|
||||
val audioFocus = FakeAudioFocus(
|
||||
requestAudioFocusResult = requestAudioFocusResult,
|
||||
)
|
||||
val player = FakeSimplePlayer(
|
||||
playLambda = playLambda,
|
||||
prepareLambda = prepareLambda,
|
||||
setMediaItemLambda = setMediaItemLambda,
|
||||
getCurrentMediaItemLambda = getCurrentMediaItemLambda,
|
||||
)
|
||||
val sut = createDefaultMediaPlayer(
|
||||
simplePlayer = player,
|
||||
audioFocus = audioFocus,
|
||||
)
|
||||
sut.state.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 0,
|
||||
duration = null,
|
||||
)
|
||||
)
|
||||
player.playbackStateResult = Player.STATE_ENDED
|
||||
sut.play()
|
||||
setMediaItemLambda.assertions().isCalledOnce().with(
|
||||
value(aMediaItem),
|
||||
value(0L),
|
||||
)
|
||||
prepareLambda.assertions().isCalledOnce()
|
||||
playLambda.assertions().isCalledOnce()
|
||||
requestAudioFocusResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pause player invokes pause on the embedded player`() = runTest {
|
||||
val pauseLambda = lambdaRecorder<Unit> { }
|
||||
val player = FakeSimplePlayer(
|
||||
pauseLambda = pauseLambda,
|
||||
)
|
||||
val sut = createDefaultMediaPlayer(
|
||||
simplePlayer = player,
|
||||
)
|
||||
sut.pause()
|
||||
pauseLambda.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close player invokes release on the embedded player`() = runTest {
|
||||
val releaseLambda = lambdaRecorder<Unit> { }
|
||||
val player = FakeSimplePlayer(
|
||||
releaseLambda = releaseLambda,
|
||||
)
|
||||
val sut = createDefaultMediaPlayer(
|
||||
simplePlayer = player,
|
||||
)
|
||||
sut.close()
|
||||
releaseLambda.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seekTo invokes release on the embedded player`() = runTest {
|
||||
val seekToLambda = lambdaRecorder<Long, Unit> { }
|
||||
val player = FakeSimplePlayer(
|
||||
seekToLambda = seekToLambda,
|
||||
)
|
||||
val sut = createDefaultMediaPlayer(
|
||||
simplePlayer = player,
|
||||
)
|
||||
sut.state.test {
|
||||
awaitItem()
|
||||
player.currentPositionResult = 33L
|
||||
sut.seekTo(33L)
|
||||
seekToLambda.assertions().isCalledOnce().with(value(33L))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 33L,
|
||||
duration = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onPlaybackStateChanged update the state`() = runTest {
|
||||
val player = FakeSimplePlayer()
|
||||
val sut = createDefaultMediaPlayer(
|
||||
simplePlayer = player,
|
||||
)
|
||||
sut.state.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 0,
|
||||
duration = null,
|
||||
)
|
||||
)
|
||||
player.currentPositionResult = 44
|
||||
player.durationResult = 123L
|
||||
player.simulatePlaybackStateChanged(Player.STATE_READY)
|
||||
val readyState = awaitItem()
|
||||
assertThat(readyState).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = true,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 44,
|
||||
duration = 123,
|
||||
)
|
||||
)
|
||||
player.simulatePlaybackStateChanged(Player.STATE_ENDED)
|
||||
val endedState = awaitItem()
|
||||
assertThat(endedState).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = true,
|
||||
mediaId = null,
|
||||
currentPosition = 44,
|
||||
duration = 123,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setMedia with timeout error`() = runTest {
|
||||
val pauseLambda = lambdaRecorder<Unit> { }
|
||||
val clearMediaItemsLambda = lambdaRecorder<Unit> { }
|
||||
val setMediaItemLambda = lambdaRecorder<MediaItem, Long, Unit> { _, _ -> }
|
||||
val prepareLambda = lambdaRecorder<Unit> { }
|
||||
val player = FakeSimplePlayer(
|
||||
pauseLambda = pauseLambda,
|
||||
clearMediaItemsLambda = clearMediaItemsLambda,
|
||||
setMediaItemLambda = setMediaItemLambda,
|
||||
prepareLambda = prepareLambda,
|
||||
)
|
||||
val sut = createDefaultMediaPlayer(
|
||||
simplePlayer = player,
|
||||
)
|
||||
sut.state.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 0,
|
||||
duration = null,
|
||||
)
|
||||
)
|
||||
@Suppress("RunCatchingNotAllowed")
|
||||
val result = runCatching {
|
||||
sut.setMedia("uri", "mediaId", "mimeType", 12)
|
||||
}
|
||||
pauseLambda.assertions().isCalledOnce()
|
||||
clearMediaItemsLambda.assertions().isCalledOnce()
|
||||
setMediaItemLambda.assertions().isCalledOnce().with(
|
||||
value(MediaItem.Builder().setUri("uri").setMediaId("mediaId").setMimeType("mimeType").build()),
|
||||
value(12L),
|
||||
)
|
||||
prepareLambda.assertions().isCalledOnce()
|
||||
assertThat(result.isFailure).isTrue()
|
||||
assertThrows(TimeoutCancellationException::class.java) {
|
||||
result.getOrThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setMedia success`() = runTest {
|
||||
var player: FakeSimplePlayer? = null
|
||||
val pauseLambda = lambdaRecorder<Unit> { }
|
||||
val clearMediaItemsLambda = lambdaRecorder<Unit> { }
|
||||
val setMediaItemLambda = lambdaRecorder<MediaItem, Long, Unit> { _, _ -> }
|
||||
val prepareLambda = lambdaRecorder<Unit> {
|
||||
player?.simulatePlaybackStateChanged(Player.STATE_READY)
|
||||
player?.simulateMediaItemTransition(aMediaItem)
|
||||
}
|
||||
player = FakeSimplePlayer(
|
||||
pauseLambda = pauseLambda,
|
||||
clearMediaItemsLambda = clearMediaItemsLambda,
|
||||
setMediaItemLambda = setMediaItemLambda,
|
||||
prepareLambda = prepareLambda,
|
||||
)
|
||||
val sut = createDefaultMediaPlayer(
|
||||
simplePlayer = player,
|
||||
)
|
||||
sut.state.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 0,
|
||||
duration = null,
|
||||
)
|
||||
)
|
||||
val state = sut.setMedia("uri", "mediaId", "mimeType", 12)
|
||||
pauseLambda.assertions().isCalledOnce()
|
||||
clearMediaItemsLambda.assertions().isCalledOnce()
|
||||
setMediaItemLambda.assertions().isCalledOnce().with(
|
||||
value(MediaItem.Builder().setUri("uri").setMediaId("mediaId").setMimeType("mimeType").build()),
|
||||
value(12L),
|
||||
)
|
||||
prepareLambda.assertions().isCalledOnce()
|
||||
|
||||
val finalState = MediaPlayer.State(
|
||||
isReady = true,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = "mediaId",
|
||||
currentPosition = 0,
|
||||
duration = 0,
|
||||
)
|
||||
assertThat(awaitItem()).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = true,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
mediaId = null,
|
||||
currentPosition = 0,
|
||||
duration = 0,
|
||||
)
|
||||
)
|
||||
assertThat(awaitItem()).isEqualTo(finalState)
|
||||
assertThat(state).isEqualTo(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createDefaultMediaPlayer(
|
||||
simplePlayer: SimplePlayer = FakeSimplePlayer(),
|
||||
audioFocus: AudioFocus = FakeAudioFocus(),
|
||||
): DefaultMediaPlayer = DefaultMediaPlayer(
|
||||
player = simplePlayer,
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
audioFocus = audioFocus,
|
||||
)
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaplayer.impl
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeSimplePlayer(
|
||||
private val clearMediaItemsLambda: () -> Unit = { lambdaError() },
|
||||
private val setMediaItemLambda: (MediaItem, Long) -> Unit = { _, _ -> lambdaError() },
|
||||
private val getCurrentMediaItemLambda: () -> MediaItem? = { lambdaError() },
|
||||
private val prepareLambda: () -> Unit = { lambdaError() },
|
||||
private val playLambda: () -> Unit = { lambdaError() },
|
||||
private val isPlayingLambda: () -> Boolean = { lambdaError() },
|
||||
private val pauseLambda: () -> Unit = { lambdaError() },
|
||||
private val seekToLambda: (Long) -> Unit = { lambdaError() },
|
||||
private val releaseLambda: () -> Unit = { lambdaError() },
|
||||
) : SimplePlayer {
|
||||
private val listeners = mutableListOf<SimplePlayer.Listener>()
|
||||
override fun addListener(listener: SimplePlayer.Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
var currentPositionResult: Long = 0
|
||||
override val currentPosition: Long get() = currentPositionResult
|
||||
var playbackStateResult: Int = 0
|
||||
override val playbackState: Int get() = playbackStateResult
|
||||
var durationResult: Long = 0
|
||||
override val duration: Long get() = durationResult
|
||||
|
||||
override fun clearMediaItems() = clearMediaItemsLambda()
|
||||
override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) {
|
||||
setMediaItemLambda(mediaItem, startPositionMs)
|
||||
}
|
||||
|
||||
override fun getCurrentMediaItem(): MediaItem? = getCurrentMediaItemLambda()
|
||||
override fun prepare() = prepareLambda()
|
||||
override fun play() = playLambda()
|
||||
override fun isPlaying() = isPlayingLambda()
|
||||
override fun pause() = pauseLambda()
|
||||
override fun seekTo(positionMs: Long) = seekToLambda(positionMs)
|
||||
override fun release() = releaseLambda()
|
||||
|
||||
fun simulateIsPlayingChanged(isPlaying: Boolean) {
|
||||
listeners.forEach { it.onIsPlayingChanged(isPlaying) }
|
||||
}
|
||||
|
||||
fun simulateMediaItemTransition(mediaItem: MediaItem?) {
|
||||
listeners.forEach { it.onMediaItemTransition(mediaItem) }
|
||||
}
|
||||
|
||||
fun simulatePlaybackStateChanged(playbackState: Int) {
|
||||
listeners.forEach { it.onPlaybackStateChanged(playbackState) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user