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
3cf8b04a07
commit
93499910ba
24 changed files with 341 additions and 49 deletions
|
|
@ -67,6 +67,7 @@ dependencies {
|
||||||
implementation(projects.features.enterprise.api)
|
implementation(projects.features.enterprise.api)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
|
implementation(projects.libraries.audio.api)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.featureflag.api)
|
implementation(projects.libraries.featureflag.api)
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ package io.element.android.features.call.impl.ui
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.media.AudioAttributes
|
|
||||||
import android.media.AudioFocusRequest
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Rational
|
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.features.enterprise.api.EnterpriseService
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.bindings
|
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.log.logger.LoggerTag
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
||||||
|
|
@ -65,16 +64,12 @@ class ElementCallActivity :
|
||||||
@Inject lateinit var enterpriseService: EnterpriseService
|
@Inject lateinit var enterpriseService: EnterpriseService
|
||||||
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
|
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
|
||||||
@Inject lateinit var buildMeta: BuildMeta
|
@Inject lateinit var buildMeta: BuildMeta
|
||||||
|
@Inject lateinit var audioFocus: AudioFocus
|
||||||
|
|
||||||
private lateinit var presenter: Presenter<CallScreenState>
|
private lateinit var presenter: Presenter<CallScreenState>
|
||||||
|
|
||||||
private lateinit var audioManager: AudioManager
|
|
||||||
|
|
||||||
private var requestPermissionCallback: RequestPermissionCallback? = null
|
private var requestPermissionCallback: RequestPermissionCallback? = null
|
||||||
|
|
||||||
private var audiofocusRequest: AudioFocusRequest? = null
|
|
||||||
private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
|
|
||||||
|
|
||||||
private val requestPermissionsLauncher = registerPermissionResultLauncher()
|
private val requestPermissionsLauncher = registerPermissionResultLauncher()
|
||||||
|
|
||||||
private val webViewTarget = mutableStateOf<CallType?>(null)
|
private val webViewTarget = mutableStateOf<CallType?>(null)
|
||||||
|
|
@ -102,8 +97,6 @@ class ElementCallActivity :
|
||||||
|
|
||||||
pictureInPicturePresenter.setPipView(this)
|
pictureInPicturePresenter.setPipView(this)
|
||||||
|
|
||||||
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val pipState = pictureInPicturePresenter.present()
|
val pipState = pictureInPicturePresenter.present()
|
||||||
ListenToAndroidEvents(pipState)
|
ListenToAndroidEvents(pipState)
|
||||||
|
|
@ -133,7 +126,13 @@ class ElementCallActivity :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setCallIsActive() {
|
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)
|
CallForegroundService.start(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +174,7 @@ class ElementCallActivity :
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
releaseAudioFocus()
|
audioFocus.releaseAudioFocus()
|
||||||
CallForegroundService.stop(this)
|
CallForegroundService.stop(this)
|
||||||
pictureInPicturePresenter.setPipView(null)
|
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)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
override fun setPipParams() {
|
override fun setPipParams() {
|
||||||
setPictureInPictureParams(getPictureInPictureParams())
|
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.androidx.media3.exoplayer)
|
||||||
|
|
||||||
implementation(libs.dagger)
|
implementation(libs.dagger)
|
||||||
|
implementation(projects.libraries.audio.api)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
|
|
||||||
implementation(libs.coroutines.core)
|
implementation(libs.coroutines.core)
|
||||||
|
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
|
testImplementation(projects.libraries.audio.test)
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.mockk)
|
testImplementation(libs.test.mockk)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
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.RoomScope
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||||
|
|
@ -36,6 +38,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
class DefaultMediaPlayer @Inject constructor(
|
class DefaultMediaPlayer @Inject constructor(
|
||||||
private val player: SimplePlayer,
|
private val player: SimplePlayer,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val audioFocus: AudioFocus,
|
||||||
) : MediaPlayer {
|
) : MediaPlayer {
|
||||||
private val listener = object : SimplePlayer.Listener {
|
private val listener = object : SimplePlayer.Listener {
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
|
@ -49,6 +52,7 @@ class DefaultMediaPlayer @Inject constructor(
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
job = coroutineScope.launch { updateCurrentPosition() }
|
job = coroutineScope.launch { updateCurrentPosition() }
|
||||||
} else {
|
} else {
|
||||||
|
audioFocus.releaseAudioFocus()
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +122,14 @@ class DefaultMediaPlayer @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun play() {
|
override fun play() {
|
||||||
|
audioFocus.requestAudioFocus(
|
||||||
|
requester = AudioFocusRequester.VoiceMessage,
|
||||||
|
onFocusLost = {
|
||||||
|
if (player.isPlaying()) {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
if (player.playbackState == Player.STATE_ENDED) {
|
if (player.playbackState == Player.STATE_ENDED) {
|
||||||
// There's a bug with some ogg files that somehow report to
|
// There's a bug with some ogg files that somehow report to
|
||||||
// have no duration.
|
// have no duration.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ interface SimplePlayer {
|
||||||
fun getCurrentMediaItem(): MediaItem?
|
fun getCurrentMediaItem(): MediaItem?
|
||||||
fun prepare()
|
fun prepare()
|
||||||
fun play()
|
fun play()
|
||||||
|
fun isPlaying(): Boolean
|
||||||
fun pause()
|
fun pause()
|
||||||
fun seekTo(positionMs: Long)
|
fun seekTo(positionMs: Long)
|
||||||
fun release()
|
fun release()
|
||||||
|
|
@ -80,6 +81,8 @@ class DefaultSimplePlayer(
|
||||||
|
|
||||||
override fun play() = p.play()
|
override fun play() = p.play()
|
||||||
|
|
||||||
|
override fun isPlaying() = p.isPlaying
|
||||||
|
|
||||||
override fun pause() = p.pause()
|
override fun pause() = p.pause()
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
|
override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.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.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
|
@ -50,8 +53,15 @@ class DefaultMediaPlayerTest {
|
||||||
playLambda = playLambda,
|
playLambda = playLambda,
|
||||||
pauseLambda = pauseLambda,
|
pauseLambda = pauseLambda,
|
||||||
)
|
)
|
||||||
|
val requestAudioFocusResult = lambdaRecorder<AudioFocusRequester, () -> Unit, Unit> { _, _ -> }
|
||||||
|
val releaseAudioFocusResult = lambdaRecorder<Unit> {}
|
||||||
|
val audioFocus = FakeAudioFocus(
|
||||||
|
requestAudioFocusResult = requestAudioFocusResult,
|
||||||
|
releaseAudioFocusResult = releaseAudioFocusResult
|
||||||
|
)
|
||||||
val sut = createDefaultMediaPlayer(
|
val sut = createDefaultMediaPlayer(
|
||||||
simplePlayer = player,
|
simplePlayer = player,
|
||||||
|
audioFocus = audioFocus,
|
||||||
)
|
)
|
||||||
sut.state.test {
|
sut.state.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
@ -67,6 +77,7 @@ class DefaultMediaPlayerTest {
|
||||||
)
|
)
|
||||||
sut.play()
|
sut.play()
|
||||||
playLambda.assertions().isCalledOnce()
|
playLambda.assertions().isCalledOnce()
|
||||||
|
requestAudioFocusResult.assertions().isCalledOnce()
|
||||||
player.durationResult = 123L
|
player.durationResult = 123L
|
||||||
player.simulateIsPlayingChanged(true)
|
player.simulateIsPlayingChanged(true)
|
||||||
val playingState = awaitItem()
|
val playingState = awaitItem()
|
||||||
|
|
@ -105,6 +116,7 @@ class DefaultMediaPlayerTest {
|
||||||
player.pause()
|
player.pause()
|
||||||
pauseLambda.assertions().isCalledOnce()
|
pauseLambda.assertions().isCalledOnce()
|
||||||
player.simulateIsPlayingChanged(false)
|
player.simulateIsPlayingChanged(false)
|
||||||
|
releaseAudioFocusResult.assertions().isCalledOnce()
|
||||||
assertThat(awaitItem()).isEqualTo(
|
assertThat(awaitItem()).isEqualTo(
|
||||||
MediaPlayer.State(
|
MediaPlayer.State(
|
||||||
isReady = false,
|
isReady = false,
|
||||||
|
|
@ -126,8 +138,13 @@ class DefaultMediaPlayerTest {
|
||||||
playLambda = playLambda,
|
playLambda = playLambda,
|
||||||
getCurrentMediaItemLambda = getCurrentMediaItemLambda,
|
getCurrentMediaItemLambda = getCurrentMediaItemLambda,
|
||||||
)
|
)
|
||||||
|
val requestAudioFocusResult = lambdaRecorder<AudioFocusRequester, () -> Unit, Unit> { _, _ -> }
|
||||||
|
val audioFocus = FakeAudioFocus(
|
||||||
|
requestAudioFocusResult = requestAudioFocusResult,
|
||||||
|
)
|
||||||
val sut = createDefaultMediaPlayer(
|
val sut = createDefaultMediaPlayer(
|
||||||
simplePlayer = player,
|
simplePlayer = player,
|
||||||
|
audioFocus = audioFocus,
|
||||||
)
|
)
|
||||||
sut.state.test {
|
sut.state.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
@ -144,6 +161,7 @@ class DefaultMediaPlayerTest {
|
||||||
player.playbackStateResult = Player.STATE_ENDED
|
player.playbackStateResult = Player.STATE_ENDED
|
||||||
sut.play()
|
sut.play()
|
||||||
playLambda.assertions().isCalledOnce()
|
playLambda.assertions().isCalledOnce()
|
||||||
|
requestAudioFocusResult.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,6 +171,10 @@ class DefaultMediaPlayerTest {
|
||||||
val prepareLambda = lambdaRecorder<Unit> { }
|
val prepareLambda = lambdaRecorder<Unit> { }
|
||||||
val getCurrentMediaItemLambda = lambdaRecorder<MediaItem?> { aMediaItem }
|
val getCurrentMediaItemLambda = lambdaRecorder<MediaItem?> { aMediaItem }
|
||||||
val setMediaItemLambda = lambdaRecorder<MediaItem, Long, Unit> { _, _ -> }
|
val setMediaItemLambda = lambdaRecorder<MediaItem, Long, Unit> { _, _ -> }
|
||||||
|
val requestAudioFocusResult = lambdaRecorder<AudioFocusRequester, () -> Unit, Unit> { _, _ -> }
|
||||||
|
val audioFocus = FakeAudioFocus(
|
||||||
|
requestAudioFocusResult = requestAudioFocusResult,
|
||||||
|
)
|
||||||
val player = FakeSimplePlayer(
|
val player = FakeSimplePlayer(
|
||||||
playLambda = playLambda,
|
playLambda = playLambda,
|
||||||
prepareLambda = prepareLambda,
|
prepareLambda = prepareLambda,
|
||||||
|
|
@ -161,6 +183,7 @@ class DefaultMediaPlayerTest {
|
||||||
)
|
)
|
||||||
val sut = createDefaultMediaPlayer(
|
val sut = createDefaultMediaPlayer(
|
||||||
simplePlayer = player,
|
simplePlayer = player,
|
||||||
|
audioFocus = audioFocus,
|
||||||
)
|
)
|
||||||
sut.state.test {
|
sut.state.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
@ -182,6 +205,7 @@ class DefaultMediaPlayerTest {
|
||||||
)
|
)
|
||||||
prepareLambda.assertions().isCalledOnce()
|
prepareLambda.assertions().isCalledOnce()
|
||||||
playLambda.assertions().isCalledOnce()
|
playLambda.assertions().isCalledOnce()
|
||||||
|
requestAudioFocusResult.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,8 +419,10 @@ class DefaultMediaPlayerTest {
|
||||||
|
|
||||||
private fun TestScope.createDefaultMediaPlayer(
|
private fun TestScope.createDefaultMediaPlayer(
|
||||||
simplePlayer: SimplePlayer = FakeSimplePlayer(),
|
simplePlayer: SimplePlayer = FakeSimplePlayer(),
|
||||||
|
audioFocus: AudioFocus = FakeAudioFocus(),
|
||||||
): DefaultMediaPlayer = DefaultMediaPlayer(
|
): DefaultMediaPlayer = DefaultMediaPlayer(
|
||||||
simplePlayer,
|
player = simplePlayer,
|
||||||
backgroundScope,
|
coroutineScope = backgroundScope,
|
||||||
|
audioFocus = audioFocus,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class FakeSimplePlayer(
|
||||||
private val getCurrentMediaItemLambda: () -> MediaItem? = { lambdaError() },
|
private val getCurrentMediaItemLambda: () -> MediaItem? = { lambdaError() },
|
||||||
private val prepareLambda: () -> Unit = { lambdaError() },
|
private val prepareLambda: () -> Unit = { lambdaError() },
|
||||||
private val playLambda: () -> Unit = { lambdaError() },
|
private val playLambda: () -> Unit = { lambdaError() },
|
||||||
|
private val isPlayingLambda: () -> Boolean = { lambdaError() },
|
||||||
private val pauseLambda: () -> Unit = { lambdaError() },
|
private val pauseLambda: () -> Unit = { lambdaError() },
|
||||||
private val seekToLambda: (Long) -> Unit = { lambdaError() },
|
private val seekToLambda: (Long) -> Unit = { lambdaError() },
|
||||||
private val releaseLambda: () -> Unit = { lambdaError() },
|
private val releaseLambda: () -> Unit = { lambdaError() },
|
||||||
|
|
@ -40,6 +41,7 @@ class FakeSimplePlayer(
|
||||||
override fun getCurrentMediaItem(): MediaItem? = getCurrentMediaItemLambda()
|
override fun getCurrentMediaItem(): MediaItem? = getCurrentMediaItemLambda()
|
||||||
override fun prepare() = prepareLambda()
|
override fun prepare() = prepareLambda()
|
||||||
override fun play() = playLambda()
|
override fun play() = playLambda()
|
||||||
|
override fun isPlaying() = isPlayingLambda()
|
||||||
override fun pause() = pauseLambda()
|
override fun pause() = pauseLambda()
|
||||||
override fun seekTo(positionMs: Long) = seekToLambda(positionMs)
|
override fun seekTo(positionMs: Long) = seekToLambda(positionMs)
|
||||||
override fun release() = releaseLambda()
|
override fun release() = releaseLambda()
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ dependencies {
|
||||||
implementation(projects.features.viewfolder.api)
|
implementation(projects.features.viewfolder.api)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
|
implementation(projects.libraries.audio.api)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.dateformatter.api)
|
implementation(projects.libraries.dateformatter.api)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
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.di.AppScope
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
|
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
|
||||||
|
|
@ -23,6 +24,7 @@ import javax.inject.Inject
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultLocalMediaRenderer @Inject constructor(
|
class DefaultLocalMediaRenderer @Inject constructor(
|
||||||
private val textFileViewer: TextFileViewer,
|
private val textFileViewer: TextFileViewer,
|
||||||
|
private val audioFocus: AudioFocus,
|
||||||
) : LocalMediaRenderer {
|
) : LocalMediaRenderer {
|
||||||
@Composable
|
@Composable
|
||||||
override fun Render(localMedia: LocalMedia) {
|
override fun Render(localMedia: LocalMedia) {
|
||||||
|
|
@ -37,7 +39,8 @@ class DefaultLocalMediaRenderer @Inject constructor(
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
textFileViewer = textFileViewer,
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
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
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
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(
|
fun LocalMediaView(
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
bottomPaddingInPixels: Int,
|
bottomPaddingInPixels: Int,
|
||||||
|
audioFocus: AudioFocus?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
textFileViewer: TextFileViewer,
|
textFileViewer: TextFileViewer,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -49,6 +51,7 @@ fun LocalMediaView(
|
||||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
autoplay = isUserSelected,
|
autoplay = isUserSelected,
|
||||||
|
audioFocus = audioFocus,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
mimeType == MimeTypes.PlainText -> TextFileView(
|
mimeType == MimeTypes.PlainText -> TextFileView(
|
||||||
|
|
@ -68,6 +71,7 @@ fun LocalMediaView(
|
||||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
info = mediaInfo,
|
info = mediaInfo,
|
||||||
|
audioFocus = audioFocus,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
else -> MediaFileView(
|
else -> MediaFileView(
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
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.components.media.WaveformPlaybackView
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -80,6 +81,7 @@ fun MediaAudioView(
|
||||||
bottomPaddingInPixels: Int,
|
bottomPaddingInPixels: Int,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
info: MediaInfo?,
|
info: MediaInfo?,
|
||||||
|
audioFocus: AudioFocus?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isDisplayed: Boolean = true,
|
isDisplayed: Boolean = true,
|
||||||
) {
|
) {
|
||||||
|
|
@ -91,6 +93,7 @@ fun MediaAudioView(
|
||||||
exoPlayer = exoPlayer,
|
exoPlayer = exoPlayer,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
info = info,
|
info = info,
|
||||||
|
audioFocus = audioFocus,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +107,7 @@ private fun ExoPlayerMediaAudioView(
|
||||||
exoPlayer: ExoPlayer,
|
exoPlayer: ExoPlayer,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
info: MediaInfo?,
|
info: MediaInfo?,
|
||||||
|
audioFocus: AudioFocus?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
|
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
|
||||||
|
|
@ -294,6 +298,7 @@ private fun ExoPlayerMediaAudioView(
|
||||||
onToggleMute = {
|
onToggleMute = {
|
||||||
// Cannot happen for audio files
|
// Cannot happen for audio files
|
||||||
},
|
},
|
||||||
|
audioFocus = audioFocus,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
|
|
@ -369,6 +374,7 @@ internal fun MediaAudioViewPreview(
|
||||||
bottomPaddingInPixels = 0,
|
bottomPaddingInPixels = 0,
|
||||||
localMediaViewState = rememberLocalMediaViewState(),
|
localMediaViewState = rememberLocalMediaViewState(),
|
||||||
info = info,
|
info = info,
|
||||||
|
audioFocus = null,
|
||||||
localMedia = null,
|
localMedia = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,11 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -32,6 +34,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
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.dateformatter.api.toHumanReadableDuration
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
|
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaPlayerControllerView(
|
fun MediaPlayerControllerView(
|
||||||
|
|
@ -48,8 +53,26 @@ fun MediaPlayerControllerView(
|
||||||
onTogglePlay: () -> Unit,
|
onTogglePlay: () -> Unit,
|
||||||
onSeekChange: (Float) -> Unit,
|
onSeekChange: (Float) -> Unit,
|
||||||
onToggleMute: () -> Unit,
|
onToggleMute: () -> Unit,
|
||||||
|
audioFocus: AudioFocus?,
|
||||||
modifier: Modifier = Modifier,
|
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(
|
AnimatedVisibility(
|
||||||
visible = state.isVisible,
|
visible = state.isVisible,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -167,5 +190,6 @@ internal fun MediaPlayerControllerViewPreview(
|
||||||
onTogglePlay = {},
|
onTogglePlay = {},
|
||||||
onSeekChange = {},
|
onSeekChange = {},
|
||||||
onToggleMute = {},
|
onToggleMute = {},
|
||||||
|
audioFocus = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import io.element.android.compound.theme.ElementTheme
|
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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.text.toDp
|
import io.element.android.libraries.designsystem.text.toDp
|
||||||
|
|
@ -63,6 +64,7 @@ fun MediaVideoView(
|
||||||
bottomPaddingInPixels: Int,
|
bottomPaddingInPixels: Int,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
autoplay: Boolean,
|
autoplay: Boolean,
|
||||||
|
audioFocus: AudioFocus?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val exoPlayer = rememberExoPlayer()
|
val exoPlayer = rememberExoPlayer()
|
||||||
|
|
@ -73,6 +75,7 @@ fun MediaVideoView(
|
||||||
exoPlayer = exoPlayer,
|
exoPlayer = exoPlayer,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
autoplay = autoplay,
|
autoplay = autoplay,
|
||||||
|
audioFocus = audioFocus,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +89,7 @@ private fun ExoPlayerMediaVideoView(
|
||||||
exoPlayer: ExoPlayer,
|
exoPlayer: ExoPlayer,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
autoplay: Boolean,
|
autoplay: Boolean,
|
||||||
|
audioFocus: AudioFocus?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
|
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
|
||||||
|
|
@ -248,6 +252,7 @@ private fun ExoPlayerMediaVideoView(
|
||||||
autoHideController++
|
autoHideController++
|
||||||
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
||||||
},
|
},
|
||||||
|
audioFocus = audioFocus,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
|
|
@ -278,6 +283,7 @@ internal fun MediaVideoViewPreview() = ElementPreview {
|
||||||
bottomPaddingInPixels = 0,
|
bottomPaddingInPixels = 0,
|
||||||
localMediaViewState = rememberLocalMediaViewState(),
|
localMediaViewState = rememberLocalMediaViewState(),
|
||||||
localMedia = null,
|
localMedia = null,
|
||||||
|
audioFocus = null,
|
||||||
autoplay = false,
|
autoplay = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
import io.element.android.compound.theme.ForcedDarkElementTheme
|
||||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||||
import io.element.android.libraries.architecture.inputs
|
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.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
@ -44,6 +45,7 @@ class MediaViewerNode @AssistedInject constructor(
|
||||||
systemClock: SystemClock,
|
systemClock: SystemClock,
|
||||||
pagerKeysHandler: PagerKeysHandler,
|
pagerKeysHandler: PagerKeysHandler,
|
||||||
private val textFileViewer: TextFileViewer,
|
private val textFileViewer: TextFileViewer,
|
||||||
|
private val audioFocus: AudioFocus,
|
||||||
) : Node(buildContext, plugins = plugins),
|
) : Node(buildContext, plugins = plugins),
|
||||||
MediaViewerNavigator {
|
MediaViewerNavigator {
|
||||||
private val inputs = inputs<MediaViewerEntryPoint.Params>()
|
private val inputs = inputs<MediaViewerEntryPoint.Params>()
|
||||||
|
|
@ -129,7 +131,8 @@ class MediaViewerNode @AssistedInject constructor(
|
||||||
state = state,
|
state = state,
|
||||||
textFileViewer = textFileViewer,
|
textFileViewer = textFileViewer,
|
||||||
modifier = modifier,
|
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.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
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
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||||
|
|
@ -91,6 +92,7 @@ fun MediaViewerView(
|
||||||
state: MediaViewerState,
|
state: MediaViewerState,
|
||||||
textFileViewer: TextFileViewer,
|
textFileViewer: TextFileViewer,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
|
audioFocus: AudioFocus?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
|
|
@ -163,6 +165,7 @@ fun MediaViewerView(
|
||||||
onShowOverlayChange = {
|
onShowOverlayChange = {
|
||||||
showOverlay = it
|
showOverlay = it
|
||||||
},
|
},
|
||||||
|
audioFocus = audioFocus,
|
||||||
isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId,
|
isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId,
|
||||||
)
|
)
|
||||||
// Bottom bar
|
// Bottom bar
|
||||||
|
|
@ -284,6 +287,7 @@ private fun MediaViewerPage(
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onDismissError: () -> Unit,
|
onDismissError: () -> Unit,
|
||||||
onShowOverlayChange: (Boolean) -> Unit,
|
onShowOverlayChange: (Boolean) -> Unit,
|
||||||
|
audioFocus: AudioFocus?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val currentShowOverlay by rememberUpdatedState(showOverlay)
|
val currentShowOverlay by rememberUpdatedState(showOverlay)
|
||||||
|
|
@ -336,6 +340,7 @@ private fun MediaViewerPage(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isUserSelected = isUserSelected,
|
isUserSelected = isUserSelected,
|
||||||
|
audioFocus = audioFocus,
|
||||||
)
|
)
|
||||||
ThumbnailView(
|
ThumbnailView(
|
||||||
mediaInfo = data.mediaInfo,
|
mediaInfo = data.mediaInfo,
|
||||||
|
|
@ -578,7 +583,8 @@ private fun ErrorView(
|
||||||
internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
|
internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
|
||||||
MediaViewerView(
|
MediaViewerView(
|
||||||
state = state,
|
state = state,
|
||||||
|
audioFocus = null,
|
||||||
textFileViewer = { _, _ -> },
|
textFileViewer = { _, _ -> },
|
||||||
onBackClick = {}
|
onBackClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
|
||||||
setContent {
|
setContent {
|
||||||
MediaViewerView(
|
MediaViewerView(
|
||||||
state = state,
|
state = state,
|
||||||
|
audioFocus = null,
|
||||||
textFileViewer = { _, _ -> },
|
textFileViewer = { _, _ -> },
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ setupAnvil()
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.libraries.voiceplayer.api)
|
api(projects.libraries.voiceplayer.api)
|
||||||
|
|
||||||
|
implementation(projects.libraries.audio.api)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
||||||
implementation(project(":libraries:eventformatter:impl"))
|
implementation(project(":libraries:eventformatter:impl"))
|
||||||
implementation(project(":libraries:indicator:impl"))
|
implementation(project(":libraries:indicator:impl"))
|
||||||
implementation(project(":libraries:permissions:impl"))
|
implementation(project(":libraries:permissions:impl"))
|
||||||
implementation(project(":libraries:push:impl"))
|
implementation(project(":libraries:audio:impl"))
|
||||||
implementation(project(":libraries:push:impl"))
|
implementation(project(":libraries:push:impl"))
|
||||||
implementation(project(":libraries:featureflag:impl"))
|
implementation(project(":libraries:featureflag:impl"))
|
||||||
implementation(project(":libraries:pushstore:impl"))
|
implementation(project(":libraries:pushstore:impl"))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue