Merge pull request #4036 from element-hq/feature/bma/extractVoiceMessagePlayer

Extract voice message player to its own module
This commit is contained in:
Benoit Marty 2024-12-13 19:38:02 +01:00 committed by GitHub
commit 3643ec30c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 321 additions and 141 deletions

View file

@ -29,8 +29,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
@Composable
fun TimelineItemEventContentView(

View file

@ -40,9 +40,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageStateProvider
import io.element.android.libraries.androidutils.accessibility.isScreenReaderEnabled
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -52,6 +49,9 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import kotlinx.coroutines.delay
@Composable

View file

@ -8,9 +8,9 @@
package io.element.android.features.messages.impl.timeline.di
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.features.messages.impl.voicemessages.timeline.aVoiceMessageState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
/**
* A fake [TimelineItemPresenterFactories] for screenshot tests.

View file

@ -1,23 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages
internal sealed class VoiceMessageException : Exception() {
data class FileException(
override val message: String?,
override val cause: Throwable? = null
) : VoiceMessageException()
data class PermissionMissing(
override val message: String?,
override val cause: Throwable?
) : VoiceMessageException()
data class PlayMessageError(
override val message: String?,
override val cause: Throwable?
) : VoiceMessageException()
}

View file

@ -21,7 +21,6 @@ import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents
@ -29,6 +28,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.services.analytics.api.AnalyticsService

View file

@ -1,13 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
sealed interface VoiceMessageEvents {
data object PlayPause : VoiceMessageEvents
data class Seek(val percentage: Float) : VoiceMessageEvents
}

View file

@ -1,103 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.mxc.MxcTools
import java.io.File
/**
* Fetches the media file for a voice message.
*
* Media is downloaded from the rust sdk and stored in the application's cache directory.
* Media files are indexed by their Matrix Content (mxc://) URI and considered immutable.
* Whenever a given mxc is found in the cache, it is returned immediately.
*/
interface VoiceMessageMediaRepo {
/**
* Factory for [VoiceMessageMediaRepo].
*/
fun interface Factory {
/**
* Creates a [VoiceMessageMediaRepo].
*
* @param mediaSource the media source of the voice message.
* @param mimeType the mime type of the voice message.
* @param filename the filename of the voice message.
*/
fun create(
mediaSource: MediaSource,
mimeType: String?,
filename: String?,
): VoiceMessageMediaRepo
}
/**
* Returns the voice message media file.
*
* In case of a cache hit the file is returned immediately.
* In case of a cache miss the file is downloaded and then returned.
*
* @return A [Result] holding either the media [File] from the cache directory or an [Exception].
*/
suspend fun getMediaFile(): Result<File>
}
class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
@CacheDirectory private val cacheDir: File,
mxcTools: MxcTools,
private val matrixMediaLoader: MatrixMediaLoader,
@Assisted private val mediaSource: MediaSource,
@Assisted("mimeType") private val mimeType: String?,
@Assisted("filename") private val filename: String?,
) : VoiceMessageMediaRepo {
@ContributesBinding(RoomScope::class)
@AssistedFactory
fun interface Factory : VoiceMessageMediaRepo.Factory {
override fun create(
mediaSource: MediaSource,
@Assisted("mimeType") mimeType: String?,
@Assisted("filename") filename: String?,
): DefaultVoiceMessageMediaRepo
}
override suspend fun getMediaFile(): Result<File> = when {
cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri."))
cachedFile.exists() -> Result.success(cachedFile)
else -> matrixMediaLoader.downloadMediaFile(
source = mediaSource,
mimeType = mimeType,
filename = filename,
).mapCatching {
it.use { mediaFile ->
val dest = cachedFile.apply { parentFile?.mkdirs() }
if (mediaFile.persist(dest.path)) {
dest
} else {
error("Failed to move file to cache.")
}
}
}
}
private val cachedFile: File? = mxcTools.mxcUri2FilePath(mediaSource.url)?.let {
File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/$it")
}
}
/**
* Subdirectory of the application's cache directory where voice messages are stored.
*/
private const val CACHE_VOICE_SUBDIR = "temp/voice"

View file

