Improve audio focus management (#4707)
* Extract Audio focus management to its own modules. * Request Audio focus when playing a voice message. * Add missing dependency. (and remove a duplicated one) * Request Audio focus when playing a video/audio in the media viewer. * Pause audio when audio focus is lost. * Rename class * Fix tests * Fix detekt issue. * Audio focus: let the system handle automatic ducking when playing media. * Document and update API * Remove useless space.
This commit is contained in:
parent
e6b1c196d6
commit
0255111528
24 changed files with 341 additions and 49 deletions
|
|
@ -67,6 +67,7 @@ dependencies {
|
|||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.audio.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ package io.element.android.features.call.impl.ui
|
|||
import android.Manifest
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Rational
|
||||
|
|
@ -46,6 +43,8 @@ import io.element.android.features.call.impl.utils.CallIntentDataParser
|
|||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.audio.api.AudioFocusRequester
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
||||
|
|
@ -65,16 +64,12 @@ class ElementCallActivity :
|
|||
@Inject lateinit var enterpriseService: EnterpriseService
|
||||
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
|
||||
@Inject lateinit var buildMeta: BuildMeta
|
||||
@Inject lateinit var audioFocus: AudioFocus
|
||||
|
||||
private lateinit var presenter: Presenter<CallScreenState>
|
||||
|
||||
private lateinit var audioManager: AudioManager
|
||||
|
||||
private var requestPermissionCallback: RequestPermissionCallback? = null
|
||||
|
||||
private var audiofocusRequest: AudioFocusRequest? = null
|
||||
private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
|
||||
|
||||
private val requestPermissionsLauncher = registerPermissionResultLauncher()
|
||||
|
||||
private val webViewTarget = mutableStateOf<CallType?>(null)
|
||||
|
|
@ -102,8 +97,6 @@ class ElementCallActivity :
|
|||
|
||||
pictureInPicturePresenter.setPipView(this)
|
||||
|
||||
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
||||
|
||||
setContent {
|
||||
val pipState = pictureInPicturePresenter.present()
|
||||
ListenToAndroidEvents(pipState)
|
||||
|
|
@ -133,7 +126,13 @@ class ElementCallActivity :
|
|||
}
|
||||
|
||||
private fun setCallIsActive() {
|
||||
requestAudioFocus()
|
||||
audioFocus.requestAudioFocus(
|
||||
requester = AudioFocusRequester.ElementCall,
|
||||
onFocusLost = {
|
||||
// If the audio focus is lost, we do not stop the call.
|
||||
Timber.tag(loggerTag.value).w("Audio focus lost")
|
||||
}
|
||||
)
|
||||
CallForegroundService.start(this)
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +174,7 @@ class ElementCallActivity :
|
|||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
releaseAudioFocus()
|
||||
audioFocus.releaseAudioFocus()
|
||||
CallForegroundService.stop(this)
|
||||
pictureInPicturePresenter.setPipView(null)
|
||||
}
|
||||
|
|
@ -241,37 +240,6 @@ class ElementCallActivity :
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun requestAudioFocus() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.build()
|
||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(audioAttributes)
|
||||
.build()
|
||||
audioManager.requestAudioFocus(request)
|
||||
audiofocusRequest = request
|
||||
} else {
|
||||
val listener = AudioManager.OnAudioFocusChangeListener { }
|
||||
audioManager.requestAudioFocus(
|
||||
listener,
|
||||
AudioManager.STREAM_VOICE_CALL,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||
)
|
||||
audioFocusChangeListener = listener
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun releaseAudioFocus() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audiofocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
audioFocusChangeListener?.let { audioManager.abandonAudioFocus(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
override fun setPipParams() {
|
||||
setPictureInPictureParams(getPictureInPictureParams())
|
||||
|
|
|
|||
13
libraries/audio/api/build.gradle.kts
Normal file
13
libraries/audio/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright 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.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.audio.api"
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright 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.audio.api
|
||||
|
||||
enum class AudioFocusRequester {
|
||||
ElementCall,
|
||||
VoiceMessage,
|
||||
MediaViewer,
|
||||
}
|
||||
|
||||
interface AudioFocus {
|
||||
/**
|
||||
* Request audio focus for the given requester.
|
||||
* @param requester The mode for which to request audio focus.
|
||||
* @param onFocusLost Callback to be invoked when the audio focus is lost.
|
||||
* @return true if the audio focus was successfully requested, false otherwise.
|
||||
*/
|
||||
fun requestAudioFocus(
|
||||
requester: AudioFocusRequester,
|
||||
onFocusLost: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Release the audio focus.
|
||||
*/
|
||||
fun releaseAudioFocus()
|
||||
}
|
||||
25
libraries/audio/impl/build.gradle.kts
Normal file
25
libraries/audio/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import extension.setupAnvil
|
||||
|
||||
/*
|
||||
* Copyright 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.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.audio.impl"
|
||||
}
|
||||
|
||||
setupAnvil()
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.audio.api)
|
||||
|
||||
implementation(libs.androidx.corektx)
|
||||
implementation(libs.dagger)
|
||||
implementation(projects.libraries.di)
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright 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.audio.impl
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.audio.api.AudioFocusRequester
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultAudioFocus @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : AudioFocus {
|
||||
private val audioManager = requireNotNull(context.getSystemService<AudioManager>())
|
||||
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun requestAudioFocus(
|
||||
requester: AudioFocusRequester,
|
||||
onFocusLost: () -> Unit,
|
||||
) {
|
||||
val listener = AudioManager.OnAudioFocusChangeListener {
|
||||
when (it) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
// Do nothing
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS,
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
onFocusLost()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(requester.toAudioUsage())
|
||||
.build()
|
||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(audioAttributes)
|
||||
.setOnAudioFocusChangeListener(listener)
|
||||
.setWillPauseWhenDucked(requester.willPausedWhenDucked())
|
||||
.build()
|
||||
audioManager.requestAudioFocus(request)
|
||||
audioFocusRequest = request
|
||||
} else {
|
||||
audioManager.requestAudioFocus(
|
||||
listener,
|
||||
requester.toAudioStream(),
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||
)
|
||||
audioFocusChangeListener = listener
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun releaseAudioFocus() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
audioFocusChangeListener?.let { audioManager.abandonAudioFocus(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioFocusRequester.toAudioUsage(): Int {
|
||||
return when (this) {
|
||||
AudioFocusRequester.ElementCall,
|
||||
AudioFocusRequester.VoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION
|
||||
AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioFocusRequester.toAudioStream(): Int {
|
||||
return when (this) {
|
||||
AudioFocusRequester.ElementCall,
|
||||
AudioFocusRequester.VoiceMessage -> AudioManager.STREAM_VOICE_CALL
|
||||
AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioFocusRequester.willPausedWhenDucked(): Boolean {
|
||||
return when (this) {
|
||||
// (note that for Element Call, there is no action when the focus is lost)
|
||||
AudioFocusRequester.ElementCall,
|
||||
AudioFocusRequester.VoiceMessage -> true
|
||||
// For the MediaViewer, we let the system automatically handle the ducking
|
||||
// https://developer.android.com/media/optimize/audio-focus#automatic-ducking
|
||||
AudioFocusRequester.MediaViewer -> false
|
||||
}
|
||||
}
|
||||
19
libraries/audio/test/build.gradle.kts
Normal file
19
libraries/audio/test/build.gradle.kts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright 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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.audio.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.audio.api)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright 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.test
|
||||
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.audio.api.AudioFocusRequester
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeAudioFocus(
|
||||
private val requestAudioFocusResult: (AudioFocusRequester, () -> Unit) -> Unit = { _, _ -> lambdaError() },
|
||||
private val releaseAudioFocusResult: () -> Unit = { lambdaError() },
|
||||
) : AudioFocus {
|
||||
override fun requestAudioFocus(
|
||||
requester: AudioFocusRequester,
|
||||
onFocusLost: () -> Unit,
|
||||
) {
|
||||
requestAudioFocusResult(requester, onFocusLost)
|
||||
}
|
||||
|
||||
override fun releaseAudioFocus() {
|
||||
releaseAudioFocusResult()
|
||||
}
|
||||
}
|
||||
|
|
@ -21,11 +21,13 @@ dependencies {
|
|||
implementation(libs.androidx.media3.exoplayer)
|
||||
|
||||
implementation(libs.dagger)
|
||||
implementation(projects.libraries.audio.api)
|
||||
implementation(projects.libraries.di)
|
||||
|
||||
implementation(libs.coroutines.core)
|
||||
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(projects.libraries.audio.test)
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.mockk)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import androidx.media3.common.C
|
|||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
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.SingleIn
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
|
|
@ -36,6 +38,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||
class DefaultMediaPlayer @Inject constructor(
|
||||
private val player: SimplePlayer,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val audioFocus: AudioFocus,
|
||||
) : MediaPlayer {
|
||||
private val listener = object : SimplePlayer.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
|
|
@ -49,6 +52,7 @@ class DefaultMediaPlayer @Inject constructor(
|
|||
if (isPlaying) {
|
||||
job = coroutineScope.launch { updateCurrentPosition() }
|
||||
} else {
|
||||
audioFocus.releaseAudioFocus()
|
||||
job?.cancel()
|
||||
}
|
||||
}
|
||||
|
|
@ -118,6 +122,14 @@ class DefaultMediaPlayer @Inject constructor(
|
|||
}
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ interface SimplePlayer {
|
|||
fun getCurrentMediaItem(): MediaItem?
|
||||
fun prepare()
|
||||
fun play()
|
||||
fun isPlaying(): Boolean
|
||||
fun pause()
|
||||
fun seekTo(positionMs: Long)
|
||||
fun release()
|
||||
|
|
@ -80,6 +81,8 @@ class DefaultSimplePlayer(
|
|||
|
||||
override fun play() = p.play()
|
||||
|
||||
override fun isPlaying() = p.isPlaying
|
||||
|
||||
override fun pause() = p.pause()
|
||||
|
||||
override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ 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
|
||||
|
|
@ -50,8 +53,15 @@ class DefaultMediaPlayerTest {
|
|||
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()
|
||||
|
|
@ -67,6 +77,7 @@ class DefaultMediaPlayerTest {
|
|||
)
|
||||
sut.play()
|
||||
playLambda.assertions().isCalledOnce()
|
||||
requestAudioFocusResult.assertions().isCalledOnce()
|
||||
player.durationResult = 123L
|
||||
player.simulateIsPlayingChanged(true)
|
||||
val playingState = awaitItem()
|
||||
|
|
@ -105,6 +116,7 @@ class DefaultMediaPlayerTest {
|
|||
player.pause()
|
||||
pauseLambda.assertions().isCalledOnce()
|
||||
player.simulateIsPlayingChanged(false)
|
||||
releaseAudioFocusResult.assertions().isCalledOnce()
|
||||
assertThat(awaitItem()).isEqualTo(
|
||||
MediaPlayer.State(
|
||||
isReady = false,
|
||||
|
|
@ -126,8 +138,13 @@ class DefaultMediaPlayerTest {
|
|||
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()
|
||||
|
|
@ -144,6 +161,7 @@ class DefaultMediaPlayerTest {
|
|||
player.playbackStateResult = Player.STATE_ENDED
|
||||
sut.play()
|
||||
playLambda.assertions().isCalledOnce()
|
||||
requestAudioFocusResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +171,10 @@ class DefaultMediaPlayerTest {
|
|||
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,
|
||||
|
|
@ -161,6 +183,7 @@ class DefaultMediaPlayerTest {
|
|||
)
|
||||
val sut = createDefaultMediaPlayer(
|
||||
simplePlayer = player,
|
||||
audioFocus = audioFocus,
|
||||
)
|
||||
sut.state.test {
|
||||
val initialState = awaitItem()
|
||||
|
|
@ -182,6 +205,7 @@ class DefaultMediaPlayerTest {
|
|||
)
|
||||
prepareLambda.assertions().isCalledOnce()
|
||||
playLambda.assertions().isCalledOnce()
|
||||
requestAudioFocusResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -395,8 +419,10 @@ class DefaultMediaPlayerTest {
|
|||
|
||||
private fun TestScope.createDefaultMediaPlayer(
|
||||
simplePlayer: SimplePlayer = FakeSimplePlayer(),
|
||||
audioFocus: AudioFocus = FakeAudioFocus(),
|
||||
): DefaultMediaPlayer = DefaultMediaPlayer(
|
||||
simplePlayer,
|
||||
backgroundScope,
|
||||
player = simplePlayer,
|
||||
coroutineScope = backgroundScope,
|
||||
audioFocus = audioFocus,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class FakeSimplePlayer(
|
|||
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() },
|
||||
|
|
@ -40,6 +41,7 @@ class FakeSimplePlayer(
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ dependencies {
|
|||
implementation(projects.features.viewfolder.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.audio.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.dateformatter.api)
|
||||
implementation(projects.libraries.di)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
|
||||
|
|
@ -23,6 +24,7 @@ import javax.inject.Inject
|
|||
@ContributesBinding(AppScope::class)
|
||||
class DefaultLocalMediaRenderer @Inject constructor(
|
||||
private val textFileViewer: TextFileViewer,
|
||||
private val audioFocus: AudioFocus,
|
||||
) : LocalMediaRenderer {
|
||||
@Composable
|
||||
override fun Render(localMedia: LocalMedia) {
|
||||
|
|
@ -37,7 +39,8 @@ class DefaultLocalMediaRenderer @Inject constructor(
|
|||
localMedia = localMedia,
|
||||
localMediaViewState = localMediaViewState,
|
||||
textFileViewer = textFileViewer,
|
||||
onClick = {}
|
||||
audioFocus = audioFocus,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ package io.element.android.libraries.mediaviewer.impl.local
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||
|
|
@ -27,6 +28,7 @@ import io.element.android.libraries.mediaviewer.impl.local.video.MediaVideoView
|
|||
fun LocalMediaView(
|
||||
localMedia: LocalMedia?,
|
||||
bottomPaddingInPixels: Int,
|
||||
audioFocus: AudioFocus?,
|
||||
onClick: () -> Unit,
|
||||
textFileViewer: TextFileViewer,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -49,6 +51,7 @@ fun LocalMediaView(
|
|||
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||
localMedia = localMedia,
|
||||
autoplay = isUserSelected,
|
||||
audioFocus = audioFocus,
|
||||
modifier = modifier,
|
||||
)
|
||||
mimeType == MimeTypes.PlainText -> TextFileView(
|
||||
|
|
@ -68,6 +71,7 @@ fun LocalMediaView(
|
|||
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||
localMedia = localMedia,
|
||||
info = mediaInfo,
|
||||
audioFocus = audioFocus,
|
||||
modifier = modifier,
|
||||
)
|
||||
else -> MediaFileView(
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import androidx.media3.ui.AspectRatioFrameLayout
|
|||
import androidx.media3.ui.PlayerView
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
|
@ -80,6 +81,7 @@ fun MediaAudioView(
|
|||
bottomPaddingInPixels: Int,
|
||||
localMedia: LocalMedia?,
|
||||
info: MediaInfo?,
|
||||
audioFocus: AudioFocus?,
|
||||
modifier: Modifier = Modifier,
|
||||
isDisplayed: Boolean = true,
|
||||
) {
|
||||
|
|
@ -91,6 +93,7 @@ fun MediaAudioView(
|
|||
exoPlayer = exoPlayer,
|
||||
localMedia = localMedia,
|
||||
info = info,
|
||||
audioFocus = audioFocus,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -104,6 +107,7 @@ private fun ExoPlayerMediaAudioView(
|
|||
exoPlayer: ExoPlayer,
|
||||
localMedia: LocalMedia?,
|
||||
info: MediaInfo?,
|
||||
audioFocus: AudioFocus?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
|
||||
|
|
@ -294,6 +298,7 @@ private fun ExoPlayerMediaAudioView(
|
|||
onToggleMute = {
|
||||
// Cannot happen for audio files
|
||||
},
|
||||
audioFocus = audioFocus,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
|
|
@ -369,6 +374,7 @@ internal fun MediaAudioViewPreview(
|
|||
bottomPaddingInPixels = 0,
|
||||
localMediaViewState = rememberLocalMediaViewState(),
|
||||
info = info,
|
||||
audioFocus = null,
|
||||
localMedia = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -32,6 +34,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.audio.api.AudioFocusRequester
|
||||
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
|
@ -41,6 +45,7 @@ import io.element.android.libraries.designsystem.theme.components.Slider
|
|||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
fun MediaPlayerControllerView(
|
||||
|
|
@ -48,8 +53,26 @@ fun MediaPlayerControllerView(
|
|||
onTogglePlay: () -> Unit,
|
||||
onSeekChange: (Float) -> Unit,
|
||||
onToggleMute: () -> Unit,
|
||||
audioFocus: AudioFocus?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (audioFocus != null) {
|
||||
val latestOnTogglePlay by rememberUpdatedState(onTogglePlay)
|
||||
LaunchedEffect(state.isPlaying) {
|
||||
if (state.isPlaying) {
|
||||
audioFocus.requestAudioFocus(
|
||||
requester = AudioFocusRequester.MediaViewer,
|
||||
onFocusLost = {
|
||||
Timber.w("Audio focus lost")
|
||||
latestOnTogglePlay()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
audioFocus.releaseAudioFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.isVisible,
|
||||
modifier = modifier,
|
||||
|
|
@ -167,5 +190,6 @@ internal fun MediaPlayerControllerViewPreview(
|
|||
onTogglePlay = {},
|
||||
onSeekChange = {},
|
||||
onToggleMute = {},
|
||||
audioFocus = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import androidx.media3.exoplayer.ExoPlayer
|
|||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
|
|
@ -63,6 +64,7 @@ fun MediaVideoView(
|
|||
bottomPaddingInPixels: Int,
|
||||
localMedia: LocalMedia?,
|
||||
autoplay: Boolean,
|
||||
audioFocus: AudioFocus?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val exoPlayer = rememberExoPlayer()
|
||||
|
|
@ -73,6 +75,7 @@ fun MediaVideoView(
|
|||
exoPlayer = exoPlayer,
|
||||
localMedia = localMedia,
|
||||
autoplay = autoplay,
|
||||
audioFocus = audioFocus,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -86,6 +89,7 @@ private fun ExoPlayerMediaVideoView(
|
|||
exoPlayer: ExoPlayer,
|
||||
localMedia: LocalMedia?,
|
||||
autoplay: Boolean,
|
||||
audioFocus: AudioFocus?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
|
||||
|
|
@ -248,6 +252,7 @@ private fun ExoPlayerMediaVideoView(
|
|||
autoHideController++
|
||||
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
||||
},
|
||||
audioFocus = audioFocus,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
|
|
@ -278,6 +283,7 @@ internal fun MediaVideoViewPreview() = ElementPreview {
|
|||
bottomPaddingInPixels = 0,
|
||||
localMediaViewState = rememberLocalMediaViewState(),
|
||||
localMedia = null,
|
||||
audioFocus = null,
|
||||
autoplay = false,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import io.element.android.anvilannotations.ContributesNode
|
|||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
|
@ -44,6 +45,7 @@ class MediaViewerNode @AssistedInject constructor(
|
|||
systemClock: SystemClock,
|
||||
pagerKeysHandler: PagerKeysHandler,
|
||||
private val textFileViewer: TextFileViewer,
|
||||
private val audioFocus: AudioFocus,
|
||||
) : Node(buildContext, plugins = plugins),
|
||||
MediaViewerNavigator {
|
||||
private val inputs = inputs<MediaViewerEntryPoint.Params>()
|
||||
|
|
@ -129,7 +131,8 @@ class MediaViewerNode @AssistedInject constructor(
|
|||
state = state,
|
||||
textFileViewer = textFileViewer,
|
||||
modifier = modifier,
|
||||
onBackClick = ::onDone
|
||||
audioFocus = audioFocus,
|
||||
onBackClick = ::onDone,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import io.element.android.compound.theme.ElementTheme
|
|||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||
|
|
@ -91,6 +92,7 @@ fun MediaViewerView(
|
|||
state: MediaViewerState,
|
||||
textFileViewer: TextFileViewer,
|
||||
onBackClick: () -> Unit,
|
||||
audioFocus: AudioFocus?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
|
|
@ -163,6 +165,7 @@ fun MediaViewerView(
|
|||
onShowOverlayChange = {
|
||||
showOverlay = it
|
||||
},
|
||||
audioFocus = audioFocus,
|
||||
isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId,
|
||||
)
|
||||
// Bottom bar
|
||||
|
|
@ -284,6 +287,7 @@ private fun MediaViewerPage(
|
|||
onRetry: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
onShowOverlayChange: (Boolean) -> Unit,
|
||||
audioFocus: AudioFocus?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val currentShowOverlay by rememberUpdatedState(showOverlay)
|
||||
|
|
@ -336,6 +340,7 @@ private fun MediaViewerPage(
|
|||
}
|
||||
},
|
||||
isUserSelected = isUserSelected,
|
||||
audioFocus = audioFocus,
|
||||
)
|
||||
ThumbnailView(
|
||||
mediaInfo = data.mediaInfo,
|
||||
|
|
@ -578,7 +583,8 @@ private fun ErrorView(
|
|||
internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
|
||||
MediaViewerView(
|
||||
state = state,
|
||||
audioFocus = null,
|
||||
textFileViewer = { _, _ -> },
|
||||
onBackClick = {}
|
||||
onBackClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
|
|||
setContent {
|
||||
MediaViewerView(
|
||||
state = state,
|
||||
audioFocus = null,
|
||||
textFileViewer = { _, _ -> },
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ setupAnvil()
|
|||
dependencies {
|
||||
api(projects.libraries.voiceplayer.api)
|
||||
|
||||
implementation(projects.libraries.audio.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
|||
implementation(project(":libraries:eventformatter:impl"))
|
||||
implementation(project(":libraries:indicator:impl"))
|
||||
implementation(project(":libraries:permissions:impl"))
|
||||
implementation(project(":libraries:push:impl"))
|
||||
implementation(project(":libraries:audio:impl"))
|
||||
implementation(project(":libraries:push:impl"))
|
||||
implementation(project(":libraries:featureflag:impl"))
|
||||
implementation(project(":libraries:pushstore:impl"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue