First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
@@ -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
}
}
@@ -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()
}
@@ -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,
)
}
@@ -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) }
}
}