@ -1,225 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import java.io.File
import javax.inject.Inject
/**
* A media player specialized in playing a single voice message.
*/
interface VoiceMessagePlayer {
fun interface Factory {
/**
* Creates a [VoiceMessagePlayer].
*
* NB: Different voice messages can use the same content uri (e.g. in case of
* a forward of a voice message),
* therefore the mxc:// uri in [mediaSource] is not enough to uniquely identify
* a voice message. This is why we must provide the eventId as well.
*
* @param eventId The eventId of the voice message event.
* @param mediaSource The media source of the voice message.
* @param mimeType The mime type of the voice message.
* @param filename The filename of the voice message.
*/
fun create(
eventId: EventId?,
mediaSource: MediaSource,
mimeType: String?,
filename: String?,
): VoiceMessagePlayer
}
/**
* The current state of this player.
*/
val state: Flow<State>
/**
* Acquires control of the underlying [MediaPlayer] and prepares it
* to play the media file.
*
* Will suspend whilst the media file is being downloaded and/or
* the underlying [MediaPlayer] is loading the media file.
*/
suspend fun prepare(): Result<Unit>
/**
* Play the media.
*/
fun play()
/**
* Pause playback.
*/
fun pause()
/**
* Seek to a specific position.
*
* @param positionMs The position in milliseconds.
*/
fun seekTo(positionMs: Long)
data class State(
/**
* Whether the player is ready to play.
*/
val isReady: Boolean,
/**
* Whether this player is currently playing.
*/
val isPlaying: Boolean,
/**
* Whether the player has reached the end of the media.
*/
val isEnded: Boolean,
/**
* The elapsed time of this player in milliseconds.
*/
val currentPosition: Long,
/**
* The duration of the current content, if available.
*/
val duration: Long?,
)
}
/**
* An implementation of [VoiceMessagePlayer] which is backed by a
* [VoiceMessageMediaRepo] to fetch and cache the media file and
* which uses a global [MediaPlayer] instance to play the media.
*/
class DefaultVoiceMessagePlayer(
private val mediaPlayer: MediaPlayer,
voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
private val eventId: EventId?,
mediaSource: MediaSource,
mimeType: String?,
filename: String?,
) : VoiceMessagePlayer {
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
class Factory @Inject constructor(
private val mediaPlayer: MediaPlayer,
private val voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
) : VoiceMessagePlayer.Factory {
override fun create(
eventId: EventId?,
mediaSource: MediaSource,
mimeType: String?,
filename: String?,
): DefaultVoiceMessagePlayer = DefaultVoiceMessagePlayer(
mediaPlayer = mediaPlayer,
voiceMessageMediaRepoFactory = voiceMessageMediaRepoFactory,
eventId = eventId,
mediaSource = mediaSource,
mimeType = mimeType,
filename = filename,
)
}
private val repo = voiceMessageMediaRepoFactory.create(
mediaSource = mediaSource,
mimeType = mimeType,
filename = filename,
)
private var internalState = MutableStateFlow(
VoiceMessagePlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
currentPosition = 0L,
duration = null
)
)
override val state: Flow<VoiceMessagePlayer.State> = combine(mediaPlayer.state, internalState) { mediaPlayerState, internalState ->
if (mediaPlayerState.isMyTrack) {
this.internalState.update {
it.copy(
isReady = mediaPlayerState.isReady,
isPlaying = mediaPlayerState.isPlaying,
isEnded = mediaPlayerState.isEnded,
currentPosition = mediaPlayerState.currentPosition,
duration = mediaPlayerState.duration,
)
}
} else {
this.internalState.update {
it.copy(
isReady = false,
isPlaying = false,
)
}
}
VoiceMessagePlayer.State(
isReady = internalState.isReady,
isPlaying = internalState.isPlaying,
isEnded = internalState.isEnded,
currentPosition = internalState.currentPosition,
duration = internalState.duration,
)
}.distinctUntilChanged()
override suspend fun prepare(): Result<Unit> = if (eventId != null) {
repo.getMediaFile().mapCatching<Unit, File> { mediaFile ->
val state = internalState.value
mediaPlayer.setMedia(
uri = mediaFile.path,
mediaId = eventId.value,
// Files in the voice cache have no extension so we need to set the mime type manually.
mimeType = MimeTypes.Ogg,
startPositionMs = if (state.isEnded) 0L else state.currentPosition,
)
}
} else {
Result.failure(IllegalStateException("Cannot acquireControl on a voice message with no eventId"))
}
override fun play() {
if (inControl()) {
mediaPlayer.play()
}
}
override fun pause() {
if (inControl()) {
mediaPlayer.pause()
}
}
override fun seekTo(positionMs: Long) {
if (inControl()) {
mediaPlayer.seekTo(positionMs)
} else {
internalState.update {
it.copy(currentPosition = positionMs)
}
}
}
private val MediaPlayer.State.isMyTrack: Boolean
get() = if (eventId == null) false else this.mediaId == eventId.value
private fun inControl(): Boolean = mediaPlayer.state.value.let {
it.isMyTrack && (it.isReady || it.isEnded)
}
}

View file

@ -8,11 +8,6 @@
package io.element.android.features.messages.impl.voicemessages.timeline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
@ -23,17 +18,10 @@ import dagger.multibindings.IntoMap
import io.element.android.features.messages.impl.timeline.di.TimelineItemEventContentKey
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.utils.time.formatShort
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
@Module
@ContributesTo(RoomScope::class)
@ -45,9 +33,7 @@ interface VoiceMessagePresenterModule {
}
class VoiceMessagePresenter @AssistedInject constructor(
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
private val analyticsService: AnalyticsService,
private val scope: CoroutineScope,
voiceMessagePresenterFactory: VoiceMessagePresenterFactory,
@Assisted private val content: TimelineItemVoiceContent,
) : Presenter<VoiceMessageState> {
@AssistedFactory
@ -55,97 +41,16 @@ class VoiceMessagePresenter @AssistedInject constructor(
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
}
private val player = voiceMessagePlayerFactory.create(
private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter(
eventId = content.eventId,
mediaSource = content.mediaSource,
mimeType = content.mimeType,
filename = content.filename,
duration = content.duration,
)
private val play = mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
@Composable
override fun present(): VoiceMessageState {
val playerState by player.state.collectAsState(
VoiceMessagePlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
currentPosition = 0L,
duration = null
)
)
val button by remember {
derivedStateOf {
when {
content.eventId == null -> VoiceMessageState.Button.Disabled
playerState.isPlaying -> VoiceMessageState.Button.Pause
play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading
play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry
else -> VoiceMessageState.Button.Play
}
}
}
val duration by remember {
derivedStateOf { playerState.duration ?: content.duration.inWholeMilliseconds }
}
val progress by remember {
derivedStateOf {
playerState.currentPosition / duration.toFloat()
}
}
val time by remember {
derivedStateOf {
when {
playerState.isReady && !playerState.isEnded -> playerState.currentPosition
playerState.currentPosition > 0 -> playerState.currentPosition
else -> duration
}.milliseconds.formatShort()
}
}
val showCursor by remember {
derivedStateOf {
!play.value.isUninitialized() && !playerState.isEnded
}
}
fun eventSink(event: VoiceMessageEvents) {
when (event) {
is VoiceMessageEvents.PlayPause -> {
if (playerState.isPlaying) {
player.pause()
} else if (playerState.isReady) {
player.play()
} else {
scope.launch {
play.runUpdatingState(
errorTransform = {
analyticsService.trackError(
VoiceMessageException.PlayMessageError("Error while trying to play voice message", it)
)
it
},
) {
player.prepare().flatMap {
runCatching { player.play() }
}
}
}
}
}
is VoiceMessageEvents.Seek -> {
player.seekTo((event.percentage * duration).toLong())
}
}
}
return VoiceMessageState(
button = button,
progress = progress,
time = time,
showCursor = showCursor,
eventSink = { eventSink(it) },
)
return presenter.present()
}
}

View file

@ -1,24 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
data class VoiceMessageState(
val button: Button,
val progress: Float,
val time: String,
val showCursor: Boolean,
val eventSink: (event: VoiceMessageEvents) -> Unit,
) {
enum class Button {
Play,
Pause,
Downloading,
Retry,
Disabled,
}
}

View file

@ -1,56 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageState> {
override val values: Sequence<VoiceMessageState>
get() = sequenceOf(
aVoiceMessageState(
VoiceMessageState.Button.Downloading,
progress = 0f,
time = "0:00",
),
aVoiceMessageState(
VoiceMessageState.Button.Retry,
progress = 0.5f,
time = "0:01",
),
aVoiceMessageState(
VoiceMessageState.Button.Play,
progress = 1f,
time = "1:00",
showCursor = true,
),
aVoiceMessageState(
VoiceMessageState.Button.Pause,
progress = 0.2f,
time = "10:00",
showCursor = true,
),
aVoiceMessageState(
VoiceMessageState.Button.Disabled,
progress = 0.2f,
time = "30:00",
),
)
}
fun aVoiceMessageState(
button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
progress: Float = 0f,
time: String = "1:00",
showCursor: Boolean = false,
) = VoiceMessageState(
button = button,
progress = progress,
time = time,
showCursor = showCursor,
eventSink = {},
